diff --git a/Dockerfile b/Dockerfile
index 8f02c95..00bc305 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,6 +22,10 @@ COPY --from=fetch /pb/pocketbase /usr/local/bin/pocketbase
# Schema-as-code: migrations are baked in and auto-applied on serve.
COPY pb_migrations /pb_migrations
+# JS hooks (custom routes + the device sign-in approve page, issue #12). Loaded
+# from --hooksDir on serve.
+COPY pb_hooks /pb_hooks
+
# Persist DB, uploaded files, and any admin-created migrations here. Runs as root
# so it can always write the volume Coolify attaches — Coolify's persistent mount
# is root-owned, and a non-root user hitting it fails with SQLite "unable to open
@@ -33,4 +37,4 @@ EXPOSE 8090
# Coolify terminates TLS at Traefik; PocketBase listens plain on 8090 behind it.
ENTRYPOINT ["pocketbase"]
-CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations"]
+CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations", "--hooksDir=/pb_hooks"]
diff --git a/README.md b/README.md
index 2d5a020..1e1d7ac 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro
(auth)│ ├─* watchlist (1 row per profile+item)
│ ├─1 prefs (playback/audio/subtitle blob)
│ └─1 addon_config (client-encrypted addon blob; ciphertext only)
+ ├─* device_auth (codeless sign-in requests / approved devices, issue #12)
└ nsfwEnabled (admin-only bool → gates adult vs clean build, issue #16)
```
@@ -34,6 +35,7 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro
| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) |
| `prefs` | base | `profile`→profiles, `data` (json), `updatedAt` (client), `updated` (server) |
| `addon_config` | base | `profile`→profiles, `blob` (**ciphertext**), `salt`, `kdf`, `updatedAt` (client), `updated` (server) |
+| `device_auth` | base | `code` (unique), `deviceName`, `status`, `user`→users, `expiresAt`, + hidden `authToken`/`keyCiphertext`/`pollSecret`/`devicePubKey` — codeless device sign-in (#12) |
**Access rules.** A user only ever reads/writes their own `users` row, their own
`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never
@@ -62,6 +64,24 @@ acceptable for re-enterable addon config, and the client detects it, clears it,
prompts a re-entry. A normal in-app *change password* re-encrypts the blob, so only
reset loses it. This does **not** change the no-E2E stance for the rest of the data.
+### Codeless device sign-in (issue #12)
+
+A new device (a TV) signs into an account with **no password typing and no code
+transcription**: it shows a QR + short code, and an already-logged-in phone/PC
+approves it from `GET /device`. On approval the TV gets a minted session token and
+— **end-to-end, the server never sees it** — the addon-config vault key, so it can
+decrypt the `addon_config` blob it then pulls. This lives in the `device_auth`
+collection (`pb_migrations/1786000000_device_auth.js`) plus two JS hooks
+(`pb_hooks/device_auth.pb.js` routes + cron, `pb_hooks/device_page.pb.js` approve
+page). All create/poll/approve logic is in the hooks (the TV is unauthenticated
+when it requests and polls); the collection exposes only the owner-scoped device
+**list + revoke**. **JWT revocation caveat:** deleting a device row removes it from
+the list but can't invalidate a token already minted (stateless JWTs) — a
+documented v1 limitation.
+
+**Full contract** (endpoints, record shape, the exact crypto — PBKDF2 params +
+the X25519/HKDF/AES-GCM key transfer): [`docs/device-auth-contract.md`](docs/device-auth-contract.md).
+
### The two clocks (issue #11)
Sync rows carry **two** timestamps, and they are not interchangeable:
diff --git a/docs/device-auth-contract.md b/docs/device-auth-contract.md
new file mode 100644
index 0000000..435f1c6
--- /dev/null
+++ b/docs/device-auth-contract.md
@@ -0,0 +1,143 @@
+# Codeless device sign-in — contract (issue #12)
+
+Signing a new device (a TV) into an account with **no password typed on the TV
+and no code transcription**. The TV shows a QR + short code; an already-logged-in
+phone/PC opens the approve page, authenticates, and authorizes the TV, which
+receives a minted session and — end-to-end, without the server ever seeing it —
+the addon-config vault key (#20) so it can decrypt the account's addons.
+
+Server pieces (this repo):
+- `pb_migrations/1786000000_device_auth.js` — the `device_auth` collection.
+- `pb_hooks/device_auth.pb.js` — the request/info/poll/approve/decline routes +
+ a cleanup cron.
+- `pb_hooks/device_page.pb.js` — the self-contained approve web page at `/device`.
+
+Client pieces (amber-app, branch `feature/codeless-signin`):
+- `lib/data/sync/device_key_transfer.dart` — the E2E key-transfer crypto.
+- `lib/data/sync/device_sign_in_service.dart` — request + poll state machine.
+- `lib/features/auth/device_sign_in_screen.dart` — the QR/code screen.
+- `lib/data/sync/device_registry_service.dart` + `features/settings/device_list.dart`
+ — device list / revoke.
+
+## `device_auth` collection
+
+| Field | Type | Notes |
+|-----------------|-----------|-------|
+| `code` | text, **unique** | Short human code (8 chars, Crockford minus I/L/O/U), also the QR's `?code=`. Stored without a dash; shown `XXXX-XXXX`. |
+| `deviceName` | text | Shown in "Approve <deviceName>?". |
+| `status` | select | `pending` \| `approved` \| `denied`. |
+| `user` | rel→users | Set on approval (the authorizing account). |
+| `devicePubKey` | text, **hidden** | TV's ephemeral X25519 public key (base64). Public by nature. |
+| `authToken` | text, **hidden** | Minted user session token. Delivered once via poll, then scrubbed. |
+| `keyCiphertext` | text, **hidden** | Vault key sealed to `devicePubKey` (E2E; opaque). Delivered once, then scrubbed. |
+| `pollSecret` | text, **hidden** | Only the requesting TV holds it; gates the poll route. |
+| `expiresAt` | number | Unix **seconds**. Pending TTL 300 s; reset to a 120 s pickup window on approval. |
+| `created`/`updated` | autodate | `created` is the device-list timestamp. |
+
+**Access rules.** `list`/`view`/`delete` are owner-scoped
+(`user = @request.auth.id`) — that's the device list + revoke. `create`/`update`
+are **locked** (empty rules → superuser only); every write goes through the hooks
+under superuser context. All sensitive fields are `hidden`, so even the owner
+never reads a token/ciphertext/secret through the record API.
+
+## Endpoints
+
+All JSON. Base is the PocketBase origin (`https://pb.petruzalekr.cz`).
+
+### `POST /api/device-auth/request` (public)
+The TV creates a request. Body `{ deviceName, devicePubKey }`.
+→ `{ id, code, pollSecret, expiresAt, ttl }`.
+
+### `GET /api/device-auth/info?code=CODE` (public, non-secret)
+Approve page reads what to show. → `{ deviceName, devicePubKey, status }`.
+404 if unknown or an expired-pending request.
+
+### `GET /api/device-auth/poll?id=..&secret=..` (public, pollSecret-gated)
+The TV polls. `secret` must equal the stored `pollSecret` (else 403).
+- `{ status: "pending", expiresAt }`
+- `{ status: "denied" }`
+- `{ status: "expired" }`
+- `{ status: "approved", token, keyCiphertext, record: {id,email,username,verified} }`
+ — sensitive fields returned **once**; a later poll after pickup returns just
+ `{ status: "approved" }`.
+
+### `POST /api/device-auth/approve` (auth: users)
+Body `{ code, keyCiphertext }`. Mints `e.auth.newAuthToken()`, sets
+`status=approved`, `user=@request.auth.id`, stores the token + ciphertext, resets
+`expiresAt` to now+120 s. → `{ ok, deviceName }`.
+
+### `POST /api/device-auth/decline` (auth: users)
+Body `{ code }`. Sets `status=denied` and scrubs the pending secrets. → `{ ok }`.
+
+### Device list / revoke (plain collection REST, user token)
+- List: `GET /api/collections/device_auth/records?filter=(status='approved')&sort=-created`
+- Revoke: `DELETE /api/collections/device_auth/records/{id}`
+
+### `GET /device?code=CODE` (HTML)
+The self-contained approve page (`device_page.pb.js`). No external assets.
+
+### Cleanup cron (`*/5 * * * *`)
+Scrubs `authToken`/`keyCiphertext`/`pollSecret`/`devicePubKey` from approved rows
+past their pickup window; deletes pending/denied rows a day after expiry. Approved
+rows remain as the device list.
+
+## Crypto (must match the app byte-for-byte)
+
+Two independent pieces. Both are pinned by app unit tests against fixed vectors
+this repo's page code produced under Node (`scratchpad/cryptocheck/*` in the app
+work — see the app PR), so a drift on either side fails a test.
+
+### 1. Addon-config vault key (PBKDF2) — what the page derives
+Matches `addon_config_crypto.dart`:
+- **PBKDF2-HMAC-SHA256**, **210000** iterations, **256-bit** output.
+- Salt = the account's `addon_config.salt` (base64-decoded) if a blob exists;
+ otherwise a fresh 16-byte salt.
+- The page derives the key from the entered password, then transfers
+ `{ salt, keyB64 }` (base64) to the TV, which caches it exactly where the app's
+ password-less reconcile reads it (`CloudSyncService.unlockWithKey`).
+
+### 2. Key transfer (X25519 → HKDF → AES-GCM) — page seals, TV opens
+Matches `device_key_transfer.dart`:
+- **X25519** ECDH (RFC 7748) → 32-byte raw shared secret. Page uses vendored
+ TweetNaCl `scalarMult`; app uses `cryptography`'s X25519. Both clamp the scalar.
+- **HKDF-SHA256**(ikm = shared secret, **salt = ""**, **info = "amber-device-key-v1"**)
+ → 32-byte AES key.
+- **AES-256-GCM**, 12-byte random nonce, 128-bit tag.
+- Public keys: raw **32-byte** X25519 u-coordinate, base64.
+- **Sealed blob (base64):** `ephemeralPubKey[32] ‖ nonce[12] ‖ ciphertext ‖ tag[16]`.
+
+The password never leaves the browser; the vault key is sealed to the TV and the
+server only forwards ciphertext. This preserves #20's "the server can't read the
+addon config" property across the device-sign-in path.
+
+## Flow
+
+```
+TV PocketBase (hooks) Phone/PC (approve page)
+── generate X25519 kp
+── POST request ─────────► create pending row ──► {id,code,pollSecret}
+ show QR (/device?code=)
+ ── open /device?code=CODE
+ ── GET info → deviceName,pubKey
+ ── sign in (email+password)
+ ── derive vault key (PBKDF2)
+ ── seal {salt,keyB64} to pubKey
+── GET poll (secret) ────► pending ── POST approve {code,ct}
+ mint token, store ct ◄──┘
+── GET poll (secret) ────► approved {token,ct,record}
+── open ct → vault key
+── adopt session ────────► (then #11 data + #20 addon pull happen normally)
+```
+
+## Deferred / limitations (v1)
+
+- **JWT revocation.** PocketBase JWTs are stateless. Revoke *deletes the device
+ row* (removes it from the list, stops further polling) but does **not**
+ invalidate a token already held by that device. Full immediate revocation means
+ rotating the user's `tokenKey`, which logs out **every** device — out of scope.
+ Follow-up: a per-device token store / short-lived tokens + refresh.
+- **No addon config yet on the account.** If the approving account has never saved
+ addon config, the page mints a fresh salt for the transfer. That salt only
+ becomes canonical once a device pushes config under it; in the normal case
+ (approving from a device that already has addons) the existing salt is used and
+ everything lines up. Documented edge, not a blocker for the intended flow.
diff --git a/pb_hooks/device_auth.pb.js b/pb_hooks/device_auth.pb.js
new file mode 100644
index 0000000..4f94483
--- /dev/null
+++ b/pb_hooks/device_auth.pb.js
@@ -0,0 +1,259 @@
+///
+
+// Codeless device sign-in — server routes (epic #6, issue #12).
+//
+// The TV is unauthenticated when it creates a request and when it polls, so the
+// whole handshake lives here in hooks (superuser context) rather than in the
+// `device_auth` collection's REST rules — those expose only the owner-scoped
+// device list + revoke. Every route hands back exactly the fields its caller may
+// see; the minted session token and the E2E-encrypted vault key are delivered to
+// the TV once, over a per-request `pollSecret`, then scrubbed.
+//
+// Crypto note: `keyCiphertext` is the addon-config vault key (issue #20)
+// encrypted by the approve page to the TV's ephemeral public key. The server
+// stores and forwards it as an opaque blob and never holds the key — see
+// `pb_hooks/device_page.pb.js` and the app's `device_key_transfer.dart`.
+
+const DEVICE_TTL_SECONDS = 300 // 5 min to approve a fresh request
+const PICKUP_TTL_SECONDS = 120 // after approval, the TV has this long to fetch
+
+// Human code: 8 chars, unambiguous alphabet (Crockford minus I L O U), shown as
+// XXXX-XXXX. Stored without the dash; the app + page normalize before lookup.
+const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+function nowSeconds() {
+ return Math.floor(Date.now() / 1000)
+}
+
+function genCode() {
+ return $security.randomStringWithAlphabet(8, CODE_ALPHABET)
+}
+
+// ── POST /api/device-auth/request ─────────────────────────────────────────────
+// Public. The TV creates a sign-in request and gets back the code to show, the
+// pollSecret only it will hold, and the id + expiry. Body: { deviceName,
+// devicePubKey }.
+routerAdd("POST", "/api/device-auth/request", (e) => {
+ const data = new DynamicModel({ deviceName: "", devicePubKey: "" })
+ e.bindBody(data)
+
+ const collection = $app.findCollectionByNameOrId("device_auth")
+ const pollSecret = $security.randomString(40)
+
+ // Retry on the (astronomically unlikely) unique-code collision.
+ let record = null
+ let lastErr = null
+ for (let i = 0; i < 5; i++) {
+ const rec = new Record(collection)
+ rec.set("code", genCode())
+ rec.set("deviceName", (data.deviceName || "").substring(0, 100))
+ rec.set("status", "pending")
+ rec.set("devicePubKey", (data.devicePubKey || "").substring(0, 500))
+ rec.set("pollSecret", pollSecret)
+ rec.set("expiresAt", nowSeconds() + DEVICE_TTL_SECONDS)
+ try {
+ $app.save(rec)
+ record = rec
+ break
+ } catch (err) {
+ lastErr = err
+ }
+ }
+ if (!record) {
+ throw new BadRequestError("could not create device request", lastErr)
+ }
+
+ return e.json(200, {
+ id: record.id,
+ code: record.getString("code"),
+ pollSecret: pollSecret,
+ expiresAt: record.getInt("expiresAt"),
+ ttl: DEVICE_TTL_SECONDS,
+ })
+})
+
+// ── GET /api/device-auth/info?code=CODE ───────────────────────────────────────
+// Public, non-secret. The approve page uses it to show "Approve ?"
+// and to read the TV's ephemeral public key it encrypts the vault key to. Returns
+// nothing sensitive (no pollSecret, no token).
+routerAdd("GET", "/api/device-auth/info", (e) => {
+ const code = (e.request.url.query().get("code") || "").toUpperCase().trim()
+ if (!code) throw new BadRequestError("missing code")
+
+ let record
+ try {
+ record = $app.findFirstRecordByFilter(
+ "device_auth", "code = {:code}", { code: code })
+ } catch (_) {
+ throw new NotFoundError("unknown code")
+ }
+ if (record.getInt("expiresAt") < nowSeconds() &&
+ record.getString("status") === "pending") {
+ throw new NotFoundError("request expired")
+ }
+ return e.json(200, {
+ deviceName: record.getString("deviceName"),
+ devicePubKey: record.getString("devicePubKey"),
+ status: record.getString("status"),
+ })
+})
+
+// ── GET /api/device-auth/poll?id=..&secret=.. ─────────────────────────────────
+// Public but pollSecret-gated. The TV polls this until approved/denied/expired.
+// The minted token + the E2E vault-key ciphertext are only ever returned here, to
+// a caller holding the matching pollSecret.
+routerAdd("GET", "/api/device-auth/poll", (e) => {
+ const id = e.request.url.query().get("id") || ""
+ const secret = e.request.url.query().get("secret") || ""
+ if (!id || !secret) throw new BadRequestError("missing id/secret")
+
+ let record
+ try {
+ record = $app.findRecordById("device_auth", id)
+ } catch (_) {
+ throw new NotFoundError("unknown request")
+ }
+ // Constant-ish comparison; secret is a 40-char random string.
+ if (record.getString("pollSecret") !== secret) {
+ throw new ForbiddenError("bad secret")
+ }
+
+ const status = record.getString("status")
+ const expiresAt = record.getInt("expiresAt")
+
+ if (status === "pending") {
+ if (expiresAt < nowSeconds()) return e.json(200, { status: "expired" })
+ return e.json(200, { status: "pending", expiresAt: expiresAt })
+ }
+ if (status === "denied") {
+ return e.json(200, { status: "denied" })
+ }
+
+ // approved.
+ const token = record.getString("authToken")
+ if (!token) {
+ // Already picked up (token scrubbed) — the TV has its session. Nothing more.
+ return e.json(200, { status: "approved" })
+ }
+ if (expiresAt < nowSeconds()) {
+ // Pickup window elapsed before the TV fetched — treat as expired so it
+ // restarts cleanly rather than adopting a token about to be scrubbed.
+ return e.json(200, { status: "expired" })
+ }
+
+ const keyCiphertext = record.getString("keyCiphertext")
+ const uid = record.getString("user")
+ let userRecord = null
+ if (uid) {
+ try {
+ const u = $app.findRecordById("users", uid)
+ userRecord = {
+ id: u.id,
+ email: u.getString("email"),
+ username: u.getString("username"),
+ verified: u.getBool("verified"),
+ }
+ } catch (_) { /* user gone; deliver token anyway */ }
+ }
+
+ return e.json(200, {
+ status: "approved",
+ token: token,
+ keyCiphertext: keyCiphertext,
+ record: userRecord,
+ })
+})
+
+// ── POST /api/device-auth/approve ─────────────────────────────────────────────
+// Auth required (a logged-in user). Authorizes a pending request: mints a session
+// token for the caller and stores the E2E vault-key ciphertext the page produced.
+// Body: { code, keyCiphertext }.
+routerAdd("POST", "/api/device-auth/approve", (e) => {
+ const data = new DynamicModel({ code: "", keyCiphertext: "" })
+ e.bindBody(data)
+ const code = (data.code || "").toUpperCase().trim()
+ if (!code) throw new BadRequestError("missing code")
+
+ let record
+ try {
+ record = $app.findFirstRecordByFilter(
+ "device_auth", "code = {:code}", { code: code })
+ } catch (_) {
+ throw new NotFoundError("unknown code")
+ }
+ if (record.getString("status") !== "pending") {
+ throw new BadRequestError("request is not pending")
+ }
+ if (record.getInt("expiresAt") < nowSeconds()) {
+ throw new BadRequestError("request expired")
+ }
+
+ // e.auth is the authenticated users record — mint a real session token for it.
+ const token = e.auth.newAuthToken()
+
+ record.set("status", "approved")
+ record.set("user", e.auth.id)
+ record.set("authToken", token)
+ record.set("keyCiphertext", (data.keyCiphertext || "").substring(0, 4000))
+ // Reset the clock to the shorter pickup window for the TV to fetch the token.
+ record.set("expiresAt", nowSeconds() + PICKUP_TTL_SECONDS)
+ $app.save(record)
+
+ return e.json(200, { ok: true, deviceName: record.getString("deviceName") })
+}, $apis.requireAuth("users"))
+
+// ── POST /api/device-auth/decline ─────────────────────────────────────────────
+// Auth required. The user declines a pending request.
+routerAdd("POST", "/api/device-auth/decline", (e) => {
+ const data = new DynamicModel({ code: "" })
+ e.bindBody(data)
+ const code = (data.code || "").toUpperCase().trim()
+ if (!code) throw new BadRequestError("missing code")
+
+ let record
+ try {
+ record = $app.findFirstRecordByFilter(
+ "device_auth", "code = {:code}", { code: code })
+ } catch (_) {
+ throw new NotFoundError("unknown code")
+ }
+ if (record.getString("status") === "pending") {
+ record.set("status", "denied")
+ // Scrub the pending secrets immediately; a declined request keeps nothing.
+ record.set("pollSecret", "")
+ record.set("devicePubKey", "")
+ $app.save(record)
+ }
+ return e.json(200, { ok: true })
+}, $apis.requireAuth("users"))
+
+// ── Cleanup cron ──────────────────────────────────────────────────────────────
+// Scrub the minted token + vault ciphertext from approved rows whose pickup
+// window elapsed (they've been delivered, or the TV gave up), and delete stale
+// pending/denied rows. The device list keeps the approved rows (status + user +
+// created), just without any live secret at rest.
+cronAdd("device_auth_cleanup", "*/5 * * * *", () => {
+ const now = nowSeconds()
+ try {
+ const stale = $app.findRecordsByFilter(
+ "device_auth", "status = 'approved' && authToken != '' && expiresAt < {:now}",
+ "created", 200, 0, { now: now })
+ for (const r of stale) {
+ r.set("authToken", "")
+ r.set("keyCiphertext", "")
+ r.set("pollSecret", "")
+ r.set("devicePubKey", "")
+ $app.save(r)
+ }
+ } catch (_) { /* best effort */ }
+ try {
+ // Pending that never got approved, and declined rows: drop them a day after
+ // expiry so they don't accumulate. (Approved rows stay — they're the device
+ // list.)
+ const dead = $app.findRecordsByFilter(
+ "device_auth",
+ "(status = 'pending' || status = 'denied') && expiresAt < {:cutoff}",
+ "created", 200, 0, { cutoff: now - 86400 })
+ for (const r of dead) $app.delete(r)
+ } catch (_) { /* best effort */ }
+})
diff --git a/pb_hooks/device_page.pb.js b/pb_hooks/device_page.pb.js
new file mode 100644
index 0000000..9c26169
--- /dev/null
+++ b/pb_hooks/device_page.pb.js
@@ -0,0 +1,279 @@
+///
+
+// Approve page for codeless device sign-in (epic #6, issue #12).
+//
+// Served at GET /device?code=CODE — the QR the TV shows encodes exactly this URL.
+// The page is self-contained (no external assets, no CDN): the user signs in with
+// email + password, the page derives the addon-config vault key (#20) from the
+// password, seals it to the TV's ephemeral public key, and approves the request.
+//
+// Crypto here MUST match the app byte-for-byte (verified by the app's
+// device_key_transfer_test + addon_config_crypto_interop_test against fixed
+// vectors this same code produced under Node):
+// - vault key: PBKDF2-HMAC-SHA256, 210000 iters, 256-bit, salt = account salt
+// (addon_config_crypto.dart).
+// - key transfer: X25519 (vendored TweetNaCl scalarMult, RFC 7748) → HKDF-SHA256
+// (salt="", info="amber-device-key-v1") → AES-256-GCM (12B nonce, 128b tag),
+// blob = ephPub[32] ‖ nonce[12] ‖ ct ‖ tag[16], base64 (device_key_transfer.dart).
+//
+// The password never leaves the page; the vault key is sealed to the TV and never
+// reaches the server (E2E for this one blob). The minted token is delivered by the
+// poll route, not here.
+
+routerAdd("GET", "/device", (e) => {
+ return e.html(200, DEVICE_APPROVE_HTML)
+})
+
+const DEVICE_APPROVE_HTML = `
+
+
+
+
+
+Amber — přihlásit zařízení
+
+
+
+
+
Přihlásit zařízení
+
Amber — přihlášení televize bez kódu
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Heslo se použije jen ve vašem prohlížeči k odemčení nastavení
+ doplňků a odešle se televizi zašifrovaně. Na server se heslo ani klíč nikdy
+ neposílají.
+
+
+
+
+`
diff --git a/pb_migrations/1786000000_device_auth.js b/pb_migrations/1786000000_device_auth.js
new file mode 100644
index 0000000..2df7f03
--- /dev/null
+++ b/pb_migrations/1786000000_device_auth.js
@@ -0,0 +1,100 @@
+///
+
+// Codeless device sign-in / phone-approve (epic #6, issue #12).
+//
+// A new device (a TV) signs into an account with **no password typing and no
+// code transcription**: it shows a QR + short human code, and an already-logged-in
+// phone/PC opens the approve page, authenticates, and authorizes the device. The
+// TV then receives a minted session token and pulls its data (#11) + its
+// client-encrypted addon config (#20) — no pairing code, nothing more to type.
+//
+// This collection is the request/handshake record. It is **never** driven through
+// the normal collection REST rules by the TV — the TV is unauthenticated when it
+// creates the request and when it polls — so all of the create/poll/approve/
+// decline logic lives in the `pb_hooks/device_auth.pb.js` routes, which run with
+// superuser context and hand back only the fields a given caller may see. The
+// collection rules here therefore expose **only** the owner-scoped list/view/
+// delete (the device list + revoke, #12's "device list / revoke"): create/update
+// are locked to superusers (i.e. the hooks), and every sensitive field is `hidden`
+// so it can never leak through the record API even to the owner.
+//
+// Fields:
+// - `code` short human-readable code (also the QR's `?code=`), unique.
+// - `deviceName` what the phone shows in "Approve ?".
+// - `status` pending | approved | denied.
+// - `user` rel→users, set on approval (the account that authorized it).
+// - `devicePubKey` the TV's ephemeral X25519 public key (base64), used by the
+// approve page to end-to-end encrypt the addon-config vault key
+// to the TV. Public by nature; hidden only to keep the record
+// API tidy.
+// - `authToken` (hidden) the minted user session token, delivered to the TV
+// exactly once via the poll route then cleared.
+// - `keyCiphertext` (hidden) the addon-config vault key, encrypted to
+// `devicePubKey` by the approve page — the server never sees the
+// key itself (E2E). Delivered once then cleared.
+// - `pollSecret` (hidden) a secret only the requesting TV holds; the poll route
+// returns the sensitive fields only to a caller presenting it.
+// - `expiresAt` unix seconds; a request past it is rejected (~5 min TTL). A
+// plain number (not a date) so the hooks can compare it against
+// the wall clock without any datetime parsing in the JS VM.
+
+migrate((app) => {
+ const users = app.findCollectionByNameOrId("users")
+
+ const deviceAuth = new Collection({
+ type: "base",
+ name: "device_auth",
+ // Owner-scoped read + delete only: this is the device list (approved rows)
+ // and revoke. Create/update happen in the hooks under superuser context, so
+ // they are intentionally locked here (null == superuser-only).
+ listRule: "@request.auth.id != '' && user = @request.auth.id",
+ viewRule: "@request.auth.id != '' && user = @request.auth.id",
+ createRule: null,
+ updateRule: null,
+ deleteRule: "@request.auth.id != '' && user = @request.auth.id",
+ fields: [
+ { type: "text", name: "code", required: true, max: 40 },
+ { type: "text", name: "deviceName", max: 100 },
+ {
+ type: "select",
+ name: "status",
+ required: true,
+ maxSelect: 1,
+ values: ["pending", "approved", "denied"],
+ },
+ {
+ type: "relation",
+ name: "user",
+ required: false,
+ collectionId: users.id,
+ cascadeDelete: true,
+ maxSelect: 1,
+ minSelect: 0,
+ },
+ // The TV's ephemeral public key (base64). Not secret.
+ { type: "text", name: "devicePubKey", hidden: true, max: 500 },
+ // Minted session token — delivered once, then cleared. Never returned by
+ // the record API (hidden); only the poll hook hands it back, over the
+ // matching pollSecret.
+ { type: "text", name: "authToken", hidden: true, max: 2000 },
+ // Vault key encrypted to devicePubKey (E2E; opaque to the server).
+ { type: "text", name: "keyCiphertext", hidden: true, max: 4000 },
+ // Only the requesting device holds this; gates the poll route.
+ { type: "text", name: "pollSecret", hidden: true, max: 200 },
+ // Unix seconds. Past it → the request is dead.
+ { type: "number", name: "expiresAt", required: true },
+ { type: "autodate", name: "created", onCreate: true },
+ { type: "autodate", name: "updated", onCreate: true, onUpdate: true },
+ ],
+ indexes: [
+ "CREATE UNIQUE INDEX `idx_device_auth_code` ON `device_auth` (`code`)",
+ "CREATE INDEX `idx_device_auth_user` ON `device_auth` (`user`)",
+ ],
+ })
+ app.save(deviceAuth)
+}, (app) => {
+ // ---- Down migration ----------------------------------------------------
+ try {
+ app.delete(app.findCollectionByNameOrId("device_auth"))
+ } catch (_) { /* already gone */ }
+})
diff --git a/scripts/verify.py b/scripts/verify.py
index 89a1a01..fee8dfa 100644
--- a/scripts/verify.py
+++ b/scripts/verify.py
@@ -105,6 +105,34 @@ check("addon_config.updated is a server autodate (pull cursor)", st,
check("addon_config has unique (profile) index", st,
any("idx_addon_config_profile" in i for i in ac.get("indexes", [])))
+# --- codeless device sign-in (issue #12) ---
+st, da = req("GET", "/api/collections/device_auth", admin)
+check("device_auth collection exists", st, st == 200)
+# Owner-scoped read + delete (the device list + revoke); create/update locked to
+# the hooks (superuser), so those rules are empty.
+check("device_auth list/view/delete are owner-scoped", st,
+ all("user = @request.auth.id" in (da.get(k) or "") for k in
+ ("listRule", "viewRule", "deleteRule")))
+check("device_auth create/update are locked (hooks-only)", st,
+ not (da.get("createRule") or "") and not (da.get("updateRule") or ""))
+da_fields = {f["name"]: f for f in da.get("fields", [])}
+check("device_auth has code/status/expiresAt", st,
+ {"code", "status", "expiresAt"} <= set(da_fields))
+check("device_auth hides authToken/keyCiphertext/pollSecret/devicePubKey", st,
+ all(da_fields.get(n, {}).get("hidden") for n in
+ ("authToken", "keyCiphertext", "pollSecret", "devicePubKey")))
+check("device_auth.code has a unique index", st,
+ any("idx_device_auth_code" in i for i in da.get("indexes", [])))
+
+# The custom routes are registered (hooks loaded). A bad request still proves the
+# route exists (400/404, not the 404-with-no-such-route body). The approve page
+# GET /device should return HTML.
+st, _ = req("POST", "/api/device-auth/request", body={"deviceName": "verify.py",
+ "devicePubKey": "AAAA"})
+check("POST /api/device-auth/request is served (hook loaded)", st, st == 200)
+st, _ = req("GET", "/api/device-auth/poll?id=nope&secret=nope")
+check("GET /api/device-auth/poll is served", st, st in (200, 400, 403, 404))
+
# --- create two users (superuser create; nsfwEnabled set by admin) ---
def make_user(email, uname):
st, r = req("POST", "/api/collections/users/records", admin, {