lambder 9.0.3 → 10.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 (72) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +25 -11
  3. package/dist/api/LambderApiDefinition.d.ts +9 -0
  4. package/dist/api/LambderApiEnvelope.d.ts +22 -10
  5. package/dist/api/LambderApiEnvelope.js +57 -22
  6. package/dist/api/LambderApiGuards.d.ts +77 -22
  7. package/dist/api/LambderApiGuards.js +13 -1
  8. package/dist/api/LambderApiIdempotency.js +4 -4
  9. package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
  10. package/dist/api/LambderApiOutputValidationError.js +14 -6
  11. package/dist/api/LambderApiPipeline.d.ts +2 -1
  12. package/dist/api/LambderApiPipeline.js +10 -2
  13. package/dist/api/LambderApiRateLimits.d.ts +15 -11
  14. package/dist/api/LambderApiRateLimits.js +31 -7
  15. package/dist/api/LambderApiRefusals.d.ts +204 -0
  16. package/dist/api/LambderApiRefusals.js +223 -0
  17. package/dist/api/LambderApiSignature.d.ts +13 -4
  18. package/dist/api/LambderApiSignature.js +21 -4
  19. package/dist/build/writeApiOptions.js +7 -5
  20. package/dist/client/LambderCaller.d.ts +28 -17
  21. package/dist/client/LambderCaller.js +19 -15
  22. package/dist/client.d.ts +5 -4
  23. package/dist/client.js +2 -1
  24. package/dist/core/Lambder.d.ts +121 -23
  25. package/dist/core/Lambder.js +105 -51
  26. package/dist/core/LambderContext.d.ts +1 -1
  27. package/dist/core/LambderContext.js +5 -0
  28. package/dist/core/LambderCreateOptions.d.ts +58 -15
  29. package/dist/core/LambderCreateOptions.js +10 -0
  30. package/dist/core/LambderPolicyBuilders.d.ts +11 -8
  31. package/dist/core/LambderPolicyBuilders.js +10 -8
  32. package/dist/core/LambderResolver.d.ts +1 -1
  33. package/dist/core/LambderResolver.js +1 -1
  34. package/dist/core/LambderResponseBuilder.d.ts +11 -9
  35. package/dist/core/LambderResponseBuilder.js +12 -11
  36. package/dist/index.d.ts +12 -8
  37. package/dist/index.js +6 -2
  38. package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
  39. package/dist/invoke/LambderInvokeCaller.js +13 -12
  40. package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
  41. package/dist/invoke/LambderInvokeOutcome.js +4 -4
  42. package/dist/mock/LambderMockApp.d.ts +86 -24
  43. package/dist/mock/LambderMockApp.js +108 -16
  44. package/dist/mock/LambderMockCallRecorder.js +6 -3
  45. package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
  46. package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
  47. package/dist/mock/LambderMockFailureInjector.js +18 -4
  48. package/dist/mock/LambderMockTypes.d.ts +34 -11
  49. package/dist/mock.d.ts +4 -3
  50. package/dist/mock.js +4 -3
  51. package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
  52. package/dist/shared/wire/LambderApiContract.d.ts +96 -31
  53. package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
  54. package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
  55. package/dist/shared/wire/LambderApiOptionValues.js +3 -3
  56. package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
  57. package/dist/shared/wire/LambderApiOutcome.js +28 -11
  58. package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
  59. package/dist/shared/wire/LambderApiRefusal.js +15 -7
  60. package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
  61. package/dist/shared/wire/LambderCrashDetail.js +1 -1
  62. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
  63. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
  64. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
  65. package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
  66. package/dist/shared/wire/LambderObjectPayload.js +24 -0
  67. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
  68. package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
  69. package/dist/testing/LambderTestApp.d.ts +1 -1
  70. package/dist/testing.d.ts +1 -1
  71. package/dist/testing.js +1 -1
  72. package/package.json +1 -1
@@ -0,0 +1,204 @@
1
+ import type { z } from "zod";
2
+ import type { LambderJsonOf } from "../shared/wire/LambderApiContract.js";
3
+ import type { LambderRefusalStatusCode } from "../shared/wire/LambderHttpStatus.js";
4
+ import type { LambderNoExtraKeys } from "../shared/util/LambderTypeUtilities.js";
5
+ import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
6
+ /**
7
+ * One code of an app's refusal vocabulary: the schema of the data it carries,
8
+ * when it carries data, and how every refusal with the code leaves the
9
+ * server. A code's data is an object or an array, as an API's output is. The
10
+ * status and the flag are declared once here rather than at each raise site,
11
+ * so a code always reaches a caller the same way.
12
+ */
13
+ export type LambderRefusalDeclaration = {
14
+ /** The schema of the code's data, when it carries data. */
15
+ data?: z.ZodType;
16
+ /** The HTTP status every refusal with this code leaves with. Default 200: the envelope is the channel. */
17
+ status?: LambderRefusalStatusCode;
18
+ /** Whether every refusal with this code sets the envelope's notAuthorized flag, which a caller routes to its notAuthorizedHandler. */
19
+ notAuthorized?: true;
20
+ };
21
+ /**
22
+ * One code as its endpoint may send it: the status and flag its declaration
23
+ * gives it, and whether it carries data, with the data's schema when it does.
24
+ * The server and the mock both resolve it from the vocabulary
25
+ * (allowedRefusalOf), so a refusal's data is parsed the same way on both.
26
+ */
27
+ export type LambderApiAllowedRefusal = {
28
+ /** The status every refusal with the code leaves with; 200 when absent. */
29
+ status?: LambderRefusalStatusCode;
30
+ /** Present when every refusal with the code sets the envelope's notAuthorized flag. */
31
+ notAuthorized?: true;
32
+ } & ({
33
+ data: false;
34
+ } | {
35
+ data: true;
36
+ schema: z.ZodType;
37
+ });
38
+ /** The codes one endpoint may refuse with: its own `refusals` option and those of the guards it declares. */
39
+ export type LambderApiAllowedRefusals = ReadonlyMap<string, LambderApiAllowedRefusal>;
40
+ /** The app's refusal vocabulary, as `initLambder().declareRefusals()` and the mock's take it: every code once, with its declaration. */
41
+ export type LambderRefusalVocabulary = Record<string, LambderRefusalDeclaration>;
42
+ /**
43
+ * What declareRefusals() hands the rest of an init, the server's and the
44
+ * mock's alike: the vocabulary as declared, checked and keyed, and whether
45
+ * every refusal an API answers with has to name a code.
46
+ */
47
+ export type LambderDeclaredVocabulary<TRefusals extends LambderRefusalVocabulary, TCodesRequired extends boolean> = {
48
+ refusals: TRefusals;
49
+ vocabulary: ReadonlyMap<string, LambderRefusalDeclaration>;
50
+ requireCodes: TCodesRequired;
51
+ };
52
+ /** A refusal's data is an object or an array, as an API's output is: the same rule, where the code is declared. */
53
+ type LambderRefusalDataCheck<TDeclaration> = TDeclaration extends {
54
+ data: infer TSchema extends z.ZodType;
55
+ } ? 0 extends 1 & z.output<TSchema> ? unknown : undefined extends z.output<TSchema> ? LambderRefusalDataRefusal : [LambderJsonOf<z.output<TSchema>>] extends [never] ? LambderRefusalDataRefusal : [LambderJsonOf<z.output<TSchema>>] extends [object] ? unknown : LambderRefusalDataRefusal : unknown;
56
+ type LambderRefusalDataRefusal = {
57
+ data: {
58
+ readonly "lambder: a refusal's data is an object or an array, as an API's output is.": never;
59
+ };
60
+ };
61
+ /**
62
+ * The vocabulary as declareRefusals() checks it, code by code: no `lambder/`
63
+ * prefix, no key beside data, status and notAuthorized (a `dat:` would leave
64
+ * a code that carries data declared as one that does not), and data that is
65
+ * an object or an array.
66
+ */
67
+ export type LambderRefusalVocabularyChecks<TVocabulary> = {
68
+ [TCode in keyof TVocabulary]: TCode extends `lambder/${string}` ? {
69
+ readonly "lambder: a refusal code may not start with lambder/, the prefix of the framework's own codes": never;
70
+ } : LambderNoExtraKeys<TVocabulary[TCode], LambderRefusalDeclaration> & LambderRefusalDataCheck<TVocabulary[TCode]>;
71
+ };
72
+ /** One code's declaration as checkedRefusal reads it: how a refusal with the code leaves, and its schema. */
73
+ export declare const allowedRefusalOf: (declaration: LambderRefusalDeclaration) => LambderApiAllowedRefusal;
74
+ /**
75
+ * What one endpoint's refusals are checked against: the codes it may refuse
76
+ * with, and whether every refusal it answers with has to name one
77
+ * (declareRefusals's `requireCodes`). Undefined on a definition whose codes
78
+ * are not known (a mock without the generated tables), which checks nothing.
79
+ */
80
+ export type LambderEndpointRefusals = {
81
+ codes: LambderApiAllowedRefusals;
82
+ codeRequired: boolean;
83
+ };
84
+ /** How a refusal broke its endpoint's declaration. */
85
+ type LambderRefusalViolation = {
86
+ uncoded: true;
87
+ } | {
88
+ undeclared: string;
89
+ } | {
90
+ dataWithoutDeclaredCode: string | undefined;
91
+ } | {
92
+ missingData: string;
93
+ } | {
94
+ ownStatusOrFlag: true;
95
+ } | {
96
+ zodError: z.ZodError;
97
+ } | {
98
+ thrown: unknown;
99
+ } | {
100
+ notObject: string;
101
+ };
102
+ /**
103
+ * A refusal an endpoint is not allowed to send, so it was not sent and the
104
+ * call is answered as a crash: no code where the app requires one, a code
105
+ * the endpoint does not declare (neither
106
+ * in its own `refusals` option nor through a guard), data on a refusal whose
107
+ * code declares none, no data on one whose code carries data, data its code's
108
+ * schema rejects or that is not an object or an array, or a declared code
109
+ * raised with a status or flag of its own, which its declaration owns.
110
+ *
111
+ * What makes a client's refusal type exact: a reader narrows `code` to the
112
+ * endpoint's declared codes and the framework's own, and relies on this to
113
+ * never see another. The refusal side's counterpart of
114
+ * LambderApiOutputValidationError. The refusal as thrown is the cause, so
115
+ * the stack points at the line that raised it.
116
+ */
117
+ export declare class LambderApiRefusalValidationError extends Error {
118
+ readonly apiName: string;
119
+ /** The code the refusal carried, if any. */
120
+ readonly code: string | undefined;
121
+ /** The schema's issues when the code's schema rejected the data; null otherwise. */
122
+ readonly zodError: z.ZodError | null;
123
+ constructor(apiName: string, thrown: LambderApiRefusal, violation: LambderRefusalViolation);
124
+ }
125
+ /**
126
+ * The refusal as endpoint `apiName` may send it, or a thrown
127
+ * LambderApiRefusalValidationError when it may not. Where a thrown refusal
128
+ * becomes an answer for a known endpoint (the pipeline, a hook refusing an
129
+ * API call on the server, an injected failure in the mock), it passes through
130
+ * here first.
131
+ *
132
+ * - An uncoded refusal, or one carrying a framework code, goes out as it is,
133
+ * provided it carries no data. Where the app requires codes, an uncoded
134
+ * refusal is refused too; a framework code still passes.
135
+ * - A declared code leaves with its declaration's status and notAuthorized
136
+ * flag, and with data exactly when the code carries data, parsed through
137
+ * the code's schema as an output is: undeclared fields stripped, defaults
138
+ * filled, transforms run. The parse is synchronous, so a data schema cannot
139
+ * be async.
140
+ * - Anything else is refused, a declared code raised with a status or flag of
141
+ * its own included.
142
+ *
143
+ * `endpoint` undefined means the endpoint's codes are not known here (a mock
144
+ * given no generated options), and nothing is checked.
145
+ */
146
+ export declare const checkedRefusal: (apiName: string, endpoint: LambderEndpointRefusals | undefined, thrown: LambderApiRefusal) => LambderApiRefusal;
147
+ /**
148
+ * Throws when `where` (an API, a guard) names a refusal code the vocabulary
149
+ * does not hold, or names one when there is no vocabulary at all.
150
+ */
151
+ export declare const assertRefusalCodesDeclared: (where: string, codes: readonly string[], vocabulary: ReadonlyMap<string, LambderRefusalDeclaration> | null) => void;
152
+ /**
153
+ * One endpoint's allowed codes, resolved from the vocabulary: the codes its
154
+ * own `refusals` option names and those of its declared guards, each as
155
+ * checkedRefusal reads it, schema included. Throws on a name the vocabulary
156
+ * does not hold, and on a declaration when there is no vocabulary at all.
157
+ */
158
+ export declare const resolveAllowedRefusals: (apiName: string, vocabulary: ReadonlyMap<string, LambderRefusalDeclaration> | null, ownCodes: readonly string[], guardCodes: readonly {
159
+ guard: string;
160
+ codes: readonly string[];
161
+ }[]) => LambderApiAllowedRefusals;
162
+ /** The refusals option in its list form: one name becomes a list of one. */
163
+ export declare const toRefusalCodes: (value: string | readonly string[] | undefined) => readonly string[];
164
+ /**
165
+ * The vocabulary, checked: every code a non-empty string outside the
166
+ * framework's `lambder/` prefix, every declaration an object whose `data`,
167
+ * when present, is a zod schema, whose `status`, when present, is one a
168
+ * reader files as a refusal, and whose `notAuthorized`, when present, is
169
+ * true. A Map, so a code named for something Object.prototype carries
170
+ * ("toString") is still an ordinary code.
171
+ */
172
+ export declare const readRefusalVocabulary: (refusals: Record<string, LambderRefusalDeclaration> | undefined) => Map<string, LambderRefusalDeclaration> | null;
173
+ /** The per-API refusals option: one code of the vocabulary, or a non-empty list of them. Empty is not a declaration, so it is not a form. */
174
+ export type LambderRefusalsOption<TVocabulary> = (keyof TVocabulary & string) | readonly [keyof TVocabulary & string, ...(keyof TVocabulary & string)[]];
175
+ /** The codes a refusals option names, whichever of its two forms is used: toRefusalCodes at the type level. */
176
+ export type LambderRefusalNamesIn<TOpt> = TOpt extends string ? TOpt : TOpt extends readonly (infer TCode extends string)[] ? TCode : never;
177
+ /**
178
+ * The codes as an API handler raises them (ctx.refuse): each mapped to
179
+ * `{ data }` in the schema's input form, the one its transforms take, or to
180
+ * `{}` for a code with no data.
181
+ */
182
+ export type LambderHandlerRefusalsOf<TVocabulary, TCodes extends string> = {
183
+ [TCode in TCodes]: TCode extends keyof TVocabulary ? TVocabulary[TCode] extends {
184
+ data: infer TSchema extends z.ZodType;
185
+ } ? {
186
+ data: z.input<TSchema>;
187
+ } : {} : never;
188
+ };
189
+ /**
190
+ * The codes as a contract records them, for the client to narrow on: each
191
+ * mapped to `{ data }` as JSON (the schema's output after its transforms, as
192
+ * it arrives) or to `{}`. never when there are none, so the entry leaves the
193
+ * member out.
194
+ */
195
+ export type LambderWireRefusalsOf<TVocabulary, TCodes extends string> = [
196
+ TCodes
197
+ ] extends [never] ? never : {
198
+ [TCode in TCodes]: TCode extends keyof TVocabulary ? TVocabulary[TCode] extends {
199
+ data: infer TSchema extends z.ZodType;
200
+ } ? {
201
+ data: LambderJsonOf<z.output<TSchema>>;
202
+ } : {} : never;
203
+ };
204
+ export {};
@@ -0,0 +1,223 @@
1
+ import { LambderApiRefusal, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
2
+ import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
3
+ /** One code's declaration as checkedRefusal reads it: how a refusal with the code leaves, and its schema. */
4
+ export const allowedRefusalOf = (declaration) => {
5
+ const leaves = {
6
+ ...(declaration.status !== undefined ? { status: declaration.status } : {}),
7
+ ...(declaration.notAuthorized ? { notAuthorized: true } : {}),
8
+ };
9
+ return declaration.data === undefined ? { ...leaves, data: false } : { ...leaves, data: true, schema: declaration.data };
10
+ };
11
+ /**
12
+ * A refusal an endpoint is not allowed to send, so it was not sent and the
13
+ * call is answered as a crash: no code where the app requires one, a code
14
+ * the endpoint does not declare (neither
15
+ * in its own `refusals` option nor through a guard), data on a refusal whose
16
+ * code declares none, no data on one whose code carries data, data its code's
17
+ * schema rejects or that is not an object or an array, or a declared code
18
+ * raised with a status or flag of its own, which its declaration owns.
19
+ *
20
+ * What makes a client's refusal type exact: a reader narrows `code` to the
21
+ * endpoint's declared codes and the framework's own, and relies on this to
22
+ * never see another. The refusal side's counterpart of
23
+ * LambderApiOutputValidationError. The refusal as thrown is the cause, so
24
+ * the stack points at the line that raised it.
25
+ */
26
+ export class LambderApiRefusalValidationError extends Error {
27
+ apiName;
28
+ /** The code the refusal carried, if any. */
29
+ code;
30
+ /** The schema's issues when the code's schema rejected the data; null otherwise. */
31
+ zodError;
32
+ constructor(apiName, thrown, violation) {
33
+ const code = thrown.refusal.code;
34
+ let message;
35
+ if ("uncoded" in violation) {
36
+ message = `Lambder: API "${apiName}" refused without a code, and this app requires every refusal an API answers with to name a declared code (declareRefusals with requireCodes), so the refusal was not sent. `
37
+ + "Name one of the API's declared codes, or a framework code.";
38
+ }
39
+ else if ("undeclared" in violation) {
40
+ message = `Lambder: API "${apiName}" refused with the code "${violation.undeclared}", which it does not declare, so the refusal was not sent. `
41
+ + "Name the code in the API's refusals option (or in a guard it declares), from the refusals vocabulary given at creation.";
42
+ }
43
+ else if ("dataWithoutDeclaredCode" in violation) {
44
+ message = violation.dataWithoutDeclaredCode === undefined
45
+ ? `Lambder: API "${apiName}" refused with data but no code, so the refusal was not sent. Only a declared code carries data.`
46
+ : `Lambder: API "${apiName}" refused with the code "${violation.dataWithoutDeclaredCode}" and data it does not declare, so the refusal was not sent.`;
47
+ }
48
+ else if ("missingData" in violation) {
49
+ message = `Lambder: API "${apiName}" refused with the code "${violation.missingData}", which carries data, and no data, so the refusal was not sent.`;
50
+ }
51
+ else if ("ownStatusOrFlag" in violation) {
52
+ message = `Lambder: API "${apiName}" refused with the declared code "${String(code)}" and a status or flag of its own, so the refusal was not sent. `
53
+ + "A declared code's status and notAuthorized flag are its declaration's: set them in the refusals vocabulary, and leave them off the refuse() call.";
54
+ }
55
+ else if ("zodError" in violation) {
56
+ // Paths and messages only, never the values: they may be the
57
+ // user's data.
58
+ const issues = violation.zodError.issues.slice(0, 3).map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
59
+ message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data its schema does not accept, so the refusal was not sent. ${issues}`;
60
+ }
61
+ else if ("thrown" in violation) {
62
+ message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data its schema threw on while parsing it (a transform that threw, `
63
+ + "or an async step: a refusal's data is parsed synchronously), so the refusal was not sent.";
64
+ }
65
+ else {
66
+ message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data that is ${violation.notObject}, and a refusal's data is an object or an array, so the refusal was not sent.`;
67
+ }
68
+ super(message, { cause: thrown });
69
+ this.name = "LambderApiRefusalValidationError";
70
+ this.apiName = apiName;
71
+ this.code = code;
72
+ this.zodError = "zodError" in violation ? violation.zodError : null;
73
+ }
74
+ }
75
+ /**
76
+ * The refusal as endpoint `apiName` may send it, or a thrown
77
+ * LambderApiRefusalValidationError when it may not. Where a thrown refusal
78
+ * becomes an answer for a known endpoint (the pipeline, a hook refusing an
79
+ * API call on the server, an injected failure in the mock), it passes through
80
+ * here first.
81
+ *
82
+ * - An uncoded refusal, or one carrying a framework code, goes out as it is,
83
+ * provided it carries no data. Where the app requires codes, an uncoded
84
+ * refusal is refused too; a framework code still passes.
85
+ * - A declared code leaves with its declaration's status and notAuthorized
86
+ * flag, and with data exactly when the code carries data, parsed through
87
+ * the code's schema as an output is: undeclared fields stripped, defaults
88
+ * filled, transforms run. The parse is synchronous, so a data schema cannot
89
+ * be async.
90
+ * - Anything else is refused, a declared code raised with a status or flag of
91
+ * its own included.
92
+ *
93
+ * `endpoint` undefined means the endpoint's codes are not known here (a mock
94
+ * given no generated options), and nothing is checked.
95
+ */
96
+ export const checkedRefusal = (apiName, endpoint, thrown) => {
97
+ if (endpoint === undefined)
98
+ return thrown;
99
+ const { code, data } = thrown.refusal;
100
+ if (code === undefined || isLambderRefusalCode(code)) {
101
+ if (code === undefined && endpoint.codeRequired)
102
+ throw new LambderApiRefusalValidationError(apiName, thrown, { uncoded: true });
103
+ if (data !== undefined)
104
+ throw new LambderApiRefusalValidationError(apiName, thrown, { dataWithoutDeclaredCode: code });
105
+ return thrown;
106
+ }
107
+ const declaration = endpoint.codes.get(code);
108
+ if (declaration === undefined)
109
+ throw new LambderApiRefusalValidationError(apiName, thrown, { undeclared: code });
110
+ if (thrown.statusCode !== undefined || thrown.notAuthorized || thrown.sessionExpired) {
111
+ throw new LambderApiRefusalValidationError(apiName, thrown, { ownStatusOrFlag: true });
112
+ }
113
+ let sent;
114
+ if (!declaration.data) {
115
+ if (data !== undefined)
116
+ throw new LambderApiRefusalValidationError(apiName, thrown, { dataWithoutDeclaredCode: code });
117
+ }
118
+ else if (data === undefined) {
119
+ throw new LambderApiRefusalValidationError(apiName, thrown, { missingData: code });
120
+ }
121
+ else {
122
+ let parsed;
123
+ try {
124
+ parsed = declaration.schema.safeParse(data);
125
+ }
126
+ catch (cause) {
127
+ throw new LambderApiRefusalValidationError(apiName, thrown, { thrown: cause });
128
+ }
129
+ if (!parsed.success)
130
+ throw new LambderApiRefusalValidationError(apiName, thrown, { zodError: parsed.error });
131
+ if (!isObjectPayload(parsed.data))
132
+ throw new LambderApiRefusalValidationError(apiName, thrown, { notObject: describePayloadKind(parsed.data) });
133
+ sent = parsed.data;
134
+ }
135
+ // The declaration's status and flag, and the data as parsed: how every
136
+ // refusal with this code leaves, wherever it was raised.
137
+ return new LambderApiRefusal(thrown.message, {
138
+ refusal: { ...thrown.refusal, ...(sent !== undefined ? { data: sent } : {}) },
139
+ notAuthorized: declaration.notAuthorized,
140
+ statusCode: declaration.status,
141
+ headers: thrown.headers,
142
+ cause: thrown.cause,
143
+ });
144
+ };
145
+ /**
146
+ * Throws when `where` (an API, a guard) names a refusal code the vocabulary
147
+ * does not hold, or names one when there is no vocabulary at all.
148
+ */
149
+ export const assertRefusalCodesDeclared = (where, codes, vocabulary) => {
150
+ for (const code of codes) {
151
+ if (!vocabulary) {
152
+ throw new Error(`Lambder: ${where} declares the refusal "${code}", but no refusals vocabulary was declared. Declare the app's codes with initLambder().declareRefusals().`);
153
+ }
154
+ if (!vocabulary.has(code)) {
155
+ throw new Error(`Lambder: ${where} declares the refusal "${code}", which the refusals vocabulary does not hold.`);
156
+ }
157
+ }
158
+ };
159
+ /**
160
+ * One endpoint's allowed codes, resolved from the vocabulary: the codes its
161
+ * own `refusals` option names and those of its declared guards, each as
162
+ * checkedRefusal reads it, schema included. Throws on a name the vocabulary
163
+ * does not hold, and on a declaration when there is no vocabulary at all.
164
+ */
165
+ export const resolveAllowedRefusals = (apiName, vocabulary, ownCodes, guardCodes) => {
166
+ const allowed = new Map();
167
+ const take = (code, where) => {
168
+ assertRefusalCodesDeclared(where, [code], vocabulary);
169
+ allowed.set(code, allowedRefusalOf(vocabulary.get(code)));
170
+ };
171
+ for (const code of ownCodes)
172
+ take(code, `API "${apiName}"`);
173
+ for (const { guard, codes } of guardCodes) {
174
+ for (const code of codes)
175
+ take(code, `guard "${guard}" (declared by API "${apiName}")`);
176
+ }
177
+ return allowed;
178
+ };
179
+ /** The refusals option in its list form: one name becomes a list of one. */
180
+ export const toRefusalCodes = (value) => value === undefined ? [] : typeof value === "string" ? [value] : value;
181
+ /**
182
+ * The vocabulary, checked: every code a non-empty string outside the
183
+ * framework's `lambder/` prefix, every declaration an object whose `data`,
184
+ * when present, is a zod schema, whose `status`, when present, is one a
185
+ * reader files as a refusal, and whose `notAuthorized`, when present, is
186
+ * true. A Map, so a code named for something Object.prototype carries
187
+ * ("toString") is still an ordinary code.
188
+ */
189
+ export const readRefusalVocabulary = (refusals) => {
190
+ if (refusals === undefined)
191
+ return null;
192
+ const entries = Object.entries(refusals);
193
+ if (entries.length === 0) {
194
+ throw new Error("Lambder: the refusals vocabulary was declared with no codes in it, which declares nothing. Name the codes APIs will refuse with, or leave declareRefusals() out.");
195
+ }
196
+ for (const [code, declaration] of entries) {
197
+ if (code === "")
198
+ throw new Error("Lambder: the refusals vocabulary declares an empty code.");
199
+ if (code.startsWith("lambder/")) {
200
+ throw new Error(`Lambder: the refusals vocabulary declares "${code}", under the "lambder/" prefix the framework's own codes use. Name it outside that prefix.`);
201
+ }
202
+ if (declaration === null || typeof declaration !== "object") {
203
+ throw new Error(`Lambder: the refusal "${code}" is not declared as an object: write {} for a code with no data, or { data: schema }.`);
204
+ }
205
+ const extra = Object.keys(declaration).filter((key) => key !== "data" && key !== "status" && key !== "notAuthorized");
206
+ if (extra.length) {
207
+ throw new Error(`Lambder: the refusal "${code}" has ${extra.map((key) => `"${key}"`).join(", ")} beside data, status and notAuthorized, which a refusal declaration does not take.`);
208
+ }
209
+ // Read for what it is used as rather than by class, so a schema from
210
+ // a second copy of zod in the bundle still counts.
211
+ if (declaration.data !== undefined && typeof declaration.data?.safeParse !== "function") {
212
+ throw new Error(`Lambder: the refusal "${code}" declares data that is not a zod schema.`);
213
+ }
214
+ const status = declaration.status;
215
+ if (status !== undefined && (typeof status !== "number" || !Number.isInteger(status) || status < 200 || status >= 500 || status === 422)) {
216
+ throw new Error(`Lambder: the refusal "${code}" declares the status ${String(status)}. A declared refusal leaves with a status from 200 to 499 other than 422: a reader files a 5xx as a server failure and a 422 as an input validation failure.`);
217
+ }
218
+ if (declaration.notAuthorized !== undefined && declaration.notAuthorized !== true) {
219
+ throw new Error(`Lambder: the refusal "${code}" declares notAuthorized as ${String(declaration.notAuthorized)}; write true, or leave it off.`);
220
+ }
221
+ }
222
+ return new Map(entries);
223
+ };
@@ -16,10 +16,19 @@ export type LambderApiSignatureEntry = {
16
16
  * The digest of an endpoint's client-facing shape: its name and mode, its
17
17
  * input and output schemas as JSON Schema, every guard it declares with the
18
18
  * schema that guard validates (the guardInput the client sends separately,
19
- * or the apiInput slice of the payload), and whether it demands an
20
- * idempotency key. Anything else (rate limits, a guard's parameter, the
21
- * handler) changes nothing for a client and is left out, so changing it never
22
- * forces a reload.
19
+ * or the apiInput slice of the payload), whether it demands an idempotency
20
+ * key, and every refusal code it may send with its data's schema, its status
21
+ * and its notAuthorized flag. Anything
22
+ * else (rate limits, a guard's parameter, the handler) changes nothing for a
23
+ * client and is left out, so changing it never forces a reload.
24
+ *
25
+ * The refusal codes count in full: a client's refusal type lists exactly the
26
+ * codes the endpoint declares, so one built before a code was added would be
27
+ * handed a code its types say cannot arrive. Adding a code, or changing a
28
+ * code's data, reloads that endpoint's clients. A code's data is received,
29
+ * so it is digested in the output position, where an extensibleEnum's values
30
+ * leave the digest. Its status and flag count too: a caller reads the one
31
+ * and routes on the other.
23
32
  *
24
33
  * Schemas are hashed as built, descriptions and titles included, so a client
25
34
  * built against a different one reloads once. The exception is an output
@@ -54,10 +54,19 @@ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasO
54
54
  * The digest of an endpoint's client-facing shape: its name and mode, its
55
55
  * input and output schemas as JSON Schema, every guard it declares with the
56
56
  * schema that guard validates (the guardInput the client sends separately,
57
- * or the apiInput slice of the payload), and whether it demands an
58
- * idempotency key. Anything else (rate limits, a guard's parameter, the
59
- * handler) changes nothing for a client and is left out, so changing it never
60
- * forces a reload.
57
+ * or the apiInput slice of the payload), whether it demands an idempotency
58
+ * key, and every refusal code it may send with its data's schema, its status
59
+ * and its notAuthorized flag. Anything
60
+ * else (rate limits, a guard's parameter, the handler) changes nothing for a
61
+ * client and is left out, so changing it never forces a reload.
62
+ *
63
+ * The refusal codes count in full: a client's refusal type lists exactly the
64
+ * codes the endpoint declares, so one built before a code was added would be
65
+ * handed a code its types say cannot arrive. Adding a code, or changing a
66
+ * code's data, reloads that endpoint's clients. A code's data is received,
67
+ * so it is digested in the output position, where an extensibleEnum's values
68
+ * leave the digest. Its status and flag count too: a caller reads the one
69
+ * and routes on the other.
61
70
  *
62
71
  * Schemas are hashed as built, descriptions and titles included, so a client
63
72
  * built against a different one reloads once. The exception is an output
@@ -75,6 +84,13 @@ export const apiSignatureOf = async (definition, guards) => {
75
84
  }];
76
85
  });
77
86
  guardShapes.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
87
+ const refusalShapes = [...(definition.refusals?.codes ?? new Map()).entries()]
88
+ .map(([code, declaration]) => [code, {
89
+ data: jsonSchemaOf(declaration.data ? declaration.schema : undefined, "output"),
90
+ status: declaration.status ?? 200,
91
+ notAuthorized: declaration.notAuthorized === true,
92
+ }]);
93
+ refusalShapes.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
78
94
  const description = {
79
95
  name: definition.name,
80
96
  mode: definition.mode,
@@ -82,6 +98,7 @@ export const apiSignatureOf = async (definition, guards) => {
82
98
  output: jsonSchemaOf(definition.output, "output"),
83
99
  guards: guardShapes,
84
100
  idempotency: definition.idempotency !== undefined && definition.idempotency !== false,
101
+ refusals: refusalShapes,
85
102
  };
86
103
  const hex = await sha256HexOf(canonicalJson(description));
87
104
  return hex.slice(0, API_SIGNATURE_HEX_LENGTH);
@@ -3,9 +3,11 @@ import { resolve } from "path";
3
3
  import { headerLinesOf, loadApiOptionEntries, nameChangesOf, readGeneratedTable, settleGeneratedFile, tableStatementLines, } from "./generatedTables.js";
4
4
  /*
5
5
  * The declared options of an app's APIs as a generated module of plain data:
6
- * every API's mode and its guards, rateLimit and idempotency options as
7
- * written, every rate-limit policy less its key handler, and every guard's
8
- * input mode, session flag and place in the call. The contract already
6
+ * every API's mode and its guards, rateLimit, idempotency and refusals
7
+ * options as written, every rate-limit policy less its key handler, and
8
+ * every guard's input mode, session flag, place in the call and refusal
9
+ * codes. The refusal vocabulary itself is not written: it is shared code,
10
+ * which the mock declares as the server does. The contract already
9
11
  * carries the options as types; this is the same fact as a value, for the
10
12
  * code that decides something at runtime with it: a mock restating the
11
13
  * server's declarations, a test walking the public surface. Neither imports
@@ -29,9 +31,9 @@ const DEFAULT_HEADER = [
29
31
  ].join("\n");
30
32
  /** The three tables the module exports, in the order they are written. */
31
33
  const TABLES = [
32
- { name: "apiOptions", key: "apis", type: "LambderApiOptionEntry", doc: "Every API the server registers: its mode and its guards, rateLimit and idempotency options as written." },
34
+ { name: "apiOptions", key: "apis", type: "LambderApiOptionEntry", doc: "Every API the server registers: its mode and its guards, rateLimit, idempotency and refusals options as written." },
33
35
  { name: "rateLimitPolicies", key: "rateLimitPolicies", type: "LambderRateLimitPolicyEntry", doc: "Every rate-limit policy the server declares, its key reduced to what it counts: an address, a session, or a key the app derives (\"custom\")." },
34
- { name: "guardDeclarations", key: "guards", type: "LambderGuardDeclarationEntry", doc: "Every guard the server declares: how it is fed, whether it needs a session, and when it runs." },
36
+ { name: "guardDeclarations", key: "guards", type: "LambderGuardDeclarationEntry", doc: "Every guard the server declares: how it is fed, whether it needs a session, when it runs, and the refusal codes it adds to the APIs declaring it." },
35
37
  ];
36
38
  /** The tables a generated file holds, read back as data, or null for a file that does not hold all three as written (never written, or rewritten by hand). */
37
39
  export const readOptionTables = (contents) => {
@@ -1,6 +1,6 @@
1
- import type { LambderAppRefusalMessage } from '../shared/wire/LambderApiRefusal.js';
1
+ import type { LambderUncheckedRefusalMessage } from '../shared/wire/LambderApiRefusal.js';
2
2
  import { type LambderRequestCompressionOption } from '../shared/wire/LambderRequestPayload.js';
3
- import type { LambderApiContractShape } from '../shared/wire/LambderApiContract.js';
3
+ import type { LambderApiContractShape, LambderContractAnyRefusalMessage, LambderContractRefusalMessage } from '../shared/wire/LambderApiContract.js';
4
4
  import { type LambderApiOutcome, type LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
5
5
  import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from '../shared/wire/LambderCallOptions.js';
6
6
  import { type LambderApiTransport } from '../shared/transport/LambderApiTransport.js';
@@ -29,18 +29,25 @@ type FetchEndEventHandler = (params: {
29
29
  }) => void | Promise<void>;
30
30
  type ErrorHandler = (err: Error) => void | Promise<void>;
31
31
  type ValidationErrorHandler = (zodError: LambderValidationError) => (void | false) | Promise<(void | false)>;
32
- /** Handed the refusal as its message object, a plain-string errorMessage having been read as one (refusalMessageOf). */
33
- type ErrorMessageHandler = (message: LambderAppRefusalMessage) => void | Promise<void>;
32
+ /**
33
+ * Handed the refusal as its message object, a plain-string refusal
34
+ * having been read as one (refusalMessageOf). TMessage is what the calls it
35
+ * hears can refuse with: one endpoint's declared codes on a per-call
36
+ * override, every endpoint's on the constructor's.
37
+ */
38
+ type RefusalHandler<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = (message: TMessage) => void | Promise<void>;
34
39
  /** The logListHandler option: an answer's logList, success or failure, when it has entries. The invoke caller's onLogList, for a browser. */
35
40
  export type LambderLogListHandler = (apiName: string, logList: unknown[]) => void | Promise<void>;
36
41
  /**
37
42
  * Per-call options: the request extras both callers share (see
38
43
  * LambderSharedCallOptions) plus an override for every constructor handler.
44
+ * TMessage is the endpoint's refusal message, which the per-call
45
+ * refusalHandler is handed.
39
46
  */
40
- export type LambderCallOptions = LambderSharedCallOptions & {
47
+ export type LambderCallOptions<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderSharedCallOptions & {
41
48
  versionExpiredHandler?: NotifyHandler;
42
49
  sessionExpiredHandler?: NotifyHandler;
43
- errorMessageHandler?: ErrorMessageHandler;
50
+ refusalHandler?: RefusalHandler<TMessage>;
44
51
  apiInputValidationErrorHandler?: ValidationErrorHandler;
45
52
  notAuthorizedHandler?: NotifyHandler;
46
53
  errorHandler?: ErrorHandler;
@@ -48,7 +55,7 @@ export type LambderCallOptions = LambderSharedCallOptions & {
48
55
  fetchStartedHandler?: FetchStartEventHandler;
49
56
  fetchEndedHandler?: FetchEndEventHandler;
50
57
  };
51
- type LambderCallerBaseOptions = {
58
+ type LambderCallerBaseOptions<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = {
52
59
  apiPath: string;
53
60
  /** Sent with every call as `version`, informational: the server stamps its own on every answer. */
54
61
  apiVersion?: string;
@@ -71,7 +78,8 @@ type LambderCallerBaseOptions = {
71
78
  timeoutMs?: number;
72
79
  versionExpiredHandler?: NotifyHandler;
73
80
  sessionExpiredHandler?: NotifyHandler;
74
- errorMessageHandler?: ErrorMessageHandler;
81
+ /** Handed every refusal a call of this caller comes back with, typed with every code the contract declares. */
82
+ refusalHandler?: RefusalHandler<TMessage>;
75
83
  notAuthorizedHandler?: NotifyHandler;
76
84
  errorHandler?: ErrorHandler;
77
85
  /** Receives each answer's logList, with the API name. Default: console.log with a `[lambder]` prefix, one line per entry. */
@@ -100,7 +108,7 @@ type LambderCallerBaseOptions = {
100
108
  transport?: LambderApiTransport;
101
109
  };
102
110
  /** Constructor options: the base options plus guardInputsProvider, mandatory once TProvided names guards. */
103
- export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions & LambderGuardInputsProviderOption<TContract, TProvided>;
111
+ export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions<LambderContractAnyRefusalMessage<TContract>> & LambderGuardInputsProviderOption<TContract, TProvided>;
104
112
  /**
105
113
  * @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
106
114
  * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
@@ -116,7 +124,7 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
116
124
  get isLoading(): boolean;
117
125
  private versionExpiredHandler?;
118
126
  private sessionExpiredHandler?;
119
- private errorMessageHandler?;
127
+ private refusalHandler?;
120
128
  private notAuthorizedHandler?;
121
129
  private errorHandler?;
122
130
  private apiInputValidationErrorHandler?;
@@ -138,18 +146,21 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
138
146
  private dispatch;
139
147
  /**
140
148
  * Full-fidelity call: resolves to a discriminated LambderApiOutcome
141
- * instead of collapsing every failure to undefined. Never throws.
149
+ * instead of collapsing every failure to undefined. Never throws. A
150
+ * success's payload is the endpoint's output, and a refusal's
151
+ * refusal narrows on the codes the endpoint declares.
142
152
  *
143
153
  * The output is computed from the contract in the return type rather than
144
154
  * taken as a type parameter, so a call site cannot replace it by
145
155
  * annotating what it assigns to.
146
156
  */
147
- apiOutcome<TApiName extends keyof TContract & string = string>(apiName: TApiName, ...rest: LambderCallArgs<TContract, TApiName, TProvidedGuards, LambderCallOptions>): Promise<LambderApiOutcome<LambderContractOutputOf<TContract, TApiName>>>;
157
+ apiOutcome<TApiName extends keyof TContract & string = string>(apiName: TApiName, ...rest: LambderCallArgs<TContract, TApiName, TProvidedGuards, LambderCallOptions<LambderContractRefusalMessage<TContract, TApiName>>>): Promise<LambderApiOutcome<LambderContractOutputOf<TContract, TApiName>, LambderContractRefusalMessage<TContract, TApiName>>>;
148
158
  /**
149
- * The payload on success, `undefined` on every failure except a
150
- * structured refusal, which hands back whatever payload the envelope
151
- * carried (usually null). Neither is distinguishable from a legitimately
152
- * null or undefined payload: use apiOutcome() when that matters.
159
+ * The endpoint's output on success, `undefined` on every failure. An
160
+ * output is always an object or an array, so the result is truthy exactly
161
+ * when the call succeeded; the handlers configured on the caller have
162
+ * already been told why it did not. Use apiOutcome() to branch on the
163
+ * reason at the call site.
153
164
  */
154
- api<TApiName extends keyof TContract & string = string>(apiName: TApiName, ...rest: LambderCallArgs<TContract, TApiName, TProvidedGuards, LambderCallOptions>): Promise<LambderContractOutputOf<TContract, TApiName> | null | undefined>;
165
+ api<TApiName extends keyof TContract & string = string>(apiName: TApiName, ...rest: LambderCallArgs<TContract, TApiName, TProvidedGuards, LambderCallOptions<LambderContractRefusalMessage<TContract, TApiName>>>): Promise<LambderContractOutputOf<TContract, TApiName> | undefined>;
155
166
  }