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,6 +1,7 @@
1
1
  import { type LambderApiRequest } from "../api/LambderApiRequest.js";
2
2
  import type { LambderApiAnswer } from "../api/LambderApiAnswer.js";
3
- import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
3
+ import type { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
4
+ import { LAMBDER_MOCK_ADAPTER_DOOR, type LambderMockAdapterDoor } from "./LambderMockAdapterDoor.js";
4
5
  /** What a resolver of Lambder's adapters is: msw's, answering a Response, or `undefined` to hand the request on. */
5
6
  type LambderMswResolver = (info: {
6
7
  request: Request;
@@ -26,21 +27,23 @@ export type LambderMswModule = {
26
27
  error(): Response;
27
28
  };
28
29
  };
29
- /** What the adapter needs from the mock app: a parsed request in, an answer out, and the runtime's own bookkeeping. */
30
+ /**
31
+ * What the adapter needs from the mock app: a parsed request in, an answer
32
+ * out, and the runtime's own bookkeeping behind the adapter door, which a
33
+ * LambderMockApp carries and nothing outside the package can name (see
34
+ * LAMBDER_MOCK_ADAPTER_DOOR). So in practice the target is a LambderMockApp.
35
+ */
30
36
  export type LambderMockMswTarget = {
31
37
  handleRequest(request: LambderApiRequest): Promise<LambderApiAnswer>;
38
+ /** Whether a name has an entry, a call handed on to the network, and an answer's readable cookies for the page. */
39
+ readonly [LAMBDER_MOCK_ADAPTER_DOOR]: LambderMockAdapterDoor;
32
40
  /**
33
- * Whether this name would be answered from the registry, overrides and a
34
- * registered rest entry included; asked once per request, so not a list to
35
- * scan.
41
+ * The page's cookie jar, which this adapter carries its calls' cookies in
42
+ * unless it is given its own: the jar signIn and signOut use when given
43
+ * none, so a session signIn starts is the one these calls send, and the
44
+ * one reset() empties.
36
45
  */
37
- hasRegisteredEntry(apiName: string): boolean;
38
- /** Records a request this adapter handed on rather than answering. */
39
- notePassthrough(request: LambderApiRequest): void;
40
- /** Mirrors an answer's readable cookies into document.cookie, and remembers them for reset(). */
41
- mirrorCookiesIntoDocument(setCookies: readonly string[]): void;
42
- /** Takes the jar this adapter built as the runtime's own, so reset() empties it too. */
43
- adoptCookieJar(jar: LambderCookieJar): void;
46
+ readonly pageCookieJar: LambderCookieJar;
44
47
  /** The IP a call carrying none is read as arriving from, so this adapter and the direct transport report one address. */
45
48
  readonly defaultClientIp: string;
46
49
  /**
@@ -66,7 +69,9 @@ export type LambderMockMswTarget = {
66
69
  * them, losing every cookie after the first. So the answer's cookies go into
67
70
  * the jar, the next request carries them back, and the ones a page's scripts
68
71
  * may see are mirrored into document.cookie. The Set-Cookie headers still
69
- * travel on the response, where the network panel shows them.
72
+ * travel on the response, where the network panel shows them. The jar is the
73
+ * runtime's page jar unless one is given, the one signIn plants into when it
74
+ * is given none, so `mockApp.signIn(key, data)` signs the page in.
70
75
  *
71
76
  * A request's cookies are therefore the jar's and document.cookie's, never
72
77
  * its Cookie header: MSW fills that from its own store, which captures the
@@ -86,6 +91,7 @@ export type LambderMockMswTarget = {
86
91
  export declare const lambderMockMswHandler: <M extends LambderMswModule>(mockApp: LambderMockMswTarget, options: {
87
92
  msw: M;
88
93
  apiPath: string;
94
+ /** The jar this adapter's calls carry their cookies in. Default: the runtime's pageCookieJar. One passed here stays the app's, and reset() leaves it alone. */
89
95
  cookieJar?: LambderCookieJar;
90
96
  /**
91
97
  * What happens to a call the runtime has no entry for. "refuse"
@@ -1,9 +1,9 @@
1
1
  import { LambderMockTransportError } from "./LambderMockFailureInjector.js";
2
2
  import { apiNameOfCallPath } from "../shared/wire/LambderApiNames.js";
3
- import { readApiEnvelope, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
3
+ import { readApiEnvelopeText, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
4
4
  import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
5
- import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
6
5
  import { normalizeClientIp } from "../shared/util/LambderClientIp.js";
6
+ import { LAMBDER_MOCK_ADAPTER_DOOR } from "./LambderMockAdapterDoor.js";
7
7
  /**
8
8
  * ONE MSW request handler for the whole API path, over the mock app: the
9
9
  * opt-in that makes mocked calls appear in the browser's network panel as
@@ -17,7 +17,9 @@ import { normalizeClientIp } from "../shared/util/LambderClientIp.js";
17
17
  * them, losing every cookie after the first. So the answer's cookies go into
18
18
  * the jar, the next request carries them back, and the ones a page's scripts
19
19
  * may see are mirrored into document.cookie. The Set-Cookie headers still
20
- * travel on the response, where the network panel shows them.
20
+ * travel on the response, where the network panel shows them. The jar is the
21
+ * runtime's page jar unless one is given, the one signIn plants into when it
22
+ * is given none, so `mockApp.signIn(key, data)` signs the page in.
21
23
  *
22
24
  * A request's cookies are therefore the jar's and document.cookie's, never
23
25
  * its Cookie header: MSW fills that from its own store, which captures the
@@ -39,12 +41,17 @@ export const lambderMockMswHandler = (mockApp, options) => {
39
41
  if (!msw?.http || !msw?.HttpResponse) {
40
42
  throw new Error('lambderMockMswHandler requires the msw module: lambderMockMswHandler(mockApp, { apiPath, msw: await import("msw") }). Install it with: npm install msw --save-dev');
41
43
  }
42
- const jar = options.cookieJar ?? new LambderCookieJar();
43
- // A jar the app passed stays the app's; the one built here is the
44
- // runtime's, so its reset() empties it along with the sessions those
45
- // cookies name.
46
- if (!options.cookieJar)
47
- mockApp.adoptCookieJar(jar);
44
+ // The runtime's bookkeeping is reached through the door a LambderMockApp
45
+ // carries; anything else would fail on its first call rather than here.
46
+ const door = mockApp?.[LAMBDER_MOCK_ADAPTER_DOOR];
47
+ if (typeof door?.hasRegisteredEntry !== "function") {
48
+ throw new Error("lambderMockMswHandler serves a LambderMockApp: pass the app initLambderMock().create() built, as lambderMockMswHandler(mockApp, { apiPath, msw }).");
49
+ }
50
+ // The page's jar unless the app passes its own: a jar of this adapter's
51
+ // alone would hold the session of the calls it serves and never the one
52
+ // signIn planted, and every session call behind the worker after a
53
+ // signIn would answer sessionExpired.
54
+ const jar = options.cookieJar ?? mockApp.pageCookieJar;
48
55
  // The runtime's default, not one of this adapter's own, so the direct
49
56
  // transport and the service worker agree on ctx.request.ip and a per-IP
50
57
  // rate limit counts one client as one. Normalized the way every other
@@ -54,13 +61,6 @@ export const lambderMockMswHandler = (mockApp, options) => {
54
61
  // Every call goes to `{apiPath}/{group}/{action}`; the endpoint is read
55
62
  // off the path as the server reads it (apiNameOfCallPath).
56
63
  const handler = msw.http.post(`${apiPath.replace(/\/+$/, "")}/:group/:action`, async ({ request }) => {
57
- let post;
58
- try {
59
- post = await request.clone().json();
60
- }
61
- catch {
62
- return undefined;
63
- }
64
64
  // Through the one header map every adapter builds, on
65
65
  // Object.create(null): on a plain object, a header literally named
66
66
  // __proto__ would be dropped here though the server keeps it as an own
@@ -68,8 +68,8 @@ export const lambderMockMswHandler = (mockApp, options) => {
68
68
  // function where every other adapter gives undefined.
69
69
  const headers = lowercaseHeaderNames(Object.fromEntries(request.headers));
70
70
  // A POST of another type is no API call on the server either: it
71
- // goes on to MSW's other handlers and the network, as a non-envelope
72
- // body does below, rather than working here and not in production.
71
+ // goes on to MSW's other handlers and the network, rather than
72
+ // working here and not in production.
73
73
  if (!isApiCallContentType(headers))
74
74
  return undefined;
75
75
  // The cookies travel as `cookies` below, as the direct transport's do.
@@ -97,15 +97,18 @@ export const lambderMockMswHandler = (mockApp, options) => {
97
97
  const apiName = apiNameOfCallPath(new URL(apiPath, url).pathname, url.pathname);
98
98
  if (apiName === null)
99
99
  return undefined;
100
- const parsed = readApiEnvelope(post, {
100
+ // Read from the text, as the server reads it: a body that is not a
101
+ // JSON object, or not JSON, is a call the pipeline refuses as the
102
+ // server does, not one handed on to the network.
103
+ const parsed = readApiEnvelopeText(await request.clone().text(), {
101
104
  headers, cookies, ip: clientIp, host: url.host, signal: request.signal,
102
105
  }, apiName);
103
106
  // Returning undefined hands the request back to MSW, which tries its
104
107
  // other handlers and then the network. Noted on the runtime first:
105
108
  // a passthrough that leaves no event and no call-log row is a
106
109
  // mistyped endpoint name reaching the real backend in silence.
107
- if (options.onUnmocked === "passthrough" && !mockApp.hasRegisteredEntry(parsed.apiName)) {
108
- mockApp.notePassthrough(parsed);
110
+ if (options.onUnmocked === "passthrough" && !door.hasRegisteredEntry(parsed.apiName)) {
111
+ door.notePassthrough(parsed);
109
112
  return undefined;
110
113
  }
111
114
  let answer;
@@ -126,7 +129,7 @@ export const lambderMockMswHandler = (mockApp, options) => {
126
129
  // this every session call fails its CSRF check. Done by the runtime,
127
130
  // which owns the one mirror implementation and remembers what was
128
131
  // planted for reset().
129
- mockApp.mirrorCookiesIntoDocument(setCookies);
132
+ door.mirrorCookiesIntoDocument(setCookies);
130
133
  const responseHeaders = new Headers();
131
134
  for (const [key, values] of Object.entries(answer.headers)) {
132
135
  for (const value of values)
package/dist/mock.d.ts CHANGED
@@ -13,6 +13,7 @@ export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMock
13
13
  export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
14
14
  export type { LambderCustomKeyedPolicyNames, LambderMockPolicyKeys } from "./mock/lambderMockPoliciesFrom.js";
15
15
  export type { LambderMockGuardShapeOf } from "./mock/LambderMockCreateOptions.js";
16
+ export type { LambderApiSchemaEntries, LambderApiSchemaEntry, LambderJsonSchema } from "./shared/wire/LambderApiSchemaEntries.js";
16
17
  export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
17
18
  export type { LambderMockConsoleLoggerOptions } from "./mock/lambderMockConsoleLogger.js";
18
19
  export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
@@ -140,7 +140,7 @@ export declare class LambderOneShotSecrets<TKinds extends Record<string, Lambder
140
140
  * less than that ago is refused instead, with the second it may ask
141
141
  * again; of two callers racing past the cooldown, exactly one is issued.
142
142
  * `meta` is what the app wants back at redemption: an identity, an
143
- * issuing organization, as small strings.
143
+ * issuing store, as small strings.
144
144
  *
145
145
  * A secret whose digest another scope holds is drawn again, up to
146
146
  * MAX_DRAWS times; past that the kind's alphabet and length leave too few
@@ -1,4 +1,4 @@
1
- import { keyedDigest, randomSecret } from "../shared/util/LambderSignedClaims.js";
1
+ import { assertCodeAlphabet, keyedDigest, randomCode, randomSecret } from "../shared/util/LambderSignedClaims.js";
2
2
  import { constantTimeEquals } from "../shared/util/LambderTextDigest.js";
3
3
  import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
4
4
  import { joinKeyFields } from "../shared/util/joinKeyFields.js";
@@ -54,12 +54,12 @@ export class LambderOneShotSecrets {
54
54
  if (kind.shape === "code") {
55
55
  assertPositiveInteger(kind.length, `kinds.${name}.length`);
56
56
  assertPositiveInteger(kind.maxAttempts, `kinds.${name}.maxAttempts`);
57
- assertAlphabet(kind.alphabet ?? DIGITS, name);
57
+ assertCodeAlphabet(kind.alphabet ?? DIGITS, `kinds.${name}.alphabet`);
58
58
  }
59
59
  else if (kind.shape === "token") {
60
60
  if (kind.alphabet !== undefined || kind.length !== undefined) {
61
61
  assertPositiveInteger(kind.length, `kinds.${name}.length`);
62
- assertAlphabet(kind.alphabet ?? "", name);
62
+ assertCodeAlphabet(kind.alphabet, `kinds.${name}.alphabet`);
63
63
  }
64
64
  else {
65
65
  assertPositiveInteger(kind.bytes ?? 32, `kinds.${name}.bytes`);
@@ -100,7 +100,7 @@ export class LambderOneShotSecrets {
100
100
  * less than that ago is refused instead, with the second it may ask
101
101
  * again; of two callers racing past the cooldown, exactly one is issued.
102
102
  * `meta` is what the app wants back at redemption: an identity, an
103
- * issuing organization, as small strings.
103
+ * issuing store, as small strings.
104
104
  *
105
105
  * A secret whose digest another scope holds is drawn again, up to
106
106
  * MAX_DRAWS times; past that the kind's alphabet and length leave too few
@@ -111,8 +111,8 @@ export class LambderOneShotSecrets {
111
111
  const cooldown = options.cooldownSeconds === undefined ? undefined : assertPositiveInteger(options.cooldownSeconds, "cooldownSeconds");
112
112
  const nowSeconds = this.nowSeconds();
113
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)
114
+ const plaintext = definition.shape === "code" ? randomCode(definition.alphabet ?? DIGITS, definition.length)
115
+ : definition.alphabet !== undefined ? randomCode(definition.alphabet, definition.length)
116
116
  : randomSecret(definition.bytes ?? 32);
117
117
  const draft = {
118
118
  kind,
@@ -185,33 +185,3 @@ export class LambderOneShotSecrets {
185
185
  return { state: "accepted", scope: record.scope, meta: { ...record.meta }, issuedAt: record.issuedAt };
186
186
  }
187
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
- };
@@ -0,0 +1,56 @@
1
+ /** The cost of a hash this instance writes. Every field is optional; the defaults are argon2id's widely used 64 MiB, 3 passes, 4 lanes. */
2
+ export type LambderPasswordHasherOptions = {
3
+ /** Memory per hash, in KiB. At least 8 per lane. Default: 65536 (64 MiB). */
4
+ memoryKib?: number;
5
+ /** Passes over that memory. Default: 3. */
6
+ passes?: number;
7
+ /** Lanes computed in parallel. Default: 4. */
8
+ parallelism?: number;
9
+ };
10
+ /**
11
+ * Hashes passwords with argon2id and verifies them against stored PHC
12
+ * strings, its own and those other argon2 libraries wrote (argon2id, argon2i
13
+ * or argon2d, any parameter order).
14
+ *
15
+ * ```ts
16
+ * const passwords = new LambderPasswordHasher();
17
+ * const stored = await passwords.hash(newPassword); // store this
18
+ * if(await passwords.verify(row?.passwordHash, attempt)){ // a missing row too, so it takes as long
19
+ * if(passwords.needsRehash(row!.passwordHash)) await savePasswordHash(await passwords.hash(attempt));
20
+ * }
21
+ * ```
22
+ *
23
+ * The password is hashed as its UTF-8 bytes, as given: an app that wants one
24
+ * password typed on two keyboards to match normalizes it (`normalize("NFC")`)
25
+ * before both calls, the same way every time.
26
+ */
27
+ export declare class LambderPasswordHasher {
28
+ #private;
29
+ /**
30
+ * Throws where the runtime has no argon2 (Node before 24.7, or a browser),
31
+ * here rather than on the first sign-in: a hasher that cannot hash would
32
+ * otherwise read as a wrong password for every account.
33
+ */
34
+ constructor(options?: LambderPasswordHasherOptions);
35
+ /** The PHC string of `password` under this instance's cost and a fresh random salt: the value to store. */
36
+ hash(password: string): Promise<string>;
37
+ /**
38
+ * Whether `password` is the one `stored` was made from, under the variant
39
+ * and cost `stored` names. False, never a throw, for anything that is not
40
+ * an argon2 PHC string within the cost ceilings: an account with no
41
+ * password stores null, and a value of another scheme or a corrupt one
42
+ * matches no password. Without a usable hash it still computes one under
43
+ * this instance's cost, so an account that does not exist (pass
44
+ * `undefined`) answers in the time a wrong password does, and the time
45
+ * says nothing about which accounts exist.
46
+ */
47
+ verify(stored: string | null | undefined, password: string): Promise<boolean>;
48
+ /**
49
+ * Whether `stored` was written under something other than what this
50
+ * instance writes: another variant, another cost, a shorter salt or tag,
51
+ * or no argon2 hash at all. Asked after a successful verify, when the
52
+ * plaintext is at hand to hash again; that is how a raised cost reaches
53
+ * the passwords stored before it.
54
+ */
55
+ needsRehash(stored: string): boolean;
56
+ }
@@ -0,0 +1,168 @@
1
+ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
2
+ /** The only argon2 encoding version: 0x13. A string naming another is not an argon2 hash anything writes today. */
3
+ const ARGON2_VERSION = 19;
4
+ const SALT_BYTES = 16;
5
+ const TAG_BYTES = 32;
6
+ /**
7
+ * The most a cost may ask: 2 GiB of memory (RFC 9106's largest recommended
8
+ * setting), 4 GiB of memory over all passes (libsodium's strongest preset,
9
+ * 1 GiB over 4), and the PHC format's 255 lanes. A verify runs under the cost
10
+ * the stored string names, before anyone is signed in, so a string naming
11
+ * hours of passes or more memory than the function has (an imported hash, a
12
+ * corrupt row) would stall every sign-in attempt on its account. A string
13
+ * over them matches no password, and a hasher is not built with a cost over
14
+ * them, so it never writes one.
15
+ */
16
+ const MAX_MEMORY_KIB = 2 * 1024 * 1024;
17
+ const MAX_MEMORY_OVER_PASSES_KIB = 4 * 1024 * 1024;
18
+ const MAX_PARALLELISM = 255;
19
+ /** The salt of the hash a verify computes when there is no stored hash to check; its result is thrown away. */
20
+ const NO_HASH_SALT = Buffer.alloc(SALT_BYTES);
21
+ /**
22
+ * The PHC string. The parameter section is read as an unordered set: the PHC
23
+ * format fixes no order, and implementations differ (the `argon2` npm package
24
+ * writes `m,t,p`, others `m,p,t`).
25
+ */
26
+ const PHC_PATTERN = /^\$(argon2id|argon2i|argon2d)\$v=(\d+)\$([a-z]+=\d+(?:,[a-z]+=\d+)*)\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)$/;
27
+ const toPhcBase64 = (bytes) => bytes.toString("base64").replace(/=+$/, "");
28
+ /** What is wrong with a cost of positive integers, or null for one inside argon2's floor and the ceilings above. */
29
+ const costProblemOf = ({ memoryKib, passes, parallelism }) => memoryKib < 8 * parallelism ? `memoryKib must be at least 8 per lane (${8 * parallelism} for parallelism ${parallelism}), got ${memoryKib}`
30
+ : parallelism > MAX_PARALLELISM ? `parallelism must be at most ${MAX_PARALLELISM}, got ${parallelism}`
31
+ : memoryKib > MAX_MEMORY_KIB ? `memoryKib must be at most ${MAX_MEMORY_KIB} (2 GiB), got ${memoryKib}`
32
+ : memoryKib * passes > MAX_MEMORY_OVER_PASSES_KIB ? `memoryKib times passes must be at most ${MAX_MEMORY_OVER_PASSES_KIB} (4 GiB over all passes), got ${memoryKib * passes}`
33
+ : null;
34
+ const parseCost = (section) => {
35
+ const values = new Map();
36
+ for (const pair of section.split(",")) {
37
+ const [key, value] = pair.split("=");
38
+ if (!key || !value || values.has(key))
39
+ return null;
40
+ values.set(key, Number(value));
41
+ }
42
+ const memoryKib = values.get("m"), passes = values.get("t"), parallelism = values.get("p");
43
+ if (memoryKib === undefined || passes === undefined || parallelism === undefined || values.size !== 3)
44
+ return null;
45
+ if (![memoryKib, passes, parallelism].every((value) => Number.isSafeInteger(value) && value >= 1))
46
+ return null;
47
+ const cost = { memoryKib, passes, parallelism };
48
+ return costProblemOf(cost) === null ? cost : null;
49
+ };
50
+ const parsePasswordHash = (stored) => {
51
+ const match = PHC_PATTERN.exec(stored);
52
+ if (!match)
53
+ return null;
54
+ const [, variant, version, costSection, saltBase64, tagBase64] = match;
55
+ if (Number(version) !== ARGON2_VERSION)
56
+ return null;
57
+ const cost = parseCost(costSection);
58
+ if (!cost)
59
+ return null;
60
+ const salt = Buffer.from(saltBase64, "base64");
61
+ const tag = Buffer.from(tagBase64, "base64");
62
+ // argon2's own floors: an 8-byte salt and a 4-byte tag.
63
+ if (salt.length < 8 || tag.length < 4)
64
+ return null;
65
+ return { variant: variant, ...cost, salt, tag };
66
+ };
67
+ /**
68
+ * Hashes passwords with argon2id and verifies them against stored PHC
69
+ * strings, its own and those other argon2 libraries wrote (argon2id, argon2i
70
+ * or argon2d, any parameter order).
71
+ *
72
+ * ```ts
73
+ * const passwords = new LambderPasswordHasher();
74
+ * const stored = await passwords.hash(newPassword); // store this
75
+ * if(await passwords.verify(row?.passwordHash, attempt)){ // a missing row too, so it takes as long
76
+ * if(passwords.needsRehash(row!.passwordHash)) await savePasswordHash(await passwords.hash(attempt));
77
+ * }
78
+ * ```
79
+ *
80
+ * The password is hashed as its UTF-8 bytes, as given: an app that wants one
81
+ * password typed on two keyboards to match normalizes it (`normalize("NFC")`)
82
+ * before both calls, the same way every time.
83
+ */
84
+ export class LambderPasswordHasher {
85
+ #crypto;
86
+ #cost;
87
+ /**
88
+ * Throws where the runtime has no argon2 (Node before 24.7, or a browser),
89
+ * here rather than on the first sign-in: a hasher that cannot hash would
90
+ * otherwise read as a wrong password for every account.
91
+ */
92
+ constructor(options = {}) {
93
+ const nodeCrypto = typeof process === "object" && typeof process.getBuiltinModule === "function"
94
+ ? process.getBuiltinModule("node:crypto")
95
+ : undefined;
96
+ if (typeof nodeCrypto?.argon2 !== "function") {
97
+ throw new Error("Lambder: LambderPasswordHasher needs argon2 from node:crypto, which Node 24.7 and later provide; this runtime has none.");
98
+ }
99
+ this.#crypto = nodeCrypto;
100
+ const memoryKib = assertPositiveInteger(options.memoryKib ?? 65536, "memoryKib");
101
+ const passes = assertPositiveInteger(options.passes ?? 3, "passes");
102
+ const parallelism = assertPositiveInteger(options.parallelism ?? 4, "parallelism");
103
+ const problem = costProblemOf({ memoryKib, passes, parallelism });
104
+ if (problem)
105
+ throw new Error(`Lambder: LambderPasswordHasher ${problem}.`);
106
+ this.#cost = { memoryKib, passes, parallelism };
107
+ }
108
+ /** The PHC string of `password` under this instance's cost and a fresh random salt: the value to store. */
109
+ async hash(password) {
110
+ if (typeof password !== "string")
111
+ throw new TypeError("Lambder: LambderPasswordHasher.hash takes the password as a string.");
112
+ const { memoryKib, passes, parallelism } = this.#cost;
113
+ const salt = this.#crypto.randomBytes(SALT_BYTES);
114
+ const tag = await this.#derive("argon2id", password, salt, this.#cost, TAG_BYTES);
115
+ return `$argon2id$v=${ARGON2_VERSION}$m=${memoryKib},t=${passes},p=${parallelism}$${toPhcBase64(salt)}$${toPhcBase64(tag)}`;
116
+ }
117
+ /**
118
+ * Whether `password` is the one `stored` was made from, under the variant
119
+ * and cost `stored` names. False, never a throw, for anything that is not
120
+ * an argon2 PHC string within the cost ceilings: an account with no
121
+ * password stores null, and a value of another scheme or a corrupt one
122
+ * matches no password. Without a usable hash it still computes one under
123
+ * this instance's cost, so an account that does not exist (pass
124
+ * `undefined`) answers in the time a wrong password does, and the time
125
+ * says nothing about which accounts exist.
126
+ */
127
+ async verify(stored, password) {
128
+ if (typeof password !== "string")
129
+ return false;
130
+ const parsed = typeof stored === "string" ? parsePasswordHash(stored) : null;
131
+ if (!parsed) {
132
+ await this.#derive("argon2id", password, NO_HASH_SALT, this.#cost, TAG_BYTES);
133
+ return false;
134
+ }
135
+ const actual = await this.#derive(parsed.variant, password, parsed.salt, parsed, parsed.tag.length);
136
+ return actual.length === parsed.tag.length && this.#crypto.timingSafeEqual(actual, parsed.tag);
137
+ }
138
+ /**
139
+ * Whether `stored` was written under something other than what this
140
+ * instance writes: another variant, another cost, a shorter salt or tag,
141
+ * or no argon2 hash at all. Asked after a successful verify, when the
142
+ * plaintext is at hand to hash again; that is how a raised cost reaches
143
+ * the passwords stored before it.
144
+ */
145
+ needsRehash(stored) {
146
+ const parsed = typeof stored === "string" ? parsePasswordHash(stored) : null;
147
+ if (!parsed)
148
+ return true;
149
+ return parsed.variant !== "argon2id"
150
+ || parsed.memoryKib !== this.#cost.memoryKib
151
+ || parsed.passes !== this.#cost.passes
152
+ || parsed.parallelism !== this.#cost.parallelism
153
+ || parsed.salt.length < SALT_BYTES
154
+ || parsed.tag.length !== TAG_BYTES;
155
+ }
156
+ #derive(variant, password, salt, cost, tagBytes) {
157
+ return new Promise((resolve, reject) => {
158
+ this.#crypto.argon2(variant, {
159
+ message: Buffer.from(password, "utf8"),
160
+ nonce: salt,
161
+ memory: cost.memoryKib,
162
+ passes: cost.passes,
163
+ parallelism: cost.parallelism,
164
+ tagLength: tagBytes,
165
+ }, (error, derived) => error ? reject(error) : resolve(Buffer.from(derived)));
166
+ });
167
+ }
168
+ }
@@ -194,7 +194,22 @@ export default class LambderSessionController<TSessionData = any> {
194
194
  * invoke caller) needs the new one to keep calling.
195
195
  */
196
196
  reissueSession(): Promise<LambderCreatedSession<TSessionData>>;
197
- fetchSession(): Promise<LambderSessionRecord<TSessionData>>;
197
+ /**
198
+ * The session the request's cookies name, checked against the posted
199
+ * CSRF token and renewed (its dataRefresh once due, its sliding write),
200
+ * onto ctx.session. Throws LambderSessionNotFoundError when there is
201
+ * none.
202
+ *
203
+ * `refreshData: true` renews the data on this read whatever its
204
+ * deadline, for a read that wants it fresh (a page load): the dataRefresh
205
+ * callback runs once and its result shares the read's one write. A read
206
+ * followed by refreshSessionData() runs both twice whenever the data was
207
+ * due. Requires dataRefresh, and throws without it once there is a
208
+ * session to renew.
209
+ */
210
+ fetchSession(options?: {
211
+ refreshData?: boolean;
212
+ }): Promise<LambderSessionRecord<TSessionData>>;
198
213
  /**
199
214
  * Re-issues both cookies at this session's expiry: after a sliding write
200
215
  * moved it, and beside the host-only eviction, which would otherwise
@@ -222,7 +237,10 @@ export default class LambderSessionController<TSessionData = any> {
222
237
  * sent. A dead cookie costs a store read per request until it expires.
223
238
  */
224
239
  private endWithNoSession;
225
- fetchSessionIfExists(): Promise<LambderSessionRecord<TSessionData> | null>;
240
+ /** fetchSession, answering null where it throws LambderSessionNotFoundError; it takes the same `refreshData`. */
241
+ fetchSessionIfExists(options?: {
242
+ refreshData?: boolean;
243
+ }): Promise<LambderSessionRecord<TSessionData> | null>;
226
244
  /**
227
245
  * Writes new data onto the current session. Throws
228
246
  * LambderSessionNotFoundError when the session was ended while this
@@ -244,8 +262,14 @@ export default class LambderSessionController<TSessionData = any> {
244
262
  * Deletes every session of the given sessionKey (e.g. a user id): "log
245
263
  * this subject out everywhere". Unlike endSessionAll it needs no fetched
246
264
  * session and touches no cookies, so it works on any subject.
265
+ *
266
+ * Answers whether it is sure none is left. False means a client
267
+ * rotating its session in a tight loop raced every pass of the delete,
268
+ * so a session of the subject may still stand (it is logged too); running
269
+ * it again is the remedy, and a caller ending sessions because a
270
+ * credential changed must not report success on false.
247
271
  */
248
- deleteSessionAllByKey(sessionKey: string): Promise<void>;
272
+ deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
249
273
  /**
250
274
  * Marks the data of every session of the given sessionKey stale, so each
251
275
  * renews via dataRefresh on its next read: the way to apply a change to
@@ -254,5 +278,14 @@ export default class LambderSessionController<TSessionData = any> {
254
278
  */
255
279
  expireSessionDataAllByKey(sessionKey: string): Promise<void>;
256
280
  endSession(): Promise<void>;
257
- endSessionAll(): Promise<void>;
281
+ /**
282
+ * Ends every session of this session's subject, this one included, and
283
+ * clears this request's cookies: "log out everywhere".
284
+ *
285
+ * Answers false on deleteSessionAllByKey's terms: a session of the
286
+ * subject may still stand, and running the delete again is the remedy.
287
+ * This request's own session is over either way, so the second run is
288
+ * deleteSessionAllByKey with the sessionKey read before this call.
289
+ */
290
+ endSessionAll(): Promise<boolean>;
258
291
  }
@@ -286,7 +286,20 @@ export default class LambderSessionController {
286
286
  return created;
287
287
  }
288
288
  ;
289
- async fetchSession() {
289
+ /**
290
+ * The session the request's cookies name, checked against the posted
291
+ * CSRF token and renewed (its dataRefresh once due, its sliding write),
292
+ * onto ctx.session. Throws LambderSessionNotFoundError when there is
293
+ * none.
294
+ *
295
+ * `refreshData: true` renews the data on this read whatever its
296
+ * deadline, for a read that wants it fresh (a page load): the dataRefresh
297
+ * callback runs once and its result shares the read's one write. A read
298
+ * followed by refreshSessionData() runs both twice whenever the data was
299
+ * due. Requires dataRefresh, and throws without it once there is a
300
+ * session to renew.
301
+ */
302
+ async fetchSession(options = {}) {
290
303
  const { sessionTokens: candidates, csrfTokens } = this.scanSessionCookies();
291
304
  if (!this.areRequestSessionTokensValid(candidates)) {
292
305
  throw new LambderSessionNotFoundError("Session tokens are invalid");
@@ -361,7 +374,7 @@ export default class LambderSessionController {
361
374
  // Renewed only once this session is known to be the caller's: a
362
375
  // slide or a dataRefresh is a write on their behalf.
363
376
  const expiresBefore = found.session.expiresAt;
364
- const session = await this.manager.renewSession(found.session);
377
+ const session = await this.manager.renewSession(found.session, { refreshData: options.refreshData });
365
378
  // Ended while this request read it (a logout, a password change), or
366
379
  // by its own dataRefresh: no session either way.
367
380
  if (!session)
@@ -431,9 +444,10 @@ export default class LambderSessionController {
431
444
  throw new LambderSessionNotFoundError();
432
445
  }
433
446
  ;
434
- async fetchSessionIfExists() {
447
+ /** fetchSession, answering null where it throws LambderSessionNotFoundError; it takes the same `refreshData`. */
448
+ async fetchSessionIfExists(options = {}) {
435
449
  try {
436
- return await this.fetchSession();
450
+ return await this.fetchSession(options);
437
451
  }
438
452
  catch (err) {
439
453
  // Only a "no session" exit becomes null, the ambiguous one
@@ -486,9 +500,15 @@ export default class LambderSessionController {
486
500
  * Deletes every session of the given sessionKey (e.g. a user id): "log
487
501
  * this subject out everywhere". Unlike endSessionAll it needs no fetched
488
502
  * session and touches no cookies, so it works on any subject.
503
+ *
504
+ * Answers whether it is sure none is left. False means a client
505
+ * rotating its session in a tight loop raced every pass of the delete,
506
+ * so a session of the subject may still stand (it is logged too); running
507
+ * it again is the remedy, and a caller ending sessions because a
508
+ * credential changed must not report success on false.
489
509
  */
490
510
  async deleteSessionAllByKey(sessionKey) {
491
- await this.manager.deleteSessionAllByKey(sessionKey);
511
+ return await this.manager.deleteSessionAllByKey(sessionKey);
492
512
  }
493
513
  ;
494
514
  /**
@@ -509,12 +529,22 @@ export default class LambderSessionController {
509
529
  this.ctx.session = null;
510
530
  }
511
531
  ;
532
+ /**
533
+ * Ends every session of this session's subject, this one included, and
534
+ * clears this request's cookies: "log out everywhere".
535
+ *
536
+ * Answers false on deleteSessionAllByKey's terms: a session of the
537
+ * subject may still stand, and running the delete again is the remedy.
538
+ * This request's own session is over either way, so the second run is
539
+ * deleteSessionAllByKey with the sessionKey read before this call.
540
+ */
512
541
  async endSessionAll() {
513
542
  if (!this.ctx.session)
514
543
  throw new LambderSessionNotFoundError();
515
- await this.manager.deleteSessionAll(this.ctx.session);
544
+ const deletedAll = await this.manager.deleteSessionAll(this.ctx.session);
516
545
  this.clearSessionCookies();
517
546
  this.ctx.session = null;
547
+ return deletedAll;
518
548
  }
519
549
  ;
520
550
  }