lambder 14.1.1 → 15.0.2

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 (158) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +8 -3
  20. package/dist/build/checkApiRefusals.js +80 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -1,33 +1,61 @@
1
1
  import { assertNumberAtLeast } from "./LambderOptionChecks.js";
2
+ /**
3
+ * The longest delay setTimeout keeps: a longer one overflows its 32-bit
4
+ * millisecond count and fires at once, in browsers and in Node alike, so a
5
+ * ladder that reached past it would retry with no pause at all.
6
+ */
7
+ const LONGEST_TIMER_DELAY_MS = 2_147_483_647;
2
8
  export class LambderBackoffTimer {
3
9
  baseMs;
4
10
  maxMs;
5
11
  factor;
6
12
  jitter;
7
- attempts = 0;
13
+ retriesSinceReset = 0;
8
14
  timer = null;
9
15
  /** Settles the promise of a `wait` that cancel() or a replacement drops, so no `await` is left hanging. */
10
16
  dropPending = null;
11
17
  constructor(options = {}) {
12
- this.baseMs = assertNumberAtLeast(options.baseMs ?? 1_000, 0, "baseMs");
18
+ const baseMs = options.baseMs ?? 1_000;
19
+ // Every rung is a multiple of the base, so a base of 0 is a retry
20
+ // loop with no pause.
21
+ if (typeof baseMs !== "number" || !(baseMs > 0) || baseMs > LONGEST_TIMER_DELAY_MS) {
22
+ throw new Error(`Lambder: LambderBackoffTimer baseMs must be a number above 0 and at most ${LONGEST_TIMER_DELAY_MS}, got ${String(baseMs)}: every wait is a multiple of it, so 0 retries with no pause, and setTimeout fires a longer delay at once.`);
23
+ }
24
+ this.baseMs = baseMs;
13
25
  this.maxMs = assertNumberAtLeast(options.maxMs ?? Math.max(60_000, this.baseMs), this.baseMs, "maxMs");
26
+ if (this.maxMs > LONGEST_TIMER_DELAY_MS) {
27
+ throw new Error(`Lambder: LambderBackoffTimer maxMs must be at most ${LONGEST_TIMER_DELAY_MS}, got ${this.maxMs}: setTimeout fires a longer delay at once, so the top of the ladder would retry with no pause.`);
28
+ }
14
29
  this.factor = assertNumberAtLeast(options.factor ?? 2, 1, "factor");
15
- this.jitter = options.jitter ?? "full";
30
+ const jitter = options.jitter ?? "full";
31
+ if (jitter !== "full" && jitter !== "none") {
32
+ throw new Error(`Lambder: LambderBackoffTimer jitter must be "full" or "none", got ${JSON.stringify(jitter)}.`);
33
+ }
34
+ this.jitter = jitter;
16
35
  }
17
36
  /** True while a wait of any kind is pending. False again by the time it runs. */
18
37
  get pending() {
19
38
  return this.timer !== null;
20
39
  }
21
40
  /**
22
- * Runs `run` after the next rung of the ladder, counting one more failed
23
- * attempt. Replaces whatever was waiting.
41
+ * How many retries `retry` and `wait` have scheduled since the last reset
42
+ * (or since the timer was built), a dropped one included: the rung the
43
+ * next one climbs from. A loop that gives up after so many retries reads
44
+ * it here rather than keeping a counter of its own.
45
+ */
46
+ get retries() {
47
+ return this.retriesSinceReset;
48
+ }
49
+ /**
50
+ * Runs `run` after the next rung of the ladder, counting one more retry.
51
+ * Replaces whatever was waiting.
24
52
  */
25
53
  retry(run) {
26
54
  this.schedule(this.nextRung(), run, null);
27
55
  }
28
56
  /**
29
- * Resolves after the next rung of the ladder, counting one more failed
30
- * attempt: the `retry` for code that awaits rather than calls back.
57
+ * Resolves after the next rung of the ladder, counting one more retry:
58
+ * the `retry` for code that awaits rather than calls back.
31
59
  * Replaces whatever was waiting. Rejects at once with the signal's reason
32
60
  * when `signal` aborts, and with an Error when cancel() or a later wait
33
61
  * drops it before it ran, so an await on it always settles.
@@ -52,9 +80,9 @@ export class LambderBackoffTimer {
52
80
  after(delayMs, run) {
53
81
  this.schedule(delayMs, run, null);
54
82
  }
55
- /** The attempt worked: the next failure waits the shortest time again. A pending wait is left alone. */
83
+ /** The attempt worked: the next failure waits the shortest time again, and `retries` is 0. A pending wait is left alone. */
56
84
  reset() {
57
- this.attempts = 0;
85
+ this.retriesSinceReset = 0;
58
86
  }
59
87
  /** Drops the pending wait (the caller is trying right now, or going away), keeping the count. */
60
88
  cancel() {
@@ -66,10 +94,10 @@ export class LambderBackoffTimer {
66
94
  this.dropPending = null;
67
95
  drop?.();
68
96
  }
69
- /** The next wait on the ladder, in milliseconds, counting one more failed attempt. */
97
+ /** The next wait on the ladder, in milliseconds, counting one more retry. */
70
98
  nextRung() {
71
- const ceiling = Math.min(this.baseMs * this.factor ** this.attempts, this.maxMs - this.baseMs);
72
- this.attempts += 1;
99
+ const ceiling = Math.min(this.baseMs * this.factor ** this.retriesSinceReset, this.maxMs - this.baseMs);
100
+ this.retriesSinceReset += 1;
73
101
  return this.baseMs + (this.jitter === "full" ? Math.random() : 1) * ceiling;
74
102
  }
75
103
  schedule(delayMs, run, drop) {
@@ -9,13 +9,22 @@
9
9
  * the two from drifting apart: a caller that believed a late answer would
10
10
  * report `ok: true` for a call its site had already abandoned.
11
11
  *
12
+ * Who gave up matters as much as when. A call's own timeout is a failure the
13
+ * callers report; the site's own signal is the site's choice (a superseded
14
+ * read, a view that closed), which nothing should report as something gone
15
+ * wrong, so the two are told apart here, once. A site signal can carry a
16
+ * deadline of its own, though: AbortSignal.timeout() aborts with a
17
+ * TimeoutError, alone or inside AbortSignal.any(), and a call it ends is
18
+ * timed out like one its own timeoutMs ended. Read as the site's choice, it
19
+ * would fail with nothing reporting it.
20
+ *
12
21
  * The listener on an external signal is removed in detach() rather than left
13
22
  * to `once`: a site's signal usually outlives the call (one controller per
14
23
  * page, per view, per request), so a listener per call would accumulate on it
15
24
  * for as long as the signal lives.
16
25
  */
17
- /** How an abandoned call is reported: its own timeout fired, or the site's signal did, which is a `network` failure like any other abort. */
18
- type LambderCallAbortReason = "timeout" | "network";
26
+ /** Who gave up on a call: a deadline ended it (`timeout`: its own timeoutMs, or a site signal that aborted with a TimeoutError), or the site's own signal did (`aborted`). */
27
+ export type LambderCallAbortReason = "timeout" | "aborted";
19
28
  /** The reason and the error to report for a call that was given up on. */
20
29
  type LambderCallAbortFailure = {
21
30
  reason: LambderCallAbortReason;
@@ -29,8 +38,12 @@ export type LambderCallAbortStage = "beforeSending" | "afterAnswering";
29
38
  type LambderCallAbort = {
30
39
  /** What the transport is handed: the chained signal when a timeout is set, the site's own otherwise, and nothing when there is neither. */
31
40
  signal: AbortSignal | undefined;
32
- /** Whether this call's own timeout is what aborted it, rather than the site's signal. */
33
- timedOut: () => boolean;
41
+ /**
42
+ * Who aborted the call, or null while nothing has. A transport that
43
+ * rejects once the call is aborted is read by this first, whatever it
44
+ * rejected with: only the caller knows which of the two aborted it.
45
+ */
46
+ abortReason: () => LambderCallAbortReason | null;
34
47
  /**
35
48
  * The failure to report, or null while the call still stands. Run before
36
49
  * handing the request to the transport (a call already abandoned should
@@ -55,8 +68,11 @@ export declare const createCallAbort: (options: {
55
68
  * transports a signal has nothing to cancel, and by the crash reporter's
56
69
  * time bound (LambderCrashHandling), where the app's report runs on.
57
70
  *
58
- * The listener is detached on either outcome, for the reason createCallAbort
59
- * detaches its own.
71
+ * A signal that aborted before the wait began rejects it at once with the
72
+ * signal's reason, since it fires no event for a listener added afterwards.
73
+ * `pending` is still attached to either way, so what it later rejects with is
74
+ * settled here rather than left an unhandled rejection. The listener is
75
+ * detached on either outcome, for the reason createCallAbort detaches its own.
60
76
  */
61
77
  export declare const stopWaitingWhenAborted: <T>(pending: Promise<T>, signal: AbortSignal | undefined) => Promise<T>;
62
78
  export {};
@@ -9,11 +9,22 @@
9
9
  * the two from drifting apart: a caller that believed a late answer would
10
10
  * report `ok: true` for a call its site had already abandoned.
11
11
  *
12
+ * Who gave up matters as much as when. A call's own timeout is a failure the
13
+ * callers report; the site's own signal is the site's choice (a superseded
14
+ * read, a view that closed), which nothing should report as something gone
15
+ * wrong, so the two are told apart here, once. A site signal can carry a
16
+ * deadline of its own, though: AbortSignal.timeout() aborts with a
17
+ * TimeoutError, alone or inside AbortSignal.any(), and a call it ends is
18
+ * timed out like one its own timeoutMs ended. Read as the site's choice, it
19
+ * would fail with nothing reporting it.
20
+ *
12
21
  * The listener on an external signal is removed in detach() rather than left
13
22
  * to `once`: a site's signal usually outlives the call (one controller per
14
23
  * page, per view, per request), so a listener per call would accumulate on it
15
24
  * for as long as the signal lives.
16
25
  */
26
+ /** What AbortSignal.timeout() aborts with: a DOMException, which is not an Error everywhere, so it is read by its name. */
27
+ const isTimeoutReason = (reason) => typeof reason === "object" && reason !== null && reason.name === "TimeoutError";
17
28
  export const createCallAbort = (options) => {
18
29
  const { timeoutMs, signal: external } = options;
19
30
  let timedOut = false;
@@ -34,21 +45,32 @@ export const createCallAbort = (options) => {
34
45
  detachExternal = () => external.removeEventListener("abort", forwardAbort);
35
46
  }
36
47
  }
37
- timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
48
+ timeoutId = setTimeout(() => {
49
+ // The first abort names the call: a timer that fires after the
50
+ // site's signal already aborted it finds the call given up on by
51
+ // the site, and must not report it as a timeout.
52
+ if (!controller.signal.aborted)
53
+ timedOut = true;
54
+ controller.abort();
55
+ }, timeoutMs);
38
56
  signal = controller.signal;
39
57
  }
58
+ // The chained controller aborts with the site signal's own reason, so a
59
+ // deadline the site set is still readable here.
60
+ const abortReason = () => !signal?.aborted ? null : timedOut || isTimeoutReason(signal.reason) ? "timeout" : "aborted";
40
61
  return {
41
62
  signal,
42
- timedOut: () => timedOut,
63
+ abortReason,
43
64
  abortFailure: (stage) => {
44
- if (!signal?.aborted)
65
+ const reason = abortReason();
66
+ if (!reason)
45
67
  return null;
46
68
  const detail = stage === "beforeSending"
47
69
  ? "the call was given up on before it was sent"
48
70
  : "the answer arrived too late to be used";
49
- return timedOut
50
- ? { reason: "timeout", error: new Error(`Request timed out after ${timeoutMs}ms; ${detail}.`) }
51
- : { reason: "network", error: new Error(`Request aborted; ${detail}.`) };
71
+ if (reason === "aborted")
72
+ return { reason, error: new Error(`Request aborted by its signal; ${detail}.`) };
73
+ return { reason, error: new Error(timedOut ? `Request timed out after ${timeoutMs}ms; ${detail}.` : `Request timed out by its signal; ${detail}.`) };
52
74
  },
53
75
  detach: () => {
54
76
  if (timeoutId !== undefined)
@@ -66,15 +88,21 @@ export const createCallAbort = (options) => {
66
88
  * transports a signal has nothing to cancel, and by the crash reporter's
67
89
  * time bound (LambderCrashHandling), where the app's report runs on.
68
90
  *
69
- * The listener is detached on either outcome, for the reason createCallAbort
70
- * detaches its own.
91
+ * A signal that aborted before the wait began rejects it at once with the
92
+ * signal's reason, since it fires no event for a listener added afterwards.
93
+ * `pending` is still attached to either way, so what it later rejects with is
94
+ * settled here rather than left an unhandled rejection. The listener is
95
+ * detached on either outcome, for the reason createCallAbort detaches its own.
71
96
  */
72
97
  export const stopWaitingWhenAborted = (pending, signal) => {
73
98
  if (!signal)
74
99
  return pending;
75
100
  return new Promise((resolve, reject) => {
76
101
  const onAbort = () => reject(signal.reason);
77
- signal.addEventListener("abort", onAbort, { once: true });
102
+ if (signal.aborted)
103
+ onAbort();
104
+ else
105
+ signal.addEventListener("abort", onAbort, { once: true });
78
106
  pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
79
107
  });
80
108
  };
@@ -0,0 +1,4 @@
1
+ /** Marks an error as reported where it arose. */
2
+ export declare const markErrorReported: (error: Error) => void;
3
+ /** Whether an error was marked as reported where it arose. Read by brand, so it holds across realms and package copies. */
4
+ export declare const isErrorReported: (error: unknown) => boolean;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The mark on an error that has already been reported, so the crash
3
+ * reporting it reaches next does not report it a second time.
4
+ *
5
+ * LambderInvokeCaller marks the error of a failure its onFailure handler
6
+ * took: api() then throws that same error, and an app that lets it propagate
7
+ * hands it to the instance's crash reporting, which skips a marked error and
8
+ * still answers it as a crash. The two ends live in invoke/ and core/, which
9
+ * may not import each other, so the mark sits below both.
10
+ *
11
+ * A key from the global symbol registry rather than a WeakSet kept here: a
12
+ * caller and a server from two copies of the package (an app's and a
13
+ * dependency's) agree on it, as they do on every Lambder brand. It is set
14
+ * non-enumerable, so it never shows up in a serialized error or a log line.
15
+ */
16
+ const LAMBDER_ERROR_REPORTED = Symbol.for("lambder.errorReported");
17
+ /** Marks an error as reported where it arose. */
18
+ export const markErrorReported = (error) => {
19
+ Object.defineProperty(error, LAMBDER_ERROR_REPORTED, { value: true, configurable: true });
20
+ };
21
+ /** Whether an error was marked as reported where it arose. Read by brand, so it holds across realms and package copies. */
22
+ export const isErrorReported = (error) => typeof error === "object" && error !== null && error[LAMBDER_ERROR_REPORTED] === true;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The digest a caller-controlled field of a store key is written as.
3
+ *
4
+ * The rate-limit engine and the idempotency engine each build a store key
5
+ * around fields only the caller controls: an address, a session key, whatever
6
+ * a custom rate-limit key returned, a callerIdentity, a posted idempotency
7
+ * key. Written as they are, a table read shows who called (an email, a device
8
+ * token, an address), and a long one passes a store's own key limit, which
9
+ * the store answers by throwing and both engines' failOpen turns into no
10
+ * limit or no idempotency for that caller. So every such field is written as
11
+ * a digest of a fixed length, whatever its own, in one implementation the two
12
+ * engines share: `<kind>:<64 hex>`, with the kind (ip, session, custom, ...)
13
+ * left readable, as the API and policy names around it are.
14
+ *
15
+ * Keyed when the app has an at-rest secret (the session salt): an HMAC under
16
+ * a subkey derived from it for this one purpose, so a table read alone cannot
17
+ * test a guess. Derived through HKDF rather than used as it is because the
18
+ * session store keys its partition hash with the salt itself, and that hash
19
+ * rides in every session cookie: a sessionKey a user can choose would
20
+ * otherwise let them read off a cookie the digest of any value they like.
21
+ * Without a secret the digest is a plain SHA-256, which keeps the values out
22
+ * of the table but lets a reader test guesses: an email from a list, an IPv4
23
+ * address from all four billion.
24
+ */
25
+ export declare class LambderKeyFieldDigest {
26
+ /** Whether the digest is keyed by the app's at-rest secret, or a plain SHA-256. */
27
+ readonly isKeyed: boolean;
28
+ private readonly atRestSecret;
29
+ private hmacKeyPromise;
30
+ constructor(atRestSecret: string | null);
31
+ /**
32
+ * `value` written as a field of `kind`: `<kind>:<hex>`. The kind is part
33
+ * of what is digested, so one value under two kinds (a session key
34
+ * counted by a rate limit and scoping an idempotency record) gives two
35
+ * unrelated digests, and a table read cannot join the two.
36
+ *
37
+ * A runtime without WebCrypto (the mock on a page served over plain http,
38
+ * a phone on the LAN) writes `<kind>:<value>` instead: the only stores
39
+ * such a runtime runs over are the mock's memory ones, which nobody reads
40
+ * at rest and which have no key limit, and a key there is only ever
41
+ * compared with keys the same runtime wrote.
42
+ */
43
+ digestOf(kind: string, value: string): Promise<string>;
44
+ /** The HMAC key, derived once per instance: HKDF-SHA256 over the secret, with no salt and this purpose as its info. */
45
+ private hmacKey;
46
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The digest a caller-controlled field of a store key is written as.
3
+ *
4
+ * The rate-limit engine and the idempotency engine each build a store key
5
+ * around fields only the caller controls: an address, a session key, whatever
6
+ * a custom rate-limit key returned, a callerIdentity, a posted idempotency
7
+ * key. Written as they are, a table read shows who called (an email, a device
8
+ * token, an address), and a long one passes a store's own key limit, which
9
+ * the store answers by throwing and both engines' failOpen turns into no
10
+ * limit or no idempotency for that caller. So every such field is written as
11
+ * a digest of a fixed length, whatever its own, in one implementation the two
12
+ * engines share: `<kind>:<64 hex>`, with the kind (ip, session, custom, ...)
13
+ * left readable, as the API and policy names around it are.
14
+ *
15
+ * Keyed when the app has an at-rest secret (the session salt): an HMAC under
16
+ * a subkey derived from it for this one purpose, so a table read alone cannot
17
+ * test a guess. Derived through HKDF rather than used as it is because the
18
+ * session store keys its partition hash with the salt itself, and that hash
19
+ * rides in every session cookie: a sessionKey a user can choose would
20
+ * otherwise let them read off a cookie the digest of any value they like.
21
+ * Without a secret the digest is a plain SHA-256, which keeps the values out
22
+ * of the table but lets a reader test guesses: an email from a list, an IPv4
23
+ * address from all four billion.
24
+ */
25
+ import { joinKeyFields } from "./joinKeyFields.js";
26
+ import { bytesToHexString, resolveWebCrypto } from "./LambderTextDigest.js";
27
+ /** HKDF's `info`: the purpose the subkey is derived for, so no other derivation from the same secret meets it. */
28
+ const KEY_FIELD_DIGEST_PURPOSE = "lambder/key-field-digest";
29
+ export class LambderKeyFieldDigest {
30
+ /** Whether the digest is keyed by the app's at-rest secret, or a plain SHA-256. */
31
+ isKeyed;
32
+ atRestSecret;
33
+ hmacKeyPromise;
34
+ constructor(atRestSecret) {
35
+ if (atRestSecret !== null && (typeof atRestSecret !== "string" || atRestSecret === "")) {
36
+ throw new Error("Lambder: the at-rest secret that keys the digest of rate-limit and idempotency keys is empty. Pass the app's real secret (the session salt), or none.");
37
+ }
38
+ this.atRestSecret = atRestSecret;
39
+ this.isKeyed = atRestSecret !== null;
40
+ }
41
+ /**
42
+ * `value` written as a field of `kind`: `<kind>:<hex>`. The kind is part
43
+ * of what is digested, so one value under two kinds (a session key
44
+ * counted by a rate limit and scoping an idempotency record) gives two
45
+ * unrelated digests, and a table read cannot join the two.
46
+ *
47
+ * A runtime without WebCrypto (the mock on a page served over plain http,
48
+ * a phone on the LAN) writes `<kind>:<value>` instead: the only stores
49
+ * such a runtime runs over are the mock's memory ones, which nobody reads
50
+ * at rest and which have no key limit, and a key there is only ever
51
+ * compared with keys the same runtime wrote.
52
+ */
53
+ async digestOf(kind, value) {
54
+ let webCrypto;
55
+ try {
56
+ webCrypto = await resolveWebCrypto();
57
+ }
58
+ catch {
59
+ return `${kind}:${value}`;
60
+ }
61
+ const message = new TextEncoder().encode(joinKeyFields(kind, value));
62
+ const digest = this.atRestSecret === null
63
+ ? await webCrypto.subtle.digest("SHA-256", message)
64
+ : await webCrypto.subtle.sign("HMAC", await this.hmacKey(webCrypto, this.atRestSecret), message);
65
+ return `${kind}:${bytesToHexString(new Uint8Array(digest))}`;
66
+ }
67
+ /** The HMAC key, derived once per instance: HKDF-SHA256 over the secret, with no salt and this purpose as its info. */
68
+ hmacKey(webCrypto, atRestSecret) {
69
+ this.hmacKeyPromise ??= (async () => {
70
+ const secretKey = await webCrypto.subtle.importKey("raw", new TextEncoder().encode(atRestSecret), "HKDF", false, ["deriveKey"]);
71
+ return await webCrypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode(KEY_FIELD_DIGEST_PURPOSE) }, secretKey, { name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign"]);
72
+ })();
73
+ return this.hmacKeyPromise;
74
+ }
75
+ }
@@ -75,4 +75,23 @@ export declare const keyedDigest: (secret: string, value: string) => Promise<str
75
75
  * without one is told so.
76
76
  */
77
77
  export declare const randomSecret: (bytes?: number) => string;
78
+ /**
79
+ * The characters of an alphabet a code is drawn from, as code points, once
80
+ * they are 2 to 256 distinct ones; anything else throws, naming `name`. One
81
+ * character draws nothing random, a repeat weighs its character double, and
82
+ * past 256 the one byte randomCode draws per character cannot reach every
83
+ * character. Returns the characters so the check reads as an assignment.
84
+ */
85
+ export declare const assertCodeAlphabet: (alphabet: unknown, name: string) => string[];
86
+ /**
87
+ * A code of `length` characters drawn uniformly from `alphabet` with the
88
+ * runtime's cryptographic random source: the digits emailed to an address,
89
+ * the letters of a pairing code somebody types. One byte draws one
90
+ * character, and a byte at or above the largest multiple of the alphabet's
91
+ * size is discarded and drawn again, since `byte % alphabet.length` alone
92
+ * favours the alphabet's first characters. The alphabet is 2 to 256 distinct
93
+ * characters (code points) and `length` a positive integer; anything else
94
+ * throws. Synchronous, as randomSecret is.
95
+ */
96
+ export declare const randomCode: (alphabet: string, length: number) => string;
78
97
  export {};
@@ -1,5 +1,6 @@
1
1
  import { base64UrlToBytes, bytesToBase64Url, isBase64Url } from "./LambderBase64.js";
2
2
  import { hmacSha256Of, importHmacKey, resolveWebCrypto } from "./LambderTextDigest.js";
3
+ import { assertPositiveInteger } from "./LambderOptionChecks.js";
3
4
  /**
4
5
  * One kind of signed token: constructed once with its secret, version and
5
6
  * claims schema, it keeps the imported HMAC key and answers `sign` and
@@ -107,3 +108,56 @@ export const randomSecret = (bytes = 32) => {
107
108
  }
108
109
  return bytesToBase64Url(webCrypto.getRandomValues(new Uint8Array(bytes)));
109
110
  };
111
+ /** The most bytes one getRandomValues call fills. */
112
+ const MAX_RANDOM_BYTES_PER_CALL = 65536;
113
+ /**
114
+ * The characters of an alphabet a code is drawn from, as code points, once
115
+ * they are 2 to 256 distinct ones; anything else throws, naming `name`. One
116
+ * character draws nothing random, a repeat weighs its character double, and
117
+ * past 256 the one byte randomCode draws per character cannot reach every
118
+ * character. Returns the characters so the check reads as an assignment.
119
+ */
120
+ export const assertCodeAlphabet = (alphabet, name) => {
121
+ const characters = typeof alphabet === "string" ? Array.from(alphabet) : [];
122
+ const distinct = new Set(characters).size;
123
+ if (characters.length < 2 || characters.length > 256 || distinct !== characters.length) {
124
+ const found = typeof alphabet === "string" ? `${characters.length} characters, ${distinct} of them distinct` : typeof alphabet;
125
+ throw new Error(`Lambder: ${name} must be 2 to 256 distinct characters, got ${found}.`);
126
+ }
127
+ return characters;
128
+ };
129
+ /**
130
+ * A code of `length` characters drawn uniformly from `alphabet` with the
131
+ * runtime's cryptographic random source: the digits emailed to an address,
132
+ * the letters of a pairing code somebody types. One byte draws one
133
+ * character, and a byte at or above the largest multiple of the alphabet's
134
+ * size is discarded and drawn again, since `byte % alphabet.length` alone
135
+ * favours the alphabet's first characters. The alphabet is 2 to 256 distinct
136
+ * characters (code points) and `length` a positive integer; anything else
137
+ * throws. Synchronous, as randomSecret is.
138
+ */
139
+ export const randomCode = (alphabet, length) => {
140
+ const characters = assertCodeAlphabet(alphabet, "randomCode alphabet");
141
+ assertPositiveInteger(length, "randomCode length");
142
+ const webCrypto = globalThis.crypto;
143
+ if (typeof webCrypto?.getRandomValues !== "function") {
144
+ 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.");
145
+ }
146
+ const ceiling = Math.floor(256 / characters.length) * characters.length;
147
+ const drawn = [];
148
+ // Twice the length lets one call cover the discarded bytes nearly
149
+ // always; an alphabet that discards close to half of them may need
150
+ // another, which the loop draws.
151
+ const buffer = new Uint8Array(Math.min(length * 2, MAX_RANDOM_BYTES_PER_CALL));
152
+ while (drawn.length < length) {
153
+ webCrypto.getRandomValues(buffer);
154
+ for (const byte of buffer) {
155
+ if (byte >= ceiling)
156
+ continue;
157
+ drawn.push(characters[byte % characters.length]);
158
+ if (drawn.length === length)
159
+ break;
160
+ }
161
+ }
162
+ return drawn.join("");
163
+ };
@@ -0,0 +1,29 @@
1
+ /** A registered instance as the test kit reaches it. */
2
+ export type LambderSwappableInstance = {
3
+ readonly instance: object;
4
+ /**
5
+ * Calls the instance's swap door with the twins the test kit offers. The
6
+ * door picks the one its class needs, so this list need not know what
7
+ * each instance is.
8
+ */
9
+ swapIn(twins: unknown): void;
10
+ };
11
+ /**
12
+ * Registers an instance `lambder/testing` puts a memory twin under, from the
13
+ * end of its constructor: the swap door keyed LAMBDER_BACKEND_SWAP must work
14
+ * by then, since a test app already in charge swaps it on the spot.
15
+ */
16
+ export declare const registerSwappableInstance: (instance: object) => void;
17
+ /** Every registered instance still alive, in the order they were constructed. */
18
+ export declare const liveSwappableInstances: () => LambderSwappableInstance[];
19
+ /** Hands every instance registered from now on to the watcher, in place of any earlier one. */
20
+ export declare const watchSwappableInstances: (watcher: (instance: LambderSwappableInstance) => void) => void;
21
+ /**
22
+ * Makes every member named answer from the twin, in place: an own property of
23
+ * the instance, bound to the twin, over the class's method. Whatever holds the
24
+ * instance (a module-level constant, a class built over it) reaches the twin
25
+ * on its next call without being told, and a second twin replaces the first.
26
+ * The members are a record over the interface's keys, so an interface that
27
+ * grows a method fails to compile until its stores' doors hand it over too.
28
+ */
29
+ export declare const delegateToTwin: <TInterface extends object>(instance: TInterface, twin: TInterface, members: { readonly [TMember in keyof TInterface]-?: true; }) => void;
@@ -0,0 +1,54 @@
1
+ import { LAMBDER_BACKEND_SWAP } from "./LambderTestingDoors.js";
2
+ const REGISTRY_KEY = Symbol.for("lambder.swappableInstances");
3
+ const MIN_SWEEP_AT = 64;
4
+ /** Created on first use rather than when the module loads, so importing it does nothing. */
5
+ const registry = () => {
6
+ const holder = globalThis;
7
+ return holder[REGISTRY_KEY] ??= { entries: [], sweepAt: MIN_SWEEP_AT, watcher: null };
8
+ };
9
+ const handleOf = (instance, door) => ({
10
+ instance,
11
+ swapIn: (twins) => instance[door](twins),
12
+ });
13
+ /**
14
+ * Registers an instance `lambder/testing` puts a memory twin under, from the
15
+ * end of its constructor: the swap door keyed LAMBDER_BACKEND_SWAP must work
16
+ * by then, since a test app already in charge swaps it on the spot.
17
+ */
18
+ export const registerSwappableInstance = (instance) => {
19
+ const state = registry();
20
+ state.entries.push({ ref: new WeakRef(instance), door: LAMBDER_BACKEND_SWAP });
21
+ if (state.entries.length >= state.sweepAt) {
22
+ state.entries = state.entries.filter((entry) => entry.ref.deref() !== undefined);
23
+ state.sweepAt = Math.max(MIN_SWEEP_AT, state.entries.length * 2);
24
+ }
25
+ state.watcher?.(handleOf(instance, LAMBDER_BACKEND_SWAP));
26
+ };
27
+ /** Every registered instance still alive, in the order they were constructed. */
28
+ export const liveSwappableInstances = () => {
29
+ const handles = [];
30
+ for (const entry of registry().entries) {
31
+ const instance = entry.ref.deref();
32
+ if (instance !== undefined)
33
+ handles.push(handleOf(instance, entry.door));
34
+ }
35
+ return handles;
36
+ };
37
+ /** Hands every instance registered from now on to the watcher, in place of any earlier one. */
38
+ export const watchSwappableInstances = (watcher) => {
39
+ registry().watcher = watcher;
40
+ };
41
+ /**
42
+ * Makes every member named answer from the twin, in place: an own property of
43
+ * the instance, bound to the twin, over the class's method. Whatever holds the
44
+ * instance (a module-level constant, a class built over it) reaches the twin
45
+ * on its next call without being told, and a second twin replaces the first.
46
+ * The members are a record over the interface's keys, so an interface that
47
+ * grows a method fails to compile until its stores' doors hand it over too.
48
+ */
49
+ export const delegateToTwin = (instance, twin, members) => {
50
+ for (const member of Object.keys(members)) {
51
+ const method = twin[member];
52
+ Object.defineProperty(instance, member, { value: method.bind(twin), configurable: true, writable: true });
53
+ }
54
+ };
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The keys of the two doors `lambder/testing` opens on a built instance.
2
+ * The keys of the two doors `lambder/testing` opens on a built instance, the
3
+ * first of which it also opens on the classes an app builds beside it.
3
4
  *
4
5
  * Symbols rather than named methods, and exported by no entry point: the
5
6
  * package's exports map is what a consumer can import, so only
@@ -14,7 +15,10 @@
14
15
  * other stores: the copy's closures would still reach the original and the
15
16
  * production table under it. The stores are therefore replaced in place, and
16
17
  * every class that holds one answers to this key with a method that takes the
17
- * replacement.
18
+ * replacement. A class an app builds itself, beside the instance (a cache, an
19
+ * upload bucket, an invoke caller), registers as it is constructed (see
20
+ * LambderSwappableInstances) and answers to this key with a method that puts
21
+ * a memory twin, or a mock app, under itself in place.
18
22
  */
19
23
  export declare const LAMBDER_BACKEND_SWAP: unique symbol;
20
24
  /**
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The keys of the two doors `lambder/testing` opens on a built instance.
2
+ * The keys of the two doors `lambder/testing` opens on a built instance, the
3
+ * first of which it also opens on the classes an app builds beside it.
3
4
  *
4
5
  * Symbols rather than named methods, and exported by no entry point: the
5
6
  * package's exports map is what a consumer can import, so only
@@ -14,7 +15,10 @@
14
15
  * other stores: the copy's closures would still reach the original and the
15
16
  * production table under it. The stores are therefore replaced in place, and
16
17
  * every class that holds one answers to this key with a method that takes the
17
- * replacement.
18
+ * replacement. A class an app builds itself, beside the instance (a cache, an
19
+ * upload bucket, an invoke caller), registers as it is constructed (see
20
+ * LambderSwappableInstances) and answers to this key with a method that puts
21
+ * a memory twin, or a mock app, under itself in place.
18
22
  */
19
23
  export const LAMBDER_BACKEND_SWAP = Symbol("lambder.backendSwap");
20
24
  /**
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * SHA-256 and HMAC-SHA256 through WebCrypto: the digests every layer shares.
3
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.
4
+ * session keys with the second, the policy engines digest the caller's
5
+ * fields of their store keys, signed claims and keyed digests are HMACs
6
+ * under an app's secret, so every key space is built from the same
7
+ * primitives 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.
9
9
  */
10
10
  /** Lowercase hex of a byte array, two characters per byte. */
11
11
  export declare const bytesToHexString: (bytes: Uint8Array) => string;
@@ -3,11 +3,11 @@ import { getCrypto } from "./LambderNodeModules.js";
3
3
  /**
4
4
  * SHA-256 and HMAC-SHA256 through WebCrypto: the digests every layer shares.
5
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.
6
+ * session keys with the second, the policy engines digest the caller's
7
+ * fields of their store keys, signed claims and keyed digests are HMACs
8
+ * under an app's secret, so every key space is built from the same
9
+ * primitives on every runtime (browsers on a secure context, Node 20+, edge
10
+ * runtimes); an upload's checksum is the same digest over the file's bytes.
11
11
  */
12
12
  /** Lowercase hex of a byte array, two characters per byte. */
13
13
  export const bytesToHexString = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");