lambder 8.1.2 → 9.0.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 (86) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +23 -31
  3. package/dist/api/LambderApiCallContext.d.ts +31 -1
  4. package/dist/api/LambderApiCallContext.js +8 -0
  5. package/dist/api/LambderApiDefinition.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +3 -4
  8. package/dist/api/LambderApiGuards.d.ts +2 -17
  9. package/dist/api/LambderApiIdempotency.js +5 -7
  10. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  11. package/dist/build/generatedTables.d.ts +72 -0
  12. package/dist/build/generatedTables.js +99 -0
  13. package/dist/build/writeApiGuardParams.d.ts +60 -0
  14. package/dist/build/writeApiGuardParams.js +85 -0
  15. package/dist/build/writeApiOptions.d.ts +68 -0
  16. package/dist/build/writeApiOptions.js +102 -0
  17. package/dist/build.d.ts +10 -4
  18. package/dist/build.js +7 -4
  19. package/dist/client/LambderCaller.d.ts +0 -4
  20. package/dist/client/LambderCaller.js +1 -9
  21. package/dist/client/LambderUploadRunner.d.ts +7 -7
  22. package/dist/client/LambderUploadRunner.js +12 -21
  23. package/dist/client.d.ts +7 -0
  24. package/dist/client.js +11 -0
  25. package/dist/core/Lambder.d.ts +71 -12
  26. package/dist/core/Lambder.js +116 -38
  27. package/dist/core/LambderContext.d.ts +9 -6
  28. package/dist/core/LambderContext.js +2 -1
  29. package/dist/core/LambderResolver.d.ts +6 -12
  30. package/dist/core/LambderResolver.js +2 -14
  31. package/dist/core/LambderResponseBuilder.d.ts +15 -70
  32. package/dist/core/LambderResponseBuilder.js +15 -99
  33. package/dist/index.d.ts +16 -3
  34. package/dist/index.js +14 -1
  35. package/dist/invoke/LambderInvokeCaller.js +3 -4
  36. package/dist/mock/LambderMockApp.d.ts +34 -17
  37. package/dist/mock/LambderMockApp.js +69 -24
  38. package/dist/mock/LambderMockCreateOptions.d.ts +70 -7
  39. package/dist/mock/LambderMockTypes.d.ts +31 -21
  40. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  41. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  42. package/dist/mock.d.ts +3 -0
  43. package/dist/mock.js +3 -0
  44. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  45. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  46. package/dist/session/LambderSessionCrypto.js +6 -16
  47. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  48. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  49. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  50. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  51. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  52. package/dist/shared/util/LambderBase64.d.ts +14 -0
  53. package/dist/shared/util/LambderBase64.js +17 -0
  54. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  55. package/dist/shared/util/LambderSignedClaims.js +109 -0
  56. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  57. package/dist/shared/util/LambderTextDigest.js +30 -5
  58. package/dist/shared/util/LambderTypeUtilities.d.ts +18 -0
  59. package/dist/shared/util/assertPlainData.d.ts +9 -0
  60. package/dist/shared/util/assertPlainData.js +41 -0
  61. package/dist/shared/wire/LambderAnswerHeaders.d.ts +3 -2
  62. package/dist/shared/wire/LambderAnswerHeaders.js +3 -2
  63. package/dist/shared/wire/LambderApiContract.d.ts +9 -14
  64. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  65. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  66. package/dist/shared/wire/LambderApiRefusal.d.ts +3 -4
  67. package/dist/shared/wire/LambderApiRefusal.js +3 -4
  68. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  69. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  70. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  71. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  72. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  73. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  74. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  75. package/dist/testing/LambderConformanceRunner.js +21 -0
  76. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  77. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  78. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  79. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  80. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  81. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  82. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  83. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  84. package/dist/testing.d.ts +14 -0
  85. package/dist/testing.js +12 -0
  86. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
2
+ /**
3
+ * How long past its expiry a record is kept, so the class can still answer
4
+ * "expired" from it rather than "none", the way a table's lazy TTL leaves an
5
+ * expired item in place for a while.
6
+ */
7
+ const EXPIRED_GRACE_SECONDS = 3600;
8
+ /**
9
+ * One-shot secrets held in memory: the same one-record-per-scope, count-then-
10
+ * compare and consume-once semantics as LambderDdbOneShotSecretStore, over a
11
+ * LambderExpiringMap in place of the table and its TTL. For tests and for an
12
+ * app's development runtime; lambderOneShotSecretStoreConformance drives
13
+ * this and the DynamoDB store through one set of rules.
14
+ *
15
+ * Two maps, as the table holds two items per secret: the scope's current
16
+ * record, and the scope a digest belongs to, which is how a token is found by
17
+ * its value. `now` is injectable so a test can expire a secret without
18
+ * waiting.
19
+ */
20
+ export class LambderMemoryOneShotSecretStore {
21
+ records;
22
+ scopesByDigest;
23
+ idCounter = 0;
24
+ constructor(options = {}) {
25
+ this.records = new LambderExpiringMap({ now: options.now, maxEntries: options.maxEntries });
26
+ this.scopesByDigest = new LambderExpiringMap({ now: options.now, maxEntries: options.maxEntries });
27
+ }
28
+ /** A record as the class reads it: a copy, so a caller writing onto what it got back cannot rewrite the record. */
29
+ static copyOf(record) {
30
+ return { ...record, meta: { ...record.meta } };
31
+ }
32
+ async issue(draft, { unlessIssuedAfter }) {
33
+ const current = this.records.get(draft.scope);
34
+ if (unlessIssuedAfter !== undefined && current && current.issuedAt > unlessIssuedAfter)
35
+ return { issued: false, refused: "cooldown", issuedAt: current.issuedAt };
36
+ // A token's digest is its only address, so another scope's record
37
+ // holding it keeps it; a code's carries its scope, and no other scope
38
+ // can hold it.
39
+ const holder = draft.shape === "token" ? this.scopesByDigest.get(draft.digest) : undefined;
40
+ if (holder !== undefined && holder !== draft.scope && this.records.get(holder)?.digest === draft.digest)
41
+ return { issued: false, refused: "digestTaken" };
42
+ if (current)
43
+ this.scopesByDigest.delete(current.digest);
44
+ this.idCounter += 1;
45
+ const id = `secret-${this.idCounter}`;
46
+ const keepUntil = draft.expiresAt + EXPIRED_GRACE_SECONDS;
47
+ const { shape: _shape, ...record } = draft;
48
+ this.records.set(draft.scope, { ...record, id, attempts: 0, meta: { ...draft.meta } }, keepUntil);
49
+ if (draft.shape === "token")
50
+ this.scopesByDigest.set(draft.digest, draft.scope, keepUntil);
51
+ return { issued: true, id };
52
+ }
53
+ async findByScope(scope) {
54
+ const record = this.records.get(scope);
55
+ return record ? LambderMemoryOneShotSecretStore.copyOf(record) : null;
56
+ }
57
+ async findByDigest(digest) {
58
+ const scope = this.scopesByDigest.get(digest);
59
+ const record = scope === undefined ? undefined : this.records.get(scope);
60
+ // The scope may have been issued a newer secret since this digest was
61
+ // written, in which case this digest is nobody's.
62
+ return record && record.digest === digest ? LambderMemoryOneShotSecretStore.copyOf(record) : null;
63
+ }
64
+ async attempt(scope, id) {
65
+ const record = this.records.get(scope);
66
+ if (!record || record.id !== id)
67
+ return null;
68
+ record.attempts += 1;
69
+ return LambderMemoryOneShotSecretStore.copyOf(record);
70
+ }
71
+ async consume(scope, id) {
72
+ const record = this.records.get(scope);
73
+ if (!record || record.id !== id)
74
+ return false;
75
+ this.records.delete(scope);
76
+ this.scopesByDigest.delete(record.digest);
77
+ return true;
78
+ }
79
+ async retire(scope) {
80
+ const record = this.records.get(scope);
81
+ if (!record)
82
+ return;
83
+ this.records.delete(scope);
84
+ this.scopesByDigest.delete(record.digest);
85
+ }
86
+ /** Number of live records held. */
87
+ get size() { return this.records.size; }
88
+ /** Forgets every record. */
89
+ reset() {
90
+ this.records.clear();
91
+ this.scopesByDigest.clear();
92
+ }
93
+ }
@@ -0,0 +1,46 @@
1
+ /** The runner's `it`: registers one case under a name. */
2
+ export type LambderConformanceIt = (name: string, run: () => Promise<void>) => unknown;
3
+ /** The part of a jest-style assertion the suites use, which vitest's and jest's `expect` both provide. */
4
+ export type LambderConformanceAssertion = {
5
+ toBe(expected: unknown): void;
6
+ toEqual(expected: unknown): void;
7
+ toMatchObject(expected: object): void;
8
+ toBeNull(): void;
9
+ toBeUndefined(): void;
10
+ toBeTruthy(): void;
11
+ toHaveLength(length: number): void;
12
+ toBeGreaterThan(value: number): void;
13
+ not: {
14
+ toBe(expected: unknown): void;
15
+ toBeNull(): void;
16
+ };
17
+ rejects: {
18
+ toThrow(): Promise<unknown>;
19
+ };
20
+ resolves: {
21
+ toBeNull(): Promise<unknown>;
22
+ };
23
+ };
24
+ /** The runner's `expect`. */
25
+ export type LambderConformanceExpect = (actual: unknown) => LambderConformanceAssertion;
26
+ /** The two things every suite takes from the runner. */
27
+ export type LambderConformanceRunner = {
28
+ it: LambderConformanceIt;
29
+ expect: LambderConformanceExpect;
30
+ };
31
+ /**
32
+ * What a store factory is handed for one case: the clock the case moves, in
33
+ * epoch milliseconds. A store that judges time itself (a memory store
34
+ * expiring its entries) must read it from here; the system clock does not
35
+ * move with it, so a store that read Date.now() behind its `now` option
36
+ * would see time standing still and fail the expiry rules.
37
+ */
38
+ export type LambderConformanceSetup = {
39
+ now: () => number;
40
+ };
41
+ /** Where every case's clock starts: a fixed moment, so the records a case writes are the same on every run. */
42
+ export declare const CONFORMANCE_START_MILLIS = 1700000000000;
43
+ /** The clock of one case: `now` for the store, `set` for the case. */
44
+ export declare const conformanceClock: () => LambderConformanceSetup & {
45
+ set(millis: number): void;
46
+ };
@@ -0,0 +1,21 @@
1
+ /*
2
+ * What the store conformance suites register their cases with, and the clock
3
+ * each case runs on.
4
+ *
5
+ * A suite is the set of rules one store interface promises, written once and
6
+ * driven through every implementation: Lambder's own memory and DynamoDB
7
+ * stores, and any store an app writes over its own database. The suites take
8
+ * the test runner's `it` and `expect` rather than importing one, so they run
9
+ * under vitest, jest, or any runner with a jest-style `expect`, and the
10
+ * package depends on none of them.
11
+ */
12
+ /** Where every case's clock starts: a fixed moment, so the records a case writes are the same on every run. */
13
+ export const CONFORMANCE_START_MILLIS = 1_700_000_000_000;
14
+ /** The clock of one case: `now` for the store, `set` for the case. */
15
+ export const conformanceClock = () => {
16
+ let millis = CONFORMANCE_START_MILLIS;
17
+ return {
18
+ now: () => millis,
19
+ set: (next) => { millis = next; },
20
+ };
21
+ };
@@ -0,0 +1,33 @@
1
+ import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
2
+ import { type LambderConformanceRunner, type LambderConformanceSetup } from "./LambderConformanceRunner.js";
3
+ export type LambderIdempotencyStoreConformanceOptions = LambderConformanceRunner & {
4
+ /** A store holding nothing, built for one case over the case's clock. */
5
+ create: (setup: LambderConformanceSetup) => LambderIdempotencyStore | Promise<LambderIdempotencyStore>;
6
+ /**
7
+ * A body this store will not hold. The budget is the store's own business
8
+ * (a DynamoDB store measures what it writes after compression, a memory
9
+ * store the bytes), so each says what "too big" means for it.
10
+ */
11
+ oversizedBody: string;
12
+ /** The other side of the same budget: the largest body this store does hold, so the boundary is pinned from both directions. */
13
+ largestStorableBody: string;
14
+ };
15
+ /**
16
+ * Registers the idempotency store rules as cases of the runner, one `it`
17
+ * each, against the store `create` builds:
18
+ *
19
+ * ```ts
20
+ * import { describe, it, expect } from "vitest";
21
+ * import { lambderIdempotencyStoreConformance } from "lambder/testing";
22
+ *
23
+ * describe("OrderIdempotencyStore", () => {
24
+ * lambderIdempotencyStoreConformance({
25
+ * it, expect,
26
+ * create: ({ now }) => new OrderIdempotencyStore({ pool, now }),
27
+ * oversizedBody: "x".repeat(2_000_000),
28
+ * largestStorableBody: "x".repeat(1_000_000),
29
+ * });
30
+ * });
31
+ * ```
32
+ */
33
+ export declare const lambderIdempotencyStoreConformance: (options: LambderIdempotencyStoreConformanceOptions) => void;
@@ -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;