-
Notifications
You must be signed in to change notification settings - Fork 9
feat(api): let the app reach the box's own API, with a role attached #865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| --- | ||
| "ftw": patch | ||
| --- | ||
|
|
||
| The FTW app can reach the box's own HTTP API over its session, and the box now knows which phone is asking. An `api.req` carries a method, a path under `/api/`, a parsed query and an optional body; the answer comes back as a status, then chunks, then an end. It runs in process through the same handler the LAN listener serves, trust boundary included. This is a security improvement rather than a relaxation: that API is already served on the home LAN with no authentication at all, and this door is pinned to an enrolled device. | ||
|
|
||
| Who is asking now exists as a value. `appenroll.Authorise` returns the grant it has always known — device, role, enrolment epoch — instead of throwing it away on a yes-or-no answer, and it reaches the HTTP layer on the request context, never on the wire: `api.req` has no headers field, so there is no client byte that could become a caller claim. `api.SecureMutations` becomes `api.Authenticate`, which keeps a caller a session already authenticated and mints a local owner for anything off the LAN. That second branch writes down what the LAN already is; authenticating it later is a change to that one branch, because every handler from here on reads `apiauth.From`. | ||
|
|
||
| A viewer cannot write, and the box is what refuses it. `cmd` finally checks the scope its own operation table has declared since the day it was written and never read, so `site.mode.set` from a viewer is rejected with the mode controller untouched. Configuration through the HTTP door needs the owner role and a step-up. | ||
|
|
||
| Every one of the 132 routes names what it costs, beside the handler it governs, and the request's method is never consulted. 55 are reads, 40 configuration, 22 actuation and 15 local. The method is not asked because it does not know: `GET /api/caldav/credentials` hands out a password that is a write channel back into dispatch, and `POST /api/self_tune/start` pauses control and drives every battery through ±3000 W for minutes. Both read as ordinary from their verb alone. | ||
|
|
||
| Anything that moves energy stays on `cmd`, naming the command to send instead where one exists — a command carries an expiry and the box revalidates against fresh state, and an HTTP request carries neither. Fifteen routes are local: their answer holds a credential or a whole file, or doing them needs somebody standing at the box, and the app is told so with `E_LOCAL_ONLY`. `POST /api/config` is refused for a third reason: it replaces the whole configuration, so a phone a year behind the box would silently drop every field it never knew about. | ||
|
|
||
| The cost of naming every route is that a read view written in the app next year needs the box to have heard of the path. That is the direction worth being wrong in, and two things hold it there: `api.handle` takes the tier as a required argument, so leaving it out does not compile and an unknown value stops the box at startup; and a route that reaches the gate with no tier the gate knows is refused rather than served. The gate is one switch with a branch for every tier and a closed default — it replaced a chain of cases with no read branch at all, which is how a credential reached a shared viewer's phone. | ||
|
|
||
| Revocation bites at once. The grant is re-read on every privileged request, so a socket cannot outlive a revoke, and tearing down a session now cancels the call it is making rather than only the next one. An answer in a media type the session cannot carry is refused before a byte streams, an oversized one stops at the ceiling and says it was truncated, and a handler that panics costs one request instead of the box. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| --- | ||
| "ftw": patch | ||
| --- | ||
|
|
||
| A household can share its home, and the box is what enforces the difference. An enrolment now carries a role — `owner` or `viewer`, from `contract/registry.yaml`, with the role table generated rather than hand-written on either side. A row loaded from a file written before roles existed reads as an owner, so an update never silently demotes every paired phone. | ||
|
|
||
| An invite is not a new cryptographic object. It is the same single-use pairing code with a different role behind it, so the QR payload does not change shape and the app's scanner learns nothing about sharing: a guest scans what an owner scans and is told what they are in `hello_ok`. The role is remembered by the box and stamped when the code is spent, never carried in the payload, because a role its holder can edit is not a role. One code is live at a time, across kinds, so asking for a guest pass cancels a pairing code still on a screen. | ||
|
|
||
| Two rules stop a household locking itself out, both in `appenroll` rather than in the API layer — otherwise the box's own page could do what the app cannot. The first enrolment on a box is an owner whatever code it used, because a box with no owner can never be administered again. The last owner cannot be removed or stepped down, and the device list says so on the row instead of offering a button that fails. | ||
|
|
||
| The last-owner refusal now carries a code as well as a sentence. Two audiences read these bodies: the box's own page prints the sentence, and the app owns every word it shows and needs a name to branch on. A 409 alone is a conflict and nothing more specific, so the app read a `code` key — which this floor had never sent, leaving the one refusal a household can meet through the app as the one refusal it could not explain. The code is `E_LAST_OWNER_PROTECTED` from `contract/registry.yaml`, through the generated constant, never a literal at the call site. | ||
|
|
||
| Sharing has no screen of its own: a guest's phone is a paired phone, so it is a row in the same device list, with the same Remove. Locking out a stray key and taking a guest's access away are one action. A role change takes effect on a session that is already open, because both doors re-read the grant on every privileged request — a demoted owner loses their writes at the next one and keeps the readings they still have every right to see. | ||
|
|
||
| A household does all of that from the app, and the box decides what the app may hand out. These routes have two doors and the doors prove different things: the LAN proves somebody is in the building, and an app session proves a phone is enrolled while saying nothing about where it is. So a session sees the roster, invites a viewer and locks a phone out — gated on `ftw.members.read` and `ftw.members.write`, neither of which a viewer's grant carries — and making another owner, by minting an owner's code or by promoting a row, still needs somebody at the box. | ||
|
|
||
| The role is no longer defaulted anywhere. A request that named none used to mint an owner, on the reasoning that a page which has not been updated should keep meaning what it used to mean. What that reasoning costs is a default that hands over a house whenever a field goes missing, and the field did go missing: the app sent its role in a query string this endpoint does not read, so every "invite someone to view" arrived here naming no role. It is a 400 now. What a caller did not say is a question, not a blank to fill in on their behalf. | ||
|
|
||
| There is now a way back in without a camera: a code the box shows, `XXXX-XXXX`, forty bits in Crockford base32 so I, L and O fold back to 1 and 0 for whoever wrote down what they heard. It is redeemed where a scanned code is, inside Noise handshake message 1, so there is no new endpoint and no new carrier. It re-admits a phone that already knows this box; a phone that has never seen it still has to scan, because the box's own key travels only in the square, and the page says that rather than offering a path that cannot work. What makes forty spoken bits safe is not their size: it is minted only on the LAN, shown only on the box's own page, spent once, five minutes long, and burned by five wrong guesses. The counter is on the code and not on the caller, because an address-keyed counter would inherit the relay's own bug, where the documented TLS terminator makes the whole fleet one address. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
contract/registry.yamlchanges in either repository, this job compares the proposed version only with the other repository's already-mergedmain, so the first half of every coordinated contract update necessarily fails CI; this commit itself changes the registry while saying the box must merge first. If the app applies the documented reciprocal check, neither half can satisfy required checks without an override. Compare against the paired revision or adopt a single authoritative source so contract updates remain mergeable.Useful? React with 👍 / 👎.