@mhome/app-facade-protocol 1.33.0 → 1.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,22 @@ domain input and output reuse `mhome-artifact-api`; Node runtimes use the
39
39
  separate transport-neutral `/artifact/*` targets and never enter the App
40
40
  Facade.
41
41
 
42
+ `pod` (1.34.0) defines native-Client pod discovery and commissioning
43
+ (`/local/pod/*`), the shared BLE advertising format and the pod credential
44
+ protocol with Lion. Sessions are owned through a renewed lease, and rejected
45
+ requests carry a stable `details.reason`. See
46
+ [the contract](contract/pod-commissioning-v1.md). Targets, BLE constants, session
47
+ timings and request reasons are in `manifest/pod-targets.v1.json`; snapshots use
48
+ `schema/pod-discovery.v1.schema.json` and `schema/pod-commission.v1.schema.json`;
49
+ the owner's credential status is `schema/pod-credential-status.v1.schema.json`.
50
+
51
+ `host_provision` (1.34.0) defines BLE Wi-Fi provisioning of embedded Hosts
52
+ (`/local/host/provision/*`) on the same BLE layer; discovery candidates carry
53
+ `kind`. A completed session carries the one-time `claimToken` that the first
54
+ claim of that Host requires. See [the contract](contract/host-provisioning-v1.md), the targets in
55
+ `manifest/host-provision-targets.v1.json` and
56
+ `schema/host-provision.v1.schema.json`.
57
+
42
58
  ## Routing contract
43
59
 
44
60
  `manifest/routing.v1.json` is the canonical client-side routing policy for the
@@ -0,0 +1,173 @@
1
+ # Host provisioning v1
2
+
3
+ Embedded Linux Hosts without a working network get Wi-Fi over BLE from a native
4
+ Client (desktop/CLI Clientd, Android, iOS). Desktop, Docker and Android Hosts are
5
+ already online and never advertise. Provisioning connects the Host to the LAN
6
+ and hands the provisioner a one-time claim token; ownership is then established
7
+ by the existing Host claim over the LAN, which requires that token (section 4).
8
+
9
+ The BLE layer is the one pods use (`pod-commissioning-v1.md` sections 2 and 3):
10
+ same service UUID, manufacturer data with device kind `2`, protocomm security 2
11
+ with username `meow` and a 6-digit code, the same standard endpoints, and the
12
+ same characteristic UUID fallback.
13
+
14
+ ## 1. Advertising
15
+
16
+ - The Host advertises only in first provisioning, inside a re-provision window,
17
+ or after a factory reset. A Host on a working network does not advertise.
18
+ - Flags: bit 0 commissionable (set while advertising, except during a code
19
+ lockout), bit 1 set when the Host still has a saved network (re-provision
20
+ window).
21
+ - `shortId`: the first 4 bytes of the SHA-256 of the Host bootstrap public key,
22
+ which survives factory reset. The bootstrap key only names the Host (`shortId`,
23
+ `fingerprint`); it signs nothing in provisioning or claim.
24
+ - Local name: the Host name, at most 20 bytes.
25
+
26
+ ## 2. Pairing code
27
+
28
+ Hosts that support BLE provisioning have a display or a local console.
29
+
30
+ - The Host generates a uniformly random 6-digit code and precomputes the SRP salt
31
+ and verifier when BLE starts.
32
+ - The code is published to the local display interface (and the Host log) only
33
+ while a central is connected.
34
+ - A new code is generated after a successful provisioning, after 5 failed
35
+ handshakes, or 10 minutes after it was first shown.
36
+ - Lockout: each rotation caused by 5 failed handshakes is followed by 30 seconds
37
+ in which the Host refuses every handshake, doubling for each consecutive such
38
+ rotation up to 10 minutes; a successful handshake resets the backoff. While
39
+ locked the Host advertises with the commissionable flag cleared, its
40
+ `proto-ver` app info is `"meow":{"ver":"1","cap":["host"],"lockedForMs":N}`
41
+ with the remaining lockout, and every handshake is refused with ATT
42
+ Insufficient Authorization, like a wrong code. The commissioner reads
43
+ `proto-ver` after each refused handshake and fails the session with
44
+ `code_locked` when `lockedForMs` is present. The Host's local status reports
45
+ the lockout end as `lockedUntilMs`.
46
+ - One BLE connection at a time: a second central is disconnected, and only the
47
+ central that owns the setup session may write. Request and response buffers
48
+ are kept per central.
49
+ - A session ends 10 minutes after connect, or 2 minutes after connect without
50
+ a handshake.
51
+
52
+ ## 3. Endpoints
53
+
54
+ | Endpoint | Encrypted | Purpose |
55
+ | --- | --- | --- |
56
+ | `proto-ver` | no | app info `"meow":{"ver":"1","cap":["host"]}` |
57
+ | `prov-session` | handshake | security 2 |
58
+ | `prov-scan` | yes | standard Wi-Fi scan |
59
+ | `prov-config` | yes | standard Wi-Fi set/apply/status |
60
+ | `prov-ctrl` | yes | standard reset after a failed join, before a retry |
61
+ | `host-info` | yes | Host information and finish |
62
+
63
+ Fallback characteristic UUIDs: the standard endpoints as for pods, `host-info`
64
+ `0xFF54`.
65
+
66
+ `host-info` bodies are UTF-8 JSON, at most 480 bytes:
67
+
68
+ - `{"op":"info"}` →
69
+
70
+ ```json
71
+ {
72
+ "protocol": 1,
73
+ "hostId": "…",
74
+ "name": "Kitchen Host",
75
+ "productId": "camera",
76
+ "firmwareVersion": "0.9.0",
77
+ "fingerprint": "<hex SHA-256 of the bootstrap public key>",
78
+ "wifiConfigured": false
79
+ }
80
+ ```
81
+
82
+ `productId` is optional.
83
+ - `{"op":"finish"}` → `{"ok":true,"claimToken":"…"}`. After a successful join
84
+ during first provisioning the response carries the claim token: 32 random
85
+ bytes as 43 unpadded base64url characters. It is created when the join
86
+ succeeds, the same token is returned by every `finish` until the Host closes
87
+ BLE, and it is absent when no join succeeded and in a re-provision window
88
+ (the Host already has an owner). The Host then closes
89
+ BLE and continues its lifecycle (starts managed services, announces itself
90
+ over mDNS). Without `finish` the Host closes BLE 30 seconds after a successful
91
+ join, or when the central disconnects; the commissioner retries `finish`
92
+ within that window.
93
+
94
+ Every request and every encrypted response is at most 512 bytes; the response
95
+ is read with offset reads. `prov-scan` start blocks while the Host scans and
96
+ answers within 12 seconds, so commissioners give that exchange a longer timeout
97
+ (the shared core uses 30 seconds). Scan result requests return as many of the
98
+ requested entries as fit one encrypted 512-byte value, possibly fewer than
99
+ `count`; commissioners advance `start_index` by the number of entries returned
100
+ and stop at `result_count` or on an empty page. This also works with ESP-IDF
101
+ devices, which return exactly the requested entries (commissioners ask for at
102
+ most 4 at a time).
103
+
104
+ `prov-config` status reports `connected` with the IPv4 address once the Host is
105
+ on the LAN (the address may be empty when the Host has none yet); `connection_failed` with `auth_error` or `network_not_found` when
106
+ the join failed. After a failed join the commissioner sends `prov-ctrl` reset
107
+ before the next `set_config`. In a re-provision window the previous network
108
+ stays saved until the new one connects.
109
+
110
+ ## 4. Native Client targets
111
+
112
+ Handled by the native Client, never forwarded. Payloads are domain input JSON.
113
+ Candidates come from the shared discovery (`/local/pod/discovery/*`) with
114
+ `kind` = `host`.
115
+
116
+ | Target | Request | Response |
117
+ | --- | --- | --- |
118
+ | `/local/host/provision/start` | `{"candidateId"}` | session snapshot |
119
+ | `/local/host/provision/code` | `{"sessionId","code"}` | session snapshot |
120
+ | `/local/host/provision/wifi` | `{"sessionId","ssid","password"?}` | session snapshot |
121
+ | `/local/host/provision/wifi/scan` | `{"sessionId"}` | session snapshot |
122
+ | `/local/host/provision/cancel` | `{"sessionId"}` | session snapshot |
123
+ | `/local/host/provision/renew` | `{"sessionId"}` | session snapshot |
124
+ | `/local/host/provision/status` | `{}` | `{"session": snapshot or null}` |
125
+
126
+ Event `/local/host/provision/changed` carries a session snapshot. One session
127
+ per native Client, and none while a pod session is active; discovery scanning
128
+ pauses during either. Rejected requests use the error envelope and the stable
129
+ `details.reason` values of `pod-commissioning-v1.md` section 7. The session is
130
+ owned through the same 30-second lease, renewed with
131
+ `/local/host/provision/renew` every 10 seconds; an expired lease cancels it.
132
+
133
+ Session states, in order: `connecting`, `awaiting_code`, `securing`,
134
+ `reading_info`, `awaiting_wifi`, `joining_wifi`, `completed`; terminal `failed`,
135
+ `cancelled`, `timed_out`. `completed` carries `host`, the reported `addresses`
136
+ (IPv4 only, possibly empty) and `claimToken` from the `finish` response; the
137
+ commissioner sends `finish` (up to 3 times while the secure session lasts)
138
+ before reporting it. `claimToken` is absent when `finish` could not be
139
+ delivered, in a re-provision window, and with Hosts that predate claim tokens.
140
+ No other state carries `claimToken`.
141
+
142
+ The first claim (TOFU) of a Host that was provisioned over BLE must present this
143
+ token: `/app/system/hosts/claim` takes `{"hostId","claimToken"?}` and the native
144
+ `host.claim` passes it through to the Host's offline claim, `POST /v1/auth/claim`
145
+ `{"proof","claimToken"?}` (`core-api` `host::auth::ClaimRequest`). Clients send
146
+ `claimToken` only when they have one, because older Hosts reject unknown fields.
147
+ While a token is outstanding the Host answers a claim without it, or with a
148
+ different one, with 403 `{"code":"HOST_CLAIM_TOKEN_REQUIRED"}`. The token stays valid until a claim succeeds or the Host is
149
+ factory reset; the UI claims automatically with it after `completed`. Hosts
150
+ that were never provisioned over BLE keep the plain first claim.
151
+
152
+ Error codes are the pod session codes without the credential ones:
153
+ `bluetooth_unavailable`, `device_busy`, `disconnected`, `code_rejected` (retry
154
+ allowed), `code_locked`, `wifi_auth_failed`, `wifi_not_found`, `wifi_failed`,
155
+ `session_timeout`. Retry rules are the pod ones: a rejected code returns to
156
+ `awaiting_code`, a failed join to `awaiting_wifi`, each with its error; `failed`
157
+ and `timed_out` always carry `error`. `code_locked` is terminal.
158
+
159
+ The schema is `schema/host-provision.v1.schema.json`.
160
+
161
+ ## 5. Host-side entry points
162
+
163
+ - First provisioning starts BLE at boot when the Host has no known network.
164
+ - Re-provision window: opened from the Host's local interface (display settings
165
+ or `meowhostd provision open`). Mutating local provisioning commands (`open`,
166
+ `close`, `factory-reset`) require root or membership in the `meow-admin`
167
+ group (checked with the peer credentials of the local control socket); status
168
+ stays readable by the same user. The command help says to use `sudo`. Wi-Fi only; Host identity, owners and services
169
+ are kept. Closes after a successful join, on `meowhostd provision close`, or
170
+ after 10 minutes.
171
+ - Factory reset: from the local interface (`meowhostd factory-reset`). Stops
172
+ services, forgets saved Wi-Fi, erases owners and the Host security store, keeps
173
+ the bootstrap key, and returns to first provisioning.
@@ -0,0 +1,447 @@
1
+ # Pod commissioning v1
2
+
3
+ A pod is a MeowLink end device that acts as an App client of one user. This
4
+ contract is implementation-neutral: ESP-IDF on BLE-capable ESP32 chips is the
5
+ first implementation, not a requirement. Commissioners are the native Clients:
6
+ desktop/CLI Clientd, Android and iOS. The shared UI and CLI never talk BLE; they
7
+ call the `/local/pod/*` targets of their native Client.
8
+
9
+ Commissioning order is fixed: secure BLE session, Wi-Fi joined, user
10
+ authorization, credential issue, delivery, activation. A credential is issued only
11
+ after the pod reports a joined network. Any failure after issue revokes it.
12
+
13
+ ## 1. Device identity
14
+
15
+ - On first boot and after factory reset the pod generates a P-256 identity key.
16
+ - `keyId` is `p256:<lowercase hex SHA-256 of the 65-byte uncompressed point>`;
17
+ `fingerprint` is the same hex without prefix. `x`/`y` are unpadded base64url
18
+ 32-byte coordinates. This matches the Hub identity format.
19
+ - `deviceId` is a stable hardware identifier chosen by the implementation that
20
+ survives factory reset (ESP: lowercase hex MAC without separators).
21
+ - `shortId` is the first 4 bytes of the fingerprint, shown as 8 uppercase hex
22
+ characters in UI and advertising. It changes with the identity key.
23
+
24
+ ## 2. BLE advertising
25
+
26
+ Pods advertise only while unprovisioned, while re-commissioning after logout or
27
+ revocation, or inside a re-provision window. A commissioned pod does not advertise.
28
+
29
+ - Service UUID (128-bit, shared by every MeowLink commissionable device):
30
+ `a17a7ad5-9b2f-4010-9cf0-aa15ac54c40e`.
31
+ - Manufacturer data, in the advertisement or scan response:
32
+
33
+ | Offset | Size | Value |
34
+ | --- | --- | --- |
35
+ | 0 | 2 | Company ID, little-endian. Development value `0xFFFF` |
36
+ | 2 | 2 | Magic `0x4D 0x57` (`MW`) |
37
+ | 4 | 1 | Advertising format version, `1` |
38
+ | 5 | 1 | Device kind: `1` pod, `2` Host (see `host-provisioning-v1.md`) |
39
+ | 6 | 1 | Flags: bit 0 commissionable, bit 1 Wi-Fi configured |
40
+ | 7 | 4 | `shortId` bytes |
41
+
42
+ Receivers ignore records whose company ID, magic or version they do not know.
43
+ - Local name: `MeowPod <model>`. `<model>` comes from product configuration and
44
+ is at most 8 ASCII characters. Commissioners display `<name> · <shortId>`.
45
+ - Commissioners scan actively and filter on the service UUID.
46
+
47
+ ## 3. Secure session
48
+
49
+ Transport is ESP protocomm over BLE GATT. Endpoints are GATT characteristics
50
+ named by their Characteristic User Description descriptor (`0x2901`). A
51
+ commissioner that cannot read descriptors falls back to fixed UUIDs: the service
52
+ UUID with bytes 2–3 replaced by `prov-ctrl` `0xFF4F`, `prov-scan` `0xFF50`,
53
+ `prov-session` `0xFF51`, `prov-config` `0xFF52`, `proto-ver` `0xFF53`, then the
54
+ custom endpoints in table order from `0xFF54` (`pod-info` `0xFF54`,
55
+ `pod-credential` `0xFF55`).
56
+
57
+ - Security: protocomm security 2 (SRP6a 3072-bit with SHA-512, then AES-256-GCM).
58
+ Username `meow`. Password: a 6-digit decimal code.
59
+ - Pods with a display generate a uniformly random code, precompute salt and
60
+ verifier at boot, and show the code only while a central is connected. A new
61
+ code is generated after a successful commissioning, after 5 failed handshakes,
62
+ or 10 minutes after it was first shown. Pods without a display are out of scope
63
+ for v1.
64
+ - The salt is 16 bytes. `x` hashes the salt as an integer (no leading zero
65
+ bytes) while the proof `M` hashes the salt bytes as sent, so a device generates
66
+ salts whose first byte is non-zero. The commissioner sends `A` as exactly 384
67
+ bytes and picks a new ephemeral when `g^a mod N` would need a leading zero
68
+ byte; devices reject any other length. `B` is sent without leading zeros and
69
+ `M` uses it as sent; `u` pads both to 384 bytes.
70
+ - The record nonce is 8 random bytes followed by a 32-bit big-endian counter
71
+ starting at 1, advanced after every encrypt and decrypt on both sides
72
+ (security 2 patch version 1; patch 0 keeps it fixed). Commissioners accept
73
+ patch versions 0 and 1 only. A lost or failed exchange leaves the two counters
74
+ unknown, so the commissioner abandons the secure session after any transport or
75
+ decryption failure. A new `SessionCmd0` restarts the handshake on the same link.
76
+ - While a device refuses handshakes after repeated wrong codes it clears the
77
+ commissionable advertising flag and reports the lockout in its `proto-ver`
78
+ app info, as `"lockedForMs":<remaining ms>` (Hosts) or `"locked":true`. A
79
+ handshake while locked is refused the same way as a wrong code (an ATT error
80
+ such as Insufficient Authorization), so after every rejected handshake the
81
+ commissioner reads `proto-ver` again and fails the session with `code_locked`
82
+ when either field is present. A pod session also fails with `code_locked` after
83
+ the fifth rejected code. ESP pods restart with a new code after 5 failed
84
+ handshakes instead of reporting a lockout.
85
+ - One BLE connection at a time; a second central receives a disconnect.
86
+ - Session deadline: 10 minutes from connect, or 2 minutes waiting for a
87
+ handshake. On expiry the pod discards all unpersisted state.
88
+
89
+ | Endpoint | Encrypted | Purpose |
90
+ | --- | --- | --- |
91
+ | `proto-ver` | no | protocomm version JSON with app info `"meow":{"ver":"1","cap":["pod"]}` (label `meow`, capability `pod`) |
92
+ | `prov-session` | handshake | security 2 |
93
+ | `prov-scan` | yes | standard Wi-Fi scan |
94
+ | `prov-config` | yes | standard Wi-Fi set/apply/status |
95
+ | `prov-ctrl` | yes | standard reset after a failed join, before a retry |
96
+ | `pod-info` | yes | device information |
97
+ | `pod-credential` | yes | credential delivery and activation status |
98
+
99
+ Custom endpoint bodies are UTF-8 JSON, at most 480 bytes in each direction.
100
+
101
+ `pod-info` request `{}`, response:
102
+
103
+ ```json
104
+ {
105
+ "protocol": 1,
106
+ "deviceId": "a0b1c2d3e4f5",
107
+ "model": "S1",
108
+ "platform": "esp32c5",
109
+ "firmwareVersion": "0.3.0",
110
+ "identity": { "keyId": "p256:…", "alg": "ES256", "x": "…", "y": "…", "fingerprint": "…" },
111
+ "wifiConfigured": false,
112
+ "state": "secured"
113
+ }
114
+ ```
115
+
116
+ `platform` is diagnostic only. `wifiConfigured` is true when the pod kept a
117
+ working network across logout; the commissioner then skips the Wi-Fi steps after
118
+ confirming connectivity with `prov-config` status. Inside a Wi-Fi change window
119
+ the response also carries `"mode":"reprovision"` and `wifiConfigured` is false
120
+ (section 4).
121
+
122
+ `pod-credential` requests:
123
+
124
+ - `{"op":"deliver","podId","refreshToken","scopeId","cloudApi","cloudWs"}` →
125
+ `{"ok":true,"state":"activating"}`. Accepted only in `wifi_connected` outside a
126
+ Wi-Fi change window; otherwise `{"ok":false,"state":<current pod state>,"reason"}`
127
+ with `reason` `invalid_payload` (a missing or malformed field, including a
128
+ scheme this firmware does not accept) or `wrong_state`. `cloudApi` is
129
+ `https://…/api/v1`, `cloudWs` is a `wss://` URL. Production firmware accepts
130
+ only these schemes; development firmware built with insecure cloud access
131
+ enabled (ESP: `MEOW_POD_ALLOW_INSECURE_CLOUD`, default off) also accepts
132
+ `http://` and `ws://`. Commissioners never check the scheme, but they refuse to
133
+ issue a credential when the cloud host is loopback (`localhost`,
134
+ `127.0.0.0/8`, `::1`), because the pod cannot reach it; the request fails with
135
+ reason `cloud_unreachable_for_device`.
136
+ - `{"op":"status"}` → `{"state":<pod state>,"error"?:{"code","message"}}`; `error`
137
+ is present only in `failed`.
138
+ - `{"op":"finish"}` → `{"ok":true}` in `commissioned`; the pod stops BLE and
139
+ restarts into normal operation. In any other state it answers
140
+ `{"ok":false,"state":<current pod state>,"reason":"wrong_state"}`. The pod also
141
+ stops BLE 30 seconds after reaching `commissioned` without `finish`. `finish` is
142
+ best-effort: the commissioner records `completed` before sending it.
143
+
144
+ Activation error codes: `time_sync_failed`, `cloud_unreachable`,
145
+ `refresh_rejected`, `auth_rejected`, `storage_failed` (the final commit, or in a
146
+ Wi-Fi change window saving the new network, failed).
147
+
148
+ The pod persists Wi-Fi, credential, endpoints and Space in one commit after a
149
+ successful activation. Nothing from an abandoned attempt survives it; an
150
+ implementation whose Wi-Fi driver caches the network on its own (ESP-IDF
151
+ provisioning does) erases that cache when the attempt ends.
152
+
153
+ ## 4. Pod state machine
154
+
155
+ ```text
156
+ unprovisioned ──connect──▶ session_open ──handshake──▶ secured
157
+ secured ──prov-config apply──▶ wifi_joining ──▶ wifi_connected | wifi_failed
158
+ wifi_failed ──retry──▶ wifi_joining
159
+ wifi_connected ──deliver──▶ activating ──time sync, refresh, commit──▶ commissioned | failed
160
+ any pre-commit state ──timeout/disconnect/cancel──▶ unprovisioned
161
+ ```
162
+
163
+ Runtime states after commit:
164
+
165
+ - `commissioned`: normal operation.
166
+ - `reprovision_window`: Wi-Fi replacement only, opened from the pod's own
167
+ settings; the pod restarts and advertises for 10 minutes from boot with a new
168
+ code. The credential is kept and the previous network stays persisted until
169
+ the new one connects. `pod-info` reports `"mode":"reprovision"` and
170
+ `wifiConfigured: false`, and only the Wi-Fi endpoints and `pod-credential`
171
+ `status` and `finish` are accepted (`deliver` answers `wrong_state`). After
172
+ `prov-config` apply the pod joins the new network, saves it, and reports
173
+ `commissioned`, or `failed` with `storage_failed` if saving failed; it then
174
+ restarts on `finish`, 30 seconds later, or at the end of the window. A failed
175
+ join or an expired window restarts with the previous network.
176
+ - `needs_recommission`: logout, or refresh rejected because the credential was
177
+ revoked or the user no longer exists. Credential and local JWTs are erased,
178
+ Wi-Fi is kept, advertising resumes with the Wi-Fi-configured flag.
179
+ - `factory_resetting`: best-effort self-revoke, erase all persisted data
180
+ including the identity key, reboot to `unprovisioned`.
181
+
182
+ Time sync uses SNTP; if NTP is unreachable the pod may use the `Date` header of a
183
+ Lion HTTPS response. Activation proofs require a synced clock.
184
+
185
+ ## 5. Lion credential API
186
+
187
+ All bodies are JSON. Errors use the standard Lion envelope
188
+ `{"error","message","details"?}`. Credential failures are `UNAUTHORIZED` with one
189
+ of these `message` values: `pod_credential_invalid` (unknown pod, wrong or
190
+ revoked refresh token, or an owner account that no longer exists),
191
+ `pod_proof_invalid`, `pod_proof_replayed`, `pod_activation_expired`. A pod treats
192
+ `pod_credential_invalid` and `pod_activation_expired` as revocation. Renaming a
193
+ pod the caller does not own, or that does not exist, is `NOT_FOUND` with
194
+ `pod_not_found`.
195
+
196
+ ### Issue
197
+
198
+ `POST /api/v1/pod/credential/issue`, user `Authorization`, `ScopeId` header.
199
+
200
+ ```json
201
+ {
202
+ "deviceId": "a0b1c2d3e4f5",
203
+ "model": "S1",
204
+ "platform": "esp32c5",
205
+ "firmwareVersion": "0.3.0",
206
+ "name": "MeowPod S1",
207
+ "identity": { "keyId": "p256:…", "alg": "ES256", "x": "…", "y": "…", "fingerprint": "…" },
208
+ "clientId": "issuing client id"
209
+ }
210
+ ```
211
+
212
+ `deviceId` matches `[A-Za-z0-9._:-]{1,64}`; `model` and `name` are 1–64
213
+ characters, `platform` and `firmwareVersion` at most 64, `clientId` at most 128. Lion requires Space membership for `ScopeId`, validates the identity, and
214
+ creates a `pending` credential that must activate before `activateBefore`
215
+ (issue time + 10 minutes). Response `{"podId","refreshToken","activateBefore"}`.
216
+
217
+ ### Refresh and activation
218
+
219
+ `POST /api/v1/pod/token/refresh`, body `{"podId","refreshToken","proof"}`.
220
+
221
+ `proof` is a compact ES256 JWS signed by the identity key: header `kid=keyId`;
222
+ claims `iss` and `sub` = `podId`, a single-valued `aud` = `pod-token-refresh`,
223
+ `token_hash` = unpadded base64url SHA-256 of the UTF-8 refresh token (including
224
+ `pod-`), `iat`, `exp` with `0 ≤ exp - iat ≤ 120 s`, and a unique `jti` of at most
225
+ 128 characters. Lion allows 120 s clock skew and remembers each `jti` for
226
+ 7 minutes (proof lifetime plus twice the skew, plus a minute), longer than any
227
+ proof can be accepted; a reused `jti` in that window fails with
228
+ `pod_proof_replayed`.
229
+
230
+ The first successful refresh moves `pending` to `active` and revokes every other
231
+ credential with the same `identity.keyId` (whichever user holds it) and the same
232
+ user's other credentials with the same `deviceId`. A `pending` credential past
233
+ `activateBefore` is rejected and deleted. Revoked credentials are deleted, so a
234
+ later refresh fails with `pod_credential_invalid`. Response
235
+ `{"accessToken","expiresIn"}`.
236
+
237
+ ### Status
238
+
239
+ `POST /api/v1/pod/credential/status`, user `Authorization`, body `{"podId"}` →
240
+ `{"podId","status","activatedAt"?}` with `status` one of `pending`, `active`,
241
+ `absent` (schema `schema/pod-credential-status.v1.schema.json`). `absent`
242
+ covers no such credential, revoked, pending past `activateBefore`, and a
243
+ credential owned by another user, so the answer never reveals other users'
244
+ pods. `activatedAt` (epoch ms) is present when `active`. Commissioners use it to
245
+ decide the outcome of an activation they could not observe (section 7).
246
+
247
+ ### Revoke
248
+
249
+ `POST /api/v1/pod/credential/revoke`, body `{"podId"}` with the owning user's
250
+ `Authorization`, or `{"podId","refreshToken","proof"}` where the proof uses
251
+ `aud` = `pod-credential-revoke` (pod logout and factory reset). Idempotent:
252
+ revoking a pod that does not exist or that the caller does not own is a no-op
253
+ that also answers `{"ok":true}`, so the answer never reveals other users' pods.
254
+ Response `{"ok":true}`.
255
+
256
+ ### List and rename
257
+
258
+ `POST /api/v1/pod/list` (user) returns `{"pods":[…]}`, the user's active pods
259
+ and unexpired pending pods, each
260
+ `{"podId","name","deviceId","model","platform","firmwareVersion","identityFingerprint","status","issuedAt","activatedAt"}`.
261
+ `POST /api/v1/pod/rename` `{"podId","name"}` (user) returns the updated pod.
262
+
263
+ ### Tokens
264
+
265
+ - Access: `pod-` + HS256 JWT. Claims: `iss`, `aud`, `sub` = `podId`, `user_id`,
266
+ `tenant_id`, `device_id`, `token_type` = `pod_access`, `iat`, `exp` (3600 s),
267
+ `jti`. The signing key is derived from the Hub secret with HMAC-SHA256 over the
268
+ label `mhome-pod-access-v1`, so Hub and pod tokens never verify as each other.
269
+ - Refresh: `pod-` + 43-character base64url of 32 random bytes. Lion stores only
270
+ its SHA-256. It never rotates; the identity proof binds it to the device.
271
+ - Pods send `Authorization: Bearer pod-…` (and `"token":"Bearer pod-…"` on the
272
+ WebSocket). `pod-` is dispatched by prefix before any JWT parsing. Only the cloud WebSocket
273
+ `/auth` and `/api/v1/hub/token/exchange` accept pod access tokens. Every other
274
+ API keeps accepting user tokens only. No target allowlist applies in v1.
275
+ - Lion checks an access token against the stored credential on every use, so a
276
+ revoked or superseded credential stops working before its `exp`.
277
+ - A pod session acts as its user across all of that user's Spaces. A cloud
278
+ WebSocket session authenticated with a pod access token remembers the `podId`
279
+ and the token `exp`. Lion closes it with a policy-violation close whose reason
280
+ is `POD_TOKEN_EXPIRED` at `exp` and `POD_REVOKED` when the pod is revoked;
281
+ revocations reach sessions on every Lion instance through a shared event
282
+ channel. Either close only ends that connection; whether the pod is revoked
283
+ is decided by its next refresh.
284
+ - `/api/v1/hub/token/exchange` with a pod access token returns a Hub JWT with
285
+ `clientKind` = `pod` and `podId` claims and the same lifetime as a Hub JWT
286
+ for a user token.
287
+ - Revoking a pod credential, by any path, also revokes the pod on the Hubs of
288
+ its user's local Spaces. Before committing the revocation Lion marks the
289
+ `scope.pods` resource dirty for each such Space; after it commits, Lion
290
+ notifies the Hub through the pull-sync contract. A Hub that is offline pulls
291
+ the marker when its bridge reconnects. `scope.pods` is a snapshot
292
+ `{"podIds":[…]}` of the active pods of the Space's members. The Hub revokes
293
+ the local tokens it issued to any other pod before the pull, disables the
294
+ local auth of that pod's app client once no active token is left, and refuses
295
+ and stops renewing that pod's local connections.
296
+
297
+ ## 6. Pod runtime authentication
298
+
299
+ - Cloud `/auth`: `{"token":"Bearer pod-…","devToken":null,"source":"pod","deviceId","activeScope"}`,
300
+ then `/focus`. Lion rejects `source=pod` without a pod token, a pod token
301
+ with any other source, and a `deviceId` other than the one the credential was
302
+ issued to.
303
+ - The pod refreshes its access token proactively, 2 minutes before `exp`, and
304
+ re-authenticates the cloud session with the new token. A refresh rejected
305
+ with `pod_credential_invalid` or `pod_activation_expired` moves the pod to
306
+ `needs_recommission`.
307
+ - Space switching: the pod chooses among the Spaces returned by `/auth` and
308
+ re-sends `/focus`; it keeps one Hub JWT and Hub key per Hub and Space.
309
+ - Local Hub: the Hub identity endpoint `POST /api/v1/hub/identity/get`
310
+ (`{"hubId","tenantId","scopeId"}`) is public and needs no token. The pod
311
+ proves the Hub identity, then authenticates to the local Hub the way a Client
312
+ does: the first local `/auth` carries its pod access token with
313
+ `source=cloud` (`clientSource=pod`, `deviceId`, `tenantId`, `scopeId`,
314
+ `hubId`). The Hub exchanges that token with the cloud and returns a
315
+ long-lived local Hub JWT in `jwtToken`, which the pod stores per Hub. Later
316
+ local `/auth` requests use only that JWT with `source=local`. When the stored
317
+ JWT is about to expire or the Hub rejects it, the pod discards it and
318
+ authenticates with `source=cloud` again.
319
+
320
+ ## 7. Native Client targets
321
+
322
+ All targets are handled by the native Client and never forwarded. Payloads are
323
+ domain input JSON. Clients without a usable Bluetooth adapter report
324
+ `adapter.state` and reject commissioning with `bluetooth_unavailable`.
325
+
326
+ A rejected request uses the facade error envelope
327
+ `{"error","message","details":{"reason"}}`: `error` is `BAD_REQUEST` for an
328
+ unreadable body, `UNSUPPORTED` for an unknown target and `PRECONDITION_FAIL`
329
+ otherwise. `PRECONDITION_FAIL` errors carry `details.reason`, one of the stable
330
+ values below. User interfaces
331
+ map the reason (and the session `error.code` and `detail`) to their own copy and
332
+ never show these values or `message` verbatim.
333
+
334
+ | Reason | Meaning |
335
+ | --- | --- |
336
+ | `session_active` | another pod or Host session is running |
337
+ | `device_gone` | the candidate is no longer nearby |
338
+ | `not_commissionable` | the device is not accepting commissioning now (also while it is locked) |
339
+ | `wrong_kind` | a Host candidate given to a pod target or the reverse |
340
+ | `unknown_session` | no session with this `sessionId` |
341
+ | `wrong_state` | the session is not in a state that accepts this request |
342
+ | `invalid_code` | the code is not 6 digits |
343
+ | `invalid_wifi` | SSID not 1–32 bytes, or password not empty or 8–63 characters (64 hex digits) |
344
+ | `bluetooth_unavailable` | no usable Bluetooth adapter |
345
+ | `not_signed_in` | no signed-in MeowLink account |
346
+ | `scope_not_offered` | the Space is not among those offered, or the account has no Space |
347
+ | `cloud_unreachable_for_device` | the cloud address is loopback |
348
+ | `busy` | the native Client cannot take the request now; retry |
349
+
350
+ | Target | Request | Response |
351
+ | --- | --- | --- |
352
+ | `/local/pod/discovery/start` | `{}` | `{"leaseId","expiresAtMs"}`, 30 s lease |
353
+ | `/local/pod/discovery/renew` | `{"leaseId"}` | `{"leaseId","expiresAtMs"}` |
354
+ | `/local/pod/discovery/stop` | `{"leaseId"}` | `{}` |
355
+ | `/local/pod/discovery/list` | `{}` | discovery snapshot |
356
+ | `/local/pod/commission/start` | `{"candidateId"}` | session snapshot |
357
+ | `/local/pod/commission/code` | `{"sessionId","code"}` | session snapshot |
358
+ | `/local/pod/commission/wifi` | `{"sessionId","ssid","password"?}` | session snapshot |
359
+ | `/local/pod/commission/wifi/scan` | `{"sessionId"}` | session snapshot |
360
+ | `/local/pod/commission/authorize` | `{"sessionId","scopeId"}` | session snapshot |
361
+ | `/local/pod/commission/cancel` | `{"sessionId"}` | session snapshot |
362
+ | `/local/pod/commission/renew` | `{"sessionId"}` | session snapshot |
363
+ | `/local/pod/commission/status` | `{}` | `{"session": snapshot or null}` |
364
+ | `/local/pod/bluetooth/settings` | `{}` | `{"opened"}` |
365
+
366
+ `/local/pod/bluetooth/settings` opens the system settings that fix the
367
+ current adapter state (enable Bluetooth or Location, or the app's permission
368
+ page) and answers whether it did; a platform without such a page answers
369
+ `{"opened":false}`.
370
+
371
+ Discovery is shared by every MeowLink device kind: each candidate carries
372
+ `kind` (`pod` or `host`). `/local/pod/commission/start` accepts only `pod`
373
+ candidates; Host candidates go to `/local/host/provision/*`.
374
+
375
+ Events: `/local/pod/discovery/changed` carries a discovery snapshot, at most
376
+ once per second; `/local/pod/commission/changed` carries a session snapshot.
377
+ Scanning runs only while at least one lease is live and pauses while a pod or
378
+ Host session runs; it resumes after Bluetooth is turned back on. Candidates
379
+ unseen for 10 s are removed. One session per native Client. Events are hints:
380
+ UIs reconcile with `status` and discovery `list` on start, resume, reconnect and
381
+ when they become visible, and a snapshot with a lower `revision` than one
382
+ already seen for the same session is stale.
383
+
384
+ The session that `start` creates is owned through a 30-second lease. The
385
+ caller renews it with `/local/pod/commission/renew` every 10 seconds while it
386
+ shows the session. An expired lease cancels the session, except in `delivering`
387
+ and `activating`, which run to their conclusion.
388
+
389
+ Sign-in and Spaces are checked at `start` (`not_signed_in`,
390
+ `scope_not_offered`) and again when the authorization prompt is built.
391
+
392
+ A pod inside a Wi-Fi change window (`pod-info` `"mode":"reprovision"`) gets a
393
+ Wi-Fi-only session. From `reading_info` on, its snapshots carry
394
+ `"mode":"reprovision"`; the commissioner always offers Wi-Fi, and after the pod
395
+ joins it polls `pod-credential` `status` (still `joining_wifi`) until the pod
396
+ reports `commissioned`, then records `completed` and sends `finish`. No
397
+ authorization prompt is shown, no credential is issued and `completed` carries
398
+ no `podId`; the pod keeps its existing credential and Space. A pod that reports
399
+ `failed` ends the session with `wifi_failed` carrying the pod's code (for
400
+ example `storage_failed`) in `detail`. UIs show such a session as changing the
401
+ pod's Wi-Fi rather than adding a pod.
402
+
403
+ Session states, in order: `connecting`, `awaiting_code`, `securing`,
404
+ `reading_info`, `awaiting_wifi`, `joining_wifi`, `awaiting_authorization`,
405
+ `issuing`, `delivering`, `activating`, `completed`; terminal `failed`,
406
+ `cancelled`, `timed_out`. Wi-Fi states are skipped when the pod reports a
407
+ configured network and `prov-config` status reports it connected; while that
408
+ status is `connecting` the commissioner keeps polling, and only after a failure
409
+ does it reset Wi-Fi (`prov-ctrl`) and offer new credentials. The commissioner
410
+ scans for networks once on entering `awaiting_wifi`; a scan request while that
411
+ scan runs waits for it. `revision` increases on every observable change.
412
+
413
+ `expiresAtMs` is 10 minutes after `start` until delivery starts; from
414
+ `delivering` on it is `activateBefore` plus 30 seconds.
415
+
416
+ Lion decides the outcome of activation. In `delivering` and `activating`, a
417
+ lost BLE link, a failed status exchange or the deadline does not revoke by
418
+ itself; the commissioner asks `/api/v1/pod/credential/status`:
419
+
420
+ - `active`: `completed` with `podId`.
421
+ - `pending`: ask again every 5 seconds until `activateBefore` plus 30 seconds,
422
+ then revoke and fail with `activation_failed`.
423
+ - `absent`: fail with `activation_failed`.
424
+
425
+ A refused `deliver` fails with `delivery_failed` carrying the pod's `reason`
426
+ (`invalid_payload` or `wrong_state`) in `detail`, and revokes. When the pod
427
+ itself reports `failed`, the commissioner revokes and fails with
428
+ `activation_failed` carrying the pod's code. `activation_failed` without a pod
429
+ code carries `not_activated`. The commissioner records `completed` before
430
+ sending `finish`. Native Clients keep a non-terminal session running while the
431
+ app is in the background (Android foreground service of type
432
+ `connectedDevice`, iOS background task).
433
+
434
+ Session error codes: `bluetooth_unavailable`, `device_busy`, `disconnected`,
435
+ `code_rejected` (retry allowed), `code_locked`, `wifi_auth_failed`,
436
+ `wifi_not_found`, `wifi_failed`, `issue_failed`, `delivery_failed`,
437
+ `activation_failed` (with the pod's activation code, or `not_activated`, in
438
+ `detail`), `session_timeout`. `code_locked` is terminal: the device shows a new
439
+ code once it accepts handshakes again. `failed` and `timed_out` always carry `error`. A rejected
440
+ code returns to `awaiting_code` with `code_rejected`; a failed join returns to
441
+ `awaiting_wifi` with the `wifi_*` code. No other state carries `error`. After
442
+ issue, every terminal state other than `completed` revokes the credential unless
443
+ Lion already reported it `absent`; the revoke uses the account that issued the
444
+ credential.
445
+
446
+ The schemas are `schema/pod-discovery.v1.schema.json` and
447
+ `schema/pod-commission.v1.schema.json`.