Files
hapi/docs/api/native-companion-contract.md
T

313 lines
13 KiB
Markdown

# Native companion API contract (phone + Wear + iOS)
**Audience:** Implementers of native companion apps (Android phone + Wear OS via FCM, iOS via APNs) that pair with a hapi hub.
**Auth:** Exchange the pairing `code` / CLI access token with `POST /api/auth`:
`{ "accessToken": "<code>" }`. Use the returned JWT as `Authorization: Bearer <token>`
for device registration and session actions. `POST /api/bind` is only for Telegram Mini App
binding (requires Telegram `initData`).
## Scope
A companion implementing this contract is a **native client to the same hub the PWA talks to**, surfacing notifications and reply / approve actions on a phone or wearable. The hub may run on the operator's development machine or a separate host; agents execute on their CLI/Runner machines.
---
## Native device registration
### Register
`POST /api/devices/register`
```json
{
"token": "<fcm-registration-token>",
"platform": "phone",
"deviceId": "<stable-install-id>",
"pushKey": "<base64 of 32 device-generated random bytes>"
}
```
`platform`: `"phone"` | `"wear"` | `"ios"`.
`pushKey`: required for iOS and for Android relay delivery. New Android phone
clients always provide it. Phone registrations without it remain valid for
direct FCM; supplied phone keys must decode to exactly 32 bytes. Wear ignores
this field. The hub canonicalizes and stores phone/iOS keys in the existing
registry; no schema or database version change is required.
`deviceId`: any string of 1-128 characters chosen by the client (does not have to be a UUID). Must be stable across re-registrations of the same install.
**Response:** `{ "ok": true }`
Upsert on `(namespace, deviceId, platform)` - same device re-registering replaces its push token.
### Unregister
`DELETE /api/devices/register`
```json
{
"token": "<fcm-registration-token>"
}
```
---
## Outbound push (hub → device)
The hub dispatches native notifications independently of PWA visibility.
Android uses direct FCM or an encrypted relay depending on hub configuration;
iOS uses APNs or the encrypted relay. A successful send to at least one native
device sets a per-dispatch gate that suppresses Web Push for that event and
namespace. Missing registrations, missing relay encryption keys, and failed
sends do not suppress the fallback. Provider acceptance is not a handset
receipt.
### Data payload (all platforms)
| Key | Example | Purpose |
|-----|---------|---------|
| `type` | `ready` | `ready`, `permission-request`, `task-notification` |
| `sessionId` | uuid | Target session |
| `sessionName` | string | Display name (`agent - project`) |
| `url` | `/sessions/{id}` | Deep link path |
| `requestId` | uuid | Permission only - approve/deny |
| `title` | string | Notification title |
| `body` | string | Notification body |
| `severity` | `info` | `info` (ready), `warning` (permission), `success` / `error` (task) |
| `contractVersion` | `1` | Present on every message; see [Versioning](#versioning) |
| `notifySummary` | JSON string | Only on `ready`: parsed `AGENT_NOTIFY_SUMMARY` line from agent text, when present |
Direct FCM is data-only. Android relay messages contain the encrypted wrapper
below; after decryption the same data contract drives rendering and actions.
### Client actions (native - not hub)
| User action | Hub API |
|-------------|---------|
| Send text | `POST /api/sessions/:id/messages` `{ "text": "...", "localId": "..." }` |
| Allow | `POST /api/sessions/:id/permissions/:requestId/approve` |
| Deny | `POST /api/sessions/:id/permissions/:requestId/deny` |
`localId` is optional in the send-message body - an opaque client-generated id for reconciling the locally shown message with the server-echoed one.
`sentFrom` extension (optional future): `android-phone`, `android-wear`.
---
## Android relay
The official Android app includes the maintainer's Firebase client
configuration. A current hub without private Firebase credentials sends to
`https://push.hapi.run`; users only pair and allow notifications. Google Play
services and FCM network access are still required. Runtime Firebase project
provisioning and Wear relay delivery are outside this version.
The app persists one `deviceId` and random `pushKey` in a dedicated
Keystore-encrypted preference file, excluded from backup and device transfer.
Registration happens only after persistence succeeds. The first upgrade from
the old DataStore ID registers a new identity; token deduplication removes
the old row. Token rotation preserves the key and ID. Registration fans out
to all paired hubs on start, pairing, token rotation and worker retries.
The hub uses the same [encrypted envelope](#encrypted-envelope) as iOS.
`POST {relayUrl}/v1/push` carries:
```json
{"platform":"android","token":"<opaque FCM token>","envelope":"<base64>","priority":10}
```
FCM sends only string-valued `data: {"hapi_v":"1","hapi_e":"<envelope>"}`,
with Android priority `HIGH` (`NORMAL` for relay priority `5`) and the
configured `restricted_package_name`. No `notification` or `collapse_key`
is sent. The existing FCM default TTL is retained. Android decrypts locally,
then passes the v1 fields to the existing renderer, action workers and
session-open/suppress-when-open logic. A present encrypted marker with an
unknown version, missing key, malformed JSON or failed authentication is
dropped, never interpreted as plaintext. Unwrapped direct-FCM data remains
supported.
The encrypted envelope is capped at 3200 Base64 bytes. If needed, the hub
removes `notifySummary`, then falls back to a minimal `HAPI / New activity`
notification retaining type, session ID, request ID and contract version.
It never truncates ciphertext or action IDs. If even the minimum exceeds the
cap, the send fails and the registration is retained.
Relay `410 unregistered` prunes the token. `413`, `429`, `501`, auth/network
errors and Firebase project mismatch retain it. The relay does not enqueue
or retry sends. The relay and Google see tokens and delivery metadata but
cannot read notification content; the private direct-FCM path retains its
existing plaintext payload.
---
## iOS (APNs)
iOS is a first-class native companion with the **same notification contract**
as Android, delivered over APNs instead of FCM. Like Android relay delivery,
the payload is **end-to-end encrypted**: neither Apple nor the optional hapi push relay
can read notification content (PUSH SPEC v1).
### Registration
`POST /api/devices/register`
```json
{
"token": "<hex-apns-device-token>",
"platform": "ios",
"deviceId": "<stable-install-id>",
"pushKey": "<base64 of 32 device-generated random bytes>"
}
```
- `token`: the hex-encoded APNs device token from `didRegisterForRemoteNotificationsWithDeviceToken`.
- `pushKey`: **required for `ios`** - 32 random bytes generated on-device
(e.g. `SecRandomCopyBytes`), base64-encoded. This is the per-device E2E
encryption key; the hub validates it decodes to exactly 32 bytes and
rejects the registration otherwise. Keep it in the Keychain (shared with
the Notification Service Extension via an app group). Rotate it by
re-registering.
- Upsert on `(namespace, deviceId, platform)`, same as Android. Unregister
is the same `DELETE /api/devices/register` `{ "token": ... }`.
### Encrypted envelope
The plaintext is the exact [data payload](#data-payload-all-platforms) JSON
(`type`, `sessionId`, `sessionName?`, `url?`, `title`, `body`, `severity?`,
`contractVersion`, `requestId?`, `notifySummary?`), serialized as
**canonical JSON** - recursively sorted object keys, no whitespace, absent
optional fields omitted.
Encryption: **AES-256-GCM** with the device's `pushKey`:
```
envelope = base64( nonce(12 random bytes) || ciphertext || tag(16 bytes) )
AAD = ASCII "hapi-push-v1"
```
Golden test vector (key `0x00..0x1f`, nonce `0x00..0x0b`):
[`shared/fixtures/push/envelope-v1.json`](https://github.com/tiann/hapi/blob/main/shared/fixtures/push/envelope-v1.json) -
the iOS implementation must reproduce it byte-for-byte.
### APNs request (what the device receives)
```json
{
"aps": {
"mutable-content": 1,
"alert": { "title": "HAPI", "body": "New activity" },
"sound": "default"
},
"hapi": { "v": 1, "e": "<envelope>" }
}
```
The generic `"HAPI / New activity"` alert is the no-decrypt fallback. The
app's **Notification Service Extension** (invoked via `mutable-content: 1`)
decrypts `hapi.e` with the Keychain `pushKey` and replaces title/body with
the real content; `hapi.v` is the envelope version (currently `1`).
Delivery headers: `apns-push-type: alert`, `apns-priority: 10`,
`apns-expiration: 0`, `apns-collapse-id: "<type>-<sessionId>"` (truncated to
64 bytes) - so newer notifications for the same session/type replace older
ones.
### Transports: self-host (direct APNs) vs official relay
The hub picks one of two transports via `HAPI_IOS_PUSH` (or the
`iosPushMode` field in `~/.hapi/settings.json` — every variable below has a
settings.json equivalent, see [Environment](#environment-hub-operator)):
| Mode | Who talks to Apple | Requirements |
|------|--------------------|--------------|
| `relay` (default) | The hapi push relay (`https://push.hapi.run`), which holds the APNs credentials for the official app | none |
| `apns` | The hub itself, over HTTP/2 with an ES256 provider JWT | Apple developer account: `.p8` auth key, key id, team id, bundle id |
| `off` | nobody | - |
```bash
# default: official relay (zero setup)
HAPI_IOS_PUSH=relay
HAPI_PUSH_RELAY_URL=https://push.hapi.run # override for a self-hosted relay
# self-host: direct APNs, no relay involved
HAPI_IOS_PUSH=apns
APNS_KEY_P8_PATH=/path/to/AuthKey_XXXXXXXXXX.p8
APNS_KEY_ID=XXXXXXXXXX
APNS_TEAM_ID=YYYYYYYYYY
APNS_BUNDLE_ID=your.ios.bundle.id
APNS_ENV=production # or sandbox (Xcode/dev builds)
```
Relay protocol (for self-hosted relays): `POST {relayUrl}/v1/push` with
`{"platform":"ios","token":"<hex>","envelope":"<base64>","collapseId":"...","priority":10}`;
responses `200 {ok:true}`, `410 {ok:false,"code":"unregistered"}` (hub prunes
the device row), `413` / `429` treated as transient.
Dead-token handling mirrors FCM: APNs `410 Unregistered` or
`400 BadDeviceToken` (and relay `410`) unregister the device row; transient
errors (auth, throttle, 5xx, network) never do.
### Privacy
The notification plaintext exists only on the hub and on the paired device.
Apple's push infrastructure and the relay (official or self-hosted) see
**ciphertext plus routing metadata only** (APNs token, collapse id, timing,
size). The `pushKey` never leaves the device except to the operator's own
hub over the authenticated registration call. Operators who prefer zero
third-party involvement beyond Apple run `HAPI_IOS_PUSH=apns`.
Like the FCM channel, iOS push fires unconditionally for registered devices
and suppresses the Web Push fallback for the namespace when a send succeeds
(one OS notification, not two).
---
## Environment (hub operator)
| `HAPI_ANDROID_PUSH` | Behavior |
|---|---|
| `auto` (default) | Configured `FCM_SERVICE_ACCOUNT_PATH`: direct FCM; otherwise official relay |
| `relay` | Force the relay, ignoring private Firebase credentials |
| `fcm` | Require private Firebase credentials, direct phone/Wear delivery |
| `off` | Disable Android/Wear push |
```bash
# Default: no Firebase configuration needed for official Android apps.
HAPI_ANDROID_PUSH=auto
HAPI_PUSH_RELAY_URL=https://push.hapi.run
# Private-project builds must match the service account's Firebase project.
HAPI_ANDROID_PUSH=fcm
FCM_SERVICE_ACCOUNT_PATH=/path/to/service-account.json
```
The Firebase project ID comes from the service-account JSON. Missing/broken
explicit credentials or an unknown Android mode disable the channel with a
diagnostic; they never silently switch private tokens to the official
project. One hub selects one Android transport/project; mixing builds from
different Firebase projects is unsupported. iOS transport selection
(`HAPI_IOS_PUSH`, `APNS_*`) is independent. The push relay does not depend on
whether the hub's network tunnel (`--relay`) is enabled.
Push configuration follows the hub-wide rule (env > `settings.json` >
default; an env value is persisted into `~/.hapi/settings.json` on first
sight, so the variable only has to be passed once). settings.json keys:
`androidPushMode`, `fcmServiceAccountPath`, `iosPushMode`, `iosPushRelayUrl`, `apnsKeyP8Path`,
`apnsKeyId`, `apnsTeamId`, `apnsBundleId`, `apnsEnv`. The historical
`iosPushRelayUrl` setting supplies the shared URL for both platforms. Path
values may use `~`.
The native push channel is **opt-in**: operators who don't run a companion
app see no behavior change. When at least one native send succeeds for a
notification, Web Push suppresses that notification for the namespace
to avoid double-notifying (one in the native app, one from the
PWA service worker). PWA-only operators are unaffected.
---
## Versioning
Contract version **1**. Breaking changes require `data.contractVersion` in FCM payload and doc update.