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
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,62 @@ concurrency:
cancel-in-progress: true

jobs:
# contract/registry.yaml is one file that lives in two repositories, and only
# a job with both checked out can tell whether it still is one file. The
# file's own header has always said CI fails when the two drift; nothing ever
# compared them, and they drifted three ways under a test that only ever
# compared this repository against itself.
#
# Its own job rather than a step in verify, because it is the only thing here
# that needs a second repository — and because a suite that cannot run on a
# laptop should not be the reason `npm run verify` fails on one.
contract:
name: the registry has not drifted from the box
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v5
with:
node-version: 24

# One file, not the box's whole history. A checkout that fails takes the
# job with it, and the check refuses to run without both copies — so a
# step deleted or a path renamed turns this red rather than green. That
# is the entire difference between this and what it replaces.
# 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 box 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@||p' | head -1 | tr -d '\r')
echo "ref=${REF:-master}" >> "$GITHUB_OUTPUT"
echo "comparing against srcfl/ftw@${REF:-master}"

- name: Check out the box
uses: actions/checkout@v5
with:
repository: srcfl/ftw
ref: ${{ steps.pair.outputs.ref }}
path: .box
token: ${{ secrets.FTW_CONTRACT_TOKEN || github.token }}
sparse-checkout: contract/registry.yaml
sparse-checkout-cone-mode: false

- run: node scripts/check-contract-drift.mjs .box/contract/registry.yaml

verify:
runs-on: ubuntu-latest

Expand Down
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ These exist because breaking one of them breaks a promise made to users.
The honest claim is that the cache resists offline disk reads and other
origins, not that it resists an attacker with the unlocked device.
- **Never hand-write a name shared with the box.** Scopes, capabilities,
error codes and field ids come from `contract/registry.yaml`.
error codes and field ids come from `contract/registry.yaml`. That file is
the same file in [srcfl/ftw](https://github.com/srcfl/ftw), byte for byte —
change one copy and change the other in the same pair of pull requests. CI
on both sides compares them and neither side passes with the other absent.

## Conventions

Expand All @@ -78,6 +81,10 @@ npm run dev # dev server
npm test # unit and contract tests
npm run check # types
npm run verify # all of the above plus a production build

# The registry against the box's copy. Needs a checkout of srcfl/ftw, which
# is why it is not part of verify — CI runs it on every push and pull request.
npm run check:contract ../ftw/contract/registry.yaml
```

Run the narrow test while iterating, `npm run verify` before handoff.
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ knows which opaque handle is online. It cannot read a watt, a device name or
a command. This is enforced by the protocol, and a CI test dumps the relay's
database and fails if anything recognisable is in it.

If a household asks, the cloud will also hold one sealed copy of its home, so a
new phone gets back in with a passkey instead of a trip to the box. Sourceful
holds a sealed copy it cannot open, with an opaque id and nothing beside it —
[`escrow/README.md`](escrow/README.md) is the claim, the file of fixed slots it
is stored in, and the tests. It is off until someone turns it on, and losing the
whole database costs a QR scan.

## Status

Early. The architecture is decided and the protocol is specified; the client
Expand All @@ -50,8 +57,8 @@ for what was decided and what was rejected, and [docs/protocol.md](docs/protocol
for the wire contract.

Not yet built: push notifications, the LAN carrier, sharing beyond two roles,
multi-site, recovery by escrow. Each is listed with its reason in the
architecture doc rather than left implied.
multi-site. Each is listed with its reason in the architecture doc rather than
left implied.

## Running it

Expand Down Expand Up @@ -83,6 +90,7 @@ Runs type checks, tests and a production build. Green before every handoff.
| `src/views` | Screens |
| `contract/` | The shared registry: scopes, capabilities, error codes, field ids |
| `relay/` | The blind relay itself — the server this app's frames pass through |
| `escrow/` | The sealed escrow — one slot per household, and it cannot open any of them |

`contract/registry.yaml` is the single source for every name shared with the
box. It generates TypeScript here and Go constants in the FTW repo, and CI
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