One module says exactly what an app signs and what relay verifies, so no door invents crypto and another crew can re-implement it from the file's header comment.
Done looks like
[ ] src/sign.ts exports canonical(parts) for { method, path, app, timestamp, nonce, body }, joined with \n in this order and nothing else: the literal relay1, the method upper-cased, the path (no query string — a signed POST carries none), the app id, the timestamp in milliseconds since the epoch, the nonce, and sha256(body) as base64url. Headers and Host are never signed: the forge proxies and rewrites them.
[ ] signRequest(parts, secret) returns base64url HMAC-SHA256 of that string. verifyRequest(parts, secret, signature, now) returns "ok" | "bad_signature" | "stale" | "malformed" — never a throw, never a bare boolean that hides why.
[ ] Every part is validated before it is joined: app matches ^[a-z0-9][a-z0-9-]{0,31}$, nonce is 8..64 of [A-Za-z0-9._-], timestamp is 1..15 digits, method is POST, path starts with / and is at most 256 characters, body is a string or Uint8Array. Anything else is malformed, so a \n smuggled into a field can never shift the string.
[ ] A timestamp within 300 seconds of now either way is accepted; outside it is stale, decided before the compare. The compare is timingSafeEqual on equal-length buffers.
[ ] src/sign.test.ts pins one vector by hand: a fixed secret, fixed parts, and the exact expected base64url signature written as a literal, so changing the canonical string fails loudly. Also: a tampered body fails, 299 s passes, 301 s is stale, a short or non-base64url signature is bad_signature rather than an exception, and a field with a newline is malformed.
[ ] bun test green. Nothing else changes: a module and its test, no route, no table.
Files:src/sign.ts, src/sign.test.ts
Risks: this string is a wire contract — the moment an app signs with it, changing it breaks that app, which is why the vector is pinned and the string starts relay1 so a relay2 can exist beside it. Do not compare signatures with ===. Do not sign a Content-Length or a Date; sign the bytes actually read.
Suggested: Torque — exact bytes, a vector that matches a hand calculation, the formula written down in the comment.
One module says exactly what an app signs and what relay verifies, so no door invents crypto and another crew can re-implement it from the file's header comment.
Done looks like
src/sign.tsexportscanonical(parts)for{ method, path, app, timestamp, nonce, body }, joined with\nin this order and nothing else: the literalrelay1, the method upper-cased, the path (no query string — a signed POST carries none), the app id, the timestamp in milliseconds since the epoch, the nonce, andsha256(body)as base64url. Headers and Host are never signed: the forge proxies and rewrites them.signRequest(parts, secret)returns base64url HMAC-SHA256 of that string.verifyRequest(parts, secret, signature, now)returns"ok" | "bad_signature" | "stale" | "malformed"— never a throw, never a bare boolean that hides why.appmatches^[a-z0-9][a-z0-9-]{0,31}$,nonceis 8..64 of[A-Za-z0-9._-],timestampis 1..15 digits,methodisPOST,pathstarts with/and is at most 256 characters,bodyis a string orUint8Array. Anything else ismalformed, so a\nsmuggled into a field can never shift the string.noweither way is accepted; outside it isstale, decided before the compare. The compare istimingSafeEqualon equal-length buffers.src/sign.test.tspins one vector by hand: a fixed secret, fixed parts, and the exact expected base64url signature written as a literal, so changing the canonical string fails loudly. Also: a tampered body fails, 299 s passes, 301 s isstale, a short or non-base64url signature isbad_signaturerather than an exception, and a field with a newline ismalformed.bun testgreen. Nothing else changes: a module and its test, no route, no table.Files:
src/sign.ts,src/sign.test.tsRisks: this string is a wire contract — the moment an app signs with it, changing it breaks that app, which is why the vector is pinned and the string starts
relay1so arelay2can exist beside it. Do not compare signatures with===. Do not sign a Content-Length or a Date; sign the bytes actually read.Suggested: Torque — exact bytes, a vector that matches a hand calculation, the formula written down in the comment.