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

+2
View File
@@ -6,3 +6,5 @@ data/
coverage/
.pnpm-store/
*.pid
# The mobile app is its own repo (13), checked out here beside the PBX.
mobile/
+1
View File
@@ -6,3 +6,4 @@ dist/
AGENTS.md
CLAUDE.md
apps/api/src/db/migrations/
mobile/
+1
View File
@@ -29,6 +29,7 @@ write the software around it: database, config generator, API, web UI.
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API |
## Hard rules
+2
View File
@@ -23,6 +23,7 @@ import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
import { meRoutes } from './routes/me.ts';
import { mobileRoutes } from './routes/mobile.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { queueRoutes } from './routes/queues.ts';
@@ -159,6 +160,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(settingsRoutes);
app.register(liveRoutes);
app.register(meRoutes);
app.register(mobileRoutes);
app.register(contactRoutes);
app.register(soundRoutes);
app.register(ringGroupRoutes);
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Paired phones (13 §2): a one-time pairing code from the web, traded by the app for a device token
// `Authorization: Bearer spm_<40 base62>`, stored as sha256, acting as the phone's user.
import { randomInt } from 'node:crypto';
import { and, eq, gt, isNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { mobileDevices, users } from '../db/schema.ts';
import { generateToken, hashToken } from './tokens.ts';
export const DEVICE_TOKEN_RE = /^spm_[A-Za-z0-9]{40}$/;
/** No 0/O/1/I: people type it from a screen. */
export const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export const PAIR_CODE_TTL_MS = 10 * 60_000;
export const generateDeviceToken = () => `spm_${generateToken().slice(3)}`;
export function generatePairCode(): string {
let s = '';
for (let i = 0; i < 12; i++) s += PAIR_CODE_CHARS[randomInt(PAIR_CODE_CHARS.length)];
return s;
}
/** The device's (active) user, or null for an unknown, removed or malformed token. */
export async function getDeviceUser(db: Db, token: string) {
if (!DEVICE_TOKEN_RE.test(token)) return null;
const [row] = await db
.select({ id: mobileDevices.id, user: users })
.from(mobileDevices)
.innerJoin(users, eq(users.id, mobileDevices.userId))
.where(and(eq(mobileDevices.tokenHash, hashToken(token)), eq(users.isActive, true)));
if (!row) return null;
await db
.update(mobileDevices)
.set({ lastUsedAt: new Date() })
.where(eq(mobileDevices.id, row.id));
return { user: row.user, deviceId: row.id };
}
/**
* Trades a pairing code for a device token, once. Null for a code that's unknown, used, expired,
* or whose user was disabled since.
*/
export async function pairDevice(
db: Db,
code: string,
device: { name: string; platform: 'android' | 'ios' },
) {
const [row] = await db
.select({ id: mobileDevices.id, user: users })
.from(mobileDevices)
.innerJoin(users, eq(users.id, mobileDevices.userId))
.where(
and(
eq(mobileDevices.pairCodeHash, hashToken(code)),
gt(mobileDevices.pairExpiresAt, new Date()),
eq(users.isActive, true),
),
);
if (!row) return null;
const token = generateDeviceToken();
// The code is cleared in the same statement: two apps racing with one code can't both win.
const [won] = await db
.update(mobileDevices)
.set({
...device,
tokenPrefix: token.slice(0, 8),
tokenHash: hashToken(token),
pairCodeHash: null,
pairExpiresAt: null,
})
.where(and(eq(mobileDevices.id, row.id), isNull(mobileDevices.tokenHash)))
.returning({ id: mobileDevices.id });
return won ? { token, deviceId: won.id, user: row.user } : null;
}
+14 -7
View File
@@ -5,6 +5,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
import { getDeviceUser } from './devices.ts';
import { getTokenUser } from './tokens.ts';
export type CurrentUser = typeof users.$inferSelect;
@@ -15,22 +16,28 @@ declare module 'fastify' {
sessionId: string | null;
/** Set when the request came with an API token instead of a session. */
apiTokenId?: string;
/** Set when the request came from a paired phone (13 §2). */
mobileDeviceId?: string;
}
}
/**
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
* Enforces must_change_password outside /api/auth/*.
* Sets req.user from a device token (`spm_`, 13 §2) or API token (Authorization: Bearer, 04 §2.3),
* or else the session cookie. Enforces must_change_password outside /api/auth/*.
*/
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
if (!t) throw unauthorized();
req.user = t.user;
const raw = auth.slice(7).trim();
const d = raw.startsWith('spm_') ? await getDeviceUser(req.server.db, raw) : null;
const t = d ? null : await getTokenUser(req.server.db, raw);
if (!d && !t) throw unauthorized();
// A phone is never an admin (13 §2): a lost one mustn't open the admin panel or recordings.
req.user = d ? { ...d.user, role: 'user' } : t!.user;
req.sessionId = null;
req.apiTokenId = t.tokenId;
if (t.user.mustChangePassword)
if (d) req.mobileDeviceId = d.deviceId;
else req.apiTokenId = t!.tokenId;
if (req.user.mustChangePassword)
throw forbidden('Change your password first', 'password_change_required');
return;
}
+6 -2
View File
@@ -5,7 +5,7 @@ import { and, eq, gt, ne } from 'drizzle-orm';
import type { FastifyReply } from 'fastify';
import type { Db } from '../db/client.ts';
import { encrypt } from '../crypto/secrets.ts';
import { sessions, users } from '../db/schema.ts';
import { mobileDevices, sessions, users } from '../db/schema.ts';
export const COOKIE = 'sp_session';
const DAY = 86_400_000;
@@ -83,8 +83,12 @@ export async function deleteSession(db: Db, token: string) {
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
}
/** Logs a user out everywhere except (optionally) the current session. */
/**
* Logs a user out everywhere except (optionally) the current session, paired phones included (13 §2:
* a password change, a reset, or disabling them).
*/
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
await db.delete(mobileDevices).where(eq(mobileDevices.userId, userId));
await db
.delete(sessions)
.where(
@@ -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',
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Pairing the mobile app (13 §2, §4): a one-time code from My settings, traded by the app for a
// device token; the person's paired phones, and removing one.
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import QRCode from 'qrcode';
import { z } from 'zod';
import {
MobilePairSchema,
type ListResponse,
type MobileDeviceDto,
type MobilePairCode,
type MobilePairResult,
} from '@selfphoneng/shared';
import { PAIR_CODE_TTL_MS, generatePairCode, pairDevice } from '../auth/devices.ts';
import { SLOW, requireAuth } from '../auth/guards.ts';
import { hashToken } from '../auth/tokens.ts';
import { mobileDevices } from '../db/schema.ts';
import { forbidden, HttpError, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
const IdParams = z.object({ id: z.uuid() });
export async function mobileRoutes(app: FastifyInstance) {
const db = app.db;
const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip });
app.post(
'/api/me/mobile-devices/pair',
{ preHandler: requireAuth, config: SLOW },
async (req): Promise<MobilePairCode> => {
// Only a person at the web page: no token (a stolen phone can't make itself a spare).
if (!req.sessionId) throw forbidden('Pair a phone from the web page');
const userId = req.user!.id;
await db
.delete(mobileDevices)
.where(and(eq(mobileDevices.userId, userId), isNull(mobileDevices.tokenHash)));
const code = generatePairCode();
const expiresAt = new Date(Date.now() + PAIR_CODE_TTL_MS);
const [d] = await db
.insert(mobileDevices)
.values({ userId, pairCodeHash: hashToken(code), pairExpiresAt: expiresAt })
.returning({ id: mobileDevices.id });
await audit(db, {
...act(req),
action: 'create',
entityType: 'mobile_device',
entityId: d!.id,
summary: 'Made a code to pair a phone',
});
const server = `https://${app.config.PUBLIC_HOSTNAME}`;
const link = `selfphoneng://pair?${new URLSearchParams({ server, code })}`;
return {
code,
server,
link,
qr: await QRCode.toDataURL(link, { margin: 2, width: 280 }),
expiresAt: expiresAt.toISOString(),
};
},
);
/** Public: the code is the credential. The only time the device token is shown. */
app.post('/api/mobile/pair', { config: SLOW }, async (req): Promise<MobilePairResult> => {
const { code, ...device } = MobilePairSchema.parse(req.body);
const paired = await pairDevice(db, code, device);
if (!paired)
throw new HttpError(
404,
'pair_code_invalid',
'That code is wrong or expired. Make a new one.',
);
await audit(db, {
actorUserId: paired.user.id,
actorIp: req.ip,
action: 'create',
entityType: 'mobile_device',
entityId: paired.deviceId,
summary: `Paired ${device.platform} phone ${device.name}`,
});
return {
token: paired.token,
user: { name: paired.user.displayName, email: paired.user.email },
};
});
app.get(
'/api/me/mobile-devices',
{ preHandler: requireAuth },
async (req): Promise<ListResponse<MobileDeviceDto>> => {
const rows = await db
.select()
.from(mobileDevices)
.where(and(eq(mobileDevices.userId, req.user!.id), isNotNull(mobileDevices.tokenHash)))
.orderBy(mobileDevices.createdAt);
return {
items: rows.map((d) => ({
id: d.id,
name: d.name!,
platform: d.platform as MobileDeviceDto['platform'],
tokenPrefix: d.tokenPrefix!,
createdAt: d.createdAt.toISOString(),
lastUsedAt: d.lastUsedAt?.toISOString() ?? null,
current: d.id === req.mobileDeviceId,
})),
nextCursor: null,
};
},
);
/** Removes one of my phones; the app's Sign out removes itself. */
app.delete('/api/me/mobile-devices/:id', { preHandler: requireAuth }, async (req) => {
const { id } = IdParams.parse(req.params);
const [d] = await db
.delete(mobileDevices)
.where(and(eq(mobileDevices.id, id), eq(mobileDevices.userId, req.user!.id)))
.returning();
if (!d) throw notFound('Phone not found');
await audit(db, {
...act(req),
action: 'delete',
entityType: 'mobile_device',
entityId: id,
summary: `Removed phone ${d.name ?? '(not paired)'}`,
});
return { ok: true };
});
}
+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) => {
+6
View File
@@ -36,6 +36,8 @@ import type {
ContactShare,
ContactShareDto,
MyCallDto,
MobileDeviceDto,
MobilePairCode,
RecordingDto,
AuthUser,
DidCreate,
@@ -303,6 +305,10 @@ export const meApi = {
disconnectContrack: () => api<MeDto>('DELETE', '/api/me/contrack'),
contrackOptions: (o: ContrackOptions) => api<MeDto>('PATCH', '/api/me/contrack', o),
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
pairPhone: () => api<MobilePairCode>('POST', '/api/me/mobile-devices/pair'),
phones: () =>
api<ListResponse<MobileDeviceDto>>('GET', '/api/me/mobile-devices').then((r) => r.items),
removePhone: (id: string) => api<{ ok: true }>('DELETE', `/api/me/mobile-devices/${id}`),
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
setQueuePaused: (queueId: string, paused: boolean) =>
api<{ ok: true }>('POST', `/api/me/queues/${queueId}/${paused ? 'pause' : 'unpause'}`),
+88 -2
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call
// report email, my Contrack account (11 §4.3).
// report email, my Contrack account (11 §4.3), pairing the mobile app (13 §2).
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
@@ -70,6 +70,7 @@ function SettingsForms({ me }: { me: MeDto }) {
</h2>
<PhoneAppSetup load={meApi.softphoneQr} />
</section>
<MobileApp />
<SignInCard />
</div>
);
@@ -411,3 +412,88 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
</section>
);
}
/** Pairing our own app (13 §2): a one-time QR code, and the phones paired so far. */
function MobileApp() {
const qc = useQueryClient();
const pair = useMutation({ mutationFn: meApi.pairPhone });
// While a code is up, look for the phone that uses it, then put the code away.
const phones = useQuery({
queryKey: ['me', 'phones'],
queryFn: meApi.phones,
refetchInterval: pair.data ? 3000 : false,
});
const [before, setBefore] = useState(0);
const paired = pair.data && (phones.data?.length ?? 0) > before;
useEffect(() => {
if (paired) pair.reset();
}, [paired, pair]);
const remove = useMutation({
mutationFn: meApi.removePhone,
onSuccess: () => qc.invalidateQueries({ queryKey: ['me', 'phones'] }),
});
return (
<section aria-labelledby="mob-h" className="flex flex-col gap-3 text-sm">
<h2 id="mob-h" className="text-lg font-semibold">
Mobile app
</h2>
<p>
The SelfPhoneNG app for Android and iPhone signs in by pairing: tap{' '}
<em>Pair with a server</em> in the app and point the camera at the code below. It rings only
while the app is open.
</p>
{phones.data && phones.data.length > 0 && (
<ul className="flex flex-col gap-2">
{phones.data.map((d) => (
<li key={d.id} className="flex flex-wrap items-center gap-3">
<span>
{d.name}{' '}
<span className="text-zinc-500">
({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '}
{new Date(d.createdAt).toLocaleDateString()}
{d.lastUsedAt && `, last used ${new Date(d.lastUsedAt).toLocaleString()}`})
</span>
</span>
<Button onClick={() => remove.mutate(d.id)} disabled={remove.isPending}>
Remove
</Button>
</li>
))}
</ul>
)}
{!pair.data ? (
<Button
className="self-start"
onClick={() => {
setBefore(phones.data?.length ?? 0);
pair.mutate();
}}
disabled={pair.isPending}
>
Pair a phone
</Button>
) : (
<div className="flex flex-col gap-2">
<img
src={pair.data.qr}
alt="Pairing QR code for the mobile app"
width={280}
height={280}
className="rounded bg-white"
/>
<p>
Or type it in the app: server <span className="font-mono">{pair.data.server}</span>,
code <span className="font-mono">{pair.data.code}</span>
</p>
<p className="text-xs text-zinc-500">
Works once, until {new Date(pair.data.expiresAt).toLocaleTimeString()}. Whoever uses it
gets into your account: don't share it.
</p>
</div>
)}
{(pair.error ?? phones.error ?? remove.error) && (
<Alert>{(pair.error ?? phones.error ?? remove.error)!.message}</Alert>
)}
</section>
);
}
+19
View File
@@ -0,0 +1,19 @@
# 0005 — Our own mobile app: React Native (Expo) in the `mobile` repo
Owner, 2026-10-26: build an Android and iPhone app instead of only recommending Linphone (this
reverses the "native mobile apps" non-goal in 00 §2). One codebase for both, in its own repo
(`git.dou.bet/spng/mobile`, AGPL-3.0), with this repo as a git submodule for
`@selfphoneng/shared`. **Expo (React Native)** because the web client is already React 19 +
TypeScript + react-query + zustand + zod: the types, schemas and the SIP.js call code carry over,
which Flutter or Kotlin Multiplatform would not allow. Calls use **SIP.js + react-native-webrtc**
(both MIT) against the browser softphone's existing `-web` endpoint, so Asterisk stays the only SIP
stack; Linphone SDK and PJSIP were rejected (GPL with a commercial dual license, which conflicts
with App Store terms, and their React Native wrappers are abandoned). The native call screen is
**expo-callkit-telecom** (MIT: CallKit and Jetpack Core-Telecom); react-native-callkeep is
unmaintained and uses the older ConnectionService. v1 has **no push**: the app rings only while
open, and CFNA to a mobile number is the fallback (07 §5). Push comes later as FCM (Play) and
UnifiedPush (F-Droid) on Android and a relay for Apple's VoIP push; that adds a closed-source
dependency (Play Services) to the Play build, which needs its own decision then. iOS builds without
a Mac use **ios-builder** (MobAI-App, MIT), which runs Xcode on a macOS CI runner. For now the app
is built and sideloaded only; App Store and Play listings, and the AGPL/App Store terms question,
come later.
+1 -1
View File
@@ -27,7 +27,7 @@ Target user: one small business or home lab, 1–100 extensions, one server.
Do NOT build these in v1. Do not leave half-built hooks for them either.
- Multi-tenancy (several companies on one install)
- Native mobile apps (we document open source SIP apps instead, see `07-provisioning.md`)
- ~~Native mobile apps~~: reversed 2026-10-26, our own app is `13-mobile.md` (decision 0005)
- Video calls, screen sharing, team chat
- Fax
- CRM integrations (the REST API + webhooks are the integration point)
+16
View File
@@ -122,6 +122,22 @@ create table api_tokens (
revoked_at timestamptz
);
-- Paired phones (13 §2, §3): a row waits with a pairing code, then holds the device token.
create table mobile_devices (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
name text, -- "Pixel 9", from the app; null until paired
platform text check (platform in ('android', 'ios')),
token_prefix text, -- "spm_AbC1", shown on the web
token_hash text unique, -- sha256 hex; null while waiting to pair
pair_code_hash text unique, -- sha256 hex; null once paired
pair_expires_at timestamptz,
last_used_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index on mobile_devices (user_id);
-- ───────────── extensions (telephony identity of a user) ─────────────
create type outbound_class as enum ('internal', 'local', 'national', 'international');
create type record_mode as enum ('never', 'inbound', 'outbound', 'all');
+4
View File
@@ -58,6 +58,10 @@ Bearer requests don't need `X-Requested-With` (no cookie is used, and a cross-si
Authorization). A token can't list, make or revoke tokens (403): a leaked one can't make itself a
spare. `lastUsedAt` is written on every request. Settings → API tokens.
### 2.3a Mobile device tokens
`Authorization: Bearer spm_<40 base62>`, made by pairing a phone and acting as its user, never as
an admin (`requireAdmin` refuses them). See `13-mobile.md` §2 and §4.
### 2.4 Guards
Fastify `preHandler` hooks in `auth/guards.ts`:
- `requireAuth`: sets `req.user`.
+1 -1
View File
@@ -106,7 +106,7 @@ Implementation notes (M3):
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5) |
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) |
### 4.1 Presence panel
Right column on wide screens, a tab on mobile. Lists every extension with its
+1 -1
View File
@@ -222,5 +222,5 @@ As built (M10-T3), `provisioning/linphone.ts`:
Mobile limitation (state it plainly in the UI and admin guide): without push
notifications, iOS and battery-optimized Android apps miss calls while in the
background. A push gateway is a v2 item. For v1, recommend the desk phone + browser softphone,
background. A push gateway is a v2 item (13 §6; our own app, 13, has the same limit in v1). For v1, recommend the desk phone + browser softphone,
or call forwarding to a mobile number (CFNA) as the fallback.
+3 -1
View File
@@ -9,6 +9,8 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
Android and iPhone from one Expo codebase in the `mobile` repo; user portal only, no push in v1.
0. **Single sign-on** (owner, 2026-10-25: next), tasks S1–S5 in [12 §8](12-sso.md#8-order-of-work):
OpenID Connect (any provider; the owner uses Keycloak), then LDAP/LDAPS.
0. Done: **Contacts** (owner, 2026-10-23), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work):
@@ -299,7 +301,7 @@ Each one checked against a real account before it's called supported (06's verif
- Piper TTS for prompts (04 §6).
- Record prompts by phone.
- Wallboard page for queues.
- Push gateway for mobile apps.
- Push gateway for mobile apps: now 13 §6.
- Polycom provisioning (07 §3.3).
- Multi-tenant (big redesign; needs a decision record).
+110
View File
@@ -0,0 +1,110 @@
# 13 — Mobile app (Android + iPhone)
Owner, 2026-10-26: our own app for the user portal, on both phones from one codebase. Why this
stack: [decision 0005](../decisions/0005-mobile-app.md).
## 1. What it is
- Repo: `git.dou.bet/spng/mobile` (AGPL-3.0), checked out at `mobile/` beside this repo. It has
this repo as a git submodule for `@selfphoneng/shared` (types and zod schemas).
- Expo (React Native) + TypeScript, react-query, zustand, zod, as the web client.
- **User portal only** (owner): calls, call history, voicemail, contacts, My settings. No admin
screens; admins use the web panel, which works in a phone's browser.
- Calls: SIP.js + react-native-webrtc on the extension's `-web` endpoint (`GET /api/me/webrtc`),
the same one the browser softphone uses (`max_contacts = 3`, so a browser and a phone fit). The
extension needs the browser softphone turned on. Native call screen: expo-callkit-telecom
(CallKit on iOS, Core-Telecom on Android); calls show in the phone's own recent calls (iOS
Recents; Android `EXTRA_LOG_SELF_MANAGED_CALLS`).
- **No push in v1** (owner): the app rings only while it's open. The app and the admin guide say
so, as 07 §5 does, and suggest forwarding unanswered calls to the mobile number.
- Built and sideloaded only for now (owner): an Android APK; iOS through ios-builder (macOS CI
runner) with a free Apple ID re-sign. No store listings yet.
## 2. Pairing a phone
The app signs in by pairing, not with a password: the person is already signed in on the web, so
OpenID, LDAP and two-step sign-in all work without the app knowing about them.
1. My settings → **Mobile app** → **Pair a phone** (`POST /api/me/mobile-devices/pair`, a browser
session only: 403 with any Bearer token). The api makes a one-time **pairing code**: 12 characters
from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no 0/O/1/I), good for 10 minutes, stored as sha256.
The answer: `{ code, server, link, qr, expiresAt }`, where `server = https://PUBLIC_HOSTNAME`,
`link = selfphoneng://pair?server=<server>&code=<code>` and `qr` is a PNG data URL of `link`.
The page shows the QR, and the server and code to type by hand.
2. The app scans it (or takes them typed) and calls `POST /api/mobile/pair`
`{ code, name, platform: 'android' | 'ios' }` (public, `SLOW`: 10 a minute per IP; it has no
token yet, so it sends `X-Requested-With: selfphoneng` like the web, 04 §2.2). Case and anything
but letters and digits are dropped from the code before the check. A code
that is unknown, used or expired → 404 `pair_code_invalid`; the user disabled since → the same.
Success → `{ token, user: { name, email } }`: the **device token**, shown this once.
3. The app keeps the token in the phone's secure storage (expo-secure-store) and sends it as
`Authorization: Bearer spm_<40 base62>` on every request.
Device tokens (`auth/devices.ts`):
- `requireAuth` takes `spm_` tokens before `sp_` API tokens; the request acts as the device's user
(`req.mobileDeviceId` set). Unknown, removed, or the user disabled → 401. `lastUsedAt` is written
on every request. `must_change_password` → 403 `password_change_required` as for API tokens.
- A phone is never an admin: `requireAuth` gives the request `role: 'user'` even for an admin's
phone, so `requireAdmin` refuses it (403) and admin-only checks inside routes (recordings) don't
pass. The app has no admin screens, and a lost phone shouldn't open the admin panel.
- A device token can't make pairing codes (a stolen phone can't make itself a spare).
- They don't expire (owner, 2026-10-26). They end when the person removes the device (on the web
or Sign out in the app), and with every "sign out everywhere" (`deleteUserSessions`): the person
changing their password (their current web session stays), an admin resetting their password or
two-step sign-in, disabling them (also by the directory check), or deleting them.
- An OpenID user disabled at the provider but not here keeps their phone until an admin disables
them on Users: the provider's session check (12 §3.5) only ends web sessions (owner: fine).
## 3. Data (`mobile_devices`, migration 0013)
The table is in 02 (`mobile_devices`).
A row is made by **Pair a phone** (code only) and filled by the app's pair call (token, code
cleared). Making a new code first deletes the user's rows that never paired.
## 4. API
| Method | Path | Notes |
|---|---|---|
| POST | `/api/me/mobile-devices/pair` | browser session only. → `{ code, server, link, qr, expiresAt }` |
| POST | `/api/mobile/pair` | public, `SLOW`. `{ code, name, platform }` → `{ token, user }` |
| GET | `/api/me/mobile-devices` | paired devices: `{ id, name, platform, tokenPrefix, createdAt, lastUsedAt, current }` |
| DELETE | `/api/me/mobile-devices/:id` | removes one of your devices (the app's Sign out removes its own) |
Audit: `create` / `delete` on `mobile_device` (pairing code made, phone paired, device removed).
What the app uses besides these (all exist): `GET|PATCH /api/me`, `GET /api/me/webrtc`,
`GET /api/me/calls`, `GET|PATCH|DELETE /api/me/voicemail[/:id]`, `GET /api/me/voicemail/:id/audio`,
`GET /api/me/contacts`, `GET|PUT|DELETE /api/me/greetings/:kind`.
## 5. Order of work
| Task | Repo | What | Done when |
|---|---|---|---|
| MB1 | this | Decision 0005; this spec; migration 0013; device tokens in the guards; §4 routes; My settings → Mobile app (QR, code, device list, Remove) | `pnpm lint && typecheck && test` pass; pairing, device token, admin refusal and removal are tested. Done, see §5.1 |
| MB2 | this | The live socket (`GET /api/live`, 04 §5.2) takes a device token as `Authorization: Bearer` (React Native's WebSocket can send headers; browsers keep the cookie) | the app gets `voicemail` and presence events |
| MB3 | mobile | Expo skeleton, the submodule, pairing by QR and by hand, Sign out, a tab bar; Android APK builds; iOS builds through ios-builder | the owner pairs an Android phone and an iPhone and sees their name |
| MB4 | mobile | Calls: SIP.js + react-native-webrtc, register while open, dial pad, in and out, hold, mute, DTMF, speaker; native call screen; the "rings only while open" notice | a call each way with the PBX, on both phones, shown on the native call screen and in the phone's recent calls |
| MB5 | mobile | Call history (`/api/me/calls`), tap to call back | matches the web's Calls page |
| MB6 | mobile | Voicemail: list, play, mark heard, delete | as the web's Voicemail page |
| MB7 | mobile | Contacts: search, tap to call | as the web's Contacts page |
| MB8 | mobile | My settings: DND, forwarding, voicemail PIN and email, greetings | as the web's My settings |
### 5.1 As built: MB1
- `auth/devices.ts` (codes, tokens, `pairDevice`: the code is cleared in the same `update` that
sets the token, so one code pairs one phone), `routes/mobile.ts`, migration 0013.
- My settings → **Mobile app**: Pair a phone shows the QR and the server and code; while it's up
the page checks the list every 3 s and puts the code away when a new phone appears. Paired
phones, with Remove.
- `apps/api/test/mobile.test.ts`. Not tried with a real phone yet: that's MB3.
## 6. Later (not in MB1–MB8)
- Push, so the app rings in the background: FCM (Play build) and UnifiedPush (F-Droid build) on
Android, Apple VoIP push through a relay the project runs (Apple's key belongs to the app's
publisher, not to each PBX). The PBX sends the push and waits for the phone to register before
ringing it. Needs a decision record (Play Services is closed source). Replaces M12's "Push
gateway for mobile apps".
- App Store and Play listings (and AGPL vs App Store terms).
- TURN for strict mobile networks (`network.turn` already reaches `/api/me/webrtc`).
+1 -1
View File
@@ -5,7 +5,7 @@ import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['**/dist/', 'data/', '**/node_modules/', '.pnpm-store/'] },
{ ignores: ['**/dist/', 'data/', 'mobile/', '**/node_modules/', '.pnpm-store/'] },
js.configs.recommended,
tseslint.configs.recommended,
{
+34
View File
@@ -74,3 +74,37 @@ export const ContrackOptionsSchema = z.object({
logSummaries: z.boolean().optional(),
});
export type ContrackOptions = z.input<typeof ContrackOptionsSchema>;
/** POST /api/mobile/pair (13 §2): the app trades a pairing code for its device token. */
export const MobilePairSchema = z.object({
// Typed by hand from a screen: case and spaces don't matter.
code: z
.string()
.transform((s) => s.toUpperCase().replace(/[^A-Z0-9]/g, ''))
.pipe(z.string().length(12)),
name: z.string().trim().min(1).max(100),
platform: z.enum(['android', 'ios']),
});
export type MobilePair = z.input<typeof MobilePairSchema>;
export type MobilePairResult = { token: string; user: { name: string; email: string } };
/** POST /api/me/mobile-devices/pair: shown on the web as a QR code and as server + code. */
export type MobilePairCode = {
code: string;
server: string;
link: string;
qr: string;
expiresAt: string;
};
export type MobileDeviceDto = {
id: string;
name: string;
platform: 'android' | 'ios';
/** "spm_AbC1", to recognize it. */
tokenPrefix: string;
createdAt: string;
lastUsedAt: string | null;
/** The device making this request. */
current: boolean;
};