@ultimat3/action 22.5.1 → 22.6.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/CLAUDE.md CHANGED
@@ -21,6 +21,10 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
21
21
  | `define-api.ts` | `defineApi({ actions, mutators, queries, llm, jobs, tasks })` — the app's one boot call |
22
22
  | `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
23
23
  | `openapi.ts` | deterministic OpenAPI 3.1 document |
24
+ | `openapi-complete.ts` | the COMPLETE document (`defineApi({ openapi })`: info, servers, security schemes, 401/429 by the ROUTE's `meta.auth`) and a bearer mount's own cut |
25
+ | `http-path.ts` | where an action is served: the app's `pathStyle` + a per-action `http.path` pin, read lazily by every projection |
26
+ | `api-declaration.ts` | `defineApi({ http, openapi })` held process-wide for the boot and `x manifest` |
27
+ | `errors-http.ts` | the three HTTP-declaration refusals (codes registered in `errors.ts`) |
24
28
  | `client.ts` | typed RPC client (browser-safe: no server imports) — dispatches through core's `clientTransport` |
25
29
  | `record-wire.ts` | the record envelope on the HTTP projection: `carriesRecords` (from the output schema), the enveloped 200, and its OpenAPI shape. Server-only — `client.ts` never imports it |
26
30
  | `wire-issues.ts` | the ONE reader of a problem document's `issues` member — an untrusted array back into `@ultimat3/schema`'s `ValidationIssue` shape |
@@ -68,6 +72,14 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
68
72
  - No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
69
73
  - **`registerAction` guards the derived PATH as well as the name** (`X_ACTION_PATH_DUPLICATE`; a
70
74
  second index cleared by `resetRegistry`).
75
+ - **A path is the app's `pathStyle` or the action's pin, never a third answer** (`http-path.ts`).
76
+ `'resource'` stays the default (changing it moves every URL). `defineApi` sets the style FIRST and
77
+ `configureActionPathStyle` re-derives every seated action (the module scan may have seated them
78
+ under the default). `derivePath(name)` is pin-aware through `installPinLookup` — handed down by
79
+ `registry.ts`, never imported up (cycle through `action.ts`). `rpc()` cannot see a pin: it takes
80
+ `pathStyle`; `action.client()` holds the pin.
81
+ - **`defineApi({ openapi })` opts into the complete document; absent, `openapi.json`'s bytes are
82
+ unchanged** — an upgrade must not make a committed contract stale by itself.
71
83
  - Registration names the action the app exported, in place. Naming an already-named action is the
72
84
  only case that twins.
73
85
  - A mutator projects `.local`, `.server`, `.conflict` plus every action member — no aliases.
package/README.md CHANGED
@@ -164,10 +164,71 @@ keeps the second number off the first caller's bill.
164
164
 
165
165
  ## Path derivation
166
166
 
167
- First camelCase word is the verb; the rest is the resource, last word pluralized,
168
- kebab-cased. The **MCP tool name is not derived at all** — it is the export name verbatim, because
167
+ One rule per app, declared once — `defineApi({ http: { pathStyle } })` — plus a per-action pin.
168
+
169
+ | `pathStyle` | `publishPost` | `signIn` | `health` | `viewCustomer360` |
170
+ |---|---|---|---|---|
171
+ | `'resource'` (default) | `/api/posts/publish` | `/api/ins/sign` | `/api/healths/invoke` | `/api/customer360s/view` |
172
+ | `'readable'` | `/api/publish-post` | `/api/sign-in` | `/api/health` | `/api/view-customer360` |
173
+
174
+ - **`'resource'`** guesses a noun from the words after the first and pluralizes it — right for
175
+ `verbNoun`, ungrammatical otherwise. It stays the default: changing it moves every URL an app
176
+ serves, so an app opts in.
177
+ - **`'readable'`** guesses nothing: `/api/<kebab-cased export name>`, the rule `/_x/query/<kebab>`
178
+ follows for reads. The OpenAPI tag becomes the resource the POLICY names (`can('cases:create')`
179
+ → `cases`), never a noun parsed from `In`.
180
+ - **`http: { path }` pins one action's URL** whatever the style — for a webhook a vendor holds:
181
+ `action({ …, http: { path: '/api/webhooks/wompi' } })`. Static, lowercase, no params, never
182
+ `/_x` (`X_ACTION_HTTP_PATH_INVALID`). A pin colliding with another path is
183
+ `X_ACTION_PATH_DUPLICATE` at boot.
184
+ - Every server-side reader agrees: `toRoute`, `toOpenApiOperation`, `describeAction`,
185
+ `derivePath(name)` (pin-aware) and `actionHttpPath(action | name)`. The browser's `rpc()` derives
186
+ from the name alone, so it is told the style: `rpc({ baseUrl, pathStyle: 'readable' })`; a pinned
187
+ action is called through `action.client()`, which holds the pin.
188
+
189
+ ```ts
190
+ import { defineApi } from '@ultimat3/action';
191
+ import type { BearerResolver } from '@ultimat3/http';
192
+
193
+ declare const resolveToken: BearerResolver; // the app's MCP token resolver
194
+ declare const MCP_SCOPES: Readonly<Record<string, readonly string[]>>;
195
+
196
+ export const api = defineApi({
197
+ // actions, queries, jobs, tasks — as always
198
+ http: {
199
+ pathStyle: 'readable',
200
+ // A second door onto a cut of the SAME routes: Authorization: Bearer only (a session cookie
201
+ // authenticates nothing there), 404 for a primitive outside the token's scopes, and a
202
+ // per-token allowance answered with RateLimit-* and 429 + Retry-After.
203
+ mounts: [{
204
+ prefix: '/v1',
205
+ scopes: MCP_SCOPES, // scope -> [primitive names]; the map defineAppMcp takes
206
+ resolveToken, // the MCP resolver: token -> { actor, scopes } | null
207
+ rateLimit: { limit: 120, windowMs: 60_000 },
208
+ openapi: 'openapi.v1.json', // x manifest writes only the cut here; x verify checks it
209
+ }],
210
+ },
211
+ // Declaring it (any key) makes openapi.json COMPLETE: info, servers, securitySchemes
212
+ // (cookie; bearer when a mount exists), 401 + 429 (Retry-After) on every authenticated
213
+ // operation, RateLimit-* on rate-limited ones. Absent, the bytes are what they were.
214
+ openapi: {
215
+ title: 'Notificado API',
216
+ version: '1.0.0',
217
+ servers: [{ url: 'https://www.notificado.co' }],
218
+ sessionCookie: 'session',
219
+ },
220
+ });
221
+ ```
222
+
223
+ `toPostBinding(action, path)` is the same projection bound to a PAGE's URL for `POST`, the URL's
224
+ query merged over the posted fields (query wins): what `defineRoute({ post: '<action>' })` mounts,
225
+ for an RFC 8058 one-click unsubscribe.
226
+
227
+ The **MCP tool name is not derived at all** — it is the export name verbatim, because
169
228
  that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell.
170
229
 
230
+ Under the default style:
231
+
171
232
  | Action | Route | MCP tool |
172
233
  |---|---|---|
173
234
  | `publishPost` | `POST /api/posts/publish` | `publishPost` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "22.5.1",
3
+ "version": "22.6.0",
4
4
  "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,11 +34,11 @@
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/cache": "22.5.1",
38
- "@ultimat3/core": "22.5.1",
39
- "@ultimat3/entity": "22.5.1",
40
- "@ultimat3/http": "22.5.1",
41
- "@ultimat3/policy": "22.5.1",
42
- "@ultimat3/schema": "22.5.1"
37
+ "@ultimat3/cache": "22.6.0",
38
+ "@ultimat3/core": "22.6.0",
39
+ "@ultimat3/entity": "22.6.0",
40
+ "@ultimat3/http": "22.6.0",
41
+ "@ultimat3/policy": "22.6.0",
42
+ "@ultimat3/schema": "22.6.0"
43
43
  }
44
44
  }
package/src/action.ts CHANGED
@@ -14,13 +14,14 @@ import type { ContractTest, ContractTestOptions } from './contract-test';
14
14
  import type { Deprecation } from './deprecation';
15
15
  import { facadeFor } from './facade';
16
16
  import type { OpenApiOperation } from './http';
17
+ import type { ActionHttp } from './http-path';
18
+ import { routeFor } from './http-path';
17
19
  import type { IdempotencyStore } from './idempotency';
18
20
  import { actionName, defOf, hasDef, invoke, stashDef } from './invoke';
19
21
  import type { ActionJobHandle } from './job-handle';
20
22
  import type { JsonSchemaObject } from './json-schema';
21
23
  import { jsonSchemaOf } from './json-schema';
22
24
  import type { McpToolDescriptor } from './mcp-tool';
23
- import { derivePath } from './naming';
24
25
  import {
25
26
  type ActionPolicy,
26
27
  policyCapability,
@@ -91,6 +92,12 @@ export interface ActionDef<
91
92
  readonly cache?: ActionCache;
92
93
  readonly mcp?: ActionMcp;
93
94
  readonly rateLimit?: ActionRateLimit;
95
+ /**
96
+ * The HTTP projection's one per-action knob: `http: { path }` pins the URL whatever the app's
97
+ * `pathStyle` derives — for a webhook a vendor was given. Everything else about the route (the
98
+ * method, the policy, the body) stays the framework's.
99
+ */
100
+ readonly http?: ActionHttp;
94
101
  /**
95
102
  * On its way out. Declared here and projected everywhere at once: `Deprecation` and `Sunset`
96
103
  * response headers (RFC 9745 / RFC 8594), a `rel="successor-version"` link when `replacedBy`
@@ -203,6 +210,7 @@ export interface AnyActionDef {
203
210
  readonly cache?: ActionCache;
204
211
  readonly mcp?: ActionMcp;
205
212
  readonly rateLimit?: ActionRateLimit;
213
+ readonly http?: ActionHttp;
206
214
  readonly deprecated?: Deprecation;
207
215
  readonly idempotent?: boolean;
208
216
  readonly audit?: boolean;
@@ -329,7 +337,7 @@ function build<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1
329
337
  export function describeAction(target: AnyAction): ActionDescriptor {
330
338
  const name = actionName(target);
331
339
  const def = defOf(target);
332
- const path = derivePath(name);
340
+ const path = routeFor(name, def.http?.path);
333
341
  const mcp = def.mcp;
334
342
  return {
335
343
  kind: 'action',
@@ -0,0 +1,123 @@
1
+ /**
2
+ * What an app says about its API as a WHOLE, beside the primitives it registers: how action names
3
+ * become URLs, which bearer-token mounts expose a cut of it, and what its OpenAPI document calls
4
+ * itself. Declared in the one `defineApi()` call and held here, process-wide like the registries,
5
+ * because every reader — the boot that mounts routes, `x manifest` writing `openapi.json` — runs
6
+ * after that module was imported and never has the call's return value in hand.
7
+ */
8
+
9
+ import type { ActionPathStyle } from '@ultimat3/core';
10
+ import type { BearerMountRateLimit, BearerResolver } from '@ultimat3/http';
11
+ import { OpenApiConfigInvalidError } from './errors-http';
12
+
13
+ /** A second door onto a cut of the API, authenticated by `Authorization: Bearer` alone. */
14
+ export interface ApiMount {
15
+ /** `'/v1'`. The cut is served there: `POST /v1/create-case`, `GET /v1/case-list`. */
16
+ readonly prefix: string;
17
+ /**
18
+ * Scope → the actions and queries it covers, by export name — the map `defineAppMcp({ scopes })`
19
+ * takes, so one declaration governs both surfaces. Its union is the cut; a token sees a
20
+ * primitive only while it carries that primitive's scope, and anything else answers 404.
21
+ */
22
+ readonly scopes: Readonly<Record<string, readonly string[]>>;
23
+ /** The app's token resolver — the SAME function its MCP endpoint is given. */
24
+ readonly resolveToken: BearerResolver;
25
+ /** Requests per window per token, answered with `RateLimit-*` and a 429 + `Retry-After`. */
26
+ readonly rateLimit?: BearerMountRateLimit;
27
+ /**
28
+ * An app-root file this mount's OWN OpenAPI document is written to (`'openapi.v1.json'`): only
29
+ * the cut, at the mounted paths, under a `bearer` security scheme. `x manifest` writes it and
30
+ * `x verify` refuses it stale, exactly as it does `openapi.json`.
31
+ */
32
+ readonly openapi?: string;
33
+ }
34
+
35
+ export interface ApiHttp {
36
+ /** `'readable'`: `signIn` → `/api/sign-in`. Default `'resource'`: `/api/ins/sign`. */
37
+ readonly pathStyle?: ActionPathStyle;
38
+ readonly mounts?: readonly ApiMount[];
39
+ }
40
+
41
+ export interface OpenApiServer {
42
+ readonly url: string;
43
+ readonly description?: string;
44
+ }
45
+
46
+ /**
47
+ * Declaring this block — any key of it — opts the app's documents into the COMPLETE shape: its
48
+ * own `info`, `servers`, `components.securitySchemes`, and 401 / 429 problem responses on every
49
+ * authenticated operation. Absent, `openapi.json` keeps the bytes it had, so an upgrade does not
50
+ * turn an app's committed contract stale by itself.
51
+ */
52
+ export interface ApiOpenApi {
53
+ /** `info.title`. Defaults to the app's name. */
54
+ readonly title?: string;
55
+ /** `info.version` — the API's contract version, not the app's build. Defaults to the app's. */
56
+ readonly version?: string;
57
+ readonly description?: string;
58
+ readonly servers?: readonly OpenApiServer[];
59
+ /** The session cookie the `cookie` security scheme names. Default `'session'`. */
60
+ readonly sessionCookie?: string;
61
+ }
62
+
63
+ export interface ApiDeclaration {
64
+ readonly http?: ApiHttp;
65
+ readonly openapi?: ApiOpenApi;
66
+ }
67
+
68
+ let declared: ApiDeclaration = {};
69
+
70
+ const DOCUMENT_FILE = /^[A-Za-z0-9._-]+\.json$/;
71
+
72
+ const isAbsoluteHttp = (url: string): boolean => {
73
+ const parsed = URL.parse(url);
74
+ return parsed !== null && (parsed.protocol === 'https:' || parsed.protocol === 'http:');
75
+ };
76
+
77
+ function assertOpenApi(openapi: ApiOpenApi | undefined, mounts: readonly ApiMount[]): void {
78
+ if (openapi !== undefined) {
79
+ for (const key of ['title', 'version'] as const) {
80
+ const value = openapi[key];
81
+ if (value !== undefined && (typeof value !== 'string' || value.trim() === '')) {
82
+ throw new OpenApiConfigInvalidError(`${key} is empty`);
83
+ }
84
+ }
85
+ for (const server of openapi.servers ?? []) {
86
+ if (!isAbsoluteHttp(server.url)) {
87
+ throw new OpenApiConfigInvalidError(
88
+ `server ${JSON.stringify(server.url)} is not an absolute http(s) URL`,
89
+ );
90
+ }
91
+ }
92
+ }
93
+ const files = new Set<string>(['openapi.json']);
94
+ for (const mount of mounts) {
95
+ if (mount.openapi === undefined) continue;
96
+ if (!DOCUMENT_FILE.test(mount.openapi) || files.has(mount.openapi)) {
97
+ throw new OpenApiConfigInvalidError(
98
+ `the ${mount.prefix} mount's document ${JSON.stringify(mount.openapi)} is not a distinct app-root .json file name`,
99
+ );
100
+ }
101
+ files.add(mount.openapi);
102
+ }
103
+ }
104
+
105
+ /** Called by `defineApi`. A later call replaces only the sections it declares. */
106
+ export function declareApi(next: ApiDeclaration): void {
107
+ assertOpenApi(next.openapi, next.http?.mounts ?? []);
108
+ declared = {
109
+ ...declared,
110
+ ...(next.http === undefined ? {} : { http: next.http }),
111
+ ...(next.openapi === undefined ? {} : { openapi: next.openapi }),
112
+ };
113
+ }
114
+
115
+ /** What the app declared — `{}` for an app that declared nothing beyond its primitives. */
116
+ export function apiDeclaration(): ApiDeclaration {
117
+ return declared;
118
+ }
119
+
120
+ /** Test seam, called by `resetRegistry`. */
121
+ export function resetApiDeclaration(): void {
122
+ declared = {};
123
+ }
package/src/client.ts CHANGED
@@ -15,7 +15,13 @@
15
15
  * check (`onResponse`) and the action-named error decode (`decodeError`). The caller still gets exactly the action's output — the envelope
16
16
  * never reaches the return type.
17
17
  */
18
- import type { ClientFlight, ClientRetry, FetchLike, UltimateError } from '@ultimat3/core';
18
+ import type {
19
+ ActionPathStyle,
20
+ ClientFlight,
21
+ ClientRetry,
22
+ FetchLike,
23
+ UltimateError,
24
+ } from '@ultimat3/core';
19
25
  import {
20
26
  actionPath,
21
27
  clientTransport,
@@ -77,6 +83,13 @@ export interface ClientOptions {
77
83
  * nothing else, which is what every caller written before this option existed already gets.
78
84
  */
79
85
  readonly flight?: ClientFlight;
86
+ /**
87
+ * The server's `defineApi({ http: { pathStyle } })`, restated: the browser derives each URL from
88
+ * the name and cannot read the server's registry. Absent is `'resource'`, the server's default.
89
+ * A per-action `http.path` pin is honoured by `action.client()` (it holds the declaration); the
90
+ * name-only `rpc()` proxy cannot see one, so a pinned action is called through its own client.
91
+ */
92
+ readonly pathStyle?: ActionPathStyle;
80
93
  }
81
94
 
82
95
  /**
@@ -108,8 +121,10 @@ export function rpc<TActions extends ActionMap>(options: ClientOptions): Client<
108
121
  export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
109
122
  name: string,
110
123
  options: ClientOptions,
124
+ pinnedPath?: string,
111
125
  ): ClientMethod<TInput, TOutput> {
112
- const url = `${options.baseUrl.replace(/\/+$/, '')}${actionPath(name)}`;
126
+ const path = pinnedPath ?? actionPath(name, options.pathStyle ?? 'resource');
127
+ const url = `${options.baseUrl.replace(/\/+$/, '')}${path}`;
113
128
  const onResponse = (response: Response): void =>
114
129
  assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
115
130
  const decodeError = (status: number, text: string): UltimateError | undefined =>
package/src/define-api.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  */
7
7
 
8
8
  import { type PrimitiveKind, primitiveRegistrar, type RegisteredPrimitive } from '@ultimat3/core';
9
- import { registerActions } from './registry';
9
+ import type { ApiHttp, ApiOpenApi } from './api-declaration';
10
+ import { declareApi } from './api-declaration';
11
+ import { configureActionPathStyle, registerActions } from './registry';
10
12
 
11
13
  /** A module namespace: `import * as postActions from './actions'`. */
12
14
  export type ApiModule = Readonly<Record<string, unknown>>;
@@ -25,6 +27,13 @@ export interface ApiDef {
25
27
  readonly jobs?: ApiModules;
26
28
  /** A task only enqueues jobs; handing it over here is what names its cron after its export. */
27
29
  readonly tasks?: ApiModules;
30
+ /**
31
+ * The API's HTTP shape as a whole: `pathStyle` (how every action name becomes a URL) and
32
+ * `mounts` (bearer-token doors onto a cut of it, like `/v1`). Read by the boot and `x manifest`.
33
+ */
34
+ readonly http?: ApiHttp;
35
+ /** Declaring it opts `openapi.json` into the complete document — see `ApiOpenApi`. */
36
+ readonly openapi?: ApiOpenApi;
28
37
  }
29
38
 
30
39
  type Get<TDef, TKey extends string> = TKey extends keyof TDef ? TDef[TKey] : undefined;
@@ -82,6 +91,13 @@ export interface Api<TDef extends ApiDef> {
82
91
  * `@ultimat3/jobs` are on this tier and importing either sideways is a build error.
83
92
  */
84
93
  export function defineApi<const TDef extends ApiDef>(def: TDef): Api<TDef> {
94
+ // FIRST: the style decides every path the registrations below are checked against, and it
95
+ // re-derives the ones the framework's module scan may already have seated under the default.
96
+ if (def.http?.pathStyle !== undefined) configureActionPathStyle(def.http.pathStyle);
97
+ declareApi({
98
+ ...(def.http === undefined ? {} : { http: def.http }),
99
+ ...(def.openapi === undefined ? {} : { openapi: def.openapi }),
100
+ });
85
101
  const actionModules = [
86
102
  ...moduleList(def.actions),
87
103
  ...moduleList(def.mutators),
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The refusals of the HTTP declaration an app makes about its actions: a pinned `http.path`, the
3
+ * app's `pathStyle`, and the `openapi` block. Split from `errors.ts` at its ceiling; the codes and
4
+ * titles stay registered there, one registry.
5
+ */
6
+
7
+ import { renderCauseValue, UltimateError } from '@ultimat3/core';
8
+
9
+ export class ActionHttpPathInvalidError extends UltimateError {
10
+ constructor(input: { name: string; path: unknown; reason: string }) {
11
+ super({
12
+ code: 'X_ACTION_HTTP_PATH_INVALID',
13
+ cause: `action "${input.name}" pins http.path ${renderCauseValue(input.path)}, which ${input.reason}`,
14
+ fix: "pin a static lowercase path: http: { path: '/api/webhooks/wompi' } — a leading slash, segments of a-z 0-9 . _ ~ -, no :param or *wildcard, no trailing slash, never under /_x",
15
+ meta: { action: input.name },
16
+ });
17
+ }
18
+ }
19
+
20
+ export class ActionPathStyleInvalidError extends UltimateError {
21
+ constructor(value: unknown) {
22
+ super({
23
+ code: 'X_ACTION_PATH_STYLE_INVALID',
24
+ cause: `defineApi({ http: { pathStyle: ${renderCauseValue(value)} } }) is not 'resource' or 'readable'`,
25
+ fix: "defineApi({ ..., http: { pathStyle: 'readable' } }) — or omit it for 'resource', the default",
26
+ });
27
+ }
28
+ }
29
+
30
+ export class OpenApiConfigInvalidError extends UltimateError {
31
+ constructor(reason: string) {
32
+ super({
33
+ code: 'X_OPENAPI_CONFIG_INVALID',
34
+ cause: `defineApi({ openapi }) cannot produce a valid document: ${reason}`,
35
+ fix: "defineApi({ ..., openapi: { title: 'My API', version: '1.0.0', servers: [{ url: 'https://www.example.com' }] } }) — non-empty title and version, absolute http(s) server URLs, and a mount document named like 'openapi.v1.json'",
36
+ });
37
+ }
38
+ }
package/src/errors.ts CHANGED
@@ -55,6 +55,9 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
55
55
  "a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
56
56
  X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
57
57
  X_RPC_FAILED: 'an RPC call failed without a problem+json body',
58
+ X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
59
+ X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
60
+ X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
58
61
  };
59
62
 
60
63
  // One unconditional call: a presence guard would turn "another package claims one of these codes"
package/src/facade.ts CHANGED
@@ -35,7 +35,7 @@ export function facadeFor<TInput extends StandardSchemaV1, TOutput extends Stand
35
35
  invoke(self(), input, { ...options, actor }) as Promise<InferOutput<TOutput>>,
36
36
  tool: () => toMcpTool(self()),
37
37
  openapi: () => toOpenApiOperation(self()),
38
- client: (options) => clientMethodFor(actionName(self()), options),
38
+ client: (options) => clientMethodFor(actionName(self()), options, def.http?.path),
39
39
  job: () => toJobHandle(self()),
40
40
  contract: (options) => contractTestsFor(self(), options),
41
41
  };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Where an action is served: its pinned `http.path` when it declares one, else the app's
3
+ * `pathStyle` applied to its name. The style is declared ONCE per app (`defineApi({ http:
4
+ * { pathStyle } })`) and read lazily by every projection — the route, the OpenAPI operation, the
5
+ * descriptor, the deprecation successor — so no projection can hold a path the others do not.
6
+ */
7
+
8
+ import type { ActionPathStyle, ActionRoute } from '@ultimat3/core';
9
+ import { ACTION_PATH_STYLES, actionRoute } from '@ultimat3/core';
10
+ import { ActionHttpPathInvalidError, ActionPathStyleInvalidError } from './errors-http';
11
+ import { defOf } from './invoke';
12
+ import { seatedActions } from './registry-store';
13
+
14
+ /**
15
+ * Name → the pin its registered action declares, read through the registry's leaf store, so a
16
+ * caller holding only a NAME (`derivePath`, a deprecation's `replacedBy`) gets the pinned URL.
17
+ */
18
+ const pinOf = (name: string): string | undefined => {
19
+ const seated = seatedActions.get(name);
20
+ return seated === undefined ? undefined : defOf(seated).http?.path;
21
+ };
22
+
23
+ /** What an action may say about its own HTTP projection. */
24
+ export interface ActionHttp {
25
+ /**
26
+ * Pin the URL, whatever the app's style derives — for a path a vendor was given and cannot be
27
+ * told to change (a webhook registered with a payment provider). Static and lowercase; the pin
28
+ * is also what a rename of the export can no longer move.
29
+ */
30
+ readonly path?: string;
31
+ }
32
+
33
+ /** Static, lowercase, no parameters, no trailing slash. */
34
+ const PINNED_PATH = /^(\/[a-z0-9][a-z0-9._~-]*)+$/;
35
+
36
+ let style: ActionPathStyle = 'resource';
37
+
38
+ /** The app's declared style — `'resource'` until `defineApi` says otherwise. */
39
+ export function actionPathStyle(): ActionPathStyle {
40
+ return style;
41
+ }
42
+
43
+ /** Called by `defineApi` (and the registry's re-derivation). Refuses an unknown style. */
44
+ export function setActionPathStyle(next: unknown): ActionPathStyle {
45
+ if (!ACTION_PATH_STYLES.some((known) => known === next)) {
46
+ throw new ActionPathStyleInvalidError(next);
47
+ }
48
+ style = next as ActionPathStyle;
49
+ return style;
50
+ }
51
+
52
+ /** Test seam, beside `resetRegistry`. */
53
+ export function resetActionPathStyle(): void {
54
+ style = 'resource';
55
+ }
56
+
57
+ /** Refuses a pin that is not a static lowercase path, or one under the framework's `/_x`. */
58
+ export function assertPinnedPath(name: string, path: unknown): void {
59
+ if (typeof path !== 'string') {
60
+ throw new ActionHttpPathInvalidError({ name, path, reason: 'is not a string' });
61
+ }
62
+ if (!PINNED_PATH.test(path)) {
63
+ throw new ActionHttpPathInvalidError({
64
+ name,
65
+ path,
66
+ reason:
67
+ 'is not a static lowercase path (a parameter, an uppercase letter or a trailing slash)',
68
+ });
69
+ }
70
+ if (path === '/_x' || path.startsWith('/_x/')) {
71
+ throw new ActionHttpPathInvalidError({
72
+ name,
73
+ path,
74
+ reason: 'claims /_x, the framework namespace',
75
+ });
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The route for `name` under the current style, with `pin` replacing the path when present. The
81
+ * verb and resource stay the style's — they label the operation, the path is where it lives.
82
+ */
83
+ export function routeFor(name: string, pin: string | undefined): ActionRoute {
84
+ const derived = actionRoute(name, style);
85
+ return pin === undefined ? derived : { ...derived, path: pin };
86
+ }
87
+
88
+ /** The route a registered NAME is served at: its pin when it declares one, else the style's. */
89
+ export function resolveActionRoute(name: string): ActionRoute {
90
+ return routeFor(name, pinOf(name));
91
+ }
package/src/http.ts CHANGED
@@ -22,6 +22,7 @@ import type { ActionRateLimit, AnyAction } from './action';
22
22
  import type { Deprecation } from './deprecation';
23
23
  import { recordDeprecatedCall, renderDeprecation } from './deprecation';
24
24
  import { ActionDeprecationInvalidError } from './errors';
25
+ import { actionPathStyle } from './http-path';
25
26
  import { actionName, defOf, invoke } from './invoke';
26
27
  import {
27
28
  derivePath,
@@ -31,7 +32,7 @@ import {
31
32
  schemaRef,
32
33
  toOperationId,
33
34
  } from './naming';
34
- import { admitsAnonymous, policyCapability } from './policy-gate';
35
+ import { admitsAnonymous, policyCapability, policyPermissions } from './policy-gate';
35
36
  import { carriesRecords, recordResponse } from './record-wire';
36
37
  import { IDEMPOTENCY_HEADER } from './wire-headers';
37
38
 
@@ -56,8 +57,30 @@ async function writeOriginOf(req: UltimateRequest): Promise<string | undefined>
56
57
  * kebab-cased (`updateUserProfile` -> `/api/user-profiles/update`). See `naming.ts`.
57
58
  */
58
59
  export function toRoute(target: AnyAction): Route {
60
+ return projectRoute(target, derivePath(actionName(target)).path, (req) => req.bodyRaw());
61
+ }
62
+
63
+ /**
64
+ * The same action, bound to a PAGE's URL for `POST` (`defineRoute({ post: 'unsubscribe' })`): the
65
+ * URL's query members merged OVER the body's, so an RFC 8058 one-click `POST /baja?t=…` with
66
+ * `List-Unsubscribe=One-Click` reaches the action as `{ t, 'List-Unsubscribe': 'One-Click' }`.
67
+ * The query wins a name both carry — it is the URL the server minted. Everything else is
68
+ * `toRoute`'s: the policy, the idempotency, the redirect a handler asks for, the problem document.
69
+ */
70
+ 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
+ });
76
+ }
77
+
78
+ function projectRoute(
79
+ target: AnyAction,
80
+ path: string,
81
+ readInput: (req: UltimateRequest) => Promise<unknown>,
82
+ ): Route {
59
83
  const name = actionName(target);
60
- const { path, resource } = derivePath(name);
61
84
  const def = defOf(target);
62
85
  // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
63
86
  // not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
@@ -80,7 +103,7 @@ export function toRoute(target: AnyAction): Route {
80
103
  //
81
104
  // The pipeline already parsed and size-capped the body; parsing it again here
82
105
  // would be a second, differently-behaved parser for the same bytes.
83
- const raw = await req.bodyRaw();
106
+ const raw = await readInput(req);
84
107
  const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
85
108
  let replayed = false;
86
109
  // The header NAMES the write whatever the declaration, idempotent or not: a page sends one
@@ -138,7 +161,7 @@ export function toRoute(target: AnyAction): Route {
138
161
  // only ever added SCHEMA validation on top. `X_BODY_INVALID` keeps its job, which is a body
139
162
  // failing a plain `route.ts`'s own schema, where no primitive owns the input.
140
163
  cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
141
- tags: [resource],
164
+ tags: [operationTagOf(target)],
142
165
  // Name AND numbers. The name alone selected a bucket the limiter's table never held, so
143
166
  // `bucketFor` fell through to `default` — 120 burst for an action that declared 5. The
144
167
  // numbers ride along and `withRouteBuckets` registers them at construction.
@@ -151,6 +174,22 @@ export function toRoute(target: AnyAction): Route {
151
174
  return { method: 'POST', path, handler, meta };
152
175
  }
153
176
 
177
+ /**
178
+ * The operation's one tag. Under `'resource'` it is the derived resource, as it always was. Under
179
+ * `'readable'` the path no longer names a resource, and guessing one from the words after the verb
180
+ * is the defect the style exists to end (`signIn` → `in`) — so the tag is the resource the POLICY
181
+ * names (`can('cases:create')` → `cases`): whose operation it is, which is what a reader groups by.
182
+ */
183
+ export function operationTagOf(target: AnyAction): string {
184
+ const name = actionName(target);
185
+ if (actionPathStyle() === 'resource') return derivePath(name).resource;
186
+ const policy = defOf(target).policy;
187
+ // A grant first; an `allow('x:y')` names its resource in the label alone.
188
+ const named = policyPermissions(policy)[0] ?? policyCapability(policy);
189
+ const resource = /^([a-z0-9][a-z0-9_-]*):/i.exec(named)?.[1];
190
+ return resource === undefined ? 'api' : resource.toLowerCase();
191
+ }
192
+
154
193
  export interface OpenApiOperation {
155
194
  readonly operationId: string;
156
195
  readonly tags: readonly string[];
@@ -168,14 +207,13 @@ export interface OpenApiOperation {
168
207
  export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
169
208
  const name = actionName(target);
170
209
  const def = defOf(target);
171
- const path = derivePath(name);
172
210
  const idempotent = def.idempotent === true;
173
211
  const deprecation = deprecationMetaFor(name, def.deprecated);
174
212
  const outputRef = schemaRef(outputSchemaName(name));
175
213
  const enveloped = carriesRecords(target.output);
176
214
  return {
177
215
  operationId: toOperationId(name),
178
- tags: [path.resource],
216
+ tags: [operationTagOf(target)],
179
217
  summary: def.mcp?.description ?? name,
180
218
  ...(deprecation === undefined ? {} : { deprecated: true }),
181
219
  parameters: idempotent ? [IDEMPOTENCY_PARAMETER] : [],
package/src/index.ts CHANGED
@@ -57,6 +57,18 @@ export type {
57
57
  McpDescriptorMeta,
58
58
  } from './action';
59
59
  export { action, describeAction, isAction } from './action';
60
+ /**
61
+ * The app's API as a whole — `defineApi({ http: { pathStyle, mounts }, openapi })` — read back by
62
+ * the boot (bearer mounts) and `x manifest` (the complete and per-mount documents).
63
+ */
64
+ export type {
65
+ ApiDeclaration,
66
+ ApiHttp,
67
+ ApiMount,
68
+ ApiOpenApi,
69
+ OpenApiServer,
70
+ } from './api-declaration';
71
+ export { apiDeclaration } from './api-declaration';
60
72
  /**
61
73
  * The audit seam. `AuditSink` is the whole extension point: the framework supplies the record
62
74
  * and never the row. `audit-gate.ts` stays unexported — the sink has one caller, and that
@@ -123,14 +135,24 @@ export {
123
135
  RemoteActionError,
124
136
  RpcFailedError,
125
137
  } from './errors';
138
+ export {
139
+ ActionHttpPathInvalidError,
140
+ ActionPathStyleInvalidError,
141
+ OpenApiConfigInvalidError,
142
+ } from './errors-http';
126
143
  export type { OpenApiOperation } from './http';
127
144
  export {
128
145
  BUILD_ID_HEADER,
129
146
  IDEMPOTENCY_HEADER,
147
+ operationTagOf,
130
148
  REPLAYED_HEADER,
131
149
  toOpenApiOperation,
150
+ toPostBinding,
132
151
  toRoute,
133
152
  } from './http';
153
+ /** `http: { path }` on an action pins its URL; the app's `pathStyle` derives every other one. */
154
+ export type { ActionHttp } from './http-path';
155
+ export { actionPathStyle } from './http-path';
134
156
  /**
135
157
  * The idempotency seam. `withIdempotency` and `IDEMPOTENCY_HEADER` are both public, so a plain
136
158
  * mutating `route` can reserve-and-replay exactly as an action does — `idempotencyKeyFor` is the
@@ -208,6 +230,12 @@ export type { ActionPath } from './naming';
208
230
  export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './naming';
209
231
  export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
210
232
  export { buildOpenApi, serializeOpenApi } from './openapi';
233
+ export {
234
+ BEARER_SCHEME,
235
+ COOKIE_SCHEME,
236
+ completeOpenApi,
237
+ mountOpenApi,
238
+ } from './openapi-complete';
211
239
  export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
212
240
  /**
213
241
  * `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
@@ -223,6 +251,8 @@ export {
223
251
  policyPermissions,
224
252
  } from './policy-gate';
225
253
  export {
254
+ actionHttpPath,
255
+ configureActionPathStyle,
226
256
  describeActions,
227
257
  getAction,
228
258
  listActions,
package/src/mutator.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  } from './action';
19
19
  import { action, isAction } from './action';
20
20
  import type { Deprecation } from './deprecation';
21
+ import type { ActionHttp } from './http-path';
21
22
  import { assertConflictClock } from './mutator-clock';
22
23
  import type { ActionPolicy } from './policy-gate';
23
24
 
@@ -94,6 +95,8 @@ export interface MutatorDef<
94
95
  readonly rateLimit?: ActionRateLimit;
95
96
  /** Same key, same meaning as an action's: `Deprecation`/`Sunset` on every response. */
96
97
  readonly deprecated?: Deprecation;
98
+ /** Same key, same meaning as an action's: `http: { path }` pins the URL. */
99
+ readonly http?: ActionHttp;
97
100
  readonly idempotent?: boolean;
98
101
  /**
99
102
  * The row a row-level `policy` decides about — an action's `row`, and dropped on the way into
@@ -164,6 +167,7 @@ export function mutator<
164
167
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
165
168
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
166
169
  ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
170
+ ...(def.http === undefined ? {} : { http: def.http }),
167
171
  ...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
168
172
  ...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
169
173
  ...(def.audit === undefined ? {} : { audit: def.audit }),
package/src/naming.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { ActionRoute } from '@ultimat3/core';
9
- import { actionRoute, splitWords } from '@ultimat3/core';
9
+ import { splitWords } from '@ultimat3/core';
10
+ import { resolveActionRoute } from './http-path';
10
11
 
11
12
  /**
12
13
  * The path rule is `@ultimat3/core`'s `client-paths.ts` — the typed client, the route and the
@@ -18,12 +19,15 @@ export { pluralize, splitWords } from '@ultimat3/core';
18
19
  export type ActionPath = ActionRoute;
19
20
 
20
21
  /**
21
- * `publishPost` -> POST /api/posts/publish
22
- * `updateUserProfile` -> POST /api/user-profiles/update
23
- * `checkout` -> POST /api/checkouts/invoke (single-word fallback)
22
+ * Where the action NAMED `name` is served, as the running app serves it: its `http.path` pin
23
+ * when the registered action declares one, else the app's `pathStyle` over the name.
24
+ *
25
+ * `'resource'` (the default): `publishPost` -> POST /api/posts/publish,
26
+ * `updateUserProfile` -> /api/user-profiles/update, `checkout` -> /api/checkouts/invoke.
27
+ * `'readable'`: `publishPost` -> /api/publish-post, `signIn` -> /api/sign-in.
24
28
  */
25
29
  export function derivePath(name: string): ActionPath {
26
- return actionRoute(name);
30
+ return resolveActionRoute(name);
27
31
  }
28
32
 
29
33
  // There is deliberately no `toToolName`. An MCP tool name is the export name verbatim — the one
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The COMPLETE document an app opts into with `defineApi({ openapi })`: its own `info` and
3
+ * `servers`, the security schemes its routes are reached through, and the two refusals every
4
+ * authenticated operation can answer — 401 and 429, with `Retry-After` — which the per-primitive
5
+ * projections cannot state because they do not know the route an operation is mounted as. Plus
6
+ * the second document a bearer mount publishes: only the cut, at the mounted paths.
7
+ *
8
+ * Built over the merged document (actions AND reads — `@ultimat3/cli` merges the two packages'
9
+ * paths) and over the `Route`s that serve it, so "is this operation authenticated" is the answer
10
+ * `meta.auth` gives the pipeline, never a second derivation. Deterministic like `openapi.ts`: the
11
+ * inputs are sorted and nothing reads the clock or the environment.
12
+ */
13
+
14
+ import type { Route } from '@ultimat3/http';
15
+ import { mountedPath } from '@ultimat3/http';
16
+ import type { ApiMount, ApiOpenApi } from './api-declaration';
17
+ import type { JsonSchemaObject } from './json-schema';
18
+ import { PROBLEM_SCHEMA_NAME, schemaRef } from './naming';
19
+ import type { OpenApiDocument } from './openapi';
20
+
21
+ /** `components.securitySchemes` keys. */
22
+ export const COOKIE_SCHEME = 'cookie';
23
+ export const BEARER_SCHEME = 'bearer';
24
+
25
+ type Operation = Record<string, unknown>;
26
+
27
+ const BEARER_SECURITY_SCHEME = {
28
+ type: 'http',
29
+ scheme: 'bearer',
30
+ description: 'A token issued by the app, sent as Authorization: Bearer <token>.',
31
+ };
32
+
33
+ const RATE_LIMIT_HEADERS: Record<string, unknown> = {
34
+ 'RateLimit-Limit': {
35
+ description: 'Requests the bucket admits per window.',
36
+ schema: { type: 'integer' },
37
+ },
38
+ 'RateLimit-Remaining': {
39
+ description: 'Requests left in the current window.',
40
+ schema: { type: 'integer' },
41
+ },
42
+ 'RateLimit-Reset': {
43
+ description: 'Seconds until the bucket is full again.',
44
+ schema: { type: 'integer' },
45
+ },
46
+ };
47
+
48
+ const RETRY_AFTER_HEADER: Record<string, unknown> = {
49
+ 'Retry-After': {
50
+ description: 'Seconds to wait before retrying.',
51
+ schema: { type: 'integer', minimum: 1 },
52
+ },
53
+ };
54
+
55
+ function problem(description: string, headers?: Record<string, unknown>): Record<string, unknown> {
56
+ return {
57
+ description,
58
+ ...(headers === undefined ? {} : { headers }),
59
+ content: { 'application/problem+json': { schema: { $ref: schemaRef(PROBLEM_SCHEMA_NAME) } } },
60
+ };
61
+ }
62
+
63
+ const lower = (method: string): string => method.toLowerCase();
64
+
65
+ /** `method path` → the route that serves it. */
66
+ function routeIndex(routes: readonly Route[]): ReadonlyMap<string, Route> {
67
+ return new Map(routes.map((route) => [`${lower(route.method)} ${route.path}`, route]));
68
+ }
69
+
70
+ interface Refusals {
71
+ readonly scheme: string;
72
+ /** The mount's own allowance applies to every operation, whatever the route declares. */
73
+ readonly everyOpLimited: boolean;
74
+ readonly unauthorizedHeaders?: Record<string, unknown>;
75
+ }
76
+
77
+ /** One operation, with its route's refusals and security added. Never mutates `operation`. */
78
+ function completeOperation(operation: Operation, route: Route | undefined, refusals: Refusals) {
79
+ const authenticated = route?.meta.auth === 'required';
80
+ const limited = refusals.everyOpLimited || route?.meta.rateLimitBucket !== undefined;
81
+ if (!authenticated && !limited) return operation;
82
+ const responses = { ...(operation['responses'] as Record<string, unknown>) };
83
+ if (limited) {
84
+ const ok = responses['200'] as Record<string, unknown> | undefined;
85
+ if (ok !== undefined) {
86
+ responses['200'] = {
87
+ ...ok,
88
+ headers: {
89
+ ...(ok['headers'] as Record<string, unknown> | undefined),
90
+ ...RATE_LIMIT_HEADERS,
91
+ },
92
+ };
93
+ }
94
+ }
95
+ if (authenticated) {
96
+ responses['401'] = problem(
97
+ 'X_UNAUTHENTICATED — no credential, or one that did not resolve',
98
+ refusals.unauthorizedHeaders,
99
+ );
100
+ }
101
+ responses['429'] = problem(
102
+ 'X_RATE_LIMITED — retry after Retry-After seconds',
103
+ limited ? { ...RETRY_AFTER_HEADER, ...RATE_LIMIT_HEADERS } : RETRY_AFTER_HEADER,
104
+ );
105
+ return {
106
+ ...operation,
107
+ responses,
108
+ ...(authenticated ? { security: [{ [refusals.scheme]: [] }] } : {}),
109
+ };
110
+ }
111
+
112
+ function mapOperations(
113
+ paths: Record<string, unknown>,
114
+ visit: (path: string, method: string, operation: Operation) => Operation,
115
+ ): Record<string, unknown> {
116
+ // `fromEntries`, never `out[path] = …`: a path is a key, and assigning `__proto__` runs a setter.
117
+ return Object.fromEntries(
118
+ Object.entries(paths).map(([path, item]) => [
119
+ path,
120
+ Object.fromEntries(
121
+ Object.entries(item as Record<string, Operation>).map(([method, operation]) => [
122
+ method,
123
+ visit(path, method, operation),
124
+ ]),
125
+ ),
126
+ ]),
127
+ );
128
+ }
129
+
130
+ function infoOf(document: OpenApiDocument, declared: ApiOpenApi) {
131
+ return {
132
+ title: declared.title ?? document.info.title,
133
+ version: declared.version ?? document.info.version,
134
+ ...(declared.description === undefined ? {} : { description: declared.description }),
135
+ };
136
+ }
137
+
138
+ const serversOf = (declared: ApiOpenApi) =>
139
+ declared.servers === undefined || declared.servers.length === 0
140
+ ? {}
141
+ : {
142
+ servers: declared.servers.map((server) => ({
143
+ url: server.url,
144
+ ...(server.description === undefined ? {} : { description: server.description }),
145
+ })),
146
+ };
147
+
148
+ /**
149
+ * `openapi.json`, complete: the app's `info` and `servers`, a `cookie` security scheme (plus
150
+ * `bearer` when a mount exists — it is the same API reached another way), and 401/429 on every
151
+ * operation its route authenticates.
152
+ */
153
+ export function completeOpenApi(
154
+ document: OpenApiDocument,
155
+ input: {
156
+ readonly declared: ApiOpenApi;
157
+ readonly routes: readonly Route[];
158
+ readonly bearer: boolean;
159
+ },
160
+ ): OpenApiDocument {
161
+ const index = routeIndex(input.routes);
162
+ const paths = mapOperations(document.paths, (path, method, operation) =>
163
+ completeOperation(operation, index.get(`${method} ${path}`), {
164
+ scheme: COOKIE_SCHEME,
165
+ everyOpLimited: false,
166
+ }),
167
+ );
168
+ return {
169
+ ...document,
170
+ info: infoOf(document, input.declared),
171
+ ...serversOf(input.declared),
172
+ paths,
173
+ components: {
174
+ ...document.components,
175
+ securitySchemes: {
176
+ [COOKIE_SCHEME]: {
177
+ type: 'apiKey',
178
+ in: 'cookie',
179
+ name: input.declared.sessionCookie ?? 'session',
180
+ },
181
+ ...(input.bearer ? { [BEARER_SCHEME]: BEARER_SECURITY_SCHEME } : {}),
182
+ },
183
+ },
184
+ };
185
+ }
186
+
187
+ /**
188
+ * A bearer mount's own document: only the operations its `scopes` name, re-keyed to the mounted
189
+ * paths, each `bearer`-secured with 401, 404-when-hidden and 429, and only the schemas those
190
+ * operations reference. `x-ultimate.scope` names the scope a token needs for each.
191
+ */
192
+ export function mountOpenApi(
193
+ document: OpenApiDocument,
194
+ input: {
195
+ readonly declared: ApiOpenApi;
196
+ readonly mount: ApiMount;
197
+ readonly routes: readonly Route[];
198
+ },
199
+ ): OpenApiDocument {
200
+ const { mount } = input;
201
+ const scopeOf = new Map<string, string>();
202
+ for (const [scope, names] of Object.entries(mount.scopes)) {
203
+ for (const name of names) scopeOf.set(name, scope);
204
+ }
205
+ // A `Map`: the key is a route path, and a plain object keyed by one would read `__proto__`.
206
+ const mountedItems = new Map<string, Record<string, unknown>>();
207
+ const tags = new Set<string>();
208
+ const byRoute = [...input.routes].sort((a, b) =>
209
+ `${a.path} ${a.method}` < `${b.path} ${b.method}` ? -1 : 1,
210
+ );
211
+ for (const route of byRoute) {
212
+ const scope = scopeOf.get(route.meta.name);
213
+ if (scope === undefined) continue;
214
+ const method = lower(route.method);
215
+ const item = (
216
+ Object.hasOwn(document.paths, route.path) ? document.paths[route.path] : undefined
217
+ ) as Record<string, Operation> | undefined;
218
+ const operation = item?.[method];
219
+ if (operation === undefined) continue;
220
+ const completed = completeOperation(
221
+ operation,
222
+ { ...route, meta: { ...route.meta, auth: 'required' } },
223
+ {
224
+ scheme: BEARER_SCHEME,
225
+ everyOpLimited: mount.rateLimit !== undefined,
226
+ unauthorizedHeaders: {
227
+ 'WWW-Authenticate': { description: 'Bearer', schema: { type: 'string' } },
228
+ },
229
+ },
230
+ );
231
+ const responses = {
232
+ ...(completed['responses'] as Record<string, unknown>),
233
+ '404': problem('X_ROUTE_NOT_FOUND — also the answer for a token without this scope'),
234
+ };
235
+ const extensions = (completed['x-ultimate'] as Record<string, unknown> | undefined) ?? {};
236
+ for (const tag of (completed['tags'] as readonly string[] | undefined) ?? []) tags.add(tag);
237
+ const mounted = mountedPath(mount.prefix, route.path);
238
+ mountedItems.set(mounted, {
239
+ ...mountedItems.get(mounted),
240
+ [method]: { ...completed, responses, 'x-ultimate': { ...extensions, scope } },
241
+ });
242
+ }
243
+ const paths = Object.fromEntries(mountedItems);
244
+ const schemas = referencedSchemas(paths, document.components.schemas);
245
+ return {
246
+ openapi: document.openapi,
247
+ info: infoOf(document, input.declared),
248
+ ...serversOf(input.declared),
249
+ paths,
250
+ components: {
251
+ schemas,
252
+ securitySchemes: { [BEARER_SCHEME]: BEARER_SECURITY_SCHEME },
253
+ },
254
+ tags: [...tags].sort().map((name) => ({ name })),
255
+ };
256
+ }
257
+
258
+ /** Every component schema `root` reaches through `$ref`, transitively. */
259
+ function referencedSchemas(
260
+ root: unknown,
261
+ schemas: Readonly<Record<string, JsonSchemaObject>>,
262
+ ): Record<string, JsonSchemaObject> {
263
+ const prefix = '#/components/schemas/';
264
+ const found: Record<string, JsonSchemaObject> = {};
265
+ const pending: unknown[] = [root];
266
+ while (pending.length > 0) {
267
+ const node = pending.pop();
268
+ if (Array.isArray(node)) {
269
+ pending.push(...node);
270
+ continue;
271
+ }
272
+ if (typeof node !== 'object' || node === null) continue;
273
+ for (const [key, value] of Object.entries(node)) {
274
+ if (key === '$ref' && typeof value === 'string' && value.startsWith(prefix)) {
275
+ const name = value.slice(prefix.length);
276
+ const schema = Object.hasOwn(schemas, name) ? schemas[name] : undefined;
277
+ if (schema !== undefined && !Object.hasOwn(found, name)) {
278
+ found[name] = schema;
279
+ pending.push(schema);
280
+ }
281
+ } else {
282
+ pending.push(value);
283
+ }
284
+ }
285
+ }
286
+ return found;
287
+ }
package/src/openapi.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { AnyAction } from './action';
11
- import { toOpenApiOperation } from './http';
11
+ import { operationTagOf, toOpenApiOperation } from './http';
12
12
  import { actionName } from './invoke';
13
13
  import { type JsonSchemaObject, jsonSchemaOf, sortSchema } from './json-schema';
14
14
  import { derivePath, inputSchemaName, outputSchemaName, PROBLEM_SCHEMA_NAME } from './naming';
@@ -18,13 +18,20 @@ import { stableStringify } from './stable';
18
18
  export interface OpenApiInfo {
19
19
  readonly title: string;
20
20
  readonly version: string;
21
+ readonly description?: string;
21
22
  }
22
23
 
23
24
  export interface OpenApiDocument {
24
25
  readonly openapi: '3.1.0';
25
26
  readonly info: OpenApiInfo;
27
+ /** Only in the complete document (`defineApi({ openapi: { servers } })`). */
28
+ readonly servers?: readonly { readonly url: string; readonly description?: string }[];
26
29
  readonly paths: Record<string, unknown>;
27
- readonly components: { readonly schemas: Record<string, JsonSchemaObject> };
30
+ readonly components: {
31
+ readonly schemas: Record<string, JsonSchemaObject>;
32
+ /** Only in the complete document — `completeOpenApi` / `mountOpenApi`. */
33
+ readonly securitySchemes?: Readonly<Record<string, unknown>>;
34
+ };
28
35
  readonly tags: readonly { readonly name: string }[];
29
36
  }
30
37
 
@@ -43,11 +50,11 @@ export function buildOpenApi(options: BuildOpenApiOptions = {}): OpenApiDocument
43
50
 
44
51
  for (const target of actions) {
45
52
  const name = actionName(target);
46
- const { path, resource } = derivePath(name);
53
+ const { path } = derivePath(name);
47
54
  paths[path] = { post: toOpenApiOperation(target) };
48
55
  schemas[inputSchemaName(name)] = sortSchema(jsonSchemaOf(target.input));
49
56
  schemas[outputSchemaName(name)] = sortSchema(jsonSchemaOf(target.output));
50
- tags.add(resource);
57
+ tags.add(operationTagOf(target));
51
58
  }
52
59
 
53
60
  return {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The registered actions, by name — the one map, held in a leaf so `http-path.ts` can resolve a
3
+ * NAME to its declaration (a pinned `http.path`) without importing `registry.ts`, which imports
4
+ * `action.ts`, which imports `http-path.ts`. `registry.ts` is the only writer.
5
+ */
6
+
7
+ import type { AnyAction } from './action';
8
+
9
+ export const seatedActions = new Map<string, AnyAction>();
package/src/registry.ts CHANGED
@@ -4,15 +4,20 @@
4
4
  * actions. Registration is also where a missing policy becomes a build error.
5
5
  */
6
6
 
7
+ import type { ActionPathStyle } from '@ultimat3/core';
7
8
  import { SchemaUnsupportedError } from '@ultimat3/schema';
8
9
  import type { ActionDescriptor, AnyAction } from './action';
9
10
  import { isAction, nameAction } from './action';
11
+ import { resetApiDeclaration } from './api-declaration';
10
12
  import { ActionDuplicateError, ActionPathDuplicateError, ActionPolicyMissingError } from './errors';
13
+ import { assertPinnedPath, resetActionPathStyle, routeFor, setActionPathStyle } from './http-path';
11
14
  import { assertIdempotencyScope } from './idempotency';
15
+ import { defOf } from './invoke';
12
16
  import { jsonSchemaOf, mcpSchemaOf } from './json-schema';
13
- import { derivePath } from './naming';
17
+ import type { ActionPath } from './naming';
18
+ import { seatedActions } from './registry-store';
14
19
 
15
- const registry = new Map<string, AnyAction>();
20
+ const registry = seatedActions;
16
21
 
17
22
  /**
18
23
  * Derived route -> the action name that owns it. A second index because the name is not the
@@ -46,7 +51,9 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
46
51
  throw new ActionPolicyMissingError(name);
47
52
  }
48
53
  assertProjectable(name, target);
49
- const { path } = derivePath(name);
54
+ const pin = defOf(target).http?.path;
55
+ if (pin !== undefined) assertPinnedPath(name, pin);
56
+ const { path } = routeFor(name, pin);
50
57
  const owner = paths.get(path);
51
58
  if (owner !== undefined && owner !== name) {
52
59
  throw new ActionPathDuplicateError({ name, existing: owner, path });
@@ -57,6 +64,37 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
57
64
  return named;
58
65
  }
59
66
 
67
+ /**
68
+ * Where `target` is served — its pin, else the app's style over its name. A NAME is resolved
69
+ * through the registry, so a pin declared on the action is honoured for a caller that only has the
70
+ * name (a deprecation's `replacedBy`, a page minting an island's endpoint).
71
+ */
72
+ export function actionHttpPath(target: AnyAction | string): ActionPath {
73
+ if (typeof target === 'string') {
74
+ const seated = registry.get(target);
75
+ return seated === undefined ? routeFor(target, undefined) : actionHttpPath(seated);
76
+ }
77
+ return routeFor(target.name, defOf(target).http?.path);
78
+ }
79
+
80
+ /**
81
+ * The app's `pathStyle`, applied to every action already seated and every one registered after.
82
+ * Re-derived whole, so two names the new style folds onto one URL are refused here rather than
83
+ * left for the router to seat one of.
84
+ */
85
+ export function configureActionPathStyle(style: ActionPathStyle): void {
86
+ setActionPathStyle(style);
87
+ paths.clear();
88
+ for (const [name, target] of [...registry.entries()].sort(byName)) {
89
+ const { path } = actionHttpPath(target);
90
+ const owner = paths.get(path);
91
+ if (owner !== undefined && owner !== name) {
92
+ throw new ActionPathDuplicateError({ name, existing: owner, path });
93
+ }
94
+ paths.set(path, name);
95
+ }
96
+ }
97
+
60
98
  /**
61
99
  * Both schemas must reach JSON Schema, and this is where that is decided — boot, beside the policy
62
100
  * check, never the first `tools/list`. `jsonSchemaOf` used to swallow the refusal into
@@ -114,6 +152,8 @@ export function describeActions(): readonly ActionDescriptor[] {
114
152
  export function resetRegistry(): void {
115
153
  registry.clear();
116
154
  paths.clear();
155
+ resetActionPathStyle();
156
+ resetApiDeclaration();
117
157
  }
118
158
 
119
159
  function byName(a: readonly [string, AnyAction], b: readonly [string, AnyAction]): number {