api: pair the mobile app with device tokens

MB1 (spec 13). Decision 0005: our own Android/iPhone app, Expo in the
mobile repo, user portal only, no push in v1.

- mobile_devices (migration 0013): one-time pairing codes from My settings
  (QR or server + code, 10 minutes), traded by the app for an spm_ device
  token that acts as its user and never as an admin
- POST /api/me/mobile-devices/pair (web session only), POST /api/mobile/pair,
  GET/DELETE /api/me/mobile-devices
- signing out everywhere (password change or reset, disabling) removes
  paired phones too
- My settings: Mobile app section with the QR code and paired phones
- mobile/ (its own repo) ignored here
This commit is contained in:
iamdoubz committed 2026-10-26 17:12:53 -05:00
1 parent adb6d52ddf
commit 0cdac0bcdc
26 files changed
+5340 -18

No files matched your search

@@ -0,0 +1,19 @@
CREATE TABLE "mobile_devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text,
"platform" text,
"token_prefix" text,
"token_hash" text,
"pair_code_hash" text,
"pair_expires_at" timestamp with time zone,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "mobile_devices_tokenHash_unique" UNIQUE("token_hash"),
CONSTRAINT "mobile_devices_pairCodeHash_unique" UNIQUE("pair_code_hash"),
CONSTRAINT "mobile_devices_platform_check" CHECK ("mobile_devices"."platform" in ('android', 'ios'))
);
--> statement-breakpoint
ALTER TABLE "mobile_devices" ADD CONSTRAINT "mobile_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mobile_devices_user_id_index" ON "mobile_devices" USING btree ("user_id");
File diff suppressed because it is too large. Load diff
@@ -92,6 +92,13 @@
"when": 1792951905226,
"tag": "0012_sso",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1793052019332,
"tag": "0013_mobile_devices",
"breakpoints": true
}
]
}
+23
View File
@@ -134,6 +134,29 @@ export const apiTokens = pgTable('api_tokens', {
...timestamps,
});
/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */
export const mobileDevices = pgTable(
'mobile_devices',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text(),
platform: text(),
tokenPrefix: text(),
tokenHash: text().unique(),
pairCodeHash: text().unique(),
pairExpiresAt: tstz(),
lastUsedAt: tstz(),
...timestamps,
},
(t) => [
index().on(t.userId),
check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`),
],
);
// ───────────── extensions ─────────────
export const outboundClass = pgEnum('outbound_class', [
'internal',