6 Commits
Author SHA1 Message Date
iamdoubz dc5965617e release: version 0.0.9
ci / check (push) Successful in 1m4s
ci / images (push) Successful in 5s
release / images (release) Successful in 1m33s
2026-10-27 12:49:05 -05:00
iamdoubz a614319990 Merge pull request 'Feat/mobile' (#4) from feat/mobile into main
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 4s
Reviewed-on: #4
2026-10-27 12:42:08 -05:00
iamdoubz 33d5f1a5f5 web: answer incoming calls from the browser notification
ci / check (push) Successful in 1m34s
ci / images (push) Successful in 27s
ci / check (pull_request) Successful in 1m18s
ci / images (pull_request) Successful in 5s
Owner follow-up to the softphone (spec 05 §5).

- the incoming call notification comes from a service worker
  (public/sw.js) so it carries Answer and Decline buttons (Chromium);
  Answer and a plain click focus the softphone tab, Decline doesn't
- shown whenever the tab isn't focused, not only when it's hidden; kept
  up while it rings and closed once it stops
- the softphone panel says how to allow notifications when they're blocked
2026-10-27 12:35:43 -05:00
iamdoubz 431ada51aa docs: confirm the mobile app id
ci / check (push) Successful in 1m30s
ci / images (push) Successful in 22s
MB3
2026-10-26 20:54:54 -05:00
iamdoubz 7da0611a98 docs: mobile app MB3 as built
MB3: the Expo skeleton and pairing are in the mobile repo (6e4bc2b);
the device test with a real Android phone and iPhone is still open.
2026-10-26 17:45:28 -05:00
iamdoubz 0cdac0bcdc 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
2026-10-26 17:12:53 -05:00
37 changed files with 5482 additions and 51 deletions

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
+18
View File
@@ -5,6 +5,24 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
Docker.
## v0.0.9 — 2026-10-27
**Before you update:** this version changes the database (paired phones); the api backs up first,
by itself. Portainer: change both tags to `0.0.9` and redeploy. Compose:
`sudo scripts/update.sh v0.0.9`.
- **Answer from the notification**: the softphone's incoming call notification has **Answer** and
**Decline** buttons (Chrome and Edge; Firefox and Safari show it without buttons). It now shows
whenever the softphone tab isn't in front, not only when it's hidden, stays up while the call
rings and goes away once it stops. If notifications are blocked, the softphone panel says how
to allow them.
- **Pair the mobile app** (spec 13): My settings → Mobile app shows a one-time code (QR, or
server + code, good for 10 minutes) that our own Android/iPhone app trades for a device token.
A paired phone acts as its user, never as an admin, and is listed there to remove. Changing or
resetting a password, or disabling a user, removes their paired phones. The app itself is
still in testing and not in the app stores.
- Database changes: `mobile_devices` (the api backs up first).
## v0.0.8 — 2026-10-26
**Before you update:** this version changes the database (sign-in); the api backs up first, by
+1 -1
View File
@@ -7,7 +7,7 @@
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
**Status:** pre-1.0 (v0.0.8), for testing. See [what's left for v1](docs/v1-checklist.md).
**Status:** pre-1.0 (v0.0.9), for testing. See [what's left for v1](docs/v1-checklist.md).
## What it does today
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/api",
"version": "0.0.8",
"version": "0.0.9",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+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) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/probe",
"version": "0.0.8",
"version": "0.0.9",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+14 -14
View File
@@ -5,19 +5,19 @@ proxy (`https://<your host>/me`). You need: a user with an extension that has th
softphone on (say 101), a second phone (desk phone or phone app, say 102), and a third extension
to transfer to (say 103, any phone). Headphones help with echo.
| # | Do this | Pass when |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Sign in as the user, click **Enable softphone**, allow the microphone. | Status turns **Ready**; Admin → Dashboard shows 101 online. |
| 2 | Reload the page. | It comes back **Ready** without asking again. |
| 3 | Open `/me` in a second tab. | The second tab says **Active in another tab**. Click **Use here**: it becomes **Ready** and the first tab says **Active in another tab**. Close the second tab and click **Use here** in the first. |
| 4 | Open **Keypad**, type `102` (also try typing on the keyboard and pressing Enter), call. | 102 rings; when answered both sides hear each other; the card shows a running timer. |
| 5 | Press **Mute**, talk, then **Unmute**. | 102 hears nothing while muted, then hears you again. |
| 6 | Press **Hold**, then **Resume**. | 102 hears hold music, then you again; the card says **On hold** meanwhile. |
| 7 | During the call open the call's **Keypad** and press `1 2 3`. If 102 is a voicemail menu or an IVR, it reacts; otherwise call `*97` instead and enter your PIN. | The tones are recognized. |
| 8 | Hang up. From 102 call 101. | The **Incoming call** dialog shows 102's name, the ringtone plays; **Answer** connects with two-way audio. Hang up from 102: the card goes away. |
| 9 | Switch to another tab or app, then have 102 call 101 again (first allow notifications with **Notify me of calls**). | A desktop notification appears; clicking it brings the tab back. **Decline** sends 102 to busy/voicemail. |
| 10 | While on a call with 102, have 103 call 101. | A second, smaller **Incoming call** card appears (no dialog). **Answer** puts 102 on hold (hears music) and connects 103. Hang up both. |
| 11 | Call 102, **Transfer** → type `103` → **Transfer now**. | 102 is connected to 103; the browser call ends. |
| 12 | Call 102, **Transfer** → `103` → **Talk first**; talk to 103; **Complete transfer**. Repeat with **Cancel** instead. | Complete: 102 and 103 are connected, the browser is out. Cancel: 103's call ends and you're back with 102. Also check **Microphone/Speaker** selects list your devices and a change takes effect on the next call. |
| # | Do this | Pass when |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Sign in as the user, click **Enable softphone**, allow the microphone. | Status turns **Ready**; Admin → Dashboard shows 101 online. |
| 2 | Reload the page. | It comes back **Ready** without asking again. |
| 3 | Open `/me` in a second tab. | The second tab says **Active in another tab**. Click **Use here**: it becomes **Ready** and the first tab says **Active in another tab**. Close the second tab and click **Use here** in the first. |
| 4 | Open **Keypad**, type `102` (also try typing on the keyboard and pressing Enter), call. | 102 rings; when answered both sides hear each other; the card shows a running timer. |
| 5 | Press **Mute**, talk, then **Unmute**. | 102 hears nothing while muted, then hears you again. |
| 6 | Press **Hold**, then **Resume**. | 102 hears hold music, then you again; the card says **On hold** meanwhile. |
| 7 | During the call open the call's **Keypad** and press `1 2 3`. If 102 is a voicemail menu or an IVR, it reacts; otherwise call `*97` instead and enter your PIN. | The tones are recognized. |
| 8 | Hang up. From 102 call 101. | The **Incoming call** dialog shows 102's name, the ringtone plays; **Answer** connects with two-way audio. Hang up from 102: the card goes away. |
| 9 | Switch to another tab or app, then have 102 call 101 again (first allow notifications with **Notify me of calls**). | A desktop notification appears; clicking it brings the tab back. In Chrome/Edge, its **Answer** button answers and brings the tab back, its **Decline** button declines; it closes by itself if 102 hangs up first. **Decline** sends 102 to busy/voicemail. |
| 10 | While on a call with 102, have 103 call 101. | A second, smaller **Incoming call** card appears (no dialog). **Answer** puts 102 on hold (hears music) and connects 103. Hang up both. |
| 11 | Call 102, **Transfer** → type `103` → **Transfer now**. | 102 is connected to 103; the browser call ends. |
| 12 | Call 102, **Transfer** → `103` → **Talk first**; talk to 103; **Complete transfer**. Repeat with **Cancel** instead. | Complete: 102 and 103 are connected, the browser is out. Cancel: 103's call ends and you're back with 102. Also check **Microphone/Speaker** selects list your devices and a change takes effect on the next call. |
Record the result (browser, version, pass/fail per step) in the PR or issue for M5-T2.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/web",
"version": "0.0.8",
"version": "0.0.9",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The softphone's incoming call notification (05 §5). Only a service worker's notifications can
// carry buttons. The softphone tab asks for one here; Answer/Decline go back to that tab only.
/* global self */
self.addEventListener('message', (e) => {
const call = e.data?.notifyCall;
if (!call) return;
e.waitUntil(
self.registration.showNotification('Incoming call', {
body: call.body,
tag: call.id,
requireInteraction: true,
actions: [
{ action: 'answer', title: 'Answer' },
{ action: 'decline', title: 'Decline' },
],
data: { id: call.id, tab: e.source.id },
}),
);
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
const { id, tab } = e.notification.data;
e.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (all) => {
const client = all.find((c) => c.id === tab);
if (!client) return;
if (e.action) client.postMessage({ softphone: e.action, id });
// Declining needs nothing from the tab; otherwise bring it back.
if (e.action !== 'decline') await client.focus();
}),
);
});
+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>
);
}
+51 -11
View File
@@ -31,6 +31,19 @@ const remember = (on: boolean) => {
}
};
// The incoming call notification comes from public/sw.js so it can have Answer/Decline buttons.
// No service worker (plain http, some private windows): no notification, the ringtone still plays.
const sw = 'serviceWorker' in navigator ? navigator.serviceWorker : null;
void sw?.register('/sw.js').catch(() => {});
const notifyCall = (id: string, body: string) =>
void sw?.ready.then((r) => r.active?.postMessage({ notifyCall: { id, body } }));
// ponytail: a call that stops ringing before its notification is up leaves it showing; its
// buttons then do nothing (answer/decline ignore unknown calls). Close it from sw.js if that bites.
const closeCallNotes = (id?: string) =>
void sw?.ready
.then((r) => r.getNotifications(id ? { tag: id } : {}))
.then((ns) => ns.forEach((n) => n.close()));
onTakeOver(async () => {
await softphone.stop();
softphone.setElsewhere();
@@ -95,30 +108,51 @@ export function SoftphoneDock() {
startSoftphone().catch((e: Error) => setProblem(e.message));
}, []);
// Ring while anything is ringing here; tell a hidden tab with a notification.
// Ring while anything is ringing here; tell an unfocused tab with a notification that stays
// up until the call stops ringing (answered, declined, or the caller hung up).
const incoming = calls.filter((c) => c.state === 'incoming');
const ringingIds = incoming.map((c) => c.id).join(',');
const notified = useRef(new Set<string>());
useEffect(() => {
if (ringingIds) startRinging();
else stopRinging();
for (const c of softphone.state.calls.filter((x) => x.state === 'incoming')) {
const ringing = softphone.state.calls.filter((x) => x.state === 'incoming');
for (const id of notified.current) {
if (ringing.some((c) => c.id === id)) continue;
closeCallNotes(id);
notified.current.delete(id);
}
for (const c of ringing) {
if (notified.current.has(c.id)) continue;
notified.current.add(c.id);
if (
document.hidden &&
!document.hasFocus() &&
typeof Notification !== 'undefined' &&
Notification.permission === 'granted'
) {
const n = new Notification('Incoming call', { body: nameOf(c), tag: c.id });
n.onclick = () => {
window.focus();
n.close();
};
}
)
notifyCall(c.id, nameOf(c));
}
}, [ringingIds, nameOf]);
useEffect(() => stopRinging, []);
useEffect(
() => () => {
stopRinging();
closeCallNotes();
},
[],
);
// The notification's Answer/Decline buttons (sw.js); stale ids are ignored by the engine.
useEffect(() => {
if (!sw) return;
const onMessage = (e: MessageEvent<{ softphone?: string; id?: string }>) => {
const { softphone: action, id } = e.data ?? {};
if (!id) return;
if (action === 'answer') run(softphone.answer(id));
else if (action === 'decline') run(softphone.decline(id));
};
sw.addEventListener('message', onMessage);
sw.startMessages();
return () => sw.removeEventListener('message', onMessage);
}, []);
async function enable() {
setProblem(null);
@@ -499,6 +533,12 @@ function Devices() {
Notify me of calls
</Button>
)}
{perm === 'denied' && (
<p className="text-zinc-600 dark:text-zinc-400">
Call notifications are blocked. To get them, allow notifications for this site in your
browser’s site settings (the icon left of the address), then reload the page.
</p>
)}
</div>
);
}
+1 -1
View File
@@ -89,7 +89,7 @@ needs inside (`selfphoneng-probe-linux-x64`, or `-arm64` for ARM machines), next
With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead.
```sh
v=v0.0.8 # the release
v=v0.0.9 # the release
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
+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`.
+2 -2
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
@@ -161,7 +161,7 @@ class SoftphoneEngine {
- Ask for microphone permission only when the user first clicks "Enable softphone". Remember the choice in
localStorage (`softphone.enabled`, try/catch).
- Incoming call: play `/sounds/ringtone.mp3` (a CC0 file we commit to `apps/web/public/sounds/`, with its license in `SOUNDS-LICENSE.md`) in a loop,
show `Notification` if permitted and the tab is hidden, and clicking the notification focuses the tab.
show a notification if permitted and the tab is not focused (`document.hasFocus()`), kept up while it rings (`requireInteraction`) and closed once it stops. It comes from a service worker (`apps/web/public/sw.js`) so it can carry **Answer** and **Decline** buttons (Chromium browsers; Firefox and Safari show no buttons); Answer and a plain click focus the softphone tab, Decline doesn't. If notifications are blocked, the softphone panel says how to allow them in the browser's site settings.
Look up the caller name: contacts → extensions → raw number.
- Auto-answer header (paging): if the INVITE has `Call-Info: ...;answer-after=0`, answer after a short beep. (Paging only targets desk phones today. Implement the check anyway; it's 5 lines.)
- Show call quality warnings if `RTCPeerConnection.getStats()` shows packet loss > 5% or jitter > 50 ms over 5 s.
+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).
+127
View File
@@ -0,0 +1,127 @@
# 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 and zustand, as the web client. Expo packages:
expo-router (screens, and the `selfphoneng://` links), expo-camera (scanning the pairing QR),
expo-secure-store (the device token), expo-device (the phone's name for the device list).
Types from `@selfphoneng/shared` are `import type` only, so the app bundles none of this repo.
- **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, by hand and by `selfphoneng://pair` link, Sign out; Android APK builds; iOS steps for ios-builder (the tab bar comes with MB5, when there's a second screen) | the owner pairs an Android phone and an iPhone and sees their name and extension |
| 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.
### 5.2 As built: MB3 (mobile `6e4bc2b`)
- Expo SDK 58, React Native 0.88, Expo Router; app id `bet.dou.selfphoneng` (Android and iOS),
confirmed by the owner (it can't change after a store listing).
- Screens: `pair` (scan, type, or a `selfphoneng://pair` link filled in and waiting for a tap, so
a link someone sends can't pair the phone to their server unnoticed) and home (name, server,
extension from `GET /api/me`, the rings-only-while-open notice, Sign out). Sign out deletes the
device on the server, then forgets the token even if the server can't be reached.
- `npx expo export` bundles both platforms; a release APK (arm64) builds on Windows. That needed
CMake 3.31 (`cmake.dir` in `android/local.properties`): the Android SDK's default CMake 3.22 has a
`ninja` without long-path support, and React Native's codegen paths pass 260 characters.
- **Open:** the owner pairs a real Android phone and an iPhone (ios-builder needs a GitHub mirror
of the mobile repo).
## 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,
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "selfphoneng",
"version": "0.0.8",
"version": "0.0.9",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/shared",
"version": "0.0.8",
"version": "0.0.9",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+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;
};