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.
Files changed (65) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +8 -7
  3. package/dist/api/LambderApiGuards.d.ts +2 -17
  4. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  5. package/dist/build/generatedTables.d.ts +72 -0
  6. package/dist/build/generatedTables.js +99 -0
  7. package/dist/build/writeApiGuardParams.d.ts +60 -0
  8. package/dist/build/writeApiGuardParams.js +85 -0
  9. package/dist/build/writeApiOptions.d.ts +68 -0
  10. package/dist/build/writeApiOptions.js +102 -0
  11. package/dist/build.d.ts +10 -4
  12. package/dist/build.js +7 -4
  13. package/dist/client/LambderUploadRunner.d.ts +7 -7
  14. package/dist/client/LambderUploadRunner.js +12 -21
  15. package/dist/client.d.ts +7 -0
  16. package/dist/client.js +11 -0
  17. package/dist/core/Lambder.d.ts +21 -0
  18. package/dist/core/Lambder.js +69 -0
  19. package/dist/index.d.ts +13 -0
  20. package/dist/index.js +13 -0
  21. package/dist/mock/LambderMockApp.d.ts +34 -17
  22. package/dist/mock/LambderMockApp.js +67 -21
  23. package/dist/mock/LambderMockCreateOptions.d.ts +68 -5
  24. package/dist/mock/LambderMockTypes.d.ts +29 -10
  25. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  26. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  27. package/dist/mock.d.ts +3 -0
  28. package/dist/mock.js +3 -0
  29. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  30. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  31. package/dist/session/LambderSessionCrypto.js +6 -16
  32. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  33. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  34. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  35. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  36. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  37. package/dist/shared/util/LambderBase64.d.ts +14 -0
  38. package/dist/shared/util/LambderBase64.js +17 -0
  39. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  40. package/dist/shared/util/LambderSignedClaims.js +109 -0
  41. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  42. package/dist/shared/util/LambderTextDigest.js +30 -5
  43. package/dist/shared/util/assertPlainData.d.ts +9 -0
  44. package/dist/shared/util/assertPlainData.js +41 -0
  45. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  46. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  47. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  48. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  49. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  50. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  51. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  52. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  53. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  54. package/dist/testing/LambderConformanceRunner.js +21 -0
  55. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  56. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  57. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  58. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  59. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  60. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  61. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  62. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  63. package/dist/testing.d.ts +14 -0
  64. package/dist/testing.js +12 -0
  65. package/package.json +1 -1
@@ -0,0 +1,78 @@
1
+ import type { z } from "zod";
2
+ export type LambderSignedClaimsOptions<TClaims> = {
3
+ /** The HMAC key. Server side, and every runtime that verifies; never a browser. */
4
+ secret: string;
5
+ /**
6
+ * Names this kind of token, as the first segment of every one it signs;
7
+ * a token of another version does not verify. Any text without a dot.
8
+ */
9
+ version: string;
10
+ /** The claims' shape, parsed on the way out and on the way back, so a token never carries a shape the app did not declare. */
11
+ schema: z.ZodType<TClaims>;
12
+ /** The clock `exp` is judged against, in epoch milliseconds. Default: Date.now. */
13
+ now?: () => number;
14
+ };
15
+ /**
16
+ * Refuses a claims type whose `exp` is not a number, at the constructor: the
17
+ * class would judge it as an expiry, find no number, and let the token live
18
+ * for ever. The property name is the message.
19
+ */
20
+ type LambderExpIsSeconds<TClaims> = TClaims extends {
21
+ exp: infer E;
22
+ } ? ([E] extends [number] ? unknown : {
23
+ readonly "lambder: the exp claim is an expiry in epoch seconds, so the schema must declare it as a number.": never;
24
+ }) : unknown;
25
+ /**
26
+ * One kind of signed token: constructed once with its secret, version and
27
+ * claims schema, it keeps the imported HMAC key and answers `sign` and
28
+ * `verify`. An app declares one instance per kind of token it hands out.
29
+ *
30
+ * The one claim with a meaning here is `exp`, an expiry in epoch seconds,
31
+ * the unit sessions, DynamoDB's TTL and JWTs use. A schema that declares it
32
+ * gets it checked on every verify; a schema without it declares a token that
33
+ * does not expire on its own, which is right where the thing the token names
34
+ * (a request, a message) decides what it may still do.
35
+ *
36
+ * `verify` answers null for every failure alike: a forged signature, another
37
+ * version, malformed text, claims the schema refuses, an `exp` in the past.
38
+ * A caller cannot tell them apart, and so cannot tell a client apart either.
39
+ */
40
+ export declare class LambderSignedClaims<TClaims extends object> {
41
+ private readonly secret;
42
+ private readonly version;
43
+ private readonly schema;
44
+ private readonly now;
45
+ /** Imported on first use and kept: signing and verifying share one key object for the life of the instance. */
46
+ private keyPromise;
47
+ constructor(options: LambderSignedClaimsOptions<TClaims> & LambderExpIsSeconds<TClaims>);
48
+ /** The token for these claims, which the schema parses first. `exp`, when the schema has it, is epoch seconds. */
49
+ sign(claims: TClaims): Promise<string>;
50
+ /**
51
+ * The claims, when the token is this version, signed under this secret,
52
+ * well formed, accepted by the schema and not past its `exp`; null
53
+ * otherwise, for every reason alike. `now`, in epoch milliseconds, is the
54
+ * moment `exp` is judged against for this call; default: the clock's.
55
+ */
56
+ verify(token: string, options?: {
57
+ now?: number;
58
+ }): Promise<TClaims | null>;
59
+ private key;
60
+ }
61
+ /**
62
+ * The keyed digest of a secret an app stores and later looks up or compares
63
+ * by value: a device's secret, a code sent by email, a pairing code. Keyed
64
+ * under the app's secret rather than a plain hash, so a copied table alone
65
+ * cannot be attacked offline, and deterministic, so a lookup is one indexed
66
+ * read. HMAC-SHA256 as 43 characters of base64url; compare two with
67
+ * constantTimeEquals.
68
+ */
69
+ export declare const keyedDigest: (secret: string, value: string) => Promise<string>;
70
+ /**
71
+ * A fresh secret from the runtime's cryptographic random source, as base64url
72
+ * (43 characters for the default 32 bytes): the credential a paired device
73
+ * keeps, the token in a link that must not be guessable. Synchronous, since
74
+ * getRandomValues is, on every runtime with a WebCrypto global; a runtime
75
+ * without one is told so.
76
+ */
77
+ export declare const randomSecret: (bytes?: number) => string;
78
+ export {};
@@ -0,0 +1,109 @@
1
+ import { base64UrlToBytes, bytesToBase64Url, isBase64Url } from "./LambderBase64.js";
2
+ import { hmacSha256Of, importHmacKey, resolveWebCrypto } from "./LambderTextDigest.js";
3
+ /**
4
+ * One kind of signed token: constructed once with its secret, version and
5
+ * claims schema, it keeps the imported HMAC key and answers `sign` and
6
+ * `verify`. An app declares one instance per kind of token it hands out.
7
+ *
8
+ * The one claim with a meaning here is `exp`, an expiry in epoch seconds,
9
+ * the unit sessions, DynamoDB's TTL and JWTs use. A schema that declares it
10
+ * gets it checked on every verify; a schema without it declares a token that
11
+ * does not expire on its own, which is right where the thing the token names
12
+ * (a request, a message) decides what it may still do.
13
+ *
14
+ * `verify` answers null for every failure alike: a forged signature, another
15
+ * version, malformed text, claims the schema refuses, an `exp` in the past.
16
+ * A caller cannot tell them apart, and so cannot tell a client apart either.
17
+ */
18
+ export class LambderSignedClaims {
19
+ secret;
20
+ version;
21
+ schema;
22
+ now;
23
+ /** Imported on first use and kept: signing and verifying share one key object for the life of the instance. */
24
+ keyPromise;
25
+ constructor(options) {
26
+ if (typeof options.secret !== "string" || options.secret.length === 0) {
27
+ throw new Error("Lambder: LambderSignedClaims needs a secret to sign with.");
28
+ }
29
+ if (typeof options.version !== "string" || options.version.length === 0 || options.version.includes(".")) {
30
+ throw new Error(`Lambder: LambderSignedClaims version must be text without a dot, got ${JSON.stringify(options.version)}: it is the first of the token's three dot-separated segments.`);
31
+ }
32
+ this.secret = options.secret;
33
+ this.version = options.version;
34
+ this.schema = options.schema;
35
+ this.now = options.now ?? (() => Date.now());
36
+ }
37
+ /** The token for these claims, which the schema parses first. `exp`, when the schema has it, is epoch seconds. */
38
+ async sign(claims) {
39
+ const body = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(this.schema.parse(claims))));
40
+ const message = `${this.version}.${body}`;
41
+ const webCrypto = await resolveWebCrypto();
42
+ const mac = await webCrypto.subtle.sign("HMAC", await this.key(webCrypto), new TextEncoder().encode(message));
43
+ return `${message}.${bytesToBase64Url(new Uint8Array(mac))}`;
44
+ }
45
+ /**
46
+ * The claims, when the token is this version, signed under this secret,
47
+ * well formed, accepted by the schema and not past its `exp`; null
48
+ * otherwise, for every reason alike. `now`, in epoch milliseconds, is the
49
+ * moment `exp` is judged against for this call; default: the clock's.
50
+ */
51
+ async verify(token, options = {}) {
52
+ const parts = token.split(".");
53
+ if (parts.length !== 3 || parts[0] !== this.version)
54
+ return null;
55
+ const [, body, mac] = parts;
56
+ if (!isBase64Url(body) || !isBase64Url(mac))
57
+ return null;
58
+ // The last character of a MAC carries bits decoding ignores, so four
59
+ // spellings decode to the same bytes. Only the one sign() writes is a
60
+ // token: an app that keys anything on the token string (a list of
61
+ // spent tokens, a rate limit) must not meet the same token again
62
+ // under another spelling.
63
+ const macBytes = base64UrlToBytes(mac);
64
+ if (bytesToBase64Url(macBytes) !== mac)
65
+ return null;
66
+ const webCrypto = await resolveWebCrypto();
67
+ const valid = await webCrypto.subtle.verify("HMAC", await this.key(webCrypto), macBytes, new TextEncoder().encode(`${this.version}.${body}`));
68
+ if (!valid)
69
+ return null;
70
+ let claims;
71
+ try {
72
+ claims = this.schema.parse(JSON.parse(new TextDecoder().decode(base64UrlToBytes(body))));
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ const exp = claims.exp;
78
+ if (typeof exp === "number" && exp * 1000 <= (options.now ?? this.now()))
79
+ return null;
80
+ return claims;
81
+ }
82
+ key(webCrypto) {
83
+ this.keyPromise ??= importHmacKey(webCrypto, this.secret, ["sign", "verify"]);
84
+ return this.keyPromise;
85
+ }
86
+ }
87
+ /**
88
+ * The keyed digest of a secret an app stores and later looks up or compares
89
+ * by value: a device's secret, a code sent by email, a pairing code. Keyed
90
+ * under the app's secret rather than a plain hash, so a copied table alone
91
+ * cannot be attacked offline, and deterministic, so a lookup is one indexed
92
+ * read. HMAC-SHA256 as 43 characters of base64url; compare two with
93
+ * constantTimeEquals.
94
+ */
95
+ export const keyedDigest = async (secret, value) => bytesToBase64Url(await hmacSha256Of(secret, value));
96
+ /**
97
+ * A fresh secret from the runtime's cryptographic random source, as base64url
98
+ * (43 characters for the default 32 bytes): the credential a paired device
99
+ * keeps, the token in a link that must not be guessable. Synchronous, since
100
+ * getRandomValues is, on every runtime with a WebCrypto global; a runtime
101
+ * without one is told so.
102
+ */
103
+ export const randomSecret = (bytes = 32) => {
104
+ const webCrypto = globalThis.crypto;
105
+ if (typeof webCrypto?.getRandomValues !== "function") {
106
+ throw new Error("Lambder needs crypto.getRandomValues in this runtime to mint a secret. Every browser provides it; Node 20+ provides it as globalThis.crypto.");
107
+ }
108
+ return bytesToBase64Url(webCrypto.getRandomValues(new Uint8Array(bytes)));
109
+ };
@@ -1,9 +1,11 @@
1
1
  /**
2
- * SHA-256 through WebCrypto: the one digest every layer shares. The session
3
- * crypto hashes bearer secrets with it and the rate-limit engine folds an
4
- * over-long tracker key with it, so both key spaces are built from the same
5
- * primitive on every runtime (browsers on a secure context, Node 20+, edge
6
- * runtimes); an upload's checksum is the same digest over the file's bytes.
2
+ * SHA-256 and HMAC-SHA256 through WebCrypto: the digests every layer shares.
3
+ * The session crypto hashes bearer secrets with the first and partitions
4
+ * session keys with the second, the rate-limit engine folds an over-long
5
+ * tracker key, signed claims and keyed digests are HMACs under an app's
6
+ * secret, so every key space is built from the same primitives on every
7
+ * runtime (browsers on a secure context, Node 20+, edge runtimes); an
8
+ * upload's checksum is the same digest over the file's bytes.
7
9
  */
8
10
  /** Lowercase hex of a byte array, two characters per byte. */
9
11
  export declare const bytesToHexString: (bytes: Uint8Array) => string;
@@ -17,3 +19,15 @@ export declare const resolveWebCrypto: () => Promise<Crypto>;
17
19
  export declare const sha256HexOf: (text: string) => Promise<string>;
18
20
  /** The SHA-256 digest of `bytes`, as base64: the form object storage checks an upload's checksum in. */
19
21
  export declare const sha256Base64Of: (bytes: Uint8Array) => Promise<string>;
22
+ /** An HMAC-SHA256 key over `secret` (UTF-8), for `usages`; a holder that signs often keeps the key rather than importing it per call. */
23
+ export declare const importHmacKey: (webCrypto: Crypto, secret: string, usages?: KeyUsage[]) => Promise<CryptoKey>;
24
+ /** HMAC-SHA256 of `text` under `secret` (both UTF-8), as bytes. */
25
+ export declare const hmacSha256Of: (secret: string, text: string) => Promise<Uint8Array>;
26
+ /**
27
+ * Length-aware, timing-neutral string comparison: no early exit on the first
28
+ * differing character, so how long it takes says nothing about where two
29
+ * digests part. For comparing a stored digest with the digest of a candidate;
30
+ * a signature is verified by WebCrypto, which compares in constant time
31
+ * itself.
32
+ */
33
+ export declare const constantTimeEquals: (a: string, b: string) => boolean;
@@ -1,11 +1,13 @@
1
1
  import { bytesToBase64 } from "./LambderBase64.js";
2
2
  import { getCrypto } from "./LambderNodeModules.js";
3
3
  /**
4
- * SHA-256 through WebCrypto: the one digest every layer shares. The session
5
- * crypto hashes bearer secrets with it and the rate-limit engine folds an
6
- * over-long tracker key with it, so both key spaces are built from the same
7
- * primitive on every runtime (browsers on a secure context, Node 20+, edge
8
- * runtimes); an upload's checksum is the same digest over the file's bytes.
4
+ * SHA-256 and HMAC-SHA256 through WebCrypto: the digests every layer shares.
5
+ * The session crypto hashes bearer secrets with the first and partitions
6
+ * session keys with the second, the rate-limit engine folds an over-long
7
+ * tracker key, signed claims and keyed digests are HMACs under an app's
8
+ * secret, so every key space is built from the same primitives on every
9
+ * runtime (browsers on a secure context, Node 20+, edge runtimes); an
10
+ * upload's checksum is the same digest over the file's bytes.
9
11
  */
10
12
  /** Lowercase hex of a byte array, two characters per byte. */
11
13
  export const bytesToHexString = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -38,3 +40,26 @@ export const sha256Base64Of = async (bytes) => {
38
40
  const webCrypto = await resolveWebCrypto();
39
41
  return bytesToBase64(new Uint8Array(await webCrypto.subtle.digest("SHA-256", bytes)));
40
42
  };
43
+ /** An HMAC-SHA256 key over `secret` (UTF-8), for `usages`; a holder that signs often keeps the key rather than importing it per call. */
44
+ export const importHmacKey = async (webCrypto, secret, usages = ["sign"]) => await webCrypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, usages);
45
+ /** HMAC-SHA256 of `text` under `secret` (both UTF-8), as bytes. */
46
+ export const hmacSha256Of = async (secret, text) => {
47
+ const webCrypto = await resolveWebCrypto();
48
+ const key = await importHmacKey(webCrypto, secret);
49
+ return new Uint8Array(await webCrypto.subtle.sign("HMAC", key, new TextEncoder().encode(text)));
50
+ };
51
+ /**
52
+ * Length-aware, timing-neutral string comparison: no early exit on the first
53
+ * differing character, so how long it takes says nothing about where two
54
+ * digests part. For comparing a stored digest with the digest of a candidate;
55
+ * a signature is verified by WebCrypto, which compares in constant time
56
+ * itself.
57
+ */
58
+ export const constantTimeEquals = (a, b) => {
59
+ if (a.length !== b.length)
60
+ return false;
61
+ let difference = 0;
62
+ for (let i = 0; i < a.length; i += 1)
63
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
64
+ return difference === 0;
65
+ };
@@ -0,0 +1,9 @@
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 declare const assertPlainData: (value: unknown, subject: string) => void;
@@ -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,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
+ };
@@ -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;