@ultimat3/action 29.4.0 → 30.1.0

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/src/facade.ts CHANGED
@@ -34,7 +34,7 @@ export function facadeFor<TInput extends StandardSchemaV1, TOutput extends Stand
34
34
  as: (actor, input, options) =>
35
35
  invoke(self(), input, { ...options, actor }) as Promise<InferOutput<TOutput>>,
36
36
  openapi: () => toOpenApiOperation(self()),
37
- client: (options) => clientMethodFor(actionName(self()), options, def.http?.path),
37
+ client: (options) => clientMethodFor(actionName(self()), options, def.http),
38
38
  job: () => toJobHandle(self()),
39
39
  contract: (options) => contractTestsFor(self(), options),
40
40
  };
package/src/http-path.ts CHANGED
@@ -6,13 +6,25 @@
6
6
  */
7
7
 
8
8
  import type { ActionPathStyle, ActionRoute } from '@ultimat3/core';
9
- import { ACTION_PATH_STYLES, actionRoute } from '@ultimat3/core';
9
+ import {
10
+ ACTION_PATH_STYLES,
11
+ actionRoute,
12
+ fillTemplate,
13
+ pathTemplateProblem,
14
+ renderCauseValue,
15
+ templateKeyProblem,
16
+ templateParams,
17
+ } from '@ultimat3/core';
18
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
19
+ import { tryIntrospect } from '@ultimat3/schema';
20
+ import type { AnyAction } from './action';
10
21
  import {
11
22
  ActionHttpPathInvalidError,
12
23
  ActionPathDerivedEarlyError,
13
24
  ActionPathStyleInvalidError,
14
25
  } from './errors-http';
15
26
  import { defOf } from './invoke';
27
+ import { jsonSchemaOf } from './json-schema';
16
28
  import { seatedActions } from './registry-store';
17
29
 
18
30
  /**
@@ -24,18 +36,30 @@ const pinOf = (name: string): string | undefined => {
24
36
  return seated === undefined ? undefined : defOf(seated).http?.path;
25
37
  };
26
38
 
39
+ /** The verbs a pinned action may answer. `POST` is every unpinned action's, and the default. */
40
+ export const ACTION_HTTP_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
41
+ export type ActionHttpMethod = (typeof ACTION_HTTP_METHODS)[number];
42
+
27
43
  /** What an action may say about its own HTTP projection. */
28
44
  export interface ActionHttp {
29
45
  /**
30
- * Pin the URL, whatever the app's style derives — for a path a vendor was given and cannot be
31
- * told to change (a webhook registered with a payment provider). Static and lowercase; the pin
32
- * is also what a rename of the export can no longer move.
46
+ * Pin the URL, whatever the app's style derives — for a path a caller already holds and cannot
47
+ * be told to change, or a public `/v1` shaped by resource (`/orders/:id/refund`). Lowercase
48
+ * static segments and `:param`s, each a REQUIRED string key of `input:`: the URL's value is merged
49
+ * into the input before it is parsed, so the one schema validates it and the policy, the row
50
+ * loader and the handler read one object. The pin is also what a rename of the export can no
51
+ * longer move. A vendor's webhook goes to `webhookReceiver()` (`@ultimat3/jobs`) instead: an
52
+ * action's schema answers before any signature is checked.
33
53
  */
34
54
  readonly path?: string;
55
+ /** The verb the pin answers: `'POST'` (default), `'PUT'`, `'PATCH'` or `'DELETE'`. Needs `path`. */
56
+ readonly method?: ActionHttpMethod;
35
57
  }
36
58
 
37
- /** Static, lowercase, no parameters, no trailing slash. */
38
- const PINNED_PATH = /^(\/[a-z0-9][a-z0-9._~-]*)+$/;
59
+ /** The method `target` is served with: its pin's, else `POST`. */
60
+ export function methodOfHttp(http: ActionHttp | undefined): ActionHttpMethod {
61
+ return http?.method ?? 'POST';
62
+ }
39
63
 
40
64
  let style: ActionPathStyle = 'resource';
41
65
 
@@ -85,26 +109,47 @@ export function resetActionPathStyle(): void {
85
109
  handedOut.clear();
86
110
  }
87
111
 
88
- /** Refuses a pin that is not a static lowercase path, or one under the framework's `/_x`. */
89
- export function assertPinnedPath(name: string, path: unknown): void {
90
- if (typeof path !== 'string') {
91
- throw new ActionHttpPathInvalidError({ name, path, reason: 'is not a string' });
92
- }
93
- if (!PINNED_PATH.test(path)) {
112
+ /**
113
+ * Refuses a pin that is not a lowercase template, claims `/_x`, repeats a `:param`, or names a
114
+ * `:param` that is not a required string key of `input` — core's one rule, shared with
115
+ * `@ultimat3/query`. No `input` given proves no key, so a parameterised pin is refused then.
116
+ */
117
+ export function assertPinnedPath(name: string, path: unknown, input?: unknown): void {
118
+ const reason =
119
+ pathTemplateProblem(path) ??
120
+ templateKeyProblem(
121
+ templateParams(path as string),
122
+ input === undefined ? undefined : tryIntrospect(input)?.properties,
123
+ );
124
+ if (reason !== undefined) throw new ActionHttpPathInvalidError({ name, path, reason });
125
+ }
126
+
127
+ /** The whole declaration: the path as above, and a method only beside a path, from the four. */
128
+ export function assertPinnedHttp(name: string, http: ActionHttp, input: unknown): void {
129
+ const method: unknown = http.method;
130
+ if (method !== undefined && http.path === undefined) {
94
131
  throw new ActionHttpPathInvalidError({
95
132
  name,
96
- path,
97
- reason:
98
- 'is not a static lowercase path (a parameter, an uppercase letter or a trailing slash)',
133
+ path: http.path,
134
+ reason: `is absent, and http.method ${renderCauseValue(method)} has no pinned path to answer on`,
99
135
  });
100
136
  }
101
- if (path === '/_x' || path.startsWith('/_x/')) {
137
+ if (method !== undefined && !ACTION_HTTP_METHODS.some((known) => known === method)) {
102
138
  throw new ActionHttpPathInvalidError({
103
139
  name,
104
- path,
105
- reason: 'claims /_x, the framework namespace',
140
+ path: http.path,
141
+ reason: `is declared with http.method ${renderCauseValue(method)}, which is not one of ${ACTION_HTTP_METHODS.join(', ')}`,
106
142
  });
107
143
  }
144
+ if (http.path !== undefined) assertPinnedPath(name, http.path, input);
145
+ }
146
+
147
+ /** `{ path, method }` exactly when `target` pins its URL — the manifest's fact, absent otherwise. */
148
+ export function actionHttpPinOf(
149
+ target: AnyAction,
150
+ ): { readonly path: string; readonly method: ActionHttpMethod } | undefined {
151
+ const http = defOf(target).http;
152
+ return http?.path === undefined ? undefined : { path: http.path, method: methodOfHttp(http) };
108
153
  }
109
154
 
110
155
  /**
@@ -134,8 +179,45 @@ export function servedActionRoute(name: string): ActionRoute {
134
179
  * The same answer, handed to APP code by name (`derivePath`, `actionHttpPath('name')`) — which
135
180
  * may keep the string, so it is the one recorded; see `handedOut`.
136
181
  */
137
- export function resolveActionRoute(name: string): ActionRoute {
138
- const route = servedActionRoute(name);
182
+ export function resolveActionRoute(name: string, params?: unknown): ActionRoute {
183
+ const route = filledRoute(name, servedActionRoute(name), params);
139
184
  handedOut.set(name, route.path);
140
185
  return route;
141
186
  }
187
+
188
+ /**
189
+ * A parameterised route with its `:param`s filled from `params`, each encoded as one segment. No
190
+ * values for a template is refused, never answered with `:id` spelled into a URL nothing serves.
191
+ */
192
+ export function filledRoute(name: string, route: ActionRoute, params: unknown): ActionRoute {
193
+ if (templateParams(route.path).length === 0) return route;
194
+ const filled = fillTemplate(route.path, params);
195
+ if (filled.ok) return { ...route, path: filled.path };
196
+ throw new ActionHttpPathInvalidError({
197
+ name,
198
+ path: route.path,
199
+ reason: `:${filled.missing} ${filled.reason}`,
200
+ fix: `actionHttpPath('${name}', { ${filled.missing}: '<value>' }) — or call ${name}.client(options), which fills it from the input`,
201
+ });
202
+ }
203
+
204
+ /**
205
+ * A `:param` pin's OpenAPI parameters, `in: path` and required, in template order, each with its
206
+ * input member's own schema — the request body's component drops the same keys (`openapi.ts`).
207
+ */
208
+ export function pathParametersOf(
209
+ path: string,
210
+ input: StandardSchemaV1,
211
+ ): readonly Record<string, unknown>[] {
212
+ const params = templateParams(path);
213
+ if (params.length === 0) return [];
214
+ const properties = jsonSchemaOf(input)['properties'];
215
+ const members: Readonly<Record<string, unknown>> =
216
+ typeof properties === 'object' && properties !== null ? { ...properties } : {};
217
+ return params.map((name) => ({
218
+ name,
219
+ in: 'path',
220
+ required: true,
221
+ schema: Object.hasOwn(members, name) ? members[name] : { type: 'string' },
222
+ }));
223
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The HTTP projection's streamed answer: a stream-declaring action asked for with
3
+ * `accept: text/event-stream` answers `event: chunk` per yielded chunk, then ONE `event: done`
4
+ * (the output) or `event: error` (the problem document), then closes. A refusal decided before the
5
+ * handler starts — the policy, the input, the rate limit — is the ordinary problem response.
6
+ */
7
+
8
+ import { useContext, withChildContext } from '@ultimat3/core';
9
+ import { toProblem } from '@ultimat3/http';
10
+ import type { ChunkSink } from './action-stream';
11
+
12
+ export const EVENT_STREAM = 'text/event-stream';
13
+
14
+ /** A comment line every interval, so an idle proxy does not close a long answer. */
15
+ export const HEARTBEAT_MS = 15_000;
16
+
17
+ /** True for a request that lists `text/event-stream` in its `accept`. */
18
+ export function wantsEventStream(accept: string | null): boolean {
19
+ if (accept === null) return false;
20
+ return accept
21
+ .split(',')
22
+ .some((part) => part.split(';')[0]?.trim().toLowerCase() === EVENT_STREAM);
23
+ }
24
+
25
+ const encoder = new TextEncoder();
26
+ const STARTED: unique symbol = Symbol('started');
27
+
28
+ /** One SSE frame. JSON never holds a raw newline, so `data:` is always one line. */
29
+ export function sseFrame(event: 'chunk' | 'done' | 'error', data: unknown): Uint8Array {
30
+ return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`);
31
+ }
32
+
33
+ /** Runs the invocation with `sink` receiving its chunks; resolves the output. */
34
+ export type StreamRun = (sink: ChunkSink) => Promise<unknown>;
35
+
36
+ /**
37
+ * Starts `run` and answers once the handler has started (the stream) or the invocation settled
38
+ * first (a refusal is THROWN, so the route's error path answers it; a replay is a lone `done`).
39
+ */
40
+ export async function streamAnswer(
41
+ owner: object,
42
+ run: StreamRun,
43
+ heartbeatMs: number = HEARTBEAT_MS,
44
+ ): Promise<Response> {
45
+ const requestId = useContext().requestId;
46
+ // The client leaving cancels the body; that aborts this signal, added to the request's own.
47
+ const left = new AbortController();
48
+ let out: ReadableStreamDefaultController<Uint8Array> | undefined;
49
+ let closed = false;
50
+ const send = (bytes: Uint8Array): void => {
51
+ if (!closed) out?.enqueue(bytes);
52
+ };
53
+ let begin: () => void = () => undefined;
54
+ const started = new Promise<typeof STARTED>((resolve) => {
55
+ begin = () => resolve(STARTED);
56
+ });
57
+ const sink: ChunkSink = {
58
+ owner,
59
+ start: () => begin(),
60
+ push: (chunk) => send(sseFrame('chunk', chunk)),
61
+ };
62
+ const settled = withChildContext({ signal: left.signal }, () => run(sink));
63
+ // Settled without starting is a refusal — thrown here, before any byte — or a replayed answer,
64
+ // which streams as a lone `done`.
65
+ await Promise.race([started, settled]);
66
+ let timer: ReturnType<typeof setInterval> | undefined;
67
+ const body = new ReadableStream<Uint8Array>({
68
+ start(controller) {
69
+ out = controller;
70
+ timer = setInterval(() => send(encoder.encode(': keep-alive\n\n')), heartbeatMs);
71
+ const finish = (bytes: Uint8Array): void => {
72
+ clearInterval(timer);
73
+ send(bytes);
74
+ if (!closed) {
75
+ closed = true;
76
+ controller.close();
77
+ }
78
+ };
79
+ settled.then(
80
+ (output) => finish(sseFrame('done', output)),
81
+ (error: unknown) => finish(sseFrame('error', toProblem(error, { requestId }))),
82
+ );
83
+ },
84
+ cancel() {
85
+ closed = true;
86
+ clearInterval(timer);
87
+ left.abort();
88
+ },
89
+ });
90
+ return new Response(body, {
91
+ status: 200,
92
+ headers: {
93
+ 'content-type': `${EVENT_STREAM}; charset=utf-8`,
94
+ 'cache-control': 'no-store',
95
+ // nginx and its kin buffer a response unless told not to; a buffered stream is a JSON answer.
96
+ 'x-accel-buffering': 'no',
97
+ },
98
+ });
99
+ }
package/src/http.ts CHANGED
@@ -10,24 +10,29 @@ import type { Deprecation } from '@ultimat3/core';
10
10
  import {
11
11
  IDEMPOTENCY_HEADER,
12
12
  isMcpExposed,
13
+ mergePathParams,
13
14
  RECORDS_OPENAPI_HEADER,
15
+ REPLAYED_HEADER,
14
16
  recordDeprecatedCall,
15
17
  recordEnvelopeSchema,
16
18
  renderDeprecation,
19
+ templateParams,
17
20
  useContext,
18
21
  withWriteOrigin,
19
22
  writeDigest,
20
23
  } from '@ultimat3/core';
21
24
  import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
22
- // `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
23
- // and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
24
- // a copy here would be a second answer to "what does this limit mean" for the read half.
25
- import { jsonResponse, publishRateLimit, redirect, takeRedirect, toBucket } from '@ultimat3/http';
25
+ import { jsonResponse, publishRateLimit, redirect, takeRedirect } from '@ultimat3/http';
26
26
  import { admitsAnonymous, policyCapability, policyPermissions } from '@ultimat3/policy';
27
27
  import type { ActionRateLimit, AnyAction } from './action';
28
+ import { withChunkSink } from './action-stream';
28
29
  import { ActionDeprecationInvalidError } from './errors';
29
- import { actionPathStyle, servedActionRoute } from './http-path';
30
+ import { ActionDeprecationSuccessorInvalidError } from './errors-http';
31
+ import { actionPathStyle, methodOfHttp, pathParametersOf, servedActionRoute } from './http-path';
32
+ import { EVENT_STREAM, streamAnswer, wantsEventStream } from './http-stream';
30
33
  import { actionName, defOf, invoke } from './invoke';
34
+ import type { ActionMeter } from './meter';
35
+ import { meterFact } from './meter';
31
36
  import {
32
37
  inputSchemaName,
33
38
  outputSchemaName,
@@ -35,10 +40,10 @@ import {
35
40
  schemaRef,
36
41
  toOperationId,
37
42
  } from './naming';
43
+ import { withDeclaredErrors } from './openapi-errors';
44
+ import { assertActionRateLimit, fixedRateLimit } from './rate-limit-gate';
38
45
  import { carriesRecords, recordResponse } from './record-wire';
39
46
 
40
- export const REPLAYED_HEADER = 'x-ultimate-replayed';
41
-
42
47
  /** The digest a request's idempotency key names its write by; none for a missing or blank one. */
43
48
  async function writeOriginOf(req: UltimateRequest): Promise<string | undefined> {
44
49
  const key = req.header(IDEMPOTENCY_HEADER);
@@ -55,9 +60,18 @@ export function toRoute(target: AnyAction): Route {
55
60
  // name still has the path it declared. Reading only the seated registry answered the style's
56
61
  // derived path for an unseated pinned action — right by coincidence under `resource`, wrong
57
62
  // under `readable` (a shared test worker whose app declared it, 22.7).
58
- const pinned = defOf(target).http?.path;
59
- const path = pinned ?? servedActionRoute(actionName(target)).path;
60
- return projectRoute(target, path, (req) => req.bodyRaw());
63
+ const http = defOf(target).http;
64
+ const path = http?.path ?? servedActionRoute(actionName(target)).path;
65
+ const owner = `action "${actionName(target)}"`;
66
+ // A `:param` pin's values come from the router's DECODED params and join the body before
67
+ // `invoke` parses it: one input for the schema, the policy, the row loader and the handler. A
68
+ // body naming the same key differently is refused (`X_HTTP_PATH_INPUT_CONFLICT`), never overridden.
69
+ const readInput =
70
+ templateParams(path).length === 0
71
+ ? (req: UltimateRequest) => req.bodyRaw()
72
+ : async (req: UltimateRequest) =>
73
+ mergePathParams({ owner, template: path, params: req.params, raw: await req.bodyRaw() });
74
+ return projectRoute(target, path, readInput, methodOfHttp(http));
61
75
  }
62
76
 
63
77
  /**
@@ -68,17 +82,23 @@ export function toRoute(target: AnyAction): Route {
68
82
  * `toRoute`'s: the policy, the idempotency, the redirect a handler asks for, the problem document.
69
83
  */
70
84
  export function toPostBinding(target: AnyAction, path: string): Route {
71
- return projectRoute(target, path, async (req) => {
72
- const body = await req.bodyRaw();
73
- const fields = typeof body === 'object' && body !== null && !Array.isArray(body) ? body : {};
74
- return { ...fields, ...req.queryRaw() };
75
- });
85
+ return projectRoute(
86
+ target,
87
+ path,
88
+ async (req) => {
89
+ const body = await req.bodyRaw();
90
+ const fields = typeof body === 'object' && body !== null && !Array.isArray(body) ? body : {};
91
+ return { ...fields, ...req.queryRaw() };
92
+ },
93
+ 'POST',
94
+ );
76
95
  }
77
96
 
78
97
  function projectRoute(
79
98
  target: AnyAction,
80
99
  path: string,
81
100
  readInput: (req: UltimateRequest) => Promise<unknown>,
101
+ method: Route['method'],
82
102
  ): Route {
83
103
  const name = actionName(target);
84
104
  const def = defOf(target);
@@ -87,8 +107,9 @@ function projectRoute(
87
107
  const sunsetting = deprecationHeadersFor(name, def.deprecated);
88
108
  // Same rule, for a limit the limiter could not run on: refused at mount (`X_RATE_LIMIT_INVALID`),
89
109
  // never on the first request that tries to spend it.
90
- if (def.rateLimit !== undefined) toBucket(name, def.rateLimit);
110
+ if (def.rateLimit !== undefined) assertActionRateLimit(name, def.rateLimit);
91
111
  const enveloped = carriesRecords(target.output);
112
+ const streams = def.stream !== undefined;
92
113
 
93
114
  const handler = async (req: UltimateRequest): Promise<Response> => {
94
115
  if (sunsetting !== undefined) {
@@ -112,18 +133,25 @@ function projectRoute(
112
133
  // The header NAMES the write whatever the declaration, idempotent or not: a page sends one
113
134
  // with every mutation, and the `records` frames its rows produce carry the digest so that
114
135
  // page can tell its own echo from somebody else's change (`@ultimat3/core`'s write origin).
115
- const result = await withWriteOrigin(await writeOriginOf(req), () =>
116
- invoke(target, raw, {
117
- surface: 'http',
118
- idempotencyKey: key,
119
- onReplay: () => {
120
- replayed = true;
121
- },
122
- clientAddress: req.ctx.ip,
123
- onRateLimit: (decision) =>
124
- publishRateLimit(req.ctx, decision, useContext().now().getTime()),
125
- }),
126
- );
136
+ const origin = await writeOriginOf(req);
137
+ const call = (): Promise<unknown> =>
138
+ withWriteOrigin(origin, () =>
139
+ invoke(target, raw, {
140
+ surface: 'http',
141
+ idempotencyKey: key,
142
+ onReplay: () => {
143
+ replayed = true;
144
+ },
145
+ clientAddress: req.ctx.ip,
146
+ onRateLimit: (decision) =>
147
+ publishRateLimit(req.ctx, decision, useContext().now().getTime()),
148
+ }),
149
+ );
150
+ // The same invocation, its chunks sent as they are yielded: one route, chosen by `accept`.
151
+ if (streams && wantsEventStream(req.header('accept'))) {
152
+ return streamAnswer(def, (sink) => withChunkSink(sink, call));
153
+ }
154
+ const result = await call();
127
155
  // The one thing an action's return value cannot say. `setRedirect()` inside the handler
128
156
  // is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
129
157
  // 200 this used to always return is a header browsers ignore, so a JS-less form left the
@@ -165,7 +193,7 @@ function projectRoute(
165
193
  // called over MCP, over the typed client or directly answered `X_INPUT_INVALID`. One action,
166
194
  // one input schema, two codes, decided by the surface the call arrived through.
167
195
  //
168
- // `invoke`'s own `validateInput` is the one parser now, and nothing is lost by dropping this:
196
+ // `invoke`'s own `parseActionInput` is the one parser now, and nothing is lost by dropping this:
169
197
  // `bodyRaw()` still parses by content-type, caches, and enforces the size cap — the body stage
170
198
  // only ever added SCHEMA validation on top. `X_BODY_INVALID` keeps its job, which is a body
171
199
  // failing a plain `route.ts`'s own schema, where no primitive owns the input.
@@ -179,7 +207,7 @@ function projectRoute(
179
207
  ...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
180
208
  };
181
209
 
182
- return { method: 'POST', path, handler, meta };
210
+ return { method, path, handler, meta };
183
211
  }
184
212
 
185
213
  /**
@@ -219,17 +247,20 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
219
247
  const deprecation = deprecationMetaFor(name, def.deprecated);
220
248
  const outputRef = schemaRef(outputSchemaName(name));
221
249
  const enveloped = carriesRecords(target.output);
250
+ // A `:param` pin's values are path parameters; an unpinned operation's bytes are unchanged.
251
+ const inPath = pathParametersOf(def.http?.path ?? '', target.input);
222
252
  return {
223
253
  operationId: toOperationId(name),
224
254
  tags: [operationTagOf(target)],
225
255
  summary: def.mcp?.description ?? name,
226
256
  ...(deprecation === undefined ? {} : { deprecated: true }),
227
- parameters: idempotent ? [IDEMPOTENCY_PARAMETER] : [],
257
+ parameters: [...inPath, ...(idempotent ? [IDEMPOTENCY_PARAMETER] : [])],
228
258
  requestBody: {
229
259
  required: true,
230
260
  content: { 'application/json': { schema: { $ref: schemaRef(inputSchemaName(name)) } } },
231
261
  },
232
- responses: {
262
+ // The declared refusals join the framework's own, per status (`openapi-errors.ts`).
263
+ responses: withDeclaredErrors(def.errors, {
233
264
  // Only an output that references an entity row changes shape: every other operation's
234
265
  // bytes are the ones `x verify`'s contract diff already holds.
235
266
  '200': enveloped
@@ -238,7 +269,14 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
238
269
  headers: RECORDS_OPENAPI_HEADER,
239
270
  content: { 'application/json': { schema: recordEnvelopeSchema({ $ref: outputRef }) } },
240
271
  }
241
- : { description: 'ok', content: { 'application/json': { schema: { $ref: outputRef } } } },
272
+ : {
273
+ description: 'ok',
274
+ content: {
275
+ 'application/json': { schema: { $ref: outputRef } },
276
+ // A stream-declaring action answers the same route as SSE when asked for it.
277
+ ...(def.stream === undefined ? {} : { [EVENT_STREAM]: STREAM_CONTENT }),
278
+ },
279
+ },
242
280
  // BOTH, because they are two different failures and this operation published only one of
243
281
  // them while the route answered only the other. `X_INPUT_INVALID` is the body that parsed
244
282
  // and failed THIS action's declared schema — the primitive's own code, identical over MCP,
@@ -246,11 +284,13 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
246
284
  // at all: malformed JSON, over `bodyLimitBytes`, an unreadable content type. That one is
247
285
  // HTTP-only, because only HTTP has bytes, and it is raised in `bodyRaw()` before any schema
248
286
  // exists to fail.
249
- '400': problemResponse('X_INPUT_INVALID'),
287
+ '400': problemResponse(
288
+ inPath.length === 0 ? 'X_INPUT_INVALID' : 'X_INPUT_INVALID or X_HTTP_PATH_INPUT_CONFLICT',
289
+ ),
250
290
  '422': problemResponse('X_BODY_INVALID'),
251
291
  '403': problemResponse('policy denied'),
252
292
  ...(idempotent ? { '409': problemResponse('X_IDEMPOTENCY_CONFLICT') } : {}),
253
- },
293
+ }),
254
294
  'x-ultimate': {
255
295
  capability: policyCapability(def.policy),
256
296
  idempotent,
@@ -263,6 +303,8 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
263
303
  // exist. Verbatim, and never derived — this is a published contract, not a label.
264
304
  mcpTool: isMcpExposed(def.mcp) ? name : null,
265
305
  rateLimit: rateLimitMeta(name, def.rateLimit),
306
+ // Absent, not null, when undeclared: an action without a meter keeps `openapi.json`'s bytes.
307
+ ...(def.meter === undefined ? {} : { meter: meterMeta(def.meter) }),
266
308
  // The dates as data, beside the boolean flag OpenAPI defines: `deprecated: true` says an
267
309
  // operation is going away and nothing else, so a client that wants to plan the migration
268
310
  // has to read the sunset out of prose. Absent, not null, for the same byte-stability
@@ -283,6 +325,15 @@ function deprecationHeadersFor(
283
325
  if (deprecated === undefined) return undefined;
284
326
  const successor =
285
327
  deprecated.replacedBy === undefined ? undefined : servedActionRoute(deprecated.replacedBy).path;
328
+ if (deprecated.replacedBy !== undefined && successor !== undefined) {
329
+ if (templateParams(successor).length > 0) {
330
+ throw new ActionDeprecationSuccessorInvalidError({
331
+ name,
332
+ successor: deprecated.replacedBy,
333
+ path: successor,
334
+ });
335
+ }
336
+ }
286
337
  const rendered = renderDeprecation(deprecated, successor);
287
338
  if (!rendered.ok) throw new ActionDeprecationInvalidError(name, rendered.field, rendered.value);
288
339
  return rendered.headers;
@@ -302,14 +353,31 @@ function deprecationMetaFor(
302
353
  function rateLimitMeta(
303
354
  name: string,
304
355
  limit: ActionRateLimit | undefined,
305
- ): Record<string, number> | null {
356
+ ): Record<string, number | string> | null {
306
357
  if (limit === undefined) return null;
307
358
  // Validated through the same call the route makes, so the spec cannot publish a pair the
308
359
  // limiter would have refused to run on.
309
- toBucket(name, limit);
310
- return { limit: limit.limit, windowMs: limit.windowMs };
360
+ assertActionRateLimit(name, limit);
361
+ // A limit resolved per call has no number to publish; saying so keeps the 429 documented.
362
+ return fixedRateLimit(limit) ?? { limit: 'per-call', windowMs: limit.windowMs };
311
363
  }
312
364
 
365
+ /** The meter as spec data: the period and its zone, and the limit when it is a fixed number. */
366
+ function meterMeta(meter: ActionMeter): Record<string, number | string> {
367
+ const fact = meterFact(meter);
368
+ return {
369
+ name: meter.name,
370
+ period: meter.period,
371
+ timeZone: 'UTC',
372
+ limit: fact?.limit ?? 'per-call',
373
+ };
374
+ }
375
+
376
+ /** `accept: text/event-stream`: `chunk` frames, then one `done` (the output) or `error` (a problem). */
377
+ const STREAM_CONTENT: Record<string, unknown> = {
378
+ schema: { type: 'string', description: 'event: chunk | done | error; data: one JSON value' },
379
+ };
380
+
313
381
  const IDEMPOTENCY_PARAMETER: Record<string, unknown> = {
314
382
  name: 'Idempotency-Key',
315
383
  in: 'header',