@nifrajs/schema 3.3.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/src/openapi.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import type { ContractShape } from "@nifrajs/core/contract"
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"
2
9
  import {
3
10
  type JsonSchema,
4
11
  type ReflectedRouteSchema,
@@ -50,6 +57,11 @@ export interface ToOpenAPIOptions {
50
57
  readonly securitySchemes?: Readonly<Record<string, Record<string, unknown>>>
51
58
  /** Document-wide security requirement; a per-operation `security` (incl. `[]` for public) overrides it. */
52
59
  readonly security?: readonly SecurityRequirement[]
60
+ /**
61
+ * Reuse a canonical token-only route-evidence pass. This is useful for build artifacts and CLI
62
+ * projections that already evaluated the project; it never supplies runtime validators.
63
+ */
64
+ readonly evidence?: ProjectEvidenceSnapshot
53
65
  /**
54
66
  * Per-operation overrides, shallow-merged over the generated operation. Keyed by `operationId`
55
67
  * (contract op name) or `"METHOD /path"` (e.g. `"GET /users/:id"`). The escape hatch for detail that
@@ -80,7 +92,7 @@ export interface ToOpenAPIOptions {
80
92
 
81
93
  interface OpenAPIParameter {
82
94
  readonly name: string
83
- readonly in: "path" | "query" | "header"
95
+ readonly in: "path" | "query" | "header" | "cookie"
84
96
  readonly required: boolean
85
97
  readonly schema: JsonSchema
86
98
  }
@@ -175,26 +187,47 @@ class SchemaStore {
175
187
  }
176
188
  }
177
189
 
178
- /** Name of a path segment's parameter, or `undefined` for a static segment. */
179
- function segmentParam(segment: string): string | undefined {
180
- if (segment.startsWith(":")) return segment.slice(1)
181
- if (segment.startsWith("*")) return segment.length > 1 ? segment.slice(1) : "wildcard"
182
- return undefined
190
+ interface PathParameter {
191
+ readonly name: string
192
+ readonly constraint: ParamConstraint | undefined
183
193
  }
184
194
 
185
- /** `/users/:id/*rest` → `/users/{id}/{rest}` (OpenAPI path templating). */
186
- function toTemplatedPath(path: string): string {
187
- return path
188
- .split("/")
189
- .map((segment) => {
190
- const param = segmentParam(segment)
191
- return param === undefined ? segment : `{${param}}`
192
- })
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 })
206
+ return `{${name}}`
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
+ )
193
226
  .join("/")
227
+ return { template: `/${template}`, parameters }
194
228
  }
195
229
 
196
230
  function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIParameter[] {
197
- const params: OpenAPIParameter[] = []
198
231
  // Build a lookup of per-field schemas from the declared params schema (if any).
199
232
  const fieldSchemas = new Map<string, JsonSchema>()
200
233
  if (paramsSchema?.fields !== undefined) {
@@ -202,16 +235,20 @@ function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIP
202
235
  fieldSchemas.set(field.name, field.schema)
203
236
  }
204
237
  }
205
- for (const segment of path.split("/")) {
206
- const name = segmentParam(segment)
207
- if (name !== undefined) {
208
- // Merge the declared constraint (uuid format, integer type, etc.) when present;
209
- // fall back to the bare { type: "string" } derived from the URL pattern.
210
- const schema = fieldSchemas.get(name) ?? { type: "string" as const }
211
- params.push({ name, in: "path", required: true, schema })
212
- }
213
- }
214
- 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
+ }))
215
252
  }
216
253
 
217
254
  function queryParameters(schema: SchemaReflection | undefined): OpenAPIParameter[] {
@@ -237,11 +274,23 @@ function headerParameters(schema: SchemaReflection | undefined): OpenAPIParamete
237
274
  }))
238
275
  }
239
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
+
240
288
  interface OperationInput {
241
289
  readonly path: string
242
290
  readonly body: SchemaReflection | undefined
243
291
  readonly query: SchemaReflection | undefined
244
292
  readonly headers: SchemaReflection | undefined
293
+ readonly cookies?: SchemaReflection | undefined
245
294
  /** Reflected params schema - per-field constraints merge into path parameters. */
246
295
  readonly params: SchemaReflection | undefined
247
296
  readonly response: SchemaReflection | undefined
@@ -341,6 +390,30 @@ function buildResponses(
341
390
  return responses
342
391
  }
343
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
+
344
417
  function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOperation {
345
418
  const operation: OpenAPIOperation = { responses: buildResponses(input, store) }
346
419
  if (input.operationId !== undefined) operation.operationId = input.operationId
@@ -354,21 +427,36 @@ function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOpera
354
427
  ...pathParameters(input.path, input.params),
355
428
  ...queryParameters(input.query),
356
429
  ...headerParameters(input.headers),
430
+ ...cookieParameters(input.cookies),
357
431
  ]
358
432
  if (parameters.length > 0) operation.parameters = parameters
359
433
 
360
434
  if (input.body !== undefined) {
361
435
  const schema = store.collect(input.body.jsonSchema)
362
436
  if (schema !== undefined) {
363
- operation.requestBody = {
364
- required: true,
365
- content: { [input.requestContentType ?? "application/json"]: { schema } },
366
- }
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 }
367
445
  }
368
446
  }
369
447
  return operation
370
448
  }
371
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
+
372
460
  function addOperation(
373
461
  paths: Record<string, Record<string, OpenAPIOperation>>,
374
462
  method: string,
@@ -376,7 +464,10 @@ function addOperation(
376
464
  store: SchemaStore,
377
465
  operations: ToOpenAPIOptions["operations"],
378
466
  ): void {
379
- 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
380
471
  const pathItem = paths[templated] ?? {}
381
472
  paths[templated] = pathItem
382
473
  let operation = buildOperation(input, store)
@@ -402,7 +493,11 @@ export function toOpenAPI(
402
493
  const store = new SchemaStore()
403
494
 
404
495
  if (isApp(input)) {
405
- for (const route of reflectRoutes(input)) {
496
+ const routes =
497
+ options.evidence === undefined
498
+ ? reflectRoutes(input)
499
+ : reflectedRoutesFromEvidence(options.evidence)
500
+ for (const route of routes) {
406
501
  addOperation(
407
502
  paths,
408
503
  route.method,
@@ -411,6 +506,7 @@ export function toOpenAPI(
411
506
  body: route.schema?.body,
412
507
  query: route.schema?.query,
413
508
  headers: route.schema?.headers,
509
+ cookies: route.schema?.cookies,
414
510
  params: route.schema?.params,
415
511
  // A route may now declare a `response` contract - emit it as the 200 body schema.
416
512
  response: route.schema?.response,
@@ -426,44 +522,54 @@ export function toOpenAPI(
426
522
  }
427
523
  } else {
428
524
  for (const [name, op] of Object.entries(input)) {
429
- addOperation(
430
- paths,
431
- op.method,
432
- {
433
- path: op.path,
434
- body: op.body === undefined ? undefined : reflectSchema(op.body),
435
- query: op.query === undefined ? undefined : reflectSchema(op.query),
436
- headers: op.headers === undefined ? undefined : reflectSchema(op.headers),
437
- params: op.params === undefined ? undefined : reflectSchema(op.params),
438
- response: op.response === undefined ? undefined : reflectSchema(op.response),
439
- operationId: name,
440
- summary: op.summary,
441
- description: op.description,
442
- tags: op.tags,
443
- deprecated: op.deprecated,
444
- security: op.security,
445
- requestContentType: op.requestContentType,
446
- responseContentType: op.responseContentType,
447
- responses:
448
- op.responses === undefined
449
- ? undefined
450
- : Object.fromEntries(
451
- Object.entries(op.responses).map(([status, response]) => {
452
- const { schema, ...metadata } = response
453
- return [
454
- status,
455
- {
456
- ...metadata,
457
- ...(schema === undefined ? {} : { schema: reflectSchema(schema) }),
458
- },
459
- ]
460
- }),
461
- ),
462
- inferredResponses: options.inferredResponses?.[`${op.method.toUpperCase()} ${op.path}`],
463
- },
464
- store,
465
- options.operations,
466
- )
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
+ }
467
573
  }
468
574
  }
469
575
 
@@ -485,3 +591,14 @@ export function toOpenAPI(
485
591
  ...(Object.keys(components).length > 0 ? { components } : {}),
486
592
  }
487
593
  }
594
+
595
+ /** Generate OpenAPI from an existing canonical project-evidence snapshot without loading a server. */
596
+ export function toOpenAPIFromEvidence(
597
+ evidence: ProjectEvidenceSnapshot,
598
+ options: Omit<ToOpenAPIOptions, "evidence"> = {},
599
+ ): OpenAPIDocument {
600
+ const app = {
601
+ routes: () => reflectedRoutesFromEvidence(evidence),
602
+ } as unknown as Server
603
+ return toOpenAPI(app, { ...options, evidence })
604
+ }
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