@ontrails/http 1.0.0-beta.18 → 1.0.0-beta.21

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,50 @@
1
1
  # @ontrails/http
2
2
 
3
+ ## 1.0.0-beta.21
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [99523f2]
8
+ - @ontrails/core@1.0.0-beta.21
9
+
10
+ ## 1.0.0-beta.20
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [851a2a3]
15
+ - @ontrails/core@1.0.0-beta.20
16
+
17
+ ## 1.0.0-beta.19
18
+
19
+ ### Patch Changes
20
+
21
+ - e41c382: Document beta-channel install guidance in package and adapter README install snippets so consumers use explicit `@beta` (or pinned `1.0.0-beta.N`) tags instead of accidental `latest` resolution during the prerelease line. Adds the policy doc at `docs/releases/beta-channel-policy.md`, prints both `latest` and `beta` dist-tags in `bun run publish:registry-check`, and aligns plugin/skill install snippets.
22
+ - 1eb5bdc: Rename first-class trail composition from the `cross` API family to the `compose` family across core contracts, testing helpers, topo projections, Warden rules, CLI scaffolds, and docs. `composes`, `ctx.compose`, `composeInput`, and `Compose*` type names are now the public authoring vocabulary; topo persistence migrates legacy composition rows and graph keys forward.
23
+ - 94a8380: Add public API examples for the shared Web Fetch route and topo handlers.
24
+ - 94a8380: Add a public API example for the HTTP intent method table.
25
+ - 84f56a5: Project live trail-version metadata on CLI, HTTP, and MCP surfaces and thread explicit surface version selection into shared trail execution.
26
+ - 5d88104: Polish Trails blaze terminology across package docs and Warden guidance.
27
+ - fc00aeb: Add adapter target conformance metadata and scaffold extracted HTTP adapters through `trails create adapter`.
28
+ - ab1c77c: Advertise first-party adapter target metadata for catalog derivation.
29
+ - 8ca5b85: Expose owner-owned HTTP adapter conformance cases from `@ontrails/http/testing`.
30
+ - Updated dependencies [e41c382]
31
+ - Updated dependencies [1eb5bdc]
32
+ - Updated dependencies [f8d80b9]
33
+ - Updated dependencies [846a597]
34
+ - Updated dependencies [223aaad]
35
+ - Updated dependencies [3125f4d]
36
+ - Updated dependencies [2494dc6]
37
+ - Updated dependencies [2d53717]
38
+ - Updated dependencies [16cb740]
39
+ - Updated dependencies [8894ecb]
40
+ - Updated dependencies [fdf7ec9]
41
+ - Updated dependencies [d76be13]
42
+ - Updated dependencies [84f56a5]
43
+ - Updated dependencies [431b04c]
44
+ - Updated dependencies [5d88104]
45
+ - Updated dependencies [f04a9ef]
46
+ - @ontrails/core@1.0.0-beta.19
47
+
3
48
  ## 1.0.0-beta.18
4
49
 
5
50
  ### Minor Changes
package/README.md CHANGED
@@ -30,27 +30,17 @@ import { surface } from '@ontrails/http/bun';
30
30
  await surface(graph, { port: 3000 });
31
31
  ```
32
32
 
33
- `@ontrails/http/bun` uses Bun's native `Bun.serve({ routes })` fast path and
34
- keeps the shared Web Fetch handler as the fallback. It requires Bun `>=1.2.3`
35
- and does not add a third-party runtime dependency.
33
+ `@ontrails/http/bun` uses Bun's native `Bun.serve({ routes })` fast path and keeps the shared Web Fetch handler as the fallback. It requires Bun `>=1.2.3` and does not add a third-party runtime dependency.
36
34
 
37
35
  ## Projection and materialization
38
36
 
39
37
  The HTTP package follows the surface API naming split:
40
38
 
41
- - `derive*` exports are pure projections from the topo. Use
42
- `deriveHttpRoutes()` for route definitions and `deriveOpenApiSpec()` for the
43
- OpenAPI contract.
44
- - `create*` exports materialize runtime objects without opening a network
45
- boundary. `@ontrails/http/fetch` exports `createRouteHandler()` for one
46
- route and `createFetchHandler()` for a full topo dispatcher.
47
- - `surface()` opens the runtime boundary. `@ontrails/hono` opens a Hono server;
48
- `@ontrails/http/bun` opens Bun's native HTTP server.
39
+ - `derive*` exports are pure projections from the topo. Use `deriveHttpRoutes()` for route definitions and `deriveOpenApiSpec()` for the OpenAPI contract.
40
+ - `create*` exports materialize runtime objects without opening a network boundary. `@ontrails/http/fetch` exports `createRouteHandler()` for one route and `createFetchHandler()` for a full topo dispatcher.
41
+ - `surface()` opens the runtime boundary. `@ontrails/hono` opens a Hono server; `@ontrails/http/bun` opens Bun's native HTTP server.
49
42
 
50
- The shared `@ontrails/http/fetch` kernel owns query/body parsing,
51
- content-length validation, public error projection, diagnostics, request IDs,
52
- headers, abort propagation, and webhook verification/parsing behavior. Hono and
53
- Bun both consume that kernel so route semantics stay aligned.
43
+ The shared `@ontrails/http/fetch` kernel owns query/body parsing, content-length validation, public error projection, diagnostics, request IDs, headers, abort propagation, and webhook verification/parsing behavior. Hono and Bun both consume that kernel so route semantics stay aligned.
54
44
 
55
45
  For more control, build the routes yourself:
56
46
 
@@ -74,8 +64,7 @@ import { deriveOpenApiSpec } from '@ontrails/http';
74
64
  const spec = deriveOpenApiSpec(graph, { basePath: '/api' });
75
65
  ```
76
66
 
77
- `deriveOpenApiSpec()` emits an OpenAPI 3.1 document from the same trail
78
- contracts used by `deriveHttpRoutes()`.
67
+ `deriveOpenApiSpec()` emits an OpenAPI 3.1 document from the same trail contracts used by `deriveHttpRoutes()`.
79
68
 
80
69
  ## API
81
70
 
@@ -85,6 +74,23 @@ contracts used by `deriveHttpRoutes()`.
85
74
  | `deriveOpenApiSpec(graph, options?)` | Generate an OpenAPI 3.1 document for the HTTP surface |
86
75
  | `@ontrails/http/fetch` | Shared Web Fetch `createRouteHandler()` and `createFetchHandler()` kernel |
87
76
  | `@ontrails/http/bun` | Bun-native `createApp()` and `surface()` materializer |
77
+ | `@ontrails/http/testing` | Owner-owned adapter conformance factory for HTTP adapter authors |
78
+
79
+ ## Adapter authoring
80
+
81
+ HTTP adapter authors should validate adapters through the owner-owned testing subpath instead of copying conformance behavior into each adapter:
82
+
83
+ ```typescript
84
+ import {
85
+ createHttpAdapterConformanceCases,
86
+ runConformance,
87
+ } from '@ontrails/http/testing';
88
+ import { myHttpAdapter } from './adapter.js';
89
+
90
+ runConformance(myHttpAdapter, createHttpAdapterConformanceCases());
91
+ ```
92
+
93
+ The adapter under test provides a `name` and `createApp(graph, options)` method that returns an object with a Web Fetch-compatible `fetch(request)` handler. The conformance cases cover query and body input projection, validation envelopes, public error redaction, request context, abort propagation, and webhook verification/parsing behavior.
88
94
 
89
95
  ## Route derivation
90
96
 
@@ -105,7 +111,7 @@ Trail IDs map to paths: `entity.show` becomes `/entity/show`. Dots become slashe
105
111
 
106
112
  ## Resource resolution
107
113
 
108
- Declared resources on each trail are resolved into the context before the implementation runs.
114
+ Declared resources on each trail are resolved into the context before the blaze receives input.
109
115
 
110
116
  ## Filtering
111
117
 
@@ -116,9 +122,7 @@ const result = deriveHttpRoutes(graph, {
116
122
  });
117
123
  ```
118
124
 
119
- `*` matches one dotted segment and `**` matches any depth. Trails declared
120
- with `visibility: 'internal'` stay hidden unless you include their exact trail
121
- ID intentionally.
125
+ `*` matches one dotted segment and `**` matches any depth. Trails declared with `visibility: 'internal'` stay hidden unless you include their exact trail ID intentionally.
122
126
 
123
127
  ## Request context and abort propagation
124
128
 
@@ -135,19 +139,16 @@ Each route definition produced by `deriveHttpRoutes` includes:
135
139
  | `trailId` | `string` | The trail ID this route was derived from |
136
140
  | `inputSource` | `'query' \| 'body'` | Where to read input |
137
141
  | `trail` | `Trail` | The original trail definition |
138
- | `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the implementation |
142
+ | `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the blazed trail |
139
143
 
140
- For GET routes on the Hono surface, repeated query keys are passed through as
141
- arrays (`?tag=one&tag=two` -> `{ tag: ['one', 'two'] }`) while a single
142
- occurrence stays a scalar string. The adapter does not coerce singleton query
143
- values into arrays.
144
+ 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.
144
145
 
145
146
  ## Installation
146
147
 
147
148
  ```bash
148
- bun add @ontrails/http @ontrails/hono
149
+ bun add @ontrails/http@beta @ontrails/hono@beta
149
150
  # or, for Bun-native serving:
150
- bun add @ontrails/http
151
+ bun add @ontrails/http@beta
151
152
  ```
152
153
 
153
154
  ## Migration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/http",
3
- "version": "1.0.0-beta.18",
3
+ "version": "1.0.0-beta.21",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -14,6 +14,7 @@
14
14
  ".": "./src/index.ts",
15
15
  "./bun": "./src/bun.ts",
16
16
  "./fetch": "./src/fetch.ts",
17
+ "./testing": "./src/testing.ts",
17
18
  "./package.json": "./package.json"
18
19
  },
19
20
  "scripts": {
@@ -24,9 +25,25 @@
24
25
  "clean": "rm -rf dist *.tsbuildinfo"
25
26
  },
26
27
  "dependencies": {
27
- "@ontrails/core": "^1.0.0-beta.17"
28
+ "@ontrails/core": "^1.0.0-beta.21"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "zod": "^4.3.5"
32
+ },
33
+ "trails": {
34
+ "adapterTargets": {
35
+ "http": {
36
+ "conformance": {
37
+ "adapterType": "HttpAdapterConformanceAdapter",
38
+ "casesFactory": "createHttpAdapterConformanceCases",
39
+ "runner": "runConformance"
40
+ },
41
+ "placements": [
42
+ "extracted",
43
+ "subpath"
44
+ ],
45
+ "testingImport": "@ontrails/http/testing"
46
+ }
47
+ }
31
48
  }
32
49
  }
package/src/build.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  ValidationError,
13
13
  buildActivationProvenanceTraceAttrs,
14
14
  collectAttachedTypedLayers,
15
+ deriveSurfaceTrailVersionProjections,
15
16
  executeTrail,
16
17
  filterSurfaceTrails,
17
18
  getActivationWherePredicate,
@@ -39,9 +40,11 @@ import type {
39
40
  BaseSurfaceOptions,
40
41
  Layer,
41
42
  ResourceOverrideMap,
43
+ SurfaceTrailVersionProjection,
42
44
  TraceContext,
43
45
  Topo,
44
46
  Trail,
47
+ TrailVersionReference,
45
48
  TrailContextInit,
46
49
  WebhookSource,
47
50
  WebhookVerifyRequest,
@@ -72,6 +75,7 @@ export type HttpHeaderSource =
72
75
 
73
76
  export interface HttpExecutionContext {
74
77
  readonly headers?: HttpHeaderSource | undefined;
78
+ readonly version?: TrailVersionReference | undefined;
75
79
  }
76
80
 
77
81
  export interface ResolveHttpPermitInput {
@@ -93,6 +97,7 @@ export interface HttpRouteDefinition {
93
97
  readonly trailId: string;
94
98
  readonly inputSource: InputSource;
95
99
  readonly trail: Trail<unknown, unknown, unknown>;
100
+ readonly versions?: readonly SurfaceTrailVersionProjection[] | undefined;
96
101
  /**
97
102
  * JSON Schema for the merged request input (trail input + projected layer
98
103
  * input fields). Empty/undefined when the trail declares no input and no
@@ -126,7 +131,7 @@ export interface HttpRouteDefinition {
126
131
  | undefined;
127
132
  readonly webhookSource?: WebhookSource | undefined;
128
133
  /**
129
- * Validate input, compose layers, and execute the trail implementation.
134
+ * Validate input, compose layers, and run the blazed trail.
130
135
  *
131
136
  * The caller is responsible for parsing raw input from the request and
132
137
  * mapping the Result to an HTTP response. This function is framework-agnostic.
@@ -572,6 +577,67 @@ const mergeHttpInputSchemas = (
572
577
  return merged;
573
578
  };
574
579
 
580
+ const TRAIL_VERSION_INPUT_FIELD = 'trailVersion';
581
+ const TRAILS_VERSION_HEADERS = ['x-trails-version', 'x-trail-version'];
582
+
583
+ const versionInputSchema = (): Record<string, unknown> => ({
584
+ properties: {
585
+ [TRAIL_VERSION_INPUT_FIELD]: {
586
+ description: 'Live trail version number or marker prefix',
587
+ type: 'string',
588
+ },
589
+ },
590
+ type: 'object',
591
+ });
592
+
593
+ const addVersionInputSchema = (
594
+ trail: Trail<unknown, unknown, unknown>,
595
+ schema: Record<string, unknown> | undefined
596
+ ): Record<string, unknown> | undefined =>
597
+ trail.version === undefined
598
+ ? schema
599
+ : mergeHttpInputSchemas(schema, versionInputSchema());
600
+
601
+ const readVersionFromHeaders = (
602
+ headers: HttpHeaderSource | undefined
603
+ ): TrailVersionReference | undefined => {
604
+ for (const name of TRAILS_VERSION_HEADERS) {
605
+ const value = readHeader(headers, name);
606
+ if (value !== undefined && value.length > 0) {
607
+ return value;
608
+ }
609
+ }
610
+ return undefined;
611
+ };
612
+
613
+ const splitHttpSurfaceVersion = (
614
+ input: unknown,
615
+ context: HttpExecutionContext | undefined,
616
+ supportsVersions: boolean
617
+ ): {
618
+ readonly input: unknown;
619
+ readonly version: TrailVersionReference | undefined;
620
+ } => {
621
+ if (!supportsVersions) {
622
+ return { input, version: undefined };
623
+ }
624
+
625
+ const headerVersion =
626
+ context?.version ?? readVersionFromHeaders(context?.headers);
627
+ if (!isJsonObjectSchema(input)) {
628
+ return { input, version: headerVersion };
629
+ }
630
+
631
+ const record = input as Record<string, unknown>;
632
+ const { [TRAIL_VERSION_INPUT_FIELD]: fieldVersion, ...rest } = record;
633
+ const version =
634
+ headerVersion ??
635
+ (typeof fieldVersion === 'string' || typeof fieldVersion === 'number'
636
+ ? fieldVersion
637
+ : undefined);
638
+ return { input: rest, version };
639
+ };
640
+
575
641
  /**
576
642
  * Partition a parsed request input into the trail input plus per-layer
577
643
  * inputs, using each layer's routing table.
@@ -639,8 +705,13 @@ const createExecute =
639
705
  layerProjections: readonly HttpLayerInputProjection[]
640
706
  ): HttpRouteDefinition['execute'] =>
641
707
  async (input, requestId, abortSignal, request) => {
642
- const { trailInput, layerInputs } = partitionHttpInput(
708
+ const versionedInput = splitHttpSurfaceVersion(
643
709
  input,
710
+ request,
711
+ t.version !== undefined
712
+ );
713
+ const { trailInput, layerInputs } = partitionHttpInput(
714
+ versionedInput.input,
644
715
  layerProjections
645
716
  );
646
717
  const permitResolution = await resolveHttpPermit(
@@ -664,6 +735,9 @@ const createExecute =
664
735
  surfaceLayers: layers,
665
736
  topo: graph,
666
737
  topoLayers: graph.layers,
738
+ ...(versionedInput.version === undefined
739
+ ? {}
740
+ : { version: versionedInput.version }),
667
741
  });
668
742
  };
669
743
 
@@ -721,8 +795,13 @@ const createWebhookConsumerExecute =
721
795
  'activation.webhook',
722
796
  'ok'
723
797
  );
724
- const { trailInput, layerInputs } = partitionHttpInput(
798
+ const versionedInput = splitHttpSurfaceVersion(
725
799
  input,
800
+ request,
801
+ t.version !== undefined
802
+ );
803
+ const { trailInput, layerInputs } = partitionHttpInput(
804
+ versionedInput.input,
726
805
  layerProjections
727
806
  );
728
807
  const permitResolution = await resolveHttpPermit(
@@ -746,6 +825,9 @@ const createWebhookConsumerExecute =
746
825
  surfaceLayers: layers,
747
826
  topo: graph,
748
827
  topoLayers: graph.layers,
828
+ ...(versionedInput.version === undefined
829
+ ? {}
830
+ : { version: versionedInput.version }),
749
831
  });
750
832
  };
751
833
 
@@ -836,6 +918,8 @@ const buildRoute = (
836
918
  options.layers
837
919
  );
838
920
  const inputProjection = projectHttpInputSchema(trail, attachedLayers);
921
+ const inputSchema = addVersionInputSchema(trail, inputProjection.schema);
922
+ const versions = deriveSurfaceTrailVersionProjections(trail);
839
923
  return {
840
924
  execute: createExecute(
841
925
  graph,
@@ -844,9 +928,7 @@ const buildRoute = (
844
928
  options,
845
929
  inputProjection.projections
846
930
  ),
847
- ...(inputProjection.schema === undefined
848
- ? {}
849
- : { inputSchema: inputProjection.schema }),
931
+ ...(inputSchema === undefined ? {} : { inputSchema }),
850
932
  inputSource: deriveHttpInputSource(method),
851
933
  ...(inputProjection.projections.length === 0
852
934
  ? {}
@@ -855,6 +937,7 @@ const buildRoute = (
855
937
  path,
856
938
  trail,
857
939
  trailId: trail.id,
940
+ ...(versions === undefined ? {} : { versions }),
858
941
  };
859
942
  };
860
943
 
@@ -973,6 +1056,8 @@ const buildWebhookRoute = (
973
1056
  options.layers
974
1057
  );
975
1058
  const inputProjection = projectHttpInputSchema(trail, attachedLayers);
1059
+ const inputSchema = addVersionInputSchema(trail, inputProjection.schema);
1060
+ const versions = deriveSurfaceTrailVersionProjections(trail);
976
1061
  const consumerExecute = createWebhookConsumerExecute(
977
1062
  graph,
978
1063
  trail,
@@ -991,9 +1076,7 @@ const buildWebhookRoute = (
991
1076
  [WEBHOOK_CONSUMERS]: [consumerExecute],
992
1077
  [WEBHOOK_INVALID_RECORDERS]: [consumerInvalidRecorder],
993
1078
  execute: createWebhookExecute(consumerExecute),
994
- ...(inputProjection.schema === undefined
995
- ? {}
996
- : { inputSchema: inputProjection.schema }),
1079
+ ...(inputSchema === undefined ? {} : { inputSchema }),
997
1080
  inputSource: 'webhook',
998
1081
  ...(inputProjection.projections.length === 0
999
1082
  ? {}
@@ -1007,6 +1090,7 @@ const buildWebhookRoute = (
1007
1090
  trail,
1008
1091
  trailId: trail.id,
1009
1092
  verifyWebhook: (request) => verifyWebhookRequest(source.value, request),
1093
+ ...(versions === undefined ? {} : { versions }),
1010
1094
  webhookSource: source.value,
1011
1095
  };
1012
1096
  return Result.ok(route);
package/src/fetch.ts CHANGED
@@ -488,6 +488,21 @@ const handleWebhookRoute = async (
488
488
 
489
489
  /**
490
490
  * Build a Web Fetch handler for one framework-agnostic HTTP route.
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * import { deriveHttpRoutes } from '@ontrails/http';
495
+ * import { createRouteHandler } from '@ontrails/http/fetch';
496
+ *
497
+ * const routes = deriveHttpRoutes(graph, { basePath: '/api' });
498
+ * if (routes.isErr()) throw routes.error;
499
+ *
500
+ * const route = routes.value[0];
501
+ * if (!route) throw new Error('No routes derived');
502
+ *
503
+ * const handle = createRouteHandler(route);
504
+ * const response = await handle(new Request('https://example.test/api/hello'));
505
+ * ```
491
506
  */
492
507
  export const createRouteHandler = (
493
508
  route: HttpRouteDefinition,
@@ -534,6 +549,16 @@ export const createRouteHandler = (
534
549
 
535
550
  /**
536
551
  * Build a Web Fetch dispatcher for all HTTP routes in a topo.
552
+ *
553
+ * @example
554
+ * ```ts
555
+ * import { createFetchHandler } from '@ontrails/http/fetch';
556
+ *
557
+ * const fetch = createFetchHandler(graph, { basePath: '/api' });
558
+ * const response = await fetch(
559
+ * new Request('https://example.test/api/hello?name=Matt')
560
+ * );
561
+ * ```
537
562
  */
538
563
  export const createFetchHandler = (
539
564
  graph: Topo,
package/src/method.ts CHANGED
@@ -6,6 +6,17 @@ export type HttpOperationMethod = Lowercase<HttpMethod>;
6
6
 
7
7
  export type InputSource = 'query' | 'body' | 'webhook';
8
8
 
9
+ /**
10
+ * Owner table for projecting trail intent onto HTTP methods.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { httpMethodByIntent } from '@ontrails/http';
15
+ *
16
+ * const readMethod = httpMethodByIntent.read;
17
+ * // readMethod === 'GET'
18
+ * ```
19
+ */
9
20
  export const httpMethodByIntent = {
10
21
  destroy: 'DELETE',
11
22
  read: 'GET',
package/src/testing.ts ADDED
@@ -0,0 +1,339 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ PermissionError,
5
+ Result,
6
+ getWebhookHeader,
7
+ trail,
8
+ topo,
9
+ webhook,
10
+ } from '@ontrails/core';
11
+ import type { Topo } from '@ontrails/core';
12
+ import { z } from 'zod';
13
+
14
+ import type {
15
+ DeriveHttpRoutesOptions,
16
+ HttpHeaderSource,
17
+ ResolveHttpPermit,
18
+ } from './build.js';
19
+ import type { CreateRouteHandlerOptions } from './fetch.js';
20
+
21
+ export interface HttpAdapterConformanceApp {
22
+ readonly fetch: (request: Request) => Response | Promise<Response>;
23
+ }
24
+
25
+ export interface HttpAdapterConformanceOptions
26
+ extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {
27
+ readonly resolvePermit?: ResolveHttpPermit | undefined;
28
+ }
29
+
30
+ export interface HttpAdapterConformanceAdapter {
31
+ readonly createApp: (
32
+ graph: Topo,
33
+ options?: HttpAdapterConformanceOptions
34
+ ) => HttpAdapterConformanceApp | Promise<HttpAdapterConformanceApp>;
35
+ readonly name: string;
36
+ }
37
+
38
+ export interface HttpAdapterConformanceCase {
39
+ readonly check: (adapter: HttpAdapterConformanceAdapter) => Promise<void>;
40
+ readonly name: string;
41
+ }
42
+
43
+ const echoTrail = trail('echo', {
44
+ blaze: (input) => Result.ok({ reply: input.message }),
45
+ input: z.object({ message: z.string() }),
46
+ intent: 'read',
47
+ output: z.object({ reply: z.string() }),
48
+ });
49
+
50
+ const tagsTrail = trail('tags', {
51
+ blaze: (input) => Result.ok({ tags: input.tags }),
52
+ input: z.object({ tags: z.array(z.string()) }),
53
+ intent: 'read',
54
+ output: z.object({ tags: z.array(z.string()) }),
55
+ });
56
+
57
+ const echoBodyTrail = trail('echo.body', {
58
+ blaze: (input) => Result.ok({ length: input.message.length }),
59
+ input: z.object({ message: z.string() }),
60
+ intent: 'write',
61
+ output: z.object({ length: z.number() }),
62
+ });
63
+
64
+ const genericRedactionError = (): Error =>
65
+ new Error('database password=secret');
66
+
67
+ const genericErrorTrail = trail('generic.error', {
68
+ blaze: () => Result.err(genericRedactionError()),
69
+ input: z.object({}),
70
+ intent: 'read',
71
+ output: z.object({ ok: z.boolean() }),
72
+ });
73
+
74
+ const protectedTrail = trail('permit.scope', {
75
+ blaze: (_input, ctx) =>
76
+ Result.ok({
77
+ permitId: ctx.permit?.id,
78
+ requestId: ctx.requestId,
79
+ }),
80
+ input: z.object({}),
81
+ intent: 'read',
82
+ output: z.object({
83
+ permitId: z.string().optional(),
84
+ requestId: z.string().optional(),
85
+ }),
86
+ permit: { scopes: ['thing:read'] },
87
+ });
88
+
89
+ const abortingTrail = trail('abort.check', {
90
+ blaze: (_input, ctx) => Result.ok({ aborted: ctx.abortSignal.aborted }),
91
+ input: z.object({}),
92
+ intent: 'read',
93
+ output: z.object({ aborted: z.boolean() }),
94
+ });
95
+
96
+ const webhookSecret = 'secret';
97
+ const paymentWebhook = webhook('webhook.payment.received', {
98
+ parse: z.object({ paymentId: z.string() }),
99
+ path: '/webhooks/payment',
100
+ verify: (request) =>
101
+ getWebhookHeader(request, 'x-webhook-secret') === webhookSecret
102
+ ? Result.ok()
103
+ : Result.err(new PermissionError('Invalid webhook secret')),
104
+ });
105
+
106
+ const paymentWebhookTrail = trail('payment.receive', {
107
+ blaze: (input) => Result.ok({ paymentId: input.paymentId }),
108
+ input: z.object({ paymentId: z.string() }),
109
+ on: [paymentWebhook],
110
+ output: z.object({ paymentId: z.string() }),
111
+ });
112
+
113
+ const buildRequest = (path: string, init: RequestInit = {}): Request =>
114
+ new Request(new URL(path, 'http://localhost').toString(), init);
115
+
116
+ const readHeader = (
117
+ headers: HttpHeaderSource | undefined,
118
+ name: string
119
+ ): string | undefined => {
120
+ if (headers === undefined) {
121
+ return undefined;
122
+ }
123
+ if (headers instanceof Headers) {
124
+ return headers.get(name) ?? undefined;
125
+ }
126
+ const needle = name.toLowerCase();
127
+ for (const [key, value] of Object.entries(headers)) {
128
+ if (key.toLowerCase() !== needle || value === undefined) {
129
+ continue;
130
+ }
131
+ return typeof value === 'string' ? value : value[0];
132
+ }
133
+ return undefined;
134
+ };
135
+
136
+ const expectJson = async (
137
+ response: Response
138
+ ): Promise<Record<string, unknown>> =>
139
+ (await response.json()) as Record<string, unknown>;
140
+
141
+ const materialize = async (
142
+ adapter: HttpAdapterConformanceAdapter,
143
+ graph: Topo,
144
+ options?: HttpAdapterConformanceOptions
145
+ ): Promise<HttpAdapterConformanceApp> =>
146
+ await adapter.createApp(graph, options);
147
+
148
+ const expectOkResponse = async (
149
+ response: Response,
150
+ data: Record<string, unknown>
151
+ ): Promise<void> => {
152
+ expect(response.status).toBe(200);
153
+ expect(await expectJson(response)).toEqual({ data });
154
+ };
155
+
156
+ const request = async (
157
+ adapter: HttpAdapterConformanceAdapter,
158
+ graph: Topo,
159
+ path: string,
160
+ init?: RequestInit,
161
+ options?: HttpAdapterConformanceOptions
162
+ ): Promise<Response> => {
163
+ const app = await materialize(adapter, graph, options);
164
+ return await app.fetch(buildRequest(path, init));
165
+ };
166
+
167
+ const readRouteCase = async (
168
+ adapter: HttpAdapterConformanceAdapter
169
+ ): Promise<void> => {
170
+ const response = await request(
171
+ adapter,
172
+ topo('http-conformance-read', { echoTrail }),
173
+ '/echo?message=hello'
174
+ );
175
+
176
+ await expectOkResponse(response, { reply: 'hello' });
177
+ };
178
+
179
+ const writeRouteCase = async (
180
+ adapter: HttpAdapterConformanceAdapter
181
+ ): Promise<void> => {
182
+ const response = await request(
183
+ adapter,
184
+ topo('http-conformance-write', { echoBodyTrail }),
185
+ '/echo/body',
186
+ {
187
+ body: JSON.stringify({ message: 'hello' }),
188
+ headers: { 'Content-Type': 'application/json' },
189
+ method: 'POST',
190
+ }
191
+ );
192
+
193
+ await expectOkResponse(response, { length: 5 });
194
+ };
195
+
196
+ const repeatedQueryCase = async (
197
+ adapter: HttpAdapterConformanceAdapter
198
+ ): Promise<void> => {
199
+ const graph = topo('http-conformance-query', { tagsTrail });
200
+ const repeated = await request(adapter, graph, '/tags?tags=red&tags=blue');
201
+ const singleton = await request(adapter, graph, '/tags?tags=solo');
202
+
203
+ await expectOkResponse(repeated, { tags: ['red', 'blue'] });
204
+ expect(singleton.status).toBe(400);
205
+ expect(await expectJson(singleton)).toMatchObject({
206
+ error: { category: 'validation' },
207
+ });
208
+ };
209
+
210
+ const publicErrorCase = async (
211
+ adapter: HttpAdapterConformanceAdapter
212
+ ): Promise<void> => {
213
+ const response = await request(
214
+ adapter,
215
+ topo('http-conformance-errors', { genericErrorTrail }),
216
+ '/generic/error',
217
+ { headers: { 'X-Request-ID': 'req-123 forged/line' } }
218
+ );
219
+ const body = await expectJson(response);
220
+
221
+ expect(response.status).toBe(500);
222
+ expect(body).toEqual({
223
+ error: {
224
+ category: 'internal',
225
+ code: 'InternalError',
226
+ message: 'Internal server error',
227
+ },
228
+ });
229
+ expect(JSON.stringify(body)).not.toContain('secret');
230
+ };
231
+
232
+ const requestContextCase = async (
233
+ adapter: HttpAdapterConformanceAdapter
234
+ ): Promise<void> => {
235
+ let observedTenant: string | null | undefined;
236
+ const resolvePermit: ResolveHttpPermit = ({ headers }) => {
237
+ observedTenant = readHeader(headers, 'x-tenant-id');
238
+ return Result.ok({ id: 'user-1', scopes: ['thing:read'] });
239
+ };
240
+ const graph = topo('http-conformance-context', {
241
+ abortingTrail,
242
+ protectedTrail,
243
+ });
244
+ const app = await materialize(adapter, graph, { resolvePermit });
245
+ const controller = new AbortController();
246
+ controller.abort();
247
+
248
+ const permitResponse = await app.fetch(
249
+ buildRequest('/permit/scope', {
250
+ headers: {
251
+ Authorization: 'Bearer strong',
252
+ 'X-Request-ID': 'req-1',
253
+ 'X-Tenant-ID': 'tenant-1',
254
+ },
255
+ })
256
+ );
257
+ const abortResponse = await app.fetch(
258
+ buildRequest('/abort/check', { signal: controller.signal })
259
+ );
260
+
261
+ await expectOkResponse(permitResponse, {
262
+ permitId: 'user-1',
263
+ requestId: 'req-1',
264
+ });
265
+ expect(observedTenant).toBe('tenant-1');
266
+ await expectOkResponse(abortResponse, { aborted: true });
267
+ };
268
+
269
+ const webhookCase = async (
270
+ adapter: HttpAdapterConformanceAdapter
271
+ ): Promise<void> => {
272
+ const graph = topo('http-conformance-webhooks', { paymentWebhookTrail });
273
+ const verified = await request(adapter, graph, '/webhooks/payment', {
274
+ body: JSON.stringify({ paymentId: 'pay_1' }),
275
+ headers: {
276
+ 'Content-Type': 'application/json',
277
+ 'X-Webhook-Secret': webhookSecret,
278
+ },
279
+ method: 'POST',
280
+ });
281
+ const denied = await request(adapter, graph, '/webhooks/payment', {
282
+ body: JSON.stringify({ paymentId: 'pay_1' }),
283
+ headers: {
284
+ 'Content-Type': 'application/json',
285
+ 'X-Webhook-Secret': 'wrong',
286
+ },
287
+ method: 'POST',
288
+ });
289
+ const invalidPayload = await request(adapter, graph, '/webhooks/payment', {
290
+ body: JSON.stringify({ paymentId: 123 }),
291
+ headers: {
292
+ 'Content-Type': 'application/json',
293
+ 'X-Webhook-Secret': webhookSecret,
294
+ },
295
+ method: 'POST',
296
+ });
297
+
298
+ await expectOkResponse(verified, { paymentId: 'pay_1' });
299
+ expect(denied.status).toBe(403);
300
+ expect(await expectJson(denied)).toMatchObject({
301
+ error: { category: 'permission' },
302
+ });
303
+ expect(invalidPayload.status).toBe(400);
304
+ expect(await expectJson(invalidPayload)).toMatchObject({
305
+ error: { category: 'validation' },
306
+ });
307
+ };
308
+
309
+ export const createHttpAdapterConformanceCases =
310
+ (): readonly HttpAdapterConformanceCase[] => [
311
+ { check: readRouteCase, name: 'serves read trails from query parameters' },
312
+ { check: writeRouteCase, name: 'serves write trails from JSON bodies' },
313
+ {
314
+ check: repeatedQueryCase,
315
+ name: 'preserves repeated query keys before validation',
316
+ },
317
+ {
318
+ check: publicErrorCase,
319
+ name: 'projects generic errors as redacted public 500 responses',
320
+ },
321
+ {
322
+ check: requestContextCase,
323
+ name: 'threads request context and abort signals',
324
+ },
325
+ { check: webhookCase, name: 'handles webhook verification and parsing' },
326
+ ];
327
+
328
+ export const runConformance = (
329
+ adapter: HttpAdapterConformanceAdapter,
330
+ cases: readonly HttpAdapterConformanceCase[] = createHttpAdapterConformanceCases()
331
+ ): void => {
332
+ describe(`${adapter.name} HTTP adapter conformance`, () => {
333
+ for (const conformanceCase of cases) {
334
+ test(conformanceCase.name, async () => {
335
+ await conformanceCase.check(adapter);
336
+ });
337
+ }
338
+ });
339
+ };