/// // 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 */ } })