@ultimat3/action 22.5.1 → 22.6.1
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 +12 -0
- package/README.md +69 -2
- package/package.json +7 -7
- package/src/action.ts +10 -2
- package/src/api-declaration.ts +123 -0
- package/src/client.ts +17 -2
- package/src/define-api.ts +17 -1
- package/src/errors-http.ts +67 -0
- package/src/errors.ts +4 -0
- package/src/facade.ts +1 -1
- package/src/http-path.ts +131 -0
- package/src/http.ts +46 -9
- package/src/index.ts +30 -0
- package/src/mutator.ts +4 -0
- package/src/naming.ts +9 -5
- package/src/openapi-complete.ts +287 -0
- package/src/openapi.ts +13 -5
- package/src/registry-store.ts +9 -0
- package/src/registry.ts +47 -3
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,77 @@ keeps the second number off the first caller's bill.
|
|
|
164
164
|
|
|
165
165
|
## Path derivation
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
- **The style is read when the path is derived, so derive it late.** The scan evaluates
|
|
189
|
+
`apps/web/api/index.ts` before any other module, so a page or admin view already sees the declared
|
|
190
|
+
style. A module the index itself imports runs before its `defineApi` call: a module-level
|
|
191
|
+
`const SIGN_OUT = derivePath('signOut').path` there captures the default style, and the
|
|
192
|
+
declaration refuses the switch with `X_ACTION_PATH_DERIVED_EARLY`, naming it. Derive inside the
|
|
193
|
+
component or function instead.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { defineApi } from '@ultimat3/action';
|
|
197
|
+
import type { BearerResolver } from '@ultimat3/http';
|
|
198
|
+
|
|
199
|
+
declare const resolveToken: BearerResolver; // the app's MCP token resolver
|
|
200
|
+
declare const MCP_SCOPES: Readonly<Record<string, readonly string[]>>;
|
|
201
|
+
|
|
202
|
+
export const api = defineApi({
|
|
203
|
+
// actions, queries, jobs, tasks — as always
|
|
204
|
+
http: {
|
|
205
|
+
pathStyle: 'readable',
|
|
206
|
+
// A second door onto a cut of the SAME routes: Authorization: Bearer only (a session cookie
|
|
207
|
+
// authenticates nothing there), 404 for a primitive outside the token's scopes, and a
|
|
208
|
+
// per-token allowance answered with RateLimit-* and 429 + Retry-After.
|
|
209
|
+
mounts: [{
|
|
210
|
+
prefix: '/v1',
|
|
211
|
+
scopes: MCP_SCOPES, // scope -> [primitive names]; the map defineAppMcp takes
|
|
212
|
+
resolveToken, // the MCP resolver: token -> { actor, scopes } | null
|
|
213
|
+
rateLimit: { limit: 120, windowMs: 60_000 },
|
|
214
|
+
openapi: 'openapi.v1.json', // x manifest writes only the cut here; x verify checks it
|
|
215
|
+
}],
|
|
216
|
+
},
|
|
217
|
+
// Declaring it (any key) makes openapi.json COMPLETE: info, servers, securitySchemes
|
|
218
|
+
// (cookie; bearer when a mount exists), 401 + 429 (Retry-After) on every authenticated
|
|
219
|
+
// operation, RateLimit-* on rate-limited ones. Absent, the bytes are what they were.
|
|
220
|
+
openapi: {
|
|
221
|
+
title: 'Notificado API',
|
|
222
|
+
version: '1.0.0',
|
|
223
|
+
servers: [{ url: 'https://www.notificado.co' }],
|
|
224
|
+
sessionCookie: 'session',
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`toPostBinding(action, path)` is the same projection bound to a PAGE's URL for `POST`, the URL's
|
|
230
|
+
query merged over the posted fields (query wins): what `defineRoute({ post: '<action>' })` mounts,
|
|
231
|
+
for an RFC 8058 one-click unsubscribe.
|
|
232
|
+
|
|
233
|
+
The **MCP tool name is not derived at all** — it is the export name verbatim, because
|
|
169
234
|
that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell.
|
|
170
235
|
|
|
236
|
+
Under the default style:
|
|
237
|
+
|
|
171
238
|
| Action | Route | MCP tool |
|
|
172
239
|
|---|---|---|
|
|
173
240
|
| `publishPost` | `POST /api/posts/publish` | `publishPost` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.6.1",
|
|
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.
|
|
38
|
-
"@ultimat3/core": "22.
|
|
39
|
-
"@ultimat3/entity": "22.
|
|
40
|
-
"@ultimat3/http": "22.
|
|
41
|
-
"@ultimat3/policy": "22.
|
|
42
|
-
"@ultimat3/schema": "22.
|
|
37
|
+
"@ultimat3/cache": "22.6.1",
|
|
38
|
+
"@ultimat3/core": "22.6.1",
|
|
39
|
+
"@ultimat3/entity": "22.6.1",
|
|
40
|
+
"@ultimat3/http": "22.6.1",
|
|
41
|
+
"@ultimat3/policy": "22.6.1",
|
|
42
|
+
"@ultimat3/schema": "22.6.1"
|
|
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 =
|
|
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 {
|
|
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
|
|
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 {
|
|
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,67 @@
|
|
|
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
|
+
/**
|
|
31
|
+
* A path was handed out under one style and the app then declared another. The string is already
|
|
32
|
+
* captured — a module-level `const SIGN_OUT = derivePath('signOut').path` — so re-deriving the
|
|
33
|
+
* registry cannot reach it: that form would post to a URL no route serves. Refused at the
|
|
34
|
+
* declaration, naming every stale capture, rather than answered 404 in production.
|
|
35
|
+
*/
|
|
36
|
+
export class ActionPathDerivedEarlyError extends UltimateError {
|
|
37
|
+
constructor(input: {
|
|
38
|
+
readonly style: string;
|
|
39
|
+
readonly stale: readonly {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly was: string;
|
|
42
|
+
readonly now: string;
|
|
43
|
+
}[];
|
|
44
|
+
}) {
|
|
45
|
+
const shown = input.stale
|
|
46
|
+
.slice(0, 5)
|
|
47
|
+
.map((row) => `${row.name} (${row.was}, now ${row.now})`)
|
|
48
|
+
.join(', ');
|
|
49
|
+
const more = input.stale.length > 5 ? ` and ${input.stale.length - 5} more` : '';
|
|
50
|
+
super({
|
|
51
|
+
code: 'X_ACTION_PATH_DERIVED_EARLY',
|
|
52
|
+
cause: `pathStyle '${input.style}' was declared after a path was already derived under the previous style: ${shown}${more} — any module that kept that string holds a URL no route serves`,
|
|
53
|
+
fix: 'derive the path where it is used — inside the component or function, never in a module-level const — or import the module that calls defineApi({ http: { pathStyle } }) before the one that derives it',
|
|
54
|
+
meta: { style: input.style, names: input.stale.map((row) => row.name) },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class OpenApiConfigInvalidError extends UltimateError {
|
|
60
|
+
constructor(reason: string) {
|
|
61
|
+
super({
|
|
62
|
+
code: 'X_OPENAPI_CONFIG_INVALID',
|
|
63
|
+
cause: `defineApi({ openapi }) cannot produce a valid document: ${reason}`,
|
|
64
|
+
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'",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -55,6 +55,10 @@ 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",
|
|
61
|
+
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
58
62
|
};
|
|
59
63
|
|
|
60
64
|
// 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
|
};
|
package/src/http-path.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
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 {
|
|
11
|
+
ActionHttpPathInvalidError,
|
|
12
|
+
ActionPathDerivedEarlyError,
|
|
13
|
+
ActionPathStyleInvalidError,
|
|
14
|
+
} from './errors-http';
|
|
15
|
+
import { defOf } from './invoke';
|
|
16
|
+
import { seatedActions } from './registry-store';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Name → the pin its registered action declares, read through the registry's leaf store, so a
|
|
20
|
+
* caller holding only a NAME (`derivePath`, a deprecation's `replacedBy`) gets the pinned URL.
|
|
21
|
+
*/
|
|
22
|
+
const pinOf = (name: string): string | undefined => {
|
|
23
|
+
const seated = seatedActions.get(name);
|
|
24
|
+
return seated === undefined ? undefined : defOf(seated).http?.path;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** What an action may say about its own HTTP projection. */
|
|
28
|
+
export interface ActionHttp {
|
|
29
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
readonly path?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Static, lowercase, no parameters, no trailing slash. */
|
|
38
|
+
const PINNED_PATH = /^(\/[a-z0-9][a-z0-9._~-]*)+$/;
|
|
39
|
+
|
|
40
|
+
let style: ActionPathStyle = 'resource';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every path handed out BY NAME (`derivePath`, `actionHttpPath('name')`), by name. A caller may
|
|
44
|
+
* keep the string — a page's form target in a module-level const — so a later style that moves
|
|
45
|
+
* one of these is a URL no route serves; `setActionPathStyle` refuses it instead. Bounded by the
|
|
46
|
+
* number of action names, and a path the new style keeps is not a stale one.
|
|
47
|
+
*/
|
|
48
|
+
const handedOut = new Map<string, string>();
|
|
49
|
+
|
|
50
|
+
/** The app's declared style — `'resource'` until `defineApi` says otherwise. */
|
|
51
|
+
export function actionPathStyle(): ActionPathStyle {
|
|
52
|
+
return style;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Called by `defineApi` (and the registry's re-derivation). Refuses an unknown style. */
|
|
56
|
+
export function setActionPathStyle(next: unknown): ActionPathStyle {
|
|
57
|
+
if (!ACTION_PATH_STYLES.some((known) => known === next)) {
|
|
58
|
+
throw new ActionPathStyleInvalidError(next);
|
|
59
|
+
}
|
|
60
|
+
const declared = next as ActionPathStyle;
|
|
61
|
+
if (declared !== style) {
|
|
62
|
+
const stale = [...handedOut]
|
|
63
|
+
.map(([name, was]) => ({ name, was, now: routeUnder(declared, name, pinOf(name)).path }))
|
|
64
|
+
.filter((row) => row.now !== row.was)
|
|
65
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
66
|
+
if (stale.length > 0) throw new ActionPathDerivedEarlyError({ style: declared, stale });
|
|
67
|
+
}
|
|
68
|
+
style = declared;
|
|
69
|
+
return style;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Test seam, beside `resetRegistry`. */
|
|
73
|
+
export function resetActionPathStyle(): void {
|
|
74
|
+
style = 'resource';
|
|
75
|
+
handedOut.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Refuses a pin that is not a static lowercase path, or one under the framework's `/_x`. */
|
|
79
|
+
export function assertPinnedPath(name: string, path: unknown): void {
|
|
80
|
+
if (typeof path !== 'string') {
|
|
81
|
+
throw new ActionHttpPathInvalidError({ name, path, reason: 'is not a string' });
|
|
82
|
+
}
|
|
83
|
+
if (!PINNED_PATH.test(path)) {
|
|
84
|
+
throw new ActionHttpPathInvalidError({
|
|
85
|
+
name,
|
|
86
|
+
path,
|
|
87
|
+
reason:
|
|
88
|
+
'is not a static lowercase path (a parameter, an uppercase letter or a trailing slash)',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (path === '/_x' || path.startsWith('/_x/')) {
|
|
92
|
+
throw new ActionHttpPathInvalidError({
|
|
93
|
+
name,
|
|
94
|
+
path,
|
|
95
|
+
reason: 'claims /_x, the framework namespace',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The route for `name` under the current style, with `pin` replacing the path when present. The
|
|
102
|
+
* verb and resource stay the style's — they label the operation, the path is where it lives.
|
|
103
|
+
*/
|
|
104
|
+
export function routeFor(name: string, pin: string | undefined): ActionRoute {
|
|
105
|
+
return routeUnder(style, name, pin);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const routeUnder = (under: ActionPathStyle, name: string, pin: string | undefined): ActionRoute => {
|
|
109
|
+
const derived = actionRoute(name, under);
|
|
110
|
+
return pin === undefined ? derived : { ...derived, path: pin };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The route a registered NAME is served at, for the framework's own projections — the route
|
|
115
|
+
* table, the OpenAPI operation, a deprecation's successor. Re-derived on every call and never
|
|
116
|
+
* held by app code, so nothing is recorded: a test that mounted routes must not make a later
|
|
117
|
+
* test's style declaration look like a stranded capture.
|
|
118
|
+
*/
|
|
119
|
+
export function servedActionRoute(name: string): ActionRoute {
|
|
120
|
+
return routeFor(name, pinOf(name));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The same answer, handed to APP code by name (`derivePath`, `actionHttpPath('name')`) — which
|
|
125
|
+
* may keep the string, so it is the one recorded; see `handedOut`.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveActionRoute(name: string): ActionRoute {
|
|
128
|
+
const route = servedActionRoute(name);
|
|
129
|
+
handedOut.set(name, route.path);
|
|
130
|
+
return route;
|
|
131
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -22,16 +22,16 @@ 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, servedActionRoute } from './http-path';
|
|
25
26
|
import { actionName, defOf, invoke } from './invoke';
|
|
26
27
|
import {
|
|
27
|
-
derivePath,
|
|
28
28
|
inputSchemaName,
|
|
29
29
|
outputSchemaName,
|
|
30
30
|
PROBLEM_SCHEMA_NAME,
|
|
31
31
|
schemaRef,
|
|
32
32
|
toOperationId,
|
|
33
33
|
} from './naming';
|
|
34
|
-
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
34
|
+
import { admitsAnonymous, policyCapability, policyPermissions } from './policy-gate';
|
|
35
35
|
import { carriesRecords, recordResponse } from './record-wire';
|
|
36
36
|
import { IDEMPOTENCY_HEADER } from './wire-headers';
|
|
37
37
|
|
|
@@ -56,8 +56,30 @@ async function writeOriginOf(req: UltimateRequest): Promise<string | undefined>
|
|
|
56
56
|
* kebab-cased (`updateUserProfile` -> `/api/user-profiles/update`). See `naming.ts`.
|
|
57
57
|
*/
|
|
58
58
|
export function toRoute(target: AnyAction): Route {
|
|
59
|
+
return projectRoute(target, servedActionRoute(actionName(target)).path, (req) => req.bodyRaw());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same action, bound to a PAGE's URL for `POST` (`defineRoute({ post: 'unsubscribe' })`): the
|
|
64
|
+
* URL's query members merged OVER the body's, so an RFC 8058 one-click `POST /baja?t=…` with
|
|
65
|
+
* `List-Unsubscribe=One-Click` reaches the action as `{ t, 'List-Unsubscribe': 'One-Click' }`.
|
|
66
|
+
* The query wins a name both carry — it is the URL the server minted. Everything else is
|
|
67
|
+
* `toRoute`'s: the policy, the idempotency, the redirect a handler asks for, the problem document.
|
|
68
|
+
*/
|
|
69
|
+
export function toPostBinding(target: AnyAction, path: string): Route {
|
|
70
|
+
return projectRoute(target, path, async (req) => {
|
|
71
|
+
const body = await req.bodyRaw();
|
|
72
|
+
const fields = typeof body === 'object' && body !== null && !Array.isArray(body) ? body : {};
|
|
73
|
+
return { ...fields, ...req.queryRaw() };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function projectRoute(
|
|
78
|
+
target: AnyAction,
|
|
79
|
+
path: string,
|
|
80
|
+
readInput: (req: UltimateRequest) => Promise<unknown>,
|
|
81
|
+
): Route {
|
|
59
82
|
const name = actionName(target);
|
|
60
|
-
const { path, resource } = derivePath(name);
|
|
61
83
|
const def = defOf(target);
|
|
62
84
|
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
63
85
|
// not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
|
|
@@ -80,7 +102,7 @@ export function toRoute(target: AnyAction): Route {
|
|
|
80
102
|
//
|
|
81
103
|
// The pipeline already parsed and size-capped the body; parsing it again here
|
|
82
104
|
// would be a second, differently-behaved parser for the same bytes.
|
|
83
|
-
const raw = await req
|
|
105
|
+
const raw = await readInput(req);
|
|
84
106
|
const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
|
|
85
107
|
let replayed = false;
|
|
86
108
|
// The header NAMES the write whatever the declaration, idempotent or not: a page sends one
|
|
@@ -138,7 +160,7 @@ export function toRoute(target: AnyAction): Route {
|
|
|
138
160
|
// only ever added SCHEMA validation on top. `X_BODY_INVALID` keeps its job, which is a body
|
|
139
161
|
// failing a plain `route.ts`'s own schema, where no primitive owns the input.
|
|
140
162
|
cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
|
|
141
|
-
tags: [
|
|
163
|
+
tags: [operationTagOf(target)],
|
|
142
164
|
// Name AND numbers. The name alone selected a bucket the limiter's table never held, so
|
|
143
165
|
// `bucketFor` fell through to `default` — 120 burst for an action that declared 5. The
|
|
144
166
|
// numbers ride along and `withRouteBuckets` registers them at construction.
|
|
@@ -151,6 +173,22 @@ export function toRoute(target: AnyAction): Route {
|
|
|
151
173
|
return { method: 'POST', path, handler, meta };
|
|
152
174
|
}
|
|
153
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The operation's one tag. Under `'resource'` it is the derived resource, as it always was. Under
|
|
178
|
+
* `'readable'` the path no longer names a resource, and guessing one from the words after the verb
|
|
179
|
+
* is the defect the style exists to end (`signIn` → `in`) — so the tag is the resource the POLICY
|
|
180
|
+
* names (`can('cases:create')` → `cases`): whose operation it is, which is what a reader groups by.
|
|
181
|
+
*/
|
|
182
|
+
export function operationTagOf(target: AnyAction): string {
|
|
183
|
+
const name = actionName(target);
|
|
184
|
+
if (actionPathStyle() === 'resource') return servedActionRoute(name).resource;
|
|
185
|
+
const policy = defOf(target).policy;
|
|
186
|
+
// A grant first; an `allow('x:y')` names its resource in the label alone.
|
|
187
|
+
const named = policyPermissions(policy)[0] ?? policyCapability(policy);
|
|
188
|
+
const resource = /^([a-z0-9][a-z0-9_-]*):/i.exec(named)?.[1];
|
|
189
|
+
return resource === undefined ? 'api' : resource.toLowerCase();
|
|
190
|
+
}
|
|
191
|
+
|
|
154
192
|
export interface OpenApiOperation {
|
|
155
193
|
readonly operationId: string;
|
|
156
194
|
readonly tags: readonly string[];
|
|
@@ -168,14 +206,13 @@ export interface OpenApiOperation {
|
|
|
168
206
|
export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
169
207
|
const name = actionName(target);
|
|
170
208
|
const def = defOf(target);
|
|
171
|
-
const path = derivePath(name);
|
|
172
209
|
const idempotent = def.idempotent === true;
|
|
173
210
|
const deprecation = deprecationMetaFor(name, def.deprecated);
|
|
174
211
|
const outputRef = schemaRef(outputSchemaName(name));
|
|
175
212
|
const enveloped = carriesRecords(target.output);
|
|
176
213
|
return {
|
|
177
214
|
operationId: toOperationId(name),
|
|
178
|
-
tags: [
|
|
215
|
+
tags: [operationTagOf(target)],
|
|
179
216
|
summary: def.mcp?.description ?? name,
|
|
180
217
|
...(deprecation === undefined ? {} : { deprecated: true }),
|
|
181
218
|
parameters: idempotent ? [IDEMPOTENCY_PARAMETER] : [],
|
|
@@ -228,7 +265,7 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
228
265
|
|
|
229
266
|
/**
|
|
230
267
|
* The headers this action's `deprecated:` block renders to, or nothing. The successor's URL comes
|
|
231
|
-
* from `
|
|
268
|
+
* from `servedActionRoute` — `derivePath`'s derivation, the one the route and the client use.
|
|
232
269
|
*/
|
|
233
270
|
function deprecationHeadersFor(
|
|
234
271
|
name: string,
|
|
@@ -236,7 +273,7 @@ function deprecationHeadersFor(
|
|
|
236
273
|
): Readonly<Record<string, string>> | undefined {
|
|
237
274
|
if (deprecated === undefined) return undefined;
|
|
238
275
|
const successor =
|
|
239
|
-
deprecated.replacedBy === undefined ? undefined :
|
|
276
|
+
deprecated.replacedBy === undefined ? undefined : servedActionRoute(deprecated.replacedBy).path;
|
|
240
277
|
const rendered = renderDeprecation(deprecated, successor);
|
|
241
278
|
if (!rendered.ok) throw new ActionDeprecationInvalidError(name, rendered.field, rendered.value);
|
|
242
279
|
return rendered.headers;
|
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 {
|
|
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
|
-
* `
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
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
|
|
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,23 +8,31 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { AnyAction } from './action';
|
|
11
|
-
import { toOpenApiOperation } from './http';
|
|
11
|
+
import { operationTagOf, toOpenApiOperation } from './http';
|
|
12
|
+
import { servedActionRoute } from './http-path';
|
|
12
13
|
import { actionName } from './invoke';
|
|
13
14
|
import { type JsonSchemaObject, jsonSchemaOf, sortSchema } from './json-schema';
|
|
14
|
-
import {
|
|
15
|
+
import { inputSchemaName, outputSchemaName, PROBLEM_SCHEMA_NAME } from './naming';
|
|
15
16
|
import { listActions } from './registry';
|
|
16
17
|
import { stableStringify } from './stable';
|
|
17
18
|
|
|
18
19
|
export interface OpenApiInfo {
|
|
19
20
|
readonly title: string;
|
|
20
21
|
readonly version: string;
|
|
22
|
+
readonly description?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface OpenApiDocument {
|
|
24
26
|
readonly openapi: '3.1.0';
|
|
25
27
|
readonly info: OpenApiInfo;
|
|
28
|
+
/** Only in the complete document (`defineApi({ openapi: { servers } })`). */
|
|
29
|
+
readonly servers?: readonly { readonly url: string; readonly description?: string }[];
|
|
26
30
|
readonly paths: Record<string, unknown>;
|
|
27
|
-
readonly components: {
|
|
31
|
+
readonly components: {
|
|
32
|
+
readonly schemas: Record<string, JsonSchemaObject>;
|
|
33
|
+
/** Only in the complete document — `completeOpenApi` / `mountOpenApi`. */
|
|
34
|
+
readonly securitySchemes?: Readonly<Record<string, unknown>>;
|
|
35
|
+
};
|
|
28
36
|
readonly tags: readonly { readonly name: string }[];
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -43,11 +51,11 @@ export function buildOpenApi(options: BuildOpenApiOptions = {}): OpenApiDocument
|
|
|
43
51
|
|
|
44
52
|
for (const target of actions) {
|
|
45
53
|
const name = actionName(target);
|
|
46
|
-
const { path
|
|
54
|
+
const { path } = servedActionRoute(name);
|
|
47
55
|
paths[path] = { post: toOpenApiOperation(target) };
|
|
48
56
|
schemas[inputSchemaName(name)] = sortSchema(jsonSchemaOf(target.input));
|
|
49
57
|
schemas[outputSchemaName(name)] = sortSchema(jsonSchemaOf(target.output));
|
|
50
|
-
tags.add(
|
|
58
|
+
tags.add(operationTagOf(target));
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
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,26 @@
|
|
|
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 {
|
|
14
|
+
assertPinnedPath,
|
|
15
|
+
resetActionPathStyle,
|
|
16
|
+
resolveActionRoute,
|
|
17
|
+
routeFor,
|
|
18
|
+
setActionPathStyle,
|
|
19
|
+
} from './http-path';
|
|
11
20
|
import { assertIdempotencyScope } from './idempotency';
|
|
21
|
+
import { defOf } from './invoke';
|
|
12
22
|
import { jsonSchemaOf, mcpSchemaOf } from './json-schema';
|
|
13
|
-
import {
|
|
23
|
+
import type { ActionPath } from './naming';
|
|
24
|
+
import { seatedActions } from './registry-store';
|
|
14
25
|
|
|
15
|
-
const registry =
|
|
26
|
+
const registry = seatedActions;
|
|
16
27
|
|
|
17
28
|
/**
|
|
18
29
|
* Derived route -> the action name that owns it. A second index because the name is not the
|
|
@@ -46,7 +57,9 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
|
|
|
46
57
|
throw new ActionPolicyMissingError(name);
|
|
47
58
|
}
|
|
48
59
|
assertProjectable(name, target);
|
|
49
|
-
const
|
|
60
|
+
const pin = defOf(target).http?.path;
|
|
61
|
+
if (pin !== undefined) assertPinnedPath(name, pin);
|
|
62
|
+
const { path } = routeFor(name, pin);
|
|
50
63
|
const owner = paths.get(path);
|
|
51
64
|
if (owner !== undefined && owner !== name) {
|
|
52
65
|
throw new ActionPathDuplicateError({ name, existing: owner, path });
|
|
@@ -57,6 +70,35 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
|
|
|
57
70
|
return named;
|
|
58
71
|
}
|
|
59
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Where `target` is served — its pin, else the app's style over its name. A NAME is resolved
|
|
75
|
+
* through the registry, so a pin declared on the action is honoured for a caller that only has the
|
|
76
|
+
* name (a deprecation's `replacedBy`, a page minting an island's endpoint).
|
|
77
|
+
*/
|
|
78
|
+
export function actionHttpPath(target: AnyAction | string): ActionPath {
|
|
79
|
+
// By NAME is how a page mints a form target, so it answers through the recorded path.
|
|
80
|
+
if (typeof target === 'string') return resolveActionRoute(target);
|
|
81
|
+
return routeFor(target.name, defOf(target).http?.path);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The app's `pathStyle`, applied to every action already seated and every one registered after.
|
|
86
|
+
* Re-derived whole, so two names the new style folds onto one URL are refused here rather than
|
|
87
|
+
* left for the router to seat one of.
|
|
88
|
+
*/
|
|
89
|
+
export function configureActionPathStyle(style: ActionPathStyle): void {
|
|
90
|
+
setActionPathStyle(style);
|
|
91
|
+
paths.clear();
|
|
92
|
+
for (const [name, target] of [...registry.entries()].sort(byName)) {
|
|
93
|
+
const { path } = routeFor(name, defOf(target).http?.path);
|
|
94
|
+
const owner = paths.get(path);
|
|
95
|
+
if (owner !== undefined && owner !== name) {
|
|
96
|
+
throw new ActionPathDuplicateError({ name, existing: owner, path });
|
|
97
|
+
}
|
|
98
|
+
paths.set(path, name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
60
102
|
/**
|
|
61
103
|
* Both schemas must reach JSON Schema, and this is where that is decided — boot, beside the policy
|
|
62
104
|
* check, never the first `tools/list`. `jsonSchemaOf` used to swallow the refusal into
|
|
@@ -114,6 +156,8 @@ export function describeActions(): readonly ActionDescriptor[] {
|
|
|
114
156
|
export function resetRegistry(): void {
|
|
115
157
|
registry.clear();
|
|
116
158
|
paths.clear();
|
|
159
|
+
resetActionPathStyle();
|
|
160
|
+
resetApiDeclaration();
|
|
117
161
|
}
|
|
118
162
|
|
|
119
163
|
function byName(a: readonly [string, AnyAction], b: readonly [string, AnyAction]): number {
|