@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "19.2.0",
3
+ "version": "19.3.1",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- const dialect = options?.dialect ?? '2020-12';
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: DIALECTS[dialect] }),
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 (PROTOTYPE_KEYS.has(key)) {
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 ${[...PROTOTYPE_KEYS].join(' | ')}`, key),
310
+ message: expected(`a record key that is not ${PROTOTYPE_KEYS.join(' | ')}`, key),
319
311
  path: at,
320
312
  });
321
313
  continue;