@ultimat3/action 22.5.0 → 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 +12 -0
- package/README.md +73 -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 +38 -0
- package/src/errors.ts +3 -0
- package/src/facade.ts +1 -1
- package/src/http-path.ts +91 -0
- package/src/http.ts +44 -6
- package/src/idempotency.ts +27 -6
- 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 +11 -4
- package/src/registry-store.ts +9 -0
- package/src/registry.ts +43 -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,71 @@ 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
|
+
|
|
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` |
|
|
@@ -358,6 +419,16 @@ post-commit: a handler that took the money and then failed its own `output:` sch
|
|
|
358
419
|
The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
|
|
359
420
|
code. Releasing it there is what made idempotency the cause of a double charge.
|
|
360
421
|
|
|
422
|
+
**The stored payload fingerprint is keyed.** `requestHash` is `keyedFingerprint(input, …)` from
|
|
423
|
+
`@ultimat3/core`: `h1:<key id>:<HMAC-SHA-256/128>` under a key derived from the app's signing
|
|
424
|
+
secret (`ULTIMATE_CURSOR_SECRET` / `configureCursorSigning`), never a bare hash — the row lives a
|
|
425
|
+
day, and an unkeyed hash of an input holding a short account or ID number is brute-forced offline
|
|
426
|
+
from a table read. A row fingerprinted by a pre-22.5.1 build (16 bare hex characters) is still
|
|
427
|
+
compared exactly until it ages out. A row keyed under a secret this process does not hold (a
|
|
428
|
+
rotation inside the window) is answered on its status alone — replay, never re-run — and logs
|
|
429
|
+
`action.idempotency.fingerprint-unverifiable`: a mismatched body under that key is not diagnosed
|
|
430
|
+
for that window, and no honest retry gets a false 409.
|
|
431
|
+
|
|
361
432
|
**Where the records live is declared, and refused at registration.** The default store is process
|
|
362
433
|
memory — bounded, swept on a 24h window, and `scope: 'process'`. An app on more than one replica
|
|
363
434
|
must say so and bring a store that can keep it, or the retry that lands on another replica finds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
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.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 =
|
|
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,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
|
};
|
package/src/http-path.ts
ADDED
|
@@ -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
|
|
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: [
|
|
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: [
|
|
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/idempotency.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* a value or a failure — and a concurrent duplicate is refused rather than run twice, because a
|
|
5
5
|
* double charge is worse than a 409.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { compareFingerprint, isUltimateError, keyedFingerprint, logger } from '@ultimat3/core';
|
|
8
8
|
import {
|
|
9
9
|
IdempotencyConflictError,
|
|
10
10
|
IdempotencyNotSharedError,
|
|
@@ -57,7 +57,12 @@ export function isIdempotencyStatus(value: string): value is IdempotencyStatus {
|
|
|
57
57
|
export interface IdempotencyRecord {
|
|
58
58
|
readonly id: string;
|
|
59
59
|
readonly key: string;
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* KEYED fingerprint of the parsed input (`keyedFingerprint`, HMAC under the app's signing
|
|
62
|
+
* secret) — a reused key with a new payload is a bug. Never a bare hash: the record outlives the
|
|
63
|
+
* request by a day, and an unkeyed hash of an input holding a short account number is an
|
|
64
|
+
* offline oracle for that number to anyone who can read the table.
|
|
65
|
+
*/
|
|
61
66
|
readonly requestHash: string;
|
|
62
67
|
readonly status: IdempotencyStatus;
|
|
63
68
|
readonly value: unknown;
|
|
@@ -172,6 +177,9 @@ export function assertIdempotencyScope(
|
|
|
172
177
|
if (store.scope !== 'shared') throw new IdempotencyNotSharedError(store.scope);
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
/** The `purpose` the request fingerprint is keyed under — one derived key, for this use only. */
|
|
181
|
+
export const IDEMPOTENCY_FINGERPRINT_PURPOSE = 'action.idempotency.request';
|
|
182
|
+
|
|
175
183
|
export interface IdempotentOutcome<T> {
|
|
176
184
|
readonly value: T;
|
|
177
185
|
readonly replayed: boolean;
|
|
@@ -196,11 +204,9 @@ export async function withIdempotency<T>(
|
|
|
196
204
|
input: unknown,
|
|
197
205
|
run: () => Promise<T>,
|
|
198
206
|
): Promise<IdempotentOutcome<T>> {
|
|
199
|
-
const requestHash =
|
|
207
|
+
const requestHash = keyedFingerprint(input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
|
|
200
208
|
const { record, created } = await store.reserve(key, requestHash);
|
|
201
|
-
if (record.requestHash !== requestHash)
|
|
202
|
-
throw new IdempotencyConflictError(key, 'payload-mismatch');
|
|
203
|
-
}
|
|
209
|
+
if (record.requestHash !== requestHash) assertSamePayload(key, record.requestHash, input);
|
|
204
210
|
if (!created) {
|
|
205
211
|
if (record.status === 'in-flight') throw new IdempotencyConflictError(key, 'in-flight');
|
|
206
212
|
if (record.status === 'failed') throw new IdempotencyReplayedFailureError(key, record.failure);
|
|
@@ -224,6 +230,21 @@ export async function withIdempotency<T>(
|
|
|
224
230
|
return { value, replayed: false };
|
|
225
231
|
}
|
|
226
232
|
|
|
233
|
+
/**
|
|
234
|
+
* A stored fingerprint that is not this request's exact string. A legacy unkeyed one (a row
|
|
235
|
+
* written before keying) is still checked exactly. One keyed under a secret this process does not
|
|
236
|
+
* hold — the signing secret rotated inside the window — cannot be checked either way, and is
|
|
237
|
+
* answered by its STATUS alone: replay, never re-run. Refusing it would 409 every honest retry
|
|
238
|
+
* across a rotation; running it would be the double charge. What is lost for that window only is
|
|
239
|
+
* the "same key, different body" diagnosis, and a warning says so.
|
|
240
|
+
*/
|
|
241
|
+
function assertSamePayload(key: string, stored: string, input: unknown): void {
|
|
242
|
+
const verdict = compareFingerprint(stored, input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
|
|
243
|
+
if (verdict === 'mismatch') throw new IdempotencyConflictError(key, 'payload-mismatch');
|
|
244
|
+
if (verdict === 'unverifiable')
|
|
245
|
+
logger.warn('action.idempotency.fingerprint-unverifiable', { key });
|
|
246
|
+
}
|
|
247
|
+
|
|
227
248
|
/**
|
|
228
249
|
* Record the failure, and never let recording it replace the failure itself. A store that refuses
|
|
229
250
|
* here would otherwise surface as the caller's error, hiding the `X_OUTPUT_INVALID` or the
|
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,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: {
|
|
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
|
|
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(
|
|
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 {
|
|
17
|
+
import type { ActionPath } from './naming';
|
|
18
|
+
import { seatedActions } from './registry-store';
|
|
14
19
|
|
|
15
|
-
const registry =
|
|
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
|
|
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 {
|