@ultimat3/action 11.3.0 → 13.0.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 +88 -5
- package/README.md +116 -6
- package/package.json +6 -6
- package/src/audit-input.ts +78 -0
- package/src/audit-memory.ts +84 -0
- package/src/audit-postgres.ts +175 -0
- package/src/audit.ts +6 -20
- package/src/client.ts +4 -0
- package/src/errors.ts +36 -2
- package/src/index.ts +31 -2
- package/src/transition.ts +144 -0
- package/src/validate.ts +13 -2
- package/src/wire-issues.ts +47 -0
package/CLAUDE.md
CHANGED
|
@@ -21,6 +21,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
21
21
|
| `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
|
|
22
22
|
| `openapi.ts` | deterministic OpenAPI 3.1 document |
|
|
23
23
|
| `client.ts` | typed RPC client (browser-safe: no server imports) |
|
|
24
|
+
| `wire-issues.ts` | the ONE reader of a problem document's `issues` member — an untrusted array back into `@ultimat3/schema`'s `ValidationIssue` shape |
|
|
25
|
+
| `transition.ts` | `transition()`: a MUTATOR factory over one entity column's state machine. Declares no error code — entity's three propagate |
|
|
24
26
|
| — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported from `src/index.ts`. There is no local copy and must not be one |
|
|
25
27
|
| `wire-headers.ts` | `BUILD_ID_HEADER` + `IDEMPOTENCY_HEADER`, and nothing else. Their own module so `client.ts` can name them without importing `http.ts` |
|
|
26
28
|
| `mcp-tool.ts` | MCP descriptor, same `invoke` |
|
|
@@ -34,13 +36,44 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
34
36
|
| `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter |
|
|
35
37
|
| `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as a type, which erases) |
|
|
36
38
|
| `cache-gate.ts` | the post-commit bust — **the only** file that calls `invalidateTags` |
|
|
37
|
-
| `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the
|
|
39
|
+
| `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the installed-sink store |
|
|
40
|
+
| `audit-memory.ts` | the process default: a bounded ring that DROPS, and counts what it dropped |
|
|
41
|
+
| `audit-postgres.ts` | the DURABLE sink — one append-only `x_audit` table, one insert per record |
|
|
42
|
+
| `audit-input.ts` | what may be written DOWN: an `input` redacted through core's table and made JSON-representable on every path |
|
|
38
43
|
| `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
|
|
39
44
|
| `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
|
|
40
45
|
| `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer plus a re-export of core's `isJsonObject` — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
|
|
41
46
|
|
|
42
47
|
## Invariants
|
|
43
48
|
|
|
49
|
+
- **`X_INPUT_INVALID` carries the rejections TWICE, and they are one value.** The flattened line
|
|
50
|
+
stays in `cause` — it is what an operator reads in a log and what a non-form caller sees — and
|
|
51
|
+
`meta.issues` carries the same list structured, so a client rebuilding a form knows WHICH field
|
|
52
|
+
each rejection belongs to instead of splitting a string on `'; '` and guessing. `validate.ts` is
|
|
53
|
+
the one caller that passes both, and `validate.test.ts` pins `cause` to
|
|
54
|
+
`formatIssues(issues).join('; ')`; the rendering deliberately does NOT happen inside
|
|
55
|
+
`InputInvalidError`, because that module is reachable from browser-safe `client.ts` and
|
|
56
|
+
`@ultimat3/schema` declares no `sideEffects`, so a value import of `formatIssues` there would drag
|
|
57
|
+
that package's whole barrel into every bundle holding the typed client.
|
|
58
|
+
- **`toValidationIssues`, never a library's raw issues.** A conforming schema library's issue object
|
|
59
|
+
may carry members Ultimate's shape does not — including the rejected VALUE — and this list is
|
|
60
|
+
handed to an HTTP surface that returns it to the caller. Four members travel. The same rule on the
|
|
61
|
+
way back in: `issuesFromWire` REBUILDS each entry member by member rather than copying it.
|
|
62
|
+
- **`X_OUTPUT_INVALID` keeps the line alone.** An output rejection is a server defect whose remedy
|
|
63
|
+
is a code change; no client can act on a per-field list, and shipping the handler's internal
|
|
64
|
+
projection to a caller is new surface for nothing.
|
|
65
|
+
- **An `issues` list off the wire is all-or-nothing.** A partly-parsed list would DROP the entries
|
|
66
|
+
it could not read, and a caller that finds `meta.issues` uses it INSTEAD of `cause` — so a dropped
|
|
67
|
+
entry is a rejection the user never hears about. `MAX_WIRE_ISSUES` bounds it, because whoever
|
|
68
|
+
displays the list renders it into a DOM.
|
|
69
|
+
- **`transition()` is a factory, not a primitive, and it decides nothing about the machine.** It
|
|
70
|
+
returns a `mutator`, so every projection is inherited rather than re-declared, and it holds no
|
|
71
|
+
legality rule: `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are
|
|
72
|
+
`@ultimat3/entity`'s and propagate untouched. `from` is REQUIRED — it is the UPDATE's predicate,
|
|
73
|
+
which is what makes the refusal free; defaulting or inferring it is the lost update coming back.
|
|
74
|
+
`conflict: 'server-wins'` is fixed (the server is the half that refused), and `audit` is OFF
|
|
75
|
+
unless declared (`audit: true` with no sink is `X_AUDIT_SINK_MISSING` before the input parse, so
|
|
76
|
+
defaulting it on would hold the factory hostage to an unrelated decision).
|
|
44
77
|
- Every surface goes through `invoke`: parse input, evaluate policy, handle, parse
|
|
45
78
|
output. Adding a second execution path is the one unforgivable change here.
|
|
46
79
|
- **An explicit `ctx` is INSTALLED, never merely passed** (`As of 2026-08`). `invoke` entered
|
|
@@ -339,10 +372,19 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
339
372
|
around its own handler could ever see one. What reaches the sink is what `invoke` already holds
|
|
340
373
|
(`at` from `ctx.now()`, the name, the mutator brand, the surface, the whole `ctx`, the parsed
|
|
341
374
|
input, the namespaced idempotency key, `replayed`, the outcome, the failure code). What does
|
|
342
|
-
**not** ship, ever: an audit entity, a
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
375
|
+
**not** ship, ever: an audit entity, a retention policy, a hash chain, a subject index, or an
|
|
376
|
+
opinion on what "who" means under impersonation — four apps model those four ways, so by axiom
|
|
377
|
+
8's own test they are business convention and shipping one makes three of them wrong.
|
|
378
|
+
**"a storage backend" was on that list until 2026-08-24 and is off it**, because the list was
|
|
379
|
+
answering a different question than it appeared to. What four apps model four ways is the ROW —
|
|
380
|
+
which of their own facts it carries, how long they keep it, whether it chains. Where the record
|
|
381
|
+
the FRAMEWORK already defines is put is not one of those: `x_audit`'s columns are the fields of
|
|
382
|
+
`AuditRecord` and nothing else, which is the same relationship `idempotency-postgres.ts` has to
|
|
383
|
+
`IdempotencyRecord` and `@ultimat3/http`'s `postgresRateLimitStore` to its `Bucket`. Leaving it
|
|
384
|
+
off meant the only sink that shipped was a ring that drops, so the shortest edit clearing
|
|
385
|
+
`X_AUDIT_SINK_MISSING` was `setAuditSink(memoryAuditSink())` — compliant in dev, silently
|
|
386
|
+
amnesiac in production, which `docs/idea/20-large-app-readiness.md` scores as **Ship**. An app
|
|
387
|
+
that wants columns of its own still writes its own sink; the seam is one method. `result` is absent for the same reason and one more: a handler's return is
|
|
346
388
|
reachable from the handler itself, so shipping it would be this package deciding a row carries
|
|
347
389
|
an after-image, which is `@ultimat3/admin`'s `diff` convention arriving one tier down.
|
|
348
390
|
- **The audit vocabulary is `@ultimat3/admin`'s, shared by name and not by import.** `AuditOutcome`
|
|
@@ -353,6 +395,47 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
353
395
|
Unifying them means lifting the vocabulary into `@ultimat3/core` — the only tier both reach —
|
|
354
396
|
and rebuilding `admin/audit.ts` on this seam. Not done here: `admin` is a shipped public API
|
|
355
397
|
and its `AuditEntry` is a different shape.
|
|
398
|
+
- **The memory sink DROPS, and both halves of that sentence are enforced.** It was a plain array
|
|
399
|
+
with a `push` — the one memory implementation in the framework with no cap, beside five that
|
|
400
|
+
have one (`memoryRateLimitStore`, `MemoryIdempotencyStore`, `createLimiter`,
|
|
401
|
+
`createTotpReplayGuard`, `createMemoryEventBus`) — and a record pins a whole `Ctx`, so at 50
|
|
402
|
+
audited writes a second it is 4.3M immortal records a day and the pod OOMs holding the trail it
|
|
403
|
+
was retaining. It is now a ring at `DEFAULT_MAX_AUDIT_RECORDS`, evicting the OLDEST (the
|
|
404
|
+
direction `createMemoryEventBus` evicts in: refusing new writes would answer "nothing has
|
|
405
|
+
happened since" for a process that has been serving all day). `dropped` is what makes "it drops"
|
|
406
|
+
checkable in a running process instead of a sentence in a header — a non-zero count on a real
|
|
407
|
+
deployment is the sink saying it is the wrong one. A `maxRecords` of `0`, negative or `NaN`
|
|
408
|
+
falls back to the default: there is no spelling of "no bound", because that spelling was the bug.
|
|
409
|
+
- **What a DURABLE sink may write down is decided in `audit-input.ts`, and it is two rules.**
|
|
410
|
+
A record's `input` is the PARSED input, which is exactly where a password, a bearer token or a
|
|
411
|
+
card number lives, so `postgresAuditSink` redacts it through `@ultimat3/core`'s `isRedactedKey`
|
|
412
|
+
— the SAME table `defineEnv({ secret: true })` extends, never a copy of the list, because a copy
|
|
413
|
+
is how a value that is `[redacted]` in a log line becomes plaintext in a table. `isSecret`
|
|
414
|
+
redacts by VALUE beside it, for a credential travelling under a harmless name. The second rule
|
|
415
|
+
is that the answer is always JSON-representable: a `bigint`, a `NaN`, a function and a cycle all
|
|
416
|
+
become a NAMED marker rather than a throw, because `auditSettled` turns a sink throw into a
|
|
417
|
+
failed invocation for a handler that has already committed — and because `JSON.stringify` over a
|
|
418
|
+
cycle takes ~4.6s in Bun 1.4 before it raises, so leaving the detection to the serializer stalls
|
|
419
|
+
the audited path either way. `toJSON` is never called: it is app code in the frame that owes the
|
|
420
|
+
caller a record.
|
|
421
|
+
- **The `Ctx` is never walked, and never will be.** `createContext` spreads every installed
|
|
422
|
+
service ONTO the context object and an HTTP surface's value is a `RequestContext` carrying the
|
|
423
|
+
request's own `Authorization` and `Cookie`, so a projection that iterated it would write an
|
|
424
|
+
app's database clients and its caller's credentials into an audit table. `postgresAuditSink`
|
|
425
|
+
reads an allow-list of framework-owned fields (`requestId`, `traceId`, `locale`, `tz`,
|
|
426
|
+
`buildId`, `role`, and the actor's `id`/`kind`/`orgId`/`onBehalfOf`) and nothing else.
|
|
427
|
+
`failure.error` is not among them — the row keeps `failure.code`, because a throwable's stack is
|
|
428
|
+
worth reading and is not worth storing, and rendering one into a column is the trap
|
|
429
|
+
`renderThrowable` exists for.
|
|
430
|
+
- **`x_audit` ships no purge, and it is the only framework table that does not.**
|
|
431
|
+
`x_idempotency` and `x_rate_limit` both ship one because a stale row there is meaningless; a
|
|
432
|
+
stale audit row IS the record, and "how long" is a legal answer that is seven years for one app
|
|
433
|
+
and thirty days for the next. Shipping a `delete` would be shipping one of those answers.
|
|
434
|
+
- **`SQL_AUDIT_INSERT` is positional, so its parameter order is pinned by a test and not by a
|
|
435
|
+
type.** `audit-parity.test.ts` names every column once and compares both sinks' answer for every
|
|
436
|
+
string field with a DIFFERENT value per field — two columns holding the same word cannot catch a
|
|
437
|
+
slip, and a `locale` in the `tz` slot type-checks perfectly. Proven by mutation: the first draft
|
|
438
|
+
of that test did NOT catch a swapped `locale`/`tz` and was widened until it did.
|
|
356
439
|
- **A sink may not silently swallow, and the two failure policies are deliberate opposites.**
|
|
357
440
|
`X_AUDIT_SINK_MISSING` is raised *before* the input parse, so an audited action nothing can
|
|
358
441
|
record refuses with no committed write behind it — there is deliberately no logger-backed
|
package/README.md
CHANGED
|
@@ -240,6 +240,53 @@ the core, because it never leaves the client; keep it a pure function of `(tx, i
|
|
|
240
240
|
SQLite). Type your tables once: `declare module '@ultimat3/action' { interface
|
|
241
241
|
LocalTables { posts: PostRow } }`.
|
|
242
242
|
|
|
243
|
+
## `transition()` — a mutator factory over a state machine
|
|
244
|
+
|
|
245
|
+
`As of 2026-08-24`. A move through an entity column's state machine is a server-authoritative write
|
|
246
|
+
with an input schema, an output schema and a policy — which is what a `mutator` already is. So
|
|
247
|
+
`transition()` **returns one**, and the move inherits the route, the OpenAPI operation, the typed
|
|
248
|
+
client, the MCP tool, the job handle and its `PRIMITIVE_FACTORIES` row. It is not a ninth primitive
|
|
249
|
+
and it declares no error code of its own.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { t, transition, type TransitionTarget } from '@ultimat3/action';
|
|
253
|
+
import type { Ctx } from '@ultimat3/core';
|
|
254
|
+
import { can } from '@ultimat3/policy';
|
|
255
|
+
|
|
256
|
+
const ORDER_STATES = ['pending', 'paid', 'shipped'] as const;
|
|
257
|
+
type OrderState = (typeof ORDER_STATES)[number];
|
|
258
|
+
|
|
259
|
+
const OrderView = t.object({ id: t.uuid, status: t.enum(ORDER_STATES) });
|
|
260
|
+
|
|
261
|
+
// `@ultimat3/entity`'s `orders(ctx)`: a real `Table` satisfies the seam as written.
|
|
262
|
+
declare function orders(ctx: Ctx): TransitionTarget<{ id: string; status: OrderState }, OrderState>;
|
|
263
|
+
declare const id: string;
|
|
264
|
+
declare const ctx: Ctx;
|
|
265
|
+
|
|
266
|
+
export const moveOrder = transition({
|
|
267
|
+
table: (ctx) => orders(ctx), // the request's table — tenant-scoped like every write
|
|
268
|
+
column: 'status', // the column whose enumerated().transitions() IS the machine
|
|
269
|
+
states: ORDER_STATES, // typed against the row: a state it cannot hold is a compile error
|
|
270
|
+
localTable: 'orders', // what the optimistic twin patches
|
|
271
|
+
output: OrderView,
|
|
272
|
+
policy: can('order:move'),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
| Rule | Why |
|
|
279
|
+
|---|---|
|
|
280
|
+
| **`from` is required, and never defaulted or inferred** | it rides in the UPDATE's own predicate, so the state observed and the state written are one decision under the row's lock. Measured on the mechanism underneath: twenty concurrent moves at one row gave 14 winners with a read-then-check-then-write and **1 winner plus 19 refusals** with `from` in the predicate. Anything that supplies `from` for the caller is the lost update coming back |
|
|
281
|
+
| the states are the **input schema**, not a `t.string` | the union survives into `InferOutput`, so the typed client refuses a typo at **compile** time, the MCP tool's `inputSchema` and the OpenAPI component both publish the legal set, and a bad state is `X_INPUT_INVALID` before a database is touched |
|
|
282
|
+
| `conflict: 'server-wins'`, not overridable | the server is the half that REFUSED the move; a local twin winning the rebase would leave the client showing a state the database rejected |
|
|
283
|
+
| `audit` is **off** unless the app says so | `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`, raised before the input parse — an on-by-default audit would make every `transition()` refuse until an unrelated decision was made. What the row is kept for, and for how long, is the same compliance question that kept a purge out of `postgresAuditSink` |
|
|
284
|
+
| `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` propagate untouched | they are `@ultimat3/entity`'s. A second error class over one failure is a second path |
|
|
285
|
+
|
|
286
|
+
`table` is typed structurally (`TransitionTarget`), not imported: `@ultimat3/action` holds no
|
|
287
|
+
dependency edge on `@ultimat3/entity` — the tier table permits one, the manifest and the lockfile do
|
|
288
|
+
not — and a real `Table` satisfies the seam as written.
|
|
289
|
+
|
|
243
290
|
## Determinism + idempotency
|
|
244
291
|
|
|
245
292
|
`serializeOpenApi(buildOpenApi())` sorts keys at every depth, iterates the registry
|
|
@@ -249,7 +296,10 @@ name-sorted, and reads no clock, env or random source — same registry ⇒ same
|
|
|
249
296
|
## `rateLimit:` is the enforced limit
|
|
250
297
|
|
|
251
298
|
```ts
|
|
252
|
-
|
|
299
|
+
import type { ActionRateLimit } from '@ultimat3/action';
|
|
300
|
+
|
|
301
|
+
// The `rateLimit:` key of an `action()`: 5 held, one back every two minutes.
|
|
302
|
+
const rateLimit: ActionRateLimit = { limit: 5, windowMs: 600_000 };
|
|
253
303
|
```
|
|
254
304
|
|
|
255
305
|
One declaration, three places it lands: the bucket the limiter runs on (named after the action,
|
|
@@ -298,6 +348,9 @@ import {
|
|
|
298
348
|
postgresIdempotencyStore,
|
|
299
349
|
setIdempotencyStore,
|
|
300
350
|
} from '@ultimat3/action';
|
|
351
|
+
import { db } from '@ultimat3/db';
|
|
352
|
+
|
|
353
|
+
const client = db();
|
|
301
354
|
|
|
302
355
|
// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` `As of 2026-08` (it is a tagged
|
|
303
356
|
// template whose positional form is `unsafe`), so that line compiles and throws on the first
|
|
@@ -348,7 +401,14 @@ A `query` has none and never will: a read has nothing to be idempotent about.
|
|
|
348
401
|
## `deprecated:` — a compat window, not a version
|
|
349
402
|
|
|
350
403
|
```ts
|
|
351
|
-
|
|
404
|
+
import type { Deprecation } from '@ultimat3/action';
|
|
405
|
+
|
|
406
|
+
// The `deprecated:` key of an `action()`.
|
|
407
|
+
const deprecated: Deprecation = {
|
|
408
|
+
since: '2026-08-01T00:00:00Z',
|
|
409
|
+
sunset: '2026-12-31T23:59:59Z',
|
|
410
|
+
replacedBy: 'searchOrders',
|
|
411
|
+
};
|
|
352
412
|
```
|
|
353
413
|
|
|
354
414
|
Four things at once: `Deprecation: @1754006400` (RFC 9745) and `Sunset: Wed, 31 Dec 2026 …`
|
|
@@ -391,9 +451,53 @@ What the framework supplies is what it genuinely knows:
|
|
|
391
451
|
| `outcome` | `allowed` \| `denied` \| `failed` |
|
|
392
452
|
| `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
|
|
393
453
|
|
|
394
|
-
What it does **not** supply: an audit entity, a
|
|
395
|
-
|
|
396
|
-
|
|
454
|
+
What it does **not** supply: an audit entity, a retention policy, a hash chain, a subject index,
|
|
455
|
+
or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping
|
|
456
|
+
one would make three of them wrong.
|
|
457
|
+
|
|
458
|
+
### Two sinks ship, and only one of them keeps anything
|
|
459
|
+
|
|
460
|
+
| Sink | Keeps | Use it for |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| `memoryAuditSink({ maxRecords })` | the newest `DEFAULT_MAX_AUDIT_RECORDS` (1,000) records, verbatim. **It DROPS** — `dropped` counts what it discarded | `x dev`, tests |
|
|
463
|
+
| `postgresAuditSink({ executor })` | one append-only `x_audit` row per attempt. Drops nothing | anything that has to keep its trail |
|
|
464
|
+
|
|
465
|
+
The memory sink is bounded because a record pins a whole `Ctx`: at 50 audited writes a second an
|
|
466
|
+
unbounded array is 4.3M immortal records a day and the pod dies holding the trail it was
|
|
467
|
+
retaining. The trap it names out loud is that the shortest edit clearing `X_AUDIT_SINK_MISSING`
|
|
468
|
+
is `setAuditSink(memoryAuditSink())`, and nothing at that call site says the result is amnesiac.
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
// apps/web/server.ts — the app owns the connection, so the app installs the sink
|
|
472
|
+
import { postgresAuditSink, setAuditSink } from '@ultimat3/action';
|
|
473
|
+
import { db } from '@ultimat3/db';
|
|
474
|
+
|
|
475
|
+
const client = db();
|
|
476
|
+
setAuditSink(
|
|
477
|
+
postgresAuditSink({ executor: { query: (text, values) => client.query({ text, values }) } }),
|
|
478
|
+
);
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
**The table is applied by the boot; the sink is not.** `startQueue` runs `SQL_AUDIT_TABLE` on
|
|
482
|
+
every start — `x dev`, the container's `web`/`worker`, and the release-phase `ROLE=migrate` — the
|
|
483
|
+
way `SQL_IDEMPOTENCY_TABLE` is applied, because a package holding no database dependency cannot
|
|
484
|
+
apply its own schema. Installing a sink stays your one line, deliberately: there is no default, so
|
|
485
|
+
`audit: true` with none installed keeps refusing with `X_AUDIT_SINK_MISSING` instead of recording
|
|
486
|
+
into a ring. `executor` is a client that already speaks `(text, values)` — never `Bun.sql`, whose
|
|
487
|
+
`.query` is `undefined`.
|
|
488
|
+
|
|
489
|
+
`x_audit` carries the framework's own facts as columns — the action, the surface, the outcome,
|
|
490
|
+
the actor, the correlation ids, the idempotency key — and the parsed `input` as `jsonb`, redacted
|
|
491
|
+
through **core's own** `isRedactedKey` table, the one `defineEnv({ secret: true })` extends. So a
|
|
492
|
+
value that renders `[redacted]` in a log line cannot be plaintext in the audit table, and a
|
|
493
|
+
boxed `Secret` is redacted by value wherever its key sits. What never reaches a column: the `Ctx`
|
|
494
|
+
itself (`createContext` spreads every installed service onto it, and an HTTP surface's is a
|
|
495
|
+
`RequestContext` carrying the caller's `Authorization` and `Cookie`), and the thrown value behind
|
|
496
|
+
a failure — the row keeps `failure.code`, never the throwable.
|
|
497
|
+
|
|
498
|
+
The table has **no purge**, deliberately, and it is the one framework table that does not: a
|
|
499
|
+
stale idempotency row is meaningless while a stale audit row *is* the record, and "how long" is a
|
|
500
|
+
legal answer that differs per app. Pruning or partitioning `x_audit` is yours.
|
|
397
501
|
|
|
398
502
|
A denial is recorded because `invoke` wraps the whole path — `guard` throws **before** `handle`,
|
|
399
503
|
so nothing you could write around your own handler would ever see one. That is the reason this
|
|
@@ -491,7 +595,7 @@ never a pass — the assertion says which code got in the way and names `input:`
|
|
|
491
595
|
| `X_ACTION_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
|
|
492
596
|
| `X_RATE_LIMIT_INVALID` | `rateLimit:` with a non-positive or non-finite half — `windowMs: 0` refills infinitely. Owned by `@ultimat3/http`, which owns the conversion | make both positive, or delete the block |
|
|
493
597
|
| `X_ACTION_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
|
|
494
|
-
| `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
|
|
598
|
+
| `X_INPUT_INVALID` | input failed the Standard Schema. Carries the rejections **twice**: the flattened line in `cause`, and the structured list in `meta.issues` — one value rendered two ways, `As of 2026-08-24` | `x actions describe <name> --json` |
|
|
495
599
|
| `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
|
|
496
600
|
| `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
|
|
497
601
|
| `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
|
|
@@ -513,6 +617,12 @@ server's own `docs`/`type` when it sent an `http(s)` one, this build's registere
|
|
|
513
617
|
knows the code, otherwise the error index. A per-code URL is never synthesized for a code
|
|
514
618
|
nothing here declares.
|
|
515
619
|
|
|
620
|
+
A document carrying an `issues` member arrives parsed as well: `meta.issues`, read by
|
|
621
|
+
`issuesFromWire` — a wire value, so the list is rebuilt member by member and a list this build
|
|
622
|
+
cannot read is dropped whole rather than half-kept, leaving `cause` (which still holds every
|
|
623
|
+
rejection) as the answer. It is exported for the island that posts with a plain `fetch` and holds
|
|
624
|
+
the body itself.
|
|
625
|
+
|
|
516
626
|
## Boundaries
|
|
517
627
|
|
|
518
628
|
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`, `http`. Never imports
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.0.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,10 +34,10 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/cache": "
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/http": "
|
|
40
|
-
"@ultimat3/policy": "
|
|
41
|
-
"@ultimat3/schema": "
|
|
37
|
+
"@ultimat3/cache": "13.0.0",
|
|
38
|
+
"@ultimat3/core": "13.0.0",
|
|
39
|
+
"@ultimat3/http": "13.0.0",
|
|
40
|
+
"@ultimat3/policy": "13.0.0",
|
|
41
|
+
"@ultimat3/schema": "13.0.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One job: turn an `AuditRecord.input` into something a durable sink may write down — redacted
|
|
3
|
+
* through core's own table, and representable as JSON on every path. Both halves are safety, not
|
|
4
|
+
* formatting: a stored credential is a leak, and a sink that throws on the caller's input fails an
|
|
5
|
+
* invocation whose handler has already committed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isRedactedKey, isSecret, REDACTED } from '@ultimat3/core';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a value this cannot represent becomes. A NAME and never `null`: `JSON.stringify` writes
|
|
12
|
+
* `null` for `NaN` and `±Infinity` and drops a function entirely, so an auditor reading the row
|
|
13
|
+
* could not tell "the field was absent" from "the field held something unwritable".
|
|
14
|
+
*/
|
|
15
|
+
export const UNREPRESENTABLE = '[unrepresentable]';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How deep the walk goes. The input is schema-parsed, so its shape is the app's declaration — but
|
|
19
|
+
* `t.record` and a recursive schema have no depth of their own, and an overflow HERE lands in the
|
|
20
|
+
* sink, after the handler committed. Anything past this is `UNREPRESENTABLE`, which is the honest
|
|
21
|
+
* answer: it was there and this row does not carry it.
|
|
22
|
+
*/
|
|
23
|
+
export const AUDIT_INPUT_MAX_DEPTH = 12;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `undefined` in, `undefined` out — an input that never parsed is a row with no input, not a row
|
|
27
|
+
* whose input was null.
|
|
28
|
+
*
|
|
29
|
+
* A cycle is CUT rather than raised on, and that is a cost decision as much as a correctness one:
|
|
30
|
+
* `JSON.stringify` over a self-referential value takes ~4.6s in Bun 1.4 before it throws, so
|
|
31
|
+
* leaving the detection to the serializer stalls the audited path whether or not the throw is
|
|
32
|
+
* caught. The ancestor set is the path, not everything seen — a value appearing twice as siblings
|
|
33
|
+
* is repetition and is written twice, exactly as `JSON.stringify` writes it.
|
|
34
|
+
*
|
|
35
|
+
* **`toJSON` is never called.** It is app code inside the frame that owes the caller a record, and
|
|
36
|
+
* one that throws is the second failure `jsonResult` already names; a `Map`, a `Set` and a `URL`
|
|
37
|
+
* therefore walk as their own enumerable keys, which is what `JSON.stringify` makes of them too.
|
|
38
|
+
*/
|
|
39
|
+
export function auditableInput(value: unknown): unknown {
|
|
40
|
+
if (value === undefined) return undefined;
|
|
41
|
+
return walk(value, 0, new Set());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function walk(value: unknown, depth: number, ancestors: Set<object>): unknown {
|
|
45
|
+
if (value === null) return null;
|
|
46
|
+
const kind = typeof value;
|
|
47
|
+
if (kind === 'string' || kind === 'boolean') return value;
|
|
48
|
+
if (kind === 'number') return Number.isFinite(value) ? value : UNREPRESENTABLE;
|
|
49
|
+
// `bigint`, `function`, `symbol`, and `undefined` reached through an array hole.
|
|
50
|
+
if (kind !== 'object') return UNREPRESENTABLE;
|
|
51
|
+
|
|
52
|
+
const object = value as object;
|
|
53
|
+
if (isSecret(object)) return REDACTED;
|
|
54
|
+
if (object instanceof Date) {
|
|
55
|
+
return Number.isNaN(object.getTime()) ? UNREPRESENTABLE : object.toISOString();
|
|
56
|
+
}
|
|
57
|
+
if (depth >= AUDIT_INPUT_MAX_DEPTH || ancestors.has(object)) return UNREPRESENTABLE;
|
|
58
|
+
|
|
59
|
+
ancestors.add(object);
|
|
60
|
+
try {
|
|
61
|
+
if (Array.isArray(object)) {
|
|
62
|
+
return object.map((item) => walk(item, depth + 1, ancestors));
|
|
63
|
+
}
|
|
64
|
+
const out: Record<string, unknown> = {};
|
|
65
|
+
// `Object.entries`, so only OWN enumerable keys are read: a prototype member is not this
|
|
66
|
+
// record's data, and reading one would put `Object.prototype`'s members in every audit row.
|
|
67
|
+
for (const [key, item] of Object.entries(object)) {
|
|
68
|
+
// The key decides before the value does, so a credential under a redacted name is never
|
|
69
|
+
// walked at all — `isRedactedKey` is core's, the same table `defineEnv({ secret: true })`
|
|
70
|
+
// extends, so a value that is `[redacted]` in a log line cannot be plaintext in a table.
|
|
71
|
+
if (isRedactedKey(key)) out[key] = REDACTED;
|
|
72
|
+
else if (item !== undefined) out[key] = walk(item, depth + 1, ancestors);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
} finally {
|
|
76
|
+
ancestors.delete(object);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit seam's process-memory sink: a bounded ring that DROPS, for tests and `x dev`. Split
|
|
3
|
+
* from `audit.ts` on the seam `idempotency.ts` / `idempotency-memory.ts` already draw, so the file
|
|
4
|
+
* declaring what a record IS is not also the file deciding how many are kept.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AuditRecord, AuditSink } from './audit';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Records held at once. A record pins a whole `Ctx` — the actor, the service bag, the parsed
|
|
11
|
+
* input — so its cost is the request's, not a row's: at 50 audited writes a second an unbounded
|
|
12
|
+
* array is 4.3M immortal records a day and the pod dies holding the trail it was retaining. This
|
|
13
|
+
* sink was the one memory implementation in the framework with no cap, beside five that have one
|
|
14
|
+
* (`memoryRateLimitStore`, `MemoryIdempotencyStore`, `createLimiter`, `createTotpReplayGuard`,
|
|
15
|
+
* `createMemoryEventBus`).
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_MAX_AUDIT_RECORDS = 1_000;
|
|
18
|
+
|
|
19
|
+
export interface MemoryAuditSinkOptions {
|
|
20
|
+
/** Records held at once. Absent, zero, negative or NaN all read as the default — never "no cap". */
|
|
21
|
+
readonly maxRecords?: number | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The seam's memory implementation, for tests and `x dev`. **Not a system of record, and not
|
|
26
|
+
* merely because it is not durable: it DISCARDS.** Past `maxRecords` the oldest record is dropped
|
|
27
|
+
* on every write, so an audited action can run, succeed, be recorded, and leave nothing behind —
|
|
28
|
+
* which is exactly what an audit trail must never do. The trap this shape exists to make visible
|
|
29
|
+
* is that the shortest edit clearing `X_AUDIT_SINK_MISSING` is `setAuditSink(memoryAuditSink())`,
|
|
30
|
+
* and nothing about the call site says the result is amnesiac. A deployment that must keep its
|
|
31
|
+
* trail installs `postgresAuditSink({ executor })`, which drops nothing.
|
|
32
|
+
*
|
|
33
|
+
* `dropped` is what makes that statement checkable in a running process rather than a sentence
|
|
34
|
+
* here: a non-zero count on a real deployment is the sink saying it is the wrong one.
|
|
35
|
+
*/
|
|
36
|
+
export interface MemoryAuditSink extends AuditSink {
|
|
37
|
+
/** The retained window, oldest first. A copy — the log cannot be mutated through it. */
|
|
38
|
+
records(): readonly AuditRecord[];
|
|
39
|
+
/** Retained right now — the bound, observable. */
|
|
40
|
+
readonly size: number;
|
|
41
|
+
/** Records this sink has DISCARDED since the last `clear()`. Never a number to ignore. */
|
|
42
|
+
readonly dropped: number;
|
|
43
|
+
clear(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The OLDEST goes, which is the same direction `createMemoryEventBus` evicts in and the opposite
|
|
48
|
+
* of refusing new writes: a sink that stopped recording at the cap would answer "nothing has
|
|
49
|
+
* happened since" for a process that has been serving all day, and the most recent attempts are
|
|
50
|
+
* the ones anyone reading `x dev` is looking at.
|
|
51
|
+
*/
|
|
52
|
+
export function memoryAuditSink(options: MemoryAuditSinkOptions = {}): MemoryAuditSink {
|
|
53
|
+
const declared = options.maxRecords;
|
|
54
|
+
const maxRecords =
|
|
55
|
+
typeof declared === 'number' && Number.isFinite(declared) && declared >= 1
|
|
56
|
+
? Math.floor(declared)
|
|
57
|
+
: DEFAULT_MAX_AUDIT_RECORDS;
|
|
58
|
+
const log: AuditRecord[] = [];
|
|
59
|
+
let dropped = 0;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
write(record: AuditRecord): void {
|
|
63
|
+
log.push(record);
|
|
64
|
+
// `shift` in a loop, not a slice: the cap is only ever exceeded by one per write, so this
|
|
65
|
+
// runs at most once — and it releases the evicted record's `Ctx` rather than copying the
|
|
66
|
+
// array, which would hold both windows alive for the length of the copy.
|
|
67
|
+
while (log.length > maxRecords) {
|
|
68
|
+
log.shift();
|
|
69
|
+
dropped += 1;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
records: (): readonly AuditRecord[] => [...log],
|
|
73
|
+
get size(): number {
|
|
74
|
+
return log.length;
|
|
75
|
+
},
|
|
76
|
+
get dropped(): number {
|
|
77
|
+
return dropped;
|
|
78
|
+
},
|
|
79
|
+
clear: (): void => {
|
|
80
|
+
log.length = 0;
|
|
81
|
+
dropped = 0;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable audit sink: one append-only Postgres table, one insert per record. Without it the
|
|
3
|
+
* shortest edit that clears `X_AUDIT_SINK_MISSING` is `setAuditSink(memoryAuditSink())`, which
|
|
4
|
+
* reads durable at the call site and is a ring that DROPS — compliant in dev, amnesiac in
|
|
5
|
+
* production. Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Actor } from '@ultimat3/core';
|
|
9
|
+
import { uuid } from '@ultimat3/core';
|
|
10
|
+
import type { AuditRecord, AuditSink } from './audit';
|
|
11
|
+
import { auditableInput } from './audit-input';
|
|
12
|
+
import type { PgExecutor } from './idempotency-postgres';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, and
|
|
16
|
+
* for the same reason: this package holds no database dependency and cannot apply its own schema.
|
|
17
|
+
* `create table if not exists` is a no-op against a database that already has it, so a new column
|
|
18
|
+
* is added by `alter table … add column if not exists` and never by editing the `create`.
|
|
19
|
+
*
|
|
20
|
+
* **Two indexes and no third.** "Who did what, when" is the only question an audit table is opened
|
|
21
|
+
* for, and an index is a write cost paid on the audited path. A SUBJECT index — which row the
|
|
22
|
+
* action was about — is deliberately absent: the framework does not know a record's subject, and
|
|
23
|
+
* guessing one is the audit ENTITY this seam refuses to ship.
|
|
24
|
+
*
|
|
25
|
+
* **`at` is the one nullable column that looks like it should not be.** It is `ctx.now()`, and a
|
|
26
|
+
* `Clock` is injectable, so an app can hand this seam an Invalid Date — whose `toISOString()`
|
|
27
|
+
* THROWS. A sink that raises fails an invocation whose handler has already committed, so an
|
|
28
|
+
* unrepresentable instant is written as "this process could not say when" and `recorded_at`, the
|
|
29
|
+
* database's own clock, still stamps the row. `not null` would have traded a lost row for a lost
|
|
30
|
+
* write.
|
|
31
|
+
*
|
|
32
|
+
* **No retention, no purge, and that is the difference from every other table this framework
|
|
33
|
+
* owns.** `x_idempotency` and `x_rate_limit` both ship a purge because a stale row there is
|
|
34
|
+
* meaningless; a stale audit row is the record. How long a trail is kept is a legal question with
|
|
35
|
+
* a different answer per app — seven years for one, thirty days for the next — so shipping a
|
|
36
|
+
* `delete` would be shipping one of those answers. The table grows until the app prunes or
|
|
37
|
+
* partitions it, and that is stated rather than solved.
|
|
38
|
+
*/
|
|
39
|
+
export const SQL_AUDIT_TABLE = `
|
|
40
|
+
create table if not exists x_audit (
|
|
41
|
+
id uuid primary key,
|
|
42
|
+
at timestamptz,
|
|
43
|
+
action text not null,
|
|
44
|
+
mutator boolean not null,
|
|
45
|
+
surface text not null,
|
|
46
|
+
outcome text not null,
|
|
47
|
+
replayed boolean not null,
|
|
48
|
+
idempotency_key text,
|
|
49
|
+
failure_code text,
|
|
50
|
+
actor_id text not null,
|
|
51
|
+
actor_kind text not null,
|
|
52
|
+
org_id text,
|
|
53
|
+
on_behalf_of_id text,
|
|
54
|
+
on_behalf_of_kind text,
|
|
55
|
+
request_id text not null,
|
|
56
|
+
trace_id text not null,
|
|
57
|
+
locale text not null,
|
|
58
|
+
tz text not null,
|
|
59
|
+
build_id text not null,
|
|
60
|
+
role text not null,
|
|
61
|
+
input jsonb,
|
|
62
|
+
recorded_at timestamptz not null default now()
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
create index if not exists x_audit_at_idx on x_audit (at desc);
|
|
66
|
+
|
|
67
|
+
create index if not exists x_audit_actor_at_idx on x_audit (actor_id, at desc);
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `id` is generated per ROW and is not an idempotency key: two identical attempts are two events
|
|
72
|
+
* and an audit trail that collapsed them would be lying about how many times something was tried.
|
|
73
|
+
* There is deliberately no `on conflict` — an append-only table has nothing to reconcile.
|
|
74
|
+
*
|
|
75
|
+
* `recorded_at` defaults to `now()` and is not a parameter: `at` is the caller's clock
|
|
76
|
+
* (`ctx.now()`) and this is the database's, so the gap between them is the audit lag, and a
|
|
77
|
+
* process whose clock has drifted is visible instead of invisible.
|
|
78
|
+
*/
|
|
79
|
+
export const SQL_AUDIT_INSERT = `
|
|
80
|
+
insert into x_audit (
|
|
81
|
+
id, at, action, mutator, surface, outcome, replayed, idempotency_key, failure_code,
|
|
82
|
+
actor_id, actor_kind, org_id, on_behalf_of_id, on_behalf_of_kind,
|
|
83
|
+
request_id, trace_id, locale, tz, build_id, role, input
|
|
84
|
+
) values (
|
|
85
|
+
$1::uuid, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9,
|
|
86
|
+
$10, $11, $12, $13, $14,
|
|
87
|
+
$15, $16, $17, $18, $19, $20, $21::jsonb
|
|
88
|
+
)
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
export interface PostgresAuditSinkOptions {
|
|
92
|
+
readonly executor: PgExecutor;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PostgresAuditSink extends AuditSink {
|
|
96
|
+
write(record: AuditRecord): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* **Install it at boot, beside the store that declares the rest of this app's durability.** The
|
|
101
|
+
* app owes one line in `apps/web/server.ts`, over the client this process already opened:
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* const client = db();
|
|
105
|
+
* setAuditSink(
|
|
106
|
+
* postgresAuditSink({
|
|
107
|
+
* executor: { query: (text, values) => client.query({ text, values }) },
|
|
108
|
+
* }),
|
|
109
|
+
* );
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* `Bun.sql` does not satisfy `PgExecutor` — `Bun.sql.query` is `undefined`; see that interface.
|
|
113
|
+
*
|
|
114
|
+
* What is written is an ALLOW-LIST of the facts the framework itself owns, never the `Ctx`. That
|
|
115
|
+
* is not tidiness: `createContext` spreads every installed service onto the context object, and on
|
|
116
|
+
* an HTTP surface the value is a `RequestContext` carrying the request's own `Authorization` and
|
|
117
|
+
* `Cookie` headers — so a projection that walked it would write an app's database clients and its
|
|
118
|
+
* caller's credentials into an audit table. An app that wants more columns writes its own
|
|
119
|
+
* `AuditSink`; the seam is one method, and that is the extension point.
|
|
120
|
+
*/
|
|
121
|
+
export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAuditSink {
|
|
122
|
+
const exec = options.executor;
|
|
123
|
+
return {
|
|
124
|
+
async write(record: AuditRecord): Promise<void> {
|
|
125
|
+
const actor = record.ctx.actor;
|
|
126
|
+
const at = record.at instanceof Date && !Number.isNaN(record.at.getTime()) ? record.at : null;
|
|
127
|
+
const onBehalfOf = onBehalfOfOf(actor);
|
|
128
|
+
const input = auditableInput(record.input);
|
|
129
|
+
await exec.query(SQL_AUDIT_INSERT, [
|
|
130
|
+
uuid(),
|
|
131
|
+
at === null ? null : at.toISOString(),
|
|
132
|
+
record.action,
|
|
133
|
+
record.mutator,
|
|
134
|
+
record.surface,
|
|
135
|
+
record.outcome,
|
|
136
|
+
record.replayed,
|
|
137
|
+
record.idempotencyKey,
|
|
138
|
+
record.failure?.code ?? null,
|
|
139
|
+
actor.id,
|
|
140
|
+
actor.kind,
|
|
141
|
+
actor.orgId ?? null,
|
|
142
|
+
onBehalfOf?.actorId ?? null,
|
|
143
|
+
onBehalfOf?.actorKind ?? null,
|
|
144
|
+
record.ctx.requestId,
|
|
145
|
+
record.ctx.traceId,
|
|
146
|
+
record.ctx.locale,
|
|
147
|
+
record.ctx.tz,
|
|
148
|
+
record.ctx.buildId,
|
|
149
|
+
record.ctx.role,
|
|
150
|
+
// `undefined` in means a parse that never produced an input, which is a NULL column and
|
|
151
|
+
// not the four characters `JSON.stringify(undefined)` does not produce either.
|
|
152
|
+
input === undefined ? null : JSON.stringify(input),
|
|
153
|
+
]);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Both halves of an impersonation, recorded and interpreted as NEITHER. `actor_id` is who the
|
|
160
|
+
* framework ran the attempt as and `on_behalf_of_*` is what `impersonate()` recorded; which of
|
|
161
|
+
* them an app calls "who did this" is the convention four apps model four ways, so the row carries
|
|
162
|
+
* the two facts and takes no position between them.
|
|
163
|
+
*
|
|
164
|
+
* Read through a guard rather than off the type: `Actor.onBehalfOf` is optional, and an actor
|
|
165
|
+
* minted by an app's own `resolveToken` is a plain object nothing in this package validated.
|
|
166
|
+
*/
|
|
167
|
+
function onBehalfOfOf(actor: Actor): { actorId: string; actorKind: string } | null {
|
|
168
|
+
const origin: unknown = actor.onBehalfOf;
|
|
169
|
+
if (typeof origin !== 'object' || origin === null) return null;
|
|
170
|
+
const record = origin as Record<string, unknown>;
|
|
171
|
+
const actorId = record['actorId'];
|
|
172
|
+
const actorKind = record['actorKind'];
|
|
173
|
+
if (typeof actorId !== 'string' || typeof actorKind !== 'string') return null;
|
|
174
|
+
return { actorId, actorKind };
|
|
175
|
+
}
|
package/src/audit.ts
CHANGED
|
@@ -53,6 +53,12 @@ export interface AuditRecord {
|
|
|
53
53
|
* a sink needs to write a row at all. Carried whole rather than projected into `actorId` +
|
|
54
54
|
* `requestId` fields, because choosing WHICH context facts an audit row keeps is precisely the
|
|
55
55
|
* convention four apps modelled four ways.
|
|
56
|
+
*
|
|
57
|
+
* **A sink that PERSISTS must project it, and `audit-postgres.ts` is where that is done.**
|
|
58
|
+
* `createContext` spreads every installed service onto this object and an HTTP surface's value
|
|
59
|
+
* is a `RequestContext` carrying the caller's `Authorization` and `Cookie`, so writing it down
|
|
60
|
+
* whole puts an app's database clients and its caller's credentials in a table. Whole here,
|
|
61
|
+
* allow-listed there — the seam hands over everything and each sink decides what it keeps.
|
|
56
62
|
*/
|
|
57
63
|
readonly ctx: Ctx;
|
|
58
64
|
/**
|
|
@@ -82,26 +88,6 @@ export interface AuditSink {
|
|
|
82
88
|
write(record: AuditRecord): Promise<void> | void;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
/** The seam's memory implementation, for tests and `x dev`. Not a system of record. */
|
|
86
|
-
export interface MemoryAuditSink extends AuditSink {
|
|
87
|
-
/** In the order `invoke` produced them. A copy — the log cannot be mutated through it. */
|
|
88
|
-
records(): readonly AuditRecord[];
|
|
89
|
-
clear(): void;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function memoryAuditSink(): MemoryAuditSink {
|
|
93
|
-
const log: AuditRecord[] = [];
|
|
94
|
-
return {
|
|
95
|
-
write(record: AuditRecord): void {
|
|
96
|
-
log.push(record);
|
|
97
|
-
},
|
|
98
|
-
records: (): readonly AuditRecord[] => [...log],
|
|
99
|
-
clear: (): void => {
|
|
100
|
-
log.length = 0;
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
91
|
/**
|
|
106
92
|
* No default. A logger-backed default would satisfy `audit: true` with a line nobody stores,
|
|
107
93
|
* which is the silent pass this seam exists to remove: an audited action with no sink installed
|
package/src/client.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { Action } from './action';
|
|
|
16
16
|
import { ContractDriftError, RemoteActionError, RpcFailedError } from './errors';
|
|
17
17
|
import { derivePath } from './naming';
|
|
18
18
|
import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
|
|
19
|
+
import { issuesFromWire } from './wire-issues';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Loose constraint on purpose: a map of concrete `Action<In, Out>` values must be
|
|
@@ -212,6 +213,9 @@ function toUltimateError(text: string, status: number, name: string): UltimateEr
|
|
|
212
213
|
action: name,
|
|
213
214
|
status,
|
|
214
215
|
code,
|
|
216
|
+
// Parsed, never taken: `body` is whatever answered the request. A list this build cannot read
|
|
217
|
+
// is dropped rather than repaired, and `cause` below still carries every rejection in it.
|
|
218
|
+
issues: issuesFromWire(body['issues']),
|
|
215
219
|
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${status}`),
|
|
216
220
|
fix: stringOr(body['fix'], `x actions describe ${name} --json`),
|
|
217
221
|
// RFC-9457's `type` IS a documentation URI, so a server that sends no `docs` extension has
|
package/src/errors.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
UltimateError,
|
|
14
14
|
} from '@ultimat3/core';
|
|
15
15
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
16
|
+
import type { ValidationIssue } from '@ultimat3/schema';
|
|
16
17
|
|
|
17
18
|
// Re-exported, not re-declared: the five idempotency failures moved to their own file when this
|
|
18
19
|
// one reached the line ceiling, and every importer still reads them from `./errors`.
|
|
@@ -177,12 +178,32 @@ export class ActionPolicyMissingError extends UltimateError {
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
export class InputInvalidError extends UltimateError {
|
|
180
|
-
|
|
181
|
+
/**
|
|
182
|
+
* The rejections, addressed by path — `undefined` where the caller had only text.
|
|
183
|
+
*
|
|
184
|
+
* A `cause` is one line for a human and an agent to read; a client that renders a form needs to
|
|
185
|
+
* know WHICH field each rejection belongs to, and splitting the line back apart is guesswork the
|
|
186
|
+
* moment a message contains the separator. Both travel: the line is unchanged, and this is a
|
|
187
|
+
* structured channel beside it.
|
|
188
|
+
*/
|
|
189
|
+
readonly issues: readonly ValidationIssue[] | undefined;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `detail` is the rendered form of `issues` and must stay so — `formatIssues(issues).join('; ')`,
|
|
193
|
+
* which is what `validate.ts` (the one caller that passes both) does, and what `validate.test.ts`
|
|
194
|
+
* pins. The rendering is NOT done here on purpose: this module is reachable from `client.ts`,
|
|
195
|
+
* which is browser-safe, and `@ultimat3/schema` declares no `sideEffects`, so a value import of
|
|
196
|
+
* `formatIssues` here would pull that package's whole barrel into every browser bundle holding
|
|
197
|
+
* the typed client.
|
|
198
|
+
*/
|
|
199
|
+
constructor(name: string, detail: string, issues?: readonly ValidationIssue[]) {
|
|
181
200
|
super({
|
|
182
201
|
code: 'X_INPUT_INVALID',
|
|
183
202
|
cause: `input for action "${name}" failed validation: ${detail}`,
|
|
184
203
|
fix: `x actions describe ${name} --json # prints the expected input schema`,
|
|
204
|
+
...(issues === undefined ? {} : { meta: { issues } }),
|
|
185
205
|
});
|
|
206
|
+
this.issues = issues;
|
|
186
207
|
}
|
|
187
208
|
}
|
|
188
209
|
|
|
@@ -233,6 +254,12 @@ export interface RemoteFailure {
|
|
|
233
254
|
* a `javascript:` in the preferred slot cannot suppress a usable link behind it.
|
|
234
255
|
*/
|
|
235
256
|
readonly docs?: readonly (string | undefined)[] | undefined;
|
|
257
|
+
/**
|
|
258
|
+
* The per-field rejections the document carried, already parsed — `issuesFromWire`'s answer,
|
|
259
|
+
* never the raw member. `undefined` where the body had none or where it had one this build
|
|
260
|
+
* refuses to read, and in both cases `cause` still holds every rejection.
|
|
261
|
+
*/
|
|
262
|
+
readonly issues?: readonly ValidationIssue[] | undefined;
|
|
236
263
|
}
|
|
237
264
|
|
|
238
265
|
/** A link, not a string the server happened to put in a field the overlay renders as an href. */
|
|
@@ -286,7 +313,14 @@ export class RemoteActionError extends UltimateError {
|
|
|
286
313
|
// `retryFor(code)`, which fails closed — so a 503 out of a typed call announced itself as
|
|
287
314
|
// `terminal` on the one field the framework promises a client never has to infer.
|
|
288
315
|
retry: retryForStatus(failure.code, failure.status),
|
|
289
|
-
meta: {
|
|
316
|
+
meta: {
|
|
317
|
+
origin: 'remote',
|
|
318
|
+
action: failure.action,
|
|
319
|
+
status: failure.status,
|
|
320
|
+
// Absent rather than `undefined`: `meta` is rendered into `--json` and the error reporter,
|
|
321
|
+
// and a null member reads as "the server sent an empty list" rather than "it sent none".
|
|
322
|
+
...(failure.issues === undefined ? {} : { issues: failure.issues }),
|
|
323
|
+
},
|
|
290
324
|
});
|
|
291
325
|
this.status = failure.status;
|
|
292
326
|
}
|
package/src/index.ts
CHANGED
|
@@ -67,9 +67,18 @@ export type {
|
|
|
67
67
|
AuditOutcome,
|
|
68
68
|
AuditRecord,
|
|
69
69
|
AuditSink,
|
|
70
|
-
MemoryAuditSink,
|
|
71
70
|
} from './audit';
|
|
72
|
-
export { getAuditSink,
|
|
71
|
+
export { getAuditSink, resetAuditSink, setAuditSink } from './audit';
|
|
72
|
+
export { AUDIT_INPUT_MAX_DEPTH, auditableInput, UNREPRESENTABLE } from './audit-input';
|
|
73
|
+
export type { MemoryAuditSink, MemoryAuditSinkOptions } from './audit-memory';
|
|
74
|
+
export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
|
|
75
|
+
/**
|
|
76
|
+
* The DURABLE sink, and the only one an app that must keep its trail may install. The statements
|
|
77
|
+
* are exported beside it because the table is applied the way `SQL_IDEMPOTENCY_TABLE` is — by the
|
|
78
|
+
* boot, never by an app migration.
|
|
79
|
+
*/
|
|
80
|
+
export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
|
|
81
|
+
export { postgresAuditSink, SQL_AUDIT_INSERT, SQL_AUDIT_TABLE } from './audit-postgres';
|
|
73
82
|
export type {
|
|
74
83
|
ActionLike,
|
|
75
84
|
ActionMap,
|
|
@@ -224,3 +233,23 @@ export {
|
|
|
224
233
|
registerActions,
|
|
225
234
|
resetRegistry,
|
|
226
235
|
} from './registry';
|
|
236
|
+
/**
|
|
237
|
+
* A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
|
|
238
|
+
* a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
|
|
239
|
+
* handle and its manifest row. The machine itself is `@ultimat3/entity`'s — this package owns the
|
|
240
|
+
* projection, not the legality rule.
|
|
241
|
+
*/
|
|
242
|
+
export type {
|
|
243
|
+
TransitionDef,
|
|
244
|
+
TransitionInput,
|
|
245
|
+
TransitionTarget,
|
|
246
|
+
TransitionValues,
|
|
247
|
+
} from './transition';
|
|
248
|
+
export { transition } from './transition';
|
|
249
|
+
/**
|
|
250
|
+
* The one reader of a problem document's `issues` member. Exported because the typed client is not
|
|
251
|
+
* the only caller that meets one: an island that posts with a plain `fetch` — which is what
|
|
252
|
+
* `x g resource` emits, to keep this package out of its chunk — holds the parsed body itself and
|
|
253
|
+
* would otherwise write a second, unvalidated reader.
|
|
254
|
+
*/
|
|
255
|
+
export { issuesFromWire, MAX_WIRE_ISSUES } from './wire-issues';
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transition()` — a MUTATOR factory over one entity column's state machine. Not a ninth primitive:
|
|
3
|
+
* a move is a server-authoritative write with an input schema, an output schema and a policy, which
|
|
4
|
+
* is what a `mutator` already is, so this RETURNS one and inherits the route, the OpenAPI operation,
|
|
5
|
+
* the typed client, the MCP tool, the job handle and its manifest row.
|
|
6
|
+
*
|
|
7
|
+
* It lives here and not in `@ultimat3/entity` because `mutator()` is tier 3 and entity is tier 2 —
|
|
8
|
+
* the same relationship `search()` has to `@ultimat3/query`. The mechanism underneath is entity's:
|
|
9
|
+
* this file makes no legality decision and answers no refusal of its own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Ctx } from '@ultimat3/core';
|
|
13
|
+
import type {
|
|
14
|
+
InferOutput,
|
|
15
|
+
ObjectSchema,
|
|
16
|
+
Schema,
|
|
17
|
+
StandardSchemaV1,
|
|
18
|
+
StringSchema,
|
|
19
|
+
} from '@ultimat3/schema';
|
|
20
|
+
import { t } from '@ultimat3/schema';
|
|
21
|
+
import { type LocalRow, type Mutator, mutator } from './mutator';
|
|
22
|
+
import type { ActionPolicy } from './policy-gate';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The one method this factory calls, declared structurally: `@ultimat3/entity`'s `Table.transition`
|
|
26
|
+
* satisfies it as written. Structural and not an import because `@ultimat3/action` holds no
|
|
27
|
+
* dependency edge on `@ultimat3/entity` — the tier table permits one (2 is below 3), the manifest
|
|
28
|
+
* and the lockfile do not — the same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down.
|
|
29
|
+
*
|
|
30
|
+
* `id` is a plain `string` rather than entity's `IdOf<Row>`: that alias "collapses to `string` for
|
|
31
|
+
* every unbranded entity" by its own account, and a branded one still satisfies this because a
|
|
32
|
+
* method's parameters compare bivariantly. The input schema mints a `string`, so declaring anything
|
|
33
|
+
* narrower here would buy a cast and nothing else.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The input every transition takes, spelled once: the row, the state the caller believes it is in,
|
|
37
|
+
* and the state it wants. Named because it is what the typed client and the MCP tool are typed by.
|
|
38
|
+
*/
|
|
39
|
+
/** The parsed input, spelled concretely — what `TransitionInput<S>` reduces to at every call site. */
|
|
40
|
+
export interface TransitionValues<S extends string> {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly from: S;
|
|
43
|
+
readonly to: S;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type TransitionInput<S extends string> = ObjectSchema<{
|
|
47
|
+
readonly id: StringSchema;
|
|
48
|
+
readonly from: Schema<S, S>;
|
|
49
|
+
readonly to: Schema<S, S>;
|
|
50
|
+
}>;
|
|
51
|
+
|
|
52
|
+
export interface TransitionTarget<Row, S extends string> {
|
|
53
|
+
transition(column: string, id: string, move: { readonly from: S; readonly to: S }): Promise<Row>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TransitionDef<
|
|
57
|
+
TOutput extends StandardSchemaV1,
|
|
58
|
+
Row extends InferOutput<TOutput> & object,
|
|
59
|
+
K extends keyof Row & string,
|
|
60
|
+
S extends Row[K] & string,
|
|
61
|
+
> {
|
|
62
|
+
/** The request's table — `(ctx) => posts(ctx)`, so the move is tenant-scoped like every write. */
|
|
63
|
+
readonly table: (ctx: Ctx) => TransitionTarget<Row, S>;
|
|
64
|
+
/** The column whose `enumerated().transitions()` declaration IS the machine. */
|
|
65
|
+
readonly column: K;
|
|
66
|
+
/**
|
|
67
|
+
* The states, as the input schema. Typed `Row[K]`, so a state the row cannot hold is a compile
|
|
68
|
+
* error here — and every projection inherits the enum: OpenAPI documents the legal set, the MCP
|
|
69
|
+
* tool's `inputSchema` carries it, the typed client refuses a typo at COMPILE time, and a
|
|
70
|
+
* misspelled state is `X_INPUT_INVALID` before the request reaches a database.
|
|
71
|
+
*
|
|
72
|
+
* It is the one thing restated from the column's own declaration, and the reason is a boundary:
|
|
73
|
+
* reading the machine off the entity needs `@ultimat3/entity` as a real dependency of this
|
|
74
|
+
* package. Listing a SUBSET refuses a legal move at the input schema — loud, and the fix is the
|
|
75
|
+
* enum in the refusal.
|
|
76
|
+
*/
|
|
77
|
+
readonly states: readonly [S, ...S[]];
|
|
78
|
+
/** The local store's name for this entity — what the optimistic twin patches. */
|
|
79
|
+
readonly localTable: string;
|
|
80
|
+
/** The projection the caller gets back. Unknown keys are dropped by the parse, so a `$view` works. */
|
|
81
|
+
readonly output: TOutput;
|
|
82
|
+
readonly policy: ActionPolicy;
|
|
83
|
+
/**
|
|
84
|
+
* OFF unless the app says otherwise, and deliberately not `?? true`.
|
|
85
|
+
*
|
|
86
|
+
* A transition is exactly the kind of event an audit sink is for — and `audit: true` with no sink
|
|
87
|
+
* installed is `X_AUDIT_SINK_MISSING`, raised before the input parse. Defaulting it on would make
|
|
88
|
+
* every `transition()` refuse in an app that has not made a separate, unrelated decision, which is
|
|
89
|
+
* a framework default holding the feature hostage. What the row is kept for, and for how long, is
|
|
90
|
+
* the same compliance question that kept a purge out of `postgresAuditSink`.
|
|
91
|
+
*/
|
|
92
|
+
readonly audit?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `from` is REQUIRED and is never defaulted or inferred. It rides in the UPDATE's own predicate, so
|
|
97
|
+
* the state observed and the state written are one decision under the row's lock — optimistic
|
|
98
|
+
* concurrency in the ETag shape. Measured on the mechanism underneath: twenty concurrent moves at
|
|
99
|
+
* one row produced 14 winners with a read-then-check-then-write, and 1 winner plus 19 refusals with
|
|
100
|
+
* `from` in the predicate. Anything that supplies `from` on the caller's behalf is the lost update
|
|
101
|
+
* coming back.
|
|
102
|
+
*/
|
|
103
|
+
export function transition<
|
|
104
|
+
TOutput extends StandardSchemaV1,
|
|
105
|
+
Row extends InferOutput<TOutput> & object,
|
|
106
|
+
K extends keyof Row & string,
|
|
107
|
+
const S extends Row[K] & string,
|
|
108
|
+
>(def: TransitionDef<TOutput, Row, K, S>): Mutator<TransitionInput<S>, TOutput> {
|
|
109
|
+
const state = t.enum(def.states);
|
|
110
|
+
// ONE cast, and it is a compiler limitation rather than an unknown value: `t.object`'s output is
|
|
111
|
+
// a mapped type over its shape, and a mapped type does not reduce while a type parameter is still
|
|
112
|
+
// open — so `input.id` is unreachable INSIDE this function even though every call site resolves
|
|
113
|
+
// it exactly. `@ultimat3/entity`'s `transitionRow` spells its own patch this way for the same
|
|
114
|
+
// reason. What arrives here has already been parsed by the schema two lines up, and nothing else
|
|
115
|
+
// can reach these two callbacks.
|
|
116
|
+
const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>;
|
|
117
|
+
return mutator({
|
|
118
|
+
input: t.object({ id: t.uuid, from: state, to: state }),
|
|
119
|
+
output: def.output,
|
|
120
|
+
policy: def.policy,
|
|
121
|
+
...(def.audit === undefined ? {} : { audit: def.audit }),
|
|
122
|
+
// Never overridable: the server is the half that REFUSED the move, and a local twin that won
|
|
123
|
+
// the rebase would leave the client showing a state the database rejected.
|
|
124
|
+
conflict: 'server-wins',
|
|
125
|
+
local: (tx, raw) => {
|
|
126
|
+
const input = valuesOf(raw);
|
|
127
|
+
// `as Partial<…>`: a computed key widens to an index signature, which is never assignable to
|
|
128
|
+
// a `Partial` of a type parameter. `def.column` is `keyof Row`, so the shape is a real one.
|
|
129
|
+
tx.table<Row & LocalRow>(def.localTable).update(input.id, {
|
|
130
|
+
[def.column]: input.to,
|
|
131
|
+
} as Partial<Row & LocalRow>);
|
|
132
|
+
},
|
|
133
|
+
// No cast on `from`/`to`: they are the enum's own union, which is `Row[K]`. And no legality
|
|
134
|
+
// check here — `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are
|
|
135
|
+
// entity's and propagate as they are. A second error class over one failure is a second path.
|
|
136
|
+
server: (ctx, raw) => {
|
|
137
|
+
const input = valuesOf(raw);
|
|
138
|
+
return def.table(ctx).transition(def.column, input.id, {
|
|
139
|
+
from: input.from,
|
|
140
|
+
to: input.to,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -5,9 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
-
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
8
|
+
import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema';
|
|
9
9
|
import { InputInvalidError, OutputInvalidError } from './errors';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The refusal carries the issue list as well as the line, and the two are ONE value rendered twice:
|
|
13
|
+
* `formatIssues` reads `path` and `message`, which is exactly what `toValidationIssues` copied out
|
|
14
|
+
* of the library's own issues, so the string is byte-identical to the one this threw before.
|
|
15
|
+
*
|
|
16
|
+
* `toValidationIssues`, never the raw `result.issues`: a conforming library's issue object may
|
|
17
|
+
* carry members Ultimate's shape does not — including the rejected VALUE — and this list is
|
|
18
|
+
* handed to an HTTP surface that returns it to the caller. Four members travel, and
|
|
19
|
+
* `describeValue` is what keeps a value out of the fifth.
|
|
20
|
+
*/
|
|
11
21
|
export async function validateInput<S extends StandardSchemaV1>(
|
|
12
22
|
schema: S,
|
|
13
23
|
raw: unknown,
|
|
@@ -15,7 +25,8 @@ export async function validateInput<S extends StandardSchemaV1>(
|
|
|
15
25
|
): Promise<InferOutput<S>> {
|
|
16
26
|
const result = await validateAsync(schema, raw);
|
|
17
27
|
if (result.issues !== undefined) {
|
|
18
|
-
|
|
28
|
+
const issues = toValidationIssues(result.issues);
|
|
29
|
+
throw new InputInvalidError(actionName, formatIssues(issues).join('; '), issues);
|
|
19
30
|
}
|
|
20
31
|
return result.value;
|
|
21
32
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one reader of a problem document's `issues` member: an untrusted array off the wire, back
|
|
3
|
+
* into the `ValidationIssue` shape `@ultimat3/schema` already mints. Its own module because it is
|
|
4
|
+
* the only place in the client where a value nobody in this process built is turned into a
|
|
5
|
+
* structure another layer will render.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { stringField } from '@ultimat3/core';
|
|
9
|
+
import type { ValidationIssue } from '@ultimat3/schema';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
|
|
13
|
+
* entries are rendered into a DOM by whoever displays them, so the bound is here rather than there.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_WIRE_ISSUES = 100;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* All-or-nothing on purpose. A partly-parsed list would DROP the entries it could not read, and
|
|
19
|
+
* nothing downstream would know: a caller that finds `meta.issues` uses it INSTEAD of the
|
|
20
|
+
* flattened `cause`, so a dropped entry is a rejection the user never hears about. Refusing the
|
|
21
|
+
* whole list leaves the `cause` — which still holds every issue — as the answer.
|
|
22
|
+
*/
|
|
23
|
+
export function issuesFromWire(value: unknown): readonly ValidationIssue[] | undefined {
|
|
24
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_WIRE_ISSUES) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const issues: ValidationIssue[] = [];
|
|
28
|
+
for (const entry of value as readonly unknown[]) {
|
|
29
|
+
if (typeof entry !== 'object' || entry === null) return undefined;
|
|
30
|
+
// Strict on the two members that DECIDE where an issue lands, defaulted on the two that only
|
|
31
|
+
// describe it: a `path` that is not a string would bind a rejection somewhere it does not
|
|
32
|
+
// belong, while a missing `expected` cannot mis-route anything.
|
|
33
|
+
const path = stringField(entry, 'path');
|
|
34
|
+
const message = stringField(entry, 'message');
|
|
35
|
+
if (path === undefined || message === undefined || message.length === 0) return undefined;
|
|
36
|
+
// Built member by member, never spread: a foreign issue object may carry the rejected VALUE
|
|
37
|
+
// (some libraries put it in `received`), and a whole-object copy would forward it to whoever
|
|
38
|
+
// renders the list. Four members travel; everything else stops here.
|
|
39
|
+
issues.push({
|
|
40
|
+
path,
|
|
41
|
+
expected: stringField(entry, 'expected') ?? '',
|
|
42
|
+
received: stringField(entry, 'received') ?? '',
|
|
43
|
+
message,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return issues;
|
|
47
|
+
}
|