@slicedearth/whoisleuth-cli 1.45.0 → 2.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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
import { defineSchemaCompatibility, isCanonicalLocalSchemaIdentifier, } from "./schema-compatibility.mjs";
|
|
2
|
+
const FAMILY_KEYS = new Set(['id', 'owner', 'privacy', 'compatibility', 'contracts', 'fixtures']);
|
|
3
|
+
const EXTENDED_FAMILY_KEYS = new Set([...FAMILY_KEYS, 'metadata']);
|
|
4
|
+
const METADATA_V1_KEYS = new Set([
|
|
5
|
+
'metadataVersion',
|
|
6
|
+
'enforcement',
|
|
7
|
+
'shapes',
|
|
8
|
+
'boundProfiles',
|
|
9
|
+
'hooks',
|
|
10
|
+
'serialisationProfiles',
|
|
11
|
+
'privacyProfiles',
|
|
12
|
+
'expiryProfiles',
|
|
13
|
+
'consumerEdges',
|
|
14
|
+
]);
|
|
15
|
+
const METADATA_V2_KEYS = new Set([
|
|
16
|
+
...METADATA_V1_KEYS,
|
|
17
|
+
'consumerRelationships',
|
|
18
|
+
]);
|
|
19
|
+
const METADATA_V3_KEYS = METADATA_V2_KEYS;
|
|
20
|
+
const METADATA_V4_KEYS = new Set([
|
|
21
|
+
...METADATA_V2_KEYS,
|
|
22
|
+
]);
|
|
23
|
+
const COMPATIBILITY_KEYS = new Set([
|
|
24
|
+
'id',
|
|
25
|
+
'tier',
|
|
26
|
+
'kind',
|
|
27
|
+
'schema',
|
|
28
|
+
'currentVersion',
|
|
29
|
+
'supportedVersions',
|
|
30
|
+
'acceptsUnversionedLegacy',
|
|
31
|
+
'futureVersionBehavior',
|
|
32
|
+
'migration',
|
|
33
|
+
'writeSemantics',
|
|
34
|
+
'byteBudget',
|
|
35
|
+
'owner',
|
|
36
|
+
'note',
|
|
37
|
+
]);
|
|
38
|
+
const CONTRACT_KEYS = new Set([
|
|
39
|
+
'compatibilityId',
|
|
40
|
+
'schema',
|
|
41
|
+
'version',
|
|
42
|
+
'role',
|
|
43
|
+
'lifecycle',
|
|
44
|
+
'readable',
|
|
45
|
+
'emitted',
|
|
46
|
+
'exactKeys',
|
|
47
|
+
'extensionPolicy',
|
|
48
|
+
'futureVersionBehaviour',
|
|
49
|
+
'migrationTarget',
|
|
50
|
+
'canonicalisation',
|
|
51
|
+
'byteBudget',
|
|
52
|
+
'fixtureIds',
|
|
53
|
+
]);
|
|
54
|
+
const TARGET_KEYS = new Set(['schema', 'version']);
|
|
55
|
+
const EMITTED_TARGET_V4_KEYS = new Set([...TARGET_KEYS, 'discriminator']);
|
|
56
|
+
const FIXTURE_KEYS = new Set([
|
|
57
|
+
'id',
|
|
58
|
+
'path',
|
|
59
|
+
'bytes',
|
|
60
|
+
'sha256',
|
|
61
|
+
'contentDigestSha256',
|
|
62
|
+
'schema',
|
|
63
|
+
'version',
|
|
64
|
+
'role',
|
|
65
|
+
'expectation',
|
|
66
|
+
'expectedOutputFixtureId',
|
|
67
|
+
'scope',
|
|
68
|
+
]);
|
|
69
|
+
const FIXTURE_V4_KEYS = new Set([...FIXTURE_KEYS, 'shapeId']);
|
|
70
|
+
const SHAPE_KEYS = new Set(['id', 'schema', 'versions', 'objects', 'fixedArrays', 'normalisation', 'target']);
|
|
71
|
+
const SHAPE_V4_KEYS = new Set([...SHAPE_KEYS, 'discriminator']);
|
|
72
|
+
const OBJECT_SHAPE_KEYS = new Set(['path', 'requiredKeys', 'optionalKeys', 'unknownKeys']);
|
|
73
|
+
const OBJECT_SHAPE_V4_KEYS = new Set([...OBJECT_SHAPE_KEYS, 'alternativeRequiredKeys']);
|
|
74
|
+
const ALTERNATIVE_REQUIRED_KEYS = new Set(['keys', 'resolution']);
|
|
75
|
+
const FIXED_ARRAY_SHAPE_KEYS = new Set(['path', 'values']);
|
|
76
|
+
const BOUND_PROFILE_KEYS = new Set(['id', 'bounds']);
|
|
77
|
+
const BOUND_KEYS = new Set(['id', 'path', 'phase', 'unit', 'minimum', 'maximum', 'handling']);
|
|
78
|
+
const HOOK_KEYS = new Set(['id', 'role', 'runtime', 'module', 'exportName']);
|
|
79
|
+
const SERIALISATION_PROFILE_KEYS = new Set([
|
|
80
|
+
'id', 'schema', 'versions', 'mediaType', 'encoding', 'bom', 'indentSpaces', 'terminalLf',
|
|
81
|
+
'propertyOrder', 'canonicalisation', 'integrity', 'serializerHookId', 'verifierHookIds',
|
|
82
|
+
]);
|
|
83
|
+
const PRIVACY_PROFILE_KEYS = new Set([
|
|
84
|
+
'id', 'classification', 'projection', 'includedCategories', 'excludedCategories', 'notePolicy',
|
|
85
|
+
'retention', 'network', 'sharingReview',
|
|
86
|
+
]);
|
|
87
|
+
const EXPIRY_POLICY_KEYS = new Set(['id', 'field', 'anchor', 'handling', 'phase', 'maximumLifetimeDays']);
|
|
88
|
+
const CONTRACT_REFERENCE_KEYS = new Set(['schema', 'versions', 'mode']);
|
|
89
|
+
const CONTRACT_REFERENCE_V3_KEYS = new Set([...CONTRACT_REFERENCE_KEYS, 'discriminator']);
|
|
90
|
+
const CONSUMER_DISCRIMINATOR_KEYS = new Set(['path', 'values']);
|
|
91
|
+
const CONTRACT_VARIANT_DISCRIMINATOR_KEYS = new Set(['path', 'value']);
|
|
92
|
+
const CONSUMER_KEYS = new Set([
|
|
93
|
+
'id', 'plane', 'operation', 'acceptedContracts', 'emittedContract', 'shapeIds', 'boundProfileIds',
|
|
94
|
+
'hookIds', 'serialisationProfileId', 'privacyProfileId', 'expiryPolicyId', 'requestMode',
|
|
95
|
+
'retentionEffect', 'bindingState', 'policyState',
|
|
96
|
+
]);
|
|
97
|
+
const CONSUMER_RELATIONSHIP_KEYS = new Set([
|
|
98
|
+
'id', 'sourceConsumerId', 'targetConsumerId', 'relationship',
|
|
99
|
+
]);
|
|
100
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9.-]{2,79}$/u;
|
|
101
|
+
const OWNER_PATTERN = /^[a-z0-9_./-]+$/iu;
|
|
102
|
+
const CANONICALISATION_PATTERN = /^[-a-z0-9.]+$/u;
|
|
103
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
104
|
+
const CONTENT_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
105
|
+
const FIELD_SEGMENT_PATTERN = '[A-Za-z0-9][A-Za-z0-9_]{0,79}';
|
|
106
|
+
const FIELD_PATTERN = new RegExp(`^${FIELD_SEGMENT_PATTERN}$`, 'u');
|
|
107
|
+
const METADATA_PATH_PATTERN = new RegExp(`^(?:\\$|${FIELD_SEGMENT_PATTERN}(?:\\[\\])?)(?:\\.(?:${FIELD_SEGMENT_PATTERN}(?:\\[\\])?))*$`, 'u');
|
|
108
|
+
const EXPORT_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]{0,79}$/u;
|
|
109
|
+
const MAX_CONTRACTS = 64;
|
|
110
|
+
const MAX_FIXTURES = 128;
|
|
111
|
+
const MAX_METADATA_COLLECTION = 128;
|
|
112
|
+
const MAX_NESTED_METADATA_COLLECTION = 64;
|
|
113
|
+
const MAX_METADATA_NODES = 8_192;
|
|
114
|
+
const MAX_METADATA_TEXT_CODE_UNITS = 262_144;
|
|
115
|
+
const MAX_METADATA_SERIALISED_BYTES = 1_048_576;
|
|
116
|
+
export const MAX_SCHEMA_LIFECYCLE_FAMILIES = 32;
|
|
117
|
+
const MAX_SCHEMA_LIFECYCLE_REGISTRY_BYTES = 4 * 1_048_576;
|
|
118
|
+
function consumeMetadataSlots(budget, count) {
|
|
119
|
+
if (!budget)
|
|
120
|
+
return;
|
|
121
|
+
budget.slots += count;
|
|
122
|
+
if (budget.slots > MAX_METADATA_NODES) {
|
|
123
|
+
throw new TypeError('Schema lifecycle metadata exceeds its aggregate node budget.');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function consumeMetadataSerialisedBytes(budget, bytes) {
|
|
127
|
+
if (!budget)
|
|
128
|
+
return;
|
|
129
|
+
budget.serialisedBytes += bytes;
|
|
130
|
+
if (budget.serialisedBytes > MAX_METADATA_SERIALISED_BYTES) {
|
|
131
|
+
throw new TypeError('Schema lifecycle metadata exceeds its serialised byte budget.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function consumeMetadataValue(budget, value) {
|
|
135
|
+
if (!budget)
|
|
136
|
+
return;
|
|
137
|
+
if (typeof value === 'string') {
|
|
138
|
+
budget.textCodeUnits += value.length;
|
|
139
|
+
if (budget.textCodeUnits > MAX_METADATA_TEXT_CODE_UNITS) {
|
|
140
|
+
throw new TypeError('Schema lifecycle metadata exceeds its aggregate text budget.');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (value === null
|
|
144
|
+
|| typeof value === 'string'
|
|
145
|
+
|| typeof value === 'number'
|
|
146
|
+
|| typeof value === 'boolean') {
|
|
147
|
+
const serialised = JSON.stringify(value);
|
|
148
|
+
if (typeof serialised === 'string') {
|
|
149
|
+
consumeMetadataSerialisedBytes(budget, utf8Length(serialised));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function ordinaryRecord(value, keys, label, budget) {
|
|
154
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
155
|
+
throw new TypeError(`${label} must be an ordinary object.`);
|
|
156
|
+
}
|
|
157
|
+
const prototype = Object.getPrototypeOf(value);
|
|
158
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
159
|
+
throw new TypeError(`${label} must be an ordinary object.`);
|
|
160
|
+
}
|
|
161
|
+
const keySets = Array.isArray(keys)
|
|
162
|
+
? keys
|
|
163
|
+
: [keys];
|
|
164
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
165
|
+
const selectedKeys = keySets.find((candidate) => ownKeys.length === candidate.size
|
|
166
|
+
&& ownKeys.every((key) => typeof key === 'string' && candidate.has(key)));
|
|
167
|
+
if (!selectedKeys) {
|
|
168
|
+
throw new TypeError(`${label} must use its exact registered fields.`);
|
|
169
|
+
}
|
|
170
|
+
consumeMetadataSlots(budget, selectedKeys.size + 1);
|
|
171
|
+
consumeMetadataSerialisedBytes(budget, 2 + Math.max(0, ownKeys.length - 1) + ownKeys
|
|
172
|
+
.reduce((bytes, key) => bytes + utf8Length(JSON.stringify(key)) + 1, 0));
|
|
173
|
+
const result = Object.create(null);
|
|
174
|
+
for (const key of ownKeys) {
|
|
175
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
176
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
177
|
+
throw new TypeError(`${label} must use ordinary enumerable data fields.`);
|
|
178
|
+
}
|
|
179
|
+
consumeMetadataValue(budget, descriptor.value);
|
|
180
|
+
result[key] = descriptor.value;
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
function denseArray(value, label, maximum, minimum = 1, budget) {
|
|
185
|
+
if (!Array.isArray(value)
|
|
186
|
+
|| Object.getPrototypeOf(value) !== Array.prototype
|
|
187
|
+
|| Object.prototype.hasOwnProperty.call(value, Symbol.iterator)) {
|
|
188
|
+
throw new TypeError(`${label} must be a bounded ordinary array.`);
|
|
189
|
+
}
|
|
190
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
|
|
191
|
+
const length = lengthDescriptor && 'value' in lengthDescriptor ? lengthDescriptor.value : null;
|
|
192
|
+
if (!Number.isSafeInteger(length) || Number(length) < minimum || Number(length) > maximum) {
|
|
193
|
+
throw new TypeError(`${label} must be a bounded ordinary array.`);
|
|
194
|
+
}
|
|
195
|
+
consumeMetadataSlots(budget, Number(length) + 1);
|
|
196
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
197
|
+
if (ownKeys.length !== Number(length) + 1
|
|
198
|
+
|| ownKeys.at(-1) !== 'length'
|
|
199
|
+
|| ownKeys.slice(0, -1).some((key, index) => key !== String(index))) {
|
|
200
|
+
throw new TypeError(`${label} must be a dense ordinary array without extra fields.`);
|
|
201
|
+
}
|
|
202
|
+
consumeMetadataSerialisedBytes(budget, 2 + Math.max(0, Number(length) - 1));
|
|
203
|
+
const result = [];
|
|
204
|
+
for (let index = 0; index < Number(length); index += 1) {
|
|
205
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
206
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
207
|
+
throw new TypeError(`${label} must contain ordinary enumerable data entries.`);
|
|
208
|
+
}
|
|
209
|
+
consumeMetadataValue(budget, descriptor.value);
|
|
210
|
+
result.push(descriptor.value);
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
function boundedId(value, label) {
|
|
215
|
+
if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
|
|
216
|
+
throw new TypeError(`${label} is invalid.`);
|
|
217
|
+
}
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
function boundedSchema(value, label) {
|
|
221
|
+
if (typeof value !== 'string'
|
|
222
|
+
|| value.length > 120
|
|
223
|
+
|| !/^[-a-z0-9.]+$/u.test(value)
|
|
224
|
+
|| (value.startsWith('whoisleuth.') && !isCanonicalLocalSchemaIdentifier(value))) {
|
|
225
|
+
throw new TypeError(`${label} is invalid.`);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
function positiveInteger(value, label) {
|
|
230
|
+
if (!Number.isSafeInteger(value) || Number(value) <= 0) {
|
|
231
|
+
throw new TypeError(`${label} must be a positive safe integer.`);
|
|
232
|
+
}
|
|
233
|
+
return Number(value);
|
|
234
|
+
}
|
|
235
|
+
function nonNegativeInteger(value, label) {
|
|
236
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0) {
|
|
237
|
+
throw new TypeError(`${label} must be a non-negative safe integer.`);
|
|
238
|
+
}
|
|
239
|
+
return Number(value);
|
|
240
|
+
}
|
|
241
|
+
function boundedField(value, label) {
|
|
242
|
+
if (typeof value !== 'string' || !FIELD_PATTERN.test(value)) {
|
|
243
|
+
throw new TypeError(`${label} is invalid.`);
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
function boundedMetadataPath(value, label) {
|
|
248
|
+
if (typeof value !== 'string'
|
|
249
|
+
|| value.length > 160
|
|
250
|
+
|| !METADATA_PATH_PATTERN.test(value)
|
|
251
|
+
|| value.split('.').length > 16) {
|
|
252
|
+
throw new TypeError(`${label} is invalid.`);
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
function parentMetadataPath(value) {
|
|
257
|
+
const separator = value.lastIndexOf('.');
|
|
258
|
+
return separator < 0 ? null : value.slice(0, separator);
|
|
259
|
+
}
|
|
260
|
+
function metadataPathField(value) {
|
|
261
|
+
const separator = value.lastIndexOf('.');
|
|
262
|
+
return separator < 0 ? null : value.slice(separator + 1).replace(/\[\]$/u, '');
|
|
263
|
+
}
|
|
264
|
+
function metadataPathIsTransitivelyRequired(path, objectsByPath) {
|
|
265
|
+
let current = path;
|
|
266
|
+
while (current !== '$') {
|
|
267
|
+
const parentPath = parentMetadataPath(current);
|
|
268
|
+
const field = metadataPathField(current);
|
|
269
|
+
if (!parentPath)
|
|
270
|
+
return false;
|
|
271
|
+
const parent = objectsByPath.get(parentPath);
|
|
272
|
+
if (!parent || !field || !parent.requiredKeys.includes(field))
|
|
273
|
+
return false;
|
|
274
|
+
current = parentPath;
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
function boundedText(value, label, maximum = 1_000) {
|
|
279
|
+
if (typeof value !== 'string'
|
|
280
|
+
|| value.length < 1
|
|
281
|
+
|| value.length > maximum
|
|
282
|
+
|| /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
283
|
+
throw new TypeError(`${label} is invalid.`);
|
|
284
|
+
}
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
function oneOf(value, values, label) {
|
|
288
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
289
|
+
throw new TypeError(`${label} is invalid.`);
|
|
290
|
+
}
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
function boundedPath(value, label) {
|
|
294
|
+
if (typeof value !== 'string'
|
|
295
|
+
|| value.length < 3
|
|
296
|
+
|| value.length > 200
|
|
297
|
+
|| !OWNER_PATTERN.test(value)
|
|
298
|
+
|| value.startsWith('/')
|
|
299
|
+
|| value.includes('..')) {
|
|
300
|
+
throw new TypeError(`${label} is invalid.`);
|
|
301
|
+
}
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
function copyTarget(value, label, budget) {
|
|
305
|
+
if (value === null)
|
|
306
|
+
return null;
|
|
307
|
+
const source = ordinaryRecord(value, TARGET_KEYS, label, budget);
|
|
308
|
+
return Object.freeze({
|
|
309
|
+
schema: boundedSchema(source.schema, `${label} schema`),
|
|
310
|
+
version: positiveInteger(source.version, `${label} version`),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
function copyConsumerDiscriminator(value, label, budget) {
|
|
314
|
+
const source = ordinaryRecord(value, CONSUMER_DISCRIMINATOR_KEYS, label, budget);
|
|
315
|
+
return Object.freeze({
|
|
316
|
+
path: boundedMetadataPath(source.path, `${label} path`),
|
|
317
|
+
values: copyTextArray(source.values, `${label} values`, false, budget),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
function copyVariantDiscriminator(value, label, budget) {
|
|
321
|
+
const source = ordinaryRecord(value, CONTRACT_VARIANT_DISCRIMINATOR_KEYS, label, budget);
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
path: boundedMetadataPath(source.path, `${label} path`),
|
|
324
|
+
value: boundedText(source.value, `${label} value`),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function copyEmittedTarget(value, label, budget, metadataVersion) {
|
|
328
|
+
if (value === null)
|
|
329
|
+
return null;
|
|
330
|
+
const source = ordinaryRecord(value, metadataVersion === 4 ? EMITTED_TARGET_V4_KEYS : TARGET_KEYS, label, budget);
|
|
331
|
+
const discriminator = metadataVersion === 4 && Object.hasOwn(source, 'discriminator')
|
|
332
|
+
&& source.discriminator !== null
|
|
333
|
+
? copyVariantDiscriminator(source.discriminator, `${label} discriminator`, budget)
|
|
334
|
+
: null;
|
|
335
|
+
return Object.freeze({
|
|
336
|
+
schema: boundedSchema(source.schema, `${label} schema`),
|
|
337
|
+
version: positiveInteger(source.version, `${label} version`),
|
|
338
|
+
...(metadataVersion === 4 ? { discriminator } : {}),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function copyStringArray(value, label) {
|
|
342
|
+
const source = denseArray(value, label, MAX_FIXTURES);
|
|
343
|
+
const result = source.map((item) => boundedId(item, `${label} id`));
|
|
344
|
+
if (new Set(result).size !== result.length)
|
|
345
|
+
throw new TypeError(`${label} must not contain duplicates.`);
|
|
346
|
+
return Object.freeze(result);
|
|
347
|
+
}
|
|
348
|
+
function copyIdArray(value, label, allowEmpty = false, budget) {
|
|
349
|
+
const source = denseArray(value, label, MAX_NESTED_METADATA_COLLECTION, allowEmpty ? 0 : 1, budget);
|
|
350
|
+
const result = source.map((item) => boundedId(item, `${label} id`));
|
|
351
|
+
if (new Set(result).size !== result.length)
|
|
352
|
+
throw new TypeError(`${label} must not contain duplicates.`);
|
|
353
|
+
return Object.freeze(result);
|
|
354
|
+
}
|
|
355
|
+
function copyFieldArray(value, label, allowEmpty = false, budget) {
|
|
356
|
+
const source = denseArray(value, label, MAX_NESTED_METADATA_COLLECTION, allowEmpty ? 0 : 1, budget);
|
|
357
|
+
const result = source.map((item) => boundedField(item, `${label} field`));
|
|
358
|
+
if (new Set(result).size !== result.length)
|
|
359
|
+
throw new TypeError(`${label} must not contain duplicates.`);
|
|
360
|
+
return Object.freeze(result);
|
|
361
|
+
}
|
|
362
|
+
function copyAlternativeRequiredKeys(value, label, budget) {
|
|
363
|
+
const groups = denseArray(value, label, MAX_NESTED_METADATA_COLLECTION, 0, budget).map((group, index) => {
|
|
364
|
+
const groupLabel = `${label} group ${index + 1}`;
|
|
365
|
+
const source = ordinaryRecord(group, ALTERNATIVE_REQUIRED_KEYS, groupLabel, budget);
|
|
366
|
+
return Object.freeze({
|
|
367
|
+
keys: copyFieldArray(source.keys, `${groupLabel} keys`, false, budget),
|
|
368
|
+
resolution: oneOf(source.resolution, ['first_present'], `${groupLabel} resolution`),
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
if (groups.some((group) => group.keys.length < 2)) {
|
|
372
|
+
throw new TypeError(`${label} groups must each contain at least two alternative fields.`);
|
|
373
|
+
}
|
|
374
|
+
const flattened = groups.flatMap((group) => group.keys);
|
|
375
|
+
if (new Set(flattened).size !== flattened.length) {
|
|
376
|
+
throw new TypeError(`${label} fields must belong to only one alternative group.`);
|
|
377
|
+
}
|
|
378
|
+
return Object.freeze(groups);
|
|
379
|
+
}
|
|
380
|
+
function copyTextArray(value, label, allowEmpty = false, budget) {
|
|
381
|
+
const source = denseArray(value, label, MAX_NESTED_METADATA_COLLECTION, allowEmpty ? 0 : 1, budget);
|
|
382
|
+
const result = source.map((item) => boundedText(item, `${label} value`));
|
|
383
|
+
if (new Set(result).size !== result.length)
|
|
384
|
+
throw new TypeError(`${label} must not contain duplicates.`);
|
|
385
|
+
return Object.freeze(result);
|
|
386
|
+
}
|
|
387
|
+
function copyVersionArray(value, label, budget) {
|
|
388
|
+
const result = denseArray(value, label, MAX_CONTRACTS, 1, budget)
|
|
389
|
+
.map((version) => positiveInteger(version, `${label} version`));
|
|
390
|
+
if (new Set(result).size !== result.length
|
|
391
|
+
|| result.some((version, index) => index > 0 && version <= (result[index - 1] ?? 0))) {
|
|
392
|
+
throw new TypeError(`${label} must contain unique ascending versions.`);
|
|
393
|
+
}
|
|
394
|
+
return Object.freeze(result);
|
|
395
|
+
}
|
|
396
|
+
function copyCompatibility(value, index) {
|
|
397
|
+
const label = `Schema lifecycle compatibility descriptor ${index + 1}`;
|
|
398
|
+
const source = ordinaryRecord(value, COMPATIBILITY_KEYS, label);
|
|
399
|
+
const supportedVersions = denseArray(source.supportedVersions, `${label} supported versions`, MAX_CONTRACTS)
|
|
400
|
+
.map((version) => positiveInteger(version, `${label} supported version`));
|
|
401
|
+
return defineSchemaCompatibility({
|
|
402
|
+
id: source.id,
|
|
403
|
+
tier: source.tier,
|
|
404
|
+
kind: source.kind,
|
|
405
|
+
schema: source.schema,
|
|
406
|
+
currentVersion: source.currentVersion,
|
|
407
|
+
supportedVersions,
|
|
408
|
+
acceptsUnversionedLegacy: source.acceptsUnversionedLegacy,
|
|
409
|
+
futureVersionBehavior: source.futureVersionBehavior,
|
|
410
|
+
migration: source.migration,
|
|
411
|
+
writeSemantics: source.writeSemantics,
|
|
412
|
+
byteBudget: source.byteBudget,
|
|
413
|
+
owner: source.owner,
|
|
414
|
+
note: source.note,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function copyContract(value, index) {
|
|
418
|
+
const label = `Schema lifecycle contract ${index + 1}`;
|
|
419
|
+
const source = ordinaryRecord(value, CONTRACT_KEYS, label);
|
|
420
|
+
const schema = boundedSchema(source.schema, `${label} schema`);
|
|
421
|
+
const version = positiveInteger(source.version, `${label} version`);
|
|
422
|
+
const role = source.role;
|
|
423
|
+
const lifecycle = source.lifecycle;
|
|
424
|
+
if (role !== 'input' && role !== 'document')
|
|
425
|
+
throw new TypeError(`${label} has an invalid role.`);
|
|
426
|
+
if (lifecycle !== 'current' && lifecycle !== 'legacy' && lifecycle !== 'retired') {
|
|
427
|
+
throw new TypeError(`${label} has an invalid lifecycle state.`);
|
|
428
|
+
}
|
|
429
|
+
if (typeof source.readable !== 'boolean' || typeof source.emitted !== 'boolean') {
|
|
430
|
+
throw new TypeError(`${label} has invalid read or emission metadata.`);
|
|
431
|
+
}
|
|
432
|
+
const exactKeys = source.exactKeys;
|
|
433
|
+
const extensionPolicy = source.extensionPolicy;
|
|
434
|
+
if ((exactKeys !== true && exactKeys !== false)
|
|
435
|
+
|| (extensionPolicy !== 'reject'
|
|
436
|
+
&& extensionPolicy !== 'preserve_bounded'
|
|
437
|
+
&& extensionPolicy !== 'discard_bounded')
|
|
438
|
+
|| (exactKeys === true) !== (extensionPolicy === 'reject')
|
|
439
|
+
|| (source.futureVersionBehaviour !== 'discard'
|
|
440
|
+
&& source.futureVersionBehaviour !== 'reject'
|
|
441
|
+
&& source.futureVersionBehaviour !== 'preserve_without_write'
|
|
442
|
+
&& source.futureVersionBehaviour !== 'not_applicable')) {
|
|
443
|
+
throw new TypeError(`${label} must reconcile exact keys with its bounded extension policy and declare future-version handling.`);
|
|
444
|
+
}
|
|
445
|
+
const canonicalisation = source.canonicalisation;
|
|
446
|
+
if (canonicalisation !== null
|
|
447
|
+
&& (typeof canonicalisation !== 'string'
|
|
448
|
+
|| canonicalisation.length > 80
|
|
449
|
+
|| !CANONICALISATION_PATTERN.test(canonicalisation))) {
|
|
450
|
+
throw new TypeError(`${label} has an invalid canonicalisation identifier.`);
|
|
451
|
+
}
|
|
452
|
+
const byteBudget = source.byteBudget;
|
|
453
|
+
if (byteBudget !== null && (!Number.isSafeInteger(byteBudget) || Number(byteBudget) <= 0)) {
|
|
454
|
+
throw new TypeError(`${label} has an invalid byte budget.`);
|
|
455
|
+
}
|
|
456
|
+
return Object.freeze({
|
|
457
|
+
compatibilityId: boundedId(source.compatibilityId, `${label} compatibility id`),
|
|
458
|
+
schema,
|
|
459
|
+
version,
|
|
460
|
+
role,
|
|
461
|
+
lifecycle,
|
|
462
|
+
readable: source.readable,
|
|
463
|
+
emitted: source.emitted,
|
|
464
|
+
exactKeys,
|
|
465
|
+
extensionPolicy,
|
|
466
|
+
futureVersionBehaviour: source.futureVersionBehaviour,
|
|
467
|
+
migrationTarget: copyTarget(source.migrationTarget, `${label} migration target`),
|
|
468
|
+
canonicalisation,
|
|
469
|
+
byteBudget: byteBudget === null ? null : Number(byteBudget),
|
|
470
|
+
fixtureIds: copyStringArray(source.fixtureIds, `${label} fixtures`),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function copyFixture(value, index) {
|
|
474
|
+
const label = `Schema lifecycle fixture ${index + 1}`;
|
|
475
|
+
const source = ordinaryRecord(value, [FIXTURE_KEYS, FIXTURE_V4_KEYS], label);
|
|
476
|
+
const role = source.role;
|
|
477
|
+
const expectation = source.expectation;
|
|
478
|
+
if (role !== 'input' && role !== 'historical' && role !== 'current') {
|
|
479
|
+
throw new TypeError(`${label} has an invalid role.`);
|
|
480
|
+
}
|
|
481
|
+
if (expectation !== 'accepted_exact'
|
|
482
|
+
&& expectation !== 'historical_output_exact'
|
|
483
|
+
&& expectation !== 'normalises_to_current_output') {
|
|
484
|
+
throw new TypeError(`${label} has an invalid expectation.`);
|
|
485
|
+
}
|
|
486
|
+
const contentDigest = source.contentDigestSha256;
|
|
487
|
+
if (contentDigest !== null
|
|
488
|
+
&& (typeof contentDigest !== 'string' || !CONTENT_DIGEST_PATTERN.test(contentDigest))) {
|
|
489
|
+
throw new TypeError(`${label} has an invalid content digest.`);
|
|
490
|
+
}
|
|
491
|
+
const expectedOutputFixtureId = source.expectedOutputFixtureId;
|
|
492
|
+
if (expectedOutputFixtureId !== null && typeof expectedOutputFixtureId !== 'string') {
|
|
493
|
+
throw new TypeError(`${label} has an invalid output fixture reference.`);
|
|
494
|
+
}
|
|
495
|
+
if (typeof source.sha256 !== 'string' || !SHA256_PATTERN.test(source.sha256)) {
|
|
496
|
+
throw new TypeError(`${label} has an invalid file digest.`);
|
|
497
|
+
}
|
|
498
|
+
if (source.scope !== 'repository')
|
|
499
|
+
throw new TypeError(`${label} must declare repository fixture scope.`);
|
|
500
|
+
return Object.freeze({
|
|
501
|
+
id: boundedId(source.id, `${label} id`),
|
|
502
|
+
path: boundedPath(source.path, `${label} path`),
|
|
503
|
+
bytes: positiveInteger(source.bytes, `${label} bytes`),
|
|
504
|
+
sha256: source.sha256,
|
|
505
|
+
contentDigestSha256: contentDigest,
|
|
506
|
+
schema: boundedSchema(source.schema, `${label} schema`),
|
|
507
|
+
version: positiveInteger(source.version, `${label} version`),
|
|
508
|
+
role,
|
|
509
|
+
expectation,
|
|
510
|
+
expectedOutputFixtureId: expectedOutputFixtureId === null
|
|
511
|
+
? null
|
|
512
|
+
: boundedId(expectedOutputFixtureId, `${label} output fixture`),
|
|
513
|
+
scope: 'repository',
|
|
514
|
+
...(Object.hasOwn(source, 'shapeId')
|
|
515
|
+
? {
|
|
516
|
+
shapeId: source.shapeId === null
|
|
517
|
+
? null
|
|
518
|
+
: boundedId(source.shapeId, `${label} shape`),
|
|
519
|
+
}
|
|
520
|
+
: {}),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
function copyObjectShape(value, index, shapeLabel, budget, metadataVersion) {
|
|
524
|
+
const label = `${shapeLabel} object ${index + 1}`;
|
|
525
|
+
const source = ordinaryRecord(value, metadataVersion === 4 ? OBJECT_SHAPE_V4_KEYS : OBJECT_SHAPE_KEYS, label, budget);
|
|
526
|
+
const requiredKeys = copyFieldArray(source.requiredKeys, `${label} required keys`, true, budget);
|
|
527
|
+
const optionalKeys = copyFieldArray(source.optionalKeys, `${label} optional keys`, true, budget);
|
|
528
|
+
if (requiredKeys.some((key) => optionalKeys.includes(key))) {
|
|
529
|
+
throw new TypeError(`${label} required and optional keys must not overlap.`);
|
|
530
|
+
}
|
|
531
|
+
const alternativeRequiredKeys = metadataVersion === 4
|
|
532
|
+
? copyAlternativeRequiredKeys(source.alternativeRequiredKeys, `${label} alternative required keys`, budget)
|
|
533
|
+
: Object.freeze([]);
|
|
534
|
+
if (alternativeRequiredKeys.flatMap((group) => group.keys)
|
|
535
|
+
.some((key) => requiredKeys.includes(key) || optionalKeys.includes(key))) {
|
|
536
|
+
throw new TypeError(`${label} alternative fields must not overlap required or optional keys.`);
|
|
537
|
+
}
|
|
538
|
+
const unknownKeys = oneOf(source.unknownKeys, metadataVersion === 4
|
|
539
|
+
? ['discard_bounded', 'preserve_bounded', 'reject']
|
|
540
|
+
: ['preserve_bounded', 'reject'], `${label} unknown-key handling`);
|
|
541
|
+
return Object.freeze({
|
|
542
|
+
path: boundedMetadataPath(source.path, `${label} path`),
|
|
543
|
+
requiredKeys,
|
|
544
|
+
optionalKeys,
|
|
545
|
+
...(metadataVersion === 4 ? { alternativeRequiredKeys } : {}),
|
|
546
|
+
unknownKeys,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
function copyFixedArrayShape(value, index, shapeLabel, budget) {
|
|
550
|
+
const label = `${shapeLabel} fixed array ${index + 1}`;
|
|
551
|
+
const source = ordinaryRecord(value, FIXED_ARRAY_SHAPE_KEYS, label, budget);
|
|
552
|
+
return Object.freeze({
|
|
553
|
+
path: boundedMetadataPath(source.path, `${label} path`),
|
|
554
|
+
values: copyTextArray(source.values, `${label} values`, false, budget),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
function copyShape(value, index, budget, metadataVersion) {
|
|
558
|
+
const label = `Schema lifecycle shape ${index + 1}`;
|
|
559
|
+
const source = ordinaryRecord(value, metadataVersion === 4 ? SHAPE_V4_KEYS : SHAPE_KEYS, label, budget);
|
|
560
|
+
const objects = Object.freeze(denseArray(source.objects, `${label} objects`, MAX_NESTED_METADATA_COLLECTION, 1, budget).map((item, objectIndex) => copyObjectShape(item, objectIndex, label, budget, metadataVersion)));
|
|
561
|
+
const fixedArrays = Object.freeze(denseArray(source.fixedArrays, `${label} fixed arrays`, MAX_NESTED_METADATA_COLLECTION, 0, budget).map((item, arrayIndex) => copyFixedArrayShape(item, arrayIndex, label, budget)));
|
|
562
|
+
if (new Set(objects.map((item) => item.path)).size !== objects.length
|
|
563
|
+
|| new Set(fixedArrays.map((item) => item.path)).size !== fixedArrays.length) {
|
|
564
|
+
throw new TypeError(`${label} paths must be unique within their shape kind.`);
|
|
565
|
+
}
|
|
566
|
+
const normalisation = oneOf(source.normalisation, metadataVersion === 4
|
|
567
|
+
? ['input_to_current', 'preserve_bounded_document', 'preserve_document', 'preserve_signed_document', 'project_known_fields']
|
|
568
|
+
: ['input_to_current', 'preserve_bounded_document', 'preserve_document', 'preserve_signed_document'], `${label} normalisation`);
|
|
569
|
+
const target = copyTarget(source.target, `${label} target`, budget);
|
|
570
|
+
if ((normalisation === 'input_to_current') !== (target !== null)) {
|
|
571
|
+
throw new TypeError(`${label} input normalisation must name one target and document preservation must not.`);
|
|
572
|
+
}
|
|
573
|
+
return Object.freeze({
|
|
574
|
+
id: boundedId(source.id, `${label} id`),
|
|
575
|
+
schema: boundedSchema(source.schema, `${label} schema`),
|
|
576
|
+
versions: copyVersionArray(source.versions, `${label} versions`, budget),
|
|
577
|
+
objects,
|
|
578
|
+
fixedArrays,
|
|
579
|
+
normalisation,
|
|
580
|
+
target,
|
|
581
|
+
...(metadataVersion === 4
|
|
582
|
+
? {
|
|
583
|
+
discriminator: source.discriminator === null
|
|
584
|
+
? null
|
|
585
|
+
: copyVariantDiscriminator(source.discriminator, `${label} discriminator`, budget),
|
|
586
|
+
}
|
|
587
|
+
: {}),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
function copyBound(value, index, profileLabel, budget) {
|
|
591
|
+
const label = `${profileLabel} bound ${index + 1}`;
|
|
592
|
+
const source = ordinaryRecord(value, BOUND_KEYS, label, budget);
|
|
593
|
+
const minimum = source.minimum === null
|
|
594
|
+
? null
|
|
595
|
+
: nonNegativeInteger(source.minimum, `${label} minimum`);
|
|
596
|
+
const maximum = positiveInteger(source.maximum, `${label} maximum`);
|
|
597
|
+
if (minimum !== null && minimum > maximum)
|
|
598
|
+
throw new TypeError(`${label} minimum exceeds its maximum.`);
|
|
599
|
+
const phase = oneOf(source.phase, ['action', 'normalised', 'pre_accumulation', 'raw_intake', 'serialised'], `${label} phase`);
|
|
600
|
+
const handling = oneOf(source.handling, ['cap_operation', 'drop_value', 'reject', 'truncate'], `${label} handling`);
|
|
601
|
+
if ((handling === 'cap_operation' && phase !== 'action')
|
|
602
|
+
|| (handling === 'truncate' && phase !== 'normalised')
|
|
603
|
+
|| (handling === 'drop_value' && phase !== 'pre_accumulation' && phase !== 'normalised')) {
|
|
604
|
+
throw new TypeError(`${label} has inconsistent phase and handling metadata.`);
|
|
605
|
+
}
|
|
606
|
+
return Object.freeze({
|
|
607
|
+
id: boundedId(source.id, `${label} id`),
|
|
608
|
+
path: boundedMetadataPath(source.path, `${label} path`),
|
|
609
|
+
phase,
|
|
610
|
+
unit: oneOf(source.unit, ['bytes', 'characters', 'concurrency', 'depth', 'entries', 'finite_number', 'integer', 'items', 'keys', 'values'], `${label} unit`),
|
|
611
|
+
minimum,
|
|
612
|
+
maximum,
|
|
613
|
+
handling,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function copyBoundProfile(value, index, budget) {
|
|
617
|
+
const label = `Schema lifecycle bound profile ${index + 1}`;
|
|
618
|
+
const source = ordinaryRecord(value, BOUND_PROFILE_KEYS, label, budget);
|
|
619
|
+
const bounds = Object.freeze(denseArray(source.bounds, `${label} bounds`, MAX_NESTED_METADATA_COLLECTION, 1, budget).map((item, boundIndex) => copyBound(item, boundIndex, label, budget)));
|
|
620
|
+
if (new Set(bounds.map((bound) => bound.id)).size !== bounds.length) {
|
|
621
|
+
throw new TypeError(`${label} bound ids must be unique.`);
|
|
622
|
+
}
|
|
623
|
+
return Object.freeze({
|
|
624
|
+
id: boundedId(source.id, `${label} id`),
|
|
625
|
+
bounds,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
function copyHook(value, index, budget) {
|
|
629
|
+
const label = `Schema lifecycle hook ${index + 1}`;
|
|
630
|
+
const source = ordinaryRecord(value, HOOK_KEYS, label, budget);
|
|
631
|
+
const exportName = source.exportName;
|
|
632
|
+
if (typeof exportName !== 'string' || !EXPORT_NAME_PATTERN.test(exportName)) {
|
|
633
|
+
throw new TypeError(`${label} export name is invalid.`);
|
|
634
|
+
}
|
|
635
|
+
return Object.freeze({
|
|
636
|
+
id: boundedId(source.id, `${label} id`),
|
|
637
|
+
role: oneOf(source.role, [
|
|
638
|
+
'budget_guard',
|
|
639
|
+
'builder',
|
|
640
|
+
'integrity_verifier',
|
|
641
|
+
'interchange_reporter',
|
|
642
|
+
'merger',
|
|
643
|
+
'monitor',
|
|
644
|
+
'normaliser',
|
|
645
|
+
'private_file_writer',
|
|
646
|
+
'reviewer',
|
|
647
|
+
'serialised_byte_counter',
|
|
648
|
+
'serialiser',
|
|
649
|
+
'sharing_reviewer',
|
|
650
|
+
'signature_verifier',
|
|
651
|
+
'signer',
|
|
652
|
+
'structure_validator',
|
|
653
|
+
], `${label} role`),
|
|
654
|
+
runtime: oneOf(source.runtime, ['browser', 'cli', 'node', 'shared'], `${label} runtime`),
|
|
655
|
+
module: boundedPath(source.module, `${label} module`),
|
|
656
|
+
exportName,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
function copySerialisationProfile(value, index, budget) {
|
|
660
|
+
const label = `Schema lifecycle serialisation profile ${index + 1}`;
|
|
661
|
+
const source = ordinaryRecord(value, SERIALISATION_PROFILE_KEYS, label, budget);
|
|
662
|
+
if (source.mediaType !== 'application/json'
|
|
663
|
+
|| source.encoding !== 'utf-8'
|
|
664
|
+
|| source.bom !== false
|
|
665
|
+
|| (source.indentSpaces !== 0 && source.indentSpaces !== 2)
|
|
666
|
+
|| typeof source.terminalLf !== 'boolean'
|
|
667
|
+
|| (source.propertyOrder !== 'normalised_fixed' && source.propertyOrder !== 'source_insertion')
|
|
668
|
+
|| (source.integrity !== 'none'
|
|
669
|
+
&& source.integrity !== 'structural_only_requires_separate_verification')) {
|
|
670
|
+
throw new TypeError(`${label} must use the registered portable JSON contract.`);
|
|
671
|
+
}
|
|
672
|
+
const integrity = source.integrity;
|
|
673
|
+
const verifierHookIds = copyIdArray(source.verifierHookIds, `${label} verifier hooks`, integrity === 'none', budget);
|
|
674
|
+
if ((integrity === 'none') !== (verifierHookIds.length === 0)) {
|
|
675
|
+
throw new TypeError(`${label} must declare verifier hooks only when separate integrity verification is required.`);
|
|
676
|
+
}
|
|
677
|
+
return Object.freeze({
|
|
678
|
+
id: boundedId(source.id, `${label} id`),
|
|
679
|
+
schema: boundedSchema(source.schema, `${label} schema`),
|
|
680
|
+
versions: copyVersionArray(source.versions, `${label} versions`, budget),
|
|
681
|
+
mediaType: 'application/json',
|
|
682
|
+
encoding: 'utf-8',
|
|
683
|
+
bom: false,
|
|
684
|
+
indentSpaces: source.indentSpaces,
|
|
685
|
+
terminalLf: source.terminalLf,
|
|
686
|
+
propertyOrder: source.propertyOrder,
|
|
687
|
+
canonicalisation: source.canonicalisation === null
|
|
688
|
+
? null
|
|
689
|
+
: (() => { throw new TypeError(`${label} portable serialisation must remain separate from digest canonicalisation.`); })(),
|
|
690
|
+
integrity,
|
|
691
|
+
serializerHookId: boundedId(source.serializerHookId, `${label} serialiser hook`),
|
|
692
|
+
verifierHookIds,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
function copyPrivacyProfile(value, index, budget) {
|
|
696
|
+
const label = `Schema lifecycle privacy profile ${index + 1}`;
|
|
697
|
+
const source = ordinaryRecord(value, PRIVACY_PROFILE_KEYS, label, budget);
|
|
698
|
+
return Object.freeze({
|
|
699
|
+
id: boundedId(source.id, `${label} id`),
|
|
700
|
+
classification: oneOf(source.classification, ['analyst_authored_sensitive', 'metadata_only'], `${label} classification`),
|
|
701
|
+
projection: oneOf(source.projection, [
|
|
702
|
+
'bounded_passive_monitor',
|
|
703
|
+
'browser_export',
|
|
704
|
+
'browser_import',
|
|
705
|
+
'full_manifest',
|
|
706
|
+
'metadata_only',
|
|
707
|
+
'review_output',
|
|
708
|
+
'signed_wrapper',
|
|
709
|
+
], `${label} projection`),
|
|
710
|
+
includedCategories: copyIdArray(source.includedCategories, `${label} included categories`, false, budget),
|
|
711
|
+
excludedCategories: copyIdArray(source.excludedCategories, `${label} excluded categories`, true, budget),
|
|
712
|
+
notePolicy: oneOf(source.notePolicy, ['allowed_bounded', 'discarded', 'forced_null', 'not_applicable'], `${label} note policy`),
|
|
713
|
+
retention: oneOf(source.retention, ['browser_indexeddb', 'deliberate_local_file', 'none', 'operator_controlled_output', 'transient_report'], `${label} retention`),
|
|
714
|
+
network: oneOf(source.network, [
|
|
715
|
+
'explicit_bounded_passive_deep',
|
|
716
|
+
'explicit_bounded_passive_fast_or_deep',
|
|
717
|
+
'none',
|
|
718
|
+
], `${label} network`),
|
|
719
|
+
sharingReview: oneOf(source.sharingReview, ['not_applicable', 'required'], `${label} sharing review`),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
function copyExpiryPolicy(value, index, budget) {
|
|
723
|
+
const label = `Schema lifecycle expiry policy ${index + 1}`;
|
|
724
|
+
const source = ordinaryRecord(value, EXPIRY_POLICY_KEYS, label, budget);
|
|
725
|
+
const handling = oneOf(source.handling, ['integrity_only', 'not_applicable', 'reject_expired', 'report_expired', 'require_after_anchor'], `${label} handling`);
|
|
726
|
+
const anchor = source.anchor === null
|
|
727
|
+
? null
|
|
728
|
+
: oneOf(source.anchor, ['checkedAt', 'generatedAt'], `${label} anchor`);
|
|
729
|
+
const field = source.field === null
|
|
730
|
+
? null
|
|
731
|
+
: oneOf(source.field, ['expiresAt'], `${label} field`);
|
|
732
|
+
if ((handling === 'not_applicable'
|
|
733
|
+
&& (field !== null || anchor !== null || source.maximumLifetimeDays !== null))
|
|
734
|
+
|| (handling !== 'not_applicable' && field !== 'expiresAt')
|
|
735
|
+
|| (handling === 'integrity_only' && anchor !== null)
|
|
736
|
+
|| (handling === 'require_after_anchor' && anchor !== 'generatedAt')
|
|
737
|
+
|| ((handling === 'reject_expired' || handling === 'report_expired') && anchor !== 'checkedAt')) {
|
|
738
|
+
throw new TypeError(`${label} has inconsistent expiry handling.`);
|
|
739
|
+
}
|
|
740
|
+
const phase = oneOf(source.phase, ['build', 'not_applicable', 'pre_action', 'review', 'verification'], `${label} phase`);
|
|
741
|
+
if ((handling === 'require_after_anchor' && phase !== 'build')
|
|
742
|
+
|| (handling === 'reject_expired' && phase !== 'pre_action')
|
|
743
|
+
|| (handling === 'report_expired' && phase !== 'review')
|
|
744
|
+
|| (handling === 'integrity_only' && phase !== 'verification')
|
|
745
|
+
|| (handling === 'not_applicable' && phase !== 'not_applicable')) {
|
|
746
|
+
throw new TypeError(`${label} has an inconsistent phase.`);
|
|
747
|
+
}
|
|
748
|
+
const maximumLifetimeDays = source.maximumLifetimeDays === null
|
|
749
|
+
? null
|
|
750
|
+
: positiveInteger(source.maximumLifetimeDays, `${label} maximum lifetime days`);
|
|
751
|
+
if (maximumLifetimeDays !== null && maximumLifetimeDays > 36_500) {
|
|
752
|
+
throw new TypeError(`${label} maximum lifetime is invalid.`);
|
|
753
|
+
}
|
|
754
|
+
return Object.freeze({
|
|
755
|
+
id: boundedId(source.id, `${label} id`),
|
|
756
|
+
field,
|
|
757
|
+
anchor,
|
|
758
|
+
handling,
|
|
759
|
+
phase,
|
|
760
|
+
maximumLifetimeDays,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
function copyContractReference(value, index, consumerLabel, budget, metadataVersion) {
|
|
764
|
+
const label = `${consumerLabel} accepted contract ${index + 1}`;
|
|
765
|
+
const source = ordinaryRecord(value, metadataVersion === 4
|
|
766
|
+
? CONTRACT_REFERENCE_V3_KEYS
|
|
767
|
+
: metadataVersion === 3
|
|
768
|
+
? [CONTRACT_REFERENCE_KEYS, CONTRACT_REFERENCE_V3_KEYS]
|
|
769
|
+
: CONTRACT_REFERENCE_KEYS, label, budget);
|
|
770
|
+
let discriminator;
|
|
771
|
+
if (metadataVersion >= 3) {
|
|
772
|
+
if (!Object.hasOwn(source, 'discriminator') || source.discriminator === null) {
|
|
773
|
+
discriminator = null;
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
discriminator = copyConsumerDiscriminator(source.discriminator, `${label} discriminator`, budget);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return Object.freeze({
|
|
780
|
+
schema: boundedSchema(source.schema, `${label} schema`),
|
|
781
|
+
versions: copyVersionArray(source.versions, `${label} versions`, budget),
|
|
782
|
+
mode: oneOf(source.mode, ['direct', 'embedded'], `${label} mode`),
|
|
783
|
+
...(metadataVersion >= 3 ? { discriminator: discriminator ?? null } : {}),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function copyConsumerEdge(value, index, budget, metadataVersion) {
|
|
787
|
+
const label = `Schema lifecycle consumer ${index + 1}`;
|
|
788
|
+
const source = ordinaryRecord(value, CONSUMER_KEYS, label, budget);
|
|
789
|
+
const serialisationProfileId = source.serialisationProfileId;
|
|
790
|
+
if (serialisationProfileId !== null && typeof serialisationProfileId !== 'string') {
|
|
791
|
+
throw new TypeError(`${label} serialisation profile is invalid.`);
|
|
792
|
+
}
|
|
793
|
+
return Object.freeze({
|
|
794
|
+
id: boundedId(source.id, `${label} id`),
|
|
795
|
+
plane: oneOf(source.plane, ['browser', 'cli', 'node', 'shared'], `${label} plane`),
|
|
796
|
+
operation: boundedId(source.operation, `${label} operation`),
|
|
797
|
+
acceptedContracts: Object.freeze(denseArray(source.acceptedContracts, `${label} accepted contracts`, MAX_NESTED_METADATA_COLLECTION, 0, budget).map((item, referenceIndex) => copyContractReference(item, referenceIndex, label, budget, metadataVersion))),
|
|
798
|
+
emittedContract: copyEmittedTarget(source.emittedContract, `${label} emitted contract`, budget, metadataVersion),
|
|
799
|
+
shapeIds: copyIdArray(source.shapeIds, `${label} shapes`, false, budget),
|
|
800
|
+
boundProfileIds: copyIdArray(source.boundProfileIds, `${label} bound profiles`, false, budget),
|
|
801
|
+
hookIds: copyIdArray(source.hookIds, `${label} hooks`, false, budget),
|
|
802
|
+
serialisationProfileId: serialisationProfileId === null
|
|
803
|
+
? null
|
|
804
|
+
: boundedId(serialisationProfileId, `${label} serialisation profile`),
|
|
805
|
+
privacyProfileId: boundedId(source.privacyProfileId, `${label} privacy profile`),
|
|
806
|
+
expiryPolicyId: boundedId(source.expiryPolicyId, `${label} expiry policy`),
|
|
807
|
+
requestMode: oneOf(source.requestMode, [
|
|
808
|
+
'explicit_bounded_passive_deep',
|
|
809
|
+
'explicit_bounded_passive_fast_or_deep',
|
|
810
|
+
'none',
|
|
811
|
+
], `${label} request mode`),
|
|
812
|
+
retentionEffect: oneOf(source.retentionEffect, ['browser_indexeddb', 'deliberate_local_file', 'none', 'operator_controlled_output', 'transient_report'], `${label} retention effect`),
|
|
813
|
+
bindingState: oneOf(source.bindingState, ['declared_unenforced'], `${label} binding state`),
|
|
814
|
+
policyState: oneOf(source.policyState, ['current', 'target'], `${label} policy state`),
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
function copyConsumerRelationship(value, index, budget) {
|
|
818
|
+
const label = `Schema lifecycle consumer relationship ${index + 1}`;
|
|
819
|
+
const source = ordinaryRecord(value, CONSUMER_RELATIONSHIP_KEYS, label, budget);
|
|
820
|
+
return Object.freeze({
|
|
821
|
+
id: boundedId(source.id, `${label} id`),
|
|
822
|
+
sourceConsumerId: boundedId(source.sourceConsumerId, `${label} source consumer`),
|
|
823
|
+
targetConsumerId: boundedId(source.targetConsumerId, `${label} target consumer`),
|
|
824
|
+
relationship: oneOf(source.relationship, ['composes'], `${label} relationship`),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
function validateFamilyRelations(family) {
|
|
828
|
+
const contractPairs = new Map(family.contracts.map((contract) => [`${contract.schema}\u0000${contract.version}`, contract]));
|
|
829
|
+
if (contractPairs.size !== family.contracts.length) {
|
|
830
|
+
throw new TypeError('Schema lifecycle contracts must use unique schema and version pairs.');
|
|
831
|
+
}
|
|
832
|
+
const compatibilityById = new Map(family.compatibility.map((descriptor) => [descriptor.id, descriptor]));
|
|
833
|
+
if (compatibilityById.size !== family.compatibility.length) {
|
|
834
|
+
throw new TypeError('Schema lifecycle compatibility descriptor ids must be unique.');
|
|
835
|
+
}
|
|
836
|
+
const compatibilityOwners = new Map();
|
|
837
|
+
for (const schema of new Set(family.contracts.map((contract) => contract.schema))) {
|
|
838
|
+
const contracts = family.contracts.filter((contract) => contract.schema === schema);
|
|
839
|
+
const current = contracts.filter((contract) => contract.lifecycle === 'current');
|
|
840
|
+
const maximumVersion = Math.max(...contracts.map((contract) => contract.version));
|
|
841
|
+
if (current.length !== 1 || current[0]?.version !== maximumVersion) {
|
|
842
|
+
throw new TypeError(`Schema lifecycle ${schema} must have one current greatest version.`);
|
|
843
|
+
}
|
|
844
|
+
if (new Set(contracts.map((contract) => contract.compatibilityId)).size !== 1
|
|
845
|
+
|| new Set(contracts.map((contract) => contract.role)).size !== 1) {
|
|
846
|
+
throw new TypeError(`Schema lifecycle ${schema} must use one compatibility id and role.`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const contract of family.contracts) {
|
|
850
|
+
if (contract.futureVersionBehaviour === 'not_applicable'
|
|
851
|
+
&& (contract.role !== 'document'
|
|
852
|
+
|| (contract.lifecycle !== 'retired'
|
|
853
|
+
&& (contract.lifecycle !== 'current' || contract.readable || !contract.emitted)))) {
|
|
854
|
+
throw new TypeError('Not-applicable future-version handling is limited to current emitted-only documents or retired documents.');
|
|
855
|
+
}
|
|
856
|
+
if (contract.lifecycle === 'legacy' && (!contract.readable || contract.emitted)) {
|
|
857
|
+
throw new TypeError('Legacy schema lifecycle contracts must remain readable and must not be emitted.');
|
|
858
|
+
}
|
|
859
|
+
if (contract.lifecycle === 'retired'
|
|
860
|
+
&& (contract.role !== 'document'
|
|
861
|
+
|| contract.readable
|
|
862
|
+
|| contract.emitted
|
|
863
|
+
|| !contract.exactKeys
|
|
864
|
+
|| contract.extensionPolicy !== 'reject'
|
|
865
|
+
|| contract.futureVersionBehaviour !== 'not_applicable'
|
|
866
|
+
|| contract.migrationTarget !== null
|
|
867
|
+
|| contract.fixtureIds.length === 0)) {
|
|
868
|
+
throw new TypeError('Retired schema lifecycle contracts must be fixture-backed output-only history.');
|
|
869
|
+
}
|
|
870
|
+
if (contract.lifecycle === 'current' && !contract.readable && !contract.emitted) {
|
|
871
|
+
throw new TypeError('Current schema lifecycle contracts must be readable or emitted.');
|
|
872
|
+
}
|
|
873
|
+
const compatibilityOwner = compatibilityOwners.get(contract.compatibilityId);
|
|
874
|
+
if (compatibilityOwner
|
|
875
|
+
&& (compatibilityOwner.schema !== contract.schema || compatibilityOwner.role !== contract.role)) {
|
|
876
|
+
throw new TypeError('Schema lifecycle compatibility ids must identify one schema and role.');
|
|
877
|
+
}
|
|
878
|
+
compatibilityOwners.set(contract.compatibilityId, { schema: contract.schema, role: contract.role });
|
|
879
|
+
if (contract.migrationTarget) {
|
|
880
|
+
const target = contractPairs.get(`${contract.migrationTarget.schema}\u0000${contract.migrationTarget.version}`);
|
|
881
|
+
const inputProjection = contract.role === 'input'
|
|
882
|
+
&& contract.lifecycle === 'current'
|
|
883
|
+
&& target?.role === 'document'
|
|
884
|
+
&& target.lifecycle === 'current'
|
|
885
|
+
&& target.emitted
|
|
886
|
+
&& target.schema !== contract.schema;
|
|
887
|
+
const versionMigration = target?.schema === contract.schema && target.version > contract.version;
|
|
888
|
+
if (!target || (!inputProjection && !versionMigration)) {
|
|
889
|
+
throw new TypeError('Schema lifecycle migration targets must name a greater version of the same schema or a current emitted document projected from a current input contract.');
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
for (const descriptor of family.compatibility) {
|
|
894
|
+
const contracts = family.contracts.filter((contract) => contract.compatibilityId === descriptor.id);
|
|
895
|
+
const versions = contracts.map((contract) => contract.version);
|
|
896
|
+
const current = contracts.find((contract) => contract.lifecycle === 'current');
|
|
897
|
+
const logicalSchema = contracts[0]?.schema ?? null;
|
|
898
|
+
if (!contracts.length
|
|
899
|
+
|| descriptor.owner !== family.owner
|
|
900
|
+
|| contracts.some((contract) => (descriptor.schema !== null && contract.schema !== descriptor.schema)
|
|
901
|
+
|| (contract.lifecycle === 'retired'
|
|
902
|
+
? contract.futureVersionBehaviour !== 'not_applicable'
|
|
903
|
+
: contract.futureVersionBehaviour !== descriptor.futureVersionBehavior)
|
|
904
|
+
|| contract.byteBudget !== descriptor.byteBudget)
|
|
905
|
+
|| versions.length !== descriptor.supportedVersions.length
|
|
906
|
+
|| versions.some((version, index) => version !== descriptor.supportedVersions[index])
|
|
907
|
+
|| current?.version !== descriptor.currentVersion) {
|
|
908
|
+
throw new TypeError(`Schema lifecycle compatibility descriptor ${descriptor.id} does not match its contracts.`);
|
|
909
|
+
}
|
|
910
|
+
if (descriptor.migration === 'normalize_to_current') {
|
|
911
|
+
const currentInputProjection = contracts.length === 1
|
|
912
|
+
&& current?.role === 'input'
|
|
913
|
+
&& current.migrationTarget !== null
|
|
914
|
+
&& contractPairs.get(`${current.migrationTarget.schema}\u0000${current.migrationTarget.version}`)?.role === 'document';
|
|
915
|
+
if ((!currentInputProjection
|
|
916
|
+
&& !contracts.some((contract) => contract.lifecycle === 'legacy')
|
|
917
|
+
&& !descriptor.acceptsUnversionedLegacy)
|
|
918
|
+
|| contracts.some((contract) => contract.lifecycle === 'retired')) {
|
|
919
|
+
throw new TypeError(`Schema lifecycle compatibility descriptor ${descriptor.id} must name a registered legacy version or an accepted unversioned legacy root before it can normalise to current.`);
|
|
920
|
+
}
|
|
921
|
+
if (!currentInputProjection && contracts.some((contract) => contract.lifecycle === 'current'
|
|
922
|
+
? contract.migrationTarget !== null
|
|
923
|
+
: contract.migrationTarget?.schema !== logicalSchema
|
|
924
|
+
|| contract.migrationTarget.version !== descriptor.currentVersion)) {
|
|
925
|
+
throw new TypeError(`Schema lifecycle compatibility descriptor ${descriptor.id} requires every legacy version to normalise to its current version.`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
else if (descriptor.migration === 'exact_current_only'
|
|
929
|
+
&& (contracts.length !== 1 || contracts[0]?.lifecycle !== 'current' || descriptor.acceptsUnversionedLegacy)) {
|
|
930
|
+
throw new TypeError(`Schema lifecycle compatibility descriptor ${descriptor.id} must contain only its exact current version.`);
|
|
931
|
+
}
|
|
932
|
+
else if (contracts.some((contract) => contract.migrationTarget !== null)) {
|
|
933
|
+
throw new TypeError(`Schema lifecycle compatibility descriptor ${descriptor.id} does not permit migration targets.`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (compatibilityOwners.size !== family.compatibility.length
|
|
937
|
+
|| [...compatibilityOwners.keys()].some((id) => !compatibilityById.has(id))) {
|
|
938
|
+
throw new TypeError('Every schema lifecycle compatibility descriptor must be used exactly once per schema family.');
|
|
939
|
+
}
|
|
940
|
+
const fixtureById = new Map(family.fixtures.map((fixture) => [fixture.id, fixture]));
|
|
941
|
+
if (fixtureById.size !== family.fixtures.length)
|
|
942
|
+
throw new TypeError('Schema lifecycle fixture ids must be unique.');
|
|
943
|
+
const referenced = new Set();
|
|
944
|
+
for (const contract of family.contracts) {
|
|
945
|
+
for (const fixtureId of contract.fixtureIds) {
|
|
946
|
+
const fixture = fixtureById.get(fixtureId);
|
|
947
|
+
if (!fixture || fixture.schema !== contract.schema || fixture.version !== contract.version) {
|
|
948
|
+
throw new TypeError('Schema lifecycle fixture references must match their registered contract.');
|
|
949
|
+
}
|
|
950
|
+
if (contract.byteBudget !== null && fixture.bytes > contract.byteBudget) {
|
|
951
|
+
throw new TypeError('Schema lifecycle fixtures must fit within their contract byte budget.');
|
|
952
|
+
}
|
|
953
|
+
if (referenced.has(fixtureId))
|
|
954
|
+
throw new TypeError('Schema lifecycle fixtures must belong to exactly one contract.');
|
|
955
|
+
referenced.add(fixtureId);
|
|
956
|
+
const expectedRole = contract.role === 'input'
|
|
957
|
+
? 'input'
|
|
958
|
+
: contract.lifecycle === 'legacy' || contract.lifecycle === 'retired' ? 'historical' : 'current';
|
|
959
|
+
if (fixture.role !== expectedRole)
|
|
960
|
+
throw new TypeError('Schema lifecycle fixture roles must match their contract.');
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (referenced.size !== family.fixtures.length)
|
|
964
|
+
throw new TypeError('Every schema lifecycle fixture must be referenced.');
|
|
965
|
+
for (const fixture of family.fixtures) {
|
|
966
|
+
if (fixture.expectation === 'normalises_to_current_output') {
|
|
967
|
+
const output = fixture.expectedOutputFixtureId ? fixtureById.get(fixture.expectedOutputFixtureId) : null;
|
|
968
|
+
const outputContract = output
|
|
969
|
+
? contractPairs.get(`${output.schema}\u0000${output.version}`)
|
|
970
|
+
: null;
|
|
971
|
+
const validInputProjection = fixture.role === 'input' && output?.schema !== fixture.schema;
|
|
972
|
+
const validHistoricalMigration = fixture.role === 'historical'
|
|
973
|
+
&& output?.schema === fixture.schema
|
|
974
|
+
&& output.version > fixture.version;
|
|
975
|
+
if ((!validInputProjection && !validHistoricalMigration)
|
|
976
|
+
|| !output
|
|
977
|
+
|| output.role !== 'current'
|
|
978
|
+
|| !outputContract
|
|
979
|
+
|| outputContract.lifecycle !== 'current'
|
|
980
|
+
|| outputContract.role !== 'document'
|
|
981
|
+
|| !outputContract.emitted) {
|
|
982
|
+
throw new TypeError('Normalising fixture expectations must target a current document fixture.');
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
else if (fixture.expectedOutputFixtureId !== null) {
|
|
986
|
+
throw new TypeError('Exact fixture expectations must not name an output fixture.');
|
|
987
|
+
}
|
|
988
|
+
const contract = contractPairs.get(`${fixture.schema}\u0000${fixture.version}`);
|
|
989
|
+
if ((fixture.expectation === 'historical_output_exact') !== (contract?.lifecycle === 'retired')) {
|
|
990
|
+
throw new TypeError('Historical output fixtures must belong exactly to retired contracts.');
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
for (const descriptor of family.compatibility) {
|
|
994
|
+
if (descriptor.migration !== 'normalize_to_current')
|
|
995
|
+
continue;
|
|
996
|
+
const contracts = family.contracts.filter((contract) => contract.compatibilityId === descriptor.id);
|
|
997
|
+
const current = contracts.find((contract) => contract.lifecycle === 'current');
|
|
998
|
+
const migrationContracts = contracts.filter((candidate) => candidate.lifecycle === 'legacy'
|
|
999
|
+
|| (candidate.lifecycle === 'current' && candidate.role === 'input' && candidate.migrationTarget !== null));
|
|
1000
|
+
for (const contract of migrationContracts) {
|
|
1001
|
+
const expectedTarget = contract.migrationTarget
|
|
1002
|
+
? contractPairs.get(`${contract.migrationTarget.schema}\u0000${contract.migrationTarget.version}`)
|
|
1003
|
+
: current;
|
|
1004
|
+
const hasMigrationFixture = contract.fixtureIds.some((fixtureId) => {
|
|
1005
|
+
const fixture = fixtureById.get(fixtureId);
|
|
1006
|
+
const output = fixture?.expectedOutputFixtureId
|
|
1007
|
+
? fixtureById.get(fixture.expectedOutputFixtureId)
|
|
1008
|
+
: null;
|
|
1009
|
+
return fixture?.expectation === 'normalises_to_current_output'
|
|
1010
|
+
&& output?.schema === expectedTarget?.schema
|
|
1011
|
+
&& output?.version === expectedTarget?.version;
|
|
1012
|
+
});
|
|
1013
|
+
if (!hasMigrationFixture) {
|
|
1014
|
+
throw new TypeError(`Schema lifecycle compatibility descriptor ${descriptor.id} must prove every declared normalisation with a fixture.`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function utf8Length(value) {
|
|
1020
|
+
let bytes = 0;
|
|
1021
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1022
|
+
const code = value.charCodeAt(index);
|
|
1023
|
+
if (code <= 0x7f)
|
|
1024
|
+
bytes += 1;
|
|
1025
|
+
else if (code <= 0x7ff)
|
|
1026
|
+
bytes += 2;
|
|
1027
|
+
else if (code >= 0xd800 && code <= 0xdbff
|
|
1028
|
+
&& index + 1 < value.length
|
|
1029
|
+
&& value.charCodeAt(index + 1) >= 0xdc00
|
|
1030
|
+
&& value.charCodeAt(index + 1) <= 0xdfff) {
|
|
1031
|
+
bytes += 4;
|
|
1032
|
+
index += 1;
|
|
1033
|
+
}
|
|
1034
|
+
else
|
|
1035
|
+
bytes += 3;
|
|
1036
|
+
}
|
|
1037
|
+
return bytes;
|
|
1038
|
+
}
|
|
1039
|
+
function metadataConsumerRelationships(metadata) {
|
|
1040
|
+
return metadata.metadataVersion === 1 ? [] : metadata.consumerRelationships;
|
|
1041
|
+
}
|
|
1042
|
+
function validateMetadataSerialisedBudget(metadata) {
|
|
1043
|
+
const serialised = JSON.stringify(metadata);
|
|
1044
|
+
if (utf8Length(serialised) > MAX_METADATA_SERIALISED_BYTES) {
|
|
1045
|
+
throw new TypeError('Schema lifecycle metadata exceeds its serialised byte budget.');
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function validateMetadataRelations(family, metadata) {
|
|
1049
|
+
if (metadata.metadataVersion < 3 && (metadata.serialisationProfiles.some((profile) => profile.propertyOrder === 'source_insertion')
|
|
1050
|
+
|| metadata.privacyProfiles.some((profile) => profile.network === 'explicit_bounded_passive_fast_or_deep')
|
|
1051
|
+
|| metadata.consumerEdges.some((edge) => edge.requestMode === 'explicit_bounded_passive_fast_or_deep'))) {
|
|
1052
|
+
throw new TypeError('Schema lifecycle metadata version 3 is required for extensible serialisation and Fast-or-Deep request policy.');
|
|
1053
|
+
}
|
|
1054
|
+
const contractPairs = new Map(family.contracts.map((contract) => [`${contract.schema}\u0000${contract.version}`, contract]));
|
|
1055
|
+
const shapesById = new Map(metadata.shapes.map((shape) => [shape.id, shape]));
|
|
1056
|
+
const boundProfilesById = new Map(metadata.boundProfiles.map((profile) => [profile.id, profile]));
|
|
1057
|
+
const hooksById = new Map(metadata.hooks.map((hook) => [hook.id, hook]));
|
|
1058
|
+
const serialisationProfilesById = new Map(metadata.serialisationProfiles.map((profile) => [profile.id, profile]));
|
|
1059
|
+
const privacyProfilesById = new Map(metadata.privacyProfiles.map((profile) => [profile.id, profile]));
|
|
1060
|
+
const expiryProfilesById = new Map(metadata.expiryProfiles.map((profile) => [profile.id, profile]));
|
|
1061
|
+
const consumerEdgesById = new Map(metadata.consumerEdges.map((edge) => [edge.id, edge]));
|
|
1062
|
+
const consumerRelationships = metadataConsumerRelationships(metadata);
|
|
1063
|
+
const consumerRelationshipsById = new Map(consumerRelationships.map((relationship) => [relationship.id, relationship]));
|
|
1064
|
+
const registries = [
|
|
1065
|
+
[shapesById, metadata.shapes.length, 'shape'],
|
|
1066
|
+
[boundProfilesById, metadata.boundProfiles.length, 'bound profile'],
|
|
1067
|
+
[hooksById, metadata.hooks.length, 'hook'],
|
|
1068
|
+
[serialisationProfilesById, metadata.serialisationProfiles.length, 'serialisation profile'],
|
|
1069
|
+
[privacyProfilesById, metadata.privacyProfiles.length, 'privacy profile'],
|
|
1070
|
+
[expiryProfilesById, metadata.expiryProfiles.length, 'expiry profile'],
|
|
1071
|
+
[consumerEdgesById, metadata.consumerEdges.length, 'consumer edge'],
|
|
1072
|
+
[consumerRelationshipsById, consumerRelationships.length, 'consumer relationship'],
|
|
1073
|
+
];
|
|
1074
|
+
for (const [registry, size, label] of registries) {
|
|
1075
|
+
if (registry.size !== size)
|
|
1076
|
+
throw new TypeError(`Schema lifecycle ${label} ids must be unique.`);
|
|
1077
|
+
}
|
|
1078
|
+
const allMetadataIds = registries.flatMap(([registry]) => [...registry.keys()]);
|
|
1079
|
+
if (new Set(allMetadataIds).size !== allMetadataIds.length) {
|
|
1080
|
+
throw new TypeError('Schema lifecycle metadata ids must be globally unique.');
|
|
1081
|
+
}
|
|
1082
|
+
const shapeIdsByContract = new Map();
|
|
1083
|
+
const shapeByVariant = new Map();
|
|
1084
|
+
for (const shape of metadata.shapes) {
|
|
1085
|
+
const contracts = shape.versions.map((version) => contractPairs.get(`${shape.schema}\u0000${version}`));
|
|
1086
|
+
const paths = [...shape.objects.map((item) => item.path), ...shape.fixedArrays.map((item) => item.path)];
|
|
1087
|
+
const objectsByPath = new Map(shape.objects.map((item) => [item.path, item]));
|
|
1088
|
+
if (contracts.some((contract) => !contract)
|
|
1089
|
+
|| contracts.some((contract) => shape.normalisation === 'input_to_current'
|
|
1090
|
+
? contract?.role !== 'input'
|
|
1091
|
+
: contract?.role !== 'document')
|
|
1092
|
+
|| shape.objects.filter((object) => object.path === '$').length !== 1
|
|
1093
|
+
|| new Set(paths).size !== paths.length
|
|
1094
|
+
|| paths.some((path) => {
|
|
1095
|
+
if (path === '$')
|
|
1096
|
+
return false;
|
|
1097
|
+
const parent = objectsByPath.get(parentMetadataPath(path) ?? '');
|
|
1098
|
+
const field = metadataPathField(path);
|
|
1099
|
+
const allowedFields = parent
|
|
1100
|
+
? [
|
|
1101
|
+
...parent.requiredKeys,
|
|
1102
|
+
...parent.optionalKeys,
|
|
1103
|
+
...(parent.alternativeRequiredKeys ?? []).flatMap((group) => group.keys),
|
|
1104
|
+
]
|
|
1105
|
+
: [];
|
|
1106
|
+
return !parent || !field || !allowedFields.includes(field);
|
|
1107
|
+
})) {
|
|
1108
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} does not exactly cover its contracts and paths.`);
|
|
1109
|
+
}
|
|
1110
|
+
const preservesExtensions = shape.objects.some((object) => object.unknownKeys === 'preserve_bounded');
|
|
1111
|
+
const discardsExtensions = shape.objects.some((object) => object.unknownKeys === 'discard_bounded');
|
|
1112
|
+
if (metadata.metadataVersion < 3 && preservesExtensions) {
|
|
1113
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} requires metadata version 3 for bounded extensions.`);
|
|
1114
|
+
}
|
|
1115
|
+
if (metadata.metadataVersion !== 4 && discardsExtensions) {
|
|
1116
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} requires metadata version 4 for bounded projection.`);
|
|
1117
|
+
}
|
|
1118
|
+
if (preservesExtensions && discardsExtensions) {
|
|
1119
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} must use one bounded extension policy.`);
|
|
1120
|
+
}
|
|
1121
|
+
const expectedExtensionPolicy = preservesExtensions
|
|
1122
|
+
? 'preserve_bounded'
|
|
1123
|
+
: discardsExtensions ? 'discard_bounded' : 'reject';
|
|
1124
|
+
if ((shape.normalisation === 'preserve_bounded_document') !== preservesExtensions
|
|
1125
|
+
|| (shape.normalisation === 'project_known_fields') !== discardsExtensions
|
|
1126
|
+
|| contracts.some((contract) => contract?.exactKeys !== (expectedExtensionPolicy === 'reject')
|
|
1127
|
+
|| contract?.extensionPolicy !== expectedExtensionPolicy)) {
|
|
1128
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} has inconsistent bounded-extension metadata.`);
|
|
1129
|
+
}
|
|
1130
|
+
if ((shape.normalisation === 'preserve_signed_document'
|
|
1131
|
+
&& contracts.some((contract) => contract?.canonicalisation === null))
|
|
1132
|
+
|| (shape.normalisation === 'preserve_document'
|
|
1133
|
+
&& contracts.some((contract) => contract?.canonicalisation !== null))
|
|
1134
|
+
|| (shape.normalisation === 'preserve_bounded_document'
|
|
1135
|
+
&& contracts.some((contract) => contract?.canonicalisation !== null))
|
|
1136
|
+
|| (shape.normalisation === 'project_known_fields'
|
|
1137
|
+
&& contracts.some((contract) => contract?.canonicalisation !== null))) {
|
|
1138
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} has inconsistent document integrity metadata.`);
|
|
1139
|
+
}
|
|
1140
|
+
const discriminator = metadata.metadataVersion === 4 ? shape.discriminator ?? null : null;
|
|
1141
|
+
if (discriminator) {
|
|
1142
|
+
if (shape.versions.length !== 1) {
|
|
1143
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} must qualify exactly one contract version.`);
|
|
1144
|
+
}
|
|
1145
|
+
if (!metadataPathIsTransitivelyRequired(discriminator.path, objectsByPath)) {
|
|
1146
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} has an unresolved required discriminator.`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
for (const version of shape.versions) {
|
|
1150
|
+
const key = `${shape.schema}\u0000${version}`;
|
|
1151
|
+
const existingShapeIds = shapeIdsByContract.get(key) ?? new Set();
|
|
1152
|
+
const existingShapes = [...existingShapeIds].map((id) => shapesById.get(id));
|
|
1153
|
+
if ((discriminator === null && existingShapeIds.size > 0)
|
|
1154
|
+
|| (discriminator !== null && existingShapes.some((existing) => existing.discriminator === null))
|
|
1155
|
+
|| (discriminator !== null && existingShapes.some((existing) => (existing.discriminator?.path !== discriminator.path)))) {
|
|
1156
|
+
throw new TypeError('A schema lifecycle contract must use one unqualified shape or disjoint qualified shapes.');
|
|
1157
|
+
}
|
|
1158
|
+
if (discriminator) {
|
|
1159
|
+
const variantKey = `${key}\u0000${discriminator.path}\u0000${discriminator.value}`;
|
|
1160
|
+
if (shapeByVariant.has(variantKey)) {
|
|
1161
|
+
throw new TypeError('Schema lifecycle shape discriminators must be unique.');
|
|
1162
|
+
}
|
|
1163
|
+
shapeByVariant.set(variantKey, shape);
|
|
1164
|
+
}
|
|
1165
|
+
existingShapeIds.add(shape.id);
|
|
1166
|
+
shapeIdsByContract.set(key, existingShapeIds);
|
|
1167
|
+
}
|
|
1168
|
+
if (shape.target) {
|
|
1169
|
+
const target = contractPairs.get(`${shape.target.schema}\u0000${shape.target.version}`);
|
|
1170
|
+
if (!target || target.lifecycle !== 'current' || target.role !== 'document' || !target.emitted) {
|
|
1171
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} must target an emitted current document.`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (shapeIdsByContract.size !== family.contracts.length) {
|
|
1176
|
+
throw new TypeError('Every schema lifecycle contract must have at least one shape.');
|
|
1177
|
+
}
|
|
1178
|
+
const fixtureShapeIds = new Set();
|
|
1179
|
+
if (metadata.metadataVersion === 4) {
|
|
1180
|
+
for (const fixture of family.fixtures) {
|
|
1181
|
+
const shape = fixture.shapeId ? shapesById.get(fixture.shapeId) : null;
|
|
1182
|
+
if (!shape
|
|
1183
|
+
|| shape.schema !== fixture.schema
|
|
1184
|
+
|| !shape.versions.includes(fixture.version)) {
|
|
1185
|
+
throw new TypeError(`Schema lifecycle fixture ${fixture.id} must name a matching contract shape.`);
|
|
1186
|
+
}
|
|
1187
|
+
fixtureShapeIds.add(shape.id);
|
|
1188
|
+
}
|
|
1189
|
+
for (const shape of metadata.shapes) {
|
|
1190
|
+
const contracts = shape.versions.map((version) => contractPairs.get(`${shape.schema}\u0000${version}`));
|
|
1191
|
+
if ((shape.discriminator !== null || contracts.some((contract) => contract?.lifecycle === 'retired'))
|
|
1192
|
+
&& !fixtureShapeIds.has(shape.id)) {
|
|
1193
|
+
throw new TypeError(`Schema lifecycle shape ${shape.id} must have an exact repository fixture.`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
for (const profile of metadata.boundProfiles) {
|
|
1198
|
+
const tuples = profile.bounds.map((bound) => `${bound.path}\u0000${bound.phase}\u0000${bound.unit}`);
|
|
1199
|
+
if (new Set(tuples).size !== tuples.length) {
|
|
1200
|
+
throw new TypeError(`Schema lifecycle bound profile ${profile.id} must use unique path, phase, and unit tuples.`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
for (const contract of family.contracts.filter((candidate) => candidate.byteBudget !== null)) {
|
|
1204
|
+
const matching = metadata.boundProfiles.flatMap((profile) => profile.bounds).filter((bound) => bound.path === '$'
|
|
1205
|
+
&& bound.phase === 'serialised'
|
|
1206
|
+
&& bound.unit === 'bytes'
|
|
1207
|
+
&& bound.handling === 'reject'
|
|
1208
|
+
&& bound.maximum === contract.byteBudget);
|
|
1209
|
+
if (matching.length !== 1) {
|
|
1210
|
+
throw new TypeError('Every schema lifecycle byte budget must have one exact serialised-byte bound.');
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (new Set(metadata.hooks.map((hook) => `${hook.module}\u0000${hook.exportName}`)).size !== metadata.hooks.length) {
|
|
1214
|
+
throw new TypeError('Schema lifecycle hooks must use unique module and export pairs.');
|
|
1215
|
+
}
|
|
1216
|
+
for (const profile of metadata.privacyProfiles) {
|
|
1217
|
+
if (profile.includedCategories.some((category) => profile.excludedCategories.includes(category))
|
|
1218
|
+
|| (profile.classification === 'metadata_only') !== (profile.projection === 'metadata_only')
|
|
1219
|
+
|| (profile.classification === 'analyst_authored_sensitive'
|
|
1220
|
+
&& (profile.retention === 'deliberate_local_file' || profile.retention === 'operator_controlled_output')
|
|
1221
|
+
&& profile.sharingReview !== 'required')) {
|
|
1222
|
+
throw new TypeError(`Schema lifecycle privacy profile ${profile.id} has inconsistent categories or classification.`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
const referencedHookIds = new Set();
|
|
1226
|
+
const referencedBoundProfileIds = new Set();
|
|
1227
|
+
const referencedSerialisationProfileIds = new Set();
|
|
1228
|
+
const referencedPrivacyProfileIds = new Set();
|
|
1229
|
+
const referencedExpiryProfileIds = new Set();
|
|
1230
|
+
const serialisationProfileByContract = new Map();
|
|
1231
|
+
for (const profile of metadata.serialisationProfiles) {
|
|
1232
|
+
const contracts = profile.versions.map((version) => contractPairs.get(`${profile.schema}\u0000${version}`));
|
|
1233
|
+
const serializer = hooksById.get(profile.serializerHookId);
|
|
1234
|
+
const verifiers = profile.verifierHookIds.map((id) => hooksById.get(id));
|
|
1235
|
+
if (contracts.some((contract) => !contract || contract.role !== 'document')
|
|
1236
|
+
|| !contracts.some((contract) => contract?.lifecycle === 'current' && contract.emitted)
|
|
1237
|
+
|| serializer?.role !== 'serialiser'
|
|
1238
|
+
|| verifiers.some((hook) => hook?.role !== 'integrity_verifier')) {
|
|
1239
|
+
throw new TypeError(`Schema lifecycle serialisation profile ${profile.id} has invalid contract or hook references.`);
|
|
1240
|
+
}
|
|
1241
|
+
const signedContracts = contracts.every((contract) => contract?.canonicalisation !== null);
|
|
1242
|
+
const unsignedContracts = contracts.every((contract) => contract?.canonicalisation === null);
|
|
1243
|
+
const boundedExtensionContracts = contracts.every((contract) => contract?.extensionPolicy === 'preserve_bounded');
|
|
1244
|
+
if ((!signedContracts && !unsignedContracts)
|
|
1245
|
+
|| (profile.integrity === 'structural_only_requires_separate_verification') !== signedContracts
|
|
1246
|
+
|| (profile.propertyOrder === 'source_insertion') !== boundedExtensionContracts) {
|
|
1247
|
+
throw new TypeError(`Schema lifecycle serialisation profile ${profile.id} has inconsistent document integrity metadata.`);
|
|
1248
|
+
}
|
|
1249
|
+
for (const version of profile.versions) {
|
|
1250
|
+
const key = `${profile.schema}\u0000${version}`;
|
|
1251
|
+
if (serialisationProfileByContract.has(key)) {
|
|
1252
|
+
throw new TypeError('Every schema lifecycle document contract must have at most one serialisation profile.');
|
|
1253
|
+
}
|
|
1254
|
+
serialisationProfileByContract.set(key, profile.id);
|
|
1255
|
+
}
|
|
1256
|
+
referencedHookIds.add(profile.serializerHookId);
|
|
1257
|
+
for (const id of profile.verifierHookIds)
|
|
1258
|
+
referencedHookIds.add(id);
|
|
1259
|
+
}
|
|
1260
|
+
const relationshipTuples = new Set();
|
|
1261
|
+
for (const relationship of consumerRelationships) {
|
|
1262
|
+
const tuple = `${relationship.sourceConsumerId}\u0000${relationship.targetConsumerId}\u0000${relationship.relationship}`;
|
|
1263
|
+
if (!consumerEdgesById.has(relationship.sourceConsumerId)
|
|
1264
|
+
|| relationship.sourceConsumerId === relationship.targetConsumerId
|
|
1265
|
+
|| consumerEdgesById.has(relationship.targetConsumerId)
|
|
1266
|
+
|| relationshipTuples.has(tuple)) {
|
|
1267
|
+
throw new TypeError(`Schema lifecycle consumer relationship ${relationship.id} has invalid local endpoints.`);
|
|
1268
|
+
}
|
|
1269
|
+
relationshipTuples.add(tuple);
|
|
1270
|
+
}
|
|
1271
|
+
const acceptedShapeIds = new Set();
|
|
1272
|
+
const emittedShapeIds = new Set();
|
|
1273
|
+
const coveredContractPairs = new Set();
|
|
1274
|
+
for (const edge of metadata.consumerEdges) {
|
|
1275
|
+
if (edge.acceptedContracts.length === 0 && edge.emittedContract === null) {
|
|
1276
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} must accept or emit a contract.`);
|
|
1277
|
+
}
|
|
1278
|
+
const shapeIds = new Set();
|
|
1279
|
+
const acceptedSelections = new Set();
|
|
1280
|
+
const acceptedContractShapes = new Set();
|
|
1281
|
+
const projectedContractBudgets = new Set();
|
|
1282
|
+
let projectedContractWithoutByteBudget = false;
|
|
1283
|
+
const relevantContractPairs = new Set();
|
|
1284
|
+
for (const reference of edge.acceptedContracts) {
|
|
1285
|
+
for (const version of reference.versions) {
|
|
1286
|
+
const key = `${reference.schema}\u0000${version}`;
|
|
1287
|
+
const contract = contractPairs.get(key);
|
|
1288
|
+
const contractShapeIds = shapeIdsByContract.get(key) ?? new Set();
|
|
1289
|
+
const contractShapes = [...contractShapeIds].map((id) => shapesById.get(id));
|
|
1290
|
+
const qualified = contractShapes.some((shape) => shape.discriminator !== null && shape.discriminator !== undefined);
|
|
1291
|
+
const selectedShapes = qualified
|
|
1292
|
+
? contractShapes.filter((shape) => reference.discriminator
|
|
1293
|
+
&& shape.discriminator?.path === reference.discriminator.path
|
|
1294
|
+
&& reference.discriminator.values.includes(shape.discriminator.value))
|
|
1295
|
+
: contractShapes;
|
|
1296
|
+
const selectionKey = `${key}\u0000${reference.discriminator?.path ?? ''}\u0000${reference.discriminator?.values.join('\u0001') ?? ''}`;
|
|
1297
|
+
if (!contract
|
|
1298
|
+
|| !contract.readable
|
|
1299
|
+
|| acceptedSelections.has(selectionKey)
|
|
1300
|
+
|| selectedShapes.some((shape) => acceptedContractShapes.has(`${key}\u0000${shape.id}`))
|
|
1301
|
+
|| (metadata.metadataVersion === 4
|
|
1302
|
+
&& ((qualified && (!reference.discriminator
|
|
1303
|
+
|| selectedShapes.length !== reference.discriminator.values.length))
|
|
1304
|
+
|| (!qualified && reference.discriminator !== null && reference.discriminator !== undefined)))) {
|
|
1305
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} references an unreadable or duplicate contract.`);
|
|
1306
|
+
}
|
|
1307
|
+
if (metadata.metadataVersion < 4 && reference.discriminator) {
|
|
1308
|
+
const parentPath = parentMetadataPath(reference.discriminator.path);
|
|
1309
|
+
const field = metadataPathField(reference.discriminator.path);
|
|
1310
|
+
if (contractShapes.some((shape) => {
|
|
1311
|
+
const parent = shape.objects.find((object) => object.path === parentPath);
|
|
1312
|
+
return !parent || !field || ![
|
|
1313
|
+
...parent.requiredKeys,
|
|
1314
|
+
...parent.optionalKeys,
|
|
1315
|
+
].includes(field);
|
|
1316
|
+
})) {
|
|
1317
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} has an unresolved contract discriminator.`);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
acceptedSelections.add(selectionKey);
|
|
1321
|
+
relevantContractPairs.add(key);
|
|
1322
|
+
coveredContractPairs.add(key);
|
|
1323
|
+
if (selectedShapes.some((shape) => shape.normalisation === 'project_known_fields')) {
|
|
1324
|
+
if (contract.byteBudget === null)
|
|
1325
|
+
projectedContractWithoutByteBudget = true;
|
|
1326
|
+
else
|
|
1327
|
+
projectedContractBudgets.add(contract.byteBudget);
|
|
1328
|
+
}
|
|
1329
|
+
for (const shape of selectedShapes) {
|
|
1330
|
+
shapeIds.add(shape.id);
|
|
1331
|
+
acceptedContractShapes.add(`${key}\u0000${shape.id}`);
|
|
1332
|
+
acceptedShapeIds.add(shape.id);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (edge.emittedContract) {
|
|
1337
|
+
const key = `${edge.emittedContract.schema}\u0000${edge.emittedContract.version}`;
|
|
1338
|
+
const emitted = contractPairs.get(key);
|
|
1339
|
+
const contractShapeIds = shapeIdsByContract.get(key) ?? new Set();
|
|
1340
|
+
const contractShapes = [...contractShapeIds].map((id) => shapesById.get(id));
|
|
1341
|
+
const qualified = contractShapes.some((shape) => shape.discriminator !== null && shape.discriminator !== undefined);
|
|
1342
|
+
const selectedShapes = qualified
|
|
1343
|
+
? contractShapes.filter((shape) => edge.emittedContract?.discriminator
|
|
1344
|
+
&& shape.discriminator?.path === edge.emittedContract.discriminator.path
|
|
1345
|
+
&& shape.discriminator.value === edge.emittedContract.discriminator.value)
|
|
1346
|
+
: contractShapes;
|
|
1347
|
+
if (!emitted
|
|
1348
|
+
|| !emitted.emitted
|
|
1349
|
+
|| (metadata.metadataVersion === 4
|
|
1350
|
+
&& ((qualified && selectedShapes.length !== 1)
|
|
1351
|
+
|| (!qualified && edge.emittedContract.discriminator !== null
|
|
1352
|
+
&& edge.emittedContract.discriminator !== undefined)))) {
|
|
1353
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} must emit a registered writable contract.`);
|
|
1354
|
+
}
|
|
1355
|
+
relevantContractPairs.add(key);
|
|
1356
|
+
coveredContractPairs.add(key);
|
|
1357
|
+
for (const shape of selectedShapes) {
|
|
1358
|
+
shapeIds.add(shape.id);
|
|
1359
|
+
emittedShapeIds.add(shape.id);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (shapeIds.size !== edge.shapeIds.length || edge.shapeIds.some((id) => !shapeIds.has(id))) {
|
|
1363
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} must exactly cover its contract shapes.`);
|
|
1364
|
+
}
|
|
1365
|
+
for (const id of edge.boundProfileIds) {
|
|
1366
|
+
if (!boundProfilesById.has(id))
|
|
1367
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} references an unknown bound profile.`);
|
|
1368
|
+
referencedBoundProfileIds.add(id);
|
|
1369
|
+
}
|
|
1370
|
+
const relevantByteBudgets = new Set([...relevantContractPairs]
|
|
1371
|
+
.map((key) => contractPairs.get(key)?.byteBudget)
|
|
1372
|
+
.filter((budget) => budget !== null && budget !== undefined));
|
|
1373
|
+
for (const byteBudget of relevantByteBudgets) {
|
|
1374
|
+
const matching = edge.boundProfileIds.flatMap((id) => boundProfilesById.get(id)?.bounds ?? [])
|
|
1375
|
+
.filter((bound) => bound.path === '$'
|
|
1376
|
+
&& bound.phase === 'serialised'
|
|
1377
|
+
&& bound.unit === 'bytes'
|
|
1378
|
+
&& bound.handling === 'reject'
|
|
1379
|
+
&& bound.maximum === byteBudget);
|
|
1380
|
+
if (matching.length !== 1) {
|
|
1381
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} must reference one exact contract byte budget.`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
const allowedHookRuntimes = edge.plane === 'browser'
|
|
1385
|
+
? new Set(['browser', 'shared'])
|
|
1386
|
+
: edge.plane === 'node'
|
|
1387
|
+
? new Set(['node', 'shared'])
|
|
1388
|
+
: edge.plane === 'cli'
|
|
1389
|
+
? new Set(['cli', 'node', 'shared'])
|
|
1390
|
+
: new Set(['shared']);
|
|
1391
|
+
for (const id of edge.hookIds) {
|
|
1392
|
+
const hook = hooksById.get(id);
|
|
1393
|
+
if (!hook || !allowedHookRuntimes.has(hook.runtime)) {
|
|
1394
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} references an unknown or incompatible hook.`);
|
|
1395
|
+
}
|
|
1396
|
+
referencedHookIds.add(id);
|
|
1397
|
+
}
|
|
1398
|
+
if (projectedContractWithoutByteBudget || projectedContractBudgets.size > 0) {
|
|
1399
|
+
const hasNormaliser = edge.hookIds.some((id) => hooksById.get(id)?.role === 'normaliser');
|
|
1400
|
+
const rawIntakeBounds = edge.boundProfileIds.flatMap((id) => (boundProfilesById.get(id)?.bounds ?? [])).filter((bound) => bound.path === '$'
|
|
1401
|
+
&& bound.phase === 'raw_intake'
|
|
1402
|
+
&& bound.unit === 'bytes'
|
|
1403
|
+
&& bound.handling === 'reject');
|
|
1404
|
+
if (projectedContractWithoutByteBudget
|
|
1405
|
+
|| !hasNormaliser
|
|
1406
|
+
|| rawIntakeBounds.length !== projectedContractBudgets.size
|
|
1407
|
+
|| [...projectedContractBudgets].some((byteBudget) => (rawIntakeBounds.filter((bound) => bound.maximum === byteBudget).length !== 1))) {
|
|
1408
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} must bind bounded projection to raw intake and a normaliser.`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
if (edge.serialisationProfileId) {
|
|
1412
|
+
const profile = serialisationProfilesById.get(edge.serialisationProfileId);
|
|
1413
|
+
const relevantContracts = edge.emittedContract
|
|
1414
|
+
? [edge.emittedContract]
|
|
1415
|
+
: edge.acceptedContracts.flatMap((reference) => reference.versions.map((version) => ({
|
|
1416
|
+
schema: reference.schema,
|
|
1417
|
+
version,
|
|
1418
|
+
})));
|
|
1419
|
+
if (!profile
|
|
1420
|
+
|| !edge.hookIds.includes(profile.serializerHookId)
|
|
1421
|
+
|| relevantContracts.some((contract) => contract.schema !== profile.schema
|
|
1422
|
+
|| !profile.versions.includes(contract.version))) {
|
|
1423
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} references an unknown serialisation profile.`);
|
|
1424
|
+
}
|
|
1425
|
+
referencedSerialisationProfileIds.add(edge.serialisationProfileId);
|
|
1426
|
+
}
|
|
1427
|
+
const privacyProfile = privacyProfilesById.get(edge.privacyProfileId);
|
|
1428
|
+
if (!privacyProfile
|
|
1429
|
+
|| privacyProfile.network !== edge.requestMode
|
|
1430
|
+
|| privacyProfile.retention !== edge.retentionEffect) {
|
|
1431
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} has inconsistent privacy, request, or retention metadata.`);
|
|
1432
|
+
}
|
|
1433
|
+
referencedPrivacyProfileIds.add(edge.privacyProfileId);
|
|
1434
|
+
const expiryProfile = expiryProfilesById.get(edge.expiryPolicyId);
|
|
1435
|
+
if (!expiryProfile) {
|
|
1436
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} references an unknown expiry profile.`);
|
|
1437
|
+
}
|
|
1438
|
+
if (expiryProfile.handling !== 'not_applicable') {
|
|
1439
|
+
const expiryFieldIsPresent = expiryProfile.field !== null && edge.shapeIds.some((shapeId) => (shapesById.get(shapeId)?.objects.some((object) => (object.requiredKeys.includes(expiryProfile.field)
|
|
1440
|
+
|| object.optionalKeys.includes(expiryProfile.field))) === true));
|
|
1441
|
+
if (!expiryFieldIsPresent) {
|
|
1442
|
+
throw new TypeError(`Schema lifecycle consumer ${edge.id} must reference an expiry field declared by its contract shapes.`);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
referencedExpiryProfileIds.add(edge.expiryPolicyId);
|
|
1446
|
+
}
|
|
1447
|
+
for (const contract of family.contracts) {
|
|
1448
|
+
const key = `${contract.schema}\u0000${contract.version}`;
|
|
1449
|
+
if (metadata.metadataVersion < 4) {
|
|
1450
|
+
if ((contract.readable || contract.emitted) && !coveredContractPairs.has(key)) {
|
|
1451
|
+
throw new TypeError('Every readable or emitted schema lifecycle contract must have a consumer edge.');
|
|
1452
|
+
}
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const contractShapeIds = shapeIdsByContract.get(key) ?? new Set();
|
|
1456
|
+
if ([...contractShapeIds].some((id) => {
|
|
1457
|
+
const shape = shapesById.get(id);
|
|
1458
|
+
return shape?.discriminator !== null
|
|
1459
|
+
&& shape?.discriminator !== undefined
|
|
1460
|
+
&& !(contractPairs.get(key)?.lifecycle === 'retired' && fixtureShapeIds.has(id))
|
|
1461
|
+
&& !acceptedShapeIds.has(id)
|
|
1462
|
+
&& !emittedShapeIds.has(id);
|
|
1463
|
+
})) {
|
|
1464
|
+
throw new TypeError(`Schema lifecycle contract ${contract.schema} v${contract.version} has an uncovered shape variant.`);
|
|
1465
|
+
}
|
|
1466
|
+
const hasReader = [...contractShapeIds].some((id) => acceptedShapeIds.has(id));
|
|
1467
|
+
const hasWriter = [...contractShapeIds].some((id) => emittedShapeIds.has(id));
|
|
1468
|
+
if (contract.readable !== hasReader || contract.emitted !== hasWriter) {
|
|
1469
|
+
throw new TypeError(`Schema lifecycle contract ${contract.schema} v${contract.version} must exactly aggregate its readable and emitted shape variants.`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (referencedBoundProfileIds.size !== metadata.boundProfiles.length
|
|
1473
|
+
|| referencedHookIds.size !== metadata.hooks.length
|
|
1474
|
+
|| referencedSerialisationProfileIds.size !== metadata.serialisationProfiles.length
|
|
1475
|
+
|| referencedPrivacyProfileIds.size !== metadata.privacyProfiles.length
|
|
1476
|
+
|| referencedExpiryProfileIds.size !== metadata.expiryProfiles.length) {
|
|
1477
|
+
throw new TypeError('Every schema lifecycle metadata record must be referenced by a consumer edge or serialisation profile.');
|
|
1478
|
+
}
|
|
1479
|
+
validateMetadataSerialisedBudget(metadata);
|
|
1480
|
+
}
|
|
1481
|
+
export function defineSchemaLifecycleFamily(value) {
|
|
1482
|
+
const metadataDescriptor = value && typeof value === 'object'
|
|
1483
|
+
? Object.getOwnPropertyDescriptor(value, 'metadata')
|
|
1484
|
+
: undefined;
|
|
1485
|
+
const source = ordinaryRecord(value, metadataDescriptor ? EXTENDED_FAMILY_KEYS : FAMILY_KEYS, 'Schema lifecycle family');
|
|
1486
|
+
const contracts = Object.freeze(denseArray(source.contracts, 'Schema lifecycle contracts', MAX_CONTRACTS)
|
|
1487
|
+
.map(copyContract));
|
|
1488
|
+
const fixtures = Object.freeze(denseArray(source.fixtures, 'Schema lifecycle fixtures', MAX_FIXTURES)
|
|
1489
|
+
.map(copyFixture));
|
|
1490
|
+
const compatibility = Object.freeze(denseArray(source.compatibility, 'Schema lifecycle compatibility descriptors', MAX_CONTRACTS)
|
|
1491
|
+
.map(copyCompatibility));
|
|
1492
|
+
const family = Object.freeze({
|
|
1493
|
+
id: boundedId(source.id, 'Schema lifecycle family id'),
|
|
1494
|
+
owner: boundedPath(source.owner, 'Schema lifecycle family owner'),
|
|
1495
|
+
privacy: oneOf(source.privacy, ['analyst_authored_sensitive', 'metadata_only'], 'Schema lifecycle family privacy'),
|
|
1496
|
+
compatibility,
|
|
1497
|
+
contracts,
|
|
1498
|
+
fixtures,
|
|
1499
|
+
});
|
|
1500
|
+
validateFamilyRelations(family);
|
|
1501
|
+
if (!metadataDescriptor) {
|
|
1502
|
+
if (contracts.some((contract) => contract.extensionPolicy === 'preserve_bounded')) {
|
|
1503
|
+
throw new TypeError('Schema lifecycle metadata version 3 is required for bounded extensions.');
|
|
1504
|
+
}
|
|
1505
|
+
if (contracts.some((contract) => contract.extensionPolicy === 'discard_bounded'
|
|
1506
|
+
|| contract.lifecycle === 'retired')
|
|
1507
|
+
|| fixtures.some((fixture) => Object.hasOwn(fixture, 'shapeId')
|
|
1508
|
+
|| fixture.expectation === 'historical_output_exact')) {
|
|
1509
|
+
throw new TypeError('Schema lifecycle metadata version 4 is required for retired or projected contracts.');
|
|
1510
|
+
}
|
|
1511
|
+
return family;
|
|
1512
|
+
}
|
|
1513
|
+
const metadataBudget = {
|
|
1514
|
+
slots: 0,
|
|
1515
|
+
textCodeUnits: 0,
|
|
1516
|
+
serialisedBytes: 0,
|
|
1517
|
+
};
|
|
1518
|
+
const metadataSource = ordinaryRecord(source.metadata, [METADATA_V1_KEYS, METADATA_V2_KEYS, METADATA_V3_KEYS, METADATA_V4_KEYS], 'Schema lifecycle metadata', metadataBudget);
|
|
1519
|
+
const metadataVersion = metadataSource.metadataVersion;
|
|
1520
|
+
const hasConsumerRelationships = Object.hasOwn(metadataSource, 'consumerRelationships');
|
|
1521
|
+
if ((metadataVersion !== 1 && metadataVersion !== 2 && metadataVersion !== 3 && metadataVersion !== 4)
|
|
1522
|
+
|| metadataSource.enforcement !== 'declarative_only'
|
|
1523
|
+
|| (metadataVersion === 1 && hasConsumerRelationships)
|
|
1524
|
+
|| ((metadataVersion === 2 || metadataVersion === 3 || metadataVersion === 4) && !hasConsumerRelationships)) {
|
|
1525
|
+
throw new TypeError('Schema lifecycle metadata must use an exact registered declarative-only version.');
|
|
1526
|
+
}
|
|
1527
|
+
const usesVersion4Vocabulary = contracts.some((contract) => contract.extensionPolicy === 'discard_bounded'
|
|
1528
|
+
|| contract.lifecycle === 'retired')
|
|
1529
|
+
|| fixtures.some((fixture) => Object.hasOwn(fixture, 'shapeId')
|
|
1530
|
+
|| fixture.expectation === 'historical_output_exact');
|
|
1531
|
+
if ((metadataVersion === 4) !== usesVersion4Vocabulary
|
|
1532
|
+
|| (metadataVersion === 4 && fixtures.some((fixture) => !Object.hasOwn(fixture, 'shapeId')))) {
|
|
1533
|
+
throw new TypeError('Schema lifecycle metadata version 4 must exclusively and completely own its retired or projected vocabulary.');
|
|
1534
|
+
}
|
|
1535
|
+
const metadataCommon = {
|
|
1536
|
+
enforcement: 'declarative_only',
|
|
1537
|
+
shapes: Object.freeze(denseArray(metadataSource.shapes, 'Schema lifecycle shapes', MAX_METADATA_COLLECTION, 1, metadataBudget).map((item, index) => copyShape(item, index, metadataBudget, metadataVersion))),
|
|
1538
|
+
boundProfiles: Object.freeze(denseArray(metadataSource.boundProfiles, 'Schema lifecycle bound profiles', MAX_METADATA_COLLECTION, 1, metadataBudget).map((item, index) => copyBoundProfile(item, index, metadataBudget))),
|
|
1539
|
+
hooks: Object.freeze(denseArray(metadataSource.hooks, 'Schema lifecycle hooks', MAX_METADATA_COLLECTION, 1, metadataBudget).map((item, index) => copyHook(item, index, metadataBudget))),
|
|
1540
|
+
serialisationProfiles: Object.freeze(denseArray(metadataSource.serialisationProfiles, 'Schema lifecycle serialisation profiles', MAX_METADATA_COLLECTION, 1, metadataBudget).map((item, index) => copySerialisationProfile(item, index, metadataBudget))),
|
|
1541
|
+
privacyProfiles: Object.freeze(denseArray(metadataSource.privacyProfiles, 'Schema lifecycle privacy profiles', MAX_METADATA_COLLECTION, 1, metadataBudget).map((item, index) => copyPrivacyProfile(item, index, metadataBudget))),
|
|
1542
|
+
expiryProfiles: Object.freeze(denseArray(metadataSource.expiryProfiles, 'Schema lifecycle expiry profiles', MAX_METADATA_COLLECTION, 1, metadataBudget).map((item, index) => copyExpiryPolicy(item, index, metadataBudget))),
|
|
1543
|
+
consumerEdges: Object.freeze(denseArray(metadataSource.consumerEdges, 'Schema lifecycle consumer edges', MAX_METADATA_COLLECTION, 1, metadataBudget).map((item, index) => copyConsumerEdge(item, index, metadataBudget, metadataVersion))),
|
|
1544
|
+
};
|
|
1545
|
+
const consumerRelationships = metadataVersion === 1
|
|
1546
|
+
? Object.freeze([])
|
|
1547
|
+
: Object.freeze(denseArray(metadataSource.consumerRelationships, 'Schema lifecycle consumer relationships', MAX_METADATA_COLLECTION, 0, metadataBudget).map((item, index) => copyConsumerRelationship(item, index, metadataBudget)));
|
|
1548
|
+
const metadata = metadataVersion === 1
|
|
1549
|
+
? Object.freeze({ metadataVersion: 1, ...metadataCommon })
|
|
1550
|
+
: metadataVersion === 2
|
|
1551
|
+
? Object.freeze({ metadataVersion: 2, ...metadataCommon, consumerRelationships })
|
|
1552
|
+
: metadataVersion === 3
|
|
1553
|
+
? Object.freeze({ metadataVersion: 3, ...metadataCommon, consumerRelationships })
|
|
1554
|
+
: Object.freeze({
|
|
1555
|
+
metadataVersion: 4,
|
|
1556
|
+
...metadataCommon,
|
|
1557
|
+
shapes: metadataCommon.shapes,
|
|
1558
|
+
consumerEdges: metadataCommon.consumerEdges,
|
|
1559
|
+
consumerRelationships,
|
|
1560
|
+
});
|
|
1561
|
+
validateMetadataRelations(family, metadata);
|
|
1562
|
+
return Object.freeze({ ...family, metadata });
|
|
1563
|
+
}
|
|
1564
|
+
function validateRegistryConsumerRelationships(families) {
|
|
1565
|
+
const consumers = new Map();
|
|
1566
|
+
for (const family of families) {
|
|
1567
|
+
if (!Object.hasOwn(family, 'metadata'))
|
|
1568
|
+
continue;
|
|
1569
|
+
const metadata = family.metadata;
|
|
1570
|
+
const hooksById = new Map(metadata.hooks.map((hook) => [hook.id, hook]));
|
|
1571
|
+
for (const edge of metadata.consumerEdges) {
|
|
1572
|
+
const hookTargets = new Set(edge.hookIds.map((id) => {
|
|
1573
|
+
const hook = hooksById.get(id);
|
|
1574
|
+
return `${hook.module}\u0000${hook.exportName}\u0000${hook.role}\u0000${hook.runtime}`;
|
|
1575
|
+
}));
|
|
1576
|
+
consumers.set(edge.id, Object.freeze({
|
|
1577
|
+
familyId: family.id,
|
|
1578
|
+
edge,
|
|
1579
|
+
hookTargets,
|
|
1580
|
+
}));
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
const adjacency = new Map();
|
|
1584
|
+
const indegree = new Map();
|
|
1585
|
+
for (const family of families) {
|
|
1586
|
+
if (!Object.hasOwn(family, 'metadata'))
|
|
1587
|
+
continue;
|
|
1588
|
+
const metadata = family.metadata;
|
|
1589
|
+
for (const relationship of metadataConsumerRelationships(metadata)) {
|
|
1590
|
+
const source = consumers.get(relationship.sourceConsumerId);
|
|
1591
|
+
const target = consumers.get(relationship.targetConsumerId);
|
|
1592
|
+
if (!source || source.familyId !== family.id) {
|
|
1593
|
+
throw new TypeError(`Schema lifecycle consumer relationship ${relationship.id} source consumer is not registered by its family.`);
|
|
1594
|
+
}
|
|
1595
|
+
if (!target) {
|
|
1596
|
+
throw new TypeError(`Schema lifecycle consumer relationship ${relationship.id} target consumer is not registered.`);
|
|
1597
|
+
}
|
|
1598
|
+
if (target.familyId === family.id) {
|
|
1599
|
+
throw new TypeError(`Schema lifecycle consumer relationship ${relationship.id} must target another family.`);
|
|
1600
|
+
}
|
|
1601
|
+
if (source.edge.plane !== target.edge.plane
|
|
1602
|
+
|| source.edge.requestMode !== target.edge.requestMode
|
|
1603
|
+
|| source.edge.retentionEffect !== target.edge.retentionEffect
|
|
1604
|
+
|| source.edge.bindingState !== target.edge.bindingState
|
|
1605
|
+
|| source.edge.policyState !== target.edge.policyState) {
|
|
1606
|
+
throw new TypeError(`Schema lifecycle consumer relationship ${relationship.id} has incompatible consumer policies.`);
|
|
1607
|
+
}
|
|
1608
|
+
if (![...source.hookTargets].some((hook) => target.hookTargets.has(hook))) {
|
|
1609
|
+
throw new TypeError(`Schema lifecycle consumer relationship ${relationship.id} must share one exact hook target.`);
|
|
1610
|
+
}
|
|
1611
|
+
const targets = adjacency.get(relationship.sourceConsumerId) ?? new Set();
|
|
1612
|
+
if (targets.has(relationship.targetConsumerId)) {
|
|
1613
|
+
throw new TypeError(`Schema lifecycle consumer relationship ${relationship.id} is duplicated.`);
|
|
1614
|
+
}
|
|
1615
|
+
targets.add(relationship.targetConsumerId);
|
|
1616
|
+
adjacency.set(relationship.sourceConsumerId, targets);
|
|
1617
|
+
indegree.set(relationship.sourceConsumerId, indegree.get(relationship.sourceConsumerId) ?? 0);
|
|
1618
|
+
indegree.set(relationship.targetConsumerId, (indegree.get(relationship.targetConsumerId) ?? 0) + 1);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const ready = [...indegree].filter(([, count]) => count === 0).map(([id]) => id);
|
|
1622
|
+
let cursor = 0;
|
|
1623
|
+
let visited = 0;
|
|
1624
|
+
while (cursor < ready.length) {
|
|
1625
|
+
const source = ready[cursor++];
|
|
1626
|
+
visited += 1;
|
|
1627
|
+
for (const target of adjacency.get(source) ?? []) {
|
|
1628
|
+
const remaining = (indegree.get(target) ?? 0) - 1;
|
|
1629
|
+
indegree.set(target, remaining);
|
|
1630
|
+
if (remaining === 0)
|
|
1631
|
+
ready.push(target);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
if (visited !== indegree.size) {
|
|
1635
|
+
throw new TypeError('Schema lifecycle consumer relationships must not form a composition cycle.');
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
export function defineSchemaLifecycleRegistry(value) {
|
|
1639
|
+
const sources = denseArray(value, 'Schema lifecycle registry families', MAX_SCHEMA_LIFECYCLE_FAMILIES);
|
|
1640
|
+
const familyIds = new Set();
|
|
1641
|
+
const compatibilityIds = new Set();
|
|
1642
|
+
const schemaOwners = new Map();
|
|
1643
|
+
const fixtureIds = new Set();
|
|
1644
|
+
const fixturePaths = new Set();
|
|
1645
|
+
const metadataIds = new Set();
|
|
1646
|
+
const families = [];
|
|
1647
|
+
let serialisedBytes = 2 + Math.max(0, sources.length - 1);
|
|
1648
|
+
for (const source of sources) {
|
|
1649
|
+
const family = defineSchemaLifecycleFamily(source);
|
|
1650
|
+
const serialised = JSON.stringify(family);
|
|
1651
|
+
serialisedBytes += utf8Length(serialised);
|
|
1652
|
+
if (serialisedBytes > MAX_SCHEMA_LIFECYCLE_REGISTRY_BYTES) {
|
|
1653
|
+
throw new TypeError('Schema lifecycle registry exceeds its aggregate serialised byte budget.');
|
|
1654
|
+
}
|
|
1655
|
+
if (familyIds.has(family.id)) {
|
|
1656
|
+
throw new TypeError(`Schema lifecycle registry family id is duplicated: ${family.id}.`);
|
|
1657
|
+
}
|
|
1658
|
+
familyIds.add(family.id);
|
|
1659
|
+
for (const compatibility of family.compatibility) {
|
|
1660
|
+
if (compatibilityIds.has(compatibility.id)) {
|
|
1661
|
+
throw new TypeError(`Schema lifecycle registry compatibility id is duplicated: ${compatibility.id}.`);
|
|
1662
|
+
}
|
|
1663
|
+
compatibilityIds.add(compatibility.id);
|
|
1664
|
+
}
|
|
1665
|
+
for (const contract of family.contracts) {
|
|
1666
|
+
const owner = schemaOwners.get(contract.schema);
|
|
1667
|
+
if (owner && owner !== family.id) {
|
|
1668
|
+
throw new TypeError(`Schema lifecycle registry schema has multiple family owners: ${contract.schema}.`);
|
|
1669
|
+
}
|
|
1670
|
+
schemaOwners.set(contract.schema, family.id);
|
|
1671
|
+
}
|
|
1672
|
+
for (const fixture of family.fixtures) {
|
|
1673
|
+
if (fixtureIds.has(fixture.id) || fixturePaths.has(fixture.path)) {
|
|
1674
|
+
throw new TypeError(`Schema lifecycle registry fixture id or path is duplicated: ${fixture.id}.`);
|
|
1675
|
+
}
|
|
1676
|
+
fixtureIds.add(fixture.id);
|
|
1677
|
+
fixturePaths.add(fixture.path);
|
|
1678
|
+
}
|
|
1679
|
+
if (Object.hasOwn(family, 'metadata')) {
|
|
1680
|
+
const metadata = family.metadata;
|
|
1681
|
+
const familyMetadataIds = [
|
|
1682
|
+
...metadata.shapes.map((item) => item.id),
|
|
1683
|
+
...metadata.boundProfiles.map((item) => item.id),
|
|
1684
|
+
...metadata.hooks.map((item) => item.id),
|
|
1685
|
+
...metadata.serialisationProfiles.map((item) => item.id),
|
|
1686
|
+
...metadata.privacyProfiles.map((item) => item.id),
|
|
1687
|
+
...metadata.expiryProfiles.map((item) => item.id),
|
|
1688
|
+
...metadata.consumerEdges.map((item) => item.id),
|
|
1689
|
+
...metadataConsumerRelationships(metadata).map((item) => item.id),
|
|
1690
|
+
];
|
|
1691
|
+
for (const id of familyMetadataIds) {
|
|
1692
|
+
if (metadataIds.has(id)) {
|
|
1693
|
+
throw new TypeError(`Schema lifecycle registry metadata id is duplicated: ${id}.`);
|
|
1694
|
+
}
|
|
1695
|
+
metadataIds.add(id);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
families.push(family);
|
|
1699
|
+
}
|
|
1700
|
+
validateRegistryConsumerRelationships(families);
|
|
1701
|
+
return Object.freeze(families);
|
|
1702
|
+
}
|