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:
1 parent
adb6d52ddf
commit
0cdac0bcdc
26 files changed
+5340
-18
No files matched your search
@@ -6,3 +6,5 @@ data/
|
||||
coverage/
|
||||
.pnpm-store/
|
||||
*.pid
|
||||
# The mobile app is its own repo (13), checked out here beside the PBX.
|
||||
mobile/
|
||||
@@ -6,3 +6,4 @@ dist/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
apps/api/src/db/migrations/
|
||||
mobile/
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'}`),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user