@ultimat3/schema 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +25 -4
- package/README.md +43 -1
- package/package.json +13 -2
- package/src/array-schema.ts +17 -4
- package/src/builder.ts +57 -3
- package/src/discriminated-union.ts +15 -4
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +5 -1
- package/src/json-schema.ts +4 -1
- package/src/json-value.ts +3 -3
- package/src/money-value.ts +7 -2
- package/src/node.ts +5 -0
- package/src/record-schema.ts +78 -0
- package/src/sample-draw.ts +118 -0
- package/src/sample-seeded.ts +147 -0
- package/src/sample.ts +161 -0
- package/src/schema-rules.ts +65 -0
- package/src/sensitive.ts +137 -0
- package/src/standard.ts +9 -0
- package/src/validators.ts +66 -75
package/CLAUDE.md
CHANGED
|
@@ -16,8 +16,22 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
|
|
|
16
16
|
|
|
17
17
|
Module order (no cycles):
|
|
18
18
|
`char-count → describe-value → node → builder → money-value → json-value → array-schema →
|
|
19
|
-
validators → discriminated-union → provider → t` (`node-fits` and `json-schema` read
|
|
20
|
-
too).
|
|
19
|
+
validators → record-schema → discriminated-union → provider → t` (`node-fits` and `json-schema` read
|
|
20
|
+
`json-value` too). `record-schema.ts` (`t.record`) split from `validators.ts` at the ceiling.
|
|
21
|
+
`schema-rules.ts` is a leaf (`SCHEMA_RULES`, `issueRuleFacts`), imported by `standard.ts`,
|
|
22
|
+
`errors.ts` (type only) and `builder.ts`. `sensitive.ts` reads `provider.ts` and is not in `t`'s graph.
|
|
23
|
+
|
|
24
|
+
- **A builtin refusal names its rule**: `rejected(path, message, rule, params?)` (`builder.ts`,
|
|
25
|
+
internal) has `rule` REQUIRED; `mistyped(path, message, value, type)` answers `required` for
|
|
26
|
+
`undefined` and `type { type }` otherwise. The exported `fail` keeps `rule?` optional for
|
|
27
|
+
providers. `schema-rules.test.ts` fails if a builtin file calls `fail(`, and pins `SCHEMA_RULES`
|
|
28
|
+
(add, never rename). `params` are declared facts only, never the rejected value.
|
|
29
|
+
- **`.sensitive()` is a node fact, never a key table**: `maskSensitive` memoises "holds a flag" per
|
|
30
|
+
node in a WeakMap, so an unflagged schema costs one map read and returns the same reference. A
|
|
31
|
+
union masks every branch's flagged keys (fails closed). `assertNoSensitiveOutput` lives here
|
|
32
|
+
because `query` cannot import `action`.
|
|
33
|
+
- Bytes: the `t`-only browser chunk is 19,788 B minified (+1,321 B, gzip +404, for a rule and params
|
|
34
|
+
at every refusal site), `Bun.build --minify`, 2026-11-03.
|
|
21
35
|
`char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
|
|
22
36
|
`describe-value.ts` (which renders the length in the same message), because they disagreed: the
|
|
23
37
|
rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
|
|
@@ -50,7 +64,7 @@ overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
|
|
|
50
64
|
|
|
51
65
|
`t.array(items, { min, max })` (`array-schema.ts`) carries its bounds as `minItems`/`maxItems` ON
|
|
52
66
|
the node — JSON Schema's names — and `json-schema.ts` and `wire-schema.ts` publish both;
|
|
53
|
-
|
|
67
|
+
`sample.ts`'s walker samples `minItems` items. A bound no count meets is
|
|
54
68
|
`X_SCHEMA_BOUNDS_INVALID` where it is declared, the same rule as the discriminant below.
|
|
55
69
|
|
|
56
70
|
`X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
|
|
@@ -190,7 +204,7 @@ Gotchas:
|
|
|
190
204
|
is `describeValue`d in the `cause` and whole in `meta`, never echoed.
|
|
191
205
|
- **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
|
|
192
206
|
has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
|
|
193
|
-
value through,
|
|
207
|
+
value through, `sample.ts` answers `null` — and they live in packages
|
|
194
208
|
a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
|
|
195
209
|
reason: a refined string still reads as a string, and a discriminated union still reads as a
|
|
196
210
|
union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
|
|
@@ -207,3 +221,10 @@ Gotchas:
|
|
|
207
221
|
- `@ultimat3/action`'s `sampleInput` does not read `refinements`, so a contract test over a refined
|
|
208
222
|
schema reports `X_CONTRACT_DRIFT` exactly as it already does for a bare `pattern`. Documented
|
|
209
223
|
there; fixing it is that package's change, not this one's.
|
|
224
|
+
|
|
225
|
+
## The `./sample` subpath
|
|
226
|
+
|
|
227
|
+
- `sample.ts`, `sample-draw.ts` and `sample-seeded.ts` are the `./sample` subpath, never re-exported
|
|
228
|
+
from `src/index.ts` (axiom 6: `t` is in every bundle graph).
|
|
229
|
+
- Module order: `sample-draw → sample-seeded → sample`, reading `node` and `provider`.
|
|
230
|
+
- `seededDraw` is mulberry32 over the seed it is handed. It never uses `Math.random` or the clock.
|
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Tier 0. The validation seam. One schema declaration projects to a runtime validator, a TypeScript
|
|
4
4
|
type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query coercion.
|
|
5
5
|
|
|
6
|
+
`bun add @ultimat3/schema` · guide: [The Eight Primitives](https://github.com/developerz-ai/ultimate/wiki/The-Eight-Primitives)
|
|
7
|
+
|
|
6
8
|
| Owns | Module |
|
|
7
9
|
|---|---|
|
|
8
10
|
| Standard Schema v1 types + `validate()` / `parse()` for **any** conforming library | `standard.ts` |
|
|
@@ -17,6 +19,9 @@ type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query c
|
|
|
17
19
|
| schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` |
|
|
18
20
|
| HTTP-boundary coercion, kept out of validation | `coerce.ts` |
|
|
19
21
|
| `X_VALIDATION_FAILED` with path-annotated issues | `errors.ts` |
|
|
22
|
+
| `SCHEMA_RULES` — the closed vocabulary an issue's `rule` names — and `issueRuleFacts` | `schema-rules.ts` |
|
|
23
|
+
| `.sensitive()`'s two readers: `maskSensitive`, `assertNoSensitiveOutput` | `sensitive.ts` |
|
|
24
|
+
| `@ultimat3/schema/sample`, off the barrel: `sampleInput` (the minimal value), `arbitrary(schema, { seed, run })` (the contract step's generated inputs), one walker over a `Draw` (`minimalDraw`, `seededDraw`), `sampleGaps` | `sample.ts`, `sample-draw.ts`, `sample-seeded.ts` |
|
|
20
25
|
|
|
21
26
|
## `t`
|
|
22
27
|
|
|
@@ -39,7 +44,7 @@ type PublishPost = Infer<typeof publishPost>;
|
|
|
39
44
|
|
|
40
45
|
| Value schemas | Factories | Methods |
|
|
41
46
|
|---|---|---|
|
|
42
|
-
| `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.refine(r)` |
|
|
47
|
+
| `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.sensitive()` `.refine(r)` |
|
|
43
48
|
| `money` `timezone` `locale` `slug` `cursor` `json()` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |
|
|
44
49
|
|
|
45
50
|
`nullable` is a value the row holds; `optional` is the caller omitting the key. Both ship as a
|
|
@@ -47,6 +52,43 @@ namespace member (`t.nullable`) and a free function (`nullableSchema`) — symme
|
|
|
47
52
|
|
|
48
53
|
Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
|
|
49
54
|
|
|
55
|
+
### `.sensitive()` — sent in, never written down, never sent back
|
|
56
|
+
|
|
57
|
+
`cardPan: t.string.sensitive()` is JSON Schema's `writeOnly`, published as such. Last in a chain:
|
|
58
|
+
it answers a plain `Schema`. Read by the walkers that hold the schema, never by a key table, so
|
|
59
|
+
flagging `note` on one action changes nothing another action records:
|
|
60
|
+
|
|
61
|
+
| Reader | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `maskSensitive(schema, value, '[redacted]')` | a copy with every flagged field replaced — through objects, arrays, records and EVERY union branch (fail closed, no branch resolution). The same reference when nothing is flagged |
|
|
64
|
+
| `assertNoSensitiveOutput(owner, schema)` | `X_SCHEMA_SENSITIVE_OUTPUT` naming the path — an action's `output` and a query's `rows` are checked at registration |
|
|
65
|
+
|
|
66
|
+
Reach, exactly: the `audit: true` record of an action or a query, and `x jobs show` (a job whose
|
|
67
|
+
declaration is gone shows its input `[redacted]` whole). **Not** an app's own log line — a
|
|
68
|
+
`logger.info('x', input)` holds no schema; that stays `redactKeys` or a `Secret`. PII read back
|
|
69
|
+
and only hidden in logs is `redactKeys(['ssn'])`, not this flag.
|
|
70
|
+
|
|
71
|
+
### An issue names its `rule`
|
|
72
|
+
|
|
73
|
+
Every builtin refusal carries `rule` from `SCHEMA_RULES` and the declared `params` a translator
|
|
74
|
+
interpolates — ``t(`form.${issue.rule}`, issue.params)``, never a match on the English `message`:
|
|
75
|
+
|
|
76
|
+
| `rule` | `params` |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `required` (nothing sent) · `type` | — · `{ type }` (`string`, `number`, `object`, `money`, …) |
|
|
79
|
+
| `string.min` `string.max` | `{ min }` `{ max }` (characters) |
|
|
80
|
+
| `string.pattern` · `string.format` | `{ pattern }` · `{ format }` (`uuid`, `email`, `uri`, `timezone`, `locale`, `date-time`) |
|
|
81
|
+
| `number.min` `number.max` `number.integer` | `{ min }` `{ max }` — |
|
|
82
|
+
| `enum` · `literal` · `union` | `{ values }` (`a \| b`) · `{ value }` · — (a discriminated union: `{ values }`) |
|
|
83
|
+
| `array.min` `array.max` | `{ min }` `{ max }` (items) |
|
|
84
|
+
| `record.key` `json` `money` | — |
|
|
85
|
+
| `refine` | `{ name }` |
|
|
86
|
+
|
|
87
|
+
The values are contract like an `X_*` code: added, never renamed (`schema-rules.test.ts` pins the
|
|
88
|
+
list). `rule` is optional on the issue type — a third-party provider names none — and the exported
|
|
89
|
+
`fail(path, message, rule?, params?)` takes both optionally for a provider. Inside this package a
|
|
90
|
+
builtin refuses through `rejected`, whose `rule` is required.
|
|
91
|
+
|
|
50
92
|
Refused at the boundary rather than guessed at (`As of 2026-10`):
|
|
51
93
|
|
|
52
94
|
| Schema | Refuses | Because |
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/schema",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.1.0",
|
|
4
4
|
"description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"validation",
|
|
10
|
+
"schema",
|
|
11
|
+
"standard-schema",
|
|
12
|
+
"json-schema"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/The-Eight-Primitives",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"sideEffects": false,
|
|
@@ -15,7 +25,8 @@
|
|
|
15
25
|
"provenance": true
|
|
16
26
|
},
|
|
17
27
|
"exports": {
|
|
18
|
-
".": "./src/index.ts"
|
|
28
|
+
".": "./src/index.ts",
|
|
29
|
+
"./sample": "./src/sample.ts"
|
|
19
30
|
},
|
|
20
31
|
"files": [
|
|
21
32
|
"src",
|
package/src/array-schema.ts
CHANGED
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
// bounds. Its own file because the bounds are a declaration-time refusal as well as a parse rule,
|
|
3
3
|
// and `validators.ts` was at the line ceiling.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type AnySchema,
|
|
7
|
+
checkOf,
|
|
8
|
+
failWith,
|
|
9
|
+
makeSchema,
|
|
10
|
+
mistyped,
|
|
11
|
+
pass,
|
|
12
|
+
rejected,
|
|
13
|
+
type Schema,
|
|
14
|
+
} from './builder';
|
|
6
15
|
import { describeValue, expected } from './describe-value';
|
|
7
16
|
import { BoundsInvalidError } from './errors';
|
|
8
17
|
import type { SchemaNode } from './node';
|
|
@@ -57,14 +66,18 @@ export function arraySchema<S extends AnySchema>(
|
|
|
57
66
|
...(max === undefined ? {} : { maxItems: max }),
|
|
58
67
|
};
|
|
59
68
|
return makeSchema<readonly InferInput<S>[], InferOutput<S>[]>(node, (value, path) => {
|
|
60
|
-
if (!Array.isArray(value)) return
|
|
69
|
+
if (!Array.isArray(value)) return mistyped(path, expected('an array', value), value, 'array');
|
|
61
70
|
// The count before the items: an over-long array is refused without reading a member, so a
|
|
62
71
|
// caller's 10,000-element body costs one comparison, not 10,000 item checks.
|
|
63
72
|
if (min !== undefined && value.length < min) {
|
|
64
|
-
return
|
|
73
|
+
return rejected(path, expected(`an array of at least ${items(min)}`, value), 'array.min', {
|
|
74
|
+
min,
|
|
75
|
+
});
|
|
65
76
|
}
|
|
66
77
|
if (max !== undefined && value.length > max) {
|
|
67
|
-
return
|
|
78
|
+
return rejected(path, expected(`an array of at most ${items(max)}`, value), 'array.max', {
|
|
79
|
+
max,
|
|
80
|
+
});
|
|
68
81
|
}
|
|
69
82
|
const issues: StandardIssue[] = [];
|
|
70
83
|
const out: unknown[] = [];
|
package/src/builder.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ValidationIssue,
|
|
10
10
|
} from './errors';
|
|
11
11
|
import type { SchemaNode, SchemaRefinement } from './node';
|
|
12
|
+
import { issueRuleFacts, type SchemaIssueParams, type SchemaRule } from './schema-rules';
|
|
12
13
|
import {
|
|
13
14
|
formatIssues,
|
|
14
15
|
formatPath,
|
|
@@ -42,8 +43,46 @@ export function pass<Out>(value: Out): CheckOk<Out> {
|
|
|
42
43
|
return { ok: true, value };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The PROVIDER's refusal: `rule` and `params` optional, because a library backing `t` may have no
|
|
48
|
+
* name for its rule. A builtin validator never calls this — it calls `rejected`, whose `rule` is
|
|
49
|
+
* required, so a builtin refusal a translator cannot key on is a type error (`schema-rules.test.ts`
|
|
50
|
+
* pins that no builtin file reaches for `fail`).
|
|
51
|
+
*/
|
|
52
|
+
export function fail(
|
|
53
|
+
path: Path,
|
|
54
|
+
message: string,
|
|
55
|
+
rule?: string,
|
|
56
|
+
params?: SchemaIssueParams,
|
|
57
|
+
): CheckErr {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
issues: [
|
|
61
|
+
{
|
|
62
|
+
message,
|
|
63
|
+
path: [...path],
|
|
64
|
+
...(rule === undefined ? {} : { rule }),
|
|
65
|
+
...(params === undefined ? {} : { params }),
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Package-internal: a builtin's refusal, naming its rule from the closed `SCHEMA_RULES`. */
|
|
72
|
+
export function rejected(
|
|
73
|
+
path: Path,
|
|
74
|
+
message: string,
|
|
75
|
+
rule: SchemaRule,
|
|
76
|
+
params?: SchemaIssueParams,
|
|
77
|
+
): CheckErr {
|
|
78
|
+
return fail(path, message, rule, params);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The wrong kind of value: `required` when nothing was sent, `type` naming what was wanted. */
|
|
82
|
+
export function mistyped(path: Path, message: string, value: unknown, type: string): CheckErr {
|
|
83
|
+
return value === undefined
|
|
84
|
+
? rejected(path, message, 'required')
|
|
85
|
+
: rejected(path, message, 'type', { type });
|
|
47
86
|
}
|
|
48
87
|
|
|
49
88
|
export function failWith(issues: readonly StandardIssue[]): CheckErr {
|
|
@@ -98,6 +137,12 @@ export interface Schema<In = unknown, Out = In> extends StandardSchemaV1<In, Out
|
|
|
98
137
|
nullable(): Schema<In | null, Out | null>;
|
|
99
138
|
default(value: Out): Schema<In | undefined, Out>;
|
|
100
139
|
describe(description: string): Schema<In, Out>;
|
|
140
|
+
/**
|
|
141
|
+
* Sent in, never written down, never sent back. The audit record and `x jobs show` mask it, it
|
|
142
|
+
* publishes as `writeOnly`, and an output that reaches it is `X_SCHEMA_SENSITIVE_OUTPUT`. NOT the
|
|
143
|
+
* app's own log lines — a `logger.info('x', input)` holds no schema; that stays `redactKeys`.
|
|
144
|
+
*/
|
|
145
|
+
sensitive(): Schema<In, Out>;
|
|
101
146
|
/**
|
|
102
147
|
* Attach a cross-field rule. Returns a plain `Schema` on purpose — `extend`/`pick`/`omit`
|
|
103
148
|
* rebuild from the shape and would silently drop the refinement, so refining last is a type
|
|
@@ -191,6 +236,7 @@ function toIssues(result: CheckErr): readonly ValidationIssue[] {
|
|
|
191
236
|
expected: issue.message,
|
|
192
237
|
received: '',
|
|
193
238
|
message: issue.message,
|
|
239
|
+
...issueRuleFacts(issue),
|
|
194
240
|
}));
|
|
195
241
|
}
|
|
196
242
|
|
|
@@ -242,6 +288,9 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
242
288
|
describe(description: string): Schema<In, Out> {
|
|
243
289
|
return makeSchema<In, Out>({ ...node, description }, check);
|
|
244
290
|
},
|
|
291
|
+
sensitive(): Schema<In, Out> {
|
|
292
|
+
return makeSchema<In, Out>({ ...node, sensitive: true }, check);
|
|
293
|
+
},
|
|
245
294
|
refine(refinement: Refinement<Out>): Schema<In, Out> {
|
|
246
295
|
const declared: SchemaRefinement = {
|
|
247
296
|
name: refinement.name,
|
|
@@ -257,7 +306,9 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
257
306
|
if (!result.ok) return result;
|
|
258
307
|
return refinement.check(result.value)
|
|
259
308
|
? result
|
|
260
|
-
:
|
|
309
|
+
: rejected([...path, ...(refinement.path ?? [])], refinement.message, 'refine', {
|
|
310
|
+
name: refinement.name,
|
|
311
|
+
});
|
|
261
312
|
},
|
|
262
313
|
);
|
|
263
314
|
},
|
|
@@ -270,6 +321,7 @@ export function checkOf<In, Out>(schema: Schema<In, Out>): Check<Out> {
|
|
|
270
321
|
return (value, path) => {
|
|
271
322
|
const result = schema['~standard'].validate(value);
|
|
272
323
|
if (isThenable(result)) {
|
|
324
|
+
// A provider fault, not a rule a value broke: no `rule`, so a form falls back to the text.
|
|
273
325
|
return fail(path, 'expected a synchronous schema, received an async one');
|
|
274
326
|
}
|
|
275
327
|
if (result.issues === undefined) return pass(result.value);
|
|
@@ -277,6 +329,8 @@ export function checkOf<In, Out>(schema: Schema<In, Out>): Check<Out> {
|
|
|
277
329
|
result.issues.map((issue) => ({
|
|
278
330
|
message: issue.message,
|
|
279
331
|
path: [...path, ...(issue.path ?? [])],
|
|
332
|
+
// A composite re-roots a member's issues; the rule that refused rides along unchanged.
|
|
333
|
+
...issueRuleFacts(issue),
|
|
280
334
|
})),
|
|
281
335
|
);
|
|
282
336
|
};
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
type AnySchema,
|
|
7
7
|
type Check,
|
|
8
8
|
checkOf,
|
|
9
|
-
fail,
|
|
10
9
|
isPlainObject,
|
|
11
10
|
makeSchema,
|
|
11
|
+
mistyped,
|
|
12
|
+
rejected,
|
|
12
13
|
type Schema,
|
|
13
14
|
} from './builder';
|
|
14
15
|
import { expected } from './describe-value';
|
|
@@ -101,13 +102,23 @@ export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySc
|
|
|
101
102
|
|
|
102
103
|
return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => {
|
|
103
104
|
if (!isPlainObject(value)) {
|
|
104
|
-
return
|
|
105
|
+
return mistyped(
|
|
106
|
+
path,
|
|
107
|
+
expected(`an object with a "${discriminant}" discriminant`, value),
|
|
108
|
+
value,
|
|
109
|
+
'object',
|
|
110
|
+
);
|
|
105
111
|
}
|
|
106
112
|
const branch = branches.get(value[discriminant]);
|
|
107
113
|
if (branch === undefined) {
|
|
108
|
-
|
|
114
|
+
const listed = renderKnown(known);
|
|
115
|
+
return rejected(
|
|
109
116
|
[...path, discriminant],
|
|
110
|
-
expected(`one of ${
|
|
117
|
+
expected(`one of ${listed}`, value[discriminant]),
|
|
118
|
+
'union',
|
|
119
|
+
{
|
|
120
|
+
values: listed,
|
|
121
|
+
},
|
|
111
122
|
);
|
|
112
123
|
}
|
|
113
124
|
// The named branch's issues, and only those: the caller already said which shape they meant,
|
package/src/error-codes.ts
CHANGED
|
@@ -28,4 +28,5 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
|
|
|
28
28
|
},
|
|
29
29
|
X_SCHEMA_DEFAULT_INVALID: { title: 'a schema default fails its own schema' },
|
|
30
30
|
X_SCHEMA_BOUNDS_INVALID: { title: 'an array bound no array can satisfy' },
|
|
31
|
+
X_SCHEMA_SENSITIVE_OUTPUT: { title: 'a sensitive field is reachable from an output' },
|
|
31
32
|
});
|
package/src/errors.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { ULTIMATE_ERROR_BRAND } from './error-brand';
|
|
6
6
|
import { SCHEMA_ERROR_CODES } from './error-codes';
|
|
7
7
|
import { renderMetaRecord } from './render-meta';
|
|
8
|
+
import type { SchemaIssueParams } from './schema-rules';
|
|
8
9
|
import { formatIssues } from './standard';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -158,6 +159,9 @@ export interface ValidationIssue {
|
|
|
158
159
|
readonly expected: string;
|
|
159
160
|
readonly received: string;
|
|
160
161
|
readonly message: string;
|
|
162
|
+
/** Which rule refused (`SCHEMA_RULES`), when the schema said. A `string`: a newer server may name one this build does not know. */
|
|
163
|
+
readonly rule?: string;
|
|
164
|
+
readonly params?: SchemaIssueParams;
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
export class ValidationFailedError extends SchemaError {
|
|
@@ -223,6 +227,19 @@ export class BoundsInvalidError extends SchemaError {
|
|
|
223
227
|
}
|
|
224
228
|
}
|
|
225
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Thrown where an action or a query is REGISTERED: a `.sensitive()` field reachable from what the
|
|
232
|
+
* server sends back contradicts `writeOnly` for every response, so boot is the honest place.
|
|
233
|
+
*/
|
|
234
|
+
export class SensitiveOutputError extends SchemaError {
|
|
235
|
+
static readonly code = 'X_SCHEMA_SENSITIVE_OUTPUT';
|
|
236
|
+
override readonly name = 'SensitiveOutputError';
|
|
237
|
+
|
|
238
|
+
constructor(init: Omit<SchemaErrorInit, 'code'>) {
|
|
239
|
+
super({ ...init, code: SensitiveOutputError.code });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
226
243
|
export class SchemaUnsupportedError extends SchemaError {
|
|
227
244
|
static readonly code = 'X_SCHEMA_UNSUPPORTED';
|
|
228
245
|
override readonly name = 'SchemaUnsupportedError';
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
isSchemaError,
|
|
44
44
|
SchemaError,
|
|
45
45
|
SchemaUnsupportedError,
|
|
46
|
+
SensitiveOutputError,
|
|
46
47
|
ValidationFailedError,
|
|
47
48
|
} from './errors';
|
|
48
49
|
// Exported for `@ultimat3/time`'s `fromIso`: one ISO-shape rule, whichever door a date comes in by.
|
|
@@ -75,6 +76,10 @@ export {
|
|
|
75
76
|
schemaProvider,
|
|
76
77
|
tryIntrospect,
|
|
77
78
|
} from './provider';
|
|
79
|
+
export { recordSchema } from './record-schema';
|
|
80
|
+
export type { IssueRuleFacts, SchemaIssueParams, SchemaRule } from './schema-rules';
|
|
81
|
+
export { issueRuleFacts, SCHEMA_RULES } from './schema-rules';
|
|
82
|
+
export { assertNoSensitiveOutput, maskSensitive } from './sensitive';
|
|
78
83
|
export type {
|
|
79
84
|
FormattableIssue,
|
|
80
85
|
InferInput,
|
|
@@ -114,7 +119,6 @@ export {
|
|
|
114
119
|
nullableSchema,
|
|
115
120
|
objectSchema,
|
|
116
121
|
optionalSchema,
|
|
117
|
-
recordSchema,
|
|
118
122
|
refineSchema,
|
|
119
123
|
unionSchema,
|
|
120
124
|
} from './validators';
|
package/src/json-schema.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface JsonSchema {
|
|
|
59
59
|
readonly anyOf?: readonly JsonSchema[];
|
|
60
60
|
readonly discriminator?: JsonSchemaDiscriminator;
|
|
61
61
|
readonly title?: string;
|
|
62
|
+
/** `.sensitive()`: a value the caller sends and the server never returns. */
|
|
63
|
+
readonly writeOnly?: true;
|
|
62
64
|
/**
|
|
63
65
|
* The refinements a consumer can act on mechanically. The prose copy also lands in
|
|
64
66
|
* `description`, because that is the only field an LLM reading an MCP tool schema is
|
|
@@ -97,7 +99,7 @@ const FORMAT_MAP: Readonly<Record<string, string>> = Object.freeze({
|
|
|
97
99
|
* `Object.hasOwn`, never the read alone: `node.format` comes from a schema PROVIDER's IR, so
|
|
98
100
|
* `FORMAT_MAP['constructor']` answered with the `Object` function and `?? node.format` could not
|
|
99
101
|
* rescue it — `JSON.stringify` then dropped `format` from the published document in silence.
|
|
100
|
-
* Same discriminator as
|
|
102
|
+
* Same discriminator as `sample-draw.ts`'s `BY_FORMAT[node.format]`.
|
|
101
103
|
*/
|
|
102
104
|
const publishedFormat = (format: string): string =>
|
|
103
105
|
Object.hasOwn(FORMAT_MAP, format) ? (FORMAT_MAP[format] ?? format) : format;
|
|
@@ -185,6 +187,7 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
185
187
|
const annotations: JsonSchema = {
|
|
186
188
|
...(described === undefined ? {} : { description: described }),
|
|
187
189
|
...(node.hasDefault === true ? { default: node.default } : {}),
|
|
190
|
+
...(node.sensitive === true ? { writeOnly: true as const } : {}),
|
|
188
191
|
...(refinements.length === 0 ? {} : { 'x-ultimate-refinements': refinements }),
|
|
189
192
|
};
|
|
190
193
|
|
package/src/json-value.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
type Check,
|
|
7
|
-
fail,
|
|
8
7
|
failWith,
|
|
9
8
|
isPlainObject,
|
|
10
9
|
makeSchema,
|
|
11
10
|
pass,
|
|
11
|
+
rejected,
|
|
12
12
|
type Schema,
|
|
13
13
|
} from './builder';
|
|
14
14
|
import { expected } from './describe-value';
|
|
@@ -69,7 +69,7 @@ const CYCLE = 'expected a JSON value, received a cycle (a reference back to an e
|
|
|
69
69
|
const NOT_PLAIN = 'expected a JSON value, received an object that is not a plain object or array';
|
|
70
70
|
|
|
71
71
|
function refuse(state: Walk, at: At | readonly PropertyKey[], message: string): null {
|
|
72
|
-
state.issues.push({ message, path: pathOf(at) });
|
|
72
|
+
state.issues.push({ message, path: pathOf(at), rule: 'json' });
|
|
73
73
|
return null;
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -180,7 +180,7 @@ const checkJson: Check<JsonValue> = (value, path) => {
|
|
|
180
180
|
// Every refusal pushes its issue; the fallback only keeps a refusal from ever reading as success.
|
|
181
181
|
return state.issues.length > 0
|
|
182
182
|
? failWith(state.issues)
|
|
183
|
-
:
|
|
183
|
+
: rejected(path, expected('a JSON value', value), 'json');
|
|
184
184
|
};
|
|
185
185
|
|
|
186
186
|
/**
|
package/src/money-value.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// — `@ultimat3/money`'s `Money`, `@ultimat3/entity`'s `MoneyValue` — so it earns a file a reader
|
|
4
4
|
// can open by name instead of scrolling to.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { failWith, isPlainObject, makeSchema, mistyped, pass, type Schema } from './builder';
|
|
7
7
|
import { expected } from './describe-value';
|
|
8
8
|
import type { StandardIssue } from './standard';
|
|
9
9
|
|
|
@@ -101,7 +101,9 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
|
|
|
101
101
|
},
|
|
102
102
|
},
|
|
103
103
|
(value, path) => {
|
|
104
|
-
if (!isPlainObject(value))
|
|
104
|
+
if (!isPlainObject(value)) {
|
|
105
|
+
return mistyped(path, expected('a Money object', value), value, 'money');
|
|
106
|
+
}
|
|
105
107
|
const minor = value['minor'];
|
|
106
108
|
const currency = value['currency'];
|
|
107
109
|
const scale = value['scale'];
|
|
@@ -113,12 +115,14 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
|
|
|
113
115
|
issues.push({
|
|
114
116
|
message: expected('a safe integer number of minor units', minor),
|
|
115
117
|
path: [...path, 'minor'],
|
|
118
|
+
rule: 'money',
|
|
116
119
|
});
|
|
117
120
|
}
|
|
118
121
|
if (!isCurrencyCode(currency)) {
|
|
119
122
|
issues.push({
|
|
120
123
|
message: expected('a 3-letter ISO 4217 code', currency),
|
|
121
124
|
path: [...path, 'currency'],
|
|
125
|
+
rule: 'money',
|
|
122
126
|
});
|
|
123
127
|
}
|
|
124
128
|
if (scale !== undefined && !isMoneyScale(scale)) {
|
|
@@ -128,6 +132,7 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
|
|
|
128
132
|
scale,
|
|
129
133
|
),
|
|
130
134
|
path: [...path, 'scale'],
|
|
135
|
+
rule: 'money',
|
|
131
136
|
});
|
|
132
137
|
}
|
|
133
138
|
if (issues.length > 0) return failWith(issues);
|
package/src/node.ts
CHANGED
|
@@ -54,6 +54,11 @@ export interface SchemaNode {
|
|
|
54
54
|
readonly hasDefault?: boolean | undefined;
|
|
55
55
|
readonly default?: unknown;
|
|
56
56
|
readonly description?: string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* `.sensitive()`: sent in, never written down by the framework, never sent back — JSON Schema's
|
|
59
|
+
* `writeOnly`. Read by the walkers that hold this schema (`maskSensitive`), never a key table.
|
|
60
|
+
*/
|
|
61
|
+
readonly sensitive?: boolean | undefined;
|
|
57
62
|
readonly format?: SchemaFormat | undefined;
|
|
58
63
|
readonly minLength?: number | undefined;
|
|
59
64
|
readonly maxLength?: number | undefined;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Single responsibility: `t.record(values)` — a map of caller-chosen keys to one value schema.
|
|
2
|
+
// Its own file because a record key is CALLER data, unlike every other path segment, and the rules
|
|
3
|
+
// that follow from that (position paths, refused keys) are the whole of it.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type AnySchema,
|
|
7
|
+
checkOf,
|
|
8
|
+
failWith,
|
|
9
|
+
isPlainObject,
|
|
10
|
+
makeSchema,
|
|
11
|
+
mistyped,
|
|
12
|
+
pass,
|
|
13
|
+
type Schema,
|
|
14
|
+
} from './builder';
|
|
15
|
+
import { expected } from './describe-value';
|
|
16
|
+
import type { SchemaNode } from './node';
|
|
17
|
+
import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
|
|
18
|
+
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
19
|
+
import { unstorable } from './unstorable-text';
|
|
20
|
+
|
|
21
|
+
export function recordSchema<S extends AnySchema>(
|
|
22
|
+
values: S,
|
|
23
|
+
): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>> {
|
|
24
|
+
const valueCheck = checkOf(values);
|
|
25
|
+
const node: SchemaNode = { kind: 'record', valueNode: values.node };
|
|
26
|
+
return makeSchema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>>(
|
|
27
|
+
node,
|
|
28
|
+
(value, path) => {
|
|
29
|
+
if (!isPlainObject(value))
|
|
30
|
+
return mistyped(path, expected('an object', value), value, 'record');
|
|
31
|
+
const issues: StandardIssue[] = [];
|
|
32
|
+
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
33
|
+
for (const [index, [key, entry]] of Object.entries(value).entries()) {
|
|
34
|
+
// The entry's POSITION, never its key. `describe-value.ts`'s absolute is stated about the
|
|
35
|
+
// rejected value, and a record's KEY is the caller's data just as much: the path travels
|
|
36
|
+
// `formatIssue` -> `bodyInvalid`'s `cause` -> the problem document AND the log line, and
|
|
37
|
+
// core's logger redacts by key, so a key baked into a string has no key left to redact.
|
|
38
|
+
// `@ultimat3/http`'s `bodyInvalid` states the contract this was breaking in its own doc
|
|
39
|
+
// block — *"`issues` … must name only facts the framework itself chose"*. A record keyed
|
|
40
|
+
// by an email address, a phone number or a pasted credential wrote every one of them into
|
|
41
|
+
// the central log index, in the same shape the password bug did.
|
|
42
|
+
//
|
|
43
|
+
// The index rather than nothing at all: the segment's whole job is to say WHICH entry
|
|
44
|
+
// failed, and dropping it makes three failing entries render three identical lines. It is
|
|
45
|
+
// the same segment `arraySchema` already uses, so `formatPath` renders `meta[3]` with no
|
|
46
|
+
// new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
|
|
47
|
+
// string ones — so it names an entry that exists rather than a byte offset in the body.
|
|
48
|
+
const at = [...path, index];
|
|
49
|
+
// A record is stored as `jsonb` far more often than not, and jsonb refuses an unstorable
|
|
50
|
+
// KEY exactly as it does a value — so the key is refused here, by position, not quoted.
|
|
51
|
+
const unfitKey = unstorable(key);
|
|
52
|
+
if (unfitKey !== undefined) {
|
|
53
|
+
issues.push({
|
|
54
|
+
message: `expected a record key, received one ${unfitKey}`,
|
|
55
|
+
path: at,
|
|
56
|
+
rule: 'record.key',
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (isPrototypeKey(key)) {
|
|
61
|
+
issues.push({
|
|
62
|
+
// The refused NAMES are a closed set declared right here, so the message may state
|
|
63
|
+
// them: that is the one part of this line the caller did not choose.
|
|
64
|
+
message: expected(`a record key that is not ${PROTOTYPE_KEYS.join(' | ')}`, key),
|
|
65
|
+
path: at,
|
|
66
|
+
rule: 'record.key',
|
|
67
|
+
});
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const result = valueCheck(entry, at);
|
|
71
|
+
if (result.ok) out[key] = result.value;
|
|
72
|
+
else issues.push(...result.issues);
|
|
73
|
+
}
|
|
74
|
+
if (issues.length > 0) return failWith(issues);
|
|
75
|
+
return pass(out as Record<string, InferOutput<S>>);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}
|