lambder 8.1.1 → 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.
- package/CHANGELOG.md +164 -0
- package/README.md +8 -7
- package/dist/api/LambderApiGuards.d.ts +2 -17
- package/dist/api/LambderApiRateLimits.d.ts +2 -29
- package/dist/build/ContractTypePrinter.d.ts +39 -9
- package/dist/build/ContractTypePrinter.js +89 -31
- package/dist/build/generatedTables.d.ts +72 -0
- package/dist/build/generatedTables.js +99 -0
- package/dist/build/writeApiContract.d.ts +2 -1
- package/dist/build/writeApiContract.js +2 -1
- package/dist/build/writeApiGuardParams.d.ts +60 -0
- package/dist/build/writeApiGuardParams.js +85 -0
- package/dist/build/writeApiOptions.d.ts +68 -0
- package/dist/build/writeApiOptions.js +102 -0
- package/dist/build.d.ts +10 -4
- package/dist/build.js +7 -4
- package/dist/client/LambderUploadRunner.d.ts +7 -7
- package/dist/client/LambderUploadRunner.js +23 -32
- package/dist/client.d.ts +7 -0
- package/dist/client.js +11 -0
- package/dist/core/Lambder.d.ts +21 -0
- package/dist/core/Lambder.js +69 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/mock/LambderMockApp.d.ts +34 -17
- package/dist/mock/LambderMockApp.js +67 -21
- package/dist/mock/LambderMockCreateOptions.d.ts +68 -5
- package/dist/mock/LambderMockTypes.d.ts +29 -10
- package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
- package/dist/mock/lambderMockPoliciesFrom.js +46 -0
- package/dist/mock.d.ts +3 -0
- package/dist/mock.js +3 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
- package/dist/secrets/LambderOneShotSecrets.js +217 -0
- package/dist/session/LambderSessionCrypto.js +6 -16
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
- package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
- package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
- package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
- package/dist/shared/util/LambderBackoffTimer.js +86 -0
- package/dist/shared/util/LambderBase64.d.ts +14 -0
- package/dist/shared/util/LambderBase64.js +17 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
- package/dist/shared/util/LambderSignedClaims.js +109 -0
- package/dist/shared/util/LambderTextDigest.d.ts +19 -5
- package/dist/shared/util/LambderTextDigest.js +30 -5
- package/dist/shared/util/assertPlainData.d.ts +9 -0
- package/dist/shared/util/assertPlainData.js +41 -0
- package/dist/shared/util/escapeXmlText.d.ts +8 -0
- package/dist/shared/util/escapeXmlText.js +8 -0
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +2 -2
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
- package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
- package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
- package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
- package/dist/stores/LambderMemoryUploadBucket.js +2 -2
- package/dist/testing/LambderConformanceRunner.d.ts +46 -0
- package/dist/testing/LambderConformanceRunner.js +21 -0
- package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
- package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
- package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
- package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
- package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
- package/dist/testing/lambderRateLimiterConformance.js +72 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
- package/dist/testing/lambderSessionStoreConformance.js +165 -0
- package/dist/testing.d.ts +14 -0
- package/dist/testing.js +12 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { CONFORMANCE_START_MILLIS as START, conformanceClock, } from "./LambderConformanceRunner.js";
|
|
2
|
+
/**
|
|
3
|
+
* Registers the session store rules as cases of the runner, one `it` each,
|
|
4
|
+
* against the store `create` builds:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { describe, it, expect } from "vitest";
|
|
8
|
+
* import { lambderSessionStoreConformance } from "lambder/testing";
|
|
9
|
+
*
|
|
10
|
+
* describe("PostgresSessionStore", () => {
|
|
11
|
+
* lambderSessionStoreConformance({ it, expect, create: async () => { await emptySessions(); return new PostgresSessionStore(pool); } });
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The records a case writes are dated from the case's clock, which starts at
|
|
16
|
+
* a fixed moment and stands still unless a case moves it: a store that
|
|
17
|
+
* expires records by the system clock, rather than by `now`, finds them all
|
|
18
|
+
* long expired.
|
|
19
|
+
*/
|
|
20
|
+
export const lambderSessionStoreConformance = (options) => {
|
|
21
|
+
const { it, expect } = options;
|
|
22
|
+
const isMemoryOnly = options.isMemoryOnly ?? false;
|
|
23
|
+
const startSeconds = Math.floor(START / 1000);
|
|
24
|
+
const begin = async () => await options.create(conformanceClock());
|
|
25
|
+
const sessionRecord = (over = {}) => ({
|
|
26
|
+
sessionKeyHash: "partition-a",
|
|
27
|
+
secretHash: "secret-1",
|
|
28
|
+
csrfTokenHash: "csrf-1",
|
|
29
|
+
sessionKey: "user-1",
|
|
30
|
+
data: { name: "Ada" },
|
|
31
|
+
createdAt: startSeconds,
|
|
32
|
+
expiresAt: startSeconds + 3600,
|
|
33
|
+
lastAccessedAt: startSeconds,
|
|
34
|
+
ttlInSeconds: 3600,
|
|
35
|
+
dataVersion: 0,
|
|
36
|
+
...over,
|
|
37
|
+
});
|
|
38
|
+
it("says whether it outlives the process, which is what gates non-cryptographic hashing", async () => {
|
|
39
|
+
expect((await begin()).isMemoryOnly).toBe(isMemoryOnly);
|
|
40
|
+
});
|
|
41
|
+
it("round-trips a record under its two hashes", async () => {
|
|
42
|
+
const store = await begin();
|
|
43
|
+
await store.create(sessionRecord());
|
|
44
|
+
const read = await store.get("partition-a", "secret-1");
|
|
45
|
+
expect(read).toMatchObject({ sessionKey: "user-1", csrfTokenHash: "csrf-1", data: { name: "Ada" }, dataVersion: 0 });
|
|
46
|
+
});
|
|
47
|
+
it("answers null for a record that is absent, never throws", async () => {
|
|
48
|
+
const store = await begin();
|
|
49
|
+
expect(await store.get("partition-a", "nope")).toBeNull();
|
|
50
|
+
expect(await store.get("no-such-partition", "secret-1")).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
it("refuses to create a record over an existing one: a session is minted once", async () => {
|
|
53
|
+
const store = await begin();
|
|
54
|
+
await store.create(sessionRecord());
|
|
55
|
+
await expect(store.create(sessionRecord({ data: { name: "Grace" } }))).rejects.toThrow();
|
|
56
|
+
expect(await store.get("partition-a", "secret-1")).toMatchObject({ data: { name: "Ada" } });
|
|
57
|
+
expect(await store.listSecretHashes("partition-a")).toEqual(["secret-1"]);
|
|
58
|
+
});
|
|
59
|
+
it("lists the secret hashes of one partition and nothing from another", async () => {
|
|
60
|
+
const store = await begin();
|
|
61
|
+
await store.create(sessionRecord({ secretHash: "secret-1" }));
|
|
62
|
+
await store.create(sessionRecord({ secretHash: "secret-2" }));
|
|
63
|
+
await store.create(sessionRecord({ sessionKeyHash: "partition-b", secretHash: "secret-3" }));
|
|
64
|
+
expect((await store.listSecretHashes("partition-a")).sort()).toEqual(["secret-1", "secret-2"]);
|
|
65
|
+
expect(await store.listSecretHashes("partition-b")).toEqual(["secret-3"]);
|
|
66
|
+
expect(await store.listSecretHashes("partition-empty")).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
it("deletes one session without touching its siblings", async () => {
|
|
69
|
+
const store = await begin();
|
|
70
|
+
await store.create(sessionRecord({ secretHash: "secret-1" }));
|
|
71
|
+
await store.create(sessionRecord({ secretHash: "secret-2" }));
|
|
72
|
+
expect(await store.delete("partition-a", "secret-1")).toMatchObject({ secretHash: "secret-1", sessionKey: "user-1" });
|
|
73
|
+
expect(await store.get("partition-a", "secret-1")).toBeNull();
|
|
74
|
+
expect(await store.get("partition-a", "secret-2")).not.toBeNull();
|
|
75
|
+
});
|
|
76
|
+
it("hands back the record as it was stored when deleted, a later write included", async () => {
|
|
77
|
+
const store = await begin();
|
|
78
|
+
await store.create(sessionRecord());
|
|
79
|
+
await store.update("partition-a", "secret-1", { data: { name: "Grace" } });
|
|
80
|
+
expect((await store.delete("partition-a", "secret-1"))?.data).toEqual({ name: "Grace" });
|
|
81
|
+
});
|
|
82
|
+
it("deleting a record that is already gone is a no-op that answers null, not an error", async () => {
|
|
83
|
+
const store = await begin();
|
|
84
|
+
await expect(store.delete("partition-a", "never-existed")).resolves.toBeNull();
|
|
85
|
+
});
|
|
86
|
+
it("updates the named fields of a live record and leaves the rest", async () => {
|
|
87
|
+
const store = await begin();
|
|
88
|
+
await store.create(sessionRecord());
|
|
89
|
+
const at = startSeconds + 30;
|
|
90
|
+
expect(await store.update("partition-a", "secret-1", { dataExpiresAt: at })).toBe("updated");
|
|
91
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Grace" }, expiresAt: at + 7200, lastAccessedAt: at })).toBe("updated");
|
|
92
|
+
expect(await store.get("partition-a", "secret-1")).toMatchObject({
|
|
93
|
+
data: { name: "Grace" }, dataExpiresAt: at, expiresAt: at + 7200, lastAccessedAt: at,
|
|
94
|
+
sessionKey: "user-1", csrfTokenHash: "csrf-1", createdAt: startSeconds,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it("never brings back a record that vanished: an update of one answers missing", async () => {
|
|
98
|
+
// A logout that lands while a renewal is in flight must stay a
|
|
99
|
+
// logout, so an update of a deleted record writes nothing at all.
|
|
100
|
+
const store = await begin();
|
|
101
|
+
await store.create(sessionRecord());
|
|
102
|
+
await store.delete("partition-a", "secret-1");
|
|
103
|
+
expect(await store.update("partition-a", "secret-1", { expiresAt: startSeconds + 7200 })).toBe("missing");
|
|
104
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Grace" } }, { dataVersion: 0 })).toBe("missing");
|
|
105
|
+
expect(await store.get("partition-a", "secret-1")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
it("moves dataVersion by one on each write of data or dataExpiresAt, and on no other write", async () => {
|
|
108
|
+
const store = await begin();
|
|
109
|
+
await store.create(sessionRecord());
|
|
110
|
+
const at = startSeconds + 30;
|
|
111
|
+
const versionNow = async () => (await store.get("partition-a", "secret-1"))?.dataVersion;
|
|
112
|
+
await store.update("partition-a", "secret-1", { lastAccessedAt: at, expiresAt: at + 3600 });
|
|
113
|
+
expect(await versionNow()).toBe(0);
|
|
114
|
+
await store.update("partition-a", "secret-1", { data: { name: "Grace" } });
|
|
115
|
+
expect(await versionNow()).toBe(1);
|
|
116
|
+
await store.update("partition-a", "secret-1", { dataExpiresAt: at });
|
|
117
|
+
expect(await versionNow()).toBe(2);
|
|
118
|
+
// One write carrying both, and the slide beside them, is one step.
|
|
119
|
+
await store.update("partition-a", "secret-1", { data: { name: "Lin" }, dataExpiresAt: at + 600, lastAccessedAt: at });
|
|
120
|
+
expect(await versionNow()).toBe(3);
|
|
121
|
+
});
|
|
122
|
+
it("applies a conditioned update only while dataVersion is the one it was read with, and says stale otherwise", async () => {
|
|
123
|
+
const store = await begin();
|
|
124
|
+
await store.create(sessionRecord());
|
|
125
|
+
const at = startSeconds + 30;
|
|
126
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Grace" }, dataExpiresAt: at }, { dataVersion: 1 })).toBe("stale");
|
|
127
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Grace" }, dataExpiresAt: at }, { dataVersion: 0 })).toBe("updated");
|
|
128
|
+
// That write moved the version, so the one it named no longer holds.
|
|
129
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Lin" } }, { dataVersion: 0 })).toBe("stale");
|
|
130
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Lin" } }, { dataVersion: 1 })).toBe("updated");
|
|
131
|
+
expect(await store.get("partition-a", "secret-1")).toMatchObject({ data: { name: "Lin" }, dataExpiresAt: at, dataVersion: 2 });
|
|
132
|
+
});
|
|
133
|
+
it("answers stale after a mark that wrote the deadline already stored, in the second a refresh read it", async () => {
|
|
134
|
+
// The record falls due this second, a read starts its refresh, and a
|
|
135
|
+
// revocation marks it due in that same second. The deadline reads
|
|
136
|
+
// exactly as the refresh read it, so a condition on the deadline
|
|
137
|
+
// would let the refresh land data derived before the revocation for
|
|
138
|
+
// a whole data TTL. Only the version shows the mark.
|
|
139
|
+
const store = await begin();
|
|
140
|
+
await store.create(sessionRecord({ dataExpiresAt: startSeconds }));
|
|
141
|
+
const read = (await store.get("partition-a", "secret-1"));
|
|
142
|
+
expect(await store.update("partition-a", "secret-1", { dataExpiresAt: startSeconds })).toBe("updated");
|
|
143
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Grace" }, dataExpiresAt: startSeconds + 600 }, { dataVersion: read.dataVersion })).toBe("stale");
|
|
144
|
+
expect(await store.get("partition-a", "secret-1")).toMatchObject({ data: { name: "Ada" }, dataExpiresAt: startSeconds });
|
|
145
|
+
});
|
|
146
|
+
it("answers stale after a second mark in the second of the first, which a refresh of the first did not see", async () => {
|
|
147
|
+
// The first revocation marks the record due, a read starts a refresh
|
|
148
|
+
// that sees only that one, and a second revocation marks it again in
|
|
149
|
+
// the same second. Both marks write the same deadline, so only the
|
|
150
|
+
// version tells the refresh that it is one revocation behind.
|
|
151
|
+
const store = await begin();
|
|
152
|
+
await store.create(sessionRecord({ dataExpiresAt: startSeconds + 600 }));
|
|
153
|
+
await store.update("partition-a", "secret-1", { dataExpiresAt: startSeconds });
|
|
154
|
+
const read = (await store.get("partition-a", "secret-1"));
|
|
155
|
+
await store.update("partition-a", "secret-1", { dataExpiresAt: startSeconds });
|
|
156
|
+
expect(await store.update("partition-a", "secret-1", { data: { name: "Grace" }, dataExpiresAt: startSeconds + 600 }, { dataVersion: read.dataVersion })).toBe("stale");
|
|
157
|
+
expect(await store.get("partition-a", "secret-1")).toMatchObject({ data: { name: "Ada" }, dataExpiresAt: startSeconds, dataVersion: 2 });
|
|
158
|
+
});
|
|
159
|
+
it("holds data with an undefined inside it the way JSON does, rather than refusing it", async () => {
|
|
160
|
+
const store = await begin();
|
|
161
|
+
await store.create(sessionRecord({ data: { name: "Ada", nickname: undefined } }));
|
|
162
|
+
await store.update("partition-a", "secret-1", { data: { name: "Grace", nickname: undefined } });
|
|
163
|
+
expect((await store.get("partition-a", "secret-1"))?.data).toEqual({ name: "Grace" });
|
|
164
|
+
});
|
|
165
|
+
};
|
package/dist/testing.d.ts
CHANGED
|
@@ -9,15 +9,29 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The sibling of `lambder/mock`, which serves a contract from mock handlers
|
|
11
11
|
* for frontend work. This one runs the real handlers.
|
|
12
|
+
*
|
|
13
|
+
* Beside the test app, the store conformance suites: the rules each store
|
|
14
|
+
* interface promises the engines, for an app to run against a store it
|
|
15
|
+
* writes itself.
|
|
12
16
|
*/
|
|
13
17
|
export { lambderTestApp } from "./testing/LambderTestApp.js";
|
|
14
18
|
export type { LambderTestApp, LambderTestAppOptions, LambderTestedInstance } from "./testing/LambderTestApp.js";
|
|
15
19
|
export type { LambderTestVisitor, LambderTestVisitorOptions, LambderTestRequestInit } from "./testing/LambderTestVisitor.js";
|
|
16
20
|
export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
17
21
|
export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
22
|
+
export { lambderSessionStoreConformance } from "./testing/lambderSessionStoreConformance.js";
|
|
23
|
+
export type { LambderSessionStoreConformanceOptions } from "./testing/lambderSessionStoreConformance.js";
|
|
24
|
+
export { lambderIdempotencyStoreConformance } from "./testing/lambderIdempotencyStoreConformance.js";
|
|
25
|
+
export type { LambderIdempotencyStoreConformanceOptions } from "./testing/lambderIdempotencyStoreConformance.js";
|
|
26
|
+
export { lambderRateLimiterConformance } from "./testing/lambderRateLimiterConformance.js";
|
|
27
|
+
export type { LambderRateLimiterConformanceOptions } from "./testing/lambderRateLimiterConformance.js";
|
|
28
|
+
export { lambderOneShotSecretStoreConformance } from "./testing/lambderOneShotSecretStoreConformance.js";
|
|
29
|
+
export type { LambderOneShotSecretStoreConformanceOptions } from "./testing/lambderOneShotSecretStoreConformance.js";
|
|
30
|
+
export type { LambderConformanceRunner, LambderConformanceIt, LambderConformanceExpect, LambderConformanceAssertion, LambderConformanceSetup, } from "./testing/LambderConformanceRunner.js";
|
|
18
31
|
export { LambderMemorySessionStore } from "./stores/LambderMemorySessionStore.js";
|
|
19
32
|
export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
|
|
20
33
|
export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
|
|
34
|
+
export { LambderMemoryOneShotSecretStore } from "./stores/LambderMemoryOneShotSecretStore.js";
|
|
21
35
|
export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
|
|
22
36
|
export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
|
|
23
37
|
export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
|
package/dist/testing.js
CHANGED
|
@@ -9,15 +9,27 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The sibling of `lambder/mock`, which serves a contract from mock handlers
|
|
11
11
|
* for frontend work. This one runs the real handlers.
|
|
12
|
+
*
|
|
13
|
+
* Beside the test app, the store conformance suites: the rules each store
|
|
14
|
+
* interface promises the engines, for an app to run against a store it
|
|
15
|
+
* writes itself.
|
|
12
16
|
*/
|
|
13
17
|
export { lambderTestApp } from "./testing/LambderTestApp.js";
|
|
14
18
|
export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
19
|
+
// The rules each store interface promises, as cases an app registers with its
|
|
20
|
+
// own runner's `it` and `expect` to hold a store it writes over its own
|
|
21
|
+
// database to the same rules Lambder's memory and DynamoDB stores meet.
|
|
22
|
+
export { lambderSessionStoreConformance } from "./testing/lambderSessionStoreConformance.js";
|
|
23
|
+
export { lambderIdempotencyStoreConformance } from "./testing/lambderIdempotencyStoreConformance.js";
|
|
24
|
+
export { lambderRateLimiterConformance } from "./testing/lambderRateLimiterConformance.js";
|
|
25
|
+
export { lambderOneShotSecretStoreConformance } from "./testing/lambderOneShotSecretStoreConformance.js";
|
|
15
26
|
// What a test reaches for beside the test app: the stores to inspect or to
|
|
16
27
|
// hand it, a file source over fixtures, the refusal codes to assert on, and
|
|
17
28
|
// the types of what a visitor and its session hand back.
|
|
18
29
|
export { LambderMemorySessionStore } from "./stores/LambderMemorySessionStore.js";
|
|
19
30
|
export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
|
|
20
31
|
export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
|
|
32
|
+
export { LambderMemoryOneShotSecretStore } from "./stores/LambderMemoryOneShotSecretStore.js";
|
|
21
33
|
export { LambderMemoryCache } from "./stores/LambderMemoryCache.js";
|
|
22
34
|
export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
|
|
23
35
|
export { LambderLocalFileSource } from "./stores/LambderLocalFileSource.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.3.1",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
|
|
6
6
|
"keywords": [
|