cursedbelt-server 4.24.1 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { createLoginThrottle } from './loginThrottle.js';
3
+ import {
4
+ createRemoteLoginThrottle,
5
+ type DurableNamespaceLike,
6
+ type DurableStorageLike,
7
+ LOGIN_THROTTLE_OPERATIONS,
8
+ LoginThrottleObject,
9
+ } from './loginThrottleDurable.js';
10
+
11
+ /** A Durable Object's storage, as a Map that survives the object — which is what storage is for. */
12
+ const memoryStorage = (): DurableStorageLike & { data: Map<string, unknown> } => {
13
+ const data = new Map<string, unknown>();
14
+ return {
15
+ data,
16
+ // Structured clone, as the runtime does — an object that kept a live reference would pass
17
+ // a persistence test without persisting anything.
18
+ get: async <T>(key: string) => (data.has(key) ? (structuredClone(data.get(key)) as T) : undefined),
19
+ put: async (key, value) => {
20
+ data.set(key, structuredClone(value));
21
+ },
22
+ };
23
+ };
24
+
25
+ const clock = (start = 5_000_000) => {
26
+ let t = start;
27
+ return { now: () => t, advance: (ms: number) => (t += ms) };
28
+ };
29
+
30
+ /** A namespace whose ONE object can be swapped — `evict()` is the platform recycling it. */
31
+ const namespaceOver = (storage: DurableStorageLike, now: () => number) => {
32
+ let object = new LoginThrottleObject({ storage }, {}, { now, threshold: 2, globalThreshold: 4 });
33
+ const ns: DurableNamespaceLike & { evict(): void; calls: number } = {
34
+ calls: 0,
35
+ idFromName: (name) => name,
36
+ get: () => ({
37
+ fetch: (request: Request) => {
38
+ ns.calls += 1;
39
+ return object.fetch(request);
40
+ },
41
+ }),
42
+ evict() {
43
+ object = new LoginThrottleObject({ storage }, {}, { now, threshold: 2, globalThreshold: 4 });
44
+ },
45
+ };
46
+ return ns;
47
+ };
48
+
49
+ describe('LoginThrottleObject + createRemoteLoginThrottle', () => {
50
+ it('🔴 two isolates share ONE ledger — failures in one lock the other', async () => {
51
+ const c = clock();
52
+ const ns = namespaceOver(memoryStorage(), c.now);
53
+ // Two isolates: each builds its own client (per request, as a Worker does) over one binding.
54
+ const isolateA = createRemoteLoginThrottle(ns, { ledger: 'owner' });
55
+ const isolateB = createRemoteLoginThrottle(ns, { ledger: 'owner' });
56
+ for (let i = 0; i < 3; i++) await isolateA.recordFailure('203.0.113.9');
57
+ const seenByB = await isolateB.check('203.0.113.9');
58
+ expect(seenByB.allowed).toBe(false);
59
+ // …and the per-isolate version of the same thing does not, which is the bug this replaces.
60
+ const perIsolateA = createLoginThrottle({ now: c.now, threshold: 2 });
61
+ const perIsolateB = createLoginThrottle({ now: c.now, threshold: 2 });
62
+ for (let i = 0; i < 3; i++) perIsolateA.recordFailure('203.0.113.9');
63
+ expect(perIsolateB.check('203.0.113.9').allowed).toBe(true);
64
+ });
65
+
66
+ it('🔴 the ledger survives the object being evicted and rebuilt over the same storage', async () => {
67
+ const c = clock();
68
+ const storage = memoryStorage();
69
+ const ns = namespaceOver(storage, c.now);
70
+ const t = createRemoteLoginThrottle(ns, { ledger: 'owner' });
71
+ for (let i = 0; i < 3; i++) await t.recordFailure('192.0.2.44');
72
+ ns.evict();
73
+ expect((await t.check('192.0.2.44')).allowed).toBe(false);
74
+ // The global counter too: a fifth failure from anywhere arms the global delay (threshold 4).
75
+ await t.recordFailure('198.51.100.1');
76
+ ns.evict();
77
+ await t.recordFailure('198.51.100.2');
78
+ ns.evict();
79
+ const v = await t.check('a-fresh-address');
80
+ expect(v.allowed).toBe(false);
81
+ if (!v.allowed) expect(v.scope).toBe('global');
82
+ });
83
+
84
+ it('lockout after N failures, and a success clears it everywhere', async () => {
85
+ const c = clock();
86
+ const ns = namespaceOver(memoryStorage(), c.now);
87
+ const t = createRemoteLoginThrottle(ns, { ledger: 'owner' });
88
+ // threshold 2: attempts 1–3 admitted (the third arms), the fourth refused.
89
+ const verdicts = [];
90
+ for (let i = 0; i < 4; i++) verdicts.push(await t.attempt('ip'));
91
+ expect(verdicts.map((v) => v.allowed)).toEqual([true, true, true, false]);
92
+ c.advance(60_000);
93
+ expect((await t.attempt('ip')).allowed).toBe(true);
94
+ await t.recordSuccess('ip');
95
+ expect(await createRemoteLoginThrottle(ns, { ledger: 'owner' }).check('ip')).toEqual({ allowed: true });
96
+ });
97
+
98
+ it('🔴 a concurrent burst is admitted only up to the threshold, not all at once', async () => {
99
+ const c = clock();
100
+ const ns = namespaceOver(memoryStorage(), c.now);
101
+ const verdicts = await Promise.all(
102
+ Array.from({ length: 12 }, () => createRemoteLoginThrottle(ns, { ledger: 'owner' }).attempt('burst')),
103
+ );
104
+ expect(verdicts.filter((v) => v.allowed).length).toBe(3);
105
+ });
106
+
107
+ it('ledgers are independent — the probe never spends the owner', async () => {
108
+ const c = clock();
109
+ const ns = namespaceOver(memoryStorage(), c.now);
110
+ const probe = createRemoteLoginThrottle(ns, { ledger: 'probe' });
111
+ const owner = createRemoteLoginThrottle(ns, { ledger: 'owner' });
112
+ for (let i = 0; i < 20; i++) await probe.recordFailure('home');
113
+ expect((await probe.check('home')).allowed).toBe(false);
114
+ expect(await owner.attempt('home')).toEqual({ allowed: true });
115
+ });
116
+
117
+ it('an unreachable object degrades to the fallback, which was mirrored all along', async () => {
118
+ const c = clock();
119
+ const storage = memoryStorage();
120
+ const ns = namespaceOver(storage, c.now);
121
+ const fallback = createLoginThrottle({ now: c.now, threshold: 2, globalThreshold: 4 });
122
+ const lines: string[] = [];
123
+ const t = createRemoteLoginThrottle(ns, { ledger: 'owner', fallback, onError: (l) => lines.push(l) });
124
+ for (let i = 0; i < 3; i++) await t.attempt('ip');
125
+ // The object goes away. The fallback has seen all three charges, so it refuses the fourth
126
+ // instead of starting from zero.
127
+ const broken: DurableNamespaceLike = {
128
+ idFromName: (n) => n,
129
+ get: () => ({ fetch: async () => new Response('overloaded', { status: 503 }) }),
130
+ };
131
+ const degraded = createRemoteLoginThrottle(broken, { ledger: 'owner', fallback, onError: (l) => lines.push(l) });
132
+ expect((await degraded.attempt('ip')).allowed).toBe(false);
133
+ expect(lines.length).toBe(1);
134
+ expect(lines[0]).toContain('503');
135
+ // Without a fallback the error is the caller's, never a silent allow.
136
+ await expect(createRemoteLoginThrottle(broken).check('ip')).rejects.toThrow('503');
137
+ });
138
+
139
+ it('answers exactly LOGIN_THROTTLE_OPERATIONS, and refuses malformed input', async () => {
140
+ const object = new LoginThrottleObject({ storage: memoryStorage() });
141
+ const post = (path: string, body: unknown) =>
142
+ object.fetch(new Request(`https://x.invalid${path}`, { method: 'POST', body: JSON.stringify(body) }));
143
+ for (const [, path] of LOGIN_THROTTLE_OPERATIONS) {
144
+ expect([path, (await post(path, { ledger: 'owner', key: 'k' })).status]).toEqual([path, 200]);
145
+ }
146
+ expect((await post('/throttle/nope', { ledger: 'owner', key: 'k' })).status).toBe(404);
147
+ expect((await post('/throttle/check', { ledger: 'Not A Slug', key: 'k' })).status).toBe(400);
148
+ expect((await post('/throttle/check', { ledger: 'owner', key: 'x'.repeat(321) })).status).toBe(400);
149
+ expect((await post('/throttle/check', { ledger: 'owner' })).status).toBe(400);
150
+ expect((await object.fetch(new Request('https://x.invalid/throttle/check'))).status).toBe(405);
151
+ });
152
+
153
+ it('persists a bounded ledger, however many keys it has seen', async () => {
154
+ const storage = memoryStorage();
155
+ const object = new LoginThrottleObject({ storage }, {}, { globalThreshold: 1e9, maxKeys: 5000 });
156
+ const t = createRemoteLoginThrottle(
157
+ { idFromName: (n) => n, get: () => ({ fetch: (r) => object.fetch(r) }) },
158
+ { ledger: 'owner' },
159
+ );
160
+ for (let i = 0; i < 1100; i++) await t.recordFailure(`10.0.${Math.floor(i / 250)}.${i % 250}`);
161
+ const saved = storage.data.get('ledger:owner') as { entries: unknown[] };
162
+ expect(saved.entries.length).toBe(1024);
163
+ expect(JSON.stringify(saved).length).toBeLessThan(128 * 1024);
164
+ });
165
+ });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The login throttle on a Cloudflare Worker: ONE Durable Object holds the ledger, every isolate
3
+ * asks it.
4
+ *
5
+ * ── Why this exists (2026-09-23, patterns task 2136) ─────────────────────────
6
+ * `createLoginThrottle` keeps its counts in memory, and its header accepts that because on the
7
+ * Mac "memory" is the one process an attacker cannot restart. On a Worker there is no such
8
+ * process. `apps/patterns`' Worker first built the throttle per REQUEST — every attempt was a
9
+ * first attempt, so the sign-in had no failed-login brake at all — and then per ISOLATE, which
10
+ * is still one ledger per colo (often several per colo), each wiped by every deploy: a guesser
11
+ * spread across N isolates gets N × the global allowance the header calls "the actual defense".
12
+ *
13
+ * A Durable Object is the exact shape of the fix: one instance addressed by name, requests to it
14
+ * serialised, so every isolate in every colo meets the same counts. It runs the SAME
15
+ * `createLoginThrottle` algorithm — nothing here re-decides a threshold or a delay — and persists
16
+ * the ledger to the object's storage after every change, so the object's own eviction (minutes
17
+ * idle, measured on patterns' live object) forgets nothing either.
18
+ *
19
+ * ── Why not D1 ──────────────────────────────────────────────────────────────
20
+ * The throttle is checked BEFORE argon2, on every attempt, and the point of checking first is
21
+ * that a refused attempt is cheap. A D1 read-modify-write per attempt is a query against the
22
+ * invocation's 1,000-query budget and, worse, not atomic across concurrent invocations: two
23
+ * guesses that snapshot the same count both write count+1. The object serialises for free.
24
+ *
25
+ * ── `attempt` is one operation, not two ─────────────────────────────────────
26
+ * `/throttle/attempt` checks and charges in one serialised step, so a burst of concurrent guesses
27
+ * is admitted only up to the threshold rather than all at once against one stale count. See
28
+ * `loginThrottle.ts`'s `attempt` section.
29
+ *
30
+ * ── When the object cannot be reached ───────────────────────────────────────
31
+ * {@link createRemoteLoginThrottle} takes a `fallback` — the isolate's own module-scope
32
+ * throttle — and MIRRORS every write into it. The object is the authority whenever it answers;
33
+ * when a call throws or answers non-2xx, the fallback decides and the error is logged. So an
34
+ * outage degrades the door to exactly the per-isolate brake it had before, never to none, and
35
+ * never to a 500 the owner cannot sign in past. Without a `fallback` the error propagates.
36
+ *
37
+ * ── Types are structural ────────────────────────────────────────────────────
38
+ * No `@cloudflare/workers-types`: an app whose `src/server` also runs under Bun cannot take Worker
39
+ * globals over its whole type graph (patterns measured five broken `randomBytes(n)` calls the one
40
+ * time it tried). The object uses the classic `fetch` interface for the same reason.
41
+ */
42
+ import {
43
+ createLoginThrottle,
44
+ type AsyncLoginThrottle,
45
+ type LoginThrottle,
46
+ type LoginThrottleOptions,
47
+ type LoginThrottleSnapshot,
48
+ type ThrottleVerdict,
49
+ } from './loginThrottle.js';
50
+
51
+ /** The name every isolate addresses unless told otherwise — one object, one ledger set. */
52
+ export const LOGIN_THROTTLE_OBJECT_NAME = 'login-throttle';
53
+
54
+ /**
55
+ * Every operation the object answers, `[method, path]` — for an app's CPU-budget census, which
56
+ * must declare each path a tail can deliver. `loginThrottleDurable.spec.ts` reds if the object
57
+ * answers a path not listed here, or stops answering one that is.
58
+ */
59
+ export const LOGIN_THROTTLE_OPERATIONS = [
60
+ ['POST', '/throttle/check'],
61
+ ['POST', '/throttle/attempt'],
62
+ ['POST', '/throttle/fail'],
63
+ ['POST', '/throttle/ok'],
64
+ ['POST', '/throttle/reset'],
65
+ ] as const;
66
+
67
+ /**
68
+ * Keys persisted per ledger — the most recently seen. The in-memory table keeps its own
69
+ * `maxKeys` cap; this one keeps a stored value well under a Durable Object's per-value limit
70
+ * (128 KiB on the KV backend) at roughly 80 bytes a key. The global counter is always kept, and
71
+ * it is the counter a distributed guesser meets, so a key dropped here costs a courtesy layer
72
+ * only.
73
+ */
74
+ export const PERSISTED_KEYS = 1024;
75
+
76
+ /** A ledger is a short slug — the object keys its storage by it. */
77
+ const LEDGER = /^[a-z0-9][a-z0-9-]{0,31}$/;
78
+ /** Longer than any address or `<email>:<ip>`; a bound so a key cannot be a storage vector. */
79
+ const MAX_KEY_CHARS = 320;
80
+
81
+ /** The two members of `DurableObjectStorage` this reads — the KV API, on either backend. */
82
+ export interface DurableStorageLike {
83
+ get<T = unknown>(key: string): Promise<T | undefined>;
84
+ put<T>(key: string, value: T): Promise<void>;
85
+ }
86
+
87
+ /** The members of `DurableObjectState` this reads. */
88
+ export interface DurableStateLike {
89
+ storage: DurableStorageLike;
90
+ blockConcurrencyWhile?<T>(callback: () => Promise<T>): Promise<T>;
91
+ }
92
+
93
+ /** The members of a `DurableObjectNamespace` binding the client calls. */
94
+ export interface DurableNamespaceLike {
95
+ idFromName(name: string): unknown;
96
+ get(id: unknown): { fetch(request: Request): Promise<Response> };
97
+ }
98
+
99
+ const json = (value: unknown, status = 200): Response =>
100
+ new Response(JSON.stringify(value ?? null), {
101
+ status,
102
+ headers: { 'content-type': 'application/json' },
103
+ });
104
+
105
+ /**
106
+ * The Durable Object. Re-export it from the Worker's entry under the `class_name` your
107
+ * `wrangler.jsonc` binds, and add it in a migration (`new_sqlite_classes`):
108
+ *
109
+ * export { LoginThrottleObject as PatternsThrottle } from "cursedbelt-server/login-throttle/durable";
110
+ *
111
+ * One object holds any number of named LEDGERS (`owner`, `probe`, …), each an independent
112
+ * `createLoginThrottle` persisted under `ledger:<name>`.
113
+ */
114
+ export class LoginThrottleObject {
115
+ private readonly ledgers = new Map<string, Promise<LoginThrottle>>();
116
+ private readonly storage: DurableStorageLike;
117
+ private readonly options: LoginThrottleOptions;
118
+
119
+ /**
120
+ * `options` is for tests (a clock, a threshold); the runtime passes `(state, env)` only, and
121
+ * the throttle's own defaults are the fleet's.
122
+ */
123
+ constructor(state: DurableStateLike, _env?: unknown, options: LoginThrottleOptions = {}) {
124
+ this.storage = state.storage;
125
+ this.options = options;
126
+ }
127
+
128
+ /**
129
+ * The ledger, loaded once per object lifetime. The PROMISE is memoised, not the result, so two
130
+ * requests arriving while the first load is in flight share it rather than each restoring its
131
+ * own copy and one of them overwriting the other's charge.
132
+ */
133
+ private ledger(name: string): Promise<LoginThrottle> {
134
+ let loading = this.ledgers.get(name);
135
+ if (!loading) {
136
+ loading = this.storage
137
+ .get<LoginThrottleSnapshot>(`ledger:${name}`)
138
+ .then((restore) => createLoginThrottle({ ...this.options, restore: restore ?? null }));
139
+ // A failed load must not be cached as the ledger for the object's whole life.
140
+ loading.catch(() => this.ledgers.delete(name));
141
+ this.ledgers.set(name, loading);
142
+ }
143
+ return loading;
144
+ }
145
+
146
+ private persist(name: string, throttle: LoginThrottle): Promise<void> {
147
+ return this.storage.put(`ledger:${name}`, throttle.snapshot(PERSISTED_KEYS));
148
+ }
149
+
150
+ async fetch(request: Request): Promise<Response> {
151
+ const path = new URL(request.url).pathname;
152
+ if (request.method !== 'POST') return json({ error: `${request.method} ${path}: POST only` }, 405);
153
+ let body: { ledger?: unknown; key?: unknown };
154
+ try {
155
+ body = (await request.json()) as typeof body;
156
+ } catch {
157
+ return json({ error: 'body must be JSON {ledger, key}' }, 400);
158
+ }
159
+ const name = body?.ledger;
160
+ const key = body?.key;
161
+ if (typeof name !== 'string' || !LEDGER.test(name)) return json({ error: 'ledger must be a short slug' }, 400);
162
+ if (typeof key !== 'string' || key.length === 0 || key.length > MAX_KEY_CHARS) {
163
+ return json({ error: `key must be 1–${MAX_KEY_CHARS} characters` }, 400);
164
+ }
165
+
166
+ const throttle = await this.ledger(name);
167
+ switch (path) {
168
+ case '/throttle/check':
169
+ return json(throttle.check(key));
170
+ case '/throttle/attempt': {
171
+ const verdict = throttle.attempt(key);
172
+ // A refused attempt changed nothing; an admitted one was charged and must be kept.
173
+ if (verdict.allowed) await this.persist(name, throttle);
174
+ return json(verdict);
175
+ }
176
+ case '/throttle/fail': {
177
+ const verdict = throttle.recordFailure(key);
178
+ await this.persist(name, throttle);
179
+ return json(verdict);
180
+ }
181
+ case '/throttle/ok':
182
+ throttle.recordSuccess(key);
183
+ await this.persist(name, throttle);
184
+ return json({ ok: true });
185
+ case '/throttle/reset':
186
+ throttle.reset(key);
187
+ await this.persist(name, throttle);
188
+ return json({ ok: true });
189
+ default:
190
+ return json({ error: `no such throttle operation ${path}` }, 404);
191
+ }
192
+ }
193
+ }
194
+
195
+ export interface RemoteLoginThrottleOptions {
196
+ /** Which ledger in the object. Default `"default"`. A second ledger never spends the first's. */
197
+ ledger?: string;
198
+ /** Which object. Default {@link LOGIN_THROTTLE_OBJECT_NAME}. */
199
+ objectName?: string;
200
+ /**
201
+ * The isolate's own throttle, at MODULE scope — mirrored on every write and consulted only when
202
+ * the object cannot answer. See the header. Omitted → an unreachable object is an error.
203
+ */
204
+ fallback?: LoginThrottle;
205
+ /** Where an unreachable-object line goes. Default `console.error`. */
206
+ onError?: (line: string) => void;
207
+ }
208
+
209
+ /**
210
+ * The client half: an {@link AsyncLoginThrottle} whose ledger lives in the object. Cheap to build —
211
+ * build it per request over `env.<BINDING>`; the state is on the other side of the call.
212
+ */
213
+ export function createRemoteLoginThrottle(
214
+ namespace: DurableNamespaceLike,
215
+ options: RemoteLoginThrottleOptions = {},
216
+ ): AsyncLoginThrottle {
217
+ const ledger = options.ledger ?? 'default';
218
+ if (!LEDGER.test(ledger)) throw new Error(`login throttle ledger "${ledger}" must be a short slug`);
219
+ const objectName = options.objectName ?? LOGIN_THROTTLE_OBJECT_NAME;
220
+ const fallback = options.fallback;
221
+ const onError = options.onError ?? ((line: string) => console.error(line));
222
+
223
+ const call = async <T>(path: string, key: string): Promise<T> => {
224
+ const stub = namespace.get(namespace.idFromName(objectName));
225
+ const response = await stub.fetch(
226
+ new Request(`https://login-throttle.invalid${path}`, {
227
+ method: 'POST',
228
+ headers: { 'content-type': 'application/json' },
229
+ body: JSON.stringify({ ledger, key }),
230
+ }),
231
+ );
232
+ if (!response.ok) throw new Error(`${path} answered ${response.status}: ${await response.text()}`);
233
+ return (await response.json()) as T;
234
+ };
235
+
236
+ /** Ask the object; on failure, let the fallback answer (or rethrow when there is none). */
237
+ const remote = async <T>(path: string, key: string, local: (() => T) | undefined): Promise<T> => {
238
+ try {
239
+ return await call<T>(path, key);
240
+ } catch (error) {
241
+ if (!local) throw error;
242
+ onError(
243
+ `[login-throttle] the "${objectName}" object did not answer ${path} (${error instanceof Error ? error.message : String(error)}) — ` +
244
+ `this isolate's own ledger decides, which is per-isolate only`,
245
+ );
246
+ return local();
247
+ }
248
+ };
249
+
250
+ return {
251
+ check: (key) => remote<ThrottleVerdict>('/throttle/check', key, fallback && (() => fallback.check(key))),
252
+ async attempt(key) {
253
+ let mirrored = false;
254
+ const verdict = await remote<ThrottleVerdict>(
255
+ '/throttle/attempt',
256
+ key,
257
+ fallback &&
258
+ (() => {
259
+ mirrored = true;
260
+ return fallback.attempt(key);
261
+ }),
262
+ );
263
+ // The mirror: an admitted attempt the object charged is charged here too, so an outage
264
+ // later in this isolate's life starts from what it has seen rather than from zero.
265
+ if (!mirrored && verdict.allowed) fallback?.recordFailure(key);
266
+ return verdict;
267
+ },
268
+ async recordFailure(key) {
269
+ let mirrored = false;
270
+ const verdict = await remote<ThrottleVerdict>(
271
+ '/throttle/fail',
272
+ key,
273
+ fallback &&
274
+ (() => {
275
+ mirrored = true;
276
+ return fallback.recordFailure(key);
277
+ }),
278
+ );
279
+ if (!mirrored) fallback?.recordFailure(key);
280
+ return verdict;
281
+ },
282
+ async recordSuccess(key) {
283
+ fallback?.recordSuccess(key);
284
+ await remote<unknown>('/throttle/ok', key, fallback && (() => null));
285
+ },
286
+ async reset(key) {
287
+ fallback?.reset(key);
288
+ await remote<unknown>('/throttle/reset', key, fallback && (() => null));
289
+ },
290
+ };
291
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 🔴 The guess throttle is CHARGED before the verify (task 2137) — a burst of guesses landing
3
+ * inside one argon2id verify is not judged against one stale count.
4
+ *
5
+ * Proved two ways, because the lock has two ledgers: its own fields (one process — the Mac's
6
+ * daemons, where a burst is K concurrent `unlock` calls on one instance) and a D1 row (a Worker,
7
+ * where every request builds its own lock over a snapshot and only D1 is shared). Both were run
8
+ * against the pre-fix `MasterLock` and failed: 12 of 12 verified, and on the per-request shape
9
+ * the burst was recorded as ONE failure.
10
+ */
11
+ import { Database } from "bun:sqlite";
12
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
13
+ import { type MasterLockKdfParams, delayAfter, deriveMasterLockVerifier } from "cursedbelt-core/master-lock";
14
+ import { createRemoteD1 } from "../d1/remote.js";
15
+ import { createFakeD1Binding } from "../d1/fakeD1.js";
16
+ import type { D1LikeDatabase } from "../d1/types.js";
17
+ import { MASTER_LOCK_ATTEMPTS_KEY, createD1MasterLockAttempts, masterLockDelaySql } from "./attempts.js";
18
+ import { MasterLock } from "./masterLock.js";
19
+ import { createMemoryMasterLockStore } from "./store.js";
20
+
21
+ const KDF: MasterLockKdfParams = { v: 1, alg: "PBKDF2-SHA256", iter: 1, salt: "AAECAwQFBgcICQoLDA0ODw" };
22
+ const BURST = 12;
23
+ /** Five free, and the sixth is evaluated before the first wait — the same as one at a time. */
24
+ const ADMITTED = 6;
25
+
26
+ let VERIFIER = "";
27
+ let HASH = "";
28
+ let SEED = "";
29
+ beforeAll(async () => {
30
+ VERIFIER = await deriveMasterLockVerifier("the owner's master password", KDF);
31
+ HASH = await Bun.password.hash(VERIFIER, { algorithm: "argon2id", memoryCost: 4096, timeCost: 1 });
32
+ SEED = JSON.stringify({ kdf: KDF, verifierHash: HASH });
33
+ });
34
+
35
+ /** How many verifies reached the REAL hash — a guess that was evaluated, not refused. */
36
+ const realVerify = Bun.password.verify;
37
+ let evaluated = 0;
38
+ function countEvaluations(): void {
39
+ evaluated = 0;
40
+ Bun.password.verify = ((candidate: string, hash: string) => {
41
+ if (hash === HASH) evaluated += 1;
42
+ return realVerify(candidate, hash);
43
+ }) as typeof Bun.password.verify;
44
+ }
45
+ afterEach(() => {
46
+ Bun.password.verify = realVerify;
47
+ });
48
+
49
+ function d1(): { db: D1LikeDatabase; sqlite: Database } {
50
+ const sqlite = new Database(":memory:");
51
+ sqlite.exec("CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL )");
52
+ return { db: createRemoteD1(createFakeD1Binding(sqlite)), sqlite };
53
+ }
54
+ const stored = (sqlite: Database) =>
55
+ JSON.parse(
56
+ (sqlite.query("SELECT value FROM meta WHERE key = ?").get(MASTER_LOCK_ATTEMPTS_KEY) as { value: string } | null)?.value ?? "{}",
57
+ ) as { failures?: number; lastFailureAt?: number };
58
+
59
+ describe("🔴 a concurrent burst of wrong verifiers", () => {
60
+ test("one process, one instance: 12 at once → 6 evaluated, the rest refused", async () => {
61
+ const lock = new MasterLock({ store: createMemoryMasterLockStore(null), seedJson: SEED, now: () => 1_000_000 });
62
+ countEvaluations();
63
+ const answers = await Promise.all(Array.from({ length: BURST }, () => lock.unlock("a wrong guess")));
64
+ expect(answers.every((a) => !a.ok)).toBe(true);
65
+ expect(evaluated).toBe(ADMITTED);
66
+ expect(lock.status().retryAfterMs).toBe(delayAfter(ADMITTED));
67
+ });
68
+
69
+ test("a Worker: a fresh lock per request over one D1 → 6 evaluated, and all 6 are RECORDED", async () => {
70
+ const { db, sqlite } = d1();
71
+ const store = createMemoryMasterLockStore(null);
72
+ const state = createMemoryMasterLockStore(null);
73
+ const now = 1_000_000;
74
+ const request = () => new MasterLock({ store, state, seedJson: SEED, now: () => now, attempts: createD1MasterLockAttempts(db) });
75
+ countEvaluations();
76
+ const answers = await Promise.all(Array.from({ length: BURST }, () => request().unlock("a wrong guess")));
77
+ expect(answers.every((a) => !a.ok)).toBe(true);
78
+ expect(evaluated).toBe(ADMITTED);
79
+ // Recorded = evaluated: never the lost update that counted a burst as one guess.
80
+ expect(stored(sqlite)).toEqual({ failures: ADMITTED, lastFailureAt: now });
81
+ // A failed guess no longer rewrites the live state, so it cannot drop an open unlock.
82
+ expect(state.read()).toBeNull();
83
+ });
84
+
85
+ test("after the window, the owner's ONE correct attempt opens it and ends the run", async () => {
86
+ const { db, sqlite } = d1();
87
+ const store = createMemoryMasterLockStore(null);
88
+ const state = createMemoryMasterLockStore(null);
89
+ let now = 1_000_000;
90
+ const request = () => new MasterLock({ store, state, seedJson: SEED, now: () => now, attempts: createD1MasterLockAttempts(db) });
91
+ await Promise.all(Array.from({ length: BURST }, () => request().unlock("a wrong guess")));
92
+ // Inside the window the right verifier is refused too — and charges nothing.
93
+ const early = await request().unlock(VERIFIER);
94
+ expect(early).toEqual({ ok: false, retryAfterMs: delayAfter(ADMITTED) });
95
+ expect(stored(sqlite).failures).toBe(ADMITTED);
96
+ now += delayAfter(ADMITTED);
97
+ const opened = await request().unlock(VERIFIER);
98
+ expect(opened.ok).toBe(true);
99
+ expect(stored(sqlite)).toEqual({});
100
+ if (opened.ok) expect(request().presents(new Request("http://x/", { headers: { "x-master-lock": opened.token } }))).toBe(true);
101
+ });
102
+ });
103
+
104
+ describe("the D1 ledger", () => {
105
+ test("the SQL schedule IS delayAfter — every count from 0 past the cap", () => {
106
+ const sqlite = new Database(":memory:");
107
+ for (let n = 0; n <= 40; n++) {
108
+ const row = sqlite.query(`SELECT ${masterLockDelaySql("?1")} AS d`).get(n) as { d: number };
109
+ expect(`${n} → ${row.d}`).toBe(`${n} → ${delayAfter(n)}`);
110
+ }
111
+ });
112
+
113
+ test("a charge is refused inside the window, admitted after it, and only admitted ones count", async () => {
114
+ const { db } = d1();
115
+ const ledger = createD1MasterLockAttempts(db);
116
+ for (let i = 1; i <= ADMITTED; i++) expect(await ledger.charge(10_000)).toEqual({ admitted: true, failures: i });
117
+ expect(await ledger.charge(10_000)).toEqual({ admitted: false, retryAfterMs: delayAfter(ADMITTED) });
118
+ expect(await ledger.charge(10_000 + delayAfter(ADMITTED) - 1)).toEqual({ admitted: false, retryAfterMs: 1 });
119
+ expect(await ledger.charge(10_000 + delayAfter(ADMITTED))).toEqual({ admitted: true, failures: ADMITTED + 1 });
120
+ });
121
+
122
+ test("refund takes one back; clear ends the run; a snapshot feeds the countdown only", async () => {
123
+ const { db, sqlite } = d1();
124
+ const ledger = createD1MasterLockAttempts(db);
125
+ await ledger.charge(5);
126
+ await ledger.charge(5);
127
+ await ledger.refund();
128
+ expect(stored(sqlite)).toEqual({ failures: 1, lastFailureAt: 5 });
129
+ await ledger.clear();
130
+ expect(stored(sqlite)).toEqual({});
131
+ const snap = JSON.stringify({ failures: ADMITTED, lastFailureAt: 100 });
132
+ expect(createD1MasterLockAttempts(db, { snapshot: snap }).retryAfterMs(100)).toBe(delayAfter(ADMITTED));
133
+ });
134
+
135
+ test("an unreadable row reads as no failures — never a lock nothing can open", async () => {
136
+ const { db, sqlite } = d1();
137
+ sqlite.run("INSERT INTO meta (key, value) VALUES (?, 'not json')", [MASTER_LOCK_ATTEMPTS_KEY]);
138
+ expect(await createD1MasterLockAttempts(db).charge(1)).toEqual({ admitted: true, failures: 1 });
139
+ });
140
+
141
+ test("🔴 a ledger that cannot charge refuses the unlock — nothing is verified uncharged", async () => {
142
+ const failing = {
143
+ retryAfterMs: () => 0,
144
+ charge: async () => {
145
+ throw new Error("D1 unavailable");
146
+ },
147
+ refund: () => {},
148
+ clear: () => {},
149
+ };
150
+ const lock = new MasterLock({ store: createMemoryMasterLockStore(null), seedJson: SEED, attempts: failing });
151
+ countEvaluations();
152
+ await expect(lock.unlock(VERIFIER)).rejects.toThrow("D1 unavailable");
153
+ expect(evaluated).toBe(0);
154
+ });
155
+
156
+ test("a table name that is not an identifier is refused", () => {
157
+ expect(() => createD1MasterLockAttempts(d1().db, { table: "meta; DROP TABLE meta" })).toThrow("not a table name");
158
+ });
159
+ });