@ontrails/http 1.0.0-beta.32 → 1.0.0-beta.41

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @ontrails/http
2
2
 
3
+ ## 1.0.0-beta.41
4
+
5
+ ## 1.0.0-beta.40
6
+
7
+ ### Minor Changes
8
+
9
+ - [`5adb995`](https://github.com/outfitter-dev/trails/commit/5adb99551c2dda6190d46cce7f60bb08d63c99aa): Complete the v1 hard cutover from the authored `blaze` field to
10
+ `implementation` across trail contracts, surface projections, tests, examples,
11
+ and public source-analysis helpers. Existing applications must rename authored
12
+ trail behavior fields and direct trail-object access before upgrading.
13
+
14
+ ### Patch Changes
15
+
16
+ - [`9bf592d`](https://github.com/outfitter-dev/trails/commit/9bf592ddba46aa12e3f4e6ffc0f772f7a41ed3df): Declare verified first-party adapter metadata for Drizzle, HTTP/Bun, and Store/Jsonfile so shared adapter checks can dogfood real owner targets.
17
+
18
+ ## 1.0.0-beta.39
19
+
20
+ ### Patch Changes
21
+
22
+ - [`6517f67`](https://github.com/outfitter-dev/trails/commit/6517f67b9dcc9d0ab64b1c7201b5d326905498e4): The OpenAPI projection now documents BlobRef routes as binary responses (`content: { '*/*': { schema: { type: 'string', format: 'binary' } } }`) instead of a JSON data envelope, matching the raw bytes the runtime serves with the blob's declared mimeType. Error responses keep the JSON error envelope. Both the fetch handler and the OpenAPI projection now share one BlobRef output recognition helper.
23
+ - [`ab5c767`](https://github.com/outfitter-dev/trails/commit/ab5c7670446baa89cc10241686d51c16d0215e04): Serve `BlobRef` trail outputs as bytes on HTTP (TRL-1192). When a trail's output schema is `blobRefSchema`, the route handler streams the blob's data with `Content-Type` from its `mimeType` and a `Content-Length` from its `size` — for both `Uint8Array` and `ReadableStream` payloads — instead of wrapping the value in the JSON envelope. Error results keep the JSON error envelope, and non-blob trails are unchanged. Apps no longer need to hand-mount raw-byte routes beside the derived surface.
24
+ - [`b9e82a3`](https://github.com/outfitter-dev/trails/commit/b9e82a33546356c93fbc302fb934a83f19f1c2c5): Webhook ingress v2 (TRL-1194, absorbing TRL-1174 and TRL-1175): store-verified, per-endpoint webhook ingress becomes framework-expressible. `webhook()` accepts dynamic path segments (`path: '/hooks/:endpoint'`) whose values are delivered as envelope fields, opt-in `rawBody: true` delivery (a non-JSON body is no longer a surface-level failure — the trail owns payload interpretation), an allowlisted `headers` list delivered lowercased, and `resources` that make `verify` resource-capable: the HTTP surface resolves the declared resources into a context for the verifier and releases them afterwards, so signature checks can reach stores holding per-endpoint secrets. Envelope-mode ingress responds 202 Accepted; classic static webhooks keep their exact-match, JSON-gated, 200 behavior. Core exports `parseWebhookPathParams`, `matchWebhookPath`, `webhookPathPatternsOverlap`, and `createResources`. The `webhook-route-collision` Warden rule now also flags dynamic patterns that overlap other webhook or derived routes, not just exact method/path duplicates.
25
+
26
+ ## 1.0.0-beta.38
27
+
28
+ ## 1.0.0-beta.37
29
+
30
+ ## 1.0.0-beta.36
31
+
32
+ ## 1.0.0-beta.35
33
+
34
+ ## 1.0.0-beta.34
35
+
36
+ ## 1.0.0-beta.33
37
+
3
38
  ## 1.0.0-beta.32
4
39
 
5
40
  ### Patch Changes
package/README.md CHANGED
@@ -13,7 +13,7 @@ const greet = trail('greet', {
13
13
  input: z.object({ name: z.string().describe('Who to greet') }),
14
14
  output: z.object({ message: z.string() }),
15
15
  intent: 'read',
16
- blaze: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
16
+ implementation: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
17
17
  });
18
18
 
19
19
  const graph = topo('myapp', { greet });
@@ -114,7 +114,7 @@ Trail IDs map to paths: `entity.show` becomes `/entity/show`. Dots become slashe
114
114
 
115
115
  ## Resource resolution
116
116
 
117
- Declared resources on each trail are resolved into the context before the blaze receives input.
117
+ Declared resources on each trail are resolved into the context before the implementation receives input.
118
118
 
119
119
  ## Filtering
120
120
 
@@ -142,7 +142,7 @@ Each route definition produced by `deriveHttpRoutes` includes:
142
142
  | `trailId` | `string` | The trail ID this route was derived from |
143
143
  | `inputSource` | `'query' \| 'body'` | Where to read input |
144
144
  | `trail` | `Trail` | The original trail definition |
145
- | `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the blazed trail |
145
+ | `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the trail |
146
146
 
147
147
  For GET routes on the Hono surface, repeated query keys are passed through as arrays (`?tag=one&tag=two` -> `{ tag: ['one', 'two'] }`) while a single occurrence stays a scalar string. The adapter does not coerce singleton query values into arrays.
148
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/http",
3
- "version": "1.0.0-beta.32",
3
+ "version": "1.0.0-beta.41",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -25,12 +25,17 @@
25
25
  "clean": "rm -rf dist *.tsbuildinfo"
26
26
  },
27
27
  "dependencies": {
28
- "@ontrails/core": "^1.0.0-beta.32"
28
+ "@ontrails/core": "^1.0.0-beta.41"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "zod": "^4.3.5"
32
32
  },
33
33
  "trails": {
34
+ "adapters": {
35
+ "./bun": {
36
+ "target": "http"
37
+ }
38
+ },
34
39
  "adapterTargets": {
35
40
  "http": {
36
41
  "conformance": {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared recognition for BlobRef trail output schemas.
3
+ *
4
+ * The runtime handler (`fetch.ts`) and the OpenAPI projection
5
+ * (`openapi.ts`) must agree on which routes serve raw bytes, so both
6
+ * read the same authored fact: the BlobRef marker meta on the trail's
7
+ * output schema.
8
+ */
9
+
10
+ import { BLOB_REF_SCHEMA_META_KEY } from '@ontrails/core';
11
+
12
+ /**
13
+ * True when a trail output schema carries the BlobRef marker meta — the
14
+ * authored fact that selects byte streaming over the JSON envelope on
15
+ * the HTTP surface and a binary response body in the OpenAPI projection.
16
+ */
17
+ export const isBlobOutputSchema = (output: unknown): boolean => {
18
+ if (typeof output !== 'object' || output === null) {
19
+ return false;
20
+ }
21
+ const maybeMeta = (output as { meta?: () => unknown }).meta;
22
+ if (typeof maybeMeta !== 'function') {
23
+ return false;
24
+ }
25
+ const meta = maybeMeta.call(output);
26
+ return (
27
+ typeof meta === 'object' &&
28
+ meta !== null &&
29
+ (meta as Record<string, unknown>)[BLOB_REF_SCHEMA_META_KEY] === true
30
+ );
31
+ };
package/src/build.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  ValidationError,
13
13
  buildActivationProvenanceTraceAttrs,
14
14
  collectAttachedTypedLayers,
15
+ createResources,
16
+ createTrailContext,
15
17
  deriveSurfaceTrailVersionProjections,
16
18
  executeTrail,
17
19
  filterSurfaceTrails,
@@ -131,7 +133,7 @@ export interface HttpRouteDefinition {
131
133
  | undefined;
132
134
  readonly webhookSource?: WebhookSource | undefined;
133
135
  /**
134
- * Validate input, compose layers, and run the blazed trail.
136
+ * Validate input, compose layers, and run the trail implementation.
135
137
  *
136
138
  * The caller is responsible for parsing raw input from the request and
137
139
  * mapping the Result to an HTTP response. This function is framework-agnostic.
@@ -1038,6 +1040,44 @@ type MergeableWebhookRoute = HttpRouteDefinition & {
1038
1040
  readonly [WEBHOOK_INVALID_RECORDERS]?: readonly WebhookInvalidConsumerRecorder[];
1039
1041
  };
1040
1042
 
1043
+ /**
1044
+ * Wrap a webhook source's `verify` for the route boundary.
1045
+ *
1046
+ * Sources that declare `resources` get a resource-capable context: the
1047
+ * declared resources are resolved (honoring surface overrides and config
1048
+ * values) for the duration of the verification and released afterwards,
1049
+ * so signature checks can reach stores holding per-endpoint secrets.
1050
+ */
1051
+ const createWebhookVerifier =
1052
+ (
1053
+ source: WebhookSource,
1054
+ options: DeriveHttpRoutesOptions
1055
+ ): ((request: WebhookVerifyRequest) => Promise<Result<void, Error>>) =>
1056
+ async (request) => {
1057
+ const declared = source.resources ?? [];
1058
+ if (source.verify === undefined || declared.length === 0) {
1059
+ return await verifyWebhookRequest(source, request);
1060
+ }
1061
+
1062
+ const seed = options.createContext
1063
+ ? await options.createContext()
1064
+ : undefined;
1065
+ const scope = await createResources(
1066
+ { resources: declared },
1067
+ createTrailContext(seed),
1068
+ options.resources,
1069
+ options.configValues
1070
+ );
1071
+ if (scope.isErr()) {
1072
+ return scope;
1073
+ }
1074
+ try {
1075
+ return await verifyWebhookRequest(source, request, scope.value.ctx);
1076
+ } finally {
1077
+ scope.value.release();
1078
+ }
1079
+ };
1080
+
1041
1081
  const buildWebhookRoute = (
1042
1082
  graph: Topo,
1043
1083
  trail: Trail<unknown, unknown, unknown>,
@@ -1089,7 +1129,7 @@ const buildWebhookRoute = (
1089
1129
  ),
1090
1130
  trail,
1091
1131
  trailId: trail.id,
1092
- verifyWebhook: (request) => verifyWebhookRequest(source.value, request),
1132
+ verifyWebhook: createWebhookVerifier(source.value, options),
1093
1133
  ...(versions === undefined ? {} : { versions }),
1094
1134
  webhookSource: source.value,
1095
1135
  };
@@ -1142,6 +1182,20 @@ const hasMatchingWebhookParse = (
1142
1182
  right: HttpRouteDefinition
1143
1183
  ): boolean => left.webhookSource?.parse === right.webhookSource?.parse;
1144
1184
 
1185
+ /**
1186
+ * Envelope facts must agree before merging: the merged route delivers one
1187
+ * envelope shape, so diverging `rawBody`/`headers` declarations would
1188
+ * silently drop a consumer's declared fields.
1189
+ */
1190
+ const hasMatchingWebhookEnvelope = (
1191
+ left: HttpRouteDefinition,
1192
+ right: HttpRouteDefinition
1193
+ ): boolean =>
1194
+ (left.webhookSource?.rawBody === true) ===
1195
+ (right.webhookSource?.rawBody === true) &&
1196
+ JSON.stringify(left.webhookSource?.headers ?? null) ===
1197
+ JSON.stringify(right.webhookSource?.headers ?? null);
1198
+
1145
1199
  const webhookConsumers = (
1146
1200
  route: MergeableWebhookRoute
1147
1201
  ): readonly WebhookConsumerExecute[] | undefined => route[WEBHOOK_CONSUMERS];
@@ -1180,6 +1234,14 @@ const mergeWebhookRoutes = (
1180
1234
  kind: 'parse-mismatch',
1181
1235
  };
1182
1236
  }
1237
+ if (!hasMatchingWebhookEnvelope(existing, route)) {
1238
+ return {
1239
+ error: new ValidationError(
1240
+ `HTTP route collision: trails "${existing.trailId}" and "${route.trailId}" share webhook source "${existing.webhookSource?.id}" on ${route.method} ${route.path} but declare mismatched rawBody/headers envelope facts. Reuse the same WebhookSource object so both consumers receive one envelope shape.`
1241
+ ),
1242
+ kind: 'parse-mismatch',
1243
+ };
1244
+ }
1183
1245
 
1184
1246
  const existingConsumers = webhookConsumers(existing as MergeableWebhookRoute);
1185
1247
  const incomingConsumers = webhookConsumers(route as MergeableWebhookRoute);
package/src/bun.ts CHANGED
@@ -109,7 +109,7 @@ const bodylessHeadResponse = (response: Response): Response =>
109
109
  const caughtErrors = new Map<string, Error>();
110
110
  const caughtErrorInput = z.object({ errorId: z.string() });
111
111
  const caughtErrorTrail = trail('__ontrails.http.bun.error', {
112
- blaze: () =>
112
+ implementation: () =>
113
113
  Result.err(new InternalError('Bun error fallback executed directly')),
114
114
  input: caughtErrorInput,
115
115
  intent: 'read',
package/src/fetch.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  CancelledError,
3
+ isBlobRef,
3
4
  isTrailsError,
5
+ matchWebhookPath,
4
6
  NotFoundError,
7
+ parseWebhookPathParams,
5
8
  projectErrorDiagnostics,
6
9
  projectPublicSurfaceError,
7
10
  ValidationError,
8
11
  } from '@ontrails/core';
9
- import type { Topo } from '@ontrails/core';
12
+ import type { BlobRef, Topo } from '@ontrails/core';
10
13
 
14
+ import { isBlobOutputSchema } from './blob-output.js';
11
15
  import { deriveHttpRoutes } from './build.js';
12
16
  import type { DeriveHttpRoutesOptions, HttpRouteDefinition } from './build.js';
13
17
 
@@ -337,11 +341,41 @@ interface ResultLike {
337
341
  readonly value?: unknown;
338
342
  }
339
343
 
344
+ /**
345
+ * True when the route's trail declares a BlobRef output schema — the
346
+ * authored fact that selects byte streaming over the JSON envelope.
347
+ */
348
+ const rendersBlobOutput = (route: HttpRouteDefinition): boolean =>
349
+ isBlobOutputSchema(route.trail.output);
350
+
351
+ /**
352
+ * Narrow blob bytes for `Response`. `BlobRef.data` is typed `Uint8Array`
353
+ * (ArrayBufferLike backing) while `BodyInit` wants a plain-ArrayBuffer
354
+ * view; blob producers construct views over plain buffers, so narrowing
355
+ * here avoids copying the bytes.
356
+ */
357
+ const blobBody = (data: BlobRef['data']): BodyInit =>
358
+ data instanceof ReadableStream ? data : (data as Uint8Array<ArrayBuffer>);
359
+
360
+ /** Stream a BlobRef's bytes with its declared content type and length. */
361
+ const blobResponse = (blob: BlobRef): Response =>
362
+ new Response(blobBody(blob.data), {
363
+ headers: {
364
+ 'Content-Length': String(blob.size),
365
+ 'Content-Type': blob.mimeType,
366
+ },
367
+ status: 200,
368
+ });
369
+
340
370
  const mapResultToResponse = (
341
371
  result: ResultLike,
342
- request: Request
372
+ request: Request,
373
+ options?: { readonly rendersBlob?: boolean }
343
374
  ): Response => {
344
375
  if (result.isOk()) {
376
+ if (options?.rendersBlob === true && isBlobRef(result.value)) {
377
+ return blobResponse(result.value);
378
+ }
345
379
  return json({ data: result.value }, 200);
346
380
  }
347
381
  const error = result.error ?? new Error('Unknown error');
@@ -429,11 +463,76 @@ const recordInvalidWebhook = async (
429
463
  const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
430
464
  error !== undefined && isTrailsError(error) ? error.category : 'internal';
431
465
 
466
+ /**
467
+ * True when the route's webhook source opts into ingress-envelope
468
+ * delivery: dynamic path segments, raw body, or allowlisted headers.
469
+ */
470
+ const usesWebhookEnvelope = (route: HttpRouteDefinition): boolean => {
471
+ const source = route.webhookSource;
472
+ return (
473
+ source !== undefined &&
474
+ (source.rawBody === true ||
475
+ source.headers !== undefined ||
476
+ parseWebhookPathParams(source.path).length > 0)
477
+ );
478
+ };
479
+
480
+ const pickAllowlistedHeaders = (
481
+ headers: Headers,
482
+ allowlist: readonly string[]
483
+ ): Record<string, string> => {
484
+ const allowed = new Set(allowlist.map((name) => name.toLowerCase()));
485
+ const kept: Record<string, string> = {};
486
+ for (const [name, value] of headers) {
487
+ const normalized = name.toLowerCase();
488
+ if (allowed.has(normalized)) {
489
+ kept[normalized] = value;
490
+ }
491
+ }
492
+ return kept;
493
+ };
494
+
495
+ /**
496
+ * Assemble the delivered webhook value.
497
+ *
498
+ * Classic webhooks deliver the parsed JSON body directly. Envelope-mode
499
+ * webhooks deliver `{ ...pathParams, body?, headers?, rawBody? }` — the
500
+ * schema-declared boundary shape TRL-1194 lifts from the hand-mounted
501
+ * ingress routes.
502
+ */
503
+ const buildWebhookDeliveredValue = (
504
+ route: HttpRouteDefinition,
505
+ request: Request,
506
+ rawBody: string,
507
+ jsonBody: unknown,
508
+ pathParams: Readonly<Record<string, string>> | undefined
509
+ ): unknown => {
510
+ const source = route.webhookSource;
511
+ if (source === undefined || !usesWebhookEnvelope(route)) {
512
+ return jsonBody;
513
+ }
514
+ return {
515
+ ...(jsonBody === undefined ? {} : { body: jsonBody }),
516
+ ...(source.headers === undefined
517
+ ? {}
518
+ : { headers: pickAllowlistedHeaders(request.headers, source.headers) }),
519
+ ...(source.rawBody === true ? { rawBody } : {}),
520
+ ...pathParams,
521
+ };
522
+ };
523
+
432
524
  const handleWebhookRoute = async (
433
525
  route: HttpRouteDefinition,
434
526
  options: RuntimeOptions,
435
527
  request: Request
436
528
  ): Promise<Response> => {
529
+ const envelope = usesWebhookEnvelope(route);
530
+ // Self-derive dynamic segment values from the route's own pattern so
531
+ // every adapter (fetch dispatcher, Bun routes, Hono) gets pattern
532
+ // support without threading params through handler signatures.
533
+ const pathParams = envelope
534
+ ? matchWebhookPath(route.path, new URL(request.url).pathname)
535
+ : undefined;
437
536
  const rawBody = await readWebhookBodyText(request, options.maxJsonBodyBytes);
438
537
 
439
538
  if (rawBody === JSON_BODY_INVALID_CONTENT_LENGTH) {
@@ -458,12 +557,22 @@ const handleWebhookRoute = async (
458
557
  }
459
558
 
460
559
  const jsonBody = parseWebhookBodyText(request, rawBody);
461
- if (jsonBody === JSON_PARSE_ERROR) {
560
+ // With rawBody delivery the trail owns payload interpretation, so a
561
+ // non-JSON body is not a surface-level failure.
562
+ if (jsonBody === JSON_PARSE_ERROR && route.webhookSource?.rawBody !== true) {
462
563
  await recordInvalidWebhook(route);
463
564
  return invalidJsonResponse();
464
565
  }
465
566
 
466
- const parsed = route.parseWebhookInput?.(jsonBody);
567
+ const delivered = buildWebhookDeliveredValue(
568
+ route,
569
+ request,
570
+ rawBody,
571
+ jsonBody === JSON_PARSE_ERROR ? undefined : jsonBody,
572
+ pathParams
573
+ );
574
+
575
+ const parsed = route.parseWebhookInput?.(delivered);
467
576
  if (parsed === undefined) {
468
577
  await recordInvalidWebhook(route, 'internal');
469
578
  return mapResultToResponse(
@@ -483,6 +592,11 @@ const handleWebhookRoute = async (
483
592
  const result = await route.execute(parsed.value, requestId, request.signal, {
484
593
  headers: request.headers,
485
594
  });
595
+ // Envelope-mode ingress acknowledges accepted work with 202, matching
596
+ // the accepted-for-processing semantics of webhook receivers.
597
+ if (envelope && result.isOk()) {
598
+ return json({ data: result.value }, 202);
599
+ }
486
600
  return mapResultToResponse(result, request);
487
601
  };
488
602
 
@@ -511,6 +625,7 @@ export const createRouteHandler = (
511
625
  const runtimeOptions = {
512
626
  maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
513
627
  };
628
+ const rendersBlob = rendersBlobOutput(route);
514
629
 
515
630
  return async (request) => {
516
631
  try {
@@ -540,7 +655,7 @@ export const createRouteHandler = (
540
655
  const result = await route.execute(rawInput, requestId, request.signal, {
541
656
  headers: request.headers,
542
657
  });
543
- return mapResultToResponse(result, request);
658
+ return mapResultToResponse(result, request, { rendersBlob });
544
659
  } catch (error: unknown) {
545
660
  return handleCaughtError(error, request);
546
661
  }
@@ -581,22 +696,45 @@ export const createFetchHandler = (
581
696
  throw routesResult.error;
582
697
  }
583
698
 
584
- const routeHandlers = new Map<
585
- string,
586
- (request: Request) => Promise<Response>
587
- >();
699
+ type BoundRouteHandler = (request: Request) => Promise<Response>;
700
+
701
+ const routeHandlers = new Map<string, BoundRouteHandler>();
702
+ const patternRoutes: {
703
+ readonly handler: BoundRouteHandler;
704
+ readonly method: string;
705
+ readonly pattern: string;
706
+ }[] = [];
588
707
  for (const route of routesResult.value) {
589
- routeHandlers.set(
590
- routeKey(route.method, route.path),
591
- createRouteHandler(route, {
592
- maxJsonBodyBytes: options.maxJsonBodyBytes,
593
- })
594
- );
708
+ const handler = createRouteHandler(route, {
709
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
710
+ });
711
+ if (parseWebhookPathParams(route.path).length > 0) {
712
+ patternRoutes.push({
713
+ handler,
714
+ method: route.method.toUpperCase(),
715
+ pattern: route.path,
716
+ });
717
+ continue;
718
+ }
719
+ routeHandlers.set(routeKey(route.method, route.path), handler);
595
720
  }
596
721
 
597
722
  return async (request) => {
598
723
  const path = new URL(request.url).pathname;
599
724
  const handler = routeHandlers.get(routeKey(request.method, path));
600
- return handler === undefined ? notFoundResponse(request) : handler(request);
725
+ if (handler !== undefined) {
726
+ return handler(request);
727
+ }
728
+ // Exact routes win; dynamic-segment routes match in registration order.
729
+ const method = request.method.toUpperCase();
730
+ for (const candidate of patternRoutes) {
731
+ if (candidate.method !== method) {
732
+ continue;
733
+ }
734
+ if (matchWebhookPath(candidate.pattern, path) !== undefined) {
735
+ return candidate.handler(request);
736
+ }
737
+ }
738
+ return notFoundResponse(request);
601
739
  };
602
740
  };
package/src/openapi.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  Trail,
20
20
  } from '@ontrails/core';
21
21
 
22
+ import { isBlobOutputSchema } from './blob-output.js';
22
23
  import { deriveHttpOperationMethod } from './method.js';
23
24
 
24
25
  type JsonSchema = Readonly<Record<string, unknown>>;
@@ -214,6 +215,21 @@ const buildSuccessResponse = (
214
215
  if (!t.output) {
215
216
  return { '200': { description: 'Success' } };
216
217
  }
218
+ if (isBlobOutputSchema(t.output)) {
219
+ // BlobRef routes stream raw bytes at runtime (`fetch.ts` serves the
220
+ // blob's declared mimeType and Content-Length), so the spec documents
221
+ // a binary body instead of the JSON data envelope. The concrete
222
+ // Content-Type is per-blob runtime data, hence the `*/*` range.
223
+ return {
224
+ '200': {
225
+ content: {
226
+ '*/*': { schema: { format: 'binary', type: 'string' } },
227
+ },
228
+ description:
229
+ "Binary content: raw blob bytes served with the blob's declared mimeType",
230
+ },
231
+ };
232
+ }
217
233
  const outputSchema = toJsonSchema(t.output);
218
234
  return {
219
235
  '200': {
package/src/testing.ts CHANGED
@@ -41,21 +41,21 @@ export interface HttpAdapterConformanceCase {
41
41
  }
42
42
 
43
43
  const echoTrail = trail('echo', {
44
- blaze: (input) => Result.ok({ reply: input.message }),
44
+ implementation: (input) => Result.ok({ reply: input.message }),
45
45
  input: z.object({ message: z.string() }),
46
46
  intent: 'read',
47
47
  output: z.object({ reply: z.string() }),
48
48
  });
49
49
 
50
50
  const tagsTrail = trail('tags', {
51
- blaze: (input) => Result.ok({ tags: input.tags }),
51
+ implementation: (input) => Result.ok({ tags: input.tags }),
52
52
  input: z.object({ tags: z.array(z.string()) }),
53
53
  intent: 'read',
54
54
  output: z.object({ tags: z.array(z.string()) }),
55
55
  });
56
56
 
57
57
  const echoBodyTrail = trail('echo.body', {
58
- blaze: (input) => Result.ok({ length: input.message.length }),
58
+ implementation: (input) => Result.ok({ length: input.message.length }),
59
59
  input: z.object({ message: z.string() }),
60
60
  intent: 'write',
61
61
  output: z.object({ length: z.number() }),
@@ -65,14 +65,14 @@ const genericRedactionError = (): Error =>
65
65
  new Error('database password=secret');
66
66
 
67
67
  const genericErrorTrail = trail('generic.error', {
68
- blaze: () => Result.err(genericRedactionError()),
68
+ implementation: () => Result.err(genericRedactionError()),
69
69
  input: z.object({}),
70
70
  intent: 'read',
71
71
  output: z.object({ ok: z.boolean() }),
72
72
  });
73
73
 
74
74
  const protectedTrail = trail('permit.scope', {
75
- blaze: (_input, ctx) =>
75
+ implementation: (_input, ctx) =>
76
76
  Result.ok({
77
77
  permitId: ctx.permit?.id,
78
78
  requestId: ctx.requestId,
@@ -87,7 +87,8 @@ const protectedTrail = trail('permit.scope', {
87
87
  });
88
88
 
89
89
  const abortingTrail = trail('abort.check', {
90
- blaze: (_input, ctx) => Result.ok({ aborted: ctx.abortSignal.aborted }),
90
+ implementation: (_input, ctx) =>
91
+ Result.ok({ aborted: ctx.abortSignal.aborted }),
91
92
  input: z.object({}),
92
93
  intent: 'read',
93
94
  output: z.object({ aborted: z.boolean() }),
@@ -104,7 +105,7 @@ const paymentWebhook = webhook('webhook.payment.received', {
104
105
  });
105
106
 
106
107
  const paymentWebhookTrail = trail('payment.receive', {
107
- blaze: (input) => Result.ok({ paymentId: input.paymentId }),
108
+ implementation: (input) => Result.ok({ paymentId: input.paymentId }),
108
109
  input: z.object({ paymentId: z.string() }),
109
110
  on: [paymentWebhook],
110
111
  output: z.object({ paymentId: z.string() }),