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
@@ -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`).
|
||||
Reference in new issue
Block a user