From b39679954c7917a228049d08fe030e0598416d69 Mon Sep 17 00:00:00 2001 From: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:01:16 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(nps-relay):=20release=20?= =?UTF-8?q?the=20per-IP=20slot=20and=20day=20count=20when=20the=20create-o?= =?UTF-8?q?nly=20registration=20write=20loses=20the=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- .../fixed-nps-relay-lost-create-race.md | 3 ++ netlify/nps-relay/relay.test.ts | 28 +++++++++++++++++++ netlify/nps-relay/relay.ts | 9 ++++++ 3 files changed, 40 insertions(+) create mode 100644 changelog.d/fixed-nps-relay-lost-create-race.md diff --git a/changelog.d/fixed-nps-relay-lost-create-race.md b/changelog.d/fixed-nps-relay-lost-create-race.md new file mode 100644 index 0000000..17e5d62 --- /dev/null +++ b/changelog.d/fixed-nps-relay-lost-create-race.md @@ -0,0 +1,3 @@ +- The NPS relay no longer consumes the per-IP registration slot or the daily + registration count when a racing registration loses the create-only write + (hivecommons/docs#559). diff --git a/netlify/nps-relay/relay.test.ts b/netlify/nps-relay/relay.test.ts index b0fa620..c0130b0 100644 --- a/netlify/nps-relay/relay.test.ts +++ b/netlify/nps-relay/relay.test.ts @@ -490,6 +490,34 @@ describe("POST /api/nps/register", () => { expect((await handleRelayRequest(register(), deps())).status).toBe(200); }); + it("releases the per-IP slot and day count when the create-only write loses the race", async () => { + const dayKey = `${REGISTER_DAY_PREFIX}${new Date(now).toISOString().slice(0, 10)}`; + const losing: RelayStore = { + ...store, + get: k => store.get(k), + getWithMetadata: k => store.getWithMetadata(k), + delete: k => store.delete(k), + list: o => store.list(o), + setJSON: async (k, v, o) => + k.startsWith(INSTALL_PREFIX) && o?.onlyIfNew + ? { modified: false } + : store.setJSON(k, v, o), + }; + const res = await handleRelayRequest( + register(), + deps({ store: losing }) + ); + expect(res.status).toBe(409); + expect(store.data.get(dayKey)).toEqual({ count: 0 }); + const ipRecords = [...store.data.entries()].filter(([k]) => + k.startsWith(REGISTER_IP_RATE_PREFIX) + ); + expect(ipRecords.length).toBeGreaterThan(0); + for (const [, rec] of ipRecords) { + expect((rec as { times: number[] }).times).toEqual([]); + } + }); + it("releases the per-IP slot when the day cap refuses the registration", async () => { const dayKey = `${REGISTER_DAY_PREFIX}${new Date(now).toISOString().slice(0, 10)}`; store.data.set(dayKey, { count: MAX_REGISTRATIONS_PER_DAY }); diff --git a/netlify/nps-relay/relay.ts b/netlify/nps-relay/relay.ts index 57bcf1c..8056e52 100644 --- a/netlify/nps-relay/relay.ts +++ b/netlify/nps-relay/relay.ts @@ -781,6 +781,15 @@ async function handleRegister( { onlyIfNew: true } ); if (!created.modified) { + // The registration was not created here, so give back both reservations. + await releaseSlot(store, ipKey, now); + await compareAndSwap(store, dayKey, current => { + const count = + current && typeof (current as DayCounter).count === "number" + ? (current as DayCounter).count + : 0; + return { next: { count: Math.max(0, count - 1) } satisfies DayCounter }; + }); const winner = await getInstall(store, reg.install_id); if (winner && winner.public_key === reg.public_key) return json(200, { ok: true, registered: false }); From e028429a786fc8bc1113b275a9215dcaa33e8cfe Mon Sep 17 00:00:00 2001 From: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:11:39 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8C=B1=20chore(nps-relay):=20format?= =?UTF-8?q?=20the=20lost-race=20test=20to=20the=20repository=20prettier=20?= =?UTF-8?q?config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- netlify/nps-relay/relay.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netlify/nps-relay/relay.test.ts b/netlify/nps-relay/relay.test.ts index c0130b0..59bab3a 100644 --- a/netlify/nps-relay/relay.test.ts +++ b/netlify/nps-relay/relay.test.ts @@ -503,10 +503,7 @@ describe("POST /api/nps/register", () => { ? { modified: false } : store.setJSON(k, v, o), }; - const res = await handleRelayRequest( - register(), - deps({ store: losing }) - ); + const res = await handleRelayRequest(register(), deps({ store: losing })); expect(res.status).toBe(409); expect(store.data.get(dayKey)).toEqual({ count: 0 }); const ipRecords = [...store.data.entries()].filter(([k]) =>