amber-backend/pb_migrations/1786000000_device_auth.js

101 lines
5 KiB
JavaScript
Raw Normal View History

/// <reference path="../pb_data/types.d.ts" />
// 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 <deviceName>?".
// - `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 */ }
})