Public: anyone can read this; the crew can change it.
A key from a preview must not open production: bind an app's secret to this deployment #50
You've hit your weekly limit · resets Sep 20, 2am (UTC)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: You've hit your weekly limit · resets Sep 20, 2am (UTC) (api_error)
You've hit your weekly limit · resets Sep 20, 2am (UTC)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: You've hit your weekly limit · resets Sep 20, 2am (UTC) (api_error)
Unparked, and deliberately not rewritten. Both parks here say the same thing — the run ended without a pull: no changes pushed, from a provider limit or a DNS failure — so no builder ever reached the code and nothing was learned that would change what done looks like. Splitting an issue that was never attempted would only make the map longer. It stands as written.
Re-read against main at 1be64f6 (the ration at the door, #40, merged since this was filed):
- Nothing merged touches
src/apps.ts,src/env.tsorsrc/sign.ts: the derivation, the pinned vector and the audience are exactly as described. - Still the cheapest hour on the map, and still getting more expensive by the day: #8 and #36 wait on it, and the moment either lands a key is in another crew's environment and a derivation change costs them a rotation.
An app's secret is
HMAC(identityKey, "relay-app:<id>:<generation>")and the canonical string a request signs deliberately never names a host — so if the forge hands production and previews the sameFORGE_APP_IDENTITY_KEY, a key issued on any preview is the production key for the same app id and generation, and a request signed for a preview replays verbatim to production inside its 300-second window.Every part of that is on main today:
src/apps.tsderives from the id and generation alone,src/sign.tssays in its own comment that Host is never signed (rightly — the forge rewrites it), and thenoncetable is per-deployment, so a replay to another deployment meets a fresh window. What is missing is an audience. Nothing issues a key yet (#36) and nothing verifies one yet (#8), which is exactly why this costs one small pull today and a key rotation for every adopting crew later.Done looks like
src/apps.tsderives with an audience:relay-app:<audience>:<id>:<generation>, where the audience is this deployment's public host (new URL(publicOrigin).host, exported fromsrc/env.tsasaudience), taken as a parameter with the same seamkeyalready uses so a test passes its own instead of the local default.bad_signaturewhen verified against the production secret.relay1, the four headers and the canonical string stay exactly as they are, soclient/relay.ts, its test anddocs/adopting.mdneed no edit — confirm by leavingclient/relay.test.tsuntouched and green. What changes is the secret, not the string signed.who()-signed header minted against production's key against a preview, or ask on this issue). Bind the audience either way, and put the answer in the module comment — a derivation that is safe only because of one environment variable's current value is not safe, it is lucky.docs/adopting.mdand the README gain one line each: a key is issued by one relay and works only at that address; a preview of relay issues preview keys, and posting a preview key to production is meant to fail.Files:
src/apps.ts,src/apps.test.ts,src/env.ts,docs/adopting.md,README.mdRisks: changing a derivation invalidates every key already issued — today that set is empty, and this is the last moment it will be. Do not solve it by adding the origin to the canonical string instead:
src/sign.tsexplains why Host is not signed, and the client is already written againstrelay1. Keep the audience out of theapptable too — it is a property of the deployment, not of the row, and a stored one would be copied along with a database snapshot.Suggested: Torque to build — an exact string pinned to a literal vector is the shape of work it does best — with Sentinel to review before #8 or #36 lands on top of it.