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

+158
View File
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// MB1: pairing the mobile app and its device tokens (13 §2, §4).
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { auditLog, mobileDevices, users } from '../src/db/schema.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('mobile app pairing', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let admin: Req;
let dan: Req;
let danId: string;
const withHeaders =
(headers: Record<string, string>): Req =>
(method, url, payload) =>
t.app.inject({ method: method as 'GET', url, payload: payload as object, headers });
const bearer = (token: string) => withHeaders({ authorization: `Bearer ${token}` });
// The app sends X-Requested-With on its one call without a token (CSRF hook, 04 §2.2).
const anon = withHeaders(H);
const pair = (code: string, name = 'Pixel 9') =>
anon('POST', '/api/mobile/pair', { code, name, platform: 'android' });
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = withHeaders({ ...H, cookie: await t.login(ADMIN.email, ADMIN.password) });
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
dan = withHeaders({ ...H, cookie: await t.login('dan@example.com', 'dan-password-123') });
// GET /api/me needs an extension.
for (const [number, userId] of [
['101', danId],
['102', (await t.db.select().from(users).where(eq(users.email, ADMIN.email)))[0]!.id],
])
await admin('POST', '/api/extensions', { number, name: `Ext ${number}`, userId });
});
afterAll(() => t?.close());
let token: string;
let deviceId: string;
it('pairs a phone once with a code from the web page', async () => {
const r = await dan('POST', '/api/me/mobile-devices/pair');
expect(r.statusCode).toBe(200);
const c = r.json();
expect(c.code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{12}$/);
expect(c.server).toBe('https://pbx.test');
expect(c.link).toBe(
`selfphoneng://pair?server=${encodeURIComponent('https://pbx.test')}&code=${c.code}`,
);
expect(c.qr).toMatch(/^data:image\/png;base64,/);
expect(new Date(c.expiresAt).getTime() - Date.now()).toBeGreaterThan(9 * 60_000);
// Typed by hand: lower case and spaces are fine.
const typed = `${c.code.slice(0, 4)} ${c.code.slice(4)}`.toLowerCase();
const p = await pair(typed);
expect(p.statusCode).toBe(200);
expect(p.json().user).toEqual({ name: expect.any(String), email: 'dan@example.com' });
token = p.json().token;
expect(token).toMatch(/^spm_[A-Za-z0-9]{40}$/);
expect((await pair(c.code)).json().error.code).toBe('pair_code_invalid'); // used
const rows = await t.db.select().from(mobileDevices);
expect(rows).toHaveLength(1);
expect(rows[0]!.tokenHash).not.toContain(token.slice(4)); // stored hashed
expect(rows[0]!.pairCodeHash).toBeNull();
deviceId = rows[0]!.id;
});
it('the device token acts as its user', async () => {
const me = bearer(token);
expect((await me('GET', '/api/me')).statusCode).toBe(200);
// No X-Requested-With needed with a Bearer token.
expect((await me('PATCH', '/api/me', { dnd: true })).statusCode).toBe(200);
const list = (await me('GET', '/api/me/mobile-devices')).json().items;
expect(list).toEqual([
{
id: deviceId,
name: 'Pixel 9',
platform: 'android',
tokenPrefix: token.slice(0, 8),
createdAt: expect.any(String),
lastUsedAt: expect.any(String),
current: true,
},
]);
expect((await dan('GET', '/api/me/mobile-devices')).json().items[0].current).toBe(false);
// A phone can't make itself a spare.
expect((await me('POST', '/api/me/mobile-devices/pair')).statusCode).toBe(403);
expect((await bearer(`spm_${'x'.repeat(40)}`)('GET', '/api/me')).statusCode).toBe(401);
});
it("an admin's phone is not an admin", async () => {
const code = (await admin('POST', '/api/me/mobile-devices/pair')).json().code;
const phone = bearer((await pair(code, 'iPhone')).json().token);
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
expect((await phone('GET', '/api/users')).statusCode).toBe(403);
expect((await phone('GET', '/api/api-tokens')).statusCode).toBe(403);
expect((await admin('GET', '/api/users')).statusCode).toBe(200);
});
it('refuses expired codes, bad input, and replaces an unused code', async () => {
const first = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
const second = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
expect((await pair(first)).statusCode).toBe(404); // replaced
await t.db
.update(mobileDevices)
.set({ pairExpiresAt: new Date(Date.now() - 1000) })
.where(eq(mobileDevices.userId, danId));
expect((await pair(second)).json().error.code).toBe('pair_code_invalid');
const bad = await anon('POST', '/api/mobile/pair', {
code: second,
name: 'x',
platform: 'windows',
});
expect(bad.statusCode).toBe(400);
});
it('a disabled user loses their phones and can’t pair one', async () => {
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
await t.db.update(users).set({ isActive: false }).where(eq(users.id, danId));
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
expect((await pair(code)).statusCode).toBe(404);
await t.db.update(users).set({ isActive: true }).where(eq(users.id, danId));
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(200);
});
it('removing a device ends its token; only your own', async () => {
const adminPhone = (await t.db.select().from(mobileDevices)).find((d) => d.name === 'iPhone')!;
expect(
(await bearer(token)('DELETE', `/api/me/mobile-devices/${adminPhone.id}`)).statusCode,
).toBe(404);
expect((await bearer(token)('DELETE', `/api/me/mobile-devices/${deviceId}`)).statusCode).toBe(
200,
);
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
const actions = (await t.db.select().from(auditLog))
.filter((a) => a.entityType === 'mobile_device')
.map((a) => a.action);
expect(actions).toContain('create');
expect(actions).toContain('delete');
});
it('changing the password removes every paired phone', async () => {
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
const phone = bearer((await pair(code)).json().token);
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
const r = await dan('POST', '/api/auth/change-password', {
currentPassword: 'dan-password-123',
newPassword: 'a-new-dan-password-456',
});
expect(r.statusCode).toBe(200);
expect((await phone('GET', '/api/me')).statusCode).toBe(401);
expect((await dan('GET', '/api/me/mobile-devices')).json().items).toEqual([]);
});
});
+2 -2
View File
@@ -115,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 44 tables', async () => {
it('creates all 45 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 44 });
expect(r.rows[0]).toEqual({ n: 45 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {