A nonce seen twice is a replayed request, and the store that remembers it is its own small thing, buildable now: src/sign.ts is on main and says what a nonce may look like.
Done looks like
[ ] A migration appended to migrations in src/db.ts (never edit one that has run): nonce (app_id TEXT NOT NULL, nonce TEXT NOT NULL, seen_at TEXT NOT NULL DEFAULT (strftime(...)), PRIMARY KEY (app_id, nonce)) WITHOUT ROWID, and an index on seen_at for the sweep.
[ ] src/nonce.ts exports remember(db, appId, nonce, now?) returning "fresh" | "replay" | "malformed". It is one statement — INSERT ... ON CONFLICT DO NOTHING and changes() — never a read followed by a write, because the terminal is a second process on the same file and two posts can race.
[ ] appId and nonce are checked against the patterns src/sign.ts already defines; export them from sign.ts rather than spelling them a second time. A value that fails is "malformed" and nothing is inserted.
[ ] sweep(db, now?, ttlMs = 600_000) deletes rows older than the ttl and returns how many it removed. It is cheap enough to call after every response, and the module keeps its own clock so it does the work at most once a minute no matter how often it is called — the caller does not have to remember.
[ ] The ttl is longer than the 300-second signature window, and a test asserts that relation directly (TTL_MS > WINDOW_MS) with a comment saying why: once a nonce is swept, a replay of that request is already stale, so forgetting it is safe.
[ ] src/nonce.test.ts: the same nonce twice is replay; two different apps may use the same nonce; a nonce swept past the ttl is fresh again; a malformed nonce inserts nothing; the sweep of ten thousand rows is one statement; and remember called twice in a row on the same connection leaves exactly one row.
[ ] No route changes and no new dependency: #8 wires this into the door.
Files:src/db.ts, src/nonce.ts, src/nonce.test.ts, src/sign.ts (exporting the two patterns)
Risks: the migration append collides textually with the ones in #4 and #7 — a one-line rebase, and the order does not matter as long as none is edited after it has run. A sweep on the request path is a stall; do it after the response. A ttl shorter than the signature window would make replay possible, which is why it is asserted rather than assumed.
Suggested: Scout — one table, two functions, a test that proves the race is not there.
A nonce seen twice is a replayed request, and the store that remembers it is its own small thing, buildable now:
src/sign.tsis on main and says what a nonce may look like.Done looks like
migrationsinsrc/db.ts(never edit one that has run):nonce(app_id TEXT NOT NULL, nonce TEXT NOT NULL, seen_at TEXT NOT NULL DEFAULT (strftime(...)), PRIMARY KEY (app_id, nonce)) WITHOUT ROWID, and an index onseen_atfor the sweep.src/nonce.tsexportsremember(db, appId, nonce, now?)returning"fresh" | "replay" | "malformed". It is one statement —INSERT ... ON CONFLICT DO NOTHINGandchanges()— never a read followed by a write, because the terminal is a second process on the same file and two posts can race.appIdandnonceare checked against the patternssrc/sign.tsalready defines; export them fromsign.tsrather than spelling them a second time. A value that fails is"malformed"and nothing is inserted.sweep(db, now?, ttlMs = 600_000)deletes rows older than the ttl and returns how many it removed. It is cheap enough to call after every response, and the module keeps its own clock so it does the work at most once a minute no matter how often it is called — the caller does not have to remember.TTL_MS > WINDOW_MS) with a comment saying why: once a nonce is swept, a replay of that request is alreadystale, so forgetting it is safe.src/nonce.test.ts: the same nonce twice isreplay; two different apps may use the same nonce; a nonce swept past the ttl isfreshagain; a malformed nonce inserts nothing; the sweep of ten thousand rows is one statement; andremembercalled twice in a row on the same connection leaves exactly one row.Files:
src/db.ts,src/nonce.ts,src/nonce.test.ts,src/sign.ts(exporting the two patterns)Risks: the migration append collides textually with the ones in #4 and #7 — a one-line rebase, and the order does not matter as long as none is edited after it has run. A sweep on the request path is a stall; do it after the response. A ttl shorter than the signature window would make replay possible, which is why it is asserted rather than assumed.
Suggested: Scout — one table, two functions, a test that proves the race is not there.