Bee Box documentation · directory: https://beebox.run/docs/contracts/ · index: https://beebox.run/docs/contracts/index.md · root: https://beebox.run/llms.txt # Cross-Platform Mobile Contract **What this document is.** The living wire/behavioral contract between a beebox (its frontend and server) and every native mobile client that embeds it — today the iOS companion app, tomorrow a planned Android app. It is not a point-in-time report: it is the specification that all three sides implement and must be kept true. **The sync rule.** Any commit that changes a contract surface listed here — a URL scheme param, an auth-token carrier, a webview query param, a bridge channel name, a JSON field, an HTTP request/response shape, or a server-side mobile-awareness branch — **must update this document in the same commit.** The Contract Surface Index (§7) and the mirrored-constants list (§8) are the artifacts an agent diffs a change against; if your change touches a row there, it touches this doc. The process that enforces this — how iOS and Android are kept in parity, who reviews a contract change — is defined in `docs/implemented-plans/mobile-parity-sync.md`. **Path conventions.** Box code paths are relative to `beebox/`; iOS paths are under `ios-app/BeeBox/`. Anchors name a file plus the identifier (function/struct/const) inside it — never line numbers, which rot. Box-relative wire values (landmark/share-destination `dir`s, uploaded-file `path`s) are opaque tokens iOS round-trips unmodified; under the one-root box layout (shapeVersion 3, `docs/implemented-plans/one-root-box-layout.md`) they land in underscore areas (`_content/…`, `_tmp/…`) — the wire shape is unchanged, only the values moved. A paired box's `baseURL` already includes the hub slug (e.g. `http://127.0.0.1:3210/main/test1` in dev, `https://host/` in prod), so every native HTTP path below is `/api/...`. **Drift legend.** For each surface, drift is either **LOUD** (a mismatch produces a visible error / 4xx / 5xx / status message) or **SILENT** (the mismatch is swallowed — message lost, composer not suppressed, wrong attribution — with no error surfaced). --- ## 1. Pairing ### 1.1 `beebox://pair` deep link - **Direction:** external (QR scan / deep link / `simctl openurl`) → native. - **Wire shape:** `beebox://pair?baseURL=&label=&pairingToken=` (all URL-encoded). Query params: | param | aliases | required | notes | |---|---|---|---| | `baseURL` | `url` | yes | box base URL incl. slug; must have scheme+host or URL is rejected | | `label` | — | no (default `"Bee Box"`) | display name | | `pairingToken` | `token` | no | short-lived ticket, redeemed for a device token | | `session` | — | no | initial chat session id | | `authToken` | — | **DEBUG builds only** | raw device token imported directly, no redeem | The box generator emits **only** `baseURL`/`label`/`pairingToken` — never `session` or `authToken`. - **Anchors:** | side | anchor | |---|---| | iOS scheme registration | `ios-app/BeeBox/Info.plist` — `CFBundleURLSchemes = ["beebox"]`, name `app.beebox.ios.pairing` | | iOS parser | `ios-app/BeeBox/Storage/PairedBoxStore.swift` — `PairedBoxStore.pair(from:)` (requires `scheme == "beebox"` AND `host == "pair"`) | | box link generator | `src/frontend/src/components/settings/CompanionPairingSection.tsx` — `pairingDeepLink(token)`, `boxBaseUrl()`, `qrSvg()` | | box render site | `src/frontend/src/pages/settings/SettingsPage.tsx` — `` | - **Drift:** SILENT. A bad/unparseable URL makes `pair` return `false` with no toast. - **Known duplicate-dispatch hazard:** both `BeeBoxApp.onOpenURL` and `BeeBoxAppDelegate.application(open:)` (→ `PairingURLInbox.shared.accept` → `BeeBoxApp.onReceive`) can fire for one URL, producing two redeem POSTs; the second fails on the single-use token and is swallowed. Carried as an open risk (§9). ### 1.2 Pairing-ticket mint (any box access) - **Direction:** box web UI (Settings) → box server. **Not called by native code.** - **Wire shape:** output `PairingTicket { token: string; expiresAt: string /* ISO */ }`. Token is `crypto.randomBytes(32).toString("base64url")` (256-bit), stored only as a SHA-256 hash in an in-process `Map` keyed by `tokenHash`. TTL `DEFAULT_PAIRING_TTL_MS = 10 min`, single-use. - **Anchors:** | side | anchor | |---|---| | box endpoint | `src/webapp/trpc/routers/pairing.ts` — `pairing.createTicket` (`authedProcedure`) | | box impl | `src/core/mobile/pairing.ts` — `createMobilePairingTicket(boxRoot, { createdBy })`, `pendingPairings`, `DEFAULT_PAIRING_TTL_MS`, `pruneExpiredPairings` | - **Who may mint:** anyone with access to the box, not the owner alone — you pair your OWN device (changed 2026-09-12; it was `ownerProcedure`). The ticket records `createdBy`, and the device then acts as that person, so a non-owner's phone gets exactly that person's access. `pairing.devices` and `pairing.revokeDevice` follow the same rule (§1.4): if you can pair a phone you can see it and unpair it. - **Drift:** LOUD (tRPC error surfaces in Settings). ### 1.3 `POST /api/pairing/redeem` - **Direction:** native → box (unauthenticated; the ticket IS the credential). - **Request (native `PairingRedeemRequest`):** ```json { "pairingToken": "", "deviceLabel": "" } ``` Headers: `Content-Type: application/json`, `User-Agent: BeeBox-iOS/0.1`. - **Server body schema (`RedeemBody`):** `{ pairingToken: string.min(1), deviceLabel?: string.min(1) }`; `deviceLabel` defaults to `"iOS companion"` when absent. - **Response 200:** ```json { "boxSlug": "", "label": "Bee Box", "deviceId": "", "deviceLabel": "