@opendatalabs/personal-server-ts-core 1.33.8 → 1.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/additions/budget.d.ts +28 -0
- package/dist/additions/budget.d.ts.map +1 -0
- package/dist/additions/budget.js +119 -0
- package/dist/additions/budget.js.map +1 -0
- package/dist/additions/durable.d.ts +64 -0
- package/dist/additions/durable.d.ts.map +1 -0
- package/dist/additions/durable.js +591 -0
- package/dist/additions/durable.js.map +1 -0
- package/dist/additions/first-added.d.ts +32 -48
- package/dist/additions/first-added.d.ts.map +1 -1
- package/dist/additions/first-added.js +68 -123
- package/dist/additions/first-added.js.map +1 -1
- package/dist/additions/index.d.ts +3 -0
- package/dist/additions/index.d.ts.map +1 -1
- package/dist/additions/index.js +3 -0
- package/dist/additions/index.js.map +1 -1
- package/dist/additions/ledger-store.js +1 -1
- package/dist/additions/record-rules.d.ts.map +1 -1
- package/dist/additions/record-rules.js +7 -0
- package/dist/additions/record-rules.js.map +1 -1
- package/dist/additions/summary.d.ts +88 -1
- package/dist/additions/summary.d.ts.map +1 -1
- package/dist/additions/summary.js +173 -8
- package/dist/additions/summary.js.map +1 -1
- package/dist/additions/version-inventory.d.ts +80 -0
- package/dist/additions/version-inventory.d.ts.map +1 -0
- package/dist/additions/version-inventory.js +232 -0
- package/dist/additions/version-inventory.js.map +1 -0
- package/dist/api/index.d.ts +70 -2
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +506 -79
- package/dist/api/index.js.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/request.d.ts +6 -0
- package/dist/auth/request.d.ts.map +1 -1
- package/dist/auth/request.js +66 -4
- package/dist/auth/request.js.map +1 -1
- package/dist/contracts/data.d.ts +54 -8
- package/dist/contracts/data.d.ts.map +1 -1
- package/dist/contracts/data.js +178 -35
- package/dist/contracts/data.js.map +1 -1
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/derivatives/registration.d.ts +0 -5
- package/dist/derivatives/registration.d.ts.map +1 -1
- package/dist/derivatives/registration.js +2 -1
- package/dist/derivatives/registration.js.map +1 -1
- package/dist/errors/pdpp-catalog.d.ts +36 -0
- package/dist/errors/pdpp-catalog.d.ts.map +1 -0
- package/dist/errors/pdpp-catalog.js +86 -0
- package/dist/errors/pdpp-catalog.js.map +1 -0
- package/dist/grants/index.d.ts +2 -0
- package/dist/grants/index.d.ts.map +1 -1
- package/dist/grants/index.js +4 -0
- package/dist/grants/index.js.map +1 -1
- package/dist/grants/pdpp-binding-store.d.ts +89 -0
- package/dist/grants/pdpp-binding-store.d.ts.map +1 -0
- package/dist/grants/pdpp-binding-store.js +112 -0
- package/dist/grants/pdpp-binding-store.js.map +1 -0
- package/dist/grants/pdpp-binding.d.ts +169 -0
- package/dist/grants/pdpp-binding.d.ts.map +1 -0
- package/dist/grants/pdpp-binding.js +236 -0
- package/dist/grants/pdpp-binding.js.map +1 -0
- package/dist/legacy-projection/bindings.d.ts +28 -1
- package/dist/legacy-projection/bindings.d.ts.map +1 -1
- package/dist/legacy-projection/bindings.js +622 -189
- package/dist/legacy-projection/bindings.js.map +1 -1
- package/dist/legacy-projection/claude-source-record.d.ts +22 -0
- package/dist/legacy-projection/claude-source-record.d.ts.map +1 -0
- package/dist/legacy-projection/claude-source-record.js +360 -0
- package/dist/legacy-projection/claude-source-record.js.map +1 -0
- package/dist/legacy-projection/declared-text.d.ts +41 -0
- package/dist/legacy-projection/declared-text.d.ts.map +1 -0
- package/dist/legacy-projection/declared-text.js +199 -0
- package/dist/legacy-projection/declared-text.js.map +1 -0
- package/dist/legacy-projection/github-browser-bindings.d.ts.map +1 -1
- package/dist/legacy-projection/github-browser-bindings.js +89 -5
- package/dist/legacy-projection/github-browser-bindings.js.map +1 -1
- package/dist/legacy-projection/github-legacy-fields.d.ts +6 -0
- package/dist/legacy-projection/github-legacy-fields.d.ts.map +1 -0
- package/dist/legacy-projection/github-legacy-fields.js +202 -0
- package/dist/legacy-projection/github-legacy-fields.js.map +1 -0
- package/dist/legacy-projection/index.d.ts +5 -1
- package/dist/legacy-projection/index.d.ts.map +1 -1
- package/dist/legacy-projection/index.js +55 -1
- package/dist/legacy-projection/index.js.map +1 -1
- package/dist/legacy-projection/oura-browser-bindings.d.ts.map +1 -1
- package/dist/legacy-projection/oura-browser-bindings.js +43 -41
- package/dist/legacy-projection/oura-browser-bindings.js.map +1 -1
- package/dist/legacy-projection/types.d.ts +38 -2
- package/dist/legacy-projection/types.d.ts.map +1 -1
- package/dist/mcp/read-client.d.ts.map +1 -1
- package/dist/mcp/read-client.js +10 -1
- package/dist/mcp/read-client.js.map +1 -1
- package/dist/mcp/resources.d.ts.map +1 -1
- package/dist/mcp/resources.js +3 -0
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +11 -2
- package/dist/mcp/tools.js.map +1 -1
- package/dist/pdpp/approval.d.ts +271 -0
- package/dist/pdpp/approval.d.ts.map +1 -0
- package/dist/pdpp/approval.js +467 -0
- package/dist/pdpp/approval.js.map +1 -0
- package/dist/pdpp/client-identity.d.ts +88 -0
- package/dist/pdpp/client-identity.d.ts.map +1 -0
- package/dist/pdpp/client-identity.js +196 -0
- package/dist/pdpp/client-identity.js.map +1 -0
- package/dist/pdpp/client-metadata.d.ts +95 -0
- package/dist/pdpp/client-metadata.d.ts.map +1 -0
- package/dist/pdpp/client-metadata.js +96 -0
- package/dist/pdpp/client-metadata.js.map +1 -0
- package/dist/pdpp/commitments.d.ts +75 -0
- package/dist/pdpp/commitments.d.ts.map +1 -0
- package/dist/pdpp/commitments.js +113 -0
- package/dist/pdpp/commitments.js.map +1 -0
- package/dist/pdpp/declaration.d.ts +118 -0
- package/dist/pdpp/declaration.d.ts.map +1 -0
- package/dist/pdpp/declaration.js +836 -0
- package/dist/pdpp/declaration.js.map +1 -0
- package/dist/pdpp/index.d.ts +37 -0
- package/dist/pdpp/index.d.ts.map +1 -0
- package/dist/pdpp/index.js +23 -0
- package/dist/pdpp/index.js.map +1 -0
- package/dist/pdpp/issuance.d.ts +126 -0
- package/dist/pdpp/issuance.d.ts.map +1 -0
- package/dist/pdpp/issuance.js +180 -0
- package/dist/pdpp/issuance.js.map +1 -0
- package/dist/pdpp/pkce.d.ts +53 -0
- package/dist/pdpp/pkce.d.ts.map +1 -0
- package/dist/pdpp/pkce.js +109 -0
- package/dist/pdpp/pkce.js.map +1 -0
- package/dist/pdpp/redirect.d.ts +60 -0
- package/dist/pdpp/redirect.d.ts.map +1 -0
- package/dist/pdpp/redirect.js +125 -0
- package/dist/pdpp/redirect.js.map +1 -0
- package/dist/pdpp/resolve.d.ts +109 -0
- package/dist/pdpp/resolve.d.ts.map +1 -0
- package/dist/pdpp/resolve.js +393 -0
- package/dist/pdpp/resolve.js.map +1 -0
- package/dist/pdpp/review.d.ts +280 -0
- package/dist/pdpp/review.d.ts.map +1 -0
- package/dist/pdpp/review.js +205 -0
- package/dist/pdpp/review.js.map +1 -0
- package/dist/pdpp/selection.d.ts +50 -0
- package/dist/pdpp/selection.d.ts.map +1 -0
- package/dist/pdpp/selection.js +442 -0
- package/dist/pdpp/selection.js.map +1 -0
- package/dist/pdpp/store.d.ts +228 -0
- package/dist/pdpp/store.d.ts.map +1 -0
- package/dist/pdpp/store.js +542 -0
- package/dist/pdpp/store.js.map +1 -0
- package/dist/pdpp/tokens.d.ts +181 -0
- package/dist/pdpp/tokens.d.ts.map +1 -0
- package/dist/pdpp/tokens.js +419 -0
- package/dist/pdpp/tokens.js.map +1 -0
- package/dist/pdpp/types.d.ts +441 -0
- package/dist/pdpp/types.d.ts.map +1 -0
- package/dist/pdpp/types.js +61 -0
- package/dist/pdpp/types.js.map +1 -0
- package/dist/pdpp-version.d.ts +28 -0
- package/dist/pdpp-version.d.ts.map +1 -0
- package/dist/pdpp-version.js +28 -0
- package/dist/pdpp-version.js.map +1 -0
- package/dist/policy/data-read.d.ts +24 -0
- package/dist/policy/data-read.d.ts.map +1 -1
- package/dist/policy/data-read.js +60 -3
- package/dist/policy/data-read.js.map +1 -1
- package/dist/policy/data-write.d.ts.map +1 -1
- package/dist/policy/data-write.js +3 -0
- package/dist/policy/data-write.js.map +1 -1
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.d.ts.map +1 -1
- package/dist/policy/index.js +1 -0
- package/dist/policy/index.js.map +1 -1
- package/dist/policy/reserved-scopes.d.ts +3 -0
- package/dist/policy/reserved-scopes.d.ts.map +1 -0
- package/dist/policy/reserved-scopes.js +5 -0
- package/dist/policy/reserved-scopes.js.map +1 -0
- package/dist/ports/index.d.ts +63 -0
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/pdpp-auth.d.ts +129 -0
- package/dist/ports/pdpp-auth.d.ts.map +1 -0
- package/dist/ports/pdpp-auth.js +60 -0
- package/dist/ports/pdpp-auth.js.map +1 -0
- package/dist/ports/pdpp-auth.test-utils.d.ts +9 -0
- package/dist/ports/pdpp-auth.test-utils.d.ts.map +1 -0
- package/dist/ports/pdpp-auth.test-utils.js +18 -0
- package/dist/ports/pdpp-auth.test-utils.js.map +1 -0
- package/dist/schemas/server-config.d.ts +44 -0
- package/dist/schemas/server-config.d.ts.map +1 -1
- package/dist/schemas/server-config.js +91 -0
- package/dist/schemas/server-config.js.map +1 -1
- package/dist/signing/request-signer.d.ts +2 -0
- package/dist/signing/request-signer.d.ts.map +1 -1
- package/dist/signing/request-signer.js +1 -0
- package/dist/signing/request-signer.js.map +1 -1
- package/dist/storage/adapters/interface.d.ts +3 -0
- package/dist/storage/adapters/interface.d.ts.map +1 -1
- package/dist/storage/adapters/vana-audience.d.ts +12 -0
- package/dist/storage/adapters/vana-audience.d.ts.map +1 -0
- package/dist/storage/adapters/vana-audience.js +72 -0
- package/dist/storage/adapters/vana-audience.js.map +1 -0
- package/dist/storage/adapters/vana.d.ts +2 -0
- package/dist/storage/adapters/vana.d.ts.map +1 -1
- package/dist/storage/adapters/vana.js +53 -20
- package/dist/storage/adapters/vana.js.map +1 -1
- package/dist/storage/canonical-legacy-projection.d.ts +262 -0
- package/dist/storage/canonical-legacy-projection.d.ts.map +1 -0
- package/dist/storage/canonical-legacy-projection.js +761 -0
- package/dist/storage/canonical-legacy-projection.js.map +1 -0
- package/dist/storage/index/manager.d.ts +29 -2
- package/dist/storage/index/manager.d.ts.map +1 -1
- package/dist/storage/index/schema.d.ts +1 -1
- package/dist/storage/index/schema.js +1 -1
- package/dist/storage/index/types.d.ts +7 -1
- package/dist/storage/index/types.d.ts.map +1 -1
- package/dist/storage/legacy-projection.d.ts +13 -10
- package/dist/storage/legacy-projection.d.ts.map +1 -1
- package/dist/storage/legacy-projection.js +269 -74
- package/dist/storage/legacy-projection.js.map +1 -1
- package/dist/storage/legacy-scope-coverage-exceptions.d.ts +8 -0
- package/dist/storage/legacy-scope-coverage-exceptions.d.ts.map +1 -0
- package/dist/storage/legacy-scope-coverage-exceptions.js +10 -0
- package/dist/storage/legacy-scope-coverage-exceptions.js.map +1 -0
- package/dist/storage/legacy-scope-coverage.d.ts +53 -0
- package/dist/storage/legacy-scope-coverage.d.ts.map +1 -0
- package/dist/storage/legacy-scope-coverage.js +403 -0
- package/dist/storage/legacy-scope-coverage.js.map +1 -0
- package/dist/storage/legacy-scope-entity-keys.d.ts +4 -0
- package/dist/storage/legacy-scope-entity-keys.d.ts.map +1 -0
- package/dist/storage/legacy-scope-entity-keys.js +92 -0
- package/dist/storage/legacy-scope-entity-keys.js.map +1 -0
- package/dist/storage/legacy-scope-view.d.ts +154 -0
- package/dist/storage/legacy-scope-view.d.ts.map +1 -0
- package/dist/storage/legacy-scope-view.js +735 -0
- package/dist/storage/legacy-scope-view.js.map +1 -0
- package/dist/storage/pdpp-records/completion.d.ts +91 -0
- package/dist/storage/pdpp-records/completion.d.ts.map +1 -0
- package/dist/storage/pdpp-records/completion.js +170 -0
- package/dist/storage/pdpp-records/completion.js.map +1 -0
- package/dist/storage/pdpp-records/cursor.d.ts +41 -0
- package/dist/storage/pdpp-records/cursor.d.ts.map +1 -0
- package/dist/storage/pdpp-records/cursor.js +49 -0
- package/dist/storage/pdpp-records/cursor.js.map +1 -0
- package/dist/storage/pdpp-records/declaration-witness.d.ts +58 -0
- package/dist/storage/pdpp-records/declaration-witness.d.ts.map +1 -0
- package/dist/storage/pdpp-records/declaration-witness.js +230 -0
- package/dist/storage/pdpp-records/declaration-witness.js.map +1 -0
- package/dist/storage/pdpp-records/enforcement.d.ts +52 -0
- package/dist/storage/pdpp-records/enforcement.d.ts.map +1 -0
- package/dist/storage/pdpp-records/enforcement.js +116 -0
- package/dist/storage/pdpp-records/enforcement.js.map +1 -0
- package/dist/storage/pdpp-records/index.d.ts +5 -0
- package/dist/storage/pdpp-records/index.d.ts.map +1 -0
- package/dist/storage/pdpp-records/index.js +5 -0
- package/dist/storage/pdpp-records/index.js.map +1 -0
- package/dist/storage/pdpp-records/ingest-plan.d.ts +39 -0
- package/dist/storage/pdpp-records/ingest-plan.d.ts.map +1 -0
- package/dist/storage/pdpp-records/ingest-plan.js +103 -0
- package/dist/storage/pdpp-records/ingest-plan.js.map +1 -0
- package/dist/storage/pdpp-records/interface.d.ts +104 -0
- package/dist/storage/pdpp-records/interface.d.ts.map +1 -0
- package/dist/storage/pdpp-records/interface.js +2 -0
- package/dist/storage/pdpp-records/interface.js.map +1 -0
- package/dist/storage/pdpp-records/memory-store.d.ts +9 -0
- package/dist/storage/pdpp-records/memory-store.d.ts.map +1 -0
- package/dist/storage/pdpp-records/memory-store.js +466 -0
- package/dist/storage/pdpp-records/memory-store.js.map +1 -0
- package/dist/storage/pdpp-records/portable.d.ts +9 -0
- package/dist/storage/pdpp-records/portable.d.ts.map +1 -0
- package/dist/storage/pdpp-records/portable.js +8 -0
- package/dist/storage/pdpp-records/portable.js.map +1 -0
- package/dist/storage/pdpp-records/read-port.d.ts +44 -0
- package/dist/storage/pdpp-records/read-port.d.ts.map +1 -0
- package/dist/storage/pdpp-records/read-port.js +22 -0
- package/dist/storage/pdpp-records/read-port.js.map +1 -0
- package/dist/storage/pdpp-records/record-key.d.ts +52 -0
- package/dist/storage/pdpp-records/record-key.d.ts.map +1 -0
- package/dist/storage/pdpp-records/record-key.js +97 -0
- package/dist/storage/pdpp-records/record-key.js.map +1 -0
- package/dist/storage/pdpp-records/schema.d.ts +41 -0
- package/dist/storage/pdpp-records/schema.d.ts.map +1 -0
- package/dist/storage/pdpp-records/schema.js +153 -0
- package/dist/storage/pdpp-records/schema.js.map +1 -0
- package/dist/storage/pdpp-records/stream-declaration.d.ts +88 -0
- package/dist/storage/pdpp-records/stream-declaration.d.ts.map +1 -0
- package/dist/storage/pdpp-records/stream-declaration.js +57 -0
- package/dist/storage/pdpp-records/stream-declaration.js.map +1 -0
- package/dist/storage/pdpp-records/types.d.ts +138 -0
- package/dist/storage/pdpp-records/types.d.ts.map +1 -0
- package/dist/storage/pdpp-records/types.js +38 -0
- package/dist/storage/pdpp-records/types.js.map +1 -0
- package/dist/sync/data-point-feed.d.ts +3 -0
- package/dist/sync/data-point-feed.d.ts.map +1 -1
- package/dist/sync/data-point-feed.js +17 -1
- package/dist/sync/data-point-feed.js.map +1 -1
- package/dist/sync/delete-data-port.d.ts +2 -0
- package/dist/sync/delete-data-port.d.ts.map +1 -1
- package/dist/sync/delete-data-port.js +1 -1
- package/dist/sync/delete-data-port.js.map +1 -1
- package/dist/sync/engine/sync-manager.d.ts.map +1 -1
- package/dist/sync/engine/sync-manager.js +5 -2
- package/dist/sync/engine/sync-manager.js.map +1 -1
- package/dist/sync/index.d.ts +3 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +4 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/pdpp-import.d.ts +192 -0
- package/dist/sync/pdpp-import.d.ts.map +1 -0
- package/dist/sync/pdpp-import.js +441 -0
- package/dist/sync/pdpp-import.js.map +1 -0
- package/dist/sync/scope-deletions.d.ts +10 -0
- package/dist/sync/scope-deletions.d.ts.map +1 -1
- package/dist/sync/scope-deletions.js +40 -7
- package/dist/sync/scope-deletions.js.map +1 -1
- package/dist/sync/workers/download.d.ts +14 -0
- package/dist/sync/workers/download.d.ts.map +1 -1
- package/dist/sync/workers/download.js +149 -13
- package/dist/sync/workers/download.js.map +1 -1
- package/dist/sync/workers/upload.d.ts +2 -0
- package/dist/sync/workers/upload.d.ts.map +1 -1
- package/dist/sync/workers/upload.js +7 -0
- package/dist/sync/workers/upload.js.map +1 -1
- package/dist/test-utils/memory-storage.d.ts +1 -1
- package/dist/test-utils/memory-storage.d.ts.map +1 -1
- package/dist/test-utils/memory-storage.js +11 -5
- package/dist/test-utils/memory-storage.js.map +1 -1
- package/package.json +54 -4
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded SourceDeclaration retrieval (§5 declaration acceptance, §10
|
|
3
|
+
* declaration retrieval hygiene).
|
|
4
|
+
*
|
|
5
|
+
* An AS that fetches a client-supplied URL is an SSRF engine unless it is
|
|
6
|
+
* fenced. The rules here are the fence: HTTPS only, no private or loopback or
|
|
7
|
+
* link-local address literals, a bounded redirect chain that is re-checked at
|
|
8
|
+
* every hop, a response size cap, and a timeout. Each redirect is validated
|
|
9
|
+
* again because a public hostname resolving to a public IP can redirect to
|
|
10
|
+
* `http://169.254.169.254/` and a one-shot check at the start would miss it.
|
|
11
|
+
*
|
|
12
|
+
* What this module deliberately does NOT do is decide whether a declaration is
|
|
13
|
+
* *trusted*. That is deployment policy (`DeclarationTrustPolicy`), applied
|
|
14
|
+
* explicitly by the caller, because "we fetched it safely" and "we accept its
|
|
15
|
+
* authority over this source" are different questions.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { isIP } from "node:net";
|
|
19
|
+
export const MAX_DECLARATION_BYTES = 256 * 1024;
|
|
20
|
+
export const MAX_REDIRECTS = 3;
|
|
21
|
+
export const DECLARATION_FETCH_TIMEOUT_MS = 5_000;
|
|
22
|
+
/**
|
|
23
|
+
* Private, loopback, link-local, and unique-local ranges. Checked against
|
|
24
|
+
* address *literals* in the URL. A hostname that resolves into these ranges is
|
|
25
|
+
* a DNS-rebinding concern the fetch layer must also handle; we reject the
|
|
26
|
+
* literal form here and keep the redirect chain bounded so a rebind has a much
|
|
27
|
+
* smaller window to work in.
|
|
28
|
+
*/
|
|
29
|
+
function isPrivateAddressLiteral(host) {
|
|
30
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
31
|
+
const version = isIP(bare);
|
|
32
|
+
if (version === 4) {
|
|
33
|
+
const parts = bare.split(".").map(Number);
|
|
34
|
+
const [a, b] = parts;
|
|
35
|
+
if (a === 10 || a === 127 || a === 0)
|
|
36
|
+
return true;
|
|
37
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
38
|
+
return true;
|
|
39
|
+
if (a === 192 && b === 168)
|
|
40
|
+
return true;
|
|
41
|
+
// 169.254.0.0/16 — link-local, and the cloud metadata endpoint.
|
|
42
|
+
if (a === 169 && b === 254)
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (version === 6) {
|
|
47
|
+
const lower = bare.toLowerCase();
|
|
48
|
+
if (lower === "::1" || lower === "::")
|
|
49
|
+
return true;
|
|
50
|
+
// fc00::/7 unique-local, fe80::/10 link-local.
|
|
51
|
+
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
|
52
|
+
return true;
|
|
53
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9"))
|
|
54
|
+
return true;
|
|
55
|
+
if (lower.startsWith("fea") || lower.startsWith("feb"))
|
|
56
|
+
return true;
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Not a literal. Hostname forms that always mean "this machine".
|
|
60
|
+
const lowerHost = bare.toLowerCase();
|
|
61
|
+
return lowerHost === "localhost" || lowerHost.endsWith(".localhost");
|
|
62
|
+
}
|
|
63
|
+
/** Validate one URL in the chain. Applied to the origin URL and every hop. */
|
|
64
|
+
export function checkDeclarationUrl(raw, policy) {
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(raw);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return {
|
|
71
|
+
code: "fetch_failed",
|
|
72
|
+
message: `malformed declaration URL: ${raw}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (url.protocol !== "https:" && !policy.allowInsecureForTesting) {
|
|
76
|
+
return {
|
|
77
|
+
code: "insecure_scheme",
|
|
78
|
+
message: `declaration URL must use https, got ${url.protocol}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (isPrivateAddressLiteral(url.hostname) &&
|
|
82
|
+
!policy.allowInsecureForTesting) {
|
|
83
|
+
return {
|
|
84
|
+
code: "private_address",
|
|
85
|
+
message: `declaration URL resolves to a private or loopback address: ${url.hostname}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!policy.allowAnyHttpsHost) {
|
|
89
|
+
const trusted = policy.trustedHosts ?? [];
|
|
90
|
+
if (!trusted.includes(url.hostname)) {
|
|
91
|
+
return {
|
|
92
|
+
code: "untrusted_source",
|
|
93
|
+
message: `declaration host '${url.hostname}' is not in this deployment's declaration trust policy`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Project a normative §5 `SourceDeclaration` onto the internal snapshot shape.
|
|
101
|
+
*
|
|
102
|
+
* Returns the document unchanged when it is already in the internal shape, so
|
|
103
|
+
* existing private fixtures keep working — this is a compatibility adapter,
|
|
104
|
+
* not a redefinition of either format.
|
|
105
|
+
*
|
|
106
|
+
* The projection is deliberately narrow:
|
|
107
|
+
* - `source.id` / `source.kind` → `source_id` / `source_kind`
|
|
108
|
+
* - `declaration_version` → `version`
|
|
109
|
+
* - per stream, `schema.properties` keys → `fields`, and `schema.required`
|
|
110
|
+
* → `required_fields`
|
|
111
|
+
*
|
|
112
|
+
* Deriving fields from the JSON Schema is the substantive part. The internal
|
|
113
|
+
* model wants a flat allowlist because §6 field selection and §8 projection
|
|
114
|
+
* operate on top-level names; the normative model carries a real schema
|
|
115
|
+
* because a declaration must describe record shape, not just name it. Reading
|
|
116
|
+
* `properties` keys is the honest projection of one onto the other, and
|
|
117
|
+
* `required` is exactly the per-stream consent floor §6 already calls for.
|
|
118
|
+
*
|
|
119
|
+
* Nothing here validates the normative document beyond what the projection
|
|
120
|
+
* needs — the checks that follow do that, and they run on the projected form
|
|
121
|
+
* so one code path enforces both.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* The source id a declaration document claims, in either shape.
|
|
125
|
+
*
|
|
126
|
+
* A caller that needs the id *before* parsing — a deployment loader keying
|
|
127
|
+
* retention by source, say — would otherwise have to reach into the document
|
|
128
|
+
* itself and pick a field. That is how the boot path came to accept only the
|
|
129
|
+
* internal flat `source_id` and silently skip every normative document, which
|
|
130
|
+
* left the AS unmounted with nothing but a warning to say why.
|
|
131
|
+
*
|
|
132
|
+
* Reading the shape is this module's job, so the question is answered here
|
|
133
|
+
* once. Returns null when neither shape carries a usable id; `parseDeclaration`
|
|
134
|
+
* remains the authority on whether the document is actually valid.
|
|
135
|
+
*/
|
|
136
|
+
export function declaredSourceId(body) {
|
|
137
|
+
let doc;
|
|
138
|
+
try {
|
|
139
|
+
doc = JSON.parse(body);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (typeof doc !== "object" || doc === null)
|
|
145
|
+
return null;
|
|
146
|
+
// Normative §5: `source.id`. Internal/flat: `source_id`.
|
|
147
|
+
const nested = doc.source?.id;
|
|
148
|
+
const flat = doc.source_id;
|
|
149
|
+
const id = typeof nested === "string" && nested.length > 0 ? nested : flat;
|
|
150
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
151
|
+
}
|
|
152
|
+
function normalizeNormativeDeclaration(doc) {
|
|
153
|
+
const source = doc.source;
|
|
154
|
+
if (typeof source !== "object" || source === null)
|
|
155
|
+
return doc;
|
|
156
|
+
const { id, kind } = source;
|
|
157
|
+
const declarationVersion = doc
|
|
158
|
+
.declaration_version;
|
|
159
|
+
const streams = Array.isArray(doc.streams)
|
|
160
|
+
? doc.streams.map((raw) => {
|
|
161
|
+
const stream = raw;
|
|
162
|
+
// Already internal-shaped: leave it alone rather than guessing.
|
|
163
|
+
if (Array.isArray(stream.fields))
|
|
164
|
+
return stream;
|
|
165
|
+
const schema = stream.schema;
|
|
166
|
+
if (!schema || typeof schema !== "object")
|
|
167
|
+
return stream;
|
|
168
|
+
return {
|
|
169
|
+
...stream,
|
|
170
|
+
fields: Object.keys(schema.properties ?? {}),
|
|
171
|
+
required_fields: Array.isArray(schema.required)
|
|
172
|
+
? schema.required
|
|
173
|
+
: [],
|
|
174
|
+
};
|
|
175
|
+
})
|
|
176
|
+
: doc.streams;
|
|
177
|
+
return {
|
|
178
|
+
...doc,
|
|
179
|
+
...(typeof id === "string" && { source_id: id }),
|
|
180
|
+
...(typeof kind === "string" && { source_kind: kind }),
|
|
181
|
+
...(typeof declarationVersion === "string" && {
|
|
182
|
+
version: declarationVersion,
|
|
183
|
+
}),
|
|
184
|
+
streams,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** The dialect §5 fixes for every embedded stream schema. */
|
|
188
|
+
const SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
189
|
+
/**
|
|
190
|
+
* Meta-validate one embedded stream schema (§5).
|
|
191
|
+
*
|
|
192
|
+
* "If `$schema` is present, it MUST equal
|
|
193
|
+
* `https://json-schema.org/draft/2020-12/schema`. [...] The AS MUST
|
|
194
|
+
* meta-validate each embedded stream schema before accepting the declaration.
|
|
195
|
+
* Embedded `$ref` and `$dynamicRef` values MUST be local fragment references.
|
|
196
|
+
* A declaration MUST NOT make consent interpretation depend on a mutable
|
|
197
|
+
* remote schema."
|
|
198
|
+
*
|
|
199
|
+
* That last sentence is what the reference check is for, and it is the reason
|
|
200
|
+
* a remote `$ref` is a refusal rather than a warning: a schema the AS never
|
|
201
|
+
* fetched and never froze can be changed by a third party after the owner has
|
|
202
|
+
* consented, which rewrites what the consent covers without the declaration
|
|
203
|
+
* changing at all.
|
|
204
|
+
*
|
|
205
|
+
* The walk is recursive because a `$ref` under `properties`, `items`, `$defs`
|
|
206
|
+
* or a combinator is exactly as remote as one at the root; a top-level-only
|
|
207
|
+
* check would be a formality rather than a fence.
|
|
208
|
+
*
|
|
209
|
+
* Returns a human-readable reason, or null when the schema is acceptable.
|
|
210
|
+
*/
|
|
211
|
+
function metaValidateSchema(schema) {
|
|
212
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
|
213
|
+
return "schema must be a JSON Schema object";
|
|
214
|
+
}
|
|
215
|
+
const dialect = schema.$schema;
|
|
216
|
+
if (dialect !== undefined && dialect !== SCHEMA_DIALECT) {
|
|
217
|
+
return `schema declares $schema '${String(dialect)}', but §5 fixes the dialect at ${SCHEMA_DIALECT}`;
|
|
218
|
+
}
|
|
219
|
+
return findRemoteReference(schema);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The first non-local `$ref`/`$dynamicRef` anywhere in `node`, as a reason.
|
|
223
|
+
*
|
|
224
|
+
* "Local fragment reference" means a value beginning with `#` — a pointer into
|
|
225
|
+
* this same document. Anything else names a document the AS does not hold.
|
|
226
|
+
*/
|
|
227
|
+
function findRemoteReference(node) {
|
|
228
|
+
if (Array.isArray(node)) {
|
|
229
|
+
for (const entry of node) {
|
|
230
|
+
const problem = findRemoteReference(entry);
|
|
231
|
+
if (problem)
|
|
232
|
+
return problem;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
if (typeof node !== "object" || node === null)
|
|
237
|
+
return null;
|
|
238
|
+
for (const keyword of ["$ref", "$dynamicRef"]) {
|
|
239
|
+
const value = node[keyword];
|
|
240
|
+
if (typeof value === "string" && !value.startsWith("#")) {
|
|
241
|
+
return `schema resolves ${keyword} '${value}', which is not a local fragment reference`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const value of Object.values(node)) {
|
|
245
|
+
const problem = findRemoteReference(value);
|
|
246
|
+
if (problem)
|
|
247
|
+
return problem;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The registered IANA top-level types. A closed set, unlike the subtypes.
|
|
253
|
+
*/
|
|
254
|
+
const TOP_LEVEL_MEDIA_TYPES = new Set([
|
|
255
|
+
"application",
|
|
256
|
+
"audio",
|
|
257
|
+
"example",
|
|
258
|
+
"font",
|
|
259
|
+
"haptics",
|
|
260
|
+
"image",
|
|
261
|
+
"message",
|
|
262
|
+
"model",
|
|
263
|
+
"multipart",
|
|
264
|
+
"text",
|
|
265
|
+
"video",
|
|
266
|
+
]);
|
|
267
|
+
/** RFC 6838 restricted-name grammar for a subtype (or a tree-prefixed one). */
|
|
268
|
+
const SUBTYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/;
|
|
269
|
+
/**
|
|
270
|
+
* Is `value` a valid IANA media type (§4.8)?
|
|
271
|
+
*
|
|
272
|
+
* Checked against the GRAMMAR, not a registry snapshot. IANA adds subtypes
|
|
273
|
+
* continuously and without a spec revision, so refusing a well-formed subtype
|
|
274
|
+
* this server has not heard of would reject conforming declarations and turn
|
|
275
|
+
* the check into a registry-freshness test rather than a validity one. The
|
|
276
|
+
* top-level types are a closed set, so that half IS an exact check.
|
|
277
|
+
*/
|
|
278
|
+
function isValidMediaType(value) {
|
|
279
|
+
// Parameters (`; charset=utf-8`) are part of a well-formed value; the
|
|
280
|
+
// type/subtype pair is what the clause constrains.
|
|
281
|
+
const [essence] = value.split(";");
|
|
282
|
+
const parts = essence.trim().split("/");
|
|
283
|
+
if (parts.length !== 2)
|
|
284
|
+
return false;
|
|
285
|
+
const [type, subtype] = parts;
|
|
286
|
+
return TOP_LEVEL_MEDIA_TYPES.has(type.toLowerCase()) && SUBTYPE.test(subtype);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The first invalid declared `blob_ref` media type in a stream, as a reason.
|
|
290
|
+
*
|
|
291
|
+
* §4 shows `mime_type` inside a RECORD's `blob_ref`. A declaration describes
|
|
292
|
+
* record shape, so it can pin one in two places and both are checked rather
|
|
293
|
+
* than one being privileged:
|
|
294
|
+
*
|
|
295
|
+
* - a `blob_fields` member listing blob-bearing fields and their types;
|
|
296
|
+
* - a `const`/`enum` on a `blob_ref.mime_type` property inside the embedded
|
|
297
|
+
* schema, which is how a declaration constrains any record field.
|
|
298
|
+
*
|
|
299
|
+
* Neither is mandatory: a stream declaring no blob field is untouched, which
|
|
300
|
+
* is the clause's own applicability condition.
|
|
301
|
+
*/
|
|
302
|
+
function findInvalidBlobMediaType(stream) {
|
|
303
|
+
const blobFields = stream.blob_fields;
|
|
304
|
+
if (Array.isArray(blobFields)) {
|
|
305
|
+
for (const raw of blobFields) {
|
|
306
|
+
if (typeof raw !== "object" || raw === null)
|
|
307
|
+
continue;
|
|
308
|
+
const { name, mime_type: mimeType } = raw;
|
|
309
|
+
if (typeof mimeType !== "string" || !isValidMediaType(mimeType)) {
|
|
310
|
+
return `declares blob_ref field '${String(name)}' with mime_type '${String(mimeType)}', which is not a valid IANA media type`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return findInvalidSchemaMediaType(stream.schema);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* A `const`/`enum` media type pinned on a `mime_type` property anywhere in the
|
|
318
|
+
* embedded schema, checked wherever it appears.
|
|
319
|
+
*/
|
|
320
|
+
function findInvalidSchemaMediaType(node) {
|
|
321
|
+
if (Array.isArray(node)) {
|
|
322
|
+
for (const entry of node) {
|
|
323
|
+
const problem = findInvalidSchemaMediaType(entry);
|
|
324
|
+
if (problem)
|
|
325
|
+
return problem;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
if (typeof node !== "object" || node === null)
|
|
330
|
+
return null;
|
|
331
|
+
const properties = node.properties;
|
|
332
|
+
if (typeof properties === "object" && properties !== null) {
|
|
333
|
+
const pinned = properties.mime_type;
|
|
334
|
+
if (typeof pinned === "object" && pinned !== null) {
|
|
335
|
+
const candidates = [
|
|
336
|
+
...(typeof pinned.const === "string"
|
|
337
|
+
? [pinned.const]
|
|
338
|
+
: []),
|
|
339
|
+
...(Array.isArray(pinned.enum)
|
|
340
|
+
? pinned.enum.filter((v) => typeof v === "string")
|
|
341
|
+
: []),
|
|
342
|
+
];
|
|
343
|
+
const bad = candidates.find((c) => !isValidMediaType(c));
|
|
344
|
+
if (bad !== undefined) {
|
|
345
|
+
return `pins mime_type '${bad}', which is not a valid IANA media type`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const value of Object.values(node)) {
|
|
350
|
+
const problem = findInvalidSchemaMediaType(value);
|
|
351
|
+
if (problem)
|
|
352
|
+
return problem;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
export function computeDeclarationDigest(body) {
|
|
357
|
+
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Read a stream's §5 `display` object, keeping only string members.
|
|
361
|
+
*
|
|
362
|
+
* Returns `{ display }` or undefined, so the caller can spread it and let an
|
|
363
|
+
* absent or empty object stay absent. `{ label: "" }` is treated as no label:
|
|
364
|
+
* §5's fallback chain is label → description → stream name, and an empty
|
|
365
|
+
* string would win that race and render nothing where a name belongs.
|
|
366
|
+
*/
|
|
367
|
+
function readDeclaredDisplay(value) {
|
|
368
|
+
if (typeof value !== "object" || value === null)
|
|
369
|
+
return undefined;
|
|
370
|
+
const { label, detail } = value;
|
|
371
|
+
const display = {
|
|
372
|
+
...(typeof label === "string" && label.length > 0 && { label }),
|
|
373
|
+
...(typeof detail === "string" && detail.length > 0 && { detail }),
|
|
374
|
+
};
|
|
375
|
+
return Object.keys(display).length > 0 ? { display } : undefined;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Parse and structurally validate a declaration document.
|
|
379
|
+
*
|
|
380
|
+
* Validates the embedded stream schemas enough that resolution can rely on
|
|
381
|
+
* them: every stream needs a name, a field list, and a primary key, and its
|
|
382
|
+
* `required_fields` must be a subset of its declared fields — otherwise the
|
|
383
|
+
* per-stream consent floor would name fields the schema does not have.
|
|
384
|
+
*/
|
|
385
|
+
export function parseDeclaration(body, expectedSourceId, expectedDigest) {
|
|
386
|
+
const digest = computeDeclarationDigest(body);
|
|
387
|
+
if (expectedDigest !== undefined && digest !== expectedDigest) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
failure: {
|
|
391
|
+
code: "digest_mismatch",
|
|
392
|
+
message: "declaration digest does not match the expected value",
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
let doc;
|
|
397
|
+
try {
|
|
398
|
+
doc = JSON.parse(body);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
failure: {
|
|
404
|
+
code: "invalid_document",
|
|
405
|
+
message: "declaration is not valid JSON",
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
// Accept the NORMATIVE §5 SourceDeclaration and project it onto this
|
|
410
|
+
// module's internal snapshot shape.
|
|
411
|
+
//
|
|
412
|
+
// PS historically parsed a private shape (`source_id`, `source_kind`,
|
|
413
|
+
// `version`, flat `fields[]`/`required_fields[]`). That shape is not the
|
|
414
|
+
// protocol: the published schema
|
|
415
|
+
// (`https://pdpp.dev/schemas/source-declaration/0.1.0`, `reference-contract`)
|
|
416
|
+
// requires `protocol_version`, nested `source {kind,id}`,
|
|
417
|
+
// `declaration_version`, `publisher`, `display`, and a real JSON-Schema
|
|
418
|
+
// `schema` per stream — and sets `additionalProperties: false`, so the
|
|
419
|
+
// private shape is affirmatively INVALID, not merely different.
|
|
420
|
+
//
|
|
421
|
+
// Normalizing here rather than loosening the checks below keeps the
|
|
422
|
+
// protocol boundary honest: a normative document is validated as normative,
|
|
423
|
+
// and the internal snapshot stays an internal detail. The raw bytes and
|
|
424
|
+
// their digest are untouched — `digest` above is computed over `body`
|
|
425
|
+
// exactly as retrieved, which is the property that proves a producer read
|
|
426
|
+
// the same bytes the owner consented against.
|
|
427
|
+
doc = normalizeNormativeDeclaration(doc);
|
|
428
|
+
if (typeof doc.source_id !== "string" || doc.source_id.length === 0) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
failure: {
|
|
432
|
+
code: "invalid_document",
|
|
433
|
+
message: "declaration is missing source_id",
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// The declaration must claim the source we asked about. Without this check a
|
|
438
|
+
// trusted host could serve an authority for a source it does not own.
|
|
439
|
+
if (doc.source_id !== expectedSourceId) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
failure: {
|
|
443
|
+
code: "source_id_mismatch",
|
|
444
|
+
message: `declaration declares source_id '${doc.source_id}' but was retrieved for '${expectedSourceId}'`,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (doc.source_kind !== "connector" &&
|
|
449
|
+
doc.source_kind !== "provider_native") {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
failure: {
|
|
453
|
+
code: "invalid_document",
|
|
454
|
+
message: "declaration source_kind must be connector or provider_native",
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
if (typeof doc.version !== "string" || doc.version.length === 0) {
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
failure: {
|
|
462
|
+
code: "invalid_document",
|
|
463
|
+
message: "declaration is missing version",
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (!Array.isArray(doc.streams) || doc.streams.length === 0) {
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
failure: {
|
|
471
|
+
code: "invalid_document",
|
|
472
|
+
message: "declaration declares no streams",
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const streams = [];
|
|
477
|
+
const seenStreams = new Set();
|
|
478
|
+
for (const raw of doc.streams) {
|
|
479
|
+
const s = raw;
|
|
480
|
+
if (typeof s.name !== "string" || s.name.length === 0) {
|
|
481
|
+
return {
|
|
482
|
+
ok: false,
|
|
483
|
+
failure: {
|
|
484
|
+
code: "invalid_document",
|
|
485
|
+
message: "a declared stream is missing name",
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (seenStreams.has(s.name)) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
failure: {
|
|
493
|
+
code: "invalid_document",
|
|
494
|
+
message: `stream '${s.name}' is declared more than once`,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
seenStreams.add(s.name);
|
|
499
|
+
if (!Array.isArray(s.fields) || s.fields.length === 0) {
|
|
500
|
+
return {
|
|
501
|
+
ok: false,
|
|
502
|
+
failure: {
|
|
503
|
+
code: "invalid_document",
|
|
504
|
+
message: `stream '${s.name}' declares no fields`,
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const fields = s.fields;
|
|
509
|
+
const requiredFields = Array.isArray(s.required_fields)
|
|
510
|
+
? s.required_fields
|
|
511
|
+
: [];
|
|
512
|
+
const missing = requiredFields.filter((f) => !fields.includes(f));
|
|
513
|
+
if (missing.length > 0) {
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
failure: {
|
|
517
|
+
code: "invalid_document",
|
|
518
|
+
message: `stream '${s.name}' marks fields required that its schema does not declare: ${missing.join(", ")}`,
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (!Array.isArray(s.primary_key) || s.primary_key.length === 0) {
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
failure: {
|
|
526
|
+
code: "invalid_document",
|
|
527
|
+
message: `stream '${s.name}' declares no primary_key`,
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
// §5 `streams[].schema`: "`primary_key` and `cursor_field` MUST reference
|
|
532
|
+
// fields declared here."
|
|
533
|
+
//
|
|
534
|
+
// Checked against `fields` rather than against `schema.properties`
|
|
535
|
+
// directly, because `fields` IS that projection for a normative document
|
|
536
|
+
// and is what the internal flat shape carries — so one check covers both
|
|
537
|
+
// shapes. Without it a declaration can promise record ordering over a
|
|
538
|
+
// field that does not exist; nothing catches the contradiction until read
|
|
539
|
+
// time, long after the owner consented to the document.
|
|
540
|
+
const undeclaredKey = s.primary_key.filter((f) => !fields.includes(f));
|
|
541
|
+
if (undeclaredKey.length > 0) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
failure: {
|
|
545
|
+
code: "invalid_document",
|
|
546
|
+
message: `stream '${s.name}' names primary_key fields its schema does not declare: ${undeclaredKey.join(", ")}`,
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
if (typeof s.cursor_field === "string" &&
|
|
551
|
+
!fields.includes(s.cursor_field)) {
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
failure: {
|
|
555
|
+
code: "invalid_document",
|
|
556
|
+
message: `stream '${s.name}' names cursor_field '${s.cursor_field}', which its schema does not declare`,
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
// §5 `streams[].consent_time_field`: "MUST reference a field declared in
|
|
561
|
+
// the schema."
|
|
562
|
+
//
|
|
563
|
+
// Its own check, not folded into the one above, because this field is not
|
|
564
|
+
// read ordering — §6 makes its PRESENCE the authoritative signal that a
|
|
565
|
+
// stream is time-range-capable, and it is the field a `time_range` grant
|
|
566
|
+
// is evaluated against. An undeclared one therefore advertises a consent
|
|
567
|
+
// boundary with no column behind it: an owner who approves "the last six
|
|
568
|
+
// months" has that bound applied to nothing.
|
|
569
|
+
//
|
|
570
|
+
// Absence stays legal — §5: "Streams that cannot define a stable
|
|
571
|
+
// `consent_time_field` simply omit it."
|
|
572
|
+
if (typeof s.consent_time_field === "string" &&
|
|
573
|
+
!fields.includes(s.consent_time_field)) {
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
failure: {
|
|
577
|
+
code: "invalid_document",
|
|
578
|
+
message: `stream '${s.name}' names consent_time_field '${s.consent_time_field}', which its schema does not declare`,
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
// §5: meta-validate the embedded schema before accepting the declaration.
|
|
583
|
+
// Only when one is present — the internal flat shape carries a field list
|
|
584
|
+
// and no schema, and §5's obligation is about the schema a normative
|
|
585
|
+
// document embeds.
|
|
586
|
+
if (s.schema !== undefined) {
|
|
587
|
+
const schemaProblem = metaValidateSchema(s.schema);
|
|
588
|
+
if (schemaProblem) {
|
|
589
|
+
return {
|
|
590
|
+
ok: false,
|
|
591
|
+
failure: {
|
|
592
|
+
code: "invalid_document",
|
|
593
|
+
message: `stream '${s.name}': ${schemaProblem}`,
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// §4.8: "`mime_type` MUST be a valid IANA media type." The value is what
|
|
599
|
+
// every consumer uses to decide how to interpret fetched bytes, so one no
|
|
600
|
+
// registry defines means each client guesses differently — and the guess
|
|
601
|
+
// is frozen into the document the owner's consent is written against.
|
|
602
|
+
const mediaTypeProblem = findInvalidBlobMediaType(s);
|
|
603
|
+
if (mediaTypeProblem) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
failure: {
|
|
607
|
+
code: "invalid_document",
|
|
608
|
+
message: `stream '${s.name}' ${mediaTypeProblem}`,
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
// §5 stream semantics. An unrecognized value is a rejection rather than a
|
|
613
|
+
// silent fallback: coercing an unknown semantics to `mutable_state` would
|
|
614
|
+
// upsert records a declaration may have meant to be immutable.
|
|
615
|
+
if (s.semantics !== undefined &&
|
|
616
|
+
s.semantics !== "mutable_state" &&
|
|
617
|
+
s.semantics !== "append_only") {
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
failure: {
|
|
621
|
+
code: "invalid_document",
|
|
622
|
+
message: `stream '${s.name}' declares unsupported semantics '${String(s.semantics)}'`,
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
streams.push({
|
|
627
|
+
name: s.name,
|
|
628
|
+
fields,
|
|
629
|
+
required_fields: requiredFields,
|
|
630
|
+
// §5 consent-surface copy. Carried verbatim and kept as two distinct
|
|
631
|
+
// fields: `display` is the consent-surface metadata the AS renders,
|
|
632
|
+
// `description` is explicitly not. Dropping either left the consent
|
|
633
|
+
// surface with nothing but the raw stream name to show the owner.
|
|
634
|
+
//
|
|
635
|
+
// Absence stays absence — an empty `display: {}` would make a renderer
|
|
636
|
+
// print a blank label where it must fall back to the stream name.
|
|
637
|
+
...(typeof s.description === "string" &&
|
|
638
|
+
s.description.length > 0 && { description: s.description }),
|
|
639
|
+
...(readDeclaredDisplay(s.display) ?? {}),
|
|
640
|
+
...(typeof s.semantics === "string" && {
|
|
641
|
+
semantics: s.semantics,
|
|
642
|
+
}),
|
|
643
|
+
...(typeof s.consent_time_field === "string" && {
|
|
644
|
+
consent_time_field: s.consent_time_field,
|
|
645
|
+
}),
|
|
646
|
+
primary_key: s.primary_key,
|
|
647
|
+
// Carried verbatim from the normative declaration so §8 metadata can
|
|
648
|
+
// report record shape and selection capability without a second lookup.
|
|
649
|
+
...(typeof s.schema === "object" && s.schema !== null
|
|
650
|
+
? { schema: s.schema }
|
|
651
|
+
: {}),
|
|
652
|
+
...(typeof s.selection === "object" && s.selection !== null
|
|
653
|
+
? { selection: s.selection }
|
|
654
|
+
: {}),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
// §6: a preset naming the same stream twice makes the declaration invalid.
|
|
658
|
+
// That is a declaration-acceptance failure, not deferred to issuance.
|
|
659
|
+
const presets = Array.isArray(doc.selection_presets)
|
|
660
|
+
? doc.selection_presets
|
|
661
|
+
: undefined;
|
|
662
|
+
if (presets) {
|
|
663
|
+
for (const preset of presets) {
|
|
664
|
+
const seen = new Set();
|
|
665
|
+
for (const stream of preset.streams ?? []) {
|
|
666
|
+
if (seen.has(stream.name)) {
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
failure: {
|
|
670
|
+
code: "invalid_document",
|
|
671
|
+
message: `selection preset '${preset.name}' names stream '${stream.name}' more than once`,
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
seen.add(stream.name);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const sourceDisplayName = doc.display
|
|
680
|
+
?.name;
|
|
681
|
+
return {
|
|
682
|
+
ok: true,
|
|
683
|
+
snapshot: {
|
|
684
|
+
source_id: doc.source_id,
|
|
685
|
+
source_kind: doc.source_kind,
|
|
686
|
+
version: doc.version,
|
|
687
|
+
digest,
|
|
688
|
+
streams,
|
|
689
|
+
// §5 top-level `display.name`. Only carried when it is a non-empty
|
|
690
|
+
// string: a blank name would outrank the source id in the renderer's
|
|
691
|
+
// fallback chain and put an empty heading on the consent surface.
|
|
692
|
+
...(typeof sourceDisplayName === "string" &&
|
|
693
|
+
sourceDisplayName.length > 0 && {
|
|
694
|
+
display: { name: sourceDisplayName },
|
|
695
|
+
}),
|
|
696
|
+
...(Array.isArray(doc.views) && {
|
|
697
|
+
views: doc.views,
|
|
698
|
+
}),
|
|
699
|
+
...(presets && {
|
|
700
|
+
selection_presets: presets,
|
|
701
|
+
}),
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Read a response body, giving up as soon as it exceeds `limit` bytes.
|
|
707
|
+
*
|
|
708
|
+
* Returns null when the cap is passed. The stream is cancelled at that point,
|
|
709
|
+
* so a hostile or misconfigured host cannot make us hold more than the cap in
|
|
710
|
+
* memory however it frames the response. Falls back to a buffered read only
|
|
711
|
+
* when the runtime gives no readable stream, and still checks the size.
|
|
712
|
+
*/
|
|
713
|
+
async function readCapped(response, limit) {
|
|
714
|
+
const stream = response.body;
|
|
715
|
+
if (!stream) {
|
|
716
|
+
const buffered = await response.text();
|
|
717
|
+
return Buffer.byteLength(buffered, "utf8") > limit ? null : buffered;
|
|
718
|
+
}
|
|
719
|
+
const reader = stream.getReader();
|
|
720
|
+
const chunks = [];
|
|
721
|
+
let total = 0;
|
|
722
|
+
try {
|
|
723
|
+
for (;;) {
|
|
724
|
+
const { done, value } = await reader.read();
|
|
725
|
+
if (done)
|
|
726
|
+
break;
|
|
727
|
+
if (!value)
|
|
728
|
+
continue;
|
|
729
|
+
total += value.byteLength;
|
|
730
|
+
if (total > limit) {
|
|
731
|
+
await reader.cancel();
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
chunks.push(value);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
reader.releaseLock();
|
|
739
|
+
}
|
|
740
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Retrieve one declaration under the bounded rules, then parse and digest it.
|
|
744
|
+
*
|
|
745
|
+
* Redirects are followed manually so each hop can be re-validated. This is the
|
|
746
|
+
* difference between a fence and a formality: `fetch`'s automatic redirect
|
|
747
|
+
* following would validate only the URL we started with.
|
|
748
|
+
*/
|
|
749
|
+
export async function retrieveDeclaration(url, expectedSourceId, policy, options = {}) {
|
|
750
|
+
const fetcher = options.fetcher ?? ((u, init) => fetch(u, init));
|
|
751
|
+
let current = url;
|
|
752
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
753
|
+
const problem = checkDeclarationUrl(current, policy);
|
|
754
|
+
if (problem)
|
|
755
|
+
return { ok: false, failure: problem };
|
|
756
|
+
const controller = new AbortController();
|
|
757
|
+
const timer = setTimeout(() => controller.abort(), DECLARATION_FETCH_TIMEOUT_MS);
|
|
758
|
+
let response;
|
|
759
|
+
try {
|
|
760
|
+
response = await fetcher(current, {
|
|
761
|
+
redirect: "manual",
|
|
762
|
+
signal: controller.signal,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
return {
|
|
767
|
+
ok: false,
|
|
768
|
+
failure: {
|
|
769
|
+
code: "fetch_failed",
|
|
770
|
+
message: `declaration retrieval failed: ${err.message}`,
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
clearTimeout(timer);
|
|
776
|
+
}
|
|
777
|
+
if (response.status >= 300 && response.status < 400) {
|
|
778
|
+
const location = response.headers.get("location");
|
|
779
|
+
if (!location) {
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
failure: {
|
|
783
|
+
code: "fetch_failed",
|
|
784
|
+
message: "redirect response carried no Location header",
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
// Resolve relative redirects against the current URL, then re-check.
|
|
789
|
+
current = new URL(location, current).toString();
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (!response.ok) {
|
|
793
|
+
return {
|
|
794
|
+
ok: false,
|
|
795
|
+
failure: {
|
|
796
|
+
code: "fetch_failed",
|
|
797
|
+
message: `declaration retrieval returned HTTP ${response.status}`,
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
const declaredLength = response.headers.get("content-length");
|
|
802
|
+
if (declaredLength && Number(declaredLength) > MAX_DECLARATION_BYTES) {
|
|
803
|
+
return {
|
|
804
|
+
ok: false,
|
|
805
|
+
failure: {
|
|
806
|
+
code: "too_large",
|
|
807
|
+
message: `declaration exceeds ${MAX_DECLARATION_BYTES} bytes`,
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
// Read incrementally and abort the moment the cap is passed. `text()`
|
|
812
|
+
// would buffer the whole response first, so a host that omits or lies
|
|
813
|
+
// about Content-Length could make us allocate an unbounded body and only
|
|
814
|
+
// then be told it was too large — the cap has to bound the allocation, not
|
|
815
|
+
// just the outcome.
|
|
816
|
+
const body = await readCapped(response, MAX_DECLARATION_BYTES);
|
|
817
|
+
if (body === null) {
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
failure: {
|
|
821
|
+
code: "too_large",
|
|
822
|
+
message: `declaration exceeds ${MAX_DECLARATION_BYTES} bytes`,
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
return parseDeclaration(body, expectedSourceId, options.expectedDigest);
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
ok: false,
|
|
830
|
+
failure: {
|
|
831
|
+
code: "too_many_redirects",
|
|
832
|
+
message: `declaration retrieval exceeded ${MAX_REDIRECTS} redirects`,
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
//# sourceMappingURL=declaration.js.map
|