@ultimat3/schema 19.2.0 → 19.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +11 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/json-schema.ts +54 -3
- package/src/prototype-keys.ts +19 -0
- package/src/validators.ts +3 -11
package/CLAUDE.md
CHANGED
|
@@ -140,6 +140,17 @@ Gotchas:
|
|
|
140
140
|
different instant per host `TZ`, and `coerceQuery` puts it one query parameter from the wire.
|
|
141
141
|
A date-only string carries no clock time and is UTC by spec, so it still parses.
|
|
142
142
|
- Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
|
|
143
|
+
- **`ToJsonSchemaOptions.dialect` is a closed vocabulary read with `Object.hasOwn`** (`As of
|
|
144
|
+
2026-09-06`). `DIALECTS[dialect]` on an object literal answered the `Object` FUNCTION for
|
|
145
|
+
`dialect: 'constructor'` — measured: `$schema` held it, `JSON.stringify` dropped the key in
|
|
146
|
+
silence, and every consumer that does not serialise (the MCP tool schema, the OpenAPI writer
|
|
147
|
+
that spreads this document) carried a function. It is a field on a PUBLIC entry point, so the
|
|
148
|
+
key is caller data. `bun run proto-index` could not see the read until its null-prototype
|
|
149
|
+
exemption became per TABLE: `convert`'s unrelated `Object.create(null)` exempted the whole file.
|
|
150
|
+
The refusal is `X_SCHEMA_UNSUPPORTED` — the same question that code already answers, and a code
|
|
151
|
+
is stable forever once shipped — and it fires whether or not `includeDialect` would have emitted
|
|
152
|
+
the value, because a rule a caller learns half of is a rule they learn wrong. The rejected value
|
|
153
|
+
is `describeValue`d in the `cause` and whole in `meta`, never echoed.
|
|
143
154
|
- **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
|
|
144
155
|
has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
|
|
145
156
|
value through, `@ultimat3/action`'s `sample-input.ts` answers `null` — and they live in packages
|
package/README.md
CHANGED
|
@@ -155,6 +155,7 @@ swap. `introspect()` is what OpenAPI/MCP generation needs; a provider without it
|
|
|
155
155
|
```ts
|
|
156
156
|
toJsonSchema(publishPost); // OpenAPI 3.1 (2020-12)
|
|
157
157
|
toMcpInputSchema(publishPost); // MCP tools/list (draft-07, no $schema)
|
|
158
|
+
toJsonSchema(publishPost, { dialect: 'x' }); // X_SCHEMA_UNSUPPORTED — the two spellings are closed
|
|
158
159
|
parse(publishPost, coerceQuery(publishPost, url.searchParams));
|
|
159
160
|
```
|
|
160
161
|
|
package/package.json
CHANGED
package/src/json-schema.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// Single responsibility: SchemaNode -> JSON Schema. Load-bearing: OpenAPI request/response
|
|
2
2
|
// bodies and MCP tool `inputSchema` are both this function's output, so an agent's view of an
|
|
3
|
-
// action and an HTTP client's view can never drift.
|
|
3
|
+
// action and an HTTP client's view can never drift. Where the projection and the PARSER differ,
|
|
4
|
+
// the published document states the narrower of the two — see `date` and `record` below.
|
|
4
5
|
|
|
6
|
+
import { describeValue } from './describe-value';
|
|
7
|
+
import { SchemaUnsupportedError } from './errors';
|
|
5
8
|
import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value';
|
|
6
9
|
import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node';
|
|
10
|
+
import { PROTOTYPE_KEYS } from './prototype-keys';
|
|
7
11
|
import { introspect } from './provider';
|
|
8
12
|
|
|
9
13
|
export type JsonSchemaType =
|
|
@@ -37,6 +41,10 @@ export interface JsonSchema {
|
|
|
37
41
|
readonly properties?: Readonly<Record<string, JsonSchema>>;
|
|
38
42
|
readonly required?: readonly string[];
|
|
39
43
|
readonly additionalProperties?: boolean | JsonSchema;
|
|
44
|
+
/** What a record's KEYS must satisfy. Draft-06 and later, so it is honest in both dialects. */
|
|
45
|
+
readonly propertyNames?: JsonSchema;
|
|
46
|
+
/** Negation. Emitted only inside `propertyNames`, to publish a closed set of refused keys. */
|
|
47
|
+
readonly not?: JsonSchema;
|
|
40
48
|
readonly items?: JsonSchema;
|
|
41
49
|
readonly anyOf?: readonly JsonSchema[];
|
|
42
50
|
readonly discriminator?: JsonSchemaDiscriminator;
|
|
@@ -177,6 +185,15 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
177
185
|
return annotate(numberNode(node));
|
|
178
186
|
case 'boolean':
|
|
179
187
|
return annotate({ type: 'boolean' });
|
|
188
|
+
// Narrower than the parser, deliberately. `dateSchema` also accepts a `number` — epoch
|
|
189
|
+
// milliseconds — as a coercion convenience for a value that never crossed a wire as JSON, and
|
|
190
|
+
// publishing `anyOf: [{ type: 'string', format: 'date-time' }, { type: 'number' }]` would tell
|
|
191
|
+
// every generated client, every agent reading a tool schema and every OpenAPI consumer that an
|
|
192
|
+
// epoch number is a supported spelling of an instant. It is not one: it carries no zone, it is
|
|
193
|
+
// ambiguous about seconds versus milliseconds, and `t.date`'s own refusal says ISO-8601. So
|
|
194
|
+
// the contract publishes the one spelling a caller should send, and the parser stays the more
|
|
195
|
+
// permissive of the two — the safe direction, where the boundary accepts a value the document
|
|
196
|
+
// did not promise rather than promising one it refuses.
|
|
180
197
|
case 'date':
|
|
181
198
|
return annotate({ type: 'string', format: 'date-time' });
|
|
182
199
|
case 'enum':
|
|
@@ -201,6 +218,13 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
201
218
|
return annotate({
|
|
202
219
|
type: 'object',
|
|
203
220
|
additionalProperties: node.valueNode === undefined ? true : convert(node.valueNode),
|
|
221
|
+
// The key rule, published rather than left implicit: `recordSchema` refuses
|
|
222
|
+
// `__proto__` / `constructor` / `prototype` by name, and without this the document
|
|
223
|
+
// advertised them — an OpenAPI body, a generated client and an MCP tool all sending a key
|
|
224
|
+
// the boundary answers with a 422. `propertyNames` is draft-06 and later, so the one
|
|
225
|
+
// emission is valid in both dialects this file publishes; the names come from
|
|
226
|
+
// `prototype-keys.ts`, which the parser reads too, so the two views cannot drift.
|
|
227
|
+
propertyNames: { not: { enum: PROTOTYPE_KEYS } },
|
|
204
228
|
});
|
|
205
229
|
case 'money':
|
|
206
230
|
return annotate({
|
|
@@ -253,12 +277,39 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
253
277
|
}
|
|
254
278
|
}
|
|
255
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The dialect a caller asked for, or a refusal. `Object.hasOwn`, never the read alone: `dialect` is
|
|
282
|
+
* a field on this file's public entry point, so it is CALLER data, and `DIALECTS['constructor']`
|
|
283
|
+
* answers the `Object` function — measured, `$schema` held that function, `JSON.stringify` dropped
|
|
284
|
+
* the key in silence, and every consumer that does not serialise (the MCP tool schema, the OpenAPI
|
|
285
|
+
* writer that spreads this document) carried it. `bun run proto-index` could not see the read: the
|
|
286
|
+
* unrelated `Object.create(null)` in `convert` above exempted this whole FILE until that rule
|
|
287
|
+
* became per-table.
|
|
288
|
+
*
|
|
289
|
+
* `X_SCHEMA_UNSUPPORTED` rather than a new code: this is the same question `toJsonSchema` already
|
|
290
|
+
* answers with it — a document this package cannot publish — and a code is stable forever once
|
|
291
|
+
* shipped, so a fourth one for one option would be a fourth thing to document.
|
|
292
|
+
*/
|
|
293
|
+
function dialectUri(dialect: string): string {
|
|
294
|
+
if (Object.hasOwn(DIALECTS, dialect)) return DIALECTS[dialect as JsonSchemaDialect];
|
|
295
|
+
throw new SchemaUnsupportedError({
|
|
296
|
+
// `describeValue`, never the value: this cause is folded into a log line and an HTTP problem
|
|
297
|
+
// document, and the two spellings that WORK are what a caller needs, not their own back.
|
|
298
|
+
cause: `a json schema dialect must be '2020-12' or 'draft-07'; got ${describeValue(dialect)}`,
|
|
299
|
+
fix: "toJsonSchema(schema, { dialect: '2020-12' }) # or 'draft-07' for an MCP tool schema",
|
|
300
|
+
meta: { dialect },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
256
304
|
/** Convert any schema the active provider can introspect. */
|
|
257
305
|
export function toJsonSchema(schema: unknown, options?: ToJsonSchemaOptions): JsonSchema {
|
|
258
306
|
const converted = convert(introspect(schema));
|
|
259
|
-
|
|
307
|
+
// Resolved BEFORE `includeDialect` is read: a dialect nothing publishes is a caller mistake
|
|
308
|
+
// whether or not this document happens to emit `$schema`, and a refusal that fires for one flag
|
|
309
|
+
// and not the other is a rule a caller learns the wrong half of.
|
|
310
|
+
const uri = dialectUri(options?.dialect ?? '2020-12');
|
|
260
311
|
return {
|
|
261
|
-
...(options?.includeDialect === false ? {} : { $schema:
|
|
312
|
+
...(options?.includeDialect === false ? {} : { $schema: uri }),
|
|
262
313
|
...(options?.title === undefined ? {} : { title: options.title }),
|
|
263
314
|
...converted,
|
|
264
315
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Single responsibility: which record keys reach an object's prototype instead of its own
|
|
2
|
+
// properties. One definition, because the rule that REFUSES a key (`validators.ts`) and the
|
|
3
|
+
// document that PUBLISHES the refusal (`json-schema.ts`) must name the same closed set — the same
|
|
4
|
+
// reason `char-count.ts` owns the unit a length is counted in.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A record's keys are the caller's, so `{"__proto__":{…}}` on a `{}` literal set the OUTPUT's
|
|
8
|
+
* prototype: `Object.keys` answered `[]` while `settings[k] ?? fallback` handed a handler the
|
|
9
|
+
* attacker's value for a key that was never sent. `recordSchema` refuses these by name AND builds
|
|
10
|
+
* on a null prototype — the null prototype alone would keep `__proto__` as a silent own key nobody
|
|
11
|
+
* declared — and `json-schema.ts` emits them as a `propertyNames` constraint, so OpenAPI, the
|
|
12
|
+
* typed client and an MCP tool schema advertise exactly what the boundary accepts rather than
|
|
13
|
+
* promising keys it answers with a 422.
|
|
14
|
+
*/
|
|
15
|
+
export const PROTOTYPE_KEYS: readonly string[] = ['__proto__', 'constructor', 'prototype'];
|
|
16
|
+
|
|
17
|
+
/** The membership test the parser applies. A list is what the projection publishes; this is the
|
|
18
|
+
* same fact asked the other way, so neither side builds its own `Set`. */
|
|
19
|
+
export const isPrototypeKey = (key: string): boolean => PROTOTYPE_KEYS.includes(key);
|
package/src/validators.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { discriminatedUnionSchema } from './discriminated-union';
|
|
|
23
23
|
import { isZonelessDateTime } from './iso-date';
|
|
24
24
|
import { type MoneyValue, moneySchema } from './money-value';
|
|
25
25
|
import type { SchemaNode } from './node';
|
|
26
|
+
import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
|
|
26
27
|
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
27
28
|
import { isIanaZoneName } from './time-zone-name';
|
|
28
29
|
|
|
@@ -275,15 +276,6 @@ export function unionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
|
275
276
|
});
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
/**
|
|
279
|
-
* Keys that reach an object's prototype rather than its own properties. A record's keys are the
|
|
280
|
-
* caller's, so `{"__proto__":{…}}` on a `{}` literal set the OUTPUT's prototype: `Object.keys`
|
|
281
|
-
* answered `[]` while `settings[k] ?? fallback` handed a handler the attacker's value for a key
|
|
282
|
-
* that was never sent. Refused by name AND built on a null prototype — the null prototype alone
|
|
283
|
-
* would keep `__proto__` as a silent own key nobody declared.
|
|
284
|
-
*/
|
|
285
|
-
const PROTOTYPE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
|
|
286
|
-
|
|
287
279
|
export function recordSchema<S extends AnySchema>(
|
|
288
280
|
values: S,
|
|
289
281
|
): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>> {
|
|
@@ -311,11 +303,11 @@ export function recordSchema<S extends AnySchema>(
|
|
|
311
303
|
// new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
|
|
312
304
|
// string ones — so it names an entry that exists rather than a byte offset in the body.
|
|
313
305
|
const at = [...path, index];
|
|
314
|
-
if (
|
|
306
|
+
if (isPrototypeKey(key)) {
|
|
315
307
|
issues.push({
|
|
316
308
|
// The refused NAMES are a closed set declared right here, so the message may state
|
|
317
309
|
// them: that is the one part of this line the caller did not choose.
|
|
318
|
-
message: expected(`a record key that is not ${
|
|
310
|
+
message: expected(`a record key that is not ${PROTOTYPE_KEYS.join(' | ')}`, key),
|
|
319
311
|
path: at,
|
|
320
312
|
});
|
|
321
313
|
continue;
|