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
|
@@ -186,8 +186,26 @@ type LambderMockInputPin<C, K extends keyof C, TSchema extends z.ZodType> = [
|
|
|
186
186
|
}) : {
|
|
187
187
|
"LambderMockApp: this input schema takes something else than the endpoint's contract input": LambderMockInputOf<C, K>;
|
|
188
188
|
};
|
|
189
|
-
/**
|
|
190
|
-
|
|
189
|
+
/**
|
|
190
|
+
* The three declaration fields of an entry on a mock created with the
|
|
191
|
+
* generated `apiOptions` table: absent, because the runtime reads them off
|
|
192
|
+
* the table. A restatement beside the table would be a second copy of the
|
|
193
|
+
* server's declaration, so writing one is an error rather than an override.
|
|
194
|
+
*/
|
|
195
|
+
type LambderMockDerivedFields = {
|
|
196
|
+
/** Read off the apiOptions table given to create(); not restated. */
|
|
197
|
+
guards?: never;
|
|
198
|
+
/** Read off the apiOptions table given to create(); not restated. */
|
|
199
|
+
rateLimit?: never;
|
|
200
|
+
/** Read off the apiOptions table given to create(); not restated. */
|
|
201
|
+
idempotency?: never;
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* An entry written in full: the handler, and the declarations restated and
|
|
205
|
+
* pinned to the contract, or, with `TDerived` (a mock created with the
|
|
206
|
+
* generated `apiOptions` table), the handler alone.
|
|
207
|
+
*/
|
|
208
|
+
export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = (TDerived extends true ? LambderMockDerivedFields : LambderMockGuardsField<C, K> & LambderMockRateLimitField<C, K> & LambderMockIdempotencyField<C, K>) & {
|
|
191
209
|
/**
|
|
192
210
|
* A schema to validate the posted payload against, so the mock answers
|
|
193
211
|
* 422 exactly as the server would. Optional, and the mock's own: the
|
|
@@ -201,15 +219,16 @@ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema ext
|
|
|
201
219
|
handler: LambderMockHandler<C, K, S, G>;
|
|
202
220
|
};
|
|
203
221
|
/**
|
|
204
|
-
* What publicApi/sessionApi accept
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
222
|
+
* What publicApi/sessionApi accept. On a mock created with the generated
|
|
223
|
+
* `apiOptions` table (`TDerived`), a bare handler or the options without the
|
|
224
|
+
* declarations, for every endpoint. Otherwise a bare handler only for an
|
|
225
|
+
* endpoint the contract declares nothing for, the full options elsewhere, so
|
|
226
|
+
* the form that cannot carry a restatement is unavailable exactly where one
|
|
227
|
+
* is owed. Keyed on all three declarations: keyed on guards alone, a
|
|
228
|
+
* guardless endpoint could drop its rate limit and idempotency through the
|
|
229
|
+
* bare form.
|
|
209
230
|
*/
|
|
210
|
-
export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType> = [
|
|
211
|
-
LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>
|
|
212
|
-
] extends [never] ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema> : LambderMockEntryOptions<C, K, S, G, TInputSchema>;
|
|
231
|
+
export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = TDerived extends true ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema, true> : [LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>] extends [never] ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema> : LambderMockEntryOptions<C, K, S, G, TInputSchema>;
|
|
213
232
|
/** One registry entry: the endpoint's definition as the pipeline runs it, and its handler (null when registered as not mocked). */
|
|
214
233
|
export type LambderMockEntry<C, K extends keyof C & string> = {
|
|
215
234
|
readonly name: K;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { LambderRateLimitKeyFn } from "../api/LambderApiRateLimits.js";
|
|
2
|
+
import type { LambderRateLimitPolicyEntry } from "../shared/wire/LambderApiOptionEntries.js";
|
|
3
|
+
/** The names of the policies in a generated table whose key the app derives, and so the ones a mock has to supply a key handler for. */
|
|
4
|
+
export type LambderCustomKeyedPolicyNames<TPolicies> = {
|
|
5
|
+
[N in keyof TPolicies]: TPolicies[N] extends {
|
|
6
|
+
per: "custom";
|
|
7
|
+
} ? N : never;
|
|
8
|
+
}[keyof TPolicies] & string;
|
|
9
|
+
/** One policy as the mock runs it: the table's entry with its `per` put back, the key handler for a custom one and the literal for the rest. */
|
|
10
|
+
type LambderMockPolicyOf<TPolicy, TKey> = Omit<TPolicy, "per"> & (TPolicy extends {
|
|
11
|
+
per: "custom";
|
|
12
|
+
} ? {
|
|
13
|
+
per: TKey;
|
|
14
|
+
} : TPolicy extends {
|
|
15
|
+
per: infer P;
|
|
16
|
+
} ? {
|
|
17
|
+
per: P;
|
|
18
|
+
} : {});
|
|
19
|
+
/** The key handlers a table needs: one per custom-keyed policy, none where the table has none. */
|
|
20
|
+
export type LambderMockPolicyKeys<TPolicies> = {
|
|
21
|
+
[N in LambderCustomKeyedPolicyNames<TPolicies>]: LambderRateLimitKeyFn<any, any>;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The rate-limit policies a mock's create() takes, built from a generated
|
|
25
|
+
* `rateLimitPolicies` table and the key handlers for its custom-keyed
|
|
26
|
+
* policies (built with the mock's own `rateLimitKey`, so they see the mock's
|
|
27
|
+
* context). Required for exactly those policies, refused for any other:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const mockApp = mock.create({
|
|
31
|
+
* rateLimits: {
|
|
32
|
+
* policies: lambderMockPoliciesFrom(rateLimitPolicies, {
|
|
33
|
+
* keys: {
|
|
34
|
+
* codePerEmail: mock.rateLimitKey({ apiInput: z.object({ email: z.string() }), handler: (_ctx, { email }) => email.toLowerCase() }),
|
|
35
|
+
* },
|
|
36
|
+
* }),
|
|
37
|
+
* },
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* Everything else about a policy (windows, budget, charge point, message) is
|
|
42
|
+
* the table's, so a mock never disagrees with the server about a limit it
|
|
43
|
+
* did not mean to change. The result keeps each policy's `per` and `budget`
|
|
44
|
+
* as literals, which is what lets create() hold it to the contract.
|
|
45
|
+
*/
|
|
46
|
+
export declare const lambderMockPoliciesFrom: <const TPolicies extends Record<string, LambderRateLimitPolicyEntry>, const TKeys extends LambderMockPolicyKeys<TPolicies> = LambderMockPolicyKeys<TPolicies>>(policies: TPolicies, options: [LambderCustomKeyedPolicyNames<TPolicies>] extends [never] ? {
|
|
47
|
+
keys?: TKeys & Record<Exclude<keyof TKeys, LambderCustomKeyedPolicyNames<TPolicies>>, never>;
|
|
48
|
+
} : {
|
|
49
|
+
keys: TKeys & Record<Exclude<keyof TKeys, LambderCustomKeyedPolicyNames<TPolicies>>, never>;
|
|
50
|
+
}) => { [N in keyof TPolicies]: LambderMockPolicyOf<TPolicies[N], N extends keyof TKeys ? TKeys[N] : never>; };
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rate-limit policies a mock's create() takes, built from a generated
|
|
3
|
+
* `rateLimitPolicies` table and the key handlers for its custom-keyed
|
|
4
|
+
* policies (built with the mock's own `rateLimitKey`, so they see the mock's
|
|
5
|
+
* context). Required for exactly those policies, refused for any other:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const mockApp = mock.create({
|
|
9
|
+
* rateLimits: {
|
|
10
|
+
* policies: lambderMockPoliciesFrom(rateLimitPolicies, {
|
|
11
|
+
* keys: {
|
|
12
|
+
* codePerEmail: mock.rateLimitKey({ apiInput: z.object({ email: z.string() }), handler: (_ctx, { email }) => email.toLowerCase() }),
|
|
13
|
+
* },
|
|
14
|
+
* }),
|
|
15
|
+
* },
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Everything else about a policy (windows, budget, charge point, message) is
|
|
20
|
+
* the table's, so a mock never disagrees with the server about a limit it
|
|
21
|
+
* did not mean to change. The result keeps each policy's `per` and `budget`
|
|
22
|
+
* as literals, which is what lets create() hold it to the contract.
|
|
23
|
+
*/
|
|
24
|
+
export const lambderMockPoliciesFrom = (policies, options) => {
|
|
25
|
+
const keys = (options.keys ?? {});
|
|
26
|
+
const built = {};
|
|
27
|
+
for (const [name, entry] of Object.entries(policies)) {
|
|
28
|
+
const { per, ...rest } = entry;
|
|
29
|
+
if (per === "custom") {
|
|
30
|
+
const key = keys[name];
|
|
31
|
+
if (typeof key?.handler !== "function") {
|
|
32
|
+
throw new Error(`LambderMockApp: rate-limit policy "${name}" is keyed by a handler of the server's, so the mock has to supply one: lambderMockPoliciesFrom(rateLimitPolicies, { keys: { ${name}: mock.rateLimitKey({ ... }) } }).`);
|
|
33
|
+
}
|
|
34
|
+
built[name] = { ...rest, per: key };
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
built[name] = per === undefined ? { ...rest } : { ...rest, per };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const name of Object.keys(keys)) {
|
|
41
|
+
if (policies[name]?.per !== "custom") {
|
|
42
|
+
throw new Error(`LambderMockApp: a key handler was given for rate-limit policy "${name}", which the server ${name in policies ? "does not key by a handler" : "does not declare"}.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return built;
|
|
46
|
+
};
|
package/dist/mock.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
|
|
|
10
10
|
export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
|
|
11
11
|
export type { LambderMockAppOptions, LambderMockSessionsOptions, LambderMockIdempotencyOptions, LambderMockInvalidInputAnswer, LambderMockTransport, LambderMockTransportOptions } from "./mock/LambderMockCreateOptions.js";
|
|
12
12
|
export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockPublicNames, LambderMockSessionNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
|
|
13
|
+
export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
|
|
14
|
+
export type { LambderCustomKeyedPolicyNames, LambderMockPolicyKeys } from "./mock/lambderMockPoliciesFrom.js";
|
|
15
|
+
export type { LambderMockGuardShapeOf } from "./mock/LambderMockCreateOptions.js";
|
|
13
16
|
export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
|
|
14
17
|
export type { LambderMockConsoleLoggerOptions } from "./mock/lambderMockConsoleLogger.js";
|
|
15
18
|
export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
|
package/dist/mock.js
CHANGED
|
@@ -12,6 +12,9 @@ export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
|
|
|
12
12
|
// exported: the runtime is reached through the app. Only the error a
|
|
13
13
|
// transport rejects with is public.
|
|
14
14
|
export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
|
|
15
|
+
// The server's rate-limit policies as the mock restates them, from the
|
|
16
|
+
// generated options module plus the key handlers the module cannot hold.
|
|
17
|
+
export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
|
|
15
18
|
export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
|
|
16
19
|
export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
|
|
17
20
|
// The storage a mock app's uploads go to: a memory bucket the mock's ticket and
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
|
|
2
|
+
/**
|
|
3
|
+
* The shapes a kind of secret takes, told apart by how they are redeemed. A
|
|
4
|
+
* code is bound to a scope the app names (an address for a purpose, a
|
|
5
|
+
* recipient, a device), redeemed with that scope, and defended by a ceiling on
|
|
6
|
+
* tries, which is what lets it be short. A token carries its own identity, is
|
|
7
|
+
* redeemed by value alone, and has no ceiling: random bytes, long enough that
|
|
8
|
+
* guessing is not a thing, or, for a code somebody types without knowing what
|
|
9
|
+
* it is for (a pairing code), characters of an alphabet, which is guessable
|
|
10
|
+
* and which the app then holds off another way, such as a rate limit per
|
|
11
|
+
* address.
|
|
12
|
+
*/
|
|
13
|
+
export type LambderOneShotSecretKind = {
|
|
14
|
+
shape: "code";
|
|
15
|
+
/** The characters a code is drawn from, each once. Default: the ten digits. */
|
|
16
|
+
alphabet?: string;
|
|
17
|
+
length: number;
|
|
18
|
+
ttlSeconds: number;
|
|
19
|
+
/** Wrong tries the code survives; the try that would pass this refuses the code as exhausted, right or wrong. */
|
|
20
|
+
maxAttempts: number;
|
|
21
|
+
} | {
|
|
22
|
+
shape: "token";
|
|
23
|
+
/** Random bytes behind the token, which is their base64url. Default: 32. */
|
|
24
|
+
bytes?: number;
|
|
25
|
+
alphabet?: undefined;
|
|
26
|
+
length?: undefined;
|
|
27
|
+
ttlSeconds: number;
|
|
28
|
+
} | {
|
|
29
|
+
shape: "token";
|
|
30
|
+
/** The characters the token is drawn from, each once, for a token somebody types. */
|
|
31
|
+
alphabet: string;
|
|
32
|
+
length: number;
|
|
33
|
+
bytes?: undefined;
|
|
34
|
+
ttlSeconds: number;
|
|
35
|
+
};
|
|
36
|
+
export type LambderOneShotSecretsOptions<TKinds extends Record<string, LambderOneShotSecretKind>> = {
|
|
37
|
+
store: LambderOneShotSecretStore;
|
|
38
|
+
/** Keys every digest at rest, so a copied store cannot be attacked offline. Server side only. */
|
|
39
|
+
secret: string;
|
|
40
|
+
kinds: TKinds;
|
|
41
|
+
/** The clock, in epoch milliseconds. Default: Date.now. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
};
|
|
44
|
+
export type LambderOneShotIssueResult = {
|
|
45
|
+
issued: true;
|
|
46
|
+
/** The secret, as it leaves this module the one time it does: a code as written, a token as base64url. */
|
|
47
|
+
plaintext: string;
|
|
48
|
+
/** Epoch seconds. */
|
|
49
|
+
expiresAt: number;
|
|
50
|
+
} | {
|
|
51
|
+
issued: false;
|
|
52
|
+
refused: "cooldown"; /** Epoch seconds when the cooldown ends. */
|
|
53
|
+
retryAt: number;
|
|
54
|
+
};
|
|
55
|
+
export type LambderOneShotRedeemResult =
|
|
56
|
+
/** The secret is the one out: spent now, with the scope it proved and what the app stored beside it. */
|
|
57
|
+
{
|
|
58
|
+
state: "accepted";
|
|
59
|
+
scope: string;
|
|
60
|
+
meta: Record<string, string>;
|
|
61
|
+
issuedAt: number;
|
|
62
|
+
}
|
|
63
|
+
/** Not the code that is out; the try was counted. */
|
|
64
|
+
| {
|
|
65
|
+
state: "wrong";
|
|
66
|
+
attemptsLeft: number;
|
|
67
|
+
}
|
|
68
|
+
/** The code that is out has run out of time; ask for a new one. */
|
|
69
|
+
| {
|
|
70
|
+
state: "expired";
|
|
71
|
+
}
|
|
72
|
+
/** The code that is out has run out of tries, this one included; ask for a new one. */
|
|
73
|
+
| {
|
|
74
|
+
state: "exhausted";
|
|
75
|
+
}
|
|
76
|
+
/** Nothing is out for this scope or this value: never issued, spent, replaced, retired, or unknown. */
|
|
77
|
+
| {
|
|
78
|
+
state: "none";
|
|
79
|
+
};
|
|
80
|
+
/** The names of the kinds a scoped code is redeemed under. */
|
|
81
|
+
export type LambderOneShotCodeKindNames<TKinds> = {
|
|
82
|
+
[K in keyof TKinds]: TKinds[K] extends {
|
|
83
|
+
shape: "code";
|
|
84
|
+
} ? K : never;
|
|
85
|
+
}[keyof TKinds] & string;
|
|
86
|
+
/** The names of the kinds a token is redeemed under, by value. */
|
|
87
|
+
export type LambderOneShotTokenKindNames<TKinds> = {
|
|
88
|
+
[K in keyof TKinds]: TKinds[K] extends {
|
|
89
|
+
shape: "token";
|
|
90
|
+
} ? K : never;
|
|
91
|
+
}[keyof TKinds] & string;
|
|
92
|
+
/**
|
|
93
|
+
* Codes and tokens an app hands out once and takes back once, over a store
|
|
94
|
+
* that settles their races.
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* const secrets = new LambderOneShotSecrets({
|
|
98
|
+
* store: new LambderDdbOneShotSecretStore({ tableName: "app-policies" }),
|
|
99
|
+
* secret: ONE_SHOT_SECRET,
|
|
100
|
+
* kinds: {
|
|
101
|
+
* emailCode: { shape: "code", length: 6, ttlSeconds: 600, maxAttempts: 5 },
|
|
102
|
+
* activationLink: { shape: "token", ttlSeconds: 48 * 3600 },
|
|
103
|
+
* },
|
|
104
|
+
* });
|
|
105
|
+
*
|
|
106
|
+
* const issued = await secrets.issue("emailCode", `register:${email}`, { cooldownSeconds: 30 });
|
|
107
|
+
* if(issued.issued) await sendEmail(email, issued.plaintext);
|
|
108
|
+
*
|
|
109
|
+
* const redeemed = await secrets.redeem("emailCode", `register:${email}`, typedCode);
|
|
110
|
+
* if(redeemed.state !== "accepted") refuse(...);
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* The store holds digests only, keyed under the app's secret with the kind
|
|
114
|
+
* and the scope folded in: a code issued to two scopes never collides, and a
|
|
115
|
+
* code cannot be replayed against another kind or scope. The plaintext leaves
|
|
116
|
+
* once, from `issue`; nothing here logs it or stores it.
|
|
117
|
+
*/
|
|
118
|
+
export declare class LambderOneShotSecrets<TKinds extends Record<string, LambderOneShotSecretKind>> {
|
|
119
|
+
private readonly store;
|
|
120
|
+
private readonly secret;
|
|
121
|
+
private readonly kinds;
|
|
122
|
+
private readonly now;
|
|
123
|
+
constructor(options: LambderOneShotSecretsOptions<TKinds>);
|
|
124
|
+
private nowSeconds;
|
|
125
|
+
private kindOf;
|
|
126
|
+
/**
|
|
127
|
+
* The digest a secret rests as. A code's carries its kind and scope, so
|
|
128
|
+
* the same code issued to two scopes never collides and a code cannot be
|
|
129
|
+
* replayed against another; a token's carries its kind, since a token is
|
|
130
|
+
* found by its digest alone, so two scopes can draw the same one, and the
|
|
131
|
+
* store's claim on the digest at issue is what keeps them apart. The
|
|
132
|
+
* fields are joined escaped, so no two distinct field lists produce one
|
|
133
|
+
* string.
|
|
134
|
+
*/
|
|
135
|
+
private digestOf;
|
|
136
|
+
/**
|
|
137
|
+
* Mints one secret for the scope, retiring whatever the scope held, and
|
|
138
|
+
* answers the plaintext: the one time it exists outside the caller's
|
|
139
|
+
* hands. With `cooldownSeconds`, a scope whose current secret was issued
|
|
140
|
+
* less than that ago is refused instead, with the second it may ask
|
|
141
|
+
* again; of two callers racing past the cooldown, exactly one is issued.
|
|
142
|
+
* `meta` is what the app wants back at redemption: an identity, an
|
|
143
|
+
* issuing organization, as small strings.
|
|
144
|
+
*
|
|
145
|
+
* A secret whose digest another scope holds is drawn again, up to
|
|
146
|
+
* MAX_DRAWS times; past that the kind's alphabet and length leave too few
|
|
147
|
+
* secrets for the ones out at once, and the issue throws.
|
|
148
|
+
*/
|
|
149
|
+
issue(kind: keyof TKinds & string, scope: string, options?: {
|
|
150
|
+
cooldownSeconds?: number;
|
|
151
|
+
meta?: Record<string, string>;
|
|
152
|
+
}): Promise<LambderOneShotIssueResult>;
|
|
153
|
+
/**
|
|
154
|
+
* Redeems a code for its scope. The try is counted before the code is
|
|
155
|
+
* looked at, in the write that reads the digest, so tries sent together
|
|
156
|
+
* are all counted; a right code past the ceiling is refused as exhausted.
|
|
157
|
+
* An accepted code is spent in the same call, exactly once.
|
|
158
|
+
*/
|
|
159
|
+
redeem(kind: LambderOneShotCodeKindNames<TKinds>, scope: string, candidate: string): Promise<LambderOneShotRedeemResult>;
|
|
160
|
+
/** Redeems a token by its value: found by its digest, spent exactly once. A token of another kind, or none, is "none". */
|
|
161
|
+
redeemToken(kind: LambderOneShotTokenKindNames<TKinds>, candidate: string): Promise<LambderOneShotRedeemResult>;
|
|
162
|
+
/** Ends whatever the scope holds: after the thing it proved is settled another way, or when what was sent never arrived. */
|
|
163
|
+
retire(scope: string): Promise<void>;
|
|
164
|
+
/** Spends the record; a redemption racing this one and winning makes it "none". */
|
|
165
|
+
private accept;
|
|
166
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { keyedDigest, randomSecret } from "../shared/util/LambderSignedClaims.js";
|
|
2
|
+
import { constantTimeEquals } from "../shared/util/LambderTextDigest.js";
|
|
3
|
+
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
4
|
+
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
5
|
+
const DIGITS = "0123456789";
|
|
6
|
+
/**
|
|
7
|
+
* How many secrets an issue draws before giving up on a digest another scope
|
|
8
|
+
* holds. A random token never meets one; a short code drawn from a small
|
|
9
|
+
* space with many out at once can, and five taken in a row means the space is
|
|
10
|
+
* too small for the codes out, which is a configuration to change rather than
|
|
11
|
+
* a draw to repeat.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_DRAWS = 5;
|
|
14
|
+
/**
|
|
15
|
+
* Codes and tokens an app hands out once and takes back once, over a store
|
|
16
|
+
* that settles their races.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* const secrets = new LambderOneShotSecrets({
|
|
20
|
+
* store: new LambderDdbOneShotSecretStore({ tableName: "app-policies" }),
|
|
21
|
+
* secret: ONE_SHOT_SECRET,
|
|
22
|
+
* kinds: {
|
|
23
|
+
* emailCode: { shape: "code", length: 6, ttlSeconds: 600, maxAttempts: 5 },
|
|
24
|
+
* activationLink: { shape: "token", ttlSeconds: 48 * 3600 },
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* const issued = await secrets.issue("emailCode", `register:${email}`, { cooldownSeconds: 30 });
|
|
29
|
+
* if(issued.issued) await sendEmail(email, issued.plaintext);
|
|
30
|
+
*
|
|
31
|
+
* const redeemed = await secrets.redeem("emailCode", `register:${email}`, typedCode);
|
|
32
|
+
* if(redeemed.state !== "accepted") refuse(...);
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* The store holds digests only, keyed under the app's secret with the kind
|
|
36
|
+
* and the scope folded in: a code issued to two scopes never collides, and a
|
|
37
|
+
* code cannot be replayed against another kind or scope. The plaintext leaves
|
|
38
|
+
* once, from `issue`; nothing here logs it or stores it.
|
|
39
|
+
*/
|
|
40
|
+
export class LambderOneShotSecrets {
|
|
41
|
+
store;
|
|
42
|
+
secret;
|
|
43
|
+
kinds;
|
|
44
|
+
now;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
if (typeof options.secret !== "string" || options.secret.length === 0)
|
|
47
|
+
throw new Error("Lambder: LambderOneShotSecrets needs a secret to key its digests with.");
|
|
48
|
+
const names = Object.keys(options.kinds);
|
|
49
|
+
if (names.length === 0)
|
|
50
|
+
throw new Error("Lambder: LambderOneShotSecrets was given no kinds; declare the kinds of secret the app hands out.");
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const kind = options.kinds[name];
|
|
53
|
+
assertPositiveInteger(kind.ttlSeconds, `kinds.${name}.ttlSeconds`);
|
|
54
|
+
if (kind.shape === "code") {
|
|
55
|
+
assertPositiveInteger(kind.length, `kinds.${name}.length`);
|
|
56
|
+
assertPositiveInteger(kind.maxAttempts, `kinds.${name}.maxAttempts`);
|
|
57
|
+
assertAlphabet(kind.alphabet ?? DIGITS, name);
|
|
58
|
+
}
|
|
59
|
+
else if (kind.shape === "token") {
|
|
60
|
+
if (kind.alphabet !== undefined || kind.length !== undefined) {
|
|
61
|
+
assertPositiveInteger(kind.length, `kinds.${name}.length`);
|
|
62
|
+
assertAlphabet(kind.alphabet ?? "", name);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
assertPositiveInteger(kind.bytes ?? 32, `kinds.${name}.bytes`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
throw new Error(`Lambder: kinds.${name}.shape must be "code" or "token", got ${JSON.stringify(kind.shape)}.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.store = options.store;
|
|
73
|
+
this.secret = options.secret;
|
|
74
|
+
this.kinds = options.kinds;
|
|
75
|
+
this.now = options.now ?? (() => Date.now());
|
|
76
|
+
}
|
|
77
|
+
nowSeconds() { return Math.floor(this.now() / 1000); }
|
|
78
|
+
kindOf(name) {
|
|
79
|
+
const kind = Object.prototype.hasOwnProperty.call(this.kinds, name) ? this.kinds[name] : undefined;
|
|
80
|
+
if (!kind)
|
|
81
|
+
throw new Error(`Lambder: LambderOneShotSecrets knows no kind "${name}".`);
|
|
82
|
+
return kind;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The digest a secret rests as. A code's carries its kind and scope, so
|
|
86
|
+
* the same code issued to two scopes never collides and a code cannot be
|
|
87
|
+
* replayed against another; a token's carries its kind, since a token is
|
|
88
|
+
* found by its digest alone, so two scopes can draw the same one, and the
|
|
89
|
+
* store's claim on the digest at issue is what keeps them apart. The
|
|
90
|
+
* fields are joined escaped, so no two distinct field lists produce one
|
|
91
|
+
* string.
|
|
92
|
+
*/
|
|
93
|
+
digestOf(kind, scope, plaintext) {
|
|
94
|
+
return keyedDigest(this.secret, scope === null ? joinKeyFields("token", kind, plaintext) : joinKeyFields("code", kind, scope, plaintext));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Mints one secret for the scope, retiring whatever the scope held, and
|
|
98
|
+
* answers the plaintext: the one time it exists outside the caller's
|
|
99
|
+
* hands. With `cooldownSeconds`, a scope whose current secret was issued
|
|
100
|
+
* less than that ago is refused instead, with the second it may ask
|
|
101
|
+
* again; of two callers racing past the cooldown, exactly one is issued.
|
|
102
|
+
* `meta` is what the app wants back at redemption: an identity, an
|
|
103
|
+
* issuing organization, as small strings.
|
|
104
|
+
*
|
|
105
|
+
* A secret whose digest another scope holds is drawn again, up to
|
|
106
|
+
* MAX_DRAWS times; past that the kind's alphabet and length leave too few
|
|
107
|
+
* secrets for the ones out at once, and the issue throws.
|
|
108
|
+
*/
|
|
109
|
+
async issue(kind, scope, options = {}) {
|
|
110
|
+
const definition = this.kindOf(kind);
|
|
111
|
+
const cooldown = options.cooldownSeconds === undefined ? undefined : assertPositiveInteger(options.cooldownSeconds, "cooldownSeconds");
|
|
112
|
+
const nowSeconds = this.nowSeconds();
|
|
113
|
+
for (let draw = 0; draw < MAX_DRAWS; draw += 1) {
|
|
114
|
+
const plaintext = definition.shape === "code" ? drawCode(definition.alphabet ?? DIGITS, definition.length)
|
|
115
|
+
: definition.alphabet !== undefined ? drawCode(definition.alphabet, definition.length)
|
|
116
|
+
: randomSecret(definition.bytes ?? 32);
|
|
117
|
+
const draft = {
|
|
118
|
+
kind,
|
|
119
|
+
scope,
|
|
120
|
+
shape: definition.shape,
|
|
121
|
+
digest: await this.digestOf(kind, definition.shape === "code" ? scope : null, plaintext),
|
|
122
|
+
issuedAt: nowSeconds,
|
|
123
|
+
expiresAt: nowSeconds + definition.ttlSeconds,
|
|
124
|
+
meta: { ...(options.meta ?? {}) },
|
|
125
|
+
};
|
|
126
|
+
const outcome = await this.store.issue(draft, { unlessIssuedAfter: cooldown === undefined ? undefined : nowSeconds - cooldown });
|
|
127
|
+
if (outcome.issued)
|
|
128
|
+
return { issued: true, plaintext, expiresAt: draft.expiresAt };
|
|
129
|
+
if (outcome.refused === "cooldown")
|
|
130
|
+
return { issued: false, refused: "cooldown", retryAt: outcome.issuedAt + (cooldown ?? 0) };
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Lambder: kind "${kind}" drew ${MAX_DRAWS} secrets in a row whose digest another scope holds; its alphabet and length leave too few for the ones out at once.`);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Redeems a code for its scope. The try is counted before the code is
|
|
136
|
+
* looked at, in the write that reads the digest, so tries sent together
|
|
137
|
+
* are all counted; a right code past the ceiling is refused as exhausted.
|
|
138
|
+
* An accepted code is spent in the same call, exactly once.
|
|
139
|
+
*/
|
|
140
|
+
async redeem(kind, scope, candidate) {
|
|
141
|
+
const definition = this.kindOf(kind);
|
|
142
|
+
if (definition.shape !== "code")
|
|
143
|
+
throw new Error(`Lambder: kind "${kind}" is a token, redeemed by value with redeemToken().`);
|
|
144
|
+
const current = await this.store.findByScope(scope);
|
|
145
|
+
if (!current || current.kind !== kind)
|
|
146
|
+
return { state: "none" };
|
|
147
|
+
if (current.expiresAt <= this.nowSeconds())
|
|
148
|
+
return { state: "expired" };
|
|
149
|
+
if (current.attempts >= definition.maxAttempts)
|
|
150
|
+
return { state: "exhausted" };
|
|
151
|
+
const counted = await this.store.attempt(scope, current.id);
|
|
152
|
+
if (!counted)
|
|
153
|
+
return { state: "none" };
|
|
154
|
+
// Counted already, so this is the count with the try being made now.
|
|
155
|
+
if (counted.attempts > definition.maxAttempts)
|
|
156
|
+
return { state: "exhausted" };
|
|
157
|
+
if (!constantTimeEquals(counted.digest, await this.digestOf(kind, scope, candidate))) {
|
|
158
|
+
return { state: "wrong", attemptsLeft: Math.max(0, definition.maxAttempts - counted.attempts) };
|
|
159
|
+
}
|
|
160
|
+
return await this.accept(counted);
|
|
161
|
+
}
|
|
162
|
+
/** Redeems a token by its value: found by its digest, spent exactly once. A token of another kind, or none, is "none". */
|
|
163
|
+
async redeemToken(kind, candidate) {
|
|
164
|
+
const definition = this.kindOf(kind);
|
|
165
|
+
if (definition.shape !== "token")
|
|
166
|
+
throw new Error(`Lambder: kind "${kind}" is a code, redeemed with its scope through redeem().`);
|
|
167
|
+
// A token is 43 characters for 32 bytes; anything far past that is not one, and is not worth a digest.
|
|
168
|
+
if (candidate.length === 0 || candidate.length > 512)
|
|
169
|
+
return { state: "none" };
|
|
170
|
+
const current = await this.store.findByDigest(await this.digestOf(kind, null, candidate));
|
|
171
|
+
if (!current || current.kind !== kind)
|
|
172
|
+
return { state: "none" };
|
|
173
|
+
if (current.expiresAt <= this.nowSeconds())
|
|
174
|
+
return { state: "expired" };
|
|
175
|
+
return await this.accept(current);
|
|
176
|
+
}
|
|
177
|
+
/** Ends whatever the scope holds: after the thing it proved is settled another way, or when what was sent never arrived. */
|
|
178
|
+
async retire(scope) {
|
|
179
|
+
await this.store.retire(scope);
|
|
180
|
+
}
|
|
181
|
+
/** Spends the record; a redemption racing this one and winning makes it "none". */
|
|
182
|
+
async accept(record) {
|
|
183
|
+
if (!(await this.store.consume(record.scope, record.id)))
|
|
184
|
+
return { state: "none" };
|
|
185
|
+
return { state: "accepted", scope: record.scope, meta: { ...record.meta }, issuedAt: record.issuedAt };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const assertAlphabet = (alphabet, name) => {
|
|
189
|
+
if (alphabet.length < 2 || alphabet.length > 256 || new Set(alphabet).size !== alphabet.length) {
|
|
190
|
+
throw new Error(`Lambder: kinds.${name}.alphabet must be 2 to 256 distinct characters.`);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* A code of `length` characters drawn uniformly from `alphabet`. Bytes at or
|
|
195
|
+
* above the largest multiple of the alphabet's size are discarded, so the
|
|
196
|
+
* modulo cannot favour the alphabet's first characters.
|
|
197
|
+
*/
|
|
198
|
+
const drawCode = (alphabet, length) => {
|
|
199
|
+
const webCrypto = globalThis.crypto;
|
|
200
|
+
if (typeof webCrypto?.getRandomValues !== "function") {
|
|
201
|
+
throw new Error("Lambder needs crypto.getRandomValues in this runtime to draw a code. Every browser provides it; Node 20+ provides it as globalThis.crypto.");
|
|
202
|
+
}
|
|
203
|
+
const ceiling = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
204
|
+
const characters = [];
|
|
205
|
+
const buffer = new Uint8Array(length * 2);
|
|
206
|
+
while (characters.length < length) {
|
|
207
|
+
webCrypto.getRandomValues(buffer);
|
|
208
|
+
for (const byte of buffer) {
|
|
209
|
+
if (byte >= ceiling)
|
|
210
|
+
continue;
|
|
211
|
+
characters.push(alphabet[byte % alphabet.length]);
|
|
212
|
+
if (characters.length === length)
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return characters.join("");
|
|
217
|
+
};
|
|
@@ -11,16 +11,7 @@
|
|
|
11
11
|
* not a table anybody can leak, so hashing there protects nothing.
|
|
12
12
|
*/
|
|
13
13
|
import { getCrypto } from "../shared/util/LambderNodeModules.js";
|
|
14
|
-
import { bytesToHexString, resolveWebCrypto, sha256HexOf } from "../shared/util/LambderTextDigest.js";
|
|
15
|
-
/** Length-aware, timing-neutral string comparison: no early exit on the first differing character. */
|
|
16
|
-
const constantTimeEqual = (a, b) => {
|
|
17
|
-
if (a.length !== b.length)
|
|
18
|
-
return false;
|
|
19
|
-
let difference = 0;
|
|
20
|
-
for (let i = 0; i < a.length; i += 1)
|
|
21
|
-
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
22
|
-
return difference === 0;
|
|
23
|
-
};
|
|
14
|
+
import { bytesToHexString, constantTimeEquals, hmacSha256Of, resolveWebCrypto, sha256HexOf } from "../shared/util/LambderTextDigest.js";
|
|
24
15
|
/** True when this runtime offers WebCrypto's subtle API (secure contexts in browsers; Node 20+). */
|
|
25
16
|
export const isWebCryptoAvailable = () => typeof globalThis.crypto?.subtle?.digest === "function" && typeof globalThis.crypto.getRandomValues === "function";
|
|
26
17
|
/** sha256 and HMAC through crypto.subtle and randomness through getRandomValues: the default. */
|
|
@@ -62,10 +53,9 @@ export class LambderWebCrypto {
|
|
|
62
53
|
return await sha256HexOf(value);
|
|
63
54
|
}
|
|
64
55
|
async hmacSha256Hex(key, value) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return bytesToHexString(new Uint8Array(await webCrypto.subtle.sign("HMAC", hmacKey, encoder.encode(value))));
|
|
56
|
+
// ready() first, as for sha256Hex; the HMAC is the one every layer shares.
|
|
57
|
+
await this.ready();
|
|
58
|
+
return bytesToHexString(await hmacSha256Of(key, value));
|
|
69
59
|
}
|
|
70
60
|
async randomHex(bytes) {
|
|
71
61
|
const webCrypto = await this.ready();
|
|
@@ -82,7 +72,7 @@ export class LambderWebCrypto {
|
|
|
82
72
|
if (left.length === right.length)
|
|
83
73
|
return nodeCrypto.timingSafeEqual(left, right);
|
|
84
74
|
}
|
|
85
|
-
return
|
|
75
|
+
return constantTimeEquals(a, b);
|
|
86
76
|
}
|
|
87
77
|
}
|
|
88
78
|
/**
|
|
@@ -110,6 +100,6 @@ export class LambderPlainSessionCrypto {
|
|
|
110
100
|
return bytesToHexString(random);
|
|
111
101
|
}
|
|
112
102
|
constantTimeEqual(a, b) {
|
|
113
|
-
return
|
|
103
|
+
return constantTimeEquals(a, b);
|
|
114
104
|
}
|
|
115
105
|
}
|
|
@@ -38,8 +38,9 @@ export type LambderIdempotencyBeginResult = {
|
|
|
38
38
|
* atomically, settled by the claim's owner. LambderDdbIdempotencyStore and
|
|
39
39
|
* LambderMemoryIdempotencyStore implement it; an app may bring its own.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* The conformance suite `lambder/testing` exports
|
|
42
|
+
* (lambderIdempotencyStoreConformance) asserts the rules, against these two
|
|
43
|
+
* and against an app's own. Four are easy to get wrong:
|
|
43
44
|
*
|
|
44
45
|
* A read hands back a COPY of the record, never the stored object, because a
|
|
45
46
|
* caller applies its own headers onto what it gets back.
|