@ultimat3/schema 2.0.0 → 4.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 CHANGED
@@ -15,7 +15,9 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
15
15
 
16
16
  Module order (no cycles):
17
17
  `describe-value → node → builder → money-value → validators → discriminated-union → provider → t`.
18
- `standard.ts` and `errors.ts` depend on nothing but each other.
18
+ `standard.ts` and `errors.ts` depend on nothing but each other. `iso-date.ts` imports nothing and
19
+ is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
20
+ the rule that a clock time must carry an offset or `Z` has one copy, not one per door.
19
21
 
20
22
  **An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`,
21
23
  which is returned to the caller AND interpolated into the log line — and core's logger redacts by
@@ -75,6 +77,14 @@ Gotchas:
75
77
  present on output. Object key optionality is derived from that — don't hand-roll it.
76
78
  - `AnySchema = Schema<unknown, unknown>` is the general constraint. Never `any`.
77
79
  - Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`.
80
+ - **An object parse reads every declared field with `Object.hasOwn` and answers a null-prototype
81
+ object** — the same two halves `recordSchema` has. A raw `value[key]` read `toString` off the
82
+ PROTOTYPE, so a field named after one was unsatisfiable for every input and its `.default()`
83
+ never fired; a `{}` output let a declared `__proto__` field write through the setter. Never
84
+ reintroduce either half, and never assume a parsed object has `Object.prototype` on it.
85
+ - `t.date` refuses a clock time with no offset and no `Z` (`iso-date.ts`): a zone-less string is a
86
+ different instant per host `TZ`, and `coerceQuery` puts it one query parameter from the wire.
87
+ A date-only string carries no clock time and is UTC by spec, so it still parses.
78
88
  - Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
79
89
  - **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
80
90
  has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/coerce.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // HTTP layer has strings that "mean" numbers. Actions, jobs and MCP calls receive real JSON and
3
3
  // must never get this leniency.
4
4
 
5
+ import { isZonelessDateTime } from './iso-date';
5
6
  import type { SchemaNode } from './node';
6
7
  import { tryIntrospect } from './provider';
7
8
 
@@ -56,6 +57,10 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
56
57
  }
57
58
  case 'date': {
58
59
  if (typeof raw !== 'string') return raw;
60
+ // Returned untouched rather than converted, this function's standing contract: converting
61
+ // it resolves a zone-less clock time through the CONTAINER's `TZ`, so `?at=2026-08-19T10:00`
62
+ // meant a different instant per pod. Validation states the refusal.
63
+ if (isZonelessDateTime(raw)) return raw;
59
64
  const parsed = new Date(raw);
60
65
  return Number.isNaN(parsed.getTime()) ? raw : parsed;
61
66
  }
@@ -81,7 +86,10 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
81
86
  const source = raw as Record<string, unknown>;
82
87
  const out: Record<string, unknown> = { ...source };
83
88
  for (const [key, child] of Object.entries(node.properties)) {
84
- if (key in source) out[key] = coerceNode(child, source[key]);
89
+ // `Object.hasOwn`, never `key in source`: `{ ...source }` above already dropped what a
90
+ // prototype carries, so an `in` check put it back — a schema field named `toString` or
91
+ // `valueOf` was coerced from the INHERITED member and handed validation a function.
92
+ if (Object.hasOwn(source, key)) out[key] = coerceNode(child, source[key]);
85
93
  }
86
94
  return out;
87
95
  }
@@ -111,11 +119,17 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
111
119
  }
112
120
  }
113
121
 
122
+ /**
123
+ * `Object.create(null)`, for the reason `@ultimat3/http`'s `parseQuery` already uses one: this
124
+ * record is built from caller-controlled keys. On a `{}` literal `out['__proto__'] = …` hits the
125
+ * prototype accessor instead of declaring a key, and every member of `Object.prototype` reads
126
+ * back as present when the client sent nothing.
127
+ */
114
128
  function toRecord(source: QuerySource): Record<string, string | readonly string[] | undefined> {
129
+ const out = Object.create(null) as Record<string, string | readonly string[] | undefined>;
115
130
  if (!(source instanceof URLSearchParams)) {
116
- return { ...source };
131
+ return Object.assign(out, source);
117
132
  }
118
- const out: Record<string, string | readonly string[]> = {};
119
133
  for (const key of new Set(source.keys())) {
120
134
  const all = source.getAll(key);
121
135
  out[key] = all.length > 1 ? all : (all[0] as string);
@@ -134,7 +148,8 @@ export function coerceQuery(schema: unknown, source: QuerySource): Record<string
134
148
 
135
149
  const out: Record<string, unknown> = { ...record };
136
150
  for (const [key, child] of Object.entries(node.properties)) {
137
- if (!(key in record)) continue;
151
+ // See `toRecord`: a declared property is coerced only when the caller actually sent it.
152
+ if (!Object.hasOwn(record, key)) continue;
138
153
  const raw = record[key];
139
154
  out[key] = coerceNode(child, child.kind === 'array' ? (raw ?? []) : normaliseSingle(raw));
140
155
  }
@@ -0,0 +1,23 @@
1
+ // Single responsibility: the one rule deciding whether a date-time STRING names an instant on its
2
+ // own. Its own file because both the validator and the HTTP coercion have to answer identically —
3
+ // two copies of this pair is how the wire path and the validation path came to disagree.
4
+
5
+ /**
6
+ * A clock time, and the zone it is stated in. `2026-08-19T10:00` carries the first and not the
7
+ * second, so `new Date` resolves it through the HOST process's zone: the same wire value is
8
+ * `14:00Z` on a `TZ=America/New_York` pod and `10:00Z` on a `TZ=UTC` one, from one request.
9
+ * That is the framework's "no date without an explicit zone, no ambient default anywhere" rule
10
+ * failing at the parse end rather than the format end.
11
+ *
12
+ * A date-only form carries no clock time and is UTC by specification, so it is not this.
13
+ *
14
+ * The same pair `@ultimat3/time`'s `fromIso` refuses on, character for character: one wire value,
15
+ * one rule, whichever door it comes through.
16
+ */
17
+ const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i;
18
+ const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
19
+
20
+ /** What a `t.date` string must not be: a clock time with no offset and no `Z`. */
21
+ export function isZonelessDateTime(value: string): boolean {
22
+ return CLOCK_TIME.test(value) && !UTC_OFFSET.test(value);
23
+ }
package/src/provider.ts CHANGED
@@ -11,8 +11,10 @@ export interface SchemaProvider {
11
11
  readonly vendor: string;
12
12
  readonly t: TNamespace;
13
13
  /**
14
- * Return the IR for one of this provider's schemas. Required for OpenAPI and MCP tool
15
- * schemas; omit it only if the provider also supplies `toJsonSchema`.
14
+ * Return the IR for one of this provider's schemas. Required for OpenAPI, MCP tool schemas
15
+ * and the admin form generator: `toJsonSchema()` calls it unconditionally and throws
16
+ * `X_SCHEMA_UNSUPPORTED` without it. There is no second way to describe a schema — the IR is
17
+ * the one projection surface, and every generator reads it.
16
18
  */
17
19
  introspect?(schema: unknown): SchemaNode | undefined;
18
20
  }
package/src/validators.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from './builder';
20
20
  import { expected } from './describe-value';
21
21
  import { discriminatedUnionSchema } from './discriminated-union';
22
+ import { isZonelessDateTime } from './iso-date';
22
23
  import { type MoneyValue, moneySchema } from './money-value';
23
24
  import type { SchemaNode } from './node';
24
25
  import type { InferInput, InferOutput, StandardIssue } from './standard';
@@ -172,9 +173,19 @@ export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {
172
173
  const check: Check<ShapeOutput<S>> = (value, path) => {
173
174
  if (!isPlainObject(value)) return fail(path, expected('an object', value));
174
175
  const issues: StandardIssue[] = [];
175
- const out: Record<string, unknown> = {};
176
+ // `Object.create(null)`, for `recordSchema`'s reason one screen down: on a `{}` literal
177
+ // `out['__proto__'] = value` hits the `Object.prototype` SETTER, so a DECLARED `__proto__`
178
+ // field parsed to `{}` whose prototype was the caller's object — every key of it then read
179
+ // back off a value nobody sent.
180
+ const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
176
181
  for (const [key, memberCheck] of checks) {
177
- const result = memberCheck(value[key], [...path, key]);
182
+ // `Object.hasOwn`, never a raw index — the guard `coerce.ts` already applies to the same
183
+ // read. `value['toString']` answered the INHERITED function for an input that carried no
184
+ // such key, so a field named after a prototype member was unsatisfiable for every input,
185
+ // its `.default()` could never fire, and the HTTP path and this one disagreed about what
186
+ // the caller sent.
187
+ const raw = Object.hasOwn(value, key) ? value[key] : undefined;
188
+ const result = memberCheck(raw, [...path, key]);
178
189
  if (result.ok) {
179
190
  // Unknown keys are dropped, never forwarded — no mass assignment through an action.
180
191
  if (result.value !== undefined) out[key] = result.value;
@@ -349,6 +360,13 @@ const dateSchema: Schema<Date | string | number, Date> = makeSchema<Date | strin
349
360
  : pass(value);
350
361
  }
351
362
  if (typeof value === 'string' || typeof value === 'number') {
363
+ // Enforced, not documented: the node publishes `format: 'date-time'` and this refusal has
364
+ // always said ISO-8601, while `new Date` resolved a zone-less clock time through the host
365
+ // process's zone — reachable from the wire, since `coerceQuery` routes a `t.date` field
366
+ // here and a container's `TZ` then decided which instant a query parameter meant.
367
+ if (typeof value === 'string' && isZonelessDateTime(value)) {
368
+ return fail(path, expected('an ISO-8601 date-time with an offset or Z', value));
369
+ }
352
370
  const parsed = new Date(value);
353
371
  return Number.isNaN(parsed.getTime())
354
372
  ? fail(path, expected('an ISO-8601 date-time', value))