feat(app): reach the box's own API, share a home read-only, and get back in - #16
Conversation
…ack in Five things, and the first is why the rest are possible. The app could ask its box six questions. The box's own page can ask 132, so every new view cost a box release. The session now carries the box's HTTP API, and a view over a route the box already prices as read is this repo's work alone. That is 55 of the 132 — every reading a house is drawn from. It is not free for a route the box has never priced: that one is closed by default, and serving it is a line in the box's table and a release. The docs say so rather than promising more. Sharing. A household can invite someone as a viewer, see who has access and take it away. The screen hides what a viewer cannot use, and that is presentation only — the box refuses, which is the part that counts. Ours asked for the role in a query string while the box read a body and defaulted an absent one to owner, so the invite meant to let family look would have handed over the house. The simulator read it the same wrong way, which is why every test passed; it now behaves like the box, closed paths and all. Getting back in. A phone that has lost its key can type the code the box shows — Crockford base32, decoded forgivingly, because someone is reading digits aloud and five wrong tries burns it. The path was unreachable from the state it exists for: any surviving site row made the shell decide pairing was not needed, so the phone that needed the floor was the one that could not stand on it. The energy view Fredrik asked for, using the box's own bar chart vendored byte for byte rather than a second one written here. And a sealed copy at Sourceful, opt-in, so a fresh install recovers with a passkey alone. Its claim — a sealed copy it cannot open, an opaque id, nothing beside it — was false six times, each one a layer below the last fix: a managed database's point-in-time history, the proxy's access log, the storage engine's page layout, the journal written by the fix for that, the time a read took, and the fsync an accepted write pays. Five are closed. The sixth is written down with its numbers, because it names no household and closing it would cost every read the same disk. Two screens stopped inventing facts. Both claimed something about the box before the box had answered — caps arrive empty and role defaults to owner, so a cold start printed "this box doesn't support that" as if it were an answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
ftw-webapp | a0407ab | Aug 07 2026, 08:01 AM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c50e3ed728
ℹ️ 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 (!writeKey || writeKey.length !== WRITE_KEY_BYTES) return answer(400) | ||
| if (!signature || signature.length !== SIGNATURE_BYTES) return answer(400) | ||
|
|
||
| const outcome = await store.write({ id, version: version as number, blob, writeKey, signature }) |
There was a problem hiding this comment.
Rate-limit unauthenticated first writes
Any internet client can generate an arbitrary ID and Ed25519 key, sign a version-1 blob, and reach this call; the signature only proves ownership of the caller-supplied key, so the first write consumes a permanent slot without authenticating an FTW household. With the default 128 buckets and 64 ways, an attacker can target IDs by the public bucket hash and fill every bucket in roughly 8,192 requests, after which every legitimate new opt-in receives 507 indefinitely. Put an admission/rate-limiting control in front of first writes so the fixed, non-evicting store cannot be exhausted anonymously.
Useful? React with 👍 / 👎.
| select(range: EnergyRangeKey): void { | ||
| this.range = range | ||
| } |
There was a problem hiding this comment.
Keep the displayed period coupled to its data
After a response for one period is displayed, selecting another period changes range and therefore the heading immediately but leaves days and its totals untouched until the new request succeeds. For example, tapping “30 days” while seven-day data is present shows those seven-day totals under “Last 30 days” during the request, and leaves them mislabeled indefinitely if the request fails. Clear the prior result or retain the prior displayed range until the matching response arrives.
Useful? React with 👍 / 👎.
| const wanted = $derived( | ||
| site.session.caps.has(CAP_PASSTHROUGH) | ||
| ? `energy ${energy.range} ${new Date().toDateString()}` | ||
| : null |
There was a problem hiding this comment.
Key daily refreshes to the box time zone
When the phone and home are in different time zones, this key changes at the phone's midnight even though /api/energy/daily defines its rows in the box's local day. If the box crosses midnight first, askWhenLive sees the same key and keeps showing the previous box day under “Today” until the handset reaches midnight, potentially hours later. Build the day key using the box time zone available in site.session.box?.tz.
Useful? React with 👍 / 👎.
The guard compares this repository's copy against the box's default branch, and the box's compares against this one's. A change that lands in both at once therefore waits for itself: neither side can go first, and the guard's own error message tells you to change both copies in the same pair of pull requests, which is the thing it forbids. A pull request that names where its pair lives is now compared against that branch. Everything else still meets the default branch, which is the drift this job exists to catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
github.event.pull_request.body is captured when the run is queued, so a pair declared after the last push is invisible to it — the job compares against the default branch and looks like it worked. Ask the API for the body instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Needs srcfl/ftw#865 — merge that first.
Five things, and the first is why the rest are possible.
The box's own API, over the session
The app could ask its box six questions. The box's own page can ask 132, so every new view cost a box release. Now a view over a route the box already prices as read is this repo's work alone — 55 of the 132, which is every reading a house is drawn from.
It is not free for a route the box has never priced: that one is closed by default, and serving it means a line in the box's route table and a release. The docs say that rather than promising more, because somebody will plan work on the sentence.
Sharing, and the trap under it
Invite someone as a viewer, see who has access, take it away. The screen hides what a viewer cannot use — that is presentation; the box refusing is the part that counts.
This app asked for the role in a query string. The box read it from a body, and defaulted an absent one to owner. So the invite meant to let family look would have handed over the house. Every test passed because the simulator read it the same wrong way — a peer that shares the app's misreading hides it. The simulator now mirrors the box's route table, closed paths and all, and several tests changed as a result.
Getting back in
A phone that has lost its key can type the code the box shows. Crockford base32, decoded forgivingly — someone is reading digits aloud over a phone, and five wrong tries burns the code, so a typo the app could have normalised must not spend an attempt.
It was unreachable from the state it exists for: any surviving site row made the shell decide pairing was not needed, so a phone whose identity database had been evicted never mounted the screen. Verified in a production build at 375 px.
The energy view
The one Fredrik asked for, using the box's own
ftw-bar-chart.jsvendored byte for byte rather than a second bar chart written here.The sealed copy
Opt-in, so a fresh install recovers with a passkey alone. Its claim — a sealed copy it cannot open, an opaque id, nothing beside it — was false six times, each one a layer below the last fix:
Five are closed. A measured survey rejected every off-the-shelf store first — file-per-key and S3 keep an mtime, Redis keeps
IDLETIME, LevelDB and Postgres keep arrival in their logs, LMDB and ndbm write different bytes for different arrival orders. The store is now one file of fixed slots: no arrival order to remove, 8.0 ms per write flat in capacity, reads 0.15 ms.The sixth is written down with its numbers rather than chased. It names no household — an observer learns a write landed, around now, and attempts to tie it to an id came out a coin flip. Closing it would cost every read the same disk for a channel that identifies nobody. A test holds all four tellings of that count in step.
Two screens that invented facts
Both claimed something about the box before the box had answered —
capsarrives empty androledefaults to owner, so a cold start printed "this box doesn't support changing how it runs" as if it were an answer.Review notes
contract/registry.yamldrifted three ways from the box's copy under a test that compared this file against itself. Now guarded for real.docs/architecture.mdwas destroyed mid-session by an agent runninggit checkouton an uncommitted tree, and recovered verbatim from an earlier agent's transcript.🤖 Generated with Claude Code
Contract-pair: srcfl/ftw@agent/app-api-passthrough