@beignet/core 0.0.52 → 0.0.54

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 (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +116 -2
  3. package/dist/broadcasting/client.d.ts +38 -0
  4. package/dist/broadcasting/client.d.ts.map +1 -0
  5. package/dist/broadcasting/client.js +519 -0
  6. package/dist/broadcasting/client.js.map +1 -0
  7. package/dist/broadcasting/index.d.ts +43 -0
  8. package/dist/broadcasting/index.d.ts.map +1 -0
  9. package/dist/broadcasting/index.js +87 -0
  10. package/dist/broadcasting/index.js.map +1 -0
  11. package/dist/broadcasting/server.d.ts +83 -0
  12. package/dist/broadcasting/server.d.ts.map +1 -0
  13. package/dist/broadcasting/server.js +213 -0
  14. package/dist/broadcasting/server.js.map +1 -0
  15. package/dist/encryption/index.d.ts +40 -0
  16. package/dist/encryption/index.d.ts.map +1 -0
  17. package/dist/encryption/index.js +134 -0
  18. package/dist/encryption/index.js.map +1 -0
  19. package/dist/notifications/index.d.ts +14 -0
  20. package/dist/notifications/index.d.ts.map +1 -1
  21. package/dist/notifications/index.js +17 -0
  22. package/dist/notifications/index.js.map +1 -1
  23. package/dist/ports/redaction.d.ts +1 -1
  24. package/dist/ports/redaction.d.ts.map +1 -1
  25. package/dist/ports/redaction.js +3 -0
  26. package/dist/ports/redaction.js.map +1 -1
  27. package/dist/server/request-executor.d.ts.map +1 -1
  28. package/dist/server/request-executor.js +13 -0
  29. package/dist/server/request-executor.js.map +1 -1
  30. package/dist/server/server.d.ts +4 -2
  31. package/dist/server/server.d.ts.map +1 -1
  32. package/dist/server/server.js +1 -1
  33. package/dist/server/server.js.map +1 -1
  34. package/dist/server/use-case-route.d.ts +82 -17
  35. package/dist/server/use-case-route.d.ts.map +1 -1
  36. package/dist/server/use-case-route.js +40 -4
  37. package/dist/server/use-case-route.js.map +1 -1
  38. package/package.json +17 -1
  39. package/skills/app-architecture/SKILL.md +48 -1
  40. package/src/broadcasting/client.ts +714 -0
  41. package/src/broadcasting/index.ts +173 -0
  42. package/src/broadcasting/server.ts +352 -0
  43. package/src/encryption/index.ts +198 -0
  44. package/src/notifications/index.ts +32 -0
  45. package/src/ports/redaction.ts +3 -0
  46. package/src/server/request-executor.ts +19 -0
  47. package/src/server/server.ts +8 -3
  48. package/src/server/use-case-route.ts +234 -27
@@ -0,0 +1,198 @@
1
+ import "../server-only.js";
2
+
3
+ /** Non-secret, authenticated metadata. Supply the same expected context on reads. */
4
+ export type EncryptionContext = Readonly<Record<string, string>>;
5
+
6
+ /** Options shared by string encryption and decryption. */
7
+ export interface EncryptionValueOptions {
8
+ /** Plaintext for encrypt; the complete encrypted value for decrypt. */
9
+ value: string;
10
+ /** Bind the value to its purpose, tenant, or record. This is not authorization. */
11
+ context?: EncryptionContext;
12
+ }
13
+
14
+ /** Server-side authenticated string encryption. */
15
+ export interface EncryptionPort {
16
+ /** Encrypt a string; persist the complete returned value. */
17
+ encrypt(options: EncryptionValueOptions): Promise<string>;
18
+ /** Authenticate and decrypt a value with the same expected context. */
19
+ decrypt(options: EncryptionValueOptions): Promise<string>;
20
+ }
21
+
22
+ /** Configuration for the built-in AES-256-GCM implementation. */
23
+ export interface CreateEncryptionOptions {
24
+ /** A base64: prefixed, canonical Base64 encoding of 32 random bytes. */
25
+ key: string;
26
+ /** Decryption-only keys in the same format. New writes always use key. */
27
+ previousKeys?: readonly string[];
28
+ }
29
+
30
+ /** Malformed, unauthenticated, or undecryptable ciphertext. Contains no input. */
31
+ export class EncryptionDecryptionError extends Error {
32
+ /** Stable error name, independent of the decryption failure's cause. */
33
+ readonly name = "EncryptionDecryptionError";
34
+
35
+ constructor() {
36
+ super("Unable to decrypt encrypted value.");
37
+ }
38
+ }
39
+
40
+ const envelopePrefix = "beignet:enc:v1:";
41
+ const ivLength = 12;
42
+ const tagLength = 16;
43
+ const encoder = new TextEncoder();
44
+ const decoder = new TextDecoder("utf-8", { fatal: true });
45
+
46
+ function encodeBase64(bytes: Uint8Array): string {
47
+ // Bound each spread so large values do not overflow the argument stack.
48
+ const chunks: string[] = [];
49
+ for (let offset = 0; offset < bytes.length; offset += 8192) {
50
+ chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 8192)));
51
+ }
52
+ return btoa(chunks.join(""));
53
+ }
54
+
55
+ function decodeBase64(value: string): Uint8Array<ArrayBuffer> {
56
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
57
+ throw new Error("Invalid Base64 encoding.");
58
+ }
59
+ const bytes = Uint8Array.from(atob(value), (character) =>
60
+ character.charCodeAt(0),
61
+ );
62
+ if (encodeBase64(bytes) !== value)
63
+ throw new Error("Noncanonical Base64 encoding.");
64
+ return bytes;
65
+ }
66
+
67
+ function decodeKey(value: string, label: string): Uint8Array<ArrayBuffer> {
68
+ try {
69
+ if (
70
+ typeof value !== "string" ||
71
+ value.length !== 51 ||
72
+ !value.startsWith("base64:")
73
+ )
74
+ throw new Error();
75
+ const bytes = decodeBase64(value.slice(7));
76
+ if (bytes.length !== 32) throw new Error();
77
+ return bytes;
78
+ } catch {
79
+ throw new TypeError(
80
+ `${label} must be base64: followed by the Base64 encoding of 32 bytes. Use generateEncryptionKey().`,
81
+ );
82
+ }
83
+ }
84
+
85
+ function authenticatedData(
86
+ context: EncryptionContext | undefined,
87
+ ): Uint8Array<ArrayBuffer> {
88
+ if (
89
+ context !== undefined &&
90
+ (context === null ||
91
+ typeof context !== "object" ||
92
+ (Object.getPrototypeOf(context) !== Object.prototype &&
93
+ Object.getPrototypeOf(context) !== null))
94
+ ) {
95
+ throw new TypeError(
96
+ "Encryption context must be a plain record of strings.",
97
+ );
98
+ }
99
+ const entries = Object.entries(context ?? {}).sort(([left], [right]) =>
100
+ left < right ? -1 : left > right ? 1 : 0,
101
+ );
102
+ if (entries.some(([, value]) => typeof value !== "string")) {
103
+ throw new TypeError("Encryption context values must be strings.");
104
+ }
105
+ return encoder.encode(JSON.stringify([envelopePrefix, entries]));
106
+ }
107
+
108
+ /** Generate a new 256-bit key. Store it in a server secret store; never log it. */
109
+ export function generateEncryptionKey(): string {
110
+ return `base64:${encodeBase64(crypto.getRandomValues(new Uint8Array(32)))}`;
111
+ }
112
+
113
+ /**
114
+ * Create authenticated string encryption using Web Crypto AES-256-GCM.
115
+ * Configuration is validated synchronously. Each write uses a random 96-bit IV
116
+ * and a 128-bit authentication tag. Previous keys are used only for reads.
117
+ * No environment variables are read and no plaintext, keys, or context are logged.
118
+ */
119
+ export function createEncryption(
120
+ options: CreateEncryptionOptions,
121
+ ): EncryptionPort {
122
+ const current = decodeKey(options?.key, "Encryption key");
123
+ if (
124
+ options.previousKeys !== undefined &&
125
+ !Array.isArray(options.previousKeys)
126
+ ) {
127
+ throw new TypeError("Encryption previousKeys must be an array of keys.");
128
+ }
129
+ const rawKeys = [
130
+ current,
131
+ ...Array.from(options.previousKeys ?? [], (key, index) =>
132
+ decodeKey(key, `Encryption previousKeys[${index}]`),
133
+ ),
134
+ ];
135
+ const importedKeys: Array<Promise<CryptoKey> | undefined> = [];
136
+ const getKey = (index: number): Promise<CryptoKey> => {
137
+ const existing = importedKeys[index];
138
+ if (existing) return existing;
139
+ const imported = crypto.subtle.importKey(
140
+ "raw",
141
+ rawKeys[index],
142
+ "AES-GCM",
143
+ false,
144
+ ["encrypt", "decrypt"],
145
+ );
146
+ importedKeys[index] = imported;
147
+ return imported;
148
+ };
149
+
150
+ return {
151
+ async encrypt({ value, context }) {
152
+ if (typeof value !== "string")
153
+ throw new TypeError("Encryption value must be a string.");
154
+ const additionalData = authenticatedData(context);
155
+ const iv = crypto.getRandomValues(new Uint8Array(ivLength));
156
+ const ciphertext = new Uint8Array(
157
+ await crypto.subtle.encrypt(
158
+ { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
159
+ await getKey(0),
160
+ // JSON preserves all JavaScript strings, including lone UTF-16 surrogates.
161
+ encoder.encode(JSON.stringify(value)),
162
+ ),
163
+ );
164
+ const envelope = new Uint8Array(iv.length + ciphertext.length);
165
+ envelope.set(iv);
166
+ envelope.set(ciphertext, iv.length);
167
+ return `${envelopePrefix}${encodeBase64(envelope)}`;
168
+ },
169
+ async decrypt(options) {
170
+ try {
171
+ const { value, context } = options;
172
+ if (typeof value !== "string" || !value.startsWith(envelopePrefix))
173
+ throw new Error();
174
+ const envelope = decodeBase64(value.slice(envelopePrefix.length));
175
+ if (envelope.length < ivLength + tagLength + 2) throw new Error();
176
+ const additionalData = authenticatedData(context);
177
+ const iv = envelope.slice(0, ivLength);
178
+ const ciphertext = envelope.slice(ivLength);
179
+ for (let index = 0; index < rawKeys.length; index++) {
180
+ try {
181
+ const plaintext = await crypto.subtle.decrypt(
182
+ { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
183
+ await getKey(index),
184
+ ciphertext,
185
+ );
186
+ const parsed: unknown = JSON.parse(decoder.decode(plaintext));
187
+ if (typeof parsed === "string") return parsed;
188
+ } catch {
189
+ // Try only the configured key ring; never return unauthenticated data.
190
+ }
191
+ }
192
+ } catch {
193
+ // All decryption failures share one non-sensitive public error.
194
+ }
195
+ throw new EncryptionDecryptionError();
196
+ },
197
+ };
198
+ }
@@ -1,4 +1,9 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { ChannelDefinition } from "../broadcasting/index.js";
3
+ import type {
4
+ BroadcastPort,
5
+ BroadcastPublication,
6
+ } from "../broadcasting/server.js";
2
7
  import {
3
8
  createJobs,
4
9
  type JobDef,
@@ -1345,6 +1350,33 @@ export function defineMailNotificationChannel<
1345
1350
  };
1346
1351
  }
1347
1352
 
1353
+ /** Deliver a typed browser hint through the existing notification delivery pipeline.
1354
+ * `sent` means provider acceptance, including when the recipient is offline.
1355
+ * Return undefined to skip. Persistent inbox writes and their outbox ordering belong to the app.
1356
+ */
1357
+ export function defineBroadcastNotificationChannel<
1358
+ C extends ChannelDefinition,
1359
+ Payload extends StandardSchema,
1360
+ Ctx extends { ports: { broadcast: BroadcastPort } },
1361
+ >(options: {
1362
+ channel: C;
1363
+ render: (
1364
+ args: NotificationChannelHandleArgs<Payload, Ctx>,
1365
+ ) => MaybePromise<BroadcastPublication<NoInfer<C>> | undefined>;
1366
+ }): NotificationChannelHandler<Payload, Ctx> {
1367
+ return async (args) => {
1368
+ const publication = await options.render(args);
1369
+ if (!publication)
1370
+ return {
1371
+ channel: args.channel,
1372
+ status: "skipped",
1373
+ reason: "No broadcast was returned.",
1374
+ };
1375
+ await args.ctx.ports.broadcast.publish(options.channel, publication);
1376
+ return { channel: args.channel, status: "sent" };
1377
+ };
1378
+ }
1379
+
1348
1380
  /**
1349
1381
  * Create an in-memory notification port for tests and examples.
1350
1382
  *
@@ -28,6 +28,9 @@ export const DEFAULT_SENSITIVE_KEYS = [
28
28
  "accesskey",
29
29
  "jwt",
30
30
  "session",
31
+ "x-beignet-broadcast-client",
32
+ "broadcastorigin",
33
+ "excludeorigin",
31
34
  ] as const;
32
35
 
33
36
  /**
@@ -83,6 +83,7 @@ import {
83
83
  } from "./route-matching.js";
84
84
  import type { TrustedRequestInfo } from "./trusted-proxy.js";
85
85
  import { InvalidRequestUrlError } from "./trusted-proxy-internal.js";
86
+ import { UseCaseRouteInputValidationError } from "./use-case-route.js";
86
87
 
87
88
  function withoutHeadResponseBody(
88
89
  response: HttpResponse,
@@ -282,6 +283,24 @@ export function createRequestExecutor<
282
283
  };
283
284
  }
284
285
 
286
+ if (currentError instanceof UseCaseRouteInputValidationError) {
287
+ return {
288
+ ctx,
289
+ response: errorResponse(
290
+ 500,
291
+ currentError.code,
292
+ currentError.message,
293
+ {
294
+ contractName: currentError.contractName,
295
+ useCaseName: currentError.useCaseName,
296
+ location: "useCaseInput",
297
+ },
298
+ ),
299
+ error: currentError,
300
+ owner: "framework",
301
+ };
302
+ }
303
+
285
304
  if (currentError instanceof InvalidRequestUrlError) {
286
305
  return {
287
306
  ctx,
@@ -29,6 +29,7 @@ import { createContextFinalizer, resolveServerContext } from "./context.js";
29
29
  import type { ContractLike, ResolveContract } from "./contract-like.js";
30
30
  import { resolveContract } from "./contract-like.js";
31
31
  import type {
32
+ AddedCtxFromHooks,
32
33
  Handler,
33
34
  HttpRequestLike,
34
35
  HttpResponse,
@@ -312,7 +313,11 @@ export interface ServerInstance<
312
313
  * are not added to the route registry; mount the returned handler at the
313
314
  * route's own path.
314
315
  */
315
- rawRoute: (init: RawRouteInit) => RawRouteBuilder<Ctx>;
316
+ rawRoute: <
317
+ const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
318
+ >(
319
+ init: RawRouteInit & { hooks?: Hooks },
320
+ ) => RawRouteBuilder<Ctx & AddedCtxFromHooks<Hooks>>;
316
321
  /**
317
322
  * Build a fully assembled request context from a framework-neutral request.
318
323
  *
@@ -960,9 +965,9 @@ export async function createServer<
960
965
  finalPorts,
961
966
  contextRuntime,
962
967
  rawRouteContract(init),
963
- fn,
968
+ fn as Handler<Ctx, HttpContractConfig>,
964
969
  hooks,
965
- [],
970
+ (init.hooks ?? []) as readonly RouteHook<unknown, object>[],
966
971
  { rawRoute: true },
967
972
  );
968
973
  // The adapter owns routing for raw routes — the handler is mounted
@@ -149,20 +149,143 @@ export type UseCaseRouteInputParts<C extends HttpContractConfig> = {
149
149
  body: InferBody<C>;
150
150
  };
151
151
 
152
+ type SegmentPathParam<Segment extends string> = Segment extends `:${infer Name}`
153
+ ? Name
154
+ : Segment extends `[${infer Name}]`
155
+ ? Name
156
+ : never;
157
+
158
+ type PathParamNames<Path extends string> = string extends Path
159
+ ? never
160
+ : Path extends `${infer Segment}/${infer Rest}`
161
+ ? SegmentPathParam<Segment> | PathParamNames<Rest>
162
+ : SegmentPathParam<Path>;
163
+
164
+ type EmptyBinderInput = Record<never, never>;
165
+
166
+ declare const UNMERGEABLE_BINDER_INPUT: unique symbol;
167
+
168
+ type UnmergeableBinderInput = {
169
+ readonly [UNMERGEABLE_BINDER_INPUT]: true;
170
+ };
171
+
172
+ type IsAny<T> = 0 extends 1 & T ? true : false;
173
+
174
+ type BinderObject<T> =
175
+ IsAny<T> extends true
176
+ ? UnmergeableBinderInput
177
+ : [T] extends [object]
178
+ ? [Extract<T, readonly unknown[]>] extends [never]
179
+ ? T
180
+ : UnmergeableBinderInput
181
+ : UnmergeableBinderInput;
182
+
183
+ type MergeBinderObjects<LowerPrecedence, HigherPrecedence> = [
184
+ BinderObject<LowerPrecedence>,
185
+ ] extends [UnmergeableBinderInput]
186
+ ? UnmergeableBinderInput
187
+ : [BinderObject<HigherPrecedence>] extends [UnmergeableBinderInput]
188
+ ? UnmergeableBinderInput
189
+ : Omit<
190
+ BinderObject<LowerPrecedence>,
191
+ keyof BinderObject<HigherPrecedence>
192
+ > &
193
+ BinderObject<HigherPrecedence>;
194
+
195
+ type InferredPathInput<C extends HttpContractConfig> = string extends C["path"]
196
+ ? UnmergeableBinderInput
197
+ : [PathParamNames<C["path"]>] extends [never]
198
+ ? EmptyBinderInput
199
+ : { [K in PathParamNames<C["path"]>]: string };
200
+
201
+ type BinderPathInput<C extends HttpContractConfig> =
202
+ C["pathParams"] extends StandardSchemaV1
203
+ ? InferOutput<C["pathParams"]>
204
+ : InferredPathInput<C>;
205
+
206
+ type BinderQueryInput<C extends HttpContractConfig> =
207
+ C["query"] extends StandardSchemaV1
208
+ ? InferOutput<C["query"]>
209
+ : EmptyBinderInput;
210
+
211
+ type BinderBodyInput<C extends HttpContractConfig> =
212
+ C["body"] extends StandardSchemaV1
213
+ ? InferOutput<C["body"]>
214
+ : EmptyBinderInput;
215
+
216
+ type HasPathSchema<C extends HttpContractConfig> =
217
+ C["pathParams"] extends StandardSchemaV1 ? true : false;
218
+
219
+ type HasQuerySchema<C extends HttpContractConfig> =
220
+ C["query"] extends StandardSchemaV1 ? true : false;
221
+
222
+ type HasBodySchema<C extends HttpContractConfig> =
223
+ C["body"] extends StandardSchemaV1 ? true : false;
224
+
225
+ type HasInferredPathInput<C extends HttpContractConfig> =
226
+ string extends C["path"]
227
+ ? true
228
+ : [PathParamNames<C["path"]>] extends [never]
229
+ ? false
230
+ : true;
231
+
232
+ type MergedBinderInput<C extends HttpContractConfig> = MergeBinderObjects<
233
+ MergeBinderObjects<BinderQueryInput<C>, BinderBodyInput<C>>,
234
+ BinderPathInput<C>
235
+ >;
236
+
237
+ /**
238
+ * Input produced when a binder route omits an explicit `input` mapper.
239
+ *
240
+ * A sole declared request schema passes through unchanged when the literal
241
+ * path has no additional inferred parameters. Every other supported default
242
+ * binding merges object inputs with path over body over query precedence.
243
+ */
244
+ type DefaultBinderRouteInput<C extends HttpContractConfig> =
245
+ HasPathSchema<C> extends true
246
+ ? HasQuerySchema<C> extends true
247
+ ? MergedBinderInput<C>
248
+ : HasBodySchema<C> extends true
249
+ ? MergedBinderInput<C>
250
+ : BinderPathInput<C>
251
+ : HasQuerySchema<C> extends true
252
+ ? HasBodySchema<C> extends true
253
+ ? MergedBinderInput<C>
254
+ : HasInferredPathInput<C> extends true
255
+ ? MergedBinderInput<C>
256
+ : BinderQueryInput<C>
257
+ : HasBodySchema<C> extends true
258
+ ? HasInferredPathInput<C> extends true
259
+ ? MergedBinderInput<C>
260
+ : BinderBodyInput<C>
261
+ : BinderPathInput<C>;
262
+
263
+ type UseCaseRouteInputMode = "default" | "mapped";
264
+
152
265
  /**
153
266
  * Constraint that checks a use case against the route that binds it.
154
267
  *
155
268
  * Produces a readable branded mismatch object on the `useCase` property when
156
- * the use case requires a context the server does not provide, or when its
157
- * output does not match the contract's declared success response schema.
269
+ * the use case requires a context the server does not provide, when its
270
+ * output does not match the contract's declared success response schema, or
271
+ * when the default binder input does not satisfy the use case input.
158
272
  */
159
- export type UseCaseFitsRoute<Ctx, C extends HttpContractConfig, UC> = [
273
+ export type UseCaseFitsRoute<
160
274
  Ctx,
161
- ] extends [UseCaseRouteCtx<UC>]
275
+ C extends HttpContractConfig,
276
+ UC,
277
+ InputMode extends UseCaseRouteInputMode = "default",
278
+ > = [Ctx] extends [UseCaseRouteCtx<UC>]
162
279
  ? [UseCaseRouteOutput<UC>] extends [
163
280
  SuccessBodyFromKeys<C["responses"], Success2xxKeys<C["responses"]>>,
164
281
  ]
165
- ? unknown
282
+ ? InputMode extends "mapped"
283
+ ? unknown
284
+ : [DefaultBinderRouteInput<C>] extends [UseCaseRouteInput<UC>]
285
+ ? unknown
286
+ : {
287
+ "~beignetError": "default binder input does not match the use case input; add an input mapper";
288
+ }
166
289
  : {
167
290
  "~beignetError": "useCase output does not match the contract's success response schema";
168
291
  }
@@ -185,21 +308,34 @@ type UseCaseRouteShape<
185
308
  * Route-scoped hooks that run after group hooks and before the use case.
186
309
  */
187
310
  hooks?: Hooks;
188
- /**
189
- * Use case bound directly to the contract.
190
- */
191
- useCase: UC & UseCaseFitsRoute<HandlerCtx, C, UC>;
192
- /**
193
- * Map parsed request parts to the use case input.
194
- *
195
- * A sole declared path, query, or body schema is passed through unchanged
196
- * when no additional path, query, or object body values are present.
197
- * Otherwise `defaultBinderInput` merges query, body, and path objects (path
198
- * wins collisions) and never merges headers.
199
- */
200
- input?: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>;
201
311
  handle?: never;
202
- } & BinderStatusOption<C>;
312
+ } & (
313
+ | {
314
+ /**
315
+ * Use case bound directly to the contract. The default binder input
316
+ * must satisfy the use case input type.
317
+ */
318
+ useCase: UC & UseCaseFitsRoute<HandlerCtx, C, UC>;
319
+ input?: never;
320
+ }
321
+ | {
322
+ /**
323
+ * Use case bound directly to the contract through an explicit input
324
+ * mapper.
325
+ */
326
+ useCase: UC & UseCaseFitsRoute<HandlerCtx, C, UC, "mapped">;
327
+ /**
328
+ * Map parsed request parts to the use case input.
329
+ *
330
+ * A sole declared path, query, or body schema is passed through
331
+ * unchanged when no additional path, query, or object body values are
332
+ * present. Otherwise `defaultBinderInput` merges query, body, and path
333
+ * objects (path wins collisions) and never merges headers.
334
+ */
335
+ input: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>;
336
+ }
337
+ ) &
338
+ BinderStatusOption<C>;
203
339
 
204
340
  /**
205
341
  * Route registration that binds a contract directly to a use case.
@@ -271,11 +407,25 @@ export type ValidatedRouteInput<Ctx, E> = E extends {
271
407
  ? {
272
408
  contract: CL;
273
409
  hooks?: HooksOf<E>;
274
- useCase: UC &
275
- UseCaseFitsRoute<Ctx & AddedCtxFromHooks<HooksOf<E>>, C, UC>;
276
- input?: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>;
277
410
  handle?: never;
278
- } & BinderStatusOption<C>
411
+ } & (
412
+ | {
413
+ useCase: UC &
414
+ UseCaseFitsRoute<Ctx & AddedCtxFromHooks<HooksOf<E>>, C, UC>;
415
+ input?: never;
416
+ }
417
+ | {
418
+ useCase: UC &
419
+ UseCaseFitsRoute<
420
+ Ctx & AddedCtxFromHooks<HooksOf<E>>,
421
+ C,
422
+ UC,
423
+ "mapped"
424
+ >;
425
+ input: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>;
426
+ }
427
+ ) &
428
+ BinderStatusOption<C>
279
429
  : unknown
280
430
  : unknown;
281
431
 
@@ -322,6 +472,49 @@ export type RuntimeUseCaseRouteDef = {
322
472
  status?: number;
323
473
  };
324
474
 
475
+ type UseCaseInputValidationFailure = Error & {
476
+ name: "UseCaseValidationError";
477
+ phase: "input";
478
+ useCaseName: string;
479
+ };
480
+
481
+ function isUseCaseInputValidationFailure(
482
+ error: unknown,
483
+ useCaseName: string,
484
+ ): error is UseCaseInputValidationFailure {
485
+ if (!(error instanceof Error)) return false;
486
+ const candidate = error as Partial<UseCaseInputValidationFailure>;
487
+ return (
488
+ candidate.name === "UseCaseValidationError" &&
489
+ candidate.phase === "input" &&
490
+ candidate.useCaseName === useCaseName
491
+ );
492
+ }
493
+
494
+ /**
495
+ * Internal framework error raised when a type-erased binder route produces an
496
+ * input that the bound use case rejects.
497
+ */
498
+ export class UseCaseRouteInputValidationError extends Error {
499
+ readonly code = "USE_CASE_INPUT_VALIDATION_ERROR";
500
+ readonly contractName: string;
501
+ readonly useCaseName: string;
502
+
503
+ constructor(args: {
504
+ contractName: string;
505
+ useCaseName: string;
506
+ cause: UseCaseInputValidationFailure;
507
+ }) {
508
+ super(
509
+ `Default binder input for contract "${args.contractName}" does not satisfy use case "${args.useCaseName}". Add an explicit input mapper.`,
510
+ { cause: args.cause },
511
+ );
512
+ this.name = "UseCaseRouteInputValidationError";
513
+ this.contractName = args.contractName;
514
+ this.useCaseName = args.useCaseName;
515
+ }
516
+ }
517
+
325
518
  function isPlainObject(value: unknown): value is Record<string, unknown> {
326
519
  return typeof value === "object" && value !== null && !Array.isArray(value);
327
520
  }
@@ -458,10 +651,24 @@ export function createUseCaseRouteHandler<Ctx, C extends HttpContractConfig>(
458
651
  : defaultBinderInput(parts);
459
652
  const run = passSingle && trustedRun ? trustedRun : def.useCase.run;
460
653
 
461
- return {
462
- status,
463
- body: await run.call(def.useCase, { ctx, input }),
464
- };
654
+ try {
655
+ return {
656
+ status,
657
+ body: await run.call(def.useCase, { ctx, input }),
658
+ };
659
+ } catch (error) {
660
+ if (
661
+ !def.input &&
662
+ isUseCaseInputValidationFailure(error, def.useCase.name)
663
+ ) {
664
+ throw new UseCaseRouteInputValidationError({
665
+ contractName: contract.name,
666
+ useCaseName: def.useCase.name,
667
+ cause: error,
668
+ });
669
+ }
670
+ throw error;
671
+ }
465
672
  };
466
673
 
467
674
  return {