lambder 8.1.2 → 8.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +8 -7
  3. package/dist/api/LambderApiGuards.d.ts +2 -17
  4. package/dist/api/LambderApiRateLimits.d.ts +2 -29
  5. package/dist/build/generatedTables.d.ts +72 -0
  6. package/dist/build/generatedTables.js +99 -0
  7. package/dist/build/writeApiGuardParams.d.ts +60 -0
  8. package/dist/build/writeApiGuardParams.js +85 -0
  9. package/dist/build/writeApiOptions.d.ts +68 -0
  10. package/dist/build/writeApiOptions.js +102 -0
  11. package/dist/build.d.ts +10 -4
  12. package/dist/build.js +7 -4
  13. package/dist/client/LambderUploadRunner.d.ts +7 -7
  14. package/dist/client/LambderUploadRunner.js +12 -21
  15. package/dist/client.d.ts +7 -0
  16. package/dist/client.js +11 -0
  17. package/dist/core/Lambder.d.ts +21 -0
  18. package/dist/core/Lambder.js +69 -0
  19. package/dist/index.d.ts +13 -0
  20. package/dist/index.js +13 -0
  21. package/dist/mock/LambderMockApp.d.ts +34 -17
  22. package/dist/mock/LambderMockApp.js +67 -21
  23. package/dist/mock/LambderMockCreateOptions.d.ts +68 -5
  24. package/dist/mock/LambderMockTypes.d.ts +29 -10
  25. package/dist/mock/lambderMockPoliciesFrom.d.ts +51 -0
  26. package/dist/mock/lambderMockPoliciesFrom.js +46 -0
  27. package/dist/mock.d.ts +3 -0
  28. package/dist/mock.js +3 -0
  29. package/dist/secrets/LambderOneShotSecrets.d.ts +166 -0
  30. package/dist/secrets/LambderOneShotSecrets.js +217 -0
  31. package/dist/session/LambderSessionCrypto.js +6 -16
  32. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +3 -2
  33. package/dist/shared/contracts/LambderOneShotSecretStore.d.ts +122 -0
  34. package/dist/shared/contracts/LambderOneShotSecretStore.js +38 -0
  35. package/dist/shared/util/LambderBackoffTimer.d.ts +82 -0
  36. package/dist/shared/util/LambderBackoffTimer.js +86 -0
  37. package/dist/shared/util/LambderBase64.d.ts +14 -0
  38. package/dist/shared/util/LambderBase64.js +17 -0
  39. package/dist/shared/util/LambderSignedClaims.d.ts +78 -0
  40. package/dist/shared/util/LambderSignedClaims.js +109 -0
  41. package/dist/shared/util/LambderTextDigest.d.ts +19 -5
  42. package/dist/shared/util/LambderTextDigest.js +30 -5
  43. package/dist/shared/util/assertPlainData.d.ts +9 -0
  44. package/dist/shared/util/assertPlainData.js +41 -0
  45. package/dist/shared/wire/LambderApiOptionEntries.d.ts +148 -0
  46. package/dist/shared/wire/LambderApiOptionEntries.js +35 -0
  47. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +64 -0
  48. package/dist/stores/LambderDdbOneShotSecretStore.js +266 -0
  49. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +3 -2
  50. package/dist/stores/LambderMemoryIdempotencyStore.js +3 -2
  51. package/dist/stores/LambderMemoryOneShotSecretStore.d.ts +36 -0
  52. package/dist/stores/LambderMemoryOneShotSecretStore.js +93 -0
  53. package/dist/testing/LambderConformanceRunner.d.ts +46 -0
  54. package/dist/testing/LambderConformanceRunner.js +21 -0
  55. package/dist/testing/lambderIdempotencyStoreConformance.d.ts +33 -0
  56. package/dist/testing/lambderIdempotencyStoreConformance.js +237 -0
  57. package/dist/testing/lambderOneShotSecretStoreConformance.d.ts +43 -0
  58. package/dist/testing/lambderOneShotSecretStoreConformance.js +224 -0
  59. package/dist/testing/lambderRateLimiterConformance.d.ts +20 -0
  60. package/dist/testing/lambderRateLimiterConformance.js +72 -0
  61. package/dist/testing/lambderSessionStoreConformance.d.ts +27 -0
  62. package/dist/testing/lambderSessionStoreConformance.js +165 -0
  63. package/dist/testing.d.ts +14 -0
  64. package/dist/testing.js +12 -0
  65. package/package.json +1 -1
@@ -47,7 +47,8 @@ const defaultCookieHost = () => globalThis.location?.host || "localhost";
47
47
  *
48
48
  * Create one with initLambderMock<Contract, SessionData>().create(...),
49
49
  * which fixes the contract and session types first so everything else is
50
- * inferred from the options.
50
+ * inferred from the options. `TDerived` is true for a mock created with the
51
+ * generated `apiOptions` table, whose entries are their handlers alone.
51
52
  */
52
53
  export class LambderMockApp {
53
54
  apiVersion;
@@ -85,10 +86,13 @@ export class LambderMockApp {
85
86
  recorder;
86
87
  /** Registered entries and the overrides over them (see LambderMockEntryRegistry). */
87
88
  registry = new LambderMockEntryRegistry();
89
+ /** The server's declared options per API, when create() was given the generated table; the entries' declarations come from here. */
90
+ apiOptions;
88
91
  /** The jars the runtime owns and what it planted in document.cookie (see LambderMockBrowserCookies). */
89
92
  browserCookies = new LambderMockBrowserCookies();
90
93
  constructor(options) {
91
94
  this.apiVersion = options.apiVersion ?? null;
95
+ this.apiOptions = options.apiOptions ?? null;
92
96
  this.failures = new LambderMockFailureInjector({ apiVersion: this.apiVersion, latency: options.latency ?? 0 });
93
97
  this.recorder = new LambderMockCallRecorder({ callLogSize: options.callLogSize ?? DEFAULT_CALL_LOG_SIZE });
94
98
  // The loopback address when nothing names a client, as for a request
@@ -196,13 +200,49 @@ export class LambderMockApp {
196
200
  throw new Error(`LambderMockApp: session endpoint "${definition.name}" needs the sessions option at creation.`);
197
201
  }
198
202
  }
203
+ /**
204
+ * The table's entry for an endpoint, when create() was given one: null
205
+ * without a table, and a throw for a name the table does not hold or an
206
+ * entry registered under the other mode. The compiler already refuses
207
+ * both against the contract; this is where a stale table meets a caller
208
+ * the compiler did not see.
209
+ */
210
+ declaredOptionsOf(name, mode) {
211
+ if (!this.apiOptions)
212
+ return null;
213
+ const declared = Object.prototype.hasOwnProperty.call(this.apiOptions, name) ? this.apiOptions[name] : undefined;
214
+ if (!declared) {
215
+ throw new Error(`LambderMockApp: "${name}" has no entry in the apiOptions table given to create(). The table predates this endpoint: regenerate it with writeApiOptions.`);
216
+ }
217
+ if (declared.mode !== mode) {
218
+ throw new Error(`LambderMockApp: "${name}" is a ${declared.mode} endpoint in the apiOptions table, registered here as a ${mode} one.`);
219
+ }
220
+ return declared;
221
+ }
222
+ /** The mode the apiOptions table gives a name, or null without a table or for a name it does not hold. */
223
+ declaredModeOf(name) {
224
+ if (!this.apiOptions || !Object.prototype.hasOwnProperty.call(this.apiOptions, name))
225
+ return null;
226
+ return this.apiOptions[name].mode;
227
+ }
199
228
  buildEntry(name, mode, input) {
200
229
  const options = (typeof input === "function" ? { handler: input } : input);
230
+ const declared = this.declaredOptionsOf(name, mode);
231
+ if (declared) {
232
+ // A caller the compiler did not see (a JavaScript slice, a cast)
233
+ // would otherwise have its restatement silently lose to the table.
234
+ for (const field of ["guards", "rateLimit", "idempotency"]) {
235
+ if (options[field] !== undefined) {
236
+ throw new Error(`LambderMockApp: "${name}" restates its ${field} option, which the apiOptions table given to create() already declares. Leave it out of the entry.`);
237
+ }
238
+ }
239
+ }
240
+ const declarations = declared ?? options;
201
241
  const definition = {
202
242
  name, mode,
203
- guards: options.guards,
204
- rateLimit: options.rateLimit,
205
- idempotency: options.idempotency,
243
+ guards: declarations.guards,
244
+ rateLimit: declarations.rateLimit,
245
+ idempotency: declarations.idempotency,
206
246
  // No cast: the entry's schema is a z.ZodType, the same type the
207
247
  // definition holds. A structural { safeParse } here would let a
208
248
  // validator that is not a zod schema reach the 422 body as
@@ -257,18 +297,21 @@ export class LambderMockApp {
257
297
  * still refused, and an entry registered later (registerPartial, or a
258
298
  * second register) takes its endpoint back from the rest.
259
299
  *
260
- * What it cannot do is the session read. The mode of an unregistered name
261
- * is not knowable at runtime (the contract is a type), so a call it
262
- * answers is processed as public: the protocol's pre-pass still runs, so
263
- * a stale client still hears versionExpired, but a signed-out call to an
264
- * unmocked session endpoint answers "not mocked" where the server answers
265
- * sessionExpired. Declare an endpoint whose signed-out path a test cares
266
- * about with sessionNotMocked instead.
300
+ * The mode of an unregistered name comes from the apiOptions table, when
301
+ * create() was given one, so an unmocked session endpoint still reads the
302
+ * session and a signed-out call answers sessionExpired as on the server.
303
+ * Without the table the mode is not knowable at runtime (the contract is
304
+ * a type), and a call it answers is processed as public: the protocol's
305
+ * pre-pass still runs, so a stale client still hears versionExpired, but
306
+ * a signed-out call to an unmocked session endpoint answers "not mocked"
307
+ * where the server answers sessionExpired. Declare an endpoint whose
308
+ * signed-out path a test cares about with sessionNotMocked there.
267
309
  */
268
310
  restNotMocked(reason) {
269
311
  return { restNotMockedReason: reason };
270
312
  }
271
313
  buildNotMockedEntry(name, mode, reason) {
314
+ this.declaredOptionsOf(name, mode);
272
315
  const definition = { name, mode };
273
316
  this.assertEntryRegistration(definition);
274
317
  return { name, mode, definition, handler: null, notMockedReason: reason };
@@ -352,16 +395,18 @@ export class LambderMockApp {
352
395
  /**
353
396
  * The entry that answers a name nothing registered, when register() was
354
397
  * given a rest entry: the notMocked refusal carrying its reason, run
355
- * through the pipeline as a public endpoint, since the mode of an
356
- * unregistered name cannot be recovered at runtime. Everything before
357
- * dispatch still runs (the signature gate, the payload restore); the
358
- * missing session read is the fidelity limit restNotMocked documents.
398
+ * through the pipeline under the mode the apiOptions table gives the
399
+ * name, and as a public endpoint where there is no table to say (the
400
+ * mode of an unregistered name cannot otherwise be recovered at runtime).
401
+ * Everything before dispatch still runs (the signature gate, the payload
402
+ * restore, and for a session endpoint the session read).
359
403
  */
360
404
  restNotMockedEntry(apiName) {
361
405
  const reason = this.registry.restNotMockedReason;
362
406
  if (reason === null)
363
407
  return null;
364
- return { name: apiName, mode: "public", definition: { name: apiName, mode: "public" }, handler: null, notMockedReason: reason };
408
+ const mode = this.declaredModeOf(apiName) ?? "public";
409
+ return { name: apiName, mode, definition: { name: apiName, mode }, handler: null, notMockedReason: reason };
365
410
  }
366
411
  // -----------------------------------------------------------------------
367
412
  // Control surface
@@ -581,12 +626,13 @@ export class LambderMockApp {
581
626
  const id = this.recorder.nextCallId();
582
627
  const registered = this.entryFor(request.apiName);
583
628
  // The rest entry answers whatever nothing registered, when register()
584
- // was given one. The mode reported stays the registered entry's, so it
585
- // is null here exactly as it is for a name the registry does not know:
586
- // the rest answer is processed as public, which is a property of the
587
- // answer rather than a claim about the endpoint.
629
+ // was given one. The mode reported is the registered entry's, or the
630
+ // apiOptions table's for a name it holds, and null otherwise, exactly
631
+ // as for a name nothing knows: without the table a rest answer is
632
+ // processed as public, which is a property of the answer rather than
633
+ // a claim about the endpoint.
588
634
  const entry = registered ?? this.restNotMockedEntry(request.apiName);
589
- const mode = registered?.mode ?? null;
635
+ const mode = registered?.mode ?? this.declaredModeOf(request.apiName);
590
636
  const facts = this.callFacts(id, request, mode);
591
637
  const startedAt = facts.startedAt;
592
638
  const ctx = this.createContext(request);
@@ -3,8 +3,9 @@ import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.
3
3
  import type { LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
4
4
  import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
5
5
  import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
6
- import type { LambderContractGuardNames, LambderContractIdempotencyOf, LambderContractKeysWithMode, LambderContractRateLimitNames, LambderContractRateLimitOf } from "../shared/wire/LambderApiContract.js";
6
+ import type { LambderContractGuardNames, LambderContractIdempotencyOf, LambderContractKeysWithMode, LambderContractMode, LambderContractRateLimitNames, LambderContractRateLimitOf } from "../shared/wire/LambderApiContract.js";
7
7
  import type { LambderApiGuard } from "../api/LambderApiGuards.js";
8
+ import type { LambderApiOptionEntry, LambderGuardDeclarationEntry } from "../shared/wire/LambderApiOptionEntries.js";
8
9
  import type { LambderApiRateLimitPolicyConfig } from "../api/LambderApiRateLimits.js";
9
10
  import type { LambderApiRequest } from "../api/LambderApiRequest.js";
10
11
  import type { LambderApiTransport } from "../shared/transport/LambderApiTransport.js";
@@ -114,6 +115,37 @@ type LambderMockRateLimitsOptions<C, S, P extends LambderMockRateLimitPolicies<S
114
115
  /** Let a call through when the limiter throws, instead of refusing it. Default: true. */
115
116
  failOpen?: boolean;
116
117
  };
118
+ /**
119
+ * The shape a mock guard has to have to stand in for a server guard the
120
+ * generated `guardDeclarations` table describes: the same input mode and the
121
+ * same session requirement. The contract cannot say these for every guard
122
+ * (it names a guardInput's shape, and nothing about a guard fed from the
123
+ * payload or from nothing), so a mock guard that reads a payload slice the
124
+ * server's guard never sees, or that requires a session where the server's
125
+ * does not, would run and decide differently without this.
126
+ */
127
+ export type LambderMockGuardShapeOf<D> = (D extends {
128
+ input: "apiInput";
129
+ } ? {
130
+ apiInput: z.ZodType;
131
+ } : D extends {
132
+ input: "guardInput";
133
+ } ? {
134
+ guardInput: z.ZodType;
135
+ } : {
136
+ apiInput?: undefined;
137
+ guardInput?: undefined;
138
+ }) & (D extends {
139
+ session: true;
140
+ } ? {
141
+ session: true;
142
+ } : {
143
+ session?: false | undefined;
144
+ });
145
+ /** Each mock guard the declarations know held to its declared shape; a guard the table does not have is free. */
146
+ type LambderMockGuardsAgree<G, D> = {
147
+ [N in keyof G & keyof D]: G[N] extends LambderMockGuardShapeOf<D[N]> ? unknown : LambderMockGuardShapeOf<D[N]>;
148
+ };
117
149
  /**
118
150
  * The guards option: required whenever the contract declares any guard name,
119
151
  * omittable only for a contract that declares none.
@@ -123,12 +155,12 @@ type LambderMockRateLimitsOptions<C, S, P extends LambderMockRateLimitPolicies<S
123
155
  * would answer 200 here. Optional, it would be the droppable half of exactly
124
156
  * the check it exists for.
125
157
  */
126
- type LambderMockGuardsOption<C, S, G> = [
158
+ type LambderMockGuardsOption<C, S, G, D> = [
127
159
  LambderContractGuardNames<C>
128
160
  ] extends [never] ? {
129
- guards?: G & LambderMockGuards<C, S> & LambderMockGuardShapes<S, G>;
161
+ guards?: G & LambderMockGuards<C, S> & LambderMockGuardShapes<S, G> & LambderMockGuardsAgree<G, NoInfer<D>>;
130
162
  } : {
131
- guards: G & LambderMockGuards<C, S> & LambderMockGuardShapes<S, G>;
163
+ guards: G & LambderMockGuards<C, S> & LambderMockGuardShapes<S, G> & LambderMockGuardsAgree<G, NoInfer<D>>;
132
164
  };
133
165
  /**
134
166
  * The sessions, idempotency and rateLimits options: each required whenever
@@ -168,7 +200,38 @@ type LambderMockRateLimitsOption<C, S, P extends LambderMockRateLimitPolicies<S>
168
200
  type LambderMockGuardShapes<S, G> = {
169
201
  [N in keyof G]: LambderMockSurplusKeys<G[N], LambderApiGuard<any, any, any, LambderMockCallContext<S>, LambderMockSessionCallContext<S>>>;
170
202
  };
171
- export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicies<S> = LambderMockRateLimitPolicies<S>, I extends boolean | LambderMockIdempotencyOptions<S> = boolean | LambderMockIdempotencyOptions<S>> = LambderMockGuardsOption<C, S, G> & LambderMockSessionsOption<C, S> & LambderMockIdempotencyOption<C, S, I> & LambderMockRateLimitsOption<C, S, P> & {
203
+ /**
204
+ * What the generated `apiOptions` table has to hold to stand in for the
205
+ * restated declarations of a contract's entries: an entry for every endpoint
206
+ * the contract declares, of the endpoint's mode. A table generated before an
207
+ * endpoint was added, or before one changed mode, is a compile error at the
208
+ * option rather than a throw when that endpoint's entry registers.
209
+ */
210
+ export type LambderMockApiOptionsCover<C> = {
211
+ [K in keyof C & string]: {
212
+ mode: LambderContractMode<C, K>;
213
+ };
214
+ };
215
+ export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicies<S> = LambderMockRateLimitPolicies<S>, I extends boolean | LambderMockIdempotencyOptions<S> = boolean | LambderMockIdempotencyOptions<S>, D extends Record<string, LambderGuardDeclarationEntry> = {}, A extends Record<string, LambderApiOptionEntry> | undefined = undefined> = LambderMockGuardsOption<C, S, G, D> & LambderMockSessionsOption<C, S> & LambderMockIdempotencyOption<C, S, I> & LambderMockRateLimitsOption<C, S, P> & {
216
+ /**
217
+ * The server's guard declarations, as the generated options module
218
+ * exports them (`guardDeclarations`). Given, every mock guard of a name
219
+ * the table has is held to its input mode and session requirement at
220
+ * the `guards` option (see LambderMockGuardShapeOf). Nothing runs on it.
221
+ */
222
+ guardDeclarations?: D;
223
+ /**
224
+ * The server's declared options per API, as the generated options module
225
+ * exports them (`apiOptions`). Given, every entry's guards, rateLimit and
226
+ * idempotency are read off the table rather than restated: an entry is
227
+ * its handler (and an input schema, if it has one), a restated option is
228
+ * a compile error, and an endpoint whose mode the table and the builder
229
+ * disagree on is refused at registration. A restNotMocked answer reads
230
+ * the endpoint's mode off the table too, so a session endpoint nothing
231
+ * mocks still reads the session first. The table has to cover the
232
+ * contract (see LambderMockApiOptionsCover).
233
+ */
234
+ apiOptions?: A & LambderMockApiOptionsCover<C>;
172
235
  /** Stamped on every answer's envelope as apiVersion, as the server's option is. */
173
236
  apiVersion?: string;
174
237
  /** The version floor, as on the server: a call naming a lower `version` answers versionExpired whatever its signature says. */
@@ -186,8 +186,26 @@ type LambderMockInputPin<C, K extends keyof C, TSchema extends z.ZodType> = [
186
186
  }) : {
187
187
  "LambderMockApp: this input schema takes something else than the endpoint's contract input": LambderMockInputOf<C, K>;
188
188
  };
189
- /** An entry written in full: the declarations restated and pinned, plus the handler. */
190
- export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType> = LambderMockGuardsField<C, K> & LambderMockRateLimitField<C, K> & LambderMockIdempotencyField<C, K> & {
189
+ /**
190
+ * The three declaration fields of an entry on a mock created with the
191
+ * generated `apiOptions` table: absent, because the runtime reads them off
192
+ * the table. A restatement beside the table would be a second copy of the
193
+ * server's declaration, so writing one is an error rather than an override.
194
+ */
195
+ type LambderMockDerivedFields = {
196
+ /** Read off the apiOptions table given to create(); not restated. */
197
+ guards?: never;
198
+ /** Read off the apiOptions table given to create(); not restated. */
199
+ rateLimit?: never;
200
+ /** Read off the apiOptions table given to create(); not restated. */
201
+ idempotency?: never;
202
+ };
203
+ /**
204
+ * An entry written in full: the handler, and the declarations restated and
205
+ * pinned to the contract, or, with `TDerived` (a mock created with the
206
+ * generated `apiOptions` table), the handler alone.
207
+ */
208
+ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = (TDerived extends true ? LambderMockDerivedFields : LambderMockGuardsField<C, K> & LambderMockRateLimitField<C, K> & LambderMockIdempotencyField<C, K>) & {
191
209
  /**
192
210
  * A schema to validate the posted payload against, so the mock answers
193
211
  * 422 exactly as the server would. Optional, and the mock's own: the
@@ -201,15 +219,16 @@ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema ext
201
219
  handler: LambderMockHandler<C, K, S, G>;
202
220
  };
203
221
  /**
204
- * What publicApi/sessionApi accept: a bare handler only for an endpoint the
205
- * contract declares nothing for, the full options otherwise, so the form that
206
- * cannot carry a restatement is unavailable exactly where one is owed. Keyed
207
- * on all three declarations: keyed on guards alone, a guardless endpoint
208
- * could drop its rate limit and idempotency through the bare form.
222
+ * What publicApi/sessionApi accept. On a mock created with the generated
223
+ * `apiOptions` table (`TDerived`), a bare handler or the options without the
224
+ * declarations, for every endpoint. Otherwise a bare handler only for an
225
+ * endpoint the contract declares nothing for, the full options elsewhere, so
226
+ * the form that cannot carry a restatement is unavailable exactly where one
227
+ * is owed. Keyed on all three declarations: keyed on guards alone, a
228
+ * guardless endpoint could drop its rate limit and idempotency through the
229
+ * bare form.
209
230
  */
210
- export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType> = [
211
- LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>
212
- ] extends [never] ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema> : LambderMockEntryOptions<C, K, S, G, TInputSchema>;
231
+ export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = TDerived extends true ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema, true> : [LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>] extends [never] ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema> : LambderMockEntryOptions<C, K, S, G, TInputSchema>;
213
232
  /** One registry entry: the endpoint's definition as the pipeline runs it, and its handler (null when registered as not mocked). */
214
233
  export type LambderMockEntry<C, K extends keyof C & string> = {
215
234
  readonly name: K;
@@ -0,0 +1,51 @@
1
+ import type { LambderRateLimitKeyFn } from "../api/LambderApiRateLimits.js";
2
+ import type { LambderRateLimitPolicyEntry } from "../shared/wire/LambderApiOptionEntries.js";
3
+ /** The names of the policies in a generated table whose key the app derives, and so the ones a mock has to supply a key handler for. */
4
+ export type LambderCustomKeyedPolicyNames<TPolicies> = {
5
+ [N in keyof TPolicies]: TPolicies[N] extends {
6
+ per: "custom";
7
+ } ? N : never;
8
+ }[keyof TPolicies] & string;
9
+ /** One policy as the mock runs it: the table's entry with its `per` put back, the key handler for a custom one and the literal for the rest. */
10
+ type LambderMockPolicyOf<TPolicy, TKey> = Omit<TPolicy, "per"> & (TPolicy extends {
11
+ per: "custom";
12
+ } ? {
13
+ per: TKey;
14
+ } : TPolicy extends {
15
+ per: infer P;
16
+ } ? {
17
+ per: P;
18
+ } : {});
19
+ /** The key handlers a table needs: one per custom-keyed policy, none where the table has none. */
20
+ export type LambderMockPolicyKeys<TPolicies> = {
21
+ [N in LambderCustomKeyedPolicyNames<TPolicies>]: LambderRateLimitKeyFn<any, any>;
22
+ };
23
+ /**
24
+ * The rate-limit policies a mock's create() takes, built from a generated
25
+ * `rateLimitPolicies` table and the key handlers for its custom-keyed
26
+ * policies (built with the mock's own `rateLimitKey`, so they see the mock's
27
+ * context). Required for exactly those policies, refused for any other:
28
+ *
29
+ * ```ts
30
+ * const mockApp = mock.create({
31
+ * rateLimits: {
32
+ * policies: lambderMockPoliciesFrom(rateLimitPolicies, {
33
+ * keys: {
34
+ * codePerEmail: mock.rateLimitKey({ apiInput: z.object({ email: z.string() }), handler: (_ctx, { email }) => email.toLowerCase() }),
35
+ * },
36
+ * }),
37
+ * },
38
+ * });
39
+ * ```
40
+ *
41
+ * Everything else about a policy (windows, budget, charge point, message) is
42
+ * the table's, so a mock never disagrees with the server about a limit it
43
+ * did not mean to change. The result keeps each policy's `per` and `budget`
44
+ * as literals, which is what lets create() hold it to the contract.
45
+ */
46
+ export declare const lambderMockPoliciesFrom: <const TPolicies extends Record<string, LambderRateLimitPolicyEntry>, const TKeys extends LambderMockPolicyKeys<TPolicies> = LambderMockPolicyKeys<TPolicies>>(policies: TPolicies, options: [LambderCustomKeyedPolicyNames<TPolicies>] extends [never] ? {
47
+ keys?: TKeys & Record<Exclude<keyof TKeys, LambderCustomKeyedPolicyNames<TPolicies>>, never>;
48
+ } : {
49
+ keys: TKeys & Record<Exclude<keyof TKeys, LambderCustomKeyedPolicyNames<TPolicies>>, never>;
50
+ }) => { [N in keyof TPolicies]: LambderMockPolicyOf<TPolicies[N], N extends keyof TKeys ? TKeys[N] : never>; };
51
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The rate-limit policies a mock's create() takes, built from a generated
3
+ * `rateLimitPolicies` table and the key handlers for its custom-keyed
4
+ * policies (built with the mock's own `rateLimitKey`, so they see the mock's
5
+ * context). Required for exactly those policies, refused for any other:
6
+ *
7
+ * ```ts
8
+ * const mockApp = mock.create({
9
+ * rateLimits: {
10
+ * policies: lambderMockPoliciesFrom(rateLimitPolicies, {
11
+ * keys: {
12
+ * codePerEmail: mock.rateLimitKey({ apiInput: z.object({ email: z.string() }), handler: (_ctx, { email }) => email.toLowerCase() }),
13
+ * },
14
+ * }),
15
+ * },
16
+ * });
17
+ * ```
18
+ *
19
+ * Everything else about a policy (windows, budget, charge point, message) is
20
+ * the table's, so a mock never disagrees with the server about a limit it
21
+ * did not mean to change. The result keeps each policy's `per` and `budget`
22
+ * as literals, which is what lets create() hold it to the contract.
23
+ */
24
+ export const lambderMockPoliciesFrom = (policies, options) => {
25
+ const keys = (options.keys ?? {});
26
+ const built = {};
27
+ for (const [name, entry] of Object.entries(policies)) {
28
+ const { per, ...rest } = entry;
29
+ if (per === "custom") {
30
+ const key = keys[name];
31
+ if (typeof key?.handler !== "function") {
32
+ throw new Error(`LambderMockApp: rate-limit policy "${name}" is keyed by a handler of the server's, so the mock has to supply one: lambderMockPoliciesFrom(rateLimitPolicies, { keys: { ${name}: mock.rateLimitKey({ ... }) } }).`);
33
+ }
34
+ built[name] = { ...rest, per: key };
35
+ }
36
+ else {
37
+ built[name] = per === undefined ? { ...rest } : { ...rest, per };
38
+ }
39
+ }
40
+ for (const name of Object.keys(keys)) {
41
+ if (policies[name]?.per !== "custom") {
42
+ throw new Error(`LambderMockApp: a key handler was given for rate-limit policy "${name}", which the server ${name in policies ? "does not key by a handler" : "does not declare"}.`);
43
+ }
44
+ }
45
+ return built;
46
+ };
package/dist/mock.d.ts CHANGED
@@ -10,6 +10,9 @@ export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
10
10
  export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
11
11
  export type { LambderMockAppOptions, LambderMockSessionsOptions, LambderMockIdempotencyOptions, LambderMockInvalidInputAnswer, LambderMockTransport, LambderMockTransportOptions } from "./mock/LambderMockCreateOptions.js";
12
12
  export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockPublicNames, LambderMockSessionNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
13
+ export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
14
+ export type { LambderCustomKeyedPolicyNames, LambderMockPolicyKeys } from "./mock/lambderMockPoliciesFrom.js";
15
+ export type { LambderMockGuardShapeOf } from "./mock/LambderMockCreateOptions.js";
13
16
  export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
14
17
  export type { LambderMockConsoleLoggerOptions } from "./mock/lambderMockConsoleLogger.js";
15
18
  export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
package/dist/mock.js CHANGED
@@ -12,6 +12,9 @@ export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
12
12
  // exported: the runtime is reached through the app. Only the error a
13
13
  // transport rejects with is public.
14
14
  export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
15
+ // The server's rate-limit policies as the mock restates them, from the
16
+ // generated options module plus the key handlers the module cannot hold.
17
+ export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
15
18
  export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
16
19
  export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
17
20
  // The storage a mock app's uploads go to: a memory bucket the mock's ticket and
@@ -0,0 +1,166 @@
1
+ import type { LambderOneShotSecretStore } from "../shared/contracts/LambderOneShotSecretStore.js";
2
+ /**
3
+ * The shapes a kind of secret takes, told apart by how they are redeemed. A
4
+ * code is bound to a scope the app names (an address for a purpose, a
5
+ * recipient, a device), redeemed with that scope, and defended by a ceiling on
6
+ * tries, which is what lets it be short. A token carries its own identity, is
7
+ * redeemed by value alone, and has no ceiling: random bytes, long enough that
8
+ * guessing is not a thing, or, for a code somebody types without knowing what
9
+ * it is for (a pairing code), characters of an alphabet, which is guessable
10
+ * and which the app then holds off another way, such as a rate limit per
11
+ * address.
12
+ */
13
+ export type LambderOneShotSecretKind = {
14
+ shape: "code";
15
+ /** The characters a code is drawn from, each once. Default: the ten digits. */
16
+ alphabet?: string;
17
+ length: number;
18
+ ttlSeconds: number;
19
+ /** Wrong tries the code survives; the try that would pass this refuses the code as exhausted, right or wrong. */
20
+ maxAttempts: number;
21
+ } | {
22
+ shape: "token";
23
+ /** Random bytes behind the token, which is their base64url. Default: 32. */
24
+ bytes?: number;
25
+ alphabet?: undefined;
26
+ length?: undefined;
27
+ ttlSeconds: number;
28
+ } | {
29
+ shape: "token";
30
+ /** The characters the token is drawn from, each once, for a token somebody types. */
31
+ alphabet: string;
32
+ length: number;
33
+ bytes?: undefined;
34
+ ttlSeconds: number;
35
+ };
36
+ export type LambderOneShotSecretsOptions<TKinds extends Record<string, LambderOneShotSecretKind>> = {
37
+ store: LambderOneShotSecretStore;
38
+ /** Keys every digest at rest, so a copied store cannot be attacked offline. Server side only. */
39
+ secret: string;
40
+ kinds: TKinds;
41
+ /** The clock, in epoch milliseconds. Default: Date.now. */
42
+ now?: () => number;
43
+ };
44
+ export type LambderOneShotIssueResult = {
45
+ issued: true;
46
+ /** The secret, as it leaves this module the one time it does: a code as written, a token as base64url. */
47
+ plaintext: string;
48
+ /** Epoch seconds. */
49
+ expiresAt: number;
50
+ } | {
51
+ issued: false;
52
+ refused: "cooldown"; /** Epoch seconds when the cooldown ends. */
53
+ retryAt: number;
54
+ };
55
+ export type LambderOneShotRedeemResult =
56
+ /** The secret is the one out: spent now, with the scope it proved and what the app stored beside it. */
57
+ {
58
+ state: "accepted";
59
+ scope: string;
60
+ meta: Record<string, string>;
61
+ issuedAt: number;
62
+ }
63
+ /** Not the code that is out; the try was counted. */
64
+ | {
65
+ state: "wrong";
66
+ attemptsLeft: number;
67
+ }
68
+ /** The code that is out has run out of time; ask for a new one. */
69
+ | {
70
+ state: "expired";
71
+ }
72
+ /** The code that is out has run out of tries, this one included; ask for a new one. */
73
+ | {
74
+ state: "exhausted";
75
+ }
76
+ /** Nothing is out for this scope or this value: never issued, spent, replaced, retired, or unknown. */
77
+ | {
78
+ state: "none";
79
+ };
80
+ /** The names of the kinds a scoped code is redeemed under. */
81
+ export type LambderOneShotCodeKindNames<TKinds> = {
82
+ [K in keyof TKinds]: TKinds[K] extends {
83
+ shape: "code";
84
+ } ? K : never;
85
+ }[keyof TKinds] & string;
86
+ /** The names of the kinds a token is redeemed under, by value. */
87
+ export type LambderOneShotTokenKindNames<TKinds> = {
88
+ [K in keyof TKinds]: TKinds[K] extends {
89
+ shape: "token";
90
+ } ? K : never;
91
+ }[keyof TKinds] & string;
92
+ /**
93
+ * Codes and tokens an app hands out once and takes back once, over a store
94
+ * that settles their races.
95
+ *
96
+ * ```ts
97
+ * const secrets = new LambderOneShotSecrets({
98
+ * store: new LambderDdbOneShotSecretStore({ tableName: "app-policies" }),
99
+ * secret: ONE_SHOT_SECRET,
100
+ * kinds: {
101
+ * emailCode: { shape: "code", length: 6, ttlSeconds: 600, maxAttempts: 5 },
102
+ * activationLink: { shape: "token", ttlSeconds: 48 * 3600 },
103
+ * },
104
+ * });
105
+ *
106
+ * const issued = await secrets.issue("emailCode", `register:${email}`, { cooldownSeconds: 30 });
107
+ * if(issued.issued) await sendEmail(email, issued.plaintext);
108
+ *
109
+ * const redeemed = await secrets.redeem("emailCode", `register:${email}`, typedCode);
110
+ * if(redeemed.state !== "accepted") refuse(...);
111
+ * ```
112
+ *
113
+ * The store holds digests only, keyed under the app's secret with the kind
114
+ * and the scope folded in: a code issued to two scopes never collides, and a
115
+ * code cannot be replayed against another kind or scope. The plaintext leaves
116
+ * once, from `issue`; nothing here logs it or stores it.
117
+ */
118
+ export declare class LambderOneShotSecrets<TKinds extends Record<string, LambderOneShotSecretKind>> {
119
+ private readonly store;
120
+ private readonly secret;
121
+ private readonly kinds;
122
+ private readonly now;
123
+ constructor(options: LambderOneShotSecretsOptions<TKinds>);
124
+ private nowSeconds;
125
+ private kindOf;
126
+ /**
127
+ * The digest a secret rests as. A code's carries its kind and scope, so
128
+ * the same code issued to two scopes never collides and a code cannot be
129
+ * replayed against another; a token's carries its kind, since a token is
130
+ * found by its digest alone, so two scopes can draw the same one, and the
131
+ * store's claim on the digest at issue is what keeps them apart. The
132
+ * fields are joined escaped, so no two distinct field lists produce one
133
+ * string.
134
+ */
135
+ private digestOf;
136
+ /**
137
+ * Mints one secret for the scope, retiring whatever the scope held, and
138
+ * answers the plaintext: the one time it exists outside the caller's
139
+ * hands. With `cooldownSeconds`, a scope whose current secret was issued
140
+ * less than that ago is refused instead, with the second it may ask
141
+ * again; of two callers racing past the cooldown, exactly one is issued.
142
+ * `meta` is what the app wants back at redemption: an identity, an
143
+ * issuing organization, as small strings.
144
+ *
145
+ * A secret whose digest another scope holds is drawn again, up to
146
+ * MAX_DRAWS times; past that the kind's alphabet and length leave too few
147
+ * secrets for the ones out at once, and the issue throws.
148
+ */
149
+ issue(kind: keyof TKinds & string, scope: string, options?: {
150
+ cooldownSeconds?: number;
151
+ meta?: Record<string, string>;
152
+ }): Promise<LambderOneShotIssueResult>;
153
+ /**
154
+ * Redeems a code for its scope. The try is counted before the code is
155
+ * looked at, in the write that reads the digest, so tries sent together
156
+ * are all counted; a right code past the ceiling is refused as exhausted.
157
+ * An accepted code is spent in the same call, exactly once.
158
+ */
159
+ redeem(kind: LambderOneShotCodeKindNames<TKinds>, scope: string, candidate: string): Promise<LambderOneShotRedeemResult>;
160
+ /** Redeems a token by its value: found by its digest, spent exactly once. A token of another kind, or none, is "none". */
161
+ redeemToken(kind: LambderOneShotTokenKindNames<TKinds>, candidate: string): Promise<LambderOneShotRedeemResult>;
162
+ /** Ends whatever the scope holds: after the thing it proved is settled another way, or when what was sent never arrived. */
163
+ retire(scope: string): Promise<void>;
164
+ /** Spends the record; a redemption racing this one and winning makes it "none". */
165
+ private accept;
166
+ }