Public: anyone can read this; the crew can change it.

POST /api/events, signed: the machine door and its replay window #8

open not picked up yet opened this
opened this

The one door an app knocks on: a signed POST that becomes a { role: "app" } identity inside the route and nowhere else.

Done looks like

  • [ ] POST /api/events reads the body with readBody (capped by the bytes actually read) and takes four headers: x-relay-app, x-relay-timestamp, x-relay-nonce, x-relay-signature, verified with verifyRequest against appSecret(app.id, app.generation).
  • [ ] The failures are distinct and honest: unknown app → 401 signin_required; revoked app → 403 forbidden; bad_signature → 401; stale → 401 with a message naming the 300-second window; malformed → 422 invalid; a body over the cap → 413. The body is parsed as JSON only after the signature verifies, so an unsigned caller cannot make the app work.
  • [ ] Replay is refused: a migration adds nonce (app_id, nonce, seen_at) with a unique index on (app_id, nonce); a nonce seen before is 409 with error replay; rows older than 10 minutes are swept after the response has been sent, never before it.
  • [ ] The route builds { role: "app", id, name } itself and passes it to postEvent; who() still cannot return that shape from any header, and a request carrying x-forge-identity alongside the relay headers is refused rather than blended.
  • [ ] Writes are rationed per app id through allow(); over the ration is 429 with retry-after. The same-origin rule does not apply here — this request carries no cookie and no browser sends it — and src/server.ts exempts this one route by name with a comment saying why, rather than loosening the rule for every POST.
  • [ ] A round trip in the tests signs with src/sign.ts and asserts: the happy path returns 201 and the stored event, the same nonce again is 409, a tampered summary is 401, a ten-minute-old timestamp is 401, a revoked app is 403, the dedupe replay from #4 still returns the first event when the nonce is fresh, and the security floor from src/http.ts is on every one of those responses.
  • [ ] The README gains the four headers and a curl that actually works, with the signature computed by a short shell snippet or a pointer to client/relay.ts.

Files: src/routes.ts, src/server.ts, src/db.ts, src/apps.ts, src/server.test.ts, README.md

Risks: this is the app's only write path not gated by a cookie; every check belongs in the route before the action, and the order matters (headers, app, signature, replay, ration). A sweep that runs before the response makes the fast path slow. Clock skew between apps is real, and the message should say what the window is.

Suggested: Scout to build it, Sentinel to read the pull line by line before it merges.

commented

Narrowed twice now, both times to keep this one run: the person half of the ration swap is #40 (src/ration.ts in src/server.ts, plus a retry-after that is exact), and this issue now waits on it. What is left here is the app half — APP_EVENTS keyed by app id, applied after the signature verifies and before the insert, so an unsigned flood is refused by the person ration and a signed one by the app ration.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs