api: pair the mobile app with device tokens

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

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

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
# 13 — Mobile app (Android + iPhone)
Owner, 2026-10-26: our own app for the user portal, on both phones from one codebase. Why this
stack: [decision 0005](../decisions/0005-mobile-app.md).
## 1. What it is
- Repo: `git.dou.bet/spng/mobile` (AGPL-3.0), checked out at `mobile/` beside this repo. It has
this repo as a git submodule for `@selfphoneng/shared` (types and zod schemas).
- Expo (React Native) + TypeScript, react-query, zustand, zod, as the web client.
- **User portal only** (owner): calls, call history, voicemail, contacts, My settings. No admin
screens; admins use the web panel, which works in a phone's browser.
- Calls: SIP.js + react-native-webrtc on the extension's `-web` endpoint (`GET /api/me/webrtc`),
the same one the browser softphone uses (`max_contacts = 3`, so a browser and a phone fit). The
extension needs the browser softphone turned on. Native call screen: expo-callkit-telecom
(CallKit on iOS, Core-Telecom on Android); calls show in the phone's own recent calls (iOS
Recents; Android `EXTRA_LOG_SELF_MANAGED_CALLS`).
- **No push in v1** (owner): the app rings only while it's open. The app and the admin guide say
so, as 07 §5 does, and suggest forwarding unanswered calls to the mobile number.
- Built and sideloaded only for now (owner): an Android APK; iOS through ios-builder (macOS CI
runner) with a free Apple ID re-sign. No store listings yet.
## 2. Pairing a phone
The app signs in by pairing, not with a password: the person is already signed in on the web, so
OpenID, LDAP and two-step sign-in all work without the app knowing about them.
1. My settings → **Mobile app** → **Pair a phone** (`POST /api/me/mobile-devices/pair`, a browser
session only: 403 with any Bearer token). The api makes a one-time **pairing code**: 12 characters
from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no 0/O/1/I), good for 10 minutes, stored as sha256.
The answer: `{ code, server, link, qr, expiresAt }`, where `server = https://PUBLIC_HOSTNAME`,
`link = selfphoneng://pair?server=<server>&code=<code>` and `qr` is a PNG data URL of `link`.
The page shows the QR, and the server and code to type by hand.
2. The app scans it (or takes them typed) and calls `POST /api/mobile/pair`
`{ code, name, platform: 'android' | 'ios' }` (public, `SLOW`: 10 a minute per IP; it has no
token yet, so it sends `X-Requested-With: selfphoneng` like the web, 04 §2.2). Case and anything
but letters and digits are dropped from the code before the check. A code
that is unknown, used or expired → 404 `pair_code_invalid`; the user disabled since → the same.
Success → `{ token, user: { name, email } }`: the **device token**, shown this once.
3. The app keeps the token in the phone's secure storage (expo-secure-store) and sends it as
`Authorization: Bearer spm_<40 base62>` on every request.
Device tokens (`auth/devices.ts`):
- `requireAuth` takes `spm_` tokens before `sp_` API tokens; the request acts as the device's user
(`req.mobileDeviceId` set). Unknown, removed, or the user disabled → 401. `lastUsedAt` is written
on every request. `must_change_password` → 403 `password_change_required` as for API tokens.
- A phone is never an admin: `requireAuth` gives the request `role: 'user'` even for an admin's
phone, so `requireAdmin` refuses it (403) and admin-only checks inside routes (recordings) don't
pass. The app has no admin screens, and a lost phone shouldn't open the admin panel.
- A device token can't make pairing codes (a stolen phone can't make itself a spare).
- They don't expire (owner, 2026-10-26). They end when the person removes the device (on the web
or Sign out in the app), and with every "sign out everywhere" (`deleteUserSessions`): the person
changing their password (their current web session stays), an admin resetting their password or
two-step sign-in, disabling them (also by the directory check), or deleting them.
- An OpenID user disabled at the provider but not here keeps their phone until an admin disables
them on Users: the provider's session check (12 §3.5) only ends web sessions (owner: fine).
## 3. Data (`mobile_devices`, migration 0013)
The table is in 02 (`mobile_devices`).
A row is made by **Pair a phone** (code only) and filled by the app's pair call (token, code
cleared). Making a new code first deletes the user's rows that never paired.
## 4. API
| Method | Path | Notes |
|---|---|---|
| POST | `/api/me/mobile-devices/pair` | browser session only. → `{ code, server, link, qr, expiresAt }` |
| POST | `/api/mobile/pair` | public, `SLOW`. `{ code, name, platform }` → `{ token, user }` |
| GET | `/api/me/mobile-devices` | paired devices: `{ id, name, platform, tokenPrefix, createdAt, lastUsedAt, current }` |
| DELETE | `/api/me/mobile-devices/:id` | removes one of your devices (the app's Sign out removes its own) |
Audit: `create` / `delete` on `mobile_device` (pairing code made, phone paired, device removed).
What the app uses besides these (all exist): `GET|PATCH /api/me`, `GET /api/me/webrtc`,
`GET /api/me/calls`, `GET|PATCH|DELETE /api/me/voicemail[/:id]`, `GET /api/me/voicemail/:id/audio`,
`GET /api/me/contacts`, `GET|PUT|DELETE /api/me/greetings/:kind`.
## 5. Order of work
| Task | Repo | What | Done when |
|---|---|---|---|
| MB1 | this | Decision 0005; this spec; migration 0013; device tokens in the guards; §4 routes; My settings → Mobile app (QR, code, device list, Remove) | `pnpm lint && typecheck && test` pass; pairing, device token, admin refusal and removal are tested. Done, see §5.1 |
| MB2 | this | The live socket (`GET /api/live`, 04 §5.2) takes a device token as `Authorization: Bearer` (React Native's WebSocket can send headers; browsers keep the cookie) | the app gets `voicemail` and presence events |
| MB3 | mobile | Expo skeleton, the submodule, pairing by QR and by hand, Sign out, a tab bar; Android APK builds; iOS builds through ios-builder | the owner pairs an Android phone and an iPhone and sees their name |
| MB4 | mobile | Calls: SIP.js + react-native-webrtc, register while open, dial pad, in and out, hold, mute, DTMF, speaker; native call screen; the "rings only while open" notice | a call each way with the PBX, on both phones, shown on the native call screen and in the phone's recent calls |
| MB5 | mobile | Call history (`/api/me/calls`), tap to call back | matches the web's Calls page |
| MB6 | mobile | Voicemail: list, play, mark heard, delete | as the web's Voicemail page |
| MB7 | mobile | Contacts: search, tap to call | as the web's Contacts page |
| MB8 | mobile | My settings: DND, forwarding, voicemail PIN and email, greetings | as the web's My settings |
### 5.1 As built: MB1
- `auth/devices.ts` (codes, tokens, `pairDevice`: the code is cleared in the same `update` that
sets the token, so one code pairs one phone), `routes/mobile.ts`, migration 0013.
- My settings → **Mobile app**: Pair a phone shows the QR and the server and code; while it's up
the page checks the list every 3 s and puts the code away when a new phone appears. Paired
phones, with Remove.
- `apps/api/test/mobile.test.ts`. Not tried with a real phone yet: that's MB3.
## 6. Later (not in MB1–MB8)
- Push, so the app rings in the background: FCM (Play build) and UnifiedPush (F-Droid build) on
Android, Apple VoIP push through a relay the project runs (Apple's key belongs to the app's
publisher, not to each PBX). The PBX sends the push and waits for the phone to register before
ringing it. Needs a decision record (Play Services is closed source). Replaces M12's "Push
gateway for mobile apps".
- App Store and Play listings (and AGPL vs App Store terms).
- TURN for strict mobile networks (`network.turn` already reaches `/api/me/webrtc`).