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.
- package/CHANGELOG.md +136 -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/generatedTables.d.ts +72 -0
- package/dist/build/generatedTables.js +99 -0
- 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 +12 -21
- 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/wire/LambderApiOptionEntries.d.ts +148 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
- 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/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,266 @@
|
|
|
1
|
+
import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
|
|
2
|
+
import { randomSecret } from "../shared/util/LambderSignedClaims.js";
|
|
3
|
+
/**
|
|
4
|
+
* Why each item of a cancelled transaction was refused, in the order the
|
|
5
|
+
* items were sent, or null when the error is not a cancelled transaction.
|
|
6
|
+
*/
|
|
7
|
+
const transactionCancellationReasons = (error) => {
|
|
8
|
+
if (!error || typeof error !== "object" || error.name !== "TransactionCanceledException")
|
|
9
|
+
return null;
|
|
10
|
+
const reasons = error.CancellationReasons;
|
|
11
|
+
return Array.isArray(reasons) ? reasons : [];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Whether DynamoDB refused a write only because another transaction held one
|
|
15
|
+
* of its items at that moment, so nothing was written and the write can be
|
|
16
|
+
* sent again: a cancelled transaction whose reasons name a conflict and no
|
|
17
|
+
* failed condition (a condition's answer stands), or a single-item write
|
|
18
|
+
* refused while a transaction held its item.
|
|
19
|
+
*/
|
|
20
|
+
const isWriteConflict = (error) => {
|
|
21
|
+
if (error?.name === "TransactionConflictException")
|
|
22
|
+
return true;
|
|
23
|
+
const reasons = transactionCancellationReasons(error);
|
|
24
|
+
return !!reasons
|
|
25
|
+
&& reasons.some((reason) => reason.Code === "TransactionConflict")
|
|
26
|
+
&& !reasons.some((reason) => reason.Code === "ConditionalCheckFailed");
|
|
27
|
+
};
|
|
28
|
+
/** How many times a write refused for a conflict is sent, in all. */
|
|
29
|
+
const CONFLICT_ATTEMPTS = 3;
|
|
30
|
+
/**
|
|
31
|
+
* Sends a write, and again when DynamoDB refused it for a conflict, which the
|
|
32
|
+
* SDK does not retry: two issues for one scope at once (a resend tapped
|
|
33
|
+
* twice), or two scopes racing for one digest. A short random pause first,
|
|
34
|
+
* so two writers that met do not meet again in step. Past the last attempt
|
|
35
|
+
* the conflict is thrown.
|
|
36
|
+
*/
|
|
37
|
+
const sendRetryingConflicts = async (write) => {
|
|
38
|
+
for (let attempt = 1;; attempt += 1) {
|
|
39
|
+
try {
|
|
40
|
+
return await write();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (attempt >= CONFLICT_ATTEMPTS || !isWriteConflict(error))
|
|
44
|
+
throw error;
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 10 + Math.random() * 40 * attempt));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** A number attribute as stored, or the fallback when it is missing or not a number, since NaN compares false to everything. */
|
|
50
|
+
const storedNumber = (raw, fallback) => {
|
|
51
|
+
const value = Number(raw);
|
|
52
|
+
return Number.isFinite(value) ? value : fallback;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* One-shot secrets in DynamoDB, under the store's prefix, so the table can be
|
|
56
|
+
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbIdempotencyStore
|
|
57
|
+
* (`IDEM#`):
|
|
58
|
+
*
|
|
59
|
+
* - `<prefix>#scope#<scope>` / `secret`: the scope's current record. Issuing
|
|
60
|
+
* writes it over whatever was there, which is how the older secret is
|
|
61
|
+
* retired in the same act; a cooldown is a condition on that write
|
|
62
|
+
* (`issuedAt <= :threshold`), so of two callers racing past it exactly one
|
|
63
|
+
* is issued and the other reads when the winner was.
|
|
64
|
+
* - `<prefix>#digest#<digest>` / `secret`, for a token alone: the scope the
|
|
65
|
+
* digest belongs to, which is how a token is found by its value. A code is
|
|
66
|
+
* found through its scope and has none. A token's two items are written in
|
|
67
|
+
* one transaction, the digest item conditioned on being free or already the
|
|
68
|
+
* scope's (`attribute_not_exists(pk) OR #scope = :scope`), so no two scopes
|
|
69
|
+
* share a digest and no issue leaves a record without its digest. A digest
|
|
70
|
+
* of a secret the scope has since replaced points at a record whose digest
|
|
71
|
+
* differs, and finds nothing; it stays claimed until TTL removes it, and a
|
|
72
|
+
* token drawn onto it is drawn again.
|
|
73
|
+
*
|
|
74
|
+
* A try is counted with a conditional `ADD`, on the record named and no
|
|
75
|
+
* other, and the item comes back with the count already spent; a consume is a
|
|
76
|
+
* conditional delete of the record named. Both are one request, which is what
|
|
77
|
+
* makes them safe against a second caller. Every write DynamoDB refuses only
|
|
78
|
+
* because a transaction held its item at that moment is sent again, up to
|
|
79
|
+
* three times, since the SDK does not retry that refusal and nothing was
|
|
80
|
+
* written. Items carry `expiresAt` for
|
|
81
|
+
* DynamoDB TTL; the class decides expiry itself, since TTL deletion is lazy,
|
|
82
|
+
* and an item TTL has not yet retired is what lets it answer "expired" rather
|
|
83
|
+
* than "none".
|
|
84
|
+
*/
|
|
85
|
+
export class LambderDdbOneShotSecretStore {
|
|
86
|
+
tableName;
|
|
87
|
+
keyPrefix;
|
|
88
|
+
ready;
|
|
89
|
+
constructor(options) {
|
|
90
|
+
if (!options.tableName.trim())
|
|
91
|
+
throw new Error("tableName is required");
|
|
92
|
+
this.tableName = options.tableName;
|
|
93
|
+
this.keyPrefix = options.keyPrefix ?? "OTS";
|
|
94
|
+
this.ready = createDynamoClientLoader({ user: "LambderDdbOneShotSecretStore", region: options.region, client: options.client });
|
|
95
|
+
}
|
|
96
|
+
scopeKey(scope) {
|
|
97
|
+
return {
|
|
98
|
+
pk: { S: assertPartitionKeyFits({ user: "LambderDdbOneShotSecretStore", what: "scope", partitionKey: `${this.keyPrefix}#scope#${scope}`, remedy: "Name the scope with an identifier rather than the data itself." }) },
|
|
99
|
+
sk: { S: "secret" },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
digestKey(digest) {
|
|
103
|
+
return { pk: { S: `${this.keyPrefix}#digest#${digest}` }, sk: { S: "secret" } };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A stored item as a record, with every field checked rather than cast: an
|
|
107
|
+
* item another writer left in a shared table, or a partial write, is a
|
|
108
|
+
* record to refuse rather than to trust.
|
|
109
|
+
*/
|
|
110
|
+
static recordOf(item) {
|
|
111
|
+
const id = item?.id?.S;
|
|
112
|
+
const kind = item?.kind?.S;
|
|
113
|
+
const scope = item?.scope?.S;
|
|
114
|
+
const digest = item?.digest?.S;
|
|
115
|
+
if (!item || !id || !kind || scope === undefined || !digest)
|
|
116
|
+
return null;
|
|
117
|
+
let meta = {};
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(item.metaJson?.S ?? "{}");
|
|
120
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
121
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
122
|
+
if (key !== "__proto__" && typeof value === "string")
|
|
123
|
+
meta[key] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
meta = {};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
id, kind, scope, digest,
|
|
132
|
+
issuedAt: storedNumber(item.issuedAt?.N, 0),
|
|
133
|
+
expiresAt: storedNumber(item.expiresAt?.N, 0),
|
|
134
|
+
attempts: storedNumber(item.attempts?.N, 0),
|
|
135
|
+
meta,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async issue(draft, { unlessIssuedAfter }) {
|
|
139
|
+
const { client, sdk } = await this.ready();
|
|
140
|
+
const id = randomSecret(12);
|
|
141
|
+
const scopePut = {
|
|
142
|
+
TableName: this.tableName,
|
|
143
|
+
Item: {
|
|
144
|
+
...this.scopeKey(draft.scope),
|
|
145
|
+
id: { S: id },
|
|
146
|
+
kind: { S: draft.kind },
|
|
147
|
+
scope: { S: draft.scope },
|
|
148
|
+
digest: { S: draft.digest },
|
|
149
|
+
issuedAt: { N: String(draft.issuedAt) },
|
|
150
|
+
expiresAt: { N: String(draft.expiresAt) },
|
|
151
|
+
attempts: { N: "0" },
|
|
152
|
+
metaJson: { S: JSON.stringify(draft.meta) },
|
|
153
|
+
},
|
|
154
|
+
// The cooldown, when there is one: an item with no issuedAt
|
|
155
|
+
// (another writer's, or a partial write) never blocks a scope.
|
|
156
|
+
...(unlessIssuedAfter === undefined ? {} : {
|
|
157
|
+
ConditionExpression: "attribute_not_exists(pk) OR attribute_not_exists(issuedAt) OR issuedAt <= :threshold",
|
|
158
|
+
ExpressionAttributeValues: { ":threshold": { N: String(unlessIssuedAfter) } },
|
|
159
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
// Refused with no item to show for it: gone again by the time the
|
|
163
|
+
// condition was read; the next call resolves it. Reported as issued
|
|
164
|
+
// this very second, so the caller waits the whole cooldown.
|
|
165
|
+
const cooldownRefusal = (refusedBy) => ({ issued: false, refused: "cooldown", issuedAt: storedNumber(refusedBy?.issuedAt?.N, draft.issuedAt) });
|
|
166
|
+
if (draft.shape === "code") {
|
|
167
|
+
try {
|
|
168
|
+
await sendRetryingConflicts(() => client.send(new sdk.PutItemCommand(scopePut)));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (!isConditionalCheckFailure(error))
|
|
172
|
+
throw error;
|
|
173
|
+
return cooldownRefusal(error.Item);
|
|
174
|
+
}
|
|
175
|
+
return { issued: true, id };
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await sendRetryingConflicts(() => client.send(new sdk.TransactWriteItemsCommand({
|
|
179
|
+
TransactItems: [
|
|
180
|
+
{ Put: scopePut },
|
|
181
|
+
{
|
|
182
|
+
Put: {
|
|
183
|
+
TableName: this.tableName,
|
|
184
|
+
Item: { ...this.digestKey(draft.digest), scope: { S: draft.scope }, expiresAt: { N: String(draft.expiresAt) } },
|
|
185
|
+
ConditionExpression: "attribute_not_exists(pk) OR #scope = :scope",
|
|
186
|
+
ExpressionAttributeNames: { "#scope": "scope" },
|
|
187
|
+
ExpressionAttributeValues: { ":scope": { S: draft.scope } },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
})));
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
const reasons = transactionCancellationReasons(error);
|
|
195
|
+
if (!reasons)
|
|
196
|
+
throw error;
|
|
197
|
+
// The scope's own condition first: a scope inside its cooldown is
|
|
198
|
+
// refused as that, whatever its draw collided with.
|
|
199
|
+
if (reasons[0]?.Code === "ConditionalCheckFailed")
|
|
200
|
+
return cooldownRefusal(reasons[0].Item);
|
|
201
|
+
if (reasons[1]?.Code === "ConditionalCheckFailed")
|
|
202
|
+
return { issued: false, refused: "digestTaken" };
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
return { issued: true, id };
|
|
206
|
+
}
|
|
207
|
+
async findByScope(scope) {
|
|
208
|
+
const { client, sdk } = await this.ready();
|
|
209
|
+
// Strongly consistent: a redeem that follows an issue by milliseconds
|
|
210
|
+
// has to see the record the issue wrote.
|
|
211
|
+
const found = await client.send(new sdk.GetItemCommand({ TableName: this.tableName, Key: this.scopeKey(scope), ConsistentRead: true }));
|
|
212
|
+
return LambderDdbOneShotSecretStore.recordOf(found.Item);
|
|
213
|
+
}
|
|
214
|
+
async findByDigest(digest) {
|
|
215
|
+
const { client, sdk } = await this.ready();
|
|
216
|
+
const lookup = await client.send(new sdk.GetItemCommand({ TableName: this.tableName, Key: this.digestKey(digest), ConsistentRead: true }));
|
|
217
|
+
const scope = lookup.Item?.scope?.S;
|
|
218
|
+
if (scope === undefined)
|
|
219
|
+
return null;
|
|
220
|
+
const record = await this.findByScope(scope);
|
|
221
|
+
return record && record.digest === digest ? record : null;
|
|
222
|
+
}
|
|
223
|
+
async attempt(scope, id) {
|
|
224
|
+
const { client, sdk } = await this.ready();
|
|
225
|
+
try {
|
|
226
|
+
const counted = await sendRetryingConflicts(() => client.send(new sdk.UpdateItemCommand({
|
|
227
|
+
TableName: this.tableName,
|
|
228
|
+
Key: this.scopeKey(scope),
|
|
229
|
+
UpdateExpression: "ADD attempts :one",
|
|
230
|
+
ConditionExpression: "id = :id",
|
|
231
|
+
ExpressionAttributeValues: { ":one": { N: "1" }, ":id": { S: id } },
|
|
232
|
+
ReturnValues: "ALL_NEW",
|
|
233
|
+
})));
|
|
234
|
+
return LambderDdbOneShotSecretStore.recordOf(counted.Attributes);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (!isConditionalCheckFailure(error))
|
|
238
|
+
throw error;
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async consume(scope, id) {
|
|
243
|
+
const { client, sdk } = await this.ready();
|
|
244
|
+
try {
|
|
245
|
+
await sendRetryingConflicts(() => client.send(new sdk.DeleteItemCommand({
|
|
246
|
+
TableName: this.tableName,
|
|
247
|
+
Key: this.scopeKey(scope),
|
|
248
|
+
ConditionExpression: "id = :id",
|
|
249
|
+
ExpressionAttributeValues: { ":id": { S: id } },
|
|
250
|
+
})));
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (!isConditionalCheckFailure(error))
|
|
255
|
+
throw error;
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async retire(scope) {
|
|
260
|
+
const { client, sdk } = await this.ready();
|
|
261
|
+
// The digest item is left to TTL: it points at a scope whose record
|
|
262
|
+
// is gone or replaced, and finds nothing either way.
|
|
263
|
+
await sendRetryingConflicts(() => client.send(new sdk.DeleteItemCommand({ TableName: this.tableName, Key: this.scopeKey(scope) })));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export default LambderDdbOneShotSecretStore;
|
|
@@ -11,8 +11,9 @@ type MemoryIdempotencyRecord = {
|
|
|
11
11
|
* Idempotency records held in memory: the same claim, settle and replay
|
|
12
12
|
* semantics as LambderDdbIdempotencyStore (owner tokens, pending expiry, lost
|
|
13
13
|
* claims as silent no-ops), over a LambderExpiringMap in place of the table
|
|
14
|
-
* and its TTL. For tests and for the mock runtime;
|
|
15
|
-
* drives this and the DynamoDB store
|
|
14
|
+
* and its TTL. For tests and for the mock runtime; the conformance suite
|
|
15
|
+
* (lambderIdempotencyStoreConformance) drives this and the DynamoDB store
|
|
16
|
+
* through one set of rules.
|
|
16
17
|
*
|
|
17
18
|
* `maxBodyBytes` stands in for the DynamoDB item budget, so the "too-large"
|
|
18
19
|
* path can be exercised; unbounded by default. `now` is injectable so a test
|
|
@@ -3,8 +3,9 @@ import { LambderExpiringMap, LambderExpiringMapFullError } from "../shared/util/
|
|
|
3
3
|
* Idempotency records held in memory: the same claim, settle and replay
|
|
4
4
|
* semantics as LambderDdbIdempotencyStore (owner tokens, pending expiry, lost
|
|
5
5
|
* claims as silent no-ops), over a LambderExpiringMap in place of the table
|
|
6
|
-
* and its TTL. For tests and for the mock runtime;
|
|
7
|
-
* drives this and the DynamoDB store
|
|
6
|
+
* and its TTL. For tests and for the mock runtime; the conformance suite
|
|
7
|
+
* (lambderIdempotencyStoreConformance) drives this and the DynamoDB store
|
|
8
|
+
* through one set of rules.
|
|
8
9
|
*
|
|
9
10
|
* `maxBodyBytes` stands in for the DynamoDB item budget, so the "too-large"
|
|
10
11
|
* path can be exercised; unbounded by default. `now` is injectable so a test
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LambderOneShotIssueOutcome, LambderOneShotSecretDraft, LambderOneShotSecretRecord, LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
|
|
2
|
+
/**
|
|
3
|
+
* One-shot secrets held in memory: the same one-record-per-scope, count-then-
|
|
4
|
+
* compare and consume-once semantics as LambderDdbOneShotSecretStore, over a
|
|
5
|
+
* LambderExpiringMap in place of the table and its TTL. For tests and for an
|
|
6
|
+
* app's development runtime; lambderOneShotSecretStoreConformance drives
|
|
7
|
+
* this and the DynamoDB store through one set of rules.
|
|
8
|
+
*
|
|
9
|
+
* Two maps, as the table holds two items per secret: the scope's current
|
|
10
|
+
* record, and the scope a digest belongs to, which is how a token is found by
|
|
11
|
+
* its value. `now` is injectable so a test can expire a secret without
|
|
12
|
+
* waiting.
|
|
13
|
+
*/
|
|
14
|
+
export declare class LambderMemoryOneShotSecretStore implements LambderOneShotSecretStore {
|
|
15
|
+
private readonly records;
|
|
16
|
+
private readonly scopesByDigest;
|
|
17
|
+
private idCounter;
|
|
18
|
+
constructor(options?: {
|
|
19
|
+
now?: () => number;
|
|
20
|
+
maxEntries?: number;
|
|
21
|
+
});
|
|
22
|
+
/** A record as the class reads it: a copy, so a caller writing onto what it got back cannot rewrite the record. */
|
|
23
|
+
private static copyOf;
|
|
24
|
+
issue(draft: LambderOneShotSecretDraft, { unlessIssuedAfter }: {
|
|
25
|
+
unlessIssuedAfter?: number;
|
|
26
|
+
}): Promise<LambderOneShotIssueOutcome>;
|
|
27
|
+
findByScope(scope: string): Promise<LambderOneShotSecretRecord | null>;
|
|
28
|
+
findByDigest(digest: string): Promise<LambderOneShotSecretRecord | null>;
|
|
29
|
+
attempt(scope: string, id: string): Promise<LambderOneShotSecretRecord | null>;
|
|
30
|
+
consume(scope: string, id: string): Promise<boolean>;
|
|
31
|
+
retire(scope: string): Promise<void>;
|
|
32
|
+
/** Number of live records held. */
|
|
33
|
+
get size(): number;
|
|
34
|
+
/** Forgets every record. */
|
|
35
|
+
reset(): void;
|
|
36
|
+
}
|
|
@@ -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;
|