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..59bab3a 100644 --- a/netlify/nps-relay/relay.test.ts +++ b/netlify/nps-relay/relay.test.ts @@ -490,6 +490,31 @@ 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 });