Android client phase 0: JSON API and canvas channel

bfa2c00 · Gabriel Morell · 2026-09-14 22:02

49 files +2247 -15
Message
{commit_body(@commit)}

Files changed

modified config/config.exs
+2 −0
@@ -105,6 +105,8 @@ config :sentry,
105 105 # upload its own dashboards on boot, and the dashboards for this fleet are
106 106 # committed in deployment/grafana and pushed from there. `metrics_server:
107 107 # :disabled` because that option is Cowboy-based — see Diogramos.MetricsPlug.
108 +config :diogramos, :metrics_port, 9568
109 +
108 110 config :diogramos, Diogramos.PromEx,
109 111 disabled: false,
110 112 manual_metrics_start_delay: :no_delay,
modified config/test.exs
+4 −0
@@ -17,6 +17,10 @@ config :diogramos, Diogramos.Repo,
17 17 pool: Ecto.Adapters.SQL.Sandbox,
18 18 pool_size: System.schedulers_online() * 2
19 19
20 +# No metrics listener in test — nothing scrapes it, and binding a fixed
21 +# port makes the suite fail whenever a dev server is running.
22 +config :diogramos, :metrics_port, nil
23 +
20 24 # We don't run a server during test. If one is required,
21 25 # you can enable the server option below.
22 26 config :diogramos, DiogramosWeb.Endpoint,
added docs/android.md
+542 −0
@@ -0,0 +1,542 @@
1 +# Diogramos Android client — design doc
2 +
3 +Status: **draft**, pre-implementation.
4 +Owner: gmp@h4xmb.org.
5 +Goal: native Android client for viewing and lightly editing canvases,
6 +sharing the existing Phoenix backend.
7 +
8 +This doc is the contract for the work. Implementation lands in phases (see
9 +[Phase plan](#8-phase-plan)); each phase has its own commit(s) and updates
10 +this doc with what shipped.
11 +
12 +Sister app to `../waxx/android` and `../camerite/android` — same Compose /
13 +OKLCh palette / B612-mono shell, so they read as one product family. Where
14 +waxx already solved a problem (bearer tokens, QR pairing, F-Droid release),
15 +this doc points at that solution rather than inventing a second one.
16 +
17 +---
18 +
19 +## 1. Goals & non-goals
20 +
21 +### Goals
22 +- Native Kotlin/Compose Android app for the case the web UI serves worst:
23 + **looking at a diagram on a phone**, and making the small fix you notice
24 + while you're there.
25 +- Correct rendering. A canvas must look like the same diagram it is on the
26 + web — same shapes, same theme colors, same connector routing.
27 +- Real-time. An edit on web appears on the phone in the same second it
28 + appears in another web tab, and vice versa.
29 +- Two auth paths: scan-a-QR from a logged-in web session, or magic-link by
30 + email. Either alone is sufficient; QR is the primary path for self-hosted
31 + installs with no SMTP.
32 +- Server stays authoritative. `Diagrams.Document` validation,
33 + `Diagrams.Permissions` authorization, and the `Diagrams.Authority`
34 + op pipeline are not reimplemented on the client.
35 +
36 +### Non-goals (v1)
37 +- **Connector authoring.** Connectors are *rendered* but not created or
38 + re-anchored on the phone. Picking an anchor on a 6" screen is its own
39 + design problem and it is not the thing mobile users are blocked on.
40 +- Folder management, permission/share-link administration, embed-token
41 + settings, canvas import. All remain web-only; the app links out to the
42 + web UI for them.
43 +- iOS. The architecture should not preclude it; no iOS work in v1.
44 +- Offline-first writes. Read caching is fine; mutations require a live
45 + connection.
46 +- Push notifications.
47 +- A public/third-party API. `/api/v1` exists for the first-party Android
48 + client; stability guarantees are weak until v2.
49 +
50 +### v1 editing surface, precisely
51 +
52 +The app emits exactly these ops:
53 +
54 +| Gesture | Op |
55 +|---|---|
56 +| Drag a shape | `update_element` (geometry) |
57 +| Drag a resize handle | `update_element` (geometry) |
58 +| Edit label / text body | `update_element` (`label`, `style`) |
59 +| Fill / stroke / label color pick | `update_element` (`style`) |
60 +| Add shape from FAB | `insert_element` |
61 +| Delete from the selection sheet | `delete_element` |
62 +| Bring to front / send to back | `set_z_order` |
63 +
64 +`insert_connector`, `update_connector`, and `delete_connector` are not sent
65 +in v1 — but they arrive over the channel from web peers and must render.
66 +Note that `delete_element` on the server also drops every connector
67 +referencing that element (`Document.apply_op/2`), so the client must accept
68 +a document where connectors vanished without a matching connector op.
69 +
70 +---
71 +
72 +## 2. Architecture overview
73 +
74 +```
75 ++-------------------+ HTTPS/JSON +------------------------------+
76 +| Android client | <---------------> | DiogramosWeb.Api.* (Phase 0)|
77 +| (Kotlin/Compose) | | - Plug pipeline :api |
78 +| | WSS/Channels | - bearer-token auth |
79 +| | <---------------> | DiogramosWeb.CanvasChannel |
80 ++-------------------+ | (Phase 0) |
81 + +---------------+--------------+
82 + |
83 + v
84 + +------------------------------+
85 + | Diogramos.{Accounts,Diagrams}|
86 + | Authority / Document / |
87 + | Permissions / Canvases |
88 + | (UNCHANGED business logic) |
89 + +------------------------------+
90 +```
91 +
92 +The server gains two surfaces — an `/api/v1` HTTP scope and a `/socket`
93 +user socket with a `CanvasChannel`. Both call into the existing contexts.
94 +No business logic moves; only thin transport layers are added.
95 +
96 +One pre-existing gap was closed alongside them, because the phone makes it
97 +much more visible (more ops per edit, more reconnects): `Diagrams.CanvasOp`
98 +described itself as an append-only WAL for crash recovery, but nothing
99 +wrote to it, so an authority crash lost every op since the last snapshot —
100 +up to 25. `Authority` now appends a row per accepted op, replays rows newer
101 +than the snapshot on load, and prunes them when a snapshot is written.
102 +
103 +The Android app is a single Gradle module under `/android`, matching
104 +`../waxx/android` (single-activity, no DI framework, dependencies
105 +constructed inline).
106 +
107 +---
108 +
109 +## 3. HTTP API design
110 +
111 +### Conventions
112 +- Scope: `/api/v1/...`. Major version bumps for breaking changes; additive
113 + fields are not breaking.
114 +- Auth: `Authorization: Bearer <token>` on every request except session
115 + redemption. Tokens live in `users_tokens` with a new context `"api"`.
116 +- Content type: `application/json`, snake_case fields (matches the schemas).
117 +- Timestamps: ISO-8601 UTC strings.
118 +- Canvases are addressed by **slug** in URLs (`Canvases.get_by_slug/2`),
119 + matching the web routes. The numeric `id` is returned in payloads because
120 + the channel topic is keyed by it.
121 +- Errors: `{"error": {"code": "...", "message": "..."}}`.
122 + `unauthenticated` (401), `forbidden` (403), `not_found` (404),
123 + `unprocessable` (422, with `details` for changeset errors).
124 +
125 +### Endpoints (v1 surface)
126 +
127 +Unauthenticated (`pipe_through :api`):
128 +
129 +| Method | Path | Notes |
130 +|---|---|---|
131 +| POST | `/sessions/request_magic_link` | `{email}` → 204 always (no account enumeration) |
132 +| POST | `/sessions/redeem` | `{token}` → `{token, user}`; mints an `"api"` token from a magic-link token |
133 +
134 +Authenticated (`pipe_through :api_authenticated`):
135 +
136 +| Method | Path | Notes |
137 +|---|---|---|
138 +| DELETE | `/sessions/current` | revokes the calling token |
139 +| GET | `/users/me` | `{id, email, confirmed_at}`; also the QR-pair probe |
140 +| GET | `/folders` | flat list; used to group the canvas list |
141 +| GET | `/canvases` | `Canvases.list/1` — id, slug, title, theme, folder_id, version, updated_at, role. **No document.** |
142 +| POST | `/canvases` | `{slug, title, theme, folder_id}` → canvas |
143 +| GET | `/canvases/:slug` | full canvas **including** `document` and `version` |
144 +| PATCH | `/canvases/:slug` | metadata only (`title`, `theme`, `folder_id`) via `Canvas.metadata_changeset/2` |
145 +| DELETE | `/canvases/:slug` | owner/admin only |
146 +| GET | `/canvases/:slug/export.json` | same payload as `Canvases.export/2` |
147 +| GET | `/canvases/:slug/export.mermaid` | `MermaidExport.to_mermaid/1`, `text/plain` |
148 +| GET | `/themes` | `{themes: [...], color_tokens: [...]}` — lets the client validate a color pick without hardcoding `Themes.color_tokens/0` |
149 +
150 +**Ops do not have an HTTP endpoint.** Every mutation to `document` goes
151 +through the channel, because `Authority` is the only thing allowed to
152 +sequence ops and bump `version`. An HTTP op endpoint would be a second
153 +write path into the same GenServer with no ordering story.
154 +
155 +### JSON shape
156 +
157 +`document` is passed through **verbatim** as the jsonb map the server
158 +stores — `%{"elements" => %{id => el}, "order" => [id], "connectors" => %{id => c}}`.
159 +It is not remapped into a client-flavored schema. The client's Kotlin
160 +models mirror `Document`'s validation rules 1:1:
161 +
162 +- element types: `rect`, `rounded`, `circle`, `text`
163 +- geometry: `x/y/w/h` for rect/rounded/text, `cx/cy/r` for circle
164 +- `label`, `style` (`fill`, `stroke`, `shadow`, `dash`, `stroke_width`,
165 + `font_family`, `font_size`, `font_bold`, `font_italic`, `label_position`,
166 + `label_size`, `label_color`, `label_font_family`, `label_bold`,
167 + `label_italic`), `links[]`
168 +- connectors: `from`/`to` `{element, anchor, x?, y?}` with anchors
169 + `auto|n|e|s|w|point`; `routing` `straight|orthogonal|curve`; `dash`,
170 + `marker_start`, `marker_end`, `start_gap`, `end_gap`, `stroke_width`,
171 + `color`, `label`
172 +
173 +Unknown keys are preserved on round-trip: an `update_element` patch is a
174 +**partial** map (the server deep-merges), so the client never has to send
175 +back fields it didn't understand.
176 +
177 +### View modules
178 +
179 +`DiogramosWeb.Api.V1.CanvasJSON` etc., alongside a shared
180 +`DiogramosWeb.Api.FallbackController` and `DiogramosWeb.Api.ErrorJSON` —
181 +lifted from `../waxx/lib/waxx_web/api/`.
182 +
183 +---
184 +
185 +## 4. Auth flows
186 +
187 +Both paths end with the app holding a raw bearer token. The backend treats
188 +them identically after that.
189 +
190 +### Token lifecycle
191 +- `"api"` tokens: 90-day inactivity expiry, `authenticated_at` refreshed at
192 + most once per day on a successful authenticated request.
193 +- Revocation: delete the row from `users_tokens`; in-flight requests get
194 + `401 unauthenticated` and the socket reconnect fails.
195 +- Stored on-device in `EncryptedSharedPreferences` (AES via the Keystore).
196 +
197 +New `Accounts` functions, mirroring waxx: `create_api_token/2`,
198 +`fetch_user_by_api_token/1`, `list_api_tokens/1`, `delete_api_token/2`;
199 +new `UserToken` functions `build_api_token/2`, `verify_api_token_query/1`,
200 +`api_token_needs_refresh?/1`.
201 +
202 +### 4a. Pair via QR (primary)
203 +
204 +The web user, already logged in, generates a token from a "Connected
205 +devices" panel added to `UserLive.Settings`. The panel renders a QR
206 +encoding server URL + token; the phone scans it and is paired. No email,
207 +no SMTP, no App Links.
208 +
209 +QR payload:
210 +
211 +```
212 +diogramos://pair?base=<url-encoded origin>&token=<url-encoded token>
213 +```
214 +
215 +The raw token is displayed exactly once, at generation time. The panel
216 +otherwise lists `sent_to`, `authenticated_at`, `inserted_at`, and a Revoke
217 +action per token.
218 +
219 +Base URL derivation, in priority order — so a self-hosted install behind a
220 +reverse proxy doesn't bake a bind address into the QR:
221 +
222 +1. `DIOGRAMOS_PUBLIC_URL` env (explicit override)
223 +2. `X-Forwarded-Proto` + `X-Forwarded-Host`
224 +3. the request's `Host` header
225 +4. endpoint config
226 +
227 +Server dep: `{:eqrcode, "~> 0.2.1"}` (pure Elixir, SVG output).
228 +Client dep: `journeyapps:zxing-android-embedded` via `ScanContract`.
229 +
230 +The `diogramos://pair` intent filter also makes the URI tappable from a
231 +note or chat, deep-linking into the Pair screen with both fields filled.
232 +
233 +### 4b. Magic link
234 +
235 +For "I'm only on my phone and have never logged in on a desktop."
236 +`POST /sessions/request_magic_link` reuses
237 +`Accounts.deliver_login_instructions/2`; the email link carries a
238 +`/m/:token` route that content-negotiates — native clients redeem it via
239 +`POST /sessions/redeem`, browsers keep falling through to the existing
240 +`/users/log-in/:token` LiveView.
241 +
242 +App Links (`autoVerify` + `/.well-known/assetlinks.json` with both debug
243 +and release SHA-256 fingerprints) make the tap-through seamless; without
244 +them the user pastes the link into the app, which still works.
245 +
246 +### Registration
247 +
248 +Diogramos registration is invite-gated (`Accounts.Invite`,
249 +`UserLive.Invites`). The app does **not** implement registration — an
250 +unregistered user is told to open the invite link in a browser. Revisit if
251 +that proves annoying.
252 +
253 +---
254 +
255 +## 5. Real-time channel
256 +
257 +### Server
258 +
259 +```
260 +DiogramosWeb.UserSocket
261 + /socket connect: ?token=<api_token> → Accounts.fetch_user_by_api_token/1
262 + channel "canvas:*", DiogramosWeb.CanvasChannel
263 +
264 +DiogramosWeb.CanvasChannel
265 + join("canvas:<id>"):
266 + - load canvas, Permissions.authorize(scope, :read, canvas)
267 + - PubSub.subscribe(Authority.topic(id))
268 + - CanvasPresence.track(...)
269 + - reply {:ok, %{canvas: meta, document: doc, version: v, role: role}}
270 + from Authority.snapshot/1
271 + handle_in("op", %{"op" => op, "actor_ref" => ref}):
272 + - Authority.apply_op(id, scope, op, ref)
273 + - reply {:ok, %{version: v}} | {:error, %{code: ...}}
274 + handle_in("cursor", %{"x" => x, "y" => y}):
275 + - CanvasPresence.update(...)
276 + handle_info({:canvas_op, payload}):
277 + - push("op", %{op:, version:, actor_ref:})
278 + handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}):
279 + - push through
280 +```
281 +
282 +Unlike waxx — where channel pushes are bare "refetch me" notifications —
283 +**this channel pushes the ops themselves**. The reasons are specific to
284 +this backend:
285 +
286 +- `Authority` already broadcasts `{:canvas_op, %{op, version, actor_ref}}`
287 + as a delta with a monotonic version. The contract exists; wrapping it is
288 + cheaper than inventing a notification.
289 +- The document is one blob. "Refetch the affected resource" means refetching
290 + the whole canvas on every keystroke-sized op.
291 +- `actor_ref` is already there for echo suppression, which is exactly what
292 + an optimistic client needs.
293 +
294 +### Client reconciliation
295 +
296 +`Authority.apply_op/4` does **not** take a client version — it applies ops
297 +in arrival order and increments `version` itself. There is no OT and no
298 +CRDT; concurrent edits to the same field are last-writer-wins, same as the
299 +web editor today. So the client rule is simple:
300 +
301 +1. Apply the op locally (optimistically) and send it with a fresh
302 + `actor_ref`.
303 +2. On an incoming `op` push whose `actor_ref` is ours, drop it — already
304 + applied — but adopt its `version`.
305 +3. On any other incoming op: if `version == local_version + 1`, apply and
306 + advance. **Otherwise the client has drifted** — re-`GET /canvases/:slug`
307 + for a fresh document + version and resync. Drift is expected after a
308 + reconnect and should be silent.
309 +4. On an `{:error, ...}` reply to our own op, roll back the optimistic
310 + change and resync.
311 +
312 +The client must not assume its op landed until the reply arrives; a
313 +`:forbidden` reply is normal for a viewer-role user whose role changed
314 +mid-session.
315 +
316 +### Presence
317 +
318 +`CanvasPresence` already carries `:name`, `:color`, `:cursor`, `:lock` per
319 +peer, with a color palette and generated two-word names. The app renders
320 +ghost cursors and ghosted elements the same way the web editor does, and
321 +publishes its own cursor on drag (throttled to ~20 Hz, and only while a
322 +drag is in flight — a phone has no hover).
323 +
324 +### Channel auth
325 +`UserSocket.connect/3` verifies the `"api"` token and assigns `user_id`.
326 +`CanvasChannel.join/3` re-checks `Permissions.authorize/3` on every join;
327 +write authorization is re-checked per op inside `Authority.do_apply_op/4`,
328 +so a role change takes effect without a rejoin.
329 +
330 +---
331 +
332 +## 6. Android app architecture
333 +
334 +### Stack
335 +
336 +Identical to waxx, deliberately — one toolchain across the family.
337 +
338 +| Layer | Choice |
339 +|---|---|
340 +| Language / UI | Kotlin 2.0 · Jetpack Compose · Material 3 |
341 +| HTTP | Retrofit · OkHttp · kotlinx.serialization |
342 +| Realtime | `com.github.dsrees:JavaPhoenixClient` (0.3.4, jitpack) |
343 +| Storage | EncryptedSharedPreferences |
344 +| Scanner | `com.journeyapps:zxing-android-embedded` |
345 +| Build | Gradle 9.0 · AGP 8.5.2 · JDK 17 |
346 +| minSdk | 26 · targetSdk 34 |
347 +
348 +### Module layout
349 +
350 +```
351 +android/app/src/main/java/org/neiam/diogramos/app/
352 + MainActivity.kt # nav host, deep-link dispatch, theme provider
353 + data/
354 + TokenStore.kt # bearer + base URL (https-coerced)
355 + ThemeStore.kt # selected UI theme key
356 + DiogramosClient.kt # Retrofit interfaces + factories
357 + CanvasSocket.kt # channel wrapper → Flow<CanvasEvent>
358 + PairPayload.kt # diogramos://pair parser
359 + Models.kt # Document / Element / Connector DTOs
360 + DocumentStore.kt # in-memory doc + version + op application
361 + auth/
362 + LoginScreen.kt · PairScreen.kt · MagicLinkScreen.kt
363 + canvas/
364 + CanvasListScreen.kt # canvases grouped by folder
365 + CanvasScreen.kt # the viewport
366 + Viewport.kt # pan/zoom/fit transform state
367 + Renderer.kt # Compose Canvas draw of elements + connectors
368 + ConnectorGeometry.kt # port of the Elixir module
369 + SelectionSheet.kt # bottom sheet: label, colors, z-order, delete
370 + AddShapeSheet.kt
371 + Peers.kt # ghost cursors from presence
372 + ui/theme/
373 + AppTheme.kt · Theme.kt · Typography.kt # lifted from waxx
374 + CanvasTokens.kt # daisyUI token → Color, per canvas theme
375 +```
376 +
377 +### Rendering
378 +
379 +The renderer is the heart of the app and the part with no waxx precedent.
380 +
381 +- Draw into a single Compose `Canvas` under a pan/zoom transform. Elements
382 + in `order` (back-to-front), then connectors on top, then presence ghosts.
383 +- `ConnectorGeometry.kt` is a **direct port** of
384 + `lib/diogramos/diagrams/connector_geometry.ex` — anchor resolution
385 + (`auto`/cardinal/`point`), gap shrinking, and the three routings. It is
386 + pure math over the document, so the port is mechanical.
387 + - **Keeping it honest:** a mix task dumps golden fixtures (document +
388 + expected path `d` strings) from the Elixir tests into
389 + `android/app/src/test/resources/`, and a Kotlin unit test asserts the
390 + port produces the same paths. Without this the two implementations
391 + drift silently and diagrams render subtly wrong.
392 +- `style.fill` / `style.stroke` / connector `color` are daisyUI tokens
393 + (`base-100`, `primary`, `error`, …), not hex. `CanvasTokens.kt` resolves
394 + them against the *canvas's* theme (`canvas.theme`), which is independent
395 + of the app chrome theme the user picked. A canvas authored in `forest`
396 + renders in `forest` colors even if the app is in `light`.
397 +- Fonts: `sans`/`serif`/`mono`/`b612`/`b612-mono`. B612 ships in
398 + `../waxx/android/app/src/main/res/font/` already.
399 +- Hit testing walks `order` in reverse so the topmost element wins.
400 +
401 +### Interaction model
402 +
403 +| Gesture | Effect |
404 +|---|---|
405 +| One-finger drag on empty canvas | pan |
406 +| Pinch | zoom (clamped; double-tap = fit-to-content) |
407 +| Tap an element | select (handles + bottom sheet) |
408 +| Drag a selected element | move → `update_element`, one op on gesture end plus throttled intermediates |
409 +| Drag a corner handle | resize → `update_element` |
410 +| Long-press empty canvas | add-shape sheet at that point |
411 +| Tap a link badge | open the link (external URL or another canvas) |
412 +
413 +Intermediate drag ops are throttled to ~10/s. Each op is a
414 +`GenServer.call` into the canvas's `Authority` plus a PubSub broadcast to
415 +every peer, and a snapshot write to Postgres every 25 ops, so a firehose of
416 +sub-pixel updates is real server load — throttling is not just a network
417 +nicety.
418 +
419 +A viewer-role user gets no handles, no FAB, and a "read-only" chip.
420 +
421 +### Error handling
422 +
423 +A top banner mirrors waxx's: "Connecting…" / "Offline — changes won't sync
424 +until reconnect." outside the joined state. Rejected ops roll back with a
425 +snackbar naming the reason (`forbidden`, `invalid_color`, …, mapped from
426 +`Document`'s error atoms).
427 +
428 +---
429 +
430 +## 7. Build & release
431 +
432 +Straight copies of the waxx setup, which is already proven:
433 +
434 +- `android/Dockerfile` builds and signs the release APK in a container with
435 + the keystore as a BuildKit secret — it never reaches a Gradle script,
436 + cache, or image layer.
437 +- CI (Forgejo style)
438 + builds on push to `master` and publishes to the F-Droid repo.
439 +- `versionCode` from the CI run number, `versionName` from the commit.
440 + Local builds stay at `versionCode = 1` / `…+dev` and are never published.
441 +- Release keystore generated once, backed up to OpenBao before it exists
442 + anywhere else. Losing it means every install has to be uninstalled and
443 + redone by hand.
444 +
445 +---
446 +
447 +## 8. Phase plan
448 +
449 +### Phase 0 — Backend foundation (no Android code) ✅ shipped
450 +- `"api"` token context: `UserToken.build_api_token/2`,
451 + `verify_api_token_query/1`, `api_token_needs_refresh?/1`, plus a
452 + `label` column on `users_tokens`; `Accounts.create_api_token/2`,
453 + `fetch_user_by_api_token/1`, `list_api_tokens/1`, `delete_api_token/2`.
454 +- `DiogramosWeb.Api.Auth` plug, `:api_authenticated` pipeline,
455 + `FallbackController`, `ErrorJSON`.
456 +- `/api/v1` controllers: sessions, users, folders, canvases, themes.
457 +- `DiogramosWeb.UserSocket` + `CanvasChannel`; `socket "/socket"` in the
458 + endpoint.
459 +- `canvas_ops` WAL now actually written and replayed (see below).
460 +- 41 tests: token expiry/refresh/revocation, authz on every endpoint
461 + (viewer vs editor vs stranger), channel join/op/reject, echo
462 + suppression, presence, and crash recovery.
463 +
464 +Shippable on its own: the API is useful to `curl` and to any script.
465 +
466 +Two things worth knowing about what shipped:
467 +
468 +- **The channel's topic is already the authority's topic.**
469 + `Authority.topic(id)` is `"canvas:<id>"`, which is also the channel
470 + topic, so joining subscribes us — an explicit `PubSub.subscribe` there
471 + would deliver every op twice. For the same reason presence diffs arrive
472 + as *outgoing* broadcasts, so the channel declares
473 + `intercept ["presence_diff"]` and collapses the diff into the peer list
474 + in `handle_out/3`.
475 +- **`GET /canvases/:slug` reads through the authority, not the row.** A
476 + canvas being actively edited holds up to 24 unpersisted ops, so the DB
477 + copy can be far behind. The controller falls back to the row only when
478 + no authority can be started.
479 +
480 +### Phase 1a — Web "Connected devices" + QR
481 +- `eqrcode` dep, public-URL derivation, token panel in `UserLive.Settings`,
482 + revoke action.
483 +
484 +### Phase 1b — Android scaffold + both auth paths
485 +- Gradle project, theme/typography lifted from waxx, `TokenStore`,
486 + `PairScreen` (scan + paste), `MagicLinkScreen`, `/users/me` probe.
487 +
488 +### Phase 2 — Canvas list + read-only render
489 +- List grouped by folder; `CanvasScreen` with pan/zoom, full element and
490 + connector rendering, `ConnectorGeometry` port + golden-fixture tests,
491 + theme token resolution. **This is the phase that fixes the actual
492 + complaint**, and it's worth landing before any write path.
493 +
494 +### Phase 3 — Realtime
495 +- `CanvasSocket`, op application, version reconciliation, resync-on-drift,
496 + presence ghosts, connection banner.
497 +
498 +### Phase 4 — Light editing
499 +- Selection, move, resize, label/text edit, color picks, add shape, delete,
500 + z-order. Optimistic apply + rollback.
501 +
502 +### Phase 5 — Polish
503 +- Export (JSON + mermaid) to the share sheet, canvas create/rename/theme,
504 + app theme picker, launcher icon, App Links + `assetlinks.json`.
505 +
506 +### Phase 6 — Release pipeline
507 +- `android/Dockerfile`, CI job, keystore, F-Droid publication.
508 +
509 +---
510 +
511 +## 9. Open questions
512 +
513 +- **Text rendering fidelity.** The web renders text elements as SVG/HTML
514 + with daisyUI typography; Compose text metrics will not match exactly.
515 + Wrapping and vertical centering are the likely visible differences. Do we
516 + accept "close", or does the server need to hand back measured line boxes?
517 +- **`set_z_order` races.** The op replaces the entire `order` list, so two
518 + peers reordering at once means one silently wins with a stale list. Same
519 + bug exists on web today; worth noting, not worth fixing in this scope.
520 +- **Connector creation.** Deferred out of v1, but the eventual gesture
521 + (drag from an edge handle to a target, anchor defaulting to `auto`) is
522 + worth prototyping early enough that the renderer doesn't foreclose it.
523 +- **Theme sync.** The app theme is local-only, like waxx's. A
524 + `PATCH /api/v1/users/me/preferences` would let it follow the user across
525 + devices — Diogramos has no `users.preferences` column yet.
526 +- **Embed/share-link viewing.** `/embed/:token` and `/s/:token` are
527 + anonymous read paths. Should the app open a shared canvas without an
528 + account? Cheap to add (`GET /api/v1/embed/:token`), but it means an
529 + unauthenticated channel join.
530 +
531 +---
532 +
533 +## 10. Glossary
534 +
535 +| Term | Meaning |
536 +|---|---|
537 +| **Authority** | `Diagrams.Authority` — the Horde-registered GenServer owning one canvas's document and version. Every write goes through it. |
538 +| **op** | One of the seven mutations in `Document.apply_op/2`. |
539 +| **version** | Monotonic counter on `canvases`, incremented per accepted op. |
540 +| **actor_ref** | Opaque binary a client attaches to its own ops to recognize the echo. |
541 +| **token (color)** | A daisyUI color name from `Themes.color_tokens/0`, resolved per theme at render time — never a hex value in the document. |
542 +| **canvas theme** | `canvases.theme`; how the document's color tokens resolve. Distinct from the app's chrome theme. |
modified lib/diogramos/accounts.ex
+96 −0
@@ -60,6 +60,15 @@ defmodule Diogramos.Accounts do
60 60 """
61 61 def get_user!(id), do: Repo.get!(User, id)
62 62
63 + @doc """
64 + Gets a single user, or nil when there is no such id.
65 +
66 + The bang version is the one to use when the id came from a trusted
67 + path; this one exists for callers holding an id that may have been
68 + deleted since, such as a socket outliving its user.
69 + """
70 + def get_user(id), do: Repo.get(User, id)
71 +
63 72 ## User registration
64 73
65 74 @doc """
@@ -347,6 +356,93 @@ defmodule Diogramos.Accounts do
347 356 UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
348 357 end
349 358
359 + ## API tokens
360 +
361 + @doc """
362 + Mints an API token for a native client and returns the encoded string.
363 + Hand it to the client exactly once — only the hash is stored.
364 +
365 + `attrs` may carry a `:label` (or `"label"`) shown in the device list.
366 + """
367 + def create_api_token(%User{} = user, attrs \\ %{}) do
368 + {encoded_token, user_token} = UserToken.build_api_token(user, attrs)
369 + Repo.insert!(user_token)
370 + encoded_token
371 + end
372 +
373 + @doc """
374 + Looks up a user by API token. Returns `{user, token_id}`, or `nil` when
375 + the token is missing, malformed, expired, or revoked.
376 +
377 + On success it opportunistically refreshes `authenticated_at` so an
378 + active client never ages out. The token id comes back so the auth plug
379 + can assign it — `DELETE /api/v1/sessions/current` revokes that specific
380 + device with it.
381 + """
382 + def fetch_user_by_api_token(token) when is_binary(token) do
383 + with {:ok, query} <- UserToken.verify_api_token_query(token),
384 + {user, token_struct} <- Repo.one(query) do
385 + maybe_touch_api_token(token_struct)
386 + {user, token_struct.id}
387 + else
388 + _ -> nil
389 + end
390 + end
391 +
392 + def fetch_user_by_api_token(_), do: nil
393 +
394 + defp maybe_touch_api_token(%UserToken{} = token_struct) do
395 + if UserToken.api_token_needs_refresh?(token_struct) do
396 + Repo.update_all(
397 + from(t in UserToken, where: t.id == ^token_struct.id),
398 + set: [authenticated_at: DateTime.utc_now(:second)]
399 + )
400 + end
401 +
402 + :ok
403 + end
404 +
405 + @doc "Lists a user's active API tokens, most recently used first."
406 + def list_api_tokens(%User{id: id}) do
407 + from(t in UserToken,
408 + where: t.user_id == ^id and t.context == "api",
409 + order_by: [desc: t.authenticated_at],
410 + select: %{
411 + id: t.id,
412 + label: t.label,
413 + sent_to: t.sent_to,
414 + authenticated_at: t.authenticated_at,
415 + inserted_at: t.inserted_at
416 + }
417 + )
418 + |> Repo.all()
419 + end
420 +
421 + @doc """
422 + Revokes one API token by id, scoped to its owner.
423 +
424 + Also broadcasts `"disconnect"` on the user's socket topic so live
425 + channels are torn down immediately. That kicks *all* of this user's
426 + sockets: the revoked token's reconnect 401s, the others reconnect
427 + cleanly. Brief disruption is the trade-off for not threading per-token
428 + socket ids through `UserSocket.id/1`.
429 + """
430 + def delete_api_token(%User{id: user_id}, token_id) do
431 + {count, _} =
432 + Repo.delete_all(
433 + from(t in UserToken,
434 + where: t.id == ^token_id and t.user_id == ^user_id and t.context == "api"
435 + )
436 + )
437 +
438 + if count == 1 do
439 + DiogramosWeb.Endpoint.broadcast("user_socket:#{user_id}", "disconnect", %{})
440 + :ok
441 + else
442 + {:error, :not_found}
443 + end
444 + end
445 +
350 446 @doc """
351 447 Deletes the signed token with the given context.
352 448 """
modified lib/diogramos/accounts/user_token.ex
+80 −0
@@ -12,11 +12,18 @@ defmodule Diogramos.Accounts.UserToken do
12 12 @change_email_validity_in_days 7
13 13 @session_validity_in_days 14
14 14
15 + # API tokens (context "api") issued to native clients. Expiry is measured
16 + # against `authenticated_at`, which is refreshed at most once a day on
17 + # use, so an active device never gets logged out.
18 + @api_token_validity_in_days 90
19 + @api_token_refresh_after_seconds 24 * 60 * 60
20 +
15 21 schema "users_tokens" do
16 22 field :token, :binary
17 23 field :context, :string
18 24 field :sent_to, :string
19 25 field :authenticated_at, :utc_datetime
26 + field :label, :string
20 27 belongs_to :user, Diogramos.Accounts.User
21 28
22 29 timestamps(type: :utc_datetime, updated_at: false)
@@ -153,4 +160,77 @@ defmodule Diogramos.Accounts.UserToken do
153 160 defp by_token_and_context_query(token, context) do
154 161 from UserToken, where: [token: ^token, context: ^context]
155 162 end
163 +
164 + ## API tokens ---------------------------------------------------------
165 +
166 + @doc """
167 + Builds an API token for native clients. Returns `{encoded_token, struct}`
168 + — the caller inserts the struct and hands `encoded_token` to the client
169 + over a secure channel exactly once. Only the hash is stored.
170 +
171 + Accepts an optional `label` (e.g. "Pixel 7, kitchen") to tell tokens
172 + apart in the device list.
173 + """
174 + def build_api_token(user, attrs \\ %{}) do
175 + token = :crypto.strong_rand_bytes(@rand_size)
176 + hashed_token = :crypto.hash(@hash_algorithm, token)
177 + now = DateTime.utc_now(:second)
178 +
179 + {Base.url_encode64(token, padding: false),
180 + %UserToken{
181 + token: hashed_token,
182 + context: "api",
183 + sent_to: user.email,
184 + label: attrs |> normalize_label() |> truncate_label(),
185 + authenticated_at: now,
186 + user_id: user.id
187 + }}
188 + end
189 +
190 + defp normalize_label(attrs) do
191 + case Map.get(attrs, :label) || Map.get(attrs, "label") do
192 + str when is_binary(str) -> str |> String.trim() |> nil_if_empty()
193 + _ -> nil
194 + end
195 + end
196 +
197 + defp nil_if_empty(""), do: nil
198 + defp nil_if_empty(str), do: str
199 +
200 + defp truncate_label(nil), do: nil
201 + defp truncate_label(str), do: String.slice(str, 0, 80)
202 +
203 + @doc """
204 + Looks up an API token. Returns `{:ok, query}` selecting `{user, token}`
205 + if the encoded token decodes, `:error` otherwise. The query enforces the
206 + @api_token_validity_in_days window against `authenticated_at`.
207 + """
208 + def verify_api_token_query(token) when is_binary(token) do
209 + case Base.url_decode64(token, padding: false) do
210 + {:ok, decoded_token} ->
211 + hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
212 +
213 + query =
214 + from token in by_token_and_context_query(hashed_token, "api"),
215 + join: user in assoc(token, :user),
216 + where: token.authenticated_at > ago(@api_token_validity_in_days, "day"),
217 + select: {user, token}
218 +
219 + {:ok, query}
220 +
221 + :error ->
222 + :error
223 + end
224 + end
225 +
226 + def verify_api_token_query(_), do: :error
227 +
228 + @doc """
229 + True when the token's `authenticated_at` is stale enough to warrant a
230 + refresh write, so the auth plug can skip the DB write on the common path
231 + where the token was used minutes ago.
232 + """
233 + def api_token_needs_refresh?(%UserToken{context: "api", authenticated_at: ts}) do
234 + DateTime.diff(DateTime.utc_now(:second), ts, :second) >= @api_token_refresh_after_seconds
235 + end
156 236 end
modified lib/diogramos/application.ex
+14 −2

Click to load diff…

modified lib/diogramos/diagrams.ex
+1 −0

Click to load diff…

modified lib/diogramos/diagrams/authority.ex
+75 −8

Click to load diff…

modified lib/diogramos/diagrams/canvases.ex
+23 −1

Click to load diff…

added lib/diogramos_web/api/auth.ex
+44 −0

Click to load diff…

added lib/diogramos_web/api/error_json.ex
+17 −0

Click to load diff…

added lib/diogramos_web/api/fallback_controller.ex
+55 −0

Click to load diff…

added lib/diogramos_web/api/v1/canvas_controller.ex
+120 −0

Click to load diff…

added lib/diogramos_web/api/v1/canvas_json.ex
+45 −0

Click to load diff…

added lib/diogramos_web/api/v1/folder_controller.ex
+20 −0

Click to load diff…

added lib/diogramos_web/api/v1/session_controller.ex
+83 −0

Click to load diff…

added lib/diogramos_web/api/v1/theme_controller.ex
+22 −0

Click to load diff…

added lib/diogramos_web/api/v1/user_controller.ex
+17 −0

Click to load diff…

added lib/diogramos_web/channels/canvas_channel.ex
+188 −0

Click to load diff…

added lib/diogramos_web/channels/user_socket.ex
+32 −0

Click to load diff…

modified lib/diogramos_web/endpoint.ex
+7 −0

Click to load diff…

modified lib/diogramos_web/router.ex
+37 −4

Click to load diff…

added priv/repo/migrations/20260914120000_add_label_to_users_tokens.exs
+12 −0

Click to load diff…

added test/diogramos/accounts_api_token_test.exs
+117 −0

Click to load diff…

added test/diogramos/diagrams/authority_wal_test.exs
+153 −0

Click to load diff…

added test/diogramos_web/api/v1/canvas_api_test.exs
+234 −0

Click to load diff…

added test/diogramos_web/channels/canvas_channel_test.exs
+185 −0

Click to load diff…

added test/support/channel_case.ex
+22 −0

Click to load diff…

Parents: 2852f94