Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/app-api-passthrough.md
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.
19 changes: 19 additions & 0 deletions .changeset/app-roles-sharing-and-box-code.md
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.
80 changes: 77 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ jobs:
while IFS= read -r file; do
[ -n "${file}" ] || continue
case "${file}" in
go/*|drivers/*|config*.yaml|Dockerfile|Dockerfile.updater|.dockerignore)
# contract/ is here because the Go suite is what checks the
# generated constants against the registry. Without it, a pull
# request touching only contract/registry.yaml selected no
# suite at all — and TestContractGenIsCurrent, the one test
# that catches a stale contract_gen.go, never ran.
go/*|contract/*|drivers/*|config*.yaml|Dockerfile|Dockerfile.updater|.dockerignore)
core=true
;;
optimizer/*|Dockerfile.optimizer|go/internal/mpc/*|go/cmd/ftw/main.go)
Expand Down Expand Up @@ -396,6 +401,74 @@ jobs:
- uses: actions/checkout@v5
- run: make compose-migration-test container-boundary-test

# contract/registry.yaml is one file that lives in this repository and in
# srcfl/ftw-webapp, and only a job with both checked out can tell whether it
# still is one file. The header has always claimed CI fails when the two
# drift; nothing compared them, and they drifted three ways — a code each
# side had that the other had never heard of, and a retryable flag that
# disagreed in the direction that decides what a phone offers a user.
#
# Not gated on changed paths. The whole failure was a check that ran only
# sometimes, and the app can change its copy without a file here moving.
contract:
name: the registry has not drifted from the app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

# A change that lands in both repositories at once cannot be compared
# against the other one's default branch: the counterpart is not there
# yet, and each side would wait for the other forever. A pull request
# that names where its pair lives is compared against that instead.
# Everything else — a stray edit, a rename, a copy someone forgot — still
# meets the default branch, which is the case this job exists for.
- name: Which copy of the app to compare against
id: pair
env:
# Read live rather than from github.event: that payload is a snapshot
# taken when the run was queued, so a pair declared after the last
# push would be invisible and the job would compare against the wrong
# branch while looking like it had worked.
GH_TOKEN: ${{ github.token }}
run: |
BODY=$(gh pr view "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" --json body -q .body)
REF=$(printf '%s\n' "$BODY" | sed -n 's|^Contract-pair: *srcfl/ftw-webapp@||p' | head -1 | tr -d '\r')
echo "ref=${REF:-main}" >> "$GITHUB_OUTPUT"
echo "comparing against srcfl/ftw-webapp@${REF:-main}"

# One file, not the app's whole history.
- name: Check out the app
uses: actions/checkout@v5
with:
repository: srcfl/ftw-webapp
ref: ${{ steps.pair.outputs.ref }}
path: .app
Comment on lines +444 to +446

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid comparing paired changes only to main

When contract/registry.yaml changes in either repository, this job compares the proposed version only with the other repository's already-merged main, 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 👍 / 👎.

token: ${{ secrets.FTW_CONTRACT_TOKEN || github.token }}
sparse-checkout: contract/registry.yaml
sparse-checkout-cone-mode: false

- name: Compare the two copies
run: |
set -euo pipefail
# A missing checkout is not agreement. Said out loud here because the
# obvious way to write this step — diff and hope — passes silently
# when the path is wrong, which is exactly how the last guard failed.
if [ ! -f .app/contract/registry.yaml ]; then
echo "the app's copy is not at .app/contract/registry.yaml."
echo "the checkout above failed or moved; this check will not guess."
exit 1
fi
if ! diff -u contract/registry.yaml .app/contract/registry.yaml; then
echo
echo "This file is one file in two repositories. Decide which side"
echo "is right by reading what each side's code does, then change"
echo "both copies in the same pair of pull requests — and rerun"
echo "go generate ./internal/... here."
exit 1
fi
echo "byte for byte the same file"

e2e:
name: full stack
needs: changes
Expand All @@ -417,15 +490,16 @@ jobs:
test:
name: go test + vet
if: always()
needs: [changes, core, optimizer, web, drivers, device-support-contract, compose, e2e]
needs:
[changes, core, optimizer, web, drivers, device-support-contract, compose, e2e, contract]
runs-on: ubuntu-latest
env:
RESULTS: >-
${{ needs.changes.result }} ${{ needs.core.result }}
${{ needs.optimizer.result }} ${{ needs.web.result }}
${{ needs.drivers.result }} ${{ needs.device-support-contract.result }}
${{ needs.compose.result }}
${{ needs.e2e.result }}
${{ needs.e2e.result }} ${{ needs.contract.result }}
steps:
- name: Require every selected suite to pass
run: |
Expand Down
69 changes: 64 additions & 5 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
# FTW shared contract registry.
#
# The single source for every name shared between this app and the box.
# Generates TypeScript (src/lib/contract/generated.ts) and Go constants in
# srcfl/ftw. CI fails when the two drift.
# The single source for every name shared between this app and the box, and
# the same file in both repositories — byte for byte.
#
# In srcfl/ftw it generates Go constants: go/internal/appproto/contract_gen.go
# and go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`.
# In srcfl/ftw-webapp there is no generator — src/lib/protocol/contract.ts and
# the error table in src/lib/protocol/messages.ts are written by hand and read
# back against this file by tests/registry-contract.test.ts.
#
# The two copies are compared in CI on both sides: the app runs
# scripts/check-contract-drift.mjs against a checkout of the box, and the box's
# test workflow runs the same comparison against a checkout of the app. Neither
# passes when the other repository is missing. Change one copy and change the
# other in the same pair of pull requests.
#
# Never hand-write one of these names in either language. Three separate
# namespaces for authorisation already exist in the codebase; this file is
# what stops that from happening again.
#
# One YAML trap, learned the hard way: a `desc` is a flow-mapping value, so an
# unquoted comma ends it. Go read `Route replaces a whole document, not part of
# one` as `Route replaces a whole document` and generated a truncated comment
# while the app's line-based reader saw the whole sentence. Keep commas out of
# a desc, or quote it.

version: 1

Expand Down Expand Up @@ -54,6 +71,11 @@ capabilities:
# Electricity prices, when the box has a zone configured and rows stored.
# Absent means the app draws no price view rather than an empty one.
- price.spot
# The box's own HTTP API, carried over the session. Absent means the app
# hides every view that needs it and never crashes — the same rule as
# history.5m. Present does not mean every path is reachable: reads and
# configuration go through, anything that moves energy stays on cmd.
- api.passthrough

# ---------------------------------------------------------------------------
# Scopes. One object axis, two verb axes: <object>.<read|write>.
Expand Down Expand Up @@ -109,8 +131,8 @@ modes:
- { key: weighted, tier: hidden }

# ---------------------------------------------------------------------------
# Error codes. The box sends the code and args; this app owns all prose.
# retryable tells the client whether to offer a retry at all.
# Error codes the box sends. The box sends the code and args; this app owns all
# prose. retryable tells the client whether to offer a retry at all.
# ---------------------------------------------------------------------------
errors:
- { code: E_BOOTING, retryable: true, desc: Box is starting up }
Expand All @@ -123,6 +145,43 @@ errors:
- { code: E_LAST_OWNER_PROTECTED, retryable: false, desc: Cannot remove the only owner }
- { code: E_RANGE_TOO_LARGE, retryable: false, desc: History window exceeds the limit }
- { code: E_UNAVAILABLE, retryable: true, desc: Source or subsystem is down }
# The passthrough's refusals. Each is a different sentence to a user, which
# is why none of them reuses a code above: a shared name that means two
# things is what this file exists to prevent.
#
# E_NEEDS_STEP_UP is the one retryable refusal here, and it is retryable
# because the very same request goes through once the passkey ceremony has
# run — the box refuses on `!req.StepUp` alone and the app sends it again
# itself. The others are the box's answer about the route, and asking a
# second time gets the same answer.
- { code: E_NEEDS_STEP_UP, retryable: true, desc: Request needs a fresh passkey ceremony }
- { code: E_USE_CMD, retryable: false, desc: Route moves energy and belongs on cmd }
- { code: E_UNSUPPORTED_MEDIA, retryable: false, desc: Answer is not a kind the session carries }
- { code: E_WHOLE_DOCUMENT, retryable: false, desc: Route replaces a whole document rather than part of one }
# A route the session does not carry at all: its answer holds a credential,
# or doing it needs somebody standing at the box. Not a permission the owner
# is missing, so neither a role nor a ceremony changes the answer.
- { code: E_LOCAL_ONLY, retryable: false, desc: Route is served only on the box's own page }

# ---------------------------------------------------------------------------
# Error codes the client raises for itself.
#
# These never cross the wire. The box generates nothing from this block and
# must never send one of them; the app raises them locally and they meet the
# same prose and retry rules as everything above.
#
# They are written down here rather than in the app alone because the app's
# error table is checked against this file in full. A code with no home here
# would need an exemption in that check, and an exemption list is exactly the
# thing this file exists to avoid.
# ---------------------------------------------------------------------------
client_errors:
# A cut-off answer is not an error on the wire: by the time the box knows it
# has run out of room, a status has gone out and the app is committed to it,
# so the box reports it as `truncated: true` on api.end. The app turns that
# into a code because half a document is not an answer and a view needs one
# thing to catch.
- { code: E_RESPONSE_TOO_LARGE, retryable: false, desc: The answer arrived cut off }

# ---------------------------------------------------------------------------
# Source states. Orthogonal to carrier state — see docs/protocol.md. Merging
Expand Down
Loading