lambder 11.1.1 → 13.0.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 (87) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +25 -15
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiDeclarations.d.ts +194 -0
  5. package/dist/api/LambderApiDeclarations.js +52 -0
  6. package/dist/api/LambderApiDefinition.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +4 -1
  8. package/dist/api/LambderApiGuards.d.ts +21 -22
  9. package/dist/api/LambderApiGuards.js +1 -1
  10. package/dist/api/LambderApiRateLimits.d.ts +19 -19
  11. package/dist/api/LambderApiRateLimits.js +1 -1
  12. package/dist/api/LambderApiRefusals.d.ts +22 -0
  13. package/dist/api/LambderApiRefusals.js +13 -0
  14. package/dist/api/LambderApiRequest.d.ts +15 -6
  15. package/dist/api/LambderApiRequest.js +8 -9
  16. package/dist/build/generateApiFiles.d.ts +60 -0
  17. package/dist/build/generateApiFiles.js +85 -0
  18. package/dist/build/generatedTables.d.ts +2 -1
  19. package/dist/build/generatedTables.js +1 -1
  20. package/dist/build.d.ts +4 -1
  21. package/dist/build.js +3 -1
  22. package/dist/client/LambderCaller.d.ts +36 -23
  23. package/dist/client/LambderCaller.js +88 -67
  24. package/dist/client/lambderFetchTransport.js +2 -1
  25. package/dist/client.d.ts +3 -1
  26. package/dist/client.js +2 -0
  27. package/dist/core/Lambder.d.ts +232 -92
  28. package/dist/core/Lambder.js +400 -103
  29. package/dist/core/LambderCallSummary.d.ts +43 -0
  30. package/dist/core/LambderCallSummary.js +10 -0
  31. package/dist/core/LambderContext.d.ts +15 -2
  32. package/dist/core/LambderContext.js +32 -9
  33. package/dist/core/LambderCreateOptions.d.ts +78 -64
  34. package/dist/core/LambderCreateOptions.js +39 -21
  35. package/dist/core/LambderInvocationScope.d.ts +25 -0
  36. package/dist/core/LambderInvocationScope.js +21 -0
  37. package/dist/core/LambderResponse.d.ts +11 -0
  38. package/dist/core/LambderResponse.js +12 -0
  39. package/dist/core/LambderResponseBuilder.js +5 -1
  40. package/dist/index.d.ts +13 -7
  41. package/dist/index.js +3 -1
  42. package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
  43. package/dist/invoke/LambderInvokeCaller.js +110 -94
  44. package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
  45. package/dist/invoke/LambderLambdaEvent.js +8 -3
  46. package/dist/invoke/lambderHandlerTransport.js +2 -1
  47. package/dist/mock/LambderMockApp.d.ts +39 -33
  48. package/dist/mock/LambderMockApp.js +66 -55
  49. package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
  50. package/dist/mock/LambderMockTypes.d.ts +44 -7
  51. package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
  52. package/dist/mock/lambderMockInvokeTransport.js +15 -7
  53. package/dist/mock/lambderMockMswHandler.js +8 -4
  54. package/dist/mock.d.ts +2 -2
  55. package/dist/session/LambderSessionManager.d.ts +46 -3
  56. package/dist/session/LambderSessionManager.js +62 -11
  57. package/dist/shared/LambderI18n.js +49 -4
  58. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  59. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  60. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  61. package/dist/shared/transport/LambderApiTransport.js +2 -3
  62. package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
  63. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  64. package/dist/shared/util/LambderInvocationScope.js +5 -0
  65. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  66. package/dist/shared/util/LambderNamedMaps.js +19 -0
  67. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  68. package/dist/shared/wire/LambderApiContract.d.ts +4 -18
  69. package/dist/shared/wire/LambderApiContract.js +1 -1
  70. package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
  71. package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
  72. package/dist/shared/wire/LambderApiNames.d.ts +55 -0
  73. package/dist/shared/wire/LambderApiNames.js +81 -0
  74. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  75. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  76. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  77. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  78. package/dist/shared/wire/LambderUploadSchemas.js +2 -1
  79. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  80. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  81. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  82. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  83. package/dist/testing/LambderTestApp.d.ts +18 -5
  84. package/dist/testing/LambderTestApp.js +21 -5
  85. package/dist/testing/LambderTestVisitor.d.ts +16 -8
  86. package/dist/testing/LambderTestVisitor.js +33 -25
  87. package/package.json +1 -1
package/dist/mock.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
10
10
  export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
11
11
  export type { LambderMockAppOptions, LambderMockSessionsOptions, LambderMockIdempotencyOptions, LambderMockInvalidInputAnswer, LambderMockTransport, LambderMockTransportOptions } from "./mock/LambderMockCreateOptions.js";
12
- export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockRefusalsOf, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockPublicNames, LambderMockSessionNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
12
+ export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockRefusalsOf, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockNotMockedInput, LambderMockSessionGuardsCheck, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
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";
@@ -21,7 +21,7 @@ export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js
21
21
  export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
22
22
  export { lambderMockUploadMswHandler } from "./mock/lambderMockUploadMswHandler.js";
23
23
  export { lambderMockInvokeTransport } from "./mock/lambderMockInvokeTransport.js";
24
- export type { LambderMockInvokeEvent, LambderMockInvokeResult } from "./mock/lambderMockInvokeTransport.js";
24
+ export type { LambderMockInvokeEvent, LambderMockInvokeResult, LambderMockInvokeTransportOptions } from "./mock/lambderMockInvokeTransport.js";
25
25
  export { LambderCookieJar } from "./shared/transport/LambderCookieJar.js";
26
26
  export { lambderCookieJarTransport } from "./shared/transport/lambderCookieJarTransport.js";
27
27
  export type { LambderApiTransport, LambderApiTransportRequest } from "./shared/transport/LambderApiTransport.js";
@@ -1,3 +1,4 @@
1
+ import type { z } from "zod";
1
2
  import type { LambderSessionCrypto } from "./LambderSessionCrypto.js";
2
3
  import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
3
4
  import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
@@ -60,6 +61,39 @@ export declare class LambderSessionDataRefreshError extends Error {
60
61
  export declare class LambderSessionReadError extends Error {
61
62
  constructor(cause: unknown);
62
63
  }
64
+ /**
65
+ * The shape session.data has, and how it is kept fresh: `dataSchema` alone,
66
+ * or with `dataRefresh`, which then requires it.
67
+ *
68
+ * A record outlives the code that wrote it, so a read checks its data
69
+ * against the schema, and hands the request the schema's output (keys it
70
+ * does not declare stripped). Data that does not match (a record written
71
+ * before a deploy changed the data's shape) is refreshed on that read when
72
+ * dataRefresh is configured, whatever its deadline; without dataRefresh
73
+ * nothing can rebuild it, and the session ends. A refresh's own output is
74
+ * held to the schema too, and one that does not match fails the read as a
75
+ * LambderSessionDataRefreshError.
76
+ *
77
+ * Data is held to the schema where it is written as well: createSession and
78
+ * updateSessionData throw on data it refuses, rather than store a session
79
+ * whose next read would end it. What is stored is the data as given, and
80
+ * what a request is served is the schema's output, so every read of a
81
+ * record parses exactly what its write was checked with.
82
+ *
83
+ * Data a refresh derives from the app's state changes shape as the app
84
+ * does, which is why dataRefresh needs the schema: without it, a record
85
+ * written before the change is served in its old shape until its refresh
86
+ * comes due.
87
+ */
88
+ export type LambderSessionDataOptions<SessionData = any> = {
89
+ /** The schema of session.data, checked on every read. */
90
+ dataSchema?: z.ZodType<SessionData>;
91
+ dataRefresh?: undefined;
92
+ } | {
93
+ /** The schema of session.data, checked on every read and on every refresh's output. Required beside dataRefresh. */
94
+ dataSchema: z.ZodType<SessionData>;
95
+ dataRefresh: LambderSessionDataRefreshConfig<SessionData>;
96
+ };
63
97
  export type LambderSessionManagerOptions<SessionData = any> = {
64
98
  /** Where sessions rest: LambderDdbSessionStore, LambderMemorySessionStore, or your own. */
65
99
  store: LambderSessionStore<SessionData>;
@@ -68,10 +102,9 @@ export type LambderSessionManagerOptions<SessionData = any> = {
68
102
  enableSlidingExpiration?: boolean;
69
103
  /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
70
104
  slidingWriteIntervalSeconds?: number;
71
- dataRefresh?: LambderSessionDataRefreshConfig<SessionData>;
72
105
  /** Hashing and randomness. Default: WebCrypto. */
73
106
  crypto?: LambderSessionCrypto;
74
- };
107
+ } & LambderSessionDataOptions<SessionData>;
75
108
  /**
76
109
  * Whether a string has the shape a minted session token has:
77
110
  * `sessionKeyHash:secret`, both hex, neither longer than 1024 characters.
@@ -105,8 +138,9 @@ export default class LambderSessionManager<SessionData = any> {
105
138
  private readonly enableSlidingExpiration;
106
139
  private readonly slidingWriteIntervalSeconds;
107
140
  private readonly dataRefresh;
141
+ private readonly dataSchema;
108
142
  private readonly crypto;
109
- constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, crypto, }: LambderSessionManagerOptions<SessionData>);
143
+ constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, dataSchema, crypto, }: LambderSessionManagerOptions<SessionData>);
110
144
  /** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
111
145
  private assertCryptoFitsStore;
112
146
  /**
@@ -184,6 +218,15 @@ export default class LambderSessionManager<SessionData = any> {
184
218
  * logout, a password change), which a renewal must not undo.
185
219
  */
186
220
  renewSession(session: LambderSessionRecord<SessionData>): Promise<LambderSessionRecord<SessionData> | null>;
221
+ /**
222
+ * Data about to be stored, held to the schema: what a read of it will
223
+ * serve, or the error `refused` builds from where it does not match.
224
+ * Data the schema refuses is the app breaking its own declaration, and
225
+ * storing it would only move the failure to the next read, far from the
226
+ * code that wrote it. The data is stored as given, not as the schema's
227
+ * output, so every later read parses exactly what this check accepted.
228
+ */
229
+ private servedDataOf;
187
230
  /**
188
231
  * Runs the dataRefresh callback immediately, regardless of
189
232
  * dataExpiresAt, and persists the result onto the same record, over the
@@ -28,6 +28,11 @@ export class LambderSessionReadError extends Error {
28
28
  this.name = "LambderSessionReadError";
29
29
  }
30
30
  }
31
+ /** A schema's issues as a message may show them: each path and what was expected there, never the value found. */
32
+ const describeSchemaIssues = (error) => error.issues.slice(0, 5).map((issue) => `${issue.path.length ? issue.path.join(".") : "(the data)"}: ${issue.message}`).join("; ")
33
+ + (error.issues.length > 5 ? `; and ${error.issues.length - 5} more` : "");
34
+ /** A refresh's output the schema refuses, as the read fails with it. */
35
+ const refreshRefused = (issues) => new LambderSessionDataRefreshError(new Error(`the refreshed data does not match session.dataSchema (${issues})`));
31
36
  /**
32
37
  * The longest either half of a session token may be. A minted token is two
33
38
  * 64-character hex halves; sixteen times that leaves a custom
@@ -95,8 +100,9 @@ export default class LambderSessionManager {
95
100
  enableSlidingExpiration;
96
101
  slidingWriteIntervalSeconds;
97
102
  dataRefresh;
103
+ dataSchema;
98
104
  crypto;
99
- constructor({ store, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, crypto, }) {
105
+ constructor({ store, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, dataSchema, crypto, }) {
100
106
  this.store = store;
101
107
  this.sessionSalt = sessionSalt;
102
108
  this.enableSlidingExpiration = enableSlidingExpiration;
@@ -105,7 +111,16 @@ export default class LambderSessionManager {
105
111
  : assertPositiveInteger(slidingWriteIntervalSeconds, "session.slidingWriteIntervalSeconds");
106
112
  if (dataRefresh)
107
113
  assertPositiveInteger(dataRefresh.ttlSeconds, "session.dataRefresh.ttlSeconds");
114
+ if (dataRefresh && !dataSchema) {
115
+ throw new Error("Lambder: session.dataRefresh needs session.dataSchema, the schema session.data has. Data a refresh derives " +
116
+ "changes shape as the app does, and without the schema a record written before the change is served in its " +
117
+ "old shape until its refresh comes due; with it, such a record refreshes on its next read.");
118
+ }
119
+ if (dataSchema !== undefined && typeof dataSchema?.safeParse !== "function") {
120
+ throw new Error("Lambder: session.dataSchema is not a zod schema.");
121
+ }
108
122
  this.dataRefresh = dataRefresh ?? null;
123
+ this.dataSchema = dataSchema ?? null;
109
124
  this.crypto = crypto ?? new LambderWebCrypto();
110
125
  this.assertCryptoFitsStore(store);
111
126
  if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
@@ -167,6 +182,9 @@ export default class LambderSessionManager {
167
182
  // logout.
168
183
  if (!sessionKey)
169
184
  throw new Error("Lambder: createSession sessionKey is empty. It names the subject the session belongs to and partitions the store, so an empty one writes a record no read accepts.");
185
+ // Refused for the same reason: data the schema refuses would end the
186
+ // session on its first read, a sign-out right after the sign-in.
187
+ const served = this.servedDataOf(data, (issues) => new Error(`Lambder: createSession was given data that does not match session.dataSchema (${issues}).`));
170
188
  const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
171
189
  // The sort-key SECRET goes to the client; only its hash becomes the
172
190
  // store's range key, so the store never contains a usable token.
@@ -186,7 +204,7 @@ export default class LambderSessionManager {
186
204
  dataVersion: 0,
187
205
  };
188
206
  await this.store.create(session);
189
- return { session, sessionToken, csrfToken };
207
+ return { session: { ...session, data: served }, sessionToken, csrfToken };
190
208
  }
191
209
  /**
192
210
  * Writes new session data onto the record, and nothing else: the expiry
@@ -217,14 +235,17 @@ export default class LambderSessionManager {
217
235
  async updateSessionData(session, newData) {
218
236
  if (!session)
219
237
  throw new Error("Invalid session");
238
+ // Data the schema refuses would end the session on its next read
239
+ // (see createSession), so the write that brings it fails instead.
240
+ const served = this.servedDataOf(newData, (issues) => new Error(`Lambder: updateSessionData was given data that does not match session.dataSchema (${issues}).`));
220
241
  const { sessionKeyHash, secretHash } = session;
221
242
  if (!this.dataRefresh) {
222
243
  const result = await this.store.update(sessionKeyHash, secretHash, { data: newData });
223
- return result === "missing" ? null : { ...session, data: newData };
244
+ return result === "missing" ? null : { ...session, data: served };
224
245
  }
225
246
  const result = await this.store.update(sessionKeyHash, secretHash, { data: newData }, { dataVersion: session.dataVersion });
226
247
  if (result === "updated")
227
- return { ...session, data: newData, dataVersion: session.dataVersion + 1 };
248
+ return { ...session, data: served, dataVersion: session.dataVersion + 1 };
228
249
  if (result === "missing")
229
250
  return null;
230
251
  // The data or its deadline was written in between: a revocation was
@@ -235,7 +256,7 @@ export default class LambderSessionManager {
235
256
  const now = Math.floor(Date.now() / 1000);
236
257
  if (await this.store.update(sessionKeyHash, secretHash, { data: newData, dataExpiresAt: now }) === "missing")
237
258
  return null;
238
- return { ...session, data: newData, dataExpiresAt: now };
259
+ return { ...session, data: served, dataExpiresAt: now };
239
260
  }
240
261
  /**
241
262
  * The record a token names, read and structurally checked, with nothing
@@ -304,10 +325,22 @@ export default class LambderSessionManager {
304
325
  const now = Math.floor(Date.now() / 1000);
305
326
  const refreshed = {};
306
327
  const slid = {};
328
+ // The record's data checked against the schema (see
329
+ // LambderSessionDataOptions). Without dataRefresh nothing can rebuild
330
+ // data that does not match, so the session ends.
331
+ const read = this.dataSchema ? this.dataSchema.safeParse(session.data) : null;
332
+ if (read && !read.success && !this.dataRefresh) {
333
+ await this.deleteSession(session);
334
+ return null;
335
+ }
336
+ // What the request is served: the data as the schema reads it, or,
337
+ // once a refresh ran, the refresh's.
338
+ let served = read?.success ? read.data : session.data;
307
339
  // Renew session.data once its shelf life has passed (opt-in
308
- // dataRefresh). A record created while dataRefresh was off has no
309
- // dataExpiresAt, so it renews on its first read.
310
- if (this.dataRefresh && (session.dataExpiresAt ?? 0) <= now) {
340
+ // dataRefresh), or at once when it does not match the schema. A
341
+ // record created while dataRefresh was off has no dataExpiresAt, so
342
+ // it renews on its first read.
343
+ if (this.dataRefresh && (read?.success === false || (session.dataExpiresAt ?? 0) <= now)) {
311
344
  let newData;
312
345
  try {
313
346
  newData = await this.dataRefresh.refresh(session);
@@ -321,6 +354,7 @@ export default class LambderSessionManager {
321
354
  await this.deleteSession(session);
322
355
  return null;
323
356
  }
357
+ served = this.servedDataOf(newData, refreshRefused);
324
358
  refreshed.data = newData;
325
359
  refreshed.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
326
360
  }
@@ -335,7 +369,7 @@ export default class LambderSessionManager {
335
369
  slid.expiresAt = now + session.ttlInSeconds;
336
370
  }
337
371
  }
338
- const renewed = { ...session, ...refreshed, ...slid };
372
+ const renewed = { ...session, ...refreshed, ...slid, data: served };
339
373
  if (refreshed.data === undefined && slid.expiresAt === undefined)
340
374
  return renewed;
341
375
  // Awaited, so it persists before Lambda freezes. A failing write is
@@ -361,11 +395,27 @@ export default class LambderSessionManager {
361
395
  }
362
396
  catch (err) {
363
397
  console.error(`Lambder session: the renewal write failed, so this session keeps its stored expiry. ${coerceToError(err).message}`);
364
- return { ...session, ...refreshed };
398
+ return { ...session, ...refreshed, data: served };
365
399
  }
366
400
  return renewed;
367
401
  }
368
402
  ;
403
+ /**
404
+ * Data about to be stored, held to the schema: what a read of it will
405
+ * serve, or the error `refused` builds from where it does not match.
406
+ * Data the schema refuses is the app breaking its own declaration, and
407
+ * storing it would only move the failure to the next read, far from the
408
+ * code that wrote it. The data is stored as given, not as the schema's
409
+ * output, so every later read parses exactly what this check accepted.
410
+ */
411
+ servedDataOf(data, refused) {
412
+ if (!this.dataSchema)
413
+ return data;
414
+ const checked = this.dataSchema.safeParse(data);
415
+ if (checked.success)
416
+ return checked.data;
417
+ throw refused(describeSchemaIssues(checked.error));
418
+ }
369
419
  /**
370
420
  * Runs the dataRefresh callback immediately, regardless of
371
421
  * dataExpiresAt, and persists the result onto the same record, over the
@@ -390,13 +440,14 @@ export default class LambderSessionManager {
390
440
  await this.deleteSession(session);
391
441
  return null;
392
442
  }
443
+ const served = this.servedDataOf(newData, refreshRefused);
393
444
  const changes = { data: newData, dataExpiresAt: Math.floor(Date.now() / 1000) + this.dataRefresh.ttlSeconds };
394
445
  const result = await this.store.update(session.sessionKeyHash, session.secretHash, changes, { dataVersion: session.dataVersion });
395
446
  if (result === "missing")
396
447
  return null;
397
448
  // "stale": served, not written, and the record keeps the version it
398
449
  // was read with (see updateSessionData).
399
- return { ...session, ...changes, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
450
+ return { ...session, ...changes, data: served, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
400
451
  }
401
452
  ;
402
453
  /**
@@ -112,6 +112,38 @@ const interpolate = (text, params) => {
112
112
  return text;
113
113
  return text.replace(/\{([^{}]+)\}/g, (token, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token);
114
114
  };
115
+ /** The `{name}` tokens a text carries, as interpolate reads them. */
116
+ const placeholdersOf = (text) => new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]));
117
+ /** A set of placeholders as a message shows it: `{count}, {name}`, or "none". */
118
+ const describePlaceholders = (placeholders) => placeholders.size ? [...placeholders].sort().map((name) => `{${name}}`).join(", ") : "none";
119
+ /** How many mismatched translations an error lists before it only counts the rest. */
120
+ const PLACEHOLDER_MISMATCH_LIMIT = 20;
121
+ /**
122
+ * Refuses translations that do not carry the placeholders of the text they
123
+ * translate. The default language's text is the contract the translator's
124
+ * type takes its parameters from, so a translation that drops one loses the
125
+ * value a caller passed, and one that adds one shows its `{token}` as it
126
+ * is. `lookupDefault` answers the default language's text for a key, and a
127
+ * key it has none for is left to the other checks.
128
+ */
129
+ const assertPlaceholdersKept = (lookupDefault, lang, dict, label) => {
130
+ const mismatches = [];
131
+ for (const [key, text] of Object.entries(dict)) {
132
+ const original = lookupDefault(key);
133
+ if (original === undefined || typeof text !== "string")
134
+ continue;
135
+ const expected = placeholdersOf(original);
136
+ const found = placeholdersOf(text);
137
+ if (expected.size === found.size && [...expected].every((name) => found.has(name)))
138
+ continue;
139
+ mismatches.push(`"${key}" has ${describePlaceholders(found)} where the default language has ${describePlaceholders(expected)}`);
140
+ }
141
+ if (mismatches.length === 0)
142
+ return;
143
+ const listed = mismatches.slice(0, PLACEHOLDER_MISMATCH_LIMIT).map((line) => ` ${line}`);
144
+ const more = mismatches.length > PLACEHOLDER_MISMATCH_LIMIT ? [` ... and ${mismatches.length - PLACEHOLDER_MISMATCH_LIMIT} more`] : [];
145
+ throw new Error([`LambderI18n: in ${label}, these "${lang}" translations carry other placeholders than the default language's text:`, ...listed, ...more].join("\n"));
146
+ };
115
147
  const layerLookup = (layer, lang, key) => {
116
148
  for (let node = layer; node; node = node.parent) {
117
149
  const value = node.dicts[lang]?.[key];
@@ -133,7 +165,12 @@ const assertInlineDefaultBlock = (dict, defaultLanguage, label) => {
133
165
  throw new Error(`LambderI18n: ${label} must give the default language "${defaultLanguage}" inline, not as a loader.`);
134
166
  }
135
167
  };
136
- const createLayer = (core, sources, parent) => {
168
+ /**
169
+ * A layer over its parent, from a dictionary set as configured. The inline
170
+ * blocks' placeholders are checked here, against the default language's
171
+ * texts the layer reaches; a loader's are checked when it has run.
172
+ */
173
+ const createLayer = (core, sources, parent, label) => {
137
174
  const layer = { dicts: {}, loaders: new Map(), inFlight: new Map(), parent };
138
175
  for (const [lang, source] of Object.entries(sources)) {
139
176
  if (typeof source === "function")
@@ -141,6 +178,10 @@ const createLayer = (core, sources, parent) => {
141
178
  else if (source)
142
179
  layer.dicts[lang] = source;
143
180
  }
181
+ for (const [lang, dict] of Object.entries(layer.dicts)) {
182
+ if (lang !== core.defaultLanguage && dict)
183
+ assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, label);
184
+ }
144
185
  if (layer.loaders.size > 0)
145
186
  core.lazyLayers.add(layer);
146
187
  return layer;
@@ -172,6 +213,7 @@ const startLayerLoad = (core, layer, lang, loader) => {
172
213
  const dict = dictionaryFromLoaded(loaded, lang);
173
214
  if (layer.parent)
174
215
  assertNoRedeclaredKeys(layer.parent, core.defaultLanguage, dict, `the "${lang}" loader`);
216
+ assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, `the "${lang}" loader`);
175
217
  // Translations registered while the loader ran override what it brought.
176
218
  layer.dicts[lang] = { ...dict, ...layer.dicts[lang] };
177
219
  })
@@ -254,11 +296,11 @@ const buildInstance = (core, layer) => {
254
296
  },
255
297
  extend(dict) {
256
298
  validateExtension(dict, core.languageList, "extend() dictionary");
257
- return buildInstance(core, createLayer(core, dict, layer));
299
+ return buildInstance(core, createLayer(core, dict, layer, "extend() dictionary"));
258
300
  },
259
301
  extendPartial(dict) {
260
302
  validateExtension(dict, core.enforced, "extendPartial() dictionary");
261
- return buildInstance(core, createLayer(core, dict, layer));
303
+ return buildInstance(core, createLayer(core, dict, layer, "extendPartial() dictionary"));
262
304
  },
263
305
  loadLanguage(code) {
264
306
  const lang = code ?? core.state.resolve();
@@ -269,6 +311,9 @@ const buildInstance = (core, layer) => {
269
311
  registerDictionary(code, dict) {
270
312
  if (!core.isCode(code))
271
313
  throw new Error(`LambderI18n: unsupported language code "${code}".`);
314
+ if (code !== core.defaultLanguage) {
315
+ assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), code, dict, `registerDictionary("${code}")`);
316
+ }
272
317
  layer.dicts[code] = { ...layer.dicts[code], ...dict };
273
318
  core.state.emitChange();
274
319
  },
@@ -350,5 +395,5 @@ export const createLambderI18n = (config) => {
350
395
  isCode,
351
396
  lazyLayers: new Set(),
352
397
  };
353
- return buildInstance(core, createLayer(core, config.base, null));
398
+ return buildInstance(core, createLayer(core, config.base, null, "base dictionary"));
354
399
  };
@@ -9,8 +9,8 @@
9
9
  * dependency-free, so the mock runtime and the browser entry can resolve it.
10
10
  */
11
11
  /**
12
- * The fixed windows a policy may cap, smallest first (the evaluation order),
13
- * with their length. The policy type derives from this table, so the two can
12
+ * The fixed windows a policy may cap, smallest first (the order a refusal
13
+ * names the smallest window by), with their length. The policy type derives from this table, so the two can
14
14
  * never drift.
15
15
  */
16
16
  export declare const RATE_LIMIT_WINDOWS: readonly [{
@@ -9,8 +9,8 @@
9
9
  * dependency-free, so the mock runtime and the browser entry can resolve it.
10
10
  */
11
11
  /**
12
- * The fixed windows a policy may cap, smallest first (the evaluation order),
13
- * with their length. The policy type derives from this table, so the two can
12
+ * The fixed windows a policy may cap, smallest first (the order a refusal
13
+ * names the smallest window by), with their length. The policy type derives from this table, so the two can
14
14
  * never drift.
15
15
  */
16
16
  export const RATE_LIMIT_WINDOWS = [
@@ -8,6 +8,7 @@ import type { LambderCompressedGzipPayload, LambderCompressedBrotliPayload } fro
8
8
  */
9
9
  export type LambderApiTransportRequest = {
10
10
  apiPath: string;
11
+ /** The endpoint called, `group.action`: the call goes to `{apiPath}/{group}/{action}` (apiCallPath). */
11
12
  apiName: string;
12
13
  version?: string;
13
14
  /** The caller's signature for this endpoint, out of its LambderApiSignatureMap; absent when it carries no map. */
@@ -90,7 +91,8 @@ export declare const isLambderTransportFailure: (err: unknown) => err is Lambder
90
91
  export type LambderApiTransport = (request: LambderApiTransportRequest) => Promise<LambderApiHttpAnswer>;
91
92
  /**
92
93
  * The fields of the request envelope, in wire order: the one statement of
93
- * what a call sends, for every sender.
94
+ * what a call sends, for every sender. The endpoint is not among them: it is
95
+ * the path the envelope is posted to (apiCallPath).
94
96
  *
95
97
  * Two senders write it. A transport hands the payload over as a value
96
98
  * (buildTransportEnvelope, below); LambderInvokeCaller has already serialized
@@ -101,7 +103,6 @@ export type LambderApiTransport = (request: LambderApiTransportRequest) => Promi
101
103
  * the wire would catch.
102
104
  */
103
105
  export declare const buildEnvelopeFields: (fields: {
104
- apiName: string;
105
106
  version?: string;
106
107
  signature?: string;
107
108
  /** The CSRF token, as the envelope names it. */
@@ -16,7 +16,8 @@ export class LambderTransportFailure extends Error {
16
16
  export const isLambderTransportFailure = (err) => err instanceof Error && err.isLambderTransportFailure === true;
17
17
  /**
18
18
  * The fields of the request envelope, in wire order: the one statement of
19
- * what a call sends, for every sender.
19
+ * what a call sends, for every sender. The endpoint is not among them: it is
20
+ * the path the envelope is posted to (apiCallPath).
20
21
  *
21
22
  * Two senders write it. A transport hands the payload over as a value
22
23
  * (buildTransportEnvelope, below); LambderInvokeCaller has already serialized
@@ -27,7 +28,6 @@ export const isLambderTransportFailure = (err) => err instanceof Error && err.is
27
28
  * the wire would catch.
28
29
  */
29
30
  export const buildEnvelopeFields = (fields) => ({
30
- apiName: fields.apiName,
31
31
  version: fields.version,
32
32
  ...(fields.signature !== undefined ? { signature: fields.signature } : {}),
33
33
  token: fields.token,
@@ -42,7 +42,6 @@ export const buildEnvelopeFields = (fields) => ({
42
42
  * runtime read one shape.
43
43
  */
44
44
  export const buildTransportEnvelope = (request) => buildEnvelopeFields({
45
- apiName: request.apiName,
46
45
  version: request.version,
47
46
  signature: request.signature,
48
47
  token: request.token,
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_SESSION_CSRF_COOKIE_KEY } from "../wire/LambderSessionCookieNames.js";
2
2
  import { resolveApiPathTarget } from "./LambderApiTransport.js";
3
+ import { apiCallPath } from "../wire/LambderApiNames.js";
3
4
  /**
4
5
  * Makes any transport carry a cookie jar the way a browser carries its
5
6
  * cookies: the jar's cookies ride on every request, the answer's Set-Cookie
@@ -36,7 +37,7 @@ export const lambderCookieJarTransport = (inner, options) => {
36
37
  // absolute cross-origin apiPath. siteHost is "" outside a browser, and
37
38
  // an empty host would scope every cookie to nothing while claiming to
38
39
  // scope it.
39
- const target = resolveApiPathTarget(request.apiPath);
40
+ const target = resolveApiPathTarget(apiCallPath(request.apiPath, request.apiName));
40
41
  const cookieScope = {
41
42
  host: target.host ?? options.host ?? (request.siteHost || undefined),
42
43
  path: target.path,
@@ -0,0 +1,10 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ /** What any layer may ask of the invocation it runs under. */
3
+ export type LambderInvocation = {
4
+ /** The invocation's own request id (the Lambda context's awsRequestId); null when the context has none. */
5
+ readonly requestId: string | null;
6
+ };
7
+ /** The async context the server's handler opens once per invocation. */
8
+ export declare const invocationScope: AsyncLocalStorage<LambderInvocation>;
9
+ /** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
10
+ export declare const currentRequestId: () => string | null;
@@ -0,0 +1,5 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ /** The async context the server's handler opens once per invocation. */
3
+ export const invocationScope = new AsyncLocalStorage();
4
+ /** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
5
+ export const currentRequestId = () => invocationScope.getStore()?.requestId ?? null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One map of named declarations, or a list of them. An app made of parts,
3
+ * each declaring its own guards, rate-limit policies or refusal codes beside
4
+ * its APIs, hands the list over, and every name in it is declared. A name two
5
+ * maps declare is refused rather than one quietly replacing the other.
6
+ */
7
+ export type LambderNamedMapsOption<TMap> = TMap | readonly TMap[];
8
+ /** The one map a named-maps option declares: a list's members intersected, a lone map as it is. */
9
+ export type LambderMergedNamedMaps<T> = T extends readonly [] ? {} : T extends readonly [infer TFirst, ...infer TRest] ? TFirst & LambderMergedNamedMaps<TRest> : T extends readonly (infer TMember)[] ? TMember : T;
10
+ /** The names a list declares in more than one of its maps: never for a lone map, or for a list whose members are not known one by one. */
11
+ type LambderRepeatedNames<T, TSeen extends PropertyKey = never> = T extends readonly [infer TFirst, ...infer TRest] ? (keyof TFirst & TSeen) | LambderRepeatedNames<TRest, TSeen | keyof TFirst> : never;
12
+ /** Intersected onto a list: a name declared in two of its maps is a compile error, and the property name is the message. */
13
+ export type LambderNoRepeatedNames<T> = [LambderRepeatedNames<T>] extends [never] ? unknown : {
14
+ readonly "lambder: a name is declared in more than one of these maps. Declare each name once.": LambderRepeatedNames<T>;
15
+ };
16
+ /**
17
+ * A named-maps option as one map: a lone map as it is, a list merged in
18
+ * order. A name declared in two maps of the list is refused; `what` names a
19
+ * declaration and `where` the call that took the list, for that error.
20
+ */
21
+ export declare const mergeNamedMaps: <TMap extends Record<string, unknown>>(option: LambderNamedMapsOption<TMap>, what: "guard" | "rate-limit policy" | "refusal code", where?: string) => TMap;
22
+ export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A named-maps option as one map: a lone map as it is, a list merged in
3
+ * order. A name declared in two maps of the list is refused; `what` names a
4
+ * declaration and `where` the call that took the list, for that error.
5
+ */
6
+ export const mergeNamedMaps = (option, what, where = "create()") => {
7
+ if (!Array.isArray(option))
8
+ return option;
9
+ const merged = {};
10
+ for (const map of option) {
11
+ for (const [name, declaration] of Object.entries(map)) {
12
+ if (Object.prototype.hasOwnProperty.call(merged, name)) {
13
+ throw new Error(`Lambder: the ${what} "${name}" is declared in two of the maps given to ${where}. Declare each name once.`);
14
+ }
15
+ merged[name] = declaration;
16
+ }
17
+ }
18
+ return merged;
19
+ };
@@ -35,7 +35,7 @@ export type LambderReadonlyDeep<T, TDepth extends unknown[] = []> = TDepth["leng
35
35
  * name, of "this one required and the rest optional".
36
36
  *
37
37
  * An all-optional map is inhabited by `{}`, which would let `guards: {}`
38
- * satisfy requireSessionApiGuards / requirePublicApiGuards at the type level
38
+ * satisfy requireApiGuards at the type level
39
39
  * while declaring no guard: the option is present, so the required-field
40
40
  * check passes, and it normalizes to zero entries, so nothing runs. Requiring
41
41
  * the chosen key also rejects `{ theGuard: undefined }`, which an optional
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Lambder API Contract System
3
3
  *
4
- * Contracts are built via method chaining and inferred using typeof lambder.ApiContract
4
+ * Contracts are the endpoint declarations an instance registered, read with typeof lambder.ApiContract
5
5
  */
6
6
  import type { LambderCrashDetail } from "./LambderCrashDetail.js";
7
7
  import type { LambderUncheckedRefusalMessage, LambderPlainRefusalMessage, LambderRefusalMessage } from "./LambderApiRefusal.js";
8
8
  import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "./LambderApiOptionValues.js";
9
- /** Whether an endpoint runs without a session or requires one (addApi versus addSessionApi). */
9
+ /** Whether an endpoint runs without a session or requires one: "session" exactly when one of its guards needs a session. */
10
10
  export type LambderApiMode = "public" | "session";
11
11
  /**
12
12
  * Base shape for API contracts: what LambderCaller, LambderInvokeCaller and
@@ -15,7 +15,7 @@ export type LambderApiMode = "public" | "session";
15
15
  export type LambderApiContractShape = Record<string, {
16
16
  input: any;
17
17
  output: any;
18
- /** "public" (addApi) or "session" (addSessionApi). */
18
+ /** "public", or "session" when one of its guards needs a session. */
19
19
  mode?: LambderApiMode;
20
20
  /** Present when the API declares guardInput-mode guards: guard name -> value the client must send via options.guardInputs. */
21
21
  guardInputs?: any;
@@ -149,7 +149,7 @@ export type LambderJsonOf<T> = unknown extends T ? T : T extends LambderJsonValu
149
149
  [K in keyof TKeyed as K extends string | number ? (string extends K ? K : number extends K ? K : [LambderJsonOf<TKeyed[K]>] extends [never] ? never : K) : never]: LambderJsonOf<TKeyed[K]>;
150
150
  } : never : never;
151
151
  /**
152
- * One contract entry as addApi/addSessionApi record it: the payload types,
152
+ * One contract entry as defineApi records it: the payload types,
153
153
  * the mode, and every declarative option exactly as written. Options that
154
154
  * were not written are absent rather than undefined, so `keyof` an entry
155
155
  * lists only what the endpoint declared.
@@ -180,20 +180,6 @@ export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInpu
180
180
  }) & ([Idempotency] extends [never] ? {} : {
181
181
  idempotency: Idempotency;
182
182
  });
183
- /**
184
- * Merges a new entry into the contract during chaining.
185
- *
186
- * The contract is therefore an intersection one member deep per endpoint, and
187
- * every `C[K]` read generically (a typed caller, a mock registry, a test
188
- * visitor) resolves the property across all of them. That costs nothing
189
- * worth measuring in a small app and most of a large client's type check,
190
- * which is what writeApiContract (lambder/build) is for: it writes the
191
- * contract out as one object type with plain members, for clients to import
192
- * instead of the server.
193
- */
194
- export type LambderMergeContract<Old, Name extends string, Entry> = Old & {
195
- [K in Name]: Entry;
196
- };
197
183
  /** Guard names referenced by a guards option, whichever of its three forms is used. */
198
184
  export type LambderGuardNamesIn<TOpt> = TOpt extends string ? TOpt : TOpt extends readonly (infer N extends string)[] ? N : TOpt extends object ? keyof TOpt & string : never;
199
185
  /** The endpoint's mode; a contract written without one admits either. */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Lambder API Contract System
3
3
  *
4
- * Contracts are built via method chaining and inferred using typeof lambder.ApiContract
4
+ * Contracts are the endpoint declarations an instance registered, read with typeof lambder.ApiContract
5
5
  */
6
6
  export {};
@@ -0,0 +1,31 @@
1
+ /** The groups a contract's endpoint names hold: the part of each before its dot. */
2
+ export type LambderContractGroupsOf<TContract> = keyof TContract extends infer TName ? (TName extends `${infer TGroup}.${string}` ? TGroup : never) : never;
3
+ /** The endpoint names of one group. */
4
+ export type LambderContractNamesInGroup<TContract, TGroup extends string> = Extract<keyof TContract, `${TGroup}.${string}`> & string;
5
+ /** An endpoint name's action: the part after its dot. */
6
+ export type LambderContractActionOf<TName> = TName extends `${string}.${infer TAction}` ? TAction : never;
7
+ /** A call made through a group: the endpoint's name, and the arguments as the call site passed them. */
8
+ type LambderGroupCallRunner = (apiName: string, args: unknown[]) => unknown;
9
+ /**
10
+ * The caller, answering for each group a contract could name besides its
11
+ * own members. A member the caller has (a method, a field) is always the
12
+ * caller's; a name that could be a group (see isGroupName: an identifier no
13
+ * caller member has) is one; anything else a runtime or a framework asks of
14
+ * an object (then, toJSON, a symbol, `__v_isRef`) is what the caller itself
15
+ * answers, so a caller is never mistaken for a promise, a ref or a
16
+ * serializable record.
17
+ *
18
+ * A caller keeps its state in #private fields, which no property name
19
+ * reaches, so a group may take any name its public members leave free. Such
20
+ * a field is readable only on the caller itself, never through this proxy,
21
+ * so a member is read and a method run on the caller; a method that returns
22
+ * the caller (setTransport) hands back the proxy, groups and all.
23
+ *
24
+ * A group answers the same way one level down: an ordinary object's own
25
+ * members (toString, valueOf, hasOwnProperty) are an ordinary object's, so
26
+ * converting a group to a string or asking it what it holds calls no
27
+ * endpoint; any other name that could be an action (see isActionName) is
28
+ * one. A group is read-only, like the type that describes it.
29
+ */
30
+ export declare const withApiGroupCalls: <TCaller extends object>(caller: TCaller, call: LambderGroupCallRunner, outcome: LambderGroupCallRunner) => TCaller;
31
+ export {};