Repository navigation
feat(applink): an owner phone adds a watch as a device of its own - #1625
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
48f0d0c to
228b8ed
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 48f0d0ca53
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if meta.role == apiauth.RoleWatch && i.authorised[meta.parent] == nil { | ||
| delete(i.authorised, key) |
There was a problem hiding this comment.
Preserve watch enrolments across rollback rewrites
If a site enrolls a watch, rolls back to a pre-watch build, and any phone reconnects, that older build rewrites applink.json without the unknown parent field; after upgrading again, this condition treats the now-parentless watch as an interrupted removal and silently deletes its enrolment. Since rollback is a supported recovery path, retain enough backward-compatible parent state or avoid discarding these rows so a routine rollback does not force the household to re-pair every watch.
AGENTS.md reference: AGENTS.md:L20-L25
Useful? React with 👍 / 👎.
ADR 0009 milestone 3, decisions 4, 5 and 9. POST /api/app-link/pairing with role watch answers only over a session, only to an owner, and only when that request carried stepUp; the passthrough's grace window does not stand in. The code binds to the calling phone and has its own slot, so it leaves a pending phone code alone. appenroll.AuthoriseWatch stores the watch with role watch and the phone as parent. A watch key gets no stream, and a phone key does not come through the watch's door. GrantReader now returns an Enrolment with the parent's current role, and GrantScopes keeps the watch role's scopes that the parent's role also carries, so demoting the phone narrows the watch at its next command. A watch spends its phone's command ids. Revoke returns every device it removed, a phone and then its watches, and the API drops each one's sessions. A watch keeps its role until it is removed. The device list, the box's page and FTW Cloud's roster carry each watch under its phone. cmd.exchange stays unsaid until the exchange socket lands in step 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
228b8ed to
9a8199d
Compare
Problem and result
Milestone 3 of ADR 0009: the watch role and its enrolment (decisions 4, 5 and 9). An owner's phone can add an Apple Watch as a device of its own. The box does not offer it yet:
cmd.exchangestays unsaid until the exchange socket lands in step 5, so no phone shows "Add watch".Step 1 (#1622) and step 2 (#1623) are merged; this is one commit on master.
What changes:
POST /api/app-link/pairingwith rolewatchgoes to its own handler. It answers only over a session, only to an owner, and only when that request carriedstepUp. The passthrough's grace window does not stand in for it: a watch is a new key that can charge the car from anywhere. A refusal for a missing step-up carriesE_NEEDS_STEP_UP, so the app can ask for the passkey. The answer is a QR payload like any other code's, with no LAN hint.appenroll.MintWatchCodebinds the code to the calling phone and keeps it in a slot of its own, so it leaves a pending phone code alone and the reverse. Spent once, timed, and burned after five wrong guesses, as a phone code is. The phone must still be an owner when the watch spends it.AuthoriseWatchstores the watch's key with rolewatchand the phone as parent. Step 5 calls it from the exchange with the code from the watch'shello. A watch key on the stream gets the silence a stranger gets, and a phone key on the watch's door does too, so a watch can never take one of a phone's four stream places.GrantReader.Grantnow returns anEnrolmentwith the parent's current role.appproto.GrantScopeskeeps the watch role's scopes that the parent's role also carries. Every command re-reads it, so demoting the phone to viewer turns the watch's next charge intoE_SCOPE_DENIED.hello_oknames the derived scopes.Enrolment.Root), so one intent sent by the watch and by its phone acts once (decision 3).Revokereturns every device it removed: a phone and then its watches. The API drops each one's sessions and push subscriptions. Removing a watch touches nothing else. Removing a phone also cancels its pending watch code. A watch whose phone is missing fromapplink.jsonis dropped at load.GET /api/app-link/devicesand the box's page put each watch right after its phone, withparent. The page draws a watch set in under its phone, "Can look and charge", with Remove and no role button. The removal warning says when a phone's watch goes with it. "N phones paired" still counts phones.watchand the scopes the box derives for it.docs/protocol-cloud.mdgains the role in step 6.watchat both doors until this step. The pairing route now sends it to the new handler first; the guard stays for role changes, with a sentence that says why.Scope and safety
onCmdpath: expiry, scope,rev, dispatch gate, guards and read-back.ftw.live.read,ftw.plan.read,ftw.events.readandftw.dispatch.writeat most, and never more than its phone holds now. No mode, no members, no history, no passthrough.AuthoriseWatch, which nothing calls until step 5.applink.jsongains an optionalparentper row. After a rollback an older box keeps a watch's row, but its role table has nowatch, so the row carries no scopes and its handler refuses to start.Verification
appenroll/watch_test.go: an owner phone adds a watch that names it as parent; a viewer and a stranger cannot mint a code; the watch code and a phone code leave each other alone; the watch code works once and only at the watch's door; each door answers the other kind as a stranger; expiry; burned by guessing; dies when its phone is demoted; removal runs from phone to watch and never back, and a removed watch's next message 1 gets no answer; the watch role cannot be given or taken by a role change; demotion moves the watch's grant and roster entry; the list order; the roster; a restart; an orphan dropped at load.appproto/watch_test.go: the scope cut under owner, viewer and no parent; a watch charges and getsE_SCOPE_DENIEDforsite.mode.set; demoting the phone narrows the watch at its next command; a watch and its phone share command ids while another phone's same id acts;hello_oknames the watch's role and scopes and nocmd.exchange.api/api_app_link_watch_test.go, over a real session into the real API: an owner phone adds a watch and the code enrols it under that phone; withoutstepUpit is refused withE_NEEDS_STEP_UPeven inside the grace window; a viewer and the box's page are refused; adding a watch leaves an invite alive; the list names the parent and a watch's role cannot change.TestNoDoorButItsPhoneHandsOutAWatchkeeps step 2's refusals for the LAN and for role changes.appuplink/watch_test.go: a watch's key on the stream gets no reply.cmd/ftw/cloud_roster_test.go: the roster gives the watch its role's scopes under an owner and onlyftw.live.readunder a viewer.web/app-link-sharing.test.mjs: the watch row and both removal warnings.Browser: ran master and this branch locally with the same
applink.json: an owner phone, a viewer and a watch whose parent is the owner. Settings → FTW app, desktop width. I also checked phone width and the two removal warnings on this branch. A human still needs to look at it.Before (master). The watch reads as a third phone that can change things, with a "Make it view only" button, and the page says "3 phones paired."
After (this branch). The watch sits set in under its phone, "Can look and charge", with Remove and no role button, and the page says "2 phones paired."
The images live on branch
pr-assets/1625-watch-list, which holds nothing else; delete it after this merges.make verifygreen.Checklist
🤖 Generated with Claude Code