lambder 8.1.2 → 8.3.1

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +8 -7
  3. package/dist/api/LambderApiGuards.d.ts +2 -17
  4. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  5. package/dist/build/generatedTables.d.ts +72 -0
  6. package/dist/build/generatedTables.js +99 -0
  7. package/dist/build/writeApiGuardParams.d.ts +60 -0
  8. package/dist/build/writeApiGuardParams.js +85 -0
  9. package/dist/build/writeApiOptions.d.ts +68 -0
  10. package/dist/build/writeApiOptions.js +102 -0
  11. package/dist/build.d.ts +10 -4
  12. package/dist/build.js +7 -4
  13. package/dist/client/LambderUploadRunner.d.ts +7 -7
  14. package/dist/client/LambderUploadRunner.js +12 -21
  15. package/dist/client.d.ts +7 -0
  16. package/dist/client.js +11 -0
  17. package/dist/core/Lambder.d.ts +21 -0
  18. package/dist/core/Lambder.js +69 -0
  19. package/dist/index.d.ts +13 -0
  20. package/dist/index.js +13 -0
  21. package/dist/mock/LambderMockApp.d.ts +34 -17
  22. package/dist/mock/LambderMockApp.js +67 -21
  23. package/dist/mock/LambderMockCreateOptions.d.ts +68 -5
  24. package/dist/mock/LambderMockTypes.d.ts +29 -10
  25. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  26. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  27. package/dist/mock.d.ts +3 -0
  28. package/dist/mock.js +3 -0
  29. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  30. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  31. package/dist/session/LambderSessionCrypto.js +6 -16
  32. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  33. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  34. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  35. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  36. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  37. package/dist/shared/util/LambderBase64.d.ts +14 -0
  38. package/dist/shared/util/LambderBase64.js +17 -0
  39. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  40. package/dist/shared/util/LambderSignedClaims.js +109 -0
  41. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  42. package/dist/shared/util/LambderTextDigest.js +30 -5
  43. package/dist/shared/util/assertPlainData.d.ts +9 -0
  44. package/dist/shared/util/assertPlainData.js +41 -0
  45. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  46. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  47. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  48. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  49. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  50. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  51. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  52. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  53. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  54. package/dist/testing/LambderConformanceRunner.js +21 -0
  55. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  56. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  57. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  58. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  59. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  60. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  61. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  62. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  63. package/dist/testing.d.ts +14 -0
  64. package/dist/testing.js +12 -0
  65. package/package.json +1 -1
@@ -0,0 +1,237 @@
1
+ import { CONFORMANCE_START_MILLIS as START, conformanceClock, } from "./LambderConformanceRunner.js";
2
+ /**
3
+ * Registers the idempotency store rules as cases of the runner, one `it`
4
+ * each, against the store `create` builds:
5
+ *
6
+ * ```ts
7
+ * import { describe, it, expect } from "vitest";
8
+ * import { lambderIdempotencyStoreConformance } from "lambder/testing";
9
+ *
10
+ * describe("OrderIdempotencyStore", () => {
11
+ * lambderIdempotencyStoreConformance({
12
+ * it, expect,
13
+ * create: ({ now }) => new OrderIdempotencyStore({ pool, now }),
14
+ * oversizedBody: "x".repeat(2_000_000),
15
+ * largestStorableBody: "x".repeat(1_000_000),
16
+ * });
17
+ * });
18
+ * ```
19
+ */
20
+ export const lambderIdempotencyStoreConformance = (options) => {
21
+ const { it, expect, oversizedBody, largestStorableBody } = options;
22
+ const answer = { statusCode: 201, headers: { "Content-Type": ["application/json"] }, body: '{"ok":true}', fingerprint: "request-1", ttlSeconds: 60 };
23
+ /** A fresh store and the clock it reads. */
24
+ const begin = async () => {
25
+ const clock = conformanceClock();
26
+ return { clock, store: await options.create(clock) };
27
+ };
28
+ /**
29
+ * A granted claim's owner token, or a failure. Every rule below is about
30
+ * what happens AFTER a claim is granted, so a rule that returned early on
31
+ * a claim that was not new would pass on a store that grants nothing.
32
+ */
33
+ const claimNew = async (store, scopeKey, pendingTtlSeconds = 60) => {
34
+ const claim = await store.begin(scopeKey, { pendingTtlSeconds, fingerprint: "request-1" });
35
+ if (claim.state !== "new")
36
+ throw new Error(`expected a new claim on "${scopeKey}", got "${claim.state}"`);
37
+ return claim.ownerToken;
38
+ };
39
+ it("keeps the request fingerprint through the claim and the settled record, so the engine can tell a retry from another request", async () => {
40
+ const { store } = await begin();
41
+ const owner = await claimNew(store, "scope-f");
42
+ expect(await store.begin("scope-f", { pendingTtlSeconds: 60, fingerprint: "request-2" })).toEqual({ state: "pending", fingerprint: "request-1" });
43
+ expect(await store.complete("scope-f", owner, { ...answer, fingerprint: "request-1" })).toBe("stored");
44
+ expect(await store.peek("scope-f")).toMatchObject({ statusCode: 201, fingerprint: "request-1" });
45
+ expect(await store.begin("scope-f", { pendingTtlSeconds: 60, fingerprint: "request-2" })).toMatchObject({ state: "done", fingerprint: "request-1" });
46
+ });
47
+ it("claims a free scope, refuses a concurrent claim, and hides the record until it is settled", async () => {
48
+ const { store } = await begin();
49
+ const ownerToken = await claimNew(store, "s");
50
+ expect(ownerToken).toBeTruthy();
51
+ expect((await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" })).state).toBe("pending");
52
+ expect(await store.peek("s")).toBeNull();
53
+ });
54
+ it("settles by the owner, then replays the answer through both peek and begin", async () => {
55
+ const { store } = await begin();
56
+ const ownerToken = await claimNew(store, "s");
57
+ expect(await store.complete("s", ownerToken, answer)).toBe("stored");
58
+ const peeked = await store.peek("s");
59
+ expect(peeked).toEqual({ statusCode: 201, headers: { "Content-Type": ["application/json"] }, body: '{"ok":true}', fingerprint: "request-1" });
60
+ const begun = await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" });
61
+ expect(begun).toMatchObject({ state: "done", statusCode: 201, body: '{"ok":true}' });
62
+ });
63
+ it("hands back a copy, so a caller writing onto what it read cannot rewrite the record", async () => {
64
+ // The pipeline applies the replaying call's own headers onto the answer
65
+ // it got back. A store that returns its own map lets one call's
66
+ // Set-Cookie become part of the record and reach every later replay.
67
+ const { store } = await begin();
68
+ const ownerToken = await claimNew(store, "s");
69
+ await store.complete("s", ownerToken, answer);
70
+ const peeked = await store.peek("s");
71
+ peeked.headers["Set-Cookie"] = ["sid=; Max-Age=0"];
72
+ const begun = await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" });
73
+ if (begun.state === "done")
74
+ begun.headers["Set-Cookie"] = ["sid=; Max-Age=0"];
75
+ expect((await store.peek("s"))?.headers["Set-Cookie"]).toBeUndefined();
76
+ });
77
+ it("reports a settle from a non-owner as lost, and writes nothing", async () => {
78
+ const { store } = await begin();
79
+ await claimNew(store, "s");
80
+ expect(await store.complete("s", "not-the-owner", answer)).toBe("lost");
81
+ expect(await store.peek("s")).toBeNull();
82
+ });
83
+ it("reports a body it cannot hold as too-large, and writes nothing", async () => {
84
+ const { store } = await begin();
85
+ const ownerToken = await claimNew(store, "s");
86
+ expect(await store.complete("s", ownerToken, { ...answer, body: oversizedBody })).toBe("too-large");
87
+ expect(await store.peek("s")).toBeNull();
88
+ // The claim survives, so the caller can release it and let retries run.
89
+ await store.abandon("s", ownerToken);
90
+ expect((await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" })).state).toBe("new");
91
+ });
92
+ it("decides size before ownership, the only order a single conditional write allows", async () => {
93
+ const { store } = await begin();
94
+ await claimNew(store, "s");
95
+ // A store settles in one write, so the only thing it can judge before
96
+ // reaching the table is whether the body fits. Both answers are safe
97
+ // for the caller; what matters is that every store gives the same one.
98
+ expect(await store.complete("s", "not-the-owner", { ...answer, body: oversizedBody })).toBe("too-large");
99
+ });
100
+ it("releases a claim only for its owner", async () => {
101
+ const { store } = await begin();
102
+ const ownerToken = await claimNew(store, "s");
103
+ await store.abandon("s", "not-the-owner");
104
+ expect((await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" })).state).toBe("pending");
105
+ await store.abandon("s", ownerToken);
106
+ expect((await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" })).state).toBe("new");
107
+ });
108
+ it("lets a pending claim expire, so a crashed original does not block retries forever", async () => {
109
+ const { clock, store } = await begin();
110
+ await claimNew(store, "s", 30);
111
+ clock.set(START + 31_000);
112
+ expect((await store.begin("s", { pendingTtlSeconds: 30, fingerprint: "request-1" })).state).toBe("new");
113
+ });
114
+ it("reports an owner settling after its own claim expired as lost", async () => {
115
+ // A claim that ran out is a claim the owner does not hold, whatever
116
+ // the storage does about it. DynamoDB's TTL deletion is lazy, so a
117
+ // store that checked only the owner token would usually still find
118
+ // the item and say "stored", and the answer would depend on whether
119
+ // AWS had swept yet.
120
+ const { clock, store } = await begin();
121
+ const ownerToken = await claimNew(store, "s", 30);
122
+ clock.set(START + 31_000);
123
+ expect(await store.complete("s", ownerToken, answer)).toBe("lost");
124
+ expect(await store.peek("s")).toBeNull();
125
+ });
126
+ it("stops replaying a settled record once its ttl runs out", async () => {
127
+ const { clock, store } = await begin();
128
+ const ownerToken = await claimNew(store, "s");
129
+ await store.complete("s", ownerToken, { ...answer, ttlSeconds: 60 });
130
+ clock.set(START + 61_000);
131
+ expect(await store.peek("s")).toBeNull();
132
+ expect((await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" })).state).toBe("new");
133
+ });
134
+ it("replays an empty body", async () => {
135
+ // A 204, or a 200 with an empty body, is an answer like any other, and
136
+ // the replay has to be the same answer. A compressed empty body must
137
+ // not declare a length the codec refuses on the way back: peek and
138
+ // begin would throw for the record's whole TTL, the engine would fail
139
+ // open on each, and every retry would execute again.
140
+ const { store } = await begin();
141
+ const ownerToken = await claimNew(store, "s");
142
+ expect(await store.complete("s", ownerToken, { ...answer, statusCode: 204, body: "" })).toBe("stored");
143
+ expect(await store.peek("s")).toEqual({ statusCode: 204, headers: { "Content-Type": ["application/json"] }, body: "", fingerprint: "request-1" });
144
+ expect(await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" })).toMatchObject({ state: "done", statusCode: 204, body: "" });
145
+ });
146
+ it("keeps scopes apart", async () => {
147
+ const { store } = await begin();
148
+ const ownerToken = await claimNew(store, "a");
149
+ await store.complete("a", ownerToken, answer);
150
+ expect(await store.peek("b")).toBeNull();
151
+ expect((await store.begin("b", { pendingTtlSeconds: 60, fingerprint: "request-1" })).state).toBe("new");
152
+ });
153
+ it("takes a copy of what it is given, so a caller writing onto the record afterwards cannot rewrite it", async () => {
154
+ // The other half of the copy rule. The pipeline hands complete() the
155
+ // answer object and goes on writing the call's own headers into it
156
+ // afterwards, so a store that kept the caller's map would take one
157
+ // request's Set-Cookie into the stored record and replay it to
158
+ // everybody else.
159
+ const { store } = await begin();
160
+ const ownerToken = await claimNew(store, "s");
161
+ const record = { statusCode: 201, headers: { "Content-Type": ["application/json"] }, body: '{"ok":true}', fingerprint: "request-1", ttlSeconds: 60 };
162
+ expect(await store.complete("s", ownerToken, record)).toBe("stored");
163
+ record.headers["Set-Cookie"] = ["sid=planted"];
164
+ record.headers["Content-Type"] = ["text/plain"];
165
+ record.statusCode = 500;
166
+ expect(await store.peek("s")).toEqual({
167
+ statusCode: 201,
168
+ headers: { "Content-Type": ["application/json"] },
169
+ body: '{"ok":true}',
170
+ fingerprint: "request-1",
171
+ });
172
+ });
173
+ it("holds a body that is exactly at its budget, the other side of the too-large boundary", async () => {
174
+ const { store } = await begin();
175
+ const ownerToken = await claimNew(store, "s");
176
+ expect(await store.complete("s", ownerToken, { ...answer, body: largestStorableBody })).toBe("stored");
177
+ expect((await store.peek("s"))?.body).toBe(largestStorableBody);
178
+ });
179
+ it("counts the expiry second itself as expired, on the claim and on the record", async () => {
180
+ // Whether expiry is `<=` or `<` decides what happens in the second a
181
+ // claim runs out, and implementations settle it in different places:
182
+ // one compares in the process, another in a database condition. On
183
+ // the boundary second the claim is gone and the record no longer
184
+ // replays.
185
+ const { clock, store } = await begin();
186
+ await claimNew(store, "s", 30);
187
+ clock.set(START + 30_000);
188
+ expect((await store.begin("s", { pendingTtlSeconds: 30, fingerprint: "request-1" })).state).toBe("new");
189
+ clock.set(START);
190
+ const second = await options.create(clock);
191
+ const secondToken = await claimNew(second, "s");
192
+ await second.complete("s", secondToken, { ...answer, ttlSeconds: 60 });
193
+ clock.set(START + 60_000);
194
+ expect(await second.peek("s")).toBeNull();
195
+ });
196
+ it("lets the owner settle the same scope twice, so a retried complete is not a lost claim", async () => {
197
+ const { store } = await begin();
198
+ const ownerToken = await claimNew(store, "s");
199
+ expect(await store.complete("s", ownerToken, answer)).toBe("stored");
200
+ expect(await store.complete("s", ownerToken, { ...answer, body: '{"ok":2}' })).toBe("stored");
201
+ expect((await store.peek("s"))?.body).toBe('{"ok":2}');
202
+ });
203
+ it("keeps the settled record when its own owner abandons after completing", async () => {
204
+ // The engine abandons after a complete() that threw, and one whose
205
+ // response was lost may have landed. A settled record still carries
206
+ // the owner token, so an abandon conditional on the token alone would
207
+ // delete the stored answer, hand the client's retry a free scope, and
208
+ // run the operation twice. Only a pending claim is released.
209
+ const { store } = await begin();
210
+ const ownerToken = await claimNew(store, "s");
211
+ await store.complete("s", ownerToken, answer);
212
+ await store.abandon("s", ownerToken);
213
+ expect(await store.peek("s")).toMatchObject({ statusCode: 201, body: '{"ok":true}', fingerprint: "request-1" });
214
+ expect((await store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" })).state).toBe("done");
215
+ });
216
+ it("treats a zero pendingTtlSeconds as a claim that is already over, rather than one that never ends", async () => {
217
+ // The engine validates the option, so this is about what a store does
218
+ // when one reaches it anyway: the claim expires in the second it is
219
+ // taken, so the next request claims the scope rather than seeing a
220
+ // pending original, and the first owner has already lost it.
221
+ const { store } = await begin();
222
+ const ownerToken = await claimNew(store, "s", 0);
223
+ expect((await store.begin("s", { pendingTtlSeconds: 0, fingerprint: "request-1" })).state).toBe("new");
224
+ expect(await store.complete("s", ownerToken, answer)).toBe("lost");
225
+ });
226
+ it("grants exactly one claim when two requests claim the same scope at once", async () => {
227
+ // The rule the whole store exists for: begin() has to be atomic, not
228
+ // read-then-write.
229
+ const { store } = await begin();
230
+ const claims = await Promise.all([
231
+ store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" }),
232
+ store.begin("s", { pendingTtlSeconds: 60, fingerprint: "request-1" }),
233
+ ]);
234
+ expect(claims.filter((claim) => claim.state === "new")).toHaveLength(1);
235
+ expect(claims.filter((claim) => claim.state === "pending")).toHaveLength(1);
236
+ });
237
+ };
@@ -0,0 +1,43 @@
1
+ import type { LambderOneShotSecretShape, LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
2
+ import { type LambderConformanceRunner, type LambderConformanceSetup } from "./LambderConformanceRunner.js";
3
+ export type LambderOneShotSecretStoreConformanceOptions = LambderConformanceRunner & {
4
+ /**
5
+ * A store holding nothing under either scope, built for one case over
6
+ * the case's clock. A store over a database empties what the previous
7
+ * case wrote here, since cases reuse the same digests.
8
+ */
9
+ create: (setup: LambderConformanceSetup) => LambderOneShotSecretStore | Promise<LambderOneShotSecretStore>;
10
+ /** Two scopes the store can hold a secret under; the second is the one a case keeps apart from the first. Default: two addresses under one purpose. */
11
+ scopes?: readonly [string, string];
12
+ /**
13
+ * The kind the cases write for each shape the store holds, which the
14
+ * store has to hand back as written. A store that holds only codes, or
15
+ * only tokens, names that shape alone, and the cases for the other are
16
+ * left out. Default: `{ code: "emailCode", token: "activationLink" }`.
17
+ */
18
+ kinds?: Partial<Record<LambderOneShotSecretShape, string>>;
19
+ /** The meta written with a record of each scope, which the store has to hand back as written. `[{}, {}]` for a store that keeps none. Default: one small map per scope. */
20
+ meta?: readonly [Record<string, string>, Record<string, string>];
21
+ /** Seconds from a record's issue to its expiry, the same for every record a case writes, as a store that derives one from the other needs. Default: 600. */
22
+ lifetimeSeconds?: number;
23
+ };
24
+ /**
25
+ * Registers the one-shot secret store rules as cases of the runner, one `it`
26
+ * each and once per shape, against the store `create` builds:
27
+ *
28
+ * ```ts
29
+ * import { describe, it, expect } from "vitest";
30
+ * import { lambderOneShotSecretStoreConformance } from "lambder/testing";
31
+ *
32
+ * describe("TicketCodeStore", () => {
33
+ * lambderOneShotSecretStoreConformance({
34
+ * it, expect,
35
+ * create: async () => { await emptyTicketCodes(); return new TicketCodeStore(pool); },
36
+ * scopes: [ticketA.id, ticketB.id],
37
+ * kinds: { code: "ticketCode" },
38
+ * meta: [{}, {}],
39
+ * });
40
+ * });
41
+ * ```
42
+ */
43
+ export declare const lambderOneShotSecretStoreConformance: (options: LambderOneShotSecretStoreConformanceOptions) => void;
@@ -0,0 +1,224 @@
1
+ import { CONFORMANCE_START_MILLIS as START, conformanceClock, } from "./LambderConformanceRunner.js";
2
+ /**
3
+ * Registers the one-shot secret store rules as cases of the runner, one `it`
4
+ * each and once per shape, against the store `create` builds:
5
+ *
6
+ * ```ts
7
+ * import { describe, it, expect } from "vitest";
8
+ * import { lambderOneShotSecretStoreConformance } from "lambder/testing";
9
+ *
10
+ * describe("TicketCodeStore", () => {
11
+ * lambderOneShotSecretStoreConformance({
12
+ * it, expect,
13
+ * create: async () => { await emptyTicketCodes(); return new TicketCodeStore(pool); },
14
+ * scopes: [ticketA.id, ticketB.id],
15
+ * kinds: { code: "ticketCode" },
16
+ * meta: [{}, {}],
17
+ * });
18
+ * });
19
+ * ```
20
+ */
21
+ export const lambderOneShotSecretStoreConformance = (options) => {
22
+ const [scopeA, scopeB] = options.scopes ?? ["register:ada@example.com", "register:grace@example.com"];
23
+ const kinds = options.kinds ?? { code: "emailCode", token: "activationLink" };
24
+ const shapes = ["code", "token"].filter((shape) => kinds[shape] !== undefined);
25
+ if (shapes.length === 0)
26
+ throw new Error("lambderOneShotSecretStoreConformance: kinds names no shape; give the kind the store holds as a code, as a token, or both.");
27
+ const { expect } = options;
28
+ const [metaA, metaB] = options.meta ?? [{ holder: "ada" }, { holder: "grace" }];
29
+ const lifetimeSeconds = options.lifetimeSeconds ?? 600;
30
+ const startSeconds = Math.floor(START / 1000);
31
+ const begin = async () => {
32
+ const clock = conformanceClock();
33
+ return { clock, store: await options.create(clock) };
34
+ };
35
+ // Every case once per shape the store holds, named for it.
36
+ for (const shape of shapes) {
37
+ const kind = kinds[shape];
38
+ const isToken = shape === "token";
39
+ const it = (name, run) => options.it(`${shape}: ${name}`, run);
40
+ /** A record of scope A issued at the start, unless a case says otherwise; the expiry always follows from the issue time. */
41
+ const draft = (over = {}) => {
42
+ const issuedAt = over.issuedAt ?? startSeconds;
43
+ return {
44
+ kind,
45
+ scope: over.scope ?? scopeA,
46
+ shape,
47
+ digest: over.digest ?? "digest-1",
48
+ issuedAt,
49
+ expiresAt: issuedAt + lifetimeSeconds,
50
+ meta: over.meta ?? (over.scope === scopeB ? metaB : metaA),
51
+ };
52
+ };
53
+ /** The record a store holds for a draft: the draft less its shape, with the id it handed out and no tries. */
54
+ const recordFor = (written, id) => {
55
+ const { shape: _shape, ...record } = written;
56
+ return { ...record, id, attempts: 0 };
57
+ };
58
+ /** An issue that went through, or a failure: every rule below is about a record that exists. */
59
+ const issued = async (store, over = {}) => {
60
+ const outcome = await store.issue(draft(over), {});
61
+ if (!outcome.issued)
62
+ throw new Error(`expected the secret to be issued, refused as ${outcome.refused}`);
63
+ return outcome.id;
64
+ };
65
+ it("holds one record per scope, found by its scope (and a token by its digest), with what was written and no tries", async () => {
66
+ const { store } = await begin();
67
+ const id = await issued(store);
68
+ const byScope = await store.findByScope(scopeA);
69
+ expect(byScope).toEqual(recordFor(draft(), id));
70
+ expect(await store.findByScope(scopeB)).toBeNull();
71
+ if (isToken) {
72
+ expect(await store.findByDigest("digest-1")).toEqual(byScope);
73
+ expect(await store.findByDigest("digest-9")).toBeNull();
74
+ }
75
+ });
76
+ it("retires the scope's older record in the act of issuing, so it can be neither found nor spent", async () => {
77
+ const { store } = await begin();
78
+ const first = await issued(store, { digest: "digest-1" });
79
+ const second = await issued(store, { digest: "digest-2", issuedAt: startSeconds + 30 });
80
+ expect(second).not.toBe(first);
81
+ expect((await store.findByScope(scopeA))?.digest).toBe("digest-2");
82
+ if (isToken) {
83
+ expect(await store.findByDigest("digest-1")).toBeNull();
84
+ expect((await store.findByDigest("digest-2"))?.id).toBe(second);
85
+ }
86
+ else {
87
+ expect(await store.attempt(scopeA, first)).toBeNull();
88
+ }
89
+ expect(await store.consume(scopeA, first)).toBe(false);
90
+ });
91
+ it("leaves exactly one live record when two issues for one scope race", async () => {
92
+ // Retire-then-write done as two statements lets both writers retire
93
+ // nothing and both write, and the scope then holds two live secrets.
94
+ // A second of apart, so a store whose id is the issue time tells the
95
+ // two apart.
96
+ const { store } = await begin();
97
+ const [idA, idB] = await Promise.all([
98
+ issued(store, { digest: "digest-a" }),
99
+ issued(store, { digest: "digest-b", issuedAt: startSeconds + 1 }),
100
+ ]);
101
+ const current = await store.findByScope(scopeA);
102
+ if (isToken) {
103
+ const live = [await store.findByDigest("digest-a"), await store.findByDigest("digest-b")].filter((record) => record !== null);
104
+ expect(live).toHaveLength(1);
105
+ expect(current?.digest).toBe(live[0].digest);
106
+ }
107
+ // Only the scope's current record can be spent.
108
+ expect([await store.consume(scopeA, idA), await store.consume(scopeA, idB)].filter(Boolean)).toHaveLength(1);
109
+ expect(current).not.toBeNull();
110
+ });
111
+ it("refuses an issue inside the cooldown, naming when the current record was issued, and writes nothing", async () => {
112
+ const { store } = await begin();
113
+ await issued(store, { digest: "digest-1", issuedAt: startSeconds });
114
+ const refused = await store.issue(draft({ digest: "digest-2", issuedAt: startSeconds + 10 }), { unlessIssuedAfter: startSeconds - 1 });
115
+ expect(refused).toEqual({ issued: false, refused: "cooldown", issuedAt: startSeconds });
116
+ expect((await store.findByScope(scopeA))?.digest).toBe("digest-1");
117
+ if (isToken)
118
+ expect(await store.findByDigest("digest-2")).toBeNull();
119
+ // At the threshold itself the cooldown is over, and a scope with no record has none.
120
+ expect((await store.issue(draft({ digest: "digest-3", issuedAt: startSeconds + 30 }), { unlessIssuedAfter: startSeconds })).issued).toBe(true);
121
+ expect((await store.issue(draft({ scope: scopeB, digest: "digest-4" }), { unlessIssuedAfter: startSeconds + 1000 })).issued).toBe(true);
122
+ });
123
+ it("grants exactly one of two issues racing past a cooldown", async () => {
124
+ const { store } = await begin();
125
+ await issued(store, { digest: "digest-0", issuedAt: startSeconds - 100 });
126
+ const [a, b] = await Promise.all([
127
+ store.issue(draft({ digest: "digest-a" }), { unlessIssuedAfter: startSeconds - 30 }),
128
+ store.issue(draft({ digest: "digest-b" }), { unlessIssuedAfter: startSeconds - 30 }),
129
+ ]);
130
+ expect([a.issued, b.issued].filter(Boolean)).toHaveLength(1);
131
+ });
132
+ if (isToken) {
133
+ it("refuses a digest another scope's record holds, writes nothing, and leaves it with its holder", async () => {
134
+ // Two scopes that drew the same token would share one digest,
135
+ // and whoever typed it would redeem the other's record.
136
+ const { store } = await begin();
137
+ const holderId = await issued(store, { digest: "digest-x" });
138
+ expect(await store.issue(draft({ scope: scopeB, digest: "digest-x" }), {})).toEqual({ issued: false, refused: "digestTaken" });
139
+ expect(await store.findByScope(scopeB)).toBeNull();
140
+ expect(await store.findByDigest("digest-x")).toEqual(recordFor(draft({ digest: "digest-x" }), holderId));
141
+ });
142
+ it("grants a digest to exactly one of two scopes racing for it", async () => {
143
+ const { store } = await begin();
144
+ const [a, b] = await Promise.all([
145
+ store.issue(draft({ digest: "digest-x" }), {}),
146
+ store.issue(draft({ scope: scopeB, digest: "digest-x" }), {}),
147
+ ]);
148
+ expect([a, b].filter((outcome) => outcome.issued)).toHaveLength(1);
149
+ expect([a, b].filter((outcome) => !outcome.issued && outcome.refused === "digestTaken")).toHaveLength(1);
150
+ const winner = a.issued ? scopeA : scopeB;
151
+ expect((await store.findByDigest("digest-x"))?.scope).toBe(winner);
152
+ expect(await store.findByScope(winner === scopeA ? scopeB : scopeA)).toBeNull();
153
+ });
154
+ }
155
+ else {
156
+ it("counts a try in the act that reads the record, on the record named and no other", async () => {
157
+ const { store } = await begin();
158
+ const id = await issued(store);
159
+ const first = await store.attempt(scopeA, id);
160
+ expect(first).toMatchObject({ id, digest: "digest-1", attempts: 1, meta: metaA });
161
+ expect((await store.attempt(scopeA, id))?.attempts).toBe(2);
162
+ expect((await store.findByScope(scopeA))?.attempts).toBe(2);
163
+ // The record is scope A's: named under another scope, it is not the one there.
164
+ expect(await store.attempt(scopeB, id)).toBeNull();
165
+ expect((await store.findByScope(scopeA))?.attempts).toBe(2);
166
+ });
167
+ it("counts tries sent together every one, so a ceiling cannot be raced past", async () => {
168
+ const { store } = await begin();
169
+ const id = await issued(store);
170
+ const counted = await Promise.all(Array.from({ length: 5 }, () => store.attempt(scopeA, id)));
171
+ expect(counted.map((record) => record?.attempts).sort()).toEqual([1, 2, 3, 4, 5]);
172
+ });
173
+ }
174
+ it("consumes the record named exactly once, after which nothing finds it", async () => {
175
+ const { store } = await begin();
176
+ const id = await issued(store);
177
+ expect(await store.consume(scopeB, id)).toBe(false);
178
+ expect(await store.consume(scopeA, id)).toBe(true);
179
+ expect(await store.consume(scopeA, id)).toBe(false);
180
+ expect(await store.findByScope(scopeA)).toBeNull();
181
+ if (isToken)
182
+ expect(await store.findByDigest("digest-1")).toBeNull();
183
+ else
184
+ expect(await store.attempt(scopeA, id)).toBeNull();
185
+ });
186
+ it("accepts exactly one of two consumes racing for one record", async () => {
187
+ const { store } = await begin();
188
+ const id = await issued(store);
189
+ const outcomes = await Promise.all([store.consume(scopeA, id), store.consume(scopeA, id)]);
190
+ expect(outcomes.filter(Boolean)).toHaveLength(1);
191
+ });
192
+ it("retires whatever the scope holds and nothing of another scope, and nothing when it holds nothing", async () => {
193
+ const { store } = await begin();
194
+ await issued(store, { digest: "digest-a" });
195
+ await issued(store, { scope: scopeB, digest: "digest-b" });
196
+ await store.retire(scopeA);
197
+ expect(await store.findByScope(scopeA)).toBeNull();
198
+ if (isToken)
199
+ expect(await store.findByDigest("digest-a")).toBeNull();
200
+ expect((await store.findByScope(scopeB))?.digest).toBe("digest-b");
201
+ await store.retire(scopeA);
202
+ });
203
+ it("keeps an expired record until its own housekeeping removes it, so the class can say expired rather than none", async () => {
204
+ const { clock, store } = await begin();
205
+ const id = await issued(store);
206
+ clock.set(START + (lifetimeSeconds + 1) * 1000);
207
+ expect((await store.findByScope(scopeA))?.id).toBe(id);
208
+ if (isToken)
209
+ expect((await store.findByDigest("digest-1"))?.id).toBe(id);
210
+ });
211
+ it("keeps scopes apart, and hands back copies", async () => {
212
+ const { store } = await begin();
213
+ const idA = await issued(store, { digest: "digest-a" });
214
+ const idB = await issued(store, { scope: scopeB, digest: "digest-b" });
215
+ const a = (await store.findByScope(scopeA));
216
+ a.meta["planted"] = "changed";
217
+ a.digest = "changed";
218
+ expect(await store.findByScope(scopeA)).toEqual(recordFor(draft({ digest: "digest-a" }), idA));
219
+ expect(await store.findByScope(scopeB)).toEqual(recordFor(draft({ scope: scopeB, digest: "digest-b" }), idB));
220
+ if (isToken)
221
+ expect((await store.findByDigest("digest-b"))?.scope).toBe(scopeB);
222
+ });
223
+ }
224
+ };
@@ -0,0 +1,20 @@
1
+ import { type LambderRateLimiter } from "../shared/contracts/LambderRateLimiter.js";
2
+ import { type LambderConformanceRunner, type LambderConformanceSetup } from "./LambderConformanceRunner.js";
3
+ export type LambderRateLimiterConformanceOptions = LambderConformanceRunner & {
4
+ /** A limiter holding no counts, built for one case over the case's clock. */
5
+ create: (setup: LambderConformanceSetup) => LambderRateLimiter | Promise<LambderRateLimiter>;
6
+ };
7
+ /**
8
+ * Registers the rate limiter rules as cases of the runner, one `it` each,
9
+ * against the limiter `create` builds:
10
+ *
11
+ * ```ts
12
+ * import { describe, it, expect } from "vitest";
13
+ * import { lambderRateLimiterConformance } from "lambder/testing";
14
+ *
15
+ * describe("RedisRateLimiter", () => {
16
+ * lambderRateLimiterConformance({ it, expect, create: ({ now }) => new RedisRateLimiter({ client, now }) });
17
+ * });
18
+ * ```
19
+ */
20
+ export declare const lambderRateLimiterConformance: (options: LambderRateLimiterConformanceOptions) => void;
@@ -0,0 +1,72 @@
1
+ import { RATE_LIMIT_WINDOWS } from "../shared/contracts/LambderRateLimiter.js";
2
+ import { CONFORMANCE_START_MILLIS as START, conformanceClock, } from "./LambderConformanceRunner.js";
3
+ /**
4
+ * Registers the rate limiter rules as cases of the runner, one `it` each,
5
+ * against the limiter `create` builds:
6
+ *
7
+ * ```ts
8
+ * import { describe, it, expect } from "vitest";
9
+ * import { lambderRateLimiterConformance } from "lambder/testing";
10
+ *
11
+ * describe("RedisRateLimiter", () => {
12
+ * lambderRateLimiterConformance({ it, expect, create: ({ now }) => new RedisRateLimiter({ client, now }) });
13
+ * });
14
+ * ```
15
+ */
16
+ export const lambderRateLimiterConformance = (options) => {
17
+ const { it, expect } = options;
18
+ const begin = async () => {
19
+ const clock = conformanceClock();
20
+ return { clock, limiter: await options.create(clock) };
21
+ };
22
+ it("counts attempts and refuses the one past the limit, naming the window", async () => {
23
+ const { limiter } = await begin();
24
+ expect(await limiter.isRateLimited("k", { perMin: 2 })).toBe(false);
25
+ expect(await limiter.isRateLimited("k", { perMin: 2 })).toBe(false);
26
+ const refused = await limiter.isRateLimited("k", { perMin: 2 });
27
+ expect(refused).toMatchObject({ window: "perMin", limit: 2 });
28
+ if (refused === false)
29
+ throw new Error("expected the third attempt to be refused");
30
+ expect(refused.resetAt).toBeGreaterThan(Math.floor(START / 1000));
31
+ });
32
+ it("counts attempts rather than allowed requests, so a refusal does not reset anything", async () => {
33
+ const { limiter } = await begin();
34
+ await limiter.isRateLimited("k", { perMin: 1 });
35
+ expect(await limiter.isRateLimited("k", { perMin: 1 })).not.toBe(false);
36
+ expect(await limiter.isRateLimited("k", { perMin: 1 })).not.toBe(false);
37
+ });
38
+ it("does not enforce a window the policy leaves out or caps at zero", async () => {
39
+ const { limiter } = await begin();
40
+ for (let i = 0; i < 5; i += 1) {
41
+ expect(await limiter.isRateLimited("k", { perHour: 0 })).toBe(false);
42
+ }
43
+ expect(await limiter.isRateLimited("k", {})).toBe(false);
44
+ });
45
+ it("reports the smallest exceeded window, since that is the one evaluated first", async () => {
46
+ const { limiter } = await begin();
47
+ await limiter.isRateLimited("k", { perMin: 1, perHour: 10 });
48
+ expect(await limiter.isRateLimited("k", { perMin: 1, perHour: 10 })).toMatchObject({ window: "perMin" });
49
+ });
50
+ it("keeps tracker keys apart", async () => {
51
+ const { limiter } = await begin();
52
+ await limiter.isRateLimited("a", { perMin: 1 });
53
+ expect(await limiter.isRateLimited("b", { perMin: 1 })).toBe(false);
54
+ });
55
+ it("starts a fresh count once the fixed window rolls over", async () => {
56
+ const { clock, limiter } = await begin();
57
+ await limiter.isRateLimited("k", { perMin: 1 });
58
+ expect(await limiter.isRateLimited("k", { perMin: 1 })).not.toBe(false);
59
+ clock.set(START + 61_000);
60
+ expect(await limiter.isRateLimited("k", { perMin: 1 })).toBe(false);
61
+ });
62
+ for (const { key, seconds } of RATE_LIMIT_WINDOWS) {
63
+ it(`enforces ${key} and resets after it`, async () => {
64
+ const { clock, limiter } = await begin();
65
+ const policy = { [key]: 1 };
66
+ expect(await limiter.isRateLimited("k", policy)).toBe(false);
67
+ expect(await limiter.isRateLimited("k", policy)).toMatchObject({ window: key, limit: 1 });
68
+ clock.set(START + (seconds + 1) * 1000);
69
+ expect(await limiter.isRateLimited("k", policy)).toBe(false);
70
+ });
71
+ }
72
+ };
@@ -0,0 +1,27 @@
1
+ import type { LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
2
+ import { type LambderConformanceRunner, type LambderConformanceSetup } from "./LambderConformanceRunner.js";
3
+ export type LambderSessionStoreConformanceOptions = LambderConformanceRunner & {
4
+ /** A store holding no sessions, built for one case over the case's clock. */
5
+ create: (setup: LambderConformanceSetup) => LambderSessionStore | Promise<LambderSessionStore>;
6
+ /** What the store's `isMemoryOnly` must say: true only for a store nothing outlives the process of. Default: false. */
7
+ isMemoryOnly?: boolean;
8
+ };
9
+ /**
10
+ * Registers the session store rules as cases of the runner, one `it` each,
11
+ * against the store `create` builds:
12
+ *
13
+ * ```ts
14
+ * import { describe, it, expect } from "vitest";
15
+ * import { lambderSessionStoreConformance } from "lambder/testing";
16
+ *
17
+ * describe("PostgresSessionStore", () => {
18
+ * lambderSessionStoreConformance({ it, expect, create: async () => { await emptySessions(); return new PostgresSessionStore(pool); } });
19
+ * });
20
+ * ```
21
+ *
22
+ * The records a case writes are dated from the case's clock, which starts at
23
+ * a fixed moment and stands still unless a case moves it: a store that
24
+ * expires records by the system clock, rather than by `now`, finds them all
25
+ * long expired.
26
+ */
27
+ export declare const lambderSessionStoreConformance: (options: LambderSessionStoreConformanceOptions) => void;