@nifrajs/web 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/dist/backend-only.d.ts +15 -0
- package/dist/backend-only.d.ts.map +1 -0
- package/dist/backend-only.js +16 -0
- package/dist/backend-only.js.map +1 -0
- package/dist/boundary.d.ts +5 -0
- package/dist/boundary.d.ts.map +1 -1
- package/dist/boundary.js +11 -2
- package/dist/boundary.js.map +1 -1
- package/dist/build-plan.d.ts +35 -6
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +89 -35
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +18 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +97 -17
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +72 -23
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +411 -98
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/cdn.d.ts +74 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.js +132 -0
- package/dist/cdn.js.map +1 -0
- package/dist/client.d.ts +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +226 -15
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +4 -3
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +12 -3
- package/dist/conformance.js.map +1 -1
- package/dist/csp.d.ts +93 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +122 -0
- package/dist/csp.js.map +1 -0
- package/dist/css-contract.d.ts +21 -0
- package/dist/css-contract.d.ts.map +1 -0
- package/dist/css-contract.js +28 -0
- package/dist/css-contract.js.map +1 -0
- package/dist/dev-client.d.ts +76 -0
- package/dist/dev-client.d.ts.map +1 -0
- package/dist/dev-client.js +280 -0
- package/dist/dev-client.js.map +1 -0
- package/dist/dev-diagnostics.d.ts +26 -2
- package/dist/dev-diagnostics.d.ts.map +1 -1
- package/dist/dev-diagnostics.js +38 -7
- package/dist/dev-diagnostics.js.map +1 -1
- package/dist/dev-error.d.ts +4 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +46 -4
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-feed.d.ts +298 -0
- package/dist/dev-feed.d.ts.map +1 -0
- package/dist/dev-feed.js +797 -0
- package/dist/dev-feed.js.map +1 -0
- package/dist/dev-indicator.d.ts +38 -0
- package/dist/dev-indicator.d.ts.map +1 -0
- package/dist/dev-indicator.js +93 -0
- package/dist/dev-indicator.js.map +1 -0
- package/dist/dev-session.d.ts +77 -0
- package/dist/dev-session.d.ts.map +1 -0
- package/dist/dev-session.js +580 -0
- package/dist/dev-session.js.map +1 -0
- package/dist/dev-sourcemap.d.ts +27 -0
- package/dist/dev-sourcemap.d.ts.map +1 -0
- package/dist/dev-sourcemap.js +235 -0
- package/dist/dev-sourcemap.js.map +1 -0
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +13 -5
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/dev.d.ts +15 -2
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +69 -44
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic-catalog.d.ts +35 -0
- package/dist/diagnostic-catalog.d.ts.map +1 -0
- package/dist/diagnostic-catalog.js +203 -0
- package/dist/diagnostic-catalog.js.map +1 -0
- package/dist/diagnostic-prompt.d.ts +49 -0
- package/dist/diagnostic-prompt.d.ts.map +1 -0
- package/dist/diagnostic-prompt.js +303 -0
- package/dist/diagnostic-prompt.js.map +1 -0
- package/dist/diagnostic.d.ts +17 -23
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +31 -39
- package/dist/diagnostic.js.map +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +4 -1
- package/dist/draft.js.map +1 -1
- package/dist/fn.d.ts +5 -0
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +13 -3
- package/dist/fn.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +4 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +15 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -12
- package/dist/index.js.map +1 -1
- package/dist/internal/cdn/cloudflare.d.ts +48 -0
- package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
- package/dist/internal/cdn/cloudflare.js +94 -0
- package/dist/internal/cdn/cloudflare.js.map +1 -0
- package/dist/internal/cdn/core.d.ts +91 -0
- package/dist/internal/cdn/core.d.ts.map +1 -0
- package/dist/internal/cdn/core.js +220 -0
- package/dist/internal/cdn/core.js.map +1 -0
- package/dist/internal/cdn/fastly.d.ts +17 -0
- package/dist/internal/cdn/fastly.d.ts.map +1 -0
- package/dist/internal/cdn/fastly.js +43 -0
- package/dist/internal/cdn/fastly.js.map +1 -0
- package/dist/internal/cdn/vercel.d.ts +31 -0
- package/dist/internal/cdn/vercel.d.ts.map +1 -0
- package/dist/internal/cdn/vercel.js +56 -0
- package/dist/internal/cdn/vercel.js.map +1 -0
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/codegen.d.ts +3 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +151 -30
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/dev-csp.d.ts +24 -0
- package/dist/internal/dev-csp.d.ts.map +1 -0
- package/dist/internal/dev-csp.js +160 -0
- package/dist/internal/dev-csp.js.map +1 -0
- package/dist/internal/dev-reserved.d.ts +25 -0
- package/dist/internal/dev-reserved.d.ts.map +1 -0
- package/dist/internal/dev-reserved.js +39 -0
- package/dist/internal/dev-reserved.js.map +1 -0
- package/dist/internal/html-spans.d.ts +10 -0
- package/dist/internal/html-spans.d.ts.map +1 -0
- package/dist/internal/html-spans.js +46 -0
- package/dist/internal/html-spans.js.map +1 -0
- package/dist/internal/loading-runtime.d.ts +44 -0
- package/dist/internal/loading-runtime.d.ts.map +1 -0
- package/dist/internal/loading-runtime.js +206 -0
- package/dist/internal/loading-runtime.js.map +1 -0
- package/dist/internal/matches-runtime.d.ts +8 -0
- package/dist/internal/matches-runtime.d.ts.map +1 -0
- package/dist/internal/matches-runtime.js +61 -0
- package/dist/internal/matches-runtime.js.map +1 -0
- package/dist/internal/mount-shadow.d.ts +36 -0
- package/dist/internal/mount-shadow.d.ts.map +1 -0
- package/dist/internal/mount-shadow.js +65 -0
- package/dist/internal/mount-shadow.js.map +1 -0
- package/dist/internal/node-builtins.d.ts +3 -0
- package/dist/internal/node-builtins.d.ts.map +1 -0
- package/dist/internal/node-builtins.js +79 -0
- package/dist/internal/node-builtins.js.map +1 -0
- package/dist/internal/output-guard.d.ts +65 -0
- package/dist/internal/output-guard.d.ts.map +1 -0
- package/dist/internal/output-guard.js +630 -0
- package/dist/internal/output-guard.js.map +1 -0
- package/dist/internal/page-execution.d.ts +69 -0
- package/dist/internal/page-execution.d.ts.map +1 -0
- package/dist/internal/page-execution.js +1021 -0
- package/dist/internal/page-execution.js.map +1 -0
- package/dist/internal/parity.d.ts +21 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +104 -22
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +12 -0
- package/dist/internal/private-env.d.ts.map +1 -0
- package/dist/internal/private-env.js +335 -0
- package/dist/internal/private-env.js.map +1 -0
- package/dist/internal/public-url.d.ts +3 -0
- package/dist/internal/public-url.d.ts.map +1 -0
- package/dist/internal/public-url.js +18 -0
- package/dist/internal/public-url.js.map +1 -0
- package/dist/internal/render-document.d.ts +37 -21
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +104 -65
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +3 -0
- package/dist/internal/request-url.d.ts.map +1 -0
- package/dist/internal/request-url.js +14 -0
- package/dist/internal/request-url.js.map +1 -0
- package/dist/internal/response-controls.d.ts +75 -0
- package/dist/internal/response-controls.d.ts.map +1 -0
- package/dist/internal/response-controls.js +256 -0
- package/dist/internal/response-controls.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +11 -16
- package/dist/internal/runtime-contract.d.ts.map +1 -1
- package/dist/internal/runtime-contract.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +104 -0
- package/dist/internal/secret-scan.d.ts.map +1 -0
- package/dist/internal/secret-scan.js +485 -0
- package/dist/internal/secret-scan.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +6 -29
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +6 -66
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/source-map.d.ts +47 -0
- package/dist/internal/source-map.d.ts.map +1 -0
- package/dist/internal/source-map.js +225 -0
- package/dist/internal/source-map.js.map +1 -0
- package/dist/internal/target-compat.d.ts +27 -0
- package/dist/internal/target-compat.d.ts.map +1 -0
- package/dist/internal/target-compat.js +77 -0
- package/dist/internal/target-compat.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +6 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -1
- package/dist/internal/timing-safe-equal.js +10 -0
- package/dist/internal/timing-safe-equal.js.map +1 -1
- package/dist/internal/vitals-runtime.d.ts +18 -0
- package/dist/internal/vitals-runtime.d.ts.map +1 -0
- package/dist/internal/vitals-runtime.js +38 -0
- package/dist/internal/vitals-runtime.js.map +1 -0
- package/dist/internal/web-app.d.ts +56 -8
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +215 -772
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/zone-graph.d.ts +89 -0
- package/dist/internal/zone-graph.d.ts.map +1 -0
- package/dist/internal/zone-graph.js +492 -0
- package/dist/internal/zone-graph.js.map +1 -0
- package/dist/isr.d.ts +83 -6
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +301 -39
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +37 -9
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +221 -14
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +386 -9
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +26 -5
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +17 -7
- package/dist/module-graph.js.map +1 -1
- package/dist/navigation.d.ts +17 -3
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +11 -5
- package/dist/navigation.js.map +1 -1
- package/dist/nonce.d.ts +47 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +63 -0
- package/dist/nonce.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +4 -3
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +9 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +18 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/svg.d.ts +22 -3
- package/dist/plugins/svg.d.ts.map +1 -1
- package/dist/plugins/svg.js +112 -1
- package/dist/plugins/svg.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +107 -3
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +378 -17
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts +61 -0
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
- package/dist/plugins/vite-zone-guard.js +208 -0
- package/dist/plugins/vite-zone-guard.js.map +1 -0
- package/dist/plugins/zone-guard.d.ts +27 -0
- package/dist/plugins/zone-guard.d.ts.map +1 -0
- package/dist/plugins/zone-guard.js +72 -0
- package/dist/plugins/zone-guard.js.map +1 -0
- package/dist/prerender.d.ts +10 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +35 -3
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +9 -0
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +131 -48
- package/dist/public-dir.js.map +1 -1
- package/dist/pwa-manifest.d.ts +68 -0
- package/dist/pwa-manifest.d.ts.map +1 -0
- package/dist/pwa-manifest.js +178 -0
- package/dist/pwa-manifest.js.map +1 -0
- package/dist/query.js +4 -4
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +42 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +6 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -0
- package/dist/route-manifest.d.ts.map +1 -1
- package/dist/route-manifest.js +26 -3
- package/dist/route-manifest.js.map +1 -1
- package/dist/route-types.d.ts +24 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +195 -0
- package/dist/route-types.js.map +1 -0
- package/dist/router.d.ts +28 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +129 -40
- package/dist/router.js.map +1 -1
- package/dist/trusted-html.d.ts +34 -0
- package/dist/trusted-html.d.ts.map +1 -0
- package/dist/trusted-html.js +35 -0
- package/dist/trusted-html.js.map +1 -0
- package/dist/vitals.d.ts +33 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +16 -0
- package/dist/vitals.js.map +1 -0
- package/dist/vite.d.ts +16 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +187 -36
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +70 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +365 -0
- package/dist/zones.js.map +1 -0
- package/package.json +67 -14
- package/src/backend-only.ts +16 -0
- package/src/boundary.ts +18 -5
- package/src/build-plan.ts +113 -36
- package/src/build-vite.ts +124 -15
- package/src/build.ts +535 -102
- package/src/bun-dev-entry.ts +1 -1
- package/src/cdn.ts +216 -0
- package/src/client.ts +251 -18
- package/src/conformance.ts +16 -3
- package/src/csp.ts +194 -0
- package/src/css-contract.ts +44 -0
- package/src/dev-client.ts +349 -0
- package/src/dev-diagnostics.ts +60 -7
- package/src/dev-error.ts +49 -5
- package/src/dev-feed.ts +1140 -0
- package/src/dev-indicator.ts +113 -0
- package/src/dev-session.ts +729 -0
- package/src/dev-sourcemap.ts +281 -0
- package/src/dev-ssr-graph.ts +16 -5
- package/src/dev.ts +86 -43
- package/src/diagnostic-catalog.ts +243 -0
- package/src/diagnostic-prompt.ts +355 -0
- package/src/diagnostic.ts +63 -66
- package/src/draft.ts +4 -1
- package/src/fn.ts +19 -3
- package/src/fs.ts +13 -2
- package/src/index.ts +57 -10
- package/src/internal/cdn/cloudflare.ts +146 -0
- package/src/internal/cdn/core.ts +316 -0
- package/src/internal/cdn/fastly.ts +62 -0
- package/src/internal/cdn/vercel.ts +95 -0
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/codegen.ts +180 -29
- package/src/internal/dev-csp.ts +176 -0
- package/src/internal/dev-reserved.ts +52 -0
- package/src/internal/html-spans.ts +50 -0
- package/src/internal/loading-runtime.ts +244 -0
- package/src/internal/matches-runtime.ts +62 -0
- package/src/internal/mount-shadow.ts +88 -0
- package/src/internal/node-builtins.ts +79 -0
- package/src/internal/output-guard.ts +701 -0
- package/src/internal/page-execution.ts +1371 -0
- package/src/internal/parity.ts +150 -36
- package/src/internal/private-env.ts +334 -0
- package/src/internal/public-url.ts +22 -0
- package/src/internal/render-document.ts +153 -73
- package/src/internal/request-url.ts +14 -0
- package/src/internal/response-controls.ts +284 -0
- package/src/internal/runtime-contract.ts +10 -15
- package/src/internal/secret-scan.ts +623 -0
- package/src/internal/server-boundary.ts +6 -69
- package/src/internal/source-map.ts +256 -0
- package/src/internal/target-compat.ts +92 -0
- package/src/internal/timing-safe-equal.ts +13 -0
- package/src/internal/vitals-runtime.ts +53 -0
- package/src/internal/web-app.ts +290 -879
- package/src/internal/zone-graph.ts +593 -0
- package/src/isr.ts +366 -39
- package/src/llms-txt.ts +44 -8
- package/src/manifest.ts +671 -17
- package/src/module-graph.ts +53 -9
- package/src/navigation.ts +29 -6
- package/src/nonce.ts +104 -0
- package/src/plugins/css-modules.ts +5 -3
- package/src/plugins/kit.ts +24 -0
- package/src/plugins/svg.ts +135 -4
- package/src/plugins/vite-leak-guard.ts +529 -16
- package/src/plugins/vite-zone-guard.ts +263 -0
- package/src/plugins/zone-guard.ts +110 -0
- package/src/prerender.ts +49 -3
- package/src/public-dir.ts +135 -49
- package/src/pwa-manifest.ts +226 -0
- package/src/query.ts +4 -4
- package/src/render-seam.ts +46 -1
- package/src/route-manifest.ts +15 -3
- package/src/route-types.ts +264 -0
- package/src/router.ts +163 -63
- package/src/trusted-html.ts +44 -0
- package/src/vitals.ts +44 -0
- package/src/vite.ts +207 -36
- package/src/zones.ts +430 -0
- package/dist/plugins/vite-server-only.d.ts +0 -49
- package/dist/plugins/vite-server-only.d.ts.map +0 -1
- package/dist/plugins/vite-server-only.js +0 -57
- package/dist/plugins/vite-server-only.js.map +0 -1
- package/dist/server-only.d.ts +0 -21
- package/dist/server-only.d.ts.map +0 -1
- package/dist/server-only.js +0 -25
- package/dist/server-only.js.map +0 -1
- package/src/plugins/vite-server-only.ts +0 -75
- package/src/server-only.ts +0 -25
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data guard. Every value a loader, action, boundary loader or server function sends toward the
|
|
3
|
+
* browser passes through its declared output schema first:
|
|
4
|
+
*
|
|
5
|
+
* - keys the schema does not declare are dropped - that is the guarantee, and it holds even when the
|
|
6
|
+
* schema itself would accept them;
|
|
7
|
+
* - a declared field of the wrong shape fails the request, and the error names field paths only,
|
|
8
|
+
* never a value;
|
|
9
|
+
* - a deferred value must be declared as one (`t.deferred(inner)`), and its resolved value is guarded
|
|
10
|
+
* by `inner` before it streams.
|
|
11
|
+
*
|
|
12
|
+
* Projection reads the schema's JSON Schema: a nifra schema's `jsonSchema`, or a Standard JSON Schema
|
|
13
|
+
* (`~standard.jsonSchema.output`). A schema that exposes neither is trusted to strip unknown keys in
|
|
14
|
+
* its own `validate` output, as zod and valibot objects do by default.
|
|
15
|
+
*/
|
|
16
|
+
import type { StandardResult, StandardSchemaV1 } from "@nifrajs/core/server"
|
|
17
|
+
|
|
18
|
+
// Registry symbols shared with core and `@nifrajs/schema`, so two copies of either still agree.
|
|
19
|
+
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
20
|
+
const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
|
|
21
|
+
/** Set by `t.deferred(inner)` on its schema node: `inner`'s own validate. */
|
|
22
|
+
const DEFERRED_VALIDATE = Symbol.for("nifra.schema.deferredValidate")
|
|
23
|
+
|
|
24
|
+
export const DECLASSIFIED_KEY = "x-nifra-declassified"
|
|
25
|
+
const DEFERRED_KEY = "x-nifra-deferred"
|
|
26
|
+
|
|
27
|
+
/** A field name that usually holds a credential or personal identifier. */
|
|
28
|
+
export function isSensitiveFieldName(name: string): boolean {
|
|
29
|
+
const key = name.toLowerCase().replace(/[-_]/g, "")
|
|
30
|
+
return SENSITIVE_NAMES.has(key) || SENSITIVE_SUFFIXES.some((suffix) => key.endsWith(suffix))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SENSITIVE_NAMES: ReadonlySet<string> = new Set([
|
|
34
|
+
"passwd",
|
|
35
|
+
"token",
|
|
36
|
+
"sessionid",
|
|
37
|
+
"ssn",
|
|
38
|
+
"socialsecuritynumber",
|
|
39
|
+
"creditcard",
|
|
40
|
+
"creditcardnumber",
|
|
41
|
+
"cardnumber",
|
|
42
|
+
"cvv",
|
|
43
|
+
"cvc",
|
|
44
|
+
"otp",
|
|
45
|
+
])
|
|
46
|
+
const SENSITIVE_SUFFIXES: readonly string[] = [
|
|
47
|
+
"password",
|
|
48
|
+
"passwordhash",
|
|
49
|
+
"secret",
|
|
50
|
+
"apikey",
|
|
51
|
+
"apitoken",
|
|
52
|
+
"privatekey",
|
|
53
|
+
"secretkey",
|
|
54
|
+
"accesstoken",
|
|
55
|
+
"refreshtoken",
|
|
56
|
+
"idtoken",
|
|
57
|
+
"authtoken",
|
|
58
|
+
"sessiontoken",
|
|
59
|
+
"bearertoken",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** A browser-bound value failed its output contract. The message carries paths, never values. */
|
|
63
|
+
export class OutputGuardError extends Error {
|
|
64
|
+
override readonly name = "OutputGuardError"
|
|
65
|
+
readonly paths: readonly string[]
|
|
66
|
+
constructor(message: string, paths: readonly string[] = []) {
|
|
67
|
+
super(`[nifra/web] ${message}`)
|
|
68
|
+
this.paths = paths
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Node = { readonly [key: string]: unknown }
|
|
73
|
+
interface RunState {
|
|
74
|
+
readonly label: string
|
|
75
|
+
readonly issues: string[]
|
|
76
|
+
}
|
|
77
|
+
type Project = (value: unknown, state: RunState) => unknown
|
|
78
|
+
|
|
79
|
+
interface CompiledGuard {
|
|
80
|
+
/** Undefined when the schema exposes no JSON Schema to project by. */
|
|
81
|
+
readonly project: Project | undefined
|
|
82
|
+
readonly validate: StandardSchemaV1["~standard"]["validate"]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Deferred {
|
|
86
|
+
readonly __nifra_deferred: true
|
|
87
|
+
readonly id: number
|
|
88
|
+
readonly promise: Promise<unknown>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isDeferred = (value: unknown): value is Deferred =>
|
|
92
|
+
typeof value === "object" &&
|
|
93
|
+
value !== null &&
|
|
94
|
+
(value as { __nifra_deferred?: unknown }).__nifra_deferred === true
|
|
95
|
+
|
|
96
|
+
const isNode = (value: unknown): value is Node =>
|
|
97
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
98
|
+
|
|
99
|
+
const isPlain = (value: object): boolean => {
|
|
100
|
+
const proto = Object.getPrototypeOf(value)
|
|
101
|
+
return proto === Object.prototype || proto === null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Re-root the issues children pushed since `from` under `key`. Runs only on failure. */
|
|
105
|
+
function prefix(state: RunState, from: number, key: string | number): void {
|
|
106
|
+
for (let i = from; i < state.issues.length; i++) state.issues[i] = `/${key}${state.issues[i]}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assign(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
110
|
+
if (key === "__proto__") {
|
|
111
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
112
|
+
} else out[key] = value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every deferred marker inside a value no schema declares as deferred is an issue. */
|
|
116
|
+
const UNKNOWN: Project = (value, state) => {
|
|
117
|
+
if (typeof value !== "object" || value === null) return value
|
|
118
|
+
if (isDeferred(value)) {
|
|
119
|
+
state.issues.push("")
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(value)) {
|
|
123
|
+
for (let i = 0; i < value.length; i++) {
|
|
124
|
+
const from = state.issues.length
|
|
125
|
+
UNKNOWN(value[i], state)
|
|
126
|
+
if (state.issues.length > from) prefix(state, from, i)
|
|
127
|
+
}
|
|
128
|
+
} else if (isPlain(value)) {
|
|
129
|
+
for (const key of Object.keys(value)) {
|
|
130
|
+
const from = state.issues.length
|
|
131
|
+
UNKNOWN((value as Record<string, unknown>)[key], state)
|
|
132
|
+
if (state.issues.length > from) prefix(state, from, key)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return value
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A scalar schema: the validator decides, and a deferred marker cannot pass a scalar check. A `Date`
|
|
140
|
+
* becomes what JSON makes of it (its ISO string), so a `t.string()` field accepts a timestamp column
|
|
141
|
+
* and the page renders on the server with the value the browser receives.
|
|
142
|
+
*/
|
|
143
|
+
const LEAF: Project = (value) => (value instanceof Date ? value.toJSON() : value)
|
|
144
|
+
|
|
145
|
+
/** An array whose items are all scalars, passed through whole. */
|
|
146
|
+
const LEAF_ARRAY: Project = (value) => value
|
|
147
|
+
|
|
148
|
+
const typesOf = (node: Node): readonly unknown[] | undefined =>
|
|
149
|
+
Array.isArray(node.type) ? node.type : node.type === undefined ? undefined : [node.type]
|
|
150
|
+
|
|
151
|
+
const COMBINATORS = ["anyOf", "oneOf", "allOf"] as const
|
|
152
|
+
|
|
153
|
+
/** What a schema node (with no `$ref` or combinator) projects as. */
|
|
154
|
+
function shapeOf(node: Node): "deferred" | "object" | "array" | "both" | "leaf" | "unknown" {
|
|
155
|
+
if (node[DEFERRED_KEY] === true) return "deferred"
|
|
156
|
+
const types = typesOf(node)
|
|
157
|
+
const object =
|
|
158
|
+
types?.includes("object") === true ||
|
|
159
|
+
(types === undefined &&
|
|
160
|
+
(node.properties !== undefined ||
|
|
161
|
+
node.patternProperties !== undefined ||
|
|
162
|
+
node.additionalProperties !== undefined))
|
|
163
|
+
const array =
|
|
164
|
+
types?.includes("array") === true ||
|
|
165
|
+
(types === undefined && (node.items !== undefined || node.prefixItems !== undefined))
|
|
166
|
+
if (object && array) return "both"
|
|
167
|
+
if (object) return "object"
|
|
168
|
+
if (array) return "array"
|
|
169
|
+
return types !== undefined ||
|
|
170
|
+
node.const !== undefined ||
|
|
171
|
+
node.enum !== undefined ||
|
|
172
|
+
node.not !== undefined
|
|
173
|
+
? "leaf"
|
|
174
|
+
: "unknown"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
class Compiler {
|
|
178
|
+
private readonly memo = new Map<Node, Project>()
|
|
179
|
+
private readonly ids = new Map<string, Node>()
|
|
180
|
+
readonly sensitive: string[] = []
|
|
181
|
+
|
|
182
|
+
constructor(private readonly root: Node) {
|
|
183
|
+
this.collectIds(root, new Set())
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private collectIds(value: unknown, seen: Set<unknown>): void {
|
|
187
|
+
if (typeof value !== "object" || value === null || seen.has(value)) return
|
|
188
|
+
seen.add(value)
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
for (const item of value) this.collectIds(item, seen)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const id = (value as Node).$id
|
|
194
|
+
if (typeof id === "string" && !this.ids.has(id)) this.ids.set(id, value as Node)
|
|
195
|
+
for (const key of Object.keys(value)) this.collectIds((value as Node)[key], seen)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private resolve(ref: string, where: string): Node {
|
|
199
|
+
const byId = this.ids.get(ref)
|
|
200
|
+
if (byId !== undefined) return byId
|
|
201
|
+
if (ref === "#") return this.root
|
|
202
|
+
if (ref.startsWith("#/")) {
|
|
203
|
+
let node: unknown = this.root
|
|
204
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
205
|
+
const segment = raw.replaceAll("~1", "/").replaceAll("~0", "~")
|
|
206
|
+
node = isNode(node) ? node[segment] : undefined
|
|
207
|
+
}
|
|
208
|
+
if (isNode(node)) return node
|
|
209
|
+
}
|
|
210
|
+
throw new OutputGuardError(
|
|
211
|
+
`the output schema references "${ref}" at ${where || "/"}, which it does not define`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
compile(node: Node, where: string): Project {
|
|
216
|
+
const known = this.memo.get(node)
|
|
217
|
+
if (known !== undefined) return known
|
|
218
|
+
let impl: Project = LEAF
|
|
219
|
+
const late: Project = (value, state) => impl(value, state)
|
|
220
|
+
this.memo.set(node, late)
|
|
221
|
+
impl = this.build(node, where)
|
|
222
|
+
this.memo.set(node, impl)
|
|
223
|
+
return impl
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private build(node: Node, where: string): Project {
|
|
227
|
+
if (typeof node.$ref === "string") return this.compile(this.resolve(node.$ref, where), where)
|
|
228
|
+
if (COMBINATORS.some((key) => Array.isArray(node[key]))) return this.merged(node, where)
|
|
229
|
+
switch (shapeOf(node)) {
|
|
230
|
+
case "deferred":
|
|
231
|
+
return this.deferred(node, where)
|
|
232
|
+
case "both":
|
|
233
|
+
return this.dispatch(this.object(node, where), this.array(node, where), undefined)
|
|
234
|
+
case "object":
|
|
235
|
+
return this.object(node, where)
|
|
236
|
+
case "array":
|
|
237
|
+
return this.array(node, where)
|
|
238
|
+
case "leaf":
|
|
239
|
+
return LEAF
|
|
240
|
+
default:
|
|
241
|
+
return UNKNOWN
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private extra(schema: unknown, where: string): Project | undefined {
|
|
246
|
+
if (schema === true || (isNode(schema) && Object.keys(schema).length === 0)) return UNKNOWN
|
|
247
|
+
return isNode(schema) ? this.compile(schema, `${where}/*`) : undefined
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private object(node: Node, where: string): Project {
|
|
251
|
+
const declared = new Map<string, Project>()
|
|
252
|
+
for (const [key, sub] of Object.entries(isNode(node.properties) ? node.properties : {})) {
|
|
253
|
+
if (!isNode(sub)) continue
|
|
254
|
+
if (isSensitiveFieldName(key) && typeof sub[DECLASSIFIED_KEY] !== "string") {
|
|
255
|
+
this.sensitive.push(`${where}/${key}`)
|
|
256
|
+
}
|
|
257
|
+
declared.set(key, this.compile(sub, `${where}/${key}`))
|
|
258
|
+
}
|
|
259
|
+
const patterns: Array<readonly [RegExp, Project]> = []
|
|
260
|
+
for (const [pattern, sub] of Object.entries(
|
|
261
|
+
isNode(node.patternProperties) ? node.patternProperties : {},
|
|
262
|
+
)) {
|
|
263
|
+
if (isNode(sub)) patterns.push([new RegExp(pattern, "u"), this.compile(sub, `${where}/*`)])
|
|
264
|
+
}
|
|
265
|
+
// Undeclared means dropped. Only an explicit `true` or schema keeps extra keys open.
|
|
266
|
+
const extra =
|
|
267
|
+
this.extra(node.additionalProperties, where) ??
|
|
268
|
+
(node.additionalProperties === undefined
|
|
269
|
+
? this.extra(node.unevaluatedProperties, where)
|
|
270
|
+
: undefined)
|
|
271
|
+
const open = extra !== undefined || patterns.length > 0
|
|
272
|
+
const undeclared = (key: string): Project | undefined => {
|
|
273
|
+
for (const [pattern, project] of patterns) if (pattern.test(key)) return project
|
|
274
|
+
return extra
|
|
275
|
+
}
|
|
276
|
+
// A closed object drops a deferred marker's keys unless it declares them, so only an open one, or
|
|
277
|
+
// one declaring the marker key, could pass a marker on.
|
|
278
|
+
const checksMarker = open || declared.has("__nifra_deferred")
|
|
279
|
+
const project: Project = (value, state) => {
|
|
280
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return value
|
|
281
|
+
if (checksMarker && isDeferred(value)) {
|
|
282
|
+
state.issues.push("")
|
|
283
|
+
return value
|
|
284
|
+
}
|
|
285
|
+
const proto = Object.getPrototypeOf(value)
|
|
286
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
287
|
+
if (value instanceof Date) return value.toJSON()
|
|
288
|
+
// A class instance: its own fields as a plain object, so no prototype `toJSON` or getter
|
|
289
|
+
// decides what is serialized.
|
|
290
|
+
const snapshot: Record<string, unknown> = {}
|
|
291
|
+
for (const key of Object.keys(value))
|
|
292
|
+
assign(snapshot, key, (value as Record<string, unknown>)[key])
|
|
293
|
+
return project(snapshot, state)
|
|
294
|
+
}
|
|
295
|
+
const input = value as Record<string, unknown>
|
|
296
|
+
// Copy-on-write: a value that already holds only declared keys is returned as it is.
|
|
297
|
+
let out: Record<string, unknown> | undefined
|
|
298
|
+
for (const key in input) {
|
|
299
|
+
// An inherited enumerable key (a polluted prototype) is never data.
|
|
300
|
+
if (!Object.hasOwn(input, key)) continue
|
|
301
|
+
const projector = declared.get(key) ?? (open ? undeclared(key) : undefined)
|
|
302
|
+
if (projector === undefined) {
|
|
303
|
+
out ??= copyBefore(input, key)
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
const before = input[key]
|
|
307
|
+
if (projector === LEAF_ARRAY || (projector === LEAF && !(before instanceof Date))) {
|
|
308
|
+
if (out !== undefined) assign(out, key, before)
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
const from = state.issues.length
|
|
312
|
+
const after = projector(before, state)
|
|
313
|
+
if (state.issues.length > from) prefix(state, from, key)
|
|
314
|
+
if (out !== undefined) assign(out, key, after)
|
|
315
|
+
else if (after !== before) {
|
|
316
|
+
out = copyBefore(input, key)
|
|
317
|
+
assign(out, key, after)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out ?? value
|
|
321
|
+
}
|
|
322
|
+
return project
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private array(node: Node, where: string): Project {
|
|
326
|
+
const prefixNodes = Array.isArray(node.prefixItems)
|
|
327
|
+
? node.prefixItems
|
|
328
|
+
: Array.isArray(node.items)
|
|
329
|
+
? node.items
|
|
330
|
+
: []
|
|
331
|
+
const head = prefixNodes.map((sub, i) =>
|
|
332
|
+
isNode(sub) ? this.compile(sub, `${where}/${i}`) : UNKNOWN,
|
|
333
|
+
)
|
|
334
|
+
const restSchema = Array.isArray(node.items) ? node.additionalItems : node.items
|
|
335
|
+
const rest = this.extra(restSchema, where) ?? UNKNOWN
|
|
336
|
+
// An array of scalars is the validator's alone: no key to drop, and walking it here would cost
|
|
337
|
+
// every `string[]` a loop. A `Date` inside one reaches the validator as it is.
|
|
338
|
+
if (rest === LEAF && head.every((projector) => projector === LEAF)) return LEAF_ARRAY
|
|
339
|
+
return (value, state) => {
|
|
340
|
+
if (!Array.isArray(value)) return value
|
|
341
|
+
let out: unknown[] | undefined
|
|
342
|
+
for (let i = 0; i < value.length; i++) {
|
|
343
|
+
const before = value[i]
|
|
344
|
+
const projector = head[i] ?? rest
|
|
345
|
+
if (projector === LEAF && !(before instanceof Date)) {
|
|
346
|
+
if (out !== undefined) out[i] = before
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
const from = state.issues.length
|
|
350
|
+
const after = projector(before, state)
|
|
351
|
+
if (state.issues.length > from) prefix(state, from, i)
|
|
352
|
+
if (out !== undefined) out[i] = after
|
|
353
|
+
else if (after !== before) {
|
|
354
|
+
out = value.slice(0, i)
|
|
355
|
+
out[i] = after
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return out ?? value
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The branches of a union or intersection, flattened, `$ref`s resolved. */
|
|
363
|
+
private branches(node: Node, where: string, into: Node[], seen: Set<Node>): void {
|
|
364
|
+
if (seen.has(node)) return
|
|
365
|
+
seen.add(node)
|
|
366
|
+
if (typeof node.$ref === "string") {
|
|
367
|
+
this.branches(this.resolve(node.$ref, where), where, into, seen)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
let combined = false
|
|
371
|
+
const own: Record<string, unknown> = {}
|
|
372
|
+
for (const [key, value] of Object.entries(node)) {
|
|
373
|
+
if ((COMBINATORS as readonly string[]).includes(key) && Array.isArray(value)) {
|
|
374
|
+
combined = true
|
|
375
|
+
for (const branch of value) if (isNode(branch)) this.branches(branch, where, into, seen)
|
|
376
|
+
} else own[key] = value
|
|
377
|
+
}
|
|
378
|
+
if (!combined) into.push(node)
|
|
379
|
+
else if (
|
|
380
|
+
own.type !== undefined ||
|
|
381
|
+
own.properties !== undefined ||
|
|
382
|
+
own.patternProperties !== undefined ||
|
|
383
|
+
own.additionalProperties !== undefined ||
|
|
384
|
+
own.items !== undefined ||
|
|
385
|
+
own.prefixItems !== undefined
|
|
386
|
+
) {
|
|
387
|
+
into.push(own)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* A union or intersection keeps a key when any branch declares it. That can keep a key a
|
|
393
|
+
* different branch than the one the value matches declares - still a declared name, and a strict
|
|
394
|
+
* branch then fails validation - but never an undeclared one.
|
|
395
|
+
*/
|
|
396
|
+
private merged(node: Node, where: string): Project {
|
|
397
|
+
const branches: Node[] = []
|
|
398
|
+
this.branches(node, where, branches, new Set())
|
|
399
|
+
const kinds = branches.map(shapeOf)
|
|
400
|
+
if (kinds.includes("unknown")) return UNKNOWN
|
|
401
|
+
const deferreds = branches.filter((_, i) => kinds[i] === "deferred")
|
|
402
|
+
if (deferreds.length > 1) {
|
|
403
|
+
throw new OutputGuardError(
|
|
404
|
+
`the output schema at ${where || "/"} unions more than one deferred value`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
const objects = branches.filter((_, i) => kinds[i] === "object" || kinds[i] === "both")
|
|
408
|
+
const arrays = branches.filter((_, i) => kinds[i] === "array" || kinds[i] === "both")
|
|
409
|
+
const deferred = deferreds[0]
|
|
410
|
+
return this.dispatch(
|
|
411
|
+
objects.length === 0 ? undefined : this.object(mergeObjects(objects), where),
|
|
412
|
+
arrays.length === 0 ? undefined : this.array(mergeArrays(arrays), where),
|
|
413
|
+
deferred === undefined ? undefined : this.compile(deferred, where),
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private dispatch(
|
|
418
|
+
object: Project | undefined,
|
|
419
|
+
array: Project | undefined,
|
|
420
|
+
deferred: Project | undefined,
|
|
421
|
+
): Project {
|
|
422
|
+
return (value, state) => {
|
|
423
|
+
if (typeof value !== "object" || value === null) return value
|
|
424
|
+
if (value instanceof Date) return value.toJSON()
|
|
425
|
+
if (Array.isArray(value)) return array === undefined ? value : array(value, state)
|
|
426
|
+
if (isDeferred(value)) {
|
|
427
|
+
if (deferred !== undefined) return deferred(value, state)
|
|
428
|
+
state.issues.push("")
|
|
429
|
+
return value
|
|
430
|
+
}
|
|
431
|
+
return object === undefined ? value : object(value, state)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private deferred(node: Node, where: string): Project {
|
|
436
|
+
const validate = (node as { readonly [DEFERRED_VALIDATE]?: unknown })[DEFERRED_VALIDATE]
|
|
437
|
+
if (!isNode(node.inner) || typeof validate !== "function") {
|
|
438
|
+
throw new OutputGuardError(
|
|
439
|
+
`the deferred value at ${where || "/"} has no inner schema; declare it with t.deferred(schema)`,
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
const inner: CompiledGuard = {
|
|
443
|
+
project: this.compile(node.inner, where),
|
|
444
|
+
validate: validate as CompiledGuard["validate"],
|
|
445
|
+
}
|
|
446
|
+
return (value, state) => {
|
|
447
|
+
if (!isDeferred(value)) return value
|
|
448
|
+
const label = `${state.label}, deferred value ${where || "/"},`
|
|
449
|
+
const promise = value.promise.then((resolved) => runGuard(inner, resolved, label))
|
|
450
|
+
// Claimed like `defer()` claims its own: the stream consumer attaches later.
|
|
451
|
+
void promise.catch(() => {})
|
|
452
|
+
return { __nifra_deferred: true, id: value.id, promise }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The keys of a plain object before `stop`, every one of them kept as it is. */
|
|
458
|
+
function copyBefore(input: Record<string, unknown>, stop: string): Record<string, unknown> {
|
|
459
|
+
const out: Record<string, unknown> = {}
|
|
460
|
+
for (const key in input) {
|
|
461
|
+
if (key === stop) break
|
|
462
|
+
if (Object.hasOwn(input, key)) assign(out, key, input[key])
|
|
463
|
+
}
|
|
464
|
+
return out
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function mergeObjects(branches: readonly Node[]): Node {
|
|
468
|
+
const properties = new Map<string, Node[]>()
|
|
469
|
+
const patterns = new Map<string, Node[]>()
|
|
470
|
+
const extras: Node[] = []
|
|
471
|
+
let open = false
|
|
472
|
+
for (const branch of branches) {
|
|
473
|
+
if (isNode(branch.properties)) {
|
|
474
|
+
for (const [key, sub] of Object.entries(branch.properties)) {
|
|
475
|
+
if (isNode(sub)) properties.set(key, [...(properties.get(key) ?? []), sub])
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (isNode(branch.patternProperties)) {
|
|
479
|
+
for (const [pattern, sub] of Object.entries(branch.patternProperties)) {
|
|
480
|
+
if (isNode(sub)) patterns.set(pattern, [...(patterns.get(pattern) ?? []), sub])
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const extra = branch.additionalProperties ?? branch.unevaluatedProperties
|
|
484
|
+
if (extra === true) open = true
|
|
485
|
+
else if (isNode(extra)) extras.push(extra)
|
|
486
|
+
}
|
|
487
|
+
const one = (subs: readonly Node[]): Node => {
|
|
488
|
+
if (subs.length === 1) return subs[0] as Node
|
|
489
|
+
// Declassified only when every branch declassifies the field.
|
|
490
|
+
const reason = subs.every((sub) => typeof sub[DECLASSIFIED_KEY] === "string")
|
|
491
|
+
? subs[0]?.[DECLASSIFIED_KEY]
|
|
492
|
+
: undefined
|
|
493
|
+
return reason === undefined ? { anyOf: subs } : { anyOf: subs, [DECLASSIFIED_KEY]: reason }
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
type: "object",
|
|
497
|
+
properties: Object.fromEntries([...properties].map(([key, subs]) => [key, one(subs)])),
|
|
498
|
+
patternProperties: Object.fromEntries([...patterns].map(([key, subs]) => [key, one(subs)])),
|
|
499
|
+
...(open
|
|
500
|
+
? { additionalProperties: true }
|
|
501
|
+
: extras.length > 0
|
|
502
|
+
? { additionalProperties: one(extras) }
|
|
503
|
+
: {}),
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function mergeArrays(branches: readonly Node[]): Node {
|
|
508
|
+
const items: Node[] = []
|
|
509
|
+
let open = false
|
|
510
|
+
for (const branch of branches) {
|
|
511
|
+
const item = Array.isArray(branch.items) ? undefined : branch.items
|
|
512
|
+
if (item === undefined || item === true) open = true
|
|
513
|
+
else if (isNode(item)) items.push(item)
|
|
514
|
+
// A tuple branch: its positions merge into the shared item schema.
|
|
515
|
+
for (const sub of [
|
|
516
|
+
...(Array.isArray(branch.prefixItems) ? branch.prefixItems : []),
|
|
517
|
+
...(Array.isArray(branch.items) ? branch.items : []),
|
|
518
|
+
]) {
|
|
519
|
+
if (isNode(sub)) items.push(sub)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
type: "array",
|
|
524
|
+
...(open ? {} : { items: items.length === 1 ? items[0] : { anyOf: items } }),
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The JSON Schema a schema exposes to project by, if any. */
|
|
529
|
+
function jsonSchemaOf(schema: StandardSchemaV1): Node | undefined {
|
|
530
|
+
const own = (schema as { readonly jsonSchema?: unknown }).jsonSchema
|
|
531
|
+
if (isNode(own)) return own
|
|
532
|
+
const standard = schema["~standard"] as {
|
|
533
|
+
readonly jsonSchema?: { readonly output?: (options: { target: string }) => unknown }
|
|
534
|
+
}
|
|
535
|
+
if (typeof standard.jsonSchema?.output !== "function") return undefined
|
|
536
|
+
try {
|
|
537
|
+
const out = standard.jsonSchema.output({ target: "draft-2020-12" })
|
|
538
|
+
return isNode(out) ? out : undefined
|
|
539
|
+
} catch {
|
|
540
|
+
return undefined
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const compiled = new WeakMap<object, CompiledGuard>()
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Prepare `schema` as the output contract of `owner`. Throws when the schema declares a sensitive
|
|
548
|
+
* field it does not declassify, or references a definition it lacks - at route load, not per request.
|
|
549
|
+
*/
|
|
550
|
+
export function outputGuard(schema: unknown, owner: string, exportName: string): CompiledGuard {
|
|
551
|
+
if (
|
|
552
|
+
typeof schema !== "object" ||
|
|
553
|
+
schema === null ||
|
|
554
|
+
typeof (schema as StandardSchemaV1)["~standard"]?.validate !== "function"
|
|
555
|
+
) {
|
|
556
|
+
throw new OutputGuardError(`${owner} exports ${exportName} that is not a Standard Schema`)
|
|
557
|
+
}
|
|
558
|
+
const cached = compiled.get(schema)
|
|
559
|
+
if (cached !== undefined) return cached
|
|
560
|
+
const standard = schema as StandardSchemaV1
|
|
561
|
+
const json = jsonSchemaOf(standard)
|
|
562
|
+
let project: Project | undefined
|
|
563
|
+
if (json !== undefined) {
|
|
564
|
+
const compiler = new Compiler(json)
|
|
565
|
+
project = compiler.compile(json, "")
|
|
566
|
+
if (compiler.sensitive.length > 0) {
|
|
567
|
+
throw new OutputGuardError(
|
|
568
|
+
`${owner} declares sensitive field(s) ${compiler.sensitive.join(", ")} in ${exportName}. Remove them, or mark a field that must reach the browser with t.declassified("why it may", schema)`,
|
|
569
|
+
compiler.sensitive,
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const guard: CompiledGuard = { project, validate: standard["~standard"].validate }
|
|
574
|
+
compiled.set(schema, guard)
|
|
575
|
+
return guard
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const pathOf = (issue: { readonly path?: ReadonlyArray<unknown> | undefined }): string =>
|
|
579
|
+
(issue.path ?? [])
|
|
580
|
+
.map(
|
|
581
|
+
(segment) =>
|
|
582
|
+
`/${String(typeof segment === "object" && segment !== null ? (segment as { key: unknown }).key : segment)}`,
|
|
583
|
+
)
|
|
584
|
+
.join("") || "/"
|
|
585
|
+
|
|
586
|
+
function settle(guard: CompiledGuard, result: StandardResult<unknown>, label: string): unknown {
|
|
587
|
+
if (result.issues !== undefined) {
|
|
588
|
+
const paths = [...new Set(result.issues.map(pathOf))]
|
|
589
|
+
throw new OutputGuardError(
|
|
590
|
+
`${label} does not match its output schema at: ${paths.join(", ")}`,
|
|
591
|
+
paths,
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
if (guard.project === undefined) {
|
|
595
|
+
const state: RunState = { label, issues: [] }
|
|
596
|
+
UNKNOWN(result.value, state)
|
|
597
|
+
if (state.issues.length > 0) throw undeclaredDeferred(label, state.issues)
|
|
598
|
+
}
|
|
599
|
+
return result.value
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const undeclaredDeferred = (label: string, paths: readonly string[]): OutputGuardError =>
|
|
603
|
+
new OutputGuardError(
|
|
604
|
+
`${label} returns a deferred value its output schema does not declare at: ${paths.map((p) => p || "/").join(", ")}. Declare it with t.deferred(schema)`,
|
|
605
|
+
paths,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
/** Project `value` through `guard`, then validate it. Throws (or rejects) an {@link OutputGuardError}. */
|
|
609
|
+
export function runGuard(guard: CompiledGuard, value: unknown, label: string): unknown {
|
|
610
|
+
let projected = value
|
|
611
|
+
if (guard.project !== undefined) {
|
|
612
|
+
const state: RunState = { label, issues: [] }
|
|
613
|
+
projected = guard.project(value, state)
|
|
614
|
+
if (state.issues.length > 0) throw undeclaredDeferred(label, state.issues)
|
|
615
|
+
}
|
|
616
|
+
const result = guard.validate(projected)
|
|
617
|
+
return result instanceof Promise
|
|
618
|
+
? result.then((settled) => settle(guard, settled, label))
|
|
619
|
+
: settle(guard, result, label)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** What a browser-bound channel sends, and how it is named in errors. */
|
|
623
|
+
export interface ChannelContract {
|
|
624
|
+
/** `the loader of "blog/[slug].tsx"` */
|
|
625
|
+
readonly label: string
|
|
626
|
+
readonly guard: CompiledGuard | undefined
|
|
627
|
+
/** Where the missing schema goes, for the error when data arrives without one. */
|
|
628
|
+
readonly missing: string
|
|
629
|
+
/** An action may wrap its data in `revalidate(paths, data)`. */
|
|
630
|
+
readonly revalidate?: boolean
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const isResponseResult = (
|
|
634
|
+
value: object,
|
|
635
|
+
): value is { readonly plain?: { readonly status: number }; toResponse(): Response } =>
|
|
636
|
+
(value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
|
|
637
|
+
typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Guard one value a channel produced. Nothing, a redirect and an error status pass as they are; a 2xx
|
|
641
|
+
* `Response` is refused because its body would reach the browser without passing the schema.
|
|
642
|
+
*/
|
|
643
|
+
export function guardValue(contract: ChannelContract, value: unknown): unknown {
|
|
644
|
+
if (value === undefined || value === null) return value
|
|
645
|
+
if (typeof value === "object") {
|
|
646
|
+
let status: number | undefined
|
|
647
|
+
if (value instanceof Response) status = STATUS_SIGNAL in value ? undefined : value.status
|
|
648
|
+
else if (isResponseResult(value)) status = value.plain?.status ?? value.toResponse().status
|
|
649
|
+
else if (contract.revalidate === true && "__nifraRevalidate" in value) {
|
|
650
|
+
const wrapper = value as { readonly data?: unknown }
|
|
651
|
+
const data = guardValue(contract, wrapper.data)
|
|
652
|
+
return data instanceof Promise
|
|
653
|
+
? data.then((settled) => ({ ...wrapper, data: settled }))
|
|
654
|
+
: { ...wrapper, data }
|
|
655
|
+
}
|
|
656
|
+
if (status !== undefined && status >= 200 && status < 300) {
|
|
657
|
+
throw new OutputGuardError(
|
|
658
|
+
`${contract.label} returned a ${status} Response, whose body would reach the browser without passing its output schema. Return the data itself, or serve the endpoint from backend/app.ts`,
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
if (value instanceof Response || isResponseResult(value)) return value
|
|
662
|
+
}
|
|
663
|
+
if (contract.guard === undefined) {
|
|
664
|
+
throw new OutputGuardError(
|
|
665
|
+
`${contract.label} returned data, but declares no output schema. Every value sent to the browser needs one: ${contract.missing}`,
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
return runGuard(contract.guard, value, contract.label)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Wrap a channel function so everything it returns passes {@link guardValue}. A scalar result stays
|
|
673
|
+
* synchronous; an object result settles through a promise, as `await` would. A thrown `Response` is
|
|
674
|
+
* the same answer as a returned one, so a thrown 2xx is refused as well.
|
|
675
|
+
*/
|
|
676
|
+
export function guardChannel<Args extends unknown[]>(
|
|
677
|
+
fn: (...args: Args) => unknown,
|
|
678
|
+
contract: ChannelContract,
|
|
679
|
+
): (...args: Args) => unknown {
|
|
680
|
+
const check = (value: unknown): unknown => guardValue(contract, value)
|
|
681
|
+
const checkThrown = (error: unknown): never => {
|
|
682
|
+
if (
|
|
683
|
+
typeof error === "object" &&
|
|
684
|
+
error !== null &&
|
|
685
|
+
(error instanceof Response || isResponseResult(error))
|
|
686
|
+
)
|
|
687
|
+
guardValue(contract, error)
|
|
688
|
+
throw error
|
|
689
|
+
}
|
|
690
|
+
return (...args) => {
|
|
691
|
+
let value: unknown
|
|
692
|
+
try {
|
|
693
|
+
value = fn(...args)
|
|
694
|
+
} catch (error) {
|
|
695
|
+
return checkThrown(error)
|
|
696
|
+
}
|
|
697
|
+
return value !== null && (typeof value === "object" || typeof value === "function")
|
|
698
|
+
? Promise.resolve(value).then(check, checkThrown)
|
|
699
|
+
: check(value)
|
|
700
|
+
}
|
|
701
|
+
}
|