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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a value survives JSON unchanged, and so can be written into a
|
|
3
|
+
* generated module as the data it is: strings, finite numbers, booleans,
|
|
4
|
+
* null, and arrays and plain objects of them. Anything else (a function, a
|
|
5
|
+
* class instance such as a zod schema, a symbol, a bigint, NaN, undefined
|
|
6
|
+
* inside a container) is code or a shape JSON would silently rewrite, and
|
|
7
|
+
* the caller is told where it sits.
|
|
8
|
+
*/
|
|
9
|
+
export const assertPlainData = (value, subject) => {
|
|
10
|
+
const offence = findNonPlain(value, "");
|
|
11
|
+
if (offence === null)
|
|
12
|
+
return;
|
|
13
|
+
throw new Error(`Lambder: ${subject} must be plain data (strings, finite numbers, booleans, null, and arrays and plain objects of them) to be written as a value, but ${offence.path || "it"} is ${offence.kind}.`);
|
|
14
|
+
};
|
|
15
|
+
const findNonPlain = (value, path) => {
|
|
16
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
17
|
+
return null;
|
|
18
|
+
if (typeof value === "number")
|
|
19
|
+
return Number.isFinite(value) ? null : { path, kind: String(value) };
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
22
|
+
const offence = findNonPlain(value[index], `${path}[${index}]`);
|
|
23
|
+
if (offence)
|
|
24
|
+
return offence;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "object") {
|
|
29
|
+
const prototype = Object.getPrototypeOf(value);
|
|
30
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
31
|
+
return { path, kind: `an instance of ${value.constructor?.name || "a class"}` };
|
|
32
|
+
}
|
|
33
|
+
for (const [key, member] of Object.entries(value)) {
|
|
34
|
+
const offence = findNonPlain(member, path ? `${path}.${key}` : key);
|
|
35
|
+
if (offence)
|
|
36
|
+
return offence;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return { path, kind: typeof value === "function" ? "a function" : `a ${typeof value}` };
|
|
41
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text made safe to place inside an XML element or a quoted attribute: the
|
|
3
|
+
* five characters XML gives meaning to become numeric references. For the
|
|
4
|
+
* small documents storage speaks, a tag set a ticket carries and an error a
|
|
5
|
+
* memory bucket answers with, which LambderHtml's `xml` template would also
|
|
6
|
+
* escape but no module in wire/ may import.
|
|
7
|
+
*/
|
|
8
|
+
export declare const escapeXmlText: (text: string) => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text made safe to place inside an XML element or a quoted attribute: the
|
|
3
|
+
* five characters XML gives meaning to become numeric references. For the
|
|
4
|
+
* small documents storage speaks, a tag set a ticket carries and an error a
|
|
5
|
+
* memory bucket answers with, which LambderHtml's `xml` template would also
|
|
6
|
+
* escape but no module in wire/ may import.
|
|
7
|
+
*/
|
|
8
|
+
export const escapeXmlText = (text) => text.replace(/[<>&'"]/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared options of an app's APIs as plain data: what
|
|
3
|
+
* `lambder.apiOptionEntries()` reports and `writeApiOptions` (lambder/build)
|
|
4
|
+
* writes to a module a mock or a test imports instead of the server. The
|
|
5
|
+
* contract carries the same options as types; this is the same fact as a
|
|
6
|
+
* value, for the code that has to decide something at runtime with it (which
|
|
7
|
+
* declarations a mock restates, which APIs a test walks). A screen reads one
|
|
8
|
+
* guard's parameters instead (writeApiGuardParams), since this table names
|
|
9
|
+
* every endpoint.
|
|
10
|
+
*
|
|
11
|
+
* Declared here, below the contract and the engines, for the reason
|
|
12
|
+
* LambderApiOptionValues is: the generated module names these types through
|
|
13
|
+
* `lambder/client`, the instance method fills them, and neither may reach
|
|
14
|
+
* into `api/`. The three vocabularies the engines share with the entries
|
|
15
|
+
* (when a guard runs, what a budget spans, when a custom key is charged) are
|
|
16
|
+
* declared here too and re-exported by the engines that read them.
|
|
17
|
+
*/
|
|
18
|
+
import type { LambderApiMode, LambderGuardNamesIn } from "./LambderApiContract.js";
|
|
19
|
+
import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "./LambderApiOptionValues.js";
|
|
20
|
+
import type { LambderAppRefusalMessage } from "./LambderApiRefusal.js";
|
|
21
|
+
import type { LambderRateLimitPolicy } from "../contracts/LambderRateLimiter.js";
|
|
22
|
+
/**
|
|
23
|
+
* When a guard runs, relative to the API's input validation.
|
|
24
|
+
*
|
|
25
|
+
* - "beforeInputValidation" (default): an unauthorized caller learns nothing
|
|
26
|
+
* about the input, and no async refinement in the schema runs for it.
|
|
27
|
+
* - "afterInputValidation": for a guard that spends something on the
|
|
28
|
+
* request, such as a single-use captcha token, which a request refused for
|
|
29
|
+
* a mistyped field would otherwise waste. The API's input schema then runs
|
|
30
|
+
* for callers this guard would refuse, so keep lookups (an "email is free"
|
|
31
|
+
* refinement) out of it, in the handler.
|
|
32
|
+
*
|
|
33
|
+
* Guards run in their declared order within each, and the limits keyed by
|
|
34
|
+
* caller data are charged after both unless their policy says otherwise
|
|
35
|
+
* (LambderRateLimitChargeAt).
|
|
36
|
+
*/
|
|
37
|
+
export type LambderGuardRunAt = "beforeInputValidation" | "afterInputValidation";
|
|
38
|
+
/**
|
|
39
|
+
* What one rate-limit budget spans:
|
|
40
|
+
*
|
|
41
|
+
* - "perApi" (default): every API referencing the policy gets its own
|
|
42
|
+
* counter, so the windows are a per-API ceiling (three APIs referencing a
|
|
43
|
+
* 60/min policy allow one subject 180/min in total). An API may tune the
|
|
44
|
+
* windows in its declaration: `rateLimit: { name: { perMin: 20 } }`.
|
|
45
|
+
* - "perPolicy": every API referencing the policy shares ONE counter, so the
|
|
46
|
+
* windows are one combined budget (e.g. one per-email allowance across
|
|
47
|
+
* send, register, and reset). The policy IS the group: to give user APIs
|
|
48
|
+
* and report APIs separate shared budgets, declare two policies.
|
|
49
|
+
*/
|
|
50
|
+
export type LambderRateLimitBudget = "perApi" | "perPolicy";
|
|
51
|
+
/**
|
|
52
|
+
* When a custom-keyed policy is charged, relative to the guards and the input
|
|
53
|
+
* schema.
|
|
54
|
+
*
|
|
55
|
+
* - "afterGuards" (default): after every guard and the input schema passed.
|
|
56
|
+
* The key is a value the caller chose (an email in the payload), so a
|
|
57
|
+
* caller who never passes a captcha guard cannot spend a victim's budget
|
|
58
|
+
* and lock them out of reset, register and send-code.
|
|
59
|
+
* - "beforeGuards": before the guards and the input schema, so an attempt
|
|
60
|
+
* they refuse is counted too. For a limit on guessing a secret a guard or
|
|
61
|
+
* the schema checks (a one-time code checked by a guard, keyed per email):
|
|
62
|
+
* charged after them, a wrong guess is refused before it is ever counted.
|
|
63
|
+
* Pair it with an IP limit, since anyone may spend this budget.
|
|
64
|
+
*/
|
|
65
|
+
export type LambderRateLimitChargeAt = "beforeGuards" | "afterGuards";
|
|
66
|
+
/**
|
|
67
|
+
* One API as the generated module records it: its mode and its three
|
|
68
|
+
* declarative options exactly as written at registration. A guard's
|
|
69
|
+
* parameter is written as the JSON it is (a permission string, a list of
|
|
70
|
+
* them, a reason), which is what makes the module plain data: a parameter
|
|
71
|
+
* that is code or a class instance fails the write and names the API.
|
|
72
|
+
*/
|
|
73
|
+
export type LambderApiOptionEntry = {
|
|
74
|
+
mode: LambderApiMode;
|
|
75
|
+
guards?: LambderGuardsOptionValue;
|
|
76
|
+
rateLimit?: LambderRateLimitOptionValue;
|
|
77
|
+
idempotency?: LambderApiIdempotencyOption;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* One rate-limit policy as the generated module records it: its windows,
|
|
81
|
+
* budget, charge point and message as declared, and its key reduced to what
|
|
82
|
+
* it counts. A policy keyed by a handler of the app's is `per: "custom"` and
|
|
83
|
+
* nothing more: the handler is code, and never written. A policy with no
|
|
84
|
+
* `per` is charged by code with a key of its own and has none here either.
|
|
85
|
+
*/
|
|
86
|
+
export type LambderRateLimitPolicyEntry = LambderRateLimitPolicy & {
|
|
87
|
+
per?: "ip" | "session" | "custom";
|
|
88
|
+
budget?: LambderRateLimitBudget;
|
|
89
|
+
chargeAt?: LambderRateLimitChargeAt;
|
|
90
|
+
errorMessage?: LambderAppRefusalMessage;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* One guard as the generated module records it: how it is fed (a slice of
|
|
94
|
+
* the API's own payload, a value the client sends separately, or nothing),
|
|
95
|
+
* whether it needs a session, and when it runs. The schema behind an input
|
|
96
|
+
* mode is never written; the mode alone is what a mock guard standing in for
|
|
97
|
+
* it has to match.
|
|
98
|
+
*/
|
|
99
|
+
export type LambderGuardDeclarationEntry = {
|
|
100
|
+
input: "apiInput" | "guardInput" | "none";
|
|
101
|
+
session: boolean;
|
|
102
|
+
runAt: LambderGuardRunAt;
|
|
103
|
+
};
|
|
104
|
+
/** Everything `lambder.apiOptionEntries()` reports, and the three tables the generated module exports. */
|
|
105
|
+
export type LambderApiOptionEntries = {
|
|
106
|
+
apis: Record<string, LambderApiOptionEntry>;
|
|
107
|
+
rateLimitPolicies: Record<string, LambderRateLimitPolicyEntry>;
|
|
108
|
+
guards: Record<string, LambderGuardDeclarationEntry>;
|
|
109
|
+
};
|
|
110
|
+
/** The names of the APIs in a generated `apiOptions` table whose guards option names guard N, in any of its three forms and whatever else it declares beside it. */
|
|
111
|
+
export type LambderApisWithGuard<TOptions, N extends string> = {
|
|
112
|
+
[K in keyof TOptions]: TOptions[K] extends {
|
|
113
|
+
guards: infer G;
|
|
114
|
+
} ? (N extends LambderGuardNamesIn<G> ? K : never) : never;
|
|
115
|
+
}[keyof TOptions] & string;
|
|
116
|
+
/**
|
|
117
|
+
* The names of the APIs whose guards option is exactly TGuards: the APIs
|
|
118
|
+
* behind `"platformAdmin"` alone, say, and not those that declare it beside
|
|
119
|
+
* another guard. For a list a test loops over, checked against the table in
|
|
120
|
+
* both directions.
|
|
121
|
+
*/
|
|
122
|
+
export type LambderApisGuardedBy<TOptions, TGuards> = {
|
|
123
|
+
[K in keyof TOptions]: TOptions[K] extends {
|
|
124
|
+
guards: infer G;
|
|
125
|
+
} ? ([G] extends [TGuards] ? K : never) : never;
|
|
126
|
+
}[keyof TOptions] & string;
|
|
127
|
+
/** The names of the APIs of one mode in a generated `apiOptions` table. */
|
|
128
|
+
export type LambderApisWithMode<TOptions, M extends LambderApiMode> = {
|
|
129
|
+
[K in keyof TOptions]: TOptions[K] extends {
|
|
130
|
+
mode: M;
|
|
131
|
+
} ? K : never;
|
|
132
|
+
}[keyof TOptions] & string;
|
|
133
|
+
/**
|
|
134
|
+
* The parameter an API's guards option gives guard N: the value in the map
|
|
135
|
+
* form, `true` for a guard named without one (the string and list forms, or
|
|
136
|
+
* `true` in the map), and `undefined` for an API that does not declare it.
|
|
137
|
+
*/
|
|
138
|
+
export type LambderGuardParamOf<TEntry, N extends string> = TEntry extends {
|
|
139
|
+
guards: infer G;
|
|
140
|
+
} ? G extends string ? (N extends G ? true : undefined) : G extends readonly string[] ? (N extends G[number] ? true : undefined) : N extends keyof G ? G[N] : undefined : undefined;
|
|
141
|
+
/**
|
|
142
|
+
* The parameter an API's guards option gives guard N, read off a generated
|
|
143
|
+
* `apiOptions` table with the type the table pins: the literal a permission
|
|
144
|
+
* was declared as, `true` for a guard named without a parameter, and
|
|
145
|
+
* `undefined` when the API does not declare the guard. What a test or a mock
|
|
146
|
+
* reads instead of the source.
|
|
147
|
+
*/
|
|
148
|
+
export declare const apiGuardParam: <TOptions extends Record<string, LambderApiOptionEntry>, K extends keyof TOptions & string, N extends string>(options: TOptions, name: K, guard: N) => LambderGuardParamOf<TOptions[K], N>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared options of an app's APIs as plain data: what
|
|
3
|
+
* `lambder.apiOptionEntries()` reports and `writeApiOptions` (lambder/build)
|
|
4
|
+
* writes to a module a mock or a test imports instead of the server. The
|
|
5
|
+
* contract carries the same options as types; this is the same fact as a
|
|
6
|
+
* value, for the code that has to decide something at runtime with it (which
|
|
7
|
+
* declarations a mock restates, which APIs a test walks). A screen reads one
|
|
8
|
+
* guard's parameters instead (writeApiGuardParams), since this table names
|
|
9
|
+
* every endpoint.
|
|
10
|
+
*
|
|
11
|
+
* Declared here, below the contract and the engines, for the reason
|
|
12
|
+
* LambderApiOptionValues is: the generated module names these types through
|
|
13
|
+
* `lambder/client`, the instance method fills them, and neither may reach
|
|
14
|
+
* into `api/`. The three vocabularies the engines share with the entries
|
|
15
|
+
* (when a guard runs, what a budget spans, when a custom key is charged) are
|
|
16
|
+
* declared here too and re-exported by the engines that read them.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The parameter an API's guards option gives guard N, read off a generated
|
|
20
|
+
* `apiOptions` table with the type the table pins: the literal a permission
|
|
21
|
+
* was declared as, `true` for a guard named without a parameter, and
|
|
22
|
+
* `undefined` when the API does not declare the guard. What a test or a mock
|
|
23
|
+
* reads instead of the source.
|
|
24
|
+
*/
|
|
25
|
+
export const apiGuardParam = (options, name, guard) => {
|
|
26
|
+
const guards = options[name]?.guards;
|
|
27
|
+
let param;
|
|
28
|
+
if (typeof guards === "string")
|
|
29
|
+
param = guards === guard ? true : undefined;
|
|
30
|
+
else if (Array.isArray(guards))
|
|
31
|
+
param = guards.includes(guard) ? true : undefined;
|
|
32
|
+
else if (guards !== undefined && Object.prototype.hasOwnProperty.call(guards, guard))
|
|
33
|
+
param = guards[guard];
|
|
34
|
+
return param;
|
|
35
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { contentDispositionHeader } from "../util/LambderContentDisposition.js";
|
|
2
|
-
|
|
2
|
+
import { escapeXmlText } from "../util/escapeXmlText.js";
|
|
3
3
|
/**
|
|
4
4
|
* What a ticket's form carries for the stored object, in the fields S3's
|
|
5
5
|
* presigned POST reads them from: the tag set as the XML `tagging` field,
|
|
@@ -12,7 +12,7 @@ export const uploadObjectFormFields = (object) => {
|
|
|
12
12
|
const fields = {};
|
|
13
13
|
const tags = Object.entries(object?.tags ?? {});
|
|
14
14
|
if (tags.length) {
|
|
15
|
-
fields.tagging = `<Tagging><TagSet>${tags.map(([key, value]) => `<Tag><Key>${
|
|
15
|
+
fields.tagging = `<Tagging><TagSet>${tags.map(([key, value]) => `<Tag><Key>${escapeXmlText(key)}</Key><Value>${escapeXmlText(value)}</Value></Tag>`).join("")}</TagSet></Tagging>`;
|
|
16
16
|
}
|
|
17
17
|
for (const [name, value] of Object.entries(object?.metadata ?? {}))
|
|
18
18
|
fields[`x-amz-meta-${name.toLowerCase()}`] = value;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import type { LambderOneShotIssueOutcome, LambderOneShotSecretDraft, LambderOneShotSecretRecord, LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
|
|
3
|
+
export interface LambderDdbOneShotSecretStoreOptions {
|
|
4
|
+
tableName: string;
|
|
5
|
+
/** Region the client is created for on first use; the SDK's default chain otherwise. */
|
|
6
|
+
region?: string;
|
|
7
|
+
/** Partition key prefix, keeps secrets separated from other systems in a shared table. Default: "OTS". */
|
|
8
|
+
keyPrefix?: string;
|
|
9
|
+
client?: DynamoDBClient;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One-shot secrets in DynamoDB, under the store's prefix, so the table can be
|
|
13
|
+
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbIdempotencyStore
|
|
14
|
+
* (`IDEM#`):
|
|
15
|
+
*
|
|
16
|
+
* - `<prefix>#scope#<scope>` / `secret`: the scope's current record. Issuing
|
|
17
|
+
* writes it over whatever was there, which is how the older secret is
|
|
18
|
+
* retired in the same act; a cooldown is a condition on that write
|
|
19
|
+
* (`issuedAt <= :threshold`), so of two callers racing past it exactly one
|
|
20
|
+
* is issued and the other reads when the winner was.
|
|
21
|
+
* - `<prefix>#digest#<digest>` / `secret`, for a token alone: the scope the
|
|
22
|
+
* digest belongs to, which is how a token is found by its value. A code is
|
|
23
|
+
* found through its scope and has none. A token's two items are written in
|
|
24
|
+
* one transaction, the digest item conditioned on being free or already the
|
|
25
|
+
* scope's (`attribute_not_exists(pk) OR #scope = :scope`), so no two scopes
|
|
26
|
+
* share a digest and no issue leaves a record without its digest. A digest
|
|
27
|
+
* of a secret the scope has since replaced points at a record whose digest
|
|
28
|
+
* differs, and finds nothing; it stays claimed until TTL removes it, and a
|
|
29
|
+
* token drawn onto it is drawn again.
|
|
30
|
+
*
|
|
31
|
+
* A try is counted with a conditional `ADD`, on the record named and no
|
|
32
|
+
* other, and the item comes back with the count already spent; a consume is a
|
|
33
|
+
* conditional delete of the record named. Both are one request, which is what
|
|
34
|
+
* makes them safe against a second caller. Every write DynamoDB refuses only
|
|
35
|
+
* because a transaction held its item at that moment is sent again, up to
|
|
36
|
+
* three times, since the SDK does not retry that refusal and nothing was
|
|
37
|
+
* written. Items carry `expiresAt` for
|
|
38
|
+
* DynamoDB TTL; the class decides expiry itself, since TTL deletion is lazy,
|
|
39
|
+
* and an item TTL has not yet retired is what lets it answer "expired" rather
|
|
40
|
+
* than "none".
|
|
41
|
+
*/
|
|
42
|
+
export declare class LambderDdbOneShotSecretStore implements LambderOneShotSecretStore {
|
|
43
|
+
readonly tableName: string;
|
|
44
|
+
readonly keyPrefix: string;
|
|
45
|
+
private readonly ready;
|
|
46
|
+
constructor(options: LambderDdbOneShotSecretStoreOptions);
|
|
47
|
+
private scopeKey;
|
|
48
|
+
private digestKey;
|
|
49
|
+
/**
|
|
50
|
+
* A stored item as a record, with every field checked rather than cast: an
|
|
51
|
+
* item another writer left in a shared table, or a partial write, is a
|
|
52
|
+
* record to refuse rather than to trust.
|
|
53
|
+
*/
|
|
54
|
+
private static recordOf;
|
|
55
|
+
issue(draft: LambderOneShotSecretDraft, { unlessIssuedAfter }: {
|
|
56
|
+
unlessIssuedAfter?: number;
|
|
57
|
+
}): Promise<LambderOneShotIssueOutcome>;
|
|
58
|
+
findByScope(scope: string): Promise<LambderOneShotSecretRecord | null>;
|
|
59
|
+
findByDigest(digest: string): Promise<LambderOneShotSecretRecord | null>;
|
|
60
|
+
attempt(scope: string, id: string): Promise<LambderOneShotSecretRecord | null>;
|
|
61
|
+
consume(scope: string, id: string): Promise<boolean>;
|
|
62
|
+
retire(scope: string): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
export default LambderDdbOneShotSecretStore;
|
|
@@ -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
|
+
}
|