@nifrajs/schema 3.5.0 → 4.0.2

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/src/openapi.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { ContractShape } from "@nifrajs/core/contract"
2
2
  import { type ProjectEvidenceSnapshot, reflectedRoutesFromEvidence } from "@nifrajs/core/evidence"
3
+ import {
4
+ compileRoutePattern,
5
+ expandOptionalParams,
6
+ type ParamConstraint,
7
+ type RoutePatternSegment,
8
+ } from "@nifrajs/core/pattern"
3
9
  import {
4
10
  type JsonSchema,
5
11
  type ReflectedRouteSchema,
@@ -86,7 +92,7 @@ export interface ToOpenAPIOptions {
86
92
 
87
93
  interface OpenAPIParameter {
88
94
  readonly name: string
89
- readonly in: "path" | "query" | "header"
95
+ readonly in: "path" | "query" | "header" | "cookie"
90
96
  readonly required: boolean
91
97
  readonly schema: JsonSchema
92
98
  }
@@ -181,54 +187,47 @@ class SchemaStore {
181
187
  }
182
188
  }
183
189
 
184
- const NIFRA_PARAM = /^[A-Za-z_][A-Za-z0-9_]*$/
185
- const MIXED_PARAM = /:([A-Za-z_][A-Za-z0-9_]*)/g
186
-
187
- /** Names captured by a route segment under Nifra's parameter grammar. */
188
- function segmentParams(segment: string): readonly string[] {
189
- if (segment.startsWith("*")) return [segment.length > 1 ? segment.slice(1) : "wildcard"]
190
- const names: string[] = []
191
- for (const match of segment.matchAll(MIXED_PARAM)) {
192
- const name = match[1]
193
- if (name === undefined || match.index === undefined) continue
194
- const previous = segment[match.index - 1]
195
- const atEnd = match.index + match[0].length === segment.length
196
- // `things:batchGet` is an established literal action path. The router only treats a terminal
197
- // colon after an identifier as a parameter when a literal suffix follows it.
198
- if (previous !== undefined && /[A-Za-z0-9_]/.test(previous) && atEnd) continue
199
- names.push(name)
200
- }
201
- return names
202
- }
203
-
204
- /** Name of a wholly dynamic path segment, or `undefined` for static/mixed segments. */
205
- function segmentParam(segment: string): string | undefined {
206
- const names = segmentParams(segment)
207
- if (segment.startsWith("*") && names.length === 1) return names[0]
208
- if (segment.startsWith(":") && NIFRA_PARAM.test(segment.slice(1)) && names.length === 1)
209
- return names[0]
210
- return undefined
190
+ interface PathParameter {
191
+ readonly name: string
192
+ readonly constraint: ParamConstraint | undefined
211
193
  }
212
194
 
213
- /** Convert one Nifra route segment, including mixed forms, to OpenAPI templates. */
214
- function toTemplatedSegment(segment: string): string {
215
- const whole = segmentParam(segment)
216
- if (whole !== undefined) return `{${whole}}`
217
- return segment.replace(MIXED_PARAM, (match, name: string, offset: number) => {
218
- const previous = segment[offset - 1]
219
- const atEnd = offset + match.length === segment.length
220
- if (previous !== undefined && /[A-Za-z0-9_]/.test(previous) && atEnd) return match
195
+ /**
196
+ * `/users/:id/*rest` as `/users/{id}/{rest}`, with its params in order. Split by the router's own
197
+ * compiler, so names and constraints end where the router ends them.
198
+ */
199
+ function templatedPath(path: string): {
200
+ readonly template: string
201
+ readonly parameters: readonly PathParameter[]
202
+ } {
203
+ const parameters: PathParameter[] = []
204
+ const take = (name: string, constraint?: ParamConstraint): string => {
205
+ parameters.push({ name, constraint })
221
206
  return `{${name}}`
222
- })
223
- }
224
-
225
- /** `/users/:id/*rest` → `/users/{id}/{rest}` (OpenAPI path templating). */
226
- function toTemplatedPath(path: string): string {
227
- return path.split("/").map(toTemplatedSegment).join("/")
207
+ }
208
+ let segments: readonly RoutePatternSegment[]
209
+ try {
210
+ segments = compileRoutePattern(path).segments
211
+ } catch {
212
+ return { template: path, parameters }
213
+ }
214
+ const template = segments
215
+ .map((segment) =>
216
+ segment.kind === "static"
217
+ ? segment.value
218
+ : segment.kind === "param"
219
+ ? take(segment.name)
220
+ : segment.kind === "wildcard"
221
+ ? take(segment.name === "*" ? "wildcard" : segment.name)
222
+ : segment.parts
223
+ .map((part) => (part.t === "lit" ? part.v : take(part.name, part.c)))
224
+ .join(""),
225
+ )
226
+ .join("/")
227
+ return { template: `/${template}`, parameters }
228
228
  }
229
229
 
230
230
  function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIParameter[] {
231
- const params: OpenAPIParameter[] = []
232
231
  // Build a lookup of per-field schemas from the declared params schema (if any).
233
232
  const fieldSchemas = new Map<string, JsonSchema>()
234
233
  if (paramsSchema?.fields !== undefined) {
@@ -236,15 +235,20 @@ function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIP
236
235
  fieldSchemas.set(field.name, field.schema)
237
236
  }
238
237
  }
239
- for (const segment of path.split("/")) {
240
- for (const name of segmentParams(segment)) {
241
- // Merge the declared constraint (uuid format, integer type, etc.) when present;
242
- // fall back to the bare { type: "string" } derived from the URL pattern.
243
- const schema = fieldSchemas.get(name) ?? { type: "string" as const }
244
- params.push({ name, in: "path", required: true, schema })
245
- }
246
- }
247
- return params
238
+ // A declared field schema (uuid format, integer type, etc.) wins. Without one, a constraint in
239
+ // the path says what the router accepts; without either, the parameter is any string.
240
+ return templatedPath(path).parameters.map(({ name, constraint }) => ({
241
+ name,
242
+ in: "path",
243
+ required: true,
244
+ schema:
245
+ fieldSchemas.get(name) ??
246
+ (constraint === undefined
247
+ ? { type: "string" }
248
+ : constraint.oneOf === undefined
249
+ ? { type: "string", pattern: `^${constraint.source}$` }
250
+ : { type: "string", enum: constraint.oneOf }),
251
+ }))
248
252
  }
249
253
 
250
254
  function queryParameters(schema: SchemaReflection | undefined): OpenAPIParameter[] {
@@ -270,11 +274,23 @@ function headerParameters(schema: SchemaReflection | undefined): OpenAPIParamete
270
274
  }))
271
275
  }
272
276
 
277
+ /** Cookie names are case-sensitive, so each declared field keeps its name as written. */
278
+ function cookieParameters(schema: SchemaReflection | undefined): OpenAPIParameter[] {
279
+ if (schema?.fields === undefined) return []
280
+ return schema.fields.map((field) => ({
281
+ name: field.name,
282
+ in: "cookie" as const,
283
+ required: field.required,
284
+ schema: field.schema,
285
+ }))
286
+ }
287
+
273
288
  interface OperationInput {
274
289
  readonly path: string
275
290
  readonly body: SchemaReflection | undefined
276
291
  readonly query: SchemaReflection | undefined
277
292
  readonly headers: SchemaReflection | undefined
293
+ readonly cookies?: SchemaReflection | undefined
278
294
  /** Reflected params schema - per-field constraints merge into path parameters. */
279
295
  readonly params: SchemaReflection | undefined
280
296
  readonly response: SchemaReflection | undefined
@@ -374,6 +390,30 @@ function buildResponses(
374
390
  return responses
375
391
  }
376
392
 
393
+ const isBinary = (schema: unknown): boolean =>
394
+ typeof schema === "object" &&
395
+ schema !== null &&
396
+ (schema as { type?: unknown }).type === "string" &&
397
+ (schema as { format?: unknown }).format === "binary"
398
+
399
+ /**
400
+ * Whether a body schema declares a file field (a binary string, or a list of them) - the shape
401
+ * `t.form` produces. JSON cannot carry a file, so such a body is `multipart/form-data`. Read off
402
+ * the plain JSON Schema so a document built from a stored evidence snapshot agrees with a live one.
403
+ */
404
+ function hasFileField(schema: JsonSchema | undefined): boolean {
405
+ if (typeof schema !== "object" || schema === null) return false
406
+ const properties = (schema as { properties?: unknown }).properties
407
+ if (typeof properties !== "object" || properties === null) return false
408
+ return Object.values(properties).some(
409
+ (property) =>
410
+ isBinary(property) ||
411
+ (typeof property === "object" &&
412
+ property !== null &&
413
+ isBinary((property as { items?: unknown }).items)),
414
+ )
415
+ }
416
+
377
417
  function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOperation {
378
418
  const operation: OpenAPIOperation = { responses: buildResponses(input, store) }
379
419
  if (input.operationId !== undefined) operation.operationId = input.operationId
@@ -387,21 +427,36 @@ function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOpera
387
427
  ...pathParameters(input.path, input.params),
388
428
  ...queryParameters(input.query),
389
429
  ...headerParameters(input.headers),
430
+ ...cookieParameters(input.cookies),
390
431
  ]
391
432
  if (parameters.length > 0) operation.parameters = parameters
392
433
 
393
434
  if (input.body !== undefined) {
394
435
  const schema = store.collect(input.body.jsonSchema)
395
436
  if (schema !== undefined) {
396
- operation.requestBody = {
397
- required: true,
398
- content: { [input.requestContentType ?? "application/json"]: { schema } },
399
- }
437
+ const contentType =
438
+ input.requestContentType ??
439
+ (hasFileField(input.body.jsonSchema) ? "multipart/form-data" : "application/json")
440
+ const content: Record<string, { schema: typeof schema }> = { [contentType]: { schema } }
441
+ // A body schema with a parser of its own reads those media types too: one entry each, in a
442
+ // fixed order so a live document and one built from a stored snapshot are the same document.
443
+ for (const type of [...(input.body.mediaTypes ?? [])].sort()) content[type] ??= { schema }
444
+ operation.requestBody = { required: true, content }
400
445
  }
401
446
  }
402
447
  return operation
403
448
  }
404
449
 
450
+ const PATH_ITEM_METHODS: ReadonlySet<string> = new Set([
451
+ "get",
452
+ "put",
453
+ "post",
454
+ "delete",
455
+ "options",
456
+ "head",
457
+ "patch",
458
+ ])
459
+
405
460
  function addOperation(
406
461
  paths: Record<string, Record<string, OpenAPIOperation>>,
407
462
  method: string,
@@ -409,7 +464,10 @@ function addOperation(
409
464
  store: SchemaStore,
410
465
  operations: ToOpenAPIOptions["operations"],
411
466
  ): void {
412
- const templated = toTemplatedPath(input.path)
467
+ // A path item has a field for each standard method and no place for any other, so a route
468
+ // registered under a custom method is left out of the document.
469
+ if (!PATH_ITEM_METHODS.has(method.toLowerCase())) return
470
+ const templated = templatedPath(input.path).template
413
471
  const pathItem = paths[templated] ?? {}
414
472
  paths[templated] = pathItem
415
473
  let operation = buildOperation(input, store)
@@ -448,6 +506,7 @@ export function toOpenAPI(
448
506
  body: route.schema?.body,
449
507
  query: route.schema?.query,
450
508
  headers: route.schema?.headers,
509
+ cookies: route.schema?.cookies,
451
510
  params: route.schema?.params,
452
511
  // A route may now declare a `response` contract - emit it as the 200 body schema.
453
512
  response: route.schema?.response,
@@ -463,44 +522,54 @@ export function toOpenAPI(
463
522
  }
464
523
  } else {
465
524
  for (const [name, op] of Object.entries(input)) {
466
- addOperation(
467
- paths,
468
- op.method,
469
- {
470
- path: op.path,
471
- body: op.body === undefined ? undefined : reflectSchema(op.body),
472
- query: op.query === undefined ? undefined : reflectSchema(op.query),
473
- headers: op.headers === undefined ? undefined : reflectSchema(op.headers),
474
- params: op.params === undefined ? undefined : reflectSchema(op.params),
475
- response: op.response === undefined ? undefined : reflectSchema(op.response),
476
- operationId: name,
477
- summary: op.summary,
478
- description: op.description,
479
- tags: op.tags,
480
- deprecated: op.deprecated,
481
- security: op.security,
482
- requestContentType: op.requestContentType,
483
- responseContentType: op.responseContentType,
484
- responses:
485
- op.responses === undefined
486
- ? undefined
487
- : Object.fromEntries(
488
- Object.entries(op.responses).map(([status, response]) => {
489
- const { schema, ...metadata } = response
490
- return [
491
- status,
492
- {
493
- ...metadata,
494
- ...(schema === undefined ? {} : { schema: reflectSchema(schema) }),
495
- },
496
- ]
497
- }),
498
- ),
499
- inferredResponses: options.inferredResponses?.[`${op.method.toUpperCase()} ${op.path}`],
500
- },
501
- store,
502
- options.operations,
503
- )
525
+ // A path ending in optional params is one operation per concrete path it serves. Operation ids
526
+ // are unique in a document, so the contract name goes to the full path and the shorter ones
527
+ // carry none; a shorter path declares only the parameters it has.
528
+ const forms = expandOptionalParams(op.path)
529
+ const responses =
530
+ op.responses === undefined
531
+ ? undefined
532
+ : Object.fromEntries(
533
+ Object.entries(op.responses).map(([status, response]) => {
534
+ const { schema, ...metadata } = response
535
+ return [
536
+ status,
537
+ {
538
+ ...metadata,
539
+ ...(schema === undefined ? {} : { schema: reflectSchema(schema) }),
540
+ },
541
+ ]
542
+ }),
543
+ )
544
+ for (const path of forms) {
545
+ addOperation(
546
+ paths,
547
+ op.method,
548
+ {
549
+ path,
550
+ body: op.body === undefined ? undefined : reflectSchema(op.body),
551
+ query: op.query === undefined ? undefined : reflectSchema(op.query),
552
+ headers: op.headers === undefined ? undefined : reflectSchema(op.headers),
553
+ cookies: op.cookies === undefined ? undefined : reflectSchema(op.cookies),
554
+ params: op.params === undefined ? undefined : reflectSchema(op.params),
555
+ response: op.response === undefined ? undefined : reflectSchema(op.response),
556
+ operationId: path === forms[forms.length - 1] ? name : undefined,
557
+ summary: op.summary,
558
+ description: op.description,
559
+ tags: op.tags,
560
+ deprecated: op.deprecated,
561
+ security: op.security,
562
+ requestContentType: op.requestContentType,
563
+ responseContentType: op.responseContentType,
564
+ responses,
565
+ inferredResponses:
566
+ options.inferredResponses?.[`${op.method.toUpperCase()} ${path}`] ??
567
+ options.inferredResponses?.[`${op.method.toUpperCase()} ${op.path}`],
568
+ },
569
+ store,
570
+ options.operations,
571
+ )
572
+ }
504
573
  }
505
574
  }
506
575
 
package/src/t.ts CHANGED
@@ -1,26 +1,66 @@
1
1
  import {
2
2
  type ArrayOptions,
3
+ CloneType,
3
4
  type IntegerOptions,
5
+ Kind,
4
6
  type NumberOptions,
5
7
  type ObjectOptions,
8
+ type Static,
6
9
  type StringOptions,
10
+ type TArray,
7
11
  type TLiteralValue,
8
12
  type TSchema,
9
13
  type TUnion,
14
+ type TUnsafe,
10
15
  Type,
16
+ TypeRegistry,
11
17
  } from "@sinclair/typebox"
12
18
  import { fromTypeBox, type NifraSchema } from "./adapter.ts"
19
+ import { fileOps, refuseFile } from "./file-kind.ts"
13
20
 
14
21
  type Props = Record<string, NifraSchema>
15
22
 
16
- /** Pull each property's raw TypeBox schema out of its `NifraSchema` wrapper. */
17
- function unwrap<P extends Props>(props: P): { [K in keyof P]: P[K]["jsonSchema"] } {
23
+ /** A value `defer()` from `@nifrajs/web` marked to stream in after the page shell. */
24
+ export interface DeferredValue<T> {
25
+ readonly __nifra_deferred: true
26
+ readonly id: number
27
+ readonly promise: Promise<T>
28
+ }
29
+
30
+ const DEFERRED_KIND = "NifraDeferred"
31
+ /** Where `@nifrajs/web`'s output guard finds the inner schema's validate. Registered for two copies. */
32
+ const DEFERRED_VALIDATE = Symbol.for("nifra.schema.deferredValidate")
33
+
34
+ const isDeferredValue = (_: unknown, value: unknown): boolean =>
35
+ typeof value === "object" &&
36
+ value !== null &&
37
+ (value as { readonly __nifra_deferred?: unknown }).__nifra_deferred === true &&
38
+ typeof (value as { readonly promise?: { readonly then?: unknown } }).promise?.then === "function"
39
+
40
+ /**
41
+ * Pull each property's raw TypeBox schema out of its `NifraSchema` wrapper. `where` names the calling
42
+ * constructor: these objects are checked as JSON, so a file field is refused here (it belongs in
43
+ * `t.form` from `@nifrajs/schema/form`) instead of failing every request later.
44
+ */
45
+ function unwrap<P extends Props>(where: string, props: P): { [K in keyof P]: P[K]["jsonSchema"] } {
18
46
  const out: Record<string, TSchema> = {}
19
47
  // Object.entries → own enumerable string keys only (no prototype walk).
20
- for (const [key, schema] of Object.entries(props)) out[key] = schema.jsonSchema
48
+ for (const [key, schema] of Object.entries(props)) {
49
+ refuseFile(where, schema.jsonSchema)
50
+ out[key] = schema.jsonSchema
51
+ }
21
52
  return out as { [K in keyof P]: P[K]["jsonSchema"] }
22
53
  }
23
54
 
55
+ /** An open object whose scalar fields are coerced from strings - the shape of every request input
56
+ * that arrives as name/value text (a query string, a `Cookie` header). */
57
+ const textFields =
58
+ (where: string) =>
59
+ <P extends Props>(props: P, options?: ObjectOptions) =>
60
+ fromTypeBox(Type.Object(unwrap(where, props), { additionalProperties: true, ...options }), {
61
+ coerce: true,
62
+ })
63
+
24
64
  /**
25
65
  * The built-in schema builder. Each constructor returns a `NifraSchema` - a
26
66
  * Standard Schema whose validated output type flows into `c.body`/`c.query`, and
@@ -46,16 +86,33 @@ export const t = {
46
86
  // properties (no mass-assignment). Use `t.looseObject` (or pass `{ additionalProperties: true }`) to
47
87
  // opt into an open object; an explicit `options.additionalProperties` always wins over the default.
48
88
  object: <P extends Props>(props: P, options?: ObjectOptions) =>
49
- fromTypeBox(Type.Object(unwrap(props), { additionalProperties: false, ...options })),
89
+ fromTypeBox(
90
+ Type.Object(unwrap("t.object", props), { additionalProperties: false, ...options }),
91
+ ),
50
92
  /** Like `t.object` but ACCEPTS (and passes through) unknown fields - the explicit opt-out of the
51
93
  * strict default. Prefer `t.object` unless you genuinely need an open object. */
52
94
  looseObject: <P extends Props>(props: P, options?: ObjectOptions) =>
53
- fromTypeBox(Type.Object(unwrap(props), { additionalProperties: true, ...options })),
54
- array: <T extends TSchema>(item: NifraSchema<T>, options?: ArrayOptions) =>
95
+ fromTypeBox(
96
+ Type.Object(unwrap("t.looseObject", props), { additionalProperties: true, ...options }),
97
+ ),
98
+ /**
99
+ * A list. A list of `t.file()` (from `@nifrajs/schema/form`) honors `minItems` / `maxItems` and is
100
+ * used inside `t.form`.
101
+ */
102
+ array: <T extends TSchema>(
103
+ item: NifraSchema<T>,
104
+ options?: ArrayOptions,
105
+ ): NifraSchema<TArray<T>> =>
106
+ (fileOps("t.array", item)?.array(item, options) as NifraSchema<TArray<T>> | undefined) ??
55
107
  fromTypeBox(Type.Array(item.jsonSchema, options)),
56
- /** Marks a property optional inside `t.object`; standalone it is `T | undefined`. */
57
- optional: <T extends TSchema>(schema: NifraSchema<T>) =>
58
- fromTypeBox(Type.Optional(schema.jsonSchema)),
108
+ /** Marks a property optional inside `t.object` / `t.form`; standalone it is `T | undefined`. */
109
+ optional: <T extends TSchema>(schema: NifraSchema<T>) => {
110
+ const plain = () => fromTypeBox(Type.Optional(schema.jsonSchema))
111
+ return (
112
+ (fileOps("t.optional", schema)?.optional(schema) as ReturnType<typeof plain> | undefined) ??
113
+ plain()
114
+ )
115
+ },
59
116
  // `const S` captures the argument as a tuple; the explicit return type then maps
60
117
  // over that captured tuple (`S[K]["jsonSchema"]`) so the union's `Static` is
61
118
  // `A | B`, not `unknown` - the value-level `.map` can't preserve per-element
@@ -63,17 +120,25 @@ export const t = {
63
120
  // (order/length are preserved by `map`, so the tuple shape is sound).
64
121
  union: <const S extends readonly NifraSchema[]>(schemas: S) =>
65
122
  fromTypeBox(
66
- Type.Union((schemas as readonly NifraSchema[]).map((schema) => schema.jsonSchema)),
123
+ Type.Union(
124
+ (schemas as readonly NifraSchema[]).map((schema) => {
125
+ refuseFile("t.union", schema.jsonSchema)
126
+ return schema.jsonSchema
127
+ }),
128
+ ),
67
129
  ) as NifraSchema<TUnion<{ -readonly [K in keyof S]: S[K]["jsonSchema"] }>>,
68
- record: <T extends TSchema>(value: NifraSchema<T>, options?: ObjectOptions) =>
69
- fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options)),
130
+ record: <T extends TSchema>(value: NifraSchema<T>, options?: ObjectOptions) => {
131
+ refuseFile("t.record", value.jsonSchema)
132
+ return fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options))
133
+ },
70
134
 
71
135
  // Composed from TypeBox directly (not `t.object`/`t.array`) so the `t` literal doesn't reference
72
136
  // itself during inference. Cursor pagination - not OFFSET - is the production default: stable under
73
137
  // concurrent inserts and O(1) per page. Build pages with `paginate()` + `encodeCursor`/`decodeCursor`.
74
138
  /** A cursor-pagination response envelope: `{ items: T[]; nextCursor: string | null }` (`null` = last page). */
75
- paginated: <T extends TSchema>(item: NifraSchema<T>, options?: ObjectOptions) =>
76
- fromTypeBox(
139
+ paginated: <T extends TSchema>(item: NifraSchema<T>, options?: ObjectOptions) => {
140
+ refuseFile("t.paginated", item.jsonSchema)
141
+ return fromTypeBox(
77
142
  Type.Object(
78
143
  {
79
144
  items: Type.Array(item.jsonSchema),
@@ -81,7 +146,8 @@ export const t = {
81
146
  },
82
147
  { additionalProperties: false, ...options },
83
148
  ),
84
- ),
149
+ )
150
+ },
85
151
  /** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
86
152
  * caps `limit` - a larger value fails validation (a 422), so a client can't request an unbounded page.
87
153
  * `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
@@ -106,8 +172,42 @@ export const t = {
106
172
  * traffic appends params your schema never declares, causing 422s that never appear in dev/CI). Pass
107
173
  * `{ additionalProperties: false }` to enforce a strict allowlist. This is the query-slot constructor;
108
174
  * `t.object` stays the constructor for body slots (no coercion, a JSON body is already typed). */
109
- query: <P extends Props>(props: P, options?: ObjectOptions) =>
110
- fromTypeBox(Type.Object(unwrap(props), { additionalProperties: true, ...options }), {
111
- coerce: true,
112
- }),
175
+ query: textFields("t.query"),
176
+ /** A request-cookie schema for the `cookies` route slot, with the same string->scalar COERCION as
177
+ * `t.query` (cookie values always arrive as strings). **Open by default**: a browser sends every
178
+ * cookie the site has set - analytics, consent, other routes' sessions - so a strict allowlist
179
+ * would 422 real traffic. Declare the cookies the route reads; the rest pass through. */
180
+ cookies: textFields("t.cookies"),
181
+
182
+ /**
183
+ * A loader value marked with `defer()`, streamed in after the shell. `inner` describes what the
184
+ * promise resolves to; `@nifrajs/web` projects and validates the resolved value by it before it
185
+ * streams, so a deferred value is held to the same output contract as the rest of the data.
186
+ */
187
+ deferred: <T extends TSchema>(
188
+ inner: NifraSchema<T>,
189
+ ): NifraSchema<TUnsafe<DeferredValue<Static<T>>>> => {
190
+ refuseFile("t.deferred", inner.jsonSchema)
191
+ if (!TypeRegistry.Has(DEFERRED_KIND)) TypeRegistry.Set(DEFERRED_KIND, isDeferredValue)
192
+ return fromTypeBox(
193
+ Type.Unsafe<DeferredValue<Static<T>>>({
194
+ [Kind]: DEFERRED_KIND,
195
+ "x-nifra-deferred": true,
196
+ inner: inner.jsonSchema,
197
+ [DEFERRED_VALIDATE]: inner["~standard"].validate,
198
+ }),
199
+ )
200
+ },
201
+ /**
202
+ * Allow a field with a sensitive name (`token`, `password`, `apiKey`, ...) in an output schema.
203
+ * An output schema that declares one without this fails at route load; `reason` records why this
204
+ * value may reach the browser, for the reviewer who finds it later.
205
+ */
206
+ declassified: <T extends TSchema>(reason: string, schema: NifraSchema<T>): NifraSchema<T> => {
207
+ if (typeof reason !== "string" || reason.trim() === "") {
208
+ throw new TypeError("t.declassified: give the reason this field may reach the browser")
209
+ }
210
+ refuseFile("t.declassified", schema.jsonSchema)
211
+ return fromTypeBox(CloneType(schema.jsonSchema, { "x-nifra-declassified": reason }) as T)
212
+ },
113
213
  } as const