From 40107eec840ae64061169bdd6185e887c998678b Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 13 Aug 2026 18:23:26 +0900 Subject: [PATCH] chore: initialize from frontend template 4dc033c --- .dependency-cruiser.json | 190 + .gitea/workflows/quality-gates.yml | 457 ++ .gitignore | 20 + .npmrc | 2 + .nvmrc | 1 + .storybook/main.ts | 15 + .storybook/preview.tsx | 108 + .../task-5-report.md | 110 + README.md | 163 + artifacts/local-preview/home.png | Bin 0 -> 13851 bytes artifacts/local-preview/not-found.png | Bin 0 -> 10411 bytes artifacts/local-preview/sample-session.png | Bin 0 -> 9656 bytes artifacts/runbooks/.gitkeep | 1 + artifacts/tests/.gitkeep | 1 + artifacts/tests/a11y-manual/APP_HOME.md | 18 + artifacts/tests/a11y-manual/EXAMPLES_AUTH.md | 18 + .../tests/a11y-manual/EXAMPLES_PLATFORM.md | 18 + .../tests/a11y-manual/EXAMPLES_STATES.md | 18 + artifacts/tests/a11y-manual/EXAMPLES_UI.md | 18 + artifacts/tests/a11y-manual/NOT_FOUND.md | 18 + .../a11y-manual/REFERENCE_RESOURCE_DETAIL.md | 18 + .../a11y-manual/REFERENCE_RESOURCE_FORM.md | 18 + .../a11y-manual/REFERENCE_RESOURCE_LIST.md | 18 + .../a11y-manual/REFERENCE_RESOURCE_STATUS.md | 18 + config/architecture/layers.json | 38 + config/ci/gates.json | 2758 +++++++ config/compatibility/fixtures.json | 63 + .../contracts/registry-baseline.approval.json | 7 + config/contracts/registry-baseline.json | 2002 +++++ .../contracts/registry-change-evidence.json | 101 + config/contracts/registry-governance.json | 513 ++ config/hosting/cache-policy.json | 35 + config/hosting/response-headers.fixture.json | 39 + config/hosting/security-headers.json | 11 + config/performance/budgets.json | 18 + config/performance/field-input.example.json | 25 + .../recipes/frontend-capability-recipes.json | 340 + config/release/coherence-fixtures.json | 77 + config/runbooks/runbooks.json | 93 + config/schemas/field-web-vitals.schema.json | 78 + config/schemas/lab-performance.schema.json | 30 + .../schemas/release-verification.schema.json | 17 + .../schemas/runbook-drill-record.schema.json | 42 + .../dependency-baseline.approval.json | 7 + config/security/dependency-baseline.json | 6669 +++++++++++++++++ .../security/dependency-change-evidence.json | 4 + config/security/dependency-policy.json | 24 + config/security/secret-scan-policy.json | 52 + config/security/vulnerability-exceptions.json | 4 + config/security/vulnerability-policy.json | 8 + config/testing/risk-coverage.json | 130 + config/testing/test-evidence.json | 22 + docs/accessibility/manual-checklist.md | 51 + ...igned-implementation-closed-deep-design.md | 5957 +++++++++++++++ ...contract-schema-mapper-and-server-state.md | 973 +++ .../backend-api-and-server-state-contract.md | 718 ++ ...owser-data-capability-completion-ledger.md | 385 + .../browser-file-and-origin-storage.md | 1177 +++ docs/architecture/client-cache-and-storage.md | 1680 +++++ .../VD-01-typescript-lint-tooling.md | 64 + .../decisions/VD-03-react-router-data-mode.md | 58 + .../decisions/VD-04-native-form-facade.md | 55 + .../decisions/VD-05-icon-and-headless-ui.md | 57 + .../VD-06-intl-typed-message-catalog.md | 96 + ...D-07-diagnostics-and-telemetry-exporter.md | 97 + .../VD-08-storybook-and-visual-evidence.md | 71 + .../decisions/VD-09-supply-chain-evidence.md | 113 + .../VD-10-optional-capability-recipes.md | 113 + .../VD-11-browser-file-and-origin-storage.md | 269 + .../VD-12-presigned-transfer-and-image-cdn.md | 202 + ...D-13-client-cache-scope-and-persistence.md | 880 +++ ...umable-download-and-background-transfer.md | 898 +++ ...-origin-storage-lifecycle-and-migration.md | 858 +++ ...transfer-composition-and-image-delivery.md | 638 ++ ...-transport-selection-and-rest-execution.md | 729 ++ ...D-24-runtime-schema-and-boundary-mapper.md | 585 ++ .../VD-25-server-state-cache-lifecycle.md | 802 ++ .../VD-26-persisted-graphql-operation.md | 653 ++ .../VD-27-grpc-web-unary-and-server-stream.md | 1065 +++ ...ime-events-web-push-and-bounded-polling.md | 640 ++ ...onnect-web-and-browser-protobuf-runtime.md | 1473 ++++ ...D-30-protobuf-contract-and-rest-gateway.md | 1093 +++ .../frontend-platform-capability-review.md | 532 ++ ...rontend-platform-implementation-roadmap.md | 1277 ++++ .../frontend-ports-adapters-and-boundaries.md | 1356 ++++ docs/architecture/layers.md | 59 + docs/architecture/optional-adapter-recipes.md | 265 + docs/architecture/overview.md | 79 + .../presigned-transfer-and-image-cdn.md | 757 ++ ...obuf-browser-transport-and-rest-gateway.md | 775 ++ ...ime-events-web-push-and-bounded-polling.md | 2355 ++++++ docs/architecture/review-evidence.md | 22 + docs/architecture/review-ledger.json | 29 + .../routing-pages-and-patterns.md | 519 ++ .../server-file-capability-infrastructure.md | 1882 +++++ docs/architecture/starter-experience.md | 79 + docs/architecture/static-delivery.md | 33 + .../typescript-state-and-data-flow.md | 689 ++ docs/contracts/compatibility.md | 22 + .../api-contract-and-server-state-recovery.md | 581 ++ .../browser-file-storage-recovery.md | 514 ++ docs/operations/browser-transfer-recovery.md | 320 + docs/operations/ci-quality-gates.md | 381 + .../client-cache-and-storage-recovery.md | 203 + docs/operations/performance-evidence.md | 22 + docs/operations/release-cache-rollback.md | 32 + docs/runbooks/FE-RB-001.md | 9 + docs/runbooks/FE-RB-002.md | 11 + docs/runbooks/FE-RB-003.md | 10 + docs/runbooks/FE-RB-004.md | 9 + docs/runbooks/FE-RB-005.md | 13 + docs/security/browser-boundary.md | 13 + docs/security/supply-chain.md | 292 + docs/styling/design-system-platform.md | 884 +++ docs/styling/design-tokens.md | 49 + ...2026-07-31-platform-overview-completion.md | 856 +++ ...6-08-01-http-worker-adapter-remediation.md | 158 + ...-08-01-quality-architecture-remediation.md | 124 + .../2026-08-01-release-boot-integrity.md | 344 + ...2026-08-01-release-evidence-remediation.md | 110 + ...6-08-01-runtime-correctness-remediation.md | 281 + .../2026-08-01-scope-owned-server-state.md | 99 + ...-08-02-cicd-frontend-assurance-delivery.md | 614 ++ ...-02-counter-bearing-coverage-provenance.md | 133 + ...02-frontend-platform-consumer-migration.md | 380 + ...6-08-02-promotion-security-review-fixes.md | 598 ++ .../plans/2026-08-02-provider-raw-guardian.md | 499 ++ .../plans/2026-08-02-security-finalizer.md | 82 + ...02-test-assurance-frontend-capabilities.md | 572 ++ ...2026-08-02-v8-coverage-counter-contract.md | 176 + ...026-07-31-platform-overview-page-design.md | 82 + ...1-refactoring-review-remediation-design.md | 322 + ...08-01-runtime-integrity-refactor-design.md | 171 + ...wned-frontend-assurance-delivery-design.md | 464 ++ ...2026-08-02-provider-raw-guardian-design.md | 222 + ...-02-v8-coverage-counter-contract-design.md | 47 + .../frontend-platform-testing-strategy.md | 1350 ++++ docs/testing/taxonomy.md | 38 + eslint.config.ts | 868 +++ index.html | 13 + package.json | 161 + playwright.capabilities.config.ts | 48 + playwright.config.ts | 49 + playwright.dev.config.ts | 16 + playwright.storybook.config.ts | 34 + playwright.visual.config.ts | 37 + pnpm-lock.yaml | 5229 +++++++++++++ pnpm-workspace.yaml | 8 + public/config.json | 18 + public/release-manifest.json | 27 + .../browser-file-storage-contracts.ts | 741 ++ .../browser-file-storage-fakes.ts | 2121 ++++++ recipes/frontend-capabilities/contracts.ts | 237 + .../frontend-capabilities/fake-adapters.ts | 578 ++ recipes/frontend-capabilities/index.ts | 4 + schemas/artifacts/build-manifest.schema.json | 116 + .../dependency-inventory.schema.json | 94 + .../artifacts/registry-snapshot.schema.json | 442 ++ .../supply-chain-verification.schema.json | 143 + .../frontend-capability-recipes.schema.json | 106 + scripts/build-frontend.ts | 60 + scripts/check-architecture.ts | 1059 +++ .../check-browser-file-storage-boundaries.ts | 207 + scripts/check-browser-security.ts | 49 + scripts/check-bundle.ts | 109 + scripts/check-ci-contract.ts | 142 + scripts/check-compatibility.ts | 58 + scripts/check-design-system.ts | 149 + scripts/check-diagnostics.ts | 120 + scripts/check-frozen-lockfile-fixture.ts | 39 + scripts/check-i18n.ts | 107 + scripts/check-optional-recipe-fixtures.ts | 204 + scripts/check-optional-recipe-types.ts | 62 + scripts/check-optional-recipes.ts | 271 + scripts/check-realtime-boundaries.ts | 43 + scripts/check-realtime-boundary-fixtures.ts | 47 + scripts/check-registries.ts | 697 ++ .../check-registry-compatibility-fixtures.ts | 66 + scripts/check-risk-coverage.ts | 78 + scripts/check-security-fixtures.ts | 10 + scripts/check-supply-chain-fixtures.ts | 151 + .../check-supply-chain-provider-fixtures.ts | 375 + scripts/check-test-evidence.ts | 450 ++ .../check-v8-coverage-counter-semantics.ts | 12 + scripts/cleanup-verified-promotion.ts | 29 + scripts/collect-web-vitals-evidence.ts | 110 + scripts/contracts/ci-gates.ts | 1201 +++ scripts/contracts/promotion-artifacts.ts | 13 + scripts/contracts/release-artifacts.ts | 1415 ++++ scripts/create-release-candidate.ts | 29 + scripts/drill-runbook.ts | 479 ++ scripts/generate-artifact-schemas.ts | 131 + scripts/generate-build-manifest.ts | 186 + scripts/generate-ci-workflow.ts | 498 ++ scripts/generate-contract-set.ts | 64 + scripts/generate-service-worker-assets.ts | 144 + scripts/generate-supply-chain.ts | 381 + scripts/lib/bounded-pnpm-script.ts | 66 + scripts/lib/build-environment.ts | 96 + scripts/lib/build-manifest-outputs.ts | 188 + scripts/lib/ci-artifact-validator.ts | 740 ++ scripts/lib/ci-candidate-archive-cli.ts | 26 + scripts/lib/ci-candidate-archive.ts | 663 ++ scripts/lib/ci-contract-report.ts | 45 + scripts/lib/ci-gate-log.ts | 187 + scripts/lib/ci-step-result.ts | 82 + scripts/lib/classify-vite-bundle.ts | 49 + scripts/lib/exact-promotion-bundle.ts | 353 + scripts/lib/field-vitals-evidence.ts | 117 + scripts/lib/hosting-probe.ts | 74 + scripts/lib/http-scenario-evidence.ts | 281 + scripts/lib/json-schema.ts | 24 + scripts/lib/local-policy-evidence.ts | 206 + scripts/lib/local-release-evidence.ts | 1330 ++++ scripts/lib/manual-a11y-evidence.ts | 57 + scripts/lib/optional-recipe-bundle.ts | 373 + scripts/lib/optional-recipes.ts | 441 ++ scripts/lib/package-script-graph.ts | 994 +++ scripts/lib/promotion-stager.ts | 709 ++ scripts/lib/provider-cgroup.ts | 169 + scripts/lib/provider-evidence.ts | 494 ++ scripts/lib/provider-guardian-client.ts | 763 ++ scripts/lib/provider-guardian-protocol.ts | 396 + scripts/lib/provider-output-limiter.ts | 27 + scripts/lib/provider-process-runner.ts | 103 + scripts/lib/provider-raw-cleanup.ts | 40 + scripts/lib/provider-raw-guardian.ts | 641 ++ scripts/lib/provider-scope-wrapper.ts | 166 + scripts/lib/provider-supervisor.ts | 163 + scripts/lib/provider-trust.ts | 42 + scripts/lib/provider-upload-validator.ts | 59 + scripts/lib/realtime-boundaries.ts | 138 + scripts/lib/registry-compatibility.ts | 336 + scripts/lib/release-candidate.ts | 261 + scripts/lib/release-input-evidence.ts | 19 + scripts/lib/release-runtime-coherence.ts | 85 + scripts/lib/removal-fixture.ts | 239 + scripts/lib/repository-file-inventory.ts | 327 + scripts/lib/risk-coverage-files.ts | 346 + scripts/lib/risk-coverage.ts | 901 +++ scripts/lib/secret-scan-evaluator.ts | 283 + scripts/lib/secret-scan-policy.ts | 59 + scripts/lib/secret-scan.ts | 73 + scripts/lib/security-fixture-check.ts | 170 + scripts/lib/service-worker-build-input.ts | 101 + scripts/lib/stage-verified-promotion-cli.ts | 94 + scripts/lib/supply-chain-time.ts | 24 + scripts/lib/supply-chain.ts | 514 ++ scripts/lib/test-evidence-artifact.ts | 36 + scripts/lib/v8-coverage-counter-semantics.ts | 294 + scripts/lib/validated-json-artifact.ts | 144 + scripts/lib/vite-module-inventory.ts | 63 + scripts/run-and-validate-provider.ts | 598 ++ scripts/run-ci-gate.ts | 251 + scripts/run-http-scenario-evidence.ts | 72 + scripts/security-scan.ts | 38 + scripts/serve-static.ts | 47 + scripts/stage-verified-promotion.ts | 3 + ...st-browser-file-storage-runtime-removal.ts | 171 + scripts/test-optional-recipe-removal.ts | 138 + scripts/test-performance.ts | 171 + scripts/test-realtime-runtime-removal.ts | 154 + scripts/test-sample-removal.ts | 448 ++ scripts/update-dependency-baseline.ts | 47 + scripts/update-registry-baseline.ts | 41 + scripts/verify-a11y-manual.ts | 74 + scripts/verify-archived-local-evidence.ts | 50 + scripts/verify-browser-capability-evidence.ts | 89 + scripts/verify-ci-candidate-archive.ts | 37 + scripts/verify-documentation-readiness.ts | 87 + scripts/verify-exact-promotion-bundle.ts | 94 + scripts/verify-hosting-headers.ts | 223 + scripts/verify-release-candidate.ts | 25 + scripts/verify-release.ts | 351 + scripts/verify-reproducible-build.ts | 82 + scripts/verify-supply-chain-artifacts.ts | 24 + scripts/write-a11y-report.ts | 19 + src/adapters/auth/external-session-adapter.ts | 134 + src/adapters/browser-file-storage/index.ts | 6 + src/adapters/browser-file-storage/result.ts | 101 + .../storage-manager-adapter.ts | 250 + .../browser-files/browser-file-picker.ts | 697 ++ .../browser-file-policy-registry.ts | 526 ++ .../browser-files/browser-file-vault.ts | 895 +++ .../create-browser-file-runtime.ts | 245 + .../download-delivery-adapter.ts | 1436 ++++ src/adapters/browser-files/file-observer.ts | 53 + src/adapters/browser-files/file-policy.ts | 441 ++ src/adapters/browser-files/index.ts | 53 + .../browser-files/object-url-lease.ts | 339 + .../browser-rpc/browser-rpc-runtime.ts | 1145 +++ src/adapters/browser-rpc/index.ts | 17 + src/adapters/browser-rpc/transport.ts | 89 + .../unavailable-browser-rpc-transport.ts | 42 + .../browser-transfer/image-cdn/README.md | 167 + .../image-cdn/browser-image-probe.ts | 609 ++ .../image-cdn/image-cdn-policy.ts | 753 ++ .../image-cdn/image-cdn-runtime.ts | 1340 ++++ .../image-cdn/image-header-metadata.ts | 734 ++ .../browser-transfer/image-cdn/index.ts | 34 + .../p256-image-capability-verifier.ts | 134 + src/adapters/browser-transfer/index.ts | 3 + .../presigned/incremental-sha256.ts | 204 + .../browser-transfer/presigned/index.ts | 18 + .../presigned-capability-http-provider.ts | 1100 +++ .../presigned/presigned-capability-vault.ts | 273 + .../presigned/presigned-transfer-executor.ts | 1188 +++ .../resumable-upload/checkpoint-schema.ts | 185 + .../resumable-upload/fetch-json-transport.ts | 729 ++ .../http-control-plane-adapter.ts | 612 ++ .../resumable-upload/index.ts | 40 + .../indexeddb-checkpoint-store.ts | 667 ++ .../presigned-upload-part-executor.ts | 114 + .../resumable-upload-runtime.ts | 2196 ++++++ .../resumable-upload/runtime-policy.ts | 112 + .../resumable-upload/upload-byte-source.ts | 600 ++ .../upload-cancellation-channel.ts | 222 + .../resumable-upload/upload-mutation-lock.ts | 58 + src/adapters/cache-storage/index.ts | 14 + .../cache-storage/public-cache-policy.ts | 199 + .../public-response-cache-adapter.ts | 1798 +++++ .../browser-cross-context-host.ts | 249 + .../browser-cross-context-invalidation.ts | 710 ++ .../cross-context-invalidation/index.ts | 23 + .../diagnostics/bounded-diagnostics.ts | 102 + src/adapters/http/bounded-body-reader.ts | 160 + src/adapters/http/bounded-json.ts | 50 + src/adapters/http/client.ts | 944 +++ src/adapters/http/http-contract-bridge.ts | 288 + src/adapters/http/http-effect-certainty.ts | 119 + src/adapters/http/http-execution-v3.ts | 1226 +++ src/adapters/http/request-builder.ts | 134 + src/adapters/http/resource-mapper.ts | 11 + src/adapters/http/retry-policy.ts | 86 + src/adapters/http/schema-registry.ts | 112 + src/adapters/platform/browser-lifecycle.ts | 186 + .../browser-mutation-intent-factory.ts | 50 + src/adapters/platform/system-clock.ts | 24 + .../conditional-validator-store.ts | 123 + .../query-cache/cursor-pagination-runtime.ts | 139 + .../query-cache/server-state-scope-runtime.ts | 202 + .../query-cache/tanstack-cache-coordinator.ts | 319 + .../query-cache/tanstack-query-cache.ts | 115 + src/adapters/realtime/event-codec.ts | 394 + src/adapters/realtime/event-consumer.ts | 226 + src/adapters/realtime/index.ts | 58 + src/adapters/realtime/json-member-scanner.ts | 218 + .../realtime/live-poll-handoff-coordinator.ts | 883 +++ .../polling/bounded-poll-coordinator.ts | 961 +++ src/adapters/realtime/polling/index.ts | 10 + .../realtime/reconnect-coordinator.ts | 1796 +++++ src/adapters/realtime/reconnect-policy.ts | 224 + src/adapters/realtime/result.ts | 278 + .../realtime/sse/fetch-sse-connection.ts | 738 ++ src/adapters/realtime/sse/index.ts | 19 + src/adapters/realtime/sse/sse-parser.ts | 346 + src/adapters/realtime/stream-coordinator.ts | 1154 +++ src/adapters/realtime/websocket/index.ts | 43 + .../websocket/websocket-connection.ts | 2003 +++++ .../realtime/websocket/websocket-protocol.ts | 518 ++ .../service-worker/service-worker-entry.ts | 170 + .../service-worker-lifecycle.ts | 442 ++ .../service-worker-page-controller.ts | 399 + .../service-worker/service-worker-protocol.ts | 178 + .../service-worker/service-worker-removal.ts | 169 + .../service-worker-static-assets.ts | 359 + .../storage/browser-storage-adapter.ts | 391 + src/adapters/storage/browser-storage-codec.ts | 231 + src/adapters/storage/indexeddb/index.ts | 25 + .../storage/indexeddb/indexeddb-failure.ts | 72 + .../storage/indexeddb/indexeddb-governance.ts | 339 + .../indexeddb/indexeddb-maintenance.ts | 1540 ++++ .../storage/indexeddb/indexeddb-migrations.ts | 208 + .../storage/indexeddb/indexeddb-runtime.ts | 2902 +++++++ .../storage/indexeddb/indexeddb-types.ts | 234 + .../storage/opfs/browser-opfs-runtime.ts | 217 + src/adapters/storage/opfs/index.ts | 53 + .../storage/opfs/indexeddb-opfs-journal.ts | 1803 +++++ .../storage/opfs/opfs-byte-store-adapter.ts | 1533 ++++ src/adapters/storage/opfs/opfs-policy.ts | 236 + .../storage/opfs/opfs-worker-client.ts | 675 ++ .../storage/opfs/opfs-worker-protocol.ts | 194 + .../storage/opfs/opfs-worker-runtime.ts | 1703 +++++ .../telemetry/best-effort-telemetry.ts | 182 + .../inbound/notification-click-adapter.ts | 230 + .../web-push/inbound/push-event-adapter.ts | 196 + src/adapters/web-push/index.ts | 66 + .../web-push/notification-registry.ts | 122 + .../web-push/push-association-fence-store.ts | 733 ++ src/adapters/web-push/push-codec.ts | 308 + .../web-push/push-registration-gateway.ts | 325 + .../web-push/push-subscription-adapter.ts | 1119 +++ src/adapters/web-push/runtime-support.ts | 137 + .../web-push/service-worker-runtime.ts | 281 + .../web-push/service-worker-scope-host.ts | 57 + src/application/create-application.ts | 205 + src/application/policies/bounded-polling.ts | 159 + src/application/policies/color-scheme.ts | 26 + src/application/policies/compatibility.ts | 107 + .../policies/performance-budgets.ts | 118 + .../policies/promotion-readiness.ts | 60 + src/application/ports/auth-session-port.ts | 30 + .../cache-storage-ports.ts | 90 + .../ports/browser-file-storage/file.ts | 250 + .../ports/browser-file-storage/index.ts | 114 + .../browser-file-storage/indexeddb-port.ts | 186 + .../ports/browser-file-storage/opfs-ports.ts | 276 + .../ports/browser-file-storage/shared.ts | 238 + .../storage-durability-port.ts | 22 + .../ports/browser-rpc/browser-rpc.ts | 36 + src/application/ports/browser-rpc/index.ts | 6 + .../browser-transfer/authorized-download.ts | 32 + .../ports/browser-transfer/image-cdn.ts | 183 + .../ports/browser-transfer/index.ts | 68 + .../browser-transfer/presigned-transfer.ts | 151 + .../browser-transfer/resumable-upload.ts | 279 + src/application/ports/clock-port.ts | 4 + src/application/ports/diagnostics-port.ts | 5 + src/application/ports/in/application-api.ts | 83 + src/application/ports/in/index.ts | 9 + .../ports/mutation-intent-factory.ts | 11 + .../ports/out/application-output-ports.ts | 23 + src/application/ports/out/index.ts | 19 + src/application/ports/out/web-push-control.ts | 31 + src/application/ports/query-cache-port.ts | 15 + .../ports/realtime/event-authority.ts | 202 + src/application/ports/realtime/index.ts | 31 + src/application/ports/realtime/shared.ts | 67 + src/application/ports/release-info-port.ts | 36 + .../ports/runtime-capabilities-port.ts | 12 + src/application/ports/storage-port.ts | 15 + src/application/ports/telemetry-port.ts | 10 + src/application/result.ts | 10 + .../use-cases/decide-chunk-recovery.ts | 51 + src/application/view-models/async-state.ts | 187 + src/bootstrap/composition-root.ts | 51 + src/bootstrap/create-runtime-composition.ts | 72 + src/bootstrap/initialize-color-scheme.ts | 36 + src/bootstrap/load-release-manifest.ts | 294 + src/bootstrap/load-runtime-config.ts | 150 + src/bootstrap/main.tsx | 41 + src/bootstrap/optional-runtime-host.ts | 152 + src/bootstrap/read-bounded-boot-json.ts | 189 + src/bootstrap/register-service-worker.ts | 57 + src/bootstrap/runtime-adapters.ts | 420 ++ src/bootstrap/runtime-application.tsx | 55 + src/bootstrap/runtime-config-schema.ts | 138 + .../server-state-generation-store.ts | 74 + src/contracts/api-operations.ts | 208 + src/contracts/boundary-mapper.ts | 69 + src/contracts/browser-rpc.ts | 772 ++ src/contracts/cache-invalidation.ts | 241 + src/contracts/contract-set-canonical.ts | 111 + src/contracts/contract-set.ts | 129 + src/contracts/cursor-pagination.ts | 23 + src/contracts/diagnostic-buckets.ts | 8 + src/contracts/diagnostics.ts | 177 + src/contracts/env.ts | 109 + src/contracts/errors.ts | 424 ++ src/contracts/external-contract-runtime.ts | 582 ++ src/contracts/mutation-intent.ts | 59 + src/contracts/offline-command.ts | 154 + src/contracts/query-invalidation.ts | 258 + src/contracts/query-keys.ts | 315 + src/contracts/realtime-events.ts | 185 + src/contracts/realtime-streams.ts | 548 ++ src/contracts/release-artifacts.ts | 268 + src/contracts/release-tokens.ts | 66 + src/contracts/rest-profiles.ts | 151 + src/contracts/route-runtime-contract.ts | 50 + src/contracts/routes.ts | 98 + src/contracts/runtime-capabilities.ts | 266 + src/contracts/schema-registry.ts | 111 + src/contracts/server-state-scope.ts | 36 + src/contracts/server-state.ts | 271 + src/contracts/service-worker.ts | 152 + src/contracts/storage-keys.ts | 157 + src/contracts/telemetry.ts | 237 + src/contracts/web-push.ts | 200 + src/contracts/web-worker.ts | 139 + .../installed-contract-contributions.ts | 27 + src/features/installed-feature-adapters.ts | 16 + src/features/installed-feature-contracts.ts | 99 + src/features/installed-feature-messages.ts | 10 + src/features/installed-feature-runtimes.tsx | 16 + .../installed-runtime-capabilities.ts | 49 + src/features/reference-feature/README.md | 68 + .../create-reference-feature-input.ts | 182 + .../adapters/reference-http-gateway.ts | 160 + .../application/reference-feature-api.ts | 91 + ...reference-feature-contract-contribution.ts | 255 + .../contracts/reference-feature-contract.ts | 257 + .../contracts/reference-mapper.ts | 97 + .../contracts/reference-message-catalog.ts | 22 + .../contracts/reference-schemas.ts | 111 + .../domain/reference-resource.ts | 14 + .../reference-feature-runtime.tsx | 30 + .../reference-resource-detail-page.tsx | 61 + .../reference-resource-form-page.tsx | 158 + .../presentation/reference-resource-form.ts | 29 + .../presentation/reference-resource-page.tsx | 61 + .../reference-resource-status-page.tsx | 25 + .../use-reference-failure-action.ts | 50 + .../presentation/use-reference-feature.ts | 138 + .../adapters/query/application-query.ts | 844 +++ src/presentation/adapters/query/index.ts | 9 + .../query/mutation-intent-provider.tsx | 35 + .../query/optimistic-layer-runtime.ts | 246 + .../query/query-invalidation-provider.tsx | 30 + .../server-state-generation-provider.tsx | 58 + .../query/server-state-scope-provider.tsx | 52 + .../boundaries/boot-error-shell.tsx | 54 + .../boundaries/chunk-recovery-boundary.tsx | 115 + .../boundaries/render-error-boundary.tsx | 77 + src/presentation/components/async-surface.tsx | 165 + src/presentation/components/error-copy.ts | 8 + src/presentation/components/page-header.tsx | 40 + .../components/state-surfaces.tsx | 75 + src/presentation/components/ui/alert.ts | 1 + src/presentation/components/ui/badge.ts | 1 + src/presentation/components/ui/button.ts | 6 + src/presentation/components/ui/card.ts | 1 + src/presentation/components/ui/dialog.ts | 1 + src/presentation/components/ui/text-field.ts | 4 + .../design-system/design-system.stories.tsx | 128 + .../design-system/icons/semantic-icons.tsx | 47 + .../design-system/icons/vendors/lucide.tsx | 21 + src/presentation/design-system/index.ts | 146 + .../patterns/common-patterns.tsx | 139 + .../design-system/primitives/core.tsx | 471 ++ .../design-system/primitives/feedback.tsx | 55 + .../design-system/primitives/forms.tsx | 411 + .../design-system/primitives/navigation.tsx | 199 + .../design-system/primitives/overlays.tsx | 447 ++ .../design-system/tokens/component.css | 26 + .../design-system/tokens/primitive.css | 63 + .../design-system/tokens/semantic.css | 83 + .../design-system/tokens/token-contract.ts | 62 + .../examples/auth-example-page.tsx | 79 + .../examples/platform-overview-page.tsx | 484 ++ .../examples/state-gallery-page.tsx | 96 + src/presentation/examples/ui-gallery-page.tsx | 318 + src/presentation/forms/form-components.tsx | 92 + src/presentation/forms/form-contracts.ts | 81 + src/presentation/forms/index.ts | 4 + src/presentation/forms/use-app-form.ts | 292 + .../forms/use-dirty-navigation-guard.tsx | 56 + src/presentation/i18n/catalog.ts | 314 + src/presentation/i18n/formatters.ts | 96 + src/presentation/i18n/index.ts | 29 + src/presentation/i18n/locale-provider.tsx | 118 + src/presentation/i18n/message-contract.ts | 135 + src/presentation/layouts/app-shell.tsx | 188 + src/presentation/pages/home-page.tsx | 87 + src/presentation/pages/not-found-page.tsx | 22 + .../providers/application-provider.tsx | 31 + .../providers/session-provider.tsx | 55 + src/presentation/providers/theme-provider.tsx | 80 + src/presentation/routes/app-router.tsx | 341 + src/presentation/routes/navigation-policy.ts | 48 + .../routes/platform-route-codecs.ts | 6 + src/presentation/routes/route-codecs.ts | 102 + src/presentation/routes/route-contract.ts | 16 + src/presentation/routes/route-input.tsx | 29 + src/presentation/routes/route-runtime.tsx | 47 + src/presentation/security/safe-text.tsx | 7 + src/presentation/styles/theme.css | 1435 ++++ src/presentation/templates/index.ts | 1 + src/presentation/templates/page-templates.tsx | 268 + .../browser-files.spec.ts | 331 + .../cross-context-invalidation.spec.ts | 290 + tests/browser-capabilities/image-cdn.spec.ts | 182 + .../indexeddb-runtime.spec.ts | 1033 +++ .../browser-capabilities/opfs-runtime.spec.ts | 221 + .../browser-capabilities/opfs-test.worker.ts | 13 + .../presigned-streaming.spec.ts | 366 + .../public-cache-storage.spec.ts | 312 + .../resumable-upload.spec.ts | 519 ++ .../storage-manager.spec.ts | 29 + tests/component/application-query.test.tsx | 2196 ++++++ tests/component/async-surface.test.tsx | 197 + tests/component/bootstrap-shell.test.tsx | 17 + tests/component/browser-security.test.tsx | 54 + .../chunk-recovery-boundary.test.tsx | 68 + .../component/design-system-platform.test.tsx | 269 + tests/component/design-tokens.test.tsx | 19 + tests/component/form-foundation.test.tsx | 280 + tests/component/locale-platform.test.tsx | 120 + tests/component/page-header-focus.test.tsx | 52 + tests/component/page-templates.test.tsx | 92 + .../component/platform-overview-page.test.tsx | 164 + tests/component/render-boundary.test.tsx | 126 + tests/component/router.test.tsx | 100 + tests/component/runtime-application.test.tsx | 74 + .../server-state-generation-provider.test.tsx | 80 + .../server-state-scope-provider.test.tsx | 83 + .../typescript-tooling-smoke.test.tsx | 20 + tests/component/ui-primitives.test.tsx | 108 + tests/e2e/accessibility.spec.ts | 58 + tests/e2e/app-shell.spec.ts | 44 + tests/e2e/compact-smoke.spec.ts | 23 + tests/e2e/design-system-interactions.spec.ts | 29 + tests/e2e/i18n.spec.ts | 41 + tests/e2e/platform-overview.spec.ts | 58 + tests/e2e/reference-form.spec.ts | 50 + tests/e2e/reference-route.spec.ts | 32 + tests/e2e/responsive.spec.ts | 36 + tests/e2e/theme.spec.ts | 38 + tests/e2e/ui-gallery.spec.ts | 34 + .../reference-contract.test.ts | 186 + .../reference-diagnostics.test.ts | 71 + .../reference-http-gateway.test.ts | 106 + .../reference-feature/reference-page.test.tsx | 490 ++ .../reference-production-vertical.test.tsx | 121 + .../reference-runtime-composition.test.ts | 195 + .../allowed/bootstrap-composes-adapter.ts | 3 + .../allowed/presentation-facade.ts | 3 + .../allowed/presentation-facade.tsx | 7 + .../allowed/application/read-value.ts | 5 + .../dependency-graph/allowed/domain/value.ts | 3 + .../allowed/presentation/value-view.tsx | 5 + .../cycle/application/first.ts | 3 + .../cycle/application/second.ts | 3 + .../layer/adapters/runtime.ts | 1 + .../layer/application/use-runtime.ts | 3 + .../unresolved/application/load-value.ts | 5 + .../unresolved/application/read-value.ts | 8 + .../unresolved/application/require-value.ts | 5 + .../forbidden/application-imports-adapter.ts | 3 + .../forbidden/application-imports-tanstack.ts | 3 + .../forbidden/domain-imports-react.ts | 3 + .../forbidden/presentation-direct-fetch.tsx | 4 + .../forbidden/presentation-imports-adapter.ts | 3 + .../presentation-imports-adapter.tsx | 5 + .../presentation-imports-output-port.tsx | 5 + .../presentation-imports-tanstack.tsx | 3 + .../template-imports-application.tsx | 5 + .../allowed/browser-storage-host.ts | 6 + .../allowed/cross-context-host.ts | 6 + .../allowed/policy-port.ts | 17 + .../forbidden/aliased-globalthis.ts | 4 + .../forbidden/class-field-alias.ts | 5 + .../forbidden/computed-alias.ts | 4 + .../forbidden/constructor-root-escape.ts | 5 + .../forbidden/default-parameter-alias.ts | 3 + .../forbidden/direct-blob.ts | 3 + .../forbidden/direct-broadcast-channel.ts | 3 + .../forbidden/direct-cache-storage.ts | 3 + .../forbidden/direct-indexeddb.ts | 3 + .../forbidden/direct-local-storage.ts | 3 + .../forbidden/direct-opfs.ts | 3 + .../forbidden/direct-session-storage.ts | 3 + .../forbidden/dynamic-capability-key.ts | 5 + .../forbidden/global-object-container.ts | 4 + .../forbidden/globalthis-storage.ts | 3 + .../forbidden/identity-wrapped-global.ts | 7 + .../forbidden/instance-property-alias.ts | 4 + .../forbidden/object-url.ts | 3 + .../forbidden/property-descriptor-access.ts | 7 + .../forbidden/reflect-get.ts | 3 + .../ci-contract/duplicate-gate-id.json | 14 + tests/fixtures/ci-contract/job-cycle.json | 4 + .../ci-contract/missing-artifact-schema.json | 11 + .../ci-contract/multiply-owned-gate.json | 4 + tests/fixtures/ci-contract/unknown-field.json | 12 + .../ci-contract/unknown-job-dependency.json | 4 + tests/fixtures/ci-contract/unowned-gate.json | 4 + tests/fixtures/coverage/below-threshold.json | 14 + .../coverage/repository-omission.json | 50 + .../design-system/forbidden/deep-import.tsx | 3 + .../design-system/forbidden/direct-icon.tsx | 3 + .../design-system/forbidden/raw-palette.tsx | 3 + .../design-system/forbidden/tooltip-only.tsx | 1 + .../diagnostics/forbidden/direct-console.ts | 3 + .../diagnostics/forbidden/raw-context.ts | 8 + .../i18n/forbidden/backend-message.tsx | 5 + .../i18n/forbidden/hardcoded-common.tsx | 3 + tests/fixtures/i18n/forbidden/unsafe-html.tsx | 3 + .../src/adapters/bad-storage.ts | 3 + .../forbidden/production-import/src/bad.ts | 3 + .../src/bootstrap/bad-runtime.ts | 3 + .../forbidden/server-state/src/bad-store.ts | 3 + .../src/presentation/bad-vendor.ts | 3 + .../src/adapters/realtime/native-websocket.ts | 3 + .../src/adapters/web-push/native-push.ts | 5 + .../src/bootstrap/composed-realtime.ts | 3 + .../src/presentation/direct-realtime.ts | 4 + .../registry/compatibility/semantic-diff.json | 225 + .../compatibility/tampered-approval.json | 6 + .../registry/forbidden/governance.json | 33 + .../registry/forbidden/invalid-registry.ts | 13 + .../invalidation/duplicate-governance.json | 40 + .../registry/invalidation/governance.json | 62 + .../registry/invalidation/registries.ts | 49 + .../fixtures/registry/routes/governance.json | 59 + .../registry/routes/invalid-routes.ts | 29 + .../security/allowed/safe-text-expression.tsx | 3 + tests/fixtures/security/allowed/safe-text.tsx | 3 + .../security/forbidden/dynamic-script.ts | 5 + tests/fixtures/security/forbidden/eval.ts | 1 + .../forbidden/raw-html-expression.tsx | 3 + .../fixtures/security/forbidden/raw-html.tsx | 3 + .../allowed/test-credentials.ts | 1 + .../secret-detection/forbidden-policy.json | 12 + .../secret-detection/forbidden/config.json | 3 + .../secret-detection/forbidden/dist.ts | 2 + .../secret-detection/forbidden/source.ts | 1 + .../secret-detection/nonmatching-policy.json | 12 + .../forbidden/full-screen-mask.fixture | 5 + .../forbidden/unowned-skip.fixture | 1 + .../test-evidence/scenarios/catalog.json | 36 + .../test-evidence/scenarios/policy.json | 13 + .../scenarios/receipt-semantic-invalid.json | 59 + .../test-evidence/scenarios/receipt.json | 32 + .../test-evidence/scenarios/source/.gitkeep | 0 .../typecheck/invalid-application-input.ts | 5 + .../typecheck/invalid-application-output.ts | 7 + .../typecheck/invalid-async-overlay.ts | 9 + .../typecheck/invalid-diagnostics-port.ts | 11 + .../typecheck/invalid-failure-kind.ts | 11 + .../typecheck/invalid-feature-input.ts | 8 + .../typecheck/invalid-icon-button.tsx | 3 + .../fixtures/typecheck/invalid-message-key.ts | 3 + .../typecheck/invalid-message-params.ts | 5 + .../typecheck/invalid-page-action.tsx | 6 + tests/fixtures/typecheck/invalid-port-call.ts | 7 + .../typecheck/invalid-port-implementation.ts | 7 + .../typecheck/invalid-reference-operation.ts | 9 + .../typecheck/invalid-result-narrowing.ts | 5 + .../typecheck/invalid-route-runtime.ts | 14 + .../counter-semantics.fixture.ts | 19 + .../src/import-empty.ts | 1 + .../src/import-side-effect.ts | 1 + .../src/import-type-empty.ts | 1 + .../src/import-value.ts | 3 + .../src/reexport-named.ts | 1 + .../src/reexport-star.ts | 1 + .../src/runtime.ts | 1 + .../src/type-only.ts | 5 + tests/helpers/create-test-application.ts | 59 + tests/helpers/external-contract-fixture.ts | 220 + tests/helpers/fake-push-control-repository.ts | 228 + tests/helpers/http-contract-fixture.ts | 115 + tests/helpers/memory-indexeddb.ts | 922 +++ tests/helpers/runtime-capabilities-stub.ts | 34 + tests/integration/auth-recovery.test.ts | 126 + tests/integration/http-boundary.test.ts | 23 + tests/integration/http-client.test.ts | 174 + tests/integration/http-diagnostics.test.ts | 193 + .../http-execution-contract.test.ts | 375 + .../integration/http-scenario-catalog.test.ts | 411 + .../security-followup-archive.test.ts | 161 + tests/mocks/contracts/envelopes.ts | 30 + tests/mocks/handlers/bootstrap.ts | 16 + tests/mocks/handlers/reference-resources.ts | 226 + tests/mocks/scenarios/catalog.ts | 435 ++ tests/mocks/server.ts | 13 + .../browser-file-storage-contracts.test.ts | 673 ++ .../optional-capability-contracts.test.ts | 290 + tests/runtime-schema/http-schema.test.ts | 70 + tests/runtime-schema/release-manifest.test.ts | 242 + tests/runtime-schema/runtime-config.test.ts | 189 + tests/setup.ts | 7 + tests/storybook/workshop.spec.ts | 24 + tests/support/browser/strict-browser-test.ts | 46 + tests/support/runtime/determinism.ts | 69 + .../ci-workflow-generation.test.ts.snap | 462 ++ tests/unit/api-operation-v2.test.ts | 58 + tests/unit/application-boundary.test.ts | 171 + tests/unit/auth-session-adapter.test.ts | 92 + tests/unit/boundary-mapper.test.ts | 34 + tests/unit/bounded-body-reader.test.ts | 225 + tests/unit/browser-file-content.test.ts | 758 ++ tests/unit/browser-file-download.test.ts | 909 +++ tests/unit/browser-file-picker.test.ts | 496 ++ .../unit/browser-file-policy-registry.test.ts | 281 + tests/unit/browser-file-runtime.test.ts | 304 + .../browser-rpc/browser-rpc-contract.test.ts | 264 + .../browser-rpc/browser-rpc-runtime.test.ts | 451 ++ tests/unit/browser-rpc/fixture.ts | 241 + tests/unit/browser-storage-policy.test.ts | 83 + tests/unit/build-environment.test.ts | 71 + tests/unit/bundle-artifact-contract.test.ts | 82 + tests/unit/bundle-classification.test.ts | 40 + tests/unit/chunk-recovery-runtime.test.ts | 156 + tests/unit/chunk-recovery.test.ts | 60 + tests/unit/ci-artifact-contract.test.ts | 2394 ++++++ tests/unit/ci-step-result.test.ts | 148 + tests/unit/ci-workflow-generation.test.ts | 1635 ++++ tests/unit/color-scheme.test.ts | 39 + tests/unit/compatibility.test.ts | 77 + .../unit/conditional-validator-store.test.ts | 66 + tests/unit/cross-tab-invalidation.test.ts | 651 ++ tests/unit/cursor-pagination-runtime.test.ts | 121 + tests/unit/design-system-contract.test.ts | 48 + tests/unit/diagnostics.test.ts | 141 + tests/unit/error-classification.test.ts | 76 + tests/unit/external-contract-runtime.test.ts | 85 + tests/unit/field-vitals-evidence.test.ts | 94 + tests/unit/hosting-probe.test.ts | 27 + tests/unit/http-execution-v3.test.ts | 627 ++ tests/unit/http-scenario-evidence.test.ts | 360 + tests/unit/i18n-contract.test.ts | 115 + tests/unit/image-cdn-runtime.test.ts | 2221 ++++++ tests/unit/indexeddb-maintenance.test.ts | 811 ++ tests/unit/indexeddb-opfs-journal.test.ts | 431 ++ tests/unit/indexeddb-runtime.test.ts | 1140 +++ tests/unit/json-schema.test.ts | 237 + tests/unit/local-promotion-evidence.test.ts | 252 + tests/unit/manual-a11y-evidence.test.ts | 67 + tests/unit/navigation-policy.test.ts | 61 + tests/unit/opfs-byte-store.test.ts | 1128 +++ tests/unit/opfs-worker-runtime.test.ts | 730 ++ tests/unit/optimistic-layer-runtime.test.ts | 251 + tests/unit/optional-runtime-host.test.ts | 90 + tests/unit/performance-budgets.test.ts | 77 + tests/unit/presigned-transfer.test.ts | 1635 ++++ tests/unit/promotion-readiness.test.ts | 52 + .../provider-guardian-transaction.test.ts | 1105 +++ tests/unit/provider-output-limiter.test.ts | 39 + tests/unit/public-response-cache.test.ts | 1217 +++ tests/unit/query-cache.test.ts | 77 + .../unit/query-invalidation-registry.test.ts | 201 + tests/unit/query-key-codec.test.ts | 73 + tests/unit/read-bounded-boot-json.test.ts | 31 + .../realtime/bounded-poll-coordinator.test.ts | 876 +++ .../realtime/bounded-polling-policy.test.ts | 105 + tests/unit/realtime/event-codec.test.ts | 243 + tests/unit/realtime/event-consumer.test.ts | 211 + .../realtime/fetch-sse-connection.test.ts | 568 ++ tests/unit/realtime/fixture.ts | 252 + .../live-poll-handoff-coordinator.test.ts | 548 ++ .../realtime-reconnect-coordinator.test.ts | 1457 ++++ .../realtime-reconnect-policy.test.ts | 160 + .../realtime/realtime-stream-registry.test.ts | 222 + tests/unit/realtime/result.test.ts | 168 + tests/unit/realtime/sse-parser.test.ts | 188 + .../unit/realtime/stream-coordinator.test.ts | 975 +++ .../realtime/websocket-connection.test.ts | 1176 +++ .../unit/realtime/websocket-protocol.test.ts | 266 + tests/unit/registry-compatibility.test.ts | 102 + tests/unit/registry-governance.test.ts | 376 + tests/unit/release-artifacts.test.ts | 600 ++ tests/unit/release-coherence.test.ts | 370 + tests/unit/removal-fixture.test.ts | 90 + tests/unit/repository-file-inventory.test.ts | 278 + tests/unit/request-builder.test.ts | 65 + tests/unit/rest-profile-contract.test.ts | 98 + .../unit/resumable-upload-checkpoint.test.ts | 238 + .../resumable-upload-fetch-transport.test.ts | 447 ++ ...esumable-upload-http-control-plane.test.ts | 511 ++ tests/unit/resumable-upload-runtime.test.ts | 1223 +++ tests/unit/retry-policy.test.ts | 47 + tests/unit/risk-coverage-files.test.ts | 372 + tests/unit/risk-coverage.test.ts | 931 +++ tests/unit/route-runtime.test.ts | 29 + tests/unit/runbook-contract.test.ts | 53 + tests/unit/runtime-adapters.test.ts | 360 + .../unit/runtime-capability-snapshot.test.ts | 104 + tests/unit/security-followup.test.ts | 1851 +++++ tests/unit/server-state-scope-runtime.test.ts | 197 + tests/unit/service-worker-build-input.test.ts | 87 + tests/unit/service-worker-runtime.test.ts | 567 ++ tests/unit/storage-manager-adapter.test.ts | 204 + tests/unit/storage-registry.test.ts | 508 ++ tests/unit/supply-chain.test.ts | 640 ++ tests/unit/system-clock.test.ts | 18 + tests/unit/tanstack-cache-coordinator.test.ts | 328 + .../unit/task3-selective-integration.test.ts | 213 + tests/unit/telemetry.test.ts | 239 + tests/unit/typescript-tooling-smoke.test.ts | 16 + .../unit/upload-cancellation-channel.test.ts | 117 + .../v8-coverage-counter-semantics.test.ts | 214 + tests/unit/validated-json-artifact.test.ts | 409 + tests/unit/web-push-codec.test.ts | 255 + tests/unit/web-push-fence-store.test.ts | 350 + tests/unit/web-push-runtime-support.test.ts | 86 + .../web-push-store-port-compatibility.test.ts | 28 + .../web-push-subscription-adapter.test.ts | 830 ++ tests/unit/web-push-worker-runtime.test.ts | 617 ++ ...ct-pseudo-drawer-chromium-visual-linux.png | Bin 0 -> 24400 bytes ...shell-wide-light-chromium-visual-linux.png | Bin 0 -> 92844 bytes ...tem-gallery-dark-chromium-visual-linux.png | Bin 0 -> 190837 bytes ...m-overview-light-chromium-visual-linux.png | Bin 0 -> 405018 bytes ...e-surfaces-light-chromium-visual-linux.png | Bin 0 -> 8792 bytes tests/visual/platform.visual.spec.ts | 54 + tsconfig.app.json | 22 + tsconfig.base.json | 18 + tsconfig.json | 11 + tsconfig.node.json | 14 + tsconfig.recipes.json | 9 + tsconfig.service-worker.json | 17 + tsconfig.test.json | 18 + tsconfig.web-worker.json | 10 + vite.config.ts | 13 + vite.service-worker.config.ts | 92 + vitest.config.ts | 24 + 897 files changed, 234824 insertions(+) create mode 100644 .dependency-cruiser.json create mode 100644 .gitea/workflows/quality-gates.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .storybook/main.ts create mode 100644 .storybook/preview.tsx create mode 100644 .superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md create mode 100644 README.md create mode 100644 artifacts/local-preview/home.png create mode 100644 artifacts/local-preview/not-found.png create mode 100644 artifacts/local-preview/sample-session.png create mode 100644 artifacts/runbooks/.gitkeep create mode 100644 artifacts/tests/.gitkeep create mode 100644 artifacts/tests/a11y-manual/APP_HOME.md create mode 100644 artifacts/tests/a11y-manual/EXAMPLES_AUTH.md create mode 100644 artifacts/tests/a11y-manual/EXAMPLES_PLATFORM.md create mode 100644 artifacts/tests/a11y-manual/EXAMPLES_STATES.md create mode 100644 artifacts/tests/a11y-manual/EXAMPLES_UI.md create mode 100644 artifacts/tests/a11y-manual/NOT_FOUND.md create mode 100644 artifacts/tests/a11y-manual/REFERENCE_RESOURCE_DETAIL.md create mode 100644 artifacts/tests/a11y-manual/REFERENCE_RESOURCE_FORM.md create mode 100644 artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md create mode 100644 artifacts/tests/a11y-manual/REFERENCE_RESOURCE_STATUS.md create mode 100644 config/architecture/layers.json create mode 100644 config/ci/gates.json create mode 100644 config/compatibility/fixtures.json create mode 100644 config/contracts/registry-baseline.approval.json create mode 100644 config/contracts/registry-baseline.json create mode 100644 config/contracts/registry-change-evidence.json create mode 100644 config/contracts/registry-governance.json create mode 100644 config/hosting/cache-policy.json create mode 100644 config/hosting/response-headers.fixture.json create mode 100644 config/hosting/security-headers.json create mode 100644 config/performance/budgets.json create mode 100644 config/performance/field-input.example.json create mode 100644 config/recipes/frontend-capability-recipes.json create mode 100644 config/release/coherence-fixtures.json create mode 100644 config/runbooks/runbooks.json create mode 100644 config/schemas/field-web-vitals.schema.json create mode 100644 config/schemas/lab-performance.schema.json create mode 100644 config/schemas/release-verification.schema.json create mode 100644 config/schemas/runbook-drill-record.schema.json create mode 100644 config/security/dependency-baseline.approval.json create mode 100644 config/security/dependency-baseline.json create mode 100644 config/security/dependency-change-evidence.json create mode 100644 config/security/dependency-policy.json create mode 100644 config/security/secret-scan-policy.json create mode 100644 config/security/vulnerability-exceptions.json create mode 100644 config/security/vulnerability-policy.json create mode 100644 config/testing/risk-coverage.json create mode 100644 config/testing/test-evidence.json create mode 100644 docs/accessibility/manual-checklist.md create mode 100644 docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md create mode 100644 docs/architecture/api-contract-schema-mapper-and-server-state.md create mode 100644 docs/architecture/backend-api-and-server-state-contract.md create mode 100644 docs/architecture/browser-data-capability-completion-ledger.md create mode 100644 docs/architecture/browser-file-and-origin-storage.md create mode 100644 docs/architecture/client-cache-and-storage.md create mode 100644 docs/architecture/decisions/VD-01-typescript-lint-tooling.md create mode 100644 docs/architecture/decisions/VD-03-react-router-data-mode.md create mode 100644 docs/architecture/decisions/VD-04-native-form-facade.md create mode 100644 docs/architecture/decisions/VD-05-icon-and-headless-ui.md create mode 100644 docs/architecture/decisions/VD-06-intl-typed-message-catalog.md create mode 100644 docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md create mode 100644 docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md create mode 100644 docs/architecture/decisions/VD-09-supply-chain-evidence.md create mode 100644 docs/architecture/decisions/VD-10-optional-capability-recipes.md create mode 100644 docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md create mode 100644 docs/architecture/decisions/VD-12-presigned-transfer-and-image-cdn.md create mode 100644 docs/architecture/decisions/VD-13-client-cache-scope-and-persistence.md create mode 100644 docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md create mode 100644 docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md create mode 100644 docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md create mode 100644 docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md create mode 100644 docs/architecture/decisions/VD-24-runtime-schema-and-boundary-mapper.md create mode 100644 docs/architecture/decisions/VD-25-server-state-cache-lifecycle.md create mode 100644 docs/architecture/decisions/VD-26-persisted-graphql-operation.md create mode 100644 docs/architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md create mode 100644 docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md create mode 100644 docs/architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md create mode 100644 docs/architecture/decisions/VD-30-protobuf-contract-and-rest-gateway.md create mode 100644 docs/architecture/frontend-platform-capability-review.md create mode 100644 docs/architecture/frontend-platform-implementation-roadmap.md create mode 100644 docs/architecture/frontend-ports-adapters-and-boundaries.md create mode 100644 docs/architecture/layers.md create mode 100644 docs/architecture/optional-adapter-recipes.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/presigned-transfer-and-image-cdn.md create mode 100644 docs/architecture/protobuf-browser-transport-and-rest-gateway.md create mode 100644 docs/architecture/realtime-events-web-push-and-bounded-polling.md create mode 100644 docs/architecture/review-evidence.md create mode 100644 docs/architecture/review-ledger.json create mode 100644 docs/architecture/routing-pages-and-patterns.md create mode 100644 docs/architecture/server-file-capability-infrastructure.md create mode 100644 docs/architecture/starter-experience.md create mode 100644 docs/architecture/static-delivery.md create mode 100644 docs/architecture/typescript-state-and-data-flow.md create mode 100644 docs/contracts/compatibility.md create mode 100644 docs/operations/api-contract-and-server-state-recovery.md create mode 100644 docs/operations/browser-file-storage-recovery.md create mode 100644 docs/operations/browser-transfer-recovery.md create mode 100644 docs/operations/ci-quality-gates.md create mode 100644 docs/operations/client-cache-and-storage-recovery.md create mode 100644 docs/operations/performance-evidence.md create mode 100644 docs/operations/release-cache-rollback.md create mode 100644 docs/runbooks/FE-RB-001.md create mode 100644 docs/runbooks/FE-RB-002.md create mode 100644 docs/runbooks/FE-RB-003.md create mode 100644 docs/runbooks/FE-RB-004.md create mode 100644 docs/runbooks/FE-RB-005.md create mode 100644 docs/security/browser-boundary.md create mode 100644 docs/security/supply-chain.md create mode 100644 docs/styling/design-system-platform.md create mode 100644 docs/styling/design-tokens.md create mode 100644 docs/superpowers/plans/2026-07-31-platform-overview-completion.md create mode 100644 docs/superpowers/plans/2026-08-01-http-worker-adapter-remediation.md create mode 100644 docs/superpowers/plans/2026-08-01-quality-architecture-remediation.md create mode 100644 docs/superpowers/plans/2026-08-01-release-boot-integrity.md create mode 100644 docs/superpowers/plans/2026-08-01-release-evidence-remediation.md create mode 100644 docs/superpowers/plans/2026-08-01-runtime-correctness-remediation.md create mode 100644 docs/superpowers/plans/2026-08-01-scope-owned-server-state.md create mode 100644 docs/superpowers/plans/2026-08-02-cicd-frontend-assurance-delivery.md create mode 100644 docs/superpowers/plans/2026-08-02-counter-bearing-coverage-provenance.md create mode 100644 docs/superpowers/plans/2026-08-02-frontend-platform-consumer-migration.md create mode 100644 docs/superpowers/plans/2026-08-02-promotion-security-review-fixes.md create mode 100644 docs/superpowers/plans/2026-08-02-provider-raw-guardian.md create mode 100644 docs/superpowers/plans/2026-08-02-security-finalizer.md create mode 100644 docs/superpowers/plans/2026-08-02-test-assurance-frontend-capabilities.md create mode 100644 docs/superpowers/plans/2026-08-02-v8-coverage-counter-contract.md create mode 100644 docs/superpowers/specs/2026-07-31-platform-overview-page-design.md create mode 100644 docs/superpowers/specs/2026-08-01-refactoring-review-remediation-design.md create mode 100644 docs/superpowers/specs/2026-08-01-runtime-integrity-refactor-design.md create mode 100644 docs/superpowers/specs/2026-08-02-platform-owned-frontend-assurance-delivery-design.md create mode 100644 docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md create mode 100644 docs/superpowers/specs/2026-08-02-v8-coverage-counter-contract-design.md create mode 100644 docs/testing/frontend-platform-testing-strategy.md create mode 100644 docs/testing/taxonomy.md create mode 100644 eslint.config.ts create mode 100644 index.html create mode 100644 package.json create mode 100644 playwright.capabilities.config.ts create mode 100644 playwright.config.ts create mode 100644 playwright.dev.config.ts create mode 100644 playwright.storybook.config.ts create mode 100644 playwright.visual.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 public/config.json create mode 100644 public/release-manifest.json create mode 100644 recipes/frontend-capabilities/browser-file-storage-contracts.ts create mode 100644 recipes/frontend-capabilities/browser-file-storage-fakes.ts create mode 100644 recipes/frontend-capabilities/contracts.ts create mode 100644 recipes/frontend-capabilities/fake-adapters.ts create mode 100644 recipes/frontend-capabilities/index.ts create mode 100644 schemas/artifacts/build-manifest.schema.json create mode 100644 schemas/artifacts/dependency-inventory.schema.json create mode 100644 schemas/artifacts/registry-snapshot.schema.json create mode 100644 schemas/artifacts/supply-chain-verification.schema.json create mode 100644 schemas/config/frontend-capability-recipes.schema.json create mode 100644 scripts/build-frontend.ts create mode 100644 scripts/check-architecture.ts create mode 100644 scripts/check-browser-file-storage-boundaries.ts create mode 100644 scripts/check-browser-security.ts create mode 100644 scripts/check-bundle.ts create mode 100644 scripts/check-ci-contract.ts create mode 100644 scripts/check-compatibility.ts create mode 100644 scripts/check-design-system.ts create mode 100644 scripts/check-diagnostics.ts create mode 100644 scripts/check-frozen-lockfile-fixture.ts create mode 100644 scripts/check-i18n.ts create mode 100644 scripts/check-optional-recipe-fixtures.ts create mode 100644 scripts/check-optional-recipe-types.ts create mode 100644 scripts/check-optional-recipes.ts create mode 100644 scripts/check-realtime-boundaries.ts create mode 100644 scripts/check-realtime-boundary-fixtures.ts create mode 100644 scripts/check-registries.ts create mode 100644 scripts/check-registry-compatibility-fixtures.ts create mode 100644 scripts/check-risk-coverage.ts create mode 100644 scripts/check-security-fixtures.ts create mode 100644 scripts/check-supply-chain-fixtures.ts create mode 100644 scripts/check-supply-chain-provider-fixtures.ts create mode 100644 scripts/check-test-evidence.ts create mode 100644 scripts/check-v8-coverage-counter-semantics.ts create mode 100644 scripts/cleanup-verified-promotion.ts create mode 100644 scripts/collect-web-vitals-evidence.ts create mode 100644 scripts/contracts/ci-gates.ts create mode 100644 scripts/contracts/promotion-artifacts.ts create mode 100644 scripts/contracts/release-artifacts.ts create mode 100644 scripts/create-release-candidate.ts create mode 100644 scripts/drill-runbook.ts create mode 100644 scripts/generate-artifact-schemas.ts create mode 100644 scripts/generate-build-manifest.ts create mode 100644 scripts/generate-ci-workflow.ts create mode 100644 scripts/generate-contract-set.ts create mode 100644 scripts/generate-service-worker-assets.ts create mode 100644 scripts/generate-supply-chain.ts create mode 100644 scripts/lib/bounded-pnpm-script.ts create mode 100644 scripts/lib/build-environment.ts create mode 100644 scripts/lib/build-manifest-outputs.ts create mode 100644 scripts/lib/ci-artifact-validator.ts create mode 100644 scripts/lib/ci-candidate-archive-cli.ts create mode 100644 scripts/lib/ci-candidate-archive.ts create mode 100644 scripts/lib/ci-contract-report.ts create mode 100644 scripts/lib/ci-gate-log.ts create mode 100644 scripts/lib/ci-step-result.ts create mode 100644 scripts/lib/classify-vite-bundle.ts create mode 100644 scripts/lib/exact-promotion-bundle.ts create mode 100644 scripts/lib/field-vitals-evidence.ts create mode 100644 scripts/lib/hosting-probe.ts create mode 100644 scripts/lib/http-scenario-evidence.ts create mode 100644 scripts/lib/json-schema.ts create mode 100644 scripts/lib/local-policy-evidence.ts create mode 100644 scripts/lib/local-release-evidence.ts create mode 100644 scripts/lib/manual-a11y-evidence.ts create mode 100644 scripts/lib/optional-recipe-bundle.ts create mode 100644 scripts/lib/optional-recipes.ts create mode 100644 scripts/lib/package-script-graph.ts create mode 100644 scripts/lib/promotion-stager.ts create mode 100644 scripts/lib/provider-cgroup.ts create mode 100644 scripts/lib/provider-evidence.ts create mode 100644 scripts/lib/provider-guardian-client.ts create mode 100644 scripts/lib/provider-guardian-protocol.ts create mode 100644 scripts/lib/provider-output-limiter.ts create mode 100644 scripts/lib/provider-process-runner.ts create mode 100644 scripts/lib/provider-raw-cleanup.ts create mode 100644 scripts/lib/provider-raw-guardian.ts create mode 100644 scripts/lib/provider-scope-wrapper.ts create mode 100644 scripts/lib/provider-supervisor.ts create mode 100644 scripts/lib/provider-trust.ts create mode 100644 scripts/lib/provider-upload-validator.ts create mode 100644 scripts/lib/realtime-boundaries.ts create mode 100644 scripts/lib/registry-compatibility.ts create mode 100644 scripts/lib/release-candidate.ts create mode 100644 scripts/lib/release-input-evidence.ts create mode 100644 scripts/lib/release-runtime-coherence.ts create mode 100644 scripts/lib/removal-fixture.ts create mode 100644 scripts/lib/repository-file-inventory.ts create mode 100644 scripts/lib/risk-coverage-files.ts create mode 100644 scripts/lib/risk-coverage.ts create mode 100644 scripts/lib/secret-scan-evaluator.ts create mode 100644 scripts/lib/secret-scan-policy.ts create mode 100644 scripts/lib/secret-scan.ts create mode 100644 scripts/lib/security-fixture-check.ts create mode 100644 scripts/lib/service-worker-build-input.ts create mode 100644 scripts/lib/stage-verified-promotion-cli.ts create mode 100644 scripts/lib/supply-chain-time.ts create mode 100644 scripts/lib/supply-chain.ts create mode 100644 scripts/lib/test-evidence-artifact.ts create mode 100644 scripts/lib/v8-coverage-counter-semantics.ts create mode 100644 scripts/lib/validated-json-artifact.ts create mode 100644 scripts/lib/vite-module-inventory.ts create mode 100644 scripts/run-and-validate-provider.ts create mode 100644 scripts/run-ci-gate.ts create mode 100644 scripts/run-http-scenario-evidence.ts create mode 100644 scripts/security-scan.ts create mode 100644 scripts/serve-static.ts create mode 100644 scripts/stage-verified-promotion.ts create mode 100644 scripts/test-browser-file-storage-runtime-removal.ts create mode 100644 scripts/test-optional-recipe-removal.ts create mode 100644 scripts/test-performance.ts create mode 100644 scripts/test-realtime-runtime-removal.ts create mode 100644 scripts/test-sample-removal.ts create mode 100644 scripts/update-dependency-baseline.ts create mode 100644 scripts/update-registry-baseline.ts create mode 100644 scripts/verify-a11y-manual.ts create mode 100644 scripts/verify-archived-local-evidence.ts create mode 100644 scripts/verify-browser-capability-evidence.ts create mode 100644 scripts/verify-ci-candidate-archive.ts create mode 100644 scripts/verify-documentation-readiness.ts create mode 100644 scripts/verify-exact-promotion-bundle.ts create mode 100644 scripts/verify-hosting-headers.ts create mode 100644 scripts/verify-release-candidate.ts create mode 100644 scripts/verify-release.ts create mode 100644 scripts/verify-reproducible-build.ts create mode 100644 scripts/verify-supply-chain-artifacts.ts create mode 100644 scripts/write-a11y-report.ts create mode 100644 src/adapters/auth/external-session-adapter.ts create mode 100644 src/adapters/browser-file-storage/index.ts create mode 100644 src/adapters/browser-file-storage/result.ts create mode 100644 src/adapters/browser-file-storage/storage-manager-adapter.ts create mode 100644 src/adapters/browser-files/browser-file-picker.ts create mode 100644 src/adapters/browser-files/browser-file-policy-registry.ts create mode 100644 src/adapters/browser-files/browser-file-vault.ts create mode 100644 src/adapters/browser-files/create-browser-file-runtime.ts create mode 100644 src/adapters/browser-files/download-delivery-adapter.ts create mode 100644 src/adapters/browser-files/file-observer.ts create mode 100644 src/adapters/browser-files/file-policy.ts create mode 100644 src/adapters/browser-files/index.ts create mode 100644 src/adapters/browser-files/object-url-lease.ts create mode 100644 src/adapters/browser-rpc/browser-rpc-runtime.ts create mode 100644 src/adapters/browser-rpc/index.ts create mode 100644 src/adapters/browser-rpc/transport.ts create mode 100644 src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts create mode 100644 src/adapters/browser-transfer/image-cdn/README.md create mode 100644 src/adapters/browser-transfer/image-cdn/browser-image-probe.ts create mode 100644 src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts create mode 100644 src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts create mode 100644 src/adapters/browser-transfer/image-cdn/image-header-metadata.ts create mode 100644 src/adapters/browser-transfer/image-cdn/index.ts create mode 100644 src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts create mode 100644 src/adapters/browser-transfer/index.ts create mode 100644 src/adapters/browser-transfer/presigned/incremental-sha256.ts create mode 100644 src/adapters/browser-transfer/presigned/index.ts create mode 100644 src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts create mode 100644 src/adapters/browser-transfer/presigned/presigned-capability-vault.ts create mode 100644 src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/index.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/runtime-policy.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts create mode 100644 src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts create mode 100644 src/adapters/cache-storage/index.ts create mode 100644 src/adapters/cache-storage/public-cache-policy.ts create mode 100644 src/adapters/cache-storage/public-response-cache-adapter.ts create mode 100644 src/adapters/cross-context-invalidation/browser-cross-context-host.ts create mode 100644 src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts create mode 100644 src/adapters/cross-context-invalidation/index.ts create mode 100644 src/adapters/diagnostics/bounded-diagnostics.ts create mode 100644 src/adapters/http/bounded-body-reader.ts create mode 100644 src/adapters/http/bounded-json.ts create mode 100644 src/adapters/http/client.ts create mode 100644 src/adapters/http/http-contract-bridge.ts create mode 100644 src/adapters/http/http-effect-certainty.ts create mode 100644 src/adapters/http/http-execution-v3.ts create mode 100644 src/adapters/http/request-builder.ts create mode 100644 src/adapters/http/resource-mapper.ts create mode 100644 src/adapters/http/retry-policy.ts create mode 100644 src/adapters/http/schema-registry.ts create mode 100644 src/adapters/platform/browser-lifecycle.ts create mode 100644 src/adapters/platform/browser-mutation-intent-factory.ts create mode 100644 src/adapters/platform/system-clock.ts create mode 100644 src/adapters/query-cache/conditional-validator-store.ts create mode 100644 src/adapters/query-cache/cursor-pagination-runtime.ts create mode 100644 src/adapters/query-cache/server-state-scope-runtime.ts create mode 100644 src/adapters/query-cache/tanstack-cache-coordinator.ts create mode 100644 src/adapters/query-cache/tanstack-query-cache.ts create mode 100644 src/adapters/realtime/event-codec.ts create mode 100644 src/adapters/realtime/event-consumer.ts create mode 100644 src/adapters/realtime/index.ts create mode 100644 src/adapters/realtime/json-member-scanner.ts create mode 100644 src/adapters/realtime/live-poll-handoff-coordinator.ts create mode 100644 src/adapters/realtime/polling/bounded-poll-coordinator.ts create mode 100644 src/adapters/realtime/polling/index.ts create mode 100644 src/adapters/realtime/reconnect-coordinator.ts create mode 100644 src/adapters/realtime/reconnect-policy.ts create mode 100644 src/adapters/realtime/result.ts create mode 100644 src/adapters/realtime/sse/fetch-sse-connection.ts create mode 100644 src/adapters/realtime/sse/index.ts create mode 100644 src/adapters/realtime/sse/sse-parser.ts create mode 100644 src/adapters/realtime/stream-coordinator.ts create mode 100644 src/adapters/realtime/websocket/index.ts create mode 100644 src/adapters/realtime/websocket/websocket-connection.ts create mode 100644 src/adapters/realtime/websocket/websocket-protocol.ts create mode 100644 src/adapters/service-worker/service-worker-entry.ts create mode 100644 src/adapters/service-worker/service-worker-lifecycle.ts create mode 100644 src/adapters/service-worker/service-worker-page-controller.ts create mode 100644 src/adapters/service-worker/service-worker-protocol.ts create mode 100644 src/adapters/service-worker/service-worker-removal.ts create mode 100644 src/adapters/service-worker/service-worker-static-assets.ts create mode 100644 src/adapters/storage/browser-storage-adapter.ts create mode 100644 src/adapters/storage/browser-storage-codec.ts create mode 100644 src/adapters/storage/indexeddb/index.ts create mode 100644 src/adapters/storage/indexeddb/indexeddb-failure.ts create mode 100644 src/adapters/storage/indexeddb/indexeddb-governance.ts create mode 100644 src/adapters/storage/indexeddb/indexeddb-maintenance.ts create mode 100644 src/adapters/storage/indexeddb/indexeddb-migrations.ts create mode 100644 src/adapters/storage/indexeddb/indexeddb-runtime.ts create mode 100644 src/adapters/storage/indexeddb/indexeddb-types.ts create mode 100644 src/adapters/storage/opfs/browser-opfs-runtime.ts create mode 100644 src/adapters/storage/opfs/index.ts create mode 100644 src/adapters/storage/opfs/indexeddb-opfs-journal.ts create mode 100644 src/adapters/storage/opfs/opfs-byte-store-adapter.ts create mode 100644 src/adapters/storage/opfs/opfs-policy.ts create mode 100644 src/adapters/storage/opfs/opfs-worker-client.ts create mode 100644 src/adapters/storage/opfs/opfs-worker-protocol.ts create mode 100644 src/adapters/storage/opfs/opfs-worker-runtime.ts create mode 100644 src/adapters/telemetry/best-effort-telemetry.ts create mode 100644 src/adapters/web-push/inbound/notification-click-adapter.ts create mode 100644 src/adapters/web-push/inbound/push-event-adapter.ts create mode 100644 src/adapters/web-push/index.ts create mode 100644 src/adapters/web-push/notification-registry.ts create mode 100644 src/adapters/web-push/push-association-fence-store.ts create mode 100644 src/adapters/web-push/push-codec.ts create mode 100644 src/adapters/web-push/push-registration-gateway.ts create mode 100644 src/adapters/web-push/push-subscription-adapter.ts create mode 100644 src/adapters/web-push/runtime-support.ts create mode 100644 src/adapters/web-push/service-worker-runtime.ts create mode 100644 src/adapters/web-push/service-worker-scope-host.ts create mode 100644 src/application/create-application.ts create mode 100644 src/application/policies/bounded-polling.ts create mode 100644 src/application/policies/color-scheme.ts create mode 100644 src/application/policies/compatibility.ts create mode 100644 src/application/policies/performance-budgets.ts create mode 100644 src/application/policies/promotion-readiness.ts create mode 100644 src/application/ports/auth-session-port.ts create mode 100644 src/application/ports/browser-file-storage/cache-storage-ports.ts create mode 100644 src/application/ports/browser-file-storage/file.ts create mode 100644 src/application/ports/browser-file-storage/index.ts create mode 100644 src/application/ports/browser-file-storage/indexeddb-port.ts create mode 100644 src/application/ports/browser-file-storage/opfs-ports.ts create mode 100644 src/application/ports/browser-file-storage/shared.ts create mode 100644 src/application/ports/browser-file-storage/storage-durability-port.ts create mode 100644 src/application/ports/browser-rpc/browser-rpc.ts create mode 100644 src/application/ports/browser-rpc/index.ts create mode 100644 src/application/ports/browser-transfer/authorized-download.ts create mode 100644 src/application/ports/browser-transfer/image-cdn.ts create mode 100644 src/application/ports/browser-transfer/index.ts create mode 100644 src/application/ports/browser-transfer/presigned-transfer.ts create mode 100644 src/application/ports/browser-transfer/resumable-upload.ts create mode 100644 src/application/ports/clock-port.ts create mode 100644 src/application/ports/diagnostics-port.ts create mode 100644 src/application/ports/in/application-api.ts create mode 100644 src/application/ports/in/index.ts create mode 100644 src/application/ports/mutation-intent-factory.ts create mode 100644 src/application/ports/out/application-output-ports.ts create mode 100644 src/application/ports/out/index.ts create mode 100644 src/application/ports/out/web-push-control.ts create mode 100644 src/application/ports/query-cache-port.ts create mode 100644 src/application/ports/realtime/event-authority.ts create mode 100644 src/application/ports/realtime/index.ts create mode 100644 src/application/ports/realtime/shared.ts create mode 100644 src/application/ports/release-info-port.ts create mode 100644 src/application/ports/runtime-capabilities-port.ts create mode 100644 src/application/ports/storage-port.ts create mode 100644 src/application/ports/telemetry-port.ts create mode 100644 src/application/result.ts create mode 100644 src/application/use-cases/decide-chunk-recovery.ts create mode 100644 src/application/view-models/async-state.ts create mode 100644 src/bootstrap/composition-root.ts create mode 100644 src/bootstrap/create-runtime-composition.ts create mode 100644 src/bootstrap/initialize-color-scheme.ts create mode 100644 src/bootstrap/load-release-manifest.ts create mode 100644 src/bootstrap/load-runtime-config.ts create mode 100644 src/bootstrap/main.tsx create mode 100644 src/bootstrap/optional-runtime-host.ts create mode 100644 src/bootstrap/read-bounded-boot-json.ts create mode 100644 src/bootstrap/register-service-worker.ts create mode 100644 src/bootstrap/runtime-adapters.ts create mode 100644 src/bootstrap/runtime-application.tsx create mode 100644 src/bootstrap/runtime-config-schema.ts create mode 100644 src/bootstrap/server-state-generation-store.ts create mode 100644 src/contracts/api-operations.ts create mode 100644 src/contracts/boundary-mapper.ts create mode 100644 src/contracts/browser-rpc.ts create mode 100644 src/contracts/cache-invalidation.ts create mode 100644 src/contracts/contract-set-canonical.ts create mode 100644 src/contracts/contract-set.ts create mode 100644 src/contracts/cursor-pagination.ts create mode 100644 src/contracts/diagnostic-buckets.ts create mode 100644 src/contracts/diagnostics.ts create mode 100644 src/contracts/env.ts create mode 100644 src/contracts/errors.ts create mode 100644 src/contracts/external-contract-runtime.ts create mode 100644 src/contracts/mutation-intent.ts create mode 100644 src/contracts/offline-command.ts create mode 100644 src/contracts/query-invalidation.ts create mode 100644 src/contracts/query-keys.ts create mode 100644 src/contracts/realtime-events.ts create mode 100644 src/contracts/realtime-streams.ts create mode 100644 src/contracts/release-artifacts.ts create mode 100644 src/contracts/release-tokens.ts create mode 100644 src/contracts/rest-profiles.ts create mode 100644 src/contracts/route-runtime-contract.ts create mode 100644 src/contracts/routes.ts create mode 100644 src/contracts/runtime-capabilities.ts create mode 100644 src/contracts/schema-registry.ts create mode 100644 src/contracts/server-state-scope.ts create mode 100644 src/contracts/server-state.ts create mode 100644 src/contracts/service-worker.ts create mode 100644 src/contracts/storage-keys.ts create mode 100644 src/contracts/telemetry.ts create mode 100644 src/contracts/web-push.ts create mode 100644 src/contracts/web-worker.ts create mode 100644 src/features/installed-contract-contributions.ts create mode 100644 src/features/installed-feature-adapters.ts create mode 100644 src/features/installed-feature-contracts.ts create mode 100644 src/features/installed-feature-messages.ts create mode 100644 src/features/installed-feature-runtimes.tsx create mode 100644 src/features/installed-runtime-capabilities.ts create mode 100644 src/features/reference-feature/README.md create mode 100644 src/features/reference-feature/adapters/create-reference-feature-input.ts create mode 100644 src/features/reference-feature/adapters/reference-http-gateway.ts create mode 100644 src/features/reference-feature/application/reference-feature-api.ts create mode 100644 src/features/reference-feature/contracts/reference-feature-contract-contribution.ts create mode 100644 src/features/reference-feature/contracts/reference-feature-contract.ts create mode 100644 src/features/reference-feature/contracts/reference-mapper.ts create mode 100644 src/features/reference-feature/contracts/reference-message-catalog.ts create mode 100644 src/features/reference-feature/contracts/reference-schemas.ts create mode 100644 src/features/reference-feature/domain/reference-resource.ts create mode 100644 src/features/reference-feature/presentation/reference-feature-runtime.tsx create mode 100644 src/features/reference-feature/presentation/reference-resource-detail-page.tsx create mode 100644 src/features/reference-feature/presentation/reference-resource-form-page.tsx create mode 100644 src/features/reference-feature/presentation/reference-resource-form.ts create mode 100644 src/features/reference-feature/presentation/reference-resource-page.tsx create mode 100644 src/features/reference-feature/presentation/reference-resource-status-page.tsx create mode 100644 src/features/reference-feature/presentation/use-reference-failure-action.ts create mode 100644 src/features/reference-feature/presentation/use-reference-feature.ts create mode 100644 src/presentation/adapters/query/application-query.ts create mode 100644 src/presentation/adapters/query/index.ts create mode 100644 src/presentation/adapters/query/mutation-intent-provider.tsx create mode 100644 src/presentation/adapters/query/optimistic-layer-runtime.ts create mode 100644 src/presentation/adapters/query/query-invalidation-provider.tsx create mode 100644 src/presentation/adapters/query/server-state-generation-provider.tsx create mode 100644 src/presentation/adapters/query/server-state-scope-provider.tsx create mode 100644 src/presentation/boundaries/boot-error-shell.tsx create mode 100644 src/presentation/boundaries/chunk-recovery-boundary.tsx create mode 100644 src/presentation/boundaries/render-error-boundary.tsx create mode 100644 src/presentation/components/async-surface.tsx create mode 100644 src/presentation/components/error-copy.ts create mode 100644 src/presentation/components/page-header.tsx create mode 100644 src/presentation/components/state-surfaces.tsx create mode 100644 src/presentation/components/ui/alert.ts create mode 100644 src/presentation/components/ui/badge.ts create mode 100644 src/presentation/components/ui/button.ts create mode 100644 src/presentation/components/ui/card.ts create mode 100644 src/presentation/components/ui/dialog.ts create mode 100644 src/presentation/components/ui/text-field.ts create mode 100644 src/presentation/design-system/design-system.stories.tsx create mode 100644 src/presentation/design-system/icons/semantic-icons.tsx create mode 100644 src/presentation/design-system/icons/vendors/lucide.tsx create mode 100644 src/presentation/design-system/index.ts create mode 100644 src/presentation/design-system/patterns/common-patterns.tsx create mode 100644 src/presentation/design-system/primitives/core.tsx create mode 100644 src/presentation/design-system/primitives/feedback.tsx create mode 100644 src/presentation/design-system/primitives/forms.tsx create mode 100644 src/presentation/design-system/primitives/navigation.tsx create mode 100644 src/presentation/design-system/primitives/overlays.tsx create mode 100644 src/presentation/design-system/tokens/component.css create mode 100644 src/presentation/design-system/tokens/primitive.css create mode 100644 src/presentation/design-system/tokens/semantic.css create mode 100644 src/presentation/design-system/tokens/token-contract.ts create mode 100644 src/presentation/examples/auth-example-page.tsx create mode 100644 src/presentation/examples/platform-overview-page.tsx create mode 100644 src/presentation/examples/state-gallery-page.tsx create mode 100644 src/presentation/examples/ui-gallery-page.tsx create mode 100644 src/presentation/forms/form-components.tsx create mode 100644 src/presentation/forms/form-contracts.ts create mode 100644 src/presentation/forms/index.ts create mode 100644 src/presentation/forms/use-app-form.ts create mode 100644 src/presentation/forms/use-dirty-navigation-guard.tsx create mode 100644 src/presentation/i18n/catalog.ts create mode 100644 src/presentation/i18n/formatters.ts create mode 100644 src/presentation/i18n/index.ts create mode 100644 src/presentation/i18n/locale-provider.tsx create mode 100644 src/presentation/i18n/message-contract.ts create mode 100644 src/presentation/layouts/app-shell.tsx create mode 100644 src/presentation/pages/home-page.tsx create mode 100644 src/presentation/pages/not-found-page.tsx create mode 100644 src/presentation/providers/application-provider.tsx create mode 100644 src/presentation/providers/session-provider.tsx create mode 100644 src/presentation/providers/theme-provider.tsx create mode 100644 src/presentation/routes/app-router.tsx create mode 100644 src/presentation/routes/navigation-policy.ts create mode 100644 src/presentation/routes/platform-route-codecs.ts create mode 100644 src/presentation/routes/route-codecs.ts create mode 100644 src/presentation/routes/route-contract.ts create mode 100644 src/presentation/routes/route-input.tsx create mode 100644 src/presentation/routes/route-runtime.tsx create mode 100644 src/presentation/security/safe-text.tsx create mode 100644 src/presentation/styles/theme.css create mode 100644 src/presentation/templates/index.ts create mode 100644 src/presentation/templates/page-templates.tsx create mode 100644 tests/browser-capabilities/browser-files.spec.ts create mode 100644 tests/browser-capabilities/cross-context-invalidation.spec.ts create mode 100644 tests/browser-capabilities/image-cdn.spec.ts create mode 100644 tests/browser-capabilities/indexeddb-runtime.spec.ts create mode 100644 tests/browser-capabilities/opfs-runtime.spec.ts create mode 100644 tests/browser-capabilities/opfs-test.worker.ts create mode 100644 tests/browser-capabilities/presigned-streaming.spec.ts create mode 100644 tests/browser-capabilities/public-cache-storage.spec.ts create mode 100644 tests/browser-capabilities/resumable-upload.spec.ts create mode 100644 tests/browser-capabilities/storage-manager.spec.ts create mode 100644 tests/component/application-query.test.tsx create mode 100644 tests/component/async-surface.test.tsx create mode 100644 tests/component/bootstrap-shell.test.tsx create mode 100644 tests/component/browser-security.test.tsx create mode 100644 tests/component/chunk-recovery-boundary.test.tsx create mode 100644 tests/component/design-system-platform.test.tsx create mode 100644 tests/component/design-tokens.test.tsx create mode 100644 tests/component/form-foundation.test.tsx create mode 100644 tests/component/locale-platform.test.tsx create mode 100644 tests/component/page-header-focus.test.tsx create mode 100644 tests/component/page-templates.test.tsx create mode 100644 tests/component/platform-overview-page.test.tsx create mode 100644 tests/component/render-boundary.test.tsx create mode 100644 tests/component/router.test.tsx create mode 100644 tests/component/runtime-application.test.tsx create mode 100644 tests/component/server-state-generation-provider.test.tsx create mode 100644 tests/component/server-state-scope-provider.test.tsx create mode 100644 tests/component/typescript-tooling-smoke.test.tsx create mode 100644 tests/component/ui-primitives.test.tsx create mode 100644 tests/e2e/accessibility.spec.ts create mode 100644 tests/e2e/app-shell.spec.ts create mode 100644 tests/e2e/compact-smoke.spec.ts create mode 100644 tests/e2e/design-system-interactions.spec.ts create mode 100644 tests/e2e/i18n.spec.ts create mode 100644 tests/e2e/platform-overview.spec.ts create mode 100644 tests/e2e/reference-form.spec.ts create mode 100644 tests/e2e/reference-route.spec.ts create mode 100644 tests/e2e/responsive.spec.ts create mode 100644 tests/e2e/theme.spec.ts create mode 100644 tests/e2e/ui-gallery.spec.ts create mode 100644 tests/features/reference-feature/reference-contract.test.ts create mode 100644 tests/features/reference-feature/reference-diagnostics.test.ts create mode 100644 tests/features/reference-feature/reference-http-gateway.test.ts create mode 100644 tests/features/reference-feature/reference-page.test.tsx create mode 100644 tests/features/reference-feature/reference-production-vertical.test.tsx create mode 100644 tests/features/reference-feature/reference-runtime-composition.test.ts create mode 100644 tests/fixtures/architecture/allowed/bootstrap-composes-adapter.ts create mode 100644 tests/fixtures/architecture/allowed/presentation-facade.ts create mode 100644 tests/fixtures/architecture/allowed/presentation-facade.tsx create mode 100644 tests/fixtures/architecture/dependency-graph/allowed/application/read-value.ts create mode 100644 tests/fixtures/architecture/dependency-graph/allowed/domain/value.ts create mode 100644 tests/fixtures/architecture/dependency-graph/allowed/presentation/value-view.tsx create mode 100644 tests/fixtures/architecture/dependency-graph/cycle/application/first.ts create mode 100644 tests/fixtures/architecture/dependency-graph/cycle/application/second.ts create mode 100644 tests/fixtures/architecture/dependency-graph/layer/adapters/runtime.ts create mode 100644 tests/fixtures/architecture/dependency-graph/layer/application/use-runtime.ts create mode 100644 tests/fixtures/architecture/dependency-graph/unresolved/application/load-value.ts create mode 100644 tests/fixtures/architecture/dependency-graph/unresolved/application/read-value.ts create mode 100644 tests/fixtures/architecture/dependency-graph/unresolved/application/require-value.ts create mode 100644 tests/fixtures/architecture/forbidden/application-imports-adapter.ts create mode 100644 tests/fixtures/architecture/forbidden/application-imports-tanstack.ts create mode 100644 tests/fixtures/architecture/forbidden/domain-imports-react.ts create mode 100644 tests/fixtures/architecture/forbidden/presentation-direct-fetch.tsx create mode 100644 tests/fixtures/architecture/forbidden/presentation-imports-adapter.ts create mode 100644 tests/fixtures/architecture/forbidden/presentation-imports-adapter.tsx create mode 100644 tests/fixtures/architecture/forbidden/presentation-imports-output-port.tsx create mode 100644 tests/fixtures/architecture/forbidden/presentation-imports-tanstack.tsx create mode 100644 tests/fixtures/architecture/forbidden/template-imports-application.tsx create mode 100644 tests/fixtures/browser-file-storage-boundaries/allowed/browser-storage-host.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/allowed/cross-context-host.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/allowed/policy-port.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/aliased-globalthis.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/class-field-alias.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/computed-alias.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/constructor-root-escape.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/default-parameter-alias.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/direct-blob.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/direct-broadcast-channel.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/direct-cache-storage.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/direct-indexeddb.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/direct-local-storage.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/direct-opfs.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/direct-session-storage.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/dynamic-capability-key.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/global-object-container.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/globalthis-storage.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/identity-wrapped-global.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/instance-property-alias.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/object-url.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/property-descriptor-access.ts create mode 100644 tests/fixtures/browser-file-storage-boundaries/forbidden/reflect-get.ts create mode 100644 tests/fixtures/ci-contract/duplicate-gate-id.json create mode 100644 tests/fixtures/ci-contract/job-cycle.json create mode 100644 tests/fixtures/ci-contract/missing-artifact-schema.json create mode 100644 tests/fixtures/ci-contract/multiply-owned-gate.json create mode 100644 tests/fixtures/ci-contract/unknown-field.json create mode 100644 tests/fixtures/ci-contract/unknown-job-dependency.json create mode 100644 tests/fixtures/ci-contract/unowned-gate.json create mode 100644 tests/fixtures/coverage/below-threshold.json create mode 100644 tests/fixtures/coverage/repository-omission.json create mode 100644 tests/fixtures/design-system/forbidden/deep-import.tsx create mode 100644 tests/fixtures/design-system/forbidden/direct-icon.tsx create mode 100644 tests/fixtures/design-system/forbidden/raw-palette.tsx create mode 100644 tests/fixtures/design-system/forbidden/tooltip-only.tsx create mode 100644 tests/fixtures/diagnostics/forbidden/direct-console.ts create mode 100644 tests/fixtures/diagnostics/forbidden/raw-context.ts create mode 100644 tests/fixtures/i18n/forbidden/backend-message.tsx create mode 100644 tests/fixtures/i18n/forbidden/hardcoded-common.tsx create mode 100644 tests/fixtures/i18n/forbidden/unsafe-html.tsx create mode 100644 tests/fixtures/optional-recipes/forbidden/credential-leak/src/adapters/bad-storage.ts create mode 100644 tests/fixtures/optional-recipes/forbidden/production-import/src/bad.ts create mode 100644 tests/fixtures/optional-recipes/forbidden/runtime-composition/src/bootstrap/bad-runtime.ts create mode 100644 tests/fixtures/optional-recipes/forbidden/server-state/src/bad-store.ts create mode 100644 tests/fixtures/optional-recipes/forbidden/vendor-import/src/presentation/bad-vendor.ts create mode 100644 tests/fixtures/realtime-boundaries/allowed/src/adapters/realtime/native-websocket.ts create mode 100644 tests/fixtures/realtime-boundaries/allowed/src/adapters/web-push/native-push.ts create mode 100644 tests/fixtures/realtime-boundaries/forbidden/src/bootstrap/composed-realtime.ts create mode 100644 tests/fixtures/realtime-boundaries/forbidden/src/presentation/direct-realtime.ts create mode 100644 tests/fixtures/registry/compatibility/semantic-diff.json create mode 100644 tests/fixtures/registry/compatibility/tampered-approval.json create mode 100644 tests/fixtures/registry/forbidden/governance.json create mode 100644 tests/fixtures/registry/forbidden/invalid-registry.ts create mode 100644 tests/fixtures/registry/invalidation/duplicate-governance.json create mode 100644 tests/fixtures/registry/invalidation/governance.json create mode 100644 tests/fixtures/registry/invalidation/registries.ts create mode 100644 tests/fixtures/registry/routes/governance.json create mode 100644 tests/fixtures/registry/routes/invalid-routes.ts create mode 100644 tests/fixtures/security/allowed/safe-text-expression.tsx create mode 100644 tests/fixtures/security/allowed/safe-text.tsx create mode 100644 tests/fixtures/security/forbidden/dynamic-script.ts create mode 100644 tests/fixtures/security/forbidden/eval.ts create mode 100644 tests/fixtures/security/forbidden/raw-html-expression.tsx create mode 100644 tests/fixtures/security/forbidden/raw-html.tsx create mode 100644 tests/fixtures/security/secret-detection/allowed/test-credentials.ts create mode 100644 tests/fixtures/security/secret-detection/forbidden-policy.json create mode 100644 tests/fixtures/security/secret-detection/forbidden/config.json create mode 100644 tests/fixtures/security/secret-detection/forbidden/dist.ts create mode 100644 tests/fixtures/security/secret-detection/forbidden/source.ts create mode 100644 tests/fixtures/security/secret-detection/nonmatching-policy.json create mode 100644 tests/fixtures/test-evidence/forbidden/full-screen-mask.fixture create mode 100644 tests/fixtures/test-evidence/forbidden/unowned-skip.fixture create mode 100644 tests/fixtures/test-evidence/scenarios/catalog.json create mode 100644 tests/fixtures/test-evidence/scenarios/policy.json create mode 100644 tests/fixtures/test-evidence/scenarios/receipt-semantic-invalid.json create mode 100644 tests/fixtures/test-evidence/scenarios/receipt.json create mode 100644 tests/fixtures/test-evidence/scenarios/source/.gitkeep create mode 100644 tests/fixtures/typecheck/invalid-application-input.ts create mode 100644 tests/fixtures/typecheck/invalid-application-output.ts create mode 100644 tests/fixtures/typecheck/invalid-async-overlay.ts create mode 100644 tests/fixtures/typecheck/invalid-diagnostics-port.ts create mode 100644 tests/fixtures/typecheck/invalid-failure-kind.ts create mode 100644 tests/fixtures/typecheck/invalid-feature-input.ts create mode 100644 tests/fixtures/typecheck/invalid-icon-button.tsx create mode 100644 tests/fixtures/typecheck/invalid-message-key.ts create mode 100644 tests/fixtures/typecheck/invalid-message-params.ts create mode 100644 tests/fixtures/typecheck/invalid-page-action.tsx create mode 100644 tests/fixtures/typecheck/invalid-port-call.ts create mode 100644 tests/fixtures/typecheck/invalid-port-implementation.ts create mode 100644 tests/fixtures/typecheck/invalid-reference-operation.ts create mode 100644 tests/fixtures/typecheck/invalid-result-narrowing.ts create mode 100644 tests/fixtures/typecheck/invalid-route-runtime.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/counter-semantics.fixture.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/import-empty.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/import-side-effect.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/import-type-empty.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/import-value.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/reexport-named.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/reexport-star.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/runtime.ts create mode 100644 tests/fixtures/v8-coverage-counter-semantics/src/type-only.ts create mode 100644 tests/helpers/create-test-application.ts create mode 100644 tests/helpers/external-contract-fixture.ts create mode 100644 tests/helpers/fake-push-control-repository.ts create mode 100644 tests/helpers/http-contract-fixture.ts create mode 100644 tests/helpers/memory-indexeddb.ts create mode 100644 tests/helpers/runtime-capabilities-stub.ts create mode 100644 tests/integration/auth-recovery.test.ts create mode 100644 tests/integration/http-boundary.test.ts create mode 100644 tests/integration/http-client.test.ts create mode 100644 tests/integration/http-diagnostics.test.ts create mode 100644 tests/integration/http-execution-contract.test.ts create mode 100644 tests/integration/http-scenario-catalog.test.ts create mode 100644 tests/integration/security-followup-archive.test.ts create mode 100644 tests/mocks/contracts/envelopes.ts create mode 100644 tests/mocks/handlers/bootstrap.ts create mode 100644 tests/mocks/handlers/reference-resources.ts create mode 100644 tests/mocks/scenarios/catalog.ts create mode 100644 tests/mocks/server.ts create mode 100644 tests/recipes/browser-file-storage-contracts.test.ts create mode 100644 tests/recipes/optional-capability-contracts.test.ts create mode 100644 tests/runtime-schema/http-schema.test.ts create mode 100644 tests/runtime-schema/release-manifest.test.ts create mode 100644 tests/runtime-schema/runtime-config.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/storybook/workshop.spec.ts create mode 100644 tests/support/browser/strict-browser-test.ts create mode 100644 tests/support/runtime/determinism.ts create mode 100644 tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap create mode 100644 tests/unit/api-operation-v2.test.ts create mode 100644 tests/unit/application-boundary.test.ts create mode 100644 tests/unit/auth-session-adapter.test.ts create mode 100644 tests/unit/boundary-mapper.test.ts create mode 100644 tests/unit/bounded-body-reader.test.ts create mode 100644 tests/unit/browser-file-content.test.ts create mode 100644 tests/unit/browser-file-download.test.ts create mode 100644 tests/unit/browser-file-picker.test.ts create mode 100644 tests/unit/browser-file-policy-registry.test.ts create mode 100644 tests/unit/browser-file-runtime.test.ts create mode 100644 tests/unit/browser-rpc/browser-rpc-contract.test.ts create mode 100644 tests/unit/browser-rpc/browser-rpc-runtime.test.ts create mode 100644 tests/unit/browser-rpc/fixture.ts create mode 100644 tests/unit/browser-storage-policy.test.ts create mode 100644 tests/unit/build-environment.test.ts create mode 100644 tests/unit/bundle-artifact-contract.test.ts create mode 100644 tests/unit/bundle-classification.test.ts create mode 100644 tests/unit/chunk-recovery-runtime.test.ts create mode 100644 tests/unit/chunk-recovery.test.ts create mode 100644 tests/unit/ci-artifact-contract.test.ts create mode 100644 tests/unit/ci-step-result.test.ts create mode 100644 tests/unit/ci-workflow-generation.test.ts create mode 100644 tests/unit/color-scheme.test.ts create mode 100644 tests/unit/compatibility.test.ts create mode 100644 tests/unit/conditional-validator-store.test.ts create mode 100644 tests/unit/cross-tab-invalidation.test.ts create mode 100644 tests/unit/cursor-pagination-runtime.test.ts create mode 100644 tests/unit/design-system-contract.test.ts create mode 100644 tests/unit/diagnostics.test.ts create mode 100644 tests/unit/error-classification.test.ts create mode 100644 tests/unit/external-contract-runtime.test.ts create mode 100644 tests/unit/field-vitals-evidence.test.ts create mode 100644 tests/unit/hosting-probe.test.ts create mode 100644 tests/unit/http-execution-v3.test.ts create mode 100644 tests/unit/http-scenario-evidence.test.ts create mode 100644 tests/unit/i18n-contract.test.ts create mode 100644 tests/unit/image-cdn-runtime.test.ts create mode 100644 tests/unit/indexeddb-maintenance.test.ts create mode 100644 tests/unit/indexeddb-opfs-journal.test.ts create mode 100644 tests/unit/indexeddb-runtime.test.ts create mode 100644 tests/unit/json-schema.test.ts create mode 100644 tests/unit/local-promotion-evidence.test.ts create mode 100644 tests/unit/manual-a11y-evidence.test.ts create mode 100644 tests/unit/navigation-policy.test.ts create mode 100644 tests/unit/opfs-byte-store.test.ts create mode 100644 tests/unit/opfs-worker-runtime.test.ts create mode 100644 tests/unit/optimistic-layer-runtime.test.ts create mode 100644 tests/unit/optional-runtime-host.test.ts create mode 100644 tests/unit/performance-budgets.test.ts create mode 100644 tests/unit/presigned-transfer.test.ts create mode 100644 tests/unit/promotion-readiness.test.ts create mode 100644 tests/unit/provider-guardian-transaction.test.ts create mode 100644 tests/unit/provider-output-limiter.test.ts create mode 100644 tests/unit/public-response-cache.test.ts create mode 100644 tests/unit/query-cache.test.ts create mode 100644 tests/unit/query-invalidation-registry.test.ts create mode 100644 tests/unit/query-key-codec.test.ts create mode 100644 tests/unit/read-bounded-boot-json.test.ts create mode 100644 tests/unit/realtime/bounded-poll-coordinator.test.ts create mode 100644 tests/unit/realtime/bounded-polling-policy.test.ts create mode 100644 tests/unit/realtime/event-codec.test.ts create mode 100644 tests/unit/realtime/event-consumer.test.ts create mode 100644 tests/unit/realtime/fetch-sse-connection.test.ts create mode 100644 tests/unit/realtime/fixture.ts create mode 100644 tests/unit/realtime/live-poll-handoff-coordinator.test.ts create mode 100644 tests/unit/realtime/realtime-reconnect-coordinator.test.ts create mode 100644 tests/unit/realtime/realtime-reconnect-policy.test.ts create mode 100644 tests/unit/realtime/realtime-stream-registry.test.ts create mode 100644 tests/unit/realtime/result.test.ts create mode 100644 tests/unit/realtime/sse-parser.test.ts create mode 100644 tests/unit/realtime/stream-coordinator.test.ts create mode 100644 tests/unit/realtime/websocket-connection.test.ts create mode 100644 tests/unit/realtime/websocket-protocol.test.ts create mode 100644 tests/unit/registry-compatibility.test.ts create mode 100644 tests/unit/registry-governance.test.ts create mode 100644 tests/unit/release-artifacts.test.ts create mode 100644 tests/unit/release-coherence.test.ts create mode 100644 tests/unit/removal-fixture.test.ts create mode 100644 tests/unit/repository-file-inventory.test.ts create mode 100644 tests/unit/request-builder.test.ts create mode 100644 tests/unit/rest-profile-contract.test.ts create mode 100644 tests/unit/resumable-upload-checkpoint.test.ts create mode 100644 tests/unit/resumable-upload-fetch-transport.test.ts create mode 100644 tests/unit/resumable-upload-http-control-plane.test.ts create mode 100644 tests/unit/resumable-upload-runtime.test.ts create mode 100644 tests/unit/retry-policy.test.ts create mode 100644 tests/unit/risk-coverage-files.test.ts create mode 100644 tests/unit/risk-coverage.test.ts create mode 100644 tests/unit/route-runtime.test.ts create mode 100644 tests/unit/runbook-contract.test.ts create mode 100644 tests/unit/runtime-adapters.test.ts create mode 100644 tests/unit/runtime-capability-snapshot.test.ts create mode 100644 tests/unit/security-followup.test.ts create mode 100644 tests/unit/server-state-scope-runtime.test.ts create mode 100644 tests/unit/service-worker-build-input.test.ts create mode 100644 tests/unit/service-worker-runtime.test.ts create mode 100644 tests/unit/storage-manager-adapter.test.ts create mode 100644 tests/unit/storage-registry.test.ts create mode 100644 tests/unit/supply-chain.test.ts create mode 100644 tests/unit/system-clock.test.ts create mode 100644 tests/unit/tanstack-cache-coordinator.test.ts create mode 100644 tests/unit/task3-selective-integration.test.ts create mode 100644 tests/unit/telemetry.test.ts create mode 100644 tests/unit/typescript-tooling-smoke.test.ts create mode 100644 tests/unit/upload-cancellation-channel.test.ts create mode 100644 tests/unit/v8-coverage-counter-semantics.test.ts create mode 100644 tests/unit/validated-json-artifact.test.ts create mode 100644 tests/unit/web-push-codec.test.ts create mode 100644 tests/unit/web-push-fence-store.test.ts create mode 100644 tests/unit/web-push-runtime-support.test.ts create mode 100644 tests/unit/web-push-store-port-compatibility.test.ts create mode 100644 tests/unit/web-push-subscription-adapter.test.ts create mode 100644 tests/unit/web-push-worker-runtime.test.ts create mode 100644 tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/app-shell-compact-pseudo-drawer-chromium-visual-linux.png create mode 100644 tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/app-shell-wide-light-chromium-visual-linux.png create mode 100644 tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/design-system-gallery-dark-chromium-visual-linux.png create mode 100644 tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png create mode 100644 tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/state-surfaces-light-chromium-visual-linux.png create mode 100644 tests/visual/platform.visual.spec.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 tsconfig.recipes.json create mode 100644 tsconfig.service-worker.json create mode 100644 tsconfig.test.json create mode 100644 tsconfig.web-worker.json create mode 100644 vite.config.ts create mode 100644 vite.service-worker.config.ts create mode 100644 vitest.config.ts diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json new file mode 100644 index 0000000..55ee8b7 --- /dev/null +++ b/.dependency-cruiser.json @@ -0,0 +1,190 @@ +{ + "forbidden": [ + { + "name": "domain-is-framework-neutral", + "severity": "error", + "from": { + "path": "^src/domain" + }, + "to": { + "path": "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)" + } + }, + { + "name": "application-does-not-know-concrete-runtime", + "severity": "error", + "from": { + "path": "^src/application" + }, + "to": { + "path": "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)" + } + }, + { + "name": "presentation-does-not-know-adapters", + "severity": "error", + "from": { + "path": "^src/presentation/(?!adapters/query)" + }, + "to": { + "path": "^(src/(adapters|bootstrap)|@tanstack)" + } + }, + { + "name": "page-templates-own-layout-only", + "severity": "error", + "from": { + "path": "^src/presentation/templates" + }, + "to": { + "path": "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)" + } + }, + { + "name": "icon-vendor-is-facade-only", + "severity": "error", + "from": { + "path": "^src", + "pathNot": "^src/presentation/design-system/icons/vendors/lucide\\.tsx$" + }, + "to": { + "path": "^lucide-react$" + } + }, + { + "name": "adapters-do-not-know-presentation", + "severity": "error", + "from": { + "path": "^src/adapters" + }, + "to": { + "path": "^src/(presentation|bootstrap)" + } + }, + { + "name": "feature-domain-is-framework-neutral", + "severity": "error", + "from": { + "path": "^src/features/[^/]+/domain" + }, + "to": { + "path": "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)" + } + }, + { + "name": "feature-application-does-not-know-runtime", + "severity": "error", + "from": { + "path": "^src/features/[^/]+/application" + }, + "to": { + "path": "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)" + } + }, + { + "name": "feature-presentation-does-not-know-outbound-adapters", + "severity": "error", + "from": { + "path": "^src/features/[^/]+/presentation" + }, + "to": { + "path": "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)" + } + }, + { + "name": "feature-adapters-do-not-know-presentation", + "severity": "error", + "from": { + "path": "^src/features/[^/]+/adapters" + }, + "to": { + "path": "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)" + } + }, + { + "name": "concrete-adapters-compose-only-in-bootstrap", + "severity": "error", + "from": { + "path": "^src/(domain|application|presentation|contracts)" + }, + "to": { + "path": "^src/adapters" + } + }, + { + "name": "external-contract-package-single-import-path", + "comment": "§4.1: a generated service package may only be imported from src/features//contracts/*-contract-contribution.ts", + "severity": "error", + "from": { + "path": "^src", + "pathNot": "^src/features/[^/]+/contracts/[^/]+-contract-contribution\\.ts$" + }, + "to": { + "path": "^@org-contracts/" + } + }, + { + "name": "presentation-does-not-fetch-directly", + "comment": "§9.2 / appendix B: a page or hook never opens a socket, worker or HTTP adapter itself", + "severity": "error", + "from": { + "path": "^src/(presentation|features/[^/]+/presentation)" + }, + "to": { + "path": "^src/adapters/(http|realtime|service-worker|web-worker|storage)" + } + }, + { + "name": "generic-worker-has-no-network-or-credentials", + "comment": "§16.13 / §21.10: a CPU worker never imports HTTP, realtime or auth", + "severity": "error", + "from": { + "path": "^src/adapters/web-worker" + }, + "to": { + "path": "^src/adapters/(http|realtime|auth|web-push)" + } + }, + { + "name": "service-worker-entry-is-not-page-code", + "comment": "§17.2.1: the worker realm never imports React, presentation or bootstrap page code", + "severity": "error", + "from": { + "path": "^src/adapters/service-worker" + }, + "to": { + "path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)" + } + }, + { + "name": "no-circular-dependencies", + "severity": "error", + "from": {}, + "to": { + "circular": true + } + } + ], + "options": { + "doNotFollow": { + "path": "node_modules" + }, + "exclude": { + "path": "^(dist|artifacts|tests/fixtures)" + }, + "enhancedResolveOptions": { + "exportsFields": [ + "exports" + ], + "conditionNames": [ + "import", + "require", + "node", + "default" + ] + }, + "tsConfig": { + "fileName": "tsconfig.app.json" + } + } +} diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml new file mode 100644 index 0000000..25e012d --- /dev/null +++ b/.gitea/workflows/quality-gates.yml @@ -0,0 +1,457 @@ +# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`. +name: frontend-quality-gates + +on: + push: + branches: [develop] + tags: ["v*"] + pull_request: + workflow_dispatch: + inputs: + stage: + description: Highest promotion tier to evaluate + required: true + default: merge + type: choice + options: + - merge + - release + - production + - field + - documentation + +permissions: + contents: read + +env: + CI: "true" + VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + VITE_COMMIT_SHA: "${{ gitea.sha }}" + RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}" + +jobs: + merge_gate: + name: "${{ matrix.gate }} / ${{ matrix.name }}" + if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-001, name: manifest-lockfile, browser: false } + - { gate: FE-GATE-002, name: lint, browser: false } + - { gate: FE-GATE-003, name: typecheck, browser: false } + - { gate: FE-GATE-004, name: runtime-schema, browser: false } + - { gate: FE-GATE-005, name: unit, browser: false } + - { gate: FE-GATE-006, name: component, browser: false } + - { gate: FE-GATE-007, name: integration, browser: false } + - { gate: FE-GATE-008, name: e2e, browser: true } + - { gate: FE-GATE-009, name: accessibility, browser: true } + - { gate: FE-GATE-010, name: architecture, browser: false } + - { gate: FE-GATE-011, name: build, browser: false } + - { gate: FE-GATE-013, name: security, browser: false } + - { gate: FE-GATE-020, name: removability, browser: false } + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Install Playwright browsers + if: ${{ matrix.browser }} + run: corepack pnpm exec playwright install --with-deps chromium firefox webkit + - name: Run blocking gate + run: corepack pnpm ci:gate -- ${{ matrix.gate }} + - name: Upload merge gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "${{ matrix.gate }}-${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + release_gate: + name: "${{ matrix.gate }} / ${{ matrix.name }}" + needs: merge_gate + if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + HOSTING_BASE_URL: "${{ vars.HOSTING_BASE_URL }}" + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-012, name: bundle, browser: false } + - { gate: FE-GATE-014, name: config-compatibility, browser: false } + - { gate: FE-GATE-019, name: hosting-header, browser: false } + - { gate: FE-GATE-026, name: lab-performance, browser: true } + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Install Playwright browsers + if: ${{ matrix.browser }} + run: corepack pnpm exec playwright install --with-deps chromium firefox webkit + - name: Run blocking gate + run: corepack pnpm ci:gate -- ${{ matrix.gate }} + - name: Upload release gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "${{ matrix.gate }}-${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + immutable_build: + name: "FE-GATE-015 / immutable-release-candidate" + needs: release_gate + if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }} + archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }} + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Build candidate once and verify local evidence + run: corepack pnpm ci:gate -- FE-GATE-015 + - name: Archive and validate the exact candidate file set + id: candidate + run: | + mkdir -p .release + tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \ + dist \ + pnpm-lock.yaml \ + artifacts/performance/bundle.json \ + artifacts/quality/vite-module-inventory.json \ + artifacts/release/build-manifest.json \ + artifacts/release/checksums.txt \ + artifacts/release/dependency-inventory.json \ + artifacts/release/provenance.json \ + artifacts/release/verification.json \ + artifacts/release/sbom.cdx.json \ + artifacts/security/dependency-diff.json \ + artifacts/security/license-report.json \ + artifacts/security/local-evidence-assessment.json \ + artifacts/security/scan.sarif \ + artifacts/security/supply-chain-coherence.json \ + artifacts/security/supply-chain-verification.json \ + artifacts/security/vulnerability-report.json \ + config/security/dependency-baseline.approval.json \ + config/security/dependency-baseline.json \ + config/security/dependency-change-evidence.json \ + config/security/dependency-policy.json \ + config/security/secret-scan-policy.json \ + config/security/vulnerability-exceptions.json \ + config/security/vulnerability-policy.json \ + schemas/artifacts/build-manifest.schema.json \ + schemas/artifacts/dependency-inventory.schema.json \ + schemas/artifacts/supply-chain-verification.schema.json \ + scripts/contracts/release-artifacts.ts \ + scripts/create-release-candidate.ts \ + scripts/generate-supply-chain.ts \ + scripts/lib/build-manifest-outputs.ts \ + scripts/lib/json-schema.ts \ + scripts/lib/local-policy-evidence.ts \ + scripts/lib/local-release-evidence.ts \ + scripts/lib/release-candidate.ts \ + scripts/lib/release-input-evidence.ts \ + scripts/lib/release-runtime-coherence.ts \ + scripts/lib/repository-file-inventory.ts \ + scripts/lib/secret-scan-evaluator.ts \ + scripts/lib/secret-scan-policy.ts \ + scripts/lib/secret-scan.ts \ + scripts/lib/supply-chain.ts \ + scripts/lib/validated-json-artifact.ts \ + src/contracts/release-artifacts.ts \ + src/features/installed-contract-contributions.ts \ + src/features/installed-feature-contracts.ts \ + artifacts/release/release-candidate.json + node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT" + - name: Upload release candidate + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + if-no-files-found: error + + vulnerability_provider: + name: external-vulnerability-provider + needs: immutable_build + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }} + env: + CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + CI_RUN_ID: "${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}" + EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}" + VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}" + VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" + VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json + VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: .release/vulnerability-candidate + - name: Run and validate external vulnerability provider in one trusted supervisor + id: supervise_vulnerability + run: node scripts/run-and-validate-provider.ts --kind vulnerability + - name: Confirm sealed vulnerability provider evidence + run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" + - name: Upload vulnerability provider evidence + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: provider-evidence/vulnerability-report.json + if-no-files-found: error + + provenance_provider: + name: external-provenance-provider + needs: immutable_build + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }} + env: + CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + CI_RUN_ID: "${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}" + EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}" + PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}" + PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" + PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json + VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: .release/provenance-candidate + - name: Run and validate external provenance provider in one trusted supervisor + id: supervise_provenance + run: node scripts/run-and-validate-provider.ts --kind provenance + - name: Confirm sealed provenance provider evidence + run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" + - name: Upload provenance provider evidence + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: provider-evidence/provenance-attestation.json + if-no-files-found: error + + promotion: + name: promote-verified-immutable-candidate + needs: [immutable_build, vulnerability_provider, provenance_provider] + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" + CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" + CI_RUN_ID: "${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}" + VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" + PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" + VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}" + PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}" + VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" + PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}" + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Download release candidate + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: .release/candidate + - name: Download vulnerability provider evidence + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: .release/vulnerability + - name: Download provenance provider evidence + uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 + with: + name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: .release/provenance + - name: Finalize verified promotion from inode-bound captured inputs + id: finalize + run: node scripts/stage-verified-promotion.ts + - name: Upload promoted release + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}" + path: | + ${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz + ${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json + ${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json + ${{ steps.finalize.outputs.staging_root }}/provider-verification.json + ${{ steps.finalize.outputs.staging_root }}/promotion-verification.json + if-no-files-found: error + - name: Always remove private promotion staging + if: always() + env: + PROMOTION_STAGING_ROOT: ${{ steps.finalize.outputs.staging_root }} + PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }} + PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }} + PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }} + PROMOTION_STAGING_DEV: ${{ steps.finalize.outputs.staging_dev }} + PROMOTION_STAGING_INO: ${{ steps.finalize.outputs.staging_ino }} + run: | + if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then + node scripts/cleanup-verified-promotion.ts + fi + + production_gate: + name: "${{ matrix.gate }} / ${{ matrix.name }}" + needs: promotion + if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-016, name: rollback-drill } + - { gate: FE-GATE-021, name: runbook-boot-config } + - { gate: FE-GATE-022, name: runbook-chunk-mismatch } + - { gate: FE-GATE-023, name: runbook-api-degradation } + - { gate: FE-GATE-024, name: runbook-telemetry } + - { gate: FE-GATE-025, name: runbook-release-rollback } + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Run blocking gate + run: corepack pnpm ci:gate -- ${{ matrix.gate }} + - name: Upload production gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "${{ matrix.gate }}-${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + field_gate: + name: "FE-GATE-018 / field-web-vitals" + needs: production_gate + if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + FIELD_WEB_VITALS_INPUT: "${{ vars.FIELD_WEB_VITALS_INPUT }}" + MIN_ELIGIBLE_SAMPLES: "${{ vars.MIN_ELIGIBLE_SAMPLES }}" + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Run blocking gate + run: corepack pnpm ci:gate -- FE-GATE-018 + - name: Upload field gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "FE-GATE-018-${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error + + documentation_gate: + name: "FE-GATE-017 / diagram-review" + if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile --ignore-scripts + - name: Run documentation gate + run: corepack pnpm ci:gate -- FE-GATE-017 + - name: Upload documentation gate evidence + if: always() + uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 + with: + name: "FE-GATE-017-${{ gitea.run_id }}" + path: artifacts/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6b3beb --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +dist/ +.vite/ +.generated/ +.tmp/ +playwright-report/ +test-results/ +coverage/ +!tests/fixtures/coverage/ +!tests/fixtures/coverage/below-threshold.json +artifacts/**/*.json +artifacts/**/*.xml +artifacts/**/*.txt +artifacts/**/*.sarif +artifacts/tests/e2e/ +artifacts/tests/browser-capabilities/ +artifacts/storybook/ +artifacts/tests/storybook/ +artifacts/tests/visual/ +!artifacts/**/.gitkeep diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d9ca886 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +save-exact=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..d845d9d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.14.0 diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..a243e16 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,15 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: ["@storybook/addon-a11y"], + framework: { + name: "@storybook/react-vite", + options: {}, + }, + core: { + disableTelemetry: true, + }, +}; + +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..7aca4b3 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,108 @@ +import type { Preview } from "@storybook/react-vite"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; + +import { createAnonymousSessionAdapter } from "../src/adapters/auth/external-session-adapter.ts"; +import { createQueryClient } from "../src/adapters/query-cache/tanstack-query-cache.ts"; +import { createApplication } from "../src/application/create-application.ts"; +import { LocaleProvider } from "../src/presentation/i18n/index.ts"; +import { ApplicationProvider } from "../src/presentation/providers/application-provider.tsx"; +import { SessionProvider } from "../src/presentation/providers/session-provider.tsx"; +import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx"; +import "../src/presentation/styles/theme.css"; + +const preferences = new Map(); +const application = createApplication({ + session: createAnonymousSessionAdapter(), + preferences: { + read: (name) => ({ ok: true, value: preferences.get(name) }), + write: (name, value) => { + preferences.set(name, structuredClone(value)); + return { ok: true }; + }, + remove: (name) => { + preferences.delete(name); + return { ok: true }; + }, + }, + diagnostics: { record() {} }, + telemetry: { emit() {} }, + releaseInfo: { + getCurrent: async () => ({ + buildId: "storybook-build", + releaseId: "storybook-release", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "storybook-assets", + routeChunks: {}, + }), + refresh: async () => ({ + buildId: "storybook-build", + releaseId: "storybook-release", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "storybook-assets", + routeChunks: {}, + }), + }, + runtimeCapabilities: { + getSnapshot: () => + Object.freeze( + ( + [ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", + ] as const + ).map((capabilityId) => + Object.freeze({ + capabilityId, + selected: 0, + active: 0, + override: "DEFAULT" as const, + }), + ), + ), + }, + navigation: { reload() {} }, +}); +const queryClient = createQueryClient(); + +const preview: Preview = { + decorators: [ + (Story) => ( + + + + + + +
+
+ +
+ + + + + + + ), + ], + parameters: { + a11y: { + test: "error", + }, + controls: { + expanded: true, + }, + options: { + storySort: { + order: ["Platform"], + }, + }, + }, +}; + +export default preview; diff --git a/.superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md b/.superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md new file mode 100644 index 0000000..1b11417 --- /dev/null +++ b/.superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md @@ -0,0 +1,110 @@ +# Task 5 Report: Retain and reconcile uncertain optimistic mutations + +## Status + +Task 5 is implemented. Mutation settlement now follows explicit effect certainty, preserves unknown optimistic projections as ordered uncertain layers, and exposes one-at-a-time reconciliation bound to the original mutation record. Missing post-dispatch certainty is fail-safe `MAYBE_APPLIED`; only controller-owned pre-dispatch failures are marked `NOT_STARTED`. + +## RED evidence + +- Initial focused command: `corepack pnpm exec vitest run tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx`. +- Initial result: exit `1`, 2 files, 11 failed / 28 passed. Missing lease/controller APIs failed directly; `APPLIED_CONFIRMED` and `MAYBE_APPLIED` were rolled back; effectless failures retained generic retry semantics. +- Review-driven RED: the three-file focused command including `tests/component/async-surface.test.tsx` exited `1` with 7 failed / 56 passed. It exposed applied-confirmed retry actions, active-submit reset, double reconciliation, stale-scope queue retention, overlay priority, and the incorrect refreshing copy. +- A final isolated RED proved a synchronous `NOT_APPLIED` double action could consume two FIFO records in one event turn. +- The production-form RED exited `1` with 2 failed / 8 passed: applied reconciliation left the original command dirty/retryable, while an `APPLIED_CONFIRMED` failure rendered generic unavailable. +- The final durability/lifecycle RED failed 2 / 2: an anonymous non-optimistic legacy channel did not survive remount, and render-time registry allocation exhausted the definition cap during an abandoned server render. + +## Implementation + +1. `OptimisticLayerLease` now supports `markUncertain()` and `reconcile("APPLIED" | "NOT_APPLIED")`. Layers are `pending | uncertain | committed`; only a committed prefix collapses into the base, while projection continues to apply every later layer in order. +2. Reconciliation is single-settlement and idempotent. `APPLIED` converts the uncertain layer to committed; `NOT_APPLIED` removes only that layer; both then collapse/reproject later committed or pending layers. Scope expiry removes stale cache instead of restoring it. +3. Legacy optimistic mutations use the same reusable always-current ordered runtime. This prevents an old manual snapshot from erasing a later successful mutation or authoritative projection. The runtime also supports optimistic entries whose base data was absent and removes them on a not-applied rollback. +4. The mutation bridge derives effect before touching optimistic state: + - `NOT_STARTED` / `NOT_APPLIED`: rollback; + - `APPLIED_CONFIRMED`: commit, then best-effort invalidate; + - `MAYBE_APPLIED`: retain as uncertain, do not invalidate, and enqueue explicit reconciliation. +5. Missing or `NOT_APPLICABLE` command effects, returned failures after dispatch, and thrown execution failures normalize to `MAYBE_APPLIED`. Unknown effects are non-retryable with `contact-support`; applied-confirmed failures are non-retryable with no resend action. Controller-owned stale scope, duplicate admission, identity/preparation, and other known pre-dispatch failures carry `NOT_STARTED`. +6. Unknown records retain their original intent, scope, layer lease, invalidation topics, and coordinator. A FIFO queue prevents parallel `ALLOW_PARALLEL` failures from overwriting each other. Reconciliation is locked through the event turn so a double action cannot consume the next intent, and it does not reset a newer active submit. +7. Scope abort discards every queued record from that scope, settles only its local stale layers, performs no invalidation, and cannot later overwrite new-generation cache data. +8. Async state adds the mutually exclusive `mutation-effect-unknown` overlay with priority `unknown > conflict > pending > stale-degraded > refreshing`. `AsyncSurface` uses dedicated safe copy and only `APPLIED` / `NOT_APPLIED` actions; it does not expose generic retry or mark the surface busy. +9. Unknown-effect admissions live in a bounded QueryClient-owned registry, so bound and legacy controllers can remount without losing reconciliation state. Channels include definition version and generation, validate the exact scope owner, count active admissions globally in O(1), release after late settlement, and preserve FIFO order even when executions finish in reverse. +10. Channel creation and scope-abort listener registration occur only in a committed React effect. An abandoned/server render performs no registry mutation and consumes no channel capacity. Non-optimistic legacy callers must provide a stable `definitionId`; optimistic legacy callers also include their query identity. +11. The reference create form blocks all generic resubmission while effect certainty is unknown. `NOT_APPLIED` preserves input and re-enables submission; `APPLIED` reconciliation and `APPLIED_CONFIRMED` settlement use the form's success-equivalent reset path so the same create command cannot be resent. + +## Test coverage + +- Certainty matrix for all four mutation effects, missing effect, `NOT_APPLICABLE`, thrown execution, ambiguous conflicts, and non-retry semantics. +- Applied-confirmed commit-before-invalidate ordering and retained commit when invalidation fails. +- Out-of-order later commits behind uncertain layers; both reconciliation outcomes; duplicate/reversed lease transitions; external cache projection; expired scope. +- Bound and legacy reconciliation, no-layer legacy fallback, parallel unknown queues, same-turn double actions, active newer submit preservation, scope-wide stale cleanup, and no-prior-cache rollback. +- Bound and non-optimistic legacy remount durability, unrelated legacy isolation, generation isolation, exact scope-owner collision handling, abandoned-render capacity, late empty-channel cleanup, QueryClient-global admission bounds, reverse completion, and fence-during-invalidation races. +- Production create-form coverage for the `MAYBE_APPLIED` block, `NOT_APPLIED` input preservation, and success-equivalent `APPLIED` / `APPLIED_CONFIRMED` settlement. +- Unknown overlay derivation, mutual-exclusion priority, dedicated localized copy, non-busy state, and reconciliation-only actions. +- The negative async-overlay type fixture now includes `mutationEffectUnknown: false`, so it continues to fail for the intended pending/conflict exclusivity violation. + +## Files changed + +- `src/presentation/adapters/query/optimistic-layer-runtime.ts` +- `src/presentation/adapters/query/application-query.ts` +- `src/application/view-models/async-state.ts` +- `src/contracts/errors.ts` +- `src/presentation/components/async-surface.tsx` +- `src/presentation/forms/form-contracts.ts` +- `src/presentation/forms/use-app-form.ts` +- `src/presentation/i18n/catalog.ts` +- `src/features/reference-feature/presentation/reference-resource-form-page.tsx` +- `tests/unit/optimistic-layer-runtime.test.ts` +- `tests/component/application-query.test.tsx` +- `tests/component/async-surface.test.tsx` +- `tests/features/reference-feature/reference-page.test.tsx` +- `tests/fixtures/typecheck/invalid-async-overlay.ts` + +The AsyncSurface, catalog, UI test, and type-fixture additions are a narrow scope expansion required to avoid rendering the new indicator as a background refresh and to preserve the overlay type contract. + +## Verification + +- Final focused command (error classification, optimistic runtime, mutation bridge, async surface, form facade, and production reference form): 6 files / 100 tests — PASS. +- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker. +- `corepack pnpm lint` — PASS with zero warnings. +- `corepack pnpm test:all` — PASS: runtime schema 40, unit 741, component 123, integration 23, reference feature 24, recipes 17. +- `git diff --check` — PASS. +- `corepack pnpm run check:types:fixture:async-overlay` — expected non-zero; TypeScript rejects `mutationConflict: true` when `mutationPending: true`, confirming the negative fixture still reaches its intended invariant. + +## Self-review decisions + +- The plan-prescribed `reconcileUnknownEffect(resolution)` API remains intact. Rather than introduce a public token incompatible with that interface, the controller retains intent-bound FIFO records and serializes reconciliation through the current event turn. A repeated action after the first promise settles is an explicit action on the next visible unknown record. +- Scope cleanup removes stale local projection without claiming or invalidating a server outcome. A stale generation cannot use its former record after the queue is discarded. +- Legacy manual snapshot restoration was removed because it could erase later successful work. Shared ordered layers are the minimal mechanism that gives legacy and bound mutations the same re-projection guarantees. +- A non-optimistic legacy mutation has no cache key from which a durable logical identity can be inferred. Its type contract therefore requires a stable caller-supplied `definitionId`; this preserves remount durability without merging unrelated controllers. +- Registry mutation was moved out of render into the committed effect lifecycle. The server-render regression fills the nominal definition count with abandoned renders, then proves a committed mutation can still acquire and execute. +- Browser/Playwright gates were not run; this task changed no browser-only integration. The jsdom component tests cover the new accessible status and actions. + +## Final review + +The scoped reviewer completed two fix rounds covering durable ownership, FIFO/races, global bounds, scope fences, and production form settlement. The final verdict reported no findings, independently passed 4 files / 84 tests, confirmed `git diff --check`, and assessed the change ready to merge. + +## Runtime final-review fix round 3 + +The runtime-wide final review identified three additional Task 5 authorities. This round addresses only those findings; the provider-neutral HTTP operation port remains deferred to its separately owned remediation plans. + +### RED evidence + +- Composite optimistic admission: `tests/unit/optimistic-layer-runtime.test.ts` failed 2 / 8 cases because a base-valid candidate that threw only after the prior layer returned a lease and orphaned both rollback and reconciliation authority. +- Candidate replay: the isolated admission test failed with candidate updater call count `2` instead of `1`; replay through `project()` could still delete the entry after successful preflight. +- Form reconciliation: `tests/component/form-foundation.test.tsx` failed 3 / 8 cases. The hook admitted a second submit during unknown effect, settled edited value B instead of submitted snapshot A, and exposed no explicit not-applied release authority. +- Production namespace parity: the mounted reference-page regression failed because `REFERENCE_RESOURCE_QUERY_NAMESPACE` was not exported; production list/detail hooks could only duplicate its id/version literals. + +### Implementation + +1. `OptimisticLayerRuntime.begin()` now computes the complete ordered projection before admission. A composite failure returns pessimistic fallback `null` without changing the existing entry, cache projection, layer IDs, or earlier lease authority. The admitted candidate is written from that precomputed value, so its updater runs exactly once during admission. +2. `useAppForm` retains the exact parsed values for a `MAYBE_APPLIED` submission. The ref is the hook-level admission lock until `settleApplied`, `settleNotApplied`, or `reset` releases it; later edits preserve the unknown result and cannot trigger another command. With `resetOnSuccess: false`, APPLIED makes submitted A the baseline while edited B remains dirty. Success, applied-confirmed, validation/conflict/unavailable outcomes, reset, and explicit not-applied settlement clear the retained snapshot. +3. The reference form routes both reconciliation outcomes into the corresponding form settlement authority. +4. `REFERENCE_RESOURCE_QUERY_NAMESPACE` is exported from the governed feature contract. Both production list and detail query definitions consume its fields, while the mounted-key regression compares both real query prefixes with the installed invalidation edge. + +### Verification + +- Focused runtime/form/reference command: 5 files / 87 tests — PASS. +- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker. +- `corepack pnpm lint` — PASS with zero warnings. +- `corepack pnpm test:all` — PASS: runtime schema 40, unit 744, component 126, integration 23, reference feature 25, recipes 17. +- `git diff --check` — PASS. +- Scoped re-review by the existing Task 5 reviewer: no findings, ready to merge. The reviewer independently passed the 5-file scoped suite (96 / 96), confirmed `git diff --check`, verified all three reconciliation authorities plus candidate single-invocation, and confirmed the deferred HTTP adapter remained untouched. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4127ce --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# Clean Architecture Frontend Template + +A React/Vite reference implementation where architecture boundaries, +integration behavior, release coherence, accessibility, performance, and +operations are executable contracts rather than conventions. + +## Start locally + +Requirements: the exact Node.js version in `.nvmrc` (currently 24.14.0) and +Corepack. The repository pins pnpm in `package.json`. + +Product source, tests, build/quality scripts, and supported tool configuration +are TypeScript/TSX. `allowJs` is disabled. Node-side `.ts` scripts run directly +on the pinned Node 24 runtime and are checked with NodeNext resolution plus +erasable-syntax enforcement. Project-owned executable source contains no +JavaScript-family files; negative architecture, security, and type-compatibility +fixtures are TypeScript/TSX as well. + +```bash +corepack pnpm install --frozen-lockfile +corepack pnpm dev +``` + +Runtime-public settings live in `public/config.json` and are validated before +the product tree mounts. Client secrets are forbidden. + +## Included starter experience + +The default build mounts a domain-neutral application shell with a header, +responsive sidebar, route focus management, session integration status, and a +persistent `system` / `light` / `dark` theme selector. + +| Route | Purpose | +| --- | --- | +| `/` | implementation readiness and starter links | +| `/examples/ui` | buttons, fields, cards, alerts, badges, modal, and tokens | +| `/examples/states` | loading, refresh, empty, error, auth, forbidden, and not-found states | +| `/examples/auth` | reactive external-auth integration seam | +| `/examples/reference-resources` | removable, session-required reference feature | + +`AUTH_MODE=demo` is credential-free and accepted only in local/development +environments. Deployments use `AUTH_MODE=external` and provide the opaque auth +owner described in +[`docs/architecture/starter-experience.md`](docs/architecture/starter-experience.md). +The client route policy is user experience only; server authorization remains +authoritative. + +## Architecture + +Dependencies point inward: + +```text +presentation -> application -> domain +adapters -----^ +bootstrap composes concrete adapters +contracts own cross-cutting registries +``` + +See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and +`docs/architecture/starter-experience.md`. The removable vertical slice is +under `src/features/reference-feature`; its domain, application input, HTTP +adapter, contracts, route runtime, and presentation are installed through the +feature contribution files in `src/features`. The generic starter routes +continue to typecheck, test, and build after that contribution is removed. + +### Platform capability review + +The starter shell is implemented, but the repository review also records the +remaining work required before feature teams can use every declared contract +through one end-to-end application path: + +- [platform capability review](docs/architecture/frontend-platform-capability-review.md) +- [ports, adapters, and feature boundaries](docs/architecture/frontend-ports-adapters-and-boundaries.md) +- [REST, GraphQL, Connect/gRPC-Web, Schema, Mapper, and Server State](docs/architecture/api-contract-schema-mapper-and-server-state.md) +- [Protobuf browser transports and REST Gateway](docs/architecture/protobuf-browser-transport-and-rest-gateway.md) +- [backend API and Server State handoff contract](docs/architecture/backend-api-and-server-state-contract.md) +- [TypeScript, state ownership, and data flow](docs/architecture/typescript-state-and-data-flow.md) +- [routing, page templates, and reusable patterns](docs/architecture/routing-pages-and-patterns.md) +- [browser data capability completion ledger](docs/architecture/browser-data-capability-completion-ledger.md) +- [browser file and origin-storage platform](docs/architecture/browser-file-and-origin-storage.md) +- [client cache and storage](docs/architecture/client-cache-and-storage.md) +- [realtime events, Web Push, and bounded polling](docs/architecture/realtime-events-web-push-and-bounded-polling.md) +- [presigned transfer, resumable upload, streaming download, and Image CDN](docs/architecture/presigned-transfer-and-image-cdn.md) +- [server file capability infrastructure](docs/architecture/server-file-capability-infrastructure.md) +- [design-system platform](docs/styling/design-system-platform.md) +- [frontend platform testing strategy](docs/testing/frontend-platform-testing-strategy.md) +- [implementation roadmap](docs/architecture/frontend-platform-implementation-roadmap.md) + +These documents distinguish repository defaults from opt-in adapters and +project-owned integrations. They are target designs and review findings; a +capability is not treated as implemented until its branch acceptance criteria +and executable gates pass. + +## Verification + +Common local checks: + +```bash +corepack pnpm lint +corepack pnpm check:types +corepack pnpm check:types:app +corepack pnpm check:types:node +corepack pnpm check:types:test +corepack pnpm check:architecture +corepack pnpm test:all +corepack pnpm test:e2e +corepack pnpm test:a11y +corepack pnpm build +corepack pnpm check:bundle +corepack pnpm test:performance +corepack pnpm verify:compatibility +corepack pnpm verify:release +corepack pnpm check:registries +corepack pnpm drill:runbooks +corepack pnpm check:ci +``` + +`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript +project로 모두 검사한다. type/architecture/security/registry의 invalid +fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로 +실행된다. 도구 호환성 결정은 +[VD-01](docs/architecture/decisions/VD-01-typescript-lint-tooling.md)에 기록돼 +있다. + +Application feature input은 module augmentation으로 닫힌 ID와 정확한 input +shape를 제공하며, 공통 `Result`는 error registry의 +failure kind만 application/presentation 경계를 통과시킨다. Architecture gate는 +TypeScript/TSX의 static, dynamic, type import를 별도 정적 그래프로 분석하고 +runtime/source 영역의 JavaScript 재유입도 거절한다. 해석되지 않은 import, +parse failure, 금지 계층 edge와 순환 의존은 모두 fail-closed이며 전용 +TypeScript/TSX negative fixture로도 검증된다. + +Install the pinned Playwright browser engines before the first cross-browser +run: + +```bash +corepack pnpm exec playwright install --with-deps chromium firefox webkit +``` + +Two gates intentionally need external evidence: + +- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review + for all six registered routes. +- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum + eligible-sample threshold and 28 days of production data exist. + +Live release verification additionally requires `HOSTING_BASE_URL`. + +## CI and evidence + +The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is +`.gitea/workflows/quality-gates.yml`. It follows: + +```text +MERGE_READY -> RELEASE_READY -> PROD_PROMOTION_READY -> FIELD_SLO_READY +``` + +`DOCUMENTATION_READY` is independent. No gate is downgraded to a warning. +Machine-readable evidence is written below `artifacts/`; generated evidence is +ignored by Git while `.gitkeep` files preserve the taxonomy. + +Operational details are in `docs/operations/`, with incident procedures in +`docs/runbooks/`. diff --git a/artifacts/local-preview/home.png b/artifacts/local-preview/home.png new file mode 100644 index 0000000000000000000000000000000000000000..f62525f7048403851e9e33e439cf2a2983ec9e87 GIT binary patch literal 13851 zcmeHu{Xf(D|G#rO<^8VYyiZ9bIVY76LZXJ9jwFP_5;iJVxz4p2+fgT-F7QT@%bi@C zSkY)^8woKim&5Er*kZP^8Fpo#b-tg!;d{HC&#hmc&)uG{+ilOw`~CU2KOT?gqpPkC zYC8|@R8dh;bG&@fT}5U4Un(lw{`lAT%9cr!?;fbA{HWr1@q%Y^$uiCV4k(HKMIo}b z4rH8>eh!H>bu_;6eZ`J5Y7KRp>^J2=&b&kALH9!j2MjET`k=)ds+jLE_k0Rp=!eCi z?5}++d6wa5df1_=J4K?JJ$Ezmm!)6dYB~xQuY_O=WVk-r&s+HnW&x>3>ar-?jg_(d!aV&YnOy;&4-R_qt+ z&uy#&zvaz!ni7|}GVc2#HAot8=@*-wSl%|1J&Hu$W?}2>+OI$LbZBz++j=x}ICpD~ zsHhz2M;z;z%i)AFK5b6aVEd;v(BlP~^FfVU+%-K$y!wf32UaXakHN})Ia=x;f?`+Z zIT(Six|dW&r5>=xu41zKTQ|-;tIp-?C#~Kb^`-dH6gcl}xIh1Se=*^9uL4M9vW8eL zsFPQYycxhYxFAl=$awXmgbn@gRDMTVq;ETtJ=2}V(neO7F@_02j(wTV)5k@I4vN(S zM;%zdQTjUjNM&xN*3^XHn?s)+QolnQ)P>rDr;@3(CL`B z0CW2O9b_HpQIB=7TrmCkWgG;-C5r5M^|S<`U#N76)2NLe_w;h9quMm(f)$19k@Z?( z8LfbTS;4p};Q%cn?3iE4bX$8cYh}yR80CwR&gaiG$P2H@eY_ZVr$WO~)JcoY?9pg> zN^2Q^zjPo39G1RytIJ)HahHvRePD&^rgg86yYjwxZ;Jku)nQ~ zz2gW=KkE&7;4bvw)Q9#yT@IL#LRs}CAA}$gVnTLyG{ifJE$$N~M%9Kded*guW1~aV zOstZ)b?L)-XV-aZi47?4ck5sJy0n%Wfiq{~W(^0q{*1);u{Nc}hoVXv8A)JC8Ts&c zD!-oMF@NvX3^`9GfDl;?CCub(TA>J_&z?ixqF*z zG$kJ6CXqg%XSh!o{$%Xd3X; z;rtH5;-#U?X{LK`q{E&D(v>Ca*RNzPf94sK$tBMv13eYk zHkQc3zD`#Dcuk&VnzYcBkV$pZw8nkLu)I9(huGqtJ1Kw~CXK$pcTmm){o=Dk&zvEX zQ>ACpg~?o5I-~6108(^YxV6-$(Os8LZn0?KeH?t{PfSbUy&q1CCQbn2=U4jm)mQ)g zXcJN`?Fd(cN$Z#3qwY3~l7o|z$q9Ehf4$@u9JNwh8vWMO2rQ8_`k!bmMdr>jn1=T` zbiRMPI7t*FuDhq-MeIx``}8pI5oG_fXxwSsRz6Fki$NOUl|cTK5;trVhc|Gsc&FS#BDIV zbSdP<`Ff@g?YUi~=KcuOklTpI^<|r;+wna`HM>*~UH7-53A!q5FWe+oX zr50(2zi(f>M{%qBt&D?%VYnT`^pS$;jjhJ8WVM85QkhZ*pOgDwP}*w+G)Eci8i7s;`lp&N^)f#~Y@iAn zsd!65A0Evg8#3ybrF4m#&4G z_FSBtt&l{b1X!_UoZYR3WkDs8-Osei_Lwi&=jIfIUWkI{^c$91B#dNsyKg@pBzCL@ z=4l1&4aq7cxuiv1U;WtrV4Ms(UI4I_uHz001;O|m{t3b+1oJbNH&cm%Rt1a|w(A+N zT(IW?P_L8UHGs7~T7T)xmL3j>T9G~28XMaV+TI65ziuNWzKzE)I;By`c+pN$@rakO+IyKY;0to_~fT57dO1*@N=!TX(wOT4>g&3ul{vac@Q* zA(5)feg+_GJZh7bIAX7{-7)xXpdprkCW9TFP$B#1vZmJ&iS}@%B?5&0O(Z77)4+2K z%T1=KPArx_aFrECdavgjg#_p0>6gt4+~A)k5|>HBf=1ebdoTZ5!xg%V7uW71TR)0Q zm#n1O1E1C(xJ4 z24DJ5yU#HbhzyeQF{r8Q)=R0pN1xBIC7bNX$jF`NWzt0Z^M@TSH!66%^X60F+$45k zpBp>dRR^+okxWiYijKF>1R5yvgtZ~C<{TPkx36lrjMrB#*L@m;pvt;JWNZf_x* zYqE}9RqG#f`b`|!Rfe2ae9K=eExV@USLZ!>VN}$@U_mvpttw%3U?lf_dl`hpv2BZ6 zoKKInL6&@RwZXu9NgzvOW?Uh8kA=?SQrqTl-Fe~w?(oWo`fXD$|J?vT%8Pqi7yYm% zFk{<=sPBDoaT&8e<{d;lpB=gSqQAa2)X(25o*D~1?$O&uy>&bK+Cf|M#rZHkHzfGW z^skA5lCH8kDUY66Y}G@36d!idJek^2ZZ~x$@pJ2LCCz(&uWG&D9%K=>6L@ZQ2!{dr zvS#@2&w;L9^L_@HKt6Lpf>PNQ+>QnFf((PDiFQBpIf>?dd>SRzF;sBXtN@nxCB!|bMl8h$<|K$8>iOl(O!txs78Jj#lHlS8witb(MJ^*$Dfe) z7tm}2;Nt=dj1C$?do36cNW>mbM*41&tHKw^CdK*24JS!}v{kTM@Fn~6)KRRol#I4n zcF`zw)^qhMJcHn_0ggJ)u1T%Zou>sg+w%Rv@-#nT94^O_|lNQ~`7Qxj7QectiL zq3TC290Q%ko?bi;oxcN0bh@=V|G0^dgDozK5=N;$2u8bQw0%MtbNNw=y6f7BN3FHW zJ19?i2LUVq&>#`C^N7vW|Z5940>rlCJ4FKA?Xt z5jFRod7}cx^1=_NyxYX5>B#ED_rtNXE<~#|d%@|EQLl(pEPK$7b3N&Jl?kR*5jBzA zYZbfc?6aqG56@<7xGjdU(y1{(ziw_;{z3HH+t6@MPhQ{NC#{IrHH~*xds->2yeOEE z0%&$@Tf>UNDaQeOXdxy$+43kwdU=CyB0rULG1vN#-2!TwK5#?|3@^)@fOS~KvU9{11Mt)o$N=TT85E*eB?iwUtbv+|Mc0^Pv5Ph3< zPh!;YBs&67sG`#0HbazCJ|XnJs-qmJ%PD2`V_ApNW>)z_@9x*e*?%8amcJ%{ud1y6 z@t+ir&AbSJyQ(ntG`4@w=O_PHu(O!JX(Yz}+qFzhuN%+EJNef@vKvdUt zn`Gxg^mI{cYBIc>`Y}-_9I&}M{^&?&e8{kzP&Mfd$?!uV_ZqKz9Xa zsq7%lX|SiFwQ*Ky1Gwr)-|E5mZ5h6OSaW{}LHI5s{JoAkJRUmm{pq{Qt>z9B2&o%w z2z|+`lmwQsWZj)J4H%`(jUt4z0Px^f*3-pT=3l6ileS_QdW>M+WM8D(E(c0;?X9_o zY$6~7mN++*pa%Yu%%)`t?=vcF0%7OXxmxU_J_{J-;rQDZFfh}c5Bqz{>;>RYOw$9) zKA=`f9%9B;AB5#i&IT>i!9s@0=r<^v!x(^`D_n#)?5zl-OSL>h#f?*f~|{o zbyk8%DS4mVo5mx@;Txu~VA2zp0-ia-uPqkBJ|LbelYSrTg`)#aPYVZ!wwZvD&>`8< zV!hT`9T`*bIn$g6vnc~2dV3kXcW<7Tty2!|na>0&n2Q^VF^EhsD-UnSf} zeIk&NR1b6ay|Gg!6eE;R(wqQbky^@FzfLOrTrjO@uQoWh;W|298jS&I`(0aQeu(M4 zgahJA3>(n~@Ri%!bch>6A&Scj$Mt*}0Gmjf%v96S1;qfAm<#x2WjG-#Z(t#7n}0QY ze0r+wuhUrg>wJ5)Ohgy}_crWRd~EMMz*(JvQv=71=oX@8Wp2RATmvul)(AE%P3v3qfKKx)m82{Xvd^ywWrya&f z?2GyvelQL^Gd?ISa%#AxnGxKmn&r8*{Cp^h6HU)Qy&`_uVmL}^MHhms+I!BSqy6Iu zZ@A15XJ4Mqzj)8YOJ4|XO&W$zTrEAvXP#bIRLW>e!S8*%20+^m6h+&waM^c!WbzXc z4ii3x+N&3M5PWAbbTGWTUH^flUL`4*Y+x0CSEQikJjG~XJIi({K?fKX^%dZ|MMkO< z117FvB)YG>M^L2OtO-=ie1^JtWy3CC~f}D&%lwku+aq z)}G4KJyp_+l_&D|9n&*e56LaooG7I0v;8qU>20*PdzlkzTG&c>6B#n9pj;P&xk8M{BCcO&{lw#0kD{Sc z!kHJ2k1uHss`+sb7J1_8%`BtTRa3H!>F*@ zhJ?9JT@jZ%*t~PElKlSjlNIxge*4zXj(w%Gvz&~1dN?b`*TDGgsnn(g+`p?#l3LIQ z19t!E8OjxGkBpA+Z8e;F8+*ZV9Zbz~iJUrE>{nvD%8|@4Eo)2`1{pI1;>$DG$%)Ca z7;>Xqp@q@7sKCQUt*RosaK5qRJoGr|O?!N@;kG1Td9K!h!%iCes-m?+4T!IQ&etmW z(owuEM7_}$!@hEXtF)GR=F9d`E71@n#DxsOv%9OFAlv^F?;hmbiUC3p%gMcO_UZv8 zu`z=s^S7#M#!L`x;iL@KJqyGAu8yM8@NilhVe>&(TrWX>%myvadosA;>ZU>VeiN{% zr`&V+zrI)f<23!i;l9h#FZ?hWxlwCdps~S8@K|ax1?KB(c`6lgC%IEl=-kTn_wYTt zs$~^x(|dmCnE6>yw)Lo~>;0226#dQ`F8l_#E{2QQzS!v@ZE2^NCk$3&?JYMp3JhCb zd4qkf+BVT51U3sPjhcAC|JW^Rpz&ArD zfR;b^mh!gDFDth-LI^BGc5${lRu9)=*vuHj}hp$|ddD&2sn9SsS~;ilt@^s!HMBxJ15WA&!UeInr{f zvfu9xapnC-0yrW(BEpa%>TL0?$YeFrn{UAFA#pH>@J9HRsu~ycp;HI6(Z?V*>s&2h z`~Tio_DY9TFcVE>+hWr;5!ixf`!m5}b%*pqvQhn!EeFBn%AL;6yt3<_Fy`E}^i-Z$ z!Ynqb@gVcOz2j5F=A5nbl0)NfdYPBqU@sRYcNuNCBHQA-!?BO~ey+Lxem1%Z%VUKE z3CA*ix8*YvH$O)Z-c0-*_qffRs7t{S#cx@$D{fApCghISX0vbX-!TNA9Jj0@@Oq?o zVz;r|vP1cmg?L>!!7vMm(&g7Z zw|9=BeY$3~u@Dr5UJmEhPJ;b&Qb#9JYHOCOT*lSmPmksg>BRjy;X7i^uuf!KouE8C z#Qh@KB$>-A9wNQ`&cRlNBGM8sN&T|1~6AgGto*~5FaOo@gnf`zwb_L2%9{|THaRE(g>!W-5jfyBW}b{7dAHCkE`+hb$$|+ zS!hcmY0m*vr^+ z$pV6TBs6c+g*2v5(INM(r^;pl4O)k6z2lPkyA~`nF3MlOiINslH&!zjLU!)l`Q(Yy z&J#+-XEx*)s4CfEzKyXSoGakeSWplNKf@y5JvuhZi_!~_6E3-wAvSyz@RdxEszZif zsi)srY-R1+)af;Gm3c;SpN-qhZZ(;xWt=51FFys$_xJIYtsN*eEZ}MnXzYjS7%814 zn%}F+cJ(7M0`=x7snu6QB1^uW2`10Z4_|ikZcn9shKe?HJ_=#ZF1~FIX^YkATj_!B zj zIB{(53sE?irr2#vw>S=~PJnjhbWTH*B&F~4m2#Mjy}t*}gLH*j&*i@+%;7Tk?U&z7 zn0t=LcZTUGN_Pfk<`ckUDle^OD|gx*zEwGQ5fj4lI23U4rEv2ul#U@5Qc^nqc(khc@L zUGAY&?LiuuI0|0;fkR>spi0bU+|APbkw9rEm0p2eN+22N3BC*-h}EPP6(w$5fixpX zBag?{z>IJgizm5b^FcT|~ zRf&bSOo%pI$TwwZKAN<1l!;WgBa79GmU)eMH`WpFmd$IDPpwoDi z#Ao(mwDw2CQ?NDzzO9|T^#^Y!H{Zl}X|cVk+L2le-!$pZ6^6c+o1AcT_rcA{<6#cb3(lmn!w2;+cj-mz3h}a-P)?;Nehj$#)DV4l zX$=*4^!}Y0_TP4#@fMeuu?6_59RD0R@PA1&DrcnB!X3o_DsM5NrqsMk@h%p{U0E%= zBCzXUKO7E7UJf%>&RZE{{JmEh1?x(vZz)~Y%G`}jck{VUjHw0W1vZvUZfrf``EqYD zlRXl~=R@bV2Eee?pP@IVcmWUh9Kl)p+4rR;tTF%4CD{!s1>?#`xx@F?w%FxXnx?VW z?xgh3(jl*Rfr_c)jw}J8$Eyv+;Lcd-T3DM3?c-<}Wf>dl=#tz+AtIZHUK8!|!P1R8 z*4KzN%|X{L2>ya-z5Ar}z2)r;fBsCbPq*}-5=p~*t(dEa`qiA_Ki3I*bPKeI(h{8D zRzMeQuW#bXQRLu!?v=9z`>KyD4k#BOyUfA!Yg`hHoi64A5sT9e`i1q{YGltra9a7s zYF1{>A~0^98u&%fr}SDo0L+!H1@&K*>!BBiovVm;b`s(M*^I+cx=OcJw1UA_w6U?d zRQ?|atT0i28Ew{L1V6;FU7Q2y7v+UigT>Q;z(2LuT6fBFk8A19+p5xt0;c;=ota2MI&8O5I zJ2l^?CK8TAZ(!-`lrXx4TFRY!BvCz;&$VrT`9?%VaaGS69X9Y6ISEmbb@)L{P~B<> z0gMrUtS)+)TkX{n6=67qDMy|D+LP(O53g7S8YeAJz7<9cb71~upy&RWdMF#^l3*xp z9n{i|`CqFE$~^yMi3%tVsp6NF7$heukc!vIx4VCdTi7IMN$jS*f82xbLJiTSe$qsN1f0XR|ksek!ls@mK&!SH#RuL1_mK&s(mD0X$Y+7 z{>O?Lb9q7Y`UKw_6Mud6^6ASOCX-|0RZii{^Z*1di5{OI)YB}^(*ZY1I|OYLM9raB z(PClIbg0eE_2Jrn>)c`z%WRun-Fw;a z?HsL5$=ZX;@{~(`H*hbovAltNnfH_GcaN0u{LPqm_qQFXSQcxOI9aKH^#U=OCq^P_ z@&D0Os`O6gbrC7_}1S#=xY%%=u%OnuKAGFkt5PB>KCMQ%doe z##k+KVyq{i?)H4PpBpMNhNZ-Ke_h)O<=UD$yFqAlLiy*RrmrrP1V9w>>g#u+xoF?s z8`jE*w_Sjj8DWN5Q;(1@He}b`^M9sU@Z3%Qz`;S8f`I%fecNvbtnqJNu2#hGnYD*t zDX7X>Ia;g7`{W|u<*WQX`oo?-kD~ALic4YsC&jm-@CW!1BNqkqa~63!=Aoh;v$XWV zZ#g!NCH6bGOGQPd%xbv#p$lJMwbBcHqipE7s634?f{eL!&{*;T|9K#c&3>6qZY8J1 zN?#c2%f_<;8k~;^u0LvjKe7mNBy=isBYsD!pdW2l_WoYF@ps~c)juvrLOJ+$dIzZYg zAVGz?7%+ z<6$v5NiP>xCvj5mk6WR72icC&sM@~mRKMqTYo;W>y zev@nJbB5?nC9`489h zH5l5%Y4@~*EoJsiQyaGuNS4?J(Rd$IiPLMJwGm>%k47ZRw|M$ETU)UwKZQmh{cKzv z?9bOOIk8>R6``q9Bkq}=Vz_Fi!qnW*H~k3cdsEzb)Jb9Gil_3IGEGVPS$&&nH$OzR zV{D{xhGQNw);P)ZHZH-b51(nL)TKQgA4*Ce`){aU8hI`2iR-a$&0NPH!TI1HtPf6& zQ}K=+eg2mC5`?>-TZZ5MZ+C`ztp!wrF{C_Sn_p6Z@~zLDIG4y zCP!8TkR-Ee1l%taXd>jsU@)BCIcBik5O!ir*xsE;VH6cHp6F423({+NS!Wy%?U{mB z6as*l{CgJsHq+L!0qx#o{OfU5y_Zs4JqC2af%oqIvgS zYW?=3o@Y<9W!>h7xC6C_e~}Is8bGUh*?tAxX*^Dzggag~Ll_BW1u?Lv0^lJv6&CL>+F5CDrHC~cM zKb)L)QAaK;H)Rz<*YU=FD^}j64_^u$-Uvn<>f~Bp1 zM2C;n)1Qb_{o*-1g3r7-v zWffz5OObMNcUKF&%goU?sX2$yAt=rmUgl%NDTO_Oh5>015HYcoOC*KY0yFRLMP!=Q zvw>#+%o1kOLKqHEyeyVw1x4G0u@-o{Jps|~2hvDXnPD5-8UmRx-F5x-vpDR`j3}or z7a?7HqEyD)oyAb$u&9`L$;MXmK-|FFrNjU<18}4dci6bhNdhiBlfOa z*H}h@Zd%5vzfYLU`DT(QZQ_m??e7)!_csb&*W39Oy8Nc=p-Fb(8%rn|`3^*3l_a%9 za;_$SO4vbKz{_ccP56V(9@U~^1kbY}5m1PO{9~`V%hE?TcLpD7K>mEpr*9_(>g|^m zn42E9+g2dTXf3I#|1FN&tL-fEqpJ~?xT4C0=b^x(+&08$pKrp3<^a?kgeM6dfoE&;bK(-Z`HB=ZH%1xQ``2de1;naU`SkksE2bd-78|Z2j z8P|JCINO+I`lbXLmB;?kdSq}O$6CIT_Jqw+0X*2Rg5_~``b%URo<9J5+v(_YqyJqL zCQ)j%Di|C<&1=&(X7epeaj(#mA1W_KZHv_}_P3strf6}{YJ_?}$e}RO>ow>MaV-=v zGU~K6d-w7gtX-?0jYnGim8@nqtZqqK%a8s0y}3ZIY`n{2hqCcSDis}@^1N2qHjW%j zq=zKyMeGnZSwg%5Yt2T*O15uj&AQHwYE7LF)o8sytIgp=d5&2Qj2+~&lscqF={!34 z)M_UUMZDb9b1p!)9&-xDoKKl5u?tOs#Wr#9RY>2_SI~#)d80bio>ngpt34+4zV;)aFW~)5}3{Gk1eHV6Y;MW4*VId3Q4%!_so2rr(vAavl?HNQb9W zZZEwE4mZ3~Ost2Jo^036)S5HdTzzjd*E9LU8?=tV9!KklwNOUx=M9JWpv6IBp^?O_UNFIo|a zU8FDm{_vUf!l>g{>3sTLDYYQHjIY1m7Eo|>@I1@e-G10I>nDT%LHP0{+c!S5ZzOry z-w`vFv1W>BVn?4F*3>cf@R{n&tqpPLE_U*ZALrW6Z!q&1W)+FWrhLE1`FT+rhmVQO z|5-L^$98eW>8Cn7?sD6*gT0_FIkyB;l{aAQP>&&uL>>yo4-JoK+6@?9z52kDIH&Vo z1d{rdR9_s{=9S3-WFPW&O;7EGa>vk>n4MNf6VhhpgNswmj%-;W*^?7DN1uZj@(FH0 zG%kwg$=Sw8->hXvB*BWFUw+ZJg?el7T}b)t@T3S`Z7kEhsmn->4I9nnWQ)JcKQuU2`FS_qzJXZcReyrE1wgZ+8G?2Zs_&LYsI0p zH~5Z^B3u0uSJL<8lKgnU(V`U?s#KO1K9Ju(q?cSh($9$@8U#9IHx0U#J6(8h2^svf zT5Kacaz2lOtUk>~RvZu5PXff0fy-+kH{pA(zm7e_RNv+JHGI`>I(;m_>KxIqIB3kQ z>)17YDWtjIEa+gASacvQWiEhl5a?8Ai(Rek@HJ}1nSqC_Ded7c&erh5G+Iq0 z+sw+NZ}5nYqASa1q9~tR2BS*h+9jk<32Wl*Kpb^}Z)~busbR>X$^)fhhy;6tm@v3b;q{(YiBeu*mHW=vZg@ZO2yFaB^!^F5yz;RsYvu6&>s za(M{*Vb+(-v!z!C(^bsld% z{kkHj8*q(Ij~4MFB3hmmopZ&{=Tvd#Jv`CV0faKF{?=5p@3R8DXW)LRrK6nWetinq zNPiZsUI&3-*L#8F@Jf11YG$+V5FLNk6Ui$h6De>PGKDTH6jDau77xDCbWcMEDK%b^ zJeCR9cdX_W3#BEPAHY1P8c60?hrPvt?91{|lDoFOsR?gI^pJ?}?CG$HgQuvfPcqJ_ z>_*MzQ)bA}%-krXBL|AY-MbIvPF$n7ySv5vbEy`iJE;#^k*XMI^#)_PoAL}OmyKJa zub(cZNa;4t=FBDLz8o?qrqH?hh6SkGaGE&4LuLa?ILO!b0fFH3N3KHtm)k@Cu;dMc zkw#Kqt&}1D)}Z{Fh$#jiQnT5BTQj!CR)8dsp6v4j9k z%dOzk$t&nQoQ~BM(RAHml}Q6r!XSLAiZI6?X5LUvw|=Ee<&22JH5HAqT|AmuckD0w zZAAnLU!~1eUeR&u-;W<=3hhj@twZ~GAm5T}6oirFjmdWOXNpR&gbL%34LVRcsbQ^h2)# zGvZY5gcK69C2*avp>|0cm*LZYXjs-%YxU@vP>->q!Ak1i4$U@->IkQS699}_@F0Sb zNKch%8il;A35xZgAk0Q?+*Ffg+iasK!z2#(nwob#8OP2dd)^cil4E9Eu|jdnp;oVK ztXuD*98rBFe6z)=gj|n7d)$W!ETja-10MxOdAU-wqZe# z5b+>7n(V?Ix+gVVkQORzeX1K8z_JD0iTayQEU%9S4Ge_WZWpDbzC5=j*zE1T0jI~d zZJdYOT9VqI+aEm=(tm(*Ff5^)M4#j9f@#@j_Y?dcN6keyy1ohw3x-o3X8Rp>dX?5B zG!Z%B4P@*@{-C5qiw;MXLcv$0r;vYn9UI^P44c?U;Nd%FLi@3)_fN^+>|NI?r19k9 zoMz`koBF>0_1ARuxU&-BkW~E3Q@6!dqkP#XJtXeM2GAdI`JY=}hY!4q8P;Fz6kbwK zee%zHbR#!}?9W0Z$v0b&j7zl#t^5l^E3xfi4#cPjla->3O^OB=m)?5e-DxylWm=Id zj-uKky;^Nsxc-^6hk4%6;<_k?|K131W_9w04Pi{&0EdqK?DW}w=CT%aB=H0!s=k^dv5;&IP^$!E)&11!L3y9sN>>s0rEeK!Htp{$Mj zS*R;X56k=NU^WX>!vNhkHsQXVHr+5Ll_3<;3x`FQCb#cMxikzd&bq@05Aw1_dhAHS zTm(!>)*4oApkr4Zx}7tc%j9t{c7wKCG_k*Jg39Z;6FmaMI}cR)k*g%)h44_g{>P}R zv?0a(q8tXtC&$ltH`iCwV>?Bs-J34Y2O^Md-Aj6eM}r}F9NiUFQzKHpH)Z4Ebws5Z zcezpf-J@2px1&D;YTGOdsK-2ORFfIeP9!{|Cbqx~nK@;Ugq%Y>ivz-=ooB^K-h_{fLh zaHG7pTjhT^vN$l9e+KKE1J*DTnO}m*NXmfsR7y?Z#!8l^XFB0%GF`V!C`?y0ek{K_ zkaNFWPO|aTGH{H>;Cwbpa`9n3ZzzzLB%531*{PBB+t0w<* z^mbSMwCbln)nUYHz^(@DYQX-7PXt#%xC+8QD+ouHE?j^>KKX9tzkomDY5OX+R#-O$D(GXE;3lUock>q~N>pQ>9KX7O6*DudWPCn=4d7bBZzn|rE?q0RC zQuzGn=KugGT)uSC5ddTg06_Yuy?el!VO^=80pJVZ^2P6*qjMKnaSvRlZqF)qcI1P+O7L%IztgzqdZX@VYwd4cd)ypw^8U}R z<4z+q&jwc}4LTb3{;T(-j%)b)wq~#Mg$JhK(iDYvVZD_PWj+20$8KyMauLyH%x2c+ zBwfO(nEd|mfd0iDeZF!R{yyY3NP*veJ7@&T#OOV8;P8e0I4FS$c~wxJH@z(l4!>xG z?aVEIwjY#V<^R_f#pgR465ZVm9cKA${kx72@nO2vbsY9qaL?0X97B6{VnIq#pC?$AQm5oGdI4uxT+$q zJ|znPj(b&mirUl%bx2x|*q0&T_8aetEEPx3A=+%z?R6B|XKd^IKAo6ZpVgaf zprdq;M7Cqp>A$9DGPHdt9PTvC*_;3AzE!?QuUoaHn3t*DJ!_zR5JSGV`*e?caEv( zb}uF^M%>bj_A%j9LiUd&w9LAv+aBxdJD{LzcN9JE)ZBbi!}OdeLmXN7{7v}guXTFG zwsi^11i2!1cmk_2#eHki0V_&5tgdFr&$#WRNO^TMiZS}$AUQ9xQTuqYz9?+*IBY<< zl`?IKzOTCa+ek8EJ4M~xT-ahR0{}}ue;s!IfyhE#%T~@uz3+IiEM%mV65D0wq|ESv z2$pl?4p*2OEe;76pXvn;4bxgLsSaB3ykBo|@M-mP&F#msF_R5NC!A<{hz|y+fYil0 zNkdbzTd2E{-wNf%TA4dUaqEm@CP(OV_M=9V&i3-F+7HPg$R5`8b*}MD56XB z8sG1hi%SS9@gt*FirzneKzv?H|^sOq1i*BYN^(fHnm>$NZ~u-V^UNRXwZh%9dFah zvUWIG`zAaSrt87?_G6ve2U9!onSS}EXvLbBl01a{!pu4VkNzMYeJyr!yFU@g@Y)P} z^dHS0A6iw}Uvb>P zENNYb7=2?bqsd%LHwS`*2ldF9i6cX;nh3a|OdZ;?P#Mj}x(s5h#!N}}e$DOb`Z&iD z67Ruk^8UF&s=@pNkZ7tejwc!pyqy*ZX`krSPqjz^^`>|$%f=O&&|kn*5|o8Lfo>Kh zLlnsTuJ>q>aIw8;On<6*5nXL+Ic1dPLs`xEjw+6w&S-&NQCTH6vg^gPtU*kOq4u?< zUo#?8wVNL@eGej3T=OA_j`SjJw}d5k?X1*xe7)qc14HsWzOfVfcIgHEWw{{6?38ZT z6Vb%&WRhXI+ZogtlS8``H*g3~C|W5hZiz?vNY^is0wkk)5#V8WA4$1?A*ZAalhD=f z0=tG8yT|TuudZYt=3BixG!?l|{i$itB4=?D99PP+Cz6tjLXV(Z)s8c$TRj%FibUb? zSYbVr(8jc#vUWf(#b>o7X=gd|m^Uvz$X?Ze*8Z?^0E#&MeY~LMIc)Xc4g_^`es4?o zaUK=tP5u#4&l)Ev{R-`eaTVb|tyqceNQ&TLFK$Q)UrxwI zz^>$(1i5*EVIX(uv;BdmQf>StH!QhJbCbFrMq;7jm}I-)WRs`(8*ipl{-v9pQ z`b$4;tQ)G)43}zD9qtUn1>2Dq(mwQCMVP$yZFicqvL!g<_LrWFl&;9#n9Vwq37lLDJDJ%j4}z_3;AtN-ch zxsZj?6E{6K#;@0Drg{2tbI-n*UZY4V-zy(`lM&d2#K!isY@tsi?+o~zQRSiTZx)*C zQ{}$B9MhhZigl|DcCuLJt02uJU($;xex1z0kRhtq(Xem<7-Tp09554A{upiN?JKk6^mXbzJa~mLiD9EHtY{!BZ=eU!$Za10)_yZ;*l)_>ZI2 zx72ZrwK*5T$Es^_X}S6#KRCD>zu5SVknAma^eh>n#jm@yGI~nGFf>@xZr)pPnbrj} z?5s=pl&1@++3@X@X~w@?X~_*|=b|>&2^A153^Jjin{L#1(ok)S6g!+5z_1Sr?{3S^ zQXlDiyK$R$KbtyrPpBR@dq>^?B*eQGAmSZZsOuFIPOgOcsB8WDgnA#5;%f1=_{>7% zb~@hlRtF_r*6GTIYr1?Vls^BXIhreRk83V#%^@zkpS!u%lx*wZ9@hSjoO7@Hf{Sf! ze=y-*J%<1q!sz2()h}L_jK-h)9_8YLuOFZ3#8qxM5B{TWwJr;- z~FiK*g9&Q$<@VLXvk8SgZOoThAI zA&|o$8EJ0iImAXdGsG-wnWd*yvU1g_J~|kC7HFMD%GUROY`C{nwajg4W&@PSUT)au z2_B}Mkr1)^7)dzP5MT#7(ozZ*F&e^B03E!SHbSwI1bIruPn%lGFcV)X0%3cEP19cB z-UmT=mXWq(LH7%Zyd&xS(eNuUR%-I%=^*QTR#O*l53VX}{8OTQ2moG!X>($Ci6gFJ z8@FsGj@h1rxhMmGRlq|Bpy2R1oS2j0q{E5-t(%SEvV@PFNtoBD!Pk1Fc5@LgF#?)! zD9th_N4s{kvMJGg5XUolIb6fxw>@W&=(Nf4^)xDnuK#)%b89BbauPhW@poYMJw9xm zS6Z%ii!*3oHTtnLn+uJrc>@=S85;P66=^J**>D^WpR1aeaQjUZhaNccFJrM@wI?yz ztVf~RSOJ}89QQ)bP71&$f;qgZXCVdCx1dnv`DJ5oc?g{uWMSP2;f9Wyhd2n?HDS13 zaR-W!jD{OpkZuk>ndcO*%`HuDt=zOBRa7j-57J9$uWUN)lO*B2WpJbt01!`rWdhbe zi-_Hx+lG?XE+?0kn*>EOZp@_Jn7J(|EcZErvP*t`jpnzmB0sO?J9uE)?J)axoVK?#?8AFG#8EIS_wGl41Qg}zkz^+6GQ{&_iI#yG^-S{|f zs%=Znl4r-YH-D=EoS)yT3JMqhPWDPEQM*jAxj3YTTr^)__Tf}RJr_p#zFouA0Lj%f z4nfL`sXao$PM-9l4ZJ`(WU|>L-5=lDO9398snS4CFnP>}&y1rpqem6>gCv6-%>uoV};9@_BjQWwX8AAN^wm~;nee4u=K0W0MEDDqnJ>d zgsrivE1bz$7~ZU!A!(0<4>XyeXM5{Jv@0vIllhA2%mntfHnrRY6ODTWTDGqd&MU{x z3>CBEFlYxY0PwIG2Sr6wPlARbeEHF>$$h$6IIa4q+~MUF8+-aQn%maI^yO0X zD+kC?+B)hX9P8E_0WsZOcH}YBhytq;)Xd$RU0;rc3sa$DNk2qgu&oS?@3Y-e$5)W= zS3#{^7@tpb@k(CM=A=M;*y4^-+bj#T>E_xa9S&2xH{gSwa1P?n6S_;LN;zyF2{Y|Q z`i5o{&zl-PbJDV(q(+bf!u|o;J1l}5%QxZk;?P@x7RCFi9Fqm1ao8npwVtA>?36T* zzSL(jl9ac+@xYE~#4UH1yiHgjm|1;z@rF<3nQeQ6Zu9y7ZL?;=PkNl)4C^ideQi~N zZ12wRGOM7iR(TQks*=&7*!gEESy7xbOSu)-2Kh^kN8{M>A+0<^x(@)TKHWhDOPGSk zOpa8Lke&JS44ymdEkCY8skJbo$!Cz=(-(v@7fQO~Vgm|6D*75MkBySxIkl2t)bd;j zXyoA(9%XFv5}xN3Iq>u}0Q|h;2voJK5B78eGPojvcq{$h)jxUxM-U))0!ww@{HeZ% zkFRm|gB6s9CM2%s8!#`E7Ll{SNA0d6)(dRGvO!ihLg*+3G|0Dg9;=q$p}{AOgL(Ho zKLd;oVCo1gtg53EfH&Ux!8p=IGWVu`XSLBmT_`aCUc6_cLteGV5J7#_4m{G60_;wM z+tbjEB{Drq;Ge0eF_pX1CCr~v$kj*~06ajjZCOO+ysck1Dk7KE_AxL09>fZ`+lzcP zi72bZ1!+KxFAbVsHv@GxuFoBKX3rpa7sEuI;~Z4?!ZiSH1$qtGR5-Fz7XE$u4WO=n zM83abJ7iZq@C5z_ZL3{7?b_*2>EkZ3>>?Kg#owm$zDr@d6t+uY|K-J;VuaO zmq7R?N$Nd#QNU&AUcg`E9s4fH?2^nb$^0*o%>PSb;eq{JKqAo~HPwJ0WOoQw8oUOf Wd%fZ;=P5V?T(-2kSbpL9kN*MexFR/// for physical dataset layout.", "For OPFS LOGOUT, UNTIL_SYNCED and ACCOUNT_DELETION maintenance, composition must provide both requestMaintenanceAuthority and consumeMaintenanceAuthority; issue a fresh proof bound to the exact frozen reason, scope and policy for at most five minutes, then atomically consume it to reject replay.", "Never expose an OPFS authority proof through the application request, persistence, diagnostics or telemetry.", "Never place user file names or identifiers in OPFS paths or diagnostics."], + "bundleBudgetGzipBytes": 36000, + "fallback": "Read-only or online-only query path; OPFS may degrade to a size-capped IndexedDB Blob only when the product policy approves it.", + "removal": ["Stop writes and background migration.", "Reconcile or export unsynced data, then purge only governance-bound owned partitions and OPFS namespaces through authorized bounded lifecycle operations.", "Close all database, channel, worker and file handles.", "Remove repository composition and dependency."], + "serverStatePolicy": "reference-or-command-only" + }, + { + "id": "service-worker-pwa", + "status": "RECIPE_AVAILABLE", + "referenceRuntime": { + "status": "AVAILABLE_NOT_COMPOSED", + "coveredCapabilities": [ + "Cache Storage public-response administration" + ], + "sourceRoots": [ + "src/application/ports/browser-file-storage/cache-storage-ports.ts", + "src/adapters/browser-file-storage", + "src/adapters/cache-storage" + ], + "conformanceScripts": [ + "test:unit", + "test:browser-capabilities", + "verify:browser-capability-evidence", + "check:browser-file-storage-boundaries", + "check:optional-recipes", + "test:browser-file-storage-removal" + ], + "productionComposition": false + }, + "trigger": "Installability, a measured offline-shell requirement, or an explicitly owned public HTTP representation cache is approved.", + "forbiddenWhen": ["Hosting cache and worker cache ownership conflict.", "Update and rollback UX is undefined.", "Authenticated, private, opaque or personal responses would be cached.", "Cache freshness, byte and entry limits are undefined."], + "boundary": "bootstrap update controller plus platform-local public Request/Response cache administration", + "port": "ServiceWorkerUpdatePort / PublicResponseCacheAdmin recipe / PublicResponseCachePort / PublicResponseCacheAdminPort reference runtime", + "fake": "FakeServiceWorkerUpdateAdapter / MemoryPublicResponseCache", + "failureKinds": ["stale-worker", "update-loop", "offline-fallback", "incomplete-candidate", "integrity-mismatch", "cache-policy-rejection", "quota"], + "lifecycleMethods": ["unregister", "rollback", "delete-owned-caches"], + "owner": "project-owner-required", + "securityPrivacy": ["Cache only explicit same-origin public GET representations.", "Never cache authenticated, cookie-dependent, private, no-store, opaque or personal responses.", "Bind candidate cache names to release identity and a canonical manifest digest that includes normalized expectedContentType, exact request identity, expected byte length and integrity digest.", "Reject a response whose normalized Content-Type differs from manifest expectedContentType even when body integrity matches.", "Derive cleanup retention only from the verified active pointer and composition retainedPreviousReleaseCount; cleanup callers cannot submit cache names, release registry IDs or any retain set.", "Read active-pointer and release-marker control JSON through a strict UTF-8 stream capped at exactly 2 MiB (2097152 bytes), cancel on overflow and fail closed before parsing oversized metadata.", "Keep exact query and Vary semantics; ignoreSearch and ignoreVary are forbidden.", "Fail closed on malformed update metadata or integrity mismatch."], + "bundleBudgetGzipBytes": 10000, + "fallback": "Normal network application with hosting cache headers.", + "removal": ["Deploy an unregister migration.", "Delete only parsed, owned cache namespaces after old controlled clients drain.", "Remove worker registration, cache metadata and manifest."], + "serverStatePolicy": "network-cache-policy-only" + }, + { + "id": "file-transfer", + "status": "RECIPE_AVAILABLE", + "referenceRuntime": { + "status": "AVAILABLE_NOT_COMPOSED", + "coveredCapabilities": [ + "File", + "Blob", + "native file input", + "system file picker", + "object URL preview", + "download delivery", + "presigned URL capability", + "bounded streaming download", + "multipart/resumable upload", + "durable non-secret upload checkpoint", + "Image CDN responsive delivery" + ], + "sourceRoots": [ + "src/application/ports/browser-file-storage/file.ts", + "src/application/ports/browser-transfer", + "src/adapters/browser-file-storage", + "src/adapters/browser-files", + "src/adapters/browser-transfer" + ], + "conformanceScripts": [ + "test:unit", + "test:browser-capabilities", + "verify:browser-capability-evidence", + "check:browser-file-storage-boundaries", + "check:optional-recipes", + "test:browser-file-storage-removal" + ], + "productionComposition": false + }, + "trigger": "The product selects, inspects, previews, uploads, downloads or delivers image renditions with bounded memory, resumability, cancellation, expiry and integrity requirements.", + "forbiddenWhen": ["Allowed count, byte, extension, MIME and content-signature policy is missing.", "The BFF does not own authorization, short-lived capability issuance, upload session reconciliation, quarantine and orphan cleanup.", "Long-lived credentials, presigned URLs or signed headers would enter persistence, application state or telemetry.", "Native File, Blob, object URL or file-system handles would cross into domain state or persistence.", "Large downloads would be returned as one in-memory byte array or Blob.", "Image callers could submit arbitrary CDN source URLs or transform parameters."], + "boundary": "BFF-owned transfer control plane plus adapter-owned browser/object-storage data plane; presentation receives opaque file/image references, registered policies and bounded result streams only", + "port": "FilePickerPort / FileContentPort / TransientPreviewPort / DownloadDeliveryPort / PresignedDownloadSourcePort / PresignedUploadPartPort / ResumableUploadPort / ImageCdnPresentationPort", + "fake": "Memory file/preview/download adapters plus injected deterministic capability, upload-control-plane, part-executor and image-verifier test doubles", + "failureKinds": ["dismissed", "permission-denied", "count-or-size-rejection", "type-or-signature-rejection", "file-changed", "abort", "integrity-failure", "partial-save", "expired-or-revoked-capability", "part-or-session-conflict", "checkpoint-conflict", "quarantined", "image-policy-rejection"], + "lifecycleMethods": ["release-file-ref", "release-or-dispose-preview-leases", "cancel-via-AbortSignal", "reconcile-or-explicitly-abort-upload", "close-checkpoint-store", "dispose-capability-and-image-runtime"], + "owner": "project-owner-required", + "securityPrivacy": ["Treat file name, extension, MIME and lastModified as untrusted metadata.", "Resolve only exact composition-issued file and image policy object identities; callers cannot raise byte, candidate, pixel, quality, format, lifetime or origin ceilings.", "Use opaque file references and verification receipts bound to an inspected immutable file snapshot and the exact registered profile; reject replay through another profile even when an inspection rule ID matches.", "Treat presigned URLs as bearer capabilities; bind exact method, resource or upload part, offset, length, media type, checksum, origin, path, query, headers and expiry in an in-memory identity vault.", "Use credentials omit, redirect error, no-referrer and no-store for direct data-plane fetch; never persist or observe URL, query, signed header, capability, file name or raw backend message, and never emit digest, raw ETag or receipt values to diagnostics or telemetry.", "A strict account-partitioned upload checkpoint may persist only the protocol-defined SHA-256 file fingerprint, per-part checksum and bounded opaque non-authorizing part receipt token required for server reconciliation; no bearer token or raw signed capability is allowed.", "Persist only strict non-authorizing upload checkpoints and reconcile them with server-authoritative status and re-hashed local parts before completion.", "Require a synchronous server-issued browser-managed download capability whose receipt exactly equals the caller's branded capability receipt and whose resource, media type, safe extension, maximum bytes, optional digest and expiry all match before handoff.", "Expose File, OPFS, Cache and transfer byte streams only as chunk-level closed Results; stop after the first failure, cancel native readers and never throw a raw native exception across the port.", "Accept Image CDN assets only through immutable allowlisted or signature-verified descriptors and registered preset identities; reject active formats, arbitrary transforms, pixel/decode-budget overflow and unsafe cache policy.", "Upload completion remains QUARANTINED until backend scan and promotion; client capability checks are not an authorization boundary.", "Active content preview requires isolation or download-only treatment."], + "bundleBudgetGzipBytes": 52000, + "fallback": "Accessible native file input, same-origin authorized server upload/download and a single bounded server-selected image rendition; generated artifacts above the buffer budget move to server-side generation.", + "removal": ["Stop new capability and upload-session issuance, then cancel active reads and transfers.", "Reconcile or explicitly abort active multipart sessions and let backend TTL cleanup remove ambiguous orphans.", "Remove non-secret checkpoints according to account and retention policy.", "Release file references, revoke preview object-URL leases and dispose file, capability and image runtimes.", "Remove transfer/image feature facades and composition, then prove browser-transfer sources are absent from the production module inventory."], + "serverStatePolicy": "query-cache-metadata-only" + }, + { + "id": "generated-api", + "status": "RECIPE_AVAILABLE", + "trigger": "A versioned backend contract justifies generated transport code.", + "forbiddenWhen": ["Generated DTOs would escape into domain or presentation.", "Contract drift cannot block CI."], + "boundary": "generated client wrapped by a feature gateway facade and mapper", + "port": "GeneratedApiFacade", + "fake": "FakeGeneratedApiAdapter", + "failureKinds": ["contract-drift", "unsupported-field"], + "lifecycleMethods": ["cancel-via-AbortSignal"], + "owner": "project-owner-required", + "securityPrivacy": ["Generate from an authenticated source.", "Review generator execution and output.", "Do not log request bodies."], + "bundleBudgetGzipBytes": 16000, + "fallback": "Existing typed request builder and runtime response schema.", + "removal": ["Restore handwritten gateway.", "Remove generated output and generator.", "Verify DTOs do not remain in public types."], + "serverStatePolicy": "query-cache-owned" + }, + { + "id": "feature-flag", + "status": "RECIPE_AVAILABLE", + "trigger": "A staged rollout or kill switch has a named owner, default and stale policy.", + "forbiddenWhen": ["A flag is used as authorization.", "Unknown and unavailable behavior is undefined."], + "boundary": "application feature policy output port", + "port": "FeatureFlagPort", + "fake": "FakeFeatureFlagAdapter", + "failureKinds": ["provider-unavailable", "unknown-flag", "stale-value"], + "lifecycleMethods": ["dispose-provider-if-installed"], + "owner": "project-owner-required", + "securityPrivacy": ["Flags are hints, never access control.", "Minimize targeting attributes.", "Apply consent rules to personal attributes."], + "bundleBudgetGzipBytes": 10000, + "fallback": "Typed local default with an explicit stale decision.", + "removal": ["Resolve the rollout permanently.", "Delete flag key and branches.", "Remove provider composition and dependency."], + "serverStatePolicy": "policy-cache-only" + }, + { + "id": "web-worker", + "status": "RECIPE_AVAILABLE", + "trigger": "Profiling shows CPU work blocking the main thread beyond the performance budget.", + "forbiddenWhen": ["The task is primarily network I/O.", "Cancellation and stale-result ownership are undefined."], + "boundary": "request/result/cancel output port with a validated message adapter", + "port": "WorkerTaskPort", + "fake": "FakeWorkerTaskAdapter", + "failureKinds": ["crash", "stale-result", "transfer-failure"], + "lifecycleMethods": ["cancel", "dispose"], + "owner": "project-owner-required", + "securityPrivacy": ["Validate worker messages.", "Do not send credentials.", "Bound transferred data and worker count."], + "bundleBudgetGzipBytes": 14000, + "fallback": "Chunked or deferred main-thread execution within a measured limit.", + "removal": ["Stop and dispose workers.", "Restore synchronous facade implementation.", "Remove worker entry and chunk."], + "serverStatePolicy": "no-server-state" + }, + { + "id": "multi-tab", + "status": "RECIPE_AVAILABLE", + "trigger": "A documented workflow must synchronize non-sensitive events across tabs.", + "forbiddenWhen": ["The server is the correct conflict authority.", "Event version and source identity are undefined."], + "boundary": "versioned browser event output/input adapter", + "port": "MultiTabPort", + "fake": "FakeMultiTabAdapter", + "failureKinds": ["self-echo", "duplicate", "conflict"], + "lifecycleMethods": ["unsubscribe", "close"], + "owner": "project-owner-required", + "securityPrivacy": ["Broadcast no credentials or personal payload.", "Validate versions.", "Treat events as hints rather than authorization."], + "bundleBudgetGzipBytes": 4000, + "fallback": "Refresh from the authoritative server on focus.", + "removal": ["Close channels.", "Remove event registry entries.", "Restore focus-based refresh."], + "serverStatePolicy": "invalidation-only" + }, + { + "id": "browser-permission", + "status": "RECIPE_AVAILABLE", + "trigger": "A user-initiated flow requires clipboard, notification or media access.", + "forbiddenWhen": ["Permission would be requested at boot.", "Denied, dismissed and unsupported UX are not designed."], + "boundary": "presentation input action through a browser capability output port", + "port": "BrowserPermissionPort", + "fake": "FakeBrowserPermissionAdapter", + "failureKinds": ["denied", "dismissed", "unsupported"], + "lifecycleMethods": ["stop-media-tracks-if-opened"], + "owner": "project-owner-required", + "securityPrivacy": ["Require an explicit user gesture.", "Minimize requested scope.", "Do not persist permission as authorization."], + "bundleBudgetGzipBytes": 3000, + "fallback": "Manual input or copy/download instruction.", + "removal": ["Stop acquired resources.", "Remove permission action and adapter.", "Retest denied-path accessibility."], + "serverStatePolicy": "no-server-state" + }, + { + "id": "client-workflow", + "status": "RECIPE_AVAILABLE", + "trigger": "A measured cross-page client-only workflow cannot be represented by URL, local state, context or query cache.", + "forbiddenWhen": ["The store would duplicate server response collections.", "A library is selected before state ownership is documented.", "Zustand and Redux Toolkit would both be installed."], + "boundary": "workflow-specific local facade; vendor types remain in its adapter", + "port": "ClientWorkflowPort", + "fake": "FakeClientWorkflowAdapter", + "failureKinds": ["reset", "version-mismatch", "server-state-duplication"], + "lifecycleMethods": ["unsubscribe", "reset"], + "owner": "project-owner-required", + "securityPrivacy": ["Persist only explicitly classified workflow fields.", "Never persist credentials.", "Define logout and version reset."], + "bundleBudgetGzipBytes": 9000, + "fallback": "URL, component state, context and TanStack Query ownership.", + "removal": ["Move remaining state to its natural owner.", "Remove facade and one selected store dependency.", "Verify logout/reset."], + "serverStatePolicy": "reference-only" + }, + { + "id": "large-data-ui", + "status": "RECIPE_AVAILABLE", + "trigger": "Production-like profiling proves a list or grid exceeds interaction and rendering budgets.", + "forbiddenWhen": ["Pagination solves the scale requirement.", "Keyboard and screen-reader focus behavior is undefined."], + "boundary": "presentation facade around virtualizer or data-grid behavior", + "port": "LargeDataUiFacade", + "fake": "FakeLargeDataUiAdapter", + "failureKinds": ["focus-loss", "stale-row", "scale-limit"], + "lifecycleMethods": ["dispose-observers-if-installed"], + "owner": "project-owner-required", + "securityPrivacy": ["Render only authorized rows.", "Do not expose hidden row data to telemetry.", "Preserve accessible row identity."], + "bundleBudgetGzipBytes": 30000, + "fallback": "Accessible pagination and bounded result sets.", + "removal": ["Restore paginated primitive.", "Remove facade adapter and dependency.", "Run keyboard and performance evidence."], + "serverStatePolicy": "query-cache-owned" + }, + { + "id": "analytics-error-sink", + "status": "RECIPE_AVAILABLE", + "trigger": "A production provider, consent policy, retention owner and event registry are approved.", + "forbiddenWhen": ["Consent and essential diagnostics are not separated.", "Arbitrary message or attribute keys can bypass redaction."], + "boundary": "closed diagnostics/analytics port with provider adapter", + "port": "AnalyticsErrorSink", + "fake": "RecordingAnalyticsAdapter", + "failureKinds": ["consent-denied", "queue-full", "provider-unavailable"], + "lifecycleMethods": ["flush", "dispose"], + "owner": "project-owner-required", + "securityPrivacy": ["Allowlist events and attributes.", "Redact before queueing.", "Apply consent, sampling and retention policy."], + "bundleBudgetGzipBytes": 25000, + "fallback": "Existing bounded local diagnostics and best-effort telemetry port.", + "removal": ["Disable provider delivery.", "Flush or discard by policy.", "Remove adapter, runtime config and dependency."], + "serverStatePolicy": "no-server-state" + } + ] +} diff --git a/config/release/coherence-fixtures.json b/config/release/coherence-fixtures.json new file mode 100644 index 0000000..c479d22 --- /dev/null +++ b/config/release/coherence-fixtures.json @@ -0,0 +1,77 @@ +{ + "schemaVersion": 1, + "fixtures": [ + { + "name": "coherent-release", + "expectedCompatible": true, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-a", + "configSchemaVersion": "1.1", + "apiContractVersion": "1.2", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + } + }, + { + "name": "mixed-html-and-assets", + "expectedCompatible": false, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-b", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-b", + "releaseId": "release-b" + } + }, + { + "name": "incompatible-runtime-config", + "expectedCompatible": false, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-a", + "configSchemaVersion": "2.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + } + }, + { + "name": "incompatible-api-contract", + "expectedCompatible": false, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "2.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + } + } + ] +} diff --git a/config/runbooks/runbooks.json b/config/runbooks/runbooks.json new file mode 100644 index 0000000..ef120df --- /dev/null +++ b/config/runbooks/runbooks.json @@ -0,0 +1,93 @@ +{ + "schemaVersion": 1, + "runbooks": { + "FE-RB-001": { + "title": "Boot configuration failure", + "gateId": "FE-GATE-021", + "triggerKinds": ["BOOT_CONFIG_FAILURE"], + "containment": "stop product route mount, show the safe support shell, and refetch at most once", + "window": "owner triage planned-default 5m", + "escalation": ["env-config owner", "release owner"], + "recoveryEvidence": [ + "clean-session boot", + "product root mount", + "config validation", + "no repeated boot error" + ], + "negativeFixture": "a valid config followed by an injected mount failure must fail recovery" + }, + "FE-RB-002": { + "title": "Chunk, manifest, or deployment mismatch", + "gateId": "FE-GATE-022", + "triggerKinds": [ + "CHUNK_LOAD_FAILURE", + "RELEASE_MANIFEST_FAILURE", + "DEPLOY_MISMATCH" + ], + "containment": "warn for dirty state, fetch manifest no-store once, and allow one guarded reload", + "window": "release owner triage planned-default 5m", + "escalation": ["release-cache owner", "hosting/CDN owner"], + "recoveryEvidence": [ + "entry and lazy assets reachable", + "release tuple coherent", + "second reload blocked", + "critical route smoke" + ], + "negativeFixture": "a second failure for the same release pair must not reload" + }, + "FE-RB-003": { + "title": "Backend API degradation", + "gateId": "FE-GATE-023", + "triggerKinds": [ + "TERMINAL_NETWORK_RATE", + "REQUEST_TIMEOUT_RATE", + "SERVER_FAILURE_RATE", + "SCHEMA_MISMATCH" + ], + "containment": "do not expand retry caps, serve safe stale reads, and never retry an unkeyed mutation", + "window": "rolling 5m trigger; first classification planned-default 10m", + "escalation": [ + "api-client owner", + "backend operation owner", + "release compatibility owner" + ], + "recoveryEvidence": [ + "terminal failure rate at baseline", + "no retry amplification", + "critical read/write smoke", + "schema fixtures" + ], + "negativeFixture": "an unkeyed POST receiving 503 must not retry" + }, + "FE-RB-004": { + "title": "Telemetry sink failure", + "gateId": "FE-GATE-024", + "triggerKinds": ["TELEMETRY_FAILURE"], + "containment": "keep product flow available, bound the queue, and never report recursively to the failing sink", + "window": "platform triage planned-default 15m", + "escalation": ["observability owner", "telemetry platform owner"], + "recoveryEvidence": [ + "product flow unaffected", + "delivery self-check", + "queue drained within bound", + "forbidden attributes absent" + ], + "negativeFixture": "raw URL and query data must be removed from telemetry" + }, + "FE-RB-005": { + "title": "Coherent release rollback", + "gateId": "FE-GATE-025", + "triggerKinds": ["RELEASE_BLOCKING_DEFECT"], + "containment": "select a prior immutable tuple, verify asset/config/API compatibility, atomically switch, and smoke", + "window": "provider recovery target TBD", + "escalation": ["release-cache owner", "release approver/hosting owner"], + "recoveryEvidence": [ + "compatibility gate", + "release coherence gate", + "critical smoke", + "release ID in incident timeline" + ], + "negativeFixture": "HTML build A with asset manifest B must be rejected" + } + } +} diff --git a/config/schemas/field-web-vitals.schema.json b/config/schemas/field-web-vitals.schema.json new file mode 100644 index 0000000..b49b2b8 --- /dev/null +++ b/config/schemas/field-web-vitals.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ART-FE-FIELD-WEB-VITALS@1", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "window", + "context", + "metrics", + "thresholds", + "eligibility", + "status", + "passed" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "window": { "type": "object", "required": ["days", "start", "end"] }, + "context": { + "type": "object", + "required": [ + "source", + "sourceSystem", + "exportId", + "network", + "routeAggregation", + "releaseId", + "privacyApprovalRef", + "thresholdDecisionRef", + "validationFailures" + ], + "properties": { + "source": { "type": "string" }, + "sourceSystem": { "type": ["string", "null"] }, + "exportId": { "type": ["string", "null"] }, + "network": { "const": "production-real-user" }, + "routeAggregation": { "const": "route-id-only" }, + "releaseId": { "type": ["string", "null"] }, + "privacyApprovalRef": { "type": ["string", "null"] }, + "thresholdDecisionRef": { "type": ["string", "null"] }, + "validationFailures": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + }, + "thresholds": { + "type": "object", + "required": [ + "p75LcpMs", + "p75Cls", + "p75InpMs", + "minimumEligibleSamples" + ] + }, + "metrics": { + "type": "object", + "required": ["p75LcpMs", "p75Cls", "p75InpMs"] + }, + "eligibility": { + "type": "object", + "required": [ + "consentRequired", + "totalSamples", + "eligibleSamples", + "minimumEligibleSamples", + "routeSamples" + ] + }, + "status": { + "enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"] + }, + "passed": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/config/schemas/lab-performance.schema.json b/config/schemas/lab-performance.schema.json new file mode 100644 index 0000000..878b9f3 --- /dev/null +++ b/config/schemas/lab-performance.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ART-FE-LAB@1", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "context", + "metrics", + "thresholds", + "fixtures", + "passed" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "context": { + "type": "object", + "required": ["runner", "browser", "viewport", "network", "cpu", "cache", "build"] + }, + "metrics": { + "type": "object", + "required": ["lcpMs", "cls", "namedInteractionMs"] + }, + "thresholds": { "type": "object" }, + "fixtures": { "type": "array", "minItems": 2 }, + "passed": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/config/schemas/release-verification.schema.json b/config/schemas/release-verification.schema.json new file mode 100644 index 0000000..29e5ff7 --- /dev/null +++ b/config/schemas/release-verification.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ART-FE-003@1", + "type": "object", + "required": ["schemaVersion", "generatedAt", "artifact", "fixtures", "passed"], + "properties": { + "schemaVersion": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "artifact": { + "type": "object", + "required": ["checked", "compatible", "mismatches"] + }, + "fixtures": { "type": "array", "minItems": 2 }, + "passed": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/config/schemas/runbook-drill-record.schema.json b/config/schemas/runbook-drill-record.schema.json new file mode 100644 index 0000000..d58daba --- /dev/null +++ b/config/schemas/runbook-drill-record.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ART-FE-RUNBOOK-DRILL@1", + "type": "object", + "required": [ + "schemaVersion", + "runbookId", + "releaseId", + "drillTimestamp", + "triggerInjected", + "triggerAsserted", + "containmentAsserted", + "escalationPathAsserted", + "recoveryAssertions", + "negativeFixtureFailedAsExpected", + "windowObservedBucket", + "passed" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "runbookId": { "pattern": "^FE-RB-00[1-5]$" }, + "releaseId": { "type": "string", "minLength": 1 }, + "drillTimestamp": { "type": "string", "format": "date-time" }, + "triggerInjected": { "type": "string" }, + "triggerAsserted": { "type": "boolean" }, + "containmentAsserted": { "type": "boolean" }, + "escalationPathAsserted": { "type": "boolean" }, + "recoveryAssertions": { + "type": "array", + "minItems": 4, + "items": { + "type": "object", + "required": ["assertion", "evidence", "passed"] + } + }, + "negativeFixtureFailedAsExpected": { "type": "boolean" }, + "windowObservedBucket": { "type": "string" }, + "providerVerificationRequired": { "type": "boolean" }, + "passed": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/config/security/dependency-baseline.approval.json b/config/security/dependency-baseline.approval.json new file mode 100644 index 0000000..9ee247b --- /dev/null +++ b/config/security/dependency-baseline.approval.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "snapshotDigest": "ce4fa9b7944f27553067228bd6c9e73e7dc05875c283255b50d7eb3ad2923f6d", + "owner": "frontend-platform", + "reason": "RP-11-initial-transitive-inventory", + "approvedAt": "2026-07-26T08:27:17.874Z" +} diff --git a/config/security/dependency-baseline.json b/config/security/dependency-baseline.json new file mode 100644 index 0000000..fe77550 --- /dev/null +++ b/config/security/dependency-baseline.json @@ -0,0 +1,6669 @@ +{ + "schemaVersion": 2, + "packageManager": "pnpm@11.17.0", + "lockfileSha256": "bedf1bbc1007a7cf0dbc6d29b1f4da0a3296717d5969c7fabc4a2844d949ae21", + "dependencyCount": 561, + "directDependencyCount": 35, + "dependencies": [ + { + "name": "@adobe/css-tools", + "version": "4.5.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==" + }, + { + "name": "@asamuzakjp/css-color", + "version": "5.1.11", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@asamuzakjp/generational-cache@1.0.1", + "@csstools/css-calc@3.3.0", + "@csstools/css-color-parser@4.1.10", + "@csstools/css-parser-algorithms@4.0.0", + "@csstools/css-tokenizer@4.0.0" + ], + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==" + }, + { + "name": "@asamuzakjp/dom-selector", + "version": "7.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@asamuzakjp/generational-cache@1.0.1", + "@asamuzakjp/nwsapi@2.3.9", + "bidi-js@1.0.3", + "css-tree@3.2.1", + "is-potential-custom-element-name@1.0.1" + ], + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==" + }, + { + "name": "@asamuzakjp/generational-cache", + "version": "1.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==" + }, + { + "name": "@asamuzakjp/nwsapi", + "version": "2.3.9", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==" + }, + { + "name": "@axe-core/playwright", + "version": "4.12.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MPL-2.0", + "dependencies": [ + "axe-core@4.12.1", + "playwright-core@1.62.0" + ], + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==" + }, + { + "name": "@babel/code-frame", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/helper-validator-identifier@7.29.7", + "js-tokens@4.0.0", + "picocolors@1.1.1" + ], + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==" + }, + { + "name": "@babel/code-frame", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/helper-validator-identifier@8.0.4", + "js-tokens@10.0.0" + ], + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==" + }, + { + "name": "@babel/compat-data", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==" + }, + { + "name": "@babel/compat-data", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==" + }, + { + "name": "@babel/core", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/code-frame@7.29.7", + "@babel/generator@7.29.7", + "@babel/helper-compilation-targets@7.29.7", + "@babel/helper-module-transforms@7.29.7", + "@babel/helpers@7.29.7", + "@babel/parser@7.29.7", + "@babel/template@7.29.7", + "@babel/traverse@7.29.7", + "@babel/types@7.29.7", + "@jridgewell/remapping@2.3.5", + "convert-source-map@2.0.0", + "debug@4.4.3", + "gensync@1.0.0-beta.2", + "json5@2.2.3", + "semver@6.3.1" + ], + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==" + }, + { + "name": "@babel/core", + "version": "8.0.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/code-frame@8.0.0", + "@babel/generator@8.0.0", + "@babel/helper-compilation-targets@8.0.0", + "@babel/helpers@8.0.0", + "@babel/parser@8.0.4", + "@babel/template@8.0.0", + "@babel/traverse@8.0.4", + "@babel/types@8.0.4", + "@types/gensync@1.0.5", + "convert-source-map@2.0.0", + "empathic@2.0.1", + "gensync@1.0.0-beta.2", + "import-meta-resolve@4.2.0", + "json5@2.2.3", + "obug@2.1.4", + "semver@7.8.5" + ], + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==" + }, + { + "name": "@babel/eslint-parser", + "version": "8.0.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/core@8.0.1", + "eslint-scope@9.1.2", + "eslint-visitor-keys@5.0.1", + "eslint@10.8.0", + "semver@7.8.5" + ], + "integrity": "sha512-2javO8pAQv/ld6sS6OcxoLAlzZEZy+xm99bnoAfMhzKSumKhdF5wylpbZB7XTorWr3KLPtx5K95eduJPOy1mzA==" + }, + { + "name": "@babel/generator", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/parser@7.29.7", + "@babel/types@7.29.7", + "@jridgewell/gen-mapping@0.3.13", + "@jridgewell/trace-mapping@0.3.31", + "jsesc@3.1.0" + ], + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==" + }, + { + "name": "@babel/generator", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/parser@8.0.4", + "@babel/types@8.0.4", + "@jridgewell/gen-mapping@0.3.13", + "@jridgewell/trace-mapping@0.3.31", + "@types/jsesc@2.5.1", + "jsesc@3.1.0" + ], + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==" + }, + { + "name": "@babel/helper-compilation-targets", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/compat-data@7.29.7", + "@babel/helper-validator-option@7.29.7", + "browserslist@4.28.7", + "lru-cache@5.1.1", + "semver@6.3.1" + ], + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==" + }, + { + "name": "@babel/helper-compilation-targets", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/compat-data@8.0.0", + "@babel/helper-validator-option@8.0.0", + "browserslist@4.28.7", + "lru-cache@11.5.2", + "semver@7.8.5" + ], + "integrity": "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==" + }, + { + "name": "@babel/helper-globals", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==" + }, + { + "name": "@babel/helper-globals", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==" + }, + { + "name": "@babel/helper-module-imports", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/traverse@7.29.7", + "@babel/types@7.29.7" + ], + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==" + }, + { + "name": "@babel/helper-module-transforms", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/core@7.29.7", + "@babel/helper-module-imports@7.29.7", + "@babel/helper-validator-identifier@7.29.7", + "@babel/traverse@7.29.7" + ], + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==" + }, + { + "name": "@babel/helper-plugin-utils", + "version": "8.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/core@8.0.1" + ], + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==" + }, + { + "name": "@babel/helper-string-parser", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" + }, + { + "name": "@babel/helper-string-parser", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==" + }, + { + "name": "@babel/helper-validator-identifier", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" + }, + { + "name": "@babel/helper-validator-identifier", + "version": "8.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==" + }, + { + "name": "@babel/helper-validator-option", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==" + }, + { + "name": "@babel/helper-validator-option", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==" + }, + { + "name": "@babel/helpers", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/template@7.29.7", + "@babel/types@7.29.7" + ], + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==" + }, + { + "name": "@babel/helpers", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/template@8.0.0", + "@babel/types@8.0.4" + ], + "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==" + }, + { + "name": "@babel/parser", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/types@7.29.7" + ], + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==" + }, + { + "name": "@babel/parser", + "version": "8.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/types@8.0.4" + ], + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==" + }, + { + "name": "@babel/plugin-syntax-jsx", + "version": "8.0.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/core@8.0.1", + "@babel/helper-plugin-utils@8.0.1" + ], + "integrity": "sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==" + }, + { + "name": "@babel/plugin-syntax-typescript", + "version": "8.0.3", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/core@8.0.1", + "@babel/helper-plugin-utils@8.0.1" + ], + "integrity": "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==" + }, + { + "name": "@babel/runtime", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" + }, + { + "name": "@babel/template", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/code-frame@7.29.7", + "@babel/parser@7.29.7", + "@babel/types@7.29.7" + ], + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==" + }, + { + "name": "@babel/template", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/code-frame@8.0.0", + "@babel/parser@8.0.4", + "@babel/types@8.0.4" + ], + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==" + }, + { + "name": "@babel/traverse", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/code-frame@7.29.7", + "@babel/generator@7.29.7", + "@babel/helper-globals@7.29.7", + "@babel/parser@7.29.7", + "@babel/template@7.29.7", + "@babel/types@7.29.7", + "debug@4.4.3" + ], + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==" + }, + { + "name": "@babel/traverse", + "version": "8.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/code-frame@8.0.0", + "@babel/generator@8.0.0", + "@babel/helper-globals@8.0.0", + "@babel/parser@8.0.4", + "@babel/template@8.0.0", + "@babel/types@8.0.4", + "obug@2.1.4" + ], + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==" + }, + { + "name": "@babel/types", + "version": "7.29.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/helper-string-parser@7.29.7", + "@babel/helper-validator-identifier@7.29.7" + ], + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==" + }, + { + "name": "@babel/types", + "version": "8.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/helper-string-parser@8.0.0", + "@babel/helper-validator-identifier@8.0.4" + ], + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==" + }, + { + "name": "@bcoe/v8-coverage", + "version": "1.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==" + }, + { + "name": "@bramus/specificity", + "version": "2.4.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "css-tree@3.2.1" + ], + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==" + }, + { + "name": "@csstools/color-helpers", + "version": "6.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT-0", + "dependencies": [], + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==" + }, + { + "name": "@csstools/css-calc", + "version": "3.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@csstools/css-parser-algorithms@4.0.0", + "@csstools/css-tokenizer@4.0.0" + ], + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==" + }, + { + "name": "@csstools/css-color-parser", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@csstools/color-helpers@6.1.0", + "@csstools/css-calc@3.3.0", + "@csstools/css-parser-algorithms@4.0.0", + "@csstools/css-tokenizer@4.0.0" + ], + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==" + }, + { + "name": "@csstools/css-parser-algorithms", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@csstools/css-tokenizer@4.0.0" + ], + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==" + }, + { + "name": "@csstools/css-syntax-patches-for-csstree", + "version": "1.1.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT-0", + "dependencies": [ + "css-tree@3.2.1" + ], + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==" + }, + { + "name": "@csstools/css-tokenizer", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==" + }, + { + "name": "@emnapi/core", + "version": "1.11.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "@emnapi/wasi-threads@1.2.2", + "tslib@2.8.1" + ], + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==" + }, + { + "name": "@emnapi/core", + "version": "1.11.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "@emnapi/wasi-threads@1.2.2", + "tslib@2.8.1" + ], + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==" + }, + { + "name": "@emnapi/core", + "version": "1.9.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "@emnapi/wasi-threads@1.2.1", + "tslib@2.8.1" + ], + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==" + }, + { + "name": "@emnapi/runtime", + "version": "1.11.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "tslib@2.8.1" + ], + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==" + }, + { + "name": "@emnapi/runtime", + "version": "1.11.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "tslib@2.8.1" + ], + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==" + }, + { + "name": "@emnapi/runtime", + "version": "1.9.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "tslib@2.8.1" + ], + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==" + }, + { + "name": "@emnapi/wasi-threads", + "version": "1.2.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "tslib@2.8.1" + ], + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==" + }, + { + "name": "@emnapi/wasi-threads", + "version": "1.2.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "tslib@2.8.1" + ], + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==" + }, + { + "name": "@esbuild/aix-ppc64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==" + }, + { + "name": "@esbuild/android-arm", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==" + }, + { + "name": "@esbuild/android-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==" + }, + { + "name": "@esbuild/android-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==" + }, + { + "name": "@esbuild/darwin-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==" + }, + { + "name": "@esbuild/darwin-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==" + }, + { + "name": "@esbuild/freebsd-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==" + }, + { + "name": "@esbuild/freebsd-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==" + }, + { + "name": "@esbuild/linux-arm", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==" + }, + { + "name": "@esbuild/linux-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==" + }, + { + "name": "@esbuild/linux-ia32", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==" + }, + { + "name": "@esbuild/linux-loong64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==" + }, + { + "name": "@esbuild/linux-mips64el", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==" + }, + { + "name": "@esbuild/linux-ppc64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==" + }, + { + "name": "@esbuild/linux-riscv64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==" + }, + { + "name": "@esbuild/linux-s390x", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==" + }, + { + "name": "@esbuild/linux-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==" + }, + { + "name": "@esbuild/netbsd-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==" + }, + { + "name": "@esbuild/netbsd-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==" + }, + { + "name": "@esbuild/openbsd-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==" + }, + { + "name": "@esbuild/openbsd-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==" + }, + { + "name": "@esbuild/openharmony-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==" + }, + { + "name": "@esbuild/sunos-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==" + }, + { + "name": "@esbuild/win32-arm64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==" + }, + { + "name": "@esbuild/win32-ia32", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==" + }, + { + "name": "@esbuild/win32-x64", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==" + }, + { + "name": "@eslint-community/eslint-utils", + "version": "4.10.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "eslint-visitor-keys@3.4.3", + "eslint@10.8.0" + ], + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==" + }, + { + "name": "@eslint-community/regexpp", + "version": "4.12.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==" + }, + { + "name": "@eslint/config-array", + "version": "0.23.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "@eslint/object-schema@3.0.5", + "debug@4.4.3", + "minimatch@10.2.5" + ], + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==" + }, + { + "name": "@eslint/config-helpers", + "version": "0.7.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "@eslint/core@1.2.1" + ], + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==" + }, + { + "name": "@eslint/core", + "version": "1.2.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "@types/json-schema@7.0.15" + ], + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==" + }, + { + "name": "@eslint/js", + "version": "10.0.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "eslint@10.8.0" + ], + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==" + }, + { + "name": "@eslint/object-schema", + "version": "3.0.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==" + }, + { + "name": "@eslint/plugin-kit", + "version": "0.7.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "@eslint/core@1.2.1", + "levn@0.4.1" + ], + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==" + }, + { + "name": "@exodus/bytes", + "version": "1.15.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==" + }, + { + "name": "@humanfs/core", + "version": "0.19.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "@humanfs/types@0.15.0" + ], + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==" + }, + { + "name": "@humanfs/node", + "version": "0.16.8", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "@humanfs/core@0.19.2", + "@humanfs/types@0.15.0", + "@humanwhocodes/retry@0.4.3" + ], + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==" + }, + { + "name": "@humanfs/types", + "version": "0.15.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==" + }, + { + "name": "@humanwhocodes/module-importer", + "version": "1.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + }, + { + "name": "@humanwhocodes/retry", + "version": "0.4.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==" + }, + { + "name": "@inquirer/ansi", + "version": "2.0.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==" + }, + { + "name": "@inquirer/confirm", + "version": "6.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@inquirer/core@11.2.1", + "@inquirer/type@4.0.7", + "@types/node@24.13.3" + ], + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==" + }, + { + "name": "@inquirer/core", + "version": "11.2.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@inquirer/ansi@2.0.7", + "@inquirer/figures@2.0.7", + "@inquirer/type@4.0.7", + "@types/node@24.13.3", + "cli-width@4.1.0", + "fast-wrap-ansi@0.2.2", + "mute-stream@3.0.0", + "signal-exit@4.1.0" + ], + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==" + }, + { + "name": "@inquirer/figures", + "version": "2.0.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==" + }, + { + "name": "@inquirer/type", + "version": "4.0.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/node@24.13.3" + ], + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==" + }, + { + "name": "@joshwooding/vite-plugin-react-docgen-typescript", + "version": "0.7.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "glob@13.0.6", + "react-docgen-typescript@2.4.0", + "typescript@7.0.2", + "vite@8.1.5" + ], + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==" + }, + { + "name": "@jridgewell/gen-mapping", + "version": "0.3.13", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@jridgewell/sourcemap-codec@1.5.5", + "@jridgewell/trace-mapping@0.3.31" + ], + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==" + }, + { + "name": "@jridgewell/remapping", + "version": "2.3.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@jridgewell/gen-mapping@0.3.13", + "@jridgewell/trace-mapping@0.3.31" + ], + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==" + }, + { + "name": "@jridgewell/resolve-uri", + "version": "3.1.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + { + "name": "@jridgewell/sourcemap-codec", + "version": "1.5.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + { + "name": "@jridgewell/trace-mapping", + "version": "0.3.31", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@jridgewell/resolve-uri@3.1.2", + "@jridgewell/sourcemap-codec@1.5.5" + ], + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==" + }, + { + "name": "@mswjs/interceptors", + "version": "0.41.9", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@open-draft/deferred-promise@2.2.0", + "@open-draft/logger@0.3.0", + "@open-draft/until@2.1.0", + "is-node-process@1.2.0", + "outvariant@1.4.3", + "strict-event-emitter@0.5.1" + ], + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==" + }, + { + "name": "@napi-rs/wasm-runtime", + "version": "1.1.6", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "@emnapi/core@1.11.1", + "@emnapi/core@1.11.2", + "@emnapi/core@1.9.2", + "@emnapi/runtime@1.11.1", + "@emnapi/runtime@1.11.2", + "@emnapi/runtime@1.9.2", + "@tybys/wasm-util@0.10.3" + ], + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==" + }, + { + "name": "@open-draft/deferred-promise", + "version": "2.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==" + }, + { + "name": "@open-draft/deferred-promise", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==" + }, + { + "name": "@open-draft/logger", + "version": "0.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "is-node-process@1.2.0", + "outvariant@1.4.3" + ], + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==" + }, + { + "name": "@open-draft/until", + "version": "2.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==" + }, + { + "name": "@oxc-parser/binding-android-arm-eabi", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==" + }, + { + "name": "@oxc-parser/binding-android-arm64", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==" + }, + { + "name": "@oxc-parser/binding-darwin-arm64", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==" + }, + { + "name": "@oxc-parser/binding-darwin-x64", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==" + }, + { + "name": "@oxc-parser/binding-freebsd-x64", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==" + }, + { + "name": "@oxc-parser/binding-linux-arm-gnueabihf", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==" + }, + { + "name": "@oxc-parser/binding-linux-arm-musleabihf", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==" + }, + { + "name": "@oxc-parser/binding-linux-arm64-gnu", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==" + }, + { + "name": "@oxc-parser/binding-linux-arm64-musl", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==" + }, + { + "name": "@oxc-parser/binding-linux-ppc64-gnu", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==" + }, + { + "name": "@oxc-parser/binding-linux-riscv64-gnu", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==" + }, + { + "name": "@oxc-parser/binding-linux-riscv64-musl", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==" + }, + { + "name": "@oxc-parser/binding-linux-s390x-gnu", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==" + }, + { + "name": "@oxc-parser/binding-linux-x64-gnu", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==" + }, + { + "name": "@oxc-parser/binding-linux-x64-musl", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==" + }, + { + "name": "@oxc-parser/binding-openharmony-arm64", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==" + }, + { + "name": "@oxc-parser/binding-wasm32-wasi", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "@emnapi/core@1.9.2", + "@emnapi/runtime@1.9.2", + "@napi-rs/wasm-runtime@1.1.6" + ], + "integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==" + }, + { + "name": "@oxc-parser/binding-win32-arm64-msvc", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==" + }, + { + "name": "@oxc-parser/binding-win32-ia32-msvc", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==" + }, + { + "name": "@oxc-parser/binding-win32-x64-msvc", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==" + }, + { + "name": "@oxc-project/types", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==" + }, + { + "name": "@oxc-project/types", + "version": "0.139.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==" + }, + { + "name": "@oxc-resolver/binding-android-arm-eabi", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==" + }, + { + "name": "@oxc-resolver/binding-android-arm64", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==" + }, + { + "name": "@oxc-resolver/binding-darwin-arm64", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==" + }, + { + "name": "@oxc-resolver/binding-darwin-x64", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==" + }, + { + "name": "@oxc-resolver/binding-freebsd-x64", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==" + }, + { + "name": "@oxc-resolver/binding-linux-arm-gnueabihf", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==" + }, + { + "name": "@oxc-resolver/binding-linux-arm-musleabihf", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==" + }, + { + "name": "@oxc-resolver/binding-linux-arm64-gnu", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==" + }, + { + "name": "@oxc-resolver/binding-linux-arm64-musl", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==" + }, + { + "name": "@oxc-resolver/binding-linux-ppc64-gnu", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==" + }, + { + "name": "@oxc-resolver/binding-linux-riscv64-gnu", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==" + }, + { + "name": "@oxc-resolver/binding-linux-riscv64-musl", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==" + }, + { + "name": "@oxc-resolver/binding-linux-s390x-gnu", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==" + }, + { + "name": "@oxc-resolver/binding-linux-x64-gnu", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==" + }, + { + "name": "@oxc-resolver/binding-linux-x64-musl", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==" + }, + { + "name": "@oxc-resolver/binding-openharmony-arm64", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==" + }, + { + "name": "@oxc-resolver/binding-wasm32-wasi", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "@emnapi/core@1.11.2", + "@emnapi/runtime@1.11.2", + "@napi-rs/wasm-runtime@1.1.6" + ], + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==" + }, + { + "name": "@oxc-resolver/binding-win32-arm64-msvc", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==" + }, + { + "name": "@oxc-resolver/binding-win32-x64-msvc", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==" + }, + { + "name": "@playwright/test", + "version": "1.62.0", + "direct": true, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "playwright@1.62.0" + ], + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==" + }, + { + "name": "@rolldown/binding-android-arm64", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==" + }, + { + "name": "@rolldown/binding-darwin-arm64", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==" + }, + { + "name": "@rolldown/binding-darwin-x64", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==" + }, + { + "name": "@rolldown/binding-freebsd-x64", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==" + }, + { + "name": "@rolldown/binding-linux-arm-gnueabihf", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==" + }, + { + "name": "@rolldown/binding-linux-arm64-gnu", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==" + }, + { + "name": "@rolldown/binding-linux-arm64-musl", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==" + }, + { + "name": "@rolldown/binding-linux-ppc64-gnu", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==" + }, + { + "name": "@rolldown/binding-linux-s390x-gnu", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==" + }, + { + "name": "@rolldown/binding-linux-x64-gnu", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==" + }, + { + "name": "@rolldown/binding-linux-x64-musl", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==" + }, + { + "name": "@rolldown/binding-openharmony-arm64", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==" + }, + { + "name": "@rolldown/binding-wasm32-wasi", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "@emnapi/core@1.11.1", + "@emnapi/runtime@1.11.1", + "@napi-rs/wasm-runtime@1.1.6" + ], + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==" + }, + { + "name": "@rolldown/binding-win32-arm64-msvc", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==" + }, + { + "name": "@rolldown/binding-win32-x64-msvc", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==" + }, + { + "name": "@rolldown/pluginutils", + "version": "1.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" + }, + { + "name": "@rollup/pluginutils", + "version": "5.4.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/estree@1.0.9", + "estree-walker@2.0.2", + "picomatch@4.0.5" + ], + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==" + }, + { + "name": "@standard-schema/spec", + "version": "1.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + { + "name": "@storybook/addon-a11y", + "version": "10.5.4", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@storybook/global@5.0.0", + "axe-core@4.12.1", + "storybook@10.5.4" + ], + "integrity": "sha512-UWdZtB7Dh1GjqxTPOrisUNDhDGF5pKGzZdzW9DSSHMBcAOx2dsTmXGzLSFprAz4LTT0OHCtKhxNqKK0JZJ0Y8g==" + }, + { + "name": "@storybook/builder-vite", + "version": "10.5.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@storybook/csf-plugin@10.5.4", + "storybook@10.5.4", + "ts-dedent@2.3.0", + "vite@8.1.5" + ], + "integrity": "sha512-eXdgow+brSzZrG3CnG18YwxZwEYNAZIh2G5qKwNoZf0uk2ZCPgLRQwtVAcd7BiiEDGnXUHm9pycAS+UiF4F4Mg==" + }, + { + "name": "@storybook/csf-plugin", + "version": "10.5.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "esbuild@0.28.1", + "storybook@10.5.4", + "unplugin@2.3.11", + "vite@8.1.5" + ], + "integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==" + }, + { + "name": "@storybook/global", + "version": "5.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==" + }, + { + "name": "@storybook/icons", + "version": "2.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "react@19.2.8" + ], + "integrity": "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==" + }, + { + "name": "@storybook/react-dom-shim", + "version": "10.5.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/react-dom@19.2.3", + "@types/react@19.2.8", + "react-dom@19.2.8", + "react@19.2.8", + "storybook@10.5.4" + ], + "integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==" + }, + { + "name": "@storybook/react-vite", + "version": "10.5.4", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", + "@rollup/pluginutils@5.4.0", + "@storybook/builder-vite@10.5.4", + "@storybook/react@10.5.4", + "empathic@2.0.1", + "magic-string@0.30.21", + "react-docgen@8.0.3", + "react-dom@19.2.8", + "react@19.2.8", + "resolve@1.22.12", + "storybook@10.5.4", + "tsconfig-paths@4.2.0", + "typescript@7.0.2", + "vite@8.1.5" + ], + "integrity": "sha512-iw7EAA98n30vpf+ZSy2Ll4Ne7oyZ/lS6W22u5Sp5UOI2oAkYuklxIWjRIKGqpY0BHP+GqaGYtUGMZQnBqgul2g==" + }, + { + "name": "@storybook/react", + "version": "10.5.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@storybook/global@5.0.0", + "@storybook/react-dom-shim@10.5.4", + "@types/react-dom@19.2.3", + "@types/react@19.2.8", + "react-docgen-typescript@2.4.0", + "react-docgen@8.0.3", + "react-dom@19.2.8", + "react@19.2.8", + "storybook@10.5.4", + "typescript@7.0.2" + ], + "integrity": "sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==" + }, + { + "name": "@tailwindcss/node", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@jridgewell/remapping@2.3.5", + "enhanced-resolve@5.24.2", + "jiti@2.7.0", + "lightningcss@1.32.0", + "magic-string@0.30.21", + "source-map-js@1.2.1", + "tailwindcss@4.3.3" + ], + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==" + }, + { + "name": "@tailwindcss/oxide-android-arm64", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==" + }, + { + "name": "@tailwindcss/oxide-darwin-arm64", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==" + }, + { + "name": "@tailwindcss/oxide-darwin-x64", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==" + }, + { + "name": "@tailwindcss/oxide-freebsd-x64", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==" + }, + { + "name": "@tailwindcss/oxide-linux-arm-gnueabihf", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==" + }, + { + "name": "@tailwindcss/oxide-linux-arm64-gnu", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==" + }, + { + "name": "@tailwindcss/oxide-linux-arm64-musl", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==" + }, + { + "name": "@tailwindcss/oxide-linux-x64-gnu", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==" + }, + { + "name": "@tailwindcss/oxide-linux-x64-musl", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==" + }, + { + "name": "@tailwindcss/oxide-wasm32-wasi", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==" + }, + { + "name": "@tailwindcss/oxide-win32-arm64-msvc", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==" + }, + { + "name": "@tailwindcss/oxide-win32-x64-msvc", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==" + }, + { + "name": "@tailwindcss/oxide", + "version": "4.3.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@tailwindcss/oxide-android-arm64@4.3.3", + "@tailwindcss/oxide-darwin-arm64@4.3.3", + "@tailwindcss/oxide-darwin-x64@4.3.3", + "@tailwindcss/oxide-freebsd-x64@4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu@4.3.3", + "@tailwindcss/oxide-linux-arm64-musl@4.3.3", + "@tailwindcss/oxide-linux-x64-gnu@4.3.3", + "@tailwindcss/oxide-linux-x64-musl@4.3.3", + "@tailwindcss/oxide-wasm32-wasi@4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc@4.3.3", + "@tailwindcss/oxide-win32-x64-msvc@4.3.3" + ], + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==" + }, + { + "name": "@tailwindcss/vite", + "version": "4.3.3", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@tailwindcss/node@4.3.3", + "@tailwindcss/oxide@4.3.3", + "tailwindcss@4.3.3", + "vite@8.1.5" + ], + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==" + }, + { + "name": "@tanstack/query-core", + "version": "5.101.4", + "direct": false, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==" + }, + { + "name": "@tanstack/react-query", + "version": "5.101.4", + "direct": true, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [ + "@tanstack/query-core@5.101.4", + "react@19.2.8" + ], + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==" + }, + { + "name": "@testing-library/dom", + "version": "10.4.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/code-frame@7.29.7", + "@babel/runtime@7.29.7", + "@types/aria-query@5.0.4", + "aria-query@5.3.0", + "dom-accessibility-api@0.5.16", + "lz-string@1.5.0", + "picocolors@1.1.1", + "pretty-format@27.5.1" + ], + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==" + }, + { + "name": "@testing-library/jest-dom", + "version": "6.10.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@adobe/css-tools@4.5.0", + "@testing-library/dom@10.4.1", + "aria-query@5.3.2", + "css.escape@1.5.1", + "dom-accessibility-api@0.6.3", + "picocolors@1.1.1", + "redent@3.0.0" + ], + "integrity": "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==" + }, + { + "name": "@testing-library/jest-dom", + "version": "7.0.0", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@adobe/css-tools@4.5.0", + "@testing-library/dom@10.4.1", + "aria-query@5.3.2", + "css.escape@1.5.1", + "dom-accessibility-api@0.6.3", + "picocolors@1.1.1", + "redent@3.0.0" + ], + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==" + }, + { + "name": "@testing-library/react", + "version": "16.3.2", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/runtime@7.29.7", + "@testing-library/dom@10.4.1", + "@types/react-dom@19.2.3", + "@types/react@19.2.8", + "react-dom@19.2.8", + "react@19.2.8" + ], + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==" + }, + { + "name": "@testing-library/user-event", + "version": "14.6.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@testing-library/dom@10.4.1" + ], + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==" + }, + { + "name": "@tybys/wasm-util", + "version": "0.10.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [ + "tslib@2.8.1" + ], + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==" + }, + { + "name": "@types/aria-query", + "version": "5.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + }, + { + "name": "@types/babel__core", + "version": "7.20.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/parser@7.29.7", + "@babel/types@7.29.7", + "@types/babel__generator@7.27.0", + "@types/babel__template@7.4.4", + "@types/babel__traverse@7.28.0" + ], + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==" + }, + { + "name": "@types/babel__generator", + "version": "7.27.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/types@7.29.7" + ], + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==" + }, + { + "name": "@types/babel__template", + "version": "7.4.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/parser@7.29.7", + "@babel/types@7.29.7" + ], + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==" + }, + { + "name": "@types/babel__traverse", + "version": "7.28.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/types@7.29.7" + ], + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==" + }, + { + "name": "@types/chai", + "version": "5.2.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/deep-eql@4.0.2", + "assertion-error@2.0.1" + ], + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==" + }, + { + "name": "@types/deep-eql", + "version": "4.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" + }, + { + "name": "@types/doctrine", + "version": "0.0.9", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==" + }, + { + "name": "@types/esrecurse", + "version": "4.3.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==" + }, + { + "name": "@types/estree", + "version": "1.0.9", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" + }, + { + "name": "@types/gensync", + "version": "1.0.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==" + }, + { + "name": "@types/jsesc", + "version": "2.5.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==" + }, + { + "name": "@types/json-schema", + "version": "7.0.15", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + { + "name": "@types/node", + "version": "24.13.3", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "undici-types@7.18.2" + ], + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==" + }, + { + "name": "@types/react-dom", + "version": "19.2.3", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/react@19.2.8" + ], + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==" + }, + { + "name": "@types/react", + "version": "19.2.8", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "csstype@3.2.3" + ], + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==" + }, + { + "name": "@types/resolve", + "version": "1.20.6", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==" + }, + { + "name": "@types/set-cookie-parser", + "version": "2.4.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/node@24.13.3" + ], + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==" + }, + { + "name": "@types/statuses", + "version": "2.0.6", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==" + }, + { + "name": "@typescript/typescript-aix-ppc64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==" + }, + { + "name": "@typescript/typescript-darwin-arm64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==" + }, + { + "name": "@typescript/typescript-darwin-x64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==" + }, + { + "name": "@typescript/typescript-freebsd-arm64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==" + }, + { + "name": "@typescript/typescript-freebsd-x64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==" + }, + { + "name": "@typescript/typescript-linux-arm", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==" + }, + { + "name": "@typescript/typescript-linux-arm64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==" + }, + { + "name": "@typescript/typescript-linux-loong64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==" + }, + { + "name": "@typescript/typescript-linux-mips64el", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==" + }, + { + "name": "@typescript/typescript-linux-ppc64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==" + }, + { + "name": "@typescript/typescript-linux-riscv64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==" + }, + { + "name": "@typescript/typescript-linux-s390x", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==" + }, + { + "name": "@typescript/typescript-linux-x64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==" + }, + { + "name": "@typescript/typescript-netbsd-arm64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==" + }, + { + "name": "@typescript/typescript-netbsd-x64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==" + }, + { + "name": "@typescript/typescript-openbsd-arm64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==" + }, + { + "name": "@typescript/typescript-openbsd-x64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==" + }, + { + "name": "@typescript/typescript-sunos-x64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==" + }, + { + "name": "@typescript/typescript-win32-arm64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==" + }, + { + "name": "@typescript/typescript-win32-x64", + "version": "7.0.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==" + }, + { + "name": "@vitejs/plugin-react", + "version": "6.0.4", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@rolldown/pluginutils@1.0.1", + "vite@8.1.5" + ], + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==" + }, + { + "name": "@vitest/coverage-v8", + "version": "4.1.10", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@bcoe/v8-coverage@1.0.2", + "@vitest/utils@4.1.10", + "ast-v8-to-istanbul@1.0.5", + "istanbul-lib-coverage@3.2.2", + "istanbul-lib-report@3.0.1", + "istanbul-reports@3.2.0", + "magicast@0.5.3", + "obug@2.1.4", + "std-env@4.2.0", + "tinyrainbow@3.1.0", + "vitest@4.1.10" + ], + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==" + }, + { + "name": "@vitest/expect", + "version": "3.2.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/chai@5.2.3", + "@vitest/spy@3.2.4", + "@vitest/utils@3.2.4", + "chai@5.3.3", + "tinyrainbow@2.0.0" + ], + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==" + }, + { + "name": "@vitest/expect", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@standard-schema/spec@1.1.0", + "@types/chai@5.2.3", + "@vitest/spy@4.1.10", + "@vitest/utils@4.1.10", + "chai@6.2.2", + "tinyrainbow@3.1.0" + ], + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==" + }, + { + "name": "@vitest/mocker", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@vitest/spy@4.1.10", + "estree-walker@3.0.3", + "magic-string@0.30.21", + "msw@2.15.0", + "vite@8.1.5" + ], + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==" + }, + { + "name": "@vitest/pretty-format", + "version": "3.2.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "tinyrainbow@2.0.0" + ], + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==" + }, + { + "name": "@vitest/pretty-format", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "tinyrainbow@3.1.0" + ], + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==" + }, + { + "name": "@vitest/runner", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@vitest/utils@4.1.10", + "pathe@2.0.3" + ], + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==" + }, + { + "name": "@vitest/snapshot", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@vitest/pretty-format@4.1.10", + "@vitest/utils@4.1.10", + "magic-string@0.30.21", + "pathe@2.0.3" + ], + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==" + }, + { + "name": "@vitest/spy", + "version": "3.2.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "tinyspy@4.0.4" + ], + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==" + }, + { + "name": "@vitest/spy", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==" + }, + { + "name": "@vitest/utils", + "version": "3.2.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@vitest/pretty-format@3.2.4", + "loupe@3.2.1", + "tinyrainbow@2.0.0" + ], + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==" + }, + { + "name": "@vitest/utils", + "version": "4.1.10", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@vitest/pretty-format@4.1.10", + "convert-source-map@2.0.0", + "tinyrainbow@3.1.0" + ], + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==" + }, + { + "name": "@webcontainer/env", + "version": "1.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==" + }, + { + "name": "acorn-jsx-walk", + "version": "2.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==" + }, + { + "name": "acorn-jsx", + "version": "5.3.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "acorn@8.17.0" + ], + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==" + }, + { + "name": "acorn-loose", + "version": "8.5.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "acorn@8.17.0" + ], + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==" + }, + { + "name": "acorn-walk", + "version": "8.3.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "acorn@8.17.0" + ], + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==" + }, + { + "name": "acorn", + "version": "8.17.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==" + }, + { + "name": "ajv", + "version": "6.15.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "fast-deep-equal@3.1.3", + "fast-json-stable-stringify@2.1.0", + "json-schema-traverse@0.4.1", + "uri-js@4.4.1" + ], + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==" + }, + { + "name": "ansi-regex", + "version": "5.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + { + "name": "ansi-styles", + "version": "4.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "color-convert@2.0.1" + ], + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==" + }, + { + "name": "ansi-styles", + "version": "5.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + }, + { + "name": "aria-query", + "version": "5.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "dequal@2.0.3" + ], + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==" + }, + { + "name": "aria-query", + "version": "5.3.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" + }, + { + "name": "assertion-error", + "version": "2.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" + }, + { + "name": "ast-types", + "version": "0.16.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "tslib@2.8.1" + ], + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==" + }, + { + "name": "ast-v8-to-istanbul", + "version": "1.0.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@jridgewell/trace-mapping@0.3.31", + "estree-walker@3.0.3", + "js-tokens@10.0.0" + ], + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==" + }, + { + "name": "axe-core", + "version": "4.12.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MPL-2.0", + "dependencies": [], + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==" + }, + { + "name": "balanced-match", + "version": "4.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + { + "name": "baseline-browser-mapping", + "version": "2.11.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==" + }, + { + "name": "bidi-js", + "version": "1.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "require-from-string@2.0.2" + ], + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==" + }, + { + "name": "brace-expansion", + "version": "5.0.8", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "balanced-match@4.0.4" + ], + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==" + }, + { + "name": "browserslist", + "version": "4.28.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "baseline-browser-mapping@2.11.1", + "caniuse-lite@1.0.30001806", + "electron-to-chromium@1.5.396", + "node-releases@2.0.51", + "update-browserslist-db@1.2.3" + ], + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==" + }, + { + "name": "bundle-name", + "version": "4.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "run-applescript@7.1.0" + ], + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==" + }, + { + "name": "caniuse-lite", + "version": "1.0.30001806", + "direct": false, + "scope": "development", + "optional": false, + "license": "CC-BY-4.0", + "dependencies": [], + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==" + }, + { + "name": "chai", + "version": "5.3.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "assertion-error@2.0.1", + "check-error@2.1.3", + "deep-eql@5.0.2", + "loupe@3.2.1", + "pathval@2.0.1" + ], + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==" + }, + { + "name": "chai", + "version": "6.2.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==" + }, + { + "name": "chalk", + "version": "4.1.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "ansi-styles@4.3.0", + "supports-color@7.2.0" + ], + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==" + }, + { + "name": "check-error", + "version": "2.1.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==" + }, + { + "name": "cli-width", + "version": "4.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==" + }, + { + "name": "cliui", + "version": "8.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [ + "string-width@4.2.3", + "strip-ansi@6.0.1", + "wrap-ansi@7.0.0" + ], + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==" + }, + { + "name": "color-convert", + "version": "2.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "color-name@1.1.4" + ], + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==" + }, + { + "name": "color-name", + "version": "1.1.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + { + "name": "commander", + "version": "15.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==" + }, + { + "name": "convert-source-map", + "version": "2.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + { + "name": "cookie", + "version": "1.1.1", + "direct": false, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, + { + "name": "cross-spawn", + "version": "7.0.6", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "path-key@3.1.1", + "shebang-command@2.0.0", + "which@2.0.2" + ], + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==" + }, + { + "name": "css-tree", + "version": "3.2.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "mdn-data@2.27.1", + "source-map-js@1.2.1" + ], + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==" + }, + { + "name": "css.escape", + "version": "1.5.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + }, + { + "name": "csstype", + "version": "3.2.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + { + "name": "data-urls", + "version": "7.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "whatwg-mimetype@5.0.0", + "whatwg-url@16.0.1" + ], + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==" + }, + { + "name": "debug", + "version": "4.4.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "ms@2.1.3", + "supports-color@7.2.0" + ], + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==" + }, + { + "name": "decimal.js", + "version": "10.6.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" + }, + { + "name": "deep-eql", + "version": "5.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" + }, + { + "name": "deep-is", + "version": "0.1.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + }, + { + "name": "default-browser-id", + "version": "5.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==" + }, + { + "name": "default-browser", + "version": "5.5.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "bundle-name@4.1.0", + "default-browser-id@5.0.1" + ], + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==" + }, + { + "name": "define-lazy-prop", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==" + }, + { + "name": "dependency-cruiser", + "version": "18.1.0", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "acorn-jsx-walk@2.0.0", + "acorn-jsx@5.3.2", + "acorn-loose@8.5.2", + "acorn-walk@8.3.5", + "acorn@8.17.0", + "commander@15.0.0", + "enhanced-resolve@5.24.2", + "ignore@7.0.6", + "interpret@3.1.1", + "is-installed-globally@1.0.0", + "json5@2.2.3", + "picomatch@4.0.5", + "prompts@2.4.2", + "rechoir@0.8.0", + "safe-regex@2.1.1", + "semver@7.8.5", + "tsconfig-paths-webpack-plugin@4.2.0", + "watskeburt@6.0.0" + ], + "integrity": "sha512-pbsH0gQ15BxXi97SCuMeVgsh8VzYpziGIVaMdnALbVu+TYjSZrW9/nOvsPU+NjHLmJSF9R1UijjoACq6Ne/ybQ==" + }, + { + "name": "dequal", + "version": "2.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, + { + "name": "detect-libc", + "version": "2.1.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, + { + "name": "doctrine", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "esutils@2.0.3" + ], + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==" + }, + { + "name": "dom-accessibility-api", + "version": "0.5.16", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + }, + { + "name": "dom-accessibility-api", + "version": "0.6.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" + }, + { + "name": "electron-to-chromium", + "version": "1.5.396", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==" + }, + { + "name": "emoji-regex", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + { + "name": "empathic", + "version": "2.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==" + }, + { + "name": "enhanced-resolve", + "version": "5.24.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "graceful-fs@4.2.11", + "tapable@2.3.3" + ], + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==" + }, + { + "name": "entities", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [], + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==" + }, + { + "name": "es-errors", + "version": "1.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + { + "name": "es-module-lexer", + "version": "2.3.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==" + }, + { + "name": "esbuild", + "version": "0.28.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@esbuild/aix-ppc64@0.28.1", + "@esbuild/android-arm64@0.28.1", + "@esbuild/android-arm@0.28.1", + "@esbuild/android-x64@0.28.1", + "@esbuild/darwin-arm64@0.28.1", + "@esbuild/darwin-x64@0.28.1", + "@esbuild/freebsd-arm64@0.28.1", + "@esbuild/freebsd-x64@0.28.1", + "@esbuild/linux-arm64@0.28.1", + "@esbuild/linux-arm@0.28.1", + "@esbuild/linux-ia32@0.28.1", + "@esbuild/linux-loong64@0.28.1", + "@esbuild/linux-mips64el@0.28.1", + "@esbuild/linux-ppc64@0.28.1", + "@esbuild/linux-riscv64@0.28.1", + "@esbuild/linux-s390x@0.28.1", + "@esbuild/linux-x64@0.28.1", + "@esbuild/netbsd-arm64@0.28.1", + "@esbuild/netbsd-x64@0.28.1", + "@esbuild/openbsd-arm64@0.28.1", + "@esbuild/openbsd-x64@0.28.1", + "@esbuild/openharmony-arm64@0.28.1", + "@esbuild/sunos-x64@0.28.1", + "@esbuild/win32-arm64@0.28.1", + "@esbuild/win32-ia32@0.28.1", + "@esbuild/win32-x64@0.28.1" + ], + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==" + }, + { + "name": "escalade", + "version": "3.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + { + "name": "escape-string-regexp", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + { + "name": "eslint-plugin-react-hooks", + "version": "7.1.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/core@7.29.7", + "@babel/parser@7.29.7", + "eslint@10.8.0", + "hermes-parser@0.25.1", + "zod-validation-error@4.0.2", + "zod@4.4.3" + ], + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==" + }, + { + "name": "eslint-scope", + "version": "9.1.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [ + "@types/esrecurse@4.3.1", + "@types/estree@1.0.9", + "esrecurse@4.3.0", + "estraverse@5.3.0" + ], + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==" + }, + { + "name": "eslint-visitor-keys", + "version": "3.4.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + }, + { + "name": "eslint-visitor-keys", + "version": "5.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==" + }, + { + "name": "eslint", + "version": "10.8.0", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@eslint-community/eslint-utils@4.10.1", + "@eslint-community/regexpp@4.12.2", + "@eslint/config-array@0.23.5", + "@eslint/config-helpers@0.7.0", + "@eslint/core@1.2.1", + "@eslint/plugin-kit@0.7.2", + "@humanfs/node@0.16.8", + "@humanwhocodes/module-importer@1.0.1", + "@humanwhocodes/retry@0.4.3", + "@types/estree@1.0.9", + "ajv@6.15.0", + "cross-spawn@7.0.6", + "debug@4.4.3", + "escape-string-regexp@4.0.0", + "eslint-scope@9.1.2", + "eslint-visitor-keys@5.0.1", + "espree@11.2.0", + "esquery@1.7.0", + "esutils@2.0.3", + "fast-deep-equal@3.1.3", + "file-entry-cache@8.0.0", + "find-up@5.0.0", + "glob-parent@6.0.2", + "ignore@5.3.2", + "imurmurhash@0.1.4", + "is-glob@4.0.3", + "jiti@2.7.0", + "json-stable-stringify-without-jsonify@1.0.1", + "minimatch@10.2.5", + "natural-compare@1.4.0", + "optionator@0.9.4" + ], + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==" + }, + { + "name": "espree", + "version": "11.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [ + "acorn-jsx@5.3.2", + "acorn@8.17.0", + "eslint-visitor-keys@5.0.1" + ], + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==" + }, + { + "name": "esprima", + "version": "4.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [], + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + { + "name": "esquery", + "version": "1.7.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-3-Clause", + "dependencies": [ + "estraverse@5.3.0" + ], + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==" + }, + { + "name": "esrecurse", + "version": "4.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [ + "estraverse@5.3.0" + ], + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==" + }, + { + "name": "estraverse", + "version": "5.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [], + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + { + "name": "estree-walker", + "version": "2.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + { + "name": "estree-walker", + "version": "3.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/estree@1.0.9" + ], + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==" + }, + { + "name": "esutils", + "version": "2.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [], + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + { + "name": "expect-type", + "version": "1.4.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==" + }, + { + "name": "fast-deep-equal", + "version": "3.1.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + { + "name": "fast-json-stable-stringify", + "version": "2.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + { + "name": "fast-levenshtein", + "version": "2.0.6", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + }, + { + "name": "fast-string-truncated-width", + "version": "3.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==" + }, + { + "name": "fast-string-width", + "version": "3.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "fast-string-truncated-width@3.0.3" + ], + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==" + }, + { + "name": "fast-wrap-ansi", + "version": "0.2.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "fast-string-width@3.0.2" + ], + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==" + }, + { + "name": "fdir", + "version": "6.5.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "picomatch@4.0.5" + ], + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==" + }, + { + "name": "file-entry-cache", + "version": "8.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "flat-cache@4.0.1" + ], + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==" + }, + { + "name": "find-up", + "version": "5.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "locate-path@6.0.0", + "path-exists@4.0.0" + ], + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==" + }, + { + "name": "flat-cache", + "version": "4.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "flatted@3.4.3", + "keyv@4.5.4" + ], + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==" + }, + { + "name": "flatted", + "version": "3.4.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==" + }, + { + "name": "fsevents", + "version": "2.3.2", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==" + }, + { + "name": "fsevents", + "version": "2.3.3", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==" + }, + { + "name": "function-bind", + "version": "1.1.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + { + "name": "gensync", + "version": "1.0.0-beta.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + { + "name": "get-caller-file", + "version": "2.0.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + { + "name": "glob-parent", + "version": "6.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [ + "is-glob@4.0.3" + ], + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==" + }, + { + "name": "glob", + "version": "13.0.6", + "direct": false, + "scope": "development", + "optional": false, + "license": "BlueOak-1.0.0", + "dependencies": [ + "minimatch@10.2.5", + "minipass@7.1.3", + "path-scurry@2.0.2" + ], + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==" + }, + { + "name": "global-directory", + "version": "4.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "ini@4.1.1" + ], + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==" + }, + { + "name": "globals", + "version": "17.7.0", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==" + }, + { + "name": "graceful-fs", + "version": "4.2.11", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + { + "name": "graphql", + "version": "16.14.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==" + }, + { + "name": "has-flag", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + { + "name": "hasown", + "version": "2.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "function-bind@1.1.2" + ], + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==" + }, + { + "name": "headers-polyfill", + "version": "5.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/set-cookie-parser@2.4.10", + "set-cookie-parser@3.1.2" + ], + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==" + }, + { + "name": "hermes-estree", + "version": "0.25.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==" + }, + { + "name": "hermes-parser", + "version": "0.25.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "hermes-estree@0.25.1" + ], + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==" + }, + { + "name": "html-encoding-sniffer", + "version": "6.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@exodus/bytes@1.15.1" + ], + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==" + }, + { + "name": "html-escaper", + "version": "2.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + }, + { + "name": "ignore", + "version": "5.3.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" + }, + { + "name": "ignore", + "version": "7.0.6", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==" + }, + { + "name": "import-meta-resolve", + "version": "4.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==" + }, + { + "name": "imurmurhash", + "version": "0.1.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + { + "name": "indent-string", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" + }, + { + "name": "ini", + "version": "4.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==" + }, + { + "name": "interpret", + "version": "3.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==" + }, + { + "name": "is-core-module", + "version": "2.16.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "hasown@2.0.4" + ], + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==" + }, + { + "name": "is-docker", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==" + }, + { + "name": "is-extglob", + "version": "2.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + { + "name": "is-fullwidth-code-point", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + { + "name": "is-glob", + "version": "4.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "is-extglob@2.1.1" + ], + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==" + }, + { + "name": "is-inside-container", + "version": "1.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "is-docker@3.0.0" + ], + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==" + }, + { + "name": "is-installed-globally", + "version": "1.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "global-directory@4.0.1", + "is-path-inside@4.0.0" + ], + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==" + }, + { + "name": "is-node-process", + "version": "1.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==" + }, + { + "name": "is-path-inside", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==" + }, + { + "name": "is-potential-custom-element-name", + "version": "1.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + { + "name": "is-wsl", + "version": "3.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "is-inside-container@1.0.0" + ], + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==" + }, + { + "name": "isexe", + "version": "2.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + { + "name": "istanbul-lib-coverage", + "version": "3.2.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-3-Clause", + "dependencies": [], + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==" + }, + { + "name": "istanbul-lib-report", + "version": "3.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-3-Clause", + "dependencies": [ + "istanbul-lib-coverage@3.2.2", + "make-dir@4.0.0", + "supports-color@7.2.0" + ], + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==" + }, + { + "name": "istanbul-reports", + "version": "3.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-3-Clause", + "dependencies": [ + "html-escaper@2.0.2", + "istanbul-lib-report@3.0.1" + ], + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==" + }, + { + "name": "jiti", + "version": "2.7.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==" + }, + { + "name": "js-tokens", + "version": "10.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==" + }, + { + "name": "js-tokens", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + { + "name": "jsdom", + "version": "29.1.1", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@asamuzakjp/css-color@5.1.11", + "@asamuzakjp/dom-selector@7.1.1", + "@bramus/specificity@2.4.2", + "@csstools/css-syntax-patches-for-csstree@1.1.7", + "@exodus/bytes@1.15.1", + "css-tree@3.2.1", + "data-urls@7.0.0", + "decimal.js@10.6.0", + "html-encoding-sniffer@6.0.0", + "is-potential-custom-element-name@1.0.1", + "lru-cache@11.5.2", + "parse5@8.0.1", + "saxes@6.0.0", + "symbol-tree@3.2.4", + "tough-cookie@6.0.2", + "undici@7.28.0", + "w3c-xmlserializer@5.0.0", + "webidl-conversions@8.0.1", + "whatwg-mimetype@5.0.0", + "whatwg-url@16.0.1", + "xml-name-validator@5.0.0" + ], + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==" + }, + { + "name": "jsesc", + "version": "3.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==" + }, + { + "name": "json-buffer", + "version": "3.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + { + "name": "json-schema-traverse", + "version": "0.4.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + { + "name": "json-stable-stringify-without-jsonify", + "version": "1.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + }, + { + "name": "json5", + "version": "2.2.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" + }, + { + "name": "jsonc-parser", + "version": "3.3.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, + { + "name": "keyv", + "version": "4.5.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "json-buffer@3.0.1" + ], + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==" + }, + { + "name": "kleur", + "version": "3.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" + }, + { + "name": "levn", + "version": "0.4.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "prelude-ls@1.2.1", + "type-check@0.4.0" + ], + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==" + }, + { + "name": "lightningcss-android-arm64", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==" + }, + { + "name": "lightningcss-android-arm64", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==" + }, + { + "name": "lightningcss-darwin-arm64", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==" + }, + { + "name": "lightningcss-darwin-arm64", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==" + }, + { + "name": "lightningcss-darwin-x64", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==" + }, + { + "name": "lightningcss-darwin-x64", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==" + }, + { + "name": "lightningcss-freebsd-x64", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==" + }, + { + "name": "lightningcss-freebsd-x64", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==" + }, + { + "name": "lightningcss-linux-arm-gnueabihf", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==" + }, + { + "name": "lightningcss-linux-arm-gnueabihf", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==" + }, + { + "name": "lightningcss-linux-arm64-gnu", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==" + }, + { + "name": "lightningcss-linux-arm64-gnu", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==" + }, + { + "name": "lightningcss-linux-arm64-musl", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==" + }, + { + "name": "lightningcss-linux-arm64-musl", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==" + }, + { + "name": "lightningcss-linux-x64-gnu", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MPL-2.0", + "dependencies": [], + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==" + }, + { + "name": "lightningcss-linux-x64-gnu", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MPL-2.0", + "dependencies": [], + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==" + }, + { + "name": "lightningcss-linux-x64-musl", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==" + }, + { + "name": "lightningcss-linux-x64-musl", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==" + }, + { + "name": "lightningcss-win32-arm64-msvc", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==" + }, + { + "name": "lightningcss-win32-arm64-msvc", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==" + }, + { + "name": "lightningcss-win32-x64-msvc", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==" + }, + { + "name": "lightningcss-win32-x64-msvc", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": true, + "license": "NOASSERTION", + "dependencies": [], + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==" + }, + { + "name": "lightningcss", + "version": "1.32.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MPL-2.0", + "dependencies": [ + "detect-libc@2.1.2", + "lightningcss-android-arm64@1.32.0", + "lightningcss-darwin-arm64@1.32.0", + "lightningcss-darwin-x64@1.32.0", + "lightningcss-freebsd-x64@1.32.0", + "lightningcss-linux-arm-gnueabihf@1.32.0", + "lightningcss-linux-arm64-gnu@1.32.0", + "lightningcss-linux-arm64-musl@1.32.0", + "lightningcss-linux-x64-gnu@1.32.0", + "lightningcss-linux-x64-musl@1.32.0", + "lightningcss-win32-arm64-msvc@1.32.0", + "lightningcss-win32-x64-msvc@1.32.0" + ], + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==" + }, + { + "name": "lightningcss", + "version": "1.33.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MPL-2.0", + "dependencies": [ + "detect-libc@2.1.2", + "lightningcss-android-arm64@1.33.0", + "lightningcss-darwin-arm64@1.33.0", + "lightningcss-darwin-x64@1.33.0", + "lightningcss-freebsd-x64@1.33.0", + "lightningcss-linux-arm-gnueabihf@1.33.0", + "lightningcss-linux-arm64-gnu@1.33.0", + "lightningcss-linux-arm64-musl@1.33.0", + "lightningcss-linux-x64-gnu@1.33.0", + "lightningcss-linux-x64-musl@1.33.0", + "lightningcss-win32-arm64-msvc@1.33.0", + "lightningcss-win32-x64-msvc@1.33.0" + ], + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==" + }, + { + "name": "locate-path", + "version": "6.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "p-locate@5.0.0" + ], + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==" + }, + { + "name": "loupe", + "version": "3.2.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==" + }, + { + "name": "lru-cache", + "version": "11.5.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "BlueOak-1.0.0", + "dependencies": [], + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==" + }, + { + "name": "lru-cache", + "version": "5.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [ + "yallist@3.1.1" + ], + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==" + }, + { + "name": "lucide-react", + "version": "1.25.0", + "direct": true, + "scope": "production", + "optional": false, + "license": "ISC", + "dependencies": [ + "react@19.2.8" + ], + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==" + }, + { + "name": "lz-string", + "version": "1.5.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==" + }, + { + "name": "magic-string", + "version": "0.30.21", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@jridgewell/sourcemap-codec@1.5.5" + ], + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==" + }, + { + "name": "magicast", + "version": "0.5.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/parser@7.29.7", + "@babel/types@7.29.7", + "source-map-js@1.2.1" + ], + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==" + }, + { + "name": "make-dir", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "semver@7.8.5" + ], + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==" + }, + { + "name": "mdn-data", + "version": "2.27.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "CC0-1.0", + "dependencies": [], + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==" + }, + { + "name": "min-indent", + "version": "1.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" + }, + { + "name": "minimatch", + "version": "10.2.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "BlueOak-1.0.0", + "dependencies": [ + "brace-expansion@5.0.8" + ], + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==" + }, + { + "name": "minimist", + "version": "1.2.8", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + { + "name": "minipass", + "version": "7.1.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "BlueOak-1.0.0", + "dependencies": [], + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + { + "name": "ms", + "version": "2.1.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + { + "name": "msw", + "version": "2.15.0", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@inquirer/confirm@6.1.1", + "@mswjs/interceptors@0.41.9", + "@open-draft/deferred-promise@3.0.0", + "@types/statuses@2.0.6", + "cookie@1.1.1", + "graphql@16.14.2", + "headers-polyfill@5.0.1", + "is-node-process@1.2.0", + "outvariant@1.4.3", + "path-to-regexp@6.3.0", + "picocolors@1.1.1", + "rettime@0.11.11", + "statuses@2.0.2", + "strict-event-emitter@0.5.1", + "tough-cookie@6.0.2", + "type-fest@5.8.0", + "typescript@7.0.2", + "until-async@3.0.2", + "yargs@17.7.3" + ], + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==" + }, + { + "name": "mute-stream", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==" + }, + { + "name": "nanoid", + "version": "3.3.16", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==" + }, + { + "name": "natural-compare", + "version": "1.4.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + }, + { + "name": "node-releases", + "version": "2.0.51", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==" + }, + { + "name": "obug", + "version": "2.1.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==" + }, + { + "name": "open", + "version": "10.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "default-browser@5.5.0", + "define-lazy-prop@3.0.0", + "is-inside-container@1.0.0", + "wsl-utils@0.1.0" + ], + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==" + }, + { + "name": "optionator", + "version": "0.9.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "deep-is@0.1.4", + "fast-levenshtein@2.0.6", + "levn@0.4.1", + "prelude-ls@1.2.1", + "type-check@0.4.0", + "word-wrap@1.2.5" + ], + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==" + }, + { + "name": "outvariant", + "version": "1.4.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==" + }, + { + "name": "oxc-parser", + "version": "0.127.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@oxc-parser/binding-android-arm-eabi@0.127.0", + "@oxc-parser/binding-android-arm64@0.127.0", + "@oxc-parser/binding-darwin-arm64@0.127.0", + "@oxc-parser/binding-darwin-x64@0.127.0", + "@oxc-parser/binding-freebsd-x64@0.127.0", + "@oxc-parser/binding-linux-arm-gnueabihf@0.127.0", + "@oxc-parser/binding-linux-arm-musleabihf@0.127.0", + "@oxc-parser/binding-linux-arm64-gnu@0.127.0", + "@oxc-parser/binding-linux-arm64-musl@0.127.0", + "@oxc-parser/binding-linux-ppc64-gnu@0.127.0", + "@oxc-parser/binding-linux-riscv64-gnu@0.127.0", + "@oxc-parser/binding-linux-riscv64-musl@0.127.0", + "@oxc-parser/binding-linux-s390x-gnu@0.127.0", + "@oxc-parser/binding-linux-x64-gnu@0.127.0", + "@oxc-parser/binding-linux-x64-musl@0.127.0", + "@oxc-parser/binding-openharmony-arm64@0.127.0", + "@oxc-parser/binding-wasm32-wasi@0.127.0", + "@oxc-parser/binding-win32-arm64-msvc@0.127.0", + "@oxc-parser/binding-win32-ia32-msvc@0.127.0", + "@oxc-parser/binding-win32-x64-msvc@0.127.0", + "@oxc-project/types@0.127.0" + ], + "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==" + }, + { + "name": "oxc-resolver", + "version": "11.24.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@oxc-resolver/binding-android-arm-eabi@11.24.2", + "@oxc-resolver/binding-android-arm64@11.24.2", + "@oxc-resolver/binding-darwin-arm64@11.24.2", + "@oxc-resolver/binding-darwin-x64@11.24.2", + "@oxc-resolver/binding-freebsd-x64@11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf@11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu@11.24.2", + "@oxc-resolver/binding-linux-arm64-musl@11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu@11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu@11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl@11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu@11.24.2", + "@oxc-resolver/binding-linux-x64-gnu@11.24.2", + "@oxc-resolver/binding-linux-x64-musl@11.24.2", + "@oxc-resolver/binding-openharmony-arm64@11.24.2", + "@oxc-resolver/binding-wasm32-wasi@11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc@11.24.2", + "@oxc-resolver/binding-win32-x64-msvc@11.24.2" + ], + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==" + }, + { + "name": "p-limit", + "version": "3.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "yocto-queue@0.1.0" + ], + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==" + }, + { + "name": "p-locate", + "version": "5.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "p-limit@3.1.0" + ], + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==" + }, + { + "name": "parse5", + "version": "8.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "entities@8.0.0" + ], + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==" + }, + { + "name": "path-exists", + "version": "4.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + { + "name": "path-key", + "version": "3.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + { + "name": "path-parse", + "version": "1.0.7", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + { + "name": "path-scurry", + "version": "2.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "BlueOak-1.0.0", + "dependencies": [ + "lru-cache@11.5.2", + "minipass@7.1.3" + ], + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==" + }, + { + "name": "path-to-regexp", + "version": "6.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + { + "name": "pathe", + "version": "2.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + { + "name": "pathval", + "version": "2.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==" + }, + { + "name": "picocolors", + "version": "1.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + { + "name": "picomatch", + "version": "4.0.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==" + }, + { + "name": "playwright-core", + "version": "1.62.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==" + }, + { + "name": "playwright", + "version": "1.62.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "fsevents@2.3.2", + "playwright-core@1.62.0" + ], + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==" + }, + { + "name": "postcss", + "version": "8.5.22", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "nanoid@3.3.16", + "picocolors@1.1.1", + "source-map-js@1.2.1" + ], + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==" + }, + { + "name": "prelude-ls", + "version": "1.2.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + }, + { + "name": "pretty-format", + "version": "27.5.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "ansi-regex@5.0.1", + "ansi-styles@5.2.0", + "react-is@17.0.2" + ], + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==" + }, + { + "name": "prompts", + "version": "2.4.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "kleur@3.0.3", + "sisteransi@1.0.5" + ], + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==" + }, + { + "name": "punycode", + "version": "2.3.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + { + "name": "react-docgen-typescript", + "version": "2.4.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "typescript@7.0.2" + ], + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==" + }, + { + "name": "react-docgen", + "version": "8.0.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@babel/core@7.29.7", + "@babel/traverse@7.29.7", + "@babel/types@7.29.7", + "@types/babel__core@7.20.5", + "@types/babel__traverse@7.28.0", + "@types/doctrine@0.0.9", + "@types/resolve@1.20.6", + "doctrine@3.0.0", + "resolve@1.22.12", + "strip-indent@4.1.1" + ], + "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==" + }, + { + "name": "react-dom", + "version": "19.2.8", + "direct": true, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [ + "react@19.2.8", + "scheduler@0.27.0" + ], + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==" + }, + { + "name": "react-is", + "version": "17.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + { + "name": "react-router-dom", + "version": "7.18.1", + "direct": true, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [ + "react-dom@19.2.8", + "react-router@7.18.1", + "react@19.2.8" + ], + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==" + }, + { + "name": "react-router", + "version": "7.18.1", + "direct": false, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [ + "cookie@1.1.1", + "react-dom@19.2.8", + "react@19.2.8", + "set-cookie-parser@2.7.2" + ], + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==" + }, + { + "name": "react", + "version": "19.2.8", + "direct": true, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==" + }, + { + "name": "recast", + "version": "0.23.12", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "ast-types@0.16.1", + "esprima@4.0.1", + "source-map@0.6.1", + "tiny-invariant@1.3.3", + "tslib@2.8.1" + ], + "integrity": "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==" + }, + { + "name": "rechoir", + "version": "0.8.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "resolve@1.22.12" + ], + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==" + }, + { + "name": "redent", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "indent-string@4.0.0", + "strip-indent@3.0.0" + ], + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==" + }, + { + "name": "regexp-tree", + "version": "0.1.27", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==" + }, + { + "name": "require-directory", + "version": "2.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + { + "name": "require-from-string", + "version": "2.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, + { + "name": "resolve", + "version": "1.22.12", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "es-errors@1.3.0", + "is-core-module@2.16.2", + "path-parse@1.0.7", + "supports-preserve-symlinks-flag@1.0.0" + ], + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==" + }, + { + "name": "rettime", + "version": "0.11.11", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==" + }, + { + "name": "rolldown", + "version": "1.1.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@oxc-project/types@0.139.0", + "@rolldown/binding-android-arm64@1.1.5", + "@rolldown/binding-darwin-arm64@1.1.5", + "@rolldown/binding-darwin-x64@1.1.5", + "@rolldown/binding-freebsd-x64@1.1.5", + "@rolldown/binding-linux-arm-gnueabihf@1.1.5", + "@rolldown/binding-linux-arm64-gnu@1.1.5", + "@rolldown/binding-linux-arm64-musl@1.1.5", + "@rolldown/binding-linux-ppc64-gnu@1.1.5", + "@rolldown/binding-linux-s390x-gnu@1.1.5", + "@rolldown/binding-linux-x64-gnu@1.1.5", + "@rolldown/binding-linux-x64-musl@1.1.5", + "@rolldown/binding-openharmony-arm64@1.1.5", + "@rolldown/binding-wasm32-wasi@1.1.5", + "@rolldown/binding-win32-arm64-msvc@1.1.5", + "@rolldown/binding-win32-x64-msvc@1.1.5", + "@rolldown/pluginutils@1.0.1" + ], + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==" + }, + { + "name": "run-applescript", + "version": "7.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==" + }, + { + "name": "safe-regex", + "version": "2.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "regexp-tree@0.1.27" + ], + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==" + }, + { + "name": "saxes", + "version": "6.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [ + "xmlchars@2.2.0" + ], + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==" + }, + { + "name": "scheduler", + "version": "0.27.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + { + "name": "semver", + "version": "6.3.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + }, + { + "name": "semver", + "version": "7.8.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + { + "name": "set-cookie-parser", + "version": "2.7.2", + "direct": false, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + { + "name": "set-cookie-parser", + "version": "3.1.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==" + }, + { + "name": "shebang-command", + "version": "2.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "shebang-regex@3.0.0" + ], + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==" + }, + { + "name": "shebang-regex", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + { + "name": "siginfo", + "version": "2.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==" + }, + { + "name": "signal-exit", + "version": "4.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + }, + { + "name": "sisteransi", + "version": "1.0.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, + { + "name": "source-map-js", + "version": "1.2.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-3-Clause", + "dependencies": [], + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + { + "name": "source-map", + "version": "0.6.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-3-Clause", + "dependencies": [], + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + { + "name": "stackback", + "version": "0.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==" + }, + { + "name": "statuses", + "version": "2.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + }, + { + "name": "std-env", + "version": "4.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==" + }, + { + "name": "storybook", + "version": "10.5.4", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@storybook/global@5.0.0", + "@storybook/icons@2.1.0", + "@testing-library/dom@10.4.1", + "@testing-library/jest-dom@6.10.0", + "@testing-library/user-event@14.6.1", + "@types/react@19.2.8", + "@vitest/expect@3.2.4", + "@vitest/spy@3.2.4", + "@webcontainer/env@1.1.1", + "esbuild@0.28.1", + "jsonc-parser@3.3.1", + "open@10.2.0", + "oxc-parser@0.127.0", + "oxc-resolver@11.24.2", + "recast@0.23.12", + "semver@7.8.5", + "use-sync-external-store@1.6.0", + "ws@8.21.1" + ], + "integrity": "sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==" + }, + { + "name": "strict-event-emitter", + "version": "0.5.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==" + }, + { + "name": "string-width", + "version": "4.2.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "emoji-regex@8.0.0", + "is-fullwidth-code-point@3.0.0", + "strip-ansi@6.0.1" + ], + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==" + }, + { + "name": "strip-ansi", + "version": "6.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "ansi-regex@5.0.1" + ], + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==" + }, + { + "name": "strip-bom", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==" + }, + { + "name": "strip-indent", + "version": "3.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "min-indent@1.0.1" + ], + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==" + }, + { + "name": "strip-indent", + "version": "4.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==" + }, + { + "name": "supports-color", + "version": "7.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "has-flag@4.0.0" + ], + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==" + }, + { + "name": "supports-preserve-symlinks-flag", + "version": "1.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + { + "name": "symbol-tree", + "version": "3.2.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + { + "name": "tagged-tag", + "version": "1.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==" + }, + { + "name": "tailwindcss", + "version": "4.3.3", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==" + }, + { + "name": "tapable", + "version": "2.3.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==" + }, + { + "name": "tiny-invariant", + "version": "1.3.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + { + "name": "tinybench", + "version": "2.9.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" + }, + { + "name": "tinyexec", + "version": "1.2.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==" + }, + { + "name": "tinyglobby", + "version": "0.2.17", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "fdir@6.5.0", + "picomatch@4.0.5" + ], + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==" + }, + { + "name": "tinyrainbow", + "version": "2.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==" + }, + { + "name": "tinyrainbow", + "version": "3.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==" + }, + { + "name": "tinyspy", + "version": "4.0.4", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==" + }, + { + "name": "tldts-core", + "version": "7.4.9", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==" + }, + { + "name": "tldts", + "version": "7.4.9", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "tldts-core@7.4.9" + ], + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==" + }, + { + "name": "tough-cookie", + "version": "6.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-3-Clause", + "dependencies": [ + "tldts@7.4.9" + ], + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==" + }, + { + "name": "tr46", + "version": "6.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "punycode@2.3.1" + ], + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==" + }, + { + "name": "ts-dedent", + "version": "2.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==" + }, + { + "name": "tsconfig-paths-webpack-plugin", + "version": "4.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "chalk@4.1.2", + "enhanced-resolve@5.24.2", + "tapable@2.3.3", + "tsconfig-paths@4.2.0" + ], + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==" + }, + { + "name": "tsconfig-paths", + "version": "4.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "json5@2.2.3", + "minimist@1.2.8", + "strip-bom@3.0.0" + ], + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==" + }, + { + "name": "tslib", + "version": "2.8.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "0BSD", + "dependencies": [], + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + { + "name": "type-check", + "version": "0.4.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "prelude-ls@1.2.1" + ], + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==" + }, + { + "name": "type-fest", + "version": "5.8.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "(MIT OR CC0-1.0)", + "dependencies": [ + "tagged-tag@1.0.0" + ], + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==" + }, + { + "name": "typescript", + "version": "7.0.2", + "direct": true, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [ + "@typescript/typescript-aix-ppc64@7.0.2", + "@typescript/typescript-darwin-arm64@7.0.2", + "@typescript/typescript-darwin-x64@7.0.2", + "@typescript/typescript-freebsd-arm64@7.0.2", + "@typescript/typescript-freebsd-x64@7.0.2", + "@typescript/typescript-linux-arm64@7.0.2", + "@typescript/typescript-linux-arm@7.0.2", + "@typescript/typescript-linux-loong64@7.0.2", + "@typescript/typescript-linux-mips64el@7.0.2", + "@typescript/typescript-linux-ppc64@7.0.2", + "@typescript/typescript-linux-riscv64@7.0.2", + "@typescript/typescript-linux-s390x@7.0.2", + "@typescript/typescript-linux-x64@7.0.2", + "@typescript/typescript-netbsd-arm64@7.0.2", + "@typescript/typescript-netbsd-x64@7.0.2", + "@typescript/typescript-openbsd-arm64@7.0.2", + "@typescript/typescript-openbsd-x64@7.0.2", + "@typescript/typescript-sunos-x64@7.0.2", + "@typescript/typescript-win32-arm64@7.0.2", + "@typescript/typescript-win32-x64@7.0.2" + ], + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==" + }, + { + "name": "undici-types", + "version": "7.18.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + }, + { + "name": "undici", + "version": "7.28.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==" + }, + { + "name": "unplugin", + "version": "2.3.11", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@jridgewell/remapping@2.3.5", + "acorn@8.17.0", + "picomatch@4.0.5", + "webpack-virtual-modules@0.6.2" + ], + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==" + }, + { + "name": "until-async", + "version": "3.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==" + }, + { + "name": "update-browserslist-db", + "version": "1.2.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "browserslist@4.28.7", + "escalade@3.2.0", + "picocolors@1.1.1" + ], + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==" + }, + { + "name": "uri-js", + "version": "4.4.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [ + "punycode@2.3.1" + ], + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==" + }, + { + "name": "use-sync-external-store", + "version": "1.6.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "react@19.2.8" + ], + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==" + }, + { + "name": "vite", + "version": "8.1.5", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/node@24.13.3", + "esbuild@0.28.1", + "fsevents@2.3.3", + "jiti@2.7.0", + "lightningcss@1.33.0", + "picomatch@4.0.5", + "postcss@8.5.22", + "rolldown@1.1.5", + "tinyglobby@0.2.17" + ], + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==" + }, + { + "name": "vitest", + "version": "4.1.10", + "direct": true, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@types/node@24.13.3", + "@vitest/coverage-v8@4.1.10", + "@vitest/expect@4.1.10", + "@vitest/mocker@4.1.10", + "@vitest/pretty-format@4.1.10", + "@vitest/runner@4.1.10", + "@vitest/snapshot@4.1.10", + "@vitest/spy@4.1.10", + "@vitest/utils@4.1.10", + "es-module-lexer@2.3.1", + "expect-type@1.4.0", + "jsdom@29.1.1", + "magic-string@0.30.21", + "obug@2.1.4", + "pathe@2.0.3", + "picomatch@4.0.5", + "std-env@4.2.0", + "tinybench@2.9.0", + "tinyexec@1.2.4", + "tinyglobby@0.2.17", + "tinyrainbow@3.1.0", + "vite@8.1.5", + "why-is-node-running@2.3.0" + ], + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==" + }, + { + "name": "w3c-xmlserializer", + "version": "5.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "xml-name-validator@5.0.0" + ], + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==" + }, + { + "name": "watskeburt", + "version": "6.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==" + }, + { + "name": "webidl-conversions", + "version": "8.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "BSD-2-Clause", + "dependencies": [], + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==" + }, + { + "name": "webpack-virtual-modules", + "version": "0.6.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" + }, + { + "name": "whatwg-mimetype", + "version": "5.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==" + }, + { + "name": "whatwg-url", + "version": "16.0.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "@exodus/bytes@1.15.1", + "tr46@6.0.0", + "webidl-conversions@8.0.1" + ], + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==" + }, + { + "name": "which", + "version": "2.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [ + "isexe@2.0.0" + ], + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==" + }, + { + "name": "why-is-node-running", + "version": "2.3.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "siginfo@2.0.0", + "stackback@0.0.2" + ], + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==" + }, + { + "name": "word-wrap", + "version": "1.2.5", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" + }, + { + "name": "wrap-ansi", + "version": "7.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "ansi-styles@4.3.0", + "string-width@4.2.3", + "strip-ansi@6.0.1" + ], + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==" + }, + { + "name": "ws", + "version": "8.21.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==" + }, + { + "name": "wsl-utils", + "version": "0.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "is-wsl@3.1.1" + ], + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==" + }, + { + "name": "xml-name-validator", + "version": "5.0.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "Apache-2.0", + "dependencies": [], + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==" + }, + { + "name": "xmlchars", + "version": "2.2.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, + { + "name": "y18n", + "version": "5.0.8", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + { + "name": "yallist", + "version": "3.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + { + "name": "yargs-parser", + "version": "21.1.1", + "direct": false, + "scope": "development", + "optional": false, + "license": "ISC", + "dependencies": [], + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + { + "name": "yargs", + "version": "17.7.3", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "cliui@8.0.1", + "escalade@3.2.0", + "get-caller-file@2.0.5", + "require-directory@2.1.1", + "string-width@4.2.3", + "y18n@5.0.8", + "yargs-parser@21.1.1" + ], + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==" + }, + { + "name": "yocto-queue", + "version": "0.1.0", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + { + "name": "zod-validation-error", + "version": "4.0.2", + "direct": false, + "scope": "development", + "optional": false, + "license": "MIT", + "dependencies": [ + "zod@4.4.3" + ], + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==" + }, + { + "name": "zod", + "version": "4.4.3", + "direct": true, + "scope": "production", + "optional": false, + "license": "MIT", + "dependencies": [], + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" + } + ] +} diff --git a/config/security/dependency-change-evidence.json b/config/security/dependency-change-evidence.json new file mode 100644 index 0000000..62d2c3c --- /dev/null +++ b/config/security/dependency-change-evidence.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "changes": [] +} diff --git a/config/security/dependency-policy.json b/config/security/dependency-policy.json new file mode 100644 index 0000000..acd24e4 --- /dev/null +++ b/config/security/dependency-policy.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "allowedLicenses": [ + "(MIT OR CC0-1.0)", + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BlueOak-1.0.0", + "CC-BY-4.0", + "CC0-1.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0" + ], + "deniedLicensePatterns": [ + "(^|\\s)AGPL", + "(^|\\s)GPL", + "SSPL", + "BUSL" + ], + "unknownLicensePolicy": "allow-only-unmaterialized-platform-optional" +} diff --git a/config/security/secret-scan-policy.json b/config/security/secret-scan-policy.json new file mode 100644 index 0000000..adeeabe --- /dev/null +++ b/config/security/secret-scan-policy.json @@ -0,0 +1,52 @@ +{ + "schemaVersion": 1, + "trackedRoots": [ + "src", + "recipes", + "scripts", + "tests", + "config", + "public", + "schemas", + ".storybook", + ".gitea/workflows/quality-gates.yml", + ".dependency-cruiser.json", + ".nvmrc", + ".npmrc", + "eslint.config.ts", + "index.html", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.base.json", + "tsconfig.node.json", + "tsconfig.recipes.json", + "tsconfig.service-worker.json", + "tsconfig.test.json", + "tsconfig.web-worker.json", + "vite.config.ts", + "vite.service-worker.config.ts", + "vitest.config.ts", + "playwright.config.ts", + "playwright.capabilities.config.ts", + "playwright.dev.config.ts", + "playwright.storybook.config.ts", + "playwright.visual.config.ts" + ], + "generatedRoots": ["dist", "artifacts/release"], + "optionalRoots": ["artifacts/release"], + "excludedPaths": [ + "tests/fixtures/security/secret-detection/forbidden" + ], + "allowlist": [ + { + "path": "tests/fixtures/security/secret-detection/allowed/test-credentials.ts", + "ruleId": "assigned-secret", + "owner": "frontend-platform", + "reason": "Synthetic credential verifies the scoped test-only allowlist.", + "expiresAt": "2027-07-26T00:00:00.000Z" + } + ] +} diff --git a/config/security/vulnerability-exceptions.json b/config/security/vulnerability-exceptions.json new file mode 100644 index 0000000..f226765 --- /dev/null +++ b/config/security/vulnerability-exceptions.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "exceptions": [] +} diff --git a/config/security/vulnerability-policy.json b/config/security/vulnerability-policy.json new file mode 100644 index 0000000..6a6a4c7 --- /dev/null +++ b/config/security/vulnerability-policy.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "providerMode": "external-file", + "inputEnvironment": "VULNERABILITY_REPORT_PATH", + "blockAtSeverity": "high", + "allowedSeverities": ["unknown", "low", "moderate", "high", "critical"], + "missingProviderStatus": "FAIL_UNVERIFIED" +} diff --git a/config/testing/risk-coverage.json b/config/testing/risk-coverage.json new file mode 100644 index 0000000..e2ef978 --- /dev/null +++ b/config/testing/risk-coverage.json @@ -0,0 +1,130 @@ +{ + "schemaVersion": 2, + "repositoryBaseline": 285, + "generatedPaths": [], + "summary": { + "lines": 75, + "statements": 73, + "functions": 80, + "branches": 68 + }, + "criticalModules": [ + { + "path": "src/adapters/http/bounded-body-reader.ts", + "owner": "http-runtime", + "minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 } + }, + { + "path": "src/adapters/http/bounded-json.ts", + "owner": "http-runtime", + "minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 } + }, + { + "path": "src/adapters/http/http-execution-v3.ts", + "owner": "http-runtime", + "minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 } + }, + { + "path": "src/adapters/http/request-builder.ts", + "owner": "http-runtime", + "minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 } + }, + { + "path": "src/adapters/http/retry-policy.ts", + "owner": "http-runtime", + "minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 } + }, + { + "path": "src/adapters/query-cache/server-state-scope-runtime.ts", + "owner": "server-state-runtime", + "minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 } + }, + { + "path": "src/adapters/service-worker/service-worker-lifecycle.ts", + "owner": "service-worker-runtime", + "minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 } + }, + { + "path": "src/adapters/storage/browser-storage-adapter.ts", + "owner": "storage-runtime", + "minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 } + }, + { + "path": "src/adapters/telemetry/best-effort-telemetry.ts", + "owner": "telemetry-runtime", + "minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 } + }, + { + "path": "src/application/create-application.ts", + "owner": "application-runtime", + "minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 } + }, + { + "path": "src/application/policies/compatibility.ts", + "owner": "application-policy", + "minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 } + }, + { + "path": "src/application/policies/performance-budgets.ts", + "owner": "application-policy", + "minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 } + }, + { + "path": "src/application/policies/promotion-readiness.ts", + "owner": "release-runtime", + "minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 } + }, + { + "path": "src/application/use-cases/decide-chunk-recovery.ts", + "owner": "application-runtime", + "minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 } + }, + { + "path": "src/bootstrap/load-release-manifest.ts", + "owner": "release-runtime", + "minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 } + }, + { + "path": "src/bootstrap/read-bounded-boot-json.ts", + "owner": "bootstrap-runtime", + "minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 } + }, + { + "path": "src/contracts/diagnostics.ts", + "owner": "diagnostics-contracts", + "minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 } + }, + { + "path": "src/features/reference-feature/adapters/reference-http-gateway.ts", + "owner": "reference-feature", + "minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 } + }, + { + "path": "src/presentation/adapters/query/application-query.ts", + "owner": "presentation-runtime", + "minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 } + } + ], + "highRiskPaths": [ + "src/adapters/http/bounded-body-reader.ts", + "src/adapters/http/bounded-json.ts", + "src/adapters/http/http-execution-v3.ts", + "src/adapters/http/request-builder.ts", + "src/adapters/http/retry-policy.ts", + "src/adapters/query-cache/server-state-scope-runtime.ts", + "src/adapters/service-worker/service-worker-lifecycle.ts", + "src/adapters/storage/browser-storage-adapter.ts", + "src/adapters/telemetry/best-effort-telemetry.ts", + "src/application/create-application.ts", + "src/application/policies/compatibility.ts", + "src/application/policies/performance-budgets.ts", + "src/application/policies/promotion-readiness.ts", + "src/application/use-cases/decide-chunk-recovery.ts", + "src/bootstrap/load-release-manifest.ts", + "src/bootstrap/read-bounded-boot-json.ts", + "src/contracts/diagnostics.ts", + "src/features/reference-feature/adapters/reference-http-gateway.ts", + "src/presentation/adapters/query/application-query.ts" + ], + "waivers": [] +} diff --git a/config/testing/test-evidence.json b/config/testing/test-evidence.json new file mode 100644 index 0000000..afa519f --- /dev/null +++ b/config/testing/test-evidence.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 2, + "scenarioCatalogs": [ + { + "owner": "reference-feature", + "path": "tests/mocks/scenarios/catalog.ts", + "expectationExport": "HTTP_SCENARIO_EXPECTATIONS", + "receiptPath": "artifacts/tests/http-scenario-executions.json", + "receiptSchemaVersion": 1 + } + ], + "sourceContracts": [ + { + "owner": "reference-feature", + "path": "tests/mocks/handlers/reference-resources.ts", + "requiredTokens": [ + "assertOperationScenario", + "../scenarios/catalog.ts" + ] + } + ] +} diff --git a/docs/accessibility/manual-checklist.md b/docs/accessibility/manual-checklist.md new file mode 100644 index 0000000..6bfac32 --- /dev/null +++ b/docs/accessibility/manual-checklist.md @@ -0,0 +1,51 @@ +# Manual accessibility review checklist + +Automated axe checks do not establish WCAG conformance. A human reviewer must +review all six route records in `artifacts/tests/a11y-manual/` against one +release candidate and sign them. The required scope is derived from the route +registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, +`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the +gate rejects blank identity/timestamp/signature fields, pending verdicts, +mismatched release IDs, or missing routes. + +Allowed item verdicts: + +- `pass` +- `not-applicable ()` + +Required record: + +```text +Status: reviewed +Route ID: APP_HOME +Release ID: +Reviewer: +Reviewed at: +Signature: +Attestation: accepted +M1 Keyboard: pass +M2 Visible focus: pass +M3 Route focus: pass +M4 Modal focus: not-applicable (no modal on this route) +M5 Error association: not-applicable (no form error on this route) +M6 Color signal: pass +M7 Reduced motion: pass +Screen reader: pass +Notes: +``` + +The reviewer must verify: + +- M1: every action works without a pointing device +- M2: every focused element has a visible indicator +- M3: route transitions move focus to a deterministic target +- M4: modal focus is trapped and restored, when a modal exists +- M5: errors are programmatically associated with their controls, when present +- M6: state never relies on color alone +- M7: non-essential motion is suppressed with reduced-motion preference +- Screen reader: headings, live regions, errors, and actions are announced once + +`EXAMPLES_UI` requires real M4 modal and M5 field-error review; those items must +not be marked not-applicable on that route. Passing automated evidence means +only that tested pages had no critical or serious axe findings under the +recorded Chromium, Firefox, and WebKit runs. diff --git a/docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md b/docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md new file mode 100644 index 0000000..cd0c5d7 --- /dev/null +++ b/docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md @@ -0,0 +1,5957 @@ +# 프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계 + +- 작성일: 2026-07-30 +- 상태: 구현 결정 정본 v1 +- 대상 저장소: `clean-architecture-frontend-template` +- 기준 저장소 스냅샷: 제공된 Repomix 전체 저장소 표현 +- 상위 설계: + - `2026-07-30-frontend-production-capability-repository-aligned-deep-design.md` +- 계약 정본: + - 별도 API·이벤트 계약 관리 저장소에서 발행한 immutable TypeScript contract package +- 기준 기술: + - Node.js 24 major + - pnpm 11 + - TypeScript strict / `allowJs: false` + - React 19 + - React Router 7 data mode + - TanStack Query 5 + - Zod 4 + - Vite 8 +- 문서 규칙: + - **MUST**: 구현자가 변경할 수 없는 기본 결정 + - **MUST NOT**: 존재하면 설계 위반 + - **MAY**: 명시적으로 선택된 capability에만 허용 + - 본 문서의 exact literal, 상태, 상한, 순서와 source path는 예시가 아니라 구현 정본이다. + +--- + +## 목차 + +- [0. 문서 권한과 제외 범위](#0-문서-권한과-제외-범위) +- [1. 현재 저장소 기준선](#1-현재-저장소-기준선) +- [2. 구현 결정 폐쇄 표](#2-구현-결정-폐쇄-표) +- [3. 목표 런타임 구조와 lifecycle](#3-목표-런타임-구조와-lifecycle) +- [4. 외부 계약 package 소비 경계](#4-외부-계약-package-소비-경계) +- [5. Contract Set과 release coherence](#5-contract-set과-release-coherence) +- [6. Runtime Config와 boot](#6-runtime-config와-boot) +- [7. Fetch HTTP execution runtime](#7-fetch-http-execution-runtime) +- [8. Retry·cancellation·mutation effect certainty](#8-retrycancellationmutation-effect-certainty) +- [9. Router](#9-router) +- [10. Query lifecycle과 cache](#10-query-lifecycle과-cache) +- [11. Mutation lifecycle](#11-mutation-lifecycle) +- [12. Cross-context invalidation](#12-cross-context-invalidation) +- [13. Realtime product composition](#13-realtime-product-composition) +- [14. Connection·Reconnect·Resume](#14-connectionreconnectresume) +- [15. Event validation과 subscription cleanup](#15-event-validation과-subscription-cleanup) +- [16. Generic Web Worker](#16-generic-web-worker) +- [17. Service Worker lifecycle](#17-service-worker-lifecycle) +- [18. Cache Storage와 fetch policy](#18-cache-storage와-fetch-policy) +- [19. Offline Command와 Background Sync](#19-offline-command와-background-sync) +- [20. 공통 start·stop·dispose](#20-공통-startstopdispose) +- [21. Security와 privacy](#21-security와-privacy) +- [22. Runtime observability](#22-runtime-observability) +- [23. 구현 검증](#23-구현-검증) +- [24. Source 배치와 migration](#24-source-배치와-migration) +- [25. 구현 순서와 stop condition](#25-구현-순서와-stop-condition) +- [26. 완료 기준](#26-완료-기준) +- [27. 위험 등록부](#27-위험-등록부) +- [28. Normative decision ledger](#28-normative-decision-ledger) +- [부록 A. Numeric constants](#부록-a-numeric-constants) +- [부록 B. 금지 구현 목록](#부록-b-금지-구현-목록) +- [부록 C. 플랫폼 참고 기준](#부록-c-플랫폼-참고-기준) + +--- + +## 0. 문서 권한과 제외 범위 + +### 0.1 목적 + +이 문서는 다음 프론트엔드 런타임 capability를 현재 저장소 구조에 맞춰 구현할 때 개발자가 다시 선택하거나 질문할 지점을 제거한다. + +```text +Fetch HTTP Client +Runtime Config +Router +Query / Mutation +Connection / Reconnect / Resume +Event Validation +Subscription Cleanup +Web Worker +Service Worker +Background Sync +``` + +상위 저장소 정합형 설계가 방향과 책임을 고정했다면, 본 문서는 다음을 추가로 고정한다. + +- exact source path; +- exact public type shape; +- exact lifecycle state; +- exact startup/shutdown order; +- exact timeout, queue, payload와 retry ceiling; +- exact optional capability selection과 removal 절차; +- external contract package를 저장소 runtime에 연결하는 단일 경로; +- browser lifecycle과 session/account transition ordering; +- failure union과 mutation uncertainty 처리; +- 구현 중 허용되지 않는 fallback; +- 구현 완료를 판정할 deterministic behavior case. + +### 0.2 본 문서가 대체하는 범위 + +상위 설계의 다음 절은 본 문서가 더 구체적인 정본이다. + +| 상위 범위 | 본 문서 정본 | +| --- | --- | +| Runtime Config와 boot coherence | §5–§6 | +| Fetch HTTP Client | §4, §7–§8 | +| Router | §9 | +| Query와 Mutation | §10–§12 | +| Realtime 공통 경계 | §13–§15 | +| Web Worker | §16 | +| Service Worker | §17–§18 | +| Offline Command와 Background Sync | §19 | +| optional capability 조립 | §3, §17.3, §24 | +| lifecycle·security·observability | §20–§22 | +| source 배치와 구현 순서 | §24–§25 | + +상위 설계와 본 문서가 충돌하면 위 범위에서는 본 문서를 따른다. + +### 0.3 API·이벤트 계약 관련 명시적 제외 + +다음은 별도 API·이벤트 계약 관리 저장소가 소유한다. 본 문서에서 다시 정의하거나 frontend local schema로 복제하지 않는다. + +```text +OpenAPI / AsyncAPI 원문 +operation method/path/status/media semantics +Problem Details type/code/extensions +pagination cursor wire shape +idempotency server semantics와 retention +event type/envelope/payload schema +replay/cursor/snapshot server protocol +provider compatibility와 deprecation/sunset +backend controller/event publisher conformance +contract package 생성·발행·서명·provenance +``` + +본 저장소가 소유하는 것은 다음뿐이다. + +```text +immutable TypeScript contract package pin +package manifest/digest 검증 +frontend-owned normalized descriptor interface +runtime validator 호출 +wire DTO -> application value mapper +HTTP/Query/Realtime runtime behavior +contractSet와 frontend release coherence +unknown/future contract input의 safe failure +``` + +외부 package의 의미가 부족하거나 서로 모순되면 frontend가 임의 의미를 보충하지 않는다. 해당 feature contribution을 composition하지 않고 `CONTRACT_CONTRIBUTION_INVALID`로 종료한다. + +### 0.4 CI 명시적 비범위 + +다음은 별도 CI/CD 플랫폼이 소유하며 본 문서에서 설계하지 않는다. + +- workflow YAML; +- merge/release stage 구성; +- gate ID와 promotion formula; +- artifact retention service; +- package publication identity; +- vulnerability/signing/SBOM pipeline; +- deployment/canary controller; +- CI evidence aggregation. + +§23은 코드가 만족해야 할 **행동 검증 케이스**만 정의한다. 어느 pipeline에서 실행할지는 정의하지 않는다. + +### 0.5 정본 우선순위 + +충돌 시 다음 순서를 사용한다. + +1. 현재 저장소 source와 실행 가능한 test가 증명하는 사실; +2. 별도 계약 저장소에서 발행한 exact immutable package; +3. 본 문서의 MUST/MUST NOT와 decision ledger; +4. 상위 저장소 정합형 설계; +5. 기존 일반 greenfield 설계; +6. 예시 코드와 설명 문구. + +예시가 public type과 다르면 public type이 정본이다. 두 설정이 같은 의미를 서로 다르게 지정하면 precedence를 만들지 않고 startup을 실패시킨다. + +--- + +## 1. 현재 저장소 기준선 + +### 1.1 물리 구조 + +현재 저장소의 canonical source root는 다음과 같다. + +```text +src/ + contracts/ # 저장소 내부 registry와 provider-neutral runtime contract + application/ # input/output port, use case, policy, view model + adapters/ # HTTP, query cache, storage, realtime, web-push 등 concrete adapter + presentation/ # React inbound adapter, Router, Query hook, UI + bootstrap/ # 유일한 composition root와 boot + features/ # 제거 가능한 vertical feature +``` + +다음 신규 parallel root는 만들지 않는다. + +```text +src/platform +src/service-worker +src/workers +src/infrastructure +src/api +src/shared/api +``` + +### 1.2 계층 방향 + +현재 `config/architecture/layers.json`의 방향을 유지한다. + +```text +domain -> domain only +application -> application, domain, contracts +presentation -> presentation, application, domain, contracts +adapters -> adapters, application, domain, contracts +bootstrap -> src 전체 +``` + +추가 불변조건: + +```text +presentation MUST NOT import adapters +application MUST NOT import React, Router, TanStack Query, browser API +adapters MUST NOT import presentation/bootstrap +feature presentation MUST call application input or approved inbound bridge +bootstrap alone selects concrete runtime +``` + +### 1.3 현재 구현을 재사용하는 항목 + +다음 source를 제거하거나 동일 기능으로 다시 만들지 않는다. + +| Capability | 현재 source | 판정 | +| --- | --- | --- | +| Runtime Config schema/load | `src/bootstrap/runtime-config-schema.ts`, `load-runtime-config.ts` | 보강 | +| Release manifest/load | `src/bootstrap/load-release-manifest.ts` | V2 migration | +| composition root | `src/bootstrap/create-runtime-composition.ts`, `composition-root.ts` | 확장 | +| bounded HTTP response | `src/adapters/http/bounded-json.ts` | 공통 bounded body reader로 확장 | +| Fetch client | `src/adapters/http/client.ts` | execution V3 보강 | +| request builder | `src/adapters/http/request-builder.ts` | package descriptor bridge 연결 | +| REST registry/profile | `src/contracts/api-operations.ts`, `rest-profiles.ts` | external contribution normalization으로 migration | +| Query adapter | `src/presentation/adapters/query/application-query.ts` | 유지·보강 | +| Query cache coordinator | `src/adapters/query-cache/*` | scope/invalidation 보강 | +| Router runtime | `src/presentation/routes/*` | 유지 | +| realtime registry/runtime | `src/contracts/realtime-*`, `src/adapters/realtime/*` | 제품 조립만 추가 | +| Web Push worker handler | `src/adapters/web-push/service-worker-runtime.ts` | 단일 SW entry handler로 재사용 | +| IndexedDB reference runtime | `src/adapters/storage/indexeddb/*` | offline dataset backend로 재사용 | +| OPFS Worker | `src/adapters/storage/opfs/*` | generic CPU Worker와 분리 유지 | +| Cache Storage reference | `src/adapters/cache-storage/*` | static asset handler에서 재사용 | + +### 1.4 현재 삭제하지 않는 compatibility surface + +다음 public/runtime surface는 migration 동안 유지한다. + +- existing reference feature route와 local fixture; +- current `ApiOperationV2` reader; +- current runtime config V1 reader; +- current release manifest V1 parser; +- current query key and invalidation contracts; +- current RT-01~RT-04 common runtime; +- current browser file/storage reference runtime. + +새 writer가 V2를 사용하도록 먼저 전환한 뒤 V1 reader를 제거한다. 같은 release에서 source shape, manifest shape와 runtime behavior를 동시에 big-bang 교체하지 않는다. + +### 1.5 현재 상태 taxonomy + +이 문서는 저장소 기존 상태 literal을 유지한다. + +```text +COMPOSED +AVAILABLE_NOT_COMPOSED +DESIGNED_NOT_IMPLEMENTED +NOT_SELECTED +PLATFORM_LIMITED +``` + +현재 주요 판정: + +| Capability | 현재 | 본 문서 목표 | +| --- | --- | --- | +| Runtime Config | `COMPOSED` V1 | V2 bounded boot | +| Release coherence | `COMPOSED` scalar contract version | contractSet V2 | +| HTTP reference path | `COMPOSED` | execution V3 | +| Router | `COMPOSED` | ownership closed | +| Query memory cache | `COMPOSED` | scope/result/invalidation closed | +| Query persistence | `NOT_SELECTED` | 계속 disabled | +| Realtime common runtime | `AVAILABLE_NOT_COMPOSED` | 그대로 재사용 | +| Product realtime | `NOT_SELECTED` | explicit contribution 필요 | +| Generic CPU Worker | `NOT_SELECTED` | opt-in runtime | +| OPFS Worker | `AVAILABLE_NOT_COMPOSED` | 별도 유지 | +| Cache Storage | `AVAILABLE_NOT_COMPOSED` | static asset only | +| PWA Service Worker lifecycle | `DESIGNED_NOT_IMPLEMENTED` | reference runtime | +| Offline command queue | `NOT_SELECTED` | opt-in reference runtime | +| Background Sync guarantee | `PLATFORM_LIMITED` | wake-only enhancement | + +--- + +## 2. 구현 결정 폐쇄 표 + +다음 표의 선택은 구현 중 다시 묻지 않는다. + +| 질문 | 결정 | +| --- | --- | +| capability별 새 `src/platform/*`를 만드는가 | 아니다. 기존 `contracts/application/adapters/presentation/bootstrap/features`에 배치한다. | +| API·이벤트 schema를 frontend에 복사하는가 | 아니다. exact immutable contract package만 소비한다. | +| deployed OpenAPI/AsyncAPI를 runtime에 읽는가 | 아니다. build dependency만 사용한다. | +| `API_CONTRACT_VERSION` scalar를 유지하는가 | 제거한다. Release Manifest V2 `contractSet`으로 교체한다. | +| Runtime Config가 optional feature를 켤 수 있는가 | 아니다. static installed capability를 끌 수만 있다. | +| boot JSON을 `response.json()`으로 읽는가 | 아니다. streaming byte bound 후 UTF-8/JSON parse한다. | +| HTTP retry owner | HTTP transport 하나다. Query/Mutation retry는 false다. | +| redirect | 모든 API/boot request에서 reject한다. | +| mutation timeout/abort를 실패로 단정하는가 | 아니다. send 이후는 `MAYBE_APPLIED`다. | +| Router loader가 server fetch/cache를 소유하는가 | 아니다. URL parse/guard/lazy lifecycle만 소유한다. | +| QueryClient 수 | application runtime당 하나다. | +| Query persistence | 기본 비활성이다. Web Storage와 IndexedDB에 query payload를 넣지 않는다. | +| query result size 추정 | 모든 definition의 required `measureResult`만 사용한다. generic fallback은 없다. | +| cross-tab data 전송 | 금지한다. opaque invalidation topic만 전송한다. | +| realtime transport 자동 fallback | 금지한다. registration이 explicit fallback을 선택한다. | +| connection open과 resume success | 별도 상태다. `OPEN_UNRESUMED`에서 event를 적용하지 않는다. | +| event checkpoint commit | application effect 성공 이후다. | +| component가 raw socket을 소유하는가 | 아니다. ref-counted subscription lease만 소유한다. | +| generic Worker 기본 설치 | 아니다. profiling과 static contribution이 있어야 한다. | +| Worker가 network/auth를 소유하는가 | 아니다. CPU task만 수행한다. | +| PWA와 Web Push worker registration을 나누는가 | 아니다. 동일 scope에 physical worker 하나다. | +| `skipWaiting()` | client drain handshake 성공 후에만 호출한다. | +| `clients.claim()` | baseline에서 사용하지 않는다. | +| navigation offline shell | 제공하지 않는다. navigation은 network-only다. | +| API response Cache Storage | 금지한다. verified static asset만 cache한다. | +| Service Worker 등록 시점 | Runtime Config/release 검증과 첫 committed render 이후다. | +| Background Sync가 command를 전송하는가 | baseline에서는 아니다. foreground drain wake-up만 기록한다. | +| offline sender 수 | origin/account partition당 하나다. | +| `SENDING` 중 crash | `PENDING`으로 되돌리지 않고 `EFFECT_UNKNOWN`이다. | +| 일반 disabled capability | listener/DB/worker/timer를 만들지 않는다. | +| persisted Service Worker disable | 새 register 없이 exact-owned registration만 unregister하고 cache를 보존한다. | +| Service Worker source 제거 | `ACTIVE -> REMOVE_REGISTRATION -> PURGE_OWNED_RESOURCES -> null` 순서다. | +| lifecycle correctness를 unload에 의존하는가 | 아니다. transaction, TTL, recovery로 닫는다. | + +--- + +## 3. 목표 런타임 구조와 lifecycle + +### 3.1 전체 흐름 + +```text +Vite embedded build identity + + installed contract contributions + + installed runtime capabilities + | + v +bounded Runtime Config V2 + | + v +bounded Release Manifest V2 + contractSet check + | + v +base composition + HTTP / auth bridge / application / QueryClient / Router + | + v +React first committed render + | + v +post-mount optional runtime start + offline foreground observer + realtime hosts + lazy Web Worker registry + Service Worker active/cleanup controller +``` + +### 3.2 boot state machine + +```text +UNINITIALIZED + -> LOADING_RUNTIME_CONFIG + -> VALIDATING_RUNTIME_CONFIG + -> LOADING_RELEASE_MANIFEST + -> VALIDATING_RELEASE_COHERENCE + -> COMPOSING_BASE + -> MOUNTING + -> BASE_READY + -> STARTING_OPTIONAL + -> READY +``` + +terminal/degraded state: + +```text +BOOT_FAILED_SAFE +BASE_READY_OPTIONAL_DEGRADED +DISPOSED +``` + +`BOOT_FAILED_SAFE`에서는 product Router와 Query provider를 mount하지 않는다. safe boot shell만 render한다. + +### 3.3 composition result + +```ts +export interface RuntimeComposition { + readonly application: ApplicationApi; + readonly queryClient: QueryClient; + readonly router: Router; + readonly scopeRuntime: ServerStateScopeRuntime; + readonly optional: OptionalRuntimeHost; + dispose(): Promise; +} +``` + +feature page에 다음을 노출하지 않는다. + +- raw Fetch client; +- endpoint URL; +- auth credential attacher; +- browser storage; +- QueryClient; +- WebSocket/EventSource; +- Worker; +- ServiceWorkerRegistration; +- IndexedDB transaction. + +### 3.4 optional runtime host + +```ts +export interface OptionalRuntimeHost { + readonly realtime: RealtimeRuntimeHost | null; + readonly webWorkers: WebWorkerRuntimeHost | null; + readonly serviceWorker: ServiceWorkerRuntimeHost | null; + readonly offlineCommands: OfflineCommandRuntimeHost | null; + startAfterMount(): Promise; + stop(): Promise; +} +``` + +start order: + +```text +1. offline foreground browser lifecycle observer +2. realtime runtime +3. Web Worker prewarm 없음 +4. Service Worker active/cleanup controller +``` + +stop order는 역순이다. + +### 3.5 static capability selection SSOT + +```text +src/features/installed-runtime-capabilities.ts +``` + +```ts +export interface InstalledRuntimeCapabilities { + readonly realtime: readonly InstalledRealtimeContribution[]; + readonly webWorkers: readonly InstalledWebWorkerContribution[]; + readonly serviceWorker: InstalledServiceWorkerSelection | null; + readonly offlineCommands: InstalledOfflineCommandContribution | null; +} + +export type RuntimeCapabilityOverride = "DEFAULT" | "DISABLED"; + +export type InstalledServiceWorkerSelection = + | Readonly<{ + mode: "ACTIVE"; + scriptPath: "service-worker.js"; + handlers: readonly ServiceWorkerHandlerId[]; + }> + | Readonly<{ + mode: "REMOVE_REGISTRATION"; + scriptPath: "service-worker.js"; + }> + | Readonly<{ + mode: "PURGE_OWNED_RESOURCES"; + scriptPath: "service-worker.js"; + }>; + +export type ServiceWorkerHandlerId = + | "WEB_PUSH" + | "PWA_STATIC_ASSETS" + | "OFFLINE_SYNC_WAKEUP"; +``` + +규칙: + +- source contribution이 설치 capability를 결정한다; +- Runtime Config는 `DEFAULT | DISABLED`만 제공한다; +- config가 source에 없는 capability를 켤 수 없다; +- dynamic import path를 config 문자열에서 만들지 않는다; +- contribution array와 object는 composition 시 deep snapshot/freeze한다; +- duplicate ID와 contradictory selection은 mount 전 실패한다. + +### 3.6 zero-side-effect 원칙과 persistent 예외 + +realtime/Web Worker/offline command가 unselected 또는 disabled이면: + +```text +factory call 0 +window listener 0 +timer 0 +network 0 +IndexedDB open 0 +Worker creation 0 +``` + +Service Worker는 browser에 persistent registration이 남을 수 있으므로 다음 예외만 허용한다. + +```text +static ACTIVE + runtime DISABLED + -> new register 0 + -> exact owned registration lookup 1 + -> owned registration unregister at most 1 + -> owned cache delete 0 +``` + +이 예외를 다른 capability의 hidden cleanup side effect 근거로 확대하지 않는다. + +--- + +## 4. 외부 계약 package 소비 경계 + +### 4.1 단일 import 위치 + +service별 generated package import는 다음 파일 한 곳에서만 허용한다. + +```text +src/features//contracts/-contract-contribution.ts +``` + +예: + +```text +src/features/worklog/ + contracts/ + worklog-service-contract-contribution.ts + worklog-mapper.ts + application/ + adapters/ + worklog-http-gateway.ts + presentation/ +``` + +금지 import: + +```text +src/domain/** -> @org-contracts/* +src/application/** -> @org-contracts/* +src/presentation/** -> @org-contracts/* +src/adapters/http/** -> service-specific package +src/bootstrap/** -> service-specific package direct import +``` + +bootstrap은 normalized feature contribution만 수집한다. + +### 4.2 normalized contribution + +```ts +export interface InstalledContractContribution { + readonly featureId: string; + readonly source: ContractContributionSource; + readonly http: readonly InstalledHttpContract[]; + readonly events: readonly InstalledEventContract[]; +} + +export type ContractContributionSource = + | Readonly<{ + kind: "EXTERNAL_PACKAGE"; + package: InstalledContractPackageIdentity; + }> + | Readonly<{ + kind: "TEMPLATE_FIXTURE"; + fixtureId: "REFERENCE_FEATURE_V1"; + revision: 1; + }>; + +export interface InstalledContractPackageIdentity { + readonly packageId: string; + readonly version: string; + readonly digest: `sha256:${string}`; + readonly runtimeProtocolVersion: 1; + readonly sourceRevision: string; +} +``` + +frontend interface는 package의 business model을 소유하지 않는다. descriptor와 runtime validator reference만 보관한다. + +### 4.3 HTTP contribution + +```ts +export interface InstalledHttpContract { + readonly contract: Readonly<{ + operationId: string; + method: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE"; + pathTemplate: string; + inputValidator: RuntimeValidator; + outputValidator: RuntimeValidator; + problemValidator: RuntimeValidator; + acceptedStatuses: readonly number[]; + emptyBodyStatuses: readonly number[]; + retrySemantics: "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER"; + requestBody: "NONE" | "JSON"; + responseBody: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE"; + commandRecovery: CommandRecoveryDescriptor | null; + commandEffect: CommandEffectDescriptor | null; + }>; + readonly frontend: HttpExecutionPolicy; +} + +export interface HttpExecutionPolicy { + readonly policyId: string; + readonly requestByteLimit: number; + readonly responseByteLimit: number; + readonly totalDeadlineMs: number; + readonly retryBudget: 0 | 1 | 2; + readonly authProfileId: string; + readonly diagnosticsOperation: string; +} +``` + +`contract`는 package가 제공한다. frontend가 method/path/status/retry/effect 의미를 다시 적지 않는다. + +`frontend`만 이 저장소가 소유한다. 값은 §7의 hard ceiling 안에 있어야 한다. + +### 4.4 command recovery descriptor + +frontend는 server semantics를 새로 설계하지 않고 package가 제공하는 recovery capability만 소비한다. + +```ts +export type CommandRecoveryDescriptor = Readonly<{ + mode: "IDEMPOTENCY_REPLAY" | "INSPECT_OPERATION"; + operationIdentityField: string; + inspectOperationId?: string; +}>; + +export interface CommandEffectDescriptor { + readonly successEffect: "APPLIED_CONFIRMED"; + classifyProblem(input: Readonly<{ + status: number; + problem: Problem; + }>): "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED"; +} +``` + +규칙: + +- query/read operation은 `commandRecovery=null`, `commandEffect=null`이다; +- command operation은 `commandEffect`가 필수다; +- command operation에 `retrySemantics=KEYED`이면 `commandRecovery`도 필수다; +- descriptor가 없거나 invalid이면 automatic retry와 durable queue가 모두 금지된다; +- frontend는 inspect request/response wire shape나 problem effect meaning을 작성하지 않는다; +- `classifyProblem`은 package가 제공한 pure bounded classifier다; +- classifier가 throw/unknown을 반환하면 effect는 fail-safe `MAYBE_APPLIED`이고 contract runtime failure를 기록한다; +- feature gateway가 package의 inspect operation을 application-safe outcome으로 mapping한다. + +### 4.5 event contribution + +```ts +export interface InstalledEventContract { + readonly eventType: string; + readonly envelopeValidator: RuntimeValidator; + readonly payloadValidator: RuntimeValidator; +} +``` + +package identity는 enclosing `InstalledContractContribution.source`가 단 한 번 소유한다. HTTP와 event가 서로 다른 package identity라면 contribution을 둘로 나눈다. + +connection, queue, dedupe, scope, recovery와 application effect는 frontend realtime contribution이 소유한다. generated package는 socket을 열거나 QueryClient를 참조하지 않는다. + +### 4.6 runtime validator protocol + +```ts +export interface RuntimeValidator { + readonly schemaId: string; + safeParse(value: unknown): + | Readonly<{ success: true; data: T }> + | Readonly<{ + success: false; + issues: readonly RuntimeValidationIssue[]; + }>; +} + +export interface RuntimeValidationIssue { + readonly path: readonly (string | number)[]; + readonly code: string; +} +``` + +규칙: + +- external response/event는 항상 `unknown`에서 시작한다; +- generated interface와 `as T`는 validator를 대체하지 않는다; +- validator throw는 `CONTRACT_RUNTIME_FAILURE`다; +- ordinary mismatch는 `CONTRACT_VALUE_INVALID`다; +- raw value, server detail, validator internals를 diagnostics에 넣지 않는다; +- 성공 값은 mapper 전에 caller-owned mutable reference로 재사용하지 않는다. + +### 4.7 mapper ownership + +```text +wire DTO + -> feature contract mapper + -> domain factory/application value + -> Query cache/UI +``` + +mapper MUST: + +- feature가 소유한다; +- no-throw discriminated result를 반환한다; +- date/number/enum/null을 application canonical value로 변환한다; +- generated DTO를 Query cache에 직접 넣지 않는다; +- package metadata/header를 application value에 포함하지 않는다; +- collection count와 field bound를 mapper 단계에서도 확인한다. + +```ts +export type MappingResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ + ok: false; + error: Readonly<{ kind: "MAPPING_CONTRACT_VIOLATION"; code: string }>; + }>; +``` + +### 4.8 installed contribution composer + +```text +src/features/installed-contract-contributions.ts +``` + +```ts +export const INSTALLED_CONTRACT_CONTRIBUTIONS = Object.freeze([ + REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, +] as const); +``` + +reference fixture 규칙: + +```text +featureId = reference-feature +source.kind = TEMPLATE_FIXTURE +fixtureId = REFERENCE_FEATURE_V1 +revision = 1 +events = [] +``` + +`TEMPLATE_FIXTURE`는 current reference HTTP vertical을 deterministic test data로 유지하기 위한 유일한 예외다. product feature는 반드시 `EXTERNAL_PACKAGE`다. fixture는 `contractSet`에 포함하지 않는다. + +### 4.9 composer exact validation + +composition 전에 다음을 모두 검사한다. + +```text +featureId: + ^[a-z][a-z0-9-]{0,63}$ + +packageId: + ^@[a-z0-9][a-z0-9._-]{0,62}/[a-z0-9][a-z0-9._-]{0,62}$ + +version: + exact SemVer + leading v 금지 + range/tag/whitespace 금지 + +digest: + ^sha256:[0-9a-f]{64}$ + +sourceRevision: + ^[0-9a-f]{7,64}$ + +operationId: + ^[A-Za-z][A-Za-z0-9_.-]{0,127}$ + +eventType: + UTF-8 <= 160 bytes + whitespace/control character 금지 + +pathTemplate: + startsWith / + UTF-8 <= 512 bytes + query/hash 금지 +``` + +추가 검증: + +- external runtime protocol version은 정확히 1; +- package ID별 정확히 한 version/digest; +- duplicate operation ID 없음; +- duplicate event type 없음; +- status는 100..599, 1..32개, sorted unique; +- empty-body status는 accepted status의 subset; +- validator와 non-empty `schemaId` 필수; +- request/response limit은 §7 hard ceiling 이하; +- same operation method/path/status projection drift 없음; +- fixture 외 local product contract 없음. + +package identity 검증은 `EXTERNAL_PACKAGE`에만 적용한다. fixture에 가짜 digest를 부여하지 않는다. + +### 4.10 package dependency 규칙 + +- `package.json`은 exact version만 사용한다; +- `latest`, caret, tilde, tag, Git URL, workspace link 금지; +- lockfile resolved integrity와 package export manifest가 존재해야 한다; +- runtime registry/Git/deployed docs에서 package를 보충하지 않는다; +- package root barrel 전체 import 금지; +- 사용 operation/event subpath만 정적 import한다; +- package import는 top-level network/DOM/listener side effect가 없어야 한다; +- package source와 generated output을 frontend에서 수정하지 않는다. + +### 4.11 reference feature에서 product feature로의 migration + +금지: + +```text +copy reference-schemas.ts +edit URL/status/type +ship as product local contract +``` + +필수: + +```text +external package pin +-> feature contribution adapter +-> feature mapper +-> feature gateway +-> application input +-> query/mutation/realtime bridge +``` + +reference fixture는 template 자체 제거 가능성 검증용이며 서비스 계약 authoring template가 아니다. + +--- + +## 5. Contract Set과 release coherence + +### 5.1 scalar 계약 version 제거 + +현재 다음 scalar는 여러 package를 표현할 수 없다. + +```text +RuntimeConfig.API_CONTRACT_VERSION +ReleaseManifest.apiContractVersion +``` + +V2 전환 결과: + +```text +Runtime Config + = deployment/browser operational setting + +Release Manifest + = build/release/assets/compiled external contract package set +``` + +`RuntimeConfig`에서 API contract version을 제거한다. + +### 5.2 Release Manifest V2 + +```ts +export interface ReleaseManifestV2 { + readonly schemaVersion: 2; + readonly appVersion: string; + readonly buildId: string; + readonly commitSha: string; + readonly configSchemaVersion: string; + readonly assetManifestHash: string; + readonly releaseId: string; + readonly builtAt: string; + readonly routeChunks: Readonly>; + readonly contractSet: Readonly<{ + setAlgorithm: "CA_CONTRACT_SET_V1"; + setDigest: `sha256:${string}`; + packages: readonly ContractSetPackage[]; + }>; +} + +export interface ContractSetPackage { + readonly packageId: string; + readonly version: string; + readonly digest: `sha256:${string}`; + readonly runtimeProtocolVersion: 1; + readonly sourceRevision: string; +} +``` + +fixture contribution은 `packages`에 들어가지 않는다. + +### 5.3 canonicalization + +단일 구현: + +```text +src/contracts/contract-set-canonical.ts +``` + +```ts +export function canonicalizeContractSet( + packages: readonly ContractSetPackage[], +): Uint8Array; +``` + +algorithm: + +```text +header = UTF-8("CA_FRONTEND_CONTRACT_SET_V1\0") +entries = packages sorted by UTF-8 byte lexicographic packageId +for each entry: + append u32be(length(packageId)) + packageId bytes + append u32be(length(version)) + version bytes + append u32be(length(digest)) + digest bytes + append u32be(runtimeProtocolVersion) + append u32be(length(sourceRevision)) + sourceRevision bytes +setDigest = lower-hex SHA-256(canonical bytes) +``` + +중복 package ID는 hash 전에 실패한다. JSON property order나 locale sort에 의존하지 않는다. + +Node build script와 browser runtime은 canonical byte 생성 함수를 공유한다. hash adapter만 Node `crypto`와 Web Crypto로 분리한다. + +### 5.4 build-time expected set + +```text +INSTALLED_CONTRACT_CONTRIBUTIONS + -> EXTERNAL_PACKAGE identities only + -> canonicalize + -> setDigest + -> build manifest embed +``` + +`TEMPLATE_FIXTURE`는 filtering 후 set에 포함하지 않는다. + +### 5.5 runtime check + +boot에서 다음을 비교한다. + +```text +embedded expected package list +manifest package list +embedded expected setDigest +manifest setDigest +recomputed manifest setDigest +runtime protocol version +``` + +모두 일치해야 한다. package range negotiation, deployed server docs와 `latest` lookup을 하지 않는다. + +### 5.6 failure code + +```text +CONTRACT_SET_SCHEMA_INVALID +CONTRACT_SET_ENTRY_INVALID +CONTRACT_SET_DUPLICATE_PACKAGE +CONTRACT_SET_DIGEST_INVALID +CONTRACT_SET_DIGEST_MISMATCH +CONTRACT_SET_PACKAGE_MISSING +CONTRACT_SET_PACKAGE_UNEXPECTED +CONTRACT_SET_VERSION_MISMATCH +CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED +``` + +이 failure는 product route mount 전 safe boot error다. + +### 5.7 independent deployment 의미 + +frontend manifest는 **frontend build에 compile된 package set**을 검증한다. provider runtime version을 추측하거나 provider와 exact equality를 요구하지 않는다. + +provider compatibility와 server support range는 별도 계약 시스템의 release responsibility다. frontend runtime은 build package set과 자신의 artifact coherence만 검증한다. + +### 5.8 V1 migration + +```text +Release 1 + reader: V1 + V2 + writer: V1 + API_CONTRACT_VERSION 유지 + +Release 2 + reader: V1 + V2 + writer: V2 + contractSet 생성 + V1 input parser만 scalar를 읽음 + V2 input에 scalar가 있으면 strict reject + +Release 3 + reader: V2 + writer: V2 + RuntimeConfig scalar reject as unknown field + V1 fixtures 제거 +``` + +V1과 V2 값을 동시에 비교해 precedence를 만들지 않는다. V2 manifest에서 scalar가 추가로 존재하면 strict parser가 거절한다. + +--- +## 6. Runtime Config와 boot + +### 6.1 Runtime Config V2 + +```ts +export const runtimeConfigV2Schema = z + .object({ + APP_ENV: z.enum(["local", "development", "staging", "production"]), + API_BASE_URL: z.url(), + REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000), + MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), + TELEMETRY_ENABLED: z.boolean(), + TELEMETRY_ENDPOINT: z.url().optional(), + AUTH_MODE: z.enum(["external", "demo"]), + CONFIG_SCHEMA_VERSION: z.literal("2.0"), + RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), + RELEASE_ID: z.string().min(1).optional(), + BUILD_ID: z.string().min(1).optional(), + CAPABILITY_OVERRIDES: z + .object({ + REALTIME: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + WEB_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + SERVICE_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + OFFLINE_COMMANDS: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + }) + .strict() + .default({}), + }) + .strict(); +``` + +`API_CONTRACT_VERSION`은 V2에 없다. + +### 6.2 URL validation + +`API_BASE_URL`: + +- absolute URL; +- username/password/hash 없음; +- pathname은 `/`로 끝남; +- local/development 외 `https:`; +- query는 없음; +- canonical `new URL(value).href`를 runtime snapshot에 저장. + +`TELEMETRY_ENDPOINT`: + +- enabled일 때 필수; +- absolute URL; +- username/password/hash 없음; +- local/development 외 `https:`; +- API base와 같을 필요 없음. + +`RELEASE_MANIFEST_URL`: + +- same-origin absolute path; +- `/`로 시작; +- query/hash 없음; +- `//`, backslash, percent-encoded slash, dot segment 거절; +- maximum UTF-8 256 bytes. + +### 6.3 config secret-name scan + +기존 `assertSafeConfigNames`를 유지하고 다음 key fragment를 case-insensitive로 거절한다. + +```text +PASSWORD +SECRET +TOKEN +PRIVATE_KEY +CLIENT_SECRET +ACCESS_KEY +REFRESH_TOKEN +COOKIE +AUTHORIZATION +``` + +`AUTH_MODE`, `TELEMETRY_ENABLED`처럼 fragment가 semantic enum인 known allowlist는 exact key allowlist로만 허용한다. arbitrary nested key에는 allowlist를 적용하지 않는다. + +### 6.4 bounded boot body reader + +공통 source: + +```text +src/bootstrap/read-bounded-boot-json.ts +``` + +```ts +export interface BootJsonPolicy { + readonly operation: "RUNTIME_CONFIG" | "RELEASE_MANIFEST"; + readonly maximumBytes: number; + readonly totalDeadlineMs: 5_000; +} +``` + +numeric policy: + +```text +Runtime Config maximum = 65,536 bytes +Release Manifest maximum = 1,048,576 bytes +total deadline = 5,000 ms each +``` + +request options: + +```ts +{ + method: "GET", + cache: "no-store", + credentials: "same-origin", + redirect: "error", + referrerPolicy: "no-referrer", + headers: { Accept: "application/json" }, + signal, +} +``` + +### 6.5 response admission + +순서: + +```text +fetch settled +-> status 200 only +-> content-type parse +-> application/json or +json only +-> Content-Length precheck when finite +-> ReadableStream bounded read +-> actual bytes <= policy maximum +-> UTF-8 fatal decode +-> JSON.parse +-> object shape check +-> Zod strict parse +-> deep snapshot +``` + +금지: + +- `response.json()`; +- unbounded `response.text()`; +- redirect follow; +- HTML error page를 JSON으로 parse; +- `Content-Length`만 신뢰; +- timeout 후 body read continuation. + +body overflow 시 reader를 cancel하고 `AbortController`를 abort한다. + +### 6.6 boot failure union + +```ts +export type BootLoadFailure = + | "FETCH_FAILED" + | "TIMEOUT" + | "HTTP_STATUS_INVALID" + | "CONTENT_TYPE_INVALID" + | "BODY_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SHAPE_INVALID" + | "SECRET_NAME_REJECTED" + | "SCHEMA_INVALID" + | "BUILD_MISMATCH" + | "RELEASE_MISMATCH" + | "ASSET_MISMATCH" + | "CONTRACT_SET_MISMATCH"; +``` + +safe error는 다음만 포함한다. + +```text +failure kind/code +buildId +releaseId if already validated +config schema version if safe +support reference = buildId + bounded failure code +``` + +URL, response body, validation value, endpoint hostname, stack trace를 포함하지 않는다. + +### 6.7 boot order + +```text +1. read embedded build config +2. load/validate Runtime Config V2 +3. compare optional BUILD_ID +4. load/validate Release Manifest V2 +5. verify build/config/release/assets +6. verify contractSet +7. compile static optional selection + runtime overrides +8. create diagnostics/telemetry no-op or selected sink +9. create auth/session adapter +10. create HTTP runtime +11. create application inputs/gateways +12. create QueryClient/scope/invalidation +13. create Router +14. create optional host objects without start side effect +15. mount React tree +16. committed effect starts optional runtime +``` + +### 6.8 partial composition rollback + +각 step은 cleanup stack에 disposer를 push한다. + +```text +create A -> push dispose A +create B -> push dispose B +create C fails +-> dispose B +-> dispose A +-> render safe boot error +``` + +cleanup은 reverse order이며 하나의 cleanup failure가 다음 cleanup을 막지 않는다. 모든 cleanup failure를 bounded count와 kind로 합산한다. + +### 6.9 boot retry + +safe boot shell의 retry action: + +- 사용자의 explicit action만; +- 동일 page session에서 maximum 1회; +- 이전 attempt의 controller/resource settle 후 실행; +- 1초 minimum interval; +- retry 실패 후 automatic loop 없음; +- retry success 시 full product root를 새로 mount; +- partial composition object 재사용 금지. + +### 6.10 Runtime Config immutability + +validated config는 deep snapshot/freeze한다. + +- runtime 중 config endpoint polling 없음; +- dynamic mutation 없음; +- kill switch 변경은 page reload/new boot에서만 적용; +- config object를 context로 노출하지 않음; +- application은 typed port를 통해 필요한 value만 본다. + +--- + +## 7. Fetch HTTP execution runtime + +### 7.1 source ownership + +유지/수정: + +```text +src/adapters/http/client.ts +src/adapters/http/request-builder.ts +src/adapters/http/bounded-json.ts +src/adapters/http/retry-policy.ts +``` + +신규: + +```text +src/adapters/http/bounded-body-reader.ts +src/adapters/http/http-execution-v3.ts +src/adapters/http/http-effect-certainty.ts +src/adapters/http/http-contract-bridge.ts +``` + +범용 Axios-like API를 만들지 않는다. caller는 `InstalledHttpContract`와 typed input을 전달한다. + +### 7.2 execution interface + +```ts +export interface ContractHttpExecutor { + execute( + operation: InstalledHttpContract, + input: Input, + context: HttpExecutionContext, + ): Promise>; +} + +export interface HttpExecutionContext { + readonly signal?: AbortSignal; + readonly scope: CacheScopeSnapshot; + readonly intent?: MutationIntentContext; +} +``` + +`MutationIntentContext`는 command에만 사용한다. + +```ts +export interface MutationIntentContext { + readonly intentId: string; + readonly idempotencyKey?: string; + readonly startedBy: "USER" | "FOREGROUND_RETRY" | "OUTBOX_REPLAY"; +} +``` + +### 7.3 hard ceilings + +```text +default request body 262,144 bytes +hard request body 1,048,576 bytes +default success response 1,048,576 bytes +hard response body 8,388,608 bytes +problem response maximum 65,536 bytes +path template maximum 512 bytes +encoded query maximum 8,192 bytes +header value examined maximum 8,192 bytes per allowlisted header +total deadline default 10,000 ms +total deadline hard 60,000 ms +retry count hard 2 +``` + +external contribution의 frontend policy는 default보다 작거나 같게 줄일 수 있다. hard ceiling을 초과할 수 없다. + +### 7.4 input validation과 request projection + +순서: + +```text +capture scope +-> verify scope current +-> package input validator +-> feature/application input already canonical +-> request projection +-> encoded path/query/body byte check +-> credential patch +-> final invariant recheck +-> dispatch +``` + +final invariant recheck 항목: + +- method unchanged; +- URL origin/path allowlist; +- redirect mode error; +- credentials mode expected; +- credential patch header allowlist; +- forbidden header 없음; +- body/request byte bound; +- abort/deadline not expired; +- scope still current. + +final invariant failure면 fetch 호출은 0회다. + +### 7.5 URL construction + +- base는 validated `API_BASE_URL`; +- path placeholder는 descriptor의 named input projection만 사용; +- segment는 `encodeURIComponent` semantics로 한 번만 encode; +- raw slash를 path value에 허용하지 않음; +- query key order는 descriptor-generated order; +- array encoding은 package descriptor가 제공한 exact projection; +- `undefined` omit, `null`은 descriptor가 허용한 representation만; +- URL string concatenation 금지; +- absolute path override, origin override 금지; +- final URL maximum 16,384 bytes. + +### 7.6 request body + +`requestBody=NONE`이면 body와 Content-Type 모두 없다. + +`requestBody=JSON`이면: + +```text +package request projection +-> deterministic JSON encoder +-> UTF-8 bytes +-> requestByteLimit check +-> Content-Type application/json +``` + +`JSON.stringify`의 default property enumeration에 semantic digest를 의존하지 않는다. idempotency request digest가 필요하면 package가 canonical request digest projection을 제공한다. + +### 7.7 credential bridge + +```ts +export type CredentialPatchOutcome = + | Readonly<{ + kind: "READY"; + headers: Readonly>; + credentials: RequestCredentials; + }> + | Readonly<{ kind: "UNAUTHENTICATED" }> + | Readonly<{ kind: "UNAVAILABLE" }> + | Readonly<{ kind: "SCOPE_FENCED" }>; +``` + +허용 header: + +```text +Authorization +X-CSRF-Token +X-Tenant-Context +``` + +실제 profile이 쓰는 subset만 등록한다. caller가 arbitrary header를 추가하지 않는다. + +credential unavailable에서 anonymous request로 downgrade하지 않는다. + +### 7.8 physical attempt state + +```ts +export type PhysicalAttemptState = + | "PREPARING" + | "READY_TO_SEND" + | "DISPATCHED" + | "RESPONSE_HEADERS" + | "READING_BODY" + | "VALIDATING" + | "MAPPING_READY" + | "SETTLED"; +``` + +`fetch()` invocation 직전에 `DISPATCHED`로 바꾸지 않는다. invocation expression에 들어가기 직전 local state를 `READY_TO_SEND`, promise가 반환된 직후 `DISPATCHED`로 기록한다. synchronous throw는 `NOT_STARTED`다. + +### 7.9 response admission + +순서: + +```text +redirected=false +-> status package allowlist +-> content-type policy +-> Content-Length precheck +-> bounded stream read +-> empty-body policy +-> UTF-8/JSON parse when required +-> problem or success validator +-> scope current fence +-> typed outcome +``` + +`response.ok`만 사용하지 않는다. + +### 7.10 body policy + +`REQUIRED_JSON`: + +- allowed success status에서 non-empty body 필수; +- JSON media 필수; +- empty/whitespace는 contract violation. + +`OPTIONAL_JSON`: + +- empty-body status 또는 zero body 허용; +- body가 있으면 JSON media/validator 필수; +- body 없는 값을 `undefined`로 명시적으로 mapping. + +`NONE`: + +- body를 application value로 읽지 않음; +- `Content-Length > 0`이면 즉시 violation + body cancel; +- length가 없고 body stream이 있으면 최대 1 byte만 probe한다; +- 첫 chunk가 있으면 violation + reader cancel; +- immediately done이면 empty success; +- descriptor가 unexpected body를 허용하지 않으므로 arbitrary drain하지 않는다. + +### 7.11 typed outcome + +```ts +export type HttpExecutionOutcome = + | Readonly<{ + kind: "SUCCESS"; + value: Value; + metadata: SafeResponseMetadata; + effect: "NOT_APPLICABLE" | "APPLIED_CONFIRMED"; + }> + | Readonly<{ + kind: "PROBLEM"; + problem: Problem; + metadata: SafeResponseMetadata; + effect: "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "UNAUTHENTICATED"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "FORBIDDEN"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "RATE_LIMITED"; + retryAfterMs?: number; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CONTRACT_VIOLATION"; + violation: HttpContractViolation; + }> + | Readonly<{ + kind: "TRANSPORT_FAILURE"; + failure: HttpTransportFailure; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CANCELLED"; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }>; +``` + +### 7.12 safe response metadata + +```ts +export interface SafeResponseMetadata { + readonly status: number; + readonly correlationReference?: string; + readonly retryAfterMs?: number; + readonly validatorToken?: string; +} +``` + +metadata에 raw header map, URL, server, cookie, traceparent, ETag raw value를 노출하지 않는다. validator token은 conditional validator store 내부 opaque binding이며 UI/application에 직접 전달하지 않는다. + +### 7.13 contract violation + +```text +UNEXPECTED_STATUS +UNEXPECTED_EMPTY_BODY +UNEXPECTED_BODY +CONTENT_TYPE_MISMATCH +RESPONSE_TOO_LARGE +UTF8_INVALID +JSON_INVALID +SUCCESS_SCHEMA_INVALID +PROBLEM_SCHEMA_INVALID +VALIDATOR_RUNTIME_FAILURE +MAPPING_CONTRACT_VIOLATION +FINAL_REQUEST_INVARIANT_FAILED +SCOPE_FENCED +``` + +```ts +export type HttpContractViolation = Readonly<{ + kind: + | "UNEXPECTED_STATUS" + | "UNEXPECTED_EMPTY_BODY" + | "UNEXPECTED_BODY" + | "CONTENT_TYPE_MISMATCH" + | "RESPONSE_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SUCCESS_SCHEMA_INVALID" + | "PROBLEM_SCHEMA_INVALID" + | "VALIDATOR_RUNTIME_FAILURE" + | "MAPPING_CONTRACT_VIOLATION" + | "FINAL_REQUEST_INVARIANT_FAILED" + | "SCOPE_FENCED"; + operation: "REQUEST" | "RESPONSE" | "VALIDATION" | "MAPPING"; +}>; +``` + +contract violation은 ordinary network retry 대상이 아니다. + +### 7.14 transport failure + +```text +DNS_OR_CONNECT_FAILURE +TLS_OR_NETWORK_FAILURE +TIMEOUT +RESPONSE_STREAM_FAILURE +ABORTED_BY_SCOPE +ABORTED_BY_CALLER +OVERLOADED_BEFORE_SEND +``` + +```ts +export type HttpTransportFailure = Readonly<{ + kind: + | "DNS_OR_CONNECT_FAILURE" + | "TLS_OR_NETWORK_FAILURE" + | "NETWORK_FAILURE" + | "TIMEOUT" + | "RESPONSE_STREAM_FAILURE" + | "ABORTED_BY_SCOPE" + | "ABORTED_BY_CALLER" + | "OVERLOADED_BEFORE_SEND"; + retryable: boolean; +}>; +``` + +browser가 세부 네트워크 원인을 제공하지 않으면 하나의 `NETWORK_FAILURE` bucket으로 축약한다. native exception message를 boundary 밖으로 내보내지 않는다. + +--- + +## 8. Retry·cancellation·mutation effect certainty + +### 8.1 retry owner + +```text +HTTP transport retry = enabled by exact operation policy +TanStack Query retry = false +TanStack Mutation retry = false +realtime reconnect = realtime coordinator +Background Sync retry = offline drain state machine +``` + +서로 다른 계층이 같은 physical operation을 중복 retry하지 않는다. + +### 8.2 HTTP retry algorithm + +```text +base delay = 250 ms +max local delay = 2,000 ms +algorithm = full jitter +maximum retries = min(operation.retryBudget, RuntimeConfig.MAX_RETRY_ATTEMPTS, 2) +``` + +retry index 0의 ceiling: + +```text +min(2,000, 250 * 2^0) = 250 ms +``` + +실제 delay: + +```text +floor(random[0,1) * ceiling) +``` + +validated `Retry-After`가 있으면 local delay의 lower bound로 사용한다. 5,000ms를 넘거나 remaining total deadline을 넘으면 retry하지 않는다. + +### 8.3 retry classification + +`SAFE`: + +- GET/HEAD; +- network failure after dispatch 허용; +- 408, 425, 429, 502, 503, 504 중 accepted retry policy; +- same frozen request 사용. + +`IDEMPOTENT`: + +- package가 operation 자체 idempotent라고 선언; +- same frozen request; +- SAFE와 같은 failure/status subset. + +`KEYED`: + +- same user intent의 동일 idempotency key 필수; +- known response status가 retry 가능하다고 package가 명시한 경우만; +- dispatch 후 response 없는 network/timeout/abort는 automatic retry 금지; +- inspect/reconciliation path로 이동. + +`NEVER`: + +- automatic retry 0. + +### 8.4 auth retry + +401 response 뒤 hidden refresh-and-replay를 하지 않는다. + +- credential owner는 **send 전** READY/UNAUTHENTICATED/UNAVAILABLE을 결정한다; +- response 401은 terminal `UNAUTHENTICATED`; +- 사용자 re-auth 뒤 새 user intent 또는 explicit retry로 실행; +- command를 새 credential로 자동 replay하지 않는다. + +### 8.5 deadline + +one logical operation deadline은 다음 전체를 포함한다. + +```text +credential resolution +request encode +retry backoff +all physical attempts +response read +runtime validation +mapper preparation +``` + +operation start에서 monotonic deadline을 capture한다. 각 sub-step이 remaining budget을 받는다. physical attempt별로 full timeout을 새로 부여하지 않는다. + +### 8.6 cancellation sources + +```ts +export type CancellationOwner = + | "CALLER" + | "ROUTE_TRANSITION" + | "SCOPE_FENCE" + | "APPLICATION_SHUTDOWN" + | "DEADLINE"; +``` + +linked AbortController는 first reason만 terminal reason으로 저장한다. later reason은 diagnostics count에만 반영한다. + +### 8.7 mutation effect certainty + +```ts +export type MutationEffectCertainty = + | "NOT_STARTED" + | "NOT_APPLIED" + | "MAYBE_APPLIED" + | "APPLIED_CONFIRMED"; +``` + +판정: + +| 관측 | certainty | +| --- | --- | +| validation/config/auth/final invariant에서 종료 | `NOT_STARTED` | +| fetch synchronous throw | `NOT_STARTED` | +| package가 no-effect problem/status로 명시 | `NOT_APPLIED` | +| success/committed receipt | `APPLIED_CONFIRMED` | +| dispatch 후 timeout/network/abort/body loss | `MAYBE_APPLIED` | +| unknown/unclassified problem | `MAYBE_APPLIED` | + +frontend가 HTTP status만 보고 `NOT_APPLIED`를 추측하지 않는다. + +### 8.8 mutation intent identity + +```ts +export interface MutationIntent { + readonly intentId: string; + readonly operationId: string; + readonly canonicalInputIdentity: string; + readonly idempotencyKey?: string; + readonly createdAtMonotonicMs: number; +} +``` + +- `intentId`: `crypto.randomUUID()`; +- 같은 submit의 transport retry/reconcile에는 동일 intent/key; +- form value가 변경되면 새 intent; +- double click은 duplicate coordinator가 처리; +- key/identity를 log/URL/query key에 넣지 않는다. + +### 8.9 uncertain outcome handling + +`MAYBE_APPLIED`면: + +```text +1. automatic resend 금지 +2. optimistic layer를 arbitrary rollback하지 않음 +3. mutation coordinator state = EFFECT_UNKNOWN +4. package recovery descriptor 확인 +5. inspect operation이 있으면 bounded inspect +6. committed -> canonical result 적용 +7. no effect -> explicit user retry 허용 +8. unknown 유지 -> status UI/support path +``` + +inspect도 timeout이면 unknown을 유지한다. + +### 8.10 UI projection + +```text +NOT_STARTED -> ordinary retry 가능 +NOT_APPLIED -> policy상 retry 가능 +MAYBE_APPLIED -> "처리 상태 확인 필요" +APPLIED_CONFIRMED-> success +``` + +사용자 문구는 i18n catalog가 소유한다. native error와 server detail을 표시하지 않는다. + +--- + +## 9. Router + +### 9.1 ownership + +Router owns: + +```text +route registry +path/search codec +navigation +redirect +lazy module lifecycle +route error boundary +focus/scroll/title +dirty navigation guard +session/access hint guard +``` + +Router does not own: + +```text +server data cache +HTTP retry +mutation lifecycle +realtime connection +business authorization +contract package loading +``` + +### 9.2 server-data loader/action 금지 + +baseline에서 loader/action은 server fetch를 수행하지 않는다. + +허용 loader: + +- URL/path/search parse; +- local route registration lookup; +- synchronous redirect; +- session snapshot 기반 access hint; +- route-local static metadata. + +금지: + +- direct fetch; +- gateway call; +- QueryClient prefetch/ensure; +- IndexedDB read; +- mutation action; +- separate loader cache. + +서버 data는 page의 feature query/mutation hook이 application input을 호출해 가져온다. + +### 9.3 route registration + +```ts +export interface InstalledRouteContribution { + readonly routeId: string; + readonly path: string; + readonly ownerFeatureId: string; + readonly load: () => Promise; + readonly inputCodecId: string; + readonly accessPolicyId: string; +} +``` + +- route ID: existing registry format 유지; +- duplicate route ID/path 거절; +- feature page가 app-router를 import하지 않음; +- lazy module은 route runtime module만 export; +- Router가 feature gateway/adapter를 props로 전달하지 않음. + +### 9.4 URL codec + +path/search raw input은 `unknown`이다. + +```text +URLSearchParams +-> registered route codec +-> normalized route input +-> RouteInputProvider +-> page/controller +``` + +- `Object.fromEntries(searchParams)` raw trust 금지; +- duplicate query key policy를 codec이 명시; +- unknown key는 default reject; +- array/count/string byte bound; +- route param은 domain ID factory 전까지 string; +- malformed input은 safe bad-request route surface; +- network/application gateway call 0. + +### 9.5 route transition cleanup + +feature page unmount 순서: + +```text +1. Query observer release +2. feature realtime subscription lease release +3. page browser listener cleanup +4. form/dirty guard cleanup +5. object URL/temporary file lease cleanup +6. page-owned abort controller abort +``` + +다음 global runtime은 유지한다. + +```text +QueryClient +scope runtime +cross-context coordinator +realtime host +Service Worker registration +offline repository +``` + +### 9.6 chunk recovery + +```text +chunk load failure +-> no-store Release Manifest V2 refresh +-> build/release/asset/contractSet compare +-> dirty state inspect +-> one guarded reload +-> second failure terminal safe surface +``` + +reload guard key: + +```text +CHUNK_RELOAD_GUARD = oldBuildId + newBuildId + failedRouteId +``` + +동일 pair/route당 한 번만 reload한다. localStorage record는 10분 TTL과 512-byte hard cap을 갖는다. + +### 9.7 focus/scroll/title + +- successful route commit 후 `main-content` focus; +- navigation마다 scroll top; +- explicit registered hash-anchor route만 anchor focus; +- document title은 typed message catalog; +- background Query refresh는 focus/scroll을 변경하지 않음; +- reduced motion 존중; +- error route도 heading focus를 제공. + +### 9.8 navigation blocker + +blocking 조건: + +```text +dirty form +active non-cancellable local file workflow +mutation EFFECT_UNKNOWN recovery in progress +offline command conflict resolution draft +``` + +ordinary loading/query refresh는 navigation을 막지 않는다. + +browser unload dialog는 browser standard prompt만 사용한다. custom string에 의존하지 않는다. + +--- + +## 10. Query lifecycle과 cache + +### 10.1 QueryClient 수와 기본 정책 + +application runtime당 QueryClient 하나다. + +```ts +export const QUERY_CACHE_DEFAULTS = Object.freeze({ + staleTime: 30_000, + gcTime: 300_000, + refetchOnWindowFocus: true, + retry: false, + mutationRetry: false, + persistence: false, +}); +``` + +route/feature/session마다 새 QueryClient를 만들지 않는다. + +### 10.2 fixed server-state profiles + +```ts +export type ServerStateProfileId = + | "DETAIL_STANDARD" + | "LIST_STANDARD" + | "LOOKUP_STABLE" + | "VOLATILE_STATUS"; + +export interface ServerStateProfile { + readonly profileId: ServerStateProfileId; + readonly staleTimeMs: number; + readonly gcTimeMs: number; + readonly refetchOnWindowFocus: boolean; + readonly maxResultItems: number; + readonly maxEstimatedResultBytes: number; +} +``` + +```text +DETAIL_STANDARD + stale 30s, gc 5m, focus true, items 1, bytes 256KiB + +LIST_STANDARD + stale 15s, gc 5m, focus true, items 200, bytes 1MiB + +LOOKUP_STABLE + stale 5m, gc 30m, focus false, items 500, bytes 2MiB + +VOLATILE_STATUS + stale 0, gc 1m, focus true, items 1, bytes 64KiB +``` + +feature가 임의 숫자를 선언하지 않는다. 네 profile로 표현할 수 없으면 본 문서와 profile registry를 개정한다. + +### 10.3 query definition + +```ts +export interface QueryDefinition { + readonly definitionId: string; + readonly definitionVersion: number; + readonly owner: string; + readonly namespace: string; + readonly namespaceVersion: number; + readonly operationId: string; + readonly profileId: ServerStateProfileId; + measureResult(value: Value): QueryResultMeasure; + execute( + input: Input, + context: Readonly<{ signal: AbortSignal }>, + ): Promise>; +} + +export interface QueryResultMeasure { + readonly itemCount: number; + readonly estimatedBytes: number; +} +``` + +### 10.4 result measurement + +`measureResult`는 모든 definition에 필수다. + +- detail/scalar: item 1; +- empty optional: item 0; +- list/page: actual mapped items; +- bytes: mapped application value의 bounded string UTF-8 bytes + primitive fixed width + collection overhead; +- `JSON.stringify` fallback 금지; +- wire DTO 재직렬화 금지; +- generic recursive walker 금지; +- throw/non-finite/negative/unsafe integer는 `RESULT_MEASUREMENT_FAILED`; +- ceiling 초과는 `RESULT_BUDGET_EXCEEDED`; +- measurement failure/overflow는 cache commit 0, retry 0. + +### 10.5 client scope authority + +현재 `ServerStateScopeRuntime`을 browser client scope authority로 확장한다. + +```ts +export type ClientScopeLifecycleEvent = + | Readonly<{ kind: "FENCED"; previousGeneration: number }> + | Readonly<{ kind: "READY"; snapshot: CacheScopeSnapshot }> + | Readonly<{ kind: "DISPOSED" }>; + +export interface ServerStateScopeRuntime { + getSnapshot(): CacheScopeSnapshot; + subscribe(listener: () => void): () => void; + subscribeLifecycle( + listener: (event: ClientScopeLifecycleEvent) => void, + ): () => void; + dispose(): void; +} +``` + +### 10.6 scope transition exact order + +identity-affecting session change: + +```text +1. generation increment +2. old snapshot isCurrent=false +3. FENCED synchronously publish +4. new HTTP/realtime/offline admission close +5. active Query cancel +6. QueryClient clear +7. conditional validator clear +8. mutation/invalidation pending lease clear +9. realtime stop/checkpoint detach +10. offline drain lease release and old partition hide +11. old identity registry close +12. new fingerprint/identity registry create +13. READY publish +14. React subscriber notify +15. new scope admission open +``` + +`FENCED`와 `READY` 사이 old value를 render하지 않는다. UI는 `scope-transition` state를 사용한다. + +### 10.7 query key + +```text +[ + "query", + 1, + scopeFingerprint, + namespace, + namespaceVersion, + definitionVersion, + runtimeIdentityToken +] +``` + +raw account/resource ID, URL, filter object, document, cursor, message를 key에 넣지 않는다. + +runtime identity registry bounds: + +```text +max depth 12 +max nodes 512 +max string bytes 2,048 +max encoded bytes 16,384 +entries 4,096 +canonical bytes 4 MiB +``` + +active reference가 있는 identity를 evict하지 않는다. capacity 부족은 network 전 local failure다. + +### 10.8 execution commit fence + +```text +capture scope +-> acquire identity lease +-> execute application input with TanStack signal +-> scope current check +-> Result success check +-> measureResult +-> profile budget check +-> immutable value return +-> Query cache commit +-> finally identity release +``` + +scope가 stale면 successful response도 `SCOPE_FENCED`로 폐기한다. + +### 10.9 UI states + +```text +initial-loading +success-fresh +success-refreshing +success-stale-degraded +empty +known-problem +transport-error +contract-error +scope-transition +``` + +background refresh에서 complete data를 full-page skeleton으로 숨기지 않는다. + +### 10.10 persistence + +baseline: + +```text +memory QueryClient = COMPOSED +query persistence = disabled +SSR hydration = NOT_SELECTED +``` + +Web Storage나 existing generic IndexedDB runtime에 Query payload를 자동 저장하지 않는다. persistence 요구는 VD-13 별도 selection을 통과해야 한다. + +### 10.11 conditional validator + +conditional validator store는 Query value와 분리한다. + +- exact operation/representation/scope/release binding; +- max entries 1,024; +- max token bytes 1,024; +- LRU zero-ref eviction; +- session transition clear; +- 304는 current complete cached value가 있을 때만 success; +- cache miss + 304는 contract/recovery failure; +- raw ETag를 Query key/UI/log에 넣지 않음. + +--- + +## 11. Mutation lifecycle + +### 11.1 mutation definition + +```ts +export interface MutationDefinition { + readonly definitionId: string; + readonly operationId: string; + readonly duplicatePolicy: + | "JOIN_IDENTICAL" + | "REJECT_WHILE_ACTIVE" + | "ALLOW_PARALLEL"; + readonly optimisticPolicyId: "NONE" | string; + readonly invalidationTopics: readonly string[]; + canonicalIdentity(input: Input): string; + execute( + input: Input, + context: Readonly<{ + signal: AbortSignal; + intent: MutationIntent; + }>, + ): Promise>; +} + +export type MutationResult = + | Readonly<{ + ok: true; + value: Value; + effect: "APPLIED_CONFIRMED"; + }> + | Readonly<{ + ok: false; + failure: AppFailure; + effect: "NOT_STARTED" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }>; +``` + +### 11.2 duplicate policy defaults + +```text +command default = REJECT_WHILE_ACTIVE +query-like idempotent action = JOIN_IDENTICAL only when exact semantic input identity is stable +ALLOW_PARALLEL = explicit feature decision only +``` + +`JOIN_IDENTICAL`은 같은 scope, definition, canonical input과 same user intent에서만 가능하다. 서로 다른 input을 logical key 하나로 합치지 않는다. + +### 11.3 duplicate coordinator bounds + +```text +active definitions per runtime = 256 +active intents total = 1,024 +canonical identity bytes = 16,384 +waiters per joined intent = 32 +settled retention = 0 ms; immediate removal +``` + +coordinator map을 result cache로 사용하지 않는다. + +### 11.4 optimistic default + +`optimisticPolicyId=NONE`이 기본이다. + +optimistic update 허용 조건: + +- deterministic local projection; +- rollback snapshot bounded; +- server revision/canonical response가 최종 authority; +- list membership/sort/filter effect가 deterministic; +- effect unknown recovery가 정의됨; +- same query key layer order가 관리됨. + +### 11.5 ordered optimistic layer + +```text +layers per query key <= 8 +single layer estimate <= 262,144 bytes +all layers per query key <= 2,097,152 bytes +layer age <= mutation total deadline + 60s +``` + +overflow면 pessimistic execution으로 전환한다. 기존 layer를 silent evict하지 않는다. + +### 11.6 result ordering + +success: + +```text +1. external wire validation +2. feature mapper +3. scope current check +4. mutation effect confirmed +5. exact detail seed if registered +6. optimistic layer commit/remove +7. registered invalidation topics emit +8. local active Query refetch schedule +9. success UI/navigation +``` + +list/aggregate는 default invalidate다. deterministic membership policy가 없으면 direct patch하지 않는다. + +### 11.7 failure ordering + +`NOT_STARTED/NOT_APPLIED`: + +```text +rollback optimistic layer +-> expose retryable failure +``` + +`MAYBE_APPLIED`: + +```text +optimistic layer를 committed/rolled-back으로 단정하지 않음 +-> effect-unknown overlay +-> inspect/reconcile +-> confirmed outcome 후 canonical cache update +``` + +### 11.8 local invalidation failure + +server command가 committed된 뒤 local invalidation이 실패해도 mutation을 failure로 바꾸지 않는다. + +```text +mutation result = success +cache health = degraded +bounded authoritative refetch schedule +``` + +command를 다시 보내지 않는다. + +### 11.9 form submit + +- double-submit coordinator 사용; +- dirty state는 `APPLIED_CONFIRMED` 뒤 clear; +- effect unknown이면 dirty state를 clear하지 않음; +- successful navigation은 invalidation scheduling 뒤; +- route blocker는 active effect-unknown recovery를 고려; +- browser abort가 server rollback 증거가 아님. + +--- + +## 12. Cross-context invalidation + +### 12.1 wire 의미 + +cross-tab wire는 invalidate-only hint다. + +전송 금지: + +```text +query key +query data +input/filter +cursor +validator +optimistic layer +mutation body +account ID +resource ID +``` + +전송: + +```text +protocol version +release epoch +scope epoch +sequence +opaque topic ID +source instance ID digest +``` + +### 12.2 registry + +```ts +export interface InvalidationRegistry { + readonly topics: readonly string[]; + readonly namespaces: readonly string[]; + readonly edges: readonly Readonly<{ + topicId: string; + namespace: string; + }>[]; +} +``` + +bounds: + +```text +topics 256 +namespaces 256 +edges 1,024 +topic fan-out 64 +namespace fan-in 64 +ID bytes 80 +``` + +duplicate edge와 orphan topic/namespace를 startup에서 거절한다. + +### 12.3 primary/fallback transport + +```text +primary = BroadcastChannel +fallback = localStorage pulse + storage event +``` + +channel/key는 registry-issued opaque token을 사용한다. + +fallback pulse: + +```text +maximum 1,024 bytes +TTL 60 seconds +write then remove not used; latest bounded envelope overwrite +storageArea must equal localStorage +same-tab local dispatch is explicit, storage event를 기대하지 않음 +``` + +### 12.4 ordering + +source instance별 monotonic sequence를 사용한다. + +- duplicate sequence: ignore; +- lower sequence: stale duplicate ignore; +- exact next: topic apply; +- gap: 모든 registered namespace stale mark; +- release/scope epoch mismatch: event ignore + local full stale transition; +- event는 authority가 아니라 refetch hint. + +### 12.5 mutation coalescing + +current local mutation lease 동안 같은 topic remote hint를 coalesce한다. + +mutation settlement 후: + +- one invalidation apply; +- active query bounded refetch; +- inactive query는 next mount/focus revalidate; +- remote hint 때문에 optimistic layer를 tab 간 복제하지 않는다. + +### 12.6 lifecycle + +- application runtime start에서 one host; +- session transition 시 scope epoch rotate; +- logout에서 old event ignore; +- pagehide bfcache는 host suspend, restore에서 duplicate listener 없이 resume; +- dispose는 listener/channel/timer 제거; +- hidden page도 invalidation hint 수신은 유지하되 refetch는 visibility policy를 따른다. + +--- +## 13. Realtime product composition + +### 13.1 existing runtime reuse + +다음 existing source를 common runtime으로 유지한다. + +```text +src/contracts/realtime-events.ts +src/contracts/realtime-streams.ts +src/application/ports/realtime/* +src/adapters/realtime/event-codec.ts +src/adapters/realtime/event-consumer.ts +src/adapters/realtime/reconnect-coordinator.ts +src/adapters/realtime/live-poll-handoff-coordinator.ts +src/adapters/realtime/stream-coordinator.ts +src/adapters/realtime/sse/* +src/adapters/realtime/websocket/* +src/adapters/realtime/polling/* +src/adapters/web-push/* +``` + +RT-01~RT-04를 새 protocol로 다시 구현하지 않는다. + +### 13.2 product contribution source + +```text +src/features//contracts/-realtime-contribution.ts +``` + +```ts +export interface InstalledRealtimeContribution { + readonly contributionId: string; + readonly featureId: string; + readonly contractSourcePackageId: string; + readonly stream: RealtimeStreamRegistration; + readonly eventEffects: readonly InstalledRealtimeEventEffect[]; + readonly transport: InstalledRealtimeTransport; +} + +export interface InstalledRealtimeEventEffect { + readonly eventType: string; + readonly mapperId: string; + readonly effect: + | "INVALIDATE_TOPICS" + | "APPLY_AUTHORITATIVE_DELTA" + | "EPHEMERAL_NOTIFICATION"; + readonly invalidationTopics: readonly string[]; +} + +export type InstalledRealtimeTransport = + | Readonly<{ kind: "SSE"; endpointId: string }> + | Readonly<{ kind: "WEBSOCKET"; endpointId: string }> + | Readonly<{ + kind: "POLLING"; + operationId: string; + intervalMs: number; + }>; +``` + +endpoint/event/recovery meaning is imported from the external package/approved frontend contribution. Frontend does not invent a server path or event payload. + +### 13.3 contribution validation + +```text +contributions max 64 +streams max 128 +event types max 512 +effects per event max 8 +invalidation topics per event 32 +``` + +validation: + +- contribution ID unique; +- stream/event registration existing registry rules pass; +- external event type exists in installed contract contribution; +- event validator package belongs to `contractSourcePackageId`; +- mapper exists and input schema matches payload validator; +- invalidation topic exists; +- transport endpoint/operation exists in installed package contribution; +- polling interval 5,000..300,000ms; +- transport exactly one; +- fallback is explicit, not inferred from runtime support. + +### 13.4 no mega port + +SSE, WebSocket, Polling, Web Push는 다음 의미로 분리한다. + +```text +SSE active page one-way stream +WebSocket active page duplex protocol +Polling visible finite HTTP scheduling +Web Push inactive/background user notification hint +``` + +하나의 `RealtimePort.connect(any)`로 합치지 않는다. + +### 13.5 transport selection + +기본 선택 규칙: + +- one-way active stream: SSE; +- duplex application protocol: WebSocket; +- stream provider가 없고 bounded freshness로 충분: Polling; +- inactive user notification: Web Push; +- Web Push를 foreground state synchronization으로 사용 금지; +- Polling을 connection failure 자동 downgrade로 사용 금지; +- primary/fallback pair는 contribution의 `stream.fallback`이 명시한다. + +### 13.6 product composition stop condition + +다음 중 하나라도 없으면 product realtime을 composition하지 않는다. + +```text +exact external package event descriptor +exact endpoint/operation descriptor +feature mapper +feature effect mapping +scope mode +recovery mode +selected transport +kill switch contribution +browser lifecycle owner +``` + +common source가 존재한다는 이유로 production bootstrap에 연결하지 않는다. + +### 13.7 host topology + +application runtime당 `RealtimeRuntimeHost` 하나다. + +```ts +export interface RealtimeRuntimeHost { + acquire(subscription: RealtimeSubscriptionRequest): RealtimeSubscriptionLease; + start(): Promise; + stop(reason: RuntimeStopReason): Promise; + dispose(): Promise; +} +``` + +transport connection은 stream registration 단위로 공유한다. component/route마다 socket을 만들지 않는다. + +### 13.8 single-writer rule + +동일 stream/scope에는 active delivery writer가 하나다. + +```text +SSE or WebSocket writer + XOR +bounded Polling writer +``` + +handoff: + +```text +old writer admission close +-> old in-flight settle/cancel +-> checkpoint snapshot +-> new writer recovery barrier +-> new writer OPEN +``` + +live와 polling이 동시에 same effect를 적용하지 않는다. + +--- + +## 14. Connection·Reconnect·Resume + +### 14.1 state machine + +```ts +export type RealtimeConnectionState = + | "IDLE" + | "CONNECTING" + | "AUTHENTICATING" + | "OPEN_UNRESUMED" + | "RECOVERING" + | "OPEN" + | "BACKING_OFF" + | "PAUSED_OFFLINE" + | "PAUSED_HIDDEN" + | "DEGRADED" + | "FATAL" + | "STOPPED"; +``` + +`OPEN_UNRESUMED`에서 application event effect를 실행하지 않는다. + +### 14.2 connection success + +connection success는 다음만 뜻한다. + +```text +transport handshake completed +selected protocol accepted +connection generation current +``` + +다음을 뜻하지 않는다. + +```text +authenticated subscription active +event gap recovered +server state latest +checkpoint durable +application effect applied +``` + +### 14.3 reconnect policy + +exact default: + +```ts +export const DEFAULT_REALTIME_RECONNECT_POLICY = Object.freeze({ + baseDelayMs: 500, + maxDelayMs: 30_000, + maxAttempts: 10, + maxElapsedMs: 300_000, + stableOpenMs: 60_000, +}); +``` + +- algorithm: full jitter; +- server not-before hint: local delay lower bound; +- hint valid range: 0..60,000ms; +- remaining episode budget 초과 hint: reconnect 종료; +- attempts/exhausted: `DEGRADED`, explicit recovery action; +- automatic new episode cooldown: 300,000ms; +- valid event/heartbeat 또는 60s stable open 후 attempt counter reset; +- offline state에서는 timer 대신 `online` signal을 기다림. + +### 14.4 error classification + +reconnect candidate: + +```text +network close +provider unavailable +retryable server close +idle timeout +validated auth refresh-ready transition +``` + +fatal: + +```text +protocol mismatch +unknown required event version +schema/mapper runtime failure +forbidden +invalid endpoint +repeated recovery invariant failure +``` + +rate limited: + +- validated delay 사용; +- maximum delay/episode budget 안에서만 retry; +- no immediate retry storm. + +### 14.5 visibility policy + +```text +VISIBLE -> normal connection/recovery +HIDDEN -> 30,000ms grace +HIDDEN after grace -> close transport, PAUSED_HIDDEN +VISIBLE restore -> authoritative recovery before OPEN +``` + +hidden 상태에서 Polling fallback을 시작하지 않는다. Web Push는 별도 capability다. + +### 14.6 network hint + +`navigator.onLine`은 hint다. + +- `offline` event: new connection/retry 중지, active transport close; +- `online` event: immediate recovery admission one time; +- online인데 connect failure면 ordinary backoff; +- offline인데 existing request가 성공해도 network authority를 재정의하지 않음. + +### 14.7 resume authority + +resume mode는 existing `RealtimeRecoveryProfile` literal을 유지한다. + +```text +CURSOR +SNAPSHOT_ONLY +SESSION_REBUILD +``` + +frontend owns: + +- checkpoint storage lifecycle; +- connection generation; +- duplicate/gap detection; +- barrier ordering; +- snapshot application ordering. + +external package/provider owns server cursor/replay/snapshot semantics. Frontend는 cursor를 decode/modify하지 않는다. + +### 14.8 recovery barrier + +`CURSOR`: + +```text +open transport +-> send/attach opaque checkpoint through selected adapter +-> replay begins +-> validate/apply events sequentially +-> replay terminal barrier +-> OPEN +``` + +`SNAPSHOT_ONLY`: + +```text +open transport or hold/buffer +-> authoritative snapshot query +-> scope current check +-> apply snapshot +-> release buffered event barrier +-> OPEN +``` + +`SESSION_REBUILD`: + +```text +close old stream +-> rebuild feature input +-> reset feature state +-> fresh subscription +-> OPEN +``` + +barrier가 `NONE`인 snapshot profile도 connection open만으로 latest라고 표시하지 않는다. snapshot operation 성공이 필요하다. + +### 14.9 checkpoint record + +```ts +export interface RealtimeCheckpointRecord { + readonly recordVersion: 1; + readonly streamId: string; + readonly scopeBinding: string; + readonly connectionGeneration: number; + readonly opaqueCheckpoint: string; + readonly lastAppliedEventIdentity: string; + readonly writtenAt: string; + readonly expiresAt: string; + readonly contractPackageDigest: `sha256:${string}`; +} +``` + +bounds: + +```text +checkpoint UTF-8 <= 4,096 bytes +record encoded <= 16,384 bytes +TTL <= 7 days +records per stream/scope = 1 +``` + +credential, endpoint, raw account ID를 넣지 않는다. + +### 14.10 gap handling + +다음은 gap이다. + +- expected sequence보다 큼; +- same sequence different fingerprint; +- cursor expired; +- dedupe window overflow; +- queue overflow; +- checkpoint version/package mismatch; +- connection generation conflict. + +처리: + +```text +new event admission stop +-> queued unapplied delta discard +-> state = RECOVERING +-> authoritative snapshot/session rebuild +-> checkpoint replace after effect +-> OPEN +``` + +gap 이후 delta를 계속 best-effort 적용하지 않는다. + +### 14.11 SSE specifics + +- Fetch streaming adapter reuse; +- response status/media validate; +- maximum line/event bytes는 common event bound; +- UTF-8 streaming decode; +- incomplete terminal event discard; +- heartbeat/comment는 payload event가 아님; +- idle deadline 45s default; +- total connection deadline 없음, lifecycle controls close; +- server `retry` hint is lower bound, max 60s; +- `Last-Event-ID` usage is adapter/profile-owned, opaque only. + +### 14.12 WebSocket specifics + +- exact URL/profile from installed endpoint; +- binary/text mode fixed per registration; +- subprotocol allowlist exact; +- outbound command disabled unless separate registered protocol; +- max event bytes 64KiB baseline; +- bufferedAmount high watermark 256KiB for outbound control messages; +- heartbeat expected 30s, idle timeout 45s when protocol declares heartbeat; +- browser WebSocket API가 backpressure를 보장한다고 가정하지 않음; +- receive queue hard bounds are common runtime authority; +- close code/reason raw text를 UI/log에 노출하지 않음. + +### 14.13 bounded polling specifics + +- existing registered terminal query operation only; +- visible page only; +- default interval 30s; +- minimum 5s, maximum 5m; +- one in-flight request; +- next schedule after previous settlement; +- hidden/offline pause; +- full jitter ±10% schedule spread; +- Query retry disabled, HTTP transport owns attempt; +- polling result enters same mapper/scope/result budget; +- polling is freshness policy, not realtime delivery guarantee. + +--- + +## 15. Event validation과 subscription cleanup + +### 15.1 hard limits + +existing constants are normative. + +```text +max event bytes 64 KiB +max payload depth 16 +max payload nodes 4,096 +max queue events 256 +max queue bytes 4 MiB +max dedupe entries 2,048 +max dedupe bytes 4 MiB +dedupe TTL 10 min +``` + +feature contribution can reduce but not raise them. + +### 15.2 validation pipeline + +```text +raw frame +-> frame byte bound +-> protocol decode +-> common envelope validation +-> stream registration lookup +-> event type membership check +-> package payload validator +-> feature mapper +-> scope binding/generation check +-> duplicate/sequence/gap decision +-> queue admission +-> sequential effect +-> effect success +-> checkpoint transaction commit +-> subscriber notification +``` + +순서를 바꾸지 않는다. + +### 15.3 envelope/payload failure + +```text +MALFORMED_FRAME +EVENT_TOO_LARGE +UNKNOWN_EVENT_TYPE +EVENT_NOT_ALLOWED_FOR_STREAM +ENVELOPE_SCHEMA_INVALID +PAYLOAD_SCHEMA_INVALID +VALIDATOR_RUNTIME_FAILURE +MAPPING_CONTRACT_VIOLATION +SCOPE_PROTOCOL_VIOLATION +``` + +unknown event를 generic object로 application에 전달하지 않는다. + +state-bearing event failure: + +```text +admission stop -> recovery +``` + +ephemeral notification failure: + +```text +drop + bounded observation +``` + +### 15.4 duplicate/conflict + +same event identity + same semantic fingerprint: + +```text +duplicate -> no effect, no checkpoint regression +``` + +same identity + different fingerprint: + +```text +EVENT_CONFLICT -> recovery +``` + +lower sequence but unknown dedupe entry: + +```text +STALE_EVENT -> drop only when registration semantics allow +otherwise recovery +``` + +### 15.5 queue + +- admission count/bytes atomic in host; +- event processing sequential per stream; +- different streams can progress independently; +- queue overflow triggers recovery, not oldest-drop; +- no unbounded `Promise.then` chain retained after settlement; +- queued item owns immutable mapped event and byte estimate; +- shutdown aborts admission and drains up to 2s, then drops unapplied items without checkpoint. + +### 15.6 effect types + +`INVALIDATE_TOPICS`: + +```text +registered opaque topics only +-> local invalidation coordinator +-> active Query bounded refetch +``` + +`APPLY_AUTHORITATIVE_DELTA`: + +- feature-owned reducer; +- current immutable snapshot and revision required; +- deterministic/no throw; +- result size budget; +- failure triggers recovery; +- partial delta not stored as event history. + +`EPHEMERAL_NOTIFICATION`: + +- no server-state authority; +- bounded UI notification; +- missed notification does not require state recovery unless separately marked state-bearing. + +### 15.7 checkpoint commit ordering + +금지: + +```text +checkpoint write +-> application effect +``` + +필수: + +```text +validate/map +-> effect commit +-> scope current recheck +-> checkpoint transaction commit +-> notify +``` + +checkpoint write failure after effect: + +- event effect success를 rollback할 수 없음; +- state `RECOVERING`; +- next connection uses authoritative snapshot; +- same delta replay에 reducer idempotency를 가정하지 않음. + +### 15.8 subscription lease + +```ts +export interface RealtimeSubscriptionLease { + readonly leaseId: string; + readonly streamId: string; + readonly state: "ACTIVE" | "RELEASED"; + release(): void; +} +``` + +key: + +```text +streamId + scope generation + subscription projection identity +``` + +ref-count behavior: + +```text +0 -> 1 : server/transport subscription admission +1 -> N : local subscriber only +N -> 1 : local release +1 -> 0 : 5s grace +0 after grace: unsubscribe/connection policy evaluation +``` + +### 15.9 cleanup triggers + +immediate release: + +- explicit component cleanup; +- route removal; +- feature disable; +- scope fence/logout; +- application shutdown; +- fatal protocol failure. + +grace release: + +- ordinary React remount/navigation; +- StrictMode setup-cleanup-setup; +- short route transition. + +scope fence는 grace를 사용하지 않는다. + +### 15.10 React bridge + +component uses feature hook only. + +```text +Component +-> useFeatureRealtime +-> acquire lease in effect +-> cleanup release +-> read external store via useSyncExternalStore +``` + +component does not: + +- open/close socket; +- store cursor; +- schedule reconnect; +- parse event; +- call QueryClient directly. + +### 15.11 StrictMode + +required behavior: + +```text +setup A acquire +cleanup A release -> grace starts +setup B acquire within grace -> same runtime retained +after final cleanup -> 5s -> actual unsubscribe +``` + +listener/ref count cannot go negative. release is idempotent. + +### 15.12 bfcache + +`pagehide persisted=true`: + +- new event effect admission close; +- transport close; +- checkpoint remains; +- lease registry retained in suspended state. + +`pageshow persisted=true`: + +- release manifest refresh; +- scope current check; +- authoritative resume; +- leases reactivate after OPEN; +- duplicate listeners/connections 없음. + +--- + +## 16. Generic Web Worker + +### 16.1 capability boundary + +Generic CPU Worker is separate from: + +```text +OPFS dedicated worker +Service Worker +Web Push handler +SharedWorker +worklet +``` + +existing OPFS worker source is not moved into generic runtime. + +### 16.2 selection condition + +Generic Worker remains `NOT_SELECTED` until: + +- measured main-thread task exceeds named interaction budget; +- task is CPU-bound; +- input/output/cancellation owner exists; +- safe main-thread fallback is defined or unsupported state is accepted; +- static `InstalledWebWorkerContribution` exists. + +network I/O task, ordinary JSON validation, small mapper work를 worker로 옮기지 않는다. + +### 16.3 source layout + +```text +src/application/ports/web-worker/ + worker-task-port.ts +src/contracts/web-worker.ts +src/adapters/web-worker/ + web-worker-runtime.ts + web-worker-client.ts + web-worker-protocol.ts + web-worker-entry.ts + web-worker-task-registry.ts +``` + +application port is semantic task-specific, not generic arbitrary function execution. + +### 16.4 installed contribution + +```ts +export interface InstalledWebWorkerContribution { + readonly taskGroupId: string; + readonly tasks: readonly InstalledWorkerTask[]; + readonly fallback: "MAIN_THREAD_CHUNKED" | "UNSUPPORTED"; +} + +export interface InstalledWorkerTask { + readonly taskId: string; + readonly taskVersion: 1; + readonly maximumInputBytes: number; + readonly maximumOutputBytes: number; + readonly deadlineMs: number; +} +``` + +bounds: + +```text +task groups max 16 +tasks per group 32 +input default 1 MiB +input hard 8 MiB +output default 1 MiB +output hard 8 MiB +deadline default 5 s +deadline hard 30 s +``` + +### 16.5 worker topology + +- task group당 worker 1개; +- baseline pool size 1; +- same group FIFO; +- groups can run independently; +- max active groups 4; +- no shared global pool; +- first task에서 lazy create; +- idle 60s with no active/queue -> terminate; +- next task creates new generation. + +### 16.6 protocol V1 + +```ts +export type WorkerRequestMessage = Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "EXECUTE"; + taskId: string; + taskVersion: 1; + requestId: string; + workerGeneration: number; + deadlineEpochMs: number; + payload: unknown; +}>; + +export type WorkerCancelMessage = Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "CANCEL"; + requestId: string; + workerGeneration: number; +}>; + +export type WorkerResponseMessage = + | Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "SUCCESS"; + requestId: string; + workerGeneration: number; + payload: unknown; + }> + | Readonly<{ + protocol: "CA_WEB_WORKER_V1"; + kind: "FAILURE"; + requestId: string; + workerGeneration: number; + code: WorkerFailureCode; + }>; +``` + +### 16.7 failure code + +```text +TASK_UNKNOWN +VERSION_UNSUPPORTED +INPUT_INVALID +INPUT_TOO_LARGE +OUTPUT_INVALID +OUTPUT_TOO_LARGE +QUEUE_FULL +DEADLINE_EXCEEDED +CANCELLED +CRASHED +TRANSFER_FAILED +STALE_RESULT +RUNTIME_PROTOCOL_FAILURE +``` + +```ts +export type WorkerFailureCode = + | "TASK_UNKNOWN" + | "VERSION_UNSUPPORTED" + | "INPUT_INVALID" + | "INPUT_TOO_LARGE" + | "OUTPUT_INVALID" + | "OUTPUT_TOO_LARGE" + | "QUEUE_FULL" + | "DEADLINE_EXCEEDED" + | "CANCELLED" + | "CRASHED" + | "TRANSFER_FAILED" + | "STALE_RESULT" + | "RUNTIME_PROTOCOL_FAILURE"; +``` + +native error message/stack is not posted to page. + +### 16.8 queue bounds + +```text +queued tasks per group 32 +queued encoded bytes 16 MiB +active tasks per group 1 +waiters per request 1 +``` + +queue full is a local typed failure. It does not create another worker. + +### 16.9 cancellation + +queued: + +- remove immediately; +- settle `CANCELLED`; +- no worker message. + +active: + +```text +send CANCEL +-> cooperative task checks signal +-> 250ms grace +-> not settled: terminate worker +-> all active/queued in same generation settle CRASHED/CANCELLED +-> next task creates new generation subject to restart budget +``` + +### 16.10 crash/restart budget + +```text +maximum restarts = 3 per 5 minutes per task group +fourth crash -> group FATAL +manual retry -> new 5-minute window only after user action/page reload +``` + +crash does not retry completed/unknown task automatically. + +### 16.11 structured clone/transfer + +- message schema validates cloned shape; +- credentials, AbortSignal, class instance, function, DOM node prohibited; +- ArrayBuffer transfer allowed only when task declares ownership transfer; +- sender must treat transferred buffer as detached immediately; +- output transfer is validated after receive; +- SharedArrayBuffer baseline prohibited; +- transferable list count max 16. + +### 16.12 stale result fence + +result is accepted only when all match. + +```text +requestId +workerGeneration +current task lease +caller scope generation +not cancelled +before deadline +``` + +stale result is dropped and observed. Query/UI is not updated. + +### 16.13 worker network prohibition + +worker entry cannot import HTTP adapter or use `fetch`, WebSocket, EventSource, credential/session adapter. + +CPU task requiring remote input: + +```text +page/application HTTP +-> validate/map/bound payload +-> Worker task +-> validate result +-> application/UI +``` + +### 16.14 main-thread fallback + +`MAIN_THREAD_CHUNKED` only: + +- same semantic task interface; +- chunk budget max 8ms; +- yield via scheduler/MessageChannel adapter; +- same total deadline; +- same cancellation/result validator; +- input hard max reduced to 1MiB; +- not presented as equivalent performance guarantee. + +`UNSUPPORTED` returns typed unavailable; it does not execute synchronously. + +--- + +## 17. Service Worker lifecycle + +### 17.1 one physical registration + +same scope has one worker entry. + +```text +PWA lifecycle +verified static asset fetch +Web Push event/click +optional sync wake-up +``` + +separate registration prohibited. + +```text +src/adapters/service-worker/service-worker-entry.ts +``` + +existing Web Push service-worker runtime becomes a handler factory inside this entry. + +### 17.2 source layout + +```text +src/contracts/service-worker.ts +src/adapters/service-worker/ + service-worker-entry.ts + service-worker-protocol.ts + service-worker-page-controller.ts + service-worker-lifecycle.ts + service-worker-static-assets.ts + service-worker-sync-handler.ts + service-worker-removal.ts +src/bootstrap/register-service-worker.ts +``` + +### 17.2.1 TypeScript execution realms + +main DOM, generic Web Worker와 Service Worker는 같은 TypeScript global lib를 공유하지 않는다. + +```text +tsconfig.app.json lib = DOM + ES + +tsconfig.web-worker.json lib = WebWorker + ES + include generic worker entry/runtime only + +tsconfig.service-worker.json lib = WebWorker + ES + include service-worker entry/handlers only +``` + +- worker tsconfig에서 DOM global type을 사용하지 않는다; +- service-worker tsconfig에서 React/presentation/bootstrap page code를 include하지 않는다; +- shared protocol file은 DOM/WebWorker global type 대신 structural types만 사용한다; +- root `tsconfig.json` project references에 두 config를 추가한다; +- `skipLibCheck`로 realm collision을 숨기지 않는다. + +### 17.2.2 deterministic two-pass build + +Service Worker가 exact hashed app asset 목록을 compile-time으로 가져야 하므로 build 순서를 다음으로 고정한다. + +```text +1. clean dist and .generated/frontend-runtime +2. generate contractSet + build-info source +3. Vite app build (emptyOutDir=true) +4. scan app dist and generate static asset source +5. ACTIVE mode일 때 Vite Service Worker build (emptyOutDir=false) +6. generate Release Manifest V2/build manifest +``` + +source: + +```text +scripts/build-frontend.ts +scripts/generate-contract-set.ts +scripts/generate-service-worker-assets.ts +vite.service-worker.config.ts +.generated/frontend-runtime/contract-set.ts +.generated/frontend-runtime/service-worker-build-info.ts +.generated/frontend-runtime/service-worker-assets.ts +``` + +Service Worker build rules: + +- input exactly `src/adapters/service-worker/service-worker-entry.ts`; +- output exactly `dist/service-worker.js` relative to deployed app root; +- ES module format; +- `inlineDynamicImports=true`; +- no additional worker chunk; +- sourcemap off by default; +- minification uses same pinned Vite toolchain; +- `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`, `null` mode does not run active worker build; +- generated asset source excludes `service-worker.js` itself and `index.html`. + +static delivery requirement: + +```text +service-worker.js Cache-Control = no-cache +runtime config/release manifest = no-store +hashed assets = immutable +``` + +runtime does not fetch hosting policy to prove it. Static delivery configuration owns this requirement. + +### 17.3 static selection modes + +#### `ACTIVE` + +handlers contain one or more unique values. + +```text +WEB_PUSH +PWA_STATIC_ASSETS +OFFLINE_SYNC_WAKEUP +``` + +worker entry and register code are included. + +#### `REMOVE_REGISTRATION` + +- worker entry not included; +- cleanup controller included; +- exact registration scope/script ownership check; +- unregister only; +- caches retained. + +#### `PURGE_OWNED_RESOURCES` + +- repeat unregister check; +- exact owned caches/metadata purge; +- outbox/OPFS/file data untouched. + +#### `null` + +- entry and cleanup code absent; +- registration lookup 0; +- Cache Storage access 0. + +transition: + +```text +ACTIVE +-> REMOVE_REGISTRATION for at least one deployed release +-> PURGE_OWNED_RESOURCES after rollback retention +-> null +``` + +### 17.4 exact ownership check + +expected: + +```text +scope = new URL(routerBasePath, location.origin).href +script = new URL("service-worker.js", scope).href +``` + +`getRegistration(routerBasePath)` result is owned only if: + +- registration.scope exact equals expected scope; +- every present installing/waiting/active worker scriptURL is same-origin; +- at least one present worker scriptURL exact equals expected script; +- no present worker has different script URL. + +ownership mismatch means no unregister/purge. + +### 17.5 registration timing + +```text +Runtime Config valid +-> Release Manifest/contractSet valid +-> base composition +-> React first committed effect +-> PostMountRuntimeStarter.start +-> ACTIVE/cleanup mode action +``` + +`ACTIVE` registration call: + +```ts +const expectedScope = new URL(routerBasePath, location.origin); +const scriptUrl = new URL("service-worker.js", expectedScope); + +navigator.serviceWorker.register(scriptUrl, { + scope: expectedScope.pathname, + type: "module", + updateViaCache: "none", +}); +``` + +StrictMode repeat returns same in-flight promise. + +### 17.6 runtime override `DISABLED` + +static `ACTIVE` + config disabled: + +```text +new register 0 +worker message/update timer 0 +sync register 0 +push handler activation 0 +exact-owned registration lookup 1 +owned registration unregister <= 1 +owned cache delete 0 +``` + +failure code `DISABLE_CLEANUP_FAILED`; capability remains inactive. next clean navigation may retry once, no loop. + +### 17.7 protocol identity + +```text +serviceWorkerProtocolVersion = 1 +cacheSchemaVersion = 1 +buildId +releaseId +contractSetDigest +staticAssetSetDigest +``` + +page and worker read the same compile-time virtual build-info module. + +### 17.8 message protocol + +```text +PAGE_HELLO +WORKER_HELLO_ACK +UPDATE_READY +ACTIVATE_REQUEST +ACTIVATE_ACCEPTED +ACTIVATE_REJECTED +CLIENT_DRAIN_REQUEST +CLIENT_DRAINED +ACTIVATED_RELOAD_REQUIRED +CACHE_RESET_REQUEST +CACHE_RESET_RESULT +SYNC_WAKE_OBSERVED +``` + +common fields: + +```text +protocolVersion = 1 +messageId = UUID +sourceBuildId +targetBuildId optional +nonce optional +``` + +origin/source client ID, exact message keys, runtime schema and one-time nonce validation required. + +### 17.9 install + +without static asset handler: + +- Cache Storage open 0; +- lifecycle/push/sync handlers register only. + +with static assets: + +```text +1. embedded manifest validate +2. candidate cache open +3. fetch concurrency 4 +4. status/content-type/bytes/integrity verify +5. candidate marker write +6. all success -> install resolve +7. any failure -> candidate cache delete and install reject +``` + +bounds: + +```text +assets count 256 +single asset 2 MiB +asset set 5 MiB +concurrency 4 +install deadline 60 s +``` + +partial candidate is never used. + +### 17.10 waiting update + +waiting worker sends `UPDATE_READY` to controlled clients. + +it does not call `skipWaiting()` itself. + +page blocks activation when: + +- dirty form; +- active mutation; +- effect unknown recovery; +- offline command `SENDING`; +- unresolved local file write; +- incompatible protocol tuple. + +### 17.11 client drain activation + +```text +page creates one-time nonce +-> ACTIVATE_REQUEST(expected tuple) +-> waiting worker validates +-> CLIENT_DRAIN_REQUEST to all controlled window clients +-> each client closes new mutation/realtime/offline admission +-> active effects settle +-> CLIENT_DRAINED(nonce) +-> all clients within 30s +-> ACTIVATE_ACCEPTED +-> waiting worker skipWaiting +-> activate event +-> no clients.claim +-> ACTIVATED_RELOAD_REQUIRED +-> clean page user/guarded reload once +``` + +one missing client rejects automatic activation. + +### 17.12 no clients.claim + +baseline does not call `clients.claim()`. + +- first install page is not silently controlled; +- update does not mix new worker with old page without reload; +- current page becomes controlled after explicit clean reload; +- future claim adoption requires separate compatibility decision. + +### 17.13 first install + +Service Worker reload guard is distinct from chunk recovery. + +```text +storage key = SERVICE_WORKER_RELOAD_GUARD +value schema = { version: 1, oldBuildId, newBuildId, writtenAt } +maximum bytes = 512 +TTL = 10 minutes +``` + +`CHUNK_RELOAD_GUARD`를 재사용하지 않는다. Storage registry/codec이 exact key와 envelope를 소유한다. + +if active tuple matches current build but `navigator.serviceWorker.controller` is null: + +```text +state = RELOAD_TO_ENABLE +``` + +- no automatic reload; +- user action only when page clean; +- reload guard once per build; +- after reload `PAGE_HELLO` handshake required; +- handshake failure -> `INCOMPATIBLE`, capability inactive. + +### 17.14 update checking + +- no immediate repeated `registration.update()`; +- visible page at most once per 6 hours; +- manual action once independently rate-limited; +- hidden page no scheduled update; +- network failure does not fail product flow; +- controllerchange reload guard `(oldBuild,newBuild)` once. + +### 17.15 activate cleanup + +- candidate marker and tuple validate; +- current revision active; +- previous verified revision retain one; +- older owned cache delete; +- exact prefix + parser only; +- config/release/API cache found -> security violation and delete; +- unregister != cache cleanup. + +### 17.16 page controller interface + +```ts +export type ServiceWorkerStartOutcome = + | Readonly<{ kind: "ACTIVE"; buildId: string }> + | Readonly<{ kind: "RELOAD_TO_ENABLE" }> + | Readonly<{ kind: "UPDATE_WAITING" }> + | Readonly<{ kind: "DISABLED" }> + | Readonly<{ kind: "INCOMPATIBLE" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerActivationOutcome = + | Readonly<{ kind: "ACTIVATED_RELOAD_REQUIRED" }> + | Readonly<{ kind: "BLOCKED_DIRTY_CLIENT" }> + | Readonly<{ kind: "CLIENT_DRAIN_TIMEOUT" }> + | Readonly<{ kind: "NO_WAITING_WORKER" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerResetOutcome = + | Readonly<{ kind: "RESET"; cachesDeleted: number }> + | Readonly<{ kind: "NOT_CONTROLLED" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export interface ServiceWorkerRuntimeHost { + start(): Promise; + requestActivation(): Promise; + resetOwnedCaches(): Promise; + stop(): Promise; +} +``` + +`stop()` removes page listener/update timer. ordinary shutdown does not unregister. + +### 17.17 cleanup mode outcomes + +```ts +export type ServiceWorkerRemovalOutcome = + | Readonly<{ kind: "ABSENT" }> + | Readonly<{ kind: "UNREGISTERED" }> + | Readonly<{ kind: "PURGED"; cachesDeleted: number; metadataDeleted: number }> + | Readonly<{ kind: "OWNERSHIP_MISMATCH" }> + | Readonly<{ kind: "FAILED"; operation: "LOOKUP" | "UNREGISTER" | "PURGE" }>; +``` + +foreign resource count can be observed only as bounded count, no names. + +--- + +## 18. Cache Storage와 fetch policy + +### 18.1 existing runtime reuse + +```text +src/adapters/cache-storage/public-cache-policy.ts +src/adapters/cache-storage/public-response-cache-adapter.ts +``` + +no generic `CacheService`. + +### 18.2 cache name + +```text +ca-static-v1- +``` + +parser requires exact prefix, schema literal and 16 hex chars. + +### 18.3 embedded static asset manifest + +Vite virtual modules: + +```text +virtual:ca-service-worker-assets +virtual:ca-service-worker-build-info +``` + +```ts +export interface StaticAssetManifestV1 { + readonly schemaVersion: 1; + readonly buildId: string; + readonly releaseId: string; + readonly setDigest: `sha256:${string}`; + readonly assets: readonly Readonly<{ + url: string; + sha256: `sha256:${string}`; + bytes: number; + contentType: string; + }>[]; +} +``` + +worker does not fetch a manifest endpoint. + +### 18.4 manifest inclusion + +include only: + +- Vite immutable hashed JS/CSS assets; +- approved font/image assets with immutable hashed URL; +- worker-owned static metadata. + +exclude: + +```text +index.html +runtime config +release manifest +API response +user/account data +telemetry/auth endpoint +source map unless explicitly public +unhashed mutable asset +cross-origin opaque response +``` + +### 18.5 fetch classification order + +```text +non-GET -> network passthrough +navigation request -> network-only +runtime config URL -> network-only +release manifest URL -> network-only +exact static asset manifest hit -> verified cache-first +all other requests -> network passthrough +``` + +API base can be `/`; exact static hit is evaluated before generic network passthrough. + +### 18.6 cache hit validation + +- exact URL; +- method GET; +- only build-generated query if manifest contains it; +- cached marker matches set digest; +- status 200; +- content type exact normalized match; +- no opaque response; +- expected byte count; +- install-time integrity verified. + +invalid hit is deleted and treated as release mismatch/network fetch. It is not returned stale. + +### 18.7 cache miss + +runtime fetch success is returned but not written into active cache. Static cache population is install-time only. + +missing immutable asset: + +```text +network failure/404 +-> release mismatch classification +-> page chunk recovery path +``` + +### 18.8 navigation + +baseline has no offline shell. + +- navigation network-only; +- cached `index.html` fallback prohibited; +- network error uses browser/app boot failure; +- stale HTML and new assets/contractSet never intentionally mix; +- installability/offline document is a separate product capability. + +### 18.9 quota + +- asset set hard max 5MiB; +- `StorageManager.estimate()` is advisory only; +- actual `QuotaExceededError` is authoritative; +- install quota failure deletes candidate; +- existing current/previous cache retained; +- no browser file/OPFS/outbox cleanup to make room; +- user data has higher priority than recomputable static cache. + +### 18.10 reset + +`CACHE_RESET_REQUEST` requires: + +- controlled same-origin client; +- current protocol; +- one-time nonce; +- user/incident action; +- owned prefix parser. + +reset deletes static caches only. registration remains. reload after reset is user/guarded action. + +--- + +## 19. Offline Command와 Background Sync + +### 19.1 capability status + +baseline product selection remains `NOT_SELECTED`. + +reference runtime can be implemented without composing a product operation. It must not create DB/listener when unselected. + +### 19.2 scope + +Offline Command is for durable user command retry after foreground interruption. It is not: + +- Query persistence; +- arbitrary request cache; +- background execution guarantee; +- exactly-once guarantee; +- event queue; +- file upload checkpoint replacement. + +### 19.3 external prerequisite consumption + +an operation is queueable only when external package contribution provides: + +```text +retrySemantics = KEYED +commandRecovery non-null +stable input validator/projection +stable operation identity +runtime validator for terminal result/problem +``` + +frontend does not define server idempotency or inspect protocol. + +### 19.4 source layout + +```text +src/application/ports/offline-command/ + offline-command-port.ts +src/contracts/offline-command.ts +src/adapters/offline-command/ + offline-command-codec.ts + indexeddb-offline-command-repository.ts + offline-command-drain.ts + offline-command-lifecycle.ts + offline-command-summary.ts +``` + +existing generic IndexedDB runtime is reused as dataset backend. + +### 19.5 installed contribution + +```ts +export interface InstalledOfflineCommandContribution { + readonly datasetId: "OFFLINE_COMMANDS_V1"; + readonly operations: readonly InstalledOfflineOperation[]; +} + +export interface InstalledOfflineOperation { + readonly operationId: string; + readonly contractPackageId: string; + readonly maximumRequestBytes: number; + readonly retentionClass: "STANDARD_7D"; +} +``` + +bounds: + +```text +operations 64 +single request 256 KiB +hard single request 1 MiB +records 1,000 +dataset bytes 50 MiB +``` + +### 19.6 record + +```ts +export interface OfflineCommandRecordV1 { + readonly recordVersion: 1; + readonly commandId: string; + readonly operationId: string; + readonly contractPackageId: string; + readonly contractPackageVersion: string; + readonly contractPackageDigest: `sha256:${string}`; + readonly scopePartition: string; + readonly requestDigest: `sha256:${string}`; + readonly requestPayload: Uint8Array; + readonly idempotencyKey: string; + readonly state: OfflineCommandState; + readonly attempt: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly nextAttemptAt?: string; + readonly leaseOwner?: string; + readonly leaseExpiresAt?: string; + readonly terminalCode?: string; +} +``` + +```ts +export type OfflineCommandState = + | "PENDING" + | "LEASED" + | "FOREGROUND_REQUIRED" + | "SENDING" + | "RETRY_WAIT" + | "ACKED" + | "CONFLICT" + | "EFFECT_UNKNOWN" + | "EXPIRED"; +``` + +### 19.7 physical storage + +- opaque DB/dataset name from existing governance; +- account/session scope partition binding; +- transaction-complete is write success; +- record codec V1 strict; +- future schema fail closed/read-only; +- blocked/versionchange typed; +- no credential/token except opaque idempotency key; +- request payload minimized to exact package request projection; +- no raw URL/header. + +### 19.8 enqueue + +```text +scope current +-> operation allowlist +-> package identity current +-> package input validator +-> deterministic request encode +-> request bytes/digest +-> create idempotency key +-> dataset count/byte admission +-> one IDB transaction insert + counters +-> PENDING +``` + +same user intent duplicate uses feature/mutation coordinator before enqueue. Repository does not silently merge by payload digest. + +### 19.9 sender topology + +origin/account partition has one sender lease. + +```text +lease TTL 30s +renew every 10s +batch count 10 +batch window 30s +parallel send 1 +``` + +foreground tabs coordinate through existing cross-context hint and IndexedDB lease CAS. BroadcastChannel is hint, IDB lease is local authority. + +### 19.10 state transitions + +```text +PENDING -> LEASED -> SENDING +SENDING -> ACKED +SENDING -> RETRY_WAIT +SENDING -> CONFLICT +SENDING -> EFFECT_UNKNOWN +PENDING/RETRY_WAIT -> FOREGROUND_REQUIRED +PENDING/RETRY_WAIT/FOREGROUND_REQUIRED -> EXPIRED +``` + +invalid transitions are corruption. + +### 19.11 transaction boundaries + +before network: + +```text +IDB transaction: + verify current state + verify lease owner/live + increment attempt + state=SENDING + write operation attempt identity +commit +``` + +after terminal response: + +```text +validate/map/effect classify +-> IDB transaction terminal/retry state commit +-> lease release +``` + +### 19.12 crash recovery + +expired `LEASED` before send: + +```text +safe -> PENDING +``` + +expired `SENDING`: + +```text +EFFECT_UNKNOWN +``` + +never reset SENDING to PENDING automatically. + +### 19.13 foreground drain triggers + +```text +startup +online +visible +focus +manual +``` + +triggers coalesce into one scheduled drain. hidden page pauses new sends. active send result still settles. + +### 19.14 auth and package fence + +before each send: + +- scope current; +- credential READY; +- installed contract package identity exact matches record; +- operation still installed; +- request payload revalidated; +- idempotency key present; +- total deadline available. + +failure: + +```text +auth unavailable -> FOREGROUND_REQUIRED +package missing/version/digest mismatch -> FOREGROUND_REQUIRED(CONTRACT_PACKAGE_UNAVAILABLE) +scope stale -> old partition hidden, no send +schema invalid -> CONFLICT/terminal corruption review +``` + +### 19.15 retry + +retryable response/failure is package/HTTP runtime decision. + +```text +base delay 1s +max delay 5m +full jitter +attempt cap 10 +``` + +Retry-After is lower bound up to 5m. + +never retry: + +- contract/schema/mapper failure; +- effect maybe applied; +- auth unavailable; +- conflict; +- expired retention. + +attempt cap -> `FOREGROUND_REQUIRED(RETRY_EXHAUSTED)`, not silent drop. + +### 19.16 effect unknown + +```text +automatic send stop +-> package recovery descriptor +-> foreground inspect when auth ready +-> committed: ACKED + canonical refresh +-> no effect: PENDING only after explicit transition +-> still unknown: EFFECT_UNKNOWN +``` + +manual retry button cannot resend EFFECT_UNKNOWN directly. + +### 19.17 retention + +```text +PENDING/RETRY_WAIT/FOREGROUND_REQUIRED max age 7d +ACKED payload delete immediately; safe summary 24h +CONFLICT/EFFECT_UNKNOWN payload max 30d +EXPIRED payload delete; safe summary 7d +``` + +30d 이후 unresolved payload를 자동 send하지 않는다. + +### 19.18 Background Sync baseline + +Background Sync is optional wake-up. + +```text +sync tag = ca-outbox-v1 +``` + +register only when: + +- service worker handler `OFFLINE_SYNC_WAKEUP` selected; +- `registration.sync` exists; +- pending/foreground-required record exists; +- no in-flight same tag; +- last registration >= 1 minute. + +sync handler: + +```text +1. inspect bounded outbox metadata +2. update wakeRequestedAt +3. post SYNC_WAKE_OBSERVED to controlled clients +4. send authenticated command = 0 +5. resolve handler +``` + +### 19.19 foreground fallback + +Background Sync unsupported/fails/never fires is normal. + +foreground triggers remain required. Queue semantics are identical with or without native Sync. + +### 19.20 future worker send explicitly excluded + +Service Worker authenticated send requires separate future design covering: + +- worker-safe credential owner; +- no credential persistence; +- refresh/revocation; +- package validators in worker bundle; +- effect reconciliation; +- logout/version fence; +- platform execution limitations; +- user recovery. + +until then worker send code is prohibited. + +### 19.21 safe summary + +```ts +export interface OfflineCommandSummary { + readonly commandId: string; + readonly operationLabelKey: string; + readonly state: OfflineCommandState; + readonly createdAt: string; + readonly nextAction: + | "WAIT" + | "OPEN_APP" + | "CHECK_STATUS" + | "RESOLVE_CONFLICT" + | "CONTACT_SUPPORT" + | "DISMISS"; +} +``` + +raw payload, idempotency key, package digest, account partition are not exposed to UI. + +### 19.22 removal + +```text +1. new enqueue close +2. drain stop +3. active send settle/lease expire +4. unresolved count inspect +5. product recovery/export decision +6. selected dataset only delete when safe +7. sync registration stop +8. SW sync handler remove +9. composition remove +``` + +unresolved user command is not silently purged with source deletion. + +--- +## 20. 공통 start·stop·dispose + +### 20.1 lifecycle interface + +```ts +export interface RuntimeLifecycle { + start(): void | Promise; + stop(reason: RuntimeStopReason): void | Promise; + dispose(): void | Promise; +} + +export type RuntimeStopReason = + | "APPLICATION_SHUTDOWN" + | "SCOPE_FENCED" + | "FEATURE_DISABLED" + | "HIDDEN_POLICY" + | "INCIDENT_CONTAINMENT"; +``` + +invariants: + +- `start()` idempotent; +- concurrent start returns same promise; +- start failure cleans partial resource and state `FAILED`; +- `stop()` is restartable quiesce unless capability says terminal; +- `dispose()` terminal; +- dispose after dispose no-op; +- start after dispose throws typed lifecycle error; +- concurrent stop/dispose has one settlement; +- cleanup error aggregation does not skip remaining cleanup. + +### 20.2 runtime lifecycle states + +```text +NEW +STARTING +RUNNING +STOPPING +STOPPED +FAILED +DISPOSING +DISPOSED +``` + +allowed transitions: + +```text +NEW -> STARTING -> RUNNING +STARTING -> FAILED +RUNNING -> STOPPING -> STOPPED +STOPPED -> STARTING +NEW/STOPPED/FAILED/RUNNING -> DISPOSING -> DISPOSED +``` + +`FAILED -> STARTING` automatic transition is prohibited. Explicit owner recovery creates a new runtime instance or calls a named reset method. + +### 20.3 application shutdown order + +```text +1. close new navigation/mutation/optional admission +2. stop Service Worker page controller/cleanup action +3. close Web Worker task admission +4. cooperative cancel/terminate Web Workers +5. stop realtime subscriptions/reconnect and drain +6. stop offline foreground drain and release lease +7. dispose client scope runtime +8. clear conditional validator store +9. dispose invalidation coordinator +10. cancel and clear QueryClient +11. close cross-context transport +12. dispose session subscription +13. telemetry flush up to deadline +14. diagnostics dispose +``` + +QueryClient를 realtime보다 먼저 clear하지 않는다. late realtime effect가 cleared cache를 다시 채우는 race를 막는다. + +### 20.4 browser lifecycle owner + +window listener owner: + +```text +src/adapters/platform/browser-lifecycle.ts +``` + +single listener set: + +```text +visibilitychange +online +offline +focus +pagehide +pageshow +beforeunload only while dirty state exists +``` + +capability별 direct window listener를 추가하지 않는다. + +```ts +export interface BrowserLifecycleSnapshot { + readonly visibility: "VISIBLE" | "HIDDEN"; + readonly connectivityHint: "ONLINE" | "OFFLINE"; + readonly pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED"; + readonly generation: number; +} +``` + +### 20.5 pagehide and bfcache + +`pagehide persisted=true`: + +- terminal dispose하지 않음; +- lifecycle state `SUSPENDED` projection; +- realtime transport close; +- offline new send pause; +- active Worker follows task cancellation policy; +- Query memory remains; +- Service Worker registration remains; +- no blocking telemetry flush. + +`pageshow persisted=true`: + +```text +1. browser lifecycle generation increment +2. Release Manifest V2 no-store refresh once +3. scope current check +4. active Query refetch +5. realtime authoritative recovery +6. offline expired lease recovery +7. duplicate listener/runtime check +``` + +manifest incompatible면 product runtime을 resume하지 않고 safe reload/recovery surface로 이동한다. + +### 20.6 unload non-guarantee + +다음을 `beforeunload`, `unload`, `sendBeacon` correctness로 처리하지 않는다. + +- command complete; +- checkpoint write; +- subscription unsubscribe guarantee; +- lease release guarantee; +- Service Worker activation acknowledgement; +- telemetry guaranteed delivery. + +correctness is transaction/TTL/recovery based. + +### 20.7 cleanup deadlines + +```text +realtime drain 2,000ms +worker cooperative stop 250ms per active group +telemetry flush 1,000ms +offline local lease release 2,000ms +Service Worker page stop no network wait +overall application stop 5,000ms +``` + +overall deadline expires: + +- remaining worker terminate; +- transport close; +- listener/timer remove; +- unresolved durable state left for recovery; +- no additional network retry. + +### 20.8 scope fence vs shutdown + +scope fence: + +- application remains alive; +- global Router/QueryClient object remains; +- account-bound data/listener/connection fenced; +- new scope READY after reset. + +shutdown: + +- all admission terminal close; +- no new scope; +- every runtime disposed. + +두 lifecycle을 같은 `clearEverything()`로 합치지 않는다. + +--- + +## 21. Security와 privacy + +### 21.1 trust boundary + +trusted build inputs: + +```text +exact installed contract package bytes +frontend source +embedded contractSet identity +embedded static asset manifest +``` + +untrusted runtime inputs: + +```text +Runtime Config bytes until validated +Release Manifest bytes until validated +HTTP response/problem +realtime frame/event +postMessage +IndexedDB historical record +Cache Storage response +URL/path/search/form input +``` + +trusted package does not make server response trusted. Runtime validation remains mandatory. + +### 21.2 secret prohibition + +must not contain credential/secret: + +```text +Runtime Config +Release Manifest +contractSet +Query key/value +Web Storage +IndexedDB query metadata +Cache Storage +Worker message +Service Worker message +realtime checkpoint diagnostics +URL +telemetry attributes +support reference +``` + +offline outbox may store opaque idempotency key but treats it as sensitive command identity: + +- no log; +- no UI; +- no cross-context message; +- delete with payload retention. + +### 21.3 generated package isolation + +contract package must: + +- have no required postinstall script; +- perform no network/DOM/listener side effect on import; +- expose descriptor/validator/type only; +- have no mutable singleton carrying user state; +- not reference `window`, `document`, `navigator`, `fetch`, WebSocket at runtime; +- not include credential helper; +- not provide user-facing error copy. + +violation blocks contribution composition. + +### 21.4 boot request security + +- same-origin Runtime Config/release manifest; +- redirect error; +- no credentials beyond same-origin browser policy; +- no-store; +- strict content type; +- byte bound; +- secret-name scan; +- safe error only; +- no arbitrary manifest URL from query/local storage. + +### 21.5 HTTP security + +- HTTPS outside local; +- final URL allowlist; +- redirect reject; +- `referrerPolicy=no-referrer`; +- credential patch allowlist; +- raw URL/query/body/header no logs; +- response byte bound; +- duplicate JSON key semantics not relied upon; +- prototype pollution keys rejected/stripped by validator/mapper; +- problem detail rendered through safe text; +- `dangerouslySetInnerHTML` forbidden. + +### 21.6 Router security + +- open redirect forbidden; +- return URL created only by same-origin route builder; +- route param validation before domain ID; +- frontend access guard not authorization proof; +- chunk URL not user-generated; +- search data not interpolated into raw HTML/style/script; +- malformed route causes local failure before network. + +### 21.7 Query/cache security + +- cache hit not authorization; +- old scope value hidden immediately at FENCED; +- raw IDs/URL/document/message absent from key; +- credential/header/trace absent from value; +- query persistence disabled; +- production devtools governed by build profile; +- optimistic layer excludes full command body/token. + +### 21.8 Cross-context security + +- no data/query key; +- opaque topic/source; +- protocol/epoch validation; +- `storageArea` exact check; +- message bytes bound; +- foreign/malformed event ignored; +- channel name not based on account ID; +- event is hint, not authority. + +### 21.9 Realtime security + +- exact endpoint/profile allowlist; +- frame/event bounds; +- envelope/payload validation; +- scope binding; +- unknown event safe recovery; +- cursor/endpoint/push subscription absent from diagnostics; +- no arbitrary WebSocket outbound command; +- open connection not auth success; +- raw close reason not user/log. + +### 21.10 Worker security + +- module worker static URL; +- Blob/data URL worker forbidden; +- `eval`, `Function`, dynamic `importScripts` forbidden; +- no credential/network; +- cloned payload validator; +- input/output bounds; +- generation/request fence; +- SharedArrayBuffer disabled; +- WASM only as separate exact-digest/memory-bound task capability. + +### 21.11 Service Worker security + +- same-origin scope/script; +- source client ID/origin/nonce validation; +- no blind skipWaiting/clients.claim; +- API/private response cache forbidden; +- opaque cache forbidden; +- owned prefix/parser cleanup only; +- global variable not durable authority; +- push payload validator and notification registry; +- notification URL registered same-origin route only; +- foreign registration never unregistered. + +### 21.12 IndexedDB/outbox privacy + +- opaque physical DB name; +- scope binding; +- historical codec validation; +- package digest recheck; +- quota/corrupt/future schema separated; +- logout old partition hidden immediately; +- same-origin encryption not XSS authorization boundary; +- payload minimization; +- retention ceiling; +- unresolved command export/recovery product-owned. + +### 21.13 diagnostics redaction + +prohibited values: + +```text +raw URL/query +request/response body +header/token/cookie +account/resource ID +event payload/cursor +idempotency key +contract package source path +cache key/value +worker payload +file name/path +``` + +allowed dimensions are closed enums and bounded IDs declared in §22. + +--- + +## 22. Runtime observability + +### 22.1 ownership + +existing diagnostics/telemetry adapters are reused. + +```text +src/adapters/diagnostics/bounded-diagnostics.ts +src/adapters/telemetry/best-effort-telemetry.ts +src/contracts/diagnostics.ts +src/contracts/telemetry.ts +``` + +raw SDK is not exposed to feature/application. + +### 22.2 closed event families + +```text +frontend_boot +frontend_http +frontend_query +frontend_mutation +frontend_scope +frontend_realtime +frontend_worker +frontend_service_worker +frontend_offline_command +frontend_drop +``` + +new arbitrary event name is prohibited. Add a typed registry entry first. + +### 22.3 common attributes + +allowlisted: + +```text +operationCategory +outcome +failureKind +appEnv +buildId bucket/release reference +featureId +operationId declared stable ID +profileId +transportKind +browserEngine bucket +queueSize bucket +payloadSize bucket +attempt bucket +lifecycleState +``` + +not allowlisted: + +```text +URL +user/account/resource ID +request ID from server +cursor/checkpoint +package digest full value +idempotency key +message/error string +``` + +contract package digest may be represented only as `MATCH | MISMATCH | MISSING`, not tag value. + +### 22.4 HTTP observation + +one logical operation terminal event plus bounded physical attempt counters. + +```text +logical operation ID +attempt count bucket +terminal outcome +certainty +request/response size bucket +latency bucket +retry reason category +``` + +physical attempts do not each emit high-cardinality traces by default. + +### 22.5 Query observation + +```text +profileId +cache state: MISS | FRESH | STALE | REFRESH +result budget: ADMITTED | REJECTED +scope current: CURRENT | FENCED +``` + +query key/input/value absent. + +### 22.6 Mutation observation + +```text +duplicate policy +duplicate outcome +optimistic used boolean +effect certainty +invalidation result +recovery state +``` + +intent/idempotency identity absent. + +### 22.7 Realtime observation + +```text +streamId stable registry ID +eventType stable registry ID +transport +state transition +reconnect attempt bucket +queue bucket +recovery reason +validation outcome +effect outcome +``` + +cursor/payload/endpoint absent. + +### 22.8 Worker observation + +```text +taskGroupId +taskId +queue bucket +input/output size bucket +latency bucket +cancel/crash/restart outcome +fallback mode +``` + +payload and native stack absent. + +### 22.9 Service Worker observation + +```text +mode +handler set bitmask or closed enum +registration outcome +update state +client drain outcome +cache install/reset outcome +owned removal outcome +``` + +scope/script URL/cache name absent. ownership mismatch is a boolean/outcome only. + +### 22.10 Offline observation + +```text +state transition +attempt bucket +age bucket +queue count/byte bucket +lease outcome +terminal/recovery category +sync wake observed boolean +``` + +operation label uses stable operation ID; no payload/key/partition. + +### 22.11 bounded queue and failure + +telemetry is best effort. + +```text +maximum queued events 500 +maximum estimated bytes 1 MiB +flush batch 50 +shutdown flush 1s +drop oldest non-critical observation when full +never block product flow +never recursively report sink failure +``` + +security/contract fatal events can be mirrored to bounded local diagnostics but do not gain infinite retention. + +### 22.12 health semantics + +frontend does not expose one global `healthy` boolean. + +```ts +export type RuntimeHealth = + | "AVAILABLE" + | "DEGRADED" + | "UNAVAILABLE" + | "INCOMPATIBLE" + | "DISABLED"; +``` + +capability health is independent: + +- HTTP unavailable does not mark Router unavailable; +- realtime fatal can fall back to explicit stale/polling policy; +- Service Worker disabled does not fail product; +- offline outbox conflict does not mean IndexedDB runtime globally corrupt; +- contractSet mismatch is boot incompatible. + +--- + +## 23. 구현 검증 + +### 23.1 범위 + +이 절은 local/unit/component/integration/real-browser behavior를 정의한다. CI stage, workflow, promotion formula는 정의하지 않는다. + +### 23.2 Runtime Config/boot cases + +```text +valid V2 +unknown field reject +API_CONTRACT_VERSION in V2 reject +secret-like key reject +URL credential/hash/query reject +HTTPS rule +config 64KiB exact and +1 +manifest 1MiB exact and +1 +Content-Length lie +chunked overflow +invalid UTF-8 +invalid JSON/media/status +5s timeout +redirect reject +build/release/assets mismatch +contractSet canonical/digest mismatch +fixture excluded from contractSet +partial composition reverse cleanup +retry once/no loop +``` + +### 23.3 contract package consumer cases + +```text +only contribution file imports package +exact version/integrity +packageId/version/digest/source regex +runtime protocol mismatch +package duplicate/mismatch +duplicate operation/event +status sorted unique/subset +path/query/hash bound +validator missing/throw +fixture exact identity/events empty +product local fixture reject +package top-level side-effect fixture reject +``` + +### 23.4 HTTP cases + +```text +request validation before fetch +final invariant after auth patch +request 256KiB/default and hard +1 +response/problem byte bounds +redirect/media/status/body policy +required/optional/none body +success/problem validator +scope fence before/after response +safe/idempotent retry +keyed dispatch-loss no retry +never retry +full jitter deterministic source +Retry-After ceiling/deadline +401 no hidden replay +caller/route/scope/deadline cancellation +NOT_STARTED vs MAYBE_APPLIED +inspect recovery projection +``` + +### 23.5 Router cases + +```text +static route registration +lazy module +path/search codec duplicate/unknown/bounds +no direct fetch/QueryClient/adapter import +loader/action server-data reject +focus/scroll/title +route unmount lease cleanup +chunk release/contractSet recovery +reload guard once +dirty navigation/effect-unknown blocker +``` + +### 23.6 Query/scope cases + +```text +one QueryClient +fixed profile registry only +required measureResult +measure throw/non-finite/negative/overflow +item/byte ceiling +scope FENCED synchronous +old snapshot immediately false +cancel/clear/validator/realtime/offline exact ordering +late success discarded +identity bounds/active lease eviction +retry false +memory-only/no persistence +304 with/without current cache +``` + +### 23.7 Mutation cases + +```text +three duplicate policies +same input different scope not joined +join waiter bound +optimistic default none +layer count/single/total bytes +out-of-order commit/rollback +effect unknown no arbitrary rollback +success seed/invalidation ordering +local invalidation failure does not resend +form dirty state clearing only confirmed +``` + +### 23.8 Cross-context cases + +```text +registry bounds/fanout/fanin +no key/data on wire +BroadcastChannel primary +localStorage fallback/storageArea +same-tab local dispatch +duplicate/lower/gap +release/scope epoch +mutation coalescing +overflow full stale +logout/dispose leak +two-page Chromium/Firefox/WebKit semantics +``` + +### 23.9 Realtime cases + +```text +zero contribution no connection/listener +one host/multiple leases +OPEN_UNRESUMED no effect +recovery barrier +500ms/30s/10/5m/60s reconnect policy +offline wait +hidden 30s/no hidden poll +visible recovery +unknown/malformed/schema/mapper/scope +64KiB event +1 +queue/dedupe bounds +conflict/gap/expired cursor +single-writer live/poll handoff +effect before checkpoint +checkpoint failure after effect -> recovery +5s subscription grace +StrictMode acquire/release +logout/dispose/bfcache +``` + +### 23.10 Generic Worker cases + +```text +unselected no worker +protocol/version/generation +input/output bounds +queue count/bytes/FIFO +queued cancellation +active cooperative cancellation +250ms terminate +stale result fence +transfer ownership/detached buffer +crash 3/5m then fatal +idle 60s +fallback exact +no network/DOM/credential +``` + +### 23.11 Service Worker cases + +```text +null selection no lookup/register/cache +ACTIVE first-render idempotent registration +module/scope/updateViaCache +ACTIVE+DISABLED exact-owned unregister/cache retain +foreign registration ownership mismatch +ACTIVE -> REMOVE -> PURGE -> null +install all-or-nothing +asset count/item/set/integrity/media +waiting no blind skipWaiting +dirty/effect-unknown activation block +multi-client drain/30s missing client +no clients.claim +first install reload-to-enable +update 6h/manual rate +controllerchange guard +network-only navigation/config/manifest/API +exact static hit before API base root +unknown GET no runtime cache +current+previous retention +owned parser cleanup only +Web Push handler composition +future protocol mismatch +``` + +### 23.12 Offline/Sync cases + +```text +unselected no DB/listener +operation not KEYED/recovery missing reject +package identity/digest +request/count/dataset bounds +transaction complete +lease acquire/renew/release/takeover +LEASED expiry -> PENDING +SENDING expiry -> EFFECT_UNKNOWN +single sender/batch 10/30s +retry/backoff/attempt cap +auth/package/scope fence +ACKED payload delete +retention +unsupported Sync foreground works +Sync event sends zero command +wake message only +crash/reload restore +no credential persistence +removal unresolved command no silent purge +``` + +### 23.13 browser result vocabulary + +```text +SUPPORTED_AND_PASSED +SUPPORTED_AND_FAILED +UNSUPPORTED_WITH_FALLBACK_PASSED +UNSUPPORTED_WITHOUT_REQUIRED_CAPABILITY +ENVIRONMENT_UNAVAILABLE +``` + +unsupported is not ordinary pass. Background Sync requires foreground fallback pass. + +### 23.14 leak inspection + +lifecycle suite terminal checks: + +```text +active listeners = 0 except declared singleton +active timers = 0 +open generic workers = 0 +open realtime transports = 0 +subscription refs = 0 +queued worker/realtime task = 0 +active IDB transaction = 0 +owned abort controller settled +unhandled rejection = 0 +``` + +persistent Service Worker registration is inspected according to selection mode, not counted as generic leak. + +--- + +## 24. Source 배치와 migration + +### 24.1 existing files to modify + +```text +src/bootstrap/runtime-config-schema.ts +src/bootstrap/load-runtime-config.ts +src/bootstrap/load-release-manifest.ts +src/bootstrap/create-runtime-composition.ts +src/bootstrap/composition-root.ts +src/bootstrap/runtime-adapters.ts +src/bootstrap/runtime-application.tsx +src/bootstrap/main.tsx +src/contracts/env.ts +src/contracts/release-tokens.ts +src/contracts/storage-keys.ts +src/contracts/api-operations.ts +src/contracts/rest-profiles.ts +src/contracts/server-state.ts +src/contracts/server-state-scope.ts +src/contracts/query-invalidation.ts +src/contracts/realtime-events.ts +src/contracts/realtime-streams.ts +src/adapters/http/bounded-json.ts +src/adapters/http/client.ts +src/adapters/http/request-builder.ts +src/adapters/http/retry-policy.ts +src/adapters/query-cache/server-state-scope-runtime.ts +src/adapters/query-cache/tanstack-cache-coordinator.ts +src/adapters/cross-context-invalidation/browser-cross-context-host.ts +src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts +src/adapters/cross-context-invalidation/index.ts +src/presentation/adapters/query/application-query.ts +src/presentation/adapters/query/optimistic-layer-runtime.ts +src/presentation/routes/app-router.tsx +src/presentation/routes/navigation-policy.ts +src/presentation/routes/platform-route-codecs.ts +src/presentation/routes/route-codecs.ts +src/presentation/routes/route-contract.ts +src/presentation/routes/route-input.tsx +src/presentation/routes/route-runtime.tsx +src/features/installed-feature-contracts.ts +src/features/installed-feature-adapters.ts +src/features/installed-feature-runtimes.tsx +scripts/generate-build-manifest.ts +vite.config.ts +tsconfig.json +tsconfig.app.json +package.json +.gitignore +eslint.config.ts +vitest.config.ts +playwright.capabilities.config.ts +config/hosting/cache-policy.json +public/config.json +public/release-manifest.json +``` + +### 24.2 new common files + +```text +src/contracts/contract-set.ts +src/contracts/contract-set-canonical.ts +src/contracts/external-contract-runtime.ts +src/contracts/runtime-capabilities.ts +src/contracts/service-worker.ts +src/contracts/web-worker.ts +src/contracts/offline-command.ts +src/bootstrap/read-bounded-boot-json.ts +src/bootstrap/register-service-worker.ts +src/features/installed-contract-contributions.ts +src/features/installed-runtime-capabilities.ts +src/adapters/http/bounded-body-reader.ts +src/adapters/http/http-execution-v3.ts +src/adapters/http/http-effect-certainty.ts +src/adapters/http/http-contract-bridge.ts +src/adapters/platform/browser-lifecycle.ts +scripts/build-frontend.ts +scripts/generate-contract-set.ts +scripts/generate-service-worker-assets.ts +vite.service-worker.config.ts +tsconfig.web-worker.json +tsconfig.service-worker.json +.generated/frontend-runtime/contract-set.ts +.generated/frontend-runtime/service-worker-build-info.ts +.generated/frontend-runtime/service-worker-assets.ts +``` + +`.generated/frontend-runtime`은 build-time derived source다. 사람이 수정하지 않고 build 시작 시 전체 삭제 후 재생성한다. Git에 commit하지 않는다. + +### 24.3 optional source files + +Generic Worker selected only: + +```text +src/application/ports/web-worker/* +src/adapters/web-worker/* +``` + +Service Worker active/removal selected: + +```text +src/adapters/service-worker/* +``` + +Offline Commands selected only: + +```text +src/application/ports/offline-command/* +src/adapters/offline-command/* +``` + +### 24.4 feature files + +```text +src/features//contracts/-contract-contribution.ts +src/features//contracts/-mapper.ts +src/features//contracts/-realtime-contribution.ts +src/features//adapters/-http-gateway.ts +src/features//application/-api.ts +src/features//presentation/use-.ts +``` + +path token replacement is deterministic. + +```text + = InstalledContractContribution.featureId exactly + lowercase kebab-case regex already validated + + = external package name의 slash 뒤 segment + lowercase npm segment 그대로 +``` + +팀이 임의 abbreviation/pluralization을 선택하지 않는다. 파일 충돌이 나면 package contribution을 한 feature 안의 named subdirectory로 분리하되 token 자체를 바꾸지 않는다. + +### 24.5 architecture rule amendments + +static checks must reject: + +```text +@org-contracts import outside feature contract contribution +presentation direct fetch +presentation direct adapters/QueryClient +application React/TanStack/browser API +generic Worker fetch/WebSocket/EventSource +separate Service Worker registration +Service Worker API response cache +runtime deployed spec/registry lookup +product TEMPLATE_FIXTURE +Router loader/action server-data call +``` + +### 24.6 Runtime Config/manifest migration + +```text +RC-1 add V2 schema/parser and bounded reader +RC-2 support V1/V2 reader, current writer V1 +RC-3 generate contractSet and writer V2 +RC-4 ignore accepted V1 scalar only in V1 input +RC-5 all fixtures/public files V2 +RC-6 remove V1 parser/scalar after compatibility window +``` + +### 24.7 HTTP migration + +```text +HTTP-1 normalize current local reference fixture into contribution +HTTP-2 introduce external package contribution interface +HTTP-3 bridge current ApiOperationV2 reader +HTTP-4 add execution V3/outcome/certainty +HTTP-5 move feature gateway one operation at a time +HTTP-6 remove duplicate local path/status/type +HTTP-7 retire V2 writer after all installed operations V3 +``` + +### 24.8 Query migration + +```text +Q-1 fixed profile registry +Q-2 required measureResult for current reference definitions +Q-3 scope lifecycle FENCED/READY +Q-4 commit fence/result budget +Q-5 many-to-many invalidation registry +Q-6 mutation duplicate/optimistic exact policies +``` + +### 24.9 Realtime migration + +```text +RT-1 keep common source unchanged where possible +RT-2 external event contract bridge +RT-3 feature effect contribution +RT-4 optional runtime host integration +RT-5 no product selection -> no bootstrap composition +``` + +### 24.10 Service Worker migration/removal + +first adoption: + +```text +SW-1 protocol/build-info virtual module +SW-2 physical entry/handler composition +SW-3 static asset manifest/cache +SW-4 page controller/waiting/drain +SW-5 first-install/update lifecycle +``` + +removal: + +```text +Release N mode ACTIVE +Release N+1 mode REMOVE_REGISTRATION +Release N+2 mode PURGE_OWNED_RESOURCES after rollback window +Release N+3 null +``` + +### 24.11 optional source zero-side-effect + +optional source may exist as `AVAILABLE_NOT_COMPOSED`, but production bootstrap import is absent unless static selection references it. + +Vite module inventory must show: + +- unselected worker entry absent; +- unselected Service Worker active entry absent; +- unselected offline adapter absent from production runtime graph where tree-shaking/static import boundary permits; +- recipe/test source is not production import. + +### 24.12 sample/reference removal + +removing `src/features/reference-feature` must not remove common runtime. + +removal sequence: + +```text +reference feature directory +-> reference template contribution +-> reference route/messages/runtime registration +-> reference tests +-> no remaining import/string in production bundle +``` + +common external contract consumer interfaces remain. + +--- + +## 25. 구현 순서와 stop condition + +### 25.1 principle + +각 phase는 independently reviewable runtime increment다. API/backend contract authoring과 CI workflow는 이 순서에 포함하지 않는다. + +### Phase A — authority cleanup + +1. old generic design을 non-authoritative로 표시; +2. this document reference add; +3. external package ownership boundary document; +4. current source path inventory freeze. + +stop condition: + +- current repository snapshot/path가 문서와 다르면 후속 code 금지; +- contract package format이 library design과 다르면 contribution interface부터 amend. + +### Phase B — contract package consumer foundation + +1. `RuntimeValidator` adapter; +2. normalized contribution types; +3. installed contribution composer; +4. fixture vs external provenance; +5. validation/duplicate checks; +6. reference fixture normalization. + +exit: + +- package import one path; +- product fixture prohibited; +- no runtime network/package lookup. + +### Phase C — contractSet and boot V2 + +1. canonical bytes/digest; +2. Release Manifest V2 schema; +3. bounded boot reader; +4. Runtime Config V2; +5. contractSet boot check; +6. partial cleanup stack; +7. public fixture migration. + +exit: + +- scalar API contract version no longer writer source; +- boot body bounded/deadline; +- product mount gated by contractSet. + +### Phase D — HTTP execution V3 + +1. request/response hard ceilings; +2. package descriptor bridge; +3. final invariant after credential patch; +4. outcome/failure union; +5. total deadline/retry owner; +6. effect certainty; +7. current reference operation migration. + +exit: + +- direct/duplicate path/status schema removed for migrated operation; +- keyed uncertain command never automatic replayed. + +### Phase E — Router/Query/Mutation lifecycle + +1. Router no-server-data rule; +2. fixed Query profiles; +3. required measureResult; +4. scope FENCED/READY exact ordering; +5. commit fence; +6. invalidation many-to-many; +7. duplicate mutation coordinator; +8. bounded optimistic layers. + +exit: + +- old scope late result cannot commit; +- Query/Mutation retry false; +- local invalidation failure cannot resend command. + +### Phase F — Cross-context + +1. registry bounds; +2. protocol/epoch envelope; +3. BroadcastChannel; +4. localStorage fallback; +5. gap/full-stale; +6. scope/logout lifecycle. + +exit: + +- no data/key on wire; +- two-page semantics; +- disposal leak zero. + +### Phase G — Realtime product bridge + +1. external event validator bridge; +2. installed realtime contribution; +3. host lifecycle; +4. subscription lease bridge; +5. recovery/effect/checkpoint ordering; +6. no-selection zero side effect. + +stop condition: + +- no exact endpoint/event/recovery contribution -> remain `NOT_SELECTED`; +- common runtime is not enough to compose. + +### Phase H — Generic Worker optional + +execute only when a selected task exists. + +1. task port/protocol; +2. group worker runtime; +3. queue/cancel/crash; +4. fallback; +5. browser behavior. + +stop condition: + +- no profiling/owner/task contribution -> do not implement production entry. + +### Phase I — Service Worker reference runtime + +1. static selection/removal mode; +2. one physical entry; +3. protocol/registration; +4. static cache; +5. waiting/client drain; +6. first install/update; +7. runtime disabled unregister; +8. removal cleanup. + +exit: + +- no API/navigation cache; +- no blind activation/claim; +- persistent removal path proven. + +### Phase J — Offline Command optional + +execute only when product operation is selected. + +1. operation allowlist/record codec; +2. IDB repository; +3. foreground sender lease; +4. state transitions; +5. effect unknown recovery; +6. retention/summary; +7. optional Sync wake handler; +8. removal. + +stop condition: + +- no KEYED+recovery package descriptor -> remain `NOT_SELECTED`; +- no product conflict/recovery UX -> do not compose. + +### Phase K — final integration review + +1. lifecycle shutdown/bfcache; +2. security/redaction; +3. all behavior cases; +4. source/path/document consistency; +5. old compatibility reader removal only after no caller. + +--- + +## 26. 완료 기준 + +### 26.1 common + +- current repository path/architecture retained; +- no parallel platform layer; +- no backend/API/event source ownership duplication; +- no CI design included; +- optional unselected side effect zero; +- exact lifecycle/disposal; +- bounded input/output/queue/deadline; +- typed failure and uncertainty; +- current scope fence; +- secret/privacy rules; +- source/removal path. + +### 26.2 external contract consumer + +- exact immutable package pin; +- one import location; +- fixture/product provenance separated; +- normalized descriptor/validator; +- package validation bounds; +- local product schema duplication absent; +- runtime registry/deployed docs lookup absent; +- mapper boundary. + +### 26.3 contractSet/boot + +- V2 manifest; +- canonical length-prefixed digest; +- fixture excluded; +- Runtime Config scalar removed; +- 64KiB/1MiB/5s bounded read; +- strict URL/media/JSON/schema; +- reverse partial cleanup; +- safe retry once. + +### 26.4 HTTP + +- package descriptor-driven; +- request/response bounds; +- final invariant after auth; +- redirect reject; +- total deadline; +- transport-only retry; +- body/status/media validation; +- effect certainty; +- keyed uncertainty no automatic resend; +- scope fence. + +### 26.5 Router + +- static route/codec; +- no server-data loader/action; +- no direct fetch/cache; +- lazy/focus/scroll/error; +- chunk contractSet recovery; +- dirty/effect-unknown blocker. + +### 26.6 Query/Mutation + +- one QueryClient; +- fixed profiles; +- required result measurement; +- scope exact reset order; +- late result discard; +- memory-only; +- bounded identity/invalidation; +- duplicate coordinator; +- optimistic default none/bounded layer; +- success/failure ordering. + +### 26.7 Realtime + +- existing common runtime reused; +- product contribution exact; +- open/resume separated; +- reconnect policy exact; +- hidden/offline lifecycle; +- validation pipeline; +- queue/dedupe bounds; +- gap recovery; +- effect-before-checkpoint; +- ref-counted cleanup; +- zero selection side effect. + +### 26.8 Generic Worker + +- explicit selected task; +- semantic port; +- one worker per group; +- protocol validation; +- queue/input/output/deadline; +- cancellation/terminate; +- crash budget; +- stale fence; +- no network/credential; +- fallback exact. + +### 26.9 Service Worker + +- one physical registration; +- post-mount module registration; +- build/protocol handshake; +- all-or-nothing static cache; +- navigation/API/config/manifest network-only; +- waiting/client drain; +- no blind skipWaiting/clients.claim; +- first install reload-to-enable; +- runtime disabled exact unregister; +- ACTIVE→REMOVE→PURGE→null; +- foreign resource protection. + +### 26.10 Offline/Background Sync + +- product operation selected; +- external KEYED/recovery descriptor; +- IDB transaction-complete; +- exact bounds; +- single sender lease; +- SENDING crash -> EFFECT_UNKNOWN; +- no automatic unknown resend; +- foreground fallback; +- Sync wake only; +- retention/recovery/removal. + +--- + +## 27. 위험 등록부 + +| ID | 위험 | 차단 결정 | 잔여 위험 | +| --- | --- | --- | --- | +| FRT-R001 | frontend local API schema가 두 번째 SSOT | external package one import/provenance | package publication quality는 external owner | +| FRT-R002 | scalar contract version으로 multi-service mismatch | contractSet V2 | provider compatibility는 external release system | +| FRT-R003 | boot body memory amplification | streaming byte bound/deadline | browser/network implementation variance | +| FRT-R004 | auth patch가 URL/method/header 변조 | final invariant recheck | external auth adapter defect | +| FRT-R005 | duplicate retry amplification | HTTP-only retry owner | user repeated intent remains product UX | +| FRT-R006 | command timeout duplicate effect | MAYBE_APPLIED/inspect/no resend | server inspect may remain unavailable | +| FRT-R007 | Router/Query dual cache | loader server-data prohibition | future SSR requires separate design | +| FRT-R008 | old account late result leak | synchronous scope FENCED/commit fence | third-party widget outside scope | +| FRT-R009 | result memory blowup | required measure/fixed profiles | feature estimator defect | +| FRT-R010 | cross-tab stale data replication | hint-only protocol | hints can be lost; refetch recovery | +| FRT-R011 | reconnect storm | full jitter/attempt/episode bounds | provider-wide outage | +| FRT-R012 | connection open mistaken as recovered | OPEN_UNRESUMED/barrier | server recovery semantics external | +| FRT-R013 | malformed event corrupts state | validator/mapper/gap recovery | malicious volume before edge controls | +| FRT-R014 | subscription leak under StrictMode | ref-count + 5s grace | browser process crash | +| FRT-R015 | Worker queue/crash heap amplification | exact bounds/restart budget | heavy task still impacts CPU/battery | +| FRT-R016 | multiple Service Worker registrations | one physical entry | existing foreign registration conflict | +| FRT-R017 | stale worker mixed with new page | drain/no claim/reload handshake | user may postpone update | +| FRT-R018 | service worker removed from source but persists | staged removal modes | clients not revisiting app remain registered | +| FRT-R019 | Cache Storage poisoning/stale HTML | static manifest only/no navigation cache | immutable asset host misconfiguration | +| FRT-R020 | offline command duplicate after crash | SENDING->EFFECT_UNKNOWN | external reconciliation may need human | +| FRT-R021 | Background Sync assumed reliable | wake-only/foreground fallback | browser may throttle background work | +| FRT-R022 | logout leaves old durable data visible | scope partition fence | physical cleanup may be delayed | +| FRT-R023 | diagnostics leak | closed allowlist/redaction | vendor SDK internal behavior | +| FRT-R024 | bfcache resumes stale release | manifest refresh/recovery | offline manifest fetch unavailable | +| FRT-R025 | over-design installs optional capability unused | static selection/stop conditions | future teams may bypass process | + +--- + +## 28. Normative decision ledger + +| Decision ID | 결정 | 정본 | +| --- | --- | --- | +| FRT-D001 | 기존 repository layers 유지 | §1.1–§1.2 | +| FRT-D002 | backend/API/event source design 제외 | §0.3 | +| FRT-D003 | CI design 제외 | §0.4 | +| FRT-D004 | immutable external package only | §4 | +| FRT-D005 | package import one feature contribution path | §4.1 | +| FRT-D006 | template fixture HTTP-only and contractSet excluded | §4.8–§4.9 | +| FRT-D007 | scalar API contract version removed | §5.1 | +| FRT-D008 | length-prefixed contractSet canonical digest | §5.3 | +| FRT-D009 | Runtime Config may disable, never enable source-absent capability | §3.5, §6.1 | +| FRT-D010 | boot 64KiB/1MiB/5s | §6.4–§6.5 | +| FRT-D011 | boot reverse cleanup | §6.8 | +| FRT-D012 | descriptor-driven HTTP execution | §7.1–§7.2 | +| FRT-D013 | request/response hard bounds | §7.3 | +| FRT-D014 | transport-only retry | §8.1 | +| FRT-D015 | 250ms/2s/full-jitter/max2 HTTP retry | §8.2 | +| FRT-D016 | 401 no hidden replay | §8.4 | +| FRT-D017 | dispatch loss MAYBE_APPLIED | §8.7–§8.9 | +| FRT-D018 | Router server-data loader/action prohibited | §9.2 | +| FRT-D019 | one QueryClient | §10.1 | +| FRT-D020 | four fixed Query profiles | §10.2 | +| FRT-D021 | required feature-owned measureResult, no fallback | §10.3–§10.4 | +| FRT-D022 | synchronous scope FENCED exact order | §10.5–§10.6 | +| FRT-D023 | Query persistence disabled | §10.10 | +| FRT-D024 | mutation duplicate default reject | §11.2 | +| FRT-D025 | optimistic default none, layer bounds | §11.4–§11.5 | +| FRT-D026 | cross-tab hint-only, many-to-many bounds | §12 | +| FRT-D027 | existing realtime runtime reused | §13.1 | +| FRT-D028 | product realtime explicit contribution | §13.2–§13.6 | +| FRT-D029 | single writer live XOR polling | §13.8 | +| FRT-D030 | OPEN_UNRESUMED separate | §14.1–§14.2 | +| FRT-D031 | realtime reconnect 500ms/30s/10/5m/60s | §14.3 | +| FRT-D032 | hidden 30s then close, no hidden polling | §14.5 | +| FRT-D033 | event hard limits reuse current constants | §15.1 | +| FRT-D034 | effect before checkpoint | §15.7 | +| FRT-D035 | subscription 5s grace, scope immediate | §15.8–§15.9 | +| FRT-D036 | generic Worker default not selected | §16.2 | +| FRT-D037 | one Worker per group, lazy/60s idle | §16.5 | +| FRT-D038 | Worker queue/cancel/crash exact policy | §16.8–§16.10 | +| FRT-D039 | Worker no network/credential | §16.13 | +| FRT-D040 | one physical Service Worker | §17.1 | +| FRT-D041 | Service Worker after first committed render | §17.5 | +| FRT-D042 | ACTIVE/REMOVE/PURGE/null lifecycle | §17.3 | +| FRT-D043 | disabled exact-owned unregister, cache retained | §17.6 | +| FRT-D044 | no blind skipWaiting, no clients.claim | §17.10–§17.12 | +| FRT-D045 | static asset cache only | §18 | +| FRT-D046 | navigation/API/config/manifest network-only | §18.5–§18.8 | +| FRT-D047 | Offline queue selected only with KEYED recovery package | §19.3 | +| FRT-D048 | offline records 1000/50MiB/256KiB | §19.5 | +| FRT-D049 | sender lease 30s/renew10s/batch10/30s | §19.9 | +| FRT-D050 | SENDING crash -> EFFECT_UNKNOWN | §19.12 | +| FRT-D051 | Background Sync wake-only | §19.18 | +| FRT-D052 | lifecycle shutdown exact order | §20.3 | +| FRT-D053 | no unload correctness | §20.6 | +| FRT-D054 | closed diagnostics allowlist | §22 | +| FRT-D055 | behavior verification separate from CI | §23 | +| FRT-D056 | implementation phase stop conditions | §25 | + +--- + +## 부록 A. Numeric constants + +### A.1 Boot + +| Constant | Value | +| --- | ---: | +| Runtime Config bytes | 65,536 | +| Release Manifest bytes | 1,048,576 | +| each boot request deadline | 5,000ms | +| boot retry count | 1 | +| manifest URL bytes | 256 | + +### A.2 HTTP + +| Constant | Value | +| --- | ---: | +| default request | 262,144 bytes | +| hard request | 1,048,576 bytes | +| default response | 1,048,576 bytes | +| hard response | 8,388,608 bytes | +| problem body | 65,536 bytes | +| query string | 8,192 bytes | +| final URL | 16,384 bytes | +| default deadline | 10,000ms | +| hard deadline | 60,000ms | +| retry base | 250ms | +| retry local max | 2,000ms | +| Retry-After max | 5,000ms | +| retries | 2 | + +### A.3 Query + +| Profile | stale | gc | items | bytes | +| --- | ---: | ---: | ---: | ---: | +| DETAIL_STANDARD | 30s | 5m | 1 | 256KiB | +| LIST_STANDARD | 15s | 5m | 200 | 1MiB | +| LOOKUP_STABLE | 5m | 30m | 500 | 2MiB | +| VOLATILE_STATUS | 0 | 1m | 1 | 64KiB | + +identity registry: + +| Constant | Value | +| --- | ---: | +| depth | 12 | +| nodes | 512 | +| string bytes | 2,048 | +| identity bytes | 16,384 | +| entries | 4,096 | +| canonical memory | 4MiB | + +### A.4 Invalidation + +| Constant | Value | +| --- | ---: | +| topics | 256 | +| namespaces | 256 | +| edges | 1,024 | +| topic fan-out | 64 | +| namespace fan-in | 64 | +| pulse bytes | 1,024 | +| pulse TTL | 60s | + +### A.5 Realtime + +| Constant | Value | +| --- | ---: | +| event bytes | 64KiB | +| payload depth | 16 | +| payload nodes | 4,096 | +| queue events | 256 | +| queue bytes | 4MiB | +| dedupe entries | 2,048 | +| dedupe bytes | 4MiB | +| dedupe TTL | 10m | +| reconnect base | 500ms | +| reconnect max | 30s | +| attempts | 10 | +| episode | 5m | +| stable reset | 60s | +| hidden grace | 30s | +| subscription grace | 5s | +| stream drain | 2s | + +### A.6 Web Worker + +| Constant | Value | +| --- | ---: | +| groups | 16 | +| tasks/group | 32 | +| active groups | 4 | +| queued/group | 32 | +| queued bytes/group | 16MiB | +| default input/output | 1MiB | +| hard input/output | 8MiB | +| default deadline | 5s | +| hard deadline | 30s | +| cancel grace | 250ms | +| idle terminate | 60s | +| restarts | 3/5m | +| transferables | 16 | + +### A.7 Service Worker/static cache + +| Constant | Value | +| --- | ---: | +| assets | 256 | +| single asset | 2MiB | +| set | 5MiB | +| fetch concurrency | 4 | +| install deadline | 60s | +| client drain | 30s | +| update check | 6h | +| retained previous cache | 1 | + +### A.8 Offline Commands + +| Constant | Value | +| --- | ---: | +| operations | 64 | +| default request | 256KiB | +| hard request | 1MiB | +| records | 1,000 | +| dataset | 50MiB | +| sender lease | 30s | +| renew | 10s | +| batch | 10 | +| batch window | 30s | +| parallel send | 1 | +| retry base | 1s | +| retry max | 5m | +| attempts | 10 | +| ordinary retention | 7d | +| conflict/unknown | 30d | +| Sync re-register minimum | 1m | + +--- + +## 부록 B. 금지 구현 목록 + +```text +create src/platform/* parallel runtime +copy OpenAPI/AsyncAPI/JSON Schema into product frontend +import backend Java DTO/module +fetch deployed /v3/api-docs during build/runtime +resolve Schema Registry latest at runtime +use mutable package tag/range +let component/hook call raw fetch/socket/Worker/IndexedDB +let Router loader create server cache or fetch +enable TanStack retry while HTTP transport retries +retry keyed command after dispatched response loss +interpret abort as server rollback +store generated wire DTO directly in Query cache +use JSON.stringify as generic Query result size fallback +persist Query payload in Web Storage by default +broadcast Query key/data/input/cursor +open one realtime connection per component +apply event before validation/scope/gap checks +write checkpoint before effect +continue applying deltas after gap/overflow +use hidden polling automatically +create generic Worker for network I/O +allow Worker credential/fetch/WebSocket +create Blob/data URL Worker +register PWA and Web Push workers separately for same scope +call skipWaiting without client drain +call clients.claim in baseline +cache index.html navigation fallback +cache API/private/auth responses in Cache Storage +treat unregister as cache deletion +switch Service Worker ACTIVE directly to null +unregister foreign registration by scope prefix guess +send authenticated command from baseline Background Sync +reset expired SENDING record to PENDING +manual resend EFFECT_UNKNOWN without inspect +store token/URL/header/cursor in diagnostics +use unload/sendBeacon as correctness boundary +claim unsupported browser capability as pass +claim COMPOSED means production/provider-ready +``` + +--- + +## 부록 C. 플랫폼 참고 기준 + +구현 시 pinned browser/library version의 공식 문서를 다시 확인하되 본 문서의 repository decision을 임의 변경하지 않는다. + +- Fetch Standard: +- HTML — Web Workers: +- WebSockets Standard: +- HTML — Server-sent events: +- Service Workers: +- ServiceWorkerContainer.register: +- Background Synchronization: +- SyncManager: +- Cache API: +- IndexedDB: +- TanStack Query v5: +- React Router: +- React `useSyncExternalStore`: + +--- + +## 최종 판정 + +본 문서는 프론트엔드 저장소가 소유하지 않는 backend/API/event 계약 authoring과 CI orchestration을 제거하고, 현재 코드 구조 안에서 contract package 소비부터 HTTP, Router, Query/Mutation, realtime, Worker, Service Worker와 offline lifecycle까지 구현 결정을 닫는다. + +구현자는 다음을 다시 선택하지 않는다. + +```text +source root +contract import boundary +contractSet format/digest +Runtime Config V2 shape +boot byte/deadline +HTTP retry owner/limits +mutation uncertainty +Router data ownership +Query profiles/result measurement +scope reset order +cross-tab wire +realtime reconnect/resume/queue +subscription cleanup +Worker topology/bounds +Service Worker registration/update/removal/cache policy +offline sender/state/retention +Background Sync role +shutdown order +``` + +외부 contract package가 필요한 의미를 제공하지 않으면 frontend가 가정으로 채우지 않는다. 해당 product contribution은 `NOT_SELECTED` 또는 `INCOMPATIBLE` 상태로 남기며, common runtime 구현은 계속 독립적으로 검증 가능해야 한다. diff --git a/docs/architecture/api-contract-schema-mapper-and-server-state.md b/docs/architecture/api-contract-schema-mapper-and-server-state.md new file mode 100644 index 0000000..cf32315 --- /dev/null +++ b/docs/architecture/api-contract-schema-mapper-and-server-state.md @@ -0,0 +1,973 @@ +# API contract, Schema, Mapper와 Server State platform + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +- 상태: capability별 current/target 분리, production design accepted +- 기준일: 2026-07-28 +- 범위: REST, GraphQL over HTTP, Connect-Web/Connect, gRPC-Web, + Protobuf/REST Gateway, runtime Schema, boundary Mapper, TanStack Query 기반 + Server State Cache +- 관련 결정: + - [VD-23 API transport selection과 REST execution](./decisions/VD-23-api-transport-selection-and-rest-execution.md) + - [VD-24 Runtime schema와 boundary mapper](./decisions/VD-24-runtime-schema-and-boundary-mapper.md) + - [VD-25 Server state cache lifecycle](./decisions/VD-25-server-state-cache-lifecycle.md) + - [VD-26 Persisted GraphQL operation](./decisions/VD-26-persisted-graphql-operation.md) + - [VD-27 gRPC-Web unary와 server stream](./decisions/VD-27-grpc-web-unary-and-server-stream.md) + - [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md) + - [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md) +- browser Protobuf/gateway 상세 설계: + [Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md) +- backend handoff: + [Backend API와 Server State contract](./backend-api-and-server-state-contract.md) +- 운영 절차: + [API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md) + +## 1. 목적 + +이 문서는 다음 질문을 하나의 production 계약으로 닫는다. + +- REST, GraphQL, Connect와 gRPC-Web 중 무엇을 어디에 사용하는가 +- Protobuf contract와 REST Gateway가 transport/runtime과 어떻게 분리되는가 +- request/response가 어느 지점까지 untrusted wire data인가 +- TypeScript type, generated code와 runtime validation의 역할은 무엇인가 +- DTO를 domain/application projection으로 누가 변환하는가 +- server response를 어떤 query identity와 lifecycle로 cache하는가 +- schema, mapper, transport와 cache가 바뀔 때 어떻게 배포·관측·rollback하는가 + +각 protocol은 서로 대체 가능한 URL 호출 문법이 아니다. transport-specific +codec, proxy와 failure semantics는 adapter가 소유한다. application은 transport +종류, URL, GraphQL document, protobuf message나 TanStack Query를 직접 알지 않고 +feature-owned gateway와 application input만 호출한다. + +## 2. 상태 모델 + +이 문서는 browser data 설계와 동일한 primary current-status literal을 사용한다. + +| primary status | 의미 | +| --- | --- | +| `COMPOSED` | production bootstrap 또는 설치된 feature 호출 경로에 concrete runtime이 실제 연결돼 있다. | +| `AVAILABLE_NOT_COMPOSED` | 실행 가능한 reference runtime과 test가 있지만 production graph에는 연결하지 않았다. | +| `DESIGNED_NOT_IMPLEMENTED` | 계약·불변조건·failure와 promotion 기준은 승인됐지만 해당 runtime 또는 필수 orchestration이 없다. | +| `NOT_SELECTED` | 제품 요구, owner와 비용이 승인되지 않아 의도적으로 선택하지 않았다. | +| `PLATFORM_LIMITED` | target browser/protocol이 요구 semantics를 공통으로 보장하지 못한다. | + +primary status와 다음 readiness 축을 섞지 않는다. + +```text +Selection + NOT_SELECTED | SELECTED | REMOVING + +TrafficAdmission + DISABLED | SHADOW | CANARY | ENABLED + +RuntimeHealth + UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE + +PromotionEvidence + MISSING | PARTIAL | COMPLETE | EXPIRED +``` + +`COMPOSED`는 traffic이 켜졌거나 provider가 conformant라는 뜻이 아니다. +`AVAILABLE_NOT_COMPOSED`도 제품 bundle에 dependency가 들어갔다는 뜻이 아니다. + +## 3. 현재 capability ledger + +| capability | primary current status | 현재 증거 | 목표 또는 잔여 | +| --- | --- | --- | --- | +| installed REST reference vertical | `COMPOSED` | operation registry → request schema → HTTP → envelope/payload schema → mapper → application input → Query 화면 경로 | 아래 REST hardening delta와 실제 제품 provider 계약 | +| shared REST JSON executor | `COMPOSED` | path/search/body codec projection, shared deadline/retry-sleep budget, AbortSignal, bounded auth recovery, exact envelope/media/status, safe failure와 diagnostics | 204/304/412 execution join과 actual provider conformance | +| REST v2 security/execution baseline | `COMPOSED` | collision-aware operation composition, path placeholder↔codec key exact join, prefix-preserving HTTPS/loopback provider, named bearer/CSRF profile와 credential-mode ceiling, auth fail-before-fetch, bounded JSON, outbound correlation/status·physical-attempt 관측 | cookie-CSRF/CORS provider evidence, 204/304/412 conditional execution과 compatibility artifact | +| GraphQL provider-neutral reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source/dependency/codegen/runtime 없음 | persisted-operation-only transport, GraphQL response decoder, mapper binding과 contract harness | +| product GraphQL composition | `NOT_SELECTED` | endpoint/schema/persisted manifest/owner 없음 | 제품 query가 REST보다 GraphQL aggregation을 정당화할 때 선택 | +| GraphQL batching, subscription, `@defer`/`@stream` | `NOT_SELECTED` | 없음 | 각각 독립 ADR, proxy/browser lifecycle과 cache semantics 필요 | +| provider-neutral Browser RPC V3 contract/runtime | `AVAILABLE_NOT_COMPOSED` | operation/profile/schema/mapper/encoder/transport exact join, typed application port, bounded unary retry/deadline/abort, server-stream idle/total/message/terminal/generation fence와 fail-closed unavailable adapter test | selected descriptor/generated client와 protocol-specific bounded transport를 붙이고 actual provider/browser conformance | +| gRPC-Web unary reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source/dependency/generated message 없음 | fixed method registry, protobuf codec, trailers/status/deadline와 proxy conformance | +| gRPC-Web server-stream reference adapter | `DESIGNED_NOT_IMPLEMENTED` | source 없음 | bounded frame/idle/total budget, sequence/resume application protocol과 stream port | +| product gRPC-Web composition | `NOT_SELECTED` | service descriptor/proxy/owner 없음 | browser-facing gRPC-Web gateway가 실제 이점을 줄 때 선택 | +| gRPC-Web client-streaming/bidi guarantee | `PLATFORM_LIMITED` | gRPC-Web browser baseline이 해당 semantics를 제공하지 않음 | REST upload, WebSocket/WebTransport 또는 별도 protocol을 선택 | +| Protobuf schema/codegen governance | `DESIGNED_NOT_IMPLEMENTED` | `.proto`, Buf config, descriptor와 generated output 없음 | authenticated source, immutable descriptor, deterministic codegen과 compatibility evidence | +| Connect-Web unary/server-stream reference adapter | `DESIGNED_NOT_IMPLEMENTED` | `@connectrpc/*`, `@bufbuild/protobuf`, generated service와 provider 없음 | exact Connect/gRPC-Web transport row, bounded decode와 actual browser/provider conformance | +| product Connect protocol composition | `NOT_SELECTED` | service/provider/owner 없음 | Protobuf-first backend의 selected browser operation이 있을 때만 선택 | +| Connect browser client-streaming/bidi guarantee | `PLATFORM_LIMITED` | Connect protocol 기능과 browser request-stream 지원은 다름 | 별도 duplex/application protocol 선택 | +| Protobuf REST Gateway reference contract/harness | `DESIGNED_NOT_IMPLEMENTED` | HttpRule/transcoder/OpenAPI/provider fixture 없음 | selected kind의 deterministic/provider conformance | +| product Protobuf REST Gateway composition | `NOT_SELECTED` | route/provider/owner 없음 | curated BFF, grpc-gateway 또는 Envoy transcoder 중 하나와 public HTTP contract 승인 | +| feature runtime request/response Schema | `COMPOSED` | Zod request/payload schemas와 installed schema registry가 reference feature에 연결 | byte/depth/node ceiling, unknown-field profile, schema artifact/digest와 multi-protocol source governance | +| schema/mapper v2 reference baseline | `COMPOSED` | collision-aware schema codec/mapper contribution install, operation schema/mapper reference resolution, request reject/response strip 방향, bounded collection, typed no-throw mapping result와 operation별 cast-free result guard | actual codec fingerprint, source provenance와 multi-protocol compatibility policy | +| generated contract artifact governance | `DESIGNED_NOT_IMPLEMENTED` | `generated-api` recipe만 있고 generator/provider 선택 없음 | OpenAPI/GraphQL/proto source authentication, pinned generation, drift/breaking gate와 N/N-1 | +| feature boundary Mapper | `COMPOSED` | installed mapper metadata composer와 response-schema exact join 뒤 typed no-throw MappingResult → immutable domain/application view 실행 | numeric/date/null/enum canonical rules와 generated-artifact join | +| TanStack Query memory Server State | `COMPOSED` | QueryClient, cancellation, stale-degraded UI, session-generation cancel/clear fence와 invalidation coordinator | account identity projection과 bounded topic↔namespace many-to-many registry | +| reference bound-query/server-state profile | `COMPOSED` | bound definition, strict canonical input, scope-private opaque identity, active lease/LRU/collision/entry-byte ceiling, per-profile policy와 result admission | account projection, conditional HTTP execution join과 pagination composition | +| mutation duplicate coordinator baseline | `COMPOSED` | QueryClient runtime/scope 단위 exact semantic input identity로 identical만 join하고 distinct input을 합치지 않으며 late scope result를 폐기 | logical-key serialization과 effect certainty/reconcile | +| optimistic ordered-layer runtime | `AVAILABLE_NOT_COMPOSED` | out-of-order commit/rollback, authoritative external update 재적용과 expired-scope 제거 test | 제품 mutation의 deterministic membership/revision contract 승인 뒤 definition에 연결 | +| conditional validator CAS sidecar | `AVAILABLE_NOT_COMPOSED` | scope/representation/cache revision exact binding, ETag validation과 bounded capacity test; session 전환 clear는 production infrastructure에 연결 | HTTP If-None-Match/304 query transaction과 query removal lifecycle join | +| bounded cursor chain runtime | `AVAILABLE_NOT_COMPOSED` | page invariant, cursor loop, snapshot drift, page/item/byte/cursor ceiling과 abort test | backend CursorPage DTO/next cursor 계약 후 reference/infinite-query binding | +| cross-context server-state invalidation | `COMPOSED` | singular opaque topic 기반 invalidate-only coordinator와 session-generation local reset | account projection과 bounded topic↔namespace many-to-many registry | +| normalized GraphQL entity cache | `NOT_SELECTED` | 없음 | TanStack operation-result cache로 해결되지 않는 측정된 요구가 있을 때 별도 선택 | +| persisted query cache | reference `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` | Web Storage persistence는 금지, IndexedDB persister 없음 | VD-13의 scope/retention/restore gate를 별도 통과 | +| offline mutation queue | `NOT_SELECTED` | foreground mutation만 존재 | backend idempotency/cursor/conflict protocol과 durable command owner 필요 | + +현재 REST reference는 `Response.json()`이 아니라 byte-bounded reader를 사용하고, +external auth owner는 allowlisted header patch만 반환한다. 인증 통합 실패는 fetch +전에 닫히며 correlation, success status와 physical attempt가 terminal observation에 +반영된다. 다만 이 baseline을 conditional response, complete pagination, +provider conformance나 GraphQL/gRPC runtime의 증거로 재사용하지 않는다. +마찬가지로 Browser RPC V3 공통 coordinator의 `AVAILABLE_NOT_COMPOSED` 판정은 +vendor wire adapter의 구현 판정이 아니다. `@connectrpc/*`, official grpc-web, +generated message와 descriptor가 없는 현재 상태에서 Connect/gRPC-Web 각 row는 +계속 `DESIGNED_NOT_IMPLEMENTED`다. + +## 4. 최상위 경계 + +```text +presentation + -> feature application input + -> feature use case + -> feature gateway port + -> operation registry + -> REST adapter + -> GraphQL adapter + -> Connect adapter + -> gRPC-Web adapter + -> bounded wire decoder + -> runtime schema / semantic validation + -> boundary mapper + -> immutable application projection + -> server-state query adapter + -> registry-owned query identity and policy + -> mapped application result only +``` + +금지 경로: + +```text +page -> fetch / GraphQL SDK / generated Connect/gRPC client +page -> raw URL / query document / protobuf message +transport DTO -> domain or presentation public type +Response / GraphQL response / generated message -> Query cache +Query cache -> authorization or business conflict authority +``` + +application port는 use-case 의미를 표현한다. 예를 들어 +`listResources(filters)`, `createResource(command)`는 허용하지만 +`executeGraphql(document, variables)`, `grpcCall(service, method, bytes)`와 +`request(url, options)`는 허용하지 않는다. + +## 5. Protocol-neutral operation contract + +각 외부 호출은 build-time registry의 discriminated row 하나로 고정한다. +application caller는 `operationId`와 schema가 허용한 input만 제출한다. + +```text +ApiOperationContractV3 + registryVersion + operationId + owner + protocol = REST | GRAPHQL_HTTP | CONNECT_HTTP | GRPC_WEB + semantics = QUERY | COMMAND | SERVER_STREAM + authProfileId + csrfProfileId + replayPolicy = SAFE | IDEMPOTENT | KEYED_COMMAND | NON_REPLAYABLE + idempotencyKeyPolicy = NONE | REQUIRED + requestSchemaId + responseSchemaId + mapperId + errorProfileId + deadlineProfileId + retryProfileId + serverStateProfileId | null + invalidationTopicRefs[] = { topicId, topicVersion } + dataClassification + compatibility + globalApiContractVersion + protocolArtifactId + minimumServerVersion + retirementEpoch | null + protocolBinding +``` + +`protocolBinding`은 transport별 closed union이다. + +```text +REST + method + relativePathTemplate + requestProjection + requestMediaProfile + responseMediaProfile + conditionalProfile + +GRAPHQL_HTTP + endpointId + graphqlHttpProfileRevision + persistedEnvelopeProfileId + responseStatusMediaProfileId + persistedOperationId + persistedOperationSha256 + operationType + partialDataPolicy + +GRPC_WEB + endpointId + clientRuntimeId + grpcWebWireSpecRevision + transportProfile + responseHttpStatusProfileId + fullyQualifiedService + method + rpcKind = UNARY | SERVER_STREAM + requestMessageId + responseMessageId + +CONNECT_HTTP + endpointId + clientRuntimeId + connectProtocolRevision + encoding = PROTO_JSON | PROTO_BINARY + requestMethod = POST | GET + fullyQualifiedService + method + rpcKind = UNARY | SERVER_STREAM + descriptorArtifactId + descriptorDigest +``` + +registry validation은 다음을 build/boot 전에 거절한다. + +- 중복 operation/mapper/profile ID +- protocol과 맞지 않는 binding field +- 등록되지 않은 schema, mapper, auth, deadline, retry와 cache profile +- `QUERY`인데 replay policy가 `SAFE | IDEMPOTENT`가 아님 +- `KEYED_COMMAND`인데 idempotency key policy가 `REQUIRED`가 아니거나 backend + dedupe/reconcile profile이 없음 +- `NON_REPLAYABLE`인데 network/401 replay가 enabled +- `COMMAND`인데 cache profile이 query data owner로 지정됨 +- `SERVER_STREAM`인데 ordinary query cache profile을 사용 +- opening retry가 enabled인데 replay policy가 `SAFE | IDEMPOTENT`가 아님 +- `KEYED_COMMAND | NON_REPLAYABLE` server stream인데 opening retry가 enabled거나 + explicit resume/reconcile/dedupe profile이 없음 +- `SERVER_STREAM`인데 `protocol=CONNECT_HTTP | GRPC_WEB`과 + `rpcKind=SERVER_STREAM` 조합이 아님. REST SSE와 GraphQL subscription을 이 + registry 의미로 암묵 등록하지 않음 +- unsafe REST method 또는 GraphQL mutation인데 CSRF/replay/key 결정이 없음 +- gRPC-Web client/bidi method +- Connect browser client/bidi method 또는 descriptor의 `NO_SIDE_EFFECTS`가 없는 + Connect GET +- absolute URL, runtime GraphQL document 또는 caller-provided service/method +- implementation hard ceiling보다 큰 timeout, byte, frame, page와 retry 값 +- GraphQL operation↔provider의 HTTP revision/envelope/status-media profile 또는 + Connect/gRPC-Web operation↔provider의 runtime/wire/status/capability tuple + mismatch + +현재 installed reference operation은 REST v2 metadata를 사용한다. operation, +runtime schema codec과 mapper contribution은 object spread가 아니라 각각의 +collision-aware composer로 설치되며 duplicate ID를 덮어쓰기 전에 거절한다. +boot-time binding 검증은 path placeholder와 codec key, provider/auth/CSRF profile, +path/request/response schema와 mapper input schema를 exact resolve한 뒤 immutable +registry를 발행한다. GraphQL/Connect/gRPC-Web discriminant와 protocol-specific +binding은 해당 reference adapter가 아직 없으므로 source에 구현됐다고 표현하지 +않는다. + +현재 `API_CONTRACT_VERSION`은 runtime config와 release manifest의 문자열 일치 +gate다. 목표 contract set은 REST/OpenAPI artifact, GraphQL schema/persisted +manifest, protobuf descriptor, runtime schema/mapper registry digest를 포함한 +bounded manifest를 만들고 global compatibility version과 함께 release tuple에 +binding한다. 문자열 일치만 actual backend compatibility 증거로 사용하지 않는다. + +## 6. 공통 실행 lifecycle + +```text +lookup exact operation + -> freeze session/account/runtime generation + -> validate and canonicalize application input + -> derive exact query/command identity + -> allocate total operation deadline + -> encode transport request from registry binding + -> attach credential/CSRF through approved owner + -> execute bounded attempt + -> bounded response/frame decode + -> validate transport envelope/status + -> validate operation DTO/message semantics + -> map to immutable application projection + -> re-check scope/generation + -> return Result + -> query adapter may admit mapped value to memory cache +``` + +현재 v2 auth owner는 `Request`를 반환하지 않고 transport가 만든 immutable +request binding에 대해 allowlisted credential patch만 제공한다. 최소한 +transport는 attach 뒤에도 URL, origin, method, body digest, content headers, +idempotency와 conditional binding이 바뀌지 않았음을 다시 검증한다. +auth-required operation은 session state가 unauthenticated/integration-failed이거나 +credential attachment가 실패하면 **fetch 0회**로 닫는다. 동시 401 recovery는 +session owner의 single-flight 한 번만 공유하며 replay-safe operation만 동일 +logical deadline/idempotency binding으로 한 번 재실행한다. + +모든 async boundary와 terminal cache write 전에 captured generation을 확인한다. +logout/account switch 뒤 끝난 response, mapper와 stream frame은 old runtime +결과로 폐기한다. + +deadline은 attempt마다 새로 시작하지 않는다. + +```text +total budget + = credential attach + + network attempts + + retry delay + + body/frame read + + schema validation + + mapper +``` + +각 phase에 별도 하위 ceiling을 둘 수 있지만 전체 deadline을 늘릴 수 없다. +caller abort, runtime teardown, timeout과 provider cancellation은 서로 다른 safe +failure로 정규화한다. + +## 7. Transport 선택 기준 + +| 요구 | 기본 선택 | 이유 | +| --- | --- | --- | +| resource/command, HTTP cache/conditional semantics, 파일 handoff | REST | Web/BFF·CDN·운영 도구와 자연스럽고 failure/status가 명확함 | +| 여러 aggregate를 한 화면 shape로 읽고 client별 selection이 유의미 | persisted GraphQL query | allowlisted operation으로 over/under-fetch를 줄일 수 있음 | +| Protobuf-first backend의 내부 web UI, unary 또는 bounded server stream | Connect-Web/Connect 우선 평가 | generated descriptor와 Fetch 기반 browser RPC를 재사용 | +| 기존 gRPC-Web proxy/conformance 자산 | selected gRPC-Web runtime | runtime별 binary/text/stream capability를 exact profile로 고정 | +| ProtoJSON/HttpRule 자체가 승인된 public HTTP contract | generated REST Gateway 검토 | envelope/status/cache/idempotency를 별도 증명 | +| 현재 REST envelope·ETag·Range·제품 DTO가 중요 | curated REST BFF 유지 | generated transcoder가 제품 HTTP 의미를 자동 제공하지 않음 | +| browser client/bidi streaming | Connect/gRPC-Web 사용 금지 | protocol 자체 기능과 browser 공통 지원을 혼동하지 않음 | +| arbitrary ad-hoc query | GraphQL 사용 금지 | cost, authorization, cache identity와 operation governance를 우회 | +| 단순 CRUD인데 GraphQL/gRPC dependency만 추가 | REST 유지 | 복잡도와 bundle/proxy 비용을 정당화하지 못함 | + +한 feature가 여러 protocol을 사용할 수 있지만 한 `operationId`는 한 protocol에만 +binding한다. query read를 shadow 비교하는 경우에도 secondary 결과는 사용자와 +cache에 반영하지 않는다. command는 protocol 장애를 이유로 자동 failover/replay +하지 않는다. + +## 8. REST 설계 요약 + +REST 상세 결정은 VD-23이 소유한다. 공통 baseline은 다음과 같다. + +- base origin과 relative path template은 composition/registry가 소유한다. +- provider base URL은 HTTPS와 exact origin/path-prefix를 고정하고 userinfo, + query와 fragment를 금지한다. path join은 선택한 base prefix를 보존하며 + leading slash가 prefix를 조용히 제거하지 않는다. +- method는 closed union이며 path/search/header/body는 각 runtime schema를 지난다. +- replay semantics는 `SAFE`, `IDEMPOTENT`, `KEYED_COMMAND`, + `NON_REPLAYABLE`로 method와 교차 검증한다. +- caller-provided URL, header, `credentials`, redirect와 cache option을 금지한다. +- cookie session이면 unsafe method에 approved CSRF owner가 필요하다. +- `SAFE | IDEMPOTENT | KEYED_COMMAND` 중 exact retry profile과 실제 provider + replay evidence가 있는 operation만 network retry한다. +- keyed retry와 401 recovery는 같은 logical idempotency key를 유지한다. +- total deadline, attempts, backoff와 `Retry-After`는 implementation ceiling 안이다. +- JSON/error body는 present/valid `Content-Length` advisory preflight와 actual + decoded-byte bounded stream reader 뒤 parse한다. encoded transfer cap은 + BFF/proxy/CDN가 집행한다. +- status, content type, response media profile과 envelope 조합을 exact하게 검증한다. +- 204, 304, 412, 422와 problem/envelope profile은 operation이 명시한 경우만 허용한다. +- strong ETag는 cache identity가 아니라 exact representation revalidation + metadata다. validator를 diagnostics에 기록하지 않는다. `Last-Modified`는 + 별도 weak/time validator profile이 승인되기 전 이번 target에 포함하지 않는다. +- cursor는 opaque하며 filter/sort/scope와 binding한다. arbitrary URL을 + `next` link로 따라가지 않는다. +- browser HTTP cache와 application ETag/TanStack revalidation owner 중 하나를 + operation별로 선택한다. 두 cache의 freshness를 서로 추측해 합치지 않는다. +- request/response body, URL query, authorization, CSRF, idempotency key와 raw + backend copy를 log/telemetry에 넣지 않는다. + +## 9. GraphQL 설계 요약 + +GraphQL 상세 결정은 VD-26이 소유한다. reference target은 +**persisted operation only**다. + +- production bundle은 arbitrary GraphQL document string을 runtime에 받지 않는다. +- build artifact가 operation name, stable ID, SHA-256, variables/result schema, + schema digest와 owner를 manifest로 만든다. +- BFF/router는 allowlist에 없는 ID/hash와 cost/depth limit 초과를 거절한다. +- endpoint는 fixed HTTPS registry ID이고 POST가 기본이다. +- selected provider가 지원하는 GraphQL-over-HTTP revision과 persisted-envelope + extension을 profile에 고정한다. ID/hash-only request를 generic 표준 envelope로 + 가장하지 않는다. +- `Accept: application/graphql-response+json`을 우선하고 final URL/media/body cap + 뒤에는 허용 HTTP status의 GraphQL envelope를 bounded decode한 다음 status/body + matrix를 교차 검증한다. legacy `application/json`은 별도 profile이다. +- public cacheable query의 GET은 별도 threat/cache review 뒤에만 허용한다. +- variables는 request schema와 byte/depth/node ceiling을 통과한다. +- HTTP status와 GraphQL `data/errors/extensions`를 두 단계로 검증한다. +- default `partialDataPolicy=REJECT`; 승인 operation만 typed completeness metadata와 + 함께 partial을 application으로 투영할 수 있다. +- error message, path value와 arbitrary extensions를 노출하지 않고 registered + safe code/category만 `AppFailure`로 mapping한다. +- APQ miss에서 full document를 자동 전송하지 않는다. manifest/version mismatch로 + fail-closed하고 coherent frontend/router artifact를 복구한다. +- batching은 auth, deadline, cancel, observation과 partial failure owner가 + 별도 승인되기 전 `NOT_SELECTED`다. +- subscription, `@defer`, `@stream`은 ordinary query adapter에 암묵적으로 넣지 + 않는다. +- Apollo/urql normalized cache는 기본 dependency가 아니다. mapped operation + result의 memory owner는 TanStack Query다. + +## 10. Browser Protobuf RPC와 REST Gateway 설계 요약 + +축과 선택 기준의 상세 계약은 +[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)가 +소유한다. Protobuf는 IDL/serialization, Connect와 gRPC-Web은 browser wire +protocol, Connect-Web/official grpc-web은 client runtime, REST Gateway는 HTTP +노출 방식이다. 네 이름을 하나의 대안 목록이나 하나의 auto-negotiating +executor로 합치지 않는다. + +### 10.1 gRPC-Web + +gRPC-Web 상세 결정은 VD-27이 소유한다. reference target은 unary와 bounded +server-stream만 다룬다. + +- checked-in/generated artifact는 pinned proto descriptor/module digest에 묶는다. +- vendor generated client/message는 feature adapter 내부에만 존재한다. +- fully-qualified service/method와 endpoint는 registry가 고정한다. +- client runtime과 gRPC-Web wire-spec revision을 operation/provider에 고정한다. + official grpc-web runtime 기준 binary profile은 unary에만 사용하고 server + stream은 `grpcwebtext`에 binding한다. +- Connect-Web의 `createGrpcWebTransport()`는 Fetch 기반 binary/JSON unary와 + server stream profile이며 `grpcwebtext` profile이 아니다. official XHR runtime의 + capability matrix를 이 runtime에 적용하지 않는다. platform-authored custom + binary streaming도 세 browser와 actual proxy의 incremental evidence가 있는 + 별도 runtime profile만 허용한다. +- frame header, message length, compression flag, total bytes와 frame count를 + bounded decoder가 검증한다. +- HTTP status와 terminal status source를 함께 검사한다. terminal status source는 + body trailer frame 또는 zero-body trailers-only response header 중 정확히 + 하나이며 중복/충돌을 거절한다. +- `grpc-message`, binary error details와 metadata는 allowlist projection 없이 + application에 반환하지 않는다. +- deadline은 `grpc-timeout`과 local total deadline의 더 짧은 값이며 + AbortSignal이 fetch/stream reader를 cancel한다. +- unary `SAFE | IDEMPOTENT | KEYED_COMMAND` 중 provider evidence가 있는 + operation만 retry한다. stream reconnect는 retry가 아니라 + server-owned sequence/resume-token을 가진 별도 application protocol이다. +- idle deadline, total deadline, max frame/message/count/buffer를 모두 둔다. +- client-streaming/bidi는 지원한다고 가장하지 않는다. +- Envoy/BFF/Connect/gRPC-Web proxy의 CORS, exposed trailers, content type, + auth와 maximum message 설정을 actual provider conformance로 검증한다. +- int64/uint64는 JavaScript number로 변환하지 않고 safe integer 범위를 + 증명하거나 decimal string/adapter-private bigint로 mapping한다. + +### 10.2 Connect-Web과 Connect protocol + +Connect 상세 결정은 VD-29가 소유한다. + +- `createConnectTransport()`와 `createGrpcWebTransport()`는 같은 package의 서로 + 다른 wire protocol이다. decoder/status/terminal profile을 공유하지 않는다. +- Connect unary의 JSON/binary와 POST를 operation row에 고정한다. +- GET은 unary + `NO_SIDE_EFFECTS` descriptor + non-sensitive bounded input + + exact URL/cache/CORS profile에서만 별도 승인한다. +- Connect server stream은 final EndStream envelope를 확인하기 전 성공이 아니다. +- stock runtime의 whole-body decode와 streaming compression 한계를 exact package + version evidence로 확인한다. proxy cap이나 custom bounded transport가 없으면 + production raw-byte ceiling을 완료로 표시하지 않는다. +- interceptor의 resolved onion order, auth 이후 final invariant, total deadline, + exactly-one retry owner와 cancel handle을 manifest에 고정한다. +- `@connectrpc/connect-query`를 기본 도입하지 않는다. generated service/message는 + adapter-private이고 mapped application value만 기존 TanStack Query에 들어간다. + +### 10.3 Protobuf contract와 REST Gateway + +Protobuf/REST Gateway 상세 결정은 VD-30이 소유한다. + +- authenticated proto/Buf source, descriptor, generator/runtime/plugin version과 + generated digest를 coherent release artifact로 고정한다. +- JSON 노출은 최소 `WIRE_JSON` compatibility를 요구하고 canonical HttpRule + route manifest와 generated OpenAPI를 별도 semantic diff한다. +- current reference REST envelope에는 curated BFF를 유지한다. +- direct grpc-gateway/Envoy transcoder는 ProtoJSON, method/path/query/body, + status/error/CORS/cache contract가 그대로 제품 API로 승인된 unary operation에만 + 적용한다. +- gateway는 idempotency store, CursorPage snapshot, ETag/304/412, file Range나 + 안전한 domain error vocabulary를 자동 제공하지 않는다. +- REST server streaming은 generated gateway의 부수 동작으로 활성화하지 않고 + framing/terminal/cache를 소유하는 별도 ADR 없이는 `NOT_SELECTED`다. + +## 11. Schema trust boundary + +TypeScript type과 generated code는 compile-time convenience이지 runtime proof가 +아니다. trust transition은 다음 순서를 지킨다. + +```text +untrusted bytes/frames + -> bounded transport decoder + -> transport envelope/status proof + -> operation DTO/message runtime or semantic proof + -> ValidatedWireValue (adapter-private) + -> boundary mapper + -> immutable application projection +``` + +schema profile은 최소 다음을 고정한다. browser가 직접 집행하는 decoded ceiling과 +provider/BFF/proxy가 집행하는 wire/encoded ceiling의 owner를 분리한다. + +```text +schemaId +schemaVersion +boundary +protocol +sourceArtifactId + sourceDigest +unknownFieldPolicy + providerMaxEncodedBytes + maxDecodedBytes +maxDepth +maxNodes +maxStringBytes +maxCollectionItems +compatibilityPolicy +owner +``` + +unknown-field 기본 정책: + +| 경계 | 정책 | +| --- | --- | +| request, config, capability, control envelope | `REJECT_UNKNOWN` | +| evolvable ordinary response DTO | `STRIP_UNKNOWN` 후 mapper에 전달 | +| discriminant/security/authorization 의미를 가진 union | unknown variant 거절 | +| unknown data 보존 | adapter 내부 forward proxy가 아닌 한 금지 | + +현재 reference request/path DTO는 `.strict()`로 unknown field를 거절하고, +ordinary response DTO는 `.strip()` projection으로 additive server field를 +cache/domain 경계 밖에 버린다. discriminant/security union을 포함한 다른 +operation은 각 compatibility profile에 따라 별도로 결정한다. + +## 12. Mapper 경계 + +mapper는 transport가 아니라 feature contract가 소유한다. + +```text +MapperDefinition + mapperId + inputSchemaId + outputContractId + mapperVersion + collectionPolicy + temporalPolicy + numericPolicy + nullabilityPolicy + owner +``` + +mapper는 pure, deterministic, side-effect-free이며 다음 union을 반환한다. + +```text +MappingResult + = { ok: true, value: T } + | { ok: false, error: MAPPING_CONTRACT_VIOLATION } +``` + +현재 reference mapper는 예상 가능한 drift를 throw하지 않고 closed +`MappingResult` failure로 반환한다. registry 실행 경계는 예상하지 못한 mapper +throw도 fail-closed mapping failure로 바꾸며 raw DTO, value, path와 backend +message를 버린다. + +공통 scalar 규칙: + +- opaque ID는 trim/재해석하지 않는 bounded branded string이다. +- ISO timestamp는 offset/precision 정책을 검증한 뒤 application instant로 + 변환한다. locale date string과 invalid date normalization을 금지한다. +- `int64`, decimal money와 high-precision value는 JSON number로 받지 않는다. +- `null`, absent와 empty string/list는 schema와 domain에서 별도 의미로 결정한다. +- unknown enum은 domain이 explicit `UNKNOWN`을 소유한 경우만 mapping한다. +- collection mapping은 count/byte ceiling 안에서 fail-fast하고 partial array를 + cache하지 않는다. +- mapper는 network, clock, storage, QueryClient, locale formatter와 telemetry를 + 호출하지 않는다. + +## 13. Server State Cache 경계 + +TanStack Query는 transport response cache가 아니라 mapped application projection의 +memory lifecycle owner다. + +cache에 허용: + +- immutable plain application projection +- registered query identity로 찾을 수 있는 bounded collection/page +- UI가 stale/refresh 상태를 계산하는 library metadata + +cache에 금지: + +- `Response`, raw JSON/GraphQL envelope, generated protobuf message +- auth/CSRF/idempotency token, request header와 arbitrary URL +- raw ETag, trace/span, backend error/details +- File/Blob/stream/native handle +- domain service, class instance, function, Promise와 `AbortSignal` + +query profile은 caller가 raw option을 전달하는 대신 registry에서 선택한다. + +```text +ServerStateProfileV1 + profileId + queryKeyCodecId + scopePersistencePolicyId + staleTimeMs + gcTimeMs + refetchOnFocus + refetchOnReconnect + networkMode + maxResultBytes + maxCollectionItems + paginationProfileId | null + revalidationProfileId | null + invalidationTopicRefs[] = { topicId, topicVersion } + placeholderPolicy + owner +``` + +query key는 validated/canonical application input과 scope projection으로 만든다. +REST URL, GraphQL document/hash, protobuf bytes와 generated message serialization은 +query key가 아니다. transport 교체가 use-case identity를 바꾸지 않으면 같은 +application query family를 유지할 수 있지만, old/new representation을 한 cache +entry에 shadow write하지 않는다. + +network retry는 transport adapter가 소유하고 Query retry는 기본 `false`다. +refresh failure에서 유효한 previous data는 stale-degraded로 유지한다. schema, +mapper, scope, authorization와 contract mismatch는 stale data를 계속 노출해도 +되는지 query profile이 명시해야 하며 기본은 security-sensitive scope에서 +즉시 숨김/clear다. + +VD-13이 scope/persistence profile, normative key layout, account/session +generation과 late-result fence를 소유한다. 이 문서는 그 profile을 exact join하고 +operation/cache policy, pagination, revalidation과 mutation coherence를 소유한다. + +## 14. Pagination과 conditional revalidation + +cursor pagination은 다음 binding을 갖는다. + +```text +PaginationBinding + query family fingerprint + canonical filters/sort + scope fingerprint + server snapshot/revision policy + page size ceiling + opaque next cursor +``` + +- cursor를 decode하거나 URL로 취급하지 않는다. +- `hasMore === (nextCursor !== null)`을 codec에서 강제한다. +- single-page cache는 runtime-scoped cursor fingerprint를 semantic key에 포함하고, + infinite query만 cursor를 root key에서 제외해 bounded `pageParam`으로 둔다. +- max pages/items/estimated bytes를 넘으면 더 불러오지 않는다. +- 동일 cursor 반복, loop와 non-progress page를 contract failure로 닫는다. +- offset pagination의 insert/delete drift를 자동 deduplicate로 숨기지 않는다. +- page merge는 mapper가 보장한 stable identity가 있을 때만 deterministic하다. +- previous filters의 page를 새 filter key에 재사용하지 않는다. + +REST `304`는 cached data가 있다는 뜻이 아니라 representation이 바뀌지 않았다는 +transport 결과다. exact query fingerprint, scope/generation과 cached mapped +value에 binding된 validator record가 모두 있을 때만 freshness를 갱신한다. +cached value가 없거나 binding이 다르면 unconditional request를 한 번 수행하거나 +closed failure로 끝낸다. + +GraphQL persisted operation과 gRPC-Web unary는 기본적으로 application-level +validator가 없다. backend가 revision을 제공하면 response schema/mapper가 opaque +revision을 application revalidation policy로 투영해야 하며 HTTP/gRPC metadata를 +임의로 ETag처럼 해석하지 않는다. + +## 15. Mutation coherence + +mutation과 query cache는 server commit authority가 아니다. + +```text +validate command + -> derive logical key + exact command equality/opaque identity token + -> runtime/scope coordinator applies concurrency + duplicate admission + -> separately acquire invalidation-topic hint-coalescing lease + -> cancel exact affected query reads + -> capture bounded base cache revision and inverse patch + -> install own ordered optimistic layer with revision CAS + -> transport derives backend idempotency binding from operation policy + -> execute command once with transport-owned retry policy + -> success: registered exact seed/compare-and-apply + list/aggregate invalidation + -> rejection: remove or invert only own layer with revision CAS + -> uncertainty/CAS miss: preserve other commits + invalidate/authoritative refetch + -> release invalidation lease + concurrency admission +``` + +- duplicate submit 정책은 `JOIN_IDENTICAL`, `REJECT_DUPLICATE`, + `ALLOW_INDEPENDENT` 중 operation별로 고정하고, 동일성은 전체 validated semantic + input의 runtime-private exact equality guard와 opaque identity token으로 + 판정한다. +- 같은 hook instance의 Promise dedupe를 server idempotency로 간주하지 않는다. +- logical key/identity admission은 local ordering이고 invalidation-topic lease는 + remote hint coalescing일 뿐이다. 둘 다 backend idempotency authority가 아니다. +- optimistic patch는 raw DTO나 generated message를 만들지 않는다. +- snapshot item/byte ceiling을 넘으면 optimistic update를 하지 않고 pending UX만 + 제공한다. +- 409/GraphQL conflict code/gRPC `ABORTED`는 동일한 safe conflict vocabulary로 + mapping하되 server revision과 merge policy는 feature use case가 소유한다. +- server가 commit한 뒤 local invalidation 실패를 command 실패로 되돌리지 않는다. + cache health를 degraded로 기록하고 bounded refetch/recovery를 예약한다. +- command의 자동 protocol failover는 중복 side effect 위험 때문에 금지한다. + +## 16. Server streaming과 cache + +gRPC-Web server stream, GraphQL subscription과 incremental delivery는 ordinary +queryFn과 다르다. + +현재 installed API operation registry는 terminal REST operation만 소유한다. +GraphQL HTTP와 gRPC-Web unary/server-stream은 각 reference adapter가 구현될 때 +protocol discriminant와 전용 binding으로 확장한다. SSE, WebSocket과 Web Push는 +VD-28 realtime registry가 소유하며 bounded polling은 registered terminal REST +`QUERY`의 scheduling policy이지 새 protocol이나 automatic failover가 아니다. +GraphQL `@defer`/`@stream`은 선택될 경우에도 한 HTTP operation의 finite +incremental response이며 subscription과 같은 장기 realtime stream이 아니다. + +```text +ServerStreamPort + open(frozen request, signal) + -> AsyncIterable> + -> close() +``` + +- frame/event마다 schema, mapper, scope와 generation을 재검증한다. +- sequence, duplicate, gap과 resume token은 backend application protocol이다. +- bounded queue, high-water mark, overflow, idle/total deadline을 선언한다. +- stream event는 registered reducer로 immutable snapshot을 만들거나 query + invalidation hint만 발행한다. +- partial event를 ordinary query success로 cache하지 않는다. +- stream 종료/재connect를 TanStack Query retry로 처리하지 않는다. + +GraphQL subscription은 현재 `NOT_SELECTED`이고, gRPC-Web server-stream은 +`DESIGNED_NOT_IMPLEMENTED`다. + +gRPC-Web `ServerStreamPort`는 operation-bound outbound stream result일 수 있다. +API adapter가 protobuf frame/message decode, semantic schema와 boundary mapper를 +끝낸 뒤 제품이 runtime-wide notification projection을 명시적으로 선택한 +branch에서만 mapped event를 VD-28 common coordinator/`FeatureEventInput`에 +전달한다. protobuf를 `REALTIME_EVENT_V1` JSON으로 감싸지 않고, 첫 event 전 +opening replay와 이후 resume 규칙은 VD-27이 계속 소유한다. + +## 17. Backend/provider 계약 + +구현 owner, 권장 topology, 현재 reference endpoint/envelope, idempotency store, +Cursor/ETag/revision과 protocol별 handoff checklist는 +[Backend API와 Server State contract](./backend-api-and-server-state-contract.md)가 +소유한다. 아래 표는 frontend 설계가 요구하는 경계 요약이다. + +| 경계 | backend/provider가 제공할 계약 | +| --- | --- | +| 공통 | authorization, contract version/artifact compatibility, encoded transfer와 decoded payload의 bounded owner, stable error code, correlation/trace projection, idempotency와 rate-limit semantics | +| REST | exact method/path/media/status/envelope, CSRF strategy, idempotency retention, cursor binding, ETag/If-None-Match 또는 revision, retry-safe status와 CORS/cache policy | +| GraphQL | schema registry, persisted-operation manifest, allowlist/cost/depth enforcement, safe error extension vocabulary, operation retirement과 N/N-1 router rollout | +| Connect | proto/descriptor/codegen source, exact Connect-Web runtime/encoding/method, EndStream/status/error, timeout/cancel/compression/CORS와 browser/server conformance | +| gRPC-Web | proto/descriptor source, Buf/protoc compatibility policy, gRPC-Web proxy, exact service/method, message/frame ceiling, status/trailer/CORS exposure와 stream resume protocol | +| Protobuf REST Gateway | selected gateway kind/version, HttpRule/ProtoJSON/OpenAPI artifact, path/query/body/status/error/header mapping, edge→upstream cancellation과 N/N-1 conformance | +| Schema | authenticated source artifact, additive/breaking classification, deprecation window, fixtures and source digest | +| Mapper | domain meaning, temporal/numeric/null/enum semantics와 stable identity | +| Cache | revision/conflict/idempotency/invalidation semantics; frontend TTL은 authorization 대체가 아님 | + +frontend가 제공하는 Zod schema, generated type과 cache invalidation은 backend +authorization, validation, idempotency와 conflict resolution을 대체하지 않는다. + +## 18. Security와 privacy + +- API base/GraphQL/Connect/gRPC-Web endpoint는 HTTPS registry ID로 고정한다. +- caller가 URL, header, GraphQL document, service/method와 metadata를 제출하지 + 못한다. +- auth owner가 credential을 붙이고 application/query/cache에는 token을 노출하지 + 않는다. +- credential attachment 뒤 URL/method/origin/body digest와 registry-owned header + binding을 재검증한다. auth integration unavailable 상태에서 request를 보내지 + 않는다. +- cookie session의 unsafe request는 CSRF token/header 또는 same-site BFF 정책을 + operation profile과 provider conformance로 증명한다. +- GET/GraphQL variables에 sensitive filter를 넣는 operation은 별도 review 없이 + 만들지 않는다. +- response byte/depth/node/string/collection/frame cap으로 resource exhaustion을 + 막는다. +- GraphQL cost/depth와 gRPC message cap은 server/proxy에서도 강제한다. +- mapper와 cache는 prototype/accessor/class/native object를 받아들이지 않는다. +- PII/business ID를 query key, diagnostics label, persisted cache physical key에 + 직접 넣지 않는다. 필요한 identity는 opaque partition/token policy를 쓴다. +- command identity token/logical mutation key는 runtime-local control data이며 + diagnostics, cross-context wire와 persistence에 넣지 않는다. +- raw request/response, GraphQL variables/errors, protobuf bytes/metadata, ETag, + cursor, idempotency key와 validation value를 관측 데이터에 넣지 않는다. + +## 19. Observability + +허용된 bounded aggregate: + +- operation registry ID, protocol, semantics +- outcome/error kind, HTTP status group 또는 gRPC status code allowlist +- GraphQL full/partial/rejected outcome +- attempt/deadline/duration/encoded-byte/result-item/frame bucket +- schema/mapper profile ID와 compatibility outcome +- query hit/miss/stale/refetch/eviction bucket +- mutation optimistic/rollback/conflict/invalidation outcome +- provider/browser/runtime version의 low-cardinality bucket + +금지: + +- URL/path parameter/search/body/header +- GraphQL document, variables, response path와 raw error message +- protobuf message/metadata/trailer raw value +- resource/account/tenant ID, cursor, validator, digest와 cache value + +하나의 logical operation은 terminal observation 하나를 만든다. attempt span은 +sampling된 내부 detail로만 남기며 terminal success/failure count를 중복시키지 +않는다. + +## 20. Failure와 fallback + +| 실패 | 기본 결과 | +| --- | --- | +| registry/schema/mapper 누락 | network 전 fail-closed, operation traffic disable | +| incompatible contract artifact | product mount 또는 해당 capability admission 차단 | +| response cap/decode/schema mismatch | body/reader cancel, cache write 금지, provider incompatibility | +| mapper violation | cache write 금지, safe contract failure | +| REST retry exhaustion | stale 허용 profile만 previous data 유지 | +| GraphQL persisted operation missing | full document fallback 금지, coherent artifact rollback | +| GraphQL partial data | default reject; explicit profile만 completeness와 함께 사용 | +| Connect missing/duplicate EndStream 또는 oversize whole body | call cancel, cache write 금지, provider/runtime incompatible | +| gRPC-Web proxy/trailer mismatch | reader cancel, provider unavailable/incompatible | +| REST Gateway HttpRule/OpenAPI/runtime drift | affected route admission 차단, coherent gateway artifact rollback | +| server stream gap/overflow | snapshot 폐기 또는 authoritative refetch | +| account/generation mismatch | late result 폐기, old-scope cache write 금지 | +| invalidation failure after commit | command 성공 유지, cache degraded + recovery refetch | + +GraphQL, Connect 또는 gRPC-Web failure를 REST로 자동 전환하지 않는다. 사전에 등록된 +read-only shadow/fallback operation이 있고 동일 authorization/mapper/result +contract를 conformance suite로 증명한 경우만 selector가 새 logical query를 +시작할 수 있다. + +## 21. Rollout과 removal + +```text +ADR + registry schema accepted + -> deterministic codec/schema/mapper fixture + -> provider-neutral adapter and fake + -> negative boundary/removal gate + -> AVAILABLE_NOT_COMPOSED + -> product/provider/operation selection + -> bootstrap composition behind TrafficAdmission=DISABLED + -> COMPOSED + -> shadow/read-only conformance + -> browser/provider/operations evidence + -> PromotionEvidence=COMPLETE + -> CANARY + -> ENABLED +``` + +REST v2 local baseline은 installed reference operation에 연결됐다. 실제 provider +traffic은 operation/profile 단위의 conformance와 canary를 거쳐야 하며, +conditional/pagination은 backend 계약 없이 enabled하지 않는다. +GraphQL/Connect/gRPC-Web/codegen/gateway dependency는 실제 selected operation이 +없으면 production inventory에 없어야 한다. + +removal: + +1. 신규 operation admission을 닫는다. +2. query는 cancel하고 command/stream은 bounded drain 또는 explicit abort한다. +3. 해당 invalidation listener, auth attachment와 provider를 close한다. +4. current scope의 mapped memory cache를 clear한다. +5. operation/schema/mapper/query profile과 generated artifact를 제거한다. +6. dependency, config, proxy route, test fixture와 production module inventory가 + 함께 제거됐음을 증명한다. +7. backend persisted-operation/method retirement은 N/N-1 client window 뒤에 한다. + +## 22. Test와 promotion evidence + +### Deterministic + +- operation registry closed union/reference/orphan/duplicate +- request canonicalization과 query-key identity +- timeout/total deadline/retry/idempotency/auth recovery +- response byte/depth/node/collection cap +- schema unknown-field, scalar, null/enum/numeric/date matrix +- mapper success/failure/no raw value leakage +- query stale/gc/refetch/pagination/mutation/rollback/generation fence + +### Contract + +- REST OpenAPI/envelope/status/media/cursor/conditional/idempotency +- GraphQL schema + persisted manifest + variables/result/error/partial policy +- proto descriptor + breaking check + gRPC status/trailer/frame fixture +- Connect unary/stream JSON/binary/GET/EndStream/CORS/deadline fixture +- HttpRule route manifest + ProtoJSON/OpenAPI/status/error/header mapping fixture +- 같은 fixture를 fake, emulator/staging과 actual provider에 실행 + +### Browser/integration + +- bootstrap → feature → transport → schema → mapper → Query → UI +- AbortSignal/navigation/logout/account switch +- CORS/cookie/CSRF/redirect/content-encoding +- HTTP/2/proxy/CDN/Connect EndStream/gRPC-Web trailer behavior +- offline/reconnect/focus와 stale-degraded UI + +### Fault + +- truncated/oversize/malformed response +- slow credential/network/body/schema/mapper phase +- 401 recovery, 429, retry exhaustion과 total deadline +- GraphQL partial/error/persisted-operation drift +- Connect missing/early/duplicate EndStream, whole-body overflow와 compression mismatch +- gRPC missing/conflicting terminal status source, corrupt/compressed/oversize + frame와 stream gap +- REST Gateway route/OpenAPI/runtime rewrite drift와 abort propagation loss +- late response, duplicate mutation, optimistic rollback과 invalidation failure + +### Operations + +- operation kill switch +- contract artifact N/N-1 rollout과 rollback +- provider incompatibility containment +- cache scope reset와 stale-data decision +- generated client/GraphQL/Connect/gRPC-Web/REST Gateway removal drill + +fake와 generated compile success만으로 actual provider, browser나 operations +evidence를 `COMPLETE`로 표시하지 않는다. + +## 23. 설계 우선 work package + +| package | 목표 | +| --- | --- | +| API-01 | REST v2 operation registry, total deadline, bounded decoder와 conditional/pagination contract | +| API-02 | multi-protocol schema artifact/digest governance와 typed Mapper result | +| API-03 | strict ServerStateProfile, query-key codec, pagination/revalidation와 mutation policy | +| API-04 | persisted-operation-only GraphQL reference adapter와 conformance harness | +| API-05 | gRPC-Web unary/server-stream reference adapter와 proxy harness | +| API-06 | Connect-Web unary/server-stream reference adapter와 provider harness | +| API-07 | Protobuf governance와 selected REST Gateway conformance | +| API-08 | atomic composition, readiness, kill switch, runbook와 provider/browser evidence | + +권장 순서는 API-01 → API-02 → API-03이다. API-04~07은 제품 선택과 +backend/provider 계약이 생긴 branch만 독립적으로 시작한다. GraphQL, Connect, +gRPC-Web과 REST Gateway를 “미래 대비” 목적으로 모두 기본 bundle에 설치하지 +않는다. + +## 24. 완료 기준 + +- [ ] 모든 installed operation은 protocol/schema/mapper/cache/error/deadline owner가 있다. +- [ ] application/presentation public type에 DTO, GraphQL SDK와 generated protobuf가 없다. +- [ ] untrusted byte부터 mapped projection까지 모든 ceiling과 trust transition이 닫혀 있다. +- [ ] query key와 실제 request input이 동일 canonical source에서 파생된다. +- [ ] transport retry와 Query retry가 중복되지 않는다. +- [ ] command idempotency, optimistic patch와 conflict/invalidation owner가 명시돼 있다. +- [ ] account/logout/release generation 뒤 late result가 cache에 들어가지 않는다. +- [ ] actual REST/GraphQL/Connect/gRPC-Web/Gateway provider에 같은 semantic + conformance fixture를 실행한다. +- [ ] contract drift, kill switch, rollback과 optional dependency removal drill이 통과한다. +- [ ] raw payload/URL/document/message/metadata/validator가 log와 cache에 없다. +- [ ] `COMPOSED`와 production-ready/provider-conformant를 같은 의미로 쓰지 않는다. + +## 25. 관련 문서 + +- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md) +- [Client cache and browser storage](./client-cache-and-storage.md) +- [VD-13 Client cache scope와 persistence](./decisions/VD-13-client-cache-scope-and-persistence.md) +- [Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md) +- [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md) +- [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md) +- [Contract compatibility](../contracts/compatibility.md) +- [Frontend platform testing strategy](../testing/frontend-platform-testing-strategy.md) diff --git a/docs/architecture/backend-api-and-server-state-contract.md b/docs/architecture/backend-api-and-server-state-contract.md new file mode 100644 index 0000000..95d45ca --- /dev/null +++ b/docs/architecture/backend-api-and-server-state-contract.md @@ -0,0 +1,718 @@ +# Backend API와 Server State handoff contract + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +- 상태: frontend handoff design accepted, backend implementation/evidence pending +- 기준일: 2026-07-28 +- 대상: Web API/BFF, application service, persistence, identity, GraphQL router, + Connect/gRPC-Web gateway, Protobuf REST Gateway와 운영 owner +- frontend 기준: + [API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md) +- browser Protobuf/gateway 기준: + [Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md) +- 파일 전송 backend 기준: + [Server file capability infrastructure](./server-file-capability-infrastructure.md) +- 복구 절차: + [API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md) + +## 1. 문서의 경계 + +이 문서는 이 frontend template이 실제 제품 backend와 연결될 때 backend가 +제공해야 하는 구조, wire contract, 상태 의미와 운영 증거를 정의한다. 특정 +언어·framework·cloud 제품을 강제하지 않는다. Spring, Nest/Fastify, Go, +.NET 또는 다른 stack을 사용해도 아래 불변조건은 동일하다. + +이 repository에는 backend source, database migration, identity provider, +GraphQL schema/router, protobuf descriptor, Connect/gRPC-Web runtime/proxy, +REST transcoder와 실제 provider evidence가 없다. 따라서 이 문서는 backend 구현 +완료 증거가 아니다. + +파일 업로드·다운로드, object storage, presigned URL, multipart와 Image CDN은 +별도 server file 문서가 소유한다. 이 문서는 ordinary REST/GraphQL/Connect/ +gRPC-Web application API, Protobuf REST Gateway와 frontend Server State 계약만 +소유한다. + +## 2. 권장 논리 구조 + +```text +Browser + -> CDN / reverse proxy / WAF + -> Browser-facing Web API or BFF + -> authentication + authorization + -> exact operation registry + -> request schema / byte / rate limit + -> REST controller + -> optional persisted GraphQL router + -> optional Connect browser RPC gateway + -> optional gRPC-Web gateway + -> optional Protobuf REST transcoder + -> application service + -> command transaction + -> query/read-model service + -> idempotency coordinator + -> revision/validator owner + -> outbox/event owner + -> primary database + -> idempotency store + -> read replica/read model + -> event broker when selected +``` + +Browser-facing contract owner와 내부 service contract owner를 분리한다. + +- Browser API/BFF는 CORS, cookie/CSRF 또는 bearer, public DTO, envelope, + body ceiling, status/media와 redaction을 소유한다. +- Application service는 authorization 재검사, transaction, idempotency, + conflict, revision과 domain invariant를 소유한다. +- Persistence adapter는 SQL/NoSQL/Redis vendor type, row version과 cursor + implementation을 외부 DTO에 노출하지 않는다. +- GraphQL router, Connect/gRPC-Web gateway와 REST transcoder는 선택 adapter다. + 다른 protocol로 임의 fallback하거나 frontend에 내부 service address를 + 노출하지 않는다. + +작은 제품은 이 논리 모듈을 하나의 deployable로 구현할 수 있다. deployable을 +나누는 것보다 transaction/idempotency/authorization owner가 하나로 명확한지가 +우선이다. + +## 3. 공통 contract artifact + +Backend와 frontend release는 다음 bounded contract set을 공유한다. + +```text +ApiContractSetV1 + globalApiContractVersion + restArtifactId + digest + runtimeSchemaManifestId + digest + mapperSemanticManifestId + digest + errorVocabularyVersion + minimumFrontendVersion + minimumBackendVersion + effectiveAt + retirementEpoch | null + + optional: + graphqlSchemaId + digest + persistedGraphqlManifestId + digest + protobufDescriptorId + digest + protobufSourceOrModuleId + digest + protobufCodegenProfileId + connectProviderProfileId + grpcWebProviderProfileId + protobufRestGatewayProfileId + httpRuleArtifactId + digest + protoJsonProfileId + gatewayOpenApiArtifactId + digest +``` + +최소 산출물: + +- authenticated OpenAPI 또는 동등한 REST schema source +- exact status/media/envelope fixture +- stable error code vocabulary +- request/response byte와 collection ceiling +- scalar/date/null/enum 의미 +- N/N-1 compatibility 결과 +- backend build와 frontend release가 참조하는 immutable digest + +runtime config의 version 문자열 일치만 compatibility 증거로 사용하지 않는다. +artifact digest가 없는 동안에는 실제 staging conformance fixture와 수동 승인 +evidence가 필요하다. + +## 4. 현재 reference REST 계약 + +현재 frontend에 실제 조립된 operation은 다음 세 개다. + +| operation | request | success | +| --- | --- | --- | +| `LIST_REFERENCE_RESOURCES` | `GET /api/reference-resources?cursor&limit&tags` | `200 application/json` | +| `GET_REFERENCE_RESOURCE` | `GET /api/reference-resources/{resourceId}` | `200 application/json` | +| `CREATE_REFERENCE_RESOURCE` | `POST /api/reference-resources` | `200` 또는 `201 application/json` | + +현재 list payload는 `ReferenceResource[]`다. `cursor` 입력이 존재하더라도 +`CursorPage` 출력 계약은 아직 아니다. backend가 같은 operation에서 배열을 +page object로 조용히 바꾸면 schema mismatch로 실패한다. + +resource DTO: + +```text +ReferenceResourceDtoV1 + id: non-empty string, maximum 120 characters + name: non-empty string, maximum 240 characters + createdAt?: RFC 3339 date-time +``` + +create command: + +```text +CreateReferenceResourceCommandV1 + name: trimmed string, 1..120 + note?: trimmed string, 0..500 +``` + +Backend는 frontend validation을 신뢰하지 않고 동일하거나 더 좁은 validation과 +authorization을 다시 수행한다. + +### 4.1 JSON envelope + +모든 현재 JSON success/failure는 다음 envelope를 사용한다. + +```json +{ + "success": true, + "data": {}, + "meta": { + "requestId": "server-request-id", + "traceId": "server-trace-id", + "correlationId": "client-correlation-id" + } +} +``` + +```json +{ + "success": false, + "error": { + "code": "STABLE_MACHINE_CODE", + "category": "optional-safe-category", + "message": "optional non-sensitive copy", + "retryable": false, + "details": {} + }, + "meta": { + "requestId": "server-request-id", + "traceId": "server-trace-id", + "correlationId": "client-correlation-id" + } +} +``` + +Envelope 최상위 unknown field는 현재 거절된다. ordinary resource DTO의 unknown +field는 frontend schema에서 strip되지만 additive compatibility는 contract +review와 fixture를 먼저 통과해야 한다. + +`requestId`, `traceId`, `correlationId`는 각각 1..128 범위의 안전한 opaque +identifier다. credential, user data, cursor, validator와 database key를 +identifier에 encode하지 않는다. + +### 4.2 Status와 error + +| HTTP status | 의미 | +| --- | --- | +| `400` | malformed request 또는 closed request contract 위반 | +| `401` | 인증 없음/만료. 이미 적용된 command를 401로 반환하지 않음 | +| `403` | authenticated principal에게 권한 없음 | +| `404` | authorization 정책상 공개 가능한 not-found | +| `409` | idempotency fingerprint, domain revision 또는 semantic conflict | +| `412` | selected conditional write의 `If-Match` precondition 실패 | +| `422` | field validation. bounded `details.issues[]`만 허용 | +| `429` | rate limit. 유효한 `Retry-After`와 operation 정책 제공 | +| `500/502/503/504` | server/provider failure. command effect certainty 별도 | + +현재 frontend의 ordinary status mapper는 `412` 전용 처리를 아직 연결하지 +않았다. conditional mutation을 선택할 때 frontend failure vocabulary와 +transaction을 함께 승격해야 한다. + +Backend `error.code`는 machine-readable stable code다. stack, SQL/vendor error, +raw validation value, authorization reason과 내부 service address를 반환하지 +않는다. + +## 5. 인증, CSRF와 CORS + +현재 reference operation은 다음 profile로 조립돼 있다. + +```text +auth = external bearer +Authorization: Bearer +fetch credentials = omit +CSRF profile = none +redirect = error +referrer policy = no-referrer +``` + +Backend/BFF는 bearer의 issuer, audience, signature algorithm, time claims와 +revocation/session policy를 검증하고 operation별 authorization을 적용한다. +401과 403을 구분하며 frontend cache를 authorization authority로 사용하지 않는다. + +쿠키 session으로 전환할 경우 같은 profile로 간주하지 않는다. 별도 +`SAME_ORIGIN_COOKIE` profile에 다음을 함께 승인한다. + +- `Secure`, `HttpOnly`, 명시적 `SameSite`와 host/path scope +- unsafe method의 CSRF token/header와 Origin/Sec-Fetch-Site 검증 +- credentialed CORS에서 wildcard origin 금지 +- login/logout/session rotation과 cache generation 전환 +- session fixation, token rotation과 concurrent tab 동작 + +Cross-origin bearer provider 최소 CORS: + +- exact allow-origin 목록과 bounded preflight cache +- `Authorization`, `Content-Type`, `Idempotency-Key`, + `X-Correlation-ID`, 향후 `If-None-Match`, `If-Match` 허용 +- 필요한 경우 `ETag`, `Retry-After`, request/trace header만 expose +- redirect login page, HTML error body와 wildcard credential 금지 + +## 6. Command와 idempotency + +`CREATE_REFERENCE_RESOURCE`는 keyed command다. frontend memory single-flight는 +backend idempotency를 대체하지 않는다. + +idempotency identity: + +```text +principal/tenant + + semantic operation ID and contract version + + Idempotency-Key + + canonical request fingerprint +``` + +권장 record: + +```text +IdempotencyRecord + principalFingerprint + operationId + contractVersion + idempotencyKeyHash + requestFingerprint + state = IN_PROGRESS | COMMITTED | FAILED_SAFE | EFFECT_UNKNOWN + responseStatus + responseEnvelopeReference + resourceRevision | null + leaseOwner + leaseExpiry + retentionExpiry + createdAt + completedAt +``` + +불변조건: + +- claim과 command transaction의 관계가 원자적이거나 crash reconciliation + 가능해야 한다. +- 같은 key와 같은 fingerprint replay는 같은 authoritative receipt를 반환한다. +- 같은 key와 다른 fingerprint는 `409 IDEMPOTENCY_KEY_REUSED`다. +- concurrent replay는 하나만 실행하고 나머지는 같은 result를 기다리거나 + bounded `IN_PROGRESS` 결과를 받는다. +- commit 뒤 response 유실은 새 resource를 만들지 않는다. +- `EFFECT_UNKNOWN`은 새 key로 자동 재시도하지 않고 status/reconcile endpoint로 + 확인한다. +- retention은 frontend retry/recovery 최대 window보다 길고 quota/abuse limit이 + 있다. +- key 원문과 request body를 log/metric label에 넣지 않는다. + +Database unique constraint 또는 durable compare-and-set이 최종 중복 방지 +authority여야 한다. process-local map/lock만 사용하지 않는다. + +## 7. Cursor pagination과 snapshot + +Backend가 pagination을 선택할 때 새 response schema/operation version으로 다음 +contract를 제공한다. + +```text +CursorPage + items: T[] + nextCursor: opaque string | null + hasMore: boolean + snapshotToken: opaque string | null +``` + +필수 불변조건: + +- `hasMore === (nextCursor !== null)` +- 동일 chain의 `snapshotToken`은 모든 page에서 동일 +- cursor는 principal/tenant, filter, sort, contract version과 snapshot에 binding +- cursor는 opaque, 무결성 보호, 만료와 key rotation 정책 보유 +- offset이 아니라 stable keyset ordering 사용 +- total order의 마지막 tie-breaker는 immutable unique ID +- deleted/inserted row가 duplicate/gap을 만드는 의미를 snapshot 정책으로 결정 +- empty page인데 `hasMore=true`인 sparse page 허용 여부를 operation profile에 고정 +- cursor 최대 encoded byte, page size와 total scan/cost ceiling을 server도 강제 +- invalid, expired, wrong-principal, wrong-filter cursor의 safe error code를 고정 + +권장 query ordering 예: + +```text +ORDER BY created_at DESC, resource_id DESC +cursor payload = version + snapshot watermark + last(created_at, resource_id) + + filter digest + principal/tenant binding + expiry +``` + +Cursor 원문은 log, trace, analytics와 frontend persistent storage에 넣지 않는다. + +### 7.1 배열에서 page로의 migration + +1. `ReferenceResourceListPagePayloadV2` schema와 새 operation/version을 추가한다. +2. backend가 N/N-1 동안 기존 배열과 page contract를 동시에 제공한다. +3. frontend가 cursor runtime을 새 bound query/infinite query에 연결한다. +4. loop/snapshot/ceiling/abort conformance를 staging에서 검증한다. +5. 새 operation을 canary한 뒤 기존 배열 operation을 retirement한다. + +동일 media/status에서 payload shape만 바꾸는 in-place migration은 금지한다. + +## 8. Conditional read와 revision/CAS + +### 8.1 Read validator + +Backend가 application-managed revalidation을 선택하면 exact mapped +representation마다 ETag를 제공한다. + +```text +GET without validator + -> 200 + JSON envelope + ETag + +GET with If-None-Match + -> representation unchanged: 304 + empty body + -> changed: 200 + JSON envelope + new ETag +``` + +불변조건: + +- validator는 principal/tenant, authorization-visible representation, + response schema/mapper semantics와 encoding variant에 binding +- weak/strong 선택을 operation profile에 고정 +- user-private response를 shared CDN/public cache에 저장하지 않음 +- cross-origin이면 `ETag`를 expose하고 `If-None-Match`를 preflight 허용 +- 304에는 JSON success envelope를 넣지 않음 +- validator 원문을 log/metric/diagnostics에 넣지 않음 +- `Vary`와 `Cache-Control` owner를 명확히 하고 browser HTTP cache와 + TanStack/application revalidation이 서로 다른 value owner가 되지 않게 함 + +Frontend는 validator와 mapped cache value의 scope, query identity, +representation version과 cache revision이 모두 일치할 때만 304를 success로 +받는다. cache value가 없으면 unconditional refetch 또는 safe failure로 닫는다. + +### 8.2 Conditional command + +수정/삭제 command가 선택되면 DTO에 opaque domain `revision`을 추가하고: + +```text +If-Match: "" +``` + +를 요구한다. 일치하지 않으면 `412` 또는 승인된 `409` contract 하나만 +사용한다. frontend optimistic layer의 commit/rollback은 backend revision +authority를 대체하지 않는다. + +## 9. Optimistic mutation을 위한 backend 의미 + +Frontend ordered optimistic layer runtime은 구현돼 있지만 제품 operation에 +연결하려면 backend가 다음을 결정해야 한다. + +- resource/list membership을 결정하는 canonical filter와 sort +- command가 생성/수정/삭제하는 stable identity +- server-assigned ID와 client correlation의 reconcile 방법 +- authoritative resource/list revision +- conflict status와 stable error code +- commit response가 complete resource인지 receipt인지 +- effect certainty와 idempotency status/reconcile endpoint +- event/outbox가 있을 때 sequence/gap/snapshot reset 의미 + +Create가 server-assigned ID를 사용하는 경우 temporary UI ID를 backend ID로 +원자적으로 교체하고 관련 detail/list key를 reconcile하는 정책이 필요하다. +이 의미 없이 generic optimistic append를 기본 활성화하지 않는다. + +## 10. Database와 application service baseline + +구현 예시는 다음 논리 table/constraint를 만족해야 한다. + +```text +reference_resource + tenant_id + resource_id + display_name + note + revision + created_at + updated_at + deleted_at | null + unique(tenant_id, resource_id) + +idempotency_record + principal/tenant fingerprint + operation + contract version + key hash + request fingerprint + state + receipt + lease/retention timestamps + unique(principal/tenant, operation, contract version, key hash) + +outbox_event when selected + aggregate identity + revision + event type/version + sequence + payload reference or bounded safe projection + publication state +``` + +Application service transaction은 authorization scope와 tenant predicate를 +모든 read/write에 적용하고, resource mutation과 revision/outbox 기록을 같은 +transaction boundary에 둔다. cache/replica lag를 고려해 command 직후 read +consistency와 invalidation owner를 선언한다. + +## 11. GraphQL 선택 시 추가 구조 + +GraphQL은 제품 operation이 REST보다 aggregation 이점을 실제로 가질 때만 +선택한다. + +```text +Browser + -> persisted-operation endpoint + -> manifest allowlist + -> auth/CSRF/rate/cost/depth/alias enforcement + -> GraphQL router + -> application services/loaders +``` + +Backend handoff: + +- authenticated immutable schema artifact와 digest +- named operation source와 persisted ID/hash manifest +- variables/result runtime fixtures +- selected GraphQL-over-HTTP revision과 exact media/status profile +- partial data policy와 safe error extension vocabulary +- field/row authorization, cost/depth/alias/list ceiling +- N/N-1 router/frontend manifest rollout과 retirement + +Production endpoint는 arbitrary document와 persisted miss 후 full-document +fallback을 받지 않는다. normalized frontend entity cache는 별도 제품 선택이다. + +## 12. gRPC-Web 선택 시 추가 구조 + +gRPC-Web은 browser-facing gateway/proxy가 실제 선택된 unary 또는 bounded +server-stream operation에만 사용한다. + +```text +Browser + -> same-origin BFF/Envoy/gRPC-Web gateway + -> exact service/method allowlist + -> frame/message/deadline/status/trailer enforcement + -> internal gRPC application service +``` + +Backend handoff: + +- authenticated proto source와 immutable descriptor digest +- Buf/protoc lint/breaking 및 deterministic generation evidence +- exact service/method/rpc-kind allowlist +- selected gRPC-Web runtime kind, client API와 binary/JSON/text/wire revision; + official XHR와 Connect-Web Fetch profile을 분리 +- proxy CORS, content-type, terminal status/trailer behavior +- Envoy를 선택하면 exact version/config digest, filter order, upstream HTTP/2, + route/idle/max-stream timeout, timeout offset와 buffering/flush +- message/frame/count/queue/idle/total budget +- server-stream sequence, gap, resume와 snapshot reset protocol +- actual browser/proxy conformance + +client streaming과 bidirectional streaming은 common gRPC-Web browser contract로 +간주하지 않는다. upload는 REST transfer, duplex는 별도 protocol을 선택한다. + +## 13. Connect-Web/Connect 선택 시 추가 구조 + +Connect는 Protobuf-first backend의 selected unary 또는 bounded server-stream +operation에만 사용한다. Connect protocol과 Connect-Web의 gRPC-Web transport는 +서로 다른 provider row다. + +```text +Browser Connect-Web adapter + -> same-origin BFF 또는 exact cross-origin Connect endpoint + -> auth/CSRF/CORS + service/method allowlist + -> Connect protocol handler + -> application service +``` + +Backend handoff: + +- authenticated proto/Buf source, descriptor와 generated-service digest +- exact Connect-Web/client runtime과 server/gateway version +- protocol revision, JSON/binary encoding, POST 또는 approved GET +- unary HTTP/error profile 또는 stream EndStream terminal profile +- request/response/envelope/message/count/queue byte ceiling +- unary/stream compression capability; stock browser stream은 identity-only +- total/idle timeout, browser abort→server context→downstream cancellation 전파 +- exact CORS allow/expose/preflight와 auth/CSRF profile +- actual Chromium/Firefox/WebKit와 selected proxy/server conformance + +GET은 descriptor `NO_SIDE_EFFECTS`, non-sensitive bounded input, URL/cache key, +`Vary`와 credential policy가 모두 승인된 unary에만 허용한다. browser +client-streaming/bidi는 Connect protocol 자체 기능과 별개로 `PLATFORM_LIMITED`다. + +## 14. Protobuf REST Gateway 선택 시 추가 구조 + +한 route는 `CURATED_BFF | GRPC_GATEWAY | ENVOY_TRANSCODER` 중 하나만 소유한다. +현재 reference REST의 envelope와 `200|201`, 향후 `204/304/412` 의미를 유지하는 +기본 선택은 curated BFF다. + +Direct gateway는 ProtoJSON/HttpRule/status/error 자체를 새 public contract로 +승인한 unary operation에서만 선택한다. Backend handoff: + +- `.proto` annotation 또는 precedence가 고정된 service config의 immutable source +- descriptor/Buf image, canonical HttpRule route manifest와 digest +- pinned gateway/runtime/generator/plugin과 generated OpenAPI artifact +- ProtoJSON name/default/enum/int64/bytes/null/presence/unknown-field profile +- exact method/path/query/body/response-body/additional-binding와 path escaping +- safe status/error/header mapping과 raw `google.rpc.Status` detail redaction +- CORS/auth/CSRF, body/header/query ceiling와 rate limit +- browser abort/deadline의 upstream gRPC/application work 전파 +- N/N-1 route/OpenAPI/runtime conformance와 coherent rollback + +Gateway는 durable idempotency, pagination snapshot, ETag/HTTP conditional, +product envelope, authorization와 file transfer semantics를 자동 구현하지 않는다. +필요한 operation은 application service와 BFF가 계속 소유한다. generated +REST streaming은 별도 framing/terminal/cache ADR 없이는 `NOT_SELECTED`다. + +## 15. Invalidation과 realtime + +현재 frontend cross-tab invalidation은 같은 browser origin 안의 opaque +invalidate-only hint다. backend event delivery를 의미하지 않는다. + +Backend-driven invalidation/realtime을 선택하면: + +- transactional outbox 또는 동등한 durable publication +- principal/tenant authorization을 통과한 event projection +- event type/version, aggregate revision, sequence와 dedupe identity +- reconnect cursor, gap detection과 snapshot reset +- retention, replay ceiling과 slow-consumer policy + +를 제공해야 한다. event payload를 authoritative resource snapshot으로 쓸지 +query invalidate hint로만 쓸지 operation별 reducer contract가 필요하다. + +## 16. Rate limit, deadline와 retry + +- backend deadline은 frontend total deadline보다 짧거나 cancellation을 전파할 수 + 있어야 한다. +- disconnect/cancel 뒤 불필요한 query 작업은 중단한다. +- keyed command는 disconnect가 transaction rollback을 보장하지 않으므로 + idempotency receipt로 effect를 판정한다. +- `Retry-After`는 selected status에서만 bounded delta/date 형식으로 제공한다. +- retry-safe read와 keyed command를 구분한다. +- proxy, BFF와 service retry가 겹쳐 retry amplification을 만들지 않게 한 owner만 + 재시도한다. +- rate limit key는 principal/tenant/operation과 abuse policy에 binding하며 raw + credential/IP를 metric label에 넣지 않는다. + +## 17. Observability와 privacy + +허용되는 공통 dimension: + +```text +operation ID +contract/profile version +status group / safe error code +attempt bucket +duration bucket +provider/runtime health +traffic admission stage +``` + +금지: + +- Authorization, cookie, CSRF와 idempotency key +- request/response body와 validation value +- URL query, cursor, snapshot, ETag/revision +- GraphQL variables/path/raw error/extensions +- protobuf bytes, metadata와 trailer 원문 +- user ID/email/file name을 metric label이나 trace attribute로 사용 + +Request ID와 trace ID는 browser에 반환할 수 있지만 credential 역할을 하지 않으며 +추측 가능한 database primary key를 포함하지 않는다. + +필수 SLO/alert 후보: + +- operation availability와 latency +- 401/403/409/412/422/429 및 5xx rate +- schema/mapper/contract mismatch +- idempotency in-progress age, collision과 unknown effect +- cursor invalid/expired/loop-equivalent server detection +- conditional hit/miss와 invalid 304 +- GraphQL persisted miss/cost reject +- Connect missing/duplicate EndStream, whole-body/queue overflow와 compression mismatch +- gRPC-Web missing terminal status, frame/idle/queue overflow +- REST Gateway route/OpenAPI/runtime rewrite drift와 cancel propagation loss + +## 18. 배포, compatibility와 rollback + +권장 순서: + +1. contract artifact와 compatibility diff를 생성한다. +2. backend가 N/N-1 fixture를 통과한 상태로 먼저 배포한다. +3. frontend operation은 traffic disabled 상태에서 staging conformance를 실행한다. +4. read-only shadow/canary 뒤 query traffic을 올린다. +5. keyed command는 idempotency/reconcile fault injection 뒤 별도 canary한다. +6. pagination, conditional, optimistic, GraphQL, Connect, gRPC-Web과 REST + Gateway는 각각 독립 gate로 승격한다. +7. provider/browser/operations evidence가 완료된 operation만 enabled한다. + +Rollback은 frontend/backend/contract artifact를 coherent set으로 되돌린다. +unknown-effect command를 다른 protocol이나 새 idempotency key로 replay하지 않는다. +Backend가 old contract를 제거하는 시점은 실제 frontend support window와 cache/CDN +retention 뒤다. + +## 19. Conformance와 fault-injection matrix + +Backend 완료 판정에는 unit test 외에 actual staging provider evidence가 필요하다. + +| 범위 | 필수 증거 | +| --- | --- | +| REST | exact path/query/body, media/status/envelope, max body, malformed/truncated JSON | +| Auth | missing/expired credential, 401/403, rotation, cross-origin preflight | +| Command | concurrent same-key replay, fingerprint mismatch, commit 뒤 response loss | +| Cursor | filter/sort binding, expiry, snapshot stability, loop/gap/duplicate 방지 | +| Conditional | 200→304, cache-missing 304 방지, representation change, 412 | +| Schema | additive/breaking/null/enum/time/number fixtures와 N/N-1 | +| GraphQL | persisted hit/miss/hash mismatch, partial, cost/depth, router rollout | +| Connect | JSON/binary unary, GET restriction, EndStream, body/message cap, compression, cancel/deadline와 CORS | +| gRPC-Web | proxy media/status/trailer, oversized frame, cancel, idle, gap/resume | +| REST Gateway | HttpRule path/query/body, ProtoJSON, OpenAPI/status/error rewrite, abort propagation과 N/N-1 | +| Operations | deadline/retry amplification, rate limit, kill switch, coherent rollback | + +## 20. Backend handoff checklist + +- [ ] Browser-facing API/BFF owner와 on-call이 정해졌다. +- [ ] reference REST exact endpoint/envelope/status/media fixture가 있다. +- [ ] bearer 또는 cookie+CSRF 중 하나의 실제 profile과 CORS evidence가 있다. +- [ ] stable error vocabulary와 redaction contract가 있다. +- [ ] keyed command idempotency store, TTL, receipt와 reconcile이 있다. +- [ ] CursorPage를 선택했다면 opaque cursor/snapshot contract가 있다. +- [ ] conditional을 선택했다면 ETag/304/412와 cache owner가 있다. +- [ ] optimistic을 선택했다면 identity/membership/revision/conflict 의미가 있다. +- [ ] OpenAPI/runtime schema/mapper semantic artifact와 digest가 release에 binding됐다. +- [ ] GraphQL을 선택했다면 schema/persisted manifest/router evidence가 있다. +- [ ] Connect를 선택했다면 descriptor/runtime/server/browser evidence가 있다. +- [ ] gRPC-Web을 선택했다면 descriptor/proxy/browser evidence가 있다. +- [ ] REST Gateway를 선택했다면 kind/HttpRule/ProtoJSON/OpenAPI와 runtime + conformance evidence가 있다. +- [ ] staging conformance, fault injection, canary, kill switch와 rollback drill이 + 통과했다. + +## 21. Frontend 완료 경계 + +Backend 구현과 별개로 현재 frontend 상태를 다음처럼 해석한다. + +| 범위 | 현재 상태 | 남은 owner | +| --- | --- | --- | +| REST path/provider/auth/deadline/bounded JSON | `COMPOSED` | actual provider conformance는 backend/operations | +| runtime schema와 mapper registry | `COMPOSED` | artifact digest/source provenance는 backend contract source + frontend/platform | +| session generation과 query identity | `COMPOSED` | account identity projection은 identity integration + frontend | +| Cursor runtime | `AVAILABLE_NOT_COMPOSED` | CursorPage backend 계약 후 frontend query binding | +| conditional validator store | `AVAILABLE_NOT_COMPOSED` | ETag/304/412 backend 계약 후 frontend HTTP/cache transaction | +| ordered optimistic layer | `AVAILABLE_NOT_COMPOSED` | product membership/revision 승인 후 frontend mutation definition | +| GraphQL adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend 선택 뒤 frontend adapter/codegen | +| Browser RPC V3 공통 계약/coordinator | `AVAILABLE_NOT_COMPOSED` | selected descriptor/generated client와 protocol transport 확정 뒤 frontend provider adapter | +| Protobuf schema/codegen | `DESIGNED_NOT_IMPLEMENTED` | authenticated backend contract source 선택 뒤 pinned frontend generation | +| Connect-Web adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend/server 선택 뒤 frontend adapter/codegen | +| gRPC-Web adapter | `DESIGNED_NOT_IMPLEMENTED` | product/backend/proxy 선택 뒤 frontend adapter/codegen | +| Protobuf REST Gateway | `NOT_SELECTED` | gateway kind와 public HTTP contract 승인 뒤 REST adapter binding | +| persisted query/offline command | `NOT_SELECTED` | 별도 product ADR와 backend durability 계약 | + +따라서 “backend만 구현하면 frontend가 아무 변경 없이 모든 capability를 자동 +사용한다”는 의미는 아니다. 현재 선택된 REST reference vertical의 공통 frontend +기반은 완료됐지만, backend contract가 확정되면 Cursor/conditional/optimistic의 +마지막 composition과 schema/mapper 변경이 frontend에 남는다. GraphQL, +Connect/gRPC-Web과 Protobuf REST Gateway는 제품이 선택되지 않았다. 공통 Browser +RPC operation/profile registry, application port와 lifecycle coordinator는 +구현했지만, wire별 generated client/decoder/provider binding은 아직 구현하지 +않았다. 따라서 backend contract가 정해져도 frontend provider adapter와 +composition 작업은 명시적으로 남는다. diff --git a/docs/architecture/browser-data-capability-completion-ledger.md b/docs/architecture/browser-data-capability-completion-ledger.md new file mode 100644 index 0000000..3ade8ea --- /dev/null +++ b/docs/architecture/browser-data-capability-completion-ledger.md @@ -0,0 +1,385 @@ +# Browser data capability completion ledger + +## 1. 목적 + +이 문서는 다음 browser data capability의 **현재 구현 상태, 목표 상태, 남은 +공통 구현, 제품 조합 책임, backend/provider 계약과 promotion 조건**을 한곳에서 +관리하는 기준 문서다. + +- File, Blob, 파일 선택기, preview와 다운로드 +- Local/Session Storage, IndexedDB, OPFS와 Cache Storage +- TanStack Query memory cache와 탭 간 무효화 +- Presigned URL, multipart/resumable upload와 streaming download +- Range resumable download와 background upload/download +- Image CDN descriptor, 검증, delivery와 presentation + +각 상세 문서는 메커니즘과 불변조건을 설명한다. 이 ledger는 상세 문서를 +대체하지 않으며, 서로 다른 문서의 "구현됨", "사용 가능", "설계됨" 표현이 +production readiness로 잘못 합쳐지는 것을 막는 상태 단일 기준이다. + +이 문서가 정한 상태만으로 실제 제품의 `PRODUCTION_READY`를 주장할 수 없다. +제품 owner, backend/provider conformance와 세 browser promotion evidence가 모두 +별도 gate를 통과해야 한다. + +## 2. 상태 체계 + +### 2.1 Primary current status + +각 capability는 다음 다섯 상태 중 정확히 하나를 갖는다. + +| 상태 | 의미 | 허용되는 주장 | +| --- | --- | --- | +| `COMPOSED` | production bootstrap 또는 설치된 feature 호출 경로에 concrete runtime이 연결돼 있다. | 저장소의 현재 제품 경로에서 실행된다. | +| `AVAILABLE_NOT_COMPOSED` | port, policy와 reference runtime이 있으나 기본 production graph에서는 제거돼 있다. | opt-in 조합 후보가 존재한다. | +| `DESIGNED_NOT_IMPLEMENTED` | 불변조건과 계약은 승인됐지만 해당 runtime 또는 필수 orchestration이 없다. | 설계/계약 backlog가 닫혔고 구현 backlog는 열려 있다. | +| `NOT_SELECTED` | 가치, 비용, 보안과 운영 owner가 승인되지 않아 의도적으로 선택하지 않았다. | 누락이 아니라 미선택이다. | +| `PLATFORM_LIMITED` | 브라우저 공통 보장이 불가능하거나 지원 범위가 제한된다. | capability probe와 fallback 안에서만 제공할 수 있다. | + +`AVAILABLE_NOT_COMPOSED`를 `COMPOSED`로 표시하거나, +`DESIGNED_NOT_IMPLEMENTED`를 테스트 fixture만으로 구현 완료 처리하지 않는다. +`NOT_SELECTED` capability를 인접 runtime의 "미완성"으로 계산하지 않는다. + +### 2.2 독립적인 canonical readiness 축 + +Primary status와 다음 네 canonical 축을 섞지 않는다. VD-15와 이 ledger를 +참조하는 운영 runbook도 축 이름과 literal을 정확히 이 표에 맞춘다. + +| canonical 축 | 값 | 의미 | +| --- | --- | --- | +| `Selection` | `NOT_SELECTED`, `SELECTED`, `REMOVING` | 특정 제품이 capability를 채택했는지 여부 | +| `TrafficAdmission` | `DISABLED`, `SHADOW`, `CANARY`, `ENABLED` | 조합된 runtime의 신규 작업 admission | +| `RuntimeHealth` | `UNKNOWN`, `AVAILABLE`, `DEGRADED`, `UNAVAILABLE`, `INCOMPATIBLE` | 현재 runtime/provider 관측 상태 | +| `PromotionEvidence` | `MISSING`, `PARTIAL`, `COMPLETE`, `EXPIRED` | 필요한 contract/provider/browser/operations 증거의 합성 결과 | + +`PromotionEvidence`의 입력은 다음 component gate다. 이 값들은 새로운 readiness +축이 아니라 합성 근거이며 evidence record와 함께 보존한다. + +| component gate | 값 | 의미 | +| --- | --- | --- | +| contract | `MISSING`, `DRAFT`, `ACCEPTED` | frontend와 provider가 맞출 wire/behavior 계약 상태 | +| provider | `NOT_REQUIRED`, `PENDING`, `CONFORMANT` | 실제 BFF, object storage, CDN 또는 hosting 증거 | +| browser | `MISSING`, `PARTIAL`, `PROMOTABLE` | 승인 browser/device matrix의 native 증거 | +| operations | `MISSING`, `DOCUMENTED`, `DRILLED` | 관측, kill switch, recovery와 rollback 실행 증거 | + +projection은 다음처럼 고정한다. + +- 필수 component artifact가 없으면 `MISSING`이다. +- 유효한 일부 증거만 있거나 component가 terminal gate 전이면 `PARTIAL`이다. +- contract가 `ACCEPTED`, provider가 `NOT_REQUIRED` 또는 `CONFORMANT`, browser가 + `PROMOTABLE`, operations가 `DRILLED`이고 모든 required artifact가 유효할 때만 + `COMPLETE`다. +- 한 번 유효했던 required artifact가 정책의 freshness/expiry를 넘으면 다른 + component 값과 무관하게 `EXPIRED`다. + +예를 들어 Image CDN reference runtime은 +`AVAILABLE_NOT_COMPOSED / Selection=NOT_SELECTED / +TrafficAdmission=DISABLED / RuntimeHealth=UNKNOWN / +PromotionEvidence=PARTIAL`이고 그 근거가 +`contract=ACCEPTED / provider=PENDING / browser=PARTIAL / +operations=DOCUMENTED`일 수 있다. 이 행을 `COMPOSED`나 +`PRODUCTION_READY`로 줄여 쓰지 않는다. + +### 2.3 가능한 구현 경로 + +다음은 제품이 아직 선택하지 않았고 reference source도 없는 capability가 거칠 수 +있는 **일반적인 경로 예시**다. 다섯 primary status를 선형 maturity로 정의하지 +않으며 모든 capability가 이 경로를 밟는 것도 아니다. 이미 reference runtime이 +있는 capability는 `AVAILABLE_NOT_COMPOSED`에서 시작할 수 있고, cross-browser +의미가 불가능한 capability는 구현량과 무관하게 `PLATFORM_LIMITED`다. + +```text +NOT_SELECTED + -> decision + owner + data classification + -> DESIGNED_NOT_IMPLEMENTED + -> implementation + deterministic evidence + removal evidence + -> AVAILABLE_NOT_COMPOSED + -> product policy + provider contract + bootstrap composition + -> COMPOSED + -> provider/browser/operations promotion gates + -> product-local production approval +``` + +`PLATFORM_LIMITED`는 위 흐름과 별도 제약이다. 지원 가능한 browser에서는 +지원 browser용 runtime 행을 별도 상태로 기록할 수 있지만, cross-browser 보장 +행의 primary status는 계속 `PLATFORM_LIMITED`다. 제품 계약은 지원 불가능한 +browser의 fallback을 동시에 선언해야 한다. + +rollback은 상태를 거꾸로 가장하지 않는다. 신규 진입을 kill switch로 닫고, +active operation을 drain 또는 abort하고, durable state를 정책대로 정리한 뒤 +composition과 production module을 제거한다. + +## 3. 구현 책임 분류 + +남은 항목은 다음 네 분류 중 하나 이상을 갖는다. + +| 분류 | owner | 설명 | +| --- | --- | --- | +| `COMMON_REQUIRED` | frontend platform | 제품 API 주소 없이도 구현할 수 있고 선택 capability의 안전성에 필수인 port, state machine, policy와 lifecycle | +| `PRODUCT_COMPOSITION` | product/feature owner | dataset, account partition, UX, retention, quota priority, query/preset profile과 use-case facade | +| `PROVIDER_CONTRACT` | backend/storage/CDN/infra owner | authorization, signing, server ledger, storage constraint, CDN preset와 conformance | +| `OPTIONAL_CAPABILITY` | architecture + product approval | 필요성이 확인될 때 별도 threat model과 비용 승인을 거쳐 설치할 기능 | + +`COMMON_REQUIRED`는 범용 mega-service를 뜻하지 않는다. 메커니즘은 공통이지만 +정책 값은 immutable composition snapshot으로 주입한다. + +## 4. 현재 capability snapshot + +### 4.1 File, Blob, picker와 download + +| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 | +| --- | --- | --- | --- | --- | +| File/Blob intake | `AVAILABLE_NOT_COMPOSED` | opaque file ref, transient vault, metadata normalization, byte/type/signature policy, bounded range read, closed-result stream | native chunk가 hard maximum을 넘지 않도록 재분할하는 ceiling과 제품 profile | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` | +| native input picker | `AVAILABLE_NOT_COMPOSED` | keyboard/focus 가능한 input baseline, multiple, same-file reselection, dismissal outcome | 제품별 copy와 workflow | `PRODUCT_COMPOSITION` | +| enhanced open picker | `AVAILABLE_NOT_COMPOSED` | user activation과 conditional enhancement | browser matrix와 native input fallback 유지 | `PRODUCT_COMPOSITION` | +| directory selection | `NOT_SELECTED` | 없음 | bounded traversal, relative-path policy, symlink/entry ceiling | `OPTIONAL_CAPABILITY` | +| persistent file handle | `NOT_SELECTED` | native handle은 transient vault 밖으로 나가지 않음 | permission recovery, handle registry, retention/logout | `OPTIONAL_CAPABILITY` | +| drag/drop·paste·capture | `NOT_SELECTED` | 공통 file capture primitive 일부만 재사용 가능 | 별도 adapter와 접근 가능한 UX | `OPTIONAL_CAPABILITY` | +| object URL preview lease | `AVAILABLE_NOT_COMPOSED` | receipt binding, active-content denylist, byte cap, lease/revoke | 제품이 preview를 선택할 때 safety probe와 함께 조합 | `PRODUCT_COMPOSITION` | +| local preview decode-safety probe | `DESIGNED_NOT_IMPLEMENTED` | 현재 dimension/pixel/decoded-memory/animation preflight 없음 | object URL 발급 전 static header/decode budget 검증 | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` | +| browser-managed download | `AVAILABLE_NOT_COMPOSED` | synchronous resolver/vault seam과 `BROWSER_HANDOFF` outcome을 saved와 구분 | concrete BFF issuer/strict response와 제품 open/share/save UX | `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` | +| picker streaming save | `AVAILABLE_NOT_COMPOSED` | bounded stream, backpressure, integrity, close/abort truth | capability/size 기반 strategy selector | `COMMON_REQUIRED` | +| bounded Blob download | `AVAILABLE_NOT_COMPOSED` | small generated artifact hard cap | browser별 상한과 server-generation fallback | `PRODUCT_COMPOSITION` | +| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | 현재 one-shot download와 명시적으로 분리 | Range/If-Range/206, validator, checkpoint, seek/truncate, final integrity | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` | +| app-managed background download | `NOT_SELECTED` | browser-managed handoff만 존재 | 지원 browser의 progressive enhancement로만 평가 | `OPTIONAL_CAPABILITY` | +| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | 장시간 worker/picker/file permission 유지가 공통 보장되지 않음 | browser-managed handoff 또는 explicit unsupported fallback | 플랫폼 제약 | + +### 4.2 Query, Web Storage와 cross-context + +| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 | +| --- | --- | --- | --- | --- | +| TanStack Query memory cache | `COMPOSED` | concrete QueryClient, cancellation, stale UI, optimistic rollback, invalidate | session/account scope lifecycle, late-result fence, strict query policy/key codec | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` | +| Local Storage registry | `COMPOSED` | 등록 key, closed codec/envelope, TTL, global hard cap, memory fallback | key별 cap, partition/logout, migration, explicit outcome, bounded sweep | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` | +| Session Storage registry | `COMPOSED` | tab-scoped 등록 control record와 동일 codec | key별 cap과 explicit durability/outcome | `COMMON_REQUIRED` | +| cross-tab invalidation | `COMPOSED` | invalidate-only, versioned envelope, duplicate/stale/gap 처리, BroadcastChannel→localStorage→local-only | account epoch, exact storage source, production coordinator browser E2E | `COMMON_REQUIRED` | +| IndexedDB query persistence reference runtime | `DESIGNED_NOT_IMPLEMENTED` | persistence key는 disabled로 강제되고 persister source는 없음 | 승인 query만 dehydrate/hydrate하는 facade | 선택 시 `COMMON_REQUIRED` | +| product query persistence | `NOT_SELECTED` | persist 대상 query, owner와 retention 승인이 없음 | reference runtime 구현 뒤 별도 opt-in | `OPTIONAL_CAPABILITY` | +| durable cache namespace epoch | `DESIGNED_NOT_IMPLEMENTED` | release epoch만 존재 | persisted resurrection 방지 transaction ledger | persistence 선택 시 `COMMON_REQUIRED` | +| offline mutation command queue | `NOT_SELECTED` | foreground optimistic mutation만 존재 | idempotent durable command/sync protocol | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` | +| SSR hydration | `NOT_SELECTED` | 현재 client SPA | request-scoped QueryClient와 precedence | SSR 선택 시 `PRODUCT_COMPOSITION` | + +### 4.3 IndexedDB, OPFS와 Cache Storage + +| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 | +| --- | --- | --- | --- | --- | +| generic IndexedDB runtime | `AVAILABLE_NOT_COMPOSED` | transaction-complete, CAS, idempotency, logical budget, TTL, lifecycle authority, additive DDL, resumable codec migration, blocked/versionchange | feature dataset repository/schema/codec/query와 production composition | `PRODUCT_COMPOSITION` | +| OPFS byte runtime | `AVAILABLE_NOT_COMPOSED` | DedicatedWorker SyncAccessHandle, async fallback, Web Locks, hash tree, IDB journal saga, budget/GC/reconcile | 제품 namespace/dataset policy와 production composition | `PRODUCT_COMPOSITION` | +| OPFS real readiness preflight | `DESIGNED_NOT_IMPLEMENTED` | 없음; API property probe와 별도 native conformance test만 존재 | worker/lock/journal/small write-read-delete-cleanup을 한 readiness operation으로 검증 | `COMMON_REQUIRED` | +| OPFS physical/journal forward migration | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 v1 layout/journal과 reconciliation만 존재 | copy-on-write generation, checkpoint, publish authority와 N-1 rollback | `COMMON_REQUIRED` | +| public static Cache release runtime | `AVAILABLE_NOT_COMPOSED` | same-origin public GET, exact Vary/URL/type/size/digest, stage/activate/previous rollback | 제품 release/hosting policy와 production composition | `PRODUCT_COMPOSITION` | +| bounded Cache inspect/cleanup | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 ownership 검사는 지키지만 cache-count scan은 unbounded | policy/epoch-bound cursor, count/deadline과 partial-success resume | `COMMON_REQUIRED` | +| Cache control/prefix forward migration | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 v1 control/prefix parser와 release primitive만 존재 | 새 schema candidate, verify/activate, N-1 retain과 bounded cleanup | `COMMON_REQUIRED` | +| cross-store quota lifecycle | `DESIGNED_NOT_IMPLEMENTED` | store별 logical budget과 StorageManager signal은 존재 | write admission, pressure hysteresis, GC priority, one retry, scheduled maintenance | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` | +| Service Worker offline fetch | `NOT_SELECTED` | Cache Storage runtime은 window에서도 독립 사용 가능 | registration, install/waiting/activation, client drain, navigation strategy | `OPTIONAL_CAPABILITY` | +| private response cache | `NOT_SELECTED` | 현재 public cache가 명시적으로 거절 | 별도 partition/encryption 오해 방지/retention threat model | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` | +| sparse Range cache | `NOT_SELECTED` | `Range` request와 206 response를 거절 | validator-bound sparse segment merge | `OPTIONAL_CAPABILITY` + `PROVIDER_CONTRACT` | + +### 4.4 Presigned transfer, upload와 Image CDN + +| capability | 현재 상태 | 현재 보장 | 목표 또는 잔여 | 남은 책임 | +| --- | --- | --- | --- | --- | +| presigned capability | `AVAILABLE_NOT_COMPOSED` | fixed endpoint provider, strict binding, in-memory single-use vault, safe data-plane fetch | explicit download wire version, browser-handoff provider, actual signer conformance | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` | +| multipart/resumable upload | `AVAILABLE_NOT_COMPOSED` | part hash/retry, IDB checkpoint, server reconcile, cross-tab cancel, complete/abort | pause, checkpoint inventory/retention sweep, unsupported lock decision와 실제 server/session provider | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` | +| one-shot streaming download | `AVAILABLE_NOT_COMPOSED` | bounded whole-object stream, length/media/integrity, picker/Blob/handoff delivery | strategy selector와 Range capability 분리 | `COMMON_REQUIRED` | +| top-level transfer composition | `DESIGNED_NOT_IMPLEMENTED` | 개별 factory와 dispose는 존재 | strict config, readiness, atomic account teardown, drain, kill switch | `COMMON_REQUIRED` | +| Image CDN verification engine | `AVAILABLE_NOT_COMPOSED` | opaque asset/preset, signed descriptor verification, responsive candidate, static metadata/decode budget | 제품 preset/presentation policy 조합과 실제 provider/private delivery E2E | `PRODUCT_COMPOSITION` + `PROVIDER_CONTRACT` | +| image descriptor HTTP provider | `DESIGNED_NOT_IMPLEMENTED` | caller가 decoded descriptor를 직접 제공 | fixed BFF endpoint, bounded schema, refresh single-flight, expiry/logout fence | `COMMON_REQUIRED` + `PROVIDER_CONTRACT` | +| safe image presentation primitive | `DESIGNED_NOT_IMPLEMENTED` | descriptor 결과만 제공 | URL 재조립 없는 picture/source/img projection | `COMMON_REQUIRED` + `PRODUCT_COMPOSITION` | +| app-managed background upload | `NOT_SELECTED` | checkpoint 기반 foreground resume만 존재 | worker lifetime/staging/permission 모델 별도 설계 | `OPTIONAL_CAPABILITY` | +| cross-browser app-managed background upload guarantee | `PLATFORM_LIMITED` | page/worker lifetime과 local source permission이 공통 보장되지 않음 | foreground resume 또는 explicit unsupported fallback | 플랫폼 제약 | + +## 5. 중요한 경계 + +### 5.1 같은 이름처럼 보이지만 다른 capability + +- streaming download는 메모리 상한을 지키며 **이번 응답을 끝까지** 저장한다. + Range resumable download는 새로운 요청에서 validator와 destination offset을 + 검증해 **이전 partial state를 이어 간다**. +- multipart resume는 upload session protocol이다. Background upload는 page + lifecycle이 끝난 뒤에도 실행 주체가 살아 있다는 별도 보장이다. +- Cache Storage release runtime은 public response를 검증·활성화한다. Service + Worker는 navigation/fetch interception과 controlled-client lifecycle을 소유한다. +- generic IndexedDB runtime은 query persistence가 아니다. Query persistence는 + query classification, dehydration, scope epoch와 restore precedence를 추가로 + 요구한다. +- browser-managed handoff는 브라우저에 전달했다는 결과다. application이 + 저장 완료, 진행률 또는 background retry를 증명한 결과가 아니다. +- Image CDN engine은 descriptor를 검증한다. BFF descriptor 발급, 실제 CDN, + `` UX를 자동으로 제공하지 않는다. + +### 5.2 정책과 도메인 + +byte ceiling, retry 상한, schema version, state transition과 fail-closed fallback은 +공통 메커니즘이다. 다음 값은 도메인 코드가 아니라 **제품 composition policy**다. + +- 어떤 file purpose와 MIME/signature profile을 허용하는가 +- 어떤 query/dataset을 어느 account partition에 얼마나 오래 저장하는가 +- quota pressure에서 무엇을 먼저 제거하는가 +- 어떤 upload purpose와 CDN preset을 설치하는가 +- save/open/share와 conflict/recovery UX를 어떻게 보여 주는가 + +업무 entity와 권한 결과는 backend/domain이 소유한다. frontend policy는 이를 +추측하거나 대체하지 않는다. + +## 6. External authority·backend·provider 계약 + +server/session/CDN 경계를 넘는 capability는 해당되는 external 계약 없이 실제 +제품에 조합하지 않는다. local-only/reconstructable dataset에 backend를 +일괄 요구하지 않는다. + +| 경계 | 맞춰야 하는 owner/authority/provider 계약 | +| --- | --- | +| File upload | Web/BFF authorization, upload session API, file server 또는 object-storage data plane, quarantine/scanner/promotion | +| Presigned URL | BFF signer, cloud object storage, CORS/CSP, method/header/length/checksum/expiry 강제 | +| Multipart resume | server session ledger, idempotency, authoritative part status, completion receipt와 orphan janitor | +| Range download | immutable object generation 또는 strong validator, exact Range/If-Range semantics, full-object digest | +| account cache scope | frontend common runtime은 scope snapshot 검증, local generation/fence/teardown을 소유한다. product composition은 account/tenant 의미를 opaque partition policy에 mapping하고, auth/session owner는 sign-in/revoke/switch 사실을 제공한다. backend-issued epoch를 선택한 경우에만 그것이 wire 계약이다. | +| offline mutation | idempotency key, entity revision/ETag, cursor/delta, conflict/merge protocol | +| Image CDN | BFF descriptor endpoint, asset revision/preset registry, signing key rotation, CDN cache/CORS/CSP/no-store | +| eviction recovery | server-authoritative projection의 재구성 또는 all-marker-loss 구분이 필요한 제품에만 re-sync cursor/opaque installation epoch 계약 | + +브라우저 native `File`, `FileSystemHandle`, IndexedDB physical store, OPFS path, +Cache name과 local checkpoint revision은 backend wire 계약이 아니다. + +## 7. 설계 우선 work package + +### WP-01. Scope-safe client cache + +- session/account/release scope snapshot과 generation +- old QueryClient cancel, fence, clear, dispose와 remount +- late-result rejection +- strict query registry/key codec와 per-query ceiling +- Web Storage per-key policy, partition/logout/migration/outcome +- production coordinator까지 연결한 multi-page browser evidence + +Exit: account A의 cache, storage event와 늦은 async result가 account B runtime에 +관측되거나 기록될 수 없음을 deterministic fault와 native browser test로 증명한다. + +### WP-02. Range resumable download + +- 별도 `ResumableDownloadPort` +- validator-bound checkpoint와 non-authorizing persistence +- 200/206/412/416 state machine +- seek/truncate 또는 OPFS staging destination +- capability renewal와 final whole-object integrity +- browser strategy selector와 fail-closed fallback + +Exit: crash, capability expiry, object replacement, malformed Content-Range, +destination mismatch와 integrity failure에서 corrupt saved outcome이 0건이다. + +### WP-03. Origin storage lifecycle + +- StorageManager signal + actual quota failure 기반 pressure controller +- policy-owned eviction priority와 hysteresis +- bounded maintenance cursor/deadline +- IDB/OPFS/Cache forward migration과 N-1 rollback +- OPFS native preflight와 clear/eviction recovery +- local preview bounded header parser/decode probe, pixel/decoded-byte/animation ceiling과 + object URL 발급 전 fail-closed rejection + +Exit: quota/migration/crash fault에서 unbounded scan, destructive auto-reset 또는 +cross-scope read 없이 read-only/online-only/recovery outcome으로 닫힌다. hostile, +oversize 또는 animated preview fixture는 object URL 발급 전에 거절되고 decode +resource와 lease가 남지 않는다. + +### WP-04. Transfer operational composition + +- strict config schema와 protocol/version registry +- file/presigned/upload/download/image runtime atomic assembly +- readiness, kill switch, active-operation drain과 idempotent close +- logout/account switch fence +- checkpoint inventory/retention owner와 safe observations +- frontend provider contract harness + +Exit: partially configured runtime이 시작되지 않고, teardown 뒤 capability나 +late refresh가 새 scope에서 재사용되지 않는다. + +### WP-05. Image descriptor delivery + +- fixed BFF provider와 bounded closed decoder +- descriptor refresh single-flight와 expiry budget +- logout/account/runtime-generation fence +- static safe picture projection +- actual private/public CDN conformance and browser evidence + +Exit: caller-provided URL/transform이 DOM에 도달하지 않고, 만료·회전·logout·decode +failure가 placeholder 또는 closed failure로 복구된다. + +### WP-06. Optional capability decisions + +directory/persistent handle, Query persistence, offline mutation, Service Worker, +private/range cache와 app-managed background upload/download는 각각 독립 ADR, +threat model, owner, budget과 removal plan을 승인한 뒤에만 시작한다. + +## 8. 문서 우선 gate + +runtime 구현을 시작하기 전에 해당 work package 문서에 다음이 모두 있어야 한다. + +- current/target status와 out-of-scope +- application port와 adapter/provider owner +- immutable policy/config schema와 implementation ceiling +- state machine, concurrency와 cancellation owner +- durable record 분류, scope, TTL, purge와 migration +- backend/provider wire version과 compatibility +- browser capability matrix와 fallback +- observability allowlist와 금지 값 +- rollout, kill switch, rollback과 removal +- deterministic, contract, native browser, fault와 operational drill +- 완료 조건과 promotion evidence 위치 + +문서가 없는 편의 API, fallback, persistence field 또는 retry owner를 구현 중에 +추가하지 않는다. 새 요구는 ledger와 해당 ADR을 먼저 변경한다. + +## 9. 구현 및 promotion 순서 + +```text +ledger/ADR accepted + -> port + closed policy/schema + -> deterministic fake/contract harness + -> reference runtime + negative boundary gate + -> fault/migration/removal evidence + -> AVAILABLE_NOT_COMPOSED + -> product owner + 필요한 external provider/config 선택 + -> bootstrap composition behind kill switch + -> COMPOSED + TrafficAdmission=DISABLED + -> native Chromium/Firefox/WebKit + device drill + -> provider/browser/operations promotion gates + -> TrafficAdmission=CANARY/ENABLED + -> project-local production promotion +``` + +추천 구현 순서는 WP-01 → WP-02 → WP-03 → WP-04 → WP-05다. WP-02와 WP-03의 +seekable/staging 정책, WP-04와 WP-05의 lifecycle/config 계약은 설계 단계에서 +서로 검토하되 한 변경에서 모든 runtime을 동시에 조합하지 않는다. + +## 10. 공통 완료 기준 + +- [ ] 모든 capability가 이 문서의 primary status 하나를 가진다. +- [ ] `AVAILABLE_NOT_COMPOSED` source가 기본 production module inventory에 없다. +- [ ] `COMPOSED` capability는 bootstrap부터 실제 consumer까지 호출 증거가 있다. +- [ ] account/session 전환이 broadcast delivery나 브라우저 종료에 의존하지 않는다. +- [ ] byte, record, queue, candidate, retry, deadline과 scan에 hard ceiling이 있다. +- [ ] durable state는 schema/codec/scope/epoch/retention/migration을 함께 선언한다. +- [ ] raw URL, query, signed header, file name/path, account ID, storage value, + digest/ETag/receipt가 diagnostics나 telemetry에 노출되지 않는다. +- [ ] backend/provider contract는 fake, emulator와 실제 provider에 재사용 가능한 + conformance suite를 가진다. +- [ ] Chromium/Firefox/WebKit과 승인 device fallback 증거가 보존된다. +- [ ] kill switch, N-1 rollback, recovery와 optional runtime removal drill이 + 통과한다. +- [ ] 외부 증거가 없는 항목을 `PRODUCTION_READY`로 표시하지 않는다. + +## 11. 상세 문서 + +- [Browser file and origin storage](./browser-file-and-origin-storage.md) +- [Client cache and storage](./client-cache-and-storage.md) +- [Presigned transfer and Image CDN](./presigned-transfer-and-image-cdn.md) +- [Server file capability infrastructure](./server-file-capability-infrastructure.md) +- [VD-11 Browser file and origin-storage](./decisions/VD-11-browser-file-and-origin-storage.md) +- [VD-12 Presigned transfer and Image CDN](./decisions/VD-12-presigned-transfer-and-image-cdn.md) +- [VD-13 Client cache scope and persistence](./decisions/VD-13-client-cache-scope-and-persistence.md) +- [VD-14 Resumable download and background download](./decisions/VD-14-resumable-download-and-background-transfer.md) +- [VD-15 Origin storage lifecycle and migration](./decisions/VD-15-origin-storage-lifecycle-and-migration.md) +- [VD-16 Browser transfer composition and image delivery](./decisions/VD-16-browser-transfer-composition-and-image-delivery.md) +- [Browser file/storage recovery](../operations/browser-file-storage-recovery.md) +- [Client cache/storage recovery](../operations/client-cache-and-storage-recovery.md) +- [Browser transfer recovery](../operations/browser-transfer-recovery.md) diff --git a/docs/architecture/browser-file-and-origin-storage.md b/docs/architecture/browser-file-and-origin-storage.md new file mode 100644 index 0000000..3f28ad2 --- /dev/null +++ b/docs/architecture/browser-file-and-origin-storage.md @@ -0,0 +1,1177 @@ +# Browser file and origin-storage platform + +이 문서는 File, Blob, 파일 선택기, 다운로드, IndexedDB, OPFS, Cache Storage를 +프로덕션에 도입할 때의 경계, 프로토콜, 실패·복구 정책과 promotion evidence를 +정의한다. 최초 결정 기준일은 2026-07-27이며 lifecycle/migration 설계는 +2026-07-28에 갱신했다. + +현재 skeleton에는 요청된 browser API를 직접 호출하는 **정책 주입형 reference +runtime**이 `AVAILABLE_NOT_COMPOSED` 상태로 들어 있다. catalog exposure의 +recipe availability는 `RECIPE_AVAILABLE`이지만 이는 primary status나 product +selection 값이 아니다. 제품 capability는 아직 선택·조합하지 않았고 +bootstrap/installed feature에는 연결하지 않았다. +따라서 production build에는 포함되지 않고, 실제 제품 요구·dataset owner와 +해당 capability가 server 경계를 넘는 경우 backend protocol까지 정한 프로젝트가 +필요한 adapter만 조립한다. Service Worker 등록, +제품 데이터 schema와 owner-specific migration policy는 아직 조합하지 않았다. +이 상태는 catalog의 `productionComposition: false`만 믿지 않는다. build가 +생성하는 `artifacts/quality/vite-module-inventory.json`과 +`check:optional-recipes`가 아래 runtime source root가 production chunk에 없음을 +검증한다. IndexedDB reference runtime 자체에는 additive DDL planner, +codecVersion 기반 resumable maintenance, dataset budget, retention lifecycle과 +receipt retention/prune가 구현되어 있으며 bootstrap에서만 분리되어 있다. + +실행 가능한 계약·runtime·evidence는 다음 위치에 있다. + +- `src/application/ports/browser-file-storage/` +- `src/adapters/browser-files/` +- `src/adapters/storage/indexeddb/` +- `src/adapters/storage/opfs/` +- `src/adapters/cache-storage/` +- `src/adapters/browser-file-storage/storage-manager-adapter.ts` +- `tests/browser-capabilities/` +- `recipes/frontend-capabilities/browser-file-storage-contracts.ts` +- `recipes/frontend-capabilities/browser-file-storage-fakes.ts` +- `tests/recipes/browser-file-storage-contracts.test.ts` +- `config/recipes/frontend-capability-recipes.json` +- `artifacts/quality/vite-module-inventory.json` (build-generated evidence) +- `scripts/test-browser-file-storage-runtime-removal.ts` + +## 0. 후속 lifecycle/migration 결정과 현재 delta + +origin storage를 제품에 composition하기 전 남은 공통 lifecycle 설계는 +[VD-15: Origin storage lifecycle, migration, and optional file capabilities](./decisions/VD-15-origin-storage-lifecycle-and-migration.md)에 +고정한다. VD-15가 Accepted됐다는 사실은 해당 runtime이 구현 또는 조립됐다는 +뜻이 아니다. + +현재와 목표를 구분하면 다음과 같다. + +| 항목 | 현재 reference runtime | VD-15 목표 | +| --- | --- | --- | +| StorageManager | `estimate/persisted/persist`와 pressure 분류 구현 | IDB·OPFS·Cache write admission, hysteresis, bounded GC와 quota failure 뒤 exact 1회 retry coordinator | +| eviction | OPFS logical/physical 및 Cache pointer/candidate의 partial mismatch 감지 | dataset sentinel/backend epoch 연계; origin 전체 marker 소실은 first install과 완전 구분할 수 없음을 유지 | +| IndexedDB migration | additive DDL, codecVersion, bounded resumable maintenance 구현 | origin coordinator와 N-1 promotion/rollback evidence 연계 | +| OPFS migration | journal/physical manifest v1, reconciliation 구현 | copy-on-write physical/journal forward migration과 historical fixture | +| OPFS readiness | API property와 required primitive 확인, 실제 operation은 native browser test에서 검증 | composition readiness에서 worker/lock/journal/small write-read-delete-cleanup real preflight | +| Cache Storage | public static release stage/verify/activate, previous retain, owned cleanup 구현 | cursor/count/deadline이 있는 bounded inspect/cleanup과 control-schema migration | +| Service Worker | 미조립·미구현 | 제품이 PWA를 선택할 때만 별도 update/client-drain controller | +| local preview | byte/signature/media/active-content/object URL lease 구현 | object URL 발급 전 pixel/decoded-byte/animation/real-decode safety probe | +| directory/persistent handle/drop | 미구현, transient file picker만 제공 | 제품 workspace/consent 요구가 있을 때 별도 optional capability | +| Range resumable download | 현재 download/public cache에 없음 | `DESIGNED_NOT_IMPLEMENTED`; VD-14의 별도 runtime/provider 계약 | +| private/sparse Range cache | public cache에서 명시적으로 거부 | 제품이 별도 선택하기 전 `NOT_SELECTED` | +| app-managed background download | 현재 미선택 | `NOT_SELECTED`; 지원 browser용 별도 optional capability | +| cross-browser app-managed background download guarantee | 대상 browser 전체에서 지속 실행을 보장할 수 없음 | `PLATFORM_LIMITED`; browser-managed handoff와 명시적 progressive enhancement | + +따라서 이 문서 아래에서 “해야 한다”로 표현된 항목 중 위 표의 VD-15 목표는 +아직 executable implementation이 아니다. primary current-status literal은 +`COMPOSED`, `AVAILABLE_NOT_COMPOSED`, `DESIGNED_NOT_IMPLEMENTED`, `NOT_SELECTED`, +`PLATFORM_LIMITED` 다섯 값만 사용하며 선형 maturity로 해석하지 않는다. 현재 기존 +native runtime은 `AVAILABLE_NOT_COMPOSED`, 새 coordinator/migrator/probe와 Range +resumable download는 `DESIGNED_NOT_IMPLEMENTED`, +directory/persistent handle/Service Worker/private·sparse Range cache와 +app-managed background download capability는 `NOT_SELECTED`, 그 cross-browser +guarantee는 `PLATFORM_LIMITED`다. selection, traffic admission, runtime health와 +promotion evidence는 이 primary status와 별도 축이다. + +축의 canonical 이름과 literal은 completion ledger의 `Selection`, +`TrafficAdmission`, `RuntimeHealth`, `PromotionEvidence`를 그대로 사용한다. +contract/provider/browser/operations는 별도 축 이름을 만들지 않고 +`PromotionEvidence`를 계산하는 component gate다. projection과 evidence expiry도 +ledger 규칙을 따른다. + +## 1. 변경할 수 없는 경계 결정 + +### 1.1 기술별 소유권 + +| 기술 | 소유하는 것 | 소유하지 않는 것 | +| --- | --- | --- | +| `File`/`Blob` | 현재 사용자가 선택했거나 현재 작업이 생성한 transient bytes | 영구 ID, 권한, 신뢰 가능한 MIME, persistence | +| native file input/picker | user activation 안의 선택 의도와 transient handle | application/domain model, boot permission | +| download/save | 검증된 artifact를 브라우저 또는 사용자가 고른 파일에 전달 | server authorization, 파일 처리 완료를 가장한 anchor click | +| IndexedDB | 구조화 record, index, revision, idempotency, OPFS journal; 명시적으로 선택된 feature command queue의 storage mechanism이 될 수 있음 | command/sync protocol authority, 큰 immutable payload, HTTP response cache, credential | +| OPFS | 큰 opaque immutable bytes, chunk, integrity manifest, staging | query/index, domain metadata authority, 사용자에게 보이는 파일 경로 | +| Cache Storage | 정책이 승인한 public HTTP `Request`/`Response` representation | domain repository, TanStack Query cache, 인증/개인 API response | +| HTTP cache | 서버 header가 소유하는 일반 freshness/revalidation | application offline database | +| 기존 `StoragePort` | 작은 동기식 public preference | IndexedDB, OPFS, Cache Storage | + +이 표가 가장 중요한 설계 규칙이다. `StorageAdapter`, `FileManager`, +`BrowserPersistence` 같은 범용 mega-port 하나로 합치지 않는다. 각 API는 +transaction, 수명, quota, 일관성, 복구 방식이 다르다. + +### 1.2 source of truth + +- 서버는 명시적으로 승인된 offline-first 동기화 protocol을 제외하면 업무 + entity와 authorization의 source of truth다. +- IndexedDB record는 server state 전체 복제가 아니라 offline projection, + unsynced command, local-only product state 중 승인된 하나다. +- OPFS의 physical manifest는 integrity/recovery 자료다. 논리적 object의 commit + authority는 IndexedDB journal과 logical row다. +- Cache Storage hit는 authorization proof가 아니다. 저장 대상 자체를 anonymous + public representation으로 제한한다. +- `File.name`, `File.type`, extension, `lastModified`와 picker permission은 + authorization 또는 content safety의 근거가 아니다. + +### 1.3 clean architecture 배치 + +실제 capability를 선택한 프로젝트의 권장 배치는 다음과 같다. + +```text +src/features// + application/ + -offline-repository.ts + file-intake.ts + file-delivery.ts + adapters/ + indexeddb/ + codec.ts + schema.ts + migrations.ts + repository.ts + file/ + browser-file-vault.ts + download-delivery-adapter.ts + upload-example/ + quarantined-upload-http-adapter.ts + +src/adapters/storage/opfs/ + opfs-object-store.ts + opfs.worker.ts + worker-protocol.ts + journal-repository.ts + +src/platform/offline/ + cache-policy.ts + cache-storage-adapter.ts + service-worker-runtime.ts +``` + +공통 reference runtime은 domain-neutral한 low-level policy port로 제공한다. +제품 feature는 이를 직접 UI에 노출하지 않고, feature-specific repository/use +case가 schema, codec, query plan, authority와 retention policy를 주입해 더 좁은 +domain contract로 감싼다. application/domain에는 `IDBDatabase`, transaction +callback, store/index 이름, `File`, `Blob`, `FileSystemHandle`, `Request`, +`Response`, `Cache`가 보이면 안 된다. + +### 1.4 정책과 도메인의 분리 + +브라우저 API의 안전한 실행 규칙은 도메인과 무관한 공통 정책이다. 예를 들면 +transaction-complete 이후 성공 처리, picker user activation, stream chunk 상한, +object URL lease, blocked/versionchange 처리, OPFS staging/journal 순서, Cache +Storage의 exact match와 fail-closed 검증이 여기에 속한다. + +반대로 모든 값을 하나의 전역 정책으로 고정할 수는 없다. 어떤 record를 저장할지, +어떤 index/query가 필요한지, server와 local 중 누가 authority인지, data +classification·retention·quota 우선순위, 허용 MIME과 공개 cache 대상은 +dataset/use-case owner가 결정한다. 코드에서는 전자는 공통 adapter에, 후자는 +주입되는 `*Policy`, codec, query planner와 feature facade에 둔다. + +따라서 reference runtime이 제공하는 것은 transaction 완료 판정, bounded stream, +scope/policy binding, integrity와 recovery 같은 **공통 메커니즘**이다. 제품 +composition root는 dataset별 owner, opaque scope, `BrowserStoragePolicy`, +schema/codec/query planner, lifecycle authority와 backend protocol을 명시적으로 +주입한다. runtime은 이 구성 객체를 검증한 뒤 깊은 snapshot/freeze하고 persisted +binding과 대조한다. 서로 다른 dataset을 하나의 편의상 전역 repository나 +가변 policy 객체로 합치지 않는다. + +## 2. 공통 데이터 정책 + +browser persistence를 선택하기 전에 dataset별로 아래 catalog를 채운다. 한 +항목이라도 비어 있으면 runtime을 설치하지 않는다. + +| 필드 | 필수 결정 | +| --- | --- | +| owner | product owner와 operational owner | +| namespace | 고정된 application/feature/dataset ID | +| classification | public, internal, personal, confidential; credential은 금지 | +| authority | server, local-first, reconstructable 중 하나 | +| retention | session, TTL, until-synced, explicit delete | +| soft/hard budget | origin 전체와 dataset별 bytes/entries/object limit | +| persistence | best-effort 허용 여부와 `persist()` 요청 조건 | +| eviction priority | reconstructable → synced copy → user-authored | +| account scope | opaque partition key, logout/account deletion 처리 | +| failure UX | online-only, read-only, export-required 중 하나 | +| recovery | re-fetch, replay, quarantine, operator/user action | +| observability | allowlisted event와 bucket attribute | +| rollback | N-1 reader, destructive migration 보류 기간 | + +credential, session token, password, signing key, raw authorization header는 +IndexedDB, OPFS, Cache Storage 모두에서 금지한다. 동일 origin JavaScript가 +암·복호화 key를 사용할 수 있는 client-side encryption은 XSS에 대한 권한 +경계가 아니다. + +`navigator.storage.estimate()`는 IndexedDB, OPFS, Cache Storage 등이 공유하는 +origin storage의 거친 추정치다. free-space 예약이나 기술별 정확한 사용량으로 +사용하지 않는다. 기본 pressure policy의 출발점은 다음과 같고 실제 field +evidence로 조정한다. + +| 추정 사용률 | 상태 | 동작 | +| --- | --- | --- | +| `< 70%` | normal | 승인된 write 계속 | +| `70–85%` | pressure | expired/reconstructable data bounded GC | +| `>= 85%` | critical | nonessential cache write 중지, sync/export 유도 | +| `QuotaExceededError` | authoritative failure | transaction rollback, GC, idempotent operation만 최대 1회 재시도 | + +추정치는 hysteresis trigger일 뿐이다. user-authored committed data를 quota 대응 +명목으로 자동 삭제하거나 DB 전체를 자동 `deleteDatabase()`하지 않는다. + +현재 `storage-manager-adapter.ts`는 이 표의 한 시점 pressure 분류와 명시적 +`persist()` 요청만 제공한다. threshold 하향 hysteresis, 기술 간 write admission, +GC ordering과 `QuotaExceededError` 뒤 idempotent operation의 최대 1회 retry는 +아직 구현되지 않았고 VD-15 coordinator의 목표다. estimate 결과만으로 native +free space를 예약했다고 간주해서는 안 된다. + +## 3. File, Blob, 파일 선택기 + +### 3.1 native type의 수명 + +`File`은 `Blob`에 이름과 수정 시각 metadata가 추가된 browser object다. +application에는 native object 대신 opaque `LocalFileRef`와 정규화된 metadata를 +전달한다. adapter의 transient vault가 ref에서 native object를 찾고 bounded +inspection/range read를 수행한다. + +```text +click/user activation + -> native input 또는 enhanced picker + -> transient browser file vault + -> opaque LocalFileRef + untrusted metadata + -> bounded inspection + -> preview 또는 download 또는 별도 선택한 backend workflow + -> ref release +``` + +`File`, `FileList`, `Blob`, `FileSystemFileHandle`, 로컬 경로와 object URL은 +domain, application state, query cache, global store, diagnostics에 넣지 않는다. +같은 파일을 다시 선택할 수 있도록 input value reset 책임도 picker adapter가 +소유한다. + +### 3.2 picker baseline과 enhancement + +- 접근 가능한 ``가 모든 browser의 canonical baseline이다. +- `showOpenFilePicker()`와 `showSaveFilePicker()`는 runtime method별 feature + detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다. +- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에 + config fetch, analytics flush, permission query를 `await`하면 transient user + activation을 잃을 수 있다. +- boot, route mount 또는 background effect에서 permission을 요청하지 않는다. +- 선택/저장 대화상자 취소는 정상적인 `DISMISSED` outcome이다. error toast, + retry alert, error telemetry로 기록하지 않는다. +- native input의 `cancel` event는 즉시 authoritative dismissal로 처리한다. + `window.focus` 기반 fallback만 사용하는 engine은 focus 복귀 뒤 기본 1초 동안 + 늦은 `change`/`FileList` 반영을 기다린 다음 dismissal로 확정한다. +- permission denied, unsupported, user dismissal, active transfer abort를 서로 다른 + outcome으로 다룬다. +- enhanced picker가 실패한 같은 click에서 native picker를 자동으로 다시 열면 + activation이 소진될 수 있다. fallback 버튼을 보여 다음 명시적 사용자 동작에서 + baseline을 실행한다. +- directory handle이나 persistent file handle은 별도 제품 capability와 별도 + consent/retention decision 없이는 저장하지 않는다. + +### 3.3 intake policy와 검증 + +선택 전에 composition root가 `BrowserFilePolicyProfile`에 다음을 고정한다. +application port의 caller는 raw selection/inspection/preview/download 정책을 +전달하지 않는다. composition에서 발급·등록한 정확한 `FilePolicyReference` +객체와 더 작은 count/byte limit만 전달한다. registry는 배열, signature pattern, +MIME allowlist까지 깊게 snapshot하며 동일한 `policyKey`/`intention` 문자열로 새 +reference를 만들어도 권한으로 인정하지 않는다. 따라서 presentation이 다른 +feature의 전략, MIME, extension, integrity 또는 byte ceiling을 선택할 수 없다. + +- purpose/policy version +- single/multiple, 최대 file count +- 개별 byte limit과 누적 byte limit +- zero-byte 허용 여부 +- allowlisted extension과 reported MIME pair +- bounded inspection bytes와 content signature rule +- active content/archive 처리 방식 +- classification, retention, telemetry redaction + +inspection 성공 receipt는 `policyId` 문자열이 아니라 정확한 registered profile과 +file snapshot에 묶인다. 같은 inspection rule ID를 공유하는 다른 preview +profile로 receipt를 replay하면 `POLICY_REJECTED`다. picker/save dialog를 기다리는 +동안 caller request, handle array, byte-source method 또는 composition dependency가 +바뀌어도 이미 snapshot/bind한 ref, signal, limits, handle loader와 stream만 +사용한다. + +`accept`, extension, `File.type`과 magic byte는 모두 client-side 조기 UX다. +선택·preview·client-generated download만 도입한 feature에는 backend upload가 +필수가 아니다. 업로드를 별도 선택했다면 최종 backend는 authorization과 함께 +다음을 다시 검증한다. + +- 실제 byte length, MIME sniffing, allowlisted parser +- image dimensions/pixel budget, PDF active content +- archive entry count, nesting, expanded size/ratio, path traversal와 symlink +- malware scan/CDR/quarantine +- polyglot/malformed input과 decompression bomb +- resource owner와 upload session scope + +그 별도 upload workflow에서 서버 검증이 끝나기 전 resource 상태는 +`QUARANTINED`다. public URL이나 active preview를 발급하지 않는다. + +### 3.4 Blob과 memory budget + +다음 API는 전체 payload를 메모리에 올릴 수 있으므로 제품별 hard cap 아래에서만 +허용한다. + +- `Blob.arrayBuffer()`, `Blob.text()`, `Blob.bytes()` +- `Response.arrayBuffer()`, `Response.blob()`, `Response.text()` +- base64/Data URL conversion +- 대형 `Uint8Array` 하나로 합치기 + +대용량 경로는 `Blob.stream()`/`Response.body`와 bounded chunk 또는 multipart를 +사용한다. application 계약에는 `readRange` 또는 닫힌 +`AsyncIterable>`만 노출하며 adapter가 browser +`ReadableStream`/`WritableStream`과 연결한다. recipe contract의 동일 경계는 +`CapabilityResult`를 쓴다. 각 chunk는 성공 또는 allowlisted failure고, +consumer는 첫 실패에서 소비를 중지한다. native `DOMException`, rejection 또는 +부분 byte 뒤의 raw throw가 application 경계를 통과해서는 안 된다. + +memory upper bound는 적어도 다음 식으로 review한다. + +```text +part size × concurrent parts × implementation copy factor ++ preview decode surface ++ framework/network buffering +<= approved foreground memory budget +``` + +progress의 total은 `null`일 수 있다. unknown total을 억지 percent로 표시하지 +않고 indeterminate UI를 사용한다. progress event는 animation frame 또는 +4–10Hz 정도로 throttle하고 항상 단조 증가해야 한다. + +Web Crypto `digest()`는 incremental stream hash가 아니다. 대용량 whole-file을 +한 번에 넣지 않는다. 업로드는 bounded part SHA-256, OPFS는 명시적인 chunk-tree +digest 또는 별도 검토된 streaming hash adapter를 사용한다. + +### 3.5 object URL lease + +`URL.createObjectURL()`은 호출마다 새 URL과 Blob 수명 lease를 만든다. +`TransientPreviewPort` 한 곳만 생성 권한을 갖고 `{url, release}`를 반환한다. + +- preview 대상 교체, load error, unmount에서 정확히 한 번 revoke한다. +- one-shot 작은 download는 click handoff 뒤 cross-engine scheduling을 위해 기본 + 30초 grace를 두고 revoke한다. feature/runtime dispose는 grace를 기다리지 않고 + 모든 active lease를 즉시 revoke한다. +- URL을 global state, IndexedDB, query cache, log, analytics에 보관하지 않는다. +- Service Worker에서 object URL을 사용하지 않는다. +- SVG, HTML, untrusted PDF 같은 active content를 same-origin object URL iframe에 + 직접 preview하지 않는다. 별도 격리 origin 또는 안전한 decode/re-encode 결과, + 아니면 attachment-only 정책을 사용한다. + +현재 reference preview는 encoded byte/signature/media allowlist와 위 +active-content denylist까지만 runtime에서 강제한다. static raster의 intrinsic +width/height, pixel 수, decoded surface, animation과 실제 decode 성공은 아직 +검증하지 않는다. preview를 제품에 조립하기 전 VD-15의 bounded header parser와 +decode probe를 구현해야 하며, 그 전에는 byte cap을 decode-memory 안전성으로 +해석하지 않는다. + +runtime factory는 `hardMaxPreviewBytes`, `hardMaxObjectUrlBytes`, +`hardMaxTransferBytes` absolute ceiling을 필수로 소유한다. registered profile과 +caller의 optional preview/buffer/transfer reduction은 이 ceiling을 낮출 수만 +있으며 초과 값은 native picker, stream 또는 object URL side effect 전에 +fail-closed한다. + +## 4. 별도 backend upload workflow 예시 + +이 절은 File/Blob/picker/download runtime의 dependency가 아니다. +`BrowserFileComposition`에는 picker, content, preview, download만 들어간다. +제품이 backend upload를 따로 선택하고 아래 서버 책임을 구현할 때에만 recipe의 +`ExampleQuarantinedUploadPort`를 feature-local 계약으로 복사한다. 단순 request가 +실제 size/latency 요구를 만족하면 bounded upload를 쓰고, 재개·대용량이 필요하면 +이 예시를 제품 protocol에 맞게 좁힌다. + +1. backend가 purpose, detected type, declared bytes와 authorization을 검증해 + short-lived session을 만든다. +2. backend가 part size, concurrency upper bound, expiry와 checksum algorithm을 + 반환한다. +3. client는 `FileContentPort.readRange()`로 bounded part만 읽는다. +4. 각 part는 part number, offset, checksum, idempotency key로 전송한다. +5. complete는 ordered part receipt와 checksum을 검증한다. +6. 성공 resource는 scan 완료 전 `QUARANTINED`다. +7. user abort는 현재 request의 `AbortSignal`뿐 아니라 server session의 explicit + `abort()`도 호출한다. unload cleanup은 신뢰하지 않으므로 backend TTL job이 + orphan session을 최종 정리한다. + +object key, storage bucket, signed URL과 long-lived credential을 client가 결정하거나 +log하지 않는다. session 생성, part, complete 단계마다 authorization을 다시 +확인한다. retry는 같은 idempotency key와 같은 part checksum에만 허용한다. +이 workflow의 설치, kill switch, 관측성, runbook과 제거는 browser-file runtime과 +독립적이어야 한다. + +## 5. 다운로드와 저장 + +### 5.1 전략 선택 + +| 상황 | 기본 전략 | 의미 | +| --- | --- | --- | +| server artifact, 모든 browser | authorized endpoint를 실제 anchor/navigation으로 전달 | `BROWSER_HANDOFF`; 완료를 관측했다고 주장하지 않음 | +| 큰 artifact + save picker 지원 | picker를 먼저 열고 response stream을 writable에 전달 | close/integrity 이후 `SAVED` | +| 작은 client-generated artifact | hard cap 이내 Blob + object URL | `SAVED`가 아니라 browser handoff일 수 있음 | +| 큰 client-generated + picker 미지원 | server-side artifact 생성 후 direct download | 전체 memory Blob fallback 금지 | + +`` click은 browser에게 전달했음을 의미할 뿐 disk write 완료가 아니다. +따라서 `DownloadOutcome`은 `BROWSER_HANDOFF`와 `SAVED`를 분리한다. 이미 close/ +commit된 뒤 늦게 AbortSignal이 발생하면 성공을 취소로 거짓 보고하지 않는다. + +stream-to-file에서 integrity를 close 전에 확인해야 하면 writable을 열린 상태로 +유지하고, digest가 맞을 때만 close한다. mismatch/abort/partial write면 abort하고 +`INTEGRITY_FAILED` 또는 partial-save failure를 반환한다. + +전략, media type, safe extension, integrity mode와 최대 bytes는 composition +registry가 소유한다. caller는 composition이 등록한 정확한 +`FilePolicyReference` 객체, 아래 source union의 최소 입력, filename과 limit +reduction만 전달한다. `policyKey`/`intention`이 같은 새 객체나 raw 전략을 +제출할 수 없다. source는 다음처럼 분리한다. + +- `BROWSER_MANAGED_RESOURCE`: resource ID와 서버 발급 capability receipt +- `AUTHORIZED_STREAM_RESOURCE`: 별도 authorization adapter가 여는 stream +- `GENERATED`: runtime이 snapshot한 closed-Result byte source + +`BROWSER_MANAGED_RESOURCE`는 user action 전에 준비된 synchronous resolver로만 +처리한다. caller의 branded `BrowserManagedDownloadCapabilityReceipt`와 resolver가 +돌려주는 receipt는 정확히 같아야 한다. capability는 그 receipt, resource ID, +media type, safe extension, server-enforced max bytes, optional SHA-256와 expiry를 +정확히 묶어야 한다. 하나라도 registered profile/source와 다르거나 expired이면 +navigation handoff를 하지 않는다. async signed-URL fetch를 click 뒤 기다리거나 +단순 `resourceId -> href` 함수를 authorization으로 간주하지 않는다. + +### 5.2 filename과 response contract + +filename은 advisory metadata다. client와 server 모두 다음을 처리한다. + +- `/`, `\`, path segment, NUL/control, bidi override 제거 +- leading/trailing space와 dot 제거 +- `.`, `..`, Windows device name 거절 +- Unicode NFC 정규화와 byte-length limit +- content type과 맞지 않는 executable/double extension 교체 +- 안전한 fallback filename + +서버가 download를 소유하면 `Content-Disposition: attachment`와 UTF-8 +`filename*`, ASCII `filename` fallback을 함께 제공한다. cross-origin download는 +`download` attribute만 믿지 않는다. JS가 header를 읽어야 하면 CORS +`Access-Control-Expose-Headers`도 계약에 넣는다. + +민감 artifact의 권장 header 시작점은 다음과 같다. 실제 caching 정책은 제품 +분류에 맞춰 backend가 결정한다. + +```http +Content-Type: application/pdf +Content-Disposition: attachment; filename="report.pdf"; + filename*=UTF-8''report.pdf +X-Content-Type-Options: nosniff +Cache-Control: private, no-store +Cross-Origin-Resource-Policy: same-origin +``` + +## 6. IndexedDB + +### 6.1 port와 wire model + +IndexedDB는 기존 동기식 `StoragePort` backend가 아니다. feature application은 +업무 intent형 async repository를 소유하고 adapter만 IndexedDB를 안다. +`StructuredOfflineStore`는 복사 후 feature query와 mutation으로 더 좁힐 +recipe다. + +reference runtime의 실제 wire layout은 하나의 conceptual envelope가 아니라 +분리된 store record다. store 이름은 composition-owned safe identifier이며 다음 +shape와 역할을 유지한다. + +```ts +type StoredRecord = Readonly<{ + key: string; + codecVersion: number; + revision: number; + payload: unknown; +}>; + +type StoredRetentionRecord = Readonly<{ + recordKey: string; + writtenAtEpochMs: number; + synchronization: "PENDING" | "CONFIRMED" | "NONE"; + measuredBytes: number; + eligibleAtEpochMs?: number; +}>; + +type StoredIdempotencyReceipt = Readonly<{ + idempotencyKey: string; + operation: "PUT" | "DELETE"; + recordKey: string; + expectedRevision: number | null; + fingerprint: string; + synchronization: "PENDING" | "CONFIRMED" | "NONE"; + revision: number; + expiresAtEpochMs: number; +}>; + +type StoredDatasetBudget = Readonly<{ + bindingKey: "dataset-budget"; + budgetVersion: 1; + usedBytes: number; + receiptCount: number; +}>; +``` + +- `recordStore`: `StoredRecord` +- `retentionStore`: `StoredRetentionRecord`와 eligibility index +- `idempotencyStore`: `StoredIdempotencyReceipt`와 expiry index +- `governanceStore`: immutable `dataset-binding`과 `StoredDatasetBudget` +- `lifecycleMetadataStores`: migration checkpoint처럼 partition/session purge 때 + 함께 비워야 하는 adapter-owned metadata. immutable governance binding은 이 + 목록에 넣지 않는다. + +위 `StoredRecord`는 reference runtime의 실제 adapter-private wire shape다. +recipe의 feature-facing `StoredRecord` view model과 혼동하지 않는다. +`writtenAtEpochMs`, synchronization, `measuredBytes`, `eligibleAtEpochMs`는 record +envelope에 중복 저장하지 않고 `retentionStore` sidecar에 둔다. + +- structured clone 성공을 업무 schema 검증으로 간주하지 않는다. +- 모든 read는 `unknown`에서 runtime codec으로 envelope와 payload version을 + 검증한 뒤 domain value로 mapping한다. +- class instance와 암묵적 Date 직렬화를 피하고 primitive wire object와 명시적 + epoch/ISO 값을 사용한다. +- database DDL schema version과 record codec version을 분리한다. +- `IndexedDbDatasetScope`의 `authorityToken`, `namespaceToken`, + `partitionToken`은 registry-issued opaque token이다. readable namespace, + account/tenant/user ID 또는 domain key를 token에 인코딩하지 않는다. +- physical DB명은 오직 세 opaque token으로 + `ca-idb-v1:..` 형태로 파생한다. caller가 주는 + database name은 생성 입력이 아니라 exact assertion으로만 허용한다. +- account scope와 storage policy의 `accountScope`가 일치해야 하며, 모든 + query/index는 해당 dataset scope 안에서만 실행한다. +- offset pagination 대신 deterministic compound key와 tie-breaker가 있는 + keyset cursor를 사용한다. +- 실제 query가 없는 index와 민감 원문 index는 만들지 않는다. + +runtime은 composition 때 scope와 전체 `BrowserStoragePolicy`를 검증하고 깊은 +snapshot/freeze한다. governance store의 immutable `dataset-binding`은 scope와 +정책 전체를 보존한다. 최초 versionchange transaction에서 생성·검증하고, +일반 open과 maintenance 진입 때도 다시 검증한다. 기존 DB에 binding이 없거나 한 +필드라도 달라지면 임의 보정하거나 다른 dataset으로 열지 않고 +`POLICY_REJECTED`로 fail-closed한다. + +### 6.2 open, versionchange, blocked 상태 + +connection manager는 다음 상태를 명시적으로 갖는다. + +```text +opening -> ready -> draining -> closed + \-> upgrade-blocked -> ready | online-only + \-> future-schema -> read-only | online-only +``` + +- connection을 만든 즉시 `versionchange`와 forced `close` handler를 등록한다. +- `versionchange`에서는 신규 operation 접수를 중지하고 connection을 즉시 + 닫는다. upgrade callback에서 UI state를 저장하려 하지 않는다. +- upgrader는 versioned prepare hint를 broadcast한 뒤 open한다. +- `blocked`면 다른 탭을 닫거나 reload하도록 접근 가능한 UI를 보여 준다. + 원격 탭 강제 종료, 무한 reload, timeout 후 자동 DB 삭제는 금지한다. +- `BroadcastChannel`은 upgrade/invalidation hint다. message loss와 partial + ordering을 전제로 항상 DB를 다시 읽는다. +- old bundle이 future schema를 만나면 write를 시도하거나 version을 내리지 않고 + read-only/online-only fallback으로 기동한다. +- native open request는 일반 AbortSignal로 실제 취소할 수 없다. caller가 취소한 + 뒤 늦게 열린 connection은 즉시 close하고 stale 결과를 폐기한다. + +### 6.3 schema와 data migration + +`onupgradeneeded`에서는 deterministic structural change와 작은 metadata request를 +동기적으로 queue한다. 다음은 금지한다. + +- network/fetch +- timer +- crypto/hash +- user callback +- unrelated Promise/`await` +- analytics와 외부 side effect +- 대량 row 변환 + +큰 migration은 expand/migrate/contract로 나눈다. + +1. **expand:** additive store/index와 migration state만 추가한다. +2. **fence:** rollout/session authority가 N-1 old-codec writer를 drain하고 + migration/contract window 동안 다시 쓰지 못함을 보장한다. 단순 + `BroadcastChannel` 알림이나 탭 열거는 이 fence가 아니다. +3. **migrate:** post-open maintenance invocation 또는 worker가 bounded batch를 + 변환한다. +4. 각 batch는 row update와 + `{migrationId, targetCodecVersion, lastKey, state}` checkpoint를 같은 + transaction에 commit하고 aggregate progress count만 caller에 반환한다. +5. row별 codec version으로 retry가 idempotent해야 한다. +6. 모든 active release와 rollback window가 지난 뒤 별도 schema version에서 + old store/index 제거를 검토한다. 공통 reference planner는 제품 owner 없는 + destructive DDL을 허용하지 않으므로, 제거는 별도 승인된 product migration + 구현과 rollback evidence가 있어야 한다. + +old writer drain은 keyset checkpoint의 correctness 전제다. drain되지 않은 N-1이 +현재 `lastKey`보다 앞선 key로 old-codec row를 새로 쓰면 이후 batch가 그 row를 다시 +보지 못한다. 따라서 reference maintenance는 주입된 +`isOldWriterDrainConfirmed`가 참이 아니면 `BLOCKED`로 중단하며, drain 보장은 +migration 완료 후 contract/rollback window까지 유지한다. 새 runtime의 정상 write는 +항상 target codec version을 기록한다. + +각 invocation은 caller가 주는 `maxRows`와 `maxDurationMs` 중 더 작은 budget을 +지키며 reference runtime의 절대 상한은 500 rows, 30,000ms다. async storage +operation 사이마다 monotonic deadline을 검사하고 소진 시 +`state: "MORE", budgetExhausted: true`와 keyset checkpoint를 반환한다. migration +transaction은 revision fence, migrated row, retention sidecar, dataset budget와 +checkpoint를 함께 commit한다. + +지원하는 모든 historical schema snapshot을 fixture로 보존하고 fresh upgrade, +중간 crash, resume, 재실행과 N-1 rollback을 실제 browser에서 검증한다. + +### 6.4 transaction 정확성 + +- raw transaction callback을 port 밖으로 내보내지 않는다. +- 한 repository method가 하나의 업무 transaction scope다. +- transaction 안에서 network, timer, UI 또는 unrelated `await`를 수행하지 않는다. +- request `success`가 아니라 transaction `complete` 이후에만 mutation 성공을 + 반환한다. +- signal이 시작 전에 abort됐으면 transaction을 만들지 않는다. +- 진행 중 취소는 안전한 경우 `tx.abort()`로 업무 단위 전체를 rollback한다. +- complete와 abort race는 settle latch 하나로 결정하며 committed write를 + `ABORTED`로 거짓 보고하지 않는다. +- read-modify-write는 하나의 readwrite transaction과 `RevisionGuard` CAS를 쓴다. +- command attempt는 unique idempotency key로 중복을 막는다. +- codec의 `measureStoredBytes()`와 보수적인 envelope reservation으로 logical + stored bytes를 계산한다. record retention sidecar의 `measuredBytes`와 + `dataset-budget.usedBytes`는 CAS, remove, lifecycle deletion, codec migration과 + 같은 transaction에서 원자적으로 갱신한다. 이는 native physical byte 측정치가 + 아니며, configured `hardBudgetBytes` 초과는 `LIMIT_EXCEEDED`로 rollback한다. +- idempotency receipt에는 canonical wire value의 lowercase SHA-256 hex만 + fingerprint로 저장한다. label/ID/PII와 reversible encoding은 거부한다. +- receipt replay window는 주입된 clock/retention으로 고정하고 expiry index 기반 + bounded maintenance로만 정리한다. 아직 replay 가능한 receipt는 삭제하지 않으며 + prune 성공도 transaction `complete` 이후에만 반환한다. reference runtime의 + receipt retention 절대 상한은 31일이다. configured + `maxIdempotencyReceipts`와 `dataset-budget.receiptCount`도 write/prune/purge + transaction에서 원자적으로 강제하며 구현 절대 상한은 1,000,000개다. 제품은 + 이 두 상한 안에서 더 짧고 작은 retry window를 선택한다. +- write 때 retention sidecar의 `eligibleAtEpochMs`를 계산한다. TTL record는 sweep + 전에도 read에서 `EXPIRED_RESOURCE`, query에서 skip 처리한다. + `UNTIL_SYNCED`는 caller가 `CONFIRMED`를 명시한 record만 lifecycle deletion + 대상이 된다. +- `SESSION_END`, `LOGOUT`, `ACCOUNT_DELETION`, `RETENTION_SWEEP` batch는 최대 + 500 rows/30,000ms의 bounded work다. 매 deleting invocation은 composition이 + 필수 주입한 `authorizeLifecycle({action, scope, storagePolicy})`의 opaque + short-lived proof를 받아 형식 검증한 뒤 즉시 폐기한다. proof를 저장하거나 + telemetry에 내보내거나 caller가 직접 제공하게 하지 않는다. +- full partition purge는 record와 retention sidecar/idempotency receipt뿐 아니라 + 등록된 모든 `lifecycleMetadataStores`를 같은 bounded transaction에서 비우고 + dataset budget counter도 원자적으로 맞춘다. governance binding 자체는 dataset + identity 검증을 위해 유지한다. +- 외부 cursor/range key는 native 변환 전에 depth, total element와 total byte + 절대 상한을 검사하고 초과·순환·비정상 key를 `INVALID_INPUT`으로 거부한다. +- network sync는 `TX1 snapshot/lease -> network outside TX -> TX2 revision와 + fencing token 재검증` 순서다. +- Web Locks는 maintenance/leader 최적화일 뿐 correctness를 대체하지 않는다. + +### 6.5 corruption과 recovery + +runtime codec이 실패한 record와 `NotReadableError`/forced close 같은 database +failure를 분리한다. + +- reconstructable record: safe issue bucket만 기록하고 purge/re-fetch 가능 +- synced copy: revision 확인 후 server rehydrate +- unsynced/user-authored: 자동 삭제 금지, read-only 격리, export/sync/runbook +- migration/open failure: bounded reopen 한 번 후 online-only +- full DB deletion: classification owner와 recovery/export 확인 후 명시적 runbook + action만 허용 + +raw key, value, account ID, exception message/stack을 telemetry로 보내지 않는다. + +## 7. OPFS + +### 7.1 역할과 layout + +OPFS는 사용자가 탐색하는 파일 시스템이 아니라 origin-private storage다. +사용자 filename, email, tenant/user ID를 path에 넣지 않는다. + +default physical layout은 구현과 동일하게 다음과 같다. + +```text +/ca-frontend-opfs-v1/ + authorities//// + objects////manifest.json + chunks/sha256//.bin + staging//receipt.json +``` + +구조화 metadata, query, revision, refcount와 operation journal은 IndexedDB가 +소유한다. OPFS에는 immutable chunk와 bounded runtime-schema-validated manifest만 +둔다. readable `scope.namespace`는 경로에 쓰지 않는다. + +IndexedDB journal DB는 authority token으로 격리되고, 첫 mutation에서 두 binding을 +같은 transaction에 기록한다. physical +`authorityToken|namespaceToken|partitionToken -> readable namespace + full policy +fingerprint`와 logical +`authorityToken|namespace|partitionToken -> physical scope key`를 양방향으로 +검증한다. 둘 중 하나만 없거나 기존 값이 다르면 `CORRUPT_DATA` 또는 +`POLICY_REJECTED`로 중단한다. runtime과 byte store도 composition 시 scope/policy를 +snapshot하고 namespace 일치를 확인하므로 caller의 사후 mutation으로 다른 +dataset 경계를 열 수 없다. + +sync access handle은 DedicatedWorker 안에서만 소유하고 `write/truncate -> verify +-> flush -> close`를 `finally`까지 보장한다. main thread에서 사용하거나 +committed file을 in-place overwrite하지 않는다. portable exclusive mode를 +기본으로 하고 `readwrite-unsafe`는 금지한다. + +### 7.2 IndexedDB journal saga + +IndexedDB와 OPFS 사이에는 cross-API atomic transaction이 없다. 다음 journal이 +“committed logical row가 partial file을 가리키지 않음”을 보장한다. + +```text +PREPARING + IDB: operation ID, object ID, expected/target generation, size, expiry + | + | OPFS immutable chunks + physical manifest write/flush/verify + v +FILES_READY + | + | one IDB transaction: generation CAS + logical object row + journal + v +COMMITTED <- 사용자에게 보이는 유일한 commit point + | + | staging/old physical object bounded cleanup + v +CLEANED -> journal 제거 +``` + +- `PREPARING` crash: partial staging을 검증 후 resume하거나 purge한다. +- `FILES_READY` crash: expected generation과 digest가 맞으면 idempotent logical + commit, 아니면 quarantine한다. +- `COMMITTED`인데 file이 없거나 digest가 다름: `CORRUPT_DATA`/ + `STORAGE_EVICTED`; reconstructable만 rehydrate한다. +- delete는 logical row tombstone을 먼저 commit해 신규 open을 막고 physical bytes를 + 나중에 정리한다. +- operation ID로 replay를 deduplicate한다. + +`Web Locks`를 쓰면 고정된 origin mutation lock과 AbortSignal/timeout을 사용한다. +nested lock을 금지하고 lock ordering을 문서화한다. lock은 multi-file transaction +또는 crash recovery를 대신하지 않는다. + +### 7.3 민감한 policy maintenance authority + +`DurableObjectMaintenancePort.enforcePolicies()` caller는 reason, 더 작은 +time/object budget과 signal만 전달한다. application caller가 proof를 만들거나 +전달하는 필드는 없다. + +`LOGOUT`, `UNTIL_SYNCED`, `ACCOUNT_DELETION`처럼 제품 authority가 필요한 삭제를 +composition하려면 `requestMaintenanceAuthority` provider와 +`consumeMaintenanceAuthority` consumer를 **둘 다** 주입한다. 하나라도 없으면 +runtime은 삭제 전 `POLICY_REJECTED`와 `READ_ONLY` recovery로 fail-closed한다. + +- provider는 매 invocation의 exact reason, frozen scope, frozen + `BrowserStoragePolicy`에 묶인 새 opaque proof와 expiry를 발급한다. +- runtime은 proof 형식과 현재 시각 기준 양수 expiry를 검증하고 수명을 최대 + 5분으로 제한한다. +- consumer는 같은 reason/scope/policy/expiry를 다시 확인하고 proof를 원자적으로 + consume한다. replay, scope/action/policy mismatch와 expiry를 거절해야 한다. +- proof는 provider에서 consumer로만 전달한다. application port로 반환하거나 + 저장·telemetry·diagnostic에 기록하지 않는다. + +`TTL`, `SESSION_END`, `PRESSURE` maintenance에는 이 proof protocol을 적용하지 +않지만 frozen policy eligibility와 bounded budget은 그대로 강제한다. + +### 7.4 integrity, GC와 fallback + +큰 object에 Web Crypto whole-file digest를 사용하지 않는다. 고정된 bounded +chunk마다 SHA-256을 계산하고 아래처럼 algorithm ID가 포함된 tree root를 쓴다. + +```text +SHA-256-TREE-V1( + canonical(totalBytes, chunkSize, ordered chunk byteLength + digest) +) +``` + +server whole-file SHA-256과 같은 값이라고 주장하지 않는다. server protocol이 +whole-file hash를 요구하면 vetted incremental implementation을 별도 공급망/ +bundle review로 선택한다. + +GC는 exclusive mutation lock 아래 mark/sweep을 bounded batch로 수행한다. + +1. stale staging +2. expired reconstructable object +3. unreferenced chunk에 grace period +4. tombstoned physical object +5. synced copy + +committed user-authored object는 자동 quota GC 대상이 아니다. max entries/bytes/time, +cursor, 다음 실행 시점과 visibility/battery 정책을 둔다. + +제품 composition의 목표 capability detection은 API property 확인만으로 끝내지 +않는다. secure context, DedicatedWorker round-trip, small write/read/delete probe, +IndexedDB journal과 필요한 lock을 확인한다. 현재 +`inspectBrowserOpfsSupport()`는 property/primitive 확인까지만 수행하고 실제 +small-operation preflight는 구현하지 않았다. VD-15가 probe scope, deadline, +cleanup과 readiness mapping을 정의한다. 목표 fallback 순서는 다음과 같다. + +```text +OPFS + IndexedDB journal + -> product-approved, size-capped IndexedDB Blob + -> online-only/unavailable +``` + +silent in-memory “durable” fallback은 금지한다. + +## 8. Cache Storage + +### 8.1 의미 + +Cache Storage는 browser HTTP cache와 분리된 script-managed +`Request`/`Response` map이다. + +- 자동 TTL, freshness, revalidation, eviction ordering을 제공하지 않는다. +- Service Worker script update나 unregister가 cache를 자동 삭제하지 않는다. +- Window/Worker에서도 사용할 수 있어 Service Worker 설치가 필수는 아니다. +- offline fetch interception을 한다면 한 Service Worker runtime만 owner다. +- TanStack Query entity cache나 application repository로 사용하지 않는다. + +raw `Cache`, `Request`, `Response`는 platform-local adapter 안에 둔다. application +port에서 generic `cache.get/set`을 제공하지 않는다. + +### 8.2 허용·금지 정책 + +기본 허용: + +- build manifest에 등록된 same-origin content-hashed JS/CSS/font/image +- HTTP 200 +- expected media type, byte length, integrity가 모두 일치 +- request credentials `omit` +- public data classification +- 선택형 runtime cache는 anonymous public GET, explicit URL class, TTL/entry/byte + limit과 owner가 있을 때만 + +기본 금지: + +- `/config.json`, `/release-manifest.json` +- bootstrap HTML; 별도 static offline document만 예외 review +- auth/API/user/tenant URL +- Authorization, cookie-dependent request, credentials include +- private, personal, confidential representation +- `Cache-Control: no-store`, `private`, 보수적으로 `no-cache` +- `Set-Cookie`, `Vary: Cookie`, `Vary: Authorization`, `Vary: *` +- opaque/opaqueredirect/error/redirect response +- 206 partial content, non-GET +- 검증할 수 없는 content type/size/integrity + +표준 Cache API가 위 정책을 자동 강제한다고 가정하지 않는다. adapter가 put 전에 +검증한다. opaque response는 body/header/integrity를 검사할 수 없으므로 기본 +거절한다. + +`PublicCacheAsset.expectedContentType`은 선택 metadata가 아니라 manifest binding의 +일부다. adapter는 media type을 정규화하고 canonical manifest의 +`URL + request headers + expectedByteLength + expectedContentType + SHA-256`에 +포함해 `manifestDigestHex`를 검증한다. fetch response의 정규화된 +`Content-Type`이 이 값과 정확히 다르면 body digest가 맞아도 +`INTEGRITY_FAILED`이고 candidate를 활성화하지 않는다. + +default match의 query와 `Vary` 의미를 보존한다. fragment는 cache key에서 +제외하지만 query는 유지한다. `ignoreSearch`, `ignoreMethod`, `ignoreVary`는 +production policy에서 금지한다. 전체 `CacheStorage.match()` 대신 정확한 owned +cache name을 열어 match한다. + +response를 network consumer와 cache 양쪽에 전달하면 body를 소비하기 전에 +`clone()`한다. clone fan-out과 backpressure 때문에 cache entry byte upper bound를 +강제한다. + +### 8.3 release candidate와 activation + +cache에는 rename API가 없으므로 name만 바꿔 atomic release라고 주장하지 않는다. + +```text +build-produced manifest + -> candidate cache 생성 + -> exact URLs fetch(credentials omit) + -> type/size/integrity 검증 후 put + -> verified marker + manifest digest + -> composition owner의 explicit activation + -> active release pointer + -> current + previous grace retain + -> incomplete candidate와 오래된 owned cache cleanup +``` + +한 entry라도 실패하면 candidate는 incomplete이며 active로 전환하지 않는다. +static Cache-only 조합은 waiting worker, `skipWaiting()`, `clients.claim()` 또는 +controlled-client drain을 activation 전제나 성공 증거로 사용하지 않는다. +composition owner가 exact release ID와 manifest digest로 +`activateRelease(releaseRegistryId, manifestDigestHex)`를 호출하고, current + +previous 또는 승인된 grace window를 유지한다. rollback은 verified previous +release만 대상으로 한다. + +Service Worker를 별도 선택한 조합만 VD-15 section 9.2의 waiting worker/page update +controller protocol을 추가한다. 이 branch에서 page controller가 dirty form, +active transfer와 compatibility를 확인하고 versioned activation을 승인하며, old +controlled client가 drain되기 전에는 그 client가 사용할 release를 cleanup +eligible로 만들지 않는다. + +cleanup caller는 retain cache name, release registry ID 또는 retain list를 +제출하지 않는다. adapter가 검증된 active pointer와 composition의 +`retainedPreviousReleaseCount`에서 보존 집합을 계산하고 owned-prefix 안의 +나머지만 삭제한다. active pointer와 release marker 같은 control JSON도 +`Response.json()`/`text()`로 무제한 materialize하지 않는다. content-length +preflight와 정확히 2 MiB(2,097,152 bytes) cap의 stream reader로 완전히 읽은 뒤 +strict UTF-8와 runtime schema를 검증하며, cap을 넘으면 reader를 cancel하고 +`CORRUPT_DATA`로 fail-closed한다. + +현재 `cleanupOwned()`와 `inspect()`는 위 ownership/integrity 경계를 지키지만 +cache count에 대한 cursor, per-invocation max count와 deadline은 제공하지 않는다. +따라서 cache 수에 비례한 unbounded maintenance를 production boot path에 +연결하지 않는다. VD-15의 목표 runtime은 active-pointer epoch와 policy에 묶인 +opaque cursor, 기본 100 caches/5초와 절대 500 caches/30초 상한을 적용한다. + +현재 static release runtime의 previous retain과 기존 +`activateRelease(releaseRegistryId, manifestDigestHex)`는 검증된 이전 release를 +다시 활성화할 수 있는 primitive다. 다만 Service Worker waiting/activation, +old controlled-client drain, `skipWaiting`/`clients.claim` 승인과 실제 rollback +controller는 구현·조립하지 않았다. Cache Storage runtime을 사용한다는 이유만으로 +Service Worker lifecycle이 설치됐다고 표현하지 않는다. + +다음 fetch 전략은 Service Worker 또는 명시적인 fetch owner를 별도 선택·조합한 +경우의 목표다. static Cache-only runtime은 navigation이나 global fetch를 +intercept하지 않고 verified release의 explicit lookup/activation primitive만 +제공한다. 특히 navigation 전략에는 Service Worker 선택이 필요하다. + +fetch 전략은 route class별로 고정한다. + +| request class | 전략 | +| --- | --- | +| immutable hashed asset | exact active cache-first | +| navigation | network-first + 명시적 static offline fallback | +| runtime config/release manifest/auth/API | network-only | +| approved public media | bounded TTL metadata가 있는 경우에만 stale-while-revalidate | + +Cache API 자체는 TTL을 계산하지 않으므로 runtime-public entry metadata와 prune +owner가 따로 있어야 한다. + +Service Worker를 선택한 조합에서 unregister는 cache removal이 아니다. worker +unregister와 parsed owned-prefix cleanup migration을 함께 배포하고, 기존 +controlled client가 drain될 때까지 old worker가 동작할 수 있음을 고려한다. +static Cache-only 조합은 worker 절차 없이 verified pointer/retention에서 계산한 +owned-prefix cleanup만 수행한다. 어느 branch도 `caches.keys()` 전체를 삭제하지 +않는다. + +## 9. 닫힌 failure vocabulary + +recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기 위한 +경계다. + +| code | 대표 원인 | 기본 복구 | +| --- | --- | --- | +| `ABORTED` | pre-commit user/caller cancellation | 조용히 종료 또는 명시적 retry | +| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback | +| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 | +| `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 | +| `BLOCKED` | IndexedDB older context | 다른 탭 close/retry UI | +| `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read | +| `MIGRATION_FAILED` | schema/data migration | read-only/online-only | +| `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry | +| `CORRUPT_DATA` | codec/manifest invalid | 분류별 quarantine/re-fetch/export | +| `NOT_READABLE` | I/O/forced close | bounded reopen 후 degrade | +| `INTEGRITY_FAILED` | byte length/digest/signature mismatch | commit 금지, quarantine | +| `STORAGE_EVICTED` | origin data loss | sentinel 확인, reconstructable rehydrate | +| `EXPIRED_RESOURCE` | upload/download session expiry | authorization 후 새 session | +| `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback | +| `UNSUPPORTED` | capability absence | baseline/online-only | + +user dismissal은 failure가 아니라 outcome이다. browser DOMException name은 +adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded +diagnostic에도 기본 저장하지 않는다. + +byte stream도 이 vocabulary 밖의 예외 통로를 만들지 않는다. File/OPFS/Cache와 +download recipe의 stream은 chunk마다 closed Result를 내보내며, failure chunk +하나를 보낸 뒤 종료한다. raw native error를 throw하거나 실패 뒤 추가 byte를 +내보내는 adapter는 contract 위반이다. + +## 10. 관측성 + +허용 event 예: + +- `file.selection.rejected` +- `file.download.handed_off` +- `file.download.completed` +- `file.transfer.failed` +- `storage.database.opened` +- `storage.upgrade.blocked` +- `storage.migration.failed` +- `storage.quota.pressure` +- `storage.fallback.entered` +- `opfs.recovery.completed` +- `cache.release.staged` +- `cache.operation.failed` +- `service_worker.update.state` + +별도 backend upload example을 설치한 feature만 자기 allowlist에 +`file.upload.completed`와 quarantine/session event를 추가한다. + +허용 attribute: + +- operation/phase/outcome/failure kind +- strategy/backend/data class/cache role +- size, duration, retry, entry count, pressure ratio의 **bucket** +- registry-owned database/store/migration/release ID +- total known 여부, persistence mode + +금지 attribute: + +- filename, local path, relative path +- object URL, download/signed URL, query string +- resource/object/record key, hash/digest +- raw MIME와 header/body +- account, tenant, email 또는 user ID +- exact usage/quota와 raw DOMException message/stack + +integrity mismatch, unauthorized/private response cache 시도, committed user data +corruption은 한 건도 canary promotion을 중지한다. 일반 latency/error rate는 +production-like baseline과 minimum sample size가 정해진 뒤 threshold를 승인한다. + +## 11. 테스트와 promotion evidence + +fake contract test만으로 native adapter를 production-ready로 선언하지 않는다. + +### 11.1 unit/property + +- filename path/control/bidi/Unicode/reserved-name sanitizer +- count/size/total/zero/unsafe integer 경계 +- MIME-extension-signature 모든 불일치 +- chunk boundary, closed Result failure termination, checksum, idempotency, + progress monotonicity +- codec version encode/decode, hostile/oversized/cyclic input +- migration planner 연속성, pressure hysteresis +- cache URL/query/Vary/header/classification policy +- DOMException mapping과 diagnostic redaction + +### 11.2 deterministic contract/fault injection + +- 별도 upload example을 선택했다면 part conflict, complete/abort race, session + expiry +- delayed/truncated stream, wrong/missing declared length +- IndexedDB request success 뒤 transaction commit failure +- atomic multi-record abort, CAS 경쟁, quota injection +- upgrade/data migration 각 checkpoint crash와 resume +- OPFS `PREPARING`, `FILES_READY`, `COMMITTED` 직후 worker termination +- partial chunk, corrupt/truncated manifest, missing committed file +- Cache candidate 한 entry 실패와 active release 불변 +- persistence denied, storage clear/eviction sentinel mismatch + +### 11.3 실제 browser + +Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한다. +`test:browser-capabilities`의 JUnit을 +`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성, +양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다. +현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2, +IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2, +presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다. +promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두 +실행되어야 한다. 이 host의 +WebKit은 필수 native libraries(예: +`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, +`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도 +Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를 +충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한 +system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야 +한다. + +- native input keyboard/focus/same-file reselection/multiple/dismissal +- Chromium conditional picker/save enhancement와 다른 engine fallback +- download event와 ASCII/UTF-8 filename +- 큰 synthetic stream의 queued-byte high-water mark +- IndexedDB fresh/upgrade/versionchange/blocked를 same-origin 두 page로 검증 +- concurrent CAS와 BroadcastChannel loss 후 authoritative re-read +- native BroadcastChannel delivery와 localStorage-event fallback cleanup +- 실제 OPFS create/open/read/remove, worker handle close +- release A active → B complete/incomplete candidate → offline → 승인 activation → + old/new client → rollback/cleanup +- fixed BFF capability 발급 → verified streaming download → writable close +- native IndexedDB/Web Locks와 cross-origin fetch를 통한 3-part resumable upload +- allowlisted Image CDN response의 static header metadata 검사와 실제 bitmap decode +- private mode/WebView/unsupported path의 explicit degraded state + +quota를 실제로 가득 채우는 flaky test는 merge gate의 유일한 근거로 쓰지 않는다. +deterministic injection을 merge gate로, 실제 pressure/clear를 staging/manual drill로 +유지한다. picker/OS UI와 storage eviction은 실제 device manual evidence도 +필요하다. + +### 11.4 negative static gate + +`check:browser-file-storage-boundaries`는 다음 source 위반을 거절한다. + +- application/presentation의 raw `indexedDB`, `caches`, + `navigator.storage.getDirectory()`, `FileSystem*` +- production `download(): Uint8Array`와 unbounded Blob/arrayBuffer/text +- preview facade 밖 `URL.createObjectURL` +- `File.type`/`accept`만으로 valid 판정 +- transaction 안의 fetch/timer/unrelated await +- sync access handle의 main-thread 또는 `readwrite-unsafe` 사용 +- cache의 auth/private/no-store/opaque/ignoreVary 허용 +- raw filename/path/key/URL/hash/value telemetry +- credential persistence +- recipe source의 production direct import + +### 11.5 production bundle과 removability evidence + +Vite plugin은 emitted chunk마다 실제 source module ID를 +`.vite/module-inventory.json`에 기록하고 release artifact로 +`artifacts/quality/vite-module-inventory.json`을 보존한다. +`check:optional-recipes`는 `productionComposition: false`인 동안 File/Blob, +IndexedDB, OPFS, Cache Storage reference runtime source prefix가 inventory에 한 +개라도 있으면 실패한다. minified marker 검색은 보조 방어이고 module inventory가 +권위 있는 bundle 증거다. + +`test:browser-file-storage-removal`은 runtime source root, 전용 browser test/gate, +catalog의 세 `referenceRuntime` metadata를 격리 copy에서 제거한 뒤 typecheck, +lint, architecture, 전체 test, build, optional catalog와 CI contract를 다시 +실행한다. 이 removal gate와 production module inventory가 모두 통과해야 +optional runtime이 skeleton core에 결합되지 않았다고 판단한다. + +## 12. rollout과 rollback + +실제 도입은 한 번에 모든 기술을 켜지 않는다. + +1. 데이터 catalog, owner와 fallback 승인; upload를 선택한 경우에만 backend + protocol 별도 승인 +2. feature-specific port와 codec/schema 구현 +3. fake contract + historical fixture + real-browser adapter test +4. runtime default off, capability probe와 online-only fallback +5. internal cohort에서 read-only/shadow write 검증 +6. 작은 cohort write, quota/recovery/rollback drill +7. N-1 reader와 migration compatibility 확인 후 확대 +8. product value가 확인된 user action에서만 persistence 설명/요청 + +kill switch는 좁게 분리한다. + +- enhanced picker/save off → native input/direct authorized download +- preview off +- 별도 upload workflow를 설치한 경우 resumable upload off → bounded simple upload +- offline read-write → read-only → online-only +- OPFS writes off → approved size-capped fallback 또는 online-only +- Cache Storage/SW off → network-only +- new worker activation off → verified current/previous 유지 + +schema version은 내리지 않는다. rollback bundle은 future schema를 감지해 +read-only/online-only로 살아나야 한다. destructive DDL, old store/cache 삭제는 +승인된 rollback window가 지난 뒤 별도 release에서 실행한다. + +운영 절차는 `docs/operations/browser-file-storage-recovery.md`, decision은 +`docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md`를 따른다. + +## 13. 공식 기준 + +- [W3C File API](https://w3c.github.io/FileAPI/) +- [WHATWG HTML file upload state](https://html.spec.whatwg.org/multipage/input.html#file-upload-state-(type=file)) +- [WHATWG HTML downloading resources](https://html.spec.whatwg.org/multipage/links.html#downloading-resources) +- [WICG File System Access](https://wicg.github.io/file-system-access/) +- [WHATWG File System](https://fs.spec.whatwg.org/) +- [WHATWG Streams](https://streams.spec.whatwg.org/) +- [WHATWG MIME Sniffing](https://mimesniff.spec.whatwg.org/) +- [RFC 6266 Content-Disposition](https://www.rfc-editor.org/rfc/rfc6266.html) +- [W3C Indexed Database API 3.0](https://w3c.github.io/IndexedDB/) +- [WHATWG Storage](https://storage.spec.whatwg.org/) +- [W3C Service Workers and Cache](https://w3c.github.io/ServiceWorker/) +- [RFC 9111 HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111.html) +- [W3C Web Locks](https://w3c.github.io/web-locks/) +- [W3C Web Cryptography](https://w3c.github.io/webcrypto/) + +File System Access와 일부 IndexedDB 3.0 기능은 evolving specification이다. +cross-browser baseline은 실제 Chromium/Firefox/WebKit evidence로 고정하고, 새 API +존재만으로 제품 fallback을 제거하지 않는다. diff --git a/docs/architecture/client-cache-and-storage.md b/docs/architecture/client-cache-and-storage.md new file mode 100644 index 0000000..731cb08 --- /dev/null +++ b/docs/architecture/client-cache-and-storage.md @@ -0,0 +1,1680 @@ +# Client cache and browser storage platform + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +- 상태: capability별 current/target 상태 분리 +- 기준일: 2026-07-28 +- 범위: TanStack Query memory cache, Local/Session Storage, IndexedDB query + persistence 경계, 탭 간 invalidation +- 관련 결정: + [VD-13 client cache scope와 persistence](./decisions/VD-13-client-cache-scope-and-persistence.md) +- query persistence reference 상태: `DESIGNED_NOT_IMPLEMENTED` +- query persistence product 상태: `NOT_SELECTED` + +이 문서는 server state cache와 browser persistence를 프로덕션에서 운영할 때의 +소유권, 정책, protocol, 실패·복구 및 promotion evidence를 정의한다. 네 기술은 +모두 값을 잠시 보관할 수 있지만 같은 저장소가 아니다. + +| 기술 | 기본 책임 | 기본 수명 | 현재 skeleton 상태 | +| --- | --- | --- | --- | +| TanStack Query memory cache | 현재 browser context의 server state 표시와 refetch 조정 | page runtime | `COMPOSED` | +| `localStorage` | 작은 공개 preference와 제한된 opaque control record | browser session을 넘어 지속 | `COMPOSED`, 등록 key만 사용 | +| `sessionStorage` | 현재 top-level tab의 작은 reload/session guard | tab page session | `COMPOSED`, 등록 key만 사용 | +| IndexedDB | 큰 구조화 record와 transaction | 명시적 retention까지 | native reference runtime `AVAILABLE_NOT_COMPOSED` | +| cross-tab invalidation | 다른 context에 stale 가능성을 알리는 best-effort hint | event | `COMPOSED`, unavailable이면 local-only | +| IndexedDB query persistence | 승인된 query projection의 optional warm restore | 승인된 max age까지 | `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` | +| SSR dehydration/hydration | request-scoped server cache를 browser로 전달 | 한 SSR navigation | `NOT_SELECTED` | + +이 문서와 VD-13은 다음 표준 상태만 사용한다. + +| 상태 | 의미 | +| --- | --- | +| `COMPOSED` | 구현과 test가 있고 production bootstrap이 실제 생성·소비한다. | +| `AVAILABLE_NOT_COMPOSED` | reusable runtime과 test가 있지만 production bootstrap에서 생성하지 않는다. | +| `DESIGNED_NOT_IMPLEMENTED` | 경계와 invariant만 승인됐고 실행 코드는 없다. | +| `NOT_SELECTED` | 제품 요구·owner·policy가 승인되지 않았다. | +| `PLATFORM_LIMITED` | browser/platform이 요구 의미를 cross-browser로 보장하지 못한다. | + +현재 `src/adapters/query-cache/tanstack-query-cache.ts`는 한 runtime당 +`QueryClient`를 만들고, query retry를 끄며, 30초 `staleTime`과 5분 `gcTime`을 +기본값으로 사용한다. `src/bootstrap/runtime-adapters.ts`와 +`src/bootstrap/runtime-application.tsx`가 이 client를 실제 provider tree에 +조립한다. + +현재 `src/contracts/storage-keys.ts`에는 `COLOR_SCHEME`, +`CHUNK_RELOAD_GUARD`, 금지된 `AUTH_TOKEN`과 비활성 +`QUERY_PERSISTENCE`가 등록되어 있다. +`src/adapters/storage/browser-storage-codec.ts`와 +`browser-storage-adapter.ts`는 closed JSON codec, 기본 16,384-byte 상한, schema +envelope, TTL과 동일 envelope를 쓰는 memory fallback을 제공한다. +`StorageDefinition.valueCodec`은 `color-scheme-v1`, `opaque-string-v1`, `none`의 +closed registry이며 adapter가 write와 read 양쪽에서 key별 codec을 적용한다. +다만 byte 상한은 아직 adapter instance의 공통 값이고 key별 byte policy, +`HIT`/`MISS`, durability-aware success, opaque partition binding, logout action과 +lifecycle sweep는 없다. memory fallback은 실제 current-runtime value를 +보관하면서도 write 결과를 degraded success가 아닌 `{ok: false}`로 반환한다. +registry의 `migration`은 `discard`만 허용하며 실제 adjacent migration read path는 +없다. expiry가 registry max TTL 안에서 발급됐는지 확인하는 clock-skew/future +검증과 quota cleanup 뒤 exact one-time retry도 아직 없다. + +현재 `src/contracts/cache-invalidation.ts`, +`src/adapters/cross-context-invalidation/`, +`src/adapters/query-cache/tanstack-cache-coordinator.ts`와 +`src/presentation/adapters/query/query-invalidation-provider.tsx`에는 +2,048-byte closed event, `BroadcastChannel` 우선·`localStorage` pulse fallback, +self/duplicate/sequence-gap 처리와 registry topic을 TanStack namespace로 +투영하는 runtime이 있다. `src/bootstrap/runtime-adapters.ts`가 release ID에서 +`cacheEpoch`를 만들고 transport/coordinator를 조립하며, +`runtime-application.tsx`가 `QueryInvalidationProvider`를 production provider +tree에 연결한다. `BroadcastChannel`과 storage pulse를 모두 쓸 수 없으면 boot를 +실패시키지 않고 `DEGRADED_LOCAL_ONLY`로 동작한다. + +native two-page browser spec은 BroadcastChannel primary와 localStorage fallback +delivery/cleanup을 검증한다. 다만 production QueryClient/coordinator까지 연결한 +account transition, bfcache/StrictMode lifecycle E2E는 없고, 현재 보존 artifact도 +Chromium/Firefox만 통과해 WebKit을 포함한 promotion evidence는 충족하지 않는다. +localStorage fallback physical key는 아직 storage registry 밖의 상수이고 native +`StorageEvent.storageArea`도 facade에서 검증하지 않는다. + +현재 epoch 구현은 `release.` 하나다. session/account epoch, opaque +account partition, scope 전환 시 새 QueryClient 생성과 durable namespace epoch는 +아직 구현되지 않았다. 따라서 현재 cross-tab event는 같은 release의 등록 query +topic을 빠르게 stale 처리하는 용도에 한정된다. + +`QUERY_REGISTRY`의 모든 현재 entry와 +`createTanStackCacheCoordinator()`는 `persistence: "disabled"`만 허용한다. +`QUERY_PERSISTENCE` Web Storage key도 `disabled`/`sensitive-forbidden`이다. +IndexedDB query persister, hydration과 durable invalidation ledger는 구현·조립하지 +않았다. + +현재 query key helper의 `canonicalize()`는 plain object 여부나 depth/byte/node +상한을 검증하지 않고 cycle, accessor, `BigInt`, function/symbol, non-finite +number와 native object를 fail-closed하지 않는다. installed registry도 +classification, scope, per-query stale/gc/result budget을 실행 정책으로 강제하지 +않고 global QueryClient default와 namespace/invalidation mapping만 사용한다. + +IndexedDB는 이미 +`src/application/ports/browser-file-storage/indexeddb-port.ts`와 +`src/adapters/storage/indexeddb/`에 transaction-complete, schema/codec +migration, revision CAS, idempotency, opaque dataset binding, retention, quota와 +blocked/versionchange를 다루는 reference runtime이 있다. 이 설계는 그 runtime을 +`StoragePort` backend enum으로 다시 구현하지 않는다. TanStack Query persistence를 +선택하는 경우에만 별도의 query-cache codec/facade를 그 runtime 위에 조합한다. + +## 0. current delta와 implementation authority + +다음 표는 **현재 실행 코드**와 VD-13의 **목표 계약**을 구분한다. + +| capability | primary current status | current implementation | target delta | delta implementation status | +| --- | --- | --- | --- | --- | +| memory QueryClient | `COMPOSED` | runtime별 생성, global stale/gc/retry default | session/account scope owner, generation fence, per-query policy | `DESIGNED_NOT_IMPLEMENTED` | +| application query bridge | `COMPOSED` | AbortSignal, stale error, optimistic rollback/conflict surface | old-generation query/mutation callback 폐기 | `DESIGNED_NOT_IMPLEMENTED` | +| Web Storage | `COMPOSED` | 두 key, strict v1 envelope/codec, lazy TTL, global 16 KiB cap | per-key cap, HIT/MISS, durability, v2 scope, migration, sweep/logout | `DESIGNED_NOT_IMPLEMENTED` | +| cross-tab invalidation | `COMPOSED` | release epoch, versioned hint, duplicate/gap, BC→localStorage | composite scope epoch, registered pulse, storageArea, durable ledger hook | `DESIGNED_NOT_IMPLEMENTED` | +| generic IndexedDB | `AVAILABLE_NOT_COMPOSED` | reusable repository/maintenance runtime | product dataset schema/codec/query/policy composition | product selection `NOT_SELECTED` | +| query persistence facade | `DESIGNED_NOT_IMPLEMENTED` | 없음, registry가 disabled 강제 | stable per-query record, bounded restore, namespace epoch/CAS | `DESIGNED_NOT_IMPLEMENTED` | +| product persistence | `NOT_SELECTED` | owner/query allowlist 없음 | measured need와 explicit composition | `NOT_SELECTED` | +| SSR | `NOT_SELECTED` | browser SPA composition | request-scoped client, safe dehydrate/merge precedence | `NOT_SELECTED` | +| exactly-once tab delivery | `PLATFORM_LIMITED` | browser acknowledgement protocol 없음 | cross-browser exactly-once target 없음 | `PLATFORM_LIMITED` | + +scope authority, query/data classification, product persistence allowlist, +retention, conflict UX와 offline sync는 composition/product owner가 결정한다. +closed codec, absolute ceiling, lifecycle state machine, transaction ordering, +wrong-scope rejection, listener cleanup과 safe failure mapping은 공통 runtime이 +구현해야 한다. backend는 server revision/ETag, mutation idempotency와 실제 +offline sync를 선택한 경우의 cursor/resume/conflict protocol을 소유한다. + +이 문서의 최상위 불변조건은 다음과 같다. + +> memory cache hit, browser persistence restore, 탭 간 invalidation 수신과 서버의 +> 최신 상태는 서로 다른 사실이다. 어느 하나도 authorization, 최신성 또는 다른 +> 단계의 성공을 암묵적으로 보장하지 않는다. + +## 1. 변경할 수 없는 설계 결정 + +### 1.1 하나의 범용 cache/storage port로 합치지 않는다 + +```text +server response + -> application result + -> React query bridge + -> TanStack Query memory cache + +small approved preference/control value + -> application preference port + -> registered Web Storage adapter + +optional reconstructable query projection + -> query persistence facade + -> query-specific codec + -> IndexedDB reference runtime + +mutation commit or scope reset + -> local cache action + -> optional durable cache epoch commit + -> cross-tab invalidation hint +``` + +`CachePort`, `StoragePort`, `BrowserStore` 하나에 memory, Web Storage와 IndexedDB를 +backend option으로 넣으면 다음 차이가 사라진다. + +- TanStack의 freshness와 observer lifecycle +- Web Storage의 동기식 main-thread 비용 +- IndexedDB의 asynchronous transaction commit +- `localStorage`와 `sessionStorage`의 context 범위 +- cache invalidation hint의 전달 손실 가능성 +- logout에서 invalidate가 아니라 remove/reset이 필요한 이유 + +### 1.2 source of truth + +- 일반적인 server state의 source of truth는 서버다. +- TanStack memory cache와 persisted query projection은 재구성 가능한 복사본이다. +- cache entry의 존재는 authorization proof가 아니다. 모든 network request는 + 현재 session credential과 서버 authorization을 다시 통과한다. +- `staleTime`은 freshness optimization이다. 업무상 유효 기간이나 권한 수명이 + 아니다. +- `gcTime`은 inactive query의 memory retention이다. active query 삭제, logout + purge 또는 persisted TTL이 아니다. +- cross-tab event는 다른 tab에 revalidation 필요성을 알리는 hint다. delivery, + ordering, exactly-once 또는 server commit을 증명하지 않는다. +- business offline record, unsynced command와 local-first draft는 query persistence가 + 아니다. 실제 요구가 생기면 feature-specific application repository와 use case를 + 별도로 만든다. + +### 1.3 domain, application과 infrastructure 경계 + +TanStack Query는 presentation infrastructure다. + +- `QueryClient`, `Query`, `DehydratedState`, persister type은 + application/domain에 노출하지 않는다. +- feature query hook은 application input을 호출하고 `AbortSignal`, query key, + stale/refresh 상태, 성공 후 invalidation을 연결하는 inbound adapter다. +- page는 raw `QueryClient`나 native storage를 직접 사용하지 않는다. +- 기존 `QueryCachePort`는 application use case가 cache 일관성을 업무 규칙으로 + 실제 요구할 때만 의미가 있다. 일반적인 TanStack 기능 전체를 + `read/write/invalidate`로 다시 추상화하지 않는다. + +Web Storage는 작은 preference용 outbound adapter가 될 수 있다. 어떤 preference를 +보관하는지가 application 계약에 포함되더라도 `Storage`, physical key, JSON, +schema envelope와 quota exception은 adapter 밖으로 나오지 않는다. + +IndexedDB query persistence와 cross-tab invalidation의 직접 소비자는 bootstrap의 +query runtime이다. 제품 domain/use case가 이를 호출하지 않는다. 제품이 +offline-first workflow를 선택한 경우에만 별도의 feature application port를 만들고, +query cache와 동일 repository를 공유하지 않는다. + +### 1.4 공통 mechanism과 제품 정책의 분리 + +공통 infrastructure가 소유한다. + +- key와 event의 syntax validation +- codec 실행과 closed failure mapping +- TTL 확인, expired-as-miss와 bounded cleanup +- byte/count hard cap +- IndexedDB transaction-complete 판정 +- scope/epoch binding 검증 +- duplicate, self-echo와 unknown protocol event drop +- listener, timer, channel, DB connection cleanup +- native exception redaction + +composition 또는 dataset/query owner가 결정한다. + +- query namespace와 query key shape +- data classification +- stale/gc/max-age +- persistence 허용 여부 +- account/tenant partition +- logout/account deletion 동작 +- quota 우선순위와 fallback +- 어떤 mutation이 어떤 namespace를 invalidate/remove하는지 +- codec와 호환 migration + +실제 business가 없는 skeleton은 공통 mechanism과 빈/safe registry를 제공할 수 +있지만, 임의의 제품 query를 persistence 대상으로 자동 등록하지 않는다. + +## 2. 상태와 commit point + +### 2.1 서로 다른 상태 축 + +한 entry의 상태를 `cached: boolean` 하나로 표현하지 않는다. + +| 축 | 예시 상태 | +| --- | --- | +| memory | `ABSENT`, `FRESH`, `STALE`, `FETCHING`, `INACTIVE` | +| persistence | `DISABLED`, `RESTORING`, `AVAILABLE`, `DEGRADED`, `UNAVAILABLE` | +| compatibility | `COMPATIBLE`, `EXPIRED`, `BUSTED`, `CORRUPT`, `FUTURE_VERSION` | +| scope | `ACTIVE`, `FENCED`, `REVOKED` | +| cross-tab delivery | `LOCAL_APPLIED`, `PUBLISH_ACCEPTED`, `DROPPED` | + +`PUBLISH_ACCEPTED`는 다른 tab이 처리했다는 뜻이 아니다. IndexedDB request +`success`는 transaction이 commit됐다는 뜻이 아니다. query data가 memory에 +남아 있다는 사실은 현재 account가 읽어도 된다는 뜻이 아니다. + +### 2.2 cache lifecycle + +```text +query execute + -> application/server success + -> memory cache write + -> FRESH + -> staleTime elapsed or explicit invalidation + -> STALE + -> active observer: bounded background refetch + -> no observer: INACTIVE + -> gcTime elapsed + -> ABSENT +``` + +Persistence를 선택한 query만 별도 lifecycle을 가진다. + +```text +approved memory entry + -> codec validation + -> policy/scope/epoch binding + -> IndexedDB transaction complete + -> PERSISTED + +boot + -> bounded open/restore + -> binding + schema + buster + TTL + codec validation + -> hydrate as reconstructable cache + -> normal freshness/refetch policy +``` + +### 2.3 mutation 이후의 순서 + +일반 mutation의 권장 순서는 다음과 같다. + +```text +server mutation committed + -> current scope/generation still active인지 확인 + -> local namespace invalidate 또는 remove + -> persistence를 사용하면 namespace epoch/tombstone transaction commit + -> cross-tab hint publish + -> active query background refetch +``` + +서버가 실패했거나 commit 여부가 불명확한 mutation에서 성공 invalidation event를 +발행하지 않는다. 반대로 서버 commit 뒤 local invalidation이나 hint 발행이 +실패해도 이미 성공한 server mutation을 실패로 되돌리지 않는다. 이 경우 mutation +결과는 성공이고 cache synchronization은 별도 `DEGRADED` observation이다. + +공유 IndexedDB record를 변경한 뒤 다른 tab에 알리는 경우에도 **transaction +complete가 먼저이고 hint가 나중**이다. hint를 먼저 보내면 receiver가 commit 전 +상태를 다시 읽고 최신으로 오인할 수 있다. + +## 3. 데이터 분류와 저장 허용표 + +### 3.1 분류 + +| 분류 | 예 | memory query | localStorage | sessionStorage | IndexedDB query persistence | invalidation payload | +| --- | --- | --- | --- | --- | --- | --- | +| `PUBLIC` | 공개 reference data, UI preference | 허용 | 작은 값 허용 | 작은 값 허용 | 정책 승인 시 허용 | opaque namespace만 | +| `INTERNAL` | 로그인 후 재구성 가능한 일반 projection | 허용, scope 필수 | 기본 금지 | 기본 금지 | partition/TTL 승인 시 허용 | opaque namespace만 | +| `PERSONAL` | 사용자별 response projection | 허용, scope/reset 필수 | 금지 | 기본 금지 | 명시적 보안·retention 승인 시만 | 데이터/ID 금지 | +| `CONFIDENTIAL` | 높은 민감도의 업무 데이터 | 필요한 순간의 memory만 | 금지 | 금지 | 기본 금지 | 금지 | +| `CREDENTIAL` | token, password, signing key, raw authorization | 금지 | 금지 | 금지 | 금지 | 금지 | + +short-lived signed URL/capability, cookie, access/refresh token, password, +authorization header, raw session object, cryptographic key, `File`, `Blob`, +object URL과 native handle을 persistence 또는 invalidation payload에 넣지 않는다. + +### 3.2 client-side encryption의 한계 + +같은 origin의 JavaScript가 ciphertext와 key를 모두 읽을 수 있으면 client-side +encryption은 XSS 또는 악성 same-origin script에 대한 authorization boundary가 +아니다. Web Crypto는 승인된 외부 key lifecycle이 있을 때 disk/backup 노출을 +줄이는 defense-in-depth가 될 수 있지만, 금지된 data class를 허용하는 근거가 +아니다. + +### 3.3 authority와 eviction + +query persistence 대상은 항상 `SERVER` authority이면서 `RECONSTRUCTABLE`이어야 +한다. user-authored unsynced value를 query cache quota cleanup으로 삭제하면 안 +된다. local-first 또는 `UNTIL_SYNCED` dataset은 기존 IndexedDB feature repository +정책을 사용하고 query persistence store와 분리한다. + +## 4. scope, registry와 policy snapshot + +### 4.1 opaque cache scope + +모든 account/tenant 종속 cache는 다음과 같은 registry-issued scope에 묶는다. + +```ts +type CacheScopeSnapshot = Readonly<{ + protocolVersion: 1; + authorityToken: string; + partitionToken: string; + sessionEpoch: string; + accountEpoch: string; + releaseEpoch: string; + generation: number; +}>; +``` + +- token은 충분한 entropy를 가진 opaque value다. +- email, account/tenant/user ID, domain object ID를 token 또는 physical key에 + 직접 넣지 않는다. +- 낮은 entropy의 account ID를 frontend에서 단순 hash한 값을 opaque token으로 + 간주하지 않는다. +- current session owner 또는 composition authority가 scope를 발급한다. +- memory query key, persisted binding과 invalidation event는 같은 frozen source + snapshot에서 query profile이 선택한 `ORIGIN_SHARED`, `ACCOUNT_BOUND` 또는 + `SESSION_BOUND` projection/fingerprint를 계산한다. 서로 다른 projection에 + raw session/account epoch를 무조건 복사하지 않는다. +- `generation`은 page-local lifecycle fence이며 backend wire/entity revision이 + 아니다. 모든 async terminal write 전에 captured generation을 다시 검증한다. + +`sessionEpoch`, `accountEpoch`, `releaseEpoch`는 서로 다른 폐기 이유를 표현한다. + +| epoch | 변경 조건 | 폐기 범위 | +| --- | --- | --- | +| session | sign-in/re-auth/session 교체 | 기존 runtime의 in-flight/result/cache | +| account | account/tenant 전환, logout/account deletion | account partition 전체 | +| release | query-key/codec/API compatibility가 깨지는 release | incompatible persisted cache | + +epoch는 backend entity version이 아니다. business conflict 해결이나 optimistic +locking에 사용하지 않는다. + +### 4.2 query scope/persistence registry + +query key factory와 함께 immutable scope/persistence profile을 등록한다. +freshness, GC, refetch, retry, result budget, pagination과 conditional policy는 +[VD-25](./decisions/VD-25-server-state-cache-lifecycle.md)의 +`ServerStateProfile`이 유일하게 소유한다. + +```ts +type QueryScopePersistencePolicy = Readonly<{ + policyId: string; + namespace: readonly [string, number]; + keySchemaVersion: number; + classification: "PUBLIC" | "INTERNAL" | "PERSONAL" | "CONFIDENTIAL"; + scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND"; + persistence: + | Readonly<{ kind: "MEMORY_ONLY" }> + | Readonly<{ + kind: "INDEXEDDB"; + profileId: string; + maxAgeMs: number; + maxEntryBytes: number; + }>; + crossTab: "NONE" | "INVALIDATE"; + invalidationTopics: readonly Readonly<{ + topicId: string; + topicVersion: number; + }>[]; +}>; +``` + +검증 규칙: + +- persistence `maxAgeMs <=` 승인된 retention +- persistence 사용 시 joined VD-25 profile의 `gcTimeMs`, restore와 hydration + retention이 모순되지 않게 구성한다. +- `PERSONAL` persistence에는 `ACCOUNT_BOUND` 이상, logout purge와 explicit approval이 + 필수다. +- `CONFIDENTIAL`과 credential은 persistence 등록을 거절한다. +- `NONE`은 topic 0개, `INVALIDATE`는 namespace당 unique topic 1..8개이고 + `(topicId, topicVersion)`당 namespace fan-out은 32개 이하로 제한한다. +- joined VD-25 profile의 topic set/version과 exact match한다. +- policy object, nested allowlist와 codec은 construction 시 deep snapshot/freeze한다. + +### 4.3 query key + +normative key layout: + +```text +[ + "query", + keySchemaVersion, + scopeFingerprint, + namespaceName, + namespaceVersion, + queryDefinitionVersion, + canonicalSemanticInput +] +``` + +규칙: + +- VD-25는 이 배열을 재정의하지 않고 definition/pagination projection을 채운다. +- query function이 의존하는 모든 non-secret 변수를 key에 포함한다. +- token, URL 전체, authorization, email, filename과 raw personal label을 넣지 + 않는다. +- object key ordering은 canonicalize하되 cycle, function, symbol, `BigInt`, + non-finite number, DOM/native object를 fail-closed로 거절한다. +- array 순서는 의미가 있으므로 유지한다. +- query namespace는 arbitrary caller string이 아니라 registry reference다. +- cross-tab event에는 전체 query key나 filter를 넣지 않는다. + +현재 `src/contracts/query-keys.ts`의 `canonicalize()`는 object key order는 +정규화하지만 cycle과 non-serializable input을 닫지 않는다. production registry는 +이를 검증하는 codec을 추가해야 한다. + +### 4.4 Web Storage registry + +각 logical key는 최소 다음을 갖는다. + +```ts +type WebStorageDefinition = Readonly<{ + logicalName: string; + backend: "localStorage" | "sessionStorage"; + scope: "ORIGIN_SHARED" | "OPAQUE_PARTITION" | "TAB"; + classification: "PUBLIC_PREFERENCE" | "OPAQUE_CONTROL"; + schemaVersion: number; + maxSerializedBytes: number; + retention: + | Readonly<{ kind: "SESSION" }> + | Readonly<{ kind: "TTL"; maxAgeMs: number }> + | Readonly<{ kind: "EXPLICIT_DELETE" }>; + valueCodec: string; + migration: "DISCARD" | Readonly<{ fromVersion: number; migrate(value: unknown): unknown }>; + quotaFallback: "MEMORY" | "NO_PERSIST" | "FEATURE_DISABLE"; + logoutAction: "KEEP" | "PURGE_PARTITION"; +}>; +``` + +현재 registry는 이 목표 계약 중 backend, classification, schema version, +`valueCodec`, TTL, migration 선언과 quota fallback을 구현한다. +`valueCodec`은 closed ID이며 `isStorageValueAllowed()`가 ID별 값을 검증한다. +key별 byte cap, opaque account partition과 logout action은 아직 추가되지 않았다. + +- 모든 read/write/remove는 logical registry reference를 받는다. +- physical key는 application ID, environment, opaque partition, logical namespace와 + schema version에서 결정적으로 파생한다. +- 다른 application key를 열거하거나 origin 전체 `clear()`를 호출하지 않는다. +- migration은 registry에 명시된 인접/지원 version만 수행한다. +- reconstructable control record의 corrupt/unknown/future version은 miss로 + 격하하고 exact key만 best-effort 제거한다. +- stored `undefined`와 miss를 구분할 수 있도록 read 결과는 `HIT`/`MISS`를 + 명시한다. + +## 5. TanStack Query memory cache + +### 5.1 기본 정책 + +memory cache는 기본이고 persistence는 기본이 아니다. + +- runtime마다 새 `QueryClient`를 만든다. +- module singleton을 만들지 않는다. +- SSR을 도입하면 HTTP request마다 새 client를 만들고 request 종료 시 폐기한다. +- HTTP transport가 bounded retry를 소유하므로 현재 기본 query/mutation retry + `false`를 유지한다. +- global stale/gc 값은 안전한 baseline일 뿐이다. 실제 query는 registry profile을 + 사용한다. +- `refetchOnWindowFocus`와 reconnect는 freshness safety net이지만 authorization + 또는 cross-tab delivery 보장은 아니다. +- active refetch failure가 cached data를 지우지 않도록 initial failure와 stale + background failure를 구분한다. +- mapper가 만든 cached value는 immutable하게 취급한다. + +### 5.2 freshness, retention과 invalidation + +- fresh query도 권한 폐기 또는 account reset 시 반드시 remove/clear한다. +- `invalidateQueries`는 matching query를 stale로 만들고 active query를 refetch할 + 수 있지만 기존 data를 즉시 제거하지 않는다. +- `removeQueries`는 민감 data, 권한 축소와 account boundary에서 사용한다. +- `resetQueries`는 query를 initial state로 되돌리고 active query를 refetch할 수 + 있으므로 scope disposal의 대체가 아니다. +- `queryClient.clear()`는 exact old runtime 전체를 폐기할 때만 사용한다. +- ordinary mutation은 namespace invalidate가 기본이다. +- delete/permission-revocation처럼 stale data 표시 자체가 위험하면 exact + `remove` 후 필요한 namespace를 invalidate한다. + +### 5.3 memory bound + +TanStack의 `gcTime`만으로 active query의 memory를 hard bound할 수 없다. + +- gateway/mapper에서 response item/count/byte limit을 검증한다. +- File, Blob, object URL, large binary와 unbounded collection을 query cache에 + 넣지 않는다. +- inactive query에는 finite `gcTime`을 둔다. +- query entry count/estimated payload pressure를 safe bucket으로 관측한다. +- hard memory cap이 필요한 제품은 approved query class별 bounded eviction + controller를 추가한다. active query를 임의 삭제하는 global timer를 기본 + skeleton에 두지 않는다. + +### 5.4 scope transition과 late result fence + +account/session 전환은 query key prefix만 바꾸는 것으로 끝나지 않는다. + +1. old runtime에 신규 query/mutation admission을 중지한다. +2. scope generation을 `FENCED`로 바꾼다. +3. old client의 query를 cancel하고 provider/controller를 detach한다. +4. old `QueryClient`를 clear/dispose한다. +5. old scope의 channel과 persistence connection을 닫는다. +6. exact old partition lifecycle purge를 시작한다. +7. 새 scope와 새 `QueryClient`로 provider를 remount한다. + +가능하면 scope마다 별도 `QueryClient` instance를 사용한다. old async result가 +늦게 resolve되어도 새 client에 쓸 수 없다. mutation transport가 이미 server로 +전달된 경우 frontend cancel은 server side effect를 되돌리지 않는다. old mutation +완료 callback은 generation mismatch로 UI/cache update를 폐기하고, server 결과는 +새 session의 정상 revalidation에서 다시 확인한다. + +## 6. Local Storage와 Session Storage + +### 6.1 용도 + +`localStorage`: + +- 작은 공개 UI preference +- release 또는 cache invalidation의 opaque pulse/epoch +- 명시적으로 승인된 reconstructable control record + +`sessionStorage`: + +- chunk reload guard +- 현재 tab의 opaque instance ID +- reload를 넘어 유지해야 하는 작은 tab-local control state + +금지: + +- server response collection persistence +- queue, counter, lock 또는 cross-tab CAS +- 인증 credential +- 큰 form draft와 file/blob +- arbitrary JSON dump +- logout을 sessionStorage tab 종료에 의존 + +Web Storage는 동기식이므로 serialization을 포함한 operation이 main thread를 +막는다. 등록 key마다 작은 byte hard cap을 강제하고 bulk scan이나 큰 value를 +저장하지 않는다. 큰 구조화 data는 IndexedDB를 사용한다. + +### 6.2 sessionStorage caveat + +`sessionStorage`는 origin과 top-level browsing context로 분리되고 reload에는 +남지만 다른 tab과 공유되지 않는다. opener가 있는 새 window는 생성 시 opener의 +sessionStorage snapshot을 복사할 수 있다. 이후 변경은 공유되지 않더라도 copied +secret/control state에 의존하면 안 된다. 새 window가 독립 session이어야 하면 +`noopener`/관련 browser policy와 새 tab instance ID를 사용한다. + +sessionStorage의 `storage` event는 같은 top-level context의 iframe에는 전달될 수 +있지만 다른 tab invalidation transport로 사용할 수 없다. + +### 6.3 envelope와 serialization + +현재 구현 envelope: + +```ts +type BrowserStorageEnvelope = Readonly<{ + schemaVersion: number; + expiresAt: number | null; + value: unknown; +}>; +``` + +opaque account partition을 선택할 때는 새 envelope version으로 scope fingerprint와 +필요한 write metadata를 추가해야 한다. 기존 세 필드 envelope에 의미를 바꾸어 +끼워 넣지 않는다. + +- codec encode 결과가 JSON-safe인지 먼저 검증한다. +- `JSON.stringify`가 변경하는 `undefined`, `NaN`, infinity, sparse array와 + 지원하지 않는 `BigInt`, cycle을 암묵적으로 허용하지 않는다. +- serialized UTF-8/보수적 UTF-16 byte estimate가 hard cap을 넘으면 native + storage를 호출하지 않는다. +- 현재 read는 JSON parse 뒤 exact envelope, schema, TTL, key별 value codec + 순으로 검증한다. scope를 추가한 version은 codec 전에 exact scope도 검증한다. +- 미래 시각이나 비정상적으로 먼 expiry는 corrupt/clock-skew policy에 따라 + fail-closed miss로 처리한다. +- TTL은 read visibility rule이다. expired bytes가 실제로 제거됐음을 보장하지 + 않으므로 bounded sweep을 별도로 둔다. + +memory fallback도 같은 envelope, TTL, codec와 scope를 사용해야 한다. raw +value만 Map에 넣으면 persistent backend에서 만료된 뒤 memory copy가 다시 보이는 +문제가 생긴다. 현재 adapter는 동일 serialized envelope를 memory overlay에도 +사용하므로 이 불변조건을 유지해야 한다. + +### 6.4 write outcome + +fallback 성공과 persistence 성공을 같은 `{ ok: true }`로 숨기거나, fallback이 +사용 가능함에도 단순 `{ ok: false }`만 반환하지 않는다. + +```ts +type StorageWriteOutcome = + | Readonly<{ ok: true; durability: "PERSISTED" }> + | Readonly<{ ok: true; durability: "MEMORY_ONLY"; degraded: true }> + | Readonly<{ ok: false; error: ClientStorageFailure }>; +``` + +key policy가 persistence를 반드시 요구하면 memory fallback은 실패다. 공개 theme +preference처럼 current runtime 사용이 가능하면 `MEMORY_ONLY` degraded success를 +허용할 수 있다. caller가 이 결정을 하지 않고 registry policy가 소유한다. + +### 6.5 exception과 cleanup + +- `Storage` property access, get, set, remove 모두 `SecurityError` 등으로 throw할 수 + 있다. +- quota zero/private mode, user policy와 embedded context를 지원 가능 여부와 + 분리해 관측한다. +- `QuotaExceededError`에서 reconstructable exact-key cleanup을 한 뒤 동일 + idempotent write를 최대 한 번만 재시도한다. +- corrupt/expired read의 exact-key 제거 실패가 validated miss를 raw exception으로 + 바꾸지 않게 한다. cleanup failure는 별도 degraded observation이다. +- origin-wide `clear()`를 recovery로 호출하지 않는다. + +## 7. IndexedDB query persistence + +### 7.1 기본 OFF + +TanStack cache persistence는 기본적으로 끈다. 현재 +`STORAGE_REGISTRY.QUERY_PERSISTENCE`가 `disabled`와 +`sensitive-forbidden`인 것은 이 기본값을 표현한다. persistence를 구현한다는 +이유로 이를 localStorage key로 바꾸지 않는다. + +다음 조건을 모두 충족한 query profile만 별도 IndexedDB persistence registry에 +등록한다. + +- server-authoritative, reconstructable data +- codec와 query-key schema가 고정됨 +- classification/partition/retention owner 승인 +- entry와 dataset byte budget 존재 +- offline 또는 warm-start 가치가 측정됨 +- logout/reset와 release busting이 정의됨 +- three-engine native contract evidence 존재 + +### 7.2 기존 IndexedDB runtime 재사용 경계 + +기존 runtime에서 재사용한다. + +- opaque dataset scope와 policy binding +- transaction-complete success +- schema migration과 record codec migration +- blocked/versionchange/forced close +- revision CAS와 idempotency +- byte budget, retention metadata와 bounded sweep +- lifecycle authority와 exact partition purge +- closed failure와 native exception redaction + +query persistence facade가 추가로 소유한다. + +- TanStack query namespace와 key codec +- persist allowlist +- dehydrated projection을 stable wire record로 변환 +- scope/cache epoch/release buster binding +- restore ordering과 hydration +- query-specific max age와 remove policy + +application/domain에는 TanStack type이나 IndexedDB store/index name을 노출하지 +않는다. + +### 7.3 저장 형식 + +TanStack의 내부 cache object 전체를 검증 없이 저장하지 않는다. 최소 record: + +```ts +type PersistedQueryRecord = Readonly<{ + recordVersion: 1; + queryHash: string; + encodedQueryKey: unknown; + policyId: string; + scopeFingerprint: string; + releaseEpoch: string; + namespaceEpoch: number; + dataUpdatedAtEpochMs: number; + persistedAtEpochMs: number; + expiresAtEpochMs: number; + payloadCodecVersion: number; + payload: unknown; + measuredBytes: number; + revision: number; +}>; +``` + +query key와 payload는 각각 codec을 통과한다. error, pending mutation, function, +Promise, `AbortSignal`, File/Blob/native handle, object URL, capability와 credential은 +저장하지 않는다. + +### 7.4 restore gate + +restore 순서: + +1. bounded deadline으로 database를 연다. +2. immutable dataset binding을 검증한다. +3. exact profile-selected `scopeFingerprint`와 `releaseEpoch`를 검증한다. + session/account epoch는 `ACCOUNT_BOUND`/`SESSION_BOUND` projection에 포함될 + 때만 검증한다. +4. record/envelope와 codec version을 검증한다. +5. TTL과 namespace invalidation epoch를 검증한다. +6. byte/count cap 안에서 decode한다. +7. approved query profile만 hydrate한다. +8. active query는 normal stale/refetch policy를 따른다. + +expired, busted, wrong-scope, corrupt reconstructable record는 UI error가 아니라 +cache miss다. exact record 또는 exact cache dataset만 bounded cleanup한다. 다른 +feature DB나 origin storage를 삭제하지 않는다. + +restore가 boot를 무한히 막지 않도록 deadline을 둔다. query persistence가 optional +이면 timeout, unavailable, blocked에서 빈 memory cache로 fail open하고 +`ONLINE_ONLY` degraded observation을 남긴다. offline-required 제품은 별도 +feature repository와 명시적 UX가 필요하므로 query persistence fallback으로 +가장하지 않는다. + +### 7.5 SSR/hydration + +- server process에서 browser IndexedDB/Web Storage/BroadcastChannel에 접근하지 + 않는다. +- SSR QueryClient는 request-scoped다. singleton은 사용자 간 data leak을 만든다. +- server dehydration도 approved successful query만 포함한다. +- browser persisted state가 최신 SSR payload를 덮지 않는다. +- merge가 필요하면 server response/version을 우선하고, 없는 approved query만 + restore하거나 명시적 server revision 비교를 사용한다. +- browser storage read 때문에 initial server/client markup이 달라지지 않게 + bootstrap 또는 hydration-safe provider 단계에서 restore한다. + +### 7.6 multi-tab writer + +여러 tab이 하나의 full QueryClient snapshot key를 last-write-wins로 덮어쓰면 +오래된 tab이 invalidated data를 다시 살릴 수 있다. 이 reference 설계는 +**shared per-query record + monotonic durable namespace ledger/CAS** 하나만 +선택한다. + +tab별 snapshot partition은 restore 의미가 달라지고, single-writer coordinator는 +leader loss/fencing/takeover라는 별도 protocol이 필요하므로 검토했지만 이 +reference runtime에서는 선택하지 않는다. 다른 writer model을 도입하려면 +VD-13 amendment와 동등한 resurrection/race/promotion evidence가 필요하다. + +단순 localStorage lock이나 best-effort BroadcastChannel election을 correctness +fence로 사용하지 않는다. shared ledger가 필요 없으면 persistence 자체를 +조립하지 않는다. + +### 7.7 write batching과 shutdown + +- cache writes는 bounded debounce/coalescing을 적용한다. +- 동시 save는 serialize하고 superseded snapshot은 쓰지 않는다. +- 각 transaction은 필요한 store만 열고 arbitrary network/async callback을 + transaction 중간에 await하지 않는다. +- request success가 아니라 transaction `complete`에서 persisted 성공을 확정한다. +- `pagehide`, `beforeunload`, browser 종료에서 새 IndexedDB transaction 완료를 + 보장한다고 가정하지 않는다. +- normal runtime 중 주기적으로 저장하고 unload write는 best-effort 보조로만 둔다. +- `dispose()`는 pending timer를 취소하고 connection/listener를 닫는다. 아직 + commit되지 않은 write를 persisted success로 기록하지 않는다. + +## 8. cache epoch와 durable invalidation ledger + +### 8.1 epoch 종류 + +```ts +type DurableCacheLedger = Readonly<{ + ledgerVersion: 1; + scopeFingerprint: string; + releaseEpoch: string; + namespaces: Readonly>; + revision: number; +}>; +``` + +- session/account epoch는 profile이 `ACCOUNT_BOUND`/`SESSION_BOUND`를 선택한 + 경우에만 frozen `CacheScopeSnapshot`에서 `scopeFingerprint`로 binding하며 raw + epoch를 ledger wire에 중복 저장하지 않는다. `ORIGIN_SHARED` fingerprint에는 + session/account epoch를 넣지 않는다. +- scope fingerprint/release epoch mismatch는 해당 scope record를 hydrate하지 않는다. +- namespace epoch는 ordinary mutation invalidation과 persisted record resurrection + 방지에 사용한다. +- counter 증가와 persisted query mutation을 함께 해야 하면 같은 IndexedDB + transaction에 둔다. +- localStorage read-modify-write counter는 cross-tab atomic하지 않으므로 durable + monotonic ledger로 사용하지 않는다. + +### 8.2 release buster + +release epoch는 모든 deploy마다 무조건 바꿀 필요는 없다. 다음 compatibility 중 +하나가 깨질 때 올린다. + +- query key schema +- payload codec +- API/mapper meaning +- scope binding +- persistence record format +- 지원하는 old reader/writer window + +안전 우선 배포는 build/release ID를 buster로 사용해 매 deploy cache를 버릴 수 +있지만 warm-start 효율이 낮다. 호환 epoch를 유지하려면 N-1 reader/writer +contract와 rollback evidence가 필요하다. + +### 8.3 lost hint 이후 안전성 + +Broadcast hint가 손실되어도: + +- finite staleTime/focus/reconnect가 eventual revalidation을 제공한다. +- persisted restore는 durable namespace epoch보다 오래된 record를 거절한다. +- visibility/focus 시 bounded ledger refresh를 선택할 수 있다. + +즉시 global consistency가 업무 invariant라면 browser invalidation bus만으로 +충족할 수 없다. backend revision/ETag, server event stream 또는 업무별 sync +protocol을 추가한다. + +## 9. 탭 간 invalidation + +### 9.1 목적과 비목적 + +목적: + +- 한 tab의 committed mutation 뒤 다른 tab의 active query를 빠르게 stale 처리 +- session/account/release epoch가 달라졌을 가능성을 알리는 non-destructive + revalidation hint 전달 +- IndexedDB versionchange/maintenance prepare hint 전달 + +비목적: + +- query payload/state replication +- authorization, logout 또는 server commit 증명 +- exactly-once delivery +- distributed lock/leader election +- business event bus +- offline command transport + +TanStack의 experimental broadcast client처럼 QueryClient state 자체를 tab 사이에 +복제하는 기능은 기본 선택하지 않는다. protocol 안정성뿐 아니라 query payload와 +scope가 broadcast boundary를 넘고, multi-tab persistence resurrection 문제가 +커지기 때문이다. 이 template은 좁은 namespace invalidation protocol을 소유한다. + +### 9.2 event envelope + +```ts +type CrossTabCacheEvent = Readonly<{ + protocolVersion: 1; + eventId: string; + sourceId: string; + sourceEpoch: string; + sequence: number; + cacheEpoch: string; + emittedAt: number; + expiresAt: number; + topic: string; + topicVersion: number; +}>; +``` + +목표 설계에서 `cacheEpoch`는 exact session/account/release scope의 opaque +compatibility fingerprint다. 현재 값은 release ID에만 묶여 있다. 원래 ID나 각 +epoch의 의미 값을 wire에 싣지 않는다. event에는 query data, query args/filter, +domain ID, user/tenant ID, URL, token, error message와 stack을 넣지 않는다. +`topic`은 registry-owned opaque/safe identifier고 receiver가 local registry를 +통해 TanStack namespace로 해석한다. + +receiver validation: + +- exact protocol version +- bounded serialized size +- registry-known topic/version +- exact composite cache epoch +- valid UUID/opaque source와 sequence +- reasonable issue/expiry time +- self source drop +- bounded LRU event ID duplicate drop +- unknown field/version/topic fail-closed drop + +duplicate와 out-of-order invalidation은 안전해야 한다. namespace epoch가 있으면 +durable ledger를 다시 읽어 현재보다 큰 epoch만 적용한다. wire event 자체의 +wall clock이나 source-local sequence를 global ordering으로 해석하지 않는다. + +### 9.3 transport + +기본 우선순위: + +```text +BroadcastChannel + -> unavailable/failure + -> dedicated localStorage pulse + window storage event + -> unavailable/failure + -> local-only cache action + normal focus/stale revalidation +``` + +`BroadcastChannel`: + +- same origin만으로 충분하다고 가정하지 않고 exact storage partition/scope를 + 검증한다. +- channel name은 application과 protocol major를 포함한다. +- channel construction/postMessage/message parsing 모두 실패할 수 있다. +- `close()`를 반드시 호출한다. + +localStorage pulse fallback: + +- dedicated registered physical key 하나만 사용한다. +- unique event envelope를 `setItem`하고 필요하면 exact key를 best-effort 제거한다. +- publishing tab에는 `storage` event가 오지 않으므로 local action은 publisher가 + 직접 수행한다. +- receiver는 exact key, storage area, scope와 envelope를 검증한다. +- localStorage write success는 다른 tab delivery acknowledgement가 아니다. +- quota/security failure는 local mutation을 실패시키지 않는다. +- sessionStorage event는 다른 tab에 전달되지 않으므로 fallback으로 쓰지 않는다. + +transport selection과 fallback은 capability detection 결과를 snapshot하되, +probe 결과를 영구 availability 보장으로 간주하지 않는다. + +### 9.4 receiver 동작 + +정상 delivery는 registry topic을 local namespace로 해석해 invalidate하고 active +query만 bounded refetch한다. source sequence gap이 보이면 특정 payload를 +신뢰하지 않고 등록 namespace 전체를 stale 처리하거나 durable ledger를 다시 +읽는다. + +remote hint는 `removeQueries`, `resetQueries`, `queryClient.clear()`를 직접 +호출할 권한이 없다. remove/reset/clear는 현재 tab의 session owner, release +coherence check 또는 사용자가 시작한 local lifecycle처럼 검증된 local authority만 +호출한다. delete mutation을 실행한 tab은 위험한 exact detail을 local remove할 수 +있지만, 다른 tab에는 namespace invalidation만 보내 서버에서 `NOT_FOUND` 또는 +새 권한 상태를 다시 확인하게 한다. + +receiver는 event burst를 namespace별로 coalesce하고 bounded queue를 사용한다. +cross-tab fan-out으로 모든 inactive query를 즉시 refetch해 thundering herd를 만들지 +않는다. active query만 refetch하고 inactive query는 다음 mount에서 fetch한다. +같은 tab의 동일 query는 TanStack dedup을 사용하되, tab 간 network dedup을 +가정하지 않는다. + +### 9.5 logout은 broadcast에 의존하지 않는다 + +현재 tab의 logout/reset은 local session owner notification이 authoritative하다. +다른 tab도 external auth owner/cookie/session 상태 변화를 자체적으로 감지하고 +보호된 request에서 재검증해야 한다. cache invalidation hint는 이를 빠르게 발견할 +수 있지만 remote event 하나가 전체 cache를 파괴하거나, event loss 때문에 logout이 +무효가 되어서는 안 된다. + +## 10. lifecycle + +### 10.1 boot + +1. runtime config와 release contract를 검증한다. +2. session owner에서 immutable cache scope를 얻는다. +3. scope별 QueryClient를 만든다. +4. persistence가 선택된 경우 bounded restore를 수행한다. +5. restore result를 compatibility/TTL/codec로 검증한다. +6. provider tree를 mount한다. +7. cross-tab runtime이 선택된 경우 listener를 시작한다. + +optional persistence 때문에 boot가 영구 blocked되지 않게 deadline과 +memory-only fallback을 둔다. listener를 restore보다 먼저 시작해야 한다면 수신 +event를 bounded queue에 두고 scope validation 완료 전에는 적용하지 않는다. + +### 10.2 logout/account switch + +```text +SESSION/ACCOUNT_TRANSITION_REQUESTED + -> old generation FENCED + -> new network/cache admission stopped + -> old query cancellation + provider detach + -> old QueryClient remove/clear + -> old persistence writer stopped + -> exact Web Storage key purge + -> exact IndexedDB partition lifecycle purge + -> optional non-destructive epoch/invalidation hint best-effort publish + -> channel/DB/timer/listener dispose + -> new opaque scope + new runtime +``` + +- old scope purge와 new scope open을 혼합하지 않는다. +- old physical data가 crash 때문에 남아도 new scope binding이 이를 읽지 못해야 + 한다. +- account deletion은 bounded purge progress와 recovery를 별도 표면으로 제공할 수 + 있다. +- confidential data를 browser에 persist했다면 “eventual cleanup”만으로 충분하지 + 않을 수 있으므로 애초 persistence를 금지한다. +- origin 전체 localStorage/IndexedDB를 지우지 않는다. + +### 10.3 page lifecycle + +- `visibilitychange`/focus는 stale query와 optional invalidation ledger 재검사 + trigger다. +- `pagehide`는 best-effort flush/close trigger일 뿐 commit 보장이 아니다. +- bfcache restore에서 source instance/generation과 listener 중복을 확인한다. +- React StrictMode mount/unmount 반복에도 subscription/channel/timer가 하나만 + 남아야 한다. +- dispose는 idempotent해야 한다. + +### 10.4 release transition + +새 release가 incompatible epoch를 발표하면: + +1. old query admission을 fence한다. +2. incompatible memory/persisted cache를 remove한다. +3. old persistence writer와 channel을 dispose한다. +4. 현재 release recovery policy에 따라 reload 또는 새 runtime 생성으로 전환한다. + +old tab이 IndexedDB schema upgrade를 막으면 existing IndexedDB +blocked/versionchange UX를 사용한다. Broadcast prepare hint는 참고용이고 실제 +connection close/transaction state가 authority다. + +## 11. quota, timeout과 resource cleanup + +### 11.1 budget + +| layer | 필수 limit | +| --- | --- | +| memory query | response count/byte, inactive gc, optional entry pressure | +| Web Storage | key별 serialized bytes, 전체 등록 key count | +| IndexedDB query persistence | entry bytes, dataset soft/hard bytes, record count, restore bytes/count | +| invalidation bus | event bytes, queue length, duplicate LRU, coalesce window | + +`navigator.storage.estimate()`는 origin 전체의 rough signal이다. Web Storage, +IndexedDB, OPFS와 Cache Storage별 free-space reservation으로 해석하지 않는다. +실제 `QuotaExceededError`가 authoritative failure다. + +query cache는 reconstructable이므로 pressure에서 expired/oldest/inactive +persisted record를 bounded batch로 제거할 수 있다. 기존 IndexedDB의 +user-authored/`UNTIL_SYNCED` dataset과 cleanup 범위를 공유하지 않는다. + +### 11.2 deadline과 cancellation + +- IndexedDB open/restore/maintenance에는 monotonic deadline과 `AbortSignal`을 + 전달한다. +- Web Storage는 synchronous이므로 큰 operation 자체를 byte/count limit으로 + 금지한다. +- cross-tab receiver가 시작한 refetch는 current query cancellation policy를 + 따른다. +- shutdown cleanup은 제품 request deadline과 분리된 짧은 cleanup budget을 + 가질 수 있다. +- deadline 초과와 caller cancellation을 같은 실패 code로 합치지 않는다. + +### 11.3 retry owner + +- HTTP retry owner가 transport면 Query retry는 끈다. +- Web Storage quota cleanup 뒤 write retry는 최대 1회다. +- IndexedDB transaction은 effect가 확실히 `NOT_APPLIED`이고 operation이 + idempotent/CAS-protected일 때만 bounded retry한다. +- channel publish는 자동 무한 retry하지 않는다. stale/focus safety net으로 + degrade한다. +- application, Query, HTTP SDK, service worker가 동시에 retry하지 않는다. + +### 11.4 cleanup 대상 + +- QueryClient observer/cache/mutation cache +- Query persistence debounce timer와 in-flight writer +- IndexedDB connection과 versionchange handler +- BroadcastChannel +- `window.storage`, focus, visibility, online listener +- duplicate LRU와 pending invalidation queue +- memory fallback envelope +- exact registered expired Web Storage keys + +cleanup 실패를 성공으로 숨기지 않되, diagnostics failure가 cleanup을 막지 않게 +한다. + +## 12. 공통 failure model + +### 12.1 closed failure + +```ts +type ClientCacheFailure = Readonly<{ + code: + | "ABORTED" + | "DEADLINE_EXCEEDED" + | "UNAVAILABLE" + | "UNSUPPORTED" + | "POLICY_REJECTED" + | "SERIALIZATION_FAILED" + | "CORRUPT_DATA" + | "VERSION_MISMATCH" + | "SCOPE_MISMATCH" + | "QUOTA_EXCEEDED" + | "BLOCKED" + | "CONFLICT" + | "DELIVERY_DROPPED" + | "INTERNAL"; + operation: + | "MEMORY_READ" + | "MEMORY_WRITE" + | "MEMORY_INVALIDATE" + | "WEB_STORAGE_READ" + | "WEB_STORAGE_WRITE" + | "WEB_STORAGE_REMOVE" + | "PERSISTENCE_OPEN" + | "PERSISTENCE_RESTORE" + | "PERSISTENCE_WRITE" + | "PERSISTENCE_PURGE" + | "INVALIDATION_PUBLISH" + | "INVALIDATION_RECEIVE" + | "RUNTIME_DISPOSE"; + retry: + | Readonly<{ kind: "NEVER" }> + | Readonly<{ kind: "SAFE"; afterMs?: number }> + | Readonly<{ kind: "AFTER_USER_ACTION" }>; + effect: "NOT_APPLIED" | "APPLIED" | "UNKNOWN"; + fallback: + | "NONE" + | "MISS" + | "MEMORY_ONLY" + | "LOCAL_ONLY" + | "ONLINE_ONLY"; +}>; +``` + +단순 `retryable: boolean`은 retry owner, delay와 side effect certainty를 표현하지 +못한다. 기존 `AppFailure`로 올려야 하는 경로는 이 closed failure를 allowlisted +kind로 mapping하되 raw native exception을 전달하지 않는다. + +### 12.2 miss와 failure + +다음은 reconstructable cache read에서 정상 `MISS`가 될 수 있다. + +- key/record 없음 +- TTL 만료 +- release buster mismatch +- old account/session epoch +- 지원 정책이 `DISCARD`인 old codec + +다음은 miss와 함께 degraded/security observation이 필요하다. + +- corrupt envelope +- scope binding mismatch +- future/unknown version +- storage eviction +- invalidation event schema violation + +unknown logical key, forbidden classification과 credential persistence 시도는 +programmer/policy error다. silent miss로 숨기지 않는다. + +### 12.3 effect certainty + +- localStorage `setItem` return: `APPLIED` +- localStorage throw: 일반적으로 `NOT_APPLIED`, native behavior가 불명확하면 + `UNKNOWN` +- IndexedDB transaction `complete`: `APPLIED` +- transaction abort: `NOT_APPLIED` +- connection loss/ambiguous lifecycle: `UNKNOWN` +- BroadcastChannel `postMessage` return: local publish accepted일 뿐 receiver effect는 + `UNKNOWN` +- local Query invalidate 완료: local effect `APPLIED`, remote effect와 무관 + +## 13. observability와 privacy + +### 13.1 event + +허용 가능한 semantic event 예: + +- `cache.operation.failed` +- `cache.persistence.degraded` +- `cache.scope.reset` +- `cache.invalidation.dropped` +- `cache.restore.completed` +- `storage.operation.failed` +- `storage.quota.pressure` + +safe attribute: + +- operation +- backend kind +- failure code +- fallback kind +- duration bucket +- byte/count bucket +- cache policy ID +- build/release compatibility epoch + +금지: + +- storage physical/logical key 원문 +- query key, hash input와 filter +- cached value +- user/account/tenant ID +- URL, token, file name +- native error message/stack +- Broadcast event payload 원문 + +### 13.2 metrics + +- memory cache entry/active/inactive count bucket +- hit/miss/stale/background-error ratio +- restore success/miss/busted/corrupt/deadline bucket +- persisted bytes/record count bucket +- quota/blocked/versionchange count +- invalidation publish/receive/drop/duplicate/coalesced count +- scope reset duration과 cleanup incomplete +- listener/channel/connection leak count + +metric은 policy tuning의 근거지만 user data cardinality를 telemetry에 복제하지 +않는다. + +### 13.3 diagnostics failure + +diagnostics/telemetry sink failure가 query, storage, logout 또는 dispose를 +실패시키면 안 된다. observation callback은 exception을 닫고 재귀 event를 만들지 +않는다. + +## 14. contract test + +### 14.1 TanStack memory contract + +- runtime마다 독립 QueryClient +- query/mutation retry owner +- fresh/stale/inactive/gc +- namespace prefix exact invalidation +- unrelated namespace 보존 +- invalidate와 remove/reset 차이 +- active refetch와 inactive no-refetch +- background failure에서 stale data 유지 +- query AbortSignal 전달 +- scope reset cancel/detach/clear +- old scope late result가 new client에 쓰이지 않음 +- File/Blob/credential query data persistence 거절 +- diagnostics에 query key/value 미노출 + +### 14.2 Web Storage contract + +- registered backend exact selection +- `HIT`/`MISS` +- local/session isolation +- schema/codec round-trip +- TTL boundary와 expired cleanup +- memory fallback도 동일 TTL/scope +- old version discard와 approved migration +- corrupt JSON/envelope/payload +- serialization cycle/BigInt/non-finite/oversize 거절 +- `SecurityError`, unavailable와 quota zero +- quota cleanup 뒤 최대 1회 retry +- exact key remove, origin-wide clear 없음 +- opaque partition key와 logout purge +- storage failure diagnostics redaction + +### 14.3 IndexedDB query persistence contract + +- fresh database/open/restore +- exact scope/policy binding +- wrong session/account/release epoch 거절 +- query/payload codec와 future version +- TTL, byte/count/restore hard cap +- transaction complete 뒤 성공 +- abort/close/quota/blocked/versionchange +- multi-tab concurrent writer와 CAS +- namespace epoch가 old record resurrection 차단 +- hint가 commit보다 먼저 발행되지 않음 +- bounded maintenance/checkpoint +- logout/account deletion exact partition purge +- optional failure가 memory-only로 degrade +- dispose connection/timer leak 0 + +### 14.4 cross-tab deterministic contract + +- publisher local apply +- self echo drop +- duplicate event drop +- out-of-order old epoch drop +- unknown version/topic drop +- wrong scope/release drop +- expired/oversize/malformed event drop +- invalidation payload에 data/query args 없음 +- BroadcastChannel primary +- localStorage pulse fallback +- sessionStorage가 fallback으로 사용되지 않음 +- transport failure가 committed mutation을 실패시키지 않음 +- namespace burst coalescing과 bounded queue +- `dispose()` 뒤 event 처리 없음 + +## 15. real browser evidence + +fake와 jsdom은 native storage/context semantics를 증명하지 않는다. Chromium, +Firefox, WebKit에서 실제 browser context와 page를 사용한다. + +필수 scenario: + +1. 두 page에서 BroadcastChannel invalidation +2. BroadcastChannel을 제거한 환경의 localStorage pulse fallback +3. publisher에는 storage event가 오지 않아도 local invalidation 적용 +4. sessionStorage가 tab별로 분리됨 +5. opener snapshot을 가진 새 tab의 instance/scope 재검증 +6. account switch 중 in-flight query와 late result +7. logout/reset event loss 후에도 local/session owner 정리 +8. persisted record와 namespace epoch의 concurrent update +9. old tab이 IndexedDB upgrade를 막는 blocked/versionchange +10. quota/corrupt/evicted/unavailable recovery +11. pagehide/bfcache/StrictMode에서 listener/channel/connection leak 없음 +12. N-1 release와 incompatible release buster + +promotion artifact는 engine, browser version, OS/image, build/release ID, contract +suite version, pass/fail/skip과 실행 시각을 기록한다. fake 통과를 native provider +통과로 보고하지 않는다. + +현재 source에는 native two-page BroadcastChannel delivery와 BroadcastChannel을 +제외한 localStorage pulse fallback/cleanup case가 있다. 이는 transport source +contract evidence다. 다음은 아직 없다. + +- production QueryClient/coordinator를 두 page에 조립한 namespace invalidation E2E +- session/account transition 중 query/mutation late-result fence +- event loss와 auth-owner local lifecycle 결합 +- bfcache/StrictMode에서 production composition leak 검증 +- Chromium/Firefox/WebKit 세 engine의 동일한 promotion artifact + +따라서 native transport spec의 존재를 “real-browser evidence 없음”으로 축소하지 +않고, 반대로 그것을 Gate 3 전체 완료로 확대하지 않는다. + +## 16. optional composition + +### 16.1 capability 상태 + +```text +InstallationState + NOT_SELECTED | INSTALLED | REMOVING + +RuntimeAvailability + UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE + +TrafficAdmission + DISABLED | CANARY | ENABLED +``` + +이 문서의 `AVAILABLE_NOT_COMPOSED`는 현재 source/runtime/contract/native test가 +실제로 존재하는 generic IndexedDB reference runtime에 적용한다. source에 +runtime, contract, test와 문서가 있지만: + +- `InstallationState=NOT_SELECTED` +- `TrafficAdmission=DISABLED` +- production bootstrap import 없음 +- database/channel/listener/timer 생성 없음 +- network/refetch 부가 traffic 없음 + +인 상태다. query persistence facade는 아직 source runtime이 없으므로 +`AVAILABLE_NOT_COMPOSED`가 아니라 `DESIGNED_NOT_IMPLEMENTED`다. 현재 cross-tab +invalidate-only runtime은 installed query contract가 실제로 소비하므로 +`COMPOSED` infrastructure다. + +### 16.2 기본 조립 + +| capability | 기본 | +| --- | --- | +| TanStack memory QueryClient | `COMPOSED` | +| 등록된 COLOR_SCHEME localStorage | `COMPOSED` | +| 등록된 CHUNK_RELOAD_GUARD sessionStorage | `COMPOSED` | +| generic IndexedDB repository/maintenance reference | `AVAILABLE_NOT_COMPOSED` | +| IndexedDB query persistence facade | `DESIGNED_NOT_IMPLEMENTED` | +| product query persistence | `NOT_SELECTED` | +| 등록 query의 cross-tab invalidation coordinator | `COMPOSED` | +| durable invalidation ledger | `DESIGNED_NOT_IMPLEMENTED` | +| SSR dehydration/hydration | `NOT_SELECTED` | + +현재 installed reference feature가 query namespace/topic을 등록하므로 +cross-tab coordinator와 channel/fallback을 조립한다. query persistence용 DB는 +열지 않는다. 향후 installed query registry가 비게 되면 빈 channel을 열지 않는 +composition으로 함께 변경해야 한다. + +### 16.3 선택 절차 + +```text +measured product need + -> data/query owner + -> classification + authority + retention + -> query/storage registry profile + -> codec + scope/epoch + -> optional adapter composition + -> deterministic contract + -> three-engine evidence + -> disabled + -> canary + -> enabled +``` + +canary에서 restore latency, stale ratio, quota, drop, scope reset과 error budget을 +관측한다. kill switch는 persistence write/restore 또는 cross-tab publish/listen을 +독립적으로 끌 수 있어야 한다. memory cache와 정상 server fetch fallback은 +유지한다. + +### 16.4 capability probing + +static API 존재, construction 성공, small operation 성공과 장기 availability를 +구분한다. + +- Web Storage property/get/set/remove small probe +- IndexedDB bounded open/write/read/delete probe +- BroadcastChannel construction/post/close local contract +- runtime scope/binding compatibility + +probe는 짧은 timeout, cancellation, TTL+jitter와 concurrent dedupe를 사용한다. +매 request의 authorization/availability proof로 사용하지 않는다. 결과에는 safe +status, checked time, capability/contract version과 bounded reason만 남긴다. + +## 17. 제거 가능성 + +### 17.1 제거 순서 + +1. runtime config/selection에서 신규 restore/write/publish admission을 끈다. +2. pending writer, refetch와 receiver queue를 drain/cancel한다. +3. listener, channel, timer와 DB connection을 dispose한다. +4. old release가 이해하는 cleanup-only 배포에서 owned persisted partition을 bounded + purge한다. +5. query/storage registry와 composition registration을 제거한다. +6. optional adapter, contract test와 dependency를 제거한다. +7. bundle/module inventory, SBOM과 dependency baseline을 갱신한다. +8. typecheck, architecture, base tests, build와 production artifact absence를 + 검증한다. + +IndexedDB code부터 삭제하면 이전 browser data를 cleanup할 실행 경로가 사라질 수 +있다. 필요한 retention 기간 동안 cleanup-only release를 먼저 운영한다. + +### 17.2 removal gate + +임시 repository copy에서 optional query persistence/cross-tab source와 관련 test, +catalog entry를 제거한 뒤 다음을 검증한다. + +- base typecheck와 architecture gate 통과 +- memory-only QueryClient와 Web Storage preference 동작 +- 전체 base test와 production build 통과 +- optional vendor/package import 부재 +- Vite production module inventory에 removed source 부재 +- IndexedDB DB/channel/event name과 feature flag 문자열 부재 +- unselected 상태에서 zero DB open/channel/listener/network side effect + +### 17.3 남겨서는 안 되는 운영 자원 + +- orphan IndexedDB database와 old partition +- localStorage pulse/epoch key +- BroadcastChannel/listener +- cleanup timer/worker +- alert/dashboard/runbook owner +- runtime flag와 stale config +- dependency/SBOM entry + +## 18. source 배치 + +현재 구현: + +```text +src/contracts/ + cache-invalidation.ts + query-invalidation.ts + query-keys.ts + storage-keys.ts + +src/adapters/query-cache/ + tanstack-query-cache.ts + tanstack-cache-coordinator.ts + +src/adapters/cross-context-invalidation/ + browser-cross-context-invalidation.ts + browser-cross-context-host.ts + index.ts + +src/adapters/storage/ + browser-storage-adapter.ts + browser-storage-codec.ts + indexeddb/ # 기존 reference runtime 재사용 + +src/bootstrap/ + runtime-adapters.ts + runtime-application.tsx + create-runtime-composition.ts + +tests/unit/ + query-cache.test.ts + storage-registry.test.ts + cross-tab-invalidation.test.ts + tanstack-cache-coordinator.test.ts +``` + +query persistence를 실제 선택할 때 추가할 경계: + +```text +src/adapters/query-cache/ + query-persistence-policy.ts + indexeddb-query-persistence.ts + +tests/unit/ + query-persistence.test.ts + +tests/browser-capabilities/ + client-cache-multi-tab.spec.ts + client-cache-persistence.spec.ts +``` + +현재 bootstrap infrastructure bundle은 concrete QueryClient, coordinator, +status와 idempotent `dispose()`를 provider tree에만 전달한다. application API에는 +QueryClient, channel, native Storage 또는 IndexedDB type이 나타나지 않는다. + +## 19. 구현 현황과 후속 순서 + +후속 구현은 VD-13 gate를 순서대로 통과한다. 뒤 gate를 먼저 구현해 앞 gate의 +scope/policy를 우회하지 않는다. + +### Gate 0 — 상태와 문서 + +- capability 상태를 이 문서의 다섯 표준 상태로만 표현 +- current source, target contract와 product selection을 별도 열로 유지 +- runbook/catalog/test evidence가 같은 상태와 browser case 수를 사용 + +현재 이 문서와 VD-13의 상태 정규화만 완료됐다. 다른 문서/catalog의 상태 변경은 +실제 source/composition 변경과 함께 별도 반영한다. + +### Gate 1 — strict registry, key와 Web Storage + +- query policy에 classification/scope/stale/gc/retry/result budget 추가 +- query key closed codec과 depth/node/part/string/byte 절대 상한 구현 +- Web Storage key별 byte cap, `HIT`/`MISS`, durability outcome 구현 +- partition-aware v2 envelope, adjacent migration/discard, bounded sweep 구현 +- quota exact cleanup 뒤 최대 한 번 retry와 clock-skew/TTL 검증 구현 + +Gate 1 delta 상태: `DESIGNED_NOT_IMPLEMENTED`. 기존 closed value codec, v1 envelope, +global 16,384-byte cap과 memory overlay는 이 gate의 출발점이지 완료 증거가 아니다. + +### Gate 2 — scope-owned QueryClient lifecycle + +- session authority의 opaque scope snapshot과 generation 구현 +- auth owner subscription에서 old admission fence 실행 +- query cancel, provider detach, client clear/dispose 후 새 QueryClient 생성 +- old query/mutation late result의 UI/cache update 폐기 +- exact old partition logout/account lifecycle + +Gate 2 delta 상태: `DESIGNED_NOT_IMPLEMENTED`. 현재 `resetLocal()`은 coordinator-local +cancel/clear만 제공하며 session/account transition에 bootstrap 조립되지 않았다. + +### Gate 3 — cross-tab scope hardening + +- release-only epoch를 composite session/account/release fingerprint로 교체 +- localStorage pulse key registry 등록과 exact `storageArea` 검증 +- topic/profile count absolute cap +- production QueryClient/coordinator two-page E2E +- account switch, event loss, bfcache/StrictMode cleanup +- Chromium/Firefox/WebKit 동일 promotion artifact + +현재 transport/coordinator는 `COMPOSED`이고 native BroadcastChannel/localStorage +fallback 두 case도 존재한다. 위 scope/account lifecycle과 세 engine artifact가 +없으므로 Gate 3은 완료되지 않았다. + +### Gate 4 — optional query persistence reference + +- stable per-query record codec와 generic IndexedDB runtime facade +- durable namespace epoch, CAS와 commit-before-hint +- bounded restore/write/debounce/dispose +- scope/release/TTL/codec/quota/blocked/migration failure +- unselected production bundle에서 zero DB open/listener/network side effect +- removal/module-inventory gate + +현재 reference facade와 ledger는 `DESIGNED_NOT_IMPLEMENTED`다. 구현과 test가 +완료돼도 product가 선택하기 전 상태는 reference +`AVAILABLE_NOT_COMPOSED`, product `NOT_SELECTED`다. + +### Gate 5 — product composition + +- measured warm-start/offline requirement와 owner 승인 +- exact persist allowlist, retention/budget/account/logout policy +- disabled → canary → enabled admission +- N-1 reader/writer, rollback과 cleanup-only release drill + +현재 상태: `NOT_SELECTED`. + +### Gate 6 — SSR/offline workflow + +SSR과 offline mutation은 각각 독립 capability다. + +- SSR 선택 시 request-scoped QueryClient, safe dehydration, SSR 우선 merge와 + request isolation을 구현 +- offline mutation 선택 시 feature-specific repository, backend + idempotency/revision/sync/conflict와 export/recovery UX를 구현 + +현재 두 product capability 모두 `NOT_SELECTED`다. query persistence 구현이 이 +gate를 자동 충족하지 않는다. + +## 20. 완료 기준 + +- [x] TanStack cache, Web Storage, IndexedDB와 invalidation bus의 소유권이 + source/module 경계에서 분리됐다. +- [x] `QUERY_REGISTRY`, coordinator와 `QUERY_PERSISTENCE` key가 query persistence를 + 기본 OFF로 강제한다. +- [ ] 승인 profile 전용 IndexedDB query persistence facade가 구현됐다. +- [x] 등록 Web Storage key가 closed value codec, schema version, TTL 선언과 + 16,384-byte adapter hard cap을 갖는다. +- [ ] 등록 Web Storage key마다 byte cap, `HIT`/`MISS`, durability, partition, + logout, migration과 bounded sweep 계약이 실행 코드로 강제된다. +- [x] credential key는 persistence가 금지되고 invalidation event는 exact + payload/query-key-free envelope만 허용한다. +- [ ] query registry가 classification/scope/stale/gc/retry/result budget을 + 검증하고 strict key codec이 hostile/native/oversize input을 거절한다. +- [ ] session/account epoch와 opaque partition이 memory/persistence/event에 + binding된다. 현재는 release cache epoch만 있다. +- [x] remote protocol은 invalidate-only이고 remove/reset/clear는 local + coordinator authority에만 있다. +- [ ] local remove/reset/clear와 account lifecycle 전체가 contract/real-browser + test로 고정됐다. +- [ ] IndexedDB query persistence transaction complete 뒤에만 hint를 발행한다. + 현재 query persistence 자체가 OFF다. +- [x] deterministic test에서 duplicate, sequence gap, stale out-of-order와 + self-echo가 안전하다. +- [x] BroadcastChannel open/publish 실패에서 localStorage pulse 또는 + `DEGRADED_LOCAL_ONLY`로 전환한다. +- [ ] localStorage pulse가 registry-owned key와 exact `storageArea`를 검증한다. +- [x] installed query registry와 coordinator가 production bootstrap/provider + tree에 조립됐다. +- [ ] logout/account switch와 late result fence가 broadcast delivery에 의존하지 + 않고 동작한다. +- [ ] SSR을 제품이 선택한 경우 request isolation, safe dehydration과 hydration + precedence가 실행 코드와 test로 구현됐다. 현재 SSR은 `NOT_SELECTED`다. +- [x] Web Storage quota/corrupt/oversize/unavailable failure와 redaction이 닫혀 + 있다. +- [ ] IndexedDB query restore의 blocked/deadline/quota/corrupt failure가 닫혀 + 있다. +- [x] 현재 invalidation listener/channel/tracking state에 idempotent + unsubscribe/close/dispose가 있다. +- [x] invalidation diagnostics에는 event/topic/epoch/source/value/query key가 + 노출되지 않고 storage diagnostics에는 physical key/value가 노출되지 않는다. +- [x] native two-page BroadcastChannel과 localStorage fallback source spec이 있다. +- [ ] Chromium/Firefox/WebKit의 production coordinator, account transition과 + lifecycle multi-tab promotion evidence가 있다. +- [ ] cross-tab/query-persistence 전용 removal/zero-side-effect gate가 통과한다. + +## 21. 관련 자료 + +- [API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md) +- [VD-25 Server State Cache lifecycle](./decisions/VD-25-server-state-cache-lifecycle.md) +- [Browser data capability completion ledger](./browser-data-capability-completion-ledger.md) +- [TypeScript, 상태 소유권, 데이터 흐름](./typescript-state-and-data-flow.md) +- [Frontend ports, adapters and boundaries](./frontend-ports-adapters-and-boundaries.md) +- [Browser file and origin-storage platform](./browser-file-and-origin-storage.md) +- [VD-11 browser file and origin-storage 경계](./decisions/VD-11-browser-file-and-origin-storage.md) +- [VD-13 client cache scope와 persistence](./decisions/VD-13-client-cache-scope-and-persistence.md) +- [Client cache and Web Storage recovery](../operations/client-cache-and-storage-recovery.md) +- [TanStack Query important defaults](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults) +- [TanStack Query persistence](https://tanstack.com/query/v5/docs/framework/react/plugins/persistQueryClient) +- [TanStack experimental broadcast client](https://tanstack.com/query/latest/docs/framework/react/plugins/broadcastQueryClient) +- [MDN Web Storage API](https://developer.mozilla.org/docs/Web/API/Web_Storage_API) +- [MDN storage event](https://developer.mozilla.org/docs/Web/API/Window/storage_event) +- [MDN sessionStorage](https://developer.mozilla.org/docs/Web/API/Window/sessionStorage) +- [MDN Broadcast Channel API](https://developer.mozilla.org/docs/Web/API/Broadcast_Channel_API) +- [MDN IndexedDB](https://developer.mozilla.org/docs/Web/API/IndexedDB_API/Using_IndexedDB) + +Web platform과 TanStack library는 계속 변한다. 실제 조립 시 pinned dependency와 +지원 browser matrix의 공식 문서를 다시 검증하고 contract evidence에 version을 +기록한다. diff --git a/docs/architecture/decisions/VD-01-typescript-lint-tooling.md b/docs/architecture/decisions/VD-01-typescript-lint-tooling.md new file mode 100644 index 0000000..408a0c7 --- /dev/null +++ b/docs/architecture/decisions/VD-01-typescript-lint-tooling.md @@ -0,0 +1,64 @@ +# VD-01: TypeScript 7과 ESLint 10의 점진적 전환 도구 + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-typescript-tooling-foundation` + +## 배경 + +저장소는 TypeScript `7.0.2`와 ESLint `10.8.0`을 고정하고 있다. 첫 전환 +브랜치는 compiler를 변경하거나 production source를 일괄 변환하지 않고 +JS/JSX/TS/TSX가 같은 품질 게이트를 통과하게 해야 한다. + +결정 시점의 package peer contract는 다음과 같다. + +- `typescript-eslint@8.65.0`과 canary는 TypeScript `<6.1.0`을 요구한다. +- `eslint-plugin-jsx-a11y@6.10.2`는 ESLint `<=9`를 요구한다. +- `eslint-plugin-react-hooks@7.1.1`은 ESLint 10을 지원한다. +- Babel 8 ESLint parser는 ESLint 10을 지원하고 Node `>=24.11.0`을 요구한다. + +호환되지 않는 peer dependency를 강제 설치하면 lockfile 검증은 통과하더라도 +지원되지 않는 parser와 rule 조합을 플랫폼 계약으로 만들게 된다. + +## 결정 + +1. TypeScript `7.0.2`와 ESLint `10.8.0`을 유지한다. +2. TypeScript/TSX의 ESLint syntax parsing에는 + `@babel/eslint-parser`와 TypeScript/JSX syntax plugin을 사용한다. +3. TypeScript의 이름 해석, unused 진단과 type semantics는 `tsc`가 소유한다. + Babel parser가 TypeScript scope manager를 제공하지 않으므로 TS 파일의 + core `no-undef`와 `no-unused-vars`는 끄고 분리된 app/node/test TypeScript + project를 필수 게이트로 실행한다. +4. React Hook 규칙은 호환되는 `eslint-plugin-react-hooks`로 즉시 적용한다. +5. JSX 접근성은 현재의 semantic component contract, Testing Library, + axe 기반 cross-browser gate와 수동 검토 계약이 계속 담당한다. 호환되지 않는 + `eslint-plugin-jsx-a11y`는 설치하지 않는다. +6. Babel 8의 지원 범위에 맞춰 Node engine 하한을 `24.11.0`으로 명시한다. +7. production source의 대량 rename은 이 결정에 포함하지 않는다. + +## 적용 후 상태 (2026-07-27) + +후속 migration에서 production source, 비-fixture tests, Node scripts와 지원되는 +tool config를 모두 TS/TSX로 전환했다. `allowJs`는 껐고 runtime/source 영역의 +JavaScript 재유입은 architecture gate가 거절한다. Node scripts는 pinned Node +24에서 `.ts`로 직접 실행되며 NodeNext, `verbatimModuleSyntax`와 +`erasableSyntaxOnly`로 별도 typecheck한다. `tests/fixtures/**`도 TS/TSX +architecture/security/type negative input으로 전환했다. 이는 7번 결정의 범위를 +변경한 것이 아니라 그 기반 위에서 완료한 별도 후속 작업이다. + +## 검증 + +- `check:types`는 app, Node scripts/config, tests project를 모두 검사한다. +- TS invalid-call, invalid port, discriminated-union fixture는 실패해야 한다. +- ESLint와 dependency-cruiser는 TS/TSX architecture fixture를 검사한다. +- registry scanner는 TS registry의 required field, uniqueness와 reference를 + 검증한다. +- browser security gate는 TSX의 금지된 raw HTML fixture를 거절한다. + +## 후속 검토와 제거 + +`typescript-eslint`가 TypeScript 7을, JSX 접근성 plugin이 ESLint 10을 공식 +지원하면 별도 dependency 브랜치에서 peer metadata와 전체 negative fixture를 +재검증한다. 교체할 때는 Babel parser package와 TS 전용 ESLint override를 +함께 제거한다. compiler downgrade나 `--force` 설치는 이 ADR의 rollback +방법이 아니다. diff --git a/docs/architecture/decisions/VD-03-react-router-data-mode.md b/docs/architecture/decisions/VD-03-react-router-data-mode.md new file mode 100644 index 0000000..a562a0f --- /dev/null +++ b/docs/architecture/decisions/VD-03-react-router-data-mode.md @@ -0,0 +1,58 @@ +# VD-03: React Router Data Mode와 서버 상태 소유권 + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-routing-release-recovery-runtime` + +## 배경 + +기존 라우터는 `BrowserRouter`와 수동 JSX route 목록을 사용했다. 직렬화 가능한 +route registry에 params/search schema, loading/error surface, access, title, +navigation과 chunk ID가 있었지만 실행 route tree와 독립적이어서 선언과 행동이 +어긋날 수 있었다. + +이 저장소는 client-only SPA이며 서버 상태는 application input과 TanStack Query가 +소유한다. Framework Mode의 loader/action 중심 데이터 소유권이나 SSR을 도입하지 +않으면서 route object, 오류 경계와 navigation lifecycle은 중앙에서 조립할 +필요가 있다. + +## 결정 + +1. 고정된 React Router `7.18.1`의 `createBrowserRouter`와 `RouterProvider`를 + 사용하는 Data Mode를 기본값으로 채택한다. +2. 직렬화 가능한 route contract와 React component/codec runtime map을 분리한다. +3. 모든 executable route object와 navigation은 registry에서 생성한다. JSX에서 + route 목록을 다시 열거하지 않는다. +4. params/search는 route 경계의 Zod codec으로 parse하고 같은 codec으로 canonical + URL을 생성한다. +5. loader/action은 같은 서버 데이터를 직접 다시 요청하지 않는다. 필요하면 + application input 또는 query adapter 한 경로를 호출한다. +6. 서버 상태, retry, cache와 mutation lifecycle은 application input과 TanStack + Query가 계속 소유한다. +7. lazy chunk rejection만 release recovery input으로 보내며 일반 render error는 + route/feature boundary가 소유한다. +8. Framework Mode, SSR, static generation과 router version upgrade는 별도 + dependency/architecture 브랜치에서 결정한다. + +## 검증 + +- route contract/runtime map의 누락과 orphan은 TypeScript negative fixture와 + registry gate가 모두 거절한다. +- duplicate ID/path, unknown codec/surface/chunk와 참조 불일치를 negative registry + fixture로 검증한다. +- params/search parse/build round-trip, canonical redirect, 최대 redirect hop, + access rejection, title/focus와 boundary reset을 unit/component test로 검증한다. +- Vite dynamic entry와 release route chunk map, runtime config JSON Schema를 + build/release 검증기가 확인한다. +- chunk failure는 no-store manifest refetch 후 build/release 쌍마다 한 번만 + reload하며 offline, malformed manifest와 storage 실패는 fail-closed한다. + +## 결과와 rollback + +Data Router는 navigation lifecycle의 조립 경계이며 서버 데이터 계층이 아니다. +이 구분을 지키면 React Router를 교체해도 application input과 output port는 +유지된다. + +rollback은 RP-04 merge를 되돌려 이전 수동 router와 generic route failure +surface로 복구한다. URL shape와 application API는 유지하고, 이미 배포된 asset +cache의 purge는 저장소 rollback 범위에 포함하지 않는다. diff --git a/docs/architecture/decisions/VD-04-native-form-facade.md b/docs/architecture/decisions/VD-04-native-form-facade.md new file mode 100644 index 0000000..374fd20 --- /dev/null +++ b/docs/architecture/decisions/VD-04-native-form-facade.md @@ -0,0 +1,55 @@ +# VD-04: Native form controller와 local facade + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-form-page-platform` + +## 배경 + +플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과 +page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면 +dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기 +field orchestration까지 플랫폼 기본값으로 고정하게 된다. + +## 결정 + +1. RP-06은 React native form event와 controlled value를 사용하는 local + `useAppForm` facade를 기본 엔진으로 채택한다. +2. Zod presentation schema, application command mapper와 domain invariant는 서로 + 다른 소유물로 유지한다. +3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`, + `mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다. +4. 422 details는 승인된 `path`와 `code`만 HTTP 경계에서 투영한다. backend + message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level + error로 이동한다. +5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다. +6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후 + dirty 상태를 해제한다. +7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`는 + layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다. + +## React Hook Form 도입 조건 + +다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로 +React Hook Form과 Zod resolver를 평가한다. + +- 동적 field array와 중첩 object를 함께 다루는 복합 form +- field 단위 비동기 validation 취소와 의존 validation +- 수백 개 field의 render isolation이 측정 가능한 병목인 경우 +- uncontrolled input 또는 vendor extension이 필요한 경우 + +도입하더라도 이 문서의 public API와 component/application tests를 유지해야 +한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다. + +## 검증과 rollback + +- client validation, transform/default, 422 allowlist, conflict, duplicate submit, + reset, dirty guard와 focus를 component test로 검증한다. +- template 최소/전체 slot과 async/status variation을 component test로 검증한다. +- architecture gate가 template의 application/HTTP/query vendor import를 + 거절한다. +- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다. + +rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다. +application input과 outbound gateway 계약은 유지되며, form facade와 template +commit은 독립적으로 되돌릴 수 있다. diff --git a/docs/architecture/decisions/VD-05-icon-and-headless-ui.md b/docs/architecture/decisions/VD-05-icon-and-headless-ui.md new file mode 100644 index 0000000..9bda349 --- /dev/null +++ b/docs/architecture/decisions/VD-05-icon-and-headless-ui.md @@ -0,0 +1,57 @@ +# VD-05: Semantic icon facade와 native-first interaction + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-design-system-platform` +- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때 + +## 배경 + +앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를 +사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들, +접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가 +없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할 +의존성과 중복 interaction 모델이 생긴다. + +## 결정 + +1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로 + 고정한다. +2. `lucide-react`의 static named import는 + `design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다. +3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만 + 노출한다. vendor component type, icon name, stroke API와 dynamic icon + registry는 노출하지 않는다. +4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는 + 아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다. +5. 현재 복합 control은 native `dialog`, form control, `details`와 local + TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape, + Tabs는 manual/automatic activation, Drawer는 modal/background + 비활성화/focus restore 계약을 가진다. +6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이 + collision, nested overlay, virtualized collection 또는 복합 select 요구를 + 충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시 + 평가한다. +7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의 + runtime gallery와 browser interaction test는 해당 workshop을 대체한다고 + 주장하지 않는다. + +## 경계와 검증 + +- 제품 코드는 `presentation/design-system/index`만 import한다. +- design-system 검사기는 direct icon/headless import, deep import, raw palette, + undefined token과 tooltip-only required information fixture를 거절한다. +- type negative fixture는 accessible name 없는 `IconButton`을 거절한다. +- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를 + 검증한다. +- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus + restore, gallery keyboard interaction과 axe를 검증한다. +- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며 + 공급자 선택이나 product behavior의 PASS로 숨기지 않는다. + +## Rollback + +기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를 +재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와 +semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에 +도입해도 public props와 interaction test를 유지한다. diff --git a/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md b/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md new file mode 100644 index 0000000..0204c94 --- /dev/null +++ b/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md @@ -0,0 +1,96 @@ +# VD-06: Intl과 typed local message catalog + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract` +- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때 + +## 배경 + +공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와 +JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로 +가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패 +정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차, +복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본 +번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다. + +## 결정 + +1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`, + `ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다. +2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke + catalog는 `satisfies Record`으로 compile-time parity를 + 강제한다. +3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를 + 선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가 + 거절한다. +4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은 + language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은 + raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다. +5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`는 + RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지 + 않는다. +6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지 + 않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid + date/number/timezone은 `—`를 반환하고 throw하지 않는다. +7. locale state는 React inbound concern이다. `LocaleProvider`가 copy, + formatter와 ``을 제공하며 application/domain은 미리 번역된 + 문자열 대신 의미 값과 timestamp를 반환한다. +8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지 + 않는다. transport/application failure kind를 등록된 사용자 message key로 + 매핑한 뒤 presentation이 해석한다. +9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를 + 둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만 + 사용한다. +10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면 + `presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와 + design-system public prop으로 노출하지 않는다. + +## 실행 경계 + +```text +route/form/failure 의미 값 + -> presentation message key + -> LocaleProvider + -> typed catalog / Intl formatter + -> text node와 accessible name +``` + +- canonical catalog: `src/presentation/i18n/catalog.ts` +- feature contribution: `src/features/*/contracts/*-message-catalog.ts`를 + `src/features/installed-feature-messages.ts`에서 조립 +- key/보간/fallback/alias: `message-contract.ts` +- locale-safe value formatting: `formatters.ts` +- React composition과 document metadata: `locale-provider.tsx` +- public entry: `src/presentation/i18n/index.ts` + +## 검증 + +- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal, + backend message JSX 렌더링과 raw HTML 사용을 검사한다. +- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다. +- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다. +- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/ + list/plural/select의 결정성을 검증한다. +- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination, + Drawer semantics를 검증한다. +- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를 + Chromium, Firefox, WebKit project에서 실행한다. + +## 한계와 재검토 조건 + +현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합, +메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중 +하나가 확인되면 별도 ADR로 엔진을 재평가한다. + +- 세 개 이상의 실제 승인 locale과 번역 담당 workflow +- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy +- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동 +- SSR locale negotiation과 hydration 일치가 필요한 rendering mode + +## Rollback + +`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로 +되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`, +`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative +fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다. diff --git a/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md new file mode 100644 index 0000000..bb7a2ec --- /dev/null +++ b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md @@ -0,0 +1,97 @@ +# VD-07: Diagnostics와 telemetry exporter 경계 + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-diagnostics-telemetry-runtime` +- 재검토: 실제 운영 sink, consent가 필요한 analytics 또는 분산 tracing provider가 + 선정될 때 + +## 배경 + +기존 telemetry registry와 best-effort HTTP queue는 있었지만 운영 진단 record와 +semantic event의 책임이 하나의 telemetry port에 섞여 있었다. boot, HTTP, +cache, storage, route와 release failure의 선언도 실제 production producer와 +완전히 연결되지 않았다. 이 상태에서는 retry attempt마다 같은 사건을 발행하거나 +raw URL, query, request body와 오류 객체가 queue에 들어갈 위험이 있다. + +반면 skeleton 단계에는 실제 관측 vendor, endpoint의 운영 보안 정책, analytics +consent와 보존 기간이 결정되지 않았다. 특정 SDK를 기본 번들에 설치하는 것은 +vendor 결정 전에는 안전한 기본값이 아니다. + +## 결정 + +1. level 기반 운영 진단은 `DiagnosticsPort`, registry 기반 semantic event는 + `TelemetryPort`로 분리한다. application은 두 port의 concrete adapter나 + exporter SDK를 알지 못한다. +2. diagnostics의 level, event ID와 context key는 닫힌 registry/allowlist다. + telemetry도 event별 required/optional attribute와 value policy를 적용한다. + 등록되지 않은 event·context·고카디널리티 값은 전송하지 않는다. +3. 기본 diagnostics adapter는 bounded in-memory evidence이고 telemetry는 + 설정이 없으면 true no-op이다. endpoint가 있을 때만 bounded oldest-drop + queue와 best-effort HTTP sink를 사용한다. +4. raw path/URL/query/body/response/storage value, credential, cookie, email, + stack과 오류 객체 전체는 context에 넣지 않는다. route ID, operation ID, + correlation ID, release ID, error kind, status/attempt/duration bucket만 + 허용한다. +5. HTTP logical execution은 success, retry recovery, terminal failure 또는 + abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다. + `api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만 + 정확히 한 번 발행한다. +6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`, + `telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage + 실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다. +7. queue full, invalid event/context, serialization과 sink failure는 제한된 + reason bucket으로 집계한다. drop observer의 failure는 다시 telemetry를 + 발행하지 않는 nonrecursive 경계다. +8. diagnostics와 telemetry failure는 제품 흐름, HTTP 결과, route transition, + storage/cache fallback과 React error surface를 바꾸지 않는다. +9. mount 전 bootstrap failure는 안전한 build/config/error kind만 별도 evidence로 + 만들며 untrusted error message, stack과 support 입력을 serialize하지 않는다. +10. 실제 error reporter, RUM, analytics나 tracing SDK는 같은 port 뒤의 외부 + adapter로만 추가한다. SDK type과 event API를 application/feature/presentation + public contract에 노출하지 않는다. + +## 실행 경계 + +```text +route/application/HTTP/cache/storage/bootstrap + -> typed DiagnosticsPort 또는 TelemetryPort + -> registry + allowlist + value policy + -> bounded memory/no-op 또는 best-effort HTTP adapter + -> 프로젝트가 선택한 외부 sink +``` + +- diagnostics contract: `src/contracts/diagnostics.ts` +- telemetry contract: `src/contracts/telemetry.ts` +- application ports: `src/application/ports/diagnostics-port.ts`, + `telemetry-port.ts` +- bounded diagnostics: `src/adapters/diagnostics/bounded-diagnostics.ts` +- best-effort telemetry: `src/adapters/telemetry/best-effort-telemetry.ts` +- composition: `src/bootstrap/runtime-adapters.ts` + +## 검증 + +- `check:diagnostics`는 모든 registry event에 production producer가 있는지와 + source의 direct console/sensitive context 우회를 검사한다. +- negative source fixture는 direct console, unknown event와 raw context를 실제로 + 거절하며 TypeScript fixture는 잘못된 level/event ID를 거절한다. +- unit test는 allowlist, hostile/circular error, bounded diagnostics, no-op, + queue full, sink/observer failure와 pre-mount boot evidence를 검증한다. +- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer + 횟수, route/operation/correlation context와 요청 값 비노출을 검증한다. +- cache/storage/release/application/runtime test는 각 production wiring과 + diagnostics failure isolation을 검증한다. + +## 한계와 재검토 조건 + +기본 adapter는 운영 log 검색, source map 연계, session replay, distributed span, +analytics consent, sampling budget과 장기 보존을 제공하지 않는다. 실제 sink를 +선정할 때 데이터 처리 지역, 보존 기간, consent, CSP, source map 접근 제어, +sampling과 비용 상한을 별도 결정해야 한다. + +## Rollback + +telemetry exporter는 runtime 설정을 끄거나 adapter wiring을 `noOpTelemetry`로 +바꾸어 독립적으로 제거할 수 있다. 이때도 `DiagnosticsPort`, registry, +redaction/value policy, producer-count와 negative fixture는 유지한다. 외부 SDK +문제로 application producer와 안전 계약을 함께 되돌리지 않는다. diff --git a/docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md b/docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md new file mode 100644 index 0000000..4f8332b --- /dev/null +++ b/docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md @@ -0,0 +1,71 @@ +# VD-08: 개발용 Storybook과 로컬 시각 회귀 증적 + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-test-registry-evidence-hardening` +- 재검토: 제품이 cloud visual review, 다중 OS baseline 또는 별도 디자인 시스템 + 배포를 요구할 때 + +## 배경 + +`/examples/ui`와 `/examples/states`는 실제 application composition 안에서 공통 +UI와 상태 표면을 보여 주지만, primitive를 격리해 interaction과 접근성을 검증하는 +workshop은 아니었다. 실패 시 screenshot도 디버깅 증거일 뿐 의도된 UI 기준선과 +현재 렌더의 차이를 차단하지 못했다. + +외부 visual review 서비스, 별도 Storybook 배포와 브랜드별 baseline은 아직 +선정되지 않았다. 이 결정을 기다리며 UI 회귀 검증을 비워 두거나 production +application bundle에 workshop runtime을 포함하는 것 모두 적절하지 않다. + +## 결정 + +1. Storybook은 development dependency와 별도 static artifact로만 사용한다. + production entry와 application `dist`에는 Storybook runtime, story 또는 + 테스트 selector를 포함하지 않는다. +2. story는 public design-system entry를 소비하고 실제 locale, theme, session, + router와 query provider 계약으로 렌더한다. production component를 복제한 + story 전용 구현을 만들지 않는다. +3. interaction과 story-level axe는 Playwright가 정적 Storybook을 대상으로 + 실행한다. unexpected console, page error와 request failure는 테스트 실패다. +4. 시각 회귀는 production `build` 후 `preview`를 대상으로 pinned Chromium, + locale, color scheme과 viewport에서 `toHaveScreenshot()`으로 실행한다. +5. 최초 기준선은 wide shell, compact pseudo-locale drawer, dark design-system + gallery, loading/empty/error/access 상태 표면을 포함한다. +6. animation과 caret만 결정적으로 비활성화한다. `html`, `body`, `main` 또는 + application 전체를 mask해 false PASS를 만드는 설정은 gate가 거절한다. +7. snapshot 갱신은 `test:visual:update`라는 명시적 명령으로 분리하고 PNG diff를 + review한다. 일반 `test:visual`은 승인 기준선을 변경하지 않는다. +8. local visual threshold는 작은 rasterization 차이만 허용하며 실제 layout, + copy, theme 또는 상태 변화가 숨겨지도록 확대하지 않는다. +9. `/examples/*`는 production composition smoke로 유지하고 Storybook story의 + 대체물로 취급하지 않는다. 반대로 Storybook만 통과해 application shell + integration을 완료 처리하지 않는다. +10. cloud service가 선정되지 않아도 repository-local workshop, interaction, + a11y와 visual baseline gate는 완전하게 실행 가능해야 한다. + +## 실행과 증적 + +- workshop config: `.storybook/main.ts`, `.storybook/preview.tsx` +- story: `src/presentation/design-system/design-system.stories.tsx` +- interaction/a11y: `tests/storybook/workshop.spec.ts` +- visual: `tests/visual/platform.visual.spec.ts` +- baseline: `tests/visual/__snapshots__/` +- production E2E: `playwright.config.ts` +- local dev E2E: `playwright.dev.config.ts` +- evidence policy: `scripts/check-test-evidence.ts` + +CI는 JUnit, HTML report, failure trace/screenshot, visual baseline 존재 여부와 +금지된 full-screen mask/무소유 skip fixture를 함께 검사한다. + +## 한계와 재검토 조건 + +로컬 기준선은 실제 iOS/Android 기기, 여러 운영체제의 font rasterization, +디자인 승인 workflow와 다중 브랜드를 증명하지 않는다. 이를 요구하면 동일 +public component와 story를 입력으로 사용하는 외부 review adapter를 추가하되, +provider 결과가 없을 때 임의 PASS로 대체하지 않는다. + +## Rollback + +Storybook dependency/config, workshop test와 visual config/baseline은 production +runtime 변경 없이 독립적으로 제거할 수 있다. rollback 후에도 `/examples/*`, +component behavior, automated accessibility와 built-dist E2E는 유지한다. diff --git a/docs/architecture/decisions/VD-09-supply-chain-evidence.md b/docs/architecture/decisions/VD-09-supply-chain-evidence.md new file mode 100644 index 0000000..128e118 --- /dev/null +++ b/docs/architecture/decisions/VD-09-supply-chain-evidence.md @@ -0,0 +1,113 @@ +# VD-09: 공급망 inventory, license, vulnerability, SBOM과 provenance + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-supply-chain-verification` +- 재검토: 조직 vulnerability scanner, signing/attestation provider와 dependency + exception 승인 체계가 선정될 때 + +## 배경 + +기존 release script는 `package.json`의 직접 dependency 이름과 버전, lockfile +전체 digest, `dist` checksum만 기록했다. 전이 dependency, 패키지별 integrity와 +license, 실제 baseline diff가 없었고 `highRiskUnreviewed: []`는 계산 결과가 아닌 +고정값이었다. secret scan도 `src`와 `dist`만 검사해 config, scripts, test와 +generated release metadata를 놓쳤다. + +반면 저장소에는 조직이 선택한 vulnerability source, severity exception 승인자, +signing identity와 attestation 저장소가 없다. 외부 provider가 없는 상태를 빈 +finding과 서명 성공으로 표현하면 local 검증과 release promotion을 혼동한다. + +## 결정 + +1. `pnpm-lock.yaml`의 모든 `packages` row와 `pnpm list --depth Infinity`의 실제 + graph를 결합해 직접/전이, production/development, required/platform-optional, + version, SHA-512 SRI, license와 dependency edge를 기록한다. +2. inventory row 수는 lockfile package row 수와 같아야 한다. 누락된 전이 + dependency, malformed integrity와 non-optional `NOASSERTION`은 local gate를 + 실패시킨다. +3. license는 설치된 package manifest에서 읽고 closed allow/deny policy로 + 검사한다. 현재 OS에 materialize되지 않은 platform optional만 + `NOASSERTION`과 그 이유를 명시적으로 허용한다. +4. 승인 dependency baseline과 approval digest를 보존하고 현재 lock inventory와 + actual add/remove/change/upgrade diff를 계산한다. 새 direct production + dependency는 owner와 서로 다른 reviewer, reason과 rollback evidence가 + 필요하다. +5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest, + SRI, license와 dependency edge가 inventory와 일치해야 한다. +6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과 + `dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 이 문서는 외부 + provenance를 대신할 수 없다. +7. `immutable_build`는 raw `pnpm-lock.yaml`, `dist`, build/module inventory와 + 모든 local verification evidence를 한 번만 archive한다. Candidate manifest는 + raw lock bytes SHA-256, dependency inventory lock digest와 manifest + `lockfileSha256`의 exact 일치를 요구한다. +8. 두 provider job은 동일 archive를 각각 받아 외부 command를 실행한다. + Vulnerability report는 raw lock digest와 `distSha256`, provenance attestation은 + `{name: "dist", digest.sha256}`를 포함한다. 두 문서 모두 strict schema와 + 별도 trust path/key ID로 선택한 실제 Ed25519 public key 서명을 통과해야 한다. +9. provider report나 trusted key가 없으면 local + inventory/license/SBOM/coherence는 `PASS`, promotion은 + `FAIL_UNVERIFIED`다. 저장소 generator나 fixture가 production용 빈 finding 또는 + signed PASS를 만들지 않는다. +10. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`와 + generated release metadata를 검사한다. allowlist는 test path에만 허용하며 + owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고 + rule, path, line과 fingerprint만 남긴다. +11. `SOURCE_DATE_EPOCH`를 지원하고 supply-chain timestamp도 build manifest의 + 동일 epoch에 결합한다. 같은 source/lock/config의 production build를 + 두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다. + +## 실행 경계와 증적 + +```text +package.json + frozen pnpm-lock.yaml + installed graph + -> deterministic dependency inventory + -> license policy + approved actual baseline diff + -> CycloneDX SBOM + +source/config/lock + production dist + -> local provenance statement + -> immutable archive + candidate manifest + distSha256 + -> external vulnerability provider + external provenance provider + -> read-only local revalidation + signature/digest verification + -> promotion PASS | FAIL_UNVERIFIED +``` + +- policy: `config/security/` +- generator: `scripts/generate-supply-chain.ts` +- coherence: `scripts/verify-supply-chain-artifacts.ts` +- secret scan: `scripts/security-scan.ts` +- reproducibility: `scripts/verify-reproducible-build.ts` +- inventory: `artifacts/release/dependency-inventory.json` +- SBOM/provenance: `artifacts/release/sbom.cdx.json`, + `artifacts/release/provenance.json` +- local/promotion status: + `artifacts/security/supply-chain-verification.json` + +## 검증 + +- 현재 lockfile의 561개 package row와 inventory row가 양방향 일치한다. +- ordering-only digest, removal, integrity tamper, baseline tamper, high-risk + self approval, denied license, critical vulnerability와 만료 exception, + provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다. +- isolated temporary candidate/PEM/report fixture는 실제 environment path wiring을 + 통해 valid immutable 입력만 promotion `PASS`임을 증명한다. Production artifact를 + 덮어쓰거나 generator를 provider 모드로 재실행하지 않는다. +- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다. +- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고 + scoped test allowlist만 통과한다. + +## 한계와 재검토 조건 + +로컬 manifest license는 법률 검토가 아니며 vulnerability report도 외부 scanner가 +제공한 데이터의 최신성 자체를 보증하지 않는다. 실제 프로젝트는 provider 버전, +database freshness, network outage, exception 승인 조직, signing identity, +attestation transparency/retention과 비밀 관리를 결정해야 한다. + +## Rollback + +외부 scanner/attestor command, report path 또는 trusted key 설정을 제거하면 즉시 +`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM, +secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로 +promotion을 PASS로 변경하지 않는다. diff --git a/docs/architecture/decisions/VD-10-optional-capability-recipes.md b/docs/architecture/decisions/VD-10-optional-capability-recipes.md new file mode 100644 index 0000000..23faee3 --- /dev/null +++ b/docs/architecture/decisions/VD-10-optional-capability-recipes.md @@ -0,0 +1,113 @@ +# VD-10: 선택형 frontend capability recipe + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-optional-adapter-recipes` +- 현재 선택 capability: 없음 +- 재검토: 실제 프로젝트가 realtime, offline, PWA, file, generated API, + feature flag, worker, multi-tab, browser permission, client workflow, + large-data UI 또는 production analytics/error provider를 요구할 때 + +## 배경 + +서버의 PostgreSQL, MongoDB, Redis, Kafka, MinIO 같은 기술을 브라우저가 직접 +소비하지는 않는다. 프론트의 변화 지점은 권한 있는 HTTP/BFF, push event, +offline persistence, file protocol, browser runtime, 사용자 동의와 UI 성능 +경계다. 이 capability를 “언젠가 필요할 수 있다”는 이유로 모두 설치하면 초기 +bundle, 공급망, runtime config, 보안 표면과 업데이트 비용만 늘어난다. + +반대로 문서에 이름만 적으면 실제 프로젝트에서 port 위치, cancellation, +fallback, fake와 제거 기준을 다시 설계해야 한다. 따라서 production runtime에 +아무것도 설치하지 않되 검증 가능한 vendor-neutral recipe를 저장소 밖이 아닌 +별도 opt-in 경계에 유지한다. + +## 결정 + +1. `config/recipes/frontend-capability-recipes.json`이 12개 recipe의 선택 기준, + 금지 조건, port/fake, failure matrix, lifecycle cleanup, owner, + security/privacy, gzip budget, fallback, server-state 정책과 제거 절차의 + machine-readable SSOT다. +2. 현재 실제 소비 요구와 project owner가 없으므로 12개 상태는 모두 + `RECIPE_AVAILABLE`이며 `INSTALLED`가 아니다. production runtime dependency와 + composition registration은 0개다. +3. `recipes/frontend-capabilities`의 TypeScript port와 fake/unavailable adapter는 + 실행 가능한 설계 예시다. `src` 또는 production entry가 이 디렉터리를 import할 + 수 없다. +4. 프로젝트가 capability를 선택하면 필요한 최소 contract를 + application-owned output port 또는 presentation facade로 이동하고, concrete + vendor adapter는 local adapter 경계에 둔다. recipe 디렉터리를 production에서 + 그대로 import하지 않는다. +5. WebSocket/SSE처럼 연결은 outbound이고 수신 event는 inbound인 양방향 기술도 + 한 종류의 “adapter”로 뭉개지 않는다. 연결·credential·reconnect 정책과 + event validation·input invocation을 분리한다. +6. Zustand/Redux Toolkit/state machine은 실제 cross-page client-only workflow가 + 확인된 경우 하나만 선택한다. URL, component state, Context, TanStack Query가 + 이미 소유한 상태를 복제하지 않는다. +7. browser credential은 localStorage, URL, recipe store, telemetry 또는 + BroadcastChannel에 넣지 않는다. 브라우저가 database/object store에 직접 + 접속하는 recipe도 금지한다. +8. lifecycle이 있는 capability는 unsubscribe, close, unregister, dispose, + cancel 또는 `AbortSignal`을 계약과 contract test에 포함해야 한다. +9. 선택하지 않은 recipe sentinel이나 reference runtime source, vendor + dependency가 production bundle에 들어가면 gate를 실패시킨다. + `referenceRuntime`이 있는 recipe는 catalog `sourceRoots` 전체를 별도의 + production-mode synthetic entry로 deterministic하게 bundle/minify하되 + tree-shaking을 끄고, 모든 출력의 gzip 합계가 recipe budget을 넘으면 + production composition 여부와 무관하게 실패시킨다. 2026-07-28 최초 실측에서 + `offline-indexeddb`가 32,930 bytes였으므로 측정 없이 선언됐던 8,000 bytes를 + 약 9% headroom의 36,000 bytes로 교정했으며 다른 budget은 자동 인상하지 + 않는다. +10. recipe 전체를 제거한 임시 worktree에서 base typecheck, architecture, + unit/component/integration test와 production build가 통과해야 한다. + +## 선택과 설치 절차 + +```text +measured product/runtime need + -> project owner + security/privacy classification + -> recipe trigger/forbidden/fallback review + -> VD-10 amendment with one selected capability + -> application port or presentation facade copied into src + -> one concrete adapter under local adapter boundary + -> composition-only wiring + -> contract/failure/cleanup/integration tests + -> bundle + dependency baseline approval + -> INSTALLED only after all evidence passes +``` + +도입 커밋에는 owner, 선택 이유, 대안, gzip 차이, runtime config, browser support, +failure UX, observability, rollback과 제거 명령을 기록한다. vendor가 필요한 +behavior를 fake만으로 확인하고 `INSTALLED`로 바꾸지 않는다. + +## 증적 + +- catalog: `config/recipes/frontend-capability-recipes.json` +- contracts/fakes: `recipes/frontend-capabilities` +- 상세 runbook: `docs/architecture/optional-adapter-recipes.md` +- file/IndexedDB/OPFS/Cache 심층 결정: + `docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md` +- browser data 상세 설계: + `docs/architecture/browser-file-and-origin-storage.md` +- realtime/Web Push/Polling 심층 설계와 결정: + `docs/architecture/realtime-events-web-push-and-bounded-polling.md`, + `docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md` +- contract test: `tests/recipes/optional-capability-contracts.test.ts` +- negative fixture: + `tests/fixtures/optional-recipes/forbidden` +- validation: + `scripts/check-optional-recipes.ts` +- removal: + `scripts/test-optional-recipe-removal.ts` +- evidence: + `artifacts/quality/optional-recipes.json`, + `artifacts/quality/optional-recipe-fixtures.json`, + `artifacts/tests/optional-recipes.xml`, + `artifacts/tests/optional-recipe-removal.xml` + +## Rollback + +현재 branch는 runtime dependency나 production composition을 바꾸지 않으므로 +recipe catalog, example과 gate를 함께 revert하면 RP-11 상태로 돌아간다. 실제 +프로젝트에서 선택한 capability는 그 capability의 port/adapter/composition/ +dependency commit만 revert한다. 여러 vendor 도입을 하나의 되돌릴 수 없는 +commit으로 묶지 않는다. diff --git a/docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md b/docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md new file mode 100644 index 0000000..6c5e8dc --- /dev/null +++ b/docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md @@ -0,0 +1,269 @@ +# VD-11: Browser file and origin-storage 경계 + +- 상태: Accepted — native reference runtime available, not composed +- 결정일: 2026-07-27 +- reference runtime 상태: `AVAILABLE_NOT_COMPOSED` +- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도) +- 관련 결정: VD-10 optional capability recipes, VD-14, VD-15 +- current status ledger: + `docs/architecture/browser-data-capability-completion-ledger.md` +- 재검토: 실제 제품이 file intake/delivery, durable offline data, large local + binary 또는 public offline HTTP representation을 선택할 때 + +## 배경 + +File, Blob, picker, IndexedDB, OPFS와 Cache Storage는 모두 browser data를 +다루지만 같은 storage abstraction이 아니다. + +- File/Blob은 transient byte container다. +- picker는 user activation과 permission UX를 소유한다. +- IndexedDB는 indexed structured record와 transaction을 제공한다. +- OPFS는 origin-private large byte storage지만 query와 cross-API transaction이 + 없다. +- Cache Storage는 HTTP Request/Response map이며 freshness를 자동 관리하지 않는다. + +이를 하나의 `StoragePort`나 `FileTransferPort`로 추상화하면 transaction complete, +blocked/versionchange, object URL 수명, stream backpressure, quota, OPFS partial +write, Cache의 인증 response 금지와 release activation이 사라진다. + +기존 recipe는 metadata-only upload와 in-memory `Uint8Array` download를 보여 주는 +얕은 예시였다. 큰 파일과 production recovery protocol의 출발점으로는 부족했다. +backend upload protocol을 browser file mechanism에 묶는 것 역시 선택하지 않은 +제품 capability를 암묵적으로 설치하므로 경계를 분리해야 한다. + +## 결정 + +1. 기존 동기식 `StoragePort`는 작은 public preference만 소유한다. IndexedDB, + OPFS, Cache Storage를 backend enum 하나로 끼우지 않는다. +2. native `File`, `Blob`, `FileList`, `FileSystemHandle`은 browser adapter의 + transient vault 안에 둔다. application은 opaque `LocalFileRef`, normalized + metadata와 bounded `readRange()`만 본다. +3. browser 파일 기능을 picker, file content, preview lease, download delivery로 + 분리한다. backend upload는 `BrowserFileComposition`의 구성요소가 아니며, + 별도 선택 가능한 `ExampleQuarantinedUploadPort` 예시로만 둔다. user + dismissal은 failure가 아닌 outcome이다. +4. backend upload를 선택한 경우 file validation, authorization, + malware/archive/active-content 검사와 quarantine은 client hint보다 항상 + authoritative하다. +5. 큰 file/download/object는 stream 또는 bounded part로 처리한다. 전체 + `Uint8Array`, Blob, base64/Data URL은 승인된 hard cap 안의 small artifact에만 + 사용한다. File/OPFS/Cache 및 recipe byte source는 + `AsyncIterable>`로 실패를 닫고 raw native + exception을 application으로 throw하지 않는다. +6. download outcome은 browser handoff와 confirmed saved를 분리한다. anchor click을 + disk write 완료로 기록하지 않는다. +7. IndexedDB는 feature-specific async repository adapter다. raw database, + transaction callback, store/index/schema version을 application에 노출하지 않는다. +8. DB DDL version과 record codec version을 분리한다. schema upgrade는 짧고 + additive하게, data migration은 resumable bounded batch로 수행한다. +9. IndexedDB mutation은 request success가 아니라 transaction complete 이후에만 + 성공이다. revision CAS와 idempotency key를 기본 계약으로 둔다. +10. 모든 connection은 versionchange/forced-close를 처리하고 blocked/future-schema + 상태를 read-only 또는 online-only UX로 드러낸다. 자동 reload loop와 자동 + database deletion을 금지한다. +11. OPFS는 큰 immutable bytes와 integrity manifest만 소유한다. logical metadata, + query, generation과 journal commit authority는 IndexedDB가 소유한다. +12. IndexedDB와 OPFS 사이의 비원자성은 + `PREPARING -> FILES_READY -> COMMITTED -> CLEANED` journal saga와 startup + reconciliation으로 처리한다. `COMMITTED`만 사용자에게 보인다. +13. OPFS sync access handle은 DedicatedWorker의 신규 staging/chunk file에만 + 사용하고 항상 flush/close한다. committed file in-place overwrite와 + `readwrite-unsafe`를 금지한다. +14. Cache Storage는 same-origin public GET representation 전용 platform-local + facade다. auth, cookie-dependent, private, personal, no-store, opaque, 206, + redirect response를 저장하지 않는다. +15. Cache match는 query/Vary를 보존하고 `ignoreSearch`/`ignoreVary`를 금지한다. + candidate 전체를 type/size/integrity 검증한 뒤에만 release를 활성화하며 + verified previous release를 rollback용으로 유지한다. +16. Service Worker lifecycle과 Cache Storage ownership을 구분한다. unregister가 + cache 삭제를 의미하지 않으므로 owned-prefix cleanup migration을 별도로 둔다. +17. IndexedDB, OPFS와 Cache Storage는 origin quota budget을 공유한다. + `estimate()`는 rough signal이고 실제 `QuotaExceededError`를 authority로 둔다. +18. credential 저장을 금지한다. same-origin client encryption을 XSS authorization + boundary로 간주하지 않는다. +19. fake는 계약 검증용이고 native production evidence를 대체하지 않는다. + Chromium/Firefox/WebKit, multi-page, crash/fault, migration/rollback과 quota + drill을 설치 capability의 promotion gate로 둔다. +20. 공통 native adapter는 정책 주입형 reference runtime으로 제공하되 현재 제품 + owner와 dataset이 없으므로 bootstrap, installed feature, Service Worker + registration과 runtime config에는 연결하지 않는다. catalog recipe + availability는 `RECIPE_AVAILABLE`, reference runtime primary status는 + `AVAILABLE_NOT_COMPOSED`이며 product selection은 별도다. +21. API lifecycle, transaction, bounded-memory, integrity와 recovery mechanism은 + 공통 adapter가 소유한다. schema/codec/query, authority, classification, + retention, quota priority와 cache/file allowlist는 dataset/use-case 정책으로 + 주입한다. +22. composition은 dataset별 opaque scope와 전체 storage policy를 검증해 깊은 + snapshot/freeze한다. 공통 runtime을 여러 dataset의 전역 mega-repository로 + 구성하지 않는다. +23. IndexedDB physical DB명은 registry-issued + `authorityToken/namespaceToken/partitionToken`에서만 파생한다. readable + namespace/business/account ID는 이름에 쓰지 않는다. immutable scope + full + policy binding을 upgrade transaction, post-open과 maintenance에서 검증하고 + mismatch 또는 기존 DB의 missing binding은 fail-closed한다. +24. IndexedDB는 codec `measureStoredBytes`, retention sidecar, dataset + `usedBytes/receiptCount` budget을 mutation과 같은 transaction에서 갱신한다. + TTL은 sweep 전에도 read/query에서 보이지 않으며 `UNTIL_SYNCED`는 confirmed + record만 삭제 가능하다. lifecycle deletion은 composition authority의 opaque + short-lived proof가 매 invocation 필요하고 proof는 검증 후 폐기한다. +25. idempotency receipt retention은 최대 31일, receipt configured cap의 구현 절대 + 상한은 1,000,000개다. codec migration은 old-writer drain proof와 revision + fence가 필요하고 한 invocation은 최대 500 rows/30,000ms다. +26. OPFS physical layout은 + `/ca-frontend-opfs-v1/authorities////...`이며 + 세 path segment는 opaque token이다. IndexedDB journal은 logical namespace와 + physical scope를 양방향 binding하고 full policy fingerprint를 검증한다. +27. Cache manifest는 정규화된 `expectedContentType`까지 digest에 binding한다. + response의 정규화된 Content-Type이 정확히 일치하지 않으면 candidate activation을 + 금지한다. +28. optional 상태는 metadata만으로 주장하지 않는다. real-browser JUnit verifier, + Vite source-module inventory, source boundary gate와 runtime removal gate를 + promotion evidence로 둔다. +29. File selection/inspection/preview/download dataset policy는 composition-time + registry가 소유한다. port caller는 정확히 등록된 `FilePolicyReference` 객체와 + limit reduction만 전달하며 같은 key/intention 문자열로 reference를 재구성해 + 다른 profile을 선택할 수 없다. verification receipt는 exact profile과 file + snapshot에 binding한다. +30. `BROWSER_MANAGED_RESOURCE` download는 resource와 함께 서버 발급 capability + receipt를 요구한다. synchronous resolver의 결과가 receipt/resource/media + type/safe extension/server max/optional digest/expiry를 정확히 binding하지 + 않으면 handoff하지 않는다. strategy와 integrity mode를 caller가 선택하지 + 않는다. +31. IndexedDB actual `StoredRecord`는 + `key/codecVersion/revision/payload`만 가지며 write time, synchronization, + measured bytes와 eligibility는 retention sidecar에 분리한다. idempotency + receipt와 governance binding/budget도 별도 store에 두고, full partition + purge에는 등록된 모든 `lifecycleMetadataStores`를 포함하되 immutable + governance identity는 유지한다. Cache cleanup retain set은 caller가 cache + name이나 release registry ID로 제출하지 않고 verified active pointer와 + composition retention에서 계산한다. control JSON은 정확히 2 MiB + (2,097,152 bytes) bounded stream으로만 decode한다. +32. OPFS의 `LOGOUT`, `UNTIL_SYNCED`, `ACCOUNT_DELETION` policy maintenance는 + composition이 `requestMaintenanceAuthority` provider와 + `consumeMaintenanceAuthority` consumer를 모두 공급해야 한다. provider는 + exact reason/frozen scope/frozen policy에 묶인 최대 5분 proof를 매번 새로 + 발급하고, consumer는 같은 binding과 expiry를 확인해 원자적으로 consume하여 + replay를 막는다. application caller는 proof를 전달할 수 없고 runtime은 이를 + 저장·반환·관측하지 않는다. +33. origin-wide pressure/write admission/GC, OPFS·Cache forward migration, + OPFS real preflight, bounded Cache maintenance와 preview decode safety의 + 후속 계약은 VD-15가 소유한다. 기존 store별 primitive를 그 coordinator의 + 구현 증거로 사용하지 않는다. +34. Service Worker lifecycle, directory/persistent handle과 private/sparse Range + cache는 제품 선택 전 `NOT_SELECTED`인 별도 capability다. Range resumable + download는 VD-14의 `DESIGNED_NOT_IMPLEMENTED` capability이며 public Cache + runtime에 섞지 않는다. + +## 계약과 증적 + +- 심층 계약: + `recipes/frontend-capabilities/browser-file-storage-contracts.ts` +- deterministic fake: + `recipes/frontend-capabilities/browser-file-storage-fakes.ts` +- contract test: + `tests/recipes/browser-file-storage-contracts.test.ts` +- selection SSOT: + `config/recipes/frontend-capability-recipes.json` +- 상세 설계: + `docs/architecture/browser-file-and-origin-storage.md` +- lifecycle/migration 결정: + `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md` +- 운영 복구: + `docs/operations/browser-file-storage-recovery.md` +- native reference runtime: + `src/adapters/browser-files/`, `src/adapters/storage/indexeddb/`, + `src/adapters/storage/opfs/`, `src/adapters/cache-storage/` +- real-browser conformance: + `tests/browser-capabilities/` +- browser evidence verifier: + `scripts/verify-browser-capability-evidence.ts` +- production module inventory: + `artifacts/quality/vite-module-inventory.json` +- boundary/removal evidence: + `check:browser-file-storage-boundaries`, + `test:browser-file-storage-removal` + +recipe의 durable byte source도 단일 `Uint8Array` 또는 raw-throw stream 대신 +chunk별 `CapabilityResult`를 반환한다. backend upload example은 +`ExampleBackendUploadComposition`으로 browser file composition과 분리되어 있다. +실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend +protocol에 맞게 contract를 더 좁힌다. + +현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2, +IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2, +presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다. +promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두 +실행해야 한다. WebKit은 현재 +host의 필수 native libraries(예: +`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, +`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는 +Chromium/Firefox 14개씩 총 28개만 통과했으므로 +`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine +evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다. + +## 선택 이후 필요한 구현 + +```text +dataset + owner + classification + backend protocol + -> VD-11 amendment + -> feature-specific application ports + -> adapter-private schema/codec/migrations + -> native picker/file/download/IDB/OPFS/cache adapter 중 필요한 것만 + -> unavailable/read-only/online-only fallback + -> deterministic fault + real browser contract tests + -> diagnostics allowlist + recovery runbook drill + -> canary + N-1 rollback evidence + -> project catalog에서만 INSTALLED +``` + +OPFS를 쓴다는 이유로 Service Worker를 설치하거나, Cache Storage를 쓴다는 이유로 +IndexedDB business repository를 만들지 않는다. 실제 capability 조합만 설치한다. + +## 결과 + +장점: + +- native API와 clean architecture 경계가 명확하다. +- 대용량 memory blow-up과 거짓 download-complete 신호를 막는다. +- IndexedDB migration/transaction과 OPFS crash recovery가 검증 가능하다. +- auth/private cache poisoning을 fail-closed한다. +- 기술별 fallback, kill switch와 제거 범위가 독립적이다. + +비용: + +- 하나의 generic adapter보다 port와 contract test 수가 많다. +- native adapter 설치 시 worker, historical schema fixture, multi-page test와 + 운영 drill이 필요하다. +- offline user-authored data는 browser storage만으로 backup을 보장할 수 없어 + server sync 또는 export 제품 결정이 필요하다. + +이 비용은 browser persistence의 실제 일관성·수명 차이를 숨기지 않기 위한 +의도적인 비용이다. + +## Rollback + +현재는 native reference runtime source가 있지만 production composition은 없다. +catalog의 세 runtime은 `AVAILABLE_NOT_COMPOSED` / +`productionComposition: false`이고 build module inventory에 runtime source가 +없어야 한다. `check:optional-recipes`가 이를 강제한다. +완전 철회하려면 `src/application/ports/browser-file-storage`, +`src/adapters/browser-files`, `src/adapters/browser-file-storage`, +`src/adapters/storage/indexeddb`, `src/adapters/storage/opfs`, +`src/adapters/cache-storage`와 전용 test를 제거하고 catalog의 +`referenceRuntime` metadata를 삭제한다. +`test:browser-file-storage-removal`은 이 상태에서 base typecheck, architecture, +test, build와 optional catalog가 유지되는지 검증한다. + +제품에 composition한 이후 rollback은 다음 순서를 따른다. + +1. 신규 write, worker activation과 cache candidate를 중지한다. 별도 upload + workflow를 설치했다면 그 session도 독립적으로 중지한다. +2. file ref/object URL/handle/connection/channel을 정리한다. +3. offline read-write를 read-only 또는 online-only로 전환한다. +4. N-1 bundle이 future schema를 destructive open 없이 감지하는지 확인한다. +5. user-authored/unsynced data는 export/sync 확인 없이 purge하지 않는다. +6. owned OPFS/cache namespace만 journal/manifest 기준으로 정리한다. +7. adapter composition, runtime config와 dependency를 제거한다. + +schema downgrade, blanket `deleteDatabase()`, `caches.keys()` 전체 삭제와 사용자 +filename 기반 OPFS 삭제는 rollback 수단으로 금지한다. diff --git a/docs/architecture/decisions/VD-12-presigned-transfer-and-image-cdn.md b/docs/architecture/decisions/VD-12-presigned-transfer-and-image-cdn.md new file mode 100644 index 0000000..51819e4 --- /dev/null +++ b/docs/architecture/decisions/VD-12-presigned-transfer-and-image-cdn.md @@ -0,0 +1,202 @@ +# VD-12: Presigned transfer, resumable upload와 Image CDN 경계 + +- 상태: Accepted — reference runtime available, not composed +- 결정일: 2026-07-28 +- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도) +- reference runtime 상태: `AVAILABLE_NOT_COMPOSED` +- 관련 결정: VD-10, VD-11, VD-14, VD-16 +- current status ledger: + `docs/architecture/browser-data-capability-completion-ledger.md` +- 재검토: 제품이 server file upload/download 또는 Image CDN delivery를 선택할 때 + +## 배경 + +Presigned URL은 URL 문자열이 아니라 짧은 수명의 bearer capability다. +multipart/resumable upload는 단순 PUT 반복이 아니라 session, part identity, +checksum, authoritative reconciliation, completion과 orphan cleanup protocol이다. +streaming download는 전체 payload를 메모리에 올리지 않지만 response binding, +truncation/overrun, destination commit과 integrity를 별도로 처리해야 한다. +Image CDN URL도 arbitrary transform builder로 노출하면 cache poisoning, pixel/decode +bomb, signed-query 유출과 source-fetch SSRF 경계가 사라진다. + +이 네 capability를 범용 `HttpClient`나 `FileService` mega-port 하나로 합치면 +control plane authorization과 byte data plane, local browser lifecycle과 server +authority가 섞인다. + +## 결정 + +1. BFF/Web API control plane과 object-storage/CDN data plane을 분리한다. +2. 브라우저는 signing key, cloud 관리자 credential, bucket/container, raw object + key 생성 규칙을 소유하지 않는다. +3. application caller는 raw URL/query/signed headers를 전달하지 않는다. + composition/provider가 발급한 exact capability만 adapter가 소비한다. +4. presigned capability는 version, opaque identity, method, logical resource 또는 + session/part, exact URL, origin/path policy, byte/media/checksum 조건과 expiry를 + immutable하게 binding한다. +5. signed URL은 bearer credential로 취급하고 persistence, checkpoint, telemetry, + analytics, referrer와 raw exception에 넣지 않는다. +6. data-plane fetch는 기본적으로 `credentials: omit`, `redirect: error`, + `referrerPolicy: no-referrer`, `cache: no-store`를 사용한다. cross-origin은 + composition allowlist와 CORS/CSP 계약이 있을 때만 연다. +7. client-side single-use 표시는 UX와 accidental replay를 줄이는 보조책이다. + cross-tab/replay의 최종 authority는 server 또는 composition-owned atomic + consumer다. +8. streaming download는 response body를 closed-result stream으로 변환하고 + output chunk, total bytes, media type, encoding과 선택적 incremental integrity를 + 검증한다. overrun/truncation/abort 시 native reader와 destination을 닫는다. +9. `BROWSER_HANDOFF`와 destination close 이후의 `SAVED`를 계속 분리한다. +10. Range resumable download는 별도 capability다. `206`, `Content-Range`, + validator, destination seek/truncate와 final integrity 없이는 append resume를 + 허용하지 않는다. +11. upload 상위 계약은 server-authoritative session/status/part/complete/abort를 + 소유하고 data-plane part executor는 capability 타입에 generic하다. 따라서 + S3-style presigned multipart와 BFF proxy part를 같은 application contract + 뒤에 둘 수 있지만 wire DTO를 공유하지 않는다. +12. reference upload protocol literal은 `PRESIGNED_MULTIPART_V1`이다. 모든 + control-plane request/response, session과 checkpoint가 이를 exact하게 + 포함하며 다른 값이나 누락을 거절한다. +13. session은 exact source binding, total bytes, media type, part size/count, + concurrency, checksum algorithm과 expiry를 묶는다. part number는 1부터 + 연속적이고 offset/length/checksum/idempotency를 정확히 binding한다. +14. control transport는 `CREATE_SESSION`, `GET_STATUS`, `COMPLETE`, `ABORT`의 + closed operation을 composition-owned fixed HTTPS endpoint map으로만 + 실행한다. presigned 발급도 factory에 고정된 단일 BFF endpoint를 사용하며 + caller-provided URL을 받지 않는다. +15. `requestBindingSha256`와 `uploadBindingSha256`는 + `RESUMABLE-UPLOAD-BINDING-V1` 및 + `RESUMABLE-UPLOAD-SESSION-BINDING-V1` canonical field sequence의 SHA-256이다. + `UPLOAD_PART` capability binding은 exact + `protocol: PRESIGNED_MULTIPART_V1`을 포함한다. BFF는 `sessionId`로 server + session을 조회하고 snapshot으로 protocol, binding과 part plan을 재계산한다. + client digest는 authorization이나 ownership 증명이 아니다. +16. part memory는 `partSize × concurrency × copyFactor` hard ceiling으로 제한한다. + retry는 같은 bytes/checksum/idempotency에만 허용한다. +17. retryable network, 429와 모든 5xx는 bounded attempt/`Retry-After`/abortable + backoff 안에서만 재시도한다. status의 404/410 또는 + `NOT_FOUND`/`EXPIRED`는 terminal로 보고 checkpoint를 CAS 제거한다. +18. PUT 성공은 capability-bound status, receipt header, + `expectedResponseByteLength`와 exact `Content-Length`를 검증하고 hard cap과 + deadline 안에서 response body를 끝까지 drain한 뒤에만 확정한다. 204는 + expected response bytes가 0일 때만 허용하며 `Content-Length` 부재를 0으로 + 정규화한다. +19. resume는 local checkpoint만 신뢰하지 않는다. server status를 다시 읽고 + 완료 part의 local range digest와 server checksum/receipt를 대조한 뒤 missing + part만 전송한다. +20. checkpoint에는 opaque session/source binding과 reconciliation에 필요한 + protocol-defined SHA-256 file fingerprint, per-part checksum, bounded opaque + non-authorizing part receipt token만 저장한다. 이 값도 account partition과 + retention을 적용하고 diagnostics/telemetry에는 내보내지 않는다. presigned + URL, signed header, bearer token/capability, file name, path, account ID, + raw provider ETag와 raw server error는 금지한다. +21. cancel과 server abort를 분리한다. same-origin 다른 tab의 active upload는 + strict `RESUMABLE_UPLOAD_CANCEL_V1` BroadcastChannel 신호로 먼저 중단한 뒤 + per-key Web Lock 안에서 server abort/reconcile을 수행한다. 이 ephemeral + 신호는 opaque upload key만 운반하고 persistence하지 않으며 authority가 + 아니다. channel이 없으면 abort caller의 bounded signal 아래 lock을 기다린다. + complete/abort가 불명확하면 server reconcile 전까지 성공으로 기록하거나 + checkpoint를 파기하지 않는다. +22. multipart complete는 ordered receipt 검증 뒤에도 `QUARANTINED`다. backend + scan/CDR/promotion이 끝나기 전 available/public URL을 발급하지 않는다. + application-facing 성공값은 state/resource/byte length/replay 여부만 노출하고 + session ID, request binding과 fingerprint를 제거한다. +23. Image CDN application contract는 opaque asset reference와 + composition-registered named preset만 받는다. arbitrary source URL과 raw + transform query는 금지한다. +24. asset descriptor는 immutable revision, delivery class, safe raster media, + natural dimensions, rendition dimensions/formats/URLs와 private expiry를 묶는다. +25. CDN policy는 allowed HTTPS origin/path, preset width/DPR/format/quality/fit, + output pixel/decoded-byte/encoded-byte/candidate/lifetime ceiling과 + cache/referrer policy를 소유한다. + composition limit은 exported adapter implementation ceiling을 초과할 수 + 없고 capability verification concurrency도 절대 상한 아래에서 제한한다. + CDN origin은 composition이 명시한 application origin과 달라야 한다. + ``가 same-origin 요청에서는 cookie를 보낼 수 + 있기 때문에 private URL의 credential omission을 probe에만 맡기지 않는다. +26. private capability signature가 허용하는 값은 versioned preset binding ID다. + CDN/BFF는 그 ID를 server-owned immutable preset registry에서 조회하고, + 요청의 width/height/DPR/fit/format/quality가 그 preset의 exact candidate인지 + 재계산해 하나라도 다르면 거절한다. signed URL에 붙은 raw transform query나 + client 계산값은 authorization proof가 아니다. + signing key policy는 bounded unique `acceptedKeyIds` overlap set이고 verifier + registry가 모든 ID를 포함해야 한다. descriptor의 단일 key ID는 양쪽 + registry에 exact membership이 있어야 한다. +27. browser probe는 native decode 전에 PNG/JPEG/WebP/AVIF header metadata와 + static-only container를 검사한다. 선언 dimensions, pixels와 decoded-byte + budget을 넘거나 APNG/WebP animation, AVIF sequence/derived image, + ambiguous/malformed container이면 decode 전에 거절한다. +28. private signed delivery는 `PRIMARY_REQUIRED` probe를 강제하고 + `credentials: omit`, exact response URL과 실제 `Cache-Control: no-store`를 + 검증한다. fetch/body/decode 전체에 하나의 timeout을 적용하고 abort/late + completion에서 reader와 bitmap을 닫는다. +29. SVG/HTML/data/blob/javascript와 unknown active media는 기본 거절한다. + animation은 frame/decode budget이 승인된 별도 protocol 전에는 허용하지 않는다. +30. responsive candidate는 한 source set에서 하나의 descriptor 종류만 사용하고, + 고유한 양수 width를 오름차순으로 반환한다. `sizes`는 registry-owned layout + token에서 결정한다. +31. public rendition은 immutable revision URL과 public immutable cache를 사용하고, + private rendition은 short-lived capability와 필수 no-store를 + 사용한다. 같은 URL의 content를 purge로 바꿔치기하지 않는다. +32. Image CDN runtime `close()`는 terminal/idempotent다. runtime lifetime + signal로 진행 중 verification/probe를 중단하고 accepted WeakMap을 새 + WeakMap으로 교체해 기존 reference를 즉시 revoke한다. 닫힌 runtime은 + 재개하지 않고 새 composition으로 교체한다. +33. 공통 runtime은 concrete browser mechanism과 policy validation을 제공하지만 + backend endpoint/vendor schema와 제품 asset/upload owner가 없으므로 bootstrap에 + 조합하지 않는다. +34. runtime source는 production module inventory와 removal gate로 기본 bundle에서 + 제외됨을 증명한다. +35. Range resume의 detailed state machine과 app-managed background의 플랫폼 + 경계는 VD-14가 소유한다. VD-12의 whole-object streaming 구현을 그 + capability의 구현 증거로 사용하지 않는다. +36. top-level transfer runtime, account-scoped teardown, upload pause/inventory, + Image descriptor HTTP provider/refresh와 safe presentation projection은 + VD-16이 소유한다. 개별 runtime factory의 존재를 operational composition + 완료로 해석하지 않는다. + +## Backend와 맞출 계약 + +- fixed BFF capability endpoint, closed session endpoint map과 runtime schema +- `PRESIGNED_MULTIPART_V1` canonical binding, server-side session lookup, + authorization/revocation +- object storage CORS, allowed method/headers, exposed receipt/checksum headers +- PUT 성공 status, receipt header, response byte length/body cap +- session expiry, 404/410 terminal 의미, list/status pagination, idempotency와 + orphan cleanup +- part/full-object checksum의 정확한 알고리즘·composite 의미 +- quarantine scan, promotion, status와 reject/delete lifecycle +- CDN source registry, immutable asset revision, versioned named preset의 exact + candidate 재계산과 query mismatch 거절 +- image signing key overlap 배포, signer 전환, capability/client drain과 + emergency revocation/forced rollout runbook +- CDN `Content-Type`, static header metadata, dimensions/decoded-byte budget, + private `no-store`, application과 분리된 CDN origin, cache key, `Vary`, CORS와 CSP + +브라우저의 local file reference, native `File`/`Blob`, IndexedDB checkpoint physical +schema, OPFS path, signed URL query와 cloud object key는 backend 공유 계약이 아니다. + +## 선택하지 않은 대안 + +- application caller가 arbitrary presigned URL을 직접 전달 +- browser bundle에서 cloud signing +- 범용 JSON `HttpClient`로 binary streaming/part protocol까지 처리 +- complete 응답을 scan 완료 또는 public availability로 간주 +- local checkpoint만 보고 upload complete +- ETag를 무조건 MD5/SHA-256으로 해석 +- private signed image URL을 query cache나 persistence에 장기 저장 +- raw transform query로 CDN URL 조립 +- large download의 unbounded Blob fallback + +## 증적 + +- application ports: `src/application/ports/browser-transfer/` +- concrete adapters: `src/adapters/browser-transfer/` +- unit/fault tests: `tests/unit/` +- real browser cases: `tests/browser-capabilities/` +- 상세 설계: + `docs/architecture/presigned-transfer-and-image-cdn.md` +- Range/background 결정: + `docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md` +- composition/Image provider 결정: + `docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md` +- 운영 복구: + `docs/operations/browser-transfer-recovery.md` diff --git a/docs/architecture/decisions/VD-13-client-cache-scope-and-persistence.md b/docs/architecture/decisions/VD-13-client-cache-scope-and-persistence.md new file mode 100644 index 0000000..571fbd9 --- /dev/null +++ b/docs/architecture/decisions/VD-13-client-cache-scope-and-persistence.md @@ -0,0 +1,880 @@ +# VD-13: Client cache scope, persistence와 탭 간 일관성 경계 + +- 상태: Accepted — staged implementation required +- 결정일: 2026-07-28 +- 관련 결정: VD-10, VD-11 +- 상세 설계: + `docs/architecture/client-cache-and-storage.md` +- current status ledger: + `docs/architecture/browser-data-capability-completion-ledger.md` +- 재검토: + account/tenant switching, query persistence, SSR 또는 offline mutation을 + 제품 capability로 선택할 때 + +## 1. 배경 + +TanStack Query memory cache, Web Storage, IndexedDB와 BroadcastChannel은 모두 +client state에 관여하지만 같은 authority, 수명과 commit point를 갖지 않는다. + +- TanStack Query memory cache는 현재 JavaScript runtime의 server-state projection다. +- `localStorage`와 `sessionStorage`는 작은 preference/control record를 위한 + 동기식 browser storage다. +- IndexedDB는 transaction, index와 durable structured record를 제공한다. +- BroadcastChannel과 `storage` event는 같은 storage partition 안의 best-effort + notification이다. +- SSR dehydration과 browser persistence hydration은 서로 다른 source에서 생성된 + cache projection을 합치는 별도 protocol이다. + +현재 skeleton은 memory QueryClient, 두 개의 등록 Web Storage key와 +invalidate-only cross-tab runtime을 production bootstrap에 조립한다. domain-neutral +IndexedDB runtime은 source와 native contract test가 있지만 product dataset 없이 +bootstrap에서 제외돼 있다. IndexedDB query persister, durable namespace epoch, +session/account-scoped QueryClient lifecycle과 SSR hydration은 아직 구현되지 +않았다. + +이 차이를 숨긴 채 “client cache가 구현됐다”고 표현하면 다음 문제가 생긴다. + +- logout 뒤 old account의 cache나 늦은 async result가 새 account 화면에 나타남 +- best-effort invalidation event를 authorization 또는 server commit으로 오인함 +- 여러 tab의 full cache snapshot이 서로 오래된 record를 다시 살림 +- browser persistence가 최신 SSR payload를 덮음 +- Web Storage memory fallback 성공과 durable write 성공을 구분하지 못함 +- query cache를 offline command repository처럼 사용해 unsynced user data를 + eviction으로 잃음 + +## 2. 표준 capability 상태 + +이 결정과 상세 설계는 다음 상태만 사용한다. + +| 상태 | 의미 | +| --- | --- | +| `COMPOSED` | 구현·계약·test가 있고 production bootstrap이 실제 생성·소비한다. | +| `AVAILABLE_NOT_COMPOSED` | reusable runtime과 test가 있지만 production bootstrap에서 생성하지 않는다. | +| `DESIGNED_NOT_IMPLEMENTED` | 경계와 invariant는 승인됐지만 실행 코드가 없다. | +| `NOT_SELECTED` | 제품 요구·owner·policy가 승인되지 않아 설치 대상이 아니다. | +| `PLATFORM_LIMITED` | browser/platform이 요구 의미를 cross-browser로 보장하지 못한다. | + +`AVAILABLE_NOT_COMPOSED`와 `NOT_SELECTED`는 같은 말이 아니다. 전자는 reusable +runtime의 구현 상태고, 후자는 제품 capability 선택 상태다. 하나의 capability에 +두 축이 필요하면 “reference runtime”과 “product selection”을 별도 행으로 쓴다. + +### 2.1 현재 상태 + +| capability | 현재 상태 | 현재 보장 | +| --- | --- | --- | +| TanStack Query memory runtime | `COMPOSED` | runtime별 QueryClient, finite inactive GC, retry owner, query AbortSignal | +| registered Web Storage | `COMPOSED` | `COLOR_SCHEME`, `CHUNK_RELOAD_GUARD`만 strict codec/envelope로 사용 | +| invalidate-only cross-tab runtime | `COMPOSED` | versioned topic, BroadcastChannel 우선, localStorage pulse fallback | +| generic IndexedDB repository/maintenance runtime | `AVAILABLE_NOT_COMPOSED` | CAS, idempotency, transaction complete, policy binding, bounded lifecycle/migration | +| session/account-scoped QueryClient lifecycle | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 cache epoch는 release ID만 포함 | +| strict query policy/key codec | `DESIGNED_NOT_IMPLEMENTED` | 현재 object key order canonicalization만 존재 | +| IndexedDB query persistence facade | `DESIGNED_NOT_IMPLEMENTED` | persistence는 registry에서 강제로 disabled | +| durable namespace invalidation ledger | `DESIGNED_NOT_IMPLEMENTED` | 없음 | +| product query persistence | `NOT_SELECTED` | persist 대상 query/owner가 없음 | +| SSR dehydration/hydration | `NOT_SELECTED` | 현재 runtime은 client SPA composition | +| exactly-once cross-tab delivery | `PLATFORM_LIMITED` | BroadcastChannel/storage event는 acknowledgement를 제공하지 않음 | +| browser storage non-eviction guarantee | `PLATFORM_LIMITED` | persist 요청도 user-agent eviction을 절대 금지하지 않음 | + +## 3. 결정 + +### 3.1 하나의 cache/storage abstraction으로 합치지 않는다 + +다음 경계를 유지한다. + +```text +server response + -> feature application result + -> query inbound adapter + -> scope-owned TanStack QueryClient + +small approved preference/control value + -> registered Web Storage facade + -> exact localStorage/sessionStorage key + +optional reconstructable query projection + -> query persistence facade + -> query-specific stable wire codec + -> governance-bound IndexedDB runtime + +committed mutation + -> local namespace invalidation + -> optional durable namespace epoch commit + -> best-effort cross-tab hint +``` + +QueryClient, native `Storage`, `IDBDatabase`, BroadcastChannel, dehydrated TanStack +types와 physical key/store/index 이름을 application/domain에 노출하지 않는다. + +### 3.2 source of truth와 authority + +1. 일반 server state와 authorization의 source of truth는 서버다. +2. memory cache와 persisted query record는 재구성 가능한 projection이다. +3. cache hit, persisted restore와 invalidation event는 authorization proof가 아니다. +4. 모든 protected network request는 현재 session credential과 server + authorization을 다시 통과한다. +5. remote invalidation event는 `invalidate`만 요청할 수 있다. `remove`, `clear`, + logout, account deletion과 credential revocation authority를 갖지 않는다. +6. unsynced command, local-first draft와 user-authored offline data는 query + persistence에 저장하지 않는다. feature-specific IndexedDB repository와 + sync use case가 소유한다. + +## 4. session/account/release scope + +### 4.1 immutable scope snapshot + +composition의 session authority는 다음 의미를 갖는 immutable snapshot을 발급한다. +구현 type과 field name은 이 의미를 보존해야 한다. + +```ts +type CacheScopeSnapshot = Readonly<{ + protocolVersion: 1; + authorityToken: string; + partitionToken: string; + sessionEpoch: string; + accountEpoch: string; + releaseEpoch: string; + generation: number; +}>; +``` + +- 모든 token은 registry/session authority가 발급한 충분한 entropy의 opaque + identifier다. +- email, account/tenant/user ID, domain ID, access token과 낮은 entropy identifier의 + 단순 hash를 사용하지 않는다. +- `generation`은 현재 page runtime에서 단조 증가하는 local lifecycle fence다. + backend entity revision이나 wire ordering으로 사용하지 않는다. +- `sessionEpoch`는 sign-in, re-auth, credential owner 교체 때 바뀐다. +- `accountEpoch`는 account/tenant switch, logout, account deletion 때 바뀐다. +- `releaseEpoch`는 query-key, mapper, codec 또는 persistence wire compatibility가 + 깨질 때 바뀐다. +- scope object와 nested policy는 construction 때 copy/freeze한다. async operation은 + 시작 시 exact snapshot과 generation을 캡처한다. + +### 4.2 profile별 scope projection + +모든 query가 account token을 key에 넣지는 않는다. registry가 분류에 따라 다음을 +고정한다. + +| scope | binding | +| --- | --- | +| `ORIGIN_SHARED` | release epoch와 origin-shared token | +| `ACCOUNT_BOUND` | partition token, account epoch, release epoch | +| `SESSION_BOUND` | partition token, account epoch, session epoch, release epoch | + +- `PUBLIC`만 `ORIGIN_SHARED`를 사용할 수 있다. +- `INTERNAL`은 제품 authority가 origin-shared public semantics를 증명하지 않는 한 + `ACCOUNT_BOUND` 이상이다. +- `PERSONAL`은 `ACCOUNT_BOUND` 이상이고 persistence에는 explicit approval, + bounded retention과 logout purge가 필요하다. +- `CONFIDENTIAL`은 query persistence가 금지되고 필요한 순간의 memory + `SESSION_BOUND`만 허용한다. +- credential은 memory query data, persistence, query key와 invalidation wire + 모두에서 금지한다. + +### 4.3 composite cache epoch + +cross-tab `cacheEpoch`는 raw token을 연결한 문자열이 아니라 선택된 scope projection과 +protocol major의 opaque compatibility fingerprint다. receiver는 exact equality만 +검사하고 원래 account/session/release 의미 값을 wire에서 복원하지 않는다. + +현재 `release.`만 사용하는 값은 transitional implementation이다. +account-dependent query를 production에 설치하기 전에 composite scope fingerprint로 +교체한다. + +## 5. QueryClient lifecycle와 late-result fence + +### 5.1 runtime state + +scope-owned query runtime은 다음 terminal lifecycle을 갖는다. + +```text +CREATING + -> ACTIVE + -> FENCING + -> DISPOSING + -> DISPOSED +``` + +- `ACTIVE`만 신규 query/mutation/cache update를 admission한다. +- scope transition이 시작되면 먼저 `FENCING`으로 바꾸고 generation을 올린다. +- `DISPOSING`에서 old query를 cancel하고 provider/controller를 detach한 뒤 + QueryClient를 clear한다. +- old cross-tab channel, persistence writer/connection, timer와 listener를 닫는다. +- exact old Web Storage key/IndexedDB partition purge는 policy와 authority를 + 통과한 bounded lifecycle operation으로 수행한다. +- 새 scope는 새 QueryClient와 새 coordinator를 만든다. old client를 재사용해 + key prefix만 바꾸지 않는다. +- dispose와 scope transition은 idempotent하다. + +### 5.2 query fence + +query execution은 TanStack의 AbortSignal과 scope generation을 모두 캡처한다. + +1. 시작 전 runtime이 `ACTIVE`인지 확인한다. +2. application request에 AbortSignal을 전달한다. +3. 완료 시 captured generation과 current generation을 비교한다. +4. mismatch면 성공/실패 모두 새 cache/UI에 적용하지 않고 `STALE_RESULT`로 + 폐기한다. +5. query cancellation 실패가 scope clear를 막지 않게 하되 safe diagnostic을 + 남긴다. + +### 5.3 mutation fence + +frontend abort는 이미 서버에 도달한 mutation을 되돌리지 않는다. + +- 시작 전 admission과 generation을 확인한다. +- server commit 전 cancellation은 transport의 idempotency/cancellation 계약을 + 따른다. +- server 결과가 old generation에서 돌아오면 새 cache에 optimistic result, + invalidation 또는 success UI를 적용하지 않는다. +- server side effect의 authoritative 결과는 새 scope에서 정상 revalidation한다. +- mutation success 후 local invalidation 실패나 hint publish 실패가 이미 committed + server mutation을 실패로 바꾸지 않는다. +- conflict resolution은 backend revision/ETag/idempotency 계약과 feature policy가 + 소유한다. query cache는 business merge authority가 아니다. + +### 5.4 session owner 연결 + +production bootstrap은 auth/session owner subscription을 query lifecycle에 +연결한다. 단순 `authenticated` boolean만으로 account identity를 추론하지 않는다. +owner는 opaque scope snapshot 또는 이를 발급할 authority를 제공해야 한다. + +다른 tab의 logout은 cache invalidation event에 의존하지 않는다. 각 tab의 auth +owner가 credential/session 변화를 독립적으로 감지하고 local lifecycle을 +실행해야 한다. + +## 6. strict query scope/persistence registry와 key codec + +### 6.1 registry + +모든 installed query namespace는 immutable scope/persistence profile을 갖는다. +freshness, GC, refetch, retry, result budget, pagination과 conditional policy의 +유일한 source of truth는 VD-25 `ServerStateProfile`이다. + +```ts +type QueryScopePersistencePolicy = Readonly<{ + policyId: string; + namespace: readonly [string, number]; + keySchemaVersion: number; + classification: "PUBLIC" | "INTERNAL" | "PERSONAL" | "CONFIDENTIAL"; + scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND"; + persistence: + | Readonly<{ kind: "MEMORY_ONLY" }> + | Readonly<{ + kind: "INDEXEDDB"; + profileId: string; + maxAgeMs: number; + maxEntryBytes: number; + }>; + crossTab: "NONE" | "INVALIDATE"; + invalidationTopics: readonly Readonly<{ + topicId: string; + topicVersion: number; + }>[]; +}>; +``` + +construction은 최소 다음을 검증한다. + +- namespace/topic/policy ID가 closed syntax와 unique version을 가짐 +- persistence가 classification, scope, max age와 맞음 +- `NONE`은 topic 0개, `INVALIDATE`는 namespace당 unique topic 1..8개 +- `(topicId, topicVersion)` 하나는 최대 32개 namespace에 fan-out하며 global + topic→namespace set과 namespace→topic set이 서로 exact inverse +- profile과 nested allowlist를 deep snapshot/freeze함 +- VD-25 query definition/`ServerStateProfile`과 join했을 때 owner, + classification, scope, namespace, persistence와 invalidation topic set/version이 + 일치함 + +composition은 `QueryDefinition -> QueryScopePersistencePolicy -> +ServerStateProfile`을 exact ID로 join한 뒤에만 TanStack option을 만든다. global +QueryClient default는 안전 baseline일 뿐이고 installed query의 정책 증거가 +아니다. + +### 6.2 query key wire subset + +query key factory의 canonical input은 다음만 허용한다. + +- `null`, boolean, finite number, bounded string +- 위 값의 dense array +- own enumerable data property만 가진 plain/null-prototype object + +다음을 fail-closed로 거절한다. + +- cycle/shared exotic graph +- `undefined`, `BigInt`, symbol, function, accessor +- `NaN`, infinity, negative zero를 구분하지 않는 암묵 변환 +- Date, RegExp, Map, Set, class/DOM/native object +- File, Blob, ArrayBuffer와 typed array +- sparse array +- `__proto__`, `prototype`, `constructor` key +- 허용 depth/node/part/string/serialized-byte ceiling 초과 + +구현 절대 상한: + +| 항목 | 상한 | +| --- | ---: | +| installed query profile | 256 | +| query key top-level part | 16 | +| canonical value depth | 8 | +| canonical value node | 256 | +| 단일 string UTF-8 | 1,024 bytes | +| 전체 canonical key UTF-8 | 4,096 bytes | + +제품 profile은 더 낮출 수 있지만 이 상한을 높이려면 ADR amendment와 +memory/telemetry cardinality evidence가 필요하다. + +normative key layout: + +```text +[ + "query", + keySchemaVersion, + scopeFingerprint, + namespaceName, + namespaceVersion, + queryDefinitionVersion, + canonicalSemanticInput +] +``` + +VD-25는 이 배열을 재정의하지 않고 마지막 두 field의 의미와 pagination +projection만 소유한다. query function이 의존하는 모든 non-secret input을 +포함하되 URL 전체, bearer +token, email, filename, human-readable personal label을 넣지 않는다. domain entity +identity가 필요하면 backend/product contract가 발급한 opaque ID와 bounded codec을 +사용한다. + +### 6.3 memory pressure + +`gcTime`은 inactive retention이지 active cache hard cap이 아니다. + +- gateway/mapper가 response count/byte ceiling을 검증한다. +- binary, native object와 unbounded collection을 query cache에 넣지 않는다. +- cache entry/active/inactive와 estimated payload를 safe bucket으로 관측한다. +- hard eviction controller는 joined VD-25 profile별 정책으로만 설치한다. +- memory pressure를 이유로 active personal data를 arbitrary global timer로 + 삭제하지 않는다. scope lifecycle의 remove/clear와 일반 eviction을 구분한다. + +## 7. Web Storage contract + +### 7.1 registered key policy + +Web Storage는 registered small value 전용이다. + +```ts +type WebStorageDefinition = Readonly<{ + logicalName: string; + backend: "localStorage" | "sessionStorage"; + scope: "ORIGIN_SHARED" | "OPAQUE_PARTITION" | "TAB"; + classification: "PUBLIC_PREFERENCE" | "OPAQUE_CONTROL"; + schemaVersion: number; + maxSerializedBytes: number; + retention: + | Readonly<{ kind: "SESSION" }> + | Readonly<{ kind: "TTL"; maxAgeMs: number }> + | Readonly<{ kind: "EXPLICIT_DELETE" }>; + valueCodec: string; + migration: + | Readonly<{ kind: "DISCARD" }> + | Readonly<{ kind: "ADJACENT"; migrationId: string }>; + quotaFallback: "MEMORY" | "NO_PERSIST" | "FEATURE_DISABLE"; + logoutAction: "KEEP" | "PURGE_PARTITION"; +}>; +``` + +구현 절대 상한: + +| 항목 | 상한 | +| --- | ---: | +| registered persistent key | 64 | +| key별 serialized value | 16,384 bytes | +| 한 sweep에서 검사할 key | 16 | +| 한 read에서 migration step | 2 | + +현재 두 key는 각각 더 좁은 codec을 유지한다. `COLOR_SCHEME`은 public +origin-shared preference이고 `CHUNK_RELOAD_GUARD`는 tab session control이다. +server response, credential, signed URL, File/Blob, large draft와 queue를 Web +Storage에 넣지 않는다. + +### 7.2 physical identity와 envelope + +physical key는 application/environment, scope kind, opaque partition 또는 tab +instance, logical key, schema version에서 결정적으로 파생한다. account/user ID를 +포함하거나 origin 전체 key를 열거하지 않는다. + +partition-aware 새 envelope는 기존 v1 세 필드의 의미를 변경하지 않고 새 +envelope version으로 도입한다. + +```ts +type BrowserStorageEnvelopeV2 = Readonly<{ + envelopeVersion: 2; + schemaVersion: number; + scopeFingerprint: string; + writtenAtEpochMs: number; + expiresAtEpochMs: number | null; + value: unknown; +}>; +``` + +- exact field set, schema, scope, codec, written/expiry time 순으로 검증한다. +- TTL expiry는 write time과 registry max age에서 계산하며 caller가 직접 주지 않는다. +- 비정상적으로 먼 expiry, future write time과 clock skew는 fail-closed miss다. +- corrupt/expired/future/wrong-scope record는 exact key만 best-effort 제거한다. +- cleanup 실패는 validated miss를 raw exception으로 바꾸지 않는다. +- memory overlay도 exact envelope와 TTL/scope validation을 공유한다. + +### 7.3 read/write outcome + +stored `undefined`와 miss를 암묵적으로 합치지 않는다. + +```ts +type WebStorageReadResult = + | Readonly<{ ok: true; state: "HIT"; value: Value; durability: "PERSISTED" | "MEMORY_ONLY" }> + | Readonly<{ ok: true; state: "MISS" }> + | Readonly<{ ok: false; error: ClientStorageFailure }>; + +type WebStorageWriteResult = + | Readonly<{ ok: true; durability: "PERSISTED" }> + | Readonly<{ ok: true; durability: "MEMORY_ONLY"; degraded: true }> + | Readonly<{ ok: false; error: ClientStorageFailure }>; +``` + +memory fallback이 current runtime에서 승인된 성공이면 `ok: true`와 +`MEMORY_ONLY`를 반환한다. durable write가 필수인 key는 fallback을 성공으로 +가장하지 않는다. + +### 7.4 migration, quota와 sweep + +- migration은 registry에 등록된 deterministic adjacent version만 실행한다. +- migration callback은 network/native storage/telemetry side effect 없이 bounded + pure codec으로 동작한다. +- future version과 unsupported old version은 `DISCARD` policy에서 miss다. +- `QuotaExceededError`이면 reconstructable exact key cleanup 뒤 동일 idempotent + write를 최대 한 번 재시도한다. +- origin 전체 `clear()`와 arbitrary LRU key enumeration을 금지한다. +- TTL은 visibility rule이므로 boot/idle/focus 중 registry-owned bounded sweep을 + 별도로 수행한다. +- logout/account switch는 exact partition key만 purge한다. public origin-shared + preference를 지우지 않는다. +- `sessionStorage` opener snapshot을 authority로 사용하지 않는다. tab-local + control에는 새 tab instance와 `noopener` policy를 적용한다. + +## 8. optional IndexedDB query persistence + +### 8.1 selection + +query persistence reference facade의 현재 상태는 +`DESIGNED_NOT_IMPLEMENTED`, product selection은 `NOT_SELECTED`다. 단순 warm-start +기대만으로 자동 설치하지 않는다. + +다음 조건을 모두 충족한 query만 등록한다. + +- server-authoritative이며 재구성 가능함 +- stable query-key와 payload codec이 있음 +- classification/scope/retention owner 승인 +- entry/dataset/restore byte와 count budget이 있음 +- logout/account deletion/release busting이 정의됨 +- measured offline/warm-start 가치가 있음 +- three-engine native contract와 rollback evidence가 있음 + +### 8.2 stable record, raw TanStack snapshot 금지 + +full QueryClient snapshot이나 library-private object를 그대로 저장하지 않는다. + +```ts +type PersistedQueryRecord = Readonly<{ + recordVersion: 1; + queryHash: string; + encodedQueryKey: unknown; + policyId: string; + scopeFingerprint: string; + releaseEpoch: string; + namespaceEpoch: number; + dataUpdatedAtEpochMs: number; + persistedAtEpochMs: number; + expiresAtEpochMs: number; + payloadCodecVersion: number; + payload: unknown; + measuredBytes: number; + revision: number; +}>; +``` + +- approved successful query data만 저장한다. +- error, pending state, mutation, function, Promise, AbortSignal, native/binary + object, credential와 capability를 저장하지 않는다. +- query key와 payload를 각각 strict codec으로 검증한다. +- generic IndexedDB runtime의 opaque scope/policy binding, transaction complete, + CAS, byte budget, migration, lifecycle와 failure mapping을 재사용한다. + +### 8.3 구현 상한 + +reference facade의 기본 절대 상한: + +| 항목 | 상한 | +| --- | ---: | +| persisted query record | 1,024 | +| 단일 encoded entry | 512 KiB | +| query persistence dataset | 32 MiB | +| 한 restore record | 256 | +| 한 restore decoded bytes | 8 MiB | +| boot restore deadline | 2,000 ms | +| max age | 7 days | +| write debounce | 250–2,000 ms | + +제품 policy는 더 낮출 수 있다. 상한 확대는 memory/quota/startup-latency evidence와 +ADR amendment가 필요하다. + +### 8.4 durable namespace epoch + +full snapshot last-write-wins를 금지한다. 기본 writer model은 shared per-query +record + monotonic namespace epoch다. + +```ts +type DurableCacheLedger = Readonly<{ + ledgerVersion: 1; + scopeFingerprint: string; + releaseEpoch: string; + namespaces: Readonly>; + revision: number; +}>; +``` + +- mutation invalidation은 namespace epoch를 같은 IndexedDB transaction에서 + 증가시킨 뒤 cross-tab hint를 publish한다. +- persisted record의 namespace epoch가 ledger보다 작으면 hydrate하지 않는다. +- record write는 current ledger epoch와 revision을 CAS 검증한다. +- BroadcastChannel sequence나 wall clock을 global durable ordering으로 사용하지 + 않는다. +- localStorage read-modify-write counter와 best-effort leader election을 correctness + fence로 쓰지 않는다. +- ledger commit 뒤 hint를 publish한다. hint가 먼저 나가면 receiver가 commit 전 + record를 읽을 수 있다. + +### 8.5 restore와 hydration order + +1. bounded deadline으로 IndexedDB를 연다. +2. immutable dataset/scope/release binding을 검증한다. +3. ledger와 record schema/codec/TTL/byte cap을 검증한다. +4. approved profile과 current namespace epoch만 decode한다. +5. current memory/SSR state와 precedence를 적용한다. +6. hydrate 뒤 normal stale/refetch policy를 실행한다. + +wrong scope, expired, busted와 corrupt reconstructable record는 cache miss로 +격하하고 exact bounded cleanup한다. persistence unavailable/blocked/timeout은 +제품이 optional로 선택했다면 memory+network `ONLINE_ONLY`로 fail open한다. +offline-required workflow를 query persistence로 가장하지 않는다. + +### 8.6 writer lifecycle + +- cache events는 bounded debounce/coalescing한다. +- writer 하나에서 concurrent save를 serialize하고 superseded write를 버린다. +- `pagehide`/`beforeunload` transaction 완료를 보장으로 간주하지 않는다. +- 정상 runtime 중 주기적으로 commit하고 unload flush는 보조 수단이다. +- dispose는 timer를 취소하고 connection/listener를 닫는다. +- 아직 transaction complete가 아닌 write를 persisted success로 기록하지 않는다. + +## 9. cross-tab invalidation + +### 9.1 authority + +cross-tab wire는 payload/query-key-free invalidate hint만 전달한다. + +- query state/data replication 금지 +- authorization/logout/server commit 증명 금지 +- distributed lock/leader election 금지 +- exactly-once/ordered delivery 주장 금지 +- offline command 전송 금지 + +remote hint는 registry topic을 local namespace로 해석해 active query를 +invalidate/refetch한다. inactive query는 다음 mount/focus/freshness 정책에서 +revalidate한다. remote hint는 `removeQueries`, `clear()` 또는 session transition을 +직접 실행하지 않는다. + +### 9.2 transport와 source validation + +```text +BroadcastChannel + -> construction/post failure + -> registered localStorage pulse + storage event + -> failure/unavailable + -> DEGRADED_LOCAL_ONLY + normal stale/focus/reconnect +``` + +- current 2,048-byte exact wire envelope와 bounded TTL/dedupe/source tracking을 + 유지한다. +- topic registry 수에도 query profile과 같은 256개 절대 상한을 적용한다. +- localStorage fallback key를 Web Storage control registry에 등록한다. +- receiver는 exact key, exact `storageArea === localStorage`, exact composite + cache epoch와 event codec을 검증한다. +- `sessionStorage`를 cross-tab fallback으로 사용하지 않는다. +- publisher는 local invalidation을 직접 수행한다. +- publish success는 receiver acknowledgement가 아니다. +- BroadcastChannel과 storage 양쪽 delivery는 event ID로 dedupe한다. +- per-source sequence gap은 global order 증명이 아니라 “hint를 잃었을 수 있음”을 + 나타낸다. + +### 9.3 lost hint + +query persistence가 꺼져 있으면 finite stale time, focus/reconnect와 manual refresh가 +eventual revalidation을 제공한다. persistence가 켜져 있으면 visibility/focus와 +sequence gap에서 durable namespace ledger를 bounded refresh한다. + +즉시 global consistency가 업무 invariant라면 browser bus만으로 충족하지 않는다. +backend revision/ETag, server push stream 또는 feature sync protocol을 추가한다. + +## 10. SSR 선택 경계 + +현재 SSR product capability는 `NOT_SELECTED`다. browser-only code가 있다는 이유로 +SSR support가 구현됐다고 주장하지 않는다. + +SSR을 선택하면 별도 implementation gate에서 다음을 모두 구현한다. + +1. HTTP request마다 새 QueryClient를 생성하고 response 뒤 폐기한다. +2. server process에서 Web Storage, IndexedDB와 BroadcastChannel에 접근하지 않는다. +3. approved successful query만 dehydrate한다. +4. serialized state를 HTML context에 안전하게 escape하고 byte/count cap을 적용한다. +5. browser의 최신 SSR payload가 old persisted projection보다 우선한다. +6. persisted state merge는 missing approved query만 복원하거나 explicit server + revision을 비교한다. +7. browser storage read 때문에 initial server/client markup이 달라지지 않게 + hydration-safe bootstrap 단계에서 restore한다. +8. request A의 QueryClient/data/scope가 request B에 공유되지 않는 test를 둔다. + +SSR support와 IndexedDB query persistence는 서로 독립 선택이다. + +## 11. privacy와 encryption + +- credential, token, signed URL, authorization header, password와 crypto key는 + memory query key/data, Web Storage, query persistence와 invalidation wire에 + 넣지 않는다. +- logical/physical key, query key/hash input, payload, account/user ID, URL과 native + exception message/stack을 telemetry에 보내지 않는다. +- 같은 origin JavaScript가 ciphertext와 key를 모두 읽을 수 있는 client-side + encryption은 XSS authorization boundary가 아니다. +- external/non-extractable key lifecycle과 compliance requirement가 있는 제품은 + encryption을 defense-in-depth로 별도 선택할 수 있지만, 금지 classification을 + 허용하는 근거가 되지 않는다. +- logout purge는 confidentiality의 유일한 방어가 아니다. wrong-scope binding은 + crash로 old bytes가 남아도 새 runtime이 읽지 못하게 해야 한다. + +## 12. failure와 observability + +failure는 최소 operation, closed code, retry owner, effect certainty와 fallback을 +표현한다. + +- `ABORTED`와 `DEADLINE_EXCEEDED`를 구분한다. +- IndexedDB transaction `complete`만 `APPLIED`다. +- Broadcast publish success의 remote effect는 `UNKNOWN`이다. +- memory fallback과 persisted success를 구분한다. +- optional persistence failure는 `ONLINE_ONLY`로 degrade할 수 있다. +- scope mismatch/corruption/future version은 raw record를 반환하지 않는다. +- diagnostics failure가 query, storage, lifecycle와 cleanup을 실패시키지 않는다. + +safe metric: + +- memory active/inactive/estimated-byte bucket +- Web Storage hit/miss/degraded/quota bucket +- persistence restore success/miss/busted/corrupt/deadline bucket +- scope reset duration/cleanup-incomplete +- invalidation publish/receive/drop/duplicate/gap/coalesced bucket +- listener/channel/connection leak count + +## 13. implementation gate + +### Gate 0 — 상태와 문서 + +- 이 ADR과 상세 설계가 current/target 상태를 분리한다. +- capability catalog, runbook과 test evidence의 상태가 같은 taxonomy를 사용한다. +- 구현되지 않은 target type을 current API처럼 문서화하지 않는다. + +### Gate 1 — strict registry와 codec + +- query policy registry와 query key closed codec 구현 +- profile/key absolute ceiling 구현 +- Web Storage per-key cap, HIT/MISS/durability result 구현 +- current v1 key의 discard/upgrade 전략 확정 +- hostile/cyclic/oversize/property-accessor test 통과 + +이 gate는 scope lifecycle을 자동 활성화하지 않는다. + +### Gate 2 — scope-owned QueryClient lifecycle + +- session authority scope snapshot contract 구현 +- auth owner subscription과 local generation fence 구현 +- old query cancel/provider detach/client clear/dispose 구현 +- late query/mutation result 폐기 구현 +- account switch/logout exact partition cleanup 구현 +- two-account and lost-event tests 통과 + +account-dependent query promotion은 이 gate 전 금지한다. + +### Gate 3 — cross-tab scope hardening + +- composite cache epoch 구현 +- registered localStorage pulse와 storageArea 검증 구현 +- browser production coordinator E2E와 bfcache/StrictMode leak test +- Chromium/Firefox/WebKit 동일 case evidence + +### Gate 4 — optional query persistence reference runtime + +- stable query record codec와 IndexedDB facade 구현 +- durable namespace ledger/CAS/commit-before-hint 구현 +- bounded restore/write/dispose 구현 +- wrong-scope/TTL/release/migration/quota/blocked test +- production bootstrap import와 DB open이 없는 module-inventory/removal gate + +완료 뒤에도 product selection은 `NOT_SELECTED`이고 reference 상태만 +`AVAILABLE_NOT_COMPOSED`로 바뀐다. + +### Gate 5 — product composition + +- measured requirement와 owner 승인 +- exact query profile/persistence allowlist/retention/budget 등록 +- account/logout/backend conflict contract 승인 +- disabled → canary → enabled traffic admission +- rollback, cleanup-only release와 operational drill + +### Gate 6 — SSR 또는 offline workflow + +각 capability를 별도 선택하고 별도 gate를 통과한다. + +- SSR: request isolation, safe dehydration, precedence와 hydration test +- offline mutation: feature repository, server idempotency/revision/sync protocol, + conflict/export/recovery UX + +query persistence gate 통과가 SSR/offline workflow 통과를 의미하지 않는다. + +## 14. test와 promotion evidence + +### 14.1 deterministic + +- independent QueryClient per runtime/scope +- session/account/release transition과 late result +- query key hostile value/ceiling/canonical equality +- Web Storage HIT/MISS/durability, TTL, migration, quota, cleanup, partition +- IndexedDB transaction complete, CAS, ledger epoch와 concurrent writer +- hint commit ordering, duplicate/self/stale/gap/coalescing +- diagnostics redaction와 dispose leak 0 + +### 14.2 real browser + +Chromium, Firefox와 WebKit에서 같은 case set을 실행한다. + +- native BroadcastChannel two-page delivery +- localStorage fallback과 exact storageArea +- account switch 중 in-flight query +- event loss 뒤 local auth lifecycle +- IndexedDB concurrent writer/blocked/versionchange/restore deadline +- bfcache/pagehide/StrictMode listener·connection cleanup +- sessionStorage tab/opener semantics +- N-1 release reader와 incompatible buster + +현재 native transport spec이 존재해도 production QueryClient lifecycle 전체와 +세 engine promotion artifact가 없으면 Gate 3 완료로 보지 않는다. + +### 14.3 promotion artifact + +artifact는 engine/browser version/OS image/build/release ID/contract suite +version/pass/fail/skip/실행 시각을 보존한다. fake/jsdom 통과를 native provider +통과로 보고하지 않는다. WebKit system dependency 부족은 capability skip이 아니라 +promotion evidence 미충족이다. + +## 15. rollout과 rollback + +### 15.1 rollout + +1. strict registry/codec을 기존 behavior 뒤 shadow validation으로 배포한다. +2. scope lifecycle을 single-account environment에서 먼저 관측한다. +3. account switch/logout fault test 뒤 account-dependent query를 허용한다. +4. optional persistence는 source와 test만 추가하고 production composition은 + 계속 끈다. +5. product selection 뒤 read-only restore/shadow write를 먼저 검증한다. +6. 작은 cohort에서 write/restore/quota/blocked/rollback drill을 수행한다. +7. error budget과 N-1 compatibility를 확인한 뒤 확대한다. + +### 15.2 kill switch + +서로 독립적으로 끌 수 있어야 한다. + +- persistence restore off +- persistence write off +- cross-tab publish off +- cross-tab receive off +- offline mutation admission off + +memory QueryClient와 정상 server fetch는 유지한다. account/session local lifecycle은 +security boundary이므로 best-effort invalidation kill switch와 함께 끄지 않는다. + +### 15.3 rollback + +1. 신규 persistence write/restore admission을 중지한다. +2. writer/timer/channel/listener/DB connection을 dispose한다. +3. scope fence와 memory QueryClient clear는 유지한다. +4. rollback bundle이 future record/schema를 miss/online-only로 처리하게 한다. +5. cleanup-only compatible release에서 exact owned partition을 bounded purge한다. +6. retention/rollback window 뒤 registry/adapter/dependency를 제거한다. + +schema version을 내리거나 origin 전체 `localStorage.clear()`/ +`indexedDB.deleteDatabase()`를 자동 실행하지 않는다. unsynced user-authored data는 +export/sync 확인 없이 query-cache cleanup으로 삭제하지 않는다. + +## 16. 완료 기준 + +- [ ] session/account/release scope가 QueryClient, key와 event에 binding된다. +- [ ] scope transition은 admission fence, cancel, detach, clear, dispose와 새 + QueryClient 생성으로 완료된다. +- [ ] old generation query/mutation result가 새 scope UI/cache를 변경하지 않는다. +- [ ] strict query scope/persistence registry와 closed key codec이 모든 absolute + ceiling을 강제하고 VD-25 profile과 exact join된다. +- [ ] Web Storage가 per-key cap, HIT/MISS, durability, partition, logout, + migration과 bounded sweep을 구현한다. +- [ ] localStorage invalidation fallback이 registered key와 exact storageArea를 + 검증한다. +- [ ] Chromium/Firefox/WebKit production coordinator/account lifecycle evidence가 + 있다. +- [ ] optional query persistence reference facade는 stable record와 durable + namespace ledger를 사용하고 production 미선택 시 zero side effect다. +- [ ] persisted mutation/error/native object/credential이 없음을 negative test가 + 증명한다. +- [ ] SSR을 선택한 경우 request isolation과 hydration precedence가 증명된다. +- [ ] offline mutation을 선택한 경우 backend idempotency/revision/sync와 + conflict/recovery UX가 별도 계약으로 증명된다. +- [ ] rollout/kill-switch/rollback/removal artifact가 보존된다. + +현재 이 체크리스트는 완료 선언이 아니라 implementation gate다. 각 행을 실제 +source, deterministic test, native evidence와 composition inventory로 증명하기 +전에는 완료로 바꾸지 않는다. + +## 17. 선택하지 않은 대안 + +- module singleton QueryClient +- account switch에서 query key prefix만 교체 +- BroadcastChannel logout event를 lifecycle authority로 사용 +- arbitrary query key와 raw TanStack cache snapshot persistence +- localStorage full-cache snapshot 또는 monotonic counter +- browser persistence를 offline command queue로 사용 +- same-origin client encryption을 credential authorization boundary로 사용 +- origin 전체 storage clear를 quota/logout/rollback 복구로 사용 +- fake browser test만으로 production promotion + +## 18. 결과 + +장점: + +- account/session boundary와 best-effort invalidation의 권한 차이가 명확하다. +- persistence를 선택하지 않은 제품에는 DB open/listener/bundle side effect가 없다. +- query key, Web Storage와 IndexedDB의 migration/retention을 독립적으로 검증한다. +- old tab/snapshot이 invalidated data를 되살리는 경로를 durable epoch로 닫는다. +- SSR, offline workflow와 query warm-start를 서로 독립 선택할 수 있다. + +비용: + +- scope authority와 QueryClient remount lifecycle이 필요하다. +- registry/codec/historical fixture와 multi-page browser test가 늘어난다. +- optional persistence를 설치하는 제품은 IndexedDB migration, quota와 cleanup + runbook을 운영해야 한다. + +이 비용은 cache hit, durable restore, cross-tab hint와 server truth를 하나의 +“cached” 상태로 잘못 합치지 않기 위한 의도적인 비용이다. diff --git a/docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md b/docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md new file mode 100644 index 0000000..342c546 --- /dev/null +++ b/docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md @@ -0,0 +1,898 @@ +# VD-14: Resumable download와 background download 경계 + +- 상태: Accepted — production design complete, implementation pending +- 결정일: 2026-07-28 +- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도) +- Range resumable download primary status: `DESIGNED_NOT_IMPLEMENTED` +- app-managed background download primary status: `NOT_SELECTED` +- cross-browser app-managed background download guarantee: `PLATFORM_LIMITED` +- 관련 결정: VD-10, VD-11, VD-12 +- current status ledger: + `docs/architecture/browser-data-capability-completion-ledger.md` +- 재검토: 제품이 Range 재개, 탭 종료 뒤 전달 또는 대용량 Safari fallback을 + 선택할 때 + +## 1. 배경과 현재 사실 + +현재 reference runtime은 whole-object `200` response를 bounded stream으로 읽어 +foreground destination에 저장하거나 browser download manager에 handoff한다. 이 +경로는 전체 payload를 하나의 `Blob`으로 만들지 않고 byte length와 SHA-256을 +검증하지만, 네트워크나 탭이 중단되면 다음 실행은 byte 0부터 다시 시작한다. + +Range resume는 기존 stream에 `Range` header 하나를 추가하는 기능이 아니다. +representation identity, exact `206 Content-Range`, durable partial destination, +checkpoint CAS, `200/412/416` reconciliation과 마지막 whole-object integrity가 +하나의 protocol이어야 한다. background download도 Range resume와 동일하지 않다. +브라우저 download manager에 넘기는 것과 애플리케이션이 Service Worker에서 +전송을 계속 관리하는 것은 완료 증거와 상호운용성이 전혀 다르다. + +이 ADR은 목표 계약을 정의한다. 이 문서가 존재한다는 사실은 runtime, endpoint, +worker 또는 제품 UX가 구현·조합되었다는 뜻이 아니다. + +## 2. 표준 capability 상태 + +설계, source 존재, 제품 조합과 플랫폼 한계를 하나의 `enabled` boolean으로 합치지 +않는다. primary current status는 다음 다섯 값 중 정확히 하나다. 이 taxonomy는 +선형 maturity model이 아니며 상태 이름만으로 rollout 또는 production readiness를 +추론하지 않는다. + +| primary status | 의미 | +| --- | --- | +| `NOT_SELECTED` | 제품 요구, owner, policy 또는 구현 범위가 아직 선택되지 않음 | +| `DESIGNED_NOT_IMPLEMENTED` | versioned contract와 불변조건은 승인됐지만 reference source가 없음 | +| `AVAILABLE_NOT_COMPOSED` | 검증 가능한 reference source가 있지만 제품 bootstrap/endpoint에는 연결되지 않음 | +| `COMPOSED` | 특정 제품 facade, config와 dependency에 실제로 조합됨 | +| `PLATFORM_LIMITED` | 요구 semantics를 target browser/platform 전체에서 보장할 수 없음 | + +production readiness와 traffic admission은 primary status와 독립된 축이다. +운영 상태는 completion ledger가 정의한 네 canonical 축만 사용한다. + +```text +Selection = + NOT_SELECTED | SELECTED | REMOVING +TrafficAdmission = + DISABLED | SHADOW | CANARY | ENABLED +RuntimeHealth = + UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE +PromotionEvidence = + MISSING | PARTIAL | COMPLETE | EXPIRED +``` + +아래 `source evidence`는 ADR과 reference source의 존재를 설명하는 문서 표기일 +뿐 canonical readiness 축이 아니다. `DESIGN_REVIEWED`는 native browser나 +provider 증거가 아니고, `REFERENCE_TESTED`도 ledger의 browser component를 +`PROMOTABLE` 또는 `PromotionEvidence=COMPLETE`로 만들지 않는다. + +현재 capability별 판정: + +| capability | primary status | source evidence | 비고 | +| --- | --- | --- | --- | +| whole-object foreground streaming | `AVAILABLE_NOT_COMPOSED` | `REFERENCE_TESTED` | 기존 VD-12 범위 | +| browser-managed handoff mechanism | `AVAILABLE_NOT_COMPOSED` | `REFERENCE_TESTED` | 실제 capability issuer는 제품 연결 시 필요 | +| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | `DESIGN_REVIEWED` | 이 ADR의 구현 대상 | +| app-managed background download | `NOT_SELECTED` | `DESIGN_REVIEWED` | 제품 요구가 선택될 때만 별도 구현 | +| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | `DESIGN_REVIEWED` | 공통 baseline으로 promotion 불가 | + +기존 foreground stream과 browser-managed handoff의 source/evidence를 Range나 +app-managed background download 구현 증거로 재사용하지 않는다. + +## 3. 결정 요약 + +1. whole-object foreground streaming, Range resumable download, + browser-managed handoff와 app-managed background download를 서로 다른 + capability와 결과 타입으로 유지한다. +2. Range protocol literal은 `RANGE_RESUMABLE_DOWNLOAD_V1`로 고정한다. 기존 + whole-object presigned contract에 암묵적으로 섞지 않는다. +3. resume의 authority는 server-owned immutable generation과 strong validator다. + local offset, file name, timestamp 또는 partial byte 존재는 authority가 아니다. +4. 각 data-plane capability는 exact representation, start/end range, method, + response status/header/length와 expiry를 묶고 한 번만 사용한다. +5. checkpoint는 비권한성 recovery metadata만 account-partitioned storage에 + 보관한다. URL, signed query/header, raw ETag, bearer token과 file path는 + 저장하지 않는다. +6. destination은 seek/truncate 가능한 명시적 port 또는 owned OPFS staging이다. + 순차 writable에 검증되지 않은 partial bytes를 append하지 않는다. +7. checkpoint offset은 destination segment가 durable하게 commit되고 exact length가 + 재확인된 뒤에만 CAS로 전진한다. +8. final success는 destination 전체를 처음부터 다시 읽어 whole-object SHA-256을 + 검증하고 final commit을 마친 경우만 `SAVED_VERIFIED`다. +9. browser-managed handoff는 탭 종료 뒤 계속될 수 있는 기본 server-file + fallback이지만 결과는 계속 `BROWSER_HANDOFF`다. +10. app-managed background download는 cross-browser baseline이 아니다. 별도 + optional protocol, platform probe, worker control plane과 owned staging이 모두 + 승인된 환경에서만 progressive enhancement로 조합한다. +11. browser 차이는 user-agent 문자열이 아니라 capability probe와 정책으로 + 결정한다. + +## 4. Topology와 책임 + +```text +product download use case + -> product-owned download facade + -> DownloadStrategySelector + -> WHOLE_OBJECT_PICKER_STREAM + -> RANGE_RESUMABLE_FOREGROUND + -> BROWSER_MANAGED_HANDOFF + -> BOUNDED_OBJECT_URL + -> APP_MANAGED_BACKGROUND_DOWNLOAD (optional) + +RANGE_RESUMABLE_FOREGROUND + -> BFF control plane + authorization + immutable representation lookup + range capability issuance/reissue + -> browser RangeDownloadRuntime + checkpoint + mutation lock + exact HTTP state machine + seekable destination or OPFS staging + whole-object verification + -> object store/BFF byte plane + +APP_MANAGED_BACKGROUND_DOWNLOAD + -> window-owned admission and user intent + -> worker-specific control plane + -> owned OPFS staging + -> later foreground export +``` + +브라우저는 bucket, object key, provider generation locator, signing key 또는 cloud +credential을 소유하지 않는다. BFF가 logical resource를 exact immutable +representation에 binding한다. direct object-store Range가 해당 binding과 +capability의 `preconditionMode`가 선택한 exact `If-Range` 또는 +immutable-generation precondition을 실제로 강제하지 못하면 BFF proxy/relay를 +사용한다. + +## 5. Versioned Range capability + +### 5.1 Application-visible handle + +application에는 raw URL이나 validator를 노출하지 않는다. + +```text +RangeDownloadCapability + protocol = RANGE_RESUMABLE_DOWNLOAD_V1 + opaque identity + safe receipt + resourceId + representationBindingSha256 + totalByteLength + mediaType + wholeObjectSha256 + requestedStart + requestedEndExclusive + preconditionMode = STRONG_IF_RANGE | IMMUTABLE_GENERATION_PRECONDITION + allowWholeObjectFallback + expiresAtEpochMs +``` + +adapter-owned identity vault에는 다음 data-plane binding을 함께 둔다. + +```text +exact HTTPS URL/query +exact GET method +exact origin/path +exact Range header +exact precondition header/value selected by preconditionMode +required response headers +allowed statuses = policy-derived exact subset of 200 | 206 | 412 | 416 +expected representation binding +maximum response bytes +single-use receipt +``` + +`representationBindingSha256`는 protocol/version, logical resource, immutable +generation, precondition mode별 normalized strong validator 또는 generation +binding, exact total length, media type와 expected whole-object digest의 canonical +binding이다. 이것은 authorization proof가 아니다. BFF는 client 값을 echo하지 +않고 registry snapshot에서 직접 재계산한다. + +### 5.2 Strong validator + +resume에는 다음 중 하나가 필요하다. + +- server registry가 소유하는 immutable object generation과 그 generation에 pin된 + proxy/direct request +- RFC semantics를 만족하는 strong ETag와 exact `If-Range` + +weak ETag(`W/`), `Last-Modified`만 있는 representation, multipart ETag를 whole +digest로 해석한 값과 CDN이 임의로 다시 쓴 validator는 resume authority로 +사용하지 않는다. provider가 strong validator를 제공하지 못하면 BFF가 immutable +generation을 pin하거나 Range resume를 `UNSUPPORTED`로 닫는다. + +raw ETag와 provider generation locator는 application, checkpoint, diagnostics와 +telemetry에 노출하지 않는다. reload 뒤에는 BFF가 새 capability를 발급하고, +runtime은 새 capability의 `representationBindingSha256`가 checkpoint와 같은지 +확인한 뒤 vault 안의 exact precondition만 사용한다. + +`STRONG_IF_RANGE` mode는 exact `If-Range`를 보내고 `206`, Range-ignore 또는 +validator mismatch의 full `200`과 해당 `416`만 계약한다. +`IMMUTABLE_GENERATION_PRECONDITION` mode는 BFF/provider가 정한 exact `If-Match` +또는 generation precondition을 보내며 `412`를 계약할 수 있다. +`allowWholeObjectFallback`과 `allowedStatuses`는 mode, requested start와 provider +topology에서 capability 발급 시 닫히며 executor가 임의로 넓히지 않는다. + +### 5.3 Capability 재발급 + +capability expiry, data-plane `401/403/410` 또는 최소 잔여 lifetime 부족은 같은 +URL의 무조건 retry가 아니다. + +1. 현재 response reader를 cancel하고 capability를 consume한다. +2. control plane에 `downloadKey`, resource와 expected representation binding, + exact next range를 전달한다. +3. BFF가 authorization와 current generation을 다시 읽는다. +4. binding이 같을 때만 새 capability로 같은 range를 재시도한다. +5. binding이 바뀌었으면 partial destination을 append하지 않고 + `REPRESENTATION_CHANGED/RESTART`로 닫는다. + +재발급 횟수, 전체 operation deadline과 retry backoff는 composition hard ceiling +안에 둔다. capability를 durable queue나 worker message에 저장하지 않는다. + +## 6. Durable checkpoint + +### 6.1 Schema + +```text +RangeDownloadCheckpointV1 + schemaVersion = 1 + protocol = RANGE_RESUMABLE_DOWNLOAD_V1 + revision + state = ACTIVE | PAUSED | FINALIZING | CLEANUP_PENDING + downloadKey + resourceBindingSha256 + representationBindingSha256 + totalByteLength + nextOffset + committedSegmentCount + destination + kind = OPFS_STAGING | SEEKABLE_FILE + opaqueDestinationBinding + createdAtEpochMs + updatedAtEpochMs + retentionExpiresAtEpochMs +``` + +`downloadKey`, destination binding과 physical database/OPFS namespace는 +composition-issued opaque token이다. 사용자 file name, resource ID, account ID, +tenant ID 또는 local path를 넣지 않는다. + +checkpoint에 금지하는 값: + +- presigned URL, query와 signed request/response header +- bearer/session/auth/CSRF token +- raw ETag, provider object key/generation locator +- file name, user path와 native exception +- incremental hash 내부 state +- raw backend response나 retry body + +허용된 digest binding과 offset은 비권한성 recovery metadata다. account partition, +retention, count/byte budget과 logout deletion을 적용하며 log/analytics/ticket에는 +내보내지 않는다. + +### 6.2 CAS와 durable offset + +한 `downloadKey`는 cross-context exclusive mutation lock으로 직렬화한다. lock은 +correctness의 유일한 authority가 아니며 checkpoint revision CAS와 exact +destination binding이 최종 local authority다. + +`nextOffset`은 다음 순서가 모두 성공한 뒤에만 전진한다. + +1. exact `206` range를 bounded stream으로 읽는다. +2. expected start 위치에만 쓴다. +3. writer close/segment commit을 완료한다. +4. destination의 committed length가 expected end 이상인지 확인한다. +5. unexpected tail이 있으면 authorized `truncate(expectedEnd)`를 완료한다. +6. checkpoint를 `revision + 1`, `nextOffset = expectedEnd`로 CAS한다. + +response가 성공했지만 destination commit 전에 crash하면 checkpoint는 이전 +offset에 머문다. 재시작은 destination을 checkpoint offset으로 truncate하고 같은 +range를 다시 요청한다. destination commit 뒤 checkpoint CAS가 유실된 경우도 +동일하게 checkpoint offset까지 truncate한 뒤 재전송한다. 따라서 중복 byte를 +append하지 않는다. + +### 6.3 Inventory와 retention + +checkpoint store는 단일 key read 외에 bounded admin operation을 제공해야 한다. + +- account partition 안의 safe summary를 cursor page로 list +- expired/terminal checkpoint를 bounded batch로 classify +- destination binding과 함께 exact owned staging을 cleanup +- active lock/lease가 있는 항목은 건너뜀 +- count, logical bytes, maximum age와 cleanup retry budget 강제 +- cleanup receipt를 durable하게 남기고 response 유실을 reconcile + +inventory에는 resource ID, file name, digest, raw validator와 path를 반환하지 +않는다. 제품 resume UI가 필요한 경우 제품 database/query가 별도 safe display +metadata를 소유하고 opaque `downloadKey`로만 연결한다. + +## 7. Destination 계약 + +### 7.1 공통 port + +```text +ResumableDownloadDestinationPort + inspect(binding) -> committedLength, readable, writable, permissionState + openWriter(binding, keepExistingData=true) + seek(offset) + write(chunk) + truncate(length) + commitSegment() + openReader(start=0) + finalize() + abortAttempt() + cleanup(authority) +``` + +native handle, OPFS handle와 path는 adapter 밖으로 노출하지 않는다. 모든 method는 +bounded deadline, AbortSignal과 closed failure를 사용한다. + +### 7.2 Seekable external file + +직접 외부 파일에 resume하려면 browser가 기존 data 보존, seek, truncate, +재읽기와 permission 재확인을 실제로 지원해야 한다. + +- picker와 permission request는 Window의 명시적 user activation에서만 실행한다. +- structured-cloned handle을 보존하는 경우 별도 privacy/retention 승인이 필요하다. +- reopen 뒤 `queryPermission`/`requestPermission`을 거치며 denied면 + `PERMISSION_DENIED/RESELECT`다. +- writer가 temporary-file commit semantics를 쓰면 segment마다 close한 뒤 + committed file size를 다시 확인한다. +- checkpoint보다 큰 tail은 검증하지 않고 사용하지 않으며 exact checkpoint + offset으로 truncate한다. +- checkpoint보다 파일이 작거나 다른 handle이면 `CONFLICT/RESTART`다. + +브라우저가 이 계약을 만족하지 못하면 external-file resume를 흉내 내지 않고 OPFS +staging 또는 browser-managed handoff로 전환한다. + +### 7.3 OPFS staging + +cross-browser app-controlled resume의 우선 destination은 policy-owned OPFS +staging이다. + +- physical path는 기존 OPFS authority/namespace/partition registry가 발급한다. +- checkpoint와 OPFS object는 immutable binding과 generation journal로 연결한다. +- quota estimate는 admission hint일 뿐이며 write 중 quota failure도 처리한다. +- download 완료 뒤 staging 전체를 다시 읽어 SHA-256을 검증한다. +- foreground user activation에서 새 외부 destination을 열고 staging을 stream + export한다. +- 외부 export close가 성공하기 전 staging을 삭제하지 않는다. +- export 결과가 유실되면 staging을 유지하고 user에게 retry 가능한 상태를 + 반환한다. + +OPFS 저장 성공은 사용자가 접근 가능한 파일 저장 완료가 아니다. 결과를 +`STAGED_VERIFIED`와 `SAVED_VERIFIED`로 구분한다. OPFS는 큰 파일에서 storage와 +I/O를 한 번 더 요구하므로 quota/retention owner 없는 기본 fallback이 아니다. + +## 8. HTTP 상태 머신 + +### 8.1 요청 전 + +1. checkpoint와 destination binding을 exact하게 읽는다. +2. destination length를 검사하고 checkpoint보다 큰 tail을 truncate한다. +3. checkpoint보다 작으면 partial을 신뢰하지 않고 restart/cleanup으로 닫는다. +4. 새 capability의 representation binding과 exact range를 검증한다. +5. `Range: bytes=S-E`와 capability의 `preconditionMode`가 정한 exact + `If-Range` 또는 immutable-generation precondition을 vault binding 그대로 + 보낸다. +6. `credentials: omit`, `redirect: error`, `no-referrer`, `no-store`, + identity content encoding을 강제한다. + +한 request의 range 크기와 exact `S/E`는 capability 발급 **전에** composition +maximum 안에서 계산한다. executor는 capability의 `requestedStart`, +`requestedEndExclusive`와 exact Range header가 일치하는지 검증하고 그대로 +전송하며 다시 줄이거나 늘리지 않는다. ceiling을 넘는 capability는 사용 전에 +거절한다. 기본 protocol은 sequential range만 허용한다. parallel range와 sparse +destination은 별도 protocol/version 없이는 사용하지 않는다. +zero-byte representation은 유효하지 않은 byte range를 만들지 않는다. exact +length가 0이고 empty-object SHA-256 binding이 일치하는 whole-object `200` 경로로 +body/length를 확인한 뒤 바로 final verification으로 이동한다. + +response body를 읽거나 destination writer를 열기 전에 `200`, `206`, `412`, +`416` 중 수신한 status가 capability vault의 exact `allowedStatuses` member인지 +검사한다. 해당 네 값 중 허용되지 않은 status는 body를 cancel하고 capability를 +consume하며 destination과 checkpoint를 변경하지 않은 채 +`CONTRACT_MISMATCH`로 fail-closed한다. 아래 네 분기는 이 공통 admission gate를 +통과한 경우에만 실행한다. 그 밖의 status는 §8.6의 별도 failure/reissue 규칙으로 +처리한다. + +### 8.2 `206 Partial Content` + +성공 조건: + +- `206`이 capability의 `allowedStatuses` member +- final response URL이 capability URL과 exact match +- `Content-Range: bytes S-E/T`가 하나만 존재하고 parse가 엄격함 +- `S`가 requested start, `E + 1`이 requested end exclusive +- `T`가 checkpoint total과 같음 +- `Content-Length = E - S + 1` +- strong validator/immutable generation binding 일치 +- media type과 identity encoding 일치 +- body 실제 bytes가 exact content length + +하나라도 다르면 reader와 current destination attempt를 abort하고 checkpoint를 +전진시키지 않는다. 정상인 경우에만 앞 절의 durable offset 순서로 commit한다. + +### 8.3 `200 OK` + +`200`은 capability의 `allowedStatuses` member인 경우에만 이 분기로 들어온다. +body를 destination에 쓰기 전에 final response URL, required response header, +media type, identity encoding과 mode별 strong validator 또는 immutable-generation +evidence가 capability의 exact representation binding과 일치하는지 검증한다. + +다음 순서로 배타적으로 처리한다. + +1. validator/generation evidence가 없거나 binding이 다르면 body를 cancel하고 + capability를 consume한다. 기존 checkpoint와 partial은 append하지 않고 + quarantine/retention policy로 전환한 뒤 control plane에서 current + representation을 다시 확인한다. 결과는 + `REPRESENTATION_CHANGED/RESTART`이며 byte 0의 새 operation만 허용한다. +2. binding은 같지만 requested start가 `0`이고 + `allowWholeObjectFallback=true`이면 fresh whole-object destination에서 기존 + whole-object stream 계약으로 처리한다. exact total length와 final + whole-object digest를 검증하기 전에는 success나 final commit을 반환하지 않는다. +3. binding은 같고 requested start가 `0`이지만 + `allowWholeObjectFallback=false`이면 body를 한 byte도 쓰지 않고 cancel한다. + 결과는 `WHOLE_OBJECT_FALLBACK_NOT_ALLOWED`이며 policy가 허용한 새 Range + capability, browser handoff 또는 explicit unsupported만 선택한다. +4. binding은 같고 requested start가 `0`보다 크면 server가 Range를 무시한 + 것이다. body를 한 byte도 쓰지 않고 cancel하며 기존 partial을 같은 writer에서 + 덮어쓰지 않는다. control plane reconcile 뒤 같은 representation의 byte 0 + restart operation, browser handoff 또는 explicit unsupported만 선택한다. + +### 8.4 `412 Precondition Failed` + +`412`가 capability의 `allowedStatuses` member이고 +`preconditionMode=IMMUTABLE_GENERATION_PRECONDITION`인 경우에만 이 분기로 들어온다. +representation precondition 실패다. body를 cancel하고 checkpoint를 유지한 채 +control plane에서 current generation을 확인한다. 같은 binding을 다시 발급하지 +못하면 partial은 cleanup policy에 따라 폐기하고 byte 0부터 새 operation을 +시작한다. + +RFC `If-Range` validator mismatch 자체의 정상 응답은 `200`이다. `412`는 BFF나 +provider가 immutable generation을 pin하기 위해 별도 `If-Match` 계열 precondition을 +함께 강제하는 topology에서만 이 상태 머신에 들어온다. topology가 `412`를 계약하지 +않았다면 unknown status로 fail-closed한다. + +### 8.5 `416 Range Not Satisfiable` + +`416`이 capability의 `allowedStatuses` member인 경우에만 이 분기로 들어온다. +response body는 download data로 소비하지 않고 cancel한다. +`Content-Range: bytes */T`를 strict하게 검사하며 final response URL, required +headers와 mode별 validator/generation binding도 확인한다. provider의 `416`이 +binding evidence를 반환할 수 없는 topology라면 BFF control plane reconcile이 +exact immutable generation을 다시 증명하기 전에는 EOF나 missing-range 분기로 +진행하지 않는다. + +다음 순서를 사용하며 한 분기를 처리한 뒤 아래 분기로 fall through하지 않는다. + +1. malformed/missing `T`, final URL/header mismatch 또는 증명되지 않은 + representation binding은 `CONTRACT_MISMATCH`로 fail-closed한다. +2. `T != expected total`이면 representation changed다. local bytes를 `T`에 맞춰 + 자동 truncate하거나 append하지 않고 capability를 consume한 뒤 partial을 + quarantine/restart한다. +3. `nextOffset > T`이면 checkpoint 자체가 corrupt/stale이다. 잘못된 offset으로 + truncate하지 않고 checkpoint와 partial을 quarantine한 뒤 restart/recovery로 + 닫는다. +4. `nextOffset <= T`이지만 `local committed length != nextOffset`이면 먼저 local + state를 reconcile한다. + - local length가 더 크면 exact `nextOffset`까지만 uncommitted tail을 + authorized truncate하고 durable length를 다시 확인한다. + - local length가 더 작으면 journal이 증명하는 마지막 confirmed segment로 + destination과 checkpoint를 함께 CAS rollback할 수 있을 때만 복구한다. + 그렇지 않으면 quarantine/restart한다. + 이 분기는 reconcile 결과를 새 state-machine invocation에서 다시 평가하며 바로 + finalization이나 missing-range request로 진행하지 않는다. +5. `local committed length == nextOffset == T`이면 data transfer가 끝난 후보로 + 보고 `FINALIZING` whole-object verification으로 이동한다. +6. `local committed length == nextOffset < T`이면 local missing range가 남아 있다. + 새 capability로 exact `nextOffset` range를 재발급한다. 같은 total에 대해 + satisfiable range가 다시 `416`이면 bounded retry하지 않고 + `CONTRACT_MISMATCH`로 fail-closed한다. + +`416` 자체를 다운로드 성공으로 간주하지 않는다. + +### 8.6 나머지 상태와 network failure + +| 조건 | 처리 | +| --- | --- | +| `401/403/410` | bounded capability reissue; binding mismatch면 restart | +| `404` | existence-hiding policy에 따라 unavailable/not-found, partial cleanup 예약 | +| `409` | server representation/session reconcile | +| `429`/모든 `5xx`/network | 동일 exact range만 bounded retry | +| redirect/opaque response | policy rejection | +| timeout/cancel | reader와 writer attempt abort, checkpoint 유지 | +| overrun/truncation | integrity failure, checkpoint 유지 | + +retry는 destination commit 여부를 먼저 판단한다. effect가 ambiguous하면 +checkpoint와 destination length를 reconcile하기 전 새 offset으로 이동하지 않는다. + +## 9. Whole-object integrity와 final commit + +Range별 transport 검증은 whole-object 무결성 증거가 아니다. 모든 bytes가 +수신되면 checkpoint를 `FINALIZING`으로 CAS하고 다음을 수행한다. + +1. destination length가 exact total과 같은지 확인한다. +2. destination을 byte 0부터 bounded chunk로 다시 읽는다. +3. vetted incremental SHA-256으로 whole-object digest를 계산한다. +4. capability/representation binding의 expected digest와 constant-time 비교한다. +5. mismatch면 사용자 destination을 성공으로 표시하지 않고 staging을 격리하거나 + authorized cleanup한다. +6. OPFS staging이면 foreground external export와 destination close를 완료한다. +7. final destination commit truth를 확인한 뒤만 `SAVED_VERIFIED`를 반환한다. +8. checkpoint와 staging cleanup을 exact revision/receipt로 완료한다. + +portable하지 않은 incremental hash 내부 state를 checkpoint에 serialize하지 않는다. +마지막 full reread 비용을 피하려면 chunk digest/Merkle manifest를 별도 protocol로 +설계하고 server가 exact proof를 제공해야 한다. + +## 10. Pause, cancel, crash와 account lifecycle + +### 10.1 Pause + +`pause(downloadKey)`는 browser work 중단이며 server resource/capability revoke가 +아니다. + +- 같은 runtime의 read/write/backoff를 AbortSignal로 중단한다. +- same-origin context에는 opaque key만 담은 versioned ephemeral pause event를 + 보낸다. +- mutation lock 안에서 checkpoint를 `PAUSED`로 CAS한다. +- in-memory URL/header/capability는 즉시 retire한다. +- committed segment는 유지하고 ambiguous writer attempt는 checkpoint offset으로 + reconcile한다. + +### 10.2 Cancel과 discard + +cancel은 transfer 중단만 의미할 수 있고, discard는 local partial 삭제다. 제품 +facade가 두 의도를 구분해야 한다. discard는 exact partition/destination binding과 +short-lived maintenance authority를 요구하며 checkpoint와 OPFS staging을 하나의 +cleanup journal로 처리한다. + +### 10.3 Crash/reload + +reload 후 runtime은: + +1. account partition과 governance binding을 검증한다. +2. checkpoint schema/protocol/revision을 검증한다. +3. destination을 reopen하고 permission/length를 검사한다. +4. server에서 새 capability를 발급받아 representation binding을 대조한다. +5. exact checkpoint offset부터 resume한다. + +source가 같은지 사용자에게 묻는 file-name 기반 확인은 사용하지 않는다. + +### 10.4 Logout/account/tenant switch + +- 신규 capability 발급과 resume admission을 먼저 닫는다. +- active foreground operation을 abort하고 writer를 정리한다. +- vault와 worker channel을 close한다. +- account partition의 checkpoint와 owned staging을 maintenance-authorized bounded + cleanup으로 제거한다. +- blocked deletion을 성공으로 보고하지 않는다. +- 이전 account handle/reference를 새 runtime에서 resolve하지 않는다. + +retention/legal-hold 정책이 local partial 보존을 요구하는 특별한 제품이 아니라면 +logout에서 partial을 제거하는 것이 기본이다. + +## 11. Download strategy selector + +selector는 presentation의 임의 조건문이 아니라 composition-owned immutable +policy와 runtime probe를 받는 공통 application service다. + +입력: + +- source가 server resource인지 client-generated artifact인지 +- exact 또는 maximum byte length +- verified integrity 필요 여부 +- resume/background 요구 +- system save picker, seek/truncate, OPFS와 worker capability +- user activation +- storage quota admission +- browser-managed capability availability +- data classification와 retention policy + +결과: + +| 조건 | 선택 | +| --- | --- | +| server file, 탭 종료 뒤 계속 필요 | `BROWSER_MANAGED_HANDOFF` | +| server file, verified foreground save, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` | +| server file, resume 필수, destination 계약 충족 | `RANGE_RESUMABLE_FOREGROUND` | +| 작은 generated artifact | `BOUNDED_OBJECT_URL` | +| 큰 generated artifact, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` | +| 큰 generated artifact, picker 미지원 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` | +| app background download가 승인·지원되고 OPFS quota 확보 | `APP_MANAGED_BACKGROUND_DOWNLOAD` | + +selector는 fallback으로 byte/memory/security ceiling을 올리지 않는다. integrity가 +필수인데 browser handoff만 가능하면 “검증된 저장”으로 downgrade하지 않고 제품이 +handoff 또는 unsupported 중 하나를 명시적으로 선택한다. + +### Safari와 picker 미지원 환경 + +user-agent 문자열로 Safari를 판별하지 않는다. 필요한 API와 실제 semantics를 +capability probe로 확인한다. + +- 대용량 server file: authorized `Content-Disposition` browser handoff +- 작은 generated file: bounded Blob/object URL +- 대용량 generated file: server-side generation 또는 unsupported +- OPFS: app-private staging일 뿐 Finder/Files 저장 완료로 표시하지 않음 +- system save picker 미지원: unbounded Blob으로 자동 전환하지 않음 +- seek/truncate/permission semantics 미충족: external Range resume 비활성화 + +browser-managed handoff endpoint는 cross-origin `download` attribute에 의존하지 +않고 server가 safe `Content-Disposition`, media type, byte/generation policy를 +실제 response에서 강제한다. + +## 12. Background download 전달의 세 의미 + +### 12.1 Foreground app-managed + +page가 열린 동안 runtime이 fetch, progress, integrity와 destination을 모두 +관리한다. 현재 whole-object stream과 목표 Range resume가 이 범주다. page lifecycle +종료 뒤 지속을 보장하지 않는다. + +### 12.2 Browser-managed handoff + +navigation/download manager에 authorized endpoint를 넘긴다. + +- page 종료 뒤 계속될 수 있는 가장 넓은 fallback +- application은 실제 disk write, 저장 위치와 final digest를 관찰하지 못함 +- 결과는 `BROWSER_HANDOFF`, `SAVED`나 `VERIFIED`가 아님 +- pause/resume UI와 retry semantics는 browser가 소유 + +### 12.3 App-managed background download + +Service Worker/Background Fetch 등에서 application이 progress/retry/staging을 +관리하려는 별도 optional capability다. + +필수 조건: + +- target browser/deployment의 explicit support matrix +- worker-safe authenticated control plane +- worker가 매 range마다 새 short-lived capability를 발급받는 계약 +- capability/URL/header를 IDB, OPFS, Cache Storage와 message에 저장하지 않음 +- private bytes는 Cache Storage가 아니라 policy-owned OPFS staging 사용 +- worker termination을 정상 상태로 보고 checkpoint에서 재개 +- concurrency, battery/network, quota와 retention ceiling +- logout/revocation event와 worker admission fence +- client/worker version compatibility와 upgrade drain +- notification/foreground export UX + +일반 Service Worker의 수명이나 background execution 시간을 correctness 근거로 +삼지 않는다. Background Fetch가 없는 환경에서 timer/keepalive로 장기 download를 +흉내 내지 않는다. user-visible external save picker는 worker에서 호출하지 않고 +완료된 OPFS staging을 다음 foreground user gesture에서 export한다. + +따라서 app-managed background download가 향후 `AVAILABLE_NOT_COMPOSED` 또는 `COMPOSED`가 +되더라도 지원 browser의 progressive enhancement일 뿐이다. cross-browser 보장 +자체의 primary status는 계속 `PLATFORM_LIMITED`다. + +## 13. Security, privacy와 observability + +- URL/query/header, validator와 capability는 bearer 또는 sensitive metadata로 + 취급한다. +- `Range`와 `preconditionMode`가 선택한 exact `If-Range` 또는 + immutable-generation precondition은 adapter vault가 binding 그대로 생성한다. +- caller는 offset을 늘리거나 arbitrary range를 요청하지 못한다. +- account partition과 resource authorization을 매 capability reissue에서 검사한다. +- partial bytes는 원본과 같은 data classification, retention, encryption-at-rest와 + deletion policy를 적용한다. +- OPFS quota pressure가 다른 account partial을 제거할 권한을 주지 않는다. +- preview, execution 또는 Cache Storage promotion은 final verification 전 금지한다. +- high-cardinality ID, file name, path, URL, raw ETag와 digest를 metric label/log에 + 넣지 않는다. + +허용된 aggregate observation: + +- strategy와 destination kind +- response state bucket +- expected/committed byte bucket +- retry/reissue/resume count bucket +- duration, pause, restart, integrity와 cleanup outcome +- browser capability support reason code + +## 14. Composition과 operational admission + +`createBrowserTransferRuntime`에 해당하는 미래 composition owner만 다음을 조합한다. + +- versioned wire codecs와 fixed BFF endpoint +- capability vault/provider/executor +- Range checkpoint store, destination registry와 mutation lock +- selector policy와 browser capability probe +- presigned, upload, image와 Range lifecycle +- account/logout cleanup authority +- safe observer +- traffic admission과 kill switch + +독립적인 canonical readiness 상태: + +```text +Selection = NOT_SELECTED | SELECTED | REMOVING +TrafficAdmission = DISABLED | SHADOW | CANARY | ENABLED +RuntimeHealth = UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE +PromotionEvidence = MISSING | PARTIAL | COMPLETE | EXPIRED +``` + +primary status가 `COMPOSED`여도 `TrafficAdmission` 기본값은 `DISABLED`다. +필수 config, strong validator/provider conformance, destination semantics, cleanup +owner 또는 valid evidence가 없으면 `TrafficAdmission=DISABLED`, +`RuntimeHealth=UNKNOWN | UNAVAILABLE`, +`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 readiness를 fail-closed한다. +이미 승인된 product selection 자체를 provider evidence 부족만으로 되돌리지 +않는다. + +Kill switch: + +- 신규 Range capability issuance off +- Range resume off → whole-object restart 또는 browser handoff +- direct object-store Range off → BFF proxy +- external seek destination off → OPFS staging 또는 handoff +- app-managed background download off → foreground/browser handoff +- final export off → verified staging 유지 + +kill switch는 partial을 자동 삭제하거나 handoff를 saved/verified로 바꾸지 않는다. + +## 15. Test와 conformance matrix + +### 15.1 Deterministic runtime + +- checkpoint CAS conflict와 corrupt/unknown field +- exact segment commit 전/후 crash +- destination larger/smaller/different binding +- pause/resume/cancel/discard races +- capability expiry/reissue와 representation change +- `200/206/412/416` 모든 분기 +- malformed/multiple/overflow `Content-Range` +- weak/missing/mismatched validator +- overrun, truncation, stalled body와 abort +- final whole-object digest mismatch +- cleanup response loss와 replay +- count/byte/age retention sweep + +### 15.2 Browser matrix + +- system picker 지원/미지원 +- seek/truncate/keep-existing-data semantics +- OPFS quota, eviction, reload와 worker termination +- cross-tab lock/pause delivery +- user activation과 permission denied/revoked +- large server handoff +- foreground export close/abort +- Chromium, Firefox와 WebKit 동일 필수 case set + +지원하지 않는 API는 skip이 아니라 selector의 expected fallback/`UNSUPPORTED` 결과로 +검증한다. + +### 15.3 BFF/object provider contract + +- immutable generation pin +- capability `preconditionMode`에 따른 strong `If-Range` 또는 immutable + generation precondition +- beginning/middle/end/empty/invalid range +- exact `206 Content-Range`와 length +- deliberate Range ignore `200` +- mode가 계약한 경우의 precondition `412`, EOF/invalid `416` +- mid-transfer capability expiry/revocation +- redirect/CORS/exposed-header/identity-encoding +- object replacement race +- direct provider와 proxy 결과 동등성 +- URL/header/log redaction + +fake와 route interception은 actual provider conformance를 대체하지 않는다. + +### 15.4 Background-download-specific fault + +- worker가 range commit 전/후 종료 +- worker/client version 교체 +- logout과 capability revocation +- offline/online 반복, quota exhaustion과 battery/network policy +- notification 유실과 foreground export replay +- unsupported browser가 foreground/handoff로 정확히 fallback + +## 16. Rollout과 promotion + +Primary status 변경과 readiness/traffic promotion은 별도로 승인한다. + +1. Range의 `DESIGNED_NOT_IMPLEMENTED`와 ADR-local + `sourceEvidence=DESIGN_REVIEWED`를 확인한다. +2. provider-neutral ports/runtime, deterministic fake, negative fixture와 browser + test를 완성한 경우에만 Range primary status를 + `AVAILABLE_NOT_COMPOSED`, source evidence를 `REFERENCE_TESTED`로 변경한다. + deterministic/reference test만으로 canonical `PromotionEvidence`를 + `COMPLETE`로 바꾸지 않는다. +3. 제품 요구, owner, data class와 fallback이 선택되지 않은 app-managed + background download는 계속 `NOT_SELECTED`로 둔다. cross-browser 보장은 + `PLATFORM_LIMITED`다. +4. fixed staging BFF/provider, actual config, account lifecycle와 runbook을 설치한 + capability만 `COMPOSED`로 기록한다. 이때도 + `TrafficAdmission=DISABLED`, `RuntimeHealth=UNKNOWN`, + `PromotionEvidence=PARTIAL`이다. +5. operator probe와 shadow에서 contract evidence를 수집한다. +6. internal cohort에서 BFF proxy Range를 먼저 canary한다. +7. direct provider Range와 external seek destination은 각각 별도 canary한다. +8. app-managed background download를 실제로 선택했다면 지원 browser cohort에서만 별도 + opt-in canary한다. +9. contract/provider/browser/operations component gate, SLO, cleanup drill, + rollback과 evidence freshness가 모두 충족된 승인 범위만 + `PromotionEvidence=COMPLETE`, `RuntimeHealth=AVAILABLE`, + `TrafficAdmission=ENABLED`로 promotion한다. + +provider, endpoint, validator semantics, browser major behavior, destination adapter, +wire protocol 또는 security policy가 바뀌면 relevant evidence를 만료시키고 +재승인한다. + +## 17. Rollback과 제거 + +운영 rollback 순서: + +1. 신규 Range/background-download admission과 capability 발급을 중지한다. +2. active writer/worker를 abort하고 checkpoint offset으로 reconcile한다. +3. app background download를 foreground/browser handoff로 낮춘다. +4. direct Range를 BFF proxy 또는 whole-object restart로 낮춘다. +5. verified OPFS staging은 retention window 안에서 foreground export 가능 상태로 + 유지한다. +6. ambiguous partial은 성공으로 표시하지 않고 cleanup queue로 넘긴다. +7. provider/signing credential 노출이 원인이면 backend revoke와 key rotation을 + 수행한다. + +완전 제거: + +1. pending checkpoint/staging inventory를 bounded하게 drain, export 또는 discard한다. +2. worker, channel, lock과 runtime을 close한다. +3. account-partition checkpoint/OPFS namespace를 maintenance-authorized cleanup한다. +4. endpoint, worker registration, config, policy와 feature facade를 제거한다. +5. production bundle/module inventory와 removal test로 source 부재를 증명한다. + +rollback은 unbounded Blob fallback, validator 완화, digest 생략 또는 partial 자동 +append를 허용하지 않는다. + +## 18. 완료 기준 + +Range resumable download는 다음이 모두 참일 때만 구현 완료다. + +- `RANGE_RESUMABLE_DOWNLOAD_V1` port와 strict wire codec이 있음 +- capability mode별 exact allowed-status subset과 `200/206/412/416` 처리 + 상태 머신이 실행 가능하게 검증됨 +- strong validator/immutable generation이 실제 provider에서 강제됨 +- checkpoint CAS, inventory, retention과 account cleanup이 구현됨 +- seek/truncate 또는 OPFS staging destination이 crash fault를 통과함 +- capability reissue가 representation mismatch를 fail-closed함 +- final whole-object SHA-256 뒤에만 verified success를 반환함 +- selector가 picker/seek 미지원과 대용량 fallback을 안전하게 결정함 +- actual BFF/provider와 Chromium/Firefox/WebKit evidence가 유효함 +- SLO, alert, runbook, kill switch, rollback과 cleanup drill이 승인됨 + +app-managed background download는 위 항목에 더해 다음이 필요하다. + +- 지원 browser/deployment 범위가 명시됨 +- worker lifecycle 종료를 checkpoint로 복구함 +- worker control plane이 durable capability 저장 없이 동작함 +- logout/revocation/version upgrade fault가 통과함 +- 미지원 browser fallback이 동일 제품 요구를 안전하게 만족하거나 명시적 + unsupported UX를 가짐 + +이 기준 전에는 기존 foreground streaming 또는 browser handoff의 성공을 Range나 +background download 구현 완료 증거로 사용하지 않는다. + +## 19. 선택하지 않은 대안 + +- `Range` header만 추가하고 기존 sequential writable에 append +- weak ETag나 file name/lastModified를 representation identity로 사용 +- serialized incremental hash state를 검증 없이 checkpoint +- `200` response를 기존 partial 뒤에 append +- `416`을 곧바로 success로 해석 +- Service Worker keepalive를 cross-browser background 보장으로 간주 +- picker 미지원 대용량 파일을 unbounded Blob으로 fallback +- OPFS staging을 사용자 파일 저장 완료로 표시 +- browser-managed handoff를 application-verified save로 표시 +- user-agent 문자열 기반 Safari 분기 + +## 20. 참고 + +- [Browser data capability completion ledger](../browser-data-capability-completion-ledger.md) +- [VD-16 Browser transfer composition과 Image delivery](./VD-16-browser-transfer-composition-and-image-delivery.md) +- [Browser transfer recovery](../../operations/browser-transfer-recovery.md) +- [RFC 9110 HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html) +- [Fetch Standard](https://fetch.spec.whatwg.org/) +- [File System Standard](https://fs.spec.whatwg.org/) +- [Service Workers](https://w3c.github.io/ServiceWorker/) +- [Background Fetch draft](https://wicg.github.io/background-fetch/) +- [기존 transfer 설계](../presigned-transfer-and-image-cdn.md) +- [browser file/origin storage 설계](../browser-file-and-origin-storage.md) diff --git a/docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md b/docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md new file mode 100644 index 0000000..8439cbe --- /dev/null +++ b/docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md @@ -0,0 +1,858 @@ +# VD-15: Origin storage lifecycle, migration, and optional file capabilities + +- 상태: Accepted design — implementation pending +- 결정일: 2026-07-28 +- 현재 구현 상태: capability별로 아래 표에 명시 +- 이 ADR이 선택한 common delta의 목표 reference 상태: + `AVAILABLE_NOT_COMPOSED` +- 관련 결정: VD-10, VD-11, VD-12, VD-14 +- current status ledger: + `docs/architecture/browser-data-capability-completion-ledger.md` +- 적용 범위: File/Blob preview, file/directory selection, IndexedDB, OPFS, + Cache Storage, StorageManager, optional Service Worker lifecycle + +이 결정은 VD-11의 reference runtime을 실제 제품에 조립하기 전에 남아 있는 +origin-wide lifecycle과 migration 경계를 고정한다. 문서가 추가됐다는 사실은 +runtime 구현, bootstrap composition 또는 production traffic 승격을 의미하지 +않는다. + +현재 구현돼 있는 것은 transient file vault, file picker, bounded download, +IndexedDB repository/migration mechanism, OPFS object/journal runtime, public static +Cache Storage release runtime과 StorageManager inspection primitive다. 다음은 아직 +구현되지 않았다. + +- IndexedDB, OPFS, Cache Storage를 함께 조정하는 pressure/write-admission/GC + coordinator +- origin 전체 eviction을 완전하게 판별하는 mechanism +- OPFS physical layout/journal과 Cache control schema의 forward migration runtime +- 실제 OPFS write/read/delete readiness probe +- cursor/deadline이 있는 bounded Cache Storage inspection/cleanup +- Service Worker update/client-drain controller +- local preview의 pixel/decode/frame safety probe +- directory/persistent handle/drag-and-drop capability +- Range/206 download 또는 private/range response cache + +## 1. 상태 모델과 현재/목표 + +### 1.1 다섯 primary current-status literal + +capability의 source 구현, 제품 선택, composition, traffic과 evidence를 하나의 +`enabled` boolean으로 합치지 않는다. 이 결정에서 capability의 **primary current +status**로 허용하는 literal은 정확히 다음 다섯 가지다. + +| primary status | 의미 | +| --- | --- | +| `COMPOSED` | 실제 owner/policy/provider가 production composition root에 연결돼 있다. traffic이 disabled/canary/enabled인지는 이 상태가 아니라 별도 admission 축이다. | +| `AVAILABLE_NOT_COMPOSED` | 실행 가능한 runtime과 test가 source에 있지만 production bootstrap과 제품 dataset에는 연결하지 않았다. | +| `DESIGNED_NOT_IMPLEMENTED` | port, invariant, failure/recovery와 promotion 기준은 결정됐지만 실행 가능한 runtime이 없다. | +| `NOT_SELECTED` | 제품 요구와 owner가 capability를 선택하지 않았다. source 설계나 일부 primitive가 있더라도 runtime, DB, worker, listener를 만들지 않는다. | +| `PLATFORM_LIMITED` | 요구한 의미를 대상 browser 전체에서 application-controlled capability로 보장할 수 없다. 지원 engine의 progressive enhancement와 명시적 fallback만 허용한다. | + +이 다섯 값은 선형 maturity 단계가 아니다. 예를 들어 구현이 존재해도 제품이 +선택하지 않은 별도 capability의 primary status는 `NOT_SELECTED`일 수 있고, +cross-browser 보장이 불가능하면 구현량과 무관하게 `PLATFORM_LIMITED`다. +`COMPOSED`도 traffic enablement나 runtime health를 암묵적으로 뜻하지 않는다. + +primary status와 별도로 다음 축을 기록한다. + +```text +Selection + NOT_SELECTED | SELECTED | REMOVING + +TrafficAdmission + DISABLED | SHADOW | CANARY | ENABLED + +RuntimeHealth + UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE + +PromotionEvidence + MISSING | PARTIAL | COMPLETE | EXPIRED +``` + +이 네 이름과 literal은 completion ledger의 canonical readiness 축이다. +`PromotionEvidence`는 별도 임의 enum이 아니라 ledger의 contract, provider, +browser, operations component gate를 합성한 값이다. required artifact가 없으면 +`MISSING`, 일부만 terminal이면 `PARTIAL`, 모든 required gate와 freshness가 +충족될 때만 `COMPLETE`, 한 번 유효했던 required artifact가 만료되면 +`EXPIRED`로 기록한다. + +예를 들어 실제 dependency를 조립한 첫 배포는 +`primaryStatus=COMPOSED`, `TrafficAdmission=DISABLED`, +`RuntimeHealth=UNKNOWN`, `PromotionEvidence=PARTIAL`일 수 있다. probe와 canary +승격은 primary status를 새 literal로 바꾸지 않고 별도 축만 변경한다. + +rollback은 `TrafficAdmission`을 먼저 `DISABLED`로 내린다. schema version을 +내리거나, user-authored data를 자동 삭제하거나, unavailable runtime을 in-memory +fake로 교체하지 않는다. + +### 1.2 current vs target + +| capability | 현재 | 이 결정의 목표 | 비고 | +| --- | --- | --- | --- | +| transient File/Blob vault와 picker | `AVAILABLE_NOT_COMPOSED` | 유지 | 제품 policy가 없으므로 조립하지 않음 | +| foreground streaming/save와 browser handoff | `AVAILABLE_NOT_COMPOSED` | 유지 | Range resume는 포함하지 않음 | +| IndexedDB repository/codec migration | `AVAILABLE_NOT_COMPOSED` | 유지, coordinator hook 추가 대상 | 제품 dataset/schema는 없음 | +| OPFS object/journal v1 | `AVAILABLE_NOT_COMPOSED` | 유지 | 현재 byte runtime과 v1 reconciliation 범위 | +| OPFS real readiness preflight | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | property probe/native test를 composition readiness로 오인하지 않음 | +| public static Cache release v1 | `AVAILABLE_NOT_COMPOSED` | 유지 | 현재 stage/activate/previous retain은 구현 | +| bounded Cache inspect/cleanup | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 현재 ownership은 검증하지만 cache-count scan은 unbounded | +| StorageManager estimate/persist primitive | `AVAILABLE_NOT_COMPOSED` | 유지 | origin coordinator는 없음 | +| origin storage lifecycle coordinator | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 이 ADR이 계약을 확정 | +| OPFS physical/journal forward migration | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 기존 v1 layout/journal을 migrator 구현으로 오인하지 않음 | +| Cache control/prefix forward migration | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 기존 v1 parser/release primitive를 migrator 구현으로 오인하지 않음 | +| local preview safety probe | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 현재 byte/signature check만 있음 | +| Service Worker update lifecycle | `NOT_SELECTED` | 제품이 PWA를 선택할 때 별도 승격 | Cache Storage 사용만으로 자동 선택하지 않음 | +| directory selection/persistent handles/drop | `NOT_SELECTED` | 제품 workspace 요구가 있을 때 별도 승격 | consent/retention 결정 필요 | +| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | VD-14의 별도 capability | backend validator/range 계약과 별도 runtime 필요 | +| sparse Range response cache | `NOT_SELECTED` | Range download와도 분리된 별도 capability | segment merge/cache threat model 필요 | +| private response cache | `NOT_SELECTED` | 별도 security review 전 금지 | public cache를 확장해 암묵 설치하지 않음 | +| app-managed background download | `NOT_SELECTED` | 지원 browser용 별도 optional capability | 별도 owner/staging/worker protocol 필요 | +| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | browser-managed handoff를 기본 fallback으로 유지 | Service Worker가 장시간 transfer 지속을 보장하지 않음 | + +## 2. 변경할 수 없는 불변조건 + +1. `navigator.storage.estimate()`는 rough origin signal이지 free-space reservation, + per-store usage 또는 eviction guarantee가 아니다. +2. 실제 `QuotaExceededError`가 write failure의 authority다. estimate가 정상이어도 + write는 실패할 수 있다. +3. IndexedDB, OPFS와 Cache Storage 사이에는 atomic transaction이 없다. coordinator는 + saga와 idempotency를 제공할 뿐 cross-API ACID를 주장하지 않는다. +4. user-authored/unsynced data는 pressure 또는 migration convenience를 이유로 자동 + 삭제하지 않는다. +5. credential, session token, raw authorization header, presigned URL과 signing key는 + 어느 origin store에도 저장하지 않는다. +6. migration은 expand/migrate/contract 순서다. committed OPFS file을 in-place로 + 변환하지 않고, Cache Storage의 검증되지 않은 candidate를 active로 만들지 않는다. +7. future schema를 이전 bundle이 발견하면 destructive open/delete 대신 read-only, + online-only 또는 export-required로 전환한다. +8. capability probe failure를 fake success로 바꾸지 않는다. +9. Service Worker 등록·활성화와 Cache Storage ownership은 서로 다른 capability다. +10. local image preview는 encoded byte cap만으로 decode safety를 주장하지 않는다. +11. directory handle과 persistent file handle은 transient file selection의 자연스러운 + 연장이 아니라 별도 consent/persistence capability다. +12. public Cache Storage는 private/auth/range representation의 repository가 아니다. + +## 3. composition과 owner/policy injection + +### 3.1 composition root + +제품이 선택하면 한 composition root가 다음 dependency를 immutable snapshot으로 +고정한다. + +```text +OriginStorageLifecycleComposition + originScope + releaseId + datasetPolicyRegistry + storageDurability + indexedDbMaintenance[] + opfsMaintenance[] + publicCacheMaintenance[] + mutationLock + clock + scheduler + lifecycleAuthority + safeObserver + killSwitches +``` + +page, hook 또는 domain use case는 native manager와 maintenance adapter를 직접 +조합하지 않는다. coordinator에 등록되는 각 dataset profile은 다음을 필수로 +소유한다. + +| 필드 | owner가 결정할 내용 | +| --- | --- | +| `datasetRegistryId` | readable user/account 값이 아닌 고정된 registry ID | +| `owner` | product owner와 operational owner | +| `technology` | IndexedDB, OPFS, public Cache 중 정확한 storage | +| `authority` | server, local-first, reconstructable | +| `classification` | public, internal, personal, confidential | +| `accountScope` | origin-shared 또는 opaque partition | +| `retention` | session, TTL, until-synced, explicit delete | +| `soft/hardBudget` | logical dataset budget; native free-space claim이 아님 | +| `pressurePriority` | expired/reconstructable, synced-copy, user-authored 순서 | +| `writeCriticality` | essential user write, sync receipt, reconstructable cache | +| `fallback` | read-only, online-only, export-required | +| `migrationOwner` | schema/codec/layout migration과 rollback owner | +| `recoveryOwner` | rehydrate, export, backend sync와 incident owner | + +registry는 composition 시 deep snapshot/freeze하고 같은 문자열을 가진 caller-created +profile을 identity로 인정하지 않는다. presentation은 priority, retention 또는 +eviction eligibility를 요청별로 고를 수 없다. + +### 3.2 authority가 필요한 동작 + +logout, account deletion, until-synced deletion과 user-authored export/purge는 제품 +authority를 요구한다. 기존 OPFS maintenance proof와 동일하게 provider가 exact +reason/scope/policy에 묶인 짧은 proof를 발급하고 consumer가 원자적으로 +consume한다. + +pressure에 따른 expired/reconstructable GC는 product proof가 없어도 실행할 수 +있지만 등록 policy와 bounded budget을 벗어나면 안 된다. `SYNCED_COPY` 삭제는 +authoritative server revision 또는 별도 sync receipt가 확인된 항목에만 허용한다. + +## 4. origin-wide pressure, write admission, and GC + +### 4.1 coordinator port + +목표 runtime은 native type을 노출하지 않는 다음 의미의 port를 제공한다. + +```ts +type PressureState = + | "UNKNOWN" + | "NORMAL" + | "PRESSURE" + | "CRITICAL" + | "QUOTA_FAILURE"; + +type WriteAdmission = + | { kind: "ADMITTED"; admissionId: string; attempt: 1 | 2 } + | { kind: "DEFERRED"; recovery: "RETRY" | "ONLINE_ONLY" } + | { kind: "DENIED"; recovery: "READ_ONLY" | "EXPORT_REQUIRED" }; +``` + +실제 API는 repository write를 대신하지 않는다. 각 adapter가 write 직전 admission을 +얻고, commit/rollback 이후 exact admission을 완료하도록 좁은 hook을 받는다. +admission ID는 diagnostic 또는 persistence에 남기지 않는 runtime-local fencing +token이다. + +### 4.2 pressure state와 hysteresis + +기본 threshold 시작점은 VD-11과 일치한다. + +| 진입 조건 | 상태 | +| --- | --- | +| usage/quota를 모름 | `UNKNOWN` | +| `< 70%` | `NORMAL` | +| `>= 70%` | `PRESSURE` | +| `>= 85%` | `CRITICAL` | +| 실제 write의 quota exception | `QUOTA_FAILURE` | + +flapping을 막기 위해 하향 전이는 더 낮은 threshold를 사용한다. + +- `CRITICAL -> PRESSURE`: 두 번 연속 inspection에서 `< 80%` +- `PRESSURE -> NORMAL`: 두 번 연속 inspection에서 `< 65%` +- inspection 간격은 composition policy가 정하되 boot polling loop를 만들지 않는다. +- tab마다 독립 GC하지 않는다. 고정된 origin Web Lock 아래 leader 하나만 maintenance를 + 수행하고, lock이 없으면 destructive maintenance를 하지 않는다. + +두 번 연속 규칙은 in-memory observation일 뿐 영구 storage truth가 아니다. 새 +runtime은 persisted pressure state를 맹신하지 않고 `UNKNOWN`에서 시작한다. + +### 4.3 admission matrix + +| pressure | essential user-authored | sync/export receipt | reconstructable/cache | +| --- | --- | --- | --- | +| `UNKNOWN` | policy hard budget 안에서 허용, failure 대비 | 허용 | 보수적으로 defer 가능 | +| `NORMAL` | 허용 | 허용 | 허용 | +| `PRESSURE` | 허용 | 허용 | 먼저 bounded GC, 신규 speculative write 제한 | +| `CRITICAL` | hard budget과 recovery path가 있을 때만 허용 | 허용 우선 | 거절/online-only | +| `QUOTA_FAILURE` | rollback 후 export/sync UX | rollback 후 retry 조건 평가 | rollback, GC, 최대 1회 retry | + +estimate만으로 “N bytes를 예약했다”고 기록하지 않는다. IndexedDB/OPFS의 logical +budget reservation은 동시 writer 간 policy ceiling을 강제하기 위한 값이며 origin +free space가 아니다. + +### 4.4 GC 순서와 bounded execution + +GC 순서는 모든 기술에서 다음 우선순위를 유지한다. + +```text +incomplete candidate / stale staging + -> expired reconstructable + -> unreferenced immutable chunk with grace + -> inactive public cache release + -> confirmed synced copy + -> stop +``` + +user-authored/unsynced는 자동 GC 목록에 들어가지 않는다. 각 invocation은 다음 +두 예산을 모두 가진다. + +- 기본 최대 100 items 또는 5초 +- 구현 절대 상한 500 items 또는 30초 + +각 native operation 사이에 deadline과 AbortSignal을 다시 확인한다. 결과는 +`inspected`, `removed`, `releasedLogicalBytes`, `moreAvailable`, +`deadlineReached`와 opaque cursor를 반환한다. cursor는 dataset/policy/release +epoch에 묶고 다른 owner에서 replay하면 `STALE_RESULT`다. + +### 4.5 quota failure 뒤 단 한 번의 retry + +자동 retry는 다음 조건을 전부 만족할 때만 허용한다. + +1. 첫 attempt가 실제 `QuotaExceededError`로 rollback됐다. +2. operation이 같은 idempotency key, revision fence와 payload digest를 가진다. +3. 외부 side effect 또는 cross-store publish가 commit되지 않았다. +4. bounded GC가 실제로 candidate를 제거했거나 pressure가 하향됐다. +5. retry가 같은 operation lifecycle에서 정확히 한 번뿐이다. +6. 새 admission token을 발급하고 현재 revision/generation을 다시 읽는다. + +두 번째 quota failure, partial external commit, user-authored destructive overwrite, +unknown idempotency는 retry하지 않는다. recovery는 policy에 따라 `READ_ONLY`, +`ONLINE_ONLY` 또는 `EXPORT_REQUIRED`다. + +## 5. eviction detection의 범위와 한계 + +### 5.1 감지할 수 있는 것 + +각 조립된 dataset은 opaque scope에 다음 binding을 둔다. + +- IndexedDB governance row와 dataset epoch +- OPFS journal logical object와 physical manifest/digest +- public Cache active pointer와 verified release marker +- 선택적으로 backend가 알고 있는 opaque dataset installation epoch + +다음 partial mismatch는 `STORAGE_EVICTED` 또는 `CORRUPT_DATA`로 구분할 수 있다. + +- logical OPFS object는 있는데 physical manifest/chunk가 없음 +- Cache active pointer는 있는데 candidate cache/marker가 없음 +- migration checkpoint는 있는데 target generation이 없음 +- expected dataset epoch와 local governance binding이 다름 + +reconstructable data는 rehydrate하고, local-first/user-authored data는 자동 empty +state로 초기화하지 않고 read-only/export-required incident로 올린다. + +### 5.2 감지할 수 없는 것 + +browser가 origin의 IndexedDB, OPFS와 Cache Storage를 모두 함께 지우면 local +sentinel도 함께 사라진다. local state만으로 다음 두 상황을 완전하게 구분할 수 +없다. + +```text +이 browser의 첫 설치 +origin storage 전체 eviction/user clear +``` + +따라서 “sentinel이 없으므로 첫 설치”라고 단정하지 않는다. 제품이 구분을 요구하면 +현재 인증 session의 backend에 opaque installation/dataset epoch를 보관하고 +authorization 후 비교해야 한다. backend marker도 browser byte backup이 아니며, +local-only data 복구를 보장하지 않는다. + +backend epoch가 없으면 UI는 empty/new와 storage-reset-possible 상태를 제품 정책에 +맞게 합쳐 표현해야 한다. raw account ID, filename, object ID 또는 digest를 +sentinel/log에 넣지 않는다. + +## 6. schema, codec, physical migration + +### 6.1 독립 version 축 + +다음 version을 하나의 숫자로 합치지 않는다. + +| 축 | 의미 | 현재 | +| --- | --- | --- | +| IndexedDB DDL | store/index/governance shape | reference runtime에 additive planner 있음 | +| IndexedDB record codec | payload decode/encode | resumable maintenance mechanism 있음 | +| OPFS journal DDL | logical object/journal/budget/refcount schema | v1 고정 | +| OPFS physical layout | root/path/manifest/chunk-tree algorithm | v1 고정 | +| Cache control schema | marker/active pointer JSON | v1 고정 | +| Cache release manifest | URL/header/type/length/digest binding | current static release contract | +| lifecycle registry schema | owner/policy/admission binding | 이 결정에서 설계, 구현 없음 | + +IndexedDB mechanism이 존재한다고 해서 제품 codec/migration이 자동으로 존재하는 +것은 아니다. OPFS와 Cache v1 parser가 있다는 사실도 forward migration 구현을 +뜻하지 않는다. + +### 6.2 공통 expand/migrate/contract + +1. **expand:** 새 reader가 N과 N-1을 읽고 새 metadata/checkpoint를 additive하게 + 추가한다. +2. **drain:** old writer가 더는 N-1 shape를 쓰지 않는다는 release/lease evidence를 + 확인한다. +3. **migrate:** bounded batch와 keyset/opaque cursor로 copy/verify한다. +4. **publish:** row, checkpoint, logical budget과 generation fence를 가능한 한 같은 + native transaction에서 commit한다. +5. **observe:** canary와 rollback window 동안 N-1 reader compatibility를 확인한다. +6. **contract:** 모든 active/rollback release가 지난 별도 release에서만 old shape를 + 정리한다. + +schema downgrade, blanket database/cache/root deletion과 read-time unbounded rewrite는 +금지한다. + +### 6.3 OPFS migration + +OPFS physical migration은 copy-on-write다. + +```text +v1 committed object + -> v2 staging transaction + -> bounded chunk copy/read + -> v2 manifest + tree digest verify + -> IDB journal generation/fencing CAS + -> v2 logical publish + -> rollback window 동안 v1 retain + -> authority 확인 후 v1 bounded cleanup +``` + +- committed v1 file/chunk를 in-place로 수정하지 않는다. +- checkpoint는 last logical object key와 source/target generation을 저장한다. +- source digest, target digest, bytes와 policy binding이 맞지 않으면 quarantine하고 + 다음 object로 성공 처리하지 않는다. +- crash가 v2 publish 전이면 v1이 authority다. +- publish 후 cleanup crash는 v2가 authority이고 cleanup을 재개한다. +- N-1 bundle은 v2를 쓰지 않고 read-only/online-only로 degrade한다. +- local-first bytes를 contract하려면 export/sync 또는 승인된 rollback-window + evidence가 필요하다. + +### 6.4 Cache migration과 rollback + +public Cache data는 reconstructable이므로 byte-by-byte schema rewrite보다 새 +release를 다시 stage/verify/activate한다. Service Worker를 선택하지 않은 static +Cache-only 조합의 migration은 다음 흐름이다. + +```text +old active verified release + -> new prefix/control schema candidate + -> exact network fetch + integrity verify + -> explicit activation + -> old + previous retain + -> composition-owned rollback/grace window 확인 + -> bounded owned-prefix cleanup +``` + +이 흐름에는 waiting worker, `controllerchange` 또는 controlled-client drain을 +성공 조건으로 넣지 않는다. Service Worker를 별도 선택한 조합만 section 9.2의 +waiting/activation protocol을 실행하고, old controlled client가 drain된 뒤 해당 +release를 cleanup eligible로 만든다. + +rollback은 검증된 previous release의 ID와 manifest digest로 같은 activation +protocol을 다시 실행한다. caller가 raw cache name이나 retain list를 전달하지 +않는다. new control schema가 unreadable하면 old pointer를 덮어쓰지 않고 +network-only로 degrade한다. + +Cache control/release cleanup도 section 4의 cursor/deadline 예산을 적용한다. +unregister 또는 새 Service Worker install만으로 cache migration이 완료됐다고 +보지 않는다. + +### 6.5 N-1 rollback contract + +모든 durable migration은 최소 다음 fixture를 보유한다. + +- N-1 fresh -> N open +- N-1 populated -> N partial migration crash -> N resume +- N migration 완료 -> N-1 open: destructive write 없이 read-only/online-only +- N canary rollback -> N-1 server path로 정상 동작 +- N rollback window 종료 뒤 별도 contract release + +rollback bundle은 schema number를 낮추지 않는다. 새로운 writer를 끄고 compatible +reader/fallback을 사용한다. + +## 7. OPFS real readiness preflight + +현재 `inspectBrowserOpfsSupport()`는 API property를 확인한다. 목표 preflight는 +실제 작은 operation을 검증한다. + +### 7.1 probe protocol + +probe는 `primaryStatus=COMPOSED`, `Selection=SELECTED`이고 readiness 확인이 +필요할 때 실행한다. 최초 composition에서는 `TrafficAdmission=DISABLED` 또는 +`SHADOW`로 probe하며, 선택하지 않은 skeleton boot에서 OPFS root/DB/worker를 +만들지 않는다. + +```text +secure context/API check + -> DedicatedWorker boot + protocol handshake + -> origin Web Lock acquire + -> owned opaque probe scope의 IDB journal transaction + -> random staging file create + -> bounded bytes write + flush/close + -> read + length/digest verify + -> file/journal cleanup + -> lock/worker/connection close +``` + +규칙: + +- main thread에서 SyncAccessHandle을 만들지 않는다. +- synchronous path와 configured async writable fallback을 각각 capability로 + 보고한다. +- probe object ID/path는 secure random opaque value이고 log에 기록하지 않는다. +- 기본 deadline 5초, 절대 상한 30초다. +- timeout/crash 뒤 stale probe는 reconciliation owner가 grace 후 bounded cleanup한다. +- 결과는 runtime memory에 짧게 cache할 수 있지만 browser update, visibility가 긴 + sleep에서 복귀, quota/permission failure 뒤 다시 `UNKNOWN`으로 돌린다. +- probe 성공은 future write 또는 persistence guarantee가 아니다. + +### 7.2 readiness mapping + +| 결과 | runtime health | admission | +| --- | --- | --- | +| full worker/lock/journal/write/read/delete 성공 | `AVAILABLE` | policy에 따라 가능 | +| sync handle 없음, 승인된 async fallback 성공 | `DEGRADED` | size/concurrency ceiling 하향 | +| API 없음/secure context 아님 | `UNAVAILABLE` | online-only | +| protocol/schema mismatch | `INCOMPATIBLE` | read/write 금지 | +| timeout/quota/permission | `DEGRADED` 또는 `UNAVAILABLE` | 신규 write 금지, recovery 실행 | + +## 8. bounded Cache Storage maintenance + +현재 static public cache는 release stage/verify/activate, previous retain과 +owned-prefix cleanup을 구현한다. 현재 `cleanupOwned()`와 `inspect()`에는 +max-count/deadline/cursor가 없다. 목표 contract는 이를 bounded operation으로 +바꾼다. + +```ts +type CacheMaintenancePage = Readonly<{ + inspectedCaches: number; + deletedCaches: number; + retainedCaches: number; + unreadableCaches: number; + nextCursor: string | null; + moreAvailable: boolean; + deadlineReached: boolean; +}>; +``` + +- default 100 caches/5초, absolute 500 caches/30초 +- cursor는 owned prefix, active pointer epoch와 policy fingerprint에 binding +- caller는 raw cache name, retain list 또는 prefix를 제출할 수 없음 +- mutation Web Lock 아래 active pointer를 다시 읽은 뒤 한 cache씩 처리 +- abort/deadline 뒤 이미 완료한 delete truth는 되돌리지 않고 cursor부터 재개 +- corrupt active pointer면 destructive cleanup을 중지하고 network-only +- unreadable inactive candidate는 grace와 current/previous binding 확인 뒤 삭제 +- `QuotaExceededError`를 이유로 다른 origin cache나 user data를 삭제하지 않음 + +inspection도 동일한 page contract를 써서 cache 수에 비례한 unbounded boot work를 +금지한다. + +## 9. static Cache release와 optional Service Worker lifecycle + +### 9.1 현재 static release capability + +현재 adapter가 소유하는 범위: + +- same-origin anonymous public GET +- exact query/request header/Vary +- type, declared/actual length와 SHA-256 +- candidate 전체 성공 뒤 explicit activation +- failed candidate 삭제와 기존 active 유지 +- active + verified previous release retain +- private/no-store/auth/opaque/redirect/206 거부 + +Window 또는 Worker에서 Cache Storage를 쓸 수 있으므로 이 기능은 Service Worker +설치를 의미하지 않는다. + +### 9.2 Service Worker를 선택할 때의 별도 protocol + +PWA/offline interception을 제품이 선택하면 별도 owner가 다음 lifecycle을 +composition한다. + +```text +installing worker + -> candidate static release stage/verify + -> waiting + -> page update controller: + dirty form / active transfer / compatibility 확인 + -> explicit ACTIVATE(version, manifest) + -> pointer flip + -> skipWaiting opt-in + -> controllerchange acknowledgement + -> old clients drain + -> clients.claim opt-in + -> previous release grace retain + -> bounded cleanup +``` + +`skipWaiting()`과 `clients.claim()`을 install handler에서 자동 호출하지 않는다. +message는 protocol version, release ID, nonce와 exact target worker에 binding하고 +unknown message를 drop한다. + +fetch 전략은 route registry에 고정한다. + +| route class | 허용 전략 | +| --- | --- | +| content-hashed static asset | exact active cache-first | +| navigation | network-first + 별도 검증된 static offline page | +| runtime config/release manifest/auth/API | network-only | +| approved public runtime media | 별도 TTL metadata owner가 있을 때만 bounded SWR | + +runtime TTL/SWR은 static release adapter의 묵시적 기능이 아니다. 별도 entry/count/ +byte/TTL budget, revalidation owner와 prune cursor가 있어야 한다. + +Service Worker는 application-controlled long-running background download를 +cross-browser로 보장하지 않는다. download lifecycle은 VD-14의 별도 capability다. + +## 10. local preview decode safety + +### 10.1 현재와 목표 + +현재 preview path는 selection byte cap, signature receipt, media allowlist, +active-content denylist와 object URL lease를 제공한다. static raster의 intrinsic +dimensions, decoded surface와 animation frame 수를 검사하지 않는다. + +목표 runtime은 object URL을 발급하기 전에 exact registered preview policy에 +묶인 `PreviewSafetyProbePort`를 호출한다. + +### 10.2 policy와 검사 순서 + +owner가 최소 다음을 결정한다. + +- 허용 static format과 signature parser version +- max encoded bytes +- max width/height +- max total pixels +- max decoded bytes +- animation 허용 여부와 max frames/total pixels +- decode concurrency와 deadline +- malformed/unsupported metadata 동작 + +기본은 JPEG, PNG, WebP, AVIF 중 검토된 static parser만 허용하고 animation, +SVG, HTML, XML, PDF는 preview에서 거절한다. animation이 제품 요구면 별도 +frame/time/memory capability로 승격한다. + +```text +bounded header read + -> container/signature parse + -> width/height/frame/static 여부 + -> overflow-safe pixel/decoded-byte 계산 + -> optional real bitmap decode + -> decoded dimensions exact match + -> bitmap close + -> object URL lease 발급 +``` + +`width * height * 4` 계산은 safe integer overflow를 검사한다. parser header만 +신뢰하지 않고 지원 browser에서는 `createImageBitmap` 등 실제 decode를 bounded +concurrency/deadline 아래 확인하고 즉시 `close()`한다. decode failure 뒤 object +URL을 발급하지 않는다. + +runtime absolute ceiling은 product policy보다 크거나 같고 caller는 낮출 수만 있다. +원본 filename, digest와 dimensions를 telemetry에 기록하지 않고 bucket만 남긴다. + +## 11. directory, persistent handles, and drag-and-drop + +세 기능은 현재 transient picker port에 추가하지 않는다. + +### 11.1 directory selection + +제품이 folder import/workspace를 선택하면 별도 `DirectorySelectionPort`를 만든다. + +- `showDirectoryPicker`는 progressive enhancement +- ``는 검증된 baseline으로만 사용 +- depth, entry count, per-file/total bytes, traversal time의 hard cap +- relative path segment NFC 정규화, `.`/`..`, separator, control/bidi 거부 +- 파일이 아닌 entry, traversal 중 permission loss와 mutation을 closed failure로 + 처리 +- traversal 결과는 opaque file refs와 sanitized relative metadata만 반환 +- directory name/path를 domain ID 또는 log로 사용하지 않음 + +directory upload가 필요하면 backend도 archive/path/symlink/traversal과 total +expanded budget을 다시 검증한다. + +### 11.2 persistent handles and permission + +persistent handle은 별도 registry와 consent가 필요하다. + +- IDB structured-clone support를 실제 probe +- handle 자체를 application/domain/query cache에 노출하지 않음 +- opaque handle ref, account partition, purpose, retention과 last-used bucket만 보관 +- boot/background에서 `requestPermission()` 금지 +- explicit user action에서 `queryPermission()` 후 필요한 경우에만 request +- denied/revoked/stale handle은 `RESELECT`, silent empty file로 처리하지 않음 +- logout/account deletion과 handle registry purge는 authority를 요구 +- browser가 OS 권한 철회를 지원하지 않을 수 있음을 UX에 명시 + +handle persistence는 local bytes backup이 아니며 파일이 외부에서 바뀔 수 있다. +매 open마다 size/lastModified와 제품이 요구하는 content identity를 다시 검사한다. + +### 11.3 drag-and-drop + +현재 `DROP` source enum은 full adapter를 의미하지 않는다. 선택 시 별도 inbound +adapter가 `DataTransfer`를 event 안에서 snapshot하고, file-only drop과 directory +traversal을 구분한다. pasted/dropped HTML, URL과 string item을 file capability로 +승격하지 않는다. same count/byte/type/path policy를 picker와 공유하되 UI event +type을 permission으로 사용하지 않는다. + +## 12. Range와 private cache는 별도 capability + +### 12.1 Range/206 + +public static cache는 `Range` request와 206 response를 계속 거절한다. resumable +download에는 별도 계약이 필요하다. + +- immutable object version 또는 strong validator +- `Range`/`If-Range` +- exact `206 Content-Range` +- `200`, `206`, `412`, `416` state transition +- destination offset/seek/truncate와 partial checkpoint +- overlap/gap 방지 +- capability 재발급 시 같은 representation binding +- 전체 완료 뒤 whole-object integrity + +sparse range를 Cache Storage entry로 합치는 것은 현재 public release port의 역할이 +아니다. 필요하면 OPFS staging 또는 별도 range store를 선택하고 backend +File/Object Server와 validator/range 계약을 맞춘다. + +### 12.2 private response cache + +private/auth/account representation은 public Cache Storage adapter에서 계속 +fail-closed한다. offline private data가 제품 요구면 별도 설계가 최소 다음을 +소유해야 한다. + +- current authorization과 server source-of-truth +- opaque account partition +- logout/account-deletion purge authority +- TTL/revalidation/revocation +- offline disclosure threat model +- export/recovery +- XSS가 same-origin key를 사용할 수 있다는 한계 + +client-side encryption만으로 authorization boundary를 만들었다고 주장하지 않는다. +security/privacy 승인이 없으면 network-only다. + +## 13. fault and recovery matrix + +| fault | fail-closed 결과 | recovery | +| --- | --- | --- | +| estimate unavailable | `UNKNOWN` | essential만 policy budget 내 허용, speculative cache defer | +| pressure/critical | admission 제한 | bounded GC, sync/export 안내 | +| first quota failure | transaction/candidate rollback | eligible GC 후 exact operation 최대 1회 retry | +| second quota failure | 신규 write 중지 | read-only/online-only/export-required | +| partial sentinel mismatch | `STORAGE_EVICTED`/`CORRUPT_DATA` | reconstructable rehydrate, local-first quarantine | +| 모든 local marker 소실 | first install과 구분 불가 | backend epoch가 있으면 비교, 없으면 정직한 degraded UX | +| migration crash | old committed generation 유지 | checkpoint부터 resume/reconcile | +| future schema | `INCOMPATIBLE` | N-1 destructive write 금지, online-only/read-only | +| OPFS real probe fail | `DEGRADED/UNAVAILABLE` | async fallback probe 또는 online-only | +| Cache cleanup deadline | partial success + cursor | 다음 bounded invocation | +| corrupt active pointer | cleanup/interception 중지 | network-only, verified recovery tool | +| preview pixel/decode limit | `LIMIT_EXCEEDED/POLICY_REJECTED` | attachment-only 또는 reselect | +| persistent permission revoked | `PERMISSION_DENIED` | 명시적 reselect/re-authorize | +| SW old/new incompatibility | activation 중지 | old active 유지 또는 verified previous 재활성화 | + +## 14. observability + +허용: + +- capability/lifecycle/runtime-health 상태 +- operation과 closed failure code +- pressure/byte/count/duration bucket +- migration version ID와 processed/remaining bucket +- GC deadline/more-available 여부 +- probe phase와 capability boolean +- release registry ID처럼 registry-owned non-user identifier + +금지: + +- filename, directory path, object ID, account/tenant ID +- URL/query/request/response body +- exact digest, ETag, raw cache/DB/path name +- handle, capability receipt, authority proof +- native exception message/stack +- exact usage/quota로 사용자의 device storage를 fingerprint하는 event + +## 15. rollout and rollback + +### 15.1 구현 순서 + +1. lifecycle policy/registry와 deterministic state machine +2. bounded maintenance page/cursor 계약 +3. cross-store admission + injected fault adapters +4. OPFS real preflight +5. OPFS/Cache historical migration fixtures와 runtime +6. preview safety probe +7. optional capability는 제품 선택 후 별도 branch에서 구현 + +새 runtime은 구현과 evidence가 끝나도 skeleton에서는 +`AVAILABLE_NOT_COMPOSED`로 종료한다. + +### 15.2 제품 승격 + +```text +owner/policy와 필요한 경우 backend authority/re-sync 결정 + -> registry 및 immutable composition + -> primaryStatus=COMPOSED, TrafficAdmission=DISABLED + -> real browser readiness/shadow inspection + -> RuntimeHealth=AVAILABLE 또는 승인된 DEGRADED + -> PromotionEvidence=COMPLETE + -> TrafficAdmission=CANARY (reconstructable dataset) + -> TrafficAdmission=CANARY (user-authored write) + -> migration/rollback drill + -> TrafficAdmission=ENABLED +``` + +user-authored/local-first를 reconstructable cache보다 먼저 canary하지 않는다. + +### 15.3 rollback + +1. 신규 write, migration, cache activation과 SW update를 disable한다. +2. in-flight operation을 abort/drain하고 native truth를 reconcile한다. +3. current schema를 읽을 수 있는 bundle은 read-only로 유지한다. +4. N-1이 future schema면 online-only/export-required로 전환한다. +5. previous verified static cache가 있으면 explicit activation으로 rollback한다. +6. user-authored data는 sync/export 확인 없이 purge하지 않는다. +7. old physical/cache generation은 rollback window와 client drain 뒤 bounded + maintenance로 정리한다. + +## 16. test and promotion evidence + +### 16.1 deterministic tests + +- threshold/hysteresis와 concurrent admission +- pressure leader lock loss, abort, timeout +- first quota failure -> GC -> exact one retry +- retry가 non-idempotent/partial commit/second failure에서 차단됨 +- GC ordering과 user-authored non-eviction +- sentinel partial mismatch와 all-marker-loss ambiguity +- migration batch crash/resume/replay/fencing +- OPFS v1->v2 copy/verify/publish/cleanup fault +- Cache candidate failure, pointer corruption, rollback과 cursor expiry +- cleanup/inspect count/deadline absolute ceiling +- preview hostile dimensions, integer overflow, animation, truncated container와 decode +- permission denied/revoked/stale persistent handle +- redaction과 dependency snapshot mutation + +### 16.2 real browser tests + +Chromium, Firefox와 WebKit에서 지원 범위를 명시하고 skip을 success로 세지 않는다. + +- StorageManager estimate/persist denial +- native IndexedDB/OPFS/Cache quota exception mapping +- DedicatedWorker + Web Lock + OPFS write/read/delete probe +- two-tab migration/maintenance serialization +- versionchange/future schema and N-1 read-only +- actual Cache stage/activate/previous rollback/controlled client drain +- storage clear 뒤 explicit degraded behavior +- file preview real static decode/cleanup +- directory/handle은 지원 engine + OS manual evidence + +quota를 실제로 완전히 채우는 flaky test는 유일한 gate로 쓰지 않는다. deterministic +fault injection과 실제 small-operation smoke를 함께 보존한다. + +### 16.3 promotion artifact + +artifact는 다음을 포함한다. + +- release/commit, browser/OS/image +- policy/registry/migration suite version과 hash +- runtime lifecycle/health/admission +- deterministic + native pass/fail/skip +- historical fixture N-1/N/N+1 결과 +- rollback drill과 recovery runbook link +- evidence expiry와 waiver + +필수 engine skip, expired evidence, migration fixture 누락, quota retry invariant 위반, +preview decode safety 누락 또는 user-authored auto-delete가 있으면 promotion을 +차단한다. + +## 17. 완료 기준 + +이 결정의 공통 runtime 구현은 다음을 모두 만족해야 +`AVAILABLE_NOT_COMPOSED`로 완료된다. + +- origin coordinator가 immutable registry, 다섯 primary status literal과 독립된 + selection/admission/health/evidence 축을 강제 +- pressure hysteresis, bounded GC와 exact one-retry가 executable test로 검증 +- all-marker-loss ambiguity를 API/result/문서에서 숨기지 않음 +- OPFS real preflight가 worker/lock/journal/write/read/delete/cleanup을 검증 +- OPFS/Cache forward migration과 N-1 rollback historical fixture 통과 +- Cache inspect/cleanup이 cursor/count/deadline 상한을 강제 +- static Cache와 optional Service Worker composition이 import/bundle 경계로 분리 +- preview가 pixel/decoded-byte/animation/decode limit을 object URL 전에 강제 +- directory/persistent/drop이 transient picker에 암묵적으로 추가되지 않음 +- Range download와 private/sparse Range cache가 서로도 별도 capability로 + 남고 public cache가 둘을 계속 거부 +- Chromium/Firefox/WebKit의 required evidence와 recovery/rollback drill 완성 +- default production build에는 선택되지 않은 runtime, worker, DB open, listener, + timer가 없음 + +이 기준 전에는 기존 `AVAILABLE_NOT_COMPOSED` runtime 일부가 존재하더라도 origin +storage lifecycle 전체를 production-ready 또는 `COMPOSED`라고 부르지 않는다. diff --git a/docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md b/docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md new file mode 100644 index 0000000..1c72b4d --- /dev/null +++ b/docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md @@ -0,0 +1,638 @@ +# VD-16: Browser transfer composition과 Image delivery + +- 상태: Accepted — design complete, implementation pending +- 결정일: 2026-07-28 +- 이 ADR이 선택한 common delta의 current status: + `DESIGNED_NOT_IMPLEMENTED` +- common delta의 목표 reference status: + `AVAILABLE_NOT_COMPOSED` +- 관련 결정: VD-10, VD-11, VD-12, VD-13, VD-14, VD-15 +- current status ledger: + `docs/architecture/browser-data-capability-completion-ledger.md` +- 재검토: 첫 product upload/download/Image CDN capability를 조합하기 전 + +## 배경 + +현재 저장소에는 File runtime, presigned capability provider/vault/executor, +multipart/resumable upload, one-shot streaming download와 Image CDN verification +runtime의 개별 factory가 있다. 이 구현들은 production bootstrap에서 제거돼 +있고 각각의 local `close()` 또는 `dispose()`만 제공한다. + +제품에서 이들을 직접 조합하면 다음 문제가 생긴다. + +- account/session이 바뀌어도 이전 capability, checkpoint, refresh 또는 async + completion이 살아남을 수 있다. +- 서로 다른 config가 같은 byte/resource/preset을 다르게 해석할 수 있다. +- 일부 provider만 생성된 partial runtime이 요청을 받기 시작할 수 있다. +- upload, download와 image에 retry, kill switch, deadline과 observation owner가 + 중복될 수 있다. +- Image engine은 이미 decode된 server descriptor를 받으므로 BFF transport, + expiry refresh와 presentation handoff의 책임이 비어 있다. +- 실제 provider가 frontend mock과 같은 계약을 지키는지 재사용 가능한 + conformance harness가 없다. + +이 결정은 도메인별 upload 화면이나 cloud vendor를 공통 플랫폼에 넣지 않는다. +선택된 capability를 안전하게 조립·폐기하는 composition protocol과 Image +descriptor acquisition 경계를 정한다. + +## 현재 상태 + +| 항목 | 상태 | 설명 | +| --- | --- | --- | +| 개별 presigned/upload/image runtime | `AVAILABLE_NOT_COMPOSED` | factory와 deterministic test가 있으나 제품 graph에는 없음 | +| one-shot streaming download | `AVAILABLE_NOT_COMPOSED` | Range resume가 아닌 전체 객체 스트림 | +| top-level transfer runtime | `DESIGNED_NOT_IMPLEMENTED` | export index만 있고 atomic factory/readiness/lifecycle 없음 | +| Image descriptor HTTP provider | `DESIGNED_NOT_IMPLEMENTED` | caller가 `BackendIssuedImageAsset`을 직접 전달 | +| safe image DOM projection | `DESIGNED_NOT_IMPLEMENTED` | presentation descriptor는 있으나 renderer boundary 없음 | +| app-managed background download | `NOT_SELECTED` | VD-14의 별도 optional capability | +| cross-browser background-download guarantee | `PLATFORM_LIMITED` | browser-managed handoff가 기본 fallback | +| app-managed background upload | `NOT_SELECTED` | durable source staging/worker auth protocol이 별도로 필요 | +| cross-browser background-upload guarantee | `PLATFORM_LIMITED` | worker lifetime/local source permission을 공통 보장할 수 없음 | + +이 ADR을 추가해도 위 상태는 자동으로 바뀌지 않는다. port, runtime, test와 +removal evidence가 구현된 뒤에만 reference 상태를 올린다. + +## 결정 + +### 1. 하나의 account-scoped composition owner + +선택된 file/transfer/image capability는 +`BrowserTransferRuntimeComposition` 역할의 단일 owner가 다음 순서로 생성한다. + +```text +parse immutable config + -> validate implementation ceilings + -> obtain immutable session/account scope + -> create policy registries + -> create provider transports + -> create capability vaults + -> create checkpoint/lock/channel owners + -> create file/upload/download/image runtimes + -> run required compatibility probes + -> publish READY facade atomically +``` + +factory가 중간에 실패하면 생성된 owner를 역순으로 닫고 facade를 반환하지 않는다. +partial runtime, degraded provider 또는 mutable config를 application에 노출하지 +않는다. + +composition은 다음 두 종류를 반환하는 union이어야 한다. + +```text +READY { + generation, + capabilities, + application facades, + readiness, + close() +} + +UNAVAILABLE { + safe reason, + retryability, + fallback capability, + disposePartial() +} +``` + +`UNAVAILABLE`에 provider URL, raw browser exception, account/tenant ID 또는 +credential을 넣지 않는다. + +### 2. Runtime config는 closed schema다 + +config는 composition root만 읽고 깊은 snapshot/freeze한다. 최소한 다음 +registry-owned reference를 갖는다. + +- config schema version과 runtime compatibility version +- opaque session/account scope와 generation +- application origin과 fixed BFF endpoint IDs +- exact `PRESIGNED_TRANSFER_V1`, `PRESIGNED_MULTIPART_V1`, + `RANGE_RESUMABLE_DOWNLOAD_V1`, `IMAGE_CDN_DESCRIPTOR_V1` 중 선택한 protocol + registry와 fixed endpoint map +- upload purpose/profile, part/concurrency/retry/deadline hard ceiling +- download profile, size/integrity/strategy와 Range capability selection +- checkpoint namespace, retention, inventory와 maintenance budget +- lock/cancel transport selection과 unsupported outcome +- Image issuer/origin/preset/key/probe/decode policy +- descriptor refresh lead time, request/decode deadline와 concurrency +- capability별 traffic admission과 kill switch +- observation sink와 redaction policy +- active-operation drain deadline + +caller는 raw endpoint, URL, header, object key, transform, retry count, byte ceiling, +cache policy 또는 account partition을 request마다 override할 수 없다. + +config는 구현 절대 상한을 높일 수 없다. 구현 상한보다 큰 값, 중복 registry ID, +same-origin private Image CDN, 모순되는 fallback 또는 provider 누락은 startup에서 +fail-closed한다. + +### 3. Lifecycle state machine + +top-level runtime은 다음 상태만 가진다. + +```text +CREATING + -> PROBING + -> READY + -> DRAINING + -> CLOSED + +CREATING | PROBING + -> FAILED + -> CLOSED +``` + +- `READY`만 새 operation을 받는다. +- `DRAINING`은 새 operation을 거절하고 진행 중 operation에 bounded deadline을 + 제공한다. +- deadline 뒤 남은 operation은 runtime lifetime signal로 abort한다. +- `close()`는 terminal/idempotent이며 `DRAINING/CLOSED`에서 반복 호출해도 + 새 side effect를 만들지 않는다. +- 닫힌 runtime은 reopen하지 않는다. 새 config/scope에는 새 generation을 만든다. +- operation은 시작할 때 runtime generation과 account scope snapshot을 얻고 + 모든 async boundary와 terminal commit 전에 다시 확인한다. +- 늦게 끝난 fetch, hash, IndexedDB transaction, image verification 또는 decode가 + old generation이면 결과를 폐기하고 native resource를 닫는다. + +### 4. Logout과 account/tenant switch + +session owner notification이 authority다. BroadcastChannel, storage event, +capability expiry 또는 page unload를 logout authority로 사용하지 않는다. + +```text +session owner announces local revoke + -> traffic admission CLOSED + -> runtime generation FENCED + -> reject new operations + -> signal active reads/fetch/backoff/probes + -> bounded drain + -> close capability and image vaults + -> close cancel channels and release locks/connections + -> apply checkpoint retention/purge policy with exact old scope + -> dispose observations + -> CLOSED + -> construct new scope/runtime independently +``` + +- old-scope purge와 new-scope open을 같은 transaction이나 facade에 섞지 않는다. +- checkpoint가 crash 때문에 남아도 exact scope/policy binding이 다르면 새 + runtime이 읽지 못해야 한다. +- presigned URL, signed headers와 Image private URL은 어떤 teardown record에도 + 저장하지 않는다. +- old account의 descriptor refresh, part completion과 download destination + commit은 generation fence 뒤 성공으로 보고하지 않는다. +- logout이 backend capability의 즉시 revoke를 보장하지 않는다. 강한 회수가 + 필요하면 BFF가 revoke authority 또는 proxy/relay를 제공해야 한다. + +### 5. Capability별 facade + +application에는 top-level runtime 자체나 native adapter를 반환하지 않는다. +composition은 설치된 feature에 필요한 좁은 facade만 주입한다. + +```text +FeatureUploadFacade + -> select local file profile + -> create/resume/pause/abort approved purpose + +FeatureDownloadFacade + -> request approved resource + -> receive policy-selected handoff/save outcome + +FeatureImageFacade + -> request opaque asset/preset + -> receive safe presentation descriptor +``` + +feature는 `File`, `Blob`, `Response`, `ReadableStream`, `FileSystemHandle`, +presigned URL, Image signature DTO, checkpoint store 또는 QueryClient를 받지 않는다. +progress UI를 위한 observation도 bounded aggregate snapshot이며 transfer +authority가 아니다. + +## Upload lifecycle integration + +### 6. Pause와 abort는 다르다 + +top-level upload facade가 향후 제공할 상태는 다음과 같다. + +```text +ACTIVE + -> PAUSE_REQUESTED + -> PAUSED + -> RESUMING + -> ACTIVE + +ACTIVE | PAUSED + -> ABORT_REQUESTED + -> ABORT_PENDING + -> ABORTED + +ACTIVE + -> COMPLETING + -> QUARANTINED +``` + +- pause는 새 part와 retry를 중지하고 현재 bounded native operation을 abort한 뒤 + non-authorizing checkpoint를 유지한다. +- cross-context pause wire literal은 `RESUMABLE_UPLOAD_PAUSE_V1`이며 + `uploadKey`, exact scope/generation과 bounded message metadata만 운반한다. +- durable `PAUSED`를 추가하는 checkpoint는 `schemaVersion: 2`다. v1 + `ACTIVE | ABORT_PENDING` reader/writer와 섞지 않고 old-writer drain, + historical migration과 N-1 fail-closed를 증명한다. +- abort는 server session authority와 reconcile한 뒤 terminal checkpoint를 + 제거한다. +- pause signal은 authority가 아니라 best-effort same-scope hint다. 수신자는 + exact upload key/scope/generation을 검증한다. +- checkpoint inventory는 presigned URL이나 raw file path 없이 opaque upload + key, safe state, age/byte/part bucket과 expiry만 반환한다. +- inventory/list와 retention sweep은 count, cursor, deadline을 갖는다. +- abandoned/expired checkpoint는 server status 또는 expiry policy와 CAS를 + 확인한 뒤 bounded batch로 제거한다. +- file 재선택 뒤 source fingerprint/size/media/part layout이 exact하게 맞지 + 않으면 resume하지 않는다. + +app-managed background upload는 이 state machine을 재사용할 수 있지만 page +runtime의 pause/resume를 background 보장으로 표현하지 않는다. + +## Download integration + +### 7. Strategy selector + +download strategy는 application caller가 지정하지 않고 immutable profile, +resource delivery class, expected bytes, browser capability와 user activation을 +입력으로 하는 headless selector가 결정한다. + +| 조건 | 결과 | +| --- | --- | +| save picker 지원, user activation 있음, large stream | `WHOLE_OBJECT_PICKER_STREAM` | +| server-managed resource, picker 없음 또는 handoff가 제품 정책 | `BROWSER_MANAGED_HANDOFF` | +| generated artifact가 approved Blob cap 이하 | `BOUNDED_OBJECT_URL` | +| large generated artifact, picker 없음 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` | +| Range profile + seekable destination + provider contract | `RANGE_RESUMABLE_FOREGROUND` | +| background download가 선택되고 지원되는 browser + owned staging | `APP_MANAGED_BACKGROUND_DOWNLOAD` | + +selector는 capability probe와 actual invocation failure를 구분한다. +`WHOLE_OBJECT_PICKER_STREAM`인데 picker가 없으면 request validation 오류가 아니라 +`UNSUPPORTED` 또는 승인 fallback이어야 한다. Safari/WebView/private mode의 +fallback도 동일 표에서 결정하며 user agent 문자열만으로 기능을 가정하지 않는다. + +selector의 위 값은 application-level `DownloadExecutionPlan`이다. 현재 +file-delivery primitive와의 mapping은 다음처럼 닫는다. + +| execution plan | 현재 adapter mapping | +| --- | --- | +| `WHOLE_OBJECT_PICKER_STREAM` | `DownloadStrategy=PROMPT_AND_STREAM` | +| `BROWSER_MANAGED_HANDOFF` | source kind `BROWSER_MANAGED_RESOURCE` + `DownloadStrategy=BROWSER_MANAGED` | +| `BOUNDED_OBJECT_URL` | `DownloadStrategy=BOUNDED_OBJECT_URL` | +| `RANGE_RESUMABLE_FOREGROUND` | VD-14의 별도 future port; 현재 adapter에 mapping 금지 | +| `APP_MANAGED_BACKGROUND_DOWNLOAD` | 별도 optional worker/staging port | +| `SERVER_GENERATION_REQUIRED` / `UNSUPPORTED` | browser delivery를 시작하지 않는 closed outcome | + +Range resume의 checkpoint, validator와 seek/truncate 결정은 VD-14를 따른다. +app-managed background download는 기본 selector 결과가 아니다. + +## Image descriptor acquisition과 delivery + +### 8. Wire protocol + +private Image descriptor BFF 계약은 다음 literal을 사용한다. + +```text +IMAGE_CDN_DESCRIPTOR_V1 +``` + +request는 composition-owned fixed HTTPS endpoint를 호출하며 최소한 다음 +application-safe 입력만 허용한다. + +- protocol +- opaque asset reference +- named preset reference 또는 preset family +- intended presentation class +- current runtime generation에 묶인 CSRF/session transport + +caller는 CDN URL, source URL, origin, object key, width, height, DPR, quality, fit, +format, cache header, signing key ID나 expiry를 제출하지 않는다. + +response decoder는 content type, status, header/body byte cap, total deadline와 +closed JSON shape를 검증한다. response에는 최소한 다음이 binding된다. + +- exact protocol과 issuer +- opaque asset ID와 immutable revision +- origin/preset binding IDs +- static raster media와 intrinsic dimensions +- allowed preset binding ID set +- issued/expiry time +- signature algorithm, key ID, canonical binding digest와 signature + +unknown field 정책은 protocol version에서 고정한다. credential, backend stack, +raw provider key 또는 arbitrary transform은 descriptor에 포함하지 않는다. + +HTTP `200`만 descriptor success다. `401/403/404`의 외부 mapping은 existence +hiding 정책에 따라 closed failure로 정규화하며 raw backend message를 버린다. +redirect, opaque response, wrong content type, oversize, timeout와 malformed +descriptor는 capability를 생성하지 않는다. + +### 9. Provider와 verifier 경계 + +- BFF는 authorization, asset existence, quarantine/promotion state와 descriptor + 발급 authority를 소유한다. +- verifier registry는 composition이 승인한 bounded old/new public key set만 + 가진다. +- client signature 검증은 BFF authorization의 대체가 아니라 response tamper와 + registry mismatch를 fail-closed하는 보조 경계다. +- CDN은 asset revision과 preset binding ID로 exact transform candidate를 + 재계산한다. signed query나 client 계산 width가 authority가 아니다. +- private asset의 emergency revocation은 backend/CDN/BFF가 소유한다. client는 + runtime close와 short expiry로 exposure를 줄인다. + +### 10. Refresh state machine + +descriptor provider는 asset/preset/scope/generation별 bounded single-flight만 +허용한다. + +```text +ABSENT + -> FETCHING + -> VERIFIED + -> FRESH + -> REFRESH_DUE + -> REFRESHING + -> FRESH + +FETCHING | REFRESHING + -> TERMINAL_POLICY_FAILURE + -> PLACEHOLDER + +FETCHING | REFRESHING + -> RETRYABLE_FAILURE + -> EXISTING_FRESH_UNTIL_EXPIRY | PLACEHOLDER + +any state + scope/runtime revoke + -> REVOKED +``` + +- refresh lead time은 config가 정하되 expiry hard ceiling을 넘지 않는다. +- private descriptor를 generic Query cache, Web Storage 또는 IndexedDB에 + persistence하지 않는다. +- concurrent callers는 같은 verified result를 받을 수 있지만 URL/string을 + application state에 장기 복사하지 않는다. +- 기존 descriptor가 아직 fresh하고 refresh가 일시 실패하면 expiry까지만 + 사용할 수 있다. expiry 뒤 stale-while-error를 금지한다. +- `lazy` load로 실제 fetch가 expiry 뒤 시작될 가능성이 있으면 eager/priority로 + 바꾸거나 load 직전에 새 descriptor를 발급한다. +- logout, key registry replacement와 runtime generation 변경은 in-flight + transport, verification과 probe를 abort하고 늦은 결과를 폐기한다. + +### 11. Safe presentation projection + +공통 presentation primitive는 검증된 `ImagePresentationDescriptor`를 다음 +정적 속성으로만 투영한다. + +- fallback `src` +- ordered `` +- registry-owned `sizes` +- intrinsic `width`와 `height` +- `loading`, `decoding`, `fetchpriority` +- `referrerpolicy` +- `crossorigin="anonymous"` + +primitive는 URL을 parse·조립·append하거나 transform query를 생성하지 않는다. +descriptor가 가진 string을 React property로 전달하기 전에 closed allowed +protocol/origin과 runtime generation을 다시 확인한다. raw HTML 주입과 CSS URL +조립을 금지한다. + +다음은 제품 presentation owner가 결정한다. + +- 의미 있는 `alt` +- placeholder와 오류 copy +- skeleton/aspect-ratio UX +- above-the-fold preload/priority +- route/SSR preload hint +- click/open/download behavior + +descriptor refresh 실패를 native broken-image UI에만 맡기지 않고 제품이 승인한 +placeholder outcome으로 매핑한다. + +## Provider contract harness + +### 12. 재사용 가능한 suite + +frontend는 transport 구현과 분리된 provider contract harness를 제공한다. 같은 +case set을 deterministic fake, local emulator와 실제 BFF/object storage/CDN에 +실행한다. + +Presigned/download case: + +- exact protocol/status/content type/body cap +- method/origin/path/query/header binding +- redirect와 credential omission +- expiry/revocation/replay +- truncation/overrun/content encoding +- CORS exposed receipt/checksum + +Multipart case: + +- create/status/part/complete/abort idempotency +- part layout/checksum/receipt reconciliation +- 404/410/expiry와 orphan cleanup +- quarantine/promotion +- retry-after와 ambiguous completion + +Image case: + +- protocol/issuer/key/preset exact match +- old/new signing key overlap과 removal +- immutable revision/cache key +- private no-store/CORS/CSP +- pixel/decode/encoded byte ceiling +- malformed/animated/active content +- expiry refresh, revocation과 placeholder + +actual provider test는 bearer URL, signature, account/asset/session ID를 artifact에 +기록하지 않는다. fixture는 synthetic opaque values와 disposable storage를 쓴다. + +## Failure, readiness와 fallback + +### 13. Readiness report + +readiness는 application-safe capability별 결과다. + +| 상태 | 의미 | +| --- | --- | +| `READY` | 필수 provider/config/browser probe가 모두 유효 | +| `DEGRADED` | 승인된 좁은 fallback만 가능 | +| `UNAVAILABLE` | 기능을 노출하지 않음 | +| `DRAINING` | 기존 작업만 정리 중 | +| `CLOSED` | terminal | + +`DEGRADED`는 byte/pixel/security ceiling을 낮출 수는 있지만 높이지 않는다. +예를 들어 enhanced picker off → native input, multipart concurrency off → +sequential, private Image CDN off → approved placeholder는 가능하다. +integrity off, arbitrary URL 허용, private response cache 또는 unbounded Blob은 +fallback이 아니다. + +### 14. Kill switch + +최소한 다음 switch를 독립적으로 둔다. + +- new presigned issuance +- direct object-storage data plane +- new upload session +- upload resume +- upload complete +- Range resume +- picker streaming save +- private image descriptor issuance +- advanced image format +- responsive candidates + +switch 변경은 active operation의 의미를 소급 변경하지 않는다. 신규 진입을 +닫은 뒤 reconcile/drain한다. remote runtime config를 사용한다면 config의 +authenticity, release compatibility와 last-known-safe 정책을 별도 hosting 계약으로 +검증한다. + +## 관측성과 개인정보 + +허용: + +- operation kind와 safe outcome +- runtime/readiness state +- byte/part/candidate/retry/age/deadline bucket +- policy rejection, abort, reconcile와 drain bucket +- aggregate active count, checkpoint count와 orphan age + +금지: + +- URL, query, signed/request/response header +- capability, bearer token, signature와 key material +- resource/session/asset/upload/account/tenant ID +- file name, local path, object key와 storage physical key +- digest, ETag, receipt와 checkpoint payload +- raw backend/browser exception message와 stack + +runtime generation과 registry ID도 외부 telemetry에 그대로 보내지 않고 bounded +compatibility bucket으로 변환한다. + +## Rollout과 rollback + +### 15. Rollout + +1. ledger와 관련 ADR을 accepted로 고정한다. +2. closed config/port와 provider contract harness를 먼저 구현한다. +3. fake와 negative fixture에서 partial composition/late result를 거절한다. +4. top-level runtime을 `AVAILABLE_NOT_COMPOSED`로 유지하고 removal gate를 만든다. +5. product owner, exact scope와 provider config를 선택하고 bootstrap에 kill + switch `DISABLED` 상태로 조합한다. 이 시점 primary status는 `COMPOSED`다. +6. 실제 BFF/storage/CDN conformance와 readiness probe를 통과한다. +7. Chromium/Firefox/WebKit과 실제 device에서 account switch, expiry와 crash를 + 검증한다. +8. runbook/rollback drill 뒤 internal cohort의 read-only/image public 또는 upload + shadow flow부터 연다. +9. private/image upload/download capability를 독립 canary와 kill switch로 확대한다. + +### 16. Rollback + +1. 신규 issuance/session/descriptor를 중지한다. +2. runtime을 `DRAINING`으로 바꾸고 bounded operation을 마무리한다. +3. ambiguous upload는 server reconcile하고 Range partial은 checkpoint 정책대로 + 보존 또는 삭제한다. +4. private capability를 backend에서 revoke하고 client runtime을 close한다. +5. old compatible composition을 새 generation으로 다시 생성하거나 기능을 + unavailable로 유지한다. +6. optional source, config와 facade를 제거하고 production module inventory와 + removal gate를 재검증한다. + +schema version을 내리거나 checkpoint를 무조건 삭제해 rollback하지 않는다. + +## 검증과 완료 기준 + +### 17. Deterministic + +- partial factory failure의 reverse-order cleanup +- close idempotency와 closed-runtime rejection +- account switch 중 late fetch/hash/transaction/decode drop +- config duplicate/ceiling/missing provider fail-closed +- selector의 picker/size/resource matrix +- upload pause/abort/reconcile race와 bounded inventory +- descriptor refresh single-flight, expiry와 generation fence +- picture projection의 arbitrary URL/query 생성 0건 +- diagnostics forbidden-value negative fixtures + +### 18. Native browser + +- Chromium/Firefox/WebKit의 native input/save picker fallback +- multi-tab upload pause/cancel과 unsupported Web Locks path +- page reload/account switch 중 active transfer drain +- public/private Image fetch, actual CORS/no-store와 bitmap decode +- offline/timeout/abort/late completion cleanup +- Safari/WebView/private mode의 approved selector result + +### 19. Provider와 operations + +- fake/emulator/실제 BFF·object storage·CDN 동일 contract suite +- signing key rotation과 emergency revoke drill +- checkpoint retention/orphan cleanup drill +- capability별 kill switch와 N-1 rollback +- runtime removal 후 source-module inventory 0건 + +다음 조건 전에는 목표 상태를 `AVAILABLE_NOT_COMPOSED`로 올리지 않는다. + +- [ ] top-level closed config와 atomic factory가 구현됐다. +- [ ] generation-bound lifecycle과 account teardown이 구현됐다. +- [ ] `RESUMABLE_UPLOAD_PAUSE_V1`, checkpoint schema v2 old-writer + migration과 bounded upload inventory/retention owner가 구현됐다. +- [ ] `PRESIGNED_TRANSFER_V1`과 선택 protocol의 strict codec, unknown-version + rejection 및 reusable provider contract harness가 구현됐다. +- [ ] strategy selector가 browser fallback을 fail-closed한다. +- [ ] Image descriptor provider/refresh와 safe projection이 구현됐다. +- [ ] deterministic fault, boundary, removal test가 통과한다. + +다음 조건 전에는 제품 상태를 `COMPOSED`로 올리지 않는다. + +- [ ] product owner와 opaque account scope가 정해졌다. +- [ ] strict config를 사용하는 top-level runtime이 production bootstrap에서 + 생성되고 실제 feature facade consumer까지 연결됐다. +- [ ] traffic 기본값이 `DISABLED`이며 local teardown/close 경로가 연결됐다. + +다음 조건 전에는 product-local production traffic을 승인하지 않는다. + +- [ ] actual BFF/storage/CDN contract harness가 통과한다. +- [ ] browser/device evidence와 runbook drill이 보존됐다. +- [ ] kill switch와 rollback owner가 운영 승인됐다. + +## 관련 문서 + +- [Browser data capability completion ledger](../browser-data-capability-completion-ledger.md) +- [Presigned transfer and Image CDN](../presigned-transfer-and-image-cdn.md) +- [VD-14 Resumable download와 background download](./VD-14-resumable-download-and-background-transfer.md) +- [Server file capability infrastructure](../server-file-capability-infrastructure.md) +- [Browser transfer recovery](../../operations/browser-transfer-recovery.md) + +## 선택하지 않은 대안 + +- feature가 개별 transfer adapter factory를 직접 조합 +- singleton runtime을 여러 account/tenant가 공유 +- raw presigned/Image URL을 Query cache나 persistence에 저장 +- Image descriptor endpoint/transform을 request마다 caller가 지정 +- page abort를 upload pause 또는 server abort 완료로 간주 +- browser-managed handoff를 저장 완료로 간주 +- user-agent 문자열만으로 Safari fallback 결정 +- Service Worker를 설치하면 background upload/download가 보장된다고 가정 +- capability 일부만 준비된 partial runtime을 degraded success로 반환 + +## 결과 + +장점: + +- account 전환과 teardown의 한 owner가 생긴다. +- 개별 runtime의 안전한 메커니즘을 제품별 facade로 좁혀 조합할 수 있다. +- provider mock과 실제 인프라 사이의 계약 차이를 같은 suite로 찾을 수 있다. +- Image URL과 transform이 application/presentation에서 재조립되지 않는다. +- capability별 rollout, kill switch와 제거가 독립적이다. + +비용: + +- config, lifecycle, provider harness와 browser evidence가 늘어난다. +- 제품이 선택하지 않은 capability는 여전히 조합할 수 없으며 이것이 의도된 + 결과다. +- actual provider와 운영 증거 없이는 reference runtime 구현만으로 production + 완료를 주장할 수 없다. diff --git a/docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md b/docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md new file mode 100644 index 0000000..4896069 --- /dev/null +++ b/docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md @@ -0,0 +1,729 @@ +# VD-23: API transport selection과 REST execution + +- 상태: Accepted — REST v2 security/execution baseline composed, advanced profiles pending +- 결정일: 2026-07-28 +- installed REST reference vertical: `COMPOSED` +- REST v2 security/execution baseline: `COMPOSED` +- provider/path/auth profile baseline: `COMPOSED` +- conditional execution/provider-conformance delta: `DESIGNED_NOT_IMPLEMENTED` +- GraphQL/Connect/gRPC-Web/REST Gateway product selection: `NOT_SELECTED` +- 관련 결정: VD-13, VD-24, VD-25, VD-26, VD-27, VD-29, VD-30 +- 상세 설계: + [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) +- browser Protobuf/gateway 설계: + [Protobuf browser transport와 REST Gateway](../protobuf-browser-transport-and-rest-gateway.md) + +## 배경 + +현재 reference feature는 operation registry, request Zod schema, shared HTTP +executor, response envelope/payload schema, mapper, gateway, application input과 +TanStack Query까지 실제 production composition에 연결된다. 따라서 REST 경로 +자체를 미구현으로 표시하지 않는다. + +현재 reference operation은 v2 metadata, collision-aware composition, allowlisted +credential patch, auth fail-before-fetch, prefix-preserving target, shared logical +deadline, exact JSON media/success status, bounded decoder와 typed mapping failure를 +실행한다. correlation header, success status group과 401 replay를 포함한 physical +attempt도 terminal observation에 연결한다. + +operation은 path placeholder↔codec key exact join, named provider/bearer/CSRF +profile, provider credential-mode ceiling과 encoded query byte ceiling도 실행한다. +남은 production delta는 cookie-CSRF/CORS evidence, 204/304/412 conditional +transaction, artifact digest와 실제 provider conformance다. +`API_CONTRACT_VERSION` 문자열 일치만 backend compatibility 증거로 사용하지 않는다. + +## 결정 + +### 1. Protocol 선택은 operation registry가 소유한다 + +feature application port는 protocol-neutral이다. + +```text +feature use case + -> feature gateway + -> exact semantic operation + -> REST adapter + -> persisted GraphQL adapter + -> Connect adapter + -> gRPC-Web adapter +``` + +- 한 `operationId`는 한 protocol에만 binding한다. +- UI, query hook과 use case는 protocol을 선택하지 않는다. +- URL, GraphQL document, service/method와 generated request를 application input으로 + 받지 않는다. +- 같은 command를 provider failure 때문에 다른 protocol로 자동 replay하지 않는다. +- read fallback도 동일 auth/freshness/schema/mapper/query identity와 하나의 total + retry budget을 증명한 registered policy만 허용한다. +- GraphQL/Connect/gRPC-Web adapter를 공통 `HttpClient`의 mode flag로 넣지 않는다. + 공통으로 공유하는 것은 execution context, auth collaboration, failure vocabulary와 + observation뿐이다. + +### 2. REST operation은 실행 source와 정적 manifest를 분리하지 않는다 + +목표 API는 feature가 generic이 연결된 definition을 만든다. + +```text +defineRestOperation< + Path, + Search, + Body, + SuccessWire, + SuccessValue, + Failure +>({ + common, + providerId, + method, + uriTemplate, + pathCodec, + searchCodec, + bodyCodec, + successCodec, + errorCodec, + mapper, + policies +}) +``` + +실행 definition에서 registry manifest를 결정적으로 투영한다. schema metadata, +실제 codec map, operation-specific TypeScript map과 mapper를 서로 다른 string +dispatch table에 수기로 중복하지 않는다. + +manifest 최소 field: + +```text +protocol = REST +registryVersion +providerId +operationId +owner +semantics +method +relativePathTemplate +pathSchemaId +searchSchemaId +bodySchemaId +requestMediaProfile +successStatusProfiles[] +errorStatusProfiles[] +responseSchemaId +mapperId +authProfileId +csrfProfileId +replayPolicy +idempotencyKeyPolicy +deadlineProfileId +retryProfileId +paginationProfileId | null +conditionalProfileId +serverStateProfileId | null +maxRequestBytes +maxDecodedResponseBytes +maxResponseItems +observabilityProfileId +compatibility +``` + +### 3. Contribution composer는 collision 전에 실패한다 + +```text +feature contributions[] + -> preserve every source row + -> validate contribution owner/version + -> detect duplicate operation/schema/mapper/query/topic ID + -> resolve every codec/mapper/profile reference + -> validate semantic coherence + -> freeze installed registry + -> emit compatibility manifest +``` + +object spread의 last-write-wins를 금지한다. duplicate ID가 payload까지 동일해도 +owner를 하나 선택하지 않고 build를 실패시킨다. alias/rename은 versioned migration +row로만 허용한다. + +semantic coherence: + +- `GET`/`HEAD`는 body 없음, `SAFE`만 허용 +- `HEAD`는 body success codec 없음 +- `POST`/`PATCH` retry는 `IDEMPOTENT | KEYED_COMMAND` 또는 명시적 + `SAFE` semantics 필요 +- `KEYED_COMMAND`는 `idempotencyKeyPolicy=REQUIRED`, 다른 replay policy의 key + attach/금지는 exact key profile과 일치 +- `NON_REPLAYABLE`은 retry와 401 replay 금지 +- success/error status가 겹치지 않음 +- 204 profile은 body/schema 없음 +- 304는 query + conditional profile + existing cache binding 필요 +- 412는 precondition profile 필요 +- path placeholder 집합과 path codec key 집합이 exact match +- query/cache profile은 `QUERY` operation에만 연결 +- mutation invalidation은 registered topic만 사용 + +### 4. Provider endpoint와 URI + +application은 provider URL을 받지 않는다. + +```text +RestProviderProfile + providerId + baseOrigin + basePathPrefix + allowedCredentialsModes + corsProfile + referrerPolicy + redirectPolicy = ERROR + defaultHeaders + allowedResponseOrigins +``` + +- non-local은 HTTPS만 허용한다. +- provider는 credential mode ceiling만 제공하고 operation의 auth transport + profile이 exact mode를 선택한다. anonymous는 `omit`, same-origin cookie는 + approved `same-origin`, approved cross-origin cookie만 `include`다. +- base URL의 username, password, query와 fragment를 금지한다. +- exact origin과 canonical base path prefix를 보존한다. +- operation template은 relative API path이며 scheme, authority, query와 fragment를 + 포함하지 않는다. +- leading slash가 base prefix를 제거하는 `new URL()` ambiguity를 쓰지 않는다. +- encoded slash, dot segment, NUL/control, duplicate slash와 overlong path를 + 정책대로 거절한다. +- redirect는 기본 `error`다. 로그인/다운로드 handoff는 일반 JSON REST operation과 + 다른 capability다. + +path parameter: + +- path codec의 parsed output만 encode한다. +- missing, extra, empty와 length 초과 parameter를 network 전에 거절한다. +- Unicode normalization을 업무 ID에 임의 적용하지 않는다. +- path value와 최종 URL은 diagnostics에 기록하지 않는다. + +query: + +- key ordering, repeated-array/comma style, boolean, null/absent/empty semantics를 + profile에 고정한다. +- URL encoded byte ceiling을 적용한다. +- raw `URLSearchParams`, query string과 next URL을 caller에게 받지 않는다. +- sensitive/private value를 GET query에 넣는 operation은 별도 security review가 + 없으면 금지한다. + +### 5. Request projection과 final invariant + +request는 다음 소유 순서로 만든다. + +```text +operation + validated input + -> immutable request binding + -> body canonical serialization + digest + -> transport-owned headers/options + -> constrained auth/CSRF patch + -> final invariant validation + -> fetch +``` + +transport-owned header: + +- `Accept`, `Content-Type` +- contract/media version +- bounded correlation/trace context +- idempotency key +- conditional validator +- approved CSRF header + +caller와 feature mapper가 arbitrary header를 추가하지 않는다. + +auth owner target: + +```text +CredentialPatch + credentialMode + allowlisted header name/value + proof expiry/generation +``` + +operation auth profile은 final Fetch `credentials`를 exact하게 고정한다. +`ANONYMOUS | BEARER_HEADER`는 ambient cookie가 섞이지 않게 `omit`, +same-origin cookie session은 `same-origin`, cross-origin cookie는 별도 CORS/CSRF +provider evidence가 있는 profile만 `include`다. provider ceiling과 맞지 않으면 +fetch 0회다. + +auth owner가 `Request` 전체를 반환하지 않는다. transition 기간에 current port를 +사용한다면 attach 전후의 다음 값이 exact하게 같아야 한다. + +- URL/origin/path/query +- method +- body digest +- content type/length +- correlation, idempotency, conditional와 CSRF binding +- redirect/cache/referrer/credentials/mode + +다르면 `AUTH_INTEGRATION_FAILURE`, fetch 0회다. + +auth-required operation은 session state가 `authenticated`가 아니면 fetch하지 않는다. +`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous +request로 downgrade하지 않는다. + +### 6. Cookie auth, CSRF와 CORS + +same-origin BFF cookie session을 기본 권장한다. + +- cookie는 Secure/HttpOnly이며 provider가 SameSite 정책을 소유한다. +- unsafe method는 server의 exact Origin/Fetch Metadata 검증과 composition-issued + anti-CSRF proof를 요구한다. +- CSRF proof는 application/query/cache에 노출하거나 persistence하지 않는다. +- custom content type/preflight가 있다는 사실만 CSRF 방어로 간주하지 않는다. + +cross-origin profile은 다음을 actual provider에서 증명한다. + +- exact `Access-Control-Allow-Origin`, wildcard 금지 +- credentials mode와 allow-credentials 일치 +- exact allow-method/allow-header +- 필요한 request ID, ETag, Retry-After만 expose +- OPTIONS와 actual response의 policy 동등성 +- redirect 없음 + +### 7. Replay와 idempotency + +```text +ReplayPolicy + SAFE + IDEMPOTENT + KEYED_COMMAND + NON_REPLAYABLE +``` + +- `SAFE`: read-only이며 network/recovery retry 가능 +- `IDEMPOTENT`: 같은 principal/operation/payload/precondition의 반복 request가 + 의도한 server effect를 추가로 만들지 않으며 duplicate response/status mapping을 + backend contract가 명시한다. response byte가 항상 동일하다는 뜻은 아니다. +- `KEYED_COMMAND`: application logical command lease가 key를 생성하고 lifecycle + 전체에서 유지 +- `NON_REPLAYABLE`: ambiguous result에서 자동 재실행 금지 + +`KEYED_COMMAND` binding: + +```text +principal scope +operation ID/version +canonical payload digest +idempotency key +server retention/expiry +``` + +backend는 atomic claim, concurrent same-key join/replay, same-key different-payload +rejection과 terminal receipt를 제공한다. header를 보냈다는 사실만 replay safety가 +아니다. provider conformance가 없으면 retry/401 recovery traffic을 켜지 않는다. + +current client가 execute 호출마다 key를 생성하는 방식은 network attempt 안에서는 +재사용되지만 ambiguous terminal 뒤 사용자 retry와 연결되지 않는다. 목표 command +owner가 effect certainty를 다음처럼 반환한다. + +```text +NOT_APPLIED | COMMITTED | UNKNOWN +``` + +`UNKNOWN`은 새 key 자동 retry가 아니라 status/reconcile 또는 명시적 사용자 복구로 +닫는다. + +### 8. Logical deadline, cancel과 retry + +```text +LogicalExecutionBudget + totalDeadlineMs + attemptTimeoutMs + maxAttempts + maxCumulativeSleepMs + maxRetryAfterMs + authRecoveryCount = 0 | 1 +``` + +total deadline은 다음 모두를 포함한다. + +- operation/schema lookup과 request encode +- credential/CSRF attachment +- fetch attempt +- body read/decode/schema/mapper +- 401 recovery +- retry backoff/Retry-After + +각 phase는 남은 total budget보다 긴 timer를 만들지 않는다. timer, abort listener, +response reader와 auth waiter는 모든 terminal path에서 정리한다. + +초회, network/status retry와 401 recovery replay를 포함한 **모든 API provider +fetch**는 하나의 monotonic `physicalAttemptCount`를 증가시키고 `maxAttempts`를 +소비한다. `authRecoveryCount`는 추가 상한일 뿐 attempt counter, sleep budget이나 +total deadline을 reset하거나 우회하지 않는다. + +retry status는 operation의 exact subset만 허용한다. + +```text +network failure +408 +429 +502 +503 +504 +``` + +- 429와 503의 `Retry-After`를 injected clock으로 parse한다. +- hard ceiling을 넘는 Retry-After는 sleep하지 않고 terminal로 닫는다. +- full jitter와 attempt/sleep/elapsed 세 상한을 모두 적용한다. +- schema, mapper, 4xx validation/authz/conflict와 redirect failure는 retry하지 않는다. +- caller cancel, navigation supersede, runtime teardown, attempt timeout과 logical + deadline을 다른 safe failure로 유지한다. +- 401 recovery는 auth-required이면서 replay-safe한 operation만 한 번 수행한다. +- 동시 401은 session owner의 single-flight recovery를 공유한다. +- physical attempt, logical retry와 auth replay를 따로 관측한다. + +### 9. Closed execution + +public executor는 promise rejection 대신 항상 closed result로 끝난다. + +```text +RestExecutionResult + SUCCESS + VALIDATION_REJECTED + AUTH_REQUIRED | AUTH_INTEGRATION_FAILURE + REQUEST_ABORTED + REQUEST_ATTEMPT_TIMEOUT | REQUEST_DEADLINE_EXCEEDED + NETWORK_UNREACHABLE + RATE_LIMITED | SERVER_FAILURE + HTTP_FAILURE + CONTENT_TYPE_MISMATCH | BODY_LIMIT_EXCEEDED | MALFORMED_BODY + SCHEMA_MISMATCH | MAPPING_CONTRACT_VIOLATION + PRECONDITION_FAILED | CONFLICT + CONTRACT_INCOMPATIBLE +``` + +attempt timer와 전체 logical deadline은 error registry, safe user copy와 telemetry +bucket에서도 별도 closed kind로 유지한다. 둘 다 raw URL/timing detail을 +노출하지 않는다. + +operation lookup, URL construction, `Headers`, body serialization, `Request`, +auth collaboration, fetch, read, parse, schema와 mapper를 모두 catch/normalize +경계 안에 둔다. thrown value/body/header/URL을 failure에 복사하지 않는다. + +### 10. Response admission과 bounded decoder + +body를 `Response.json()`으로 바로 읽지 않는다. + +```text +response + -> final URL/status/header admission + -> exact media type parser + -> present/valid Content-Length advisory preflight + -> bounded stream reader + -> actual browser-visible decoded byte count + -> UTF-8/profile decoder + -> JSON structural ceiling + -> envelope/status codec + -> operation response codec + -> mapper +``` + +browser Fetch의 `Response.body`는 일반적으로 content decoding 뒤 stream이므로 +client가 actual wire/encoded byte를 신뢰성 있게 세었다고 주장하지 않는다. + +- BFF/proxy/CDN가 encoded transfer와 decompression ratio ceiling을 집행한다. +- browser client는 present/valid `Content-Length`를 advisory rejection에만 쓰고 + actual decoded bytes를 hard cap으로 센다. +- 표준 `JSON.parse` profile은 decoded-byte cap이 pre-parse resource guard이고 + depth/node/key/string/item cap은 materialization 뒤 admission guard다. +- 구조 cap을 parse 중 강제해야 하는 더 큰 profile은 bounded tokenizing JSON + parser를 별도로 선택하고 actual browser evidence를 가져야 한다. + +cap 초과, truncation과 invalid UTF-8에서 reader를 cancel하고 cache에 쓰지 않는다. + +media parser는 type/subtype/parameter를 exact하게 해석한다. + +- `application/json` +- approved vendor `application/*+json` +- RFC Problem Details profile +- explicit 204 no-content + +`includes("application/json")` 검사는 목표 계약이 아니다. + +각 success status는 response codec을 가진다. 현재 envelope는 +`REST_ENVELOPE_V1` profile로 유지할 수 있지만 모든 REST provider에 강제하지 +않는다. success status와 error body가 모순되면 status/profile 계약 실패다. + +### 11. Error projection + +backend error는 먼저 status/media별 codec을 통과한다. + +- raw message, stack, body와 arbitrary extensions를 버린다. +- code/category는 operation error profile의 allowlist로 mapping한다. +- unknown backend code는 closed generic failure다. +- validation issue는 최대 count, path/code byte/charset와 allowed path를 제한한다. +- request ID, trace ID와 correlation ID도 length/charset cap을 적용한다. +- `retryable` backend boolean을 client retry authority로 사용하지 않는다. +- 401/403/404 existence-hiding은 provider/product policy를 따른다. +- 409 business conflict와 412 representation precondition failure를 분리한다. + +### 12. Cursor pagination + +```text +CursorPageWire + items + nextCursor | null + hasMore + snapshotToken | null +``` + +response codec은 item count, item size, cursor/snapshot byte와 total decoded ceiling을 +검증한다. mapper는 immutable `CursorPage`을 만든다. + +- cursor는 opaque이며 decode/로그/telemetry 금지 +- arbitrary next URL을 따라가지 않음 +- filters/sort/scope/snapshot과 cursor를 exact binding +- same cursor 반복, `hasMore=true`인데 cursor 없음, non-progress loop 거절 +- maximum pages/items/cache bytes 이후 fetch 중지 +- TanStack infinite query의 root key에는 semantic filter만 넣고 cursor는 bounded + `pageParam`으로 관리 +- offset pagination은 stable small dataset이 증명된 별도 profile만 허용 + +current reference list array는 complete pagination 구현이 아니다. + +### 13. Conditional read와 optimistic concurrency + +operation별 owner: + +```text +ConditionalProfile + NO_STORE + APP_ETAG + BROWSER_HTTP_CACHE + APPLICATION_REVISION +``` + +모든 operation은 한 profile을 가져야 한다. + +- `NO_STORE`, `APP_ETAG`, `APPLICATION_REVISION`은 Fetch `cache=no-store`. +- `BROWSER_HTTP_CACHE`만 exact browser cache mode와 server + `Cache-Control`/`Vary` contract를 사용한다. +- caller/library default에 맡기는 implicit `NONE`은 없다. + +`APP_ETAG`: + +- strong ETag/generation은 adapter-private metadata +- exact operation/query/scope/representation binding과 함께 memory에 보존 +- `If-None-Match`를 transport가 생성 +- 304는 same binding의 mapped cached value가 있을 때만 freshness 갱신 +- 304는 same query-entry cache revision CAS가 성공할 때만 commit +- cached value가 없으면 one-time unconditional request 또는 closed failure +- app-managed conditional operation은 fetch cache mode를 `no-store`로 고정 +- full 200 mapped commit과 validator install/update는 같은 entry transaction +- query removal/GC, scope/logout, release/contract/schema/mapper epoch reset에서 + validator sidecar도 함께 폐기 +- ordinary invalidation에서 validator 보존 여부는 profile이 고정 + +`BROWSER_HTTP_CACHE`: + +- standard browser cache가 revalidation을 소유 +- application이 hidden validator/304 logic을 중복 구현하지 않음 +- HTTP `Cache-Control`을 TanStack `staleTime`으로 자동 변환하지 않음 + +write precondition: + +- exact resource revision을 `If-Match` 또는 body contract로 binding +- 412는 `PRECONDITION_FAILED` +- current server representation을 refetch한 뒤 feature use case가 overwrite, + merge 또는 cancel을 결정 +- 409 business conflict와 합치지 않음 + +ETag/revision은 authorization proof가 아니며 raw value를 diagnostics에 넣지 않는다. + +### 14. Schema와 Mapper 연결 + +VD-24의 typed codec/mapper를 사용한다. + +```text +unknown + -> RuntimeCodec + -> Mapper + -> Result +``` + +request는 strict/normalized parsed output만 serialize한다. ordinary additive +response는 required/discriminant를 검증하고 unknown field를 폐기한다. control, +authorization와 sealed union은 unknown을 거절한다. + +generated OpenAPI client/DTO를 선택해도 adapter-private이다. handwritten gateway와 +mapper를 제거하지 않는다. + +### 15. Query cache와의 관계 + +REST transport는 raw response cache를 소유하지 않는다. VD-25가 mapped application +projection을 TanStack Query에 admission한다. + +- operation registry의 cache profile만 query를 만들 수 있다. +- Query retry는 `false`; REST transport가 network retry를 소유한다. +- URL, ETag, idempotency key, Response와 DTO를 query key/value에 넣지 않는다. +- mutation success 뒤 registered invalidation topic 또는 exact typed seed policy만 + 사용한다. +- REST timeout/error를 Query가 다시 network retry하지 않는다. + +### 16. Contract source와 release coherence + +OpenAPI가 backend authority인 제품: + +```text +authenticated immutable OpenAPI artifact + -> source digest/provenance + -> lint + breaking diff + -> pinned deterministic generation + -> runtime codec parity + -> adapter-private DTO/client + -> handwritten mapper/gateway +``` + +CI: + +- generator/runtime/plugin/Node version pin +- clean checkout regenerate diff 0 +- stable operation ID +- source/artifact/generated output digest +- runtime codec vs specification fixtures +- N/N-1 and future-major failure +- generated import boundary + +global `API_CONTRACT_VERSION`은 selected contract-set compatibility와 digest에 +연결한다. runtime config와 release manifest의 같은 문자열만으로 backend +compatibility를 주장하지 않는다. + +major version 전략은 URI version 또는 vendor media version 중 provider가 하나를 +선택한다. 동시에 둘을 임의 증가시키지 않는다. v1/v2 adapters는 같은 +application gateway를 구현할 수 있지만 같은 query entry에 representation을 +섞지 않는다. + +### 17. Security와 observability + +관측 허용: + +- semantic operation/provider/profile ID +- method/semantics +- outcome/AppFailure kind/HTTP status group +- logical retry, auth recovery와 physical attempt bucket +- duration/deadline/request-response byte/item bucket +- conditional/cache outcome + +금지: + +- URL, path/search/header/body +- cursor/snapshot/ETag/revision +- idempotency/CSRF/auth token +- raw backend code/message/request/trace value +- application resource/account/tenant ID + +client correlation ID는 bounded syntax로 request에 전달하고 server-projected +request/trace ID는 safe failure/observation에만 제한한다. 한 logical execution은 +terminal event 하나를 만든다. + +### 18. Composition과 readiness + +REST v2 composition owner가 다음을 atomic하게 만든다. + +```text +parse provider/config + -> compose collision-free operation registry + -> resolve codec/mapper/policy references + -> install auth/CSRF owner + -> run compatibility/provider probes + -> publish READY facade +``` + +partial registry/client를 application에 노출하지 않는다. + +```text +Selection +TrafficAdmission +RuntimeHealth +PromotionEvidence +``` + +primary status가 `COMPOSED`여도 provider/auth/CSRF/idempotency/conditional +conformance가 없으면 `TrafficAdmission=DISABLED`와 +`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 닫는다. + +kill switch: + +- provider 전체 +- operation family +- keyed retry/401 replay +- conditional request +- optimistic mutation +- query cache admission + +### 19. Test와 provider conformance + +deterministic: + +- contribution collision, missing codec/mapper/profile와 owner mismatch +- URI template/path/query canonicalization/base-prefix preservation +- method/body/replay/status/media coherence +- anonymous/cookie/bearer credentials와 Fetch cache mode matrix +- auth final-request mutation 공격과 unavailable auth fetch 0 +- attempt timeout vs total deadline, timer/listener/reader cleanup +- concurrent 401 single-flight +- 401 replay를 포함한 monotonic physical-attempt cap +- retry matrix, injected-clock 429/503 Retry-After +- same key/same payload replay와 same key/different payload rejection +- 204/304/412/422/problem/envelope +- oversized/truncated/malformed/decompression overflow +- cursor loop/snapshot/page ceiling +- ETag 304 without cache, If-Match 412 +- mapper failure와 redaction + +actual staging provider: + +- HTTPS/base path/CORS/preflight/credential +- cookie/Origin/CSRF +- idempotency concurrent claim/TTL/reconcile +- status/media/error codec +- cursor/snapshot/conditional semantics +- rate limit/Retry-After +- correlation/request/trace projection +- proxy/CDN content encoding and body cap +- browser decoded-byte cap과 provider encoded/decompression ceiling +- outbound correlation, success status group와 401 physical-attempt observation + +MSW 통과는 provider conformance가 아니다. + +### 20. Rollout + +1. collision-aware v2 registry/codec과 boot-time binding 검증을 설치한다. +2. auth fail-closed, final invariant, bounded decoder와 total deadline을 local + reference vertical에서 검증한다. +3. actual provider에 같은 fixture를 실행하고 read operation을 canary한다. +4. keyed command는 backend idempotency conformance 뒤 별도 canary한다. +5. pagination/conditional operation을 각각 별도 traffic gate로 올린다. +6. provider/browser/operations evidence가 complete인 operation만 enabled한다. +7. rollback은 우선 safe unavailable로 내리고 contract artifact/frontend/backend를 + coherent set으로 복구한다. v1 fallback은 해당 operation의 unexpired + provider/security evidence가 있고 incident가 v1/shared boundary에 영향이 없으며 + auth fail-close/final invariant hardening이 유지될 때만 허용한다. + +### 21. Removal + +GraphQL/Connect/gRPC-Web/REST Gateway 선택을 취소해도 REST v2 common execution +context는 남을 수 있다. +REST provider 제거 시: + +1. 신규 operation admission 중지 +2. read cancel, command effect certainty reconcile +3. auth/CSRF/retry timer와 response reader close +4. current scope query cache clear/invalidate +5. operation/schema/mapper/query profile 제거 +6. provider config/proxy/dependency/fixture 제거 +7. production module inventory와 backend route retirement evidence + +## 완료 기준 + +- installed REST operation이 typed path/search/body/success/error codec과 mapper에 + 하나의 definition으로 연결된다. +- auth unavailable 또는 mutated final request에서 fetch가 0회다. +- 모든 throw/response size/status/media/schema/mapper failure가 closed result다. +- total logical deadline이 auth/recovery/backoff/decode/mapper를 포함한다. +- replay는 declared policy와 actual backend idempotency evidence를 가진다. +- complete cursor page와 conditional/412 state가 bounded하게 동작한다. +- response DTO/URL/header/token/validator가 application/query/log에 없다. +- actual provider conformance와 rollback/removal drill이 통과한다. diff --git a/docs/architecture/decisions/VD-24-runtime-schema-and-boundary-mapper.md b/docs/architecture/decisions/VD-24-runtime-schema-and-boundary-mapper.md new file mode 100644 index 0000000..39c6f3d --- /dev/null +++ b/docs/architecture/decisions/VD-24-runtime-schema-and-boundary-mapper.md @@ -0,0 +1,585 @@ +# VD-24: Runtime Schema와 boundary Mapper + +- 상태: Accepted — reference typed codec/mapper baseline composed, artifact governance pending +- 결정일: 2026-07-28 +- reference REST Schema/Mapper vertical: `COMPOSED` +- semantic compatibility/codegen governance delta: + `DESIGNED_NOT_IMPLEMENTED` +- generated API product selection: `NOT_SELECTED` +- 관련 결정: VD-13, VD-23, VD-25, VD-26, VD-27, VD-29, VD-30 +- 상세 설계: + [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) + +## 배경 + +현재 reference vertical은 다음 trust path를 실제 실행한다. + +```text +HTTP response + -> common envelope Zod + -> feature payload Zod + -> feature mapper + -> domain factory + -> application view + -> TanStack Query +``` + +reference baseline은 schema contribution collision을 boot 전에 거절하고, +schema version/direction/unknown-field policy를 기록한다. request는 strict reject, +ordinary response DTO는 strip projection을 사용하며 list item 수와 response/cache +byte admission을 제한한다. mapper는 no-throw `MappingResult`를 반환하고 예상 가능한 +drift는 `MAPPING_CONTRACT_VIOLATION`으로 분류한다. + +runtime schema codec과 mapper contribution은 collision-aware composer로 설치되고, +각 REST operation의 path/request/response schema와 mapper input schema reference를 +boot 전에 exact resolve한다. operation별 cast-free result guard도 raw executor의 +성공 값을 fail-closed로 재검증한다. 남은 delta는 +actual codec fingerprint/source provenance/generated artifact join과 전체 scalar +policy set이다. + +이 결정은 runtime validation을 특정 library 이름으로 축소하지 않는다. 현재 +owned schema는 Zod를 사용하지만 GraphQL generated types와 protobuf messages에도 +동일한 trust transition을 적용한다. + +## 결정 + +### 1. 다섯 validation 경계를 분리한다 + +| 경계 | owner | 목적 | +| --- | --- | --- | +| route/form input | presentation/feature | 사용자 입력 정규화와 UX issue | +| application command/query input | application/feature | use-case precondition과 canonical semantic input | +| transport request wire | adapter/contract | exact outbound representation | +| transport response wire | adapter/contract | untrusted server bytes/message 검증 | +| domain invariant | domain | 업무상 유효한 entity/value 생성 | + +하나의 Zod schema를 form, API request, response와 domain에 재사용하지 않는다. +field 이름이 같아도 trust source와 failure semantics가 다르다. + +### 2. TypeScript와 generated type은 proof가 아니다 + +```text +unknown bytes/message + -> bounded decoder + -> RuntimeCodec + -> ValidatedDto + -> BoundaryMapper + -> MappingResult +``` + +`as Dto`, generic `execute()`, generated TypeScript interface와 protobuf class +instance는 runtime proof를 만들지 않는다. + +목표 API: + +```text +RuntimeCodec + schemaId + parse(input, budget) -> ValidationResult + +BoundaryMapper + mapperId + inputSchemaId + map(dto) -> MappingResult + +BoundOperation + requestCodec + responseCodec + mapper +``` + +operation definition 생성 시 codec output과 mapper input type을 compiler가 +연결한다. runtime registry도 same IDs/fingerprints를 검증한다. + +### 3. Schema registry v2 + +```text +SchemaDefinitionV2 + schemaId + wireVersion + boundary + protocol + sourceKind = OWNED | GENERATED + sourceArtifactId + sourceArtifactDigest + codecId + codecFingerprint + unknownFieldPolicy + numericPolicyId + temporalPolicyId + providerMaxEncodedBytes + maxDecodedBytes + maxDepth + maxNodes + maxObjectKeys + maxStringBytes + maxCollectionItems + compatibilityPolicy + dataClassification + owner +``` + +정적 metadata는 실행 codec definition에서 결정적으로 투영한다. 실제 codec +resolver가 없는 schema ID, fingerprint가 다른 resolver와 duplicate ID는 +contribution composition에서 실패한다. + +`codecFingerprint`는 library 내부 AST serialization을 무조건 신뢰하지 않는다. +프로젝트가 소유한 canonical schema manifest를 사용한다. + +```text +canonical schema manifest + field/path + required/nullability + scalar/format/range + enum/discriminant + collection/item ceiling + unknown-field policy + transform identifier/version +``` + +Zod upgrade로 내부 representation이 바뀌어도 canonical meaning diff가 안정적이어야 +한다. + +### 4. Decode budget + +Content-Length나 protobuf frame length만으로 충분하지 않다. + +```text +ValidationBudget + decodedBytesRemaining + nodesRemaining + depthRemaining + objectKeysRemaining + stringBytesRemaining + collectionItemsRemaining + deadlineRemaining +``` + +- BFF/proxy/provider가 actual wire/encoded transfer와 decompression-ratio cap을 + 집행한다. +- browser Fetch adapter는 present/valid Content-Length를 advisory preflight로만 + 사용하고 browser-visible decoded stream bytes를 hard cap으로 센다. +- codec은 depth/node/key/string/item cap을 적용한다. +- collection nested item도 global budget을 함께 소모한다. +- transform/refine도 남은 logical deadline 안에서 동기적이고 bounded해야 한다. +- async network/storage refinement를 runtime wire schema에 넣지 않는다. +- budget 초과는 validation issue list를 무한 생성하지 않고 첫 bounded summary로 + 닫는다. + +표준 `JSON.parse` profile에서 decoded-byte cap은 pre-parse guard지만 +depth/node/key/string/item cap은 materialization 뒤 admission guard다. parse 중 +구조 cap이 필요한 payload는 bounded tokenizing parser를 별도 profile로 선택하며, +그 구현 전에는 큰 byte ceiling을 승인하지 않는다. + +current request `limit <= 100`은 response item ceiling이 아니다. response schema가 +items maximum과 total byte budget을 별도로 검증한다. + +### 5. Unknown-field 정책 + +```text +UnknownFieldPolicy + REJECT_UNKNOWN + STRIP_UNKNOWN +``` + +`PRESERVE_UNKNOWN`은 application boundary에서 허용하지 않는다. + +| schema class | 기본 정책 | +| --- | --- | +| request, config, command, capability | `REJECT_UNKNOWN` | +| auth/authorization/control envelope | `REJECT_UNKNOWN` | +| ordinary additive REST response DTO | `STRIP_UNKNOWN` | +| GraphQL selected data object | requested field shape만 투영 | +| sealed discriminated union | unknown discriminator 거절 | +| protobuf generated message | codec/library unknown-field behavior 뒤 mapper는 known projection만 사용 | + +current response `.strict()`를 모두 `.passthrough()`로 바꾸지 않는다. unknown +field를 제거한 typed projection만 mapper로 보낸다. unknown field 이름/value를 +log에 남기지 않는다. 필요한 경우 low-cardinality `unknown-field-detected` +observation만 sampling한다. + +### 6. Request와 response 방향성 + +request: + +- strict field set +- trim/coerce/default/normalization 정책이 명시됨 +- parsed output만 transport가 serialize +- input 원본을 query key나 request에 따로 사용하지 않음 +- route/search/application command 변환이 같은 canonical semantic input을 공유 + +response: + +- untrusted value를 coerce하지 않음 +- required/nullability/discriminant/range를 검증 +- additive unknown은 profile에 따라 strip +- default value를 서버가 보낸 값처럼 조용히 생성하지 않음 +- missing/null/empty를 mapper가 명시적으로 소진 + +`z.coerce`는 URL/form 같은 string input 경계에서만 허용한다. JSON/protobuf +response에 적용하지 않는다. + +### 7. Scalar 의미 + +#### ID + +- opaque bounded string +- empty/control/overlong 거절 +- 업무 계약이 없는 case folding, Unicode normalization과 numeric parse 금지 +- account/resource ID를 diagnostics label이나 physical cache key에 직접 넣지 않음 + +#### Integer와 decimal + +- JSON integer는 finite safe integer 범위를 증명 +- `int64`/`uint64`는 JavaScript number로 mapping하지 않음 +- protobuf bigint/string representation은 adapter-private +- money/decimal/high precision은 canonical decimal string + currency/scale policy +- `NaN`, Infinity와 negative zero가 의미상 허용되는지 explicit +- string-to-number response coercion 금지 + +#### Time + +- exact RFC 3339 profile과 offset/precision을 검증 +- date-only, instant, local date-time과 duration을 다른 type으로 둠 +- leap/invalid date를 JavaScript `Date` normalization에 맡기지 않음 +- protobuf Timestamp/Duration range/nanos를 검증 +- mapper가 application temporal value로 변환 +- locale/timezone formatting은 presentation에서만 수행 + +#### Null과 absent + +```text +ABSENT +NULL +EMPTY +VALUE +``` + +네 의미를 schema/mapper contract에 명시한다. current mapper처럼 “string이 +아니면 모두 null”로 합치지 않는다. optional server field의 default가 필요하면 +application policy가 이름 있는 결정으로 적용한다. + +#### Enum/union/oneof + +- unknown discriminator는 sealed control union에서 fail-closed +- evolvable business enum은 domain이 explicit `UNKNOWN` case와 UX를 소유한 + 경우에만 mapping +- raw unknown string/number를 domain에 전달하지 않음 +- protobuf enum zero value, unknown numeric enum과 oneof absence를 명시적으로 + 처리 + +#### Binary + +- REST base64는 decoded byte cap과 canonical encoding profile 필요 +- GraphQL upload/binary는 이 JSON schema 경계의 기본 기능이 아님 +- protobuf `bytes`는 bounded copy/stream policy 뒤에만 application으로 projection +- large binary는 File/transfer capability를 사용 + +### 8. Transport-specific schema + +#### REST + +- exact status/media/envelope profile 뒤 operation DTO codec 실행 +- error body도 별도 bounded codec +- Problem Details의 type/title/detail/instance를 raw UI copy로 사용하지 않음 +- response envelope와 payload unknown policy를 따로 설정 + +#### GraphQL + +- variables와 selected `data` shape에 separate codec +- top-level `data`, `errors`, `extensions`를 GraphQL response codec이 검증 +- errors path/message/extensions는 safe failure mapper 전 untrusted +- partial policy가 허용한 missing/null만 operation DTO type에 표현 +- persisted operation manifest의 schema digest와 codec fingerprint 일치 + +#### gRPC-Web + +- frame/trailer 검증 뒤 generated protobuf decoder 실행 +- generated decode success 뒤에도 semantic validator가 range/presence/enum/oneof를 + 검증 +- descriptor digest/message full name과 codec binding 일치 +- `google.rpc.Status` details는 allowlisted type만 decode + +#### Connect-Web/Connect + +- Connect unary HTTP/error 또는 stream EndStream proof 뒤 generated message decode +- JSON/binary encoding과 descriptor/message binding을 operation profile에 고정 +- generated decode와 `ConnectError` code는 semantic domain proof가 아니므로 + 같은 validator/mapper와 safe failure vocabulary를 통과 + +#### Protobuf REST Gateway + +- HttpRule/ProtoJSON/status/error profile 뒤 ordinary REST DTO codec 실행 +- generated OpenAPI type이나 ProtoJSON message를 application model로 사용하지 않음 +- direct gateway와 curated BFF의 envelope/schema를 같은 codec으로 추측하지 않음 + +### 9. Boundary Mapper v2 + +```text +MapperDefinitionV2 + mapperId + mapperVersion + inputSchemaId + outputContractId + scalarPolicySetId + maxOutputItems + maxEstimatedOutputBytes + owner +``` + +mapper는: + +- pure +- deterministic +- synchronous +- side-effect-free +- locale/timezone-independent +- input mutation 없음 +- immutable output +- exhaustive +- bounded + +mapper가 호출하면 안 되는 것: + +- fetch, generated client, QueryClient +- clock/random +- storage/cache +- telemetry/logger +- DOM/browser API +- authorization/feature flag + +### 10. Mapping result + +```text +MappingResult + { ok: true, value: T } + { ok: false, + error: + MAPPING_INVARIANT_REJECTED | + UNSUPPORTED_WIRE_VALUE | + OUTPUT_LIMIT_EXCEEDED } +``` + +예상 가능한 domain invariant/unknown enum/temporal conversion 실패는 throw하지 +않는다. programming defect가 throw되더라도 adapter boundary가 +`MAPPING_CONTRACT_VIOLATION`으로 정규화한다. raw DTO/value/path/message를 failure에 +복사하지 않는다. + +`UNKNOWN_FAILURE`는 mapper drift의 정상 분류가 아니다. operation ID, +schema/mapper profile/version과 safe outcome만 관측한다. + +### 11. Domain, application projection과 view + +```text +ValidatedDto + -> domain value/entity factory + -> ApplicationReadModel / command result + -> presentation-only ViewModel +``` + +- DTO는 adapter/contracts 내부 +- domain은 transport nullability/error/envelope를 모름 +- application read model은 query cache에 넣을 수 있는 immutable plain value +- presentation view는 locale/formatted copy와 UI-only optimistic marker를 소유 +- domain class/service, function, native object와 generated message를 Query cache에 + 넣지 않음 + +current reference가 domain을 거쳐 view를 만드는 구조는 유지한다. 단 collection과 +return object를 immutable/bounded하게 만들고 mapping type proof를 연결한다. + +### 12. Collection mapping + +- input array/page count는 codec에서 먼저 제한 +- mapper는 output count와 estimated bytes를 다시 제한 +- item 하나 실패 시 partial collection을 success/cache하지 않음 +- stable identity, ordering, duplicate 의미는 feature contract가 결정 +- duplicate ID를 임의로 마지막 값으로 덮지 않음 +- mapper가 sort/filter/deduplicate를 한다면 이름 있는 policy와 fixture 필요 +- pagination page/snapshot binding을 보존 + +estimated output bytes는 quota/serialization exact value가 아니라 cache admission +ceiling용 보수적 측정이다. 측정 실패는 unlimited로 간주하지 않고 cache +admission을 거절한다. + +### 13. Generated와 owned source + +```text +backend-authoritative contract + -> authenticated immutable source artifact + -> source digest + provenance + -> pinned codegen + -> adapter-private DTO/client/codec + -> owned semantic validator where required + -> handwritten boundary mapper +``` + +| protocol | generated source 후보 | 반드시 owned인 것 | +| --- | --- | --- | +| REST | OpenAPI DTO/client/codec | gateway, mapper, application model, query policy | +| GraphQL | schema types, operation types | persisted manifest policy, runtime result/error codec, mapper | +| gRPC-Web | protobuf messages/client | semantic validation, failure mapping, mapper, stream reducer | + +normal build가 네트워크에서 최신 schema를 암묵적으로 내려받지 않는다. source +fetch/update는 authenticated explicit workflow이며 reviewable diff를 만든다. + +generator: + +- exact package/plugin/runtime/Node version pin +- reproducible output +- generated directory 수동 수정 금지 +- clean regenerate diff 0 +- license/SBOM/secret scan +- vendor import boundary +- generated artifact removal gate + +현재 `generated-api` recipe의 generic `execute(unknown)`와 caller-selected +cast는 production schema proof가 아니다. + +### 14. Compatibility + +change classification: + +| 변경 | 기본 판정 | +| --- | --- | +| optional ordinary response field 추가 + strip policy | additive | +| request required field 추가 | breaking | +| response required field 제거/rename/type/nullability 축소 | breaking | +| enum value 추가 | domain unknown policy에 따라 additive 또는 breaking | +| numeric range/precision/temporal profile 변경 | semantic breaking | +| mapper output meaning/identity/order 변경 | application breaking | +| unknown-field policy 변경 | compatibility review | +| codec transform/default 변경 | semantic diff 필수 | + +`apiContractVersion` 하나만 올리지 않는다. + +```text +ContractSetManifest + globalCompatibilityVersion + REST/OpenAPI artifact digest + GraphQL schema + persisted operation digest + protobuf descriptor digest + runtime schema registry digest + mapper registry digest + query policy digest +``` + +- N과 N-1 fixture를 보존 +- breaking deployment는 old frontend window와 backend compatibility를 고려 +- future major는 fail-closed +- rollback은 frontend, generated artifacts, config, BFF/router/proxy와 backend + compatibility를 coherent set으로 복구 +- mapper-only semantic change도 cache/release epoch invalidation을 검토 + +### 15. Failure와 cache admission + +다음 상태에서는 cache write가 0회다. + +- body/frame limit +- envelope/status/media mismatch +- operation schema mismatch +- mapper failure +- scope/runtime generation mismatch +- output item/byte ceiling 초과 +- incompatible contract/source digest + +stale data를 유지할지는 VD-25 query profile이 결정한다. schema/mapper +incompatibility를 ordinary transient network failure와 동일하게 retry하지 않는다. + +### 16. Security와 privacy + +- validation failure에 raw value를 포함하지 않음 +- issue path/code를 allowlist와 count/byte cap으로 projection +- schema/mapper error가 PII field/value를 diagnostics에 넣지 않음 +- prototype pollution key와 accessor/class/native object 거절 +- `structuredClone` 성공을 safe plain-data proof로 사용하지 않음 +- generated code가 arbitrary URL/header/logger를 application에 노출하지 않음 +- source artifact와 generator provenance 검증 +- schema가 frontend authorization boundary라는 주장 금지 + +관측 허용: + +- operation/schema/mapper ID와 version +- source/compatibility outcome +- validation/mapping failure kind +- encoded/decoded/output size와 item bucket +- unknown-field detected bucket + +source digest 실제 값, field path/value, DTO, GraphQL error path와 protobuf payload는 +high-cardinality/sensitive이므로 telemetry label에 넣지 않는다. + +### 17. Testing + +schema: + +- missing/null/empty/unknown field +- numeric safe bounds, decimal, negative zero, NaN/Infinity +- RFC 3339/Timestamp/Duration edge +- enum/union/oneof future value +- depth/node/key/string/array/byte cap +- invalid UTF-8, base64와 binary cap +- N/N-1/future-major + +registry: + +- duplicate ID before spread +- missing/mismatched codec/mapper resolver +- codec fingerprint/source digest drift +- operation schema/mapper output type binding +- orphan/owner/version mismatch + +mapper: + +- typed DTO only +- deterministic/pure/input unmodified +- immutable output +- no throw for expected semantic rejection +- collection partial failure +- item/output byte ceiling +- missing/null/date/numeric/unknown enum matrix +- no raw value leakage + +codegen: + +- source provenance/digest +- lint/breaking +- clean reproducible generation +- generated import boundary +- runtime codec fixture parity +- dependency/removal inventory + +integration: + +- bytes → decoder → schema → mapper → application/query +- schema/mapper drift에서 cache write 0 +- old runtime generation result 폐기 +- actual REST/GraphQL/gRPC provider fixture + +### 18. Rollout + +1. current string-dispatch schema/mapper를 그대로 두고 typed definition builder를 + 추가한다. +2. reference operation에 shadow validation/mapping을 실행하되 secondary result를 + UI/cache에 쓰지 않는다. +3. collision-aware contribution composer와 codec fingerprint를 먼저 blocking한다. +4. bounded decoder/collection ceiling을 query read부터 canary한다. +5. typed mapping result와 failure taxonomy를 적용한다. +6. current unchecked binder/cast를 제거한다. +7. OpenAPI/GraphQL/proto generation은 제품 contract source가 선택된 것만 + 별도 canary한다. +8. schema/mapper version을 release/cache epoch와 연결한다. + +rollback은 codec schema number를 낮추거나 cache의 incompatible value를 억지로 +decode하지 않는다. old adapter/backend path와 coherent artifact로 돌리고 current +scope의 incompatible mapped cache를 폐기한다. + +## 완료 기준 + +- runtime codec output과 mapper input이 compiler/runtime registry 양쪽에서 연결된다. +- schema registry가 actual meaning fingerprint, provenance와 budget을 가진다. +- duplicate contribution이 overwrite 전에 실패한다. +- request strict/response additive 방향 정책이 test로 증명된다. +- scalar/null/enum/collection 의미가 mapper policy로 닫힌다. +- mapper가 typed DTO만 받고 expected failure를 `Result`로 반환한다. +- generated DTO/message가 domain/application/presentation/query public type에 없다. +- schema/mapper failure에서 cache write와 raw-data observation이 0회다. +- N/N-1, breaking diff, provider fixture와 rollback/removal drill이 통과한다. diff --git a/docs/architecture/decisions/VD-25-server-state-cache-lifecycle.md b/docs/architecture/decisions/VD-25-server-state-cache-lifecycle.md new file mode 100644 index 0000000..7092b1f --- /dev/null +++ b/docs/architecture/decisions/VD-25-server-state-cache-lifecycle.md @@ -0,0 +1,802 @@ +# VD-25: Server State Cache lifecycle + +- 상태: Accepted — reference bound-query/input-aware mutation baseline composed, lifecycle delta pending +- 결정일: 2026-07-28 +- TanStack Query memory runtime: `COMPOSED` +- reference bound-query/profile/input-aware duplicate coordination: `COMPOSED` +- session-generation/identity lifecycle: `COMPOSED` +- conditional sidecar/optimistic layer/cursor runtime: `AVAILABLE_NOT_COMPOSED` +- account projection/infinite-query/effect reconciliation delta: + `DESIGNED_NOT_IMPLEMENTED` +- normalized graph cache: `NOT_SELECTED` +- query persistence product selection: `NOT_SELECTED` +- 관련 결정: VD-13, VD-23, VD-24, VD-26, VD-27, VD-29, VD-30 +- 상세 설계: + [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) + +## 배경 + +현재 production bootstrap은 runtime별 QueryClient와 invalidation coordinator를 +실제로 조립한다. reference presentation은 application input을 +`useApplicationQuery()`/`useApplicationMutation()`에 연결하며 다음을 제공한다. + +- AbortSignal cancellation +- finite global stale/gc default +- Query network retry off +- stale-degraded 표시 +- exact-key optimistic snapshot/rollback +- conflict 표면 +- mutation topic lease와 cross-context invalidate-only hint + +reference list/detail은 bound definition이 key, executor와 profile을 함께 +제공한다. strict canonical codec은 depth/node/string/encoded-byte, cycle/shared +reference, undefined/NaN/negative-zero, sparse array, accessor와 non-plain object를 +닫고 runtime-private opaque identity만 query key에 넣는다. profile의 stale/gc/ +refetch/retry owner와 result item/byte admission이 실제 hook에 적용된다. + +current invalidation definition은 namespace당 singular topic이고 같은 topic의 +multi-namespace fan-out을 compose하지 못한다. 아래 bounded topic-set registry는 +target delta이며 현재 runtime 증거가 아니다. + +production scope runtime은 session transition 즉시 old generation을 fence하고 +QueryClient cancel/clear 뒤 새 opaque scope를 발급한다. identity registry는 active +lease, refcount, bounded LRU, canonical-byte/entry ceiling과 token collision 검사를 +scope별로 소유한다. mutation duplicate baseline도 scope exact semantic input만 +join하고 late result를 폐기한다. + +ordered optimistic layer, conditional validator CAS sidecar와 bounded cursor chain은 +실행 가능한 test runtime까지 존재하지만 reference backend/definition에는 아직 +연결하지 않았다. 남은 부분은 account identity projection, logical-key serialization, +HTTP 304 transaction, product optimistic membership/revision, infinite-query binding과 +effect certainty reconcile이다. + +## 소유권 + +VD-13: + +- `CacheScopeSnapshot` +- ORIGIN/ACCOUNT/SESSION projection +- QueryClient generation/fence/remount +- strict canonical key codec 공통 구현 +- cross-tab wire와 durable namespace epoch +- optional IndexedDB persistence와 restore +- logout/account switch purge + +VD-25: + +- operation/application query definition binding +- per-query freshness/gc/refetch/result budget +- cache admission과 mapped value contract +- cursor/infinite pagination +- conditional revalidation integration +- mutation concurrency, optimistic patch와 reconciliation +- invalidation/seed policy +- transport-independent error/stale behavior + +VD-25는 VD-13의 scope snapshot/key codec을 소비하고 다른 epoch/fingerprint +protocol을 만들지 않는다. + +## 결정 + +### 1. TanStack Query가 유일한 기본 Server State owner다 + +REST, GraphQL과 gRPC-Web unary result는 transport-independent application +projection으로 mapping된 뒤 TanStack Query memory cache에 들어갈 수 있다. + +기본적으로 설치하지 않는다. + +- Redux/Zustand server entity copy +- Apollo/urql normalized cache +- raw HTTP response cache wrapper +- generated client SDK cache +- custom Map singleton + +GraphQL normalized cache가 실제로 필요하면 bounded context에서 TanStack +operation-result cache를 대체하는 별도 ADR을 승인한다. 두 cache에 같은 entity를 +동시 write하지 않는다. + +browser HTTP cache/Cache Storage, TanStack Query memory와 IndexedDB query +persistence는 서로 다른 owner다. + +### 2. Bound Query Definition + +caller가 query key와 executor를 독립적으로 조립하지 않는다. + +```text +QueryDefinition + definitionId + owner + operationId + inputCodec + keyCodecId + serverStateProfileId + scopePersistencePolicyId + resultContractId + execute(validatedInput, executionContext) +``` + +binding: + +```text +bindQuery(definition, rawInput, CacheScopeSnapshot) + -> validate/canonicalize input + -> derive branded query key + -> resolve immutable policy + -> freeze execute closure and captured generation + -> BoundQuery +``` + +presentation API: + +```text +useApplicationQuery(boundQuery) +``` + +`queryKey`, `queryFn`, stale/gc/retry와 arbitrary TanStack option을 feature page에서 +따로 넘기지 않는다. escape hatch가 필요하면 새 profile을 먼저 등록한다. + +### 3. Semantic query identity + +query key는 VD-13의 단일 normative layout을 그대로 사용한다. + +```text +[ + "query", + keySchemaVersion, + scopeProjectionFingerprint, + namespaceName, + namespaceVersion, + queryDefinitionVersion, + canonicalSemanticInput +] +``` + +- namespace/key schema/version과 scope projection은 VD-13 profile-owned +- query definition version과 semantic input projection은 VD-25 definition-owned +- scope projection은 VD-13의 exact profile output +- input은 strict codec의 plain immutable representation +- REST URL/query string, ETag와 cursor raw value를 root identity에 넣지 않음 +- GraphQL document/persisted hash를 넣지 않음 +- protobuf bytes/generated message를 넣지 않음 +- presentation locale/formatted string을 넣지 않음 + +transport migration이 use-case/result meaning을 보존하면 semantic query family를 +유지할 수 있다. schema/mapper meaning, scope나 output identity가 바뀌면 +query-definition/release epoch를 바꾼다. + +strict key codec은 다음을 거절한다. + +- `undefined`, sparse array +- NaN, Infinity, negative zero policy mismatch +- bigint/symbol/function +- Date/Map/Set/RegExp/typed array/native/class instance +- accessor/proxy/prototype pollution key +- cycle/shared-reference ambiguity +- non-plain object +- depth/node/part/string/encoded-byte ceiling 초과 + +query key에 PII/business ID를 직접 넣지 않는다. 필요한 resource identity는 +feature policy가 발급한 opaque bounded token으로 투영한다. + +cursor와 command 동일성은 raw value나 충돌 가능 digest만으로 판정하지 않는다. + +```text +RuntimeIdentityTokenCodecV1 + canonicalCodecVersion + maxCanonicalBytes + maxInternEntries + maxInternCanonicalBytes + tokenEntropyBits >= 128 + lifetime = RUNTIME_SCOPE +``` + +- strict length-prefixed typed canonical encoding이 exact equality source다. +- runtime-private intern table이 canonical bytes를 opaque random token에 + 일대일로 binding하고 token collision을 reverse map으로 검사한다. +- 같은 token 후보가 다른 canonical bytes와 충돌하면 새 token을 발급한다. bounded + 재시도 후에도 해결되지 않으면 `IDENTITY_TOKEN_COLLISION`으로 admission/join을 + fail-closed한다. +- intern row는 lease/refcount를 가진다. Query entry가 설치된 동안, active + observer/fetch와 mutation/join이 진행되는 동안 해당 token을 eviction하지 않는다. +- Query removal/GC에서 query token lease를, mutation terminal/join waiter + settlement에서 command token lease를 exact once release한다. runtime/scope + close는 남은 table을 전부 폐기한다. +- refcount 0 row만 bounded LRU eviction할 수 있다. entry 수 또는 total canonical + bytes ceiling을 active lease 때문에 회수할 수 없으면 + `IDENTITY_INTERN_LIMIT_EXCEEDED`로 신규 cache/command admission을 fail-closed한다. +- canonical bytes/raw cursor/command input은 query key, diagnostics, + cross-context wire와 persistence에 넣지 않고 runtime/scope close에서 폐기한다. +- token은 backend idempotency key, authorization proof나 durable identity가 아니다. + +### 4. ServerStateProfile + +```text +ServerStateProfileV1 + profileId + classification + scopePersistencePolicyId + staleTimeMs + gcTimeMs + refetchOnMount + refetchOnFocus + refetchOnReconnect + networkMode + retryOwner = TRANSPORT | QUERY | NONE + maxResultItems + maxEstimatedResultBytes + paginationProfileId | null + conditionalProfileId | null + placeholderPolicy + initialFailurePolicy + refreshFailurePolicy + authorizationFailurePolicy + contractFailurePolicy + invalidationTopicRefs[] = { topicId, topicVersion } + owner +``` + +implementation ceilings: + +- query `invalidationTopicRefs` set/version은 joined VD-13 + `QueryScopePersistencePolicy`와 exact match +- `gcTimeMs`는 inactive retention이고 `staleTimeMs`는 freshness이므로 + `staleTimeMs <= gcTimeMs`를 일반 불변조건으로 강제하지 않는다. +- VD-13 persistence를 선택한 경우에만 restore `maxAgeMs`, retention과 + `gcTimeMs`의 join compatibility를 검증한다. +- finite gc 기본, `Infinity`는 explicit immortal-static profile만 +- stale/gc 최대값 +- result item/estimated byte 상한 +- maximum pages +- refetch trigger storm coalescing +- foreground/background concurrency + +현재 global 30초/5분은 reference default이지 모든 product query의 production +정책이 아니다. + +### 5. Retry owner + +한 network operation에는 retry owner가 정확히 하나다. + +| 상황 | 기본 owner | +| --- | --- | +| installed REST | REST transport | +| persisted GraphQL | GraphQL transport | +| Connect unary | Connect adapter 또는 selected edge 중 exact one | +| gRPC-Web unary | gRPC-Web transport | +| pure local query computation | Query 또는 none | + +transport retry가 있는 definition은 TanStack `retry=false`다. Query retry callback이 +same gateway call을 다시 실행해 transport attempts를 배가하지 않는다. + +manual UI retry는 새 logical query execution이다. keyed command의 ambiguous outcome을 +query retry처럼 재실행하지 않는다. + +### 6. Cache admission + +query cache에 admission 가능한 값: + +- VD-24 mapper가 만든 immutable plain application read model +- exact result contract/version +- current scope/runtime generation +- item/estimated-byte ceiling 안 +- complete result 또는 operation이 허용한 typed partial result + +금지: + +- raw JSON/GraphQL envelope +- generated protobuf message/client +- Response/ReadableStream +- auth/CSRF/idempotency/cursor/validator/trace metadata +- thrown Error/AppFailure detail payload +- function/class/domain service/native object + +admission 순서: + +```text +transport success + -> schema + -> mapper + -> result budget + -> scope/generation fence + -> Query commit +``` + +어느 단계든 실패하면 cache write 0회다. + +### 7. Result size + +`maxEstimatedResultBytes`는 memory reservation이 아니라 hard admission guard다. + +- mapper output plain data를 bounded estimator로 측정 +- string UTF-8 bytes, key overhead, array/object node count를 보수적으로 합산 +- cycle/class/native/accessor는 측정 전에 거절 +- 측정 자체가 deadline/node ceiling을 넘으면 admission 거절 +- result cap을 넘겨도 transport success를 unbounded UI state로 반환하지 않고 + `RESULT_LIMIT_EXCEEDED` 또는 server pagination requirement로 닫음 + +large binary/collection은 streaming/file 또는 cursor page capability로 이동한다. + +### 8. Query lifecycle + +```text +IDLE + -> LOADING + -> SUCCESS | EMPTY | TERMINAL_ERROR + +SUCCESS | EMPTY + -> REFRESHING + -> SUCCESS | EMPTY + -> STALE_DEGRADED + -> TERMINAL/REAUTH when policy forbids stale visibility +``` + +current AsyncState의 base와 overlay 구분을 유지한다. + +failure policy: + +| failure | default | +| --- | --- | +| transient network/5xx refresh failure | valid previous data + stale-degraded | +| caller/navigation abort | terminal error로 표시하지 않음 | +| auth required | sensitive/account query는 stale 숨김, reauth | +| forbidden/account switch | current scope data 즉시 숨김/clear | +| schema/mapper/contract mismatch | cache write 금지, default stale 숨김 또는 explicit safe-static exception | +| rate limit | previous data 정책 + retry-after UX | +| not found | feature policy에 따라 empty/remove/tombstone | + +query profile이 sensitive stale data를 계속 보여 주는 결정을 global fallback으로 +상속하지 않는다. + +### 9. Freshness와 refetch + +`staleTime`은 business correctness/authorization TTL이 아니다. + +- focus/reconnect/mount refetch는 profile별 +- simultaneous trigger는 one in-flight query로 coalesce +- minimum refetch interval과 deadline 적용 +- visibility offline state는 hint이며 server revision을 대체하지 않음 +- response age/cache-control을 staleTime으로 자동 변환하지 않음 +- backend push/invalidation은 stale hint이며 authoritative refetch를 시작 + +freshness-sensitive command/read-after-write는 mutation receipt/revision 또는 +authoritative refetch contract를 사용한다. + +### 10. Conditional revalidation + +REST app-managed ETag profile만 internal validator metadata를 사용할 수 있다. + +```text +ValidatorBinding + query definition/fingerprint + scope fingerprint + runtime generation + representation version + opaque validator +``` + +- raw validator는 query value/key/diagnostics에 넣지 않음 +- 304는 exact binding + existing mapped cache value가 있을 때만 fresh transition +- 304 freshness transition은 같은 query-entry cache revision에 CAS가 성공할 때만 + commit하며, concurrent 200/removal 뒤의 late 304를 폐기 +- value가 없거나 wrong generation이면 304 success로 만들지 않음 +- validator mismatch/full 200은 normal schema/mapper/admission을 다시 수행하고 + mapped value commit과 validator install/update를 하나의 entry transaction으로 + 취급 +- Query removal/GC, scope/logout/account switch, release/contract/schema/mapper + epoch 변경과 incompatible cache clear에서 validator sidecar도 함께 폐기 +- ordinary invalidation 때 validator를 conditional refetch까지 보존할지 즉시 + 폐기할지는 profile이 고정하며 query entry와 독립적으로 남기지 않음 + +GraphQL/gRPC metadata를 arbitrary ETag로 해석하지 않는다. application revision +field를 schema/mapper가 명시적으로 제공한 경우 별도 revalidation policy가 +사용한다. + +### 11. Cursor pagination + +```text +PaginationProfile + CURSOR_SINGLE_PAGE + CURSOR_INFINITE + OFFSET_STABLE +``` + +cursor page: + +```text +CursorPage + items + nextCursor | null + hasMore + snapshotToken | null +``` + +page invariant: + +- `hasMore === (nextCursor !== null)`을 codec에서 강제한다. +- page item count는 requested/implementation ceiling 이하다. +- chain 안의 snapshot token은 provider profile이 허용한 null/동일 값만 사용한다. +- `hasMore=true`인 empty/non-progress page는 explicit sparse-page profile이 없으면 + contract failure다. + +`CURSOR_INFINITE` root key: + +- filters/sort/page size semantics +- scope +- page definition version +- cursor 제외 + +`CURSOR_INFINITE` page parameter: + +- adapter-private/opaque bounded cursor +- previous page/snapshot binding +- raw cursor를 diagnostics/URL state/persistence에 임의 저장하지 않음 + +`CURSOR_SINGLE_PAGE`: + +- first page의 null marker 또는 current cursor의 runtime-scoped non-reversible + identity token을 `canonicalSemanticInput`에 포함한다. +- raw cursor는 bound executor closure에만 두며 query key/value/diagnostics에 넣지 + 않는다. +- runtime-private exact equality guard/token binding이 실패하면 single-page cache + admission을 끄고 closed failure로 끝낸다. +- runtime-scoped token을 쓰는 `CURSOR_SINGLE_PAGE`는 `MEMORY_ONLY`다. + +infinite policy: + +- max pages +- max total items +- max estimated bytes +- repeated cursor/non-progress/loop detection +- page eviction direction +- refresh strategy: first page only, visible window 또는 complete bounded chain +- item stable identity/duplicate/revision conflict policy +- snapshot changed 시 old/new page를 섞지 않고 restart + +pagination persistence는 기본 disabled다. `CURSOR_SINGLE_PAGE`를 durable하게 +만들려면 stable partition-bound keyed codec/key lifecycle을 별도 ADR로 승인해야 +하며 runtime token을 persistence key로 재사용하지 않는다. `CURSOR_INFINITE` +persistence를 선택하려면 VD-13 profile이 cursor와 snapshot의 +classification/expiry, maximum persisted pages/bytes, restored `pageParams` 사용 +여부를 명시적으로 승인해야 한다. raw sensitive cursor 또는 만료 후 page +parameter를 IndexedDB에 저장하지 않는다. + +offset pagination은 insert/delete drift를 허용하는 dataset에서 사용하지 않는다. + +### 12. Mutation Definition + +```text +MutationDefinition + definitionId + operationId + inputCodec + logicalKeyCodec + commandIdentityTokenCodecId + concurrencyPolicy + duplicatePolicy + optimisticPolicyId | null + invalidationTopicRefs[] = { topicId, topicVersion } + seedPolicyId | null + conflictPolicyId + effectCertaintyPolicy + owner +``` + +presentation: + +```text +useApplicationMutation(boundMutation) +``` + +caller는 raw optimistic query key/update function과 invalidation topic을 조립하지 +않는다. + +mutation topic ref는 unique 0..16개이고 모두 VD-13 global topic registry의 exact +version으로 resolve돼야 한다. 한 ref가 가리키는 bounded namespace set만 +invalidate하며 caller가 runtime에 topic을 추가하지 못한다. + +### 13. Mutation concurrency + +```text +ConcurrencyPolicy + PARALLEL + SERIAL_BY_LOGICAL_KEY + SUPERSEDE_PENDING_READ_BY_LOGICAL_KEY + REJECT_WHILE_ACTIVE_BY_LOGICAL_KEY + +DuplicatePolicy + JOIN_IDENTICAL + REJECT_DUPLICATE + ALLOW_INDEPENDENT +``` + +- logical key는 validated input의 approved opaque identity +- command identity token은 operation/definition version, current scope + partition과 **전체 validated semantic input**을 + `RuntimeIdentityTokenCodecV1`으로 intern해 만든다. UI transient field와 + transport bytes/idempotency key는 canonical equality source에 포함하지 않는다. +- logical key는 serialization/conflict group이고 exact canonical equality + + identity token은 동일 command 판별 값이다. 두 값을 서로 대체하지 않는다. +- `JOIN_IDENTICAL`은 exact equality guard도 통과한 같은 identity token의 기존 + in-flight Promise와 terminal result를 공유하며 transport/optimistic layer를 + 추가하지 않는다. +- `REJECT_DUPLICATE`는 exact-identical token이 active이면 fetch 0회와 closed + `DUPLICATE_IN_FLIGHT`를 반환한다. +- `ALLOW_INDEPENDENT`는 같은 exact identity도 독립 command로 실행한다. backend + replay/idempotency와 UX가 이를 명시적으로 허용한 operation에만 등록한다. +- distinct input을 같은 Promise에 join하지 않음 +- same hook, two hooks, two routes의 coordinator가 동일 policy를 사용 +- coordinator는 hook-local singleton이 아니라 runtime/scope 수명의 registry-owned + service이며 scope generation 전환에서 신규 admission을 닫고 late commit을 fence +- `SUPERSEDE`는 이미 server로 보낸 non-replayable command를 cancel/rollback했다고 + 가정하지 않음 +- local serialization은 server idempotency/concurrency authority가 아님 +- scope/generation change는 pending result commit을 fence + +### 14. Optimistic patch + +snapshot 전체 restore만 사용하지 않는다. + +```text +OptimisticLayer + mutationId + logicalKey + commandIdentityToken + baseCacheRevision + expectedEntityRevision | null + patch + inversePatch + affectedQueryDefinitions +``` + +선택 가능한 구현: + +- cache entry revision CAS +- ordered optimistic layer log +- operation-specific compare-and-apply patch + +공통: + +1. affected exact queries cancel +2. bounded current revision/value 확인 +3. registered pure patch 적용 +4. other mutation layer와 ordering 보존 +5. failure에서 자기 layer만 제거/역적용 +6. success result/revision과 reconcile +7. invalidation/refetch + +old whole snapshot을 복원해 다른 mutation commit을 덮지 않는다. + +optimistic update를 하지 않는 조건: + +- snapshot/patch/result byte ceiling 초과 +- cache entry missing/wrong revision +- non-deterministic merge +- high-conflict command +- scope/generation transition +- unknown effect certainty + +그 경우 pending UX만 보여 주고 server response/refetch를 기다린다. + +### 15. Effect certainty와 conflict + +```text +MutationEffect + NOT_APPLIED + COMMITTED + UNKNOWN +``` + +- timeout/cancel/network failure가 `NOT_APPLIED`를 자동 의미하지 않음 +- keyed backend status/receipt가 있어야 ambiguous command reconcile 가능 +- `UNKNOWN`은 새 idempotency key로 자동 retry 금지 +- 409 business conflict, REST 412, GraphQL safe conflict code, gRPC `ABORTED`를 + common conflict surface로 mapping하되 의미 차이는 feature policy가 소유 +- server revision/merge/overwrite decision은 cache가 아니라 use case 소유 + +### 16. Mutation success, seed와 invalidation + +server commit 뒤: + +- exact returned result를 schema/mapper/fence/budget 검증 +- registered detail seed policy가 있으면 exact current entity/revision만 write +- list/aggregate는 default invalidate +- list patch는 deterministic sort/filter/membership policy가 있을 때만 +- invalidation topic은 query key가 아닌 opaque registry identity +- current mutation lease 동안 remote hints coalesce + +local invalidation failure는 committed command를 failure로 바꾸지 않는다. +cache health를 degraded로 기록하고 bounded authoritative refetch를 예약한다. + +### 17. Cross-context + +현재 cross-tab wire는 invalidate-only다. 유지한다. + +- query data/key/input/cursor/validator를 broadcast하지 않음 +- remote event는 authority가 아니라 stale hint +- account/scope/version 검증은 VD-13 +- mutation ordering/optimistic layer를 tab 간 복제하지 않음 +- sequence gap은 모든 registered namespace를 stale 처리하되 active query만 + bounded refetch한다. inactive query는 다음 mount/focus에서 revalidate하고, + persistence가 선택된 경우 durable ledger refresh는 VD-13 절차를 따른다. + +### 18. GraphQL과 normalized cache + +persisted GraphQL query도 mapped operation result를 TanStack에 cache한다. + +- query key는 semantic application input +- GraphQL document/hash는 key/value에 없음 +- GraphQL SDK cache는 `no-cache`/disabled +- partial data default reject +- approved partial result는 completeness metadata를 application contract가 소유 +- missing/error field를 previous complete value와 자동 merge하지 않음 + +normalized entity cache가 필요하면: + +- bounded context 하나가 TanStack operation cache를 대체 +- key fields/typename, eviction, pagination merge, optimistic layer, logout/scope, + persistence와 removal을 별도 ADR +- dual write/read 금지 + +현재 `NOT_SELECTED`다. + +### 19. Connect/gRPC-Web server stream + +ordinary Query는 terminal operation 결과를 전제로 한다. + +- unary는 normal query 가능 +- finite server stream을 complete aggregate로 쓸 경우 staging buffer에 bounded + accumulate하고 valid Connect EndStream 또는 gRPC-Web terminal status, + schema/mapper/fence 뒤 atomic cache commit +- long-running stream은 `ServerStreamPort`와 registered reducer/invalidation owner +- frame마다 query cache를 append하여 unbounded event history를 만들지 않음 +- stream reconnect를 Query retry로 하지 않음 +- gap/overflow는 current snapshot 폐기 또는 authoritative query refetch + +### 20. Persistence, SSR와 offline + +- memory cache runtime은 `COMPOSED` +- IndexedDB query persister reference는 VD-13 기준 + `DESIGNED_NOT_IMPLEMENTED` +- product persistence는 `NOT_SELECTED` +- SSR hydration은 `NOT_SELECTED` +- offline mutation queue는 `NOT_SELECTED` + +VD-25 profile은 persistence를 직접 켜지 않는다. joined VD-13 +scope/persistence profile, reference runtime과 제품 allowlist/retention/scope가 +모두 구현·선택된 query만 IndexedDB persistence를 사용할 수 있다. + +server-state policy를 이유로 Web Storage에 query payload를 넣지 않는다. + +### 21. Security와 privacy + +- authorization result를 staleTime/cache hit으로 대체하지 않음 +- account/logout transition에서 sensitive data를 즉시 fence/hide +- query key에 raw PII/account/resource ID/URL/document/message 금지 +- cache value에 credential/header/validator/trace/error raw data 금지 +- optimistic layer에도 command token/raw body를 저장하지 않음 +- command identity token/logical key를 diagnostics, cross-context wire나 persistence에 + 넣지 않음 +- developer tools/diagnostics production exposure policy +- cache poisoning 방지를 위해 schema/mapper/result contract와 generation 검증 +- cross-context event에 data 없음 + +### 22. Observability + +허용: + +- query/mutation definition/profile ID +- hit/miss/stale/fresh/refresh/evict outcome +- result item/estimated-byte/page bucket +- runtime identity intern entry/canonical-byte/active-lease bucket +- focus/reconnect/invalidation refetch reason +- mutation concurrency/duplicate/optimistic/rollback/conflict/effect bucket +- invalidation/seed/degraded recovery outcome +- scope/profile version의 low-cardinality bucket + +금지: + +- query key/input/value +- command identity token/logical key +- identity intern canonical bytes/token actual value +- cursor/snapshot/validator/revision actual value +- resource/account/tenant ID +- GraphQL/protobuf/REST DTO +- optimistic patch/snapshot + +### 23. Testing + +query definition/key: + +- definition/input/key/executor type/runtime binding +- VD-13↔VD-25 topic set/version exact join과 bounded many-to-many fan-out +- runtime identity token same-input stability, random-token collision regeneration과 + bounded failure의 cache/join 0회 +- intern entry/total-byte ceiling, active non-eviction, Query GC/mutation terminal + lease release와 runtime-close leak 0 +- undefined/NaN/Date/class/accessor/cycle/sparse/oversize collision fixture +- same semantic input stable identity +- protocol wire identity 변화가 key에 들어가지 않음 +- per-profile stale/gc/refetch/retry owner + +cache admission: + +- mapped immutable plain value only +- item/estimated byte cap +- schema/mapper/generation failure write 0 +- auth/contract failure stale visibility + +mutation: + +- same semantic command identity, same logical key의 distinct input +- same hook/two hooks/two routes +- runtime/scope coordinator의 logical-key serial/parallel과 + join/reject/independent duplicate 결과 +- out-of-order success/failure +- optimistic layer/CAS rollback without overwriting other commit +- effect `NOT_APPLIED/COMMITTED/UNKNOWN` +- server response detail seed + list invalidate +- invalidation failure after commit +- account switch during pending command + +pagination: + +- null/repeated/cyclic cursor +- single-page cursor identity-token collision/isolation과 memory-only enforcement +- `hasMore`/`nextCursor` 불일치와 snapshot drift +- snapshot change +- max page/item/byte +- page eviction/refetch +- duplicate identity/revision policy +- cancellation and late page + +integration: + +- REST/GraphQL/gRPC unary → schema → mapper → cache → UI +- focus/reconnect/offline/stale-degraded +- conditional 304 exact binding +- cross-tab invalidation/mutation lease +- logout/account/release generation +- finite stream atomic commit and overflow + +### 24. Rollout + +1. VD-13 strict key codec/scope snapshot interface를 확정한다. +2. current `useApplicationQuery({queryKey, execute})` 뒤에 bound definition adapter를 + 추가한다. +3. reference queries를 shadow key/policy로 비교하되 secondary cache write 금지. +4. per-profile policy/result ceiling을 read query에 canary한다. +5. typed mutation definition과 input-aware coordinator를 도입한다. +6. optimistic layer/CAS를 low-conflict command에만 canary한다. +7. cursor page reference vertical을 구현한다. +8. arbitrary key/executor와 raw optimistic callback API를 제거한다. +9. account/generation browser evidence와 runbook drill 뒤 traffic을 올린다. + +rollback: + +- 신규 query/mutation admission/optimistic patch를 kill switch로 닫음 +- current scope queries cancel +- unsafe/incompatible memory cache clear +- pending command effect certainty reconcile +- current basic facade 또는 no-optimistic authoritative refetch로 downgrade +- schema/mapper/query definition/backend artifact를 coherent set으로 복구 + +## 규범 기준 + +- [TanStack Query v5 Important Defaults](https://tanstack.com/query/v5/docs/framework/react/guides/important-defaults) +- [TanStack Query v5 Query Cancellation](https://tanstack.com/query/v5/docs/framework/react/guides/query-cancellation) + +## 완료 기준 + +- caller가 arbitrary key/executor/TanStack option을 조합할 수 없다. +- strict key codec과 VD-13 scope projection이 모든 query에 적용된다. +- VD-13 normative key layout과 scope/persistence profile을 재정의하지 않고 exact + join한다. +- mapped/bounded/current-generation value만 cache에 들어간다. +- transport와 Query retry owner가 중복되지 않는다. +- cursor page가 next/snapshot/loop/page/item/byte ceiling을 갖는다. +- runtime-private exact equality guard까지 통과한 command identity만 declared + join되고 distinct input이 같은 Promise로 잘못 join되지 않는다. +- concurrent optimistic rollback이 다른 committed update를 덮지 않는다. +- effect certainty, conflict, seed와 invalidation owner가 operation별로 닫힌다. +- GraphQL normalized dual cache와 unbounded stream cache가 없다. +- scope/logout/provider fault와 rollback/removal evidence가 통과한다. diff --git a/docs/architecture/decisions/VD-26-persisted-graphql-operation.md b/docs/architecture/decisions/VD-26-persisted-graphql-operation.md new file mode 100644 index 0000000..70d1f80 --- /dev/null +++ b/docs/architecture/decisions/VD-26-persisted-graphql-operation.md @@ -0,0 +1,653 @@ +# VD-26: Persisted GraphQL operation + +- 상태: Accepted design — reference runtime implementation pending +- 결정일: 2026-07-28 +- provider-neutral GraphQL reference adapter: + `DESIGNED_NOT_IMPLEMENTED` +- product GraphQL composition: `NOT_SELECTED` +- batching/subscription/`@defer`/`@stream`: `NOT_SELECTED` +- normalized GraphQL cache: `NOT_SELECTED` +- 관련 결정: VD-13, VD-23, VD-24, VD-25, VD-28 +- 상세 설계: + [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) + +## 배경 + +현재 source, package direct dependency, config와 test에는 GraphQL runtime, +operation document/codegen, persisted manifest나 endpoint provider가 없다. +lockfile의 transitive `graphql` package는 MSW 개발 의존성일 뿐 capability +구현 증거가 아니다. + +GraphQL은 임의 query string을 보내는 범용 API escape hatch로 도입하지 않는다. +제품이 여러 backend aggregate를 화면별 shape로 조회해야 하고 schema/router, +field authorization, persisted allowlist와 cost budget을 운영할 수 있을 때만 +bounded-context operation family로 선택한다. + +## 결정 + +### 1. Production GraphQL은 persisted operation only다 + +```text +semantic application query/command + -> registered GraphqlOperationDefinition + -> fixed endpoint + -> persisted operation ID/hash + -> validated variables + -> bounded GraphQL response decoder + -> operation data schema + -> boundary mapper + -> application projection + -> TanStack Query or command result +``` + +production runtime은 다음을 받지 않는다. + +- arbitrary GraphQL document +- caller-provided operation name/hash +- arbitrary endpoint/header +- generated SDK selection set builder +- field/fragment string + +### 2. Operation artifact + +```text +PersistedGraphqlOperationV1 + protocol = PERSISTED_GRAPHQL_V1 + semanticOperationId + operationName + operationKind = QUERY | MUTATION + canonicalDocumentSha256 + persistedOperationId + schemaArtifactId + schemaDigest + variablesSchemaId + dataSchemaId + mapperId + errorProfileId + partialDataPolicy + endpointId + graphqlHttpProfileRevision + persistedEnvelopeProfileId + responseStatusMediaProfileId + authProfileId + csrfProfileId + replayPolicy + deadlineProfileId + retryProfileId + serverStateProfileId | null + maxVariablesBytes + maxResponseBytes + maxErrorCount + maxCost + maxDepth + maxAliases + owner +``` + +canonical document는 build artifact이고 runtime string이 아니다. stable operation +ID와 hash는 schema/operation manifest에 binding한다. + +manifest 생성: + +```text +authenticated immutable schema + -> named operation sources + -> parse/validate against schema + -> canonical document + -> operation hash/ID + -> variables/result type generation + -> runtime codec manifest + -> mapper/query profile binding + -> persisted operation manifest +``` + +### 3. Schema와 codegen + +- schema source URL에서 normal build마다 latest를 받지 않는다. +- authenticated explicit update workflow가 immutable artifact와 provenance를 + 저장한다. +- schema/source/operation manifest digest를 release contract set에 binding한다. +- anonymous operation, duplicate operation name와 invalid fragment를 거절한다. +- generator, plugins, Node와 runtime version을 pin한다. +- clean checkout regenerate diff가 0이어야 한다. +- schema breaking diff, operation validation, deprecated field budget와 generated + output digest를 CI gate로 둔다. +- generated type은 adapter-private DTO다. +- generated TypeScript type만 믿지 않고 variables/data runtime codec과 mapper를 + 유지한다. +- schema introspection을 production에서 끄는 결정은 server 보안 옵션일 뿐 + authorization/cost control을 대체하지 않는다. + +### 4. Endpoint와 HTTP profile + +```text +GraphqlProviderProfile + endpointId + fixedHttpsUrl + graphqlHttpProfileRevision + persistedEnvelopeProfileId + methodPolicy + credentialsMode + corsProfile + referrerPolicy + redirect = ERROR + mediaProfile +``` + +GraphQL-over-HTTP draft를 움직이는 implicit `latest`로 구현하지 않는다. selected +revision의 request/response/status 규칙과 provider의 persisted-operation +extension을 immutable profile/fixture에 고정한다. persisted ID/hash-only envelope는 +표준 request의 required `query` field를 생략하는 provider extension일 수 있으므로 +generic GraphQL-over-HTTP compliance로 가장하지 않는다. + +private query와 mutation은 POST가 기본이다. + +GET은 다음을 모두 만족하는 public read profile에서만 선택한다. + +- persisted ID/hash와 non-sensitive bounded variables +- URL byte ceiling +- no credential/private representation 또는 명시된 safe cache contract +- exact cache key/Vary/CDN policy +- mutation 아님 + +raw document와 sensitive variables를 URL에 넣지 않는다. + +request `Content-Type: application/json`과 +`Accept: application/graphql-response+json`을 기본 exact profile로 둔다. +`application/json` response 지원은 legacy provider profile로 분리한다. caller가 +`fetch` option, headers와 credentials를 override하지 않는다. + +status/media matrix: + +- final URL/origin과 media/body ceiling을 먼저 확인한다. +- `application/graphql-response+json`은 profile이 허용한 HTTP status 전체에서 + bounded GraphQL envelope를 먼저 decode하고 selected revision의 status/body + 불변조건을 교차 검증한다. +- non-null `data`가 있는 response는 selected revision이 요구하는 2xx여야 한다. + no-data/error와 partial response의 status는 pinned revision/provider fixture와 + exact match해야 한다. +- legacy `application/json`은 허용된 2xx body만 GraphQL envelope로 신뢰한다. + non-2xx body는 intermediary일 수 있으므로 GraphQL error/extensions로 + 해석하지 않고 bounded generic HTTP failure로 닫는다. + +### 5. Request envelope + +wire shape는 provider의 persisted-envelope extension이 versioned codec으로 +고정한다. 최소 의미: + +```text +protocol +persisted operation ID +canonical document hash +operation name +validated variables +client contract manifest version +``` + +full document는 포함하지 않는다. + +provider가 ID/hash-only envelope를 지원하지 않으면 이 capability를 그 endpoint에 +compose하지 않는다. production에서 표준 `query` field를 채우기 위해 full +document fallback을 보내는 것으로 우회하지 않는다. + +variables: + +- request runtime schema의 parsed output만 사용 +- unknown field 거절 +- depth/node/string/list/encoded byte ceiling +- File/Blob/stream/native/generated class 금지 +- ID/decimal/int64/time semantics는 VD-24 +- secret/credential를 variable로 전달하는 operation 금지 + +### 6. APQ와 manifest miss + +runtime Automatic Persisted Query negotiation을 production default로 사용하지 +않는다. + +```text +persisted miss/hash mismatch + -> body/reader cancel + -> PERSISTED_OPERATION_MISMATCH + -> operation traffic disable or coherent manifest recovery +``` + +hash miss 뒤 full document를 자동 전송하면 server allowlist와 cost governance를 +우회할 수 있다. trusted development profile에서만 explicit opt-in 가능하며 +production promotion 증거로 사용하지 않는다. + +frontend manifest와 router manifest의 N/N-1 rollout을 먼저 증명한다. + +### 7. Total deadline, cancellation과 retry + +VD-23 common logical deadline을 사용한다. + +- credential/CSRF attach +- network attempts/backoff +- response read/JSON parse +- GraphQL envelope/data/error validation +- mapper + +Query retry와 GraphQL transport retry를 중복하지 않는다. + +retry: + +- idempotent query의 selected network/408/429/502/503/504 +- keyed mutation은 backend idempotency evidence가 있을 때만 +- GraphQL validation, persisted miss, cost/depth, schema/data/error mismatch는 + retry하지 않음 +- HTTP 200 GraphQL business error를 transient network failure로 자동 retry하지 않음 +- UNAUTHENTICATED recovery는 safe query/keyed mutation만 same logical binding으로 + 한 번 + +AbortSignal은 fetch와 body/incremental reader를 cancel한다. local cancel이 mutation +미적용을 의미하지 않으며 ambiguous effect는 status/reconcile contract로 닫는다. + +### 8. Response decoder + +```text +HTTP response + -> final URL/origin/media/header + -> present/valid Content-Length advisory preflight + -> bounded stream reader + -> decoded byte/depth/node/string/list cap + -> GraphQL response envelope + -> pinned HTTP status/body matrix + -> data/errors state machine + -> operation data codec + -> mapper +``` + +top-level: + +```text +GraphqlResponse + data? + errors? + extensions? +``` + +unknown top-level/extension behavior는 provider profile과 VD-24 unknown-field +정책을 따른다. response body, error message, path와 extensions를 log에 복사하지 +않는다. + +### 9. Data/error state machine + +다음 순서로 배타적으로 처리한다. + +1. network/final URL/unsupported media/body limit 실패 또는 legacy + `application/json` non-2xx: + transport 또는 media/limit failure, data/cache write 0. +2. `application/graphql-response+json`은 profile이 허용한 status 전체에서, + legacy `application/json`은 profile-admitted 2xx에서만 bounded parse한다. + top-level response shape 불일치는 `GRAPHQL_ENVELOPE_MISMATCH`. +3. `errors` key가 있으면 non-empty list여야 한다. `errors=[]`는 항상 + `GRAPHQL_ENVELOPE_MISMATCH`다. +4. selected GraphQL-over-HTTP revision의 status/body matrix가 맞지 않으면 + `GRAPHQL_HTTP_PROFILE_MISMATCH`다. +5. `data` key 존재 + non-null, `errors` 없음: + data codec → mapper → generation fence → success. +6. `data` 없음/null, non-empty `errors`: + safe error mapping; success/cache write 0. +7. non-null `data`와 non-empty `errors` 동시: + operation `partialDataPolicy` 적용. +8. `data` 없음/null이고 errors도 없음: + contract mismatch. + +### 10. Error projection + +GraphQL error는 untrusted다. + +```text +GraphqlError + message + locations + path + extensions +``` + +application에 허용: + +- operation error profile이 allowlist한 `extensions.code` +- bounded typed validation field issue +- effect certainty/conflict category +- bounded server request/trace ID projection + +금지: + +- raw `message` +- source location +- path actual value +- arbitrary extensions +- resolver/service/stack/database detail + +error count, path segment/count/string와 extensions decoded byte cap을 적용한다. +unknown code는 generic closed failure다. backend `retryable` boolean은 retry +authority가 아니다. + +common mapping 예: + +| safe GraphQL category | AppFailure | +| --- | --- | +| unauthenticated | `AUTH_REQUIRED` | +| forbidden | `FORBIDDEN` | +| not found | `NOT_FOUND` 또는 existence-hiding policy | +| validation | `VALIDATION_REJECTED` | +| conflict/precondition | `CONFLICT` 또는 typed precondition | +| rate limited | `RATE_LIMITED` | +| internal/unavailable | `SERVER_FAILURE` | +| unknown | `UNKNOWN_CLIENT_FAILURE` 또는 contract failure | + +### 11. Partial data + +default: + +```text +partialDataPolicy = REJECT +``` + +query에만 다음 explicit profile을 허용할 수 있다. + +```text +ALLOW_TYPED_PARTIAL + requiredCompletePaths + optionalPartialPaths + errorCodeAllowlist + completenessSchemaId + staleVisibilityPolicy +``` + +조건: + +- data codec이 missing/null path를 정확히 표현 +- mapper가 completeness를 application result로 투영 +- UI가 complete success와 partial-degraded를 구분 +- partial value/result size ceiling +- authorization/error path를 숨기며 unsafe field를 사용하지 않음 +- previous complete cache와 field 단위로 임의 merge하지 않음 + +mutation은 errors가 있으면 partial success data를 ordinary command success로 +cache하지 않는다. backend가 effect certainty/receipt를 제공해야 +`COMMITTED | NOT_APPLIED | UNKNOWN`을 판단한다. error가 있다는 이유만으로 +optimistic layer 전체를 즉시 rollback해 다른 commit을 덮지 않는다. + +### 12. Null bubbling + +GraphQL nullability propagation은 application null 의미와 다르다. + +- nullable field, error-caused null과 absent partial field를 data/error state + machine이 함께 해석 +- generated type의 `T | null`만으로 cause를 추측하지 않음 +- operation data codec/mapper가 approved partial path와 error code를 결합 +- required root/aggregate null은 default failure +- unauthorized field null을 stale previous field로 자동 채우지 않음 + +### 13. Cache identity + +VD-25 TanStack Query가 기본 sole owner다. + +- query key는 semantic operation input + VD-13 scope +- persisted operation ID/hash/document를 key에 넣지 않음 +- GraphQL data/envelope/generated type을 cache하지 않음 +- mapped bounded application projection만 cache +- schema/mapper meaning change는 query/release epoch invalidation +- GraphQL client library cache는 disabled/`no-cache` + +normalized cache가 필요하면 separate ADR: + +- key fields/`__typename` +- fragment completeness +- pagination merge +- optimistic layers +- eviction/gc/logout/scope +- persistence/SSR +- TanStack replacement/removal + +dual cache는 금지한다. + +### 14. Batching + +현재 `NOT_SELECTED`. + +`@defer`/`@stream`은 한 GraphQL HTTP operation의 finite incremental response다. +장기 subscription이나 unsolicited realtime event가 아니며, reconnect/resume +owner를 realtime runtime에 넘기지 않는다. + +선택 조건: + +- 같은 endpoint/auth/scope +- query only +- same credentials/CSRF policy +- max operation count +- total variables/request bytes +- total cost/depth +- per-operation deadline/result/error/observation 보존 +- one operation cancel/failure가 다른 operation semantics를 바꾸지 않음 + +금지: + +- mutation 포함 +- query+mutation mixed batch +- 서로 다른 account/session +- batching으로 idempotency/retry owner 합치기 +- one HTTP result를 one query cache value로 저장 + +batch transport failure와 per-operation GraphQL failure를 분리한다. + +### 15. Incremental `@defer`/`@stream` + +현재 `NOT_SELECTED`. + +선택 시 별도 profile: + +- exact incremental-delivery draft/provider revision +- exact `Accept`, response `Content-Type`와 boundary/version parameter +- exact `multipart/mixed` media/boundary parser +- total bytes/parts/depth/patch count +- initial/subsequent/terminal payload discriminant와 completion grammar +- operation-owned ID/label/path allowlist와 path progression +- patch/data/items/errors/extensions runtime schema +- part별 및 cumulative error/extension count/byte ceiling +- duplicate/out-of-order/missing path +- terminal marker +- idle/total deadline +- backpressure/cancel/reader cleanup +- proxy/CDN buffering conformance + +cache: + +- staging projection에 immutable patch 적용 +- terminal integrity/completeness 뒤 atomic commit +- 또는 UI가 explicit progressive state를 소유 +- existing cached object를 in-place mutate하지 않음 +- truncated stream을 complete success로 cache하지 않음 + +Chromium/Firefox/WebKit과 actual proxy 증거 없이는 traffic promotion 금지다. +exact protocol revision/profile이 없으면 registry composition 자체를 거절한다. + +### 16. Subscription + +GraphQL HTTP query adapter에 subscription을 넣지 않는다. 현재 `NOT_SELECTED`. + +선택 시 transport-specific registered GraphQL subscription capability와 +feature-owned `FeatureEventInput`이 필요하다. 범용 `RealtimePort`를 만들지 +않는다. + +```text +GraphqlSubscriptionCapability + subscribe(registered subscription, validated variables, signal) + -> AsyncIterable> + -> unsubscribe() +``` + +선택된 WebSocket/SSE subprotocol adapter가 frame, media, auth, reconnect/resume를 +소유하고 GraphQL event schema와 pure mapper를 통과한 event만 +`FeatureEventInput` 또는 invalidation bridge로 전달한다. backend contract가 +명시적으로 같은 의미를 채택하지 않는 한 GraphQL payload를 +`REALTIME_EVENT_V1`로 강제하거나 다시 감싸지 않는다. + +backend 계약: + +- exact WebSocket/SSE protocol/version +- auth attach/refresh/revoke +- heartbeat/idle timeout +- reconnect/backoff +- sequence/duplicate/gap/resume cursor +- bounded queue/overflow +- logout/route unmount unsubscribe + +event는 invalidation hint 또는 registered bounded reducer를 통해 server-state를 +갱신한다. raw event history를 Query cache에 무한 적재하지 않는다. + +### 17. Authorization, CSRF와 DoS + +- BFF/router가 field/resource authorization을 매 request에 수행 +- persisted allowlist는 authorization이 아님 +- cookie mutation은 POST + exact Origin/Fetch Metadata + approved CSRF proof +- SameSite/custom header/preflight 단일 요소만 방어라고 주장하지 않음 +- cross-origin credential wildcard 금지 +- server에서 depth, aliases, fragments, variables/list/page/field cost, total + execution와 response bytes 강제 +- frontend ceiling은 server DoS 방어를 대체하지 않음 +- introspection off는 field authorization/cost control 대체 아님 +- persisted operation manifest와 field authorization change를 coherent rollout + +### 18. Backend/router 계약 + +provider가 제공: + +- immutable schema artifact/provenance +- persisted operation registration/lookup +- exact operation hash/schema digest binding +- N/N-1 manifest window와 retirement +- cost/depth/alias/list/response budget enforcement +- stable safe error code vocabulary +- partial/null/effect certainty semantics +- idempotency/conflict/revision +- auth/CSRF/CORS +- request/trace projection +- kill switch와 per-operation traffic + +frontend manifest echo만으로 등록/authorization을 승인하지 않는다. router가 +server-owned manifest에서 operation binding을 재계산한다. + +### 19. Observability + +허용: + +- semantic operation ID/persisted profile ID +- schema/manifest compatibility outcome +- full/partial/rejected/transport outcome +- safe GraphQL error category +- cost/depth/variables/response/error/part count bucket +- duration/deadline/retry/auth recovery bucket +- cache hit/stale/admission outcome + +금지: + +- document/hash actual value +- variables/data +- raw error message/path/extensions +- field/resolver name high-cardinality label +- account/resource/cursor/revision + +server가 resolver-level telemetry를 소유한다. browser가 raw field trace를 수집하지 +않는다. + +### 20. Testing + +build/contract: + +- schema source provenance/digest +- schema lint/breaking/deprecation budget +- named operation validation +- canonical hash/manifest determinism +- clean codegen diff +- generated import boundary +- variables/data codec parity +- N/N-1 persisted manifest and retirement + +runtime: + +- unknown/hash mismatch, full-document fallback 0 +- variables depth/node/string/list/byte cap +- GraphQL HTTP revision/media/status-body matrix와 legacy intermediary body +- HTTP/media/body cap +- all data/errors state branches +- empty errors와 null/absent data matrix +- error count/path/extensions cap/redaction +- null bubbling +- partial allowed/rejected/completeness +- timeout/cancel/retry/auth recovery +- mutation effect certainty/idempotency +- scope/generation late result +- cache admission/write 0 on failure + +optional: + +- batching mixed/mutation/limit rejection +- multipart boundary/truncated/duplicate/out-of-order/terminal +- subscription ordering/reconnect/resume/logout + +provider/browser: + +- actual BFF/router allowlist/cost/auth/CSRF/CORS +- manifest rollout/retirement +- proxy/CDN media/body behavior +- Chromium/Firefox/WebKit for selected incremental/subscription capability + +### 21. Rollout + +1. product owner가 GraphQL이 필요한 bounded operation family를 승인한다. +2. schema/router/manifest owner와 endpoint/auth/cost/error contract를 확정한다. +3. provider-neutral codec/adapter/fake를 구현한다. +4. generated source, boundary mapper와 TanStack query definition을 연결한다. +5. REST current read와 GraphQL shadow read를 비교하되 shadow result는 UI/cache에 + 쓰지 않는다. +6. actual router conformance를 통과한다. +7. `AVAILABLE_NOT_COMPOSED`에서 product composition behind + `TrafficAdmission=DISABLED`로 이동한다. +8. read-only internal canary 뒤 selected operation만 traffic을 올린다. +9. mutation은 idempotency/effect certainty provider evidence 뒤 별도 canary한다. +10. batching/incremental/subscription은 계속 `NOT_SELECTED` 또는 독립 gate다. + +rollback: + +- 신규 GraphQL operation admission 중지 +- in-flight query cancel, mutation effect reconcile +- current scope GraphQL-mapped query cache clear +- coherent frontend/schema/manifest/router rollback +- approved REST read fallback이 있으면 새 logical read로 전환 +- arbitrary/full-document fallback 금지 + +### 22. Removal + +1. operation traffic/registration retirement 시작 +2. query/subscription cancel과 mutation reconcile +3. Query cache/invalidation listener clear +4. operation/codec/mapper/query profile 제거 +5. generated files, GraphQL runtime/codegen dependencies 제거 +6. schema/operation manifest/config/endpoint 제거 +7. router persisted entries는 N/N-1 window 뒤 제거 +8. production module/dependency/SBOM/removal test 통과 + +## 규범 기준 + +- [GraphQL Specification, September 2025](https://spec.graphql.org/September2025/) +- [GraphQL over HTTP draft](https://graphql.github.io/graphql-over-http/draft/) + +GraphQL-over-HTTP 문서는 현재 draft이므로 링크의 moving text를 production +profile로 쓰지 않고 위에서 결정한 revision/provider fixture로 고정한다. + +## 완료 기준 + +- production에서 registered persisted operation 외 document가 전송되지 않는다. +- schema/operation/codegen/runtime codec/mapper manifest가 digest로 연결된다. +- variables/response/errors가 bounded runtime validation을 거친다. +- persisted envelope extension과 GraphQL-over-HTTP revision/media/status matrix가 + actual router profile에 고정된다. +- data/errors/partial/null/effect certainty 상태가 배타적으로 닫힌다. +- auth/CSRF/cost/field authorization과 manifest N/N-1을 actual router에서 증명한다. +- GraphQL SDK normalized cache와 TanStack dual cache가 없다. +- query key/cache에 document/hash/envelope/generated DTO가 없다. +- batching/incremental/subscription은 선택 전 설치되지 않는다. +- kill switch, rollback과 dependency/manifest removal drill이 통과한다. diff --git a/docs/architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md b/docs/architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md new file mode 100644 index 0000000..43283a5 --- /dev/null +++ b/docs/architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md @@ -0,0 +1,1065 @@ +# VD-27: gRPC-Web unary와 server stream + +- 상태: Accepted design — common coordinator available, wire adapter pending +- 결정일: 2026-07-28 +- provider-neutral Browser RPC V3 coordinator: + `AVAILABLE_NOT_COMPOSED` +- gRPC-Web unary/server-stream reference adapter: + `DESIGNED_NOT_IMPLEMENTED` +- product gRPC-Web composition: `NOT_SELECTED` +- client-streaming/bidirectional-streaming guarantee: `PLATFORM_LIMITED` +- 관련 결정: VD-13, VD-23, VD-24, VD-25, VD-28, VD-29, VD-30 +- 상세 설계: + [Protobuf browser transport와 REST Gateway](../protobuf-browser-transport-and-rest-gateway.md) +- 상위 API 설계: + [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) + +## 배경 + +현재 source에는 provider-neutral operation/profile registry, application port와 +unary/server-stream lifecycle coordinator가 있다. package direct dependency, +generated output, `.proto`/descriptor, Buf/protoc config, proxy route와 actual +browser test에는 gRPC-Web capability가 없다. + +browser는 native gRPC transport를 직접 실행하지 않는다. gRPC-Web 또는 선택한 +Connect protocol을 지원하는 BFF/Envoy/gateway가 필요하다. protobuf backend가 +있다는 이유만으로 browser client에 generated SDK를 노출하지 않는다. + +gRPC-Web은 unary와 server streaming에 적합할 수 있지만 browser baseline에서 +client streaming과 bidirectional streaming을 일반 gRPC처럼 보장하지 않는다. +요구 의미에 따라 upload/job은 REST, one-way server event는 SSE, 진짜 duplex는 +WebSocket/WebTransport 또는 별도 protocol로 다시 설계한다. + +## 결정 + +### 1. Protocol profile을 혼용하지 않는다 + +선택 가능한 provider profile 예: + +```text +GRPC_WEB_BINARY +GRPC_WEB_TEXT +``` + +`GRPC_WEB_BINARY | GRPC_WEB_TEXT`는 wire transfer/framing profile이고 Protobuf +binary와 JSON message encoding을 뜻하는 이름이 아니다. Connect protocol +`CONNECT_V1`은 이 목록에서 선택하지 않으며 VD-29의 Connect state machine과 +registry가 별도로 소유한다. + +gRPC-Web transport implementation도 별도 discriminant로 고정한다. + +```text +GrpcWebTransportRuntimeKind + = OFFICIAL_GRPC_WEB_XHR + | CONNECT_WEB_GRPC_WEB_FETCH + | CUSTOM_FETCH_FRAMED + +GrpcWebClientApiKind + = CALLBACK_STREAM + | PROMISE_UNARY + | ASYNC_ITERABLE + +GrpcWebMessageEncoding + = PROTO + | JSON +``` + +한 operation/provider는 하나의 exact profile에 binding한다. + +- gRPC-Web framing/status와 Connect envelope/status를 같은 decoder로 추측하지 않음 +- content type을 보고 runtime이 vendor를 자동 선택하지 않음 +- `OFFICIAL_GRPC_WEB_XHR`는 pinned official `grpc-web` runtime이 XHR, request + framing, text base64, response framing과 status 해석을 소유한다. 이를 custom + Fetch/raw-frame 경로라고 기록하지 않음 +- `CONNECT_WEB_GRPC_WEB_FETCH`는 VD-29가 소유하는 pinned Connect-Web + `createGrpcWebTransport()`다. Fetch를 사용하지만 request/response gRPC-Web + binary envelope, JSON/Proto serialization, status와 trailer decode를 selected + runtime이 소유하므로 `CUSTOM_FETCH_FRAMED` raw decoder로 분류하지 않음 +- `CUSTOM_FETCH_FRAMED`만 browser Fetch `ReadableStream`, `AbortController`와 + adapter-owned raw frame decoder를 사용함 +- official grpc-web runtime의 unary는 binary 또는 text, server-stream은 + `grpcwebtext`만 허용 +- official runtime의 `SERVER_STREAM + GRPC_WEB_BINARY` registry row는 거절 +- Connect-Web gRPC-Web runtime은 binary envelope의 Proto unary/server-stream을 + 후보로 허용한다. JSON은 exact provider/runtime fixture가 있을 때만 별도 + `messageEncoding=JSON` row이고 `GRPC_WEB_TEXT`는 항상 거절한다. +- Connect-Web gRPC-Web server stream은 `ASYNC_ITERABLE` API와 Chromium, Firefox, + WebKit 및 actual proxy의 incremental-read/cancel/backpressure/trailer evidence가 + 있을 때만 허용 +- custom Fetch binary stream은 selected runtime ID, 세 browser와 actual proxy의 + incremental-read/cancel/backpressure evidence가 있을 때만 별도 profile +- official production composition은 cancel handle과 terminal status event를 + 유지하는 `CALLBACK_STREAM`을 기본으로 한다. official `PROMISE_UNARY`는 + `.on(...)` metadata/status event와 direct call handle을 제공하지 않는다. pinned + runtime/codegen이 `PromiseCallOptions.signal`을 지원하면 cancellation은 그 + `AbortSignal`로 충족할 수 있지만, required status/metadata event는 충족하지 + 못하므로 registry에서 거절 +- text mode의 base64 overhead를 message/total budget에 반영 + +이 ADR의 state machine은 gRPC-Web을 기준으로 한다. Connect-Web runtime이 +`CONNECT_V1`을 선택하는 경우는 이 ADR의 runtime row가 아니라 VD-29의 별도 +protocol/profile/fixture를 사용하고 gRPC-Web frame을 재사용하지 않는다. + +### 2. Application 경계 + +```text +feature gateway + -> registered semantic operation + -> GrpcWebOperationDefinition + -> generated request mapping + -> selected gRPC-Web transport runtime + -> runtime-owned OR custom adapter-owned frame/status/trailer validation + -> selected runtime/generated response decode + -> semantic runtime validation + -> boundary mapper + -> application result or mapped stream event +``` + +application/domain/presentation/query cache에 노출하지 않는다. + +- generated service client +- generated protobuf message +- service/method string +- endpoint/metadata +- raw frame/trailer/status details +- protobuf bigint/bytes representation + +### 3. Operation definition + +```text +GrpcWebOperationV1 + protocol = GRPC_WEB_V1 + semanticOperationId + providerId + clientRuntimeId + transportRuntimeKind + clientApiKind + grpcWebWireSpecRevision + transportProfile + messageEncoding = PROTO | JSON + fullyQualifiedService + method + rpcKind = UNARY | SERVER_STREAM + requestMessageId + responseMessageId + descriptorArtifactId + descriptorDigest + requestSemanticSchemaId + responseSemanticSchemaId + mapperId + statusProfileId + responseHttpStatusProfileId + authProfileId + csrfProfileId + replayPolicy + deadlineProfileId + retryProfileId + retryOwner = FRONTEND_ADAPTER | ENVOY | NONE + proxyProfileId + headerBudgetProfileId + serverStateProfileId | null + maxRequestMessageBytes + maxResponseMessageBytes + maxResponseMessages + maxTotalResponseBytes + idleDeadlineMs + totalDeadlineMs + compressionProfile + owner +``` + +registry가 거절: + +- client/bidi method +- selected `(rpcKind, transportProfile, clientRuntimeId, browser/proxy profile)`이 + capability matrix에 없음 +- operation/provider의 runtime kind 또는 client API kind가 다름 +- official runtime인데 custom Fetch/raw-frame/AbortSignal capability를 요구하거나 + custom runtime인데 adapter-owned framing/status decoder가 없음 +- official runtime인데 `messageEncoding=JSON`이거나 Connect-Web runtime인데 + `transportProfile=GRPC_WEB_TEXT`임 +- Connect-Web JSON row에 exact provider media/status/generated-code fixture가 없거나 + Connect-Web server stream이 `clientApiKind=ASYNC_ITERABLE`이 아님 +- official `PROMISE_UNARY`인데 terminal metadata/status event가 operation + requirement이거나 pinned runtime/codegen에 `PromiseCallOptions.signal` + capability가 없는데 explicit cancellation이 requirement임 +- unknown service/method/message/descriptor +- descriptor digest mismatch +- unary인데 response max count가 1이 아님 +- server stream인데 queue/idle/total/message/count budget 없음 +- non-replayable command에 retry +- frontend/Envoy/backend 중 retry owner가 둘 이상이거나 selected owner의 exact + attempt/backoff/deadline policy가 없음 +- executable proxy/header budget profile이 없음 +- long-running stream에 ordinary Query profile +- caller-provided endpoint/metadata + +### 4. Protobuf source와 codegen + +```text +authenticated proto/Buf module + -> immutable source/descriptor set + -> provenance + digest + -> lint + -> breaking comparison against production baseline + -> pinned deterministic generation + -> adapter-private client/messages + -> semantic validators + mapper +``` + +governance: + +- field number 재사용 금지 +- removed field number/name reserve +- package/service/method full name 안정성 +- proto3 presence/optional 의미 +- enum zero/unknown numeric value +- oneof absence/unknown case +- map ordering을 application identity로 사용하지 않음 +- bytes/message nesting/repeated count ceiling +- Timestamp/Duration valid range/nanos +- int64/uint64 projection policy +- unknown fields library behavior와 mapper projection + +generated decoder success는 semantic domain proof가 아니다. VD-24 runtime semantic +validator가 range, presence, enum/oneof와 collection budget을 확인한다. + +CI: + +- pinned Buf/protoc/plugin/runtime/Node versions +- source/descriptor/generated digest +- clean regenerate diff 0 +- Buf lint/breaking +- generated import boundary +- N/N-1 descriptor fixture +- generated code dependency/SBOM/removal + +### 5. Provider endpoint와 proxy + +```text +GrpcWebProviderProfile + providerId + fixedHttpsOrigin + pathPrefix + clientRuntimeId + transportRuntimeKind + clientApiKind + grpcWebWireSpecRevision + transportProfile + messageEncoding + browserCapabilityProfileId + proxyCapabilityProfileId + proxyProfileId + headerBudgetProfileId + responseHttpStatusProfileId + credentialsMode + corsProfileId + referrerPolicy + redirect = ERROR + maximumEnvelopeBytes +``` + +operation과 provider의 runtime kind/client API/runtime version/wire spec/transport/ +message encoding/proxy/header tuple은 exact match해야 한다. moving `latest` wire +spec이나 library auto-detection을 compatibility proof로 사용하지 않는다. + +```text +GrpcWebHttpStatusProfile + allowedGrpcMediaHttpStatuses[] + missingGrpcStatusMap = CANONICAL_HTTP_TO_GRPC_V1 + emitSyntheticGrpcStatusWireHeader = false + httpGrpcConsistencyFixtureId +``` + +status/media/source matrix: + +- valid `grpc-status`가 있으면 HTTP status와 무관하게 그 값이 authoritative다. + accepted gRPC-Web media와 terminal source가 있으면 bounded decode한 뒤 selected + provider의 HTTP↔gRPC consistency fixture를 별도로 검사한다. +- `grpc-status`가 없으면 client는 wire response를 변조하지 않고 아래 canonical + HTTP→gRPC mapping으로 **internal normalized status**를 만든다. synthetic response + header를 생성하거나 이 표를 server-side gRPC→HTTP 역매핑으로 사용하지 않는다. + + | HTTP status | missing `grpc-status`의 normalized gRPC status | + | --- | --- | + | `400` | `INTERNAL` | + | `401` | `UNAUTHENTICATED` | + | `403` | `PERMISSION_DENIED` | + | `404` | `UNIMPLEMENTED` | + | `429`, `502`, `503`, `504` | `UNAVAILABLE` | + | 그 밖의 모든 status | `UNKNOWN` | + +- invalid/missing gRPC media, missing terminal source와 malformed body는 위 normalized + status와 별개로 `protocolViolation=true`인 transport/contract failure로 기록한다. + HTTP 2xx라도 missing `grpc-status`를 success로 만들지 않는다. +- `429`를 domain `RATE_LIMITED`로 곧바로 해석하지 않는다. canonical + `UNAVAILABLE` normalization 뒤 selected application status mapper가 별도 증거가 + 있을 때만 rate-limit 의미를 부여한다. +- operation/provider `responseHttpStatusProfileId`가 다르면 composition을 + 거절한다. + +same-origin BFF/Envoy gateway를 권장한다. + +```text +GrpcWebCorsProfile + topology = SAME_ORIGIN | CROSS_ORIGIN + allowedOrigins[] + allowCredentials + allowMethods = [POST, OPTIONS] + allowHeaders[] + exposeHeaders[] + forwardNotMatchingPreflights = false + clientSuppressCorsPreflight = false + maxAgeSeconds +``` + +cross-origin profile과 actual provider evidence: + +- exact allow-origin, wildcard credential 금지 +- `POST`, `OPTIONS` 외 method 금지 +- 최소 `content-type`, `x-grpc-web`, `x-user-agent`, selected deadline의 + `grpc-timeout`과 exact auth/CSRF/idempotency/trace header만 allowlist +- trailers-only response header를 위해 `grpc-status`, `grpc-message`, rich details를 + 선택한 profile은 `grpc-status-details-bin`까지 expose +- official runtime의 preflight 억제/query-parameter metadata 광고를 사용하지 + 않는다. credential/metadata가 URL, browser history, access log로 이동하지 않게 + client option `suppressCorsPreflight=false`로 고정 +- Envoy CORS filter는 non-matching preflight를 upstream으로 전달하지 않고 + fail-closed한다. +- TLS +- redirect 없음 +- stream buffering 없음 + +same-origin profile도 CSRF/Fetch Metadata/Origin 검증을 생략한다는 뜻이 아니다. + +```text +GrpcWebHeaderBudgetProfile + maxRequestHeaderListBytes + maxResponseHeaderListBytes + maxTrailerListBytes + maxHeaderFieldCount + maxBinaryMetadataDecodedBytes +``` + +- 각 값은 양의 유한값이며 operation/provider/proxy에서 exact match한다. native + gRPC protocol이 제안하는 header-list별 8 KiB를 initial ceiling으로 검토하되 + 제품이 다른 값을 택하면 actual runtime/proxy fixture와 함께 고정한다. +- gateway는 browser/runtime allocation 전에 request/response header hard cap을 + 집행하고 custom trailer parser는 field count, encoded/decoded bytes cap을 + 집행한다. official runtime은 raw frame 이전 cap을 application code가 소유한다고 + 주장하지 않는다. +- registered `-bin` metadata만 허용한다. padded/unpadded base64를 처리하고, + transport가 duplicate binary header를 comma-join했다면 각 value를 먼저 분리한 + 뒤 개별 decode한다. unknown metadata는 application에 projection하지 않는다. + +```text +GrpcWebProxyProfile + gatewayKind = ENVOY_GRPC_WEB | SELECTED_BFF + gatewayVersion + immutableConfigDigest + downstreamTlsProfileId + upstreamProtocol = HTTP2 + filterOrder = [grpc_web, cors, router] + routeTimeoutMs = DISABLED | positive-integer + hcmStreamIdleTimeoutMs + routeIdleTimeoutMs + maxStreamDurationMs + grpcTimeoutHeaderMaxMs + grpcTimeoutHeaderOffsetMs + buffering = DISABLED + flushProfileId + localReplyProfileId + retryOwner + headerBudgetProfileId +``` + +Envoy profile은 개념 필드로만 끝내지 않고 pinned v3 config artifact의 다음 위치로 +render한다. + +| 요구 | Envoy v3 config 위치 | +| --- | --- | +| bridge/filter order | HCM `http_filters`의 `envoy.filters.http.grpc_web`, `envoy.filters.http.cors`, `envoy.filters.http.router` 순서 | +| exact operation route | `Route.match`의 registered service/method path와 `RouteAction.cluster` | +| upstream HTTP/2 | cluster `typed_extension_protocol_options`의 `envoy.extensions.upstreams.http.v3.HttpProtocolOptions.explicit_http_config.http2_protocol_options` | +| total/idle/max duration | `RouteAction.timeout`, `RouteAction.idle_timeout`, `RouteAction.max_stream_duration` 및 HCM `stream_idle_timeout` | +| deadline clamp | `RouteAction.max_stream_duration.grpc_timeout_header_max`와 `grpc_timeout_header_offset` | +| CORS | route/virtual-host `typed_per_filter_config[envoy.filters.http.cors]`; deprecated `cors` shortcut을 새 profile에 사용하지 않음 | +| buffer | `envoy.filters.http.buffer`를 설치하지 않거나 selected route의 `BufferPerRoute`를 disabled | +| retry | `RouteAction.retry_policy`; owner가 Envoy가 아니면 absent, hedge policy absent | + +Envoy grpc_web filter 또는 selected gateway가: + +- pinned version/config digest와 exact route/service/method allowlist +- Envoy일 때 `grpc_web -> cors -> router` filter 순서와 upstream HTTP/2 +- exact content type/framing, local reply와 missing-status fixture +- message/body/header/deadline cap +- terminal trailers/status preservation +- server stream flush/backpressure +- auth/CSRF/Origin +- retry owner와 upstream status mapping + +을 staging에서 증명해야 한다. Envoy route timeout 기본값에 기대지 않는다. +server stream route는 `timeout: 0s` 또는 application total deadline보다 긴 exact +finite value를 선택하고, 별도 finite stream-idle/max-stream-duration cap을 둔다. +`grpc_timeout_header_max`와 offset은 proxy가 browser deadline을 늘리지 않고 +필요하면 browser보다 먼저 종료하도록 맞춘다. route/HCM idle과 buffer/flush도 +명시하며 selected route에서 complete-body buffer filter를 비활성화한다. fake +fetch나 15초 미만 fixture만으로 provider conformant가 아니다. + +### 6. Request + +```text +validated application input + -> generated request message + -> semantic validation + -> bounded selected Proto/JSON encode + -> transport-owned metadata/auth/CSRF + -> final invariant + -> selected transport runtime + -> OFFICIAL_GRPC_WEB_XHR: runtime-owned frame/base64 + XHR + -> CONNECT_WEB_GRPC_WEB_FETCH: runtime-owned frame/status + Fetch + -> CUSTOM_FETCH_FRAMED: adapter-owned frame/base64 + Fetch +``` + +- service/method path는 registry가 만든다. +- wire method는 `POST`로 고정한다. +- binary Proto baseline은 `Content-Type: application/grpc-web+proto`, + Connect-Web JSON provider row는 `Content-Type: application/grpc-web+json`, text + baseline은 + `Content-Type: application/grpc-web-text`와 + `Accept: application/grpc-web-text`를 사용한다. exact selected media type은 + transport profile에 고정하고 response media를 검증한다. +- `X-Grpc-Web: 1`과 `X-User-Agent`를 selected runtime이 소유한다. browser + `User-Agent`를 대신 설정하거나 caller가 이 fixed header를 override하지 못한다. +- caller가 endpoint, metadata, `grpc-timeout` 또는 transport header를 제출하지 + 않는다. +- request message byte cap을 selected Proto/JSON encode 전후 검사한다. +- official runtime은 request framing/base64를 소유하고 generated client에 validated + message만 넘긴다. Connect-Web runtime은 selected Proto/JSON serialization과 + binary envelope를 소유하고 VD-29의 bounded `Transport`/Fetch evidence를 + 충족한다. custom runtime만 encoded Protobuf를 bounded gRPC-Web data frame으로 + 직접 감싼다. +- official grpc-web baseline의 message compression flag는 disabled이며 set bit를 + 거절한다. unary full-body HTTP `Content-Encoding`은 별도 provider profile과 + decoded-body ceiling으로 다룬다. custom message compression은 exact + runtime/proxy fixture 없이는 승인하지 않는다. +- credential owner가 URL/path/method/body digest를 바꿀 수 없다. +- auth-required operation은 unavailable/unauthenticated에서 fetch 0회다. +- cookie profile은 POST/custom content type만으로 CSRF가 해결됐다고 보지 않고 + exact Origin/Fetch Metadata/CSRF proof를 요구한다. + +### 7. Frame decoder + +raw frame pipeline은 `CUSTOM_FETCH_FRAMED`에만 적용한다. gRPC-Web binary response를 +whole `Uint8Array`로 무제한 materialize하지 않는다. + +```text +Fetch ReadableStream + -> bounded incremental frame prefix + -> validate flag/type/compression + -> validate declared frame length + -> bounded exact frame payload + -> data message OR trailer block +``` + +`OFFICIAL_GRPC_WEB_XHR`에서는 pinned official runtime이 XHR body, binary/text +framing, base64와 terminal status parsing을 소유한다. adapter가 이미 decoded된 +message를 raw frame처럼 다시 parse하지 않는다. wire/header/message hard cap은 +gateway/upstream이 allocation 전에 집행하고, adapter는 runtime output의 +message-count/semantic/collection/total budget을 다시 검사한다. official runtime이 +내부에서 거절해야 하는 malformed framing/status는 pinned-version conformance +fixture로 증명한다. + +`CONNECT_WEB_GRPC_WEB_FETCH`도 selected Connect-Web `Transport`가 Fetch, binary +envelope, Proto/JSON decode와 terminal status를 소유한다. stock transport 앞의 +bounded Fetch wrapper 또는 verified upstream hook이 response byte ceiling을 +집행할 수는 있지만 gRPC-Web frame을 별도 custom decoder로 다시 추측하지 않는다. +required pre-decode cap/terminal invariant hook과 malformed corpus를 exact package +version에서 증명하지 못하면 VD-29와 이 ADR 모두 `DESIGNED_NOT_IMPLEMENTED`를 +유지한다. + +검증: + +- truncated prefix/payload +- unknown/reserved flag +- negative/overflow/over-limit length +- disallowed compression flag +- decompressed message cap +- trailer before/after allowed data count +- duplicate body trailer 또는 body/header terminal source 충돌 +- data after terminal trailer +- extra bytes after terminal +- total message/frame/byte ceiling +- idle and total deadline + +reader는 failure/cancel/limit/terminal 뒤 cancel/release한다. + +text mode: + +- `CONNECT_WEB_GRPC_WEB_FETCH`는 이 branch에 들어오지 않고 build/boot 전에 + 거절한다. +- response 전체가 단일 valid base64 entity라고 가정하지 않는다. runtime flush로 + padding이 중간에 나타난 뒤 다음 base64 entity가 이어질 수 있으므로 + **concatenated padded/unpadded base64 entities**를 수용한다. +- custom runtime은 stateful incremental decoder를 사용한다. padding은 해당 entity를 + 끝내고 decoder quantum을 reset하지만 padding 또는 새 entity가 browser chunk, + gRPC-Web frame과 일치한다고 추정하지 않는다. +- invalid alphabet/padding/whitespace policy +- encoded와 decoded byte cap +- browser chunk가 base64 quantum/frame prefix와 일치한다고 가정하지 않음 +- decoded byte stream만 공통 bounded frame decoder에 전달하고 + `Content-Transfer-Encoding`에 의존하지 않음 +- binary와 별도 conformance fixture + +### 8. Trailer와 status + +success는 HTTP success만으로 결정하지 않는다. + +```text +HTTP admission + + valid gRPC-Web frame sequence + + exactly one TerminalStatusSource + + exactly one valid grpc-status + + grpc-status = 0 + +TerminalStatusSource + = BODY_TRAILER_FRAME + | ZERO_BODY_TRAILERS_ONLY_RESPONSE_HEADERS +``` + +custom runtime은 두 source를 raw parser에서 직접 판정한다. official runtime은 +pinned runtime이 같은 protocol rule을 집행하고 `CALLBACK_STREAM`의 normalized +status event를 adapter가 소비한다. Connect-Web runtime은 VD-29의 bounded +`Transport`와 generated Promise/`AsyncIterable` error/terminal contract로 같은 +gRPC-Web authority를 보존한다. 두 runtime 모두 raw source를 application code가 +직접 관찰했다고 거짓으로 기록하지 않고 malformed/missing/duplicate source fixture를 +selected runtime conformance에서 통과시킨다. missing `grpc-status`로 section 5의 +HTTP mapping을 실행한 결과는 valid terminal source나 success가 아니다. + +`BODY_TRAILER_FRAME`과 response-header `grpc-status`가 동시에 있거나 값이 +충돌하면 contract failure다. trailers-only header source는 body/data frame이 +0개일 때만 허용한다. 이후 unary/server-stream cardinality를 별도로 판정한다. + +trailer/status validator: + +- bounded ASCII/header parser +- body trailer name은 lowercase만 허용하고 모든 source는 case-normalized + duplicate detection +- allowed name/value syntax와 `GrpcWebHeaderBudgetProfile`의 bounded field + count/encoded/decoded bytes +- `grpc-status` exactly once, canonical decimal `0..16` +- percent-decoded grpc-message byte cap +- malformed `grpc-message`는 raw 노출 없이 safe replacement/omission하되 valid + authoritative status 자체를 잃지 않음 +- unknown/raw trailer 폐기 +- `grpc-status-details-bin`은 count/decoded byte cap과 allowlisted protobuf + `Any` type만 허용 +- decoded outer `google.rpc.Status.code`가 `grpc-status`와 exact match +- rich details는 non-OK status에서만 허용 + +raw `grpc-message`, status details와 metadata를 application/log에 보내지 않는다. + +### 9. Unary state machine + +순서가 있는 판정: + +1. network/CORS/final URL 실패처럼 HTTP response 자체가 없으면 exact transport + failure mapping이며 gRPC status가 있다고 가장하지 않는다. +2. invalid/unaccepted gRPC-Web media, malformed local reply 또는 redirect는 body를 + gRPC frame으로 추측하지 않고 contract/transport failure로 닫는다. valid exposed + status가 없으면 section 5의 canonical HTTP→gRPC normalization도 함께 기록한다. +3. accepted gRPC-Web media는 profile이 허용한 HTTP status에서 frame과 terminal + source를 bounded decode한다. invalid/truncated/oversize frame은 + contract failure. +4. accepted media인데 valid terminal source 또는 `grpc-status`가 없으면 canonical + HTTP→gRPC normalized status와 `protocolViolation=true`로 실패한다. 모든 2xx와 + mapping 표에 없는 status는 `UNKNOWN`이며 cache/data success는 0이다. +5. HTTP status와 terminal source/status matrix mismatch: + contract failure. +6. non-OK terminal status: + status mapping, data/cache success 0. +7. OK status + data message 0개: + contract failure. `google.protobuf.Empty`도 payload length가 0인 data frame + **하나**다. +8. OK status + data message 정확히 1개: + generated decode → semantic schema → mapper → generation fence → success. +9. OK status + data message 2개 이상: + contract failure. + +trailers-only non-OK response는 safe failure가 될 수 있다. trailers-only OK + +unary zero message는 contract failure다. non-OK에서 받은 data message를 partial +success로 사용하지 않는다. server-stream의 zero-event + OK는 stream profile이 +허용할 수 있다. + +### 10. Status mapping + +operation status profile이 exact allowlist/mapping을 소유한다. + +| gRPC status | 기본 safe mapping | +| --- | --- | +| `OK` | success state machine 계속 | +| `CANCELLED` | local signal이면 `REQUEST_ABORTED`, 아니면 provider failure | +| `DEADLINE_EXCEEDED` | `REQUEST_TIMEOUT` | +| `UNAUTHENTICATED` | `AUTH_REQUIRED` | +| `PERMISSION_DENIED` | `FORBIDDEN` | +| `NOT_FOUND` | `NOT_FOUND` 또는 existence-hiding | +| `ALREADY_EXISTS` | `CONFLICT` | +| `ABORTED` | `CONFLICT`/optimistic concurrency | +| `FAILED_PRECONDITION` | typed precondition/contract profile | +| `INVALID_ARGUMENT`, `OUT_OF_RANGE` | `VALIDATION_REJECTED` | +| `RESOURCE_EXHAUSTED` | `RATE_LIMITED` 또는 bounded resource failure | +| `UNAVAILABLE` | retry-eligible server failure | +| `UNIMPLEMENTED` | provider/contract incompatible | +| `INTERNAL`, `UNKNOWN` | safe server failure | +| `DATA_LOSS` | integrity/contract failure, retry default off | + +validation details는 allowlisted `google.rpc.BadRequest` 같은 registered message +type과 bounded field path/code만 projection한다. arbitrary `Any` type URL/message를 +decode하거나 application에 전달하지 않는다. + +### 11. Deadline과 cancellation + +```text +total logical deadline + -> local scheduler + -> remaining attempt deadline + -> OFFICIAL_GRPC_WEB_XHR + -> absolute Unix epoch-ms `deadline` metadata + -> official runtime-owned grpc-timeout/XHR timer + -> returned call.cancel() + | pinned PromiseCallOptions.signal + -> CONNECT_WEB_GRPC_WEB_FETCH + -> positive remaining timeoutMs + -> runtime-owned grpc-timeout/Fetch timer + -> AbortSignal + AsyncIterator close + -> CUSTOM_FETCH_FRAMED + -> bounded grpc-timeout wire metadata + -> Fetch AbortController + -> response reader/idle timer +``` + +deadline은 auth attach, network, retry/backoff, frame read, protobuf decode, +semantic validation과 mapper를 포함한다. + +- official runtime public API에는 현재 시각 기준 남은 duration이 아니라 absolute + Unix timestamp milliseconds인 `deadline` metadata를 전달한다. pinned runtime이 + 이를 wire `grpc-timeout`과 XHR timer로 변환한다. +- Connect-Web runtime에는 VD-29의 positive remaining `timeoutMs`와 local + `AbortSignal`을 전달하고 runtime이 wire `Grpc-Timeout`을 소유한다. `timeoutMs` + 0 이하를 timeout 없음으로 해석할 수 있으므로 remaining이 0 이하이면 runtime을 + 호출하지 않는다. +- custom runtime만 remaining duration을 `grpc-timeout`으로 encode한다. 값은 최대 + 8자리 양의 정수와 `H | M | S | m | u | n` unit grammar를 지키고 local remaining + deadline을 늘리지 않게 clamp한다. +- retry/auth recovery의 각 physical attempt 직전에 elapsed time을 차감해 absolute + deadline 또는 `grpc-timeout`을 다시 계산한다. 최초 attempt 값을 재사용하지 + 않는다. +- attempt timer는 remaining total보다 길지 않음 +- server stream은 idle + total deadline 둘 다 +- Envoy의 route/max-stream/`grpc_timeout_header_max`/offset은 frontend deadline을 + 늘리지 않는다. proxy가 먼저 끊는 profile이면 그 차이를 명시하고 normalized + terminal reason을 검증한다. +- official callback unary/server-stream은 보관한 returned call의 `cancel()`, + Promise unary는 pinned runtime/codegen이 지원하는 + `PromiseCallOptions.signal`로 중단한다. Promise API에 direct call handle이나 + `.on(...)` status/metadata event가 있다고 가장하지 않는다. Connect-Web call은 + `AbortSignal`과 server-stream `AsyncIterator` close로 중단한다. custom call은 + `AbortController.abort()`와 reader cancel/release로 중단한다. +- caller/navigation/logout/runtime close를 별도 cancellation reason으로 유지 +- local abort가 server command rollback을 보장하지 않음 +- gateway는 downstream disconnect를 upstream cancel로 전달하고 backend handler는 + cancellation을 cooperative하게 관찰하며 downstream RPC에도 remaining deadline과 + cancel을 전파함 +- timer/listener/reader를 모든 terminal path에서 정리 + +### 12. Replay와 retry + +모든 gRPC method가 HTTP POST여도 semantic safety는 method registry가 결정한다. + +```text +SAFE +IDEMPOTENT +KEYED_COMMAND +NON_REPLAYABLE +``` + +```text +GrpcWebRetryProfile + retryOwner = FRONTEND_ADAPTER | ENVOY | NONE + maxPhysicalAttempts + maxSleepMs + maxElapsedMs + initialBackoffMs + maxBackoffMs + backoffMultiplier + jitterProfile + retryableTransportOutcomes[] + retryableGrpcStatuses[] + retryPushbackProfile = UNSUPPORTED | SELECTED_EXTENSION + hedging = DISABLED +``` + +gRPC-Web protocol 자체는 browser retry를 규정하지 않고 official JS runtime은 native +gRPC service-config retry를 제공하지 않는다. 따라서: + +- `FRONTEND_ADAPTER`는 application/provider extension retry다. Envoy/BFF/Query/ + downstream service의 같은 logical operation retry를 끄고 physical attempt를 + adapter가 계수한다. +- `ENVOY`를 선택하면 frontend와 Query retry를 끈다. exact route retry policy, + per-try timeout과 total route/deadline budget을 고정한다. Envoy가 + `grpc-status` response header에 대해서만 gRPC status retry를 할 수 있고 response + trailers의 status로는 retry하지 못한다는 제약을 capability matrix에 기록한다. + server stream route retry/hedging은 금지한다. +- `NONE`이면 모든 layer의 automatic retry를 끈다. +- caller-provided `x-envoy-*` retry/timeout header는 gateway에서 제거한다. +- `grpc-retry-pushback-ms` 같은 metadata는 기본 `UNSUPPORTED`다. exact + runtime/provider/proxy fixture와 bounded parser를 가진 selected extension일 때만 + 사용하며 native gRPC transparent retry라고 표시하지 않는다. +- route timeout은 모든 Envoy attempt를 포함한다. per-try와 backoff가 logical total + deadline을 넘어가지 않으며 frontend/Envoy/backend의 retry multiplication을 + fault injection으로 거절한다. + +unary: + +- safe/idempotent 또는 backend-proven keyed operation만 retry +- selected transient network/`UNAVAILABLE`/approved resource condition만 +- deadline/schema/status/frame/mapper failure retry 금지 +- total attempt/sleep/elapsed ceiling +- hedging은 모든 production profile에서 off + +auth recovery: + +- `UNAUTHENTICATED`에서 replay-safe operation만 one-time single-flight refresh +- same request semantic input/idempotency/deadline + +keyed command: + +- logical command key + principal + service/method + deterministic request digest +- backend atomic dedupe/status/TTL evidence +- ambiguous outcome은 new key retry가 아니라 reconcile + +server stream: + +- Envoy retry는 off다. 첫 application event 전달 전 frontend opening retry는 + `retryOwner=FRONTEND_ADAPTER`인 `SAFE | IDEMPOTENT` operation만 original total + deadline, monotonic physical-attempt와 sleep cap 안에서 가능 +- `KEYED_COMMAND | NON_REPLAYABLE` server stream은 explicit backend + resume/reconcile/dedupe profile이 없으면 registry에서 거절 +- event 전달 뒤 transport-only automatic replay 금지 +- resume하려면 backend sequence/snapshot/resume token protocol 필요 + +### 13. Server-stream state machine + +```text +OPENING + -> STREAMING + -> COMPLETING + -> COMPLETED + +OPENING | STREAMING + -> FAILED | CANCELLED | GAP | OVERFLOW +``` + +```text +ServerStreamPort + open(bound request, signal) + -> AsyncIterable> + -> close() +``` + +각 data frame: + +1. message/frame/total budget +2. generated protobuf decode +3. semantic schema +4. boundary mapper +5. scope/runtime generation +6. sequence/snapshot protocol +7. bounded queue admission + +`ServerStreamPort`는 use case가 명시적으로 연 operation-bound outbound result로 +남을 수 있다. 오직 제품이 runtime-wide external notification projection을 +선택한 branch에서만 mapper 이후 event를 VD-28의 common scope/generation/ +dedupe/gap/resync coordinator 또는 `FeatureEventInput`에 전달한다. protobuf +frame/message를 `REALTIME_EVENT_V1` JSON으로 다시 감싸지 않으며 reconnect는 위 +opening/resume 규칙이 계속 소유한다. + +stream policy: + +- max messages/bytes/duration +- idle deadline +- consumer backpressure +- bounded queue/high-water mark +- overflow outcome +- duplicate/gap/order +- resume token expiry +- terminal status source +- cancellation/unsubscribe + +worker/timer keepalive로 infinite correctness를 주장하지 않는다. + +### 14. Query cache integration + +unary: + +- mapped application result만 VD-25 TanStack query에 admission +- service/method/descriptor/protobuf bytes를 query key/value에 넣지 않음 +- `retryOwner` 값과 무관하게 Query retry는 false다. `NONE`을 Query가 암묵적으로 + 대체하지 않으며 selected gRPC-Web retry owner만 physical attempt를 만든다. + +finite server-stream aggregate: + +```text +mapped events + -> bounded staging reducer + -> valid terminal OK status + -> completeness/integrity/schema + -> generation fence + -> immutable aggregate + -> atomic Query commit +``` + +long-running stream: + +- ordinary queryFn 아님 +- registered reducer가 bounded snapshot을 만들거나 invalidation hint 발행 +- event history 무한 cache 금지 +- partial/truncated/gap stream을 complete query success로 쓰지 않음 +- reconnect를 Query retry로 하지 않음 + +### 15. Protobuf mapping + +VD-24 scalar policy를 사용한다. + +- int64/uint64를 JS number로 자동 변환하지 않음 +- safe range를 증명하거나 decimal string/application value로 mapping +- Timestamp/Duration range/nanos 확인 +- bytes copy/size/classification +- map ordering 독립 +- enum zero/unknown numeric +- oneof presence +- wrappers/optional/default distinction +- unknown field를 domain에 passthrough하지 않음 +- generated message mutation/reuse 금지, immutable application projection 생성 + +### 16. Client/bidi streaming + +primary status는 `PLATFORM_LIMITED`. + +다음으로 fallback/re-design한다. + +| 요구 | 대안 | +| --- | --- | +| large upload stream | REST/presigned multipart upload | +| browser event duplex | WebSocket/WebTransport 또는 별도 duplex protocol | +| server→client one-way + 분리 가능한 command | SSE + registered HTTP command | +| command sequence | server-owned job/session API | +| telemetry batch | bounded unary/REST batch | + +generated service가 client/bidi RPC를 가진다는 이유로 browser에서 unary loop로 +흉내 내지 않는다. ordering/backpressure/half-close semantics가 달라진다. + +### 17. Security와 privacy + +- fixed endpoint/service/method +- caller metadata/header 금지 +- `x-envoy-*`, transport/deadline header caller override 제거 +- preflight를 피하려 metadata/credential을 query parameter로 이동 금지 +- credential/CSRF adapter confinement/final invariant +- request/response header, trailer, frame, message, decompressed cap +- rich status details allowlist +- raw grpc-message/payload/metadata/trailer log 금지 +- generated debug JSON/stringifier를 production logging에 사용 금지 +- server/proxy authorization와 method-level resource authorization +- frontend protobuf validation을 authorization으로 간주 금지 +- query/cache에 generated message/token/metadata 없음 + +### 18. Observability + +허용: + +- semantic operation ID +- allowlisted service/method profile ID +- gRPC status bucket + HTTP status group +- logical/physical attempt, auth recovery, deadline bucket +- request/response message/total bytes/count bucket +- stream terminal reason/idle/gap/overflow bucket +- descriptor/provider/browser version low-cardinality bucket +- cache admission/invalidation outcome + +금지: + +- message/proto JSON +- metadata/trailer raw value +- grpc-message/status-details bytes +- resource/account/request ID actual value +- resume token/sequence high-cardinality value + +### 19. Testing + +protobuf/codegen: + +- source provenance/digest +- Buf lint/breaking +- field reserve/presence/enum/oneof/int64/time fixture +- clean generation +- generated import boundary/removal + +frame/status: + +- official XHR, Connect-Web runtime-owned Fetch와 custom raw-frame Fetch runtime의 + framing/status 책임이 섞이지 않음 +- Connect-Web Proto/JSON binary-envelope row와 `grpc-web-text` rejection +- partial prefix/payload +- unknown flag/over-limit length +- compressed/decompression overflow +- body trailer vs trailers-only header source, missing/duplicate/conflicting status +- missing `grpc-status`의 `400/401/403/404/429/502/503/504/other` canonical mapping, + valid status 존재 시 HTTP mapping 미적용, server-side 역매핑 금지 +- status canonical `0..16`, body lowercase names, rich-details outer-code mismatch +- data after trailer/extra bytes +- unary 0/1/2 messages including one zero-length `Empty` frame +- non-OK with data +- text base64 quantum/padding을 browser chunk 여러 개로 분할 +- padded entity 뒤 추가 data/trailer entity, 연속 padded/unpadded entity와 invalid + alphabet/padding/whitespace +- header/trailer field-count/byte overflow, padded/unpadded `-bin`, comma-joined duplicate + binary metadata +- rich details allowlist/redaction + +deadline/retry/auth: + +- official absolute epoch-ms `deadline` metadata, returned call `cancel()`과 pinned + Promise `AbortSignal` +- Connect-Web positive remaining `timeoutMs`, AbortSignal과 AsyncIterator close +- custom 8-digit/unit `grpc-timeout`, Fetch abort와 reader cancel +- headers 전/후와 첫 stream event 전/후 caller cancel, attempt/total/idle timeout +- retry/auth recovery마다 remaining deadline 재계산, Envoy timeout max/offset ordering +- reader/timer/listener cleanup +- UNAUTHENTICATED single-flight +- SAFE/IDEMPOTENT/KEYED_COMMAND/NON_REPLAYABLE +- `FRONTEND_ADAPTER | ENVOY | NONE` exact-one owner와 Query retry off +- Envoy response-header `grpc-status` retry와 trailer-status no-retry, route total budget +- transient status exact retry, pushback unsupported default, `x-envoy-*` caller strip +- frontend/Envoy/backend retry amplification과 hedging 거절 +- ambiguous command reconcile + +server stream: + +- zero/many events +- backpressure/high-water/overflow +- duplicate/order/gap/resume +- terminal/non-terminal/truncated +- account/logout/generation +- finite aggregate atomic cache commit + +provider/browser: + +- runtime kind/client API/spec/rpcKind/wire-profile capability matrix와 official Promise + status-event rejection/`PromiseCallOptions.signal` version capability +- Connect-Web unary Promise/server-stream `ASYNC_ITERABLE`, Proto/JSON exact media와 + `GRPC_WEB_TEXT` rejection +- exact POST/media/Accept/`X-Grpc-Web`/`X-User-Agent` wire header +- pinned actual Envoy/BFF version/config digest, upstream HTTP/2와 + `grpc_web -> cors -> router` order +- exact-origin CORS positive/negative preflight, credential/CSRF, exposed status headers, + non-matching preflight fail-closed와 preflight suppression 금지 +- local reply/missing-status, compression/message/header/deadline cap +- stream이 15초를 넘는 fixture, explicit route/idle/max-stream timeout과 no + buffering/flush +- Chromium/Firefox/WebKit cancellation/status/trailer/backpressure +- Safari/enterprise proxy buffering and selected text fallback + +MSW/memory frame fixture는 actual gateway evidence가 아니다. + +### 20. Rollout + +1. product가 protobuf-first operation family와 gateway owner를 선택한다. +2. proto/descriptor/proxy/auth/status/deadline contract를 확정한다. +3. selected runtime별 official-XHR, Connect-Web runtime-owned Fetch 또는 custom + raw-frame Fetch transport adapter와 generated boundary를 구현한다. 세 runtime의 + framing/cancel 책임을 합치지 않는다. +4. unary read operation을 REST shadow와 비교하되 secondary cache/UI write 금지. +5. actual proxy/browser conformance 통과. +6. reference runtime을 `AVAILABLE_NOT_COMPOSED`로 판정. +7. product composition 뒤 `COMPOSED`, + `TrafficAdmission=DISABLED`로 시작. +8. unary safe read internal canary. +9. keyed command는 backend dedupe/reconcile 뒤 별도 canary. +10. finite server stream은 unary와 별도 evidence/traffic gate. +11. client/bidi는 계속 `PLATFORM_LIMITED`. + +rollback: + +- 신규 operation/stream admission 중지 +- read/stream cancel, command effect reconcile +- current scope gRPC-mapped cache clear/invalidate +- coherent frontend/generated descriptor/proxy/backend rollback +- approved REST read fallback은 새 logical query로만 실행 + +### 21. Removal + +1. operation traffic과 backend method retirement window 시작 +2. unary/stream cancel, command reconcile +3. Query cache/reducer/invalidation listener clear +4. operation/schema/mapper/query profile 제거 +5. generated files, proto/descriptor, generator/runtime dependency 제거 +6. proxy route/CORS/config 제거 +7. backend method는 N/N-1 client window 뒤 제거/reserve +8. production module/dependency/SBOM/removal test 통과 + +## 규범 기준 + +아래 링크는 upstream 기준 위치다. production profile과 evidence는 `master`/`latest` +문자열이 아니라 검증한 immutable commit, package version과 Envoy release를 +별도로 기록한다. + +- [gRPC-Web protocol delta](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) +- [Official grpc-web runtime support matrix](https://github.com/grpc/grpc-web) +- [Official grpc-web browser features와 CORS](https://github.com/grpc/grpc-web/blob/master/doc/browser-features.md) +- [Official grpc-web XHR/deadline implementation](https://github.com/grpc/grpc-web/blob/master/javascript/net/grpc/web/grpcwebclientbase.js) +- [Official grpc-web cancellation handle](https://github.com/grpc/grpc-web/blob/master/javascript/net/grpc/web/clientreadablestream.js) +- [VD-29 Connect-Web과 browser Protobuf runtime](./VD-29-connect-web-and-browser-protobuf-runtime.md) +- [Connect-Web gRPC-Web transport source](https://github.com/connectrpc/connect-es/blob/main/packages/connect-web/src/grpc-web-transport.ts) +- [gRPC HTTP/2 protocol와 timeout/header 규칙](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md) +- [Canonical HTTP→gRPC status mapping](https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md) +- [gRPC deadline guide](https://grpc.io/docs/guides/deadlines/) +- [gRPC cancellation guide](https://grpc.io/docs/guides/cancellation/) +- [gRPC retry guide](https://grpc.io/docs/guides/retry/) +- [gRFC A6 client retries](https://github.com/grpc/proposal/blob/master/A6-client-retries.md) +- [Envoy gRPC-Web filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_web_filter) +- [Official grpc-web Envoy example](https://github.com/grpc/grpc-web/blob/master/net/grpc/gateway/examples/echo/envoy.yaml) +- [Envoy timeout guidance](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html) +- [Envoy route timeout API](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto.html) +- [Envoy router retry constraints](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/router_filter) +- [Envoy CORS filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/cors_filter) +- [Envoy CORS API](https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/cors/v3/cors.proto.html) +- [Envoy buffer filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/buffer_filter) +- [Buf breaking-change policy](https://buf.build/docs/breaking/) + +## 완료 기준 + +- exact provider/profile/service/method/descriptor에 operation이 binding된다. +- official XHR/Connect-Web runtime-owned Fetch/custom raw-frame Fetch runtime kind, + client API, runtime version과 gRPC-Web wire-spec revision이 고정되고 책임이 + 섞이지 않는다. official runtime의 binary server stream, Connect-Web의 text와 + evidence 없는 JSON/server-stream 조합이 거절된다. +- missing `grpc-status`가 canonical HTTP→gRPC 표로 failure normalization되고 wire + header 합성이나 reverse mapping을 하지 않는다. +- pinned executable Envoy/BFF profile이 upstream HTTP/2, filter order, CORS, + route/idle/max-stream/deadline, buffering, header budget과 config digest를 닫는다. +- deadline/cancel dialect가 runtime별로 고정되고 retry owner가 frontend/Envoy/none + 중 정확히 하나이며 Query/backend와 amplification을 만들지 않는다. +- text runtime은 concatenated padded/unpadded base64 entity와 arbitrary browser + chunk 경계를 처리한다. +- protobuf generated type이 adapter 밖으로 나오지 않는다. +- frame/message/trailer/status/deadline/cancel/retry가 closed state machine이다. +- unary success가 exact one message + body terminal trailer의 유일한 OK status로 + 증명되고, trailers-only header status는 zero-message unary를 success로 만들지 + 않는다. +- stream이 bounded backpressure/sequence/gap/resume/terminal 계약을 가진다. +- int64/time/enum/oneof/bytes semantic mapper가 닫힌다. +- actual proxy와 Chromium/Firefox/WebKit evidence가 유효하다. +- client/bidi를 지원한다고 표시하지 않는다. +- cache에 raw/generated/partial stream state가 없다. +- kill switch, coherent rollback과 dependency/proxy/generated removal drill이 통과한다. diff --git a/docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md b/docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md new file mode 100644 index 0000000..d009671 --- /dev/null +++ b/docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md @@ -0,0 +1,640 @@ +# VD-28: Realtime events, Web Push와 bounded polling + +- 상태: Accepted — reference runtime available, product implementation pending +- 결정일: 2026-07-28 +- 관련 결정: VD-10, VD-13, VD-23, VD-24, VD-25, VD-26, VD-27, VD-29 +- 상세 설계: + `docs/architecture/realtime-events-web-push-and-bounded-polling.md` +- 현재 product selection: `NOT_SELECTED` +- common runtime delta: `AVAILABLE_NOT_COMPOSED` +- 재검토: + 첫 제품 stream/Web Push를 선택할 때, 또는 backend replay/hosting/provider + protocol이 바뀔 때 + +## 배경 + +현재 optional recipe catalog는 realtime capability에 +`referenceRuntime.status=AVAILABLE_NOT_COMPOSED`를 기록한다. 공통 event authority, +bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE, +bounded Polling, closed WebSocket protocol과 Web Push window/worker adapter는 +deterministic test와 함께 존재하지만 production entry에서는 제외된다. generic +mega `RealtimePort`, 제품 event schema, 실제 endpoint, backend replay/provider +contract와 composition은 선택하지 않았다. + +추가 설계 범위에는 성격이 다른 네 capability가 있다. + +- SSE: active document의 server-to-client event stream +- WebSocket: active document의 duplex application protocol +- Web Push: inactive browser에도 도착할 수 있는 Service Worker 기반 notification +- bounded polling: 기존 HTTP/query operation의 제한된 scheduling policy + +이를 “realtime transport” 하나로 합치면 다음 문제가 생긴다. + +- Web Push의 permission, push service와 worker lifecycle이 connection 상태에 숨는다. +- Polling을 무한 timer나 transport downgrade로 오해한다. +- WebSocket이 필요하지 않은 server notification까지 duplex protocol이 된다. +- connection open, event delivery, application effect와 server 최신성을 같은 성공으로 + 표시한다. +- auth refresh, reconnect, HTTP retry와 Query retry가 중첩된다. +- gap, cursor expiry와 browser restore 뒤 authoritative resync owner가 사라진다. +- push subscription endpoint/key나 cursor가 일반 application state와 telemetry에 + 노출될 수 있다. + +기존 recipe의 generic `channel: string`, `sequence: number`, 고정 +`resumeToken`, callback과 `heartbeat()`는 선택 시 복사해 좁힐 출발점이다. +scope/epoch, closed event type, byte/queue limit, gap/reset, 진행되는 cursor, +generation과 effect certainty가 없어 production wire authority로 사용할 수 없다. + +## 현재 상태 + +| 항목 | 상태 | 설명 | +| --- | --- | --- | +| optional realtime catalog/recipe | `RECIPE_AVAILABLE` / product `NOT_SELECTED` | uncomposed reference runtime과 conformance script가 있음 | +| common event/recovery/reconnect runtime | `AVAILABLE_NOT_COMPOSED` | scope/gap/barrier authority, finite reconnect owner와 exact close classification test가 있음 | +| live↔Poll handoff coordinator | `AVAILABLE_NOT_COMPOSED` | monotonic generation, one effect writer와 bounded checkpoint/quiescence test가 있음 | +| SSE runtime | `AVAILABLE_NOT_COMPOSED` | fetch-stream parser/adapter/reconnect test 있음; local server/browser evidence pending | +| WebSocket runtime | `AVAILABLE_NOT_COMPOSED` | exact handshake/protocol/queue/recovery test 있음; load/browser evidence pending | +| bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | finite single-flight visible/online lease와 deterministic budget test 있음 | +| Web Push window/worker runtime | `AVAILABLE_NOT_COMPOSED` | subscription, registration/revoke, durable fence, strict inbound worker factory가 있음; provider/browser evidence pending | +| exactly-once/global ordering | `PLATFORM_LIMITED` | 공통 browser delivery 목표로 보장하지 않음 | +| always-on background connection/polling | `PLATFORM_LIMITED` | hidden/frozen/terminated document에서 보장하지 않음 | +| timely cross-browser Web Push | `PLATFORM_LIMITED` | provider/browser/OS가 즉시 delivery를 보장하지 않음 | + +reference source는 `AVAILABLE_NOT_COMPOSED`까지 승격됐다. 그러나 이 ADR과 +deterministic test만으로 `COMPOSED` 또는 `PRODUCTION_READY`로 올리지 않는다. +제품 endpoint/registry와 backend/provider/target-browser evidence가 생긴 뒤 +선택 capability만 별도 승격한다. + +## 결정 + +### 1. 네 capability를 분리한다 + +다음 의미를 고정한다. + +| capability | 선택 의미 | 기본 fallback | +| --- | --- | --- | +| SSE | foreground one-way ordered hint stream | bounded polling 또는 stale UI | +| WebSocket | foreground duplex interaction protocol | 의미가 축소되지 않으면 bounded polling, 아니면 disabled/stale UI | +| Web Push | background user-visible notification hint | foreground inbox/focus refresh | +| bounded polling | finite visible HTTP scheduling | manual refresh/explicit stale UI | + +Web Push는 SSE/WebSocket의 fallback이 아니라 보완 capability다. Polling은 +WebSocket duplex 기능을 대신할 수 없다. SSE↔WebSocket 자동 downgrade도 하지 +않는다. 같은 사용자 의미를 보존하는 fallback만 registry에 명시한다. + +추가 transport를 선택하기 전 기존 TanStack Query의 focus/reconnect refetch와 +manual refresh가 측정된 freshness 요구를 만족하는지 먼저 확인한다. + +Connect/gRPC-Web server stream은 VD-29/VD-27의 operation-bound API protocol이고 GraphQL +subscription은 현재 `NOT_SELECTED`다. GraphQL `@defer`/`@stream`은 finite +incremental HTTP response이지 realtime subscription이 아니다. RPC adapter가 +protocol-specific terminal proof와 protobuf decode/schema/mapper를 끝낸 +runtime-wide notification branch에서만 공통 +scope/gap/resync coordinator를 재사용한다. frame/media/trailer, reconnect와 +operation deadline owner를 SSE/WebSocket adapter로 합치거나 protobuf message를 +`REALTIME_EVENT_V1` JSON으로 다시 감싸지 않는다. Polling의 개별 attempt는 VD-23의 +terminal·replay-safe REST `QUERY` execution contract를 재사용하되 transport/Query +retry는 끄고, 이 결정은 attempt 사이 bounded lease만 소유한다. + +### 2. source of truth는 서버다 + +SSE/WebSocket event의 기본 효과는 registered `QueryInvalidationTopic`과 authoritative +HTTP refetch다. raw event payload를 domain entity나 Query cache의 authoritative +state로 자동 승격하지 않는다. + +authoritative delta 적용은 event type별 server revision, base revision, commit +뒤 publication, idempotent reducer, gap/reset과 snapshot reconciliation이 모두 +승인된 경우에만 별도 선택한다. + +Web Push payload는 작은 opaque notification hint다. Poll response는 해당 HTTP +representation의 결과다. 어느 것도 authorization이나 exactly-once effect를 +증명하지 않는다. + +### 3. outbound connection과 inbound event adapter를 분리한다 + +outbound가 소유한다. + +- fixed endpoint와 credential 협력 +- connect/subscribe/resume/reconnect/close +- selected WebSocket typed send +- push subscription register/revoke +- bounded poll scheduling/cancel + +inbound가 소유한다. + +- raw byte/frame hard cap +- UTF-8/JSON/schema/version 검증 +- stream/event/scope/generation 확인 +- dedupe/order/gap +- feature input 또는 query invalidation mapping +- effect 뒤 cursor/ack commit + +application/domain에 native browser, TanStack, URL/header나 vendor type을 노출하지 +않는다. `send(unknown)`과 arbitrary `channel`/endpoint도 금지한다. + +### 4. target event protocol을 versioning한다 + +foreground common envelope은 다음 의미를 가져야 한다. + +```text +protocol = REALTIME_EVENT_V1 +streamId = registry-owned ID +streamEpoch = opaque server reset epoch +eventType = closed registry ID +eventId = bounded dedupe ID +sequence = canonical unsigned decimal string +recoveryMode = CURSOR | SNAPSHOT_ONLY | SESSION_REBUILD +resumeCursor = CURSOR면 opaque replay position, 아니면 exact null +occurredAt = strict RFC 3339, ordering authority 아님 +scopeBinding = session/BFF-issued opaque exact-match token +payload = event-type-specific closed codec +``` + +`eventId`, `sequence`, `resumeCursor`와 business revision은 별도 의미다. +sequence는 JSON safe-integer 문제를 피하도록 decimal string으로 전달하고 +stream + epoch 안에서만 비교한다. + +credential, readable subject/account ID, signed URL, PushSubscription material과 +자유 형식 message는 envelope에 넣지 않는다. +event type registry는 payload schema, pure boundary mapper와 effect profile을 +함께 bind한다. `scopeBinding`은 cache fingerprint/authorization proof가 아니고, +cursor는 protocol/stream/feed/epoch/registered subscription set/auth scope에 +server-side로 bind한다. client는 opaque cursor를 해석하지 않는다. +state-bearing stream의 recovery profile은 snapshot operation/checkpoint codec과 +replay/connect-buffer/server-hold barrier를 닫는다. `SESSION_REBUILD`는 +EPHEMERAL-only다. V1 server-side subset filter는 `NOT_SELECTED`이며 필요하면 +contiguous sequence/checkpoint를 가진 별도 stream으로 등록한다. + +### 5. delivery guarantee와 authoritative resync를 분리한다 + +apply 순서는 다음과 같다. + +```text +byte cap + -> parse/schema/version + -> registry/scope/generation + -> dedupe/order/gap + -> registered boundary mapper + -> sequential application effect + -> effect commit + -> last-applied cursor + -> optional selected WebSocket protocol ACK +``` + +effect 뒤 cursor를 commit하므로 crash window에서 duplicate가 생길 수 있다. +effect는 idempotent하거나 query invalidation/refetch여야 한다. + +- 전체 browser lifecycle에 대한 delivery guarantee는 없음 +- retention 안의 `CURSOR` foreground event 처리만 duplicate-tolerant + at-least-once model +- V1 ordering은 stream-wide 하나; partition은 별도 logical stream +- exact duplicate/old sequence는 safe drop +- 같은 event ID/sequence의 conflicting content는 protocol failure +- old captured generation callback만 safe drop; current connection의 + `scopeBinding` mismatch는 security protocol violation으로 close/revalidate/resync +- sequence gap, stream epoch change, cursor expiry, queue overflow는 delta 적용 중단 +- authoritative snapshot과 + `SnapshotCheckpoint(streamEpoch,lastAppliedSequence,resumeCursor|null,snapshotRevision)`를 + 같은 commit point로 얻은 뒤에만 resume +- exactly-once와 global ordering은 비목표 + +backend는 commit 이후 publication, replay retention, cursor reset과 +snapshot/checkpoint 의미를 소유한다. subscribe ACK는 accepted cursor와 +`nextExpectedSequence`를 반환한다. replay가 없는 `SNAPSHOT_ONLY`는 +connect/bounded-buffer 또는 server hold barrier 없이는 snapshot/connect 사이 +event를 잃을 수 있으므로 `CURRENT`를 보장하지 않고 finite revalidation/stale UX로 +degrade한다. + +### 6. lifecycle은 scope generation으로 fence한다 + +connection, freshness, authorization, availability와 traffic admission을 별도 +상태 축으로 둔다. `connected: boolean` 하나로 표현하지 않는다. + +- runtime config/release/session recovery 뒤에만 connect한다. +- route lease는 unmount에서 release한다. +- logout/account/release transition은 old generation을 먼저 fence한다. +- connect/read/backoff/poll/snapshot을 abort하고 queue/cursor/dedupe를 폐기한다. +- late event/response/worker handoff는 captured old generation이면 적용하지 않는다. +- close/dispose/unsubscribe는 terminal/idempotent다. +- React StrictMode 반복 뒤 physical listener/connection/timer가 하나만 남는다. +- admission은 canonical `DISABLED | SHADOW | CANARY | ENABLED`만 사용하고, + drain은 connection lifecycle의 `DRAINING`으로 표현한다. +- `DISABLED`는 새 data-plane side effect를 0으로 한다. 이미 소유한 fixed + resource의 idempotent close/revoke만 bounded `DRAINING` cleanup plane에서 + 허용하며 `CLOSED` 뒤 network side effect는 0이다. + +hidden에서는 Polling을 중지하고 live connection은 configured bounded grace 뒤 +close/pause한다. `pagehide`에서 document-owned SSE/WS/Poll을 모두 정리하고 +`pageshow`/visible 복귀에는 snapshot freshness gate 뒤 새 runtime으로 resume한다. +`unload` 완료에 의존하지 않는다. backend는 active authorization revoke를 +close/control event로 전파하거나 bounded max connection age에 재인가한다. + +### 7. retry owner를 하나로 제한한다 + +reconnect는 capped full-jitter exponential backoff를 사용한다. base/max delay, +max attempts와 max elapsed는 immutable registry/implementation ceiling으로 +제한한다. + +- stable-open window 또는 valid heartbeat/event 뒤에만 attempt reset +- valid server hint는 local delay보다 이른 retry를 금지하는 not-before bound +- server hint가 implementation max/remaining elapsed budget을 넘으면 낮춰 + clamp하지 않고 degraded/stale로 종료 +- offline에서는 timer retry를 멈춤 +- auth expiry는 session owner single-flight recovery 한 번 +- forbidden/protocol/schema failure는 terminal +- 외부 rate/provider failure는 exact bounded server not-before hint가 있을 때만 + retry하고, hint가 없으면 terminal +- retry budget 소진 뒤 declared Polling fallback 또는 stale UI +- reconnect는 realtime coordinator, auth는 session owner, Poll cadence는 poll + coordinator가 소유하고 Poll-bound HTTP/Query retry는 비활성 +- recovery checkpoint는 exact branded object identity로 다음 attempt에 전달한다. + SSE `onOpen`/WebSocket `onSubscribed` proof와 attempt 성공 proof가 같은 + object일 때만 common transport barrier를 확인하고 event admission을 연다. + clone/missing proof와 30초 readiness deadline 초과는 fail-closed다. +- aborted sleep/attempt/closed-receipt는 기본 2초 bounded drain 뒤 run을 + fail-closed로 끝내되, 실제 old task가 settle할 때까지 `DRAINING`을 유지한다. + 정상 active session의 `waitClosed`에는 deadline을 두지 않는다. + +### 8. SSE baseline은 bounded fetch-stream이다 + +common reference target은 fixed same-origin BFF에 대한 fetch-stream SSE다. +native EventSource보다 다음을 명시적으로 제어하기 위해서다. + +- credential integration +- status/content type/redirect +- AbortSignal과 lifecycle +- parser/event byte ceiling +- reconnect/idle/retry budget +- explicit current cursor + +native EventSource는 same-origin cookie auth, native `Last-Event-ID`/reconnect, +`204` terminal contract와 lifecycle 뒤 cursor recovery를 backend가 수용한 +별도 profile에서만 허용한다. UA cursor를 application effect commit과 묶을 수 +없으므로 `INVALIDATION_HINT` 전용이고 reconnect/restore마다 authoritative +snapshot gate를 수행한다. gate 중 hint는 bounded `pendingInvalidation`으로 +coalesce하고 checkpoint 뒤 pending refetch까지 drain한다. 이 buffer/barrier가 +없으면 `CURRENT`를 금지한다. `AUTHORITATIVE_DELTA`는 fetch-stream만 허용한다. +token을 URL에 넣지 않는다. + +fetch-stream parser는 표준 UTF-8 SSE format, BOM/line ending/comment/multi-line +data/id/retry/incomplete EOF를 bounded하게 구현한다. exact `200 +text/event-stream`만 stream 성공이며 auth/rate/reset/provider status를 closed +failure로 mapping한다. parsed candidate ID와 effect-committed cursor를 분리하고 +각 application event block의 직접 `id`와 envelope cursor를 exact match한다. + +SSE baseline은 registry-owned session feed 하나와 feed-wide cursor 하나다. +route lease는 local dispatch만 바꾸며 arbitrary server multiplex와 +per-subscription cursor는 `NOT_SELECTED`다. + +hosting은 proxy buffering, idle/request timeout, heartbeat, cache/transform, +HTTP connection budget와 client disconnect cleanup을 실제로 검증한다. + +### 9. WebSocket은 versioned duplex protocol로만 선택한다 + +- fixed same-origin `wss:` endpoint와 exact subprotocol +- server `Origin` 검증과 current session authorization +- URL/query/subprotocol에 credential 금지 +- closed welcome/subscribe/unsubscribe-ack/event/reset/heartbeat/close frame +- baseline text JSON, binary/extension은 별도 승인 +- application heartbeat/watchdog +- bounded incoming sequential queue +- bounded outgoing queue와 `bufferedAmount` +- raw close reason redaction +- same-epoch cursor resume의 `nextExpectedSequence = lastApplied + 1`; accepted + cursor silent advance 금지, mismatch는 reset/snapshot +- state-bearing initial subscribe는 snapshot/checkpoint + barrier 전 `CURRENT` 금지 +- `UNSUBSCRIBE` 뒤 matching `UNSUBSCRIBED`까지 tombstone과 quota를 유지하고 late + event/control은 effect 없이 버린다. unknown ACK와 ACK deadline 초과는 + connection-level failure다. + +classic browser WebSocket은 incoming backpressure를 제공하지 않으므로 queue +overflow에서 임의 delta drop을 하지 않는다. baseline은 connection을 close하고 +snapshot resync한다. server의 bounded pause/resume ACK protocol을 별도 증명한 +profile에서만 subscription pause를 허용한다. + +모든 client control frame은 하나의 FIFO outbound queue를 통과한다. negotiated +message count/queued bytes와 native `bufferedAmount` 중 하나라도 넘으면 +`QUEUE_OVERFLOW`, `retryable=false`, `OVERLOADED`로 generation 전체를 닫고 +snapshot recovery를 요청한다. + +durable business command는 기존 HTTP path를 기본으로 유지한다. WebSocket +command를 선택하면 closed operation, command ID/idempotency, expected revision, +ack와 business commit certainty를 별도로 정의한다. + +### 10. Web Push는 별도 window/worker/backend capability다 + +Web Push 선택에는 다음이 모두 필요하다. + +- user-action 기반 permission UX +- active Service Worker registration +- `userVisibleOnly: true`인 window subscription manager +- authenticated backend register/revoke +- server subscription registry +- VAPID private-key/provider owner +- worker push/notification/click inbound adapters + +PushSubscription endpoint, `p256dh`, `auth`는 capability material로 취급하고 +application state, browser storage, URL, BroadcastChannel과 telemetry에서 +금지한다. VAPID private key는 server-only다. + +push payload는 versioned, association/release-bound, expiring opaque notification +hint로 제한한다. 개인 내용은 foreground BFF가 current authorization으로 +조회한다. worker handler는 `waitUntil` 안에서 bounded validation과 +`showNotification`만 수행하며 long retry/sync/migration을 하지 않는다. +decoded application hint는 3 KiB를 넘지 않으며 최상위 JSON member name 중복은 +last-wins로 해석하지 않고 거절한다. `issuedAt`의 client clock 대비 future +skew는 최대 5분, `expiresAt - issuedAt` lifetime은 최대 24시간이다. +window의 native permission/subscription operation은 30초, backend +register/reconcile/revoke operation은 15초 안에 종료하며 제품 config는 이 +implementation ceiling을 높일 수 없다. +`pushsubscriptionchange` window handoff도 worker lifecycle abort와 10초 +deadline을 사용하고, non-cooperative `matchAll()` 또는 동기 `waitUntil()` 예외 +뒤에는 늦은 `postMessage`를 허용하지 않는다. + +notification copy와 click route는 closed registry를 사용한다. arbitrary backend +text나 URL을 OS notification/openWindow에 전달하지 않는다. +worker restart 뒤 click을 처리하도록 bounded non-sensitive +`NotificationClickDataV1`만 `NotificationOptions.data`에 넣고 click 시 +codec/expiry/current association/release를 다시 검증한다. logout 때 owned +notification은 bounded best-effort close하지만 OS 잔존 가능성 때문에 copy는 +항상 account-neutral이어야 한다. + +worker는 window in-memory session을 authority로 사용할 수 없으므로 opaque +`fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`과 +`UNASSOCIATED | ACTIVE | REVOKED` association discriminant를 가진 +adapter-owned IndexedDB `PUSH_CONTROL_V1` record를 사용한다. +account ID, endpoint/key, credential과 notification content는 이 record에서 +금지한다. missing/corrupt/mismatch는 fail-closed한다. 동일 association epoch의 +`REVOKED`는 terminal tombstone이다. logout은 durable fence generation rotate와 +REVOKED를 먼저 commit한다. 새 `ACTIVE`는 distinct backend epoch와 captured/current +fence generation, server session binding, prior record revision/epoch, release를 +한 IDB transaction에서 CAS해 stale-tab response를 거절한다. 첫 register 전 +`UNASSOCIATED` record도 같은 generation을 durable하게 보관하므로 logout과 +in-flight register response의 race를 association sentinel 없이 닫는다. client +`updatedAt`은 ordering authority가 아니다. + +logout은 old generation fence, durable local association `REVOKED` commit과 +backend account association revoke를 정상 security commit으로 사용한다. boot에서 +native subscription/local fence/server association을 reconcile하고, local commit +실패나 ambiguous revoke는 `PUSH_UNAVAILABLE`로 내려 짧은 TTL, send-time auth와 +click-time 재인가에 의존한다. native unsubscribe/old notification close는 +best-effort지만 captured native subscription, exact association tag와 unchanged +durable fence를 모두 다시 확인한 경우에만 수행한다. 새 association이 commit되면 +old cleanup은 건너뛴다. local fence 실패 뒤 current native subscription 조회나 +association wildcard cleanup은 금지한다. + +control tombstone purge는 자동 revoke 단계가 아니다. 별도 maintenance owner만 +captured revision/authority/association epoch가 exact한 `REVOKED` record를 +repository CAS로 삭제할 수 있고, concurrent newer owner가 있으면 +`STALE_REVISION`으로 끝난다. + +backend register/revoke는 VD-23의 fixed `COMMAND`로 등록하고 cookie session의 +exact CSRF를 검증한다. register는 keyed idempotency 또는 atomic installation +upsert/receipt, revoke는 duplicate/`ALREADY_GONE` 성공 의미를 가져야 하며 +`associationEpoch`은 server commit 뒤에만 발급한다. + +Service Worker를 우회해 UA가 직접 notification을 표시할 수 있는 declarative push +message는 V1에서 `NOT_SELECTED`다. outbound `web_push: 8030` shape를 거절하고 +별도 ADR 전에는 encrypted `WEB_PUSH_HINT_V1`만 허용한다. + +Service Worker를 선택해도 offline fetch, PWA shell cache나 background sync가 +자동 승인되지 않는다. 하나의 worker composition/update owner가 선택된 handler를 +조립한다. + +### 11. Polling은 bounded lease다 + +허용 형태: + +- visible query의 낮은 빈도 conditional freshness poll +- 사용자 시작 async job의 terminal-state convergence poll + +각 lease는 operation owner, minimum/success/max interval, max attempts, +max elapsed, response byte cap, visible-only policy와 terminal states를 가진다. + +- operation은 registered terminal·replay-safe REST `QUERY`여야 함 +- Poll `maxAttempts`는 physical request 하나인 logical completion을 셈 +- Poll-bound VD-23 budget은 `maxAttempts=1`, `authRecoveryCount=0`, + `maxCumulativeSleepMs=0`; TanStack Query retry도 끔 +- completion-chained timeout으로 single-flight +- hidden/offline/pagehide/unmount/scope change/user cancel에서 stop +- ETag/`If-None-Match` 또는 server cursor 사용 +- `304`, auth, cursor reset, `429/503 Retry-After`를 closed mapping +- common recovery coordinator가 `POLL_ACTIVE -> LIVE_PROBING`에서 poll만 effect + writer로 유지하고 live candidate는 bounded buffer만 사용. handoff mutex에서 + poll fence/abort + quiescence를 먼저 완료하고 current-generation + snapshot/checkpoint와 buffered event를 적용한 뒤 live를 활성화 +- active writer effect tail도 in-flight 포함 256건/4MiB로 제한하고 overflow는 + 전체 generation을 `QUEUE_OVERFLOW`로 fail-close +- budget 소진 뒤 manual refresh/stale UI +- page component `setInterval`과 unlimited loop 금지 + +### 12. resource ceiling과 privacy를 fail-closed한다 + +상세 설계의 target hard ceiling은 physical connection, logical subscription, +event/frame/parser/queue/dedupe/reorder/outbound buffer, reconnect, poll lease, +push hint와 worker deadline을 제한한다. 제품 config는 더 작게만 설정할 수 있다. + +2026-07-28 reference-runtime amendment로, RT-01~RT-04 source 전체를 +tree-shaking 없이 합성하는 optional-recipe gzip 예산을 40,000 bytes로 +고정한다. 이는 production bundle 허용량이 아니며 미선택 production asset의 +realtime module 허용량은 계속 0이다. SSE replay-open과 WebSocket +`SUBSCRIBED`가 exact recovery checkpoint를 증명하고 common barrier가 확인될 +때까지 event admission을 막는 readiness gate는 attempt당 최대 30초다. +phase abort 뒤 비협조적인 retry sleep, connect attempt 또는 closed-receipt +cleanup을 기다리는 drain은 2초로 고정하고 구현 절대 최대는 30초다. 상한을 +넘긴 task가 settle할 때까지 lifecycle은 `DRAINING`을 유지하며 정상 active +session의 `waitClosed`에는 이 cleanup deadline을 적용하지 않는다. + +ceiling 초과는 limit 자동 인상이나 silent drop이 아니라 new lease rejection, +connection close, snapshot resync, typed backpressure, stale/degraded 또는 +notification drop으로 처리한다. + +telemetry에는 transport/registry ID, closed outcome, count/duration/lag bucket만 +허용한다. raw URL/query/credential/subject/event ID/cursor/payload/close reason/ +PushSubscription key와 notification private content는 금지한다. + +### 13. 실제 provider/browser/operations evidence 전에는 promotion하지 않는다 + +evidence를 분리한다. + +1. pure unit/property와 deterministic fault contract +2. 실제 local SSE/WS server integration +3. backend replay/snapshot/auth/hosting/provider conformance +4. built production asset의 target-browser lifecycle +5. Web Push provider + browser/OS 자동·수동 evidence +6. load/chaos/security negative gate +7. dashboards, kill switch와 drain/recovery/rollback drill + +fake/jsdom/MSW만으로 native stream, socket, worker, notification이나 provider +readiness를 주장하지 않는다. 외부 evidence가 없으면 `PromotionEvidence`는 +`MISSING | PARTIAL`이고 promotion gate result는 `FAIL_UNVERIFIED`다. + +## 선택하지 않은 대안 + +### 범용 transport enum을 가진 `RealtimePort` + +전송 교체는 가능해 보이지만 direction, permission, lifecycle, delivery certainty와 +fallback 의미를 잃는다. 공통 protocol coordinator만 재사용하고 native capability +port는 분리한다. + +### 모든 server event에 WebSocket 사용 + +one-way notification에도 duplex handshake, heartbeat, queue와 server connection +운영 비용을 강제한다. one-way stream은 SSE를 우선 검토한다. + +### native EventSource만 공통 baseline으로 사용 + +arbitrary auth header, detailed status mapping, bounded reconnect와 explicit +lifecycle cursor 제어가 부족하다. 조건부 profile로는 허용하지만 reference +baseline은 fetch-stream이다. + +### token을 SSE/WS URL에 전달 + +history, log, proxy, analytics와 referrer에 노출될 수 있다. same-origin +BFF/cookie 또는 승인된 별도 handshake를 사용한다. + +### event payload로 Query cache 직접 patch + +filter/pagination/revision/gap 의미가 없으면 stale projection을 만든다. 기본은 +namespace invalidation과 authoritative refetch다. + +### Web Push를 silent sync로 사용 + +permission/browser/OS/provider가 background execution과 timely delivery를 +보장하지 않는다. user-visible notification hint와 foreground refresh로 제한한다. + +### 무한 `setInterval` Polling + +overlap, hidden resource 사용, retry 중첩과 terminal cleanup 누락을 만든다. +finite immutable lease와 single owner를 사용한다. + +### cross-tab leader를 기본 제공 + +leader election/crash/handoff/partition과 SharedWorker 지원이 별도 protocol을 +요구한다. 기본은 tab별 bounded runtime과 focus snapshot이다. + +### exactly-once delivery + +cursor commit과 application effect 사이 crash window, push service와 browser +lifecycle을 공통 frontend만으로 제거할 수 없다. retention 안의 CURSOR event만 +duplicate-tolerant하게 처리하고 나머지는 best-effort + authoritative resync를 +사용한다. + +## 결과 + +긍정적 결과: + +- 전송 선택이 요구와 failure semantics에 연결된다. +- server state/query ownership과 clean architecture 경계를 유지한다. +- gap, late callback, logout과 page restore가 명시적 복구 경로를 가진다. +- Web Push permission/subscription material이 일반 realtime state와 분리된다. +- Polling fallback이 resource-unbounded loop가 되지 않는다. +- 미선택 capability의 bundle/worker/runtime side effect를 0으로 유지할 수 있다. + +비용: + +- common coordinator 외에도 transport별 adapter와 실제 provider harness가 필요하다. +- backend는 replay/snapshot/outbox/auth와 provider 운영 계약을 제공해야 한다. +- worker와 window에 별도 composition/test matrix가 필요하다. +- direct delta보다 invalidation/refetch가 추가 HTTP 비용을 만들 수 있다. +- target browser/OS에서 자동화할 수 없는 Web Push evidence를 운영해야 한다. + +## 구현 순서 + +```text +RT-00 contract/status + -> RT-01 event authority + scope/gap/resync + -> RT-02 SSE + bounded polling + -> RT-03 WebSocket + -> RT-04 Web Push + -> RT-05 product composition/provider/browser/operations +``` + +SSE와 WebSocket을 모두 구현해야 skeleton이 완성되는 것은 아니다. 공통 +mechanism을 구현한 뒤 실제 product requirement에 필요한 최소 transport만 +선택한다. + +reference source와 deterministic/native evidence가 생기면 해당 runtime만 +`AVAILABLE_NOT_COMPOSED`로 올린다. 제품 endpoint/event registry/policy가 +bootstrap에 연결된 transport만 `COMPOSED`다. + +## Rollout + +capability별 traffic admission: + +```text +DISABLED -> SHADOW -> CANARY -> ENABLED +SHADOW | CANARY | ENABLED -> DISABLED +``` + +- transport, stream, Poll fallback과 push category kill switch를 분리한다. +- safe config default는 `DISABLED`다. +- canary 전에 backend/provider/browser/operations evidence를 만료 검증한다. +- deploy/drain과 reconnect herd를 load test한다. +- freshness/latency만 아니라 gap/resync/queue/memory/battery/push permission + 지표를 함께 본다. + +## Rollback과 제거 + +1. admission을 `DISABLED`, connection lifecycle을 `DRAINING`으로 전환한다. +2. logical subscription/send/poll/push registration을 중지한다. +3. active reader/socket/timer/handler를 bounded close한다. +4. HTTP focus/manual refresh 또는 명시된 fallback을 노출한다. +5. server publisher/replay/subscription compatibility window를 유지한다. +6. composition/registry/adapter/dependency/worker handler를 제거한다. +7. CSP/runtime config/provider key와 retained server subscription을 정리한다. +8. typecheck, architecture, tests, build, bundle/module inventory와 removal gate를 + 실행한다. + +미선택/제거 상태에서 connection, timer, push listener/subscription request와 +production bundle sentinel이 0이어야 한다. + +## 완료 기준 + +### 이 결정의 설계 완료 + +- [x] 네 capability의 의미와 선택 조건을 분리했다. +- [x] current status와 target runtime 상태를 구분했다. +- [x] source of truth와 delivery/effect certainty를 정했다. +- [x] target envelope, ordering, cursor와 resync를 정했다. +- [x] lifecycle/retry/resource/security/privacy 경계를 정했다. +- [x] transport별 auth/hosting/worker/Poll contract를 정했다. +- [x] evidence, rollout, rollback과 제거 기준을 정했다. + +### 구현과 promotion 상태 + +- [x] RT-01 공통 coordinator/reconnect/contract suite +- [x] RT-02 SSE/Poll 및 single-writer handoff reference runtime과 deterministic evidence +- [x] RT-03 WebSocket reference runtime과 deterministic evidence +- [x] RT-04 Web Push window/worker reference runtime과 deterministic evidence +- [x] static boundary/security fixture, synthetic bundle budget와 removal blocking gate +- [ ] actual SSE/WS local server, load와 target-browser evidence +- [ ] actual Web Push provider, permission UX와 target-browser evidence +- [ ] provider/browser evidence와 operations drill의 release-blocking gate 등록 +- [ ] 실제 product/backend/provider selection +- [ ] operations runbook drill + +common runtime status는 `AVAILABLE_NOT_COMPOSED`다. 위의 미완료 promotion +항목 전에는 product selection이 계속 `NOT_SELECTED`이고 production-ready를 +주장하지 않는다. + +## 관련 자료 + +- [상세 설계](../realtime-events-web-push-and-bounded-polling.md) +- [VD-10 optional capability recipes](./VD-10-optional-capability-recipes.md) +- [VD-13 client cache scope and persistence](./VD-13-client-cache-scope-and-persistence.md) +- [VD-23 API transport selection and REST execution](./VD-23-api-transport-selection-and-rest-execution.md) +- [VD-25 Server State Cache lifecycle](./VD-25-server-state-cache-lifecycle.md) +- [VD-27 gRPC-Web unary and server stream](./VD-27-grpc-web-unary-and-server-stream.md) +- [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) +- [Optional adapter recipes](../optional-adapter-recipes.md) +- [Client cache and browser storage](../client-cache-and-storage.md) +- [Frontend ports, adapters, and boundaries](../frontend-ports-adapters-and-boundaries.md) +- [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html) +- [WHATWG WebSockets](https://websockets.spec.whatwg.org/) +- [W3C Push API](https://www.w3.org/TR/push-api/) +- [WHATWG Notifications API](https://notifications.spec.whatwg.org/) +- [W3C Service Workers](https://www.w3.org/TR/service-workers/) +- [RFC 8030](https://www.rfc-editor.org/rfc/rfc8030) +- [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291) +- [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292) diff --git a/docs/architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md b/docs/architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md new file mode 100644 index 0000000..389a98a --- /dev/null +++ b/docs/architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md @@ -0,0 +1,1473 @@ +# VD-29: Connect-Web과 browser Protobuf runtime + +- 상태: Accepted design — common coordinator available, wire adapter pending +- 결정일: 2026-07-28 +- provider-neutral Browser RPC V3 coordinator: + `AVAILABLE_NOT_COMPOSED` +- Connect-Web reference runtime: `DESIGNED_NOT_IMPLEMENTED` +- product Connect-Web composition: `NOT_SELECTED` +- browser client-streaming/bidirectional-streaming guarantee: + `PLATFORM_LIMITED` +- 관련 결정: VD-13, VD-23, VD-24, VD-25, VD-27, VD-28, VD-30 +- 상세 설계: + [Protobuf browser transport와 REST Gateway](../protobuf-browser-transport-and-rest-gateway.md) +- 상위 API 설계: + [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) + +## 배경 + +현재 source에는 provider-neutral operation/profile registry, application port와 +unary/server-stream lifecycle coordinator가 있다. direct dependency, +`.proto`/descriptor, Buf config, generated output, provider route와 actual +browser/proxy test에는 Connect-Web capability가 없다. +문서와 future binding이 있다는 사실은 runtime 구현 또는 backend 연결 증거가 아니다. + +`@connectrpc/connect-web`은 library 이름이고 protocol 이름이 아니다. 같은 browser +runtime이 다음 두 wire protocol을 선택적으로 실행할 수 있다. + +- Connect protocol +- gRPC-Web protocol + +두 protocol은 service/method와 Protobuf schema 의미를 공유할 수 있지만 HTTP +media, framing, status, error와 terminal 규칙은 다르다. application gateway를 +공유할 수 있다는 이유로 decoder와 provider profile을 공유하지 않는다. + +Connect protocol 자체는 unary, client stream, server stream과 bidirectional +stream을 정의하지만 browser용 Connect-Web Fetch transport는 unary와 server +stream만 제공한다. protocol capability와 selected browser runtime capability를 +같다고 보지 않는다. + +VD-27은 gRPC-Web frame/status state machine을 소유한다. 이 결정은 +Connect-Web-specific runtime matrix와 Connect protocol state machine을 소유한다. +VD-27의 특정 XHR/base64 runtime 제약을 모든 gRPC-Web runtime에 일반화하지 않고, +선택한 `(protocol, runtime, version, rpc kind, encoding, browser, proxy)` tuple로 +capability를 판정한다. + +## 결정 + +### 1. Protocol, runtime, message encoding과 framing을 분리한다 + +다음 축은 서로 대체할 수 없다. + +```text +protocol + CONNECT_V1 + GRPC_WEB_V1 + +client runtime + CONNECT_ES_WEB_V2_ + other explicitly registered runtime + +rpc kind + UNARY + SERVER_STREAM + +message encoding + JSON + PROTO + +framing + BARE + CONNECT_ENVELOPE + GRPC_WEB_BINARY_ENVELOPE + GRPC_WEB_BASE64_TEXT +``` + +금지하는 축약: + +- `CONNECT_WEB`이라는 값 하나로 protocol과 runtime을 동시에 표현 +- `binary`를 Protobuf encoding과 HTTP transfer/framing 모두의 의미로 사용 +- `text`를 Protobuf JSON과 `grpc-web-text` base64에 함께 사용 +- response `Content-Type`을 보고 client가 protocol/runtime을 자동 선택 +- 같은 body를 Connect envelope와 gRPC-Web frame decoder에 차례로 시도 + +operation은 exact protocol 하나에 binding한다. provider가 같은 procedure path에서 +Connect와 gRPC-Web을 모두 제공하더라도 client operation이 runtime negotiation이나 +failure fallback을 하지 않는다. + +### 2. Connect-Web browser capability matrix + +선택한 exact Connect-Web v2 package에 대해 허용할 수 있는 baseline: + +| protocol | RPC | encoding | method | selected framing | baseline | +| --- | --- | --- | --- | --- | --- | +| Connect | unary | JSON | POST | bare JSON | 후보 | +| Connect | unary | Proto | POST | bare Proto | 후보 | +| Connect | unary | JSON | GET | URL query | 제한적 후보 | +| Connect | unary | Proto | GET | URL-safe base64 query | 제한적 후보 | +| Connect | server stream | JSON | POST | Connect envelope | 후보 | +| Connect | server stream | Proto | POST | Connect envelope | 후보 | +| gRPC-Web | unary | Proto | POST | binary envelope | 후보 | +| gRPC-Web | unary | JSON | POST | binary envelope | provider evidence 필요 | +| gRPC-Web | server stream | Proto | POST | binary envelope | actual Fetch/proxy evidence 필요 | +| gRPC-Web | server stream | JSON | POST | binary envelope | provider와 actual Fetch/proxy evidence 필요 | + +Connect-Web의 gRPC-Web transport는 `grpc-web-text`를 구현하지 않는다. 따라서 +다음 row는 build/boot 전에 거절한다. + +```text +CONNECT_ES_WEB_V2_* + GRPC_WEB_BASE64_TEXT +``` + +Connect-Web Fetch transport에서 다음 row도 거절한다. + +```text +CLIENT_STREAM +BIDIRECTIONAL_STREAM +``` + +client/bidi가 필요한 use case는 upload/job command, WebSocket, WebTransport 또는 +별도 backend aggregation protocol로 다시 선택한다. browser Fetch request body +streaming이 일부 환경에 존재한다는 사실만으로 이 platform guarantee를 바꾸지 +않는다. + +### 3. Application 경계 + +```text +application port + -> registered semantic operation + -> exact WebRpcOperationV1 + -> generated request construction + -> semantic request validation + -> selected Connect-Web transport + -> protocol-specific bounded response validation + -> generated response decode + -> semantic runtime validation + -> handwritten boundary mapper + -> application result or mapped stream event +``` + +application, domain, presentation과 Query cache에 다음을 노출하지 않는다. + +- generated service descriptor/client +- generated Protobuf message +- `ConnectError` +- service/method string +- raw headers/trailers +- Connect envelope 또는 gRPC-Web frame +- protobuf `bigint`, `bytes`, enum/oneof representation +- transport, base URL, call option와 arbitrary metadata + +feature는 `client.say(request)` 같은 generated RPC를 직접 호출하지 않는다. +`getResource`, `searchResources`, `watchJob`처럼 use-case 의미를 가진 port만 사용한다. + +### 4. Versioned operation binding + +기존 구현 baseline의 operation registry에는 Connect가 없다. 이 설계의 +`ApiOperationContractV3`는 기존 `GRPC_WEB` 값을 재해석하지 않고 +`CONNECT_HTTP` discriminant를 추가한다. source registry와 runtime이 V3를 실제 +구현하기 전에는 Connect operation을 `COMPOSED`로 판정할 수 없다. + +```text +WebRpcOperationV1 + registryVersion + semanticOperationId + owner + providerId + + protocol = CONNECT_V1 | GRPC_WEB_V1 + protocolRevision + clientRuntimeId + clientRuntimeIntegrity + rpcKind = UNARY | SERVER_STREAM + messageEncoding = JSON | PROTO + framingProfileId + requestMethodProfileId + + fullyQualifiedService + method + requestMessageId + responseMessageId + descriptorArtifactId + descriptorDigest + idempotencyLevel + + requestSemanticSchemaId + responseSemanticSchemaId + mapperId + errorProfileId + statusMediaProfileId + metadataProfileId + + authProfileId + csrfProfileId + replayPolicy + idempotencyKeyPolicy + deadlineProfileId + retryProfileId + interceptorChainId + compressionProfileId + serverStateProfileId | null + + maxRequestMessageBytes + maxBrowserVisibleResponseBytes + maxEnvelopeBytes + maxResponseMessageBytes + maxResponseMessages + maxTotalResponseBytes + maxBufferedBytes + idleDeadlineMs + totalDeadlineMs + dataClassification +``` + +protocol-specific closed binding: + +```text +CONNECT_V1 + connectProtocolVersion = 1 + unaryMode = POST_ONLY | GET_NO_SIDE_EFFECTS + connectStatusProfileId + connectEndStreamProfileId | null + +GRPC_WEB_V1 + grpcWebWireSpecRevision + grpcWebTransportProfileId + grpcWebHttpStatusProfileId + grpcWebTrailerProfileId +``` + +registry가 build/boot 전에 거절: + +- protocol과 media/framing/status binding 불일치 +- floating runtime version, missing lock integrity 또는 unknown runtime ID +- descriptor/source/generated digest mismatch +- unknown service/method/message +- generated method kind와 registered `rpcKind` 불일치 +- Connect binding을 gRPC-Web decoder에 연결 +- Connect-Web runtime의 `grpc-web-text` +- Connect-Web runtime의 client/bidi method +- unary response count가 1이 아님 +- server stream의 message/count/total/buffer/idle/total cap 누락 +- caller-provided endpoint/service/method/header +- selected runtime/browser/proxy capability matrix에 없는 row +- non-replayable command retry +- ordinary Query cache가 stream data owner + +### 5. Exact provider profile + +```text +ConnectWebProviderProfileV1 + providerId + fixedHttpsOrigin + pathPrefix + + clientRuntimeId + packageName = @connectrpc/connect-web + exactPackageVersion + packageLockIntegrity + runtimeSourceRevision + runtimeConformanceFixtureDigest + + protocol = CONNECT_V1 | GRPC_WEB_V1 + protocolRevision + rpcKind + messageEncoding + framingProfileId + requestMethodProfileId + statusMediaProfileId + compressionProfileId + + transportInstanceId + fetchImplementationId + interceptorChainId + browserCapabilityProfileId + proxyCapabilityProfileId + + credentialsMode + authProfileId + csrfProfileId + corsProfileId + referrerPolicy + redirect = ERROR + + maxRequestMessageBytes + maxBrowserVisibleResponseBytes + maxEnvelopeBytes + maxResponseMessageBytes + maxResponseMessages + maxTotalResponseBytes + maxBufferedBytes + maximumDeadlineMs +``` + +operation과 provider의 다음 tuple은 exact match해야 한다. + +```text +( + protocol, + protocolRevision, + clientRuntimeId, + exactPackageVersion, + rpcKind, + messageEncoding, + framingProfileId, + requestMethodProfileId, + statusMediaProfileId, + compressionProfileId, + browserCapabilityProfileId, + proxyCapabilityProfileId +) +``` + +권장 profile ID 예: + +```text +CONNECT_ES_WEB__CONNECT_UNARY_JSON_POST_IDENTITY +CONNECT_ES_WEB__CONNECT_UNARY_PROTO_POST_IDENTITY +CONNECT_ES_WEB__CONNECT_UNARY_JSON_GET_PUBLIC_HEADERLESS +CONNECT_ES_WEB__CONNECT_UNARY_PROTO_GET_PUBLIC_HEADERLESS +CONNECT_ES_WEB__CONNECT_SERVER_STREAM_JSON_IDENTITY +CONNECT_ES_WEB__CONNECT_SERVER_STREAM_PROTO_IDENTITY + +CONNECT_ES_WEB__GRPC_WEB_UNARY_PROTO_IDENTITY +CONNECT_ES_WEB__GRPC_WEB_UNARY_JSON_IDENTITY +CONNECT_ES_WEB__GRPC_WEB_SERVER_STREAM_PROTO_IDENTITY +CONNECT_ES_WEB__GRPC_WEB_SERVER_STREAM_JSON_IDENTITY +``` + +`latest`, major version만 있는 runtime ID와 runtime-independent +`GRPC_WEB_BINARY` 같은 profile 이름은 compatibility proof가 아니다. + +### 6. Protobuf source와 Connect-ES v2 codegen + +schema source는 backend와 frontend가 공유하는 contract authority다. + +```text +authenticated `.proto` or Buf module + -> immutable source artifact + -> descriptor set + -> provenance + digest + -> Buf lint + -> production baseline breaking comparison + -> pinned `protoc-gen-es` + -> service descriptors + message schemas + -> adapter-private generated output + -> semantic validators + mapper +``` + +Connect-ES v2는 Connect-specific code generator를 요구하지 않는다. reference +toolchain 후보: + +```text +development + @bufbuild/buf + @bufbuild/protoc-gen-es + +runtime + @bufbuild/protobuf + @connectrpc/connect + @connectrpc/connect-web +``` + +신규 v2 구성에 deprecated Connect generator를 추가하지 않는다. + +governance: + +- exact Buf, `protoc-gen-es`, runtime, Node와 TypeScript version pin +- package lock integrity와 SBOM +- source/descriptor/generated output digest +- normal build에서 remote `latest` schema fetch 금지 +- authenticated explicit schema update와 reviewable diff +- clean checkout regenerate diff 0 +- Buf lint/breaking +- removed field number와 name reserve +- package/service/method full name 안정성 +- proto3 presence와 optional 의미 +- enum zero/unknown numeric policy +- oneof absence/unknown case policy +- repeated/map/nesting/bytes ceiling +- Timestamp/Duration range와 nanos +- int64/uint64 safe projection +- N/N-1 descriptor/provider conformance fixture +- generated import boundary와 dependency removal test + +generated service descriptor의 idempotency option은 GET/retry 검증 입력이다. build +manifest가 `NO_SIDE_EFFECTS`, `IDEMPOTENT`와 unspecified를 구분하고 handwritten +operation row와 교차 검증한다. + +transport가 unknown Protobuf field를 무시하는 default는 forward compatibility +정책일 수 있지만 semantic proof가 아니다. mapper는 known field만 projection하고 +VD-24 validator가 presence, range, enum, oneof와 collection budget을 검증한다. +`google.protobuf.Any`와 error detail registry는 allowlisted descriptor만 포함한다. + +### 7. Request construction + +```text +validated application input + -> generated request shape + -> request semantic validator + -> bounded JSON/Proto serialization + -> protocol-owned method/path/media + -> registry-owned metadata/auth/deadline + -> final request invariant + -> bounded transport +``` + +invariant: + +- origin/path/service/method는 registry가 생성 +- application caller가 `Headers`, endpoint와 call options를 받지 않음 +- request serialization 전 collection/string/bytes cap +- serialization 후 encoded byte cap +- `Content-Type`, protocol version과 timeout header는 transport owner만 설정 +- credentials가 URL, method, body digest를 바꾸지 않음 +- redirects are errors +- final URL/origin이 fixed provider와 다르면 response를 읽기 전에 실패 +- auth-required operation은 valid credential이 없으면 network call 0회 + +Connect JSON/Proto와 gRPC-Web JSON/Proto serialization option은 profile에 +고정한다. runtime caller가 `jsonOptions`, `binaryOptions` 또는 type registry를 +override하지 않는다. + +### 8. Connect unary JSON/Proto(binary) POST + +Connect unary POST는 gRPC-Web frame을 사용하지 않는다. + +| encoding | request media | success media | body | +| --- | --- | --- | --- | +| JSON | `application/json` | `application/json` | bare Protobuf JSON | +| Proto | `application/proto` | `application/proto` | bare binary Protobuf | + +request는 `Connect-Protocol-Version: 1`을 포함하는 selected profile을 기본으로 한다. +timeout을 server에 전파하면 `Connect-Timeout-Ms`를 사용한다. + +success: + +```text +HTTP 200 + + exact selected media + + bounded exactly one bare response message + + semantic validator success + + mapper success +``` + +`204`, empty body, extra JSON/body bytes, wrong media, redirect와 multiple response +message 의미는 success가 아니다. + +error: + +- non-200 Connect error body는 `application/json`이어야 함 +- error body를 별도 작은 byte/depth/detail cap으로 bounded decode +- valid error code와 HTTP status mapping을 교차 검증 +- missing/malformed body는 exact HTTP-to-Connect mapping profile로 닫음 +- success media를 가진 intermediary HTML/JSON을 application DTO로 해석하지 않음 +- unary trailing metadata는 `Trailer-*` response header profile로만 수용 + +HTTP status가 meaningful하다는 사실은 REST adapter 재사용 근거가 아니다. +procedure path, Protobuf JSON mapping, error code와 trailing metadata가 다르다. + +### 9. Connect unary GET + +GET은 편의 flag가 아니라 별도 threat/cache/provider profile이다. + +필수 조건: + +- `rpcKind=UNARY` +- descriptor idempotency가 정확히 `NO_SIDE_EFFECTS` +- application semantics가 `QUERY` +- replay policy가 `SAFE` +- product owner가 side effect 없음과 representation privacy를 승인 +- deterministic request encoding evidence +- encoded URL byte ceiling +- non-sensitive request +- exact browser/proxy/CDN cache policy +- redirect 없음 + +`IDEMPOTENT`는 GET 허용 조건이 아니다. 같은 command를 재실행해도 된다는 의미와 +side effect가 없다는 의미를 섞지 않는다. + +wire query는 runtime이 만들며 caller가 직접 조합하지 않는다. + +```text +connect=v1 +encoding=json | proto +message= +base64=1 when required +compression= +``` + +Proto 또는 binary/compressed payload는 URL-safe base64 규칙을 따른다. 같은 +semantic input이 같은 query encoding을 만들지 actual runtime fixture로 검증한다. +URL/history, CDN, reverse proxy, browser telemetry와 access log에 노출할 수 없는 +field가 있으면 POST를 사용한다. + +Connect-Web의 `useHttpGet`은 transport option이고 generated method의 +`NO_SIDE_EFFECTS`를 보고 GET으로 바꾼다. GET-enabled transport를 검토되지 않은 +service/method와 공유하지 않는다. + +```text +ConnectGetTransportProfile + transportInstanceId + allowedSemanticOperationIds[] + allowedDescriptorMethods[] + deterministicEncodingFixtureDigest + maximumUrlBytes + cacheProfileId + headerProfileId +``` + +baseline `PUBLIC_HEADERLESS` GET: + +- Authorization, CSRF, trace와 application header 없음 +- cookie/private credential 없음 +- `Connect-Timeout-Ms` 없음 +- local AbortSignal deadline만 사용 +- exact `Access-Control-Allow-Origin` +- public cacheability와 response classification 일치 + +timeout/auth header가 필요하면 GET 자체는 가능해도 “headerless/no-preflight” +profile이 아니다. `GET_WITH_PREFLIGHT_PRIVATE`를 별도 승인하지 않은 한 POST를 +사용한다. + +HTTP/CDN cache hit도 response validator와 mapper를 건너뛰지 않는다. HTTP cache, +TanStack Query memory cache와 persisted cache는 서로 다른 owner다. + +### 10. Connect server-stream state machine + +Connect server stream: + +```text +POST +Content-Type: application/connect+json | application/connect+proto +request body: exactly one non-terminal request envelope +HTTP success status: exactly 200 +response body: one or more Connect envelopes +final envelope: exactly one EndStreamResponse +``` + +browser `SERVER_STREAM` method의 request는 generated input message 정확히 하나다. +zero/multiple request message, request-side EndStream flag와 compressed/reserved flag를 +거절한다. + +stream HTTP 200은 application success가 아니다. + +identity baseline envelope: + +```text +1 byte flags +4 byte unsigned big-endian message length +N bytes message +``` + +selected revision에서: + +- bit 0: compressed message +- bit 1: `EndStreamResponse` +- 나머지 bit: reserved + +reference identity profile은 compressed bit와 reserved bit를 거절한다. +data envelope는 selected JSON/Proto response message이고 `EndStreamResponse`는 +stream encoding과 무관하게 bounded JSON contract다. + +state: + +```text +INIT + -> HEADERS_ACCEPTED + -> DATA* + -> END_STREAM_SUCCESS + -> END_STREAM_ERROR + -> CLOSED +``` + +terminal invariant: + +- `EndStreamResponse`가 정확히 한 번 존재 +- terminal은 마지막 envelope +- terminal 뒤 byte/data/envelope 없음 +- success terminal은 `error` property 없음 +- failure terminal은 valid non-empty error code +- `error: null`, empty error와 unknown code 거절 +- trailing metadata key/value/count/decoded-byte cap +- error detail type/count/value/decoded-byte cap +- EOF 전에 terminal이 없으면 contract failure +- duplicate/early terminal은 contract failure + +incremental decoder: + +```text +ReadableStream + -> bounded 5-byte prefix + -> validate flags + -> validate declared length before allocation + -> bounded envelope payload + -> data or EndStream decoder + -> semantic message validator + -> mapper + -> bounded application queue +``` + +`Content-Length`와 declared envelope length만 믿지 않는다. 다음 budget을 각각 +enforce한다. + +- maximum browser-visible total bytes +- maximum envelope bytes +- maximum decoded response message bytes +- maximum message count +- maximum terminal/error JSON bytes +- maximum buffered mapped events +- idle deadline +- total deadline + +consumer가 queue ceiling보다 느리면 reader backpressure를 유지한다. 계속 증가하는 +buffer, UI event마다 무제한 render와 stream 전체 `Uint8Array` materialization을 +금지한다. + +stream에서 이미 전달한 event는 뒤의 terminal error로 rollback되지 않는다. +application은 typed terminal failure를 받고 reducer/resume 정책을 수행한다. +partial stream을 Query success value로 cache하지 않는다. + +### 11. Connect-Web의 gRPC-Web transport + +`createGrpcWebTransport()`를 선택하면 VD-27의 gRPC-Web frame, trailer와 status +state machine을 적용한다. Connect envelope와 `EndStreamResponse`를 적용하지 +않는다. + +Connect-Web runtime-specific 차이: + +- Fetch 기반 unary/server-stream +- binary Protobuf가 default +- Protobuf JSON은 provider가 지원할 때 별도 profile +- `grpc-web-text` 미지원 +- binary server stream은 exact browser/proxy incremental evidence가 있을 때 후보 + +따라서 VD-27의 다른 runtime용 base64 text row를 삭제하거나 재해석하지 않고 +runtime ID별 capability matrix를 만든다. + +```text +GOOGLE_GRPC_WEB_XHR_ + its exact supported profiles +CONNECT_ES_WEB_ + binary-envelope profiles only +``` + +gRPC-Web binary server stream을 Connect-Web에서 선택하려면 Chromium, Firefox, +WebKit과 actual provider/proxy에서 fragmentation, flush, cancellation, +backpressure, trailer와 terminal status를 증명해야 한다. + +### 12. Stock Connect-Web runtime 한계 + +stock runtime dependency 설치와 generated client call 성공만으로 production +adapter를 `COMPLETE`로 판정하지 않는다. + +선택한 runtime source와 package conformance에서 확인할 항목: + +- unary success가 `Response.json()` 또는 `Response.arrayBuffer()`로 전체 body를 + materialize하는지 +- unary error JSON도 bounded read 없이 materialize하는지 +- envelope parser에 registry-owned envelope/message/total cap hook이 있는지 +- compressed output flag 처리 +- terminal exact-once/final enforcement +- reserved flag 처리 +- abort와 reader cleanup +- `timeoutMs <= 0` 의미 + +현재 reference 대상 Connect-Web transport API만으로 registry의 raw-byte, +per-envelope와 per-message ceiling을 모두 전달할 수 있다고 가정하지 않는다. + +reference implementation에는 다음 중 하나가 필요하다. + +1. stock client serialization/service descriptor를 사용하고 platform-owned bounded + `Transport`가 Fetch, media/status와 decode state를 소유 +2. stock transport 앞의 bounded Fetch wrapper와 별도 strict stream conformance + layer가 모든 ceiling/terminal invariant를 실제로 증명 +3. 필요한 cap hook이 있는 검증된 upstream version을 exact pin + +bounded Fetch/Transport 요구: + +- network body가 browser에 노출하는 decoded byte를 incremental count +- cap 초과 즉시 reader cancel과 typed contract failure +- success와 error body 모두 같은 ceiling owner 통과 +- final URL/origin, status와 media를 decode 전 검증 +- timeout/AbortSignal을 fetch와 reader에 전달 +- response/reader lock을 모든 terminal path에서 release +- interceptor 이후가 아니라 wire decode 전에 ceiling 적용 + +Fetch가 content decoding을 끝낸 뒤 제공하는 byte만 client에서 셀 수 있는 경우, +raw compressed byte와 decompression ratio ceiling은 BFF/proxy가 소유한다. client +cap을 upstream cap의 대체물로 간주하지 않는다. + +selected exact package version의 malformed terminal fixture가 protocol의 +exact-once/final 요구를 만족하지 못하면 wrapper가 아니라 custom bounded +Transport/parser로 닫는다. + +이 gap이 닫히기 전 status: + +```text +DESIGNED_NOT_IMPLEMENTED +``` + +### 13. Compression profile + +Connect protocol은 unary HTTP compression과 streaming message compression을 +정의한다. protocol capability가 stock browser runtime implementation 증거는 아니다. + +reference baseline: + +```text +requestMessageCompression = IDENTITY +streamResponseMessageCompression = IDENTITY +connectGetCompression = IDENTITY +``` + +Connect-Web stream decoder가 compressed output을 지원하지 않는 selected version은 +compressed flag를 contract failure로 처리한다. proxy가 임의로 message compression을 +활성화하지 않도록 upstream configuration과 fixture를 둔다. + +unary whole-body HTTP `Content-Encoding`은 별도 profile이다. + +```text +UnaryHttpCompressionProfile + mode = IDENTITY_ONLY | BROWSER_MANAGED + allowedCodings[] + maximumEncodedBytesAtProxy + maximumDecodedBytesAtProxy + maximumBrowserVisibleDecodedBytes +``` + +`BROWSER_MANAGED`는 actual browser가 decode한 body에 client ceiling을 적용하고, +BFF/proxy가 encoded/decompressed ceiling과 decompression bomb 방어를 증명할 때만 +승인한다. 첫 reference profile은 `IDENTITY_ONLY`다. + +### 14. Interceptor chain + +Connect interceptor는 onion 구조이고 configuration array의 마지막 interceptor가 +먼저 적용된다. source array 순서를 읽고 security order를 추측하지 않도록 effective +execution order를 registry artifact와 test evidence로 만든다. + +권장 logical order: + +```text +total deadline owner + -> logical-call telemetry + -> exactly-one replay/retry coordinator + -> attempt telemetry + -> credential owner + -> final request invariant + -> bounded transport +``` + +```text +ConnectInterceptorChainV1 + interceptorChainId + effectiveOrder[] + implementationDigests[] + allowedContextKeyIds[] + retryOwner + credentialOwner + finalMutatorId +``` + +invariant: + +- deadline owner는 retry/backoff 전체를 감쌈 +- retry coordinator가 attempt마다 남은 deadline을 계산 +- credential attach 뒤 URL/method/body를 바꾸는 interceptor 없음 +- final invariant 뒤 mutating interceptor 없음 +- retry/auth-refresh/Query/proxy가 같은 call을 중복 replay하지 않음 +- streaming response 관찰은 `AsyncIterable`을 bounded wrapper로 감쌈 +- stream을 logging 목적으로 선소비하거나 tee하여 무제한 buffer하지 않음 +- application caller에게 arbitrary `ContextValues`를 노출하지 않음 +- context key는 typed, collision-free, registry-owned control 값만 사용 + +interceptor logging은 raw request/response, URL query message, metadata, Protobuf +payload와 backend error message를 기록하지 않는다. + +### 15. Error boundary + +Connect-Web은 supported protocol의 실패를 `ConnectError`로 표면화할 수 있지만 +application error type으로 사용하지 않는다. + +```text +bounded protocol failure + -> ConnectError inspection inside adapter + -> registered error profile + -> safe AppFailure +``` + +safe mapping 입력: + +- protocol +- registered operation/provider/runtime/profile ID +- allowlisted Connect/gRPC code +- local cancel/deadline/retry context +- allowlisted typed error detail projection + +기본적으로 폐기 또는 redaction: + +- `message`와 `rawMessage` +- arbitrary metadata/header/trailer +- error detail raw bytes +- error detail `debug` +- field values, resource identifiers와 provider stack + +```text +WebRpcErrorProfileV1 + errorProfileId + allowedCodes[] + codeToAppFailure[] + allowedDetailTypes[] + maximumErrorBodyBytes + maximumErrorDepth + maximumDetailCount + maximumDetailValueBytes + maximumTotalDetailBytes + metadataAllowlist[] +``` + +Connect unary는 HTTP status와 explicit error code를 교차 검증한다. malformed/missing +error body의 HTTP mapping과 valid explicit error mapping을 구분한다. Connect +stream은 final `EndStreamResponse.error`가 authority다. gRPC-Web은 VD-27의 +terminal status/trailer authority를 사용한다. + +같은 `ConnectError` API가 세 wire status source를 같게 만들지 않는다. + +error code만으로 retry하지 않는다. `Unavailable`은 operation replay safety와 +backend effect 여부를 증명하지 않는다. + +### 16. Total deadline, timeout과 cancellation + +deadline은 다음 전체를 포함한다. + +```text +auth readiness + + request validation/serialization + + network attempts + + retry backoff + + response read + + generated decode + + semantic validation + + mapper + + stream idle/total lifetime +``` + +각 attempt 직전: + +```text +remaining = deadlineAt - monotonicNow +if remaining <= 0: + fail locally, fetch 0회 +else: + pass remaining as timeoutMs +``` + +stock runtime에서 `timeoutMs <= 0`이 “timeout 없음”으로 해석될 수 있으므로 0을 +전달해 즉시 timeout을 기대하지 않는다. + +wire timeout: + +- Connect: `Connect-Timeout-Ms` +- gRPC-Web: `Grpc-Timeout` + +local timer와 AbortSignal도 유지한다. server timeout header만으로 browser reader와 +mapper가 정지한다고 가정하지 않는다. + +cancel cause를 별도로 분류한다. + +```text +USER +NAVIGATION +SCOPE_CHANGE +SESSION_CHANGE +DEADLINE +KILL_SWITCH +CONSUMER_CLOSED +``` + +Connect-Web이 AbortSignal failure를 `ConnectError(Code.Canceled)`로 정규화해도 +local cause를 잃지 않는다. cancel 시 fetch, stream reader, mapper queue와 +downstream subscription을 함께 닫는다. + +command abort는 backend effect가 없다는 증거가 아니다. keyed command는 같은 +idempotency key로 reconcile하고 non-replayable command는 outcome unknown을 +표시한다. + +### 17. Retry와 stream resume + +한 logical operation의 retry owner는 정확히 하나다. + +| semantics | baseline | +| --- | --- | +| safe unary query | bounded retry 후보 | +| idempotent unary | descriptor + backend evidence가 있을 때 후보 | +| keyed command | backend dedupe/reconcile 뒤 같은 key로만 후보 | +| non-replayable command | retry 금지 | +| server stream open | 첫 mapped message 전 safe/idempotent row만 후보 | +| server stream after message | transport retry 금지 | + +retry decision은 다음 conjunction이다. + +```text +operation replay policy +AND descriptor idempotency +AND provider evidence +AND failure category +AND remaining deadline/backoff budget +AND attempt ceiling +``` + +Connect code의 일반 설명만으로 command를 replay하지 않는다. 401 refresh replay, +interceptor retry, TanStack Query retry와 proxy retry를 별개 owner로 동시에 켜지 +않는다. + +stream에서 한 message라도 application에 전달한 뒤 reconnect는 새로운 transport +attempt가 아니라 application resume protocol이다. + +```text +sequence +resume token +dedupe window +gap policy +snapshot/resync +retention +reauth +``` + +이 계약이 backend에 없으면 stream failure 뒤 자동 reconnect하지 않는다. + +### 18. CORS, credentials, auth와 CSRF + +same-origin BFF를 기본 권장한다. cross-origin provider는 actual origin에서 exact +CORS evidence가 필요하다. + +gRPC-Web baseline: + +```text +Access-Control-Allow-Methods: POST +Access-Control-Allow-Headers: + Content-Type, + Grpc-Timeout, + X-Grpc-Web, + X-User-Agent +Access-Control-Expose-Headers: + Grpc-Status, + Grpc-Message, + Grpc-Status-Details-Bin +``` + +Connect baseline: + +```text +Access-Control-Allow-Methods: GET, POST +Access-Control-Allow-Headers: + Content-Type, + Connect-Protocol-Version, + Connect-Timeout-Ms, + X-User-Agent +``` + +Authorization, CSRF, idempotency와 trace header가 있으면 exact allowlist에 +추가한다. custom response header는 expose하고 Connect unary custom trailer는 +`Trailer-`을 expose한다. + +dynamic preflight response: + +```text +Vary: + Origin, + Access-Control-Request-Method, + Access-Control-Request-Headers +``` + +preflight cache TTL은 rollback propagation budget 안에 둔다. cached permissive +CORS가 incident response보다 오래 남지 않게 한다. + +credentials: + +- wildcard origin과 credentials 조합 금지 +- fixed allowed origin +- exact `credentialsMode` +- TLS와 redirect 없음 +- credential owner 외 caller header 금지 +- token/cookie를 GET query message에 넣지 않음 + +Connect GET이 preflight를 피하려면 timeout을 포함한 request header가 없어야 한다. +response는 origin을 허용해야 한다. header가 추가되면 normal preflight profile로 +판정한다. + +cookie profile은 protocol이 POST 또는 non-simple media라는 이유만으로 CSRF가 +해결됐다고 보지 않는다. backend가 Origin, Fetch Metadata와 selected CSRF token +contract를 검증한다. baseline public GET에는 cookie credential을 사용하지 않는다. + +### 19. Provider, BFF와 proxy evidence + +Connect-native server/BFF는 content type으로 같은 procedure path의 Connect와 +gRPC-Web을 구분해 제공할 수 있다. + +```text +Connect unary: + application/json + application/proto + +Connect stream: + application/connect+json + application/connect+proto + +gRPC-Web: + application/grpc-web + application/grpc-web+proto + application/grpc-web+json +``` + +client는 이 content type을 protocol negotiation에 사용하지 않고 selected +provider row의 response 검증에만 사용한다. + +Envoy `grpc_web` filter가 존재한다는 사실은 Connect protocol endpoint 증거가 +아니다. Connect protocol을 선택하려면 Connect-aware BFF/server/gateway의 exact +version, route와 fixture가 필요하다. + +```text +ProxyCapabilityProfileV1 + proxyProduct + exactVersion + configDigest + downstreamProtocol + upstreamProtocol + routeDigest + corsProfileId + authProfileId + requestLimitProfileId + responseLimitProfileId + timeoutPropagationProfileId + streamBuffering = DISABLED + streamFlushProfileId + statusPreservationProfileId + terminalPreservationProfileId +``` + +staging/production-like evidence: + +- exact service/method routing +- request/response content type preservation +- protocol version/timeout/auth header behavior +- encoded/decompressed/message/body ceiling +- Connect unary HTTP/error mapping +- Connect stream HTTP 200 + final EndStream preservation +- gRPC-Web body trailer/status preservation +- no gateway status synthesis without selected fixture +- stream first-byte and inter-message flush +- no proxy/CDN antivirus full-body buffering +- AbortSignal/downstream disconnect propagation +- slow consumer/backpressure behavior +- idle/total timeout behavior +- Chromium, Firefox와 WebKit +- supported HTTP/1.1/HTTP/2 and enterprise path + +fake Fetch와 unit mock만으로 provider conformant가 아니다. + +### 20. Runtime schema와 mapper + +generated Protobuf decode success 뒤에도 semantic validation을 수행한다. + +```text +untrusted bounded bytes/message + -> generated JSON/Proto decode + -> operation-specific RuntimeCodec + -> ValidatedDto + -> BoundaryMapper + -> immutable application projection +``` + +validator/mapper가 소유: + +- required/presence meaning +- string/list/map/bytes semantic ceiling +- int64/uint64 safe range 또는 decimal projection +- Timestamp/Duration normalization +- enum zero와 unknown numeric mapping +- oneof absence/unknown branch +- NaN/Infinity and numeric domain rule +- field-level data classification +- unknown-field projection +- error detail allowlist + +mapper는 throw-only 함수가 아니라 typed mapping result를 반환한다. generated +message, native `Uint8Array`, class instance와 transport metadata가 output에 남으면 +cache admission과 application boundary를 거절한다. + +server stream은 message마다 validate/map한 뒤 bounded reducer로 넘긴다. raw +Protobuf message를 realtime JSON envelope로 다시 감싸지 않는다. + +### 21. Server State와 Connect-Query + +TanStack Query가 mapped unary server state의 유일한 기본 memory owner다. + +```text +Connect/gRPC-Web unary + -> bounded protocol success + -> semantic validation + -> mapper + -> immutable application read model + -> TanStack Query +``` + +Query key는 semantic identity를 사용한다. + +금지: + +- service/method string +- Protobuf request bytes/generated message +- Connect GET URL/query string +- protocol name/runtime version +- raw metadata/trailer +- `ConnectError` + +transport가 REST에서 Connect 또는 gRPC-Web으로 바뀌어도 use-case/result 의미가 +같으면 semantic query family를 유지할 수 있다. mapper/result identity나 scope가 +바뀌면 query definition version/release epoch를 바꾼다. + +`@connectrpc/connect-query`는 reference default dependency가 아니다. generated +operation을 presentation에서 직접 호출하거나 generated message를 Query cache에 +넣어 기존 gateway/schema/mapper/key policy를 우회하지 않는다. 실제 도입하려면 +별도 ADR이 같은 boundary와 single cache owner를 증명해야 한다. + +Connect GET의 browser/CDN cache와 TanStack Query는 별도 owner다. HTTP cache +response도 adapter validation/mapper를 통과하고, private scope를 public cache에서 +복원하지 않는다. + +command result를 query data owner로 쓰지 않는다. success 뒤 registered invalidation +topic/version을 발행한다. stream partial state는 ordinary Query cache가 아니라 +bounded feature reducer/snapshot owner가 관리한다. + +transport retry가 enabled인 query는 TanStack `retry=false`다. + +### 22. Observability와 privacy + +low-cardinality dimensions: + +```text +semanticOperationId +providerId +protocol +clientRuntimeId +rpcKind +messageEncoding +framingProfileId +result category +retry bucket +cancel cause +browserCapabilityProfileId +proxyCapabilityProfileId +``` + +metrics: + +- logical calls/attempts/retries +- auth wait +- first-byte latency +- unary total latency +- stream open/active duration +- message count/bytes/buffer high-water mark +- terminal success/error/missing/early/duplicate +- media/status mismatch +- byte/envelope/message cap rejection +- deadline/cancel cause +- CORS/preflight/provider incompatibility +- mapper/schema rejection + +기록하지 않음: + +- request/response Protobuf or JSON +- Connect GET `message` query +- auth/CSRF/idempotency token +- metadata/trailer raw value +- backend error message/detail/debug +- user/resource identifier +- generated descriptor payload + +trace propagation은 interceptor-owned allowlisted header만 사용한다. arbitrary +provider metadata를 trace attribute로 복사하지 않는다. + +### 23. Security invariants + +- fixed HTTPS origin/path prefix +- caller-controlled absolute URL 없음 +- redirects rejected +- service/method from immutable descriptor/registry only +- arbitrary header/context value 없음 +- auth/CSRF owner 분리 +- request/response/error/metadata/stream queue cap +- decompression cap +- no raw payload logging +- GET sensitive input 금지 +- application-level authorization은 backend authority +- frontend schema validation을 authorization으로 간주하지 않음 +- stream event는 현재 session/scope generation과 일치할 때만 적용 +- kill switch가 신규 call/stream admission을 막고 active reader를 cancel + +같은 origin에 Connect와 gRPC-Web route가 있어도 content-type confusion fixture를 +실행한다. Connect body를 REST JSON endpoint가 받아들이거나 gRPC-Web frame을 +Connect stream으로 해석하는 route를 허용하지 않는다. + +### 24. Verification matrix + +registry/build: + +- duplicate/unknown profile와 descriptor 거절 +- runtime/package integrity mismatch 거절 +- protocol/framing/media/status tuple mismatch 거절 +- Connect-Web + `grpc-web-text` 거절 +- client/bidi 거절 +- GET without `NO_SIDE_EFFECTS` 거절 +- missing cap/deadline/replay policy 거절 +- generated import boundary +- clean code generation, lint/breaking와 digest + +Connect unary: + +- JSON/Proto success +- wrong media, empty/extra body, 204와 redirect +- bounded malformed success body +- all allowlisted explicit error codes +- malformed/missing/oversized error body +- HTTP/error code mismatch +- prefixed trailer allowlist/cap +- browser-visible byte cap + +Connect GET: + +- only `NO_SIDE_EFFECTS` +- deterministic JSON/Proto encoding +- URL-safe base64 +- URL ceiling +- sensitive field rejection +- headerless/no-preflight profile +- timeout/auth header가 있는 preflight profile rejection +- cache key/Vary/private/public behavior + +Connect server stream: + +- prefix가 모든 byte 경계에서 fragmentation +- declared length 0, boundary와 cap 초과 +- truncated prefix/payload +- compressed/reserved flag rejection +- JSON/Proto message decode failure +- zero/many messages followed by valid terminal +- missing, duplicate, early terminal +- data/bytes after terminal +- malformed/oversized terminal JSON +- terminal error/details/metadata cap +- slow consumer/backpressure +- idle/total deadline와 cancellation + +Connect-Web gRPC-Web: + +- binary Proto unary/server stream +- approved JSON provider row +- `grpc-web-text` rejection +- VD-27 frame/trailer/status negative corpus +- actual Fetch incremental behavior + +retry/cancel: + +- one retry owner +- total attempt/backoff/deadline ceiling +- `timeoutMs <= 0` network call 0회 +- AbortSignal propagates to reader +- no replay for non-replayable command +- keyed command same key/reconcile +- stream first message 이후 reconnect 0회 + +CORS/proxy/browser: + +- exact preflight allow/expose/Vary +- credential/wildcard rejection +- `Trailer-*` exposure +- content type and terminal preservation +- no stream buffering +- Chromium/Firefox/WebKit with actual provider/proxy + +cache/mapper: + +- generated/raw values admission rejection +- semantic key only +- scope/session generation switch +- protocol migration without duplicate server-state owner +- stream partial state Query cache rejection + +malformed/oversized fixture는 OOM, unhandled rejection과 secret-bearing log 없이 +fail-closed해야 한다. + +### 25. Capability state와 promotion + +현재: + +```text +design = ACCEPTED +referenceRuntime = DESIGNED_NOT_IMPLEMENTED +productComposition = NOT_SELECTED +clientOrBidi = PLATFORM_LIMITED +``` + +state transition: + +```text +DESIGNED_NOT_IMPLEMENTED + -> AVAILABLE_NOT_COMPOSED + -> COMPOSED / TrafficAdmission=DISABLED + -> INTERNAL_CANARY + -> LIMITED + -> ACTIVE +``` + +`AVAILABLE_NOT_COMPOSED` 조건: + +- exact dependencies와 generated artifacts 존재 +- versioned registry and runtime profiles 구현 +- bounded unary/stream transport 구현 +- schema/mapper/error/deadline/retry/cache boundary 구현 +- negative/conformance corpus 통과 +- dependency/removal test 통과 + +`COMPOSED` 조건: + +- product operation/provider/backend owner 선택 +- proto/descriptor and N/N-1 contract +- auth/CSRF/CORS/cache/retry/resume contract +- actual provider/proxy/browser evidence +- runtime config/kill switch/traffic admission +- observability and incident owner + +문서만으로 state를 올리지 않는다. + +### 26. Rollout + +1. product가 bounded-context operation family와 backend/provider owner를 선택한다. +2. operation마다 Connect 또는 gRPC-Web protocol 하나를 선택한다. +3. immutable proto/descriptor, idempotency와 compatibility baseline을 확정한다. +4. exact Connect-Web/codegen/runtime dependency를 optional adapter boundary에 설치한다. +5. bounded unary와 stream transport, validator/mapper/error profile을 구현한다. +6. fake provider negative corpus와 exact package conformance를 통과한다. +7. actual BFF/proxy/browser matrix를 통과한다. +8. reference runtime을 `AVAILABLE_NOT_COMPOSED`로 판정한다. +9. product composition 뒤 `TrafficAdmission=DISABLED`로 배포한다. +10. safe unary read를 shadow 비교하되 secondary cache/UI write를 금지한다. +11. internal canary에서 단일 protocol만 admission한다. +12. server stream은 unary와 별도 canary/evidence로 승격한다. +13. keyed command는 backend dedupe/reconcile 뒤 별도 canary한다. +14. client/bidi는 계속 `PLATFORM_LIMITED`로 표시한다. + +shadow/fallback: + +- Connect 결과와 REST 결과를 비교해도 cache/UI owner는 primary 하나 +- client가 Connect failure를 REST/gRPC-Web로 자동 재실행하지 않음 +- command dual-write 금지 +- protocol 전환은 새 logical operation/controlled rollout + +rollback: + +- 신규 operation/stream traffic admission 중지 +- active reader와 pending call cancel +- ambiguous command reconcile +- affected mapped Query cache clear/invalidate +- frontend/generated descriptor/provider route의 coherent rollback +- preflight/CDN cache TTL 고려 +- approved previous protocol은 별도 registered operation으로만 복구 + +### 27. Incident와 recovery + +kill switch granularity: + +```text +provider +protocol +runtime version +operation +rpc kind +message encoding +GET profile +server stream +``` + +incident triage 순서: + +1. semantic operation/provider/runtime/profile 확인 +2. admission과 retry 차단 +3. active stream cancel +4. command outcome/dedupe reconciliation +5. browser/proxy/provider/backend change correlation +6. media/status/terminal/cap/deadline bucket 확인 +7. raw payload 없이 approved fixture로 재현 +8. coherent rollback 또는 provider quarantine + +missing terminal, wrong media와 mapper rejection을 generic network error로 숨기지 +않는다. 사용자는 safe application failure만 보지만 operations telemetry는 +contract/provider category를 구분한다. + +### 28. Removal + +1. operation traffic admission 중지와 usage 확인 +2. active stream/call cancel, command reconcile +3. mapped Query cache/reducer/invalidation listener clear +4. operation/provider/schema/mapper/error/cache profile 제거 +5. generated files와 descriptor/source artifact reference 제거 +6. Buf/codegen/Connect runtime direct dependency 제거 +7. route/CORS/proxy/CDN config 제거 또는 다른 client owner에게 이관 +8. deprecated proto field reserve와 backend N/N-1 retirement window 준수 +9. lockfile/SBOM/license/manifest에서 dependency 제거 확인 +10. production bundle/module graph와 removal test 통과 + +같은 provider route가 다른 native/mobile/backend client를 지원하면 frontend 제거가 +provider route 삭제 권한을 뜻하지 않는다. owner와 traffic evidence를 확인한다. + +## 금지 조합 요약 + +- Connect protocol을 `GRPC_WEB` registry row로 가장 +- Connect와 gRPC-Web frame/status decoder 공유 +- Connect-Web + `grpc-web-text` +- browser Connect-Web client/bidi +- protocol auto-detection/fallback +- GET without exact `NO_SIDE_EFFECTS` +- sensitive/authenticated baseline headerless GET +- shared unreviewed `useHttpGet=true` transport +- compressed stream with unsupported runtime +- unbounded `Response.json()`/`arrayBuffer()`를 production cap으로 간주 +- `timeoutMs=0`을 immediate timeout으로 사용 +- arbitrary interceptor/header/context/endpoint +- duplicate transport/Query/proxy retry +- raw `ConnectError`/metadata/detail/debug 노출 +- generated client/message in presentation, application or Query cache +- Connect-Query direct page usage +- generic Envoy gRPC-Web filter를 Connect endpoint 증거로 사용 +- fake Fetch만으로 provider/browser conformance 판정 +- partial stream을 ordinary Query success로 cache +- command protocol failover/dual-write + +## 규범 기준 + +- [Connect protocol reference](https://connectrpc.com/docs/protocol/) +- [Choosing Connect or gRPC-Web](https://connectrpc.com/docs/web/choosing-a-protocol/) +- [Using Connect-Web clients](https://connectrpc.com/docs/web/using-clients/) +- [Connect GET requests and caching](https://connectrpc.com/docs/web/get-requests-and-caching/) +- [Connect-Web interceptors](https://connectrpc.com/docs/web/interceptors/) +- [Connect-Web errors](https://connectrpc.com/docs/web/errors/) +- [Connect-Web cancellation and timeouts](https://connectrpc.com/docs/web/cancellation-and-timeouts/) +- [Connect and gRPC-Web CORS](https://connectrpc.com/docs/cors/) +- [Connect multi-protocol support](https://connectrpc.com/docs/multi-protocol/) +- [Connect-Web code generation](https://connectrpc.com/docs/web/generating-code/) +- [Connect-ES v2 migration](https://connectrpc.com/docs/web/migrating-to-v2/) +- [Connect-Web Connect transport source](https://github.com/connectrpc/connect-es/blob/main/packages/connect-web/src/connect-transport.ts) +- [Connect-Web gRPC-Web transport source](https://github.com/connectrpc/connect-es/blob/main/packages/connect-web/src/grpc-web-transport.ts) + +## 완료 기준 + +- Connect와 gRPC-Web이 separate protocol binding/status/framing profile이다. +- exact runtime package version/integrity/source와 provider tuple이 고정된다. +- Connect-Web runtime의 gRPC-Web text와 client/bidi 조합이 거절된다. +- Connect unary JSON/Proto POST가 bounded status/media/error state를 가진다. +- GET이 exact `NO_SIDE_EFFECTS`, privacy, deterministic encoding, URL/CORS/cache + profile을 모두 만족한다. +- Connect server stream이 bounded envelope/message/queue와 exact final + `EndStreamResponse`를 검증한다. +- stock runtime의 unary raw-byte와 stream compression/terminal gap이 bounded + Fetch/Transport 또는 검증된 upstream hook으로 닫힌다. +- total deadline, positive remaining timeout, cancellation과 exactly-one retry owner가 + 증명된다. +- CORS/auth/CSRF/proxy/browser evidence가 exact provider에서 유효하다. +- `protoc-gen-es` v2 generation, descriptor provenance/breaking/digest가 재현 가능하다. +- generated message/client/ConnectError가 adapter 밖으로 나오지 않는다. +- mapped immutable unary result만 semantic key로 TanStack Query에 admission된다. +- stream partial state와 raw metadata가 Query cache에 없다. +- actual Chromium/Firefox/WebKit과 proxy conformance가 통과한다. +- kill switch, coherent rollback, incident recovery와 dependency/generated/provider + removal drill이 통과한다. +- product operation/provider가 선택되기 전 `NOT_SELECTED`를 유지한다. diff --git a/docs/architecture/decisions/VD-30-protobuf-contract-and-rest-gateway.md b/docs/architecture/decisions/VD-30-protobuf-contract-and-rest-gateway.md new file mode 100644 index 0000000..0452545 --- /dev/null +++ b/docs/architecture/decisions/VD-30-protobuf-contract-and-rest-gateway.md @@ -0,0 +1,1093 @@ +# VD-30: Protobuf contract와 REST transcoding gateway + +- 상태: Accepted design — schema/codegen과 provider runtime implementation pending +- 결정일: 2026-07-28 +- Protobuf schema/codegen reference governance: + `DESIGNED_NOT_IMPLEMENTED` +- provider-neutral REST transcoding reference: + `DESIGNED_NOT_IMPLEMENTED` +- product REST Gateway composition: `NOT_SELECTED` +- 현재 installed REST reference의 기본 public adapter: curated BFF +- direct REST Gateway default RPC kind: unary only +- REST server-stream/client-stream/bidirectional-stream composition: + `NOT_SELECTED` +- 관련 결정: VD-12, VD-13, VD-14, VD-23, VD-24, VD-25, VD-27, VD-29 +- 상세 설계: + [Protobuf browser transport와 REST Gateway](../protobuf-browser-transport-and-rest-gateway.md) +- 상위 API 설계: + [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) +- backend handoff: + [Backend API와 Server State contract](../backend-api-and-server-state-contract.md) + +## 배경 + +현재 repository에는 authenticated `.proto` source, Buf module, `buf.yaml`, +`buf.lock`, `buf.gen.yaml`, immutable descriptor set, generated gateway, +generated OpenAPI, protobuf runtime dependency와 actual REST transcoding provider +evidence가 없다. 따라서 이 ADR은 production contract와 구현 순서를 정의하지만 +capability 구현 완료 증거는 아니다. + +VD-27의 gRPC-Web과 이 ADR의 REST Gateway는 같은 protobuf source를 사용할 수 +있어도 같은 browser protocol이 아니다. + +```text +gRPC-Web + browser generated protobuf client + -> gRPC-Web binary/text frame + -> gRPC-Web proxy + +REST transcoding + browser ordinary HTTP/JSON client + -> google.api.http route + ProtoJSON + -> REST/gRPC transcoding gateway +``` + +gRPC-Web의 frame, terminal trailer와 client runtime을 REST Gateway에 재사용하지 +않는다. REST Gateway의 route, ProtoJSON, HTTP status/header와 OpenAPI contract를 +gRPC-Web descriptor compatibility로 대신 증명하지도 않는다. + +현재 installed REST reference는 다음 public 의미를 가진다. + +- curated `{ success, data, error, meta }` envelope +- stable frontend error vocabulary와 redaction +- create의 승인된 `200 | 201` +- optional HTTP `ETag`, `304`, `If-Match`, `412` +- `Idempotency-Key`와 effect reconciliation +- browser-facing auth/CORS/CSRF, body ceiling과 mapper + +일반 transcoding gateway의 기본 ProtoJSON/status/error 출력은 이 contract와 +동일하지 않다. 따라서 현재 reference operation은 curated BFF를 기본 선택으로 +유지한다. 제품이 direct gateway를 선택할 때는 이 ADR의 별도 artifact와 public +contract compatibility를 먼저 승인한다. + +## 결정 + +### 1. Public protocol과 adapter를 혼용하지 않는다 + +지원 가능한 adapter profile은 다음처럼 분리한다. + +```text +CURATED_REST_BFF +PROTOBUF_REST_JSON_GATEWAY +GRPC_WEB_BINARY +GRPC_WEB_TEXT +CONNECT_PROTOCOL +``` + +한 semantic operation의 한 public endpoint는 하나의 exact profile에만 +binding한다. + +- HTTP media를 보고 runtime이 BFF와 gateway를 자동 선택하지 않는다. +- provider 장애를 이유로 command를 다른 protocol로 자동 replay하지 않는다. +- 같은 path에 BFF envelope와 direct ProtoJSON을 content negotiation으로 + 섞지 않는다. +- gRPC-Web proxy route를 HTTP/JSON REST route로 간주하지 않는다. +- 내부 gRPC service address, fully-qualified method와 metadata를 browser input으로 + 받지 않는다. +- shadow read는 secondary 결과를 사용자나 cache에 반영하지 않는다. + +public adapter 변경은 동일 backend method를 사용하더라도 API contract migration +이다. N/N-1 route, payload, status, error와 cache evidence 없이 in-place 전환하지 +않는다. + +### 2. Topology와 현재 기본 선택 + +현재 기본: + +```text +Browser + -> CDN / reverse proxy / WAF + -> same-origin or approved-origin curated BFF + -> public REST contract enforcement + -> application service or internal gRPC service +``` + +제품 승인 뒤 가능한 direct topology: + +```text +Browser + -> CDN / reverse proxy / WAF + -> authentication / authorization edge + -> pinned REST transcoding gateway + -> exact generated route allowlist + -> ProtoJSON codec + -> internal gRPC application service +``` + +gateway deployable이 독립 process인지 application과 같은 process인지는 이 ADR의 +핵심이 아니다. 다음 owner가 논리적으로 분리돼야 한다. + +- edge: TLS, origin, request admission, encoded byte/rate ceiling +- gateway: HTTP route, path/query/body mapping, ProtoJSON, status/header projection +- application service: authorization 재검사, validation, transaction, + idempotency, pagination, revision과 domain invariant +- persistence: durable dedupe, cursor/snapshot과 concurrency authority + +### 3. Protobuf source of truth와 artifact + +`.proto` source와 dependency는 authenticated immutable input이어야 한다. + +```text +ProtoContractArtifactV1 + artifactId + contractVersion + sourceRepository + sourceCommit + sourceTreeDigest + bufModuleRef + bufModuleCommit + bufModuleDigest + bufLockDigest + bufImageId + bufImageDigest + fileDescriptorSetId + fileDescriptorSetDigest + includeImports + includeSourceInfo + httpRuleSource = PROTO_ANNOTATION | SERVICE_CONFIG + httpRuleArtifactId + httpRuleArtifactDigest + productionBaselineArtifactId + productionBaselineDigest + protoJsonProfileId + generatedGatewayArtifactId + generatedGatewayDigest + generatedOpenApiArtifactId + generatedOpenApiDigest + minimumBackendVersion + minimumGatewayVersion + retirementEpoch | null + owner +``` + +불변조건: + +- moving branch, tag, label이나 `latest`를 production generation input으로 사용하지 + 않는다. +- Buf Schema Registry를 사용하면 exact module commit/digest를 release에 고정한다. +- `buf.lock`의 direct/transitive dependency commit과 digest를 check-in한다. +- `google/api/annotations.proto`, `http.proto`, well-known type dependency가 실제 + descriptor input과 일치해야 한다. +- Buf image와 native `google.protobuf.FileDescriptorSet`을 구분해 저장한다. +- descriptor digest는 exact artifact byte의 integrity/provenance 식별자다. + digest가 다르다는 사실만으로 semantic breaking을 추측하지 않고 compatibility + gate를 별도로 실행한다. +- production server reflection을 source/codegen 공급망으로 사용하지 않는다. + reflection은 승인된 diagnostics profile에서만 사용할 수 있다. +- artifact download는 authenticated channel, integrity verification과 audit + evidence를 가져야 한다. + +### 4. Schema evolution과 breaking policy + +공통 protobuf governance: + +- field number 재사용 금지 +- 제거한 field number와 name을 모두 reserve +- package/service/method full name 안정성 +- 삭제 대신 deprecate와 retirement window +- proto3 optional/presence와 implicit default 의미 명시 +- enum zero value와 unknown numeric value 처리 +- 제거한 enum name/number reserve +- oneof absence, 새 case와 unknown case 처리 +- map iteration order를 identity, signature와 pagination order로 사용하지 않음 +- repeated/map/message depth와 decoded byte ceiling +- Timestamp/Duration valid range, nanos와 timezone projection +- int64/uint64를 JavaScript `number`로 무조건 변환하지 않음 +- bytes base64와 maximum decoded length +- `Any`, `Struct`, `Value`는 type allowlist와 semantic validator 없이는 public + contract에 넣지 않음 + +breaking baseline은 mutable main branch가 아니라 마지막으로 production에 +promote된 immutable artifact다. + +Buf category: + +- generated gateway, generated server/client 또는 여러 언어 source compatibility를 + 보호하는 기본은 `FILE`이다. +- source compatibility를 의도적으로 별도 관리해도 HTTP/JSON을 제공하는 module은 + 최소 `WIRE_JSON`을 통과해야 한다. +- JSON을 사용하는 module에서 `WIRE`만 통과한 것을 REST compatibility 증거로 + 사용하지 않는다. + +`google.api.http`와 다른 custom option의 의미는 generic Buf breaking rule만으로 +닫히지 않는다. 따라서 다음을 별도 gate로 둔다. + +- canonical route manifest semantic diff +- generated OpenAPI compatibility diff +- HTTP method/path/body/query/response body 변경 분류 +- additional binding 추가/삭제/충돌 검사 +- path escaping/unescaping profile diff +- auth, idempotency, pagination, conditional과 status profile diff + +schema wire-safe 변경도 제품 의미, validation ceiling, pagination이나 auth scope를 +바꾸면 semantic breaking일 수 있다. Buf success를 mapper/domain compatibility +승인으로 사용하지 않는다. + +### 5. Code generation provenance와 reproducibility + +generation toolchain: + +```text +CodegenToolchainV1 + bufCliVersion + protocVersion + plugins[]: + pluginId + pluginVersion + pluginRevision + binaryDigest + optionsDigest + invocationStrategy + protobufRuntimeId + protobufRuntimeVersion + grpcRuntimeVersion + gatewayRuntimeVersion + languageRuntimeVersion + osImageDigest +``` + +- Buf CLI, protoc, every local/remote plugin과 runtime을 exact version으로 pin한다. +- BSR remote plugin은 upstream version뿐 아니라 repackaging revision도 고정한다. +- version을 생략해 latest plugin을 선택하지 않는다. +- local plugin은 package lock과 executable digest를 함께 기록한다. +- plugin option, managed-mode override, include/exclude path/type와 invocation + strategy를 manifest에 포함한다. +- generated code보다 오래된 incompatible protobuf runtime을 조립하지 않는다. +- `buf.gen.yaml`의 output 경계는 생성 전에 clean되며 generated directory에 + hand-written file을 두지 않는다. + +CI pipeline: + +```text +authenticated immutable source + -> dependency lock verification + -> buf format/lint + -> descriptor/image build + -> breaking check against production baseline + -> HttpRule route manifest generation/diff + -> pinned gateway/OpenAPI/runtime code generation + -> clean regenerate + -> worktree diff == 0 + -> generated compile/typecheck/test + -> N/N-1 binary + ProtoJSON fixtures + -> provider conformance + -> artifact sign/attest/SBOM + -> release contract set +``` + +“Buf를 사용했다”는 이유만으로 deterministic generation이라 부르지 않는다. +동일 input/toolchain에서 clean regenerate diff가 0이고 artifact digest가 +일치하는 증거를 남긴다. timestamp, absolute local path와 host-dependent output을 +생성물에서 금지하거나 normalize한다. + +generated gateway/server/message/OpenAPI는 application/domain model이 아니다. +frontend가 generated OpenAPI client를 선택하더라도 transport adapter 밖으로 +generated DTO를 노출하지 않고 VD-24 runtime schema와 mapper를 유지한다. + +### 6. ProtoJSON profile + +모든 gateway operation은 immutable `ProtoJsonProfile`에 binding한다. + +```text +ProtoJsonProfileV1 + profileId + specificationRevision + useProtoNames + discardUnknownOnRequest + emitUnpopulated + useEnumNumbers + allowPartial + int64Projection + bytesProjection + nullPresencePolicy + duplicateFieldPolicy + timestampPolicy + durationPolicy + fieldMaskPolicy + anyTypeAllowlist[] + maxDecodedMessageBytes + maxDepth + maxNodes + maxStringBytes + maxRepeatedItems + maxMapEntries +``` + +reference 기본: + +```text +useProtoNames = false +discardUnknownOnRequest = false +emitUnpopulated = false +useEnumNumbers = false +allowPartial = false +int64Projection = DECIMAL_STRING +bytesProjection = STANDARD_BASE64 +nullPresencePolicy = PROTOJSON_UNSET +duplicateFieldPolicy = REJECT_OR_PROVIDER_PINNED +``` + +wire 의미: + +- JSON field name은 lowerCamelCase가 기본이며 proto field name 입력 수용 여부와 + 출력 option을 fixture로 고정한다. +- int64/uint64 출력은 decimal string이다. frontend mapper가 safe integer 범위를 + 증명하지 않는 한 `number`로 변환하지 않는다. +- bytes는 base64를 decode하기 전 encoded/decoded ceiling을 모두 확인한다. +- enum output은 name이며 unknown numeric value를 application enum으로 추측하지 + 않는다. +- serializer는 presence가 없는 default field를 기본 생략한다. +- `null`은 일반 field의 명시적 domain null이 아니라 protobuf unset으로 + 해석될 수 있다. nullable domain 의미는 별도 message/oneof/mapper가 소유한다. +- unknown request field를 production에서 조용히 버리지 않는다. +- response additive rollout은 old frontend가 unknown field를 받는 N/N-1 fixture를 + 통과한 뒤 writer를 활성화한다. +- duplicate field를 사용하는 client 의미에 의존하지 않는다. selected runtime이 + reject하지 않으면 deterministic last-value behavior와 WAF/parser differential + test를 별도 증명한다. +- Timestamp/Duration/FieldMask/wrapper well-known type의 special JSON mapping을 + ordinary object로 추측하지 않는다. + +gateway decoder success는 semantic validation proof가 아니다. application +service와 frontend boundary는 range, presence, enum/oneof, collection ceiling과 +domain invariant를 각각 다시 검증한다. + +### 7. HttpRule과 route manifest + +기본 source of truth는 RPC의 `google.api.http` annotation이다. + +외부 gRPC API service-config YAML은 proto를 직접 변경할 수 없거나 동일 service를 +서로 다른 public API로 투영해야 하는 승인된 경우에만 사용한다. + +- annotation과 service-config를 같은 RPC에 중복 정의하지 않는 것이 기본이다. +- service-config가 matching annotation을 override할 수 있으므로 artifact digest, + selector, precedence와 generated route 결과를 release에 포함한다. +- config rule의 last-one-wins에 의존해 중복 selector를 숨기지 않는다. +- `generate_unbound_methods`는 기본 false다. + +```text +RestGatewayRouteManifestV1 + manifestId + protoContractArtifactId + httpRuleArtifactDigest + routes[]: + semanticOperationId + fullyQualifiedService + method + rpcKind + httpMethod + pathTemplate + additionalBindingIndex | null + pathFieldBindings + queryFieldBindings + requestBodyBinding | null + responseBodyBinding | null + protoJsonProfileId + operationProfileId + routeManifestDigest +``` + +HttpRule 불변조건: + +- GET/DELETE operation은 request body를 갖지 않는다. +- path variable은 허용된 non-repeated primitive field에만 binding한다. +- body field는 top-level이며 path field와 중복되지 않는다. +- `body: "*"`이면 path에 포함되지 않은 모든 field가 body로 가고 query + parameter가 없음을 계약한다. +- body를 생략하면 request body가 없고 나머지 field가 query로 가는 것을 + 계약한다. +- repeated primitive query의 repeated-key 의미와 maximum item을 고정한다. +- nested message query flattening은 approved field set과 depth ceiling을 가진다. +- additional binding 안에 additional binding을 중첩하지 않는다. +- 한 RPC의 additional binding이 다른 RPC route와 충돌하지 않는다. +- reserved path character와 multi-segment parameter는 selected gateway의 + `AllExceptReserved` 동등 unescaping profile과 actual fixture를 통과한다. +- caller-provided custom HTTP verb와 arbitrary route를 허용하지 않는다. + +PATCH가 FieldMask를 사용하면 gateway 자동-population 여부와 `body: "*"` 예외를 +profile에 고정한다. partial update의 최종 authorization, writable field allowlist와 +mask validation은 application service가 수행한다. + +### 8. OpenAPI contract + +generated OpenAPI는 documentation 부산물이 아니라 browser-facing compatibility +artifact다. + +```text +GatewayOpenApiArtifactV1 + generatorId + generatorVersion + generatorRevision + generatorOptionsDigest + routeManifestDigest + protoJsonProfileId + errorProfileId + statusProfileId + documentDigest +``` + +- production-stable pipeline은 selected grpc-gateway + `protoc-gen-openapiv2` version을 pin한다. +- 공식 상태가 alpha인 OpenAPI v3 generator는 별도 승인과 toolchain conformance + 전까지 production source of truth로 승격하지 않는다. +- generated document를 authenticated immutable artifact로 배포한다. +- generated path, parameter, request/response schema, status와 error가 actual + gateway fixture와 일치해야 한다. +- runtime response rewriter처럼 OpenAPI에 표현되지 않는 변환은 direct gateway의 + 기본 public contract에서 금지한다. +- custom transformation이 필요하면 curated BFF를 선택하거나 별도 hand-written + contract와 bidirectional conformance를 소유한다. +- OpenAPI client 생성 여부와 무관하게 frontend runtime boundary validation을 + 제거하지 않는다. + +`google.api.HttpBody`는 raw/binary body를 전달할 수 있지만 일반 application +REST에 암묵적으로 사용하지 않는다. 파일 upload/download, Range, presigned URL, +background transfer와 CDN은 VD-12/VD-14의 transfer 계약을 유지한다. + +### 9. Gateway provider와 operation definition + +```text +RestTranscodingProviderProfileV1 + providerId + gatewayImplementation + gatewayVersion + fixedHttpsOrigin + basePathPrefix + routeManifestId + routeManifestDigest + protoContractArtifactId + protoJsonProfileId + openApiArtifactId + authProfileId + credentialsMode + corsProfileId + incomingHeaderProfileId + outgoingHeaderProfileId + pathUnescapingProfileId + errorProfileId + statusProfileId + requestAdmissionProfileId + redirect = ERROR + referrerPolicy + owner +``` + +```text +ProtobufRestOperationV1 + protocol = PROTOBUF_REST_JSON_V1 + semanticOperationId + providerId + fullyQualifiedService + method + rpcKind = UNARY + requestMessageId + responseMessageId + protoContractArtifactId + descriptorDigest + routeManifestDigest + httpMethod + pathTemplate + requestBodyBinding | null + responseBodyBinding | null + requestSemanticSchemaId + responseSemanticSchemaId + mapperId + successStatusMediaProfileId + errorProfileId + authProfileId + csrfProfileId + replayPolicy + idempotencyProfileId | null + deadlineProfileId + retryProfileId + paginationProfileId | null + conditionalProfileId | null + serverStateProfileId | null + maxUrlBytes + maxRequestHeaderBytes + maxRequestBodyBytes + maxDecodedRequestBytes + maxResponseHeaderBytes + maxEncodedResponseBytes + maxDecodedResponseBytes + maxResponseItems + owner +``` + +composition이 거절: + +- unknown service/method/message/descriptor +- descriptor, route manifest, ProtoJSON과 OpenAPI digest mismatch +- operation과 provider profile mismatch +- client-streaming 또는 bidirectional RPC +- 별도 streaming ADR 없는 server-streaming RPC +- caller-provided URL, service, method, metadata와 header +- body/query/path/status가 generated route와 다름 +- ceiling, deadline, auth, error 또는 owner가 없음 +- non-replayable command에 retry +- keyed command에 durable idempotency evidence가 없음 + +### 10. Unary-only default와 streaming 경계 + +direct REST Gateway reference는 unary RPC만 허용한다. + +- unary request 한 개와 unary response 한 개 +- exact terminal HTTP status/media/body +- bounded response decode + +grpc-gateway의 server stream은 newline-separated JSON chunk envelope와 terminal +error body를 사용할 수 있다. 이것은 ordinary REST JSON response도, VD-27의 +gRPC-Web framing도 아니다. + +따라서 다음은 `NOT_SELECTED`다. + +- REST server stream +- REST client stream +- REST bidirectional stream +- streaming `HttpBody` file delivery를 ordinary API adapter로 사용 + +제품이 server stream을 요구하면 chunk media type, message delimiter, +partial-delivery meaning, terminal error, backpressure, sequence/gap/resume, +idle/total deadline과 browser buffering을 별도 ADR과 actual provider evidence로 +설계한다. SSE, gRPC-Web, WebSocket 또는 bounded polling과도 요구를 다시 +비교한다. + +### 11. Direct gateway와 curated BFF 선택 + +| 요구 | 기본 선택 | +| --- | --- | +| `google.api.http`와 canonical ProtoJSON이 그대로 public contract | direct gateway 가능 | +| simple unary resource operation, 별도 envelope/aggregation 없음 | direct gateway 가능 | +| 현재 `{ success, data, error, meta }` envelope 유지 | curated BFF | +| 제품별 DTO projection, aggregation 또는 여러 backend orchestration | curated BFF | +| cookie session, CSRF와 same-origin session lifecycle | curated BFF | +| stable safe error vocabulary와 PII redaction | curated BFF 권장 | +| custom `201/204`, HTTP `304/412`와 conditional cache transaction | curated BFF 또는 별도 custom gateway | +| idempotency receipt/status/reconcile UX | curated BFF 권장 | +| file transfer, Range, presigned URL, background download와 Image CDN | transfer/BFF 경계 | +| server streaming | 별도 protocol ADR | + +direct gateway는 다음을 모두 증명할 때만 선택한다. + +- public DTO가 canonical ProtoJSON과 일치 +- no custom success envelope +- exact google.api.http route가 product REST route와 일치 +- safe error/status mapping이 frontend failure vocabulary와 호환 +- edge/gateway/upstream auth owner가 명확 +- operation이 unary이고 bounded +- custom header/status/cache 의미가 제한적이고 artifact에 표현 가능 +- actual browser/gateway/backend conformance 통과 + +BFF 뒤에서 internal gRPC를 호출하는 것은 direct gateway가 아니다. public +contract owner는 계속 BFF다. + +### 12. Request admission과 semantic validation + +request 처리: + +```text +TLS/WAF encoded admission + -> fixed route/method + -> origin/auth/CSRF/rate admission + -> header/path/query/body byte ceiling + -> HttpRule binding + -> strict ProtoJSON decode + -> generated request message + -> application semantic validation + -> authorization + -> application service +``` + +- edge는 encoded request/body/header와 request rate ceiling을 소유한다. +- gateway는 URL/path/query/body mapping과 decoded protobuf message ceiling을 + 소유한다. +- application service는 generated message를 신뢰하지 않고 domain validation과 + authorization을 다시 수행한다. +- path와 body에 같은 field가 중복되거나 충돌하면 fail-closed한다. +- unknown query/body field, malformed percent encoding과 parser differential을 + 거절한다. +- decompression ratio, nested depth, repeated/map count와 string/bytes length에 + 별도 ceiling을 둔다. +- error body도 bounded writer와 redaction을 통과한다. + +### 13. Success status, body와 media + +operation은 exact success matrix를 가진다. + +```text +GatewaySuccessProfileV1 + allowedHttpStatuses[] + responseMediaTypes[] + bodyPolicy = REQUIRED | EMPTY | PROTOJSON_MESSAGE + responseHeaderAllowlist[] + responseBodyMessageId | null +``` + +- default transcoded unary success를 임의로 `201` 또는 `204`로 추측하지 않는다. +- `google.protobuf.Empty`의 default `200 {}`와 `204`는 같은 contract가 아니다. +- `201 Created`, `Location`, `204 No Content`가 필요하면 generated OpenAPI, + gateway hook, actual fixture와 rollback을 함께 승인한다. +- upstream metadata로 status를 바꾸는 기능은 registered response type/method와 + allowlisted value만 허용한다. +- caller가 `x-http-code` 같은 internal control metadata를 제출하지 못한다. +- status hook 뒤 body가 이미 write된 상태에서 status를 변경하지 않는다. +- response body rewrite가 OpenAPI에 반영되지 않으면 direct public gateway에서 + 사용하지 않는다. +- HTML proxy error, redirect login page와 unexpected media를 ProtoJSON으로 + 해석하지 않는다. + +현재 curated REST envelope를 direct ProtoJSON으로 조용히 바꾸지 않는다. direct +gateway adoption에는 새 operation/version 또는 증명된 compatibility facade가 +필요하다. + +### 14. Error contract와 redaction + +gateway error profile: + +```text +GatewayErrorProfileV1 + grpcToHttpStatusMapId + publicEnvelope = GOOGLE_RPC_STATUS | CURATED_APP_FAILURE + safeReasonDomainAllowlist[] + safeDetailTypeAllowlist[] + exposeGrpcMessage = false + exposeUnknownDetails = false + maximumErrorBytes + maximumDetails +``` + +- gRPC canonical status와 HTTP status mapping을 exact gateway revision에 pin한다. +- routing `404/405/400`과 upstream application error를 구분한다. +- `Status.message`는 machine branch key가 아니다. +- arbitrary `Status.message`, stack, SQL/vendor copy, internal address와 raw + `Any.details`를 frontend에 노출하지 않는다. +- frontend branch는 registered stable reason/code/category만 사용한다. +- `ErrorInfo`를 사용하면 `(reason, domain)`과 approved metadata key를 versioned + vocabulary로 관리한다. +- authorization detail은 resource 존재 여부나 policy reason을 누출하지 않는다. +- malformed/oversize upstream error는 safe generic provider failure로 닫는다. +- unary custom error handler와 routing error handler가 actual OpenAPI/fixture와 + 일치해야 한다. +- stream error handler는 unary handler와 다르므로 streaming이 선택되기 전 + production guarantee로 표시하지 않는다. + +partial success/error는 기본 거절한다. bulk partial semantics가 필요하면 +long-running operation이나 별도 result contract를 선택한다. + +### 15. Authentication, authorization, CORS와 CSRF + +gateway는 authorization authority를 자동 제공하지 않는다. + +- edge 또는 gateway가 credential을 검증해도 application service가 operation과 + resource authorization을 다시 수행한다. +- bearer profile은 issuer, audience, signature algorithm, time claims, revocation과 + tenant binding을 검증한다. +- cookie profile은 `Secure`, `HttpOnly`, explicit `SameSite`, host/path scope와 + unsafe method CSRF를 함께 승인한다. +- credentialed CORS에 wildcard origin을 사용하지 않는다. +- exact origin/method/header/expose 목록과 bounded preflight cache를 fixture로 + 검증한다. + +grpc-gateway의 incoming `Authorization`은 upstream gRPC metadata로 전달될 수 +있다. 이 동작을 다음처럼 다룬다. + +- public bearer를 upstream service가 검증하는 profile인지 명시 +- edge가 identity를 변환하면 original credential 전달/제거 owner를 별도 설계 +- 외부 caller가 내부 principal, tenant, role, trace와 policy header를 spoof하지 + 못하도록 edge에서 제거 +- incoming/outgoing header matcher는 closed allowlist +- hop-by-hop, cookie, raw credential, internal debug와 arbitrary `grpc-*` + metadata를 forwarding하지 않음 +- auth attach 뒤 final origin/path/method/body digest와 registry binding 재검증 + +current cookie/same-origin이나 curated auth/error/meta 요구가 크면 BFF를 선택한다. + +### 16. Deadline과 cancellation + +```text +browser total deadline + -> edge admission + -> gateway request context + -> upstream gRPC deadline + -> application/downstream deadline propagation +``` + +- gRPC는 deadline이 기본으로 자동 설정된다고 가정하지 않는다. +- frontend total deadline 안에서 edge/gateway/upstream phase가 더 짧은 ceiling을 + 가진다. +- retry/backoff가 total deadline을 늘리지 않는다. +- gateway는 remaining budget을 upstream deadline으로 전달하고 queue/connection + time을 제외하지 않는다. +- browser AbortSignal/navigation/logout와 connection close를 구분된 safe + cancellation reason으로 정규화한다. +- HTTP disconnect가 upstream gRPC cancel과 application work stop으로 이어지는지 + actual provider에서 검증한다. +- server handler와 spawned/downstream work는 cancellation을 주기적으로 확인하고 + 불필요한 계산/I/O를 중단한다. +- deadline/cancel 뒤 timer, request body, response writer와 upstream call을 + 정리한다. + +cancellation은 이미 commit된 effect를 rollback하지 않는다. client와 server의 +success 판단이 다를 수 있으므로 command는 cancellation 결과만으로 effect +없음을 선언하지 않는다. + +### 17. Retry와 idempotency + +semantic replay policy: + +```text +SAFE +IDEMPOTENT +KEYED_COMMAND +NON_REPLAYABLE +``` + +- gateway의 모든 upstream gRPC call이 HTTP POST라는 이유로 retry-safe라 + 간주하지 않는다. +- frontend, CDN/proxy, gateway, gRPC client와 service retry 중 한 owner만 physical + retry를 수행한다. +- automatic retry 기본 대상은 bounded unary safe read다. +- transactional sequence, `ABORTED`, validation, auth와 non-replayable command를 + transport가 자동 retry하지 않는다. +- response header/body가 시작된 뒤 retry하지 않는다. +- attempt count, backoff, sleep, elapsed와 total deadline ceiling을 둔다. + +idempotency transport는 operation마다 하나를 선택한다. + +```text +HTTP_IDEMPOTENCY_KEY +PROTO_REQUEST_ID +NONE +``` + +- 현재 curated REST의 `Idempotency-Key`를 direct gateway가 사용하면 exact + incoming metadata mapping과 upstream binding을 정의한다. +- protobuf `request_id` field를 사용하면 HttpRule body/query 위치, UUID format, + payload fingerprint와 replay window를 정의한다. +- 두 identity를 동시에 받아 어느 값을 authority로 쓸지 추측하지 않는다. +- gateway process-local map/lock을 idempotency store로 사용하지 않는다. +- principal/tenant + semantic operation/version + key + canonical request + fingerprint를 durable store에 binding한다. +- same key/same fingerprint replay는 authoritative prior result/receipt를 + 반환한다. +- same key/different fingerprint는 stable conflict다. +- commit 뒤 response loss는 새 effect를 만들지 않는다. +- `EFFECT_UNKNOWN`은 새 key나 다른 protocol retry가 아니라 status/reconcile + endpoint로 확인한다. +- retention은 frontend recovery window보다 길고 quota/abuse policy를 가진다. + +### 18. Pagination + +HttpRule/gateway는 protobuf field를 HTTP query/body로 옮길 뿐 pagination 의미를 +구현하지 않는다. + +paginated list는 첫 public version부터 다음을 포함한다. + +```text +ListRequest + pageSize + pageToken + +ListResponse + items[] + nextPageToken +``` + +application/backend 책임: + +- finite default와 maximum page size +- negative size rejection과 oversized coercion/approved policy +- opaque URL-safe page token +- principal/tenant/filter/sort/contract/snapshot binding +- token integrity, expiry와 key rotation +- stable total ordering과 immutable unique tie-breaker +- insertion/deletion 중 snapshot or documented consistency +- duplicate/gap/loop 방지 +- next token empty만 end-of-collection 의미 +- page token이 authorization을 대체하지 않음 + +unpaginated RPC에 field만 추가하고 default response를 일부로 줄이는 변경은 +behavioral breaking이다. 현재 reference array response를 page object로 바꾸려면 +새 operation/schema와 N/N-1 migration을 사용한다. + +frontend는 arbitrary next URL을 따라가지 않고 opaque token만 registered query +input에 투영한다. raw token을 log, analytics와 persistent cache에 넣지 않는다. + +### 19. ETag, revision과 HTTP conditional + +다음 두 profile을 구분한다. + +```text +PROTO_RESOURCE_ETAG +HTTP_REPRESENTATION_CONDITIONAL +``` + +`PROTO_RESOURCE_ETAG`: + +- resource/request message의 `string etag` field +- server-owned output +- strong/weak meaning 명시 +- mismatch는 selected gRPC status, 보통 `ABORTED` +- ProtoJSON body/query field로 왕복 + +`HTTP_REPRESENTATION_CONDITIONAL`: + +- HTTP response `ETag` +- request `If-None-Match` 또는 `If-Match` +- unchanged read의 `304` empty body +- failed write precondition의 approved `412 | 409` +- exact principal/tenant-visible representation과 encoding variant binding +- `Cache-Control`, `Vary`, CORS expose/preflight와 frontend cache transaction + +proto `etag` field가 존재한다고 gateway가 HTTP `ETag`, `304` 또는 `If-Match`를 +자동 제공한다고 간주하지 않는다. body ETag를 HTTP validator로 투영하려면 +allowlisted response header hook, request header mapping, status conversion, +generated/curated OpenAPI와 actual cache fixture가 필요하다. + +현재 REST reference의 TanStack/application conditional transaction은 +`HTTP_REPRESENTATION_CONDITIONAL`이다. direct gateway가 이 의미를 정확히 +구현하지 않으면 curated BFF를 유지한다. + +frontend는 validator와 mapped cached value의 scope/query identity/representation +version/cache revision이 모두 일치할 때만 304를 success로 처리한다. raw validator를 +log, diagnostics와 metric label에 넣지 않는다. + +### 20. Cache와 server state + +gateway adoption이 frontend query identity를 바꾸지 않게 semantic operation +input을 canonical source로 유지한다. + +- generated path/query serializer와 query-key codec가 같은 validated application + input에서 파생 +- ProtoJSON DTO와 generated message를 TanStack Query cache에 저장하지 않음 +- schema/semantic validation과 mapper가 끝난 immutable application projection만 + 저장 +- auth/session/tenant generation이 cache scope를 소유 +- late response와 retry result는 generation fence 뒤 cache에 들어가지 않음 +- protocol migration 중 BFF와 gateway 결과를 같은 key에 섞지 않음 +- pagination token과 ETag는 domain resource data와 별도 bounded sidecar owner +- mutation commit 뒤 exact invalidation/reconcile owner 필요 + +### 21. Observability, privacy와 abuse control + +허용 dimension: + +```text +semantic operation ID +gateway/provider profile ID +proto contract artifact version +route manifest version +ProtoJSON profile ID +status group / safe error code +attempt bucket +duration and size bucket +admission/cancellation stage +``` + +금지: + +- raw Authorization/cookie/CSRF/idempotency key +- raw path/query/body와 protobuf message +- cursor/page token/ETag/revision +- `Status.message`, unknown details와 validation value +- principal, tenant, resource ID와 IP의 unbounded label +- descriptor/proto source URL credential + +gateway는 operation별 request rate, concurrent upstream call, body/response byte, +CPU-heavy JSON decode, error volume와 upstream saturation ceiling을 가진다. + +최소 metric: + +- route admission/rejection +- ProtoJSON malformed/unknown/oversize +- descriptor/route/OpenAPI artifact mismatch +- gRPC→HTTP status/error category +- deadline/cancel propagation stage +- retry attempts/amplification +- idempotency replay/conflict/unknown effect +- page token invalid/expired +- conditional hit/miss/precondition failure +- provider/browser conformance version + +### 22. Provider conformance와 promotion evidence + +#### Deterministic + +- Buf format/lint/breaking +- exact production baseline +- source/dependency/image/descriptor/toolchain/generated digest +- clean regenerate diff 0 +- gencode/runtime compatibility +- generated import boundary와 SBOM +- HttpRule custom-option semantic diff + +#### Contract + +- descriptor N/N-1 binary fixture +- ProtoJSON scalar/presence/null/unknown/enum/int64/bytes/time matrix +- method/path/body/query/additional binding +- path percent-encoding and `AllExceptReserved` behavior +- generated OpenAPI vs actual request/response +- exact success/media/status/body +- safe unary/routing error mapping +- auth/header allowlist +- idempotency/pagination/conditional fixtures + +#### Integration/browser + +- actual gateway version + actual gRPC backend +- Chromium/Firefox/WebKit fetch +- CORS/preflight, bearer 또는 cookie+CSRF +- reverse proxy/CDN/WAF body and header ceiling +- AbortSignal/disconnect/deadline propagation +- navigation/logout/account switch +- HTTP/1.1, HTTP/2와 intermediary error behavior + +#### Fault/security + +- malformed/oversize/truncated JSON +- unknown/duplicate field와 parser differential +- route collision, encoded slash와 path traversal +- spoofed identity/internal control header +- upstream unavailable/deadline/status mismatch +- error detail/stack/PII redaction +- response started 뒤 failure/retry +- commit 뒤 response loss와 idempotency reconciliation +- invalid/expired/wrong-scope page token +- body ETag와 HTTP conditional 혼동 방지 + +#### Performance/operations + +- bounded JSON transcoding CPU/memory +- maximum request/response와 concurrent load +- slow client/upstream and queue saturation +- canary, kill switch와 traffic admission +- coherent proto/gateway/OpenAPI/frontend/backend rollback +- generated/proxy/dependency removal drill + +fake gateway, generated compile과 OpenAPI 생성 success만으로 actual provider +conformance를 완료 처리하지 않는다. + +### 23. Rollout + +1. backend/product owner가 direct gateway가 필요한 operation과 BFF 대비 이점을 + 승인한다. +2. authenticated proto source, exact production baseline과 artifact owner를 + 확정한다. +3. Buf/codegen toolchain, breaking category와 HttpRule source를 pin한다. +4. descriptor, route manifest, ProtoJSON profile와 OpenAPI artifact를 생성한다. +5. provider/gateway version, auth/error/status/deadline ceiling을 승인한다. +6. unary safe read 한 개로 reference conformance harness를 만든다. +7. frontend adapter를 `AVAILABLE_NOT_COMPOSED`로 판정한다. +8. product composition 뒤 `TrafficAdmission=DISABLED`로 배포한다. +9. internal/staging shadow read는 cache/UI에 반영하지 않고 BFF result와 semantic + diff를 관찰한다. +10. actual browser canary를 낮은 비율로 시작한다. +11. error/latency/schema/cache SLO와 artifact digest가 정상일 때 단계적으로 + 확대한다. +12. keyed command는 durable dedupe/reconcile fault evidence 뒤 별도 canary한다. +13. server stream은 계속 `NOT_SELECTED`다. + +한 release에서 proto writer, gateway route, error envelope와 frontend consumer를 +동시에 breaking 전환하지 않는다. additive reader-first/writer-later와 N/N-1 +window를 사용한다. + +### 24. Rollback과 removal + +rollback: + +- 신규 gateway operation traffic admission 중지 +- in-flight safe read cancel +- command는 cancel 뒤 authoritative receipt/status로 effect reconcile +- gateway-scoped query/validator/pagination sidecar clear 또는 invalidate +- last-known-good proto/descriptor/route/OpenAPI/gateway/backend/frontend artifact를 + coherent하게 복구 +- approved BFF read fallback은 새 logical request와 독립 total deadline으로만 + 실행 +- command를 BFF나 gRPC-Web로 자동 replay하지 않음 + +removal: + +1. operation traffic과 route retirement window 시작 +2. N/N-1 frontend/backend/gateway 소비자를 확인 +3. route manifest와 traffic admission에서 제거 +4. frontend operation/schema/mapper/cache profile 제거 +5. generated gateway/OpenAPI/message output 제거 +6. gateway route, proxy/CORS/auth/config 제거 +7. plugin/runtime/package와 SBOM entry 제거 +8. proto RPC는 다른 소비자가 없고 deprecation window가 끝난 뒤 제거 +9. message field/enum number와 name은 제거 시 reserve +10. descriptor/baseline artifact는 audit/retention 정책에 따라 보존 +11. build, bundle, dependency, provider probe와 removal drill 통과 + +### 25. Backend/provider handoff checklist + +- [ ] proto source/module owner와 immutable production baseline이 있다. +- [ ] `buf.lock`, Buf image, native descriptor와 digest가 release에 binding됐다. +- [ ] Buf/protoc/plugin version/revision/options/runtime이 pin됐다. +- [ ] `FILE` 또는 승인된 최소 `WIRE_JSON` breaking gate가 있다. +- [ ] HttpRule custom-option와 generated OpenAPI semantic diff gate가 있다. +- [ ] ProtoJSON profile과 N/N-1 fixture가 있다. +- [ ] direct gateway와 curated BFF 중 operation별 public owner가 하나다. +- [ ] exact unary service/method/route/status/media/error가 allowlist됐다. +- [ ] bearer 또는 cookie+CSRF/CORS provider evidence가 있다. +- [ ] incoming/outgoing header와 internal identity spoofing 방지가 검증됐다. +- [ ] edge/gateway/upstream byte/rate/depth/count ceiling이 있다. +- [ ] total deadline, browser abort와 downstream cancellation evidence가 있다. +- [ ] retry owner가 하나이고 command idempotency/reconcile가 durable하다. +- [ ] pagination을 선택했다면 token/snapshot/authorization contract가 있다. +- [ ] conditional을 선택했다면 body ETag와 HTTP validator profile이 분리됐다. +- [ ] generated OpenAPI와 actual gateway wire fixture가 일치한다. +- [ ] actual browser/gateway/backend canary, kill switch와 rollback drill이 통과했다. +- [ ] generated code, proxy route와 dependency removal drill이 통과했다. + +## 규범 기준 + +- [Protocol Buffers ProtoJSON format](https://protobuf.dev/programming-guides/json/) +- [Protocol Buffers proto3 language guide](https://protobuf.dev/programming-guides/proto3/) +- [Protocol Buffers cross-version runtime guarantee](https://protobuf.dev/support/cross-version-runtime-guarantee/) +- [Buf breaking-change detection](https://buf.build/docs/breaking/) +- [Buf breaking rules and categories](https://buf.build/docs/breaking/rules/) +- [Buf code generation](https://buf.build/docs/generate/) +- [Buf `buf.gen.yaml` v2](https://buf.build/docs/configuration/v2/buf-gen-yaml/) +- [Buf dependency management](https://buf.build/docs/bsr/module/dependency-management/) +- [Buf FileDescriptorSet](https://buf.build/docs/bsr/module/descriptor/) +- [Google API HTTP and gRPC transcoding](https://google.aip.dev/127) +- [Google API errors](https://google.aip.dev/193) +- [Google API request identification](https://google.aip.dev/155) +- [Google API pagination](https://google.aip.dev/158) +- [Google API resource freshness validation](https://google.aip.dev/154) +- [Google API `HttpRule`](https://docs.cloud.google.com/endpoints/docs/grpc-service-config/reference/rpc/google.api) +- [gRPC-Gateway introduction](https://grpc-ecosystem.github.io/grpc-gateway/docs/tutorials/introduction/) +- [gRPC-Gateway FAQ](https://grpc-ecosystem.github.io/grpc-gateway/docs/faq/) +- [gRPC-Gateway customization](https://grpc-ecosystem.github.io/grpc-gateway/docs/mapping/customizing_your_gateway/) +- [gRPC-Gateway PATCH](https://grpc-ecosystem.github.io/grpc-gateway/docs/mapping/patch_feature/) +- [gRPC-Gateway HttpBody](https://grpc-ecosystem.github.io/grpc-gateway/docs/mapping/httpbody_messages/) +- [gRPC-Gateway OpenAPI v3 status](https://grpc-ecosystem.github.io/grpc-gateway/docs/mapping/openapi_v3/) +- [gRPC deadlines](https://grpc.io/docs/guides/deadlines/) +- [gRPC cancellation](https://grpc.io/docs/guides/cancellation/) +- [gRPC status codes](https://grpc.io/docs/guides/status-codes/) + +## 완료 기준 + +- Protobuf source/dependency/descriptor/toolchain/generated artifact의 provenance와 + digest가 production baseline에 binding된다. +- Buf binary/source compatibility와 HttpRule/OpenAPI semantic compatibility를 + 별도 gate로 검증한다. +- ProtoJSON field/int64/bytes/enum/null/presence/unknown 의미가 exact profile로 + 닫힌다. +- direct REST Gateway와 gRPC-Web/curated BFF가 서로 다른 protocol artifact와 + state machine을 가진다. +- 현재 installed REST reference는 별도 product 승인 전 curated BFF를 유지한다. +- direct gateway operation은 unary, fixed route/service/method와 bounded + request/response에만 compose된다. +- status/body/error/auth/header/deadline/cancel/retry가 actual provider fixture와 + 일치한다. +- gateway가 idempotency, pagination, revision과 authorization authority를 + 대신한다고 표시하지 않는다. +- body ETag와 HTTP conditional semantics를 혼동하지 않는다. +- generated OpenAPI와 actual wire contract drift가 promotion을 차단한다. +- actual browser/gateway/backend conformance, canary, kill switch, coherent rollback과 + removal drill이 통과한다. +- schema/codegen implementation 전에는 `DESIGNED_NOT_IMPLEMENTED`, product 선택 + 전에는 REST Gateway를 `NOT_SELECTED`로 유지한다. diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md new file mode 100644 index 0000000..4df124e --- /dev/null +++ b/docs/architecture/frontend-platform-capability-review.md @@ -0,0 +1,532 @@ +# 프론트엔드 플랫폼 역량 재검토 + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +## 1. 문서 목적 + +이 문서는 도메인 기능과 실제 운영 환경의 배포 증적을 제외하고, 이 저장소가 새 +프론트엔드 제품의 출발점으로 제공해야 하는 공통 역량을 다시 평가한다. 평가 +기준은 다음과 같다. + +- 코드나 설정 파일이 존재하는지만 보지 않는다. +- 부트스트랩부터 화면까지 실제 호출 경로가 연결되는지 확인한다. +- 선언한 레지스트리와 정책이 런타임 및 CI에서 집행되는지 확인한다. +- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다. +- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다. + +최초 검토 기준은 `develop`의 `cb195f8`이며, RP-01~RP-12 구현 결과를 이 문서에 +누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은 +문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다. + +## 2. 결론 + +현재 저장소는 다음 기반이 강하다. + +- 런타임 설정과 릴리스 매니페스트 검증 +- 도메인, 애플리케이션, 프레젠테이션, outbound adapter의 의존 방향 +- 공통 HTTP 실패 형태와 제한된 retry 정책 +- 앱 셸, 반응형 내비게이션, 테마, 비동기 상태 표면 +- Vitest, Testing Library, MSW, Playwright, axe를 이용한 테스트 계층 +- CI 게이트 taxonomy와 호환성·보안·성능·릴리스 계약 문서 + +RP-01~RP-12에서 TypeScript 도구 안전망, application runtime 주입, +query/mutation inbound adapter, HTTP 실행 계약과 executable route/release +recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과 +i18n 실행 경계, diagnostics/telemetry production wiring, registry/test 증거, +local 공급망 검증과 제거 가능한 optional adapter recipe가 구현됐다. 저장소 내부 +P0/P1 acceptance와 P2 recipe 기본값은 `LOCAL_TEMPLATE_READY`다. 다만 실제 제품 +도메인과 hosting, IdP, vulnerability/signing provider, analytics consent/provider, +지원 browser/접근성·field 증거는 프로젝트가 선택하고 검증해야 한다. + +따라서 더 정확한 표현은 다음과 같다. + +> application API, 서버 상태, 폼, 라우팅, 페이지, 디자인 시스템, 테스트와 +> local 공급망 증적의 표준 수직 경로와 opt-in adapter recipe는 갖춰졌다. +> 실제 capability 설치와 hosting·IdP·취약점/서명/운영 provider는 프로젝트 +> 통합 범위이며, 없는 외부 증거를 완료로 표시하지 않는다. + +## 3. 판정 기준 + +| 판정 | 의미 | +| --- | --- | +| 준비됨 | 구현, 실제 조립, 자동 검증이 모두 존재한다. | +| 부분 준비 | 핵심 구현은 있으나 실제 호출 경로, 정책 집행, 예제가 불완전하다. | +| 미제공 | 새 기능을 만들 때 팀이 직접 선택·설계해야 한다. | +| 프로젝트 선택 | 기본 번들에 강제하면 비용이 더 크며, 경계와 recipe만 제공한다. | + +## 4. 역량 매트릭스 + +| 영역 | 현재 판정 | 근거 | 필요한 다음 상태 | +| --- | --- | --- | --- | +| 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 | +| 계층 의존 방향 | 준비됨 | dependency-cruiser + TS-aware static graph, unresolved/parse/layer/cycle negative fixture | 새 rule shape와 source extension도 같은 fail-closed graph에 추가 | +| application facade | 준비됨 | module-augmented feature input registry, typed output catalog, provider, production composition test | feature별 input contribution과 제거 gate 유지 | +| REST HTTP client | 준비됨 + hardening delta | reference vertical의 path/search/body projection, attempt timeout/retry, abort/cleanup은 `COMPOSED` | auth fail-close/final invariant, total deadline, bounded decoder, status/media/CSRF/conditional/pagination은 `DESIGNED_NOT_IMPLEMENTED` | +| Browser RPC 공통 계약/runtime | 준비됨/미조립 | V3 operation/profile registry, typed application port, bounded unary/server-stream lifecycle와 unavailable adapter는 `AVAILABLE_NOT_COMPOSED` | actual descriptor/generated client, protocol transport, proxy/provider/browser conformance | +| GraphQL·Connect·gRPC-Web·Protobuf REST Gateway 제품 adapter | 설계됨/제품 미선택 | wire dependency/generated source/provider는 없고 VD-26/VD-27/VD-29/VD-30 production contract 승인 | 제품 operation/provider 선택 전 `NOT_SELECTED`; 선택 branch wire adapter 구현 뒤에만 `AVAILABLE_NOT_COMPOSED` | +| retry | 준비됨 + hardening delta | REST 단일 소유, runtime max attempts, Query retry off, logical terminal diagnostics | total elapsed/sleep, 401 single-flight와 protocol별 exact retry/effect certainty 구현 | +| 오류 모델 | 준비됨 | registry-derived `AppFailure`, 공통 `Result`, HTTP normalization과 invalid-kind fixture | 새 failure kind는 registry·copy·telemetry 계약과 함께 추가 | +| Schema·Mapper | reference 준비됨 + governance delta | reference Zod → mapper → domain/application path는 `COMPOSED` | typed codec/mapper proof, semantic fingerprint/provenance, bounded decode와 generated drift gate는 `DESIGNED_NOT_IMPLEMENTED` | +| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | +| 서버 상태 | 기본 경로 준비됨 + lifecycle delta | reference query/mutation, cancellation, stale, basic optimistic/conflict/rollback은 `COMPOSED` | strict bound query policy/key, result/page ceiling, mutation concurrency/CAS rollback은 `DESIGNED_NOT_IMPLEMENTED` | +| 클라이언트 상태 | 준비됨/프로젝트 선택 | local/URL/query/context 소유권, session external store, typed workflow recipe | 실제 cross-page workflow가 생길 때 하나의 store 선택 | +| 범용 global store | 프로젝트 선택 | runtime library 없음, typed facade/fake와 server-state duplication gate | VD-10 조건에 따라 Zustand/Redux Toolkit/state machine 중 하나 선택 | +| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, 분리된 route-input provider, metadata consumer, bounded chunk recovery | 새 lazy route도 router 역참조 없이 contribution으로 추가 | +| 앱 셸·반응형 | 준비됨 | native modal Drawer, compact/desktop layout, Escape/link dismiss/focus restore, pseudo reflow와 RTL direction | compact browser matrix 유지 | +| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 | +| 디자인 토큰 | 준비됨 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 | +| 공통 UI | 준비됨 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | public story와 visual state matrix 유지 | +| 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 | +| 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 | +| 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 | +| logging/diagnostics | 준비됨 | 별도 `DiagnosticsPort`, 8-event registry, allowlist, bounded/no-op adapter와 production producer | 실제 프로젝트의 remote sink는 port 뒤에서 선택 | +| telemetry | 준비됨/프로젝트 선택 | 5-event registry, bounded queue, redaction/value policy, boot·HTTP·render·release·drop producer | analytics/RUM/error vendor와 consent는 프로젝트에서 선택 | +| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 | +| 단위·통합·E2E | 준비됨 | source/test strict typecheck, shared MSW 19개 scenario, 실제 bootstrap, built-dist 3엔진·compact E2E | 제품별 critical flow를 같은 catalog/gate에 추가 | +| UI 회귀 검증 | 준비됨 | dev-only Storybook interaction/axe와 pinned Chromium visual baseline 4종 | cloud review와 다중 OS/device는 프로젝트 선택 | +| 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 9단계 검증 | 새 contribution도 같은 제거 gate에 포함 | +| registry·compatibility 집행 | 준비됨 | 10개 registry type/reference/consumer/orphan, 승인 digest와 actual semantic diff, breaking evidence | public 계약 변경 시 baseline review 유지 | +| 공급망 검사 | 준비됨/프로젝트 선택 | 561개 transitive inventory/integrity/license, actual diff, CycloneDX, local provenance, secret/reproducible build gate | 실제 vulnerability scanner와 signed attestation 없이는 promotion `FAIL_UNVERIFIED` | +| realtime delivery | 참조 런타임 준비됨/프로젝트 미선택 | RT-01~04 공통 event authority, fetch-stream SSE, closed WebSocket, bounded Polling, Web Push window/worker와 handoff/reconnect 조정자는 `AVAILABLE_NOT_COMPOSED`; backend/provider/browser evidence는 없음 | [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)의 RT-05 제품 조립·운영 증거를 capability별로 통과 | +| offline·file·browser data | 부분 준비/프로젝트 선택 | opt-in recipe와 gate; capability별 구현·조합·미구현·미선택·platform 제한 상태가 서로 다름 | [Browser data capability completion ledger](./browser-data-capability-completion-ledger.md)의 상태와 work package를 통과한 capability만 설치 | + +REST/GraphQL/Connect/gRPC-Web/REST Gateway, Schema/Mapper와 Server State의 상세 current/target 판정은 +[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)를 +따른다. Browser Protobuf 축의 선택 기준은 +[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)를 +따른다. 현재 REST reference 경로의 `COMPOSED` 판정을 GraphQL/Connect/gRPC-Web/ +Gateway 또는 REST v2 hardening 완료 증거로 재사용하지 않는다. + +## 5. 우선순위별 발견 사항 + +### 5.1 P0: 기능 개발을 막는 항목 + +#### RP-02에서 application 런타임 우회 해결 + +`src/bootstrap/composition-root.ts`가 만든 typed application input API는 +production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와 +release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime +query만 사용한다. + +목표 상태: + +- `Application`은 UI가 호출할 query/command use case를 제공한다. +- `ApplicationProvider`는 이 API만 React tree에 제공한다. +- 페이지는 HTTP, storage, auth SDK, telemetry sink를 직접 호출하지 않는다. +- bootstrap만 concrete outbound adapter를 알고 조합한다. +- 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다. + +#### RP-03에서 표준 서버 상태 bridge 구현 + +`src/presentation/adapters/query` 한 경계만 `@tanstack/**`를 import한다. +`useApplicationQuery`와 `useApplicationMutation`은 application result를 React +lifecycle에 연결하며 cancellation, stale failure, duplicate submit, optimistic +rollback, conflict resolution과 invalidation을 검증한다. 다른 presentation +경로의 직접 TanStack import는 negative fixture가 거절한다. + +목표 상태: + +- canonical target인 `src/adapters/inbound/react/platform/query`에 벤더 연동을 + 한정한다. 마이그레이션 중에는 기존 `presentation`을 같은 inbound 경계로 + 취급하되 새 대체 경로를 만들지 않는다. +- `useApplicationQuery`, `useApplicationMutation` 또는 같은 역할의 typed + controller hook을 제공한다. +- HTTP retry와 query retry 중 한 계층만 재시도 책임을 갖는다. +- loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을 + reference feature에서 보여 준다. + +#### RP-01에서 TypeScript 검사 도구 안전망 구현 + +초기 source는 JS/JSX와 `strict + allowJs + checkJs`를 사용했다. RP-01 후속 +migration으로 현재 product source, tests, Node scripts와 지원 config는 모두 +TS/TSX이며 `allowJs`는 꺼져 있다. + +- ESLint의 계층·보안 규칙은 product와 negative fixture의 TS/TSX를 함께 검사한다. +- registry scanner와 governance/baseline은 `.ts`와 `.tsx` 경로를 사용한다. +- app, Node scripts/config, tests는 분리된 strict project로 모두 검사된다. +- architecture gate는 fixture를 포함한 실행 source의 `.js/.jsx/.mjs/.cjs` + 재유입을 거절한다. + +전환은 tooling glob과 CI를 먼저 고친 뒤 계약 계층부터 화면·테스트·운영 script +순서로 완료했다. Node 24가 운영 `.ts` script를 직접 실행하고 NodeNext strict +typecheck가 같은 경로를 검증한다. + +#### RP-03에서 HTTP 선언과 실행의 차이 해결 + +HTTP request builder는 path segment escaping, canonical optional/array search, +Zod default/trim 결과의 실제 query/body 전송을 담당한다. runtime timeout과 +0/1/N max retry가 client factory에 주입되고 caller abort와 timeout을 다른 typed +failure로 투영한다. validation 조기 반환은 fetch/timer 0회이며 success, schema +failure, abort, timeout과 exhausted retry는 scheduler/listener cleanup을 +검증한다. HTTP 사건의 semantic telemetry 연결은 RP-09 범위다. + +client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth, +timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP +메서드보다 feature가 요구하는 gateway interface를 노출한다. + +#### RP-04에서 route registry를 실행 계약으로 전환 + +platform route 계약과 `src/features/installed-feature-contracts.ts`의 직렬화 +가능한 contribution을 기준으로 +`src/presentation/routes/app-router.tsx`가 Data Router route object와 +navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만 +소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry +gate가 모두 거절한다. + +현재 보장: + +- serializable contract와 executable runtime map을 분리한다. +- `satisfies Record`로 양방향 완전성을 검사한다. +- params/search는 Zod codec으로 경계에서 parse하고 URL builder도 같은 codec을 + 사용한다. +- loading/error/chunk/access/title/navigation metadata를 실제 route object에 + 연결한다. +- route change 시 boundary reset, title, focus와 scroll을 검증한다. +- Vite manifest의 실제 dynamic entry와 route chunk ID를 release manifest에 + 연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를 + production application input까지 연결한다. + +#### RP-05에서 제거 가능한 reference feature 구현 + +`src/features/reference-feature`가 domain, application input, outbound gateway, +DTO/schema, mapper, route/API/query contract, query/mutation controller와 page를 +한 소유 경계에 둔다. production composition은 generic feature input catalog를 +통해 이 input을 주입하며 UI는 HTTP나 output port를 직접 보지 않는다. + +`test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed +contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck, +architecture, registry, unit/integration, coverage, source evidence, home smoke, +build와 fixture ID 잔여 0개를 검사한다. feature-owned coverage/evidence policy도 +함께 제거되며 generic registry 성공 경로는 공통 unit test가 유지한다. 설치 +모드에서는 MSW를 사용한 bootstrap → router → application → HTTP → schema → +mapper → query cache → page 수직 테스트가 실행된다. + +#### 비동기·복구 상태의 불변식이 닫혀 있지 않다 + +공통 async model과 gallery가 있지만 선언 가능한 상태 조합 중 일부는 사용자 +행동과 모순될 수 있다. stale data가 있는 degraded 상태와 refreshing, mutation +pending과 conflict, retry button과 실제 handler 존재 여부를 typed state로 +닫아야 한다. 상태를 boolean 여러 개로 조합하지 않고 다음과 같은 discriminated +state와 action capability로 표현한다. + +```text +initial-loading +ready +refreshing-with-data +empty +degraded-with-data +terminal-error +mutation-pending +mutation-conflict +``` + +RP-04에서 lazy import failure는 `ChunkRecoveryBoundary` → application recovery +input → `ReleaseInfoPort.refresh()`의 no-store manifest 조회 → build/release 쌍 +guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 render +failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage +실패는 지원 표면으로 fail-closed된다. + +#### RP-09에서 diagnostics/telemetry 실행 깊이 보강 + +`DiagnosticsPort`와 `TelemetryPort`를 분리하고 boot, HTTP logical outcome, +render, cache, storage, route, release mismatch와 delivery drop을 production +producer에 연결했다. HTTP retry는 attempt별 terminal event를 발행하지 않고 +logical execution 종료 시 한 번만 bounded outcome을 남긴다. allowlist와 +value policy가 raw URL/query/body/storage value/error object를 거절하고 queue와 +sink failure는 nonrecursive drop evidence로 제한된다. + +registry/compatibility 검사는 다음까지 확장한다. + +- ID와 enum/type의 양방향 완전성 +- referenced schema/message/token의 존재 +- 실행 코드에서 소비되지 않는 orphan 항목 +- 기준 commit과 현재 commit 사이의 실제 contract diff +- breaking change의 version/migration/rollback metadata + +공급망 검사는 단순 문자열 secret 탐지에 머물지 않고 transitive dependency, +known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해야 한다. +도구 장애와 취약점 발견을 구분하고, 예외에는 owner·사유·만료일을 요구한다. + +### 5.2 P1: 공통 플랫폼 기본 제공 항목 + +- RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지 +- RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리 +- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive +- token → primitive → pattern → template로 이어지는 디자인 시스템 +- Lucide를 감싼 local icon registry와 `IconButton` +- typed message key, locale provider, formatter, pseudo-locale/RTL smoke +- RP-09에서 완료한 redacted structured diagnostics와 telemetry wiring 유지 +- Storybook 또는 동급 isolated UI workshop +- Playwright visual baseline, shared MSW scenarios, built-dist E2E +- React Hooks, JSX accessibility, TanStack Query 관련 lint +- source와 tests를 모두 포함하는 TypeScript project references +- registry/compatibility의 실제 diff와 orphan reference 검사 +- transitive vulnerability, license, SBOM/provenance 공급망 gate + +#### RP-08에서 국제화 실행 경계 구현 + +`src/presentation/i18n`은 shell, route, async/form error, page template와 +design-system 기본 copy의 canonical 경계다. `MessageKey`와 key별 +`MessageParameters`가 잘못된 key/보간을 compile time에 막고, runtime +`resolveMessage`는 unknown locale/key와 누락 보간에서 raw 값 대신 안전한 +fallback을 반환한다. application mapper는 locale-formatted date를 반환하지 +않고 timestamp를 유지하며 presentation formatter가 `UTC` 또는 명시 timezone을 +적용한다. + +`LocaleProvider`는 `ko-KR`, `en-US`, `en-XA`, `ar-EG` smoke set과 document +`lang/dir`을 동기화한다. `en-XA`는 긴 문구 reflow, `ar-EG`는 logical CSS, +Drawer, Tabs arrow와 pagination 방향 icon을 검증하기 위한 개발 locale이다. +실제 아랍어 번역 완료를 뜻하지 않는다. `check:i18n`과 negative fixture는 common +UI literal, backend raw message render와 raw HTML interpolation을 거절한다. +새 key rename은 canonical type에는 넣지 않고 runtime alias/migration window로 +호환한다. + +### 5.3 P2: 경계와 recipe를 제공할 선택 항목 + +다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local +vendor facade, 선택 조건, 실패 정책, 테스트 fixture를 문서로 제공한다. + +| capability | 대표 기술 | 기본 제공할 경계 | 실제 설치 조건 | +| --- | --- | --- | --- | +| foreground realtime | SSE, WebSocket | closed stream/event registry, resume/gap/snapshot, bounded queue와 lifecycle | 측정된 one-way event 또는 duplex interaction 요구와 backend replay/auth owner가 있을 때 | +| background notification | Web Push, persistent notification | permission, subscription register/revoke, Service Worker push/click와 safe route | user-visible background notification과 provider/privacy owner가 승인됐을 때 | +| bounded polling | conditional HTTP query | visible-only single-flight lease, request/time budget와 terminal stop | relaxed freshness 또는 의미가 보존되는 stream fallback이면 충분할 때 | +| structured offline storage | IndexedDB | feature repository, codec/schema 분리, resumable migration, revision/idempotency, blocked/quota recovery | offline record/command가 제품 요구일 때 | +| large local binary | OPFS + IndexedDB journal | immutable chunk, generation/integrity, crash reconciliation, bounded GC | 실제 large local object와 retention owner가 있을 때 | +| public HTTP representation cache | Cache Storage, Service Worker | auth/private 배제, exact match, candidate integrity, update/rollback | install/offline shell 또는 승인된 public cache가 필요할 때 | +| file selection/transfer/delivery | native input, File/Blob, picker, multipart, stream save | opaque file ref, bounded inspection, upload session, handoff/save 구분, object-URL lease | backend 재검증을 포함한 file workflow가 있을 때 | +| generated API | OpenAPI, GraphQL, Connect/gRPC-Web, Protobuf | generated client를 semantic gateway 뒤에 감싸고 transport/gateway 축을 분리하는 규칙 | 서버 계약 형식과 selected operation/provider가 확정됐을 때 | +| feature flag | local/remote flag provider | typed flag key, default, stale behavior | staged rollout가 필요할 때 | +| worker | Web Worker | request/result/cancel protocol | UI thread를 막는 CPU 작업이 있을 때 | +| multi-tab | BroadcastChannel | event versioning, source ID, conflict policy | 탭 간 동기화가 필요할 때 | +| browser capability | clipboard, notification, media | permission/result port | 해당 UX가 있을 때 | +| client workflow | Zustand, Redux Toolkit, state machine | state ownership decision과 local facade | cross-feature workflow가 실제로 생길 때 | +| large data UI | virtualization, data grid | owned component facade | 데이터 규모가 측정 기준을 넘을 때 | +| analytics/error sink | vendor SDK, OpenTelemetry | redaction, consent, sampling adapter | 운영 provider와 정책이 정해졌을 때 | + +catalog의 12개 항목 수는 유지하며 OPFS는 offline recipe의 large-object +sub-capability, Cache Storage는 Service Worker/PWA recipe의 독립 cache policy +sub-capability로 깊이를 보강했다. 현재 상태는 모두 +제품 선택 기준으로 `RECIPE_AVAILABLE / NOT_INSTALLED`다. 다만 File/Blob/picker/ +download, IndexedDB/OPFS/StorageManager, Cache Storage에는 실제 native API를 +호출하는 정책 주입형 reference runtime이 `AVAILABLE_NOT_COMPOSED` 상태로 있으며, +bootstrap과 installed feature에서는 import하지 않는다. +`config/recipes/frontend-capability-recipes.json`이 선택/금지 조건, failure, +cleanup, security/privacy, bundle budget, fallback과 제거 절차의 SSOT이며, +`recipes/frontend-capabilities`에 production-excluded TypeScript port와 +fake/unavailable adapter가 있다. file/IndexedDB/OPFS/Cache의 상세한 기술 소유권, +journal, migration, cache activation, test와 운영 복구는 +`docs/architecture/browser-file-and-origin-storage.md`와 VD-11을 따른다. +presigned capability, multipart/resume, bounded streaming과 Image CDN은 +`docs/architecture/presigned-transfer-and-image-cdn.md`와 VD-12를 따르며, 공통 +도입 절차는 `docs/architecture/optional-adapter-recipes.md`를 따른다. +SSE, WebSocket, Web Push와 bounded polling은 +`docs/architecture/realtime-events-web-push-and-bounded-polling.md`와 VD-28을 +따른다. copyable recipe/fake와 별개로 RT-01~04 reusable source와 deterministic +gate는 `AVAILABLE_NOT_COMPOSED`다. foreground transport, 제품 event registry와 +Web Push provider selection은 여전히 `NOT_SELECTED`이며 production bootstrap과 +worker에는 조립하지 않는다. + +이 capability들을 한꺼번에 "준비됨"으로 표시하지 않는다. +[Browser data capability completion ledger](./browser-data-capability-completion-ledger.md)가 +`COMPOSED`, `AVAILABLE_NOT_COMPOSED`, `DESIGNED_NOT_IMPLEMENTED`, +`NOT_SELECTED`, `PLATFORM_LIMITED`를 구분하는 현재 상태의 기준이다. 특히 +session/account Query lifecycle, Range resumable download, cross-store quota +orchestration, top-level transfer composition과 Image descriptor HTTP provider는 +설계가 승인됐어도 runtime 구현 전에는 `DESIGNED_NOT_IMPLEMENTED`다. Query +persistence, Service Worker offline fetch와 app-managed background +upload/download는 제품이 별도로 선택하기 전에는 `NOT_SELECTED`, 그 cross-browser +guarantee는 `PLATFORM_LIMITED` 상태를 유지한다. + +서버의 Redis, MongoDB, PostgreSQL, MinIO를 브라우저가 직접 연결하는 구조는 기본 +frontend adapter catalog에 넣지 않는다. 브라우저는 권한 있는 backend API/BFF를 +통해 이 자원에 접근해야 한다. 프론트에서 대응되는 변화 지점은 데이터베이스 +vendor가 아니라 HTTP/GraphQL/Connect/gRPC-Web/REST Gateway, realtime, file +transfer, cache, storage, worker, browser capability 같은 프로토콜·런타임 +capability다. + +### 성능 최적화는 모두 adapter 문제인가 + +아니다. 먼저 측정하고 병목의 소유 계층에 맞는 수단을 적용한다. + +| 문제 | 기본 제공할 수단 | adapter/facade가 필요한 경우 | +| --- | --- | --- | +| 초기 JS가 큼 | route/feature lazy loading, bundle budget, dependency inventory | remote module이나 별도 delivery 전략이 있을 때 | +| 중복 네트워크 | TanStack deduplication/cache, abort, bounded retry | offline cache나 generated client를 교체할 때 | +| 느린 화면 전환 | prefetch policy, stable shell, cached-data surface | route별 prefetch provider가 필요할 때 | +| 긴 main-thread task | profiler 기준으로 계산 분리 | Web Worker message adapter | +| 대량 목록 | pagination과 server filter를 우선 | virtualizer/data-grid facade | +| 이미지 전송량 | width/height, lazy loading, responsive source 규칙 | Image CDN URL builder adapter | +| 재방문/offline | HTTP cache contract | Service Worker/IndexedDB adapter | +| 불필요한 render | 상태 소유권 축소와 component boundary | 보통 adapter가 아니며 측정 후 memoization | + +기본 skeleton은 bundle budget, lazy route, query cancellation/cache, responsive +image 규칙, stable layout, lab performance test를 제공한다. Web Worker, +virtualization, Image CDN, Service Worker는 실제 병목과 제품 요구가 확인될 때 +설치한다. 라이브러리를 미리 많이 넣는 것은 최적화가 아니라 초기 번들·공급망 +표면을 늘리는 일이 될 수 있다. + +## 6. 질문별 직접 답변 + +### 프론트도 inbound/outbound로 나누는가 + +나눈다. 현재 구조에서는 `presentation`이 사실상 inbound adapter이고 +`src/adapters`가 outbound adapter다. 이름과 문서가 이 역할을 명확히 드러내지 +않아 모두 같은 adapter처럼 보인 것이다. + +| 역할 | 프론트 예 | +| --- | --- | +| input/inbound port | `ListResources`, `CreateResource` 같은 application API | +| inbound adapter | React page/controller, router, form event, push-event translator | +| output/outbound port | resource gateway, session, storage, clock, diagnostics | +| outbound adapter | HTTP, auth SDK, browser storage, TanStack cache, telemetry sink | + +React, router, form library, icon library마다 application port를 만들 필요는 없다. +UI 내부 교체만 필요한 라이브러리는 React inbound adapter 내부 vendor facade로 +충분하다. port는 application 정책과 외부 소유권 사이의 경계에 둔다. + +### 왜 현재 모두 `adapters` 아래에 있는가 + +실제로 모두 있지는 않다. UI driver가 `presentation`이라는 이름으로 분리돼 있고, +`adapters`에는 주로 outbound 구현이 있다. 다만 다음 두 대안 중 하나를 명시적으로 +선택해야 한다. + +1. 변경량을 줄여 `presentation = inbound adapter`로 문서화하고 + `adapters/outbound`만 명시한다. +2. TypeScript/feature migration과 함께 `adapters/inbound/react`와 + `adapters/outbound`로 재구성한다. + +이 저장소는 input API 부재와 flat contracts 문제도 함께 고쳐야 하므로 두 번째 +구조가 장기적으로 더 명확하다. 단, 대규모 rename 자체를 기능 개선으로 세지 말고 +architecture gate와 수직 reference feature가 먼저 또는 같은 브랜치에서 +증명되어야 한다. + +### TypeScript로 바꾸는 것이 좋은가 + +좋다. 특히 registry ID, Result/error union, port generic, route params/search, +component variant를 컴파일 시점에 닫을 수 있다. 다만 일괄 rename은 권장하지 +않는다. tooling → core contracts → application ports/use cases → outbound → +bootstrap → React TSX → tests 순서로 이동한다. + +### store 기본 설정이 필요한가 + +상태 전략은 기본 제공해야 하지만 범용 global store dependency는 필수로 넣지 +않는다. + +- local interaction: `useState`/`useReducer` +- shareable navigation state: URL +- server state: TanStack Query +- form state: form facade +- low-frequency cross-cutting state: context 또는 typed external store +- complex cross-feature workflow: Zustand/Redux Toolkit/state machine 중 선택 +- persistence: `StoragePort` + +서버 데이터를 global store에 복사하지 않는 규칙이 중요하다. +[TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview), +[Redux Toolkit](https://redux-toolkit.js.org/introduction/getting-started), +[Zustand](https://zustand.docs.pmnd.rs/)의 역할은 서로 같지 않다. + +### retry, API client, logger, token manager, error, validation은 어디에 있는가 + +- retry: `src/adapters/http/retry-policy.ts`, runtime max-attempt/cleanup 계약까지 구현 +- API client: `src/adapters/http/client.ts`, path/search/body projection과 + runtime schema/mapper를 사용하며 feature gateway 뒤에서 실행 +- logger: `DiagnosticsPort`로 telemetry와 분리해 구현. closed event/level, + allowlist와 bounded/no-op adapter 제공 +- token manager: 의도적으로 없음. opaque external auth owner가 credential을 소유 +- error: `src/contracts/errors.ts`의 registry-derived `AppFailure`, 공통 + `Result`와 HTTP normalization으로 구현 +- validation: runtime/API/route/form Zod와 domain invariant를 소유 경계별로 분리 + +token manager를 기본으로 추가하지 않는 이유는 token lifecycle이 인증 방식마다 +다르고 localStorage token을 일반 해법으로 만들면 보안 위험이 커지기 때문이다. +BFF HttpOnly cookie 또는 OIDC/Auth SDK가 credential을 소유하도록 두고, SPA +memory token이 필요한 프로젝트만 auth adapter를 추가한다. + +### Lucide React를 쓰면 디자인 시스템이 되는가 + +아니다. [Lucide React](https://lucide.dev/guide/packages/lucide-react)는 +tree-shakable SVG icon source로 적절하지만 select, dialog, menu, focus management +같은 UI behavior는 제공하지 않는다. Lucide는 local icon facade 뒤에 두고, +복잡한 interaction은 [Radix Primitives](https://www.radix-ui.com/primitives/docs/overview/introduction) +또는 React Aria 계열과 같은 headless primitive를 owned wrapper 뒤에서 선택한다. + +### 테스트는 현재 어떤 상태인가 + +테스트 도구 구성은 강한 편이다. 다만 다음이 빠져 있다. + +- TS source와 test 전체 typecheck +- 실제 composition root부터 page까지의 통합 +- query/mutation controller와 optimistic rollback +- route registry/runtime map 정합성은 RP-04에서 unit, component, negative + registry/type fixture와 built artifact 검증으로 구현됨 +- runtime timeout/retry와 path/query/parsed body +- shared MSW scenario catalog +- isolated component stories와 interaction test +- stable-environment visual regression +- built `dist` 대상 release E2E +- 위험 기반 coverage gate + +### 라우팅 전략은 무엇이 적절한가 + +현재 client-only clean architecture와 TanStack Query 조합은 유지하되, 목표 +skeleton은 React Router Data Mode의 route object, blocker, scroll restoration, +route error 경계를 사용한다. 서버 상태의 소유자는 계속 application input과 +TanStack Query이며 loader/action이 같은 데이터를 별도로 요청하지 않는다. +[React Router 공식 mode 설명](https://reactrouter.com/start/modes)에 따라 +Framework Mode는 SSR/static generation, route module, framework-owned data +loading을 실제 요구할 때만 선택한다. + +### 바로 쓸 수 있는 디자인 패턴은 무엇을 제공해야 하는가 + +패턴 이름만 나열하지 않고 다음 executable blueprint를 제공해야 한다. + +- page controller: route/form event를 application input으로 변환 +- query/mutation adapter: server state lifecycle을 React에 연결 +- command/query use case: 읽기와 상태 변경 의도를 분리 +- gateway: application이 외부 데이터 소유자를 추상화 +- mapper/anti-corruption layer: transport DTO를 core model로 변환 +- Result + failure mapper: throw와 사용자 메시지 경계를 통제 +- strategy: retry, cache, auth recovery, feature flag 정책 교체 +- observer/external store: session/theme/realtime 구독 +- state machine: 복잡한 workflow에만 선택적으로 사용 +- compound component/headless wrapper: 접근 가능한 복합 UI를 소유 +- page template: layout과 상태 표면을 데이터 소유권에서 분리 + +## 7. 실전 투입 준비 완료 기준 + +막연한 백분율 대신 아래 조건을 모두 자동 또는 명시적 검토로 확인한다. + +1. reference feature가 route → controller → input use case → output gateway → + adapter → mapper → query cache → UI 상태 표면을 통과한다. +2. application input API 외에는 UI에서 outbound dependency에 접근할 수 없다. +3. TypeScript source와 tests가 strict 검사되고 JS 우회 경로가 없다. +4. HTTP path/query/body/auth/timeout/retry/cancel/decode 실패가 계약 테스트된다. +5. typed route registry와 runtime map이 양방향 완전성을 가진다. +6. list/detail/form/status page template과 form error 정책이 준비돼 있다. +7. 디자인 시스템 primitive/pattern이 isolated workshop, interaction, a11y, + visual test를 가진다. +8. sample/reference feature 전체 삭제 후 typecheck/test/build가 통과한다. +9. optional adapter는 설치 조건, 보안 경계, 실패 정책, 테스트 recipe가 있다. +10. 새 feature 추가 문서가 파일 경로, 금지 의존, 실패 상태, 테스트, 검증 명령까지 + 안내한다. + +이 기준은 배포 provider, 실제 인증 tenant, 운영 telemetry vendor, production field +data 같은 프로젝트별 외부 작업을 포함하지 않는다. + +## 8. 관련 상세 문서 + +- [API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md) +- [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md) +- [프론트 포트·어댑터와 기능 경계](./frontend-ports-adapters-and-boundaries.md) +- [TypeScript·상태·데이터 흐름](./typescript-state-and-data-flow.md) +- [라우팅·페이지·재사용 패턴](./routing-pages-and-patterns.md) +- [프론트 플랫폼 구현 로드맵](./frontend-platform-implementation-roadmap.md) +- [디자인 시스템 플랫폼](../styling/design-system-platform.md) +- [프론트 플랫폼 테스트 전략](../testing/frontend-platform-testing-strategy.md) diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md new file mode 100644 index 0000000..1049d05 --- /dev/null +++ b/docs/architecture/frontend-platform-implementation-roadmap.md @@ -0,0 +1,1277 @@ +# 프론트엔드 플랫폼 구현 로드맵 + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +## 1. 목적과 준비 완료의 의미 + +이 문서는 `develop`의 `cb195f8`을 계획 기준선으로 삼아, 현재 저장소에 선언된 +프론트엔드 계약을 실제 런타임과 자동 검증까지 연결하는 구현 순서를 정의한다. +기준 커밋은 출발점일 뿐이며 각 구현 브랜치는 직전 브랜치가 병합된 최신 +`develop`에서 시작한다. + +구현 단계는 다음처럼 구분한다. + +| 단계 | 목적 | exit 상태 | +| --- | --- | --- | +| P0 | 새 기능이 사용할 input/output, HTTP/query, route/recovery 수직 경로 완성 | `LOCAL_CORE_READY` | +| P1 | form/page/design/i18n, diagnostics, test, 공급망까지 템플릿 품질 완성 | `LOCAL_TEMPLATE_READY` | +| P2 | 실제 요구가 생겼을 때 선택할 adapter recipe 제공 | 선택 항목만 `RECIPE_READY` | + +P0와 P1은 사용자가 요청한 높은 수준의 저장소 내부 준비도를 판단하기 전에 모두 +끝내야 한다. 이를 정밀한 백분율 점수로 표현하지 않고 다음 세 조건으로 +판정한다. + +1. 계약과 레지스트리의 모든 필드에 실제 consumer가 있다. +2. 정상 경로와 실패 경로가 자동 테스트로 증명된다. +3. 각 변경이 독립적으로 되돌릴 수 있고 feature branch와 evidence가 보존된다. + +P2는 제품 번들에 모든 기술을 미리 넣는 단계가 아니다. 선택 조건, port/facade, +fake adapter, 실패 정책과 제거 절차를 준비하는 단계다. + +## 2. 명시적인 범위 밖 + +다음 항목은 저장소가 seam, fail-closed 기본값, 검증 명령과 증거 형식을 제공할 +수는 있지만 실제 완료는 외부 프로젝트 또는 운영 환경의 책임이다. + +- 실제 hosting/CDN의 atomic deploy, cache purge, 응답 헤더와 rollback 실행 +- 실제 OAuth/OIDC/기업 IdP tenant, client, redirect URI와 token lifecycle +- production telemetry, RUM, analytics, error-reporting vendor와 consent 정책 +- 실제 사용자에게서 수집한 28일 field Web Vitals와 최소 표본 결정 +- 저장소 호스팅 서비스의 required check, branch protection과 evidence retention +- 운영용 CSP allowlist, signing key, provenance attestation과 비밀 관리 +- 실제 브랜드, 업무 문구, 번역 승인, 지원 locale와 법적 고지 +- 도메인 API, 권한 모델, 제품별 form schema와 업무 규칙 +- 운영 release 승인 + +이 항목이 준비되지 않아도 demo, fake, no-op, unavailable adapter를 사용해 +P0/P1의 저장소 내부 구현과 테스트를 끝낼 수 있어야 한다. 반대로 외부 증거가 +없는데 `PRODUCTION_READY`, `WCAG_READY`, `FIELD_SLO_READY`를 주장해서는 안 된다. +상위 promotion gate는 임의의 값을 만들어 PASS하지 않고 `FAIL_UNVERIFIED`를 +유지한다. + +## 3. 공통 구현 원칙 + +- `.ts`나 `.tsx`를 만들기 전에 lint, architecture, registry, typecheck와 test + 검색 범위가 해당 확장자를 검사한다. +- React는 application input port를 호출하고, application은 output port만 안다. + 구체 adapter 조립은 bootstrap만 담당한다. +- route, API, error, query, storage, telemetry와 release registry는 문서용 목록이 + 아니라 실행과 검증의 단일 소스다. +- path, search, body, response와 external event는 경계에서 parse하며 parse된 + 결과를 다음 계층으로 전달한다. +- retry, cache, redirect, reload, submission과 redaction에는 각각 한 명확한 + 정책 소유자만 둔다. +- 정상 테스트와 함께 invalid import, schema, URL, mutation, chunk, secret 같은 + negative fixture가 실제 gate에 의해 거절돼야 한다. +- vendor SDK type과 import는 local facade/adapter 밖으로 노출하지 않는다. +- 선택 gate가 닫히지 않은 라이브러리는 lockfile에 추가하지 않는다. +- 대규모 rename과 여러 capability를 한 merge에 섞지 않는다. + +## 4. Gitflow 브랜치 보존 규칙 + +현재 저장소는 `main`과 `develop`을 사용하고 feature prefix가 비어 있다. 아래 +표의 `feature-...` 이름을 그대로 `git flow feature start`에 전달한다. + +```text +최신 develop 확인 + -> git flow feature start <정확한 feature-... 이름> + -> 작은 검증 가능한 commit + -> git flow feature publish <정확한 feature-... 이름> + -> 자동 gate와 review + -> develop에 --no-ff merge + -> develop과 feature branch를 모두 push + -> local/origin feature branch 보존 +``` + +`git flow feature finish`는 feature branch를 삭제할 수 있으므로 사용하지 않는다. +병합 후에도 로컬 브랜치와 `origin/feature-...`를 제거하지 않는다. merge commit +SHA, feature tip, 실행한 gate와 artifact 위치를 PR 또는 merge evidence에 +기록한다. + +후속 브랜치는 미병합 feature branch에서 시작하지 않는다. 선행 브랜치를 먼저 +`develop`에 병합하고 최신 `develop`에서 새 브랜치를 시작한다. 병합 순서를 +바꾸려면 dependency와 acceptance를 다시 검토해야 한다. + +rollback은 reset이나 branch 삭제로 수행하지 않는다. + +1. 관련 promotion을 중단한다. +2. 보존된 feature branch와 merge commit으로 정확한 변경 범위를 찾는다. +3. `develop`에서 별도 `hotfix/...` 또는 revert branch를 만든다. +4. `git revert -m 1 `으로 merge를 되돌린다. +5. rollback gate 후 revert branch와 원래 feature branch를 모두 보존한다. +6. 수정은 최신 `develop`에서 새 feature branch로 다시 진행한다. + +## 5. 12개 브랜치와 병합 순서 + +```mermaid +flowchart TD + B01["01 P0 TypeScript tooling"] + B02["02 P0 input/output boundary"] + B03["03 P0 HTTP and query"] + B04["04 P0 routing and recovery"] + B05["05 P0 reference feature"] + B06["06 P1 forms and pages"] + B07["07 P1 design system"] + B08["08 P1 i18n"] + B09["09 P1 diagnostics"] + B10["10 P1 tests and registry evidence"] + B11["11 P1 supply chain"] + B12["12 P2 optional recipes"] + + B01 --> B02 --> B03 --> B04 --> B05 + B05 --> B06 --> B07 --> B08 --> B09 --> B10 --> B11 + B11 -. "요구가 확인된 recipe만" .-> B12 +``` + +| 순서 | 보존할 Gitflow feature branch | 단계 | 직접 선행조건 | rollback point | +| --- | --- | --- | --- | --- | +| 01 | `feature-frontend-typescript-tooling-foundation` | P0 | 기준선, VD-01 | RP-01 | +| 02 | `feature-frontend-application-boundary-runtime` | P0 | 01 | RP-02 | +| 03 | `feature-frontend-http-query-contract-execution` | P0 | 02 | RP-03 | +| 04 | `feature-frontend-routing-release-recovery-runtime` | P0 | 03, VD-03 | RP-04 | +| 05 | `feature-frontend-reference-feature-vertical-slice` | P0 | 04 | RP-05 | +| 06 | `feature-frontend-form-page-platform` | P1 | 05, VD-04 | RP-06 | +| 07 | `feature-frontend-design-system-platform` | P1 | 06, VD-05 | RP-07 | +| 08 | `feature-frontend-i18n-message-formatting-contract` | P1 | 07, VD-06 | RP-08 | +| 09 | `feature-frontend-diagnostics-telemetry-runtime` | P1 | 08, VD-07 | RP-09 | +| 10 | `feature-frontend-test-registry-evidence-hardening` | P1 | 09, VD-08 | RP-10 | +| 11 | `feature-frontend-supply-chain-verification` | P1 | 10, VD-09 | RP-11 | +| 12 | `feature-frontend-optional-adapter-recipes` | P2 | 11, VD-10 | RP-12 | + +## 6. 브랜치별 구현과 acceptance + +### 01. `feature-frontend-typescript-tooling-foundation` + +**목표** + +점진적 TypeScript 전환 전에 JS, JSX, TS, TSX와 테스트가 모두 같은 정적 분석과 +CI gate를 통과하게 한다. 애플리케이션 전체를 일괄 변환하지 않는다. + +**선행조건과 decision** + +- 기준선의 `pnpm ci:gate` 결과를 보존한다. +- TypeScript compiler와 기존 dependency 버전은 별도 dependency review 없이는 + 변경하지 않는다. +- 공식 JavaScript migration 방식처럼 점진적으로 전환한다. + [TypeScript JavaScript migration](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html) + +**구체 변경 범위** + +- root, application/source, tests용 TypeScript project/reference를 분리한다. +- `src`, `tests`, `scripts`의 허용 확장자와 build output 제외 범위를 명시한다. +- ESLint, dependency-cruiser, registry scanner와 architecture script의 검색 + 범위를 TS/TSX source와 fixture로 통일한다. +- Vite, Vitest와 Playwright config가 TS source/test 오류를 우회하지 않게 한다. +- 기존 fixture까지 TS/TSX로 전환하고 strict 정책을 적용한다. +- port, registry와 forbidden import용 `.ts`/`.tsx` fixture를 추가한다. + +**자동 테스트와 negative test** + +- TS/TSX source/test를 함께 typecheck하는 smoke +- TSX presentation의 concrete HTTP adapter import를 architecture gate가 거절 +- 잘못된 port 구현과 discriminated union 사용이 typecheck 실패 +- TS registry row의 누락 필드, 중복 ID와 unknown reference가 gate 실패 +- 변환된 TS invalid-call fixture도 계속 실패 +- `pnpm lint`, `pnpm check:types`, `pnpm check:architecture`, + `pnpm check:registries`, `pnpm test:all`, `pnpm build` + +**Acceptance** + +- 허용된 모든 source/test 확장자가 typecheck, lint, architecture, registry gate + 중 필요한 검색 범위에 포함된다. +- 모든 TS/TSX negative fixture가 CI에 의해 차단된다. +- 광범위한 unchecked cast나 임시 `any`로 통과시키지 않는다. +- production source 대량 변환 없이 독립적으로 revert할 수 있다. + +**Rollback** + +RP-01에서 merge를 revert하면 기존 JS 기준선으로 돌아간다. 후속 TS 작업은 +RP-01이 복구될 때까지 병합하지 않는다. + +### 02. `feature-frontend-application-boundary-runtime` + +**목표** + +UI가 호출할 input port와 application이 외부에 요구할 output port를 분리하고, +composition root가 생성한 `Application`을 실제 React tree에 주입한다. + +**선행조건과 decision** + +- 01의 TS-aware architecture gate가 병합돼 있어야 한다. +- 범용 client store는 추가하지 않는다. local interaction은 React state, 공유 + navigation state는 URL, server state는 query adapter가 소유한다. +- 실제 IdP 없이 demo/external/unavailable session adapter를 유지한다. + +**구체 변경 범위** + +- application input API를 query/command use case 중심으로 정의한다. +- gateway, session, cache, storage, clock, diagnostics와 release capability를 + output port로 분류한다. +- `createApplication`이 raw adapter를 다시 노출하지 않고 input API만 반환한다. +- `ApplicationProvider`와 `useApplication` 역할의 inbound React adapter를 둔다. +- bootstrap이 concrete output adapter를 선택하고 application을 router/page에 + 전달한다. +- presentation의 fetch, browser storage, concrete QueryClient와 outbound adapter + 직접 접근을 금지한다. +- inbound/outbound ownership을 architecture gate와 type fixture에 반영한다. + +**자동 테스트와 negative test** + +- fake output ports를 이용한 application use-case 단위 테스트 +- runtime config → composition root → provider → smoke page 통합 테스트 +- presentation의 `adapters/outbound`, `fetch`, concrete QueryClient import 거절 +- application의 React, router, browser storage, vendor SDK import 거절 +- 잘못된 output port 구현과 누락 input이 typecheck 실패 +- bootstrap 이외 계층의 concrete adapter 조립 거절 + +**Acceptance** + +- visible page가 raw outbound dependency 대신 application input API를 사용한다. +- 조립됐지만 사용되지 않는 application, HTTP와 release-info 객체가 없다. +- fake adapter만으로 application과 presentation의 정상·실패 경로를 테스트한다. +- vendor type이 application public API에 나타나지 않는다. + +**Rollback** + +RP-02는 기존 provider wiring을 한 merge로 되돌릴 수 있어야 한다. 임시 +compatibility wrapper는 05 reference feature 완료 전에 제거한다. + +### 03. `feature-frontend-http-query-contract-execution` + +**목표** + +API operation registry의 path/search/body/schema/timeout/retry가 실제 요청에 +반영되게 하고, TanStack Query를 application input과 React 사이의 제한된 inbound +adapter로 연결한다. + +**선행조건과 decision** + +- 02의 application error와 gateway 경계가 고정돼 있어야 한다. +- shared HTTP가 network retry를 소유하고 query adapter의 중복 retry는 끈다. + 다른 결정을 원하면 이 브랜치 전에 ADR을 남긴다. +- TanStack Query를 server-state adapter로 유지하되 Zustand/Redux Toolkit에 + server state를 복사하지 않는다. + +**구체 변경 범위** + +- request input을 operation/route ID, path params, search params, body, signal과 + idempotency key로 분리한다. +- operation registry에서 method, path, request/response schema, timeout과 retry + eligibility를 찾는다. +- deterministic path/search builder를 만들고 parse/transform된 query/body를 + 실제 URL/payload에 사용한다. +- runtime config의 timeout과 max attempts를 client factory에 주입한다. +- abort, timeout, retry delay와 listener cleanup을 injectable clock/scheduler + 경계로 통제한다. +- TanStack import를 허용할 local query adapter 경계를 하나만 둔다. +- `useApplicationQuery`, `useApplicationMutation` 역할의 controller API를 + 정의하고 query key, cancellation, invalidation과 optimistic rollback을 + 연결한다. +- base state를 loading/success/empty/terminal로, overlay를 + refreshing/stale-degraded/pending/conflict로 닫는다. +- refreshing/stale 및 pending/conflict의 배타성과 stale-failure latch를 + 명시적으로 구현한다. +- stale retry, duplicate-submit 방지와 conflict action을 실제 callback에 + 연결한다. + +**자동 테스트와 negative test** + +- path escaping, optional/array search, stable ordering 및 query key/request URL 일치 +- query/body transform 결과 전송, invalid input에서 `fetch` 0회 +- runtime timeout과 max attempts 0/1/N의 실제 호출 횟수 +- validation 실패, abort, success, exhausted retry의 timer/listener cleanup +- caller abort와 timeout의 서로 다른 typed failure +- unsafe mutation, non-retryable status와 schema failure retry 0회 +- 네 base state, 네 overlay와 허용 transition +- refreshing 실패의 stale latch 및 성공/재시도 후 해제 +- pending/conflict 동시 상태 fixture 거절 +- duplicate submit 1회, optimistic success/rejection rollback/conflict resolution +- unmount/navigation abort가 terminal error로 오인되지 않음 +- local adapter 밖의 `@tanstack/**` import 거절 + +**Acceptance** + +- cache key에 포함된 request 입력이 실제 URL/body에서도 동일하게 사용된다. +- runtime timeout/retry 설정이 실제 동작을 바꾼다. +- 모든 종료 경로의 cleanup과 한계 횟수를 fake clock으로 결정적으로 증명한다. +- QueryClientProvider가 단순 마운트 객체가 아니며 reference controller가 사용할 + 표준 API를 제공한다. +- `AsyncSurface`의 모든 action이 실제 command와 검증된 state transition을 가진다. + +**구현 증거 (2026-07-28)** + +- installed `QUERY_REGISTRY`가 namespace와 별도의 versioned invalidation topic, + `crossContext: "invalidate-only"`와 `persistence: "disabled"`를 소유한다. +- `tanstack-cache-coordinator.ts`가 mutation lease, local active-query + invalidation, remote hint coalescing, sequence-gap 전체 reconciliation과 + idempotent dispose를 구현한다. +- production bootstrap/provider tree가 coordinator를 실제 조립한다. + native `BroadcastChannel`을 우선하고 실패하면 exact localStorage pulse로 + fallback하며 둘 다 없으면 local-only로 degrade한다. +- wire contract는 2,048 bytes, exact field set, release cache epoch, topic + allowlist, TTL, self/duplicate/stale sequence를 검증하고 query key/data를 + 허용하지 않는다. +- `storage-keys.ts`와 browser storage adapter가 key별 closed value codec, + schema/TTL, 기본 16,384-byte cap과 memory fallback envelope를 적용한다. +- `cross-tab-invalidation.test.ts`, `tanstack-cache-coordinator.test.ts`, + `storage-registry.test.ts`, runtime adapter/component test가 deterministic + 동작과 production wiring을 검증한다. +- 상세 상태와 남은 session/account epoch, native multi-tab evidence 및 optional + IndexedDB query persistence는 + [Client cache and browser storage platform](./client-cache-and-storage.md)에 + 기록한다. + +**Rollback** + +RP-03은 application port를 유지하면서 이전 HTTP compatibility adapter 또는 +imperative controller로 되돌릴 수 있어야 한다. request builder와 query adapter +commit을 구분해 부분 revert가 가능하게 한다. + +### 04. `feature-frontend-routing-release-recovery-runtime` + +**목표** + +route registry를 executable router의 단일 계약으로 만들고 params/search/access/ +surface/chunk metadata를 route flow에 연결한다. lazy chunk 실패도 release +manifest와 bounded recovery에 실제 연결한다. + +**선행조건과 decision** + +- 03의 controller가 parsed route input을 받을 수 있어야 한다. +- VD-03 기본값은 React Router Data Mode다. route object, blocker, scroll + restoration과 route error 경계를 사용하되 server state는 application input과 + TanStack Query가 계속 소유한다. + [React Router modes](https://reactrouter.com/start/modes), + [Data Mode custom setup](https://reactrouter.com/start/data/custom) +- 고정된 `7.18.1` API로 전환하고 router version upgrade는 별도 dependency + 브랜치로 분리한다. +- 실제 hosting cache/purge는 범위 밖이며 test manifest server/browser adapter를 + 사용한다. + +**구체 변경 범위** + +- 직렬화 가능한 route contract와 component/codec runtime map을 분리한다. +- route ID의 contract/runtime 양방향 완전성을 TypeScript와 registry gate로 + 검사한다. +- registry에서 route objects, navigation, access hint, title, loading/error + surface와 chunk ID를 조립한다. +- params/search를 application 호출 전에 Zod codec으로 parse하고 URL builder도 + 같은 codec을 사용한다. +- unknown/authentication-required/forbidden/not-found/error surface의 소유자를 + 하나씩 정한다. +- route change의 boundary reset, document title, main focus와 scroll 정책을 + 적용한다. +- redirect-loop guard와 최대 hop을 실제 자동 redirect flow에 연결한다. +- build의 Vite asset manifest와 route chunk ID를 release manifest의 검증 가능한 + map으로 연결하고 `runtime-config.schema.json`을 artifact로 생성한다. +- config/release/build/asset mismatch 오류 kind를 정확히 나눈다. +- lazy rejection → no-store manifest refetch → active build/release pair 비교 → + guarded reload 1회 → 반복 실패 support/rollback surface를 연결한다. +- 일반 render error는 chunk reload 경로에 들어가지 않고 local reset을 유지한다. +- `FeatureBoundary`, `ReleaseInfoPort`와 chunk recovery controller를 production + call graph에 연결한다. + +**자동 테스트와 negative test** + +- registry row 추가 시 route tree/navigation 동시 추가 +- runtime 누락/orphan, duplicate path/ID, unknown schema/surface/chunk 거절 +- invalid params/search, unknown route와 access rejection에서 application/API 0회 +- URL parse/build round-trip과 canonical search +- redirect cycle이 최대 hop에서 종료 +- route 전환 후 error reset, main heading focus와 중복 surface 없음 +- stale chunk + manifest mismatch에서 no-store refetch 후 reload 정확히 1회 +- 같은 build/release pair의 두 번째 실패에서 reload 0회와 support surface +- manifest malformed/offline/storage unavailable의 fail-closed 동작 +- 일반 component throw가 chunk failure로 오분류되지 않음 +- build/config/release/asset mismatch의 정확한 error kind +- built `dist`의 route chunk map, runtime schema와 release artifact set 검증 + +**Acceptance** + +- 수동 route 열거와 registry가 서로 어긋날 수 없다. +- route metadata 중 consumer 없는 token이 0개다. +- invalid/unknown/access-rejected URL에서 product API 0회를 증명한다. +- chunk recovery 결정이 production call graph에 있으며 무한 reload가 불가능하다. +- 실제 CDN 없이 저장소 내부 recovery matrix가 결정적으로 통과한다. + +**Rollback** + +RP-04는 이전 수동 router와 generic route failure surface로 한 merge를 되돌릴 수 +있어야 한다. URL shape는 바꾸지 않으며 불가피한 변경은 compatibility redirect와 +migration이 있어야 한다. 실제 CDN purge는 rollback 범위에 포함하지 않는다. + +### 05. `feature-frontend-reference-feature-vertical-slice` + +**목표** + +route에서 실제 HTTP/query/application/presentation까지 연결되며 전체를 제거해도 +generic platform이 정상 부팅되는 하나의 reference feature를 만든다. + +**선행조건과 decision** + +- 01~04가 모두 병합돼 있어야 한다. +- 실제 backend와 IdP 대신 MSW/fake gateway와 demo auth를 사용한다. +- reference 업무 용어를 제품 도메인 계약으로 승격하지 않는다. + +**구체 변경 범위** + +- 흩어진 sample route, operation, schema, mapper, domain, query key, controller, + page와 tests를 canonical `src/features/reference-feature` 한 ownership 경계로 + 이동한다. +- feature 내부에 domain, application input/output, outbound gateway, DTO/schema, + mapper, query/mutation controller와 presentation page를 둔다. +- list filter가 URL codec, query key와 실제 HTTP request에 동일하게 반영된다. +- safe mutation으로 pending, duplicate prevention, optimistic state, conflict와 + rollback을 보여 준다. +- registry/application에는 removable contribution contract를 사용하고 generic + source에는 fixture-specific ID를 하드코딩하지 않는다. +- production bootstrap과 같은 composition 경로에 fake adapter를 주입한다. +- `src/features/reference-feature` 전체를 제거한 ephemeral copy에서 + typecheck/test/build/home smoke를 실행하도록 sample-removal gate를 다시 + 작성한다. + +**자동 테스트와 negative test** + +- bootstrap → router → controller → input use case → gateway → HTTP/MSW → + response schema → mapper → cache → page의 수직 통합 +- loading/success/empty/terminal/refreshing/stale/pending/conflict/recovery UI +- invalid search에서 gateway/fetch 0회 +- invalid DTO가 domain/view model로 전파되지 않음 +- backend forbidden을 client access hint와 무관하게 최종 반영 +- duplicate/unsafe mutation 전송 횟수 +- `src/features/reference-feature` 삭제 후 typecheck, architecture, registry, + unit/integration, home smoke와 build +- 삭제 후 sample route 부재와 `src`/`tests`의 fixture-specific ID 잔여 0개 +- generic home/examples route가 sample 없이 계속 작동 + +**Acceptance** + +- static page와 분리된 fixture가 아니라 실제 수직 경로 하나가 존재한다. +- reference feature를 삭제할 때 central registry를 수작업으로 수정하는 숨은 + 단계가 없거나 제거 명령이 이를 결정적으로 수행한다. +- feature 존재/제거 두 모드에서 P0 gate가 모두 통과한다. +- 01~04를 함께 revert해야만 sample을 제거할 수 있는 숨은 결합이 없다. + +**Rollback** + +RP-05는 P0 최종 지점이다. reference feature merge만 revert해도 generic platform은 +정상이어야 한다. + +## 7. P0 exit gate + +- JS/TS source와 tests가 동일한 정적 gate에 포함된다. +- UI는 application input API만 호출하고 concrete outbound adapter를 모른다. +- HTTP path/search/body/auth/timeout/retry/cancel/decode가 실행 계약과 일치한다. +- query/mutation controller와 모든 async state transition이 작동한다. +- route registry/runtime map과 route chunk map이 양방향 완전성을 가진다. +- lazy chunk failure가 bounded release recovery에 연결된다. +- reference feature가 실제 수직 경로를 실행하고 전체 삭제 gate를 통과한다. +- `pnpm ci:gate`와 P0에서 추가한 모든 negative fixture가 통과한다. + +위 조건이 모두 충족돼야 `LOCAL_CORE_READY`다. RP-05 증거를 보존한 뒤 P1을 +시작한다. + +## 8. P1 브랜치 + +### 06. `feature-frontend-form-page-platform` + +**목표** + +Zod schema와 application command를 연결하는 form controller 및 +standard/collection/detail/form/status page template를 제공한다. + +**선행조건과 decision** + +- 05의 mutation과 async state가 실제 reference flow에서 작동해야 한다. +- VD-04에서 React Hook Form + Zod resolver 사용 여부를 정한다. 복합 form 요구가 + 확정되면 local facade 뒤에 추가하고, 거절되면 native form + local controller가 + 같은 public API와 tests를 충족한다. + [React Hook Form resolvers](https://github.com/react-hook-form/resolvers), + [Zod](https://zod.dev/) +- vendor 결정 전에 dependency/lockfile을 변경하지 않는다. + +**구체 변경 범위** + +- presentation form schema와 domain invariant를 분리한다. +- field registration, parse/error map, dirty/touched/reset/pending/result를 local + form API로 감싼다. +- `422` field/form/unknown-server-field/global error mapping을 정의한다. +- 첫 오류 focus, error summary, field ID와 live-region을 연결한다. +- duplicate submit, cancel/leave와 unsaved-change 정책을 적용한다. +- `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`의 slot + 계약을 정의한다. +- heading, breadcrumb, actions, filters, content, aside와 feedback의 소유 위치를 + 표준화한다. +- reference list/detail/create-edit/status page를 controller와 template로 + 전환한다. +- template가 application use case나 query vendor를 직접 선택하지 않게 한다. + +**자동 테스트와 negative test** + +- client validation 실패에서 command 0회와 첫 invalid field focus +- transformed/defaulted data가 command에 전달됨 +- field/form/unknown server error와 conflict 후 입력 보존 +- pending 중 연속 submit에도 command 1회 +- reset 후 dirty 해제와 navigation confirmation/focus restore +- secret-like input이 URL/storage/log/telemetry에 나타나면 실패 +- 각 template의 최소/전체 slot 및 async/error variation +- 320px, zoom, 긴 heading/action과 wide layout +- heading/landmark/accessibility-name/axe +- template가 application/HTTP/query vendor를 import하면 gate 실패 +- action callback 없이 활성 버튼을 렌더링하면 실패 + +**Acceptance** + +- form engine을 바꿔도 page와 application API가 변하지 않는다. +- multi-field form의 validation/pending/dirty/422/conflict가 실행된다. +- 새 feature가 layout CSS를 복사하지 않고 다섯 page 유형을 조립한다. +- template는 layout/state slot만 소유하고 데이터 정책은 소유하지 않는다. + +**Rollback** + +RP-06은 기존 reference controls/layout으로 돌아가도 controller/application +경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해 +부분 revert가 가능하게 한다. + +**구현 증거 (2026-07-26)** + +- VD-04에서 dependency 추가 없는 native controller + Zod local facade를 + 채택했고 vendor 도입 조건을 문서화했다. +- `src/presentation/forms`가 field registration, parse/error map, + dirty/touched/reset/pending/result, 422 allowlist, first-error focus, + duplicate submit과 dirty navigation을 제공한다. +- `src/presentation/templates`가 다섯 page 유형의 slot/landmark/responsive + 계약을 제공하며 architecture negative fixture가 application/vendor import를 + 거절한다. +- reference list/detail/create/status route가 네 구체 template를 사용하고 + production composition test가 list → form → command → HTTP → invalidation → + list 경로를 실행한다. +- component/integration/E2E test가 validation, transform, 422, conflict, + secret 비노출, navigation focus와 320px reflow를 검증한다. + +### 07. `feature-frontend-design-system-platform` + +**목표** + +기존 theme와 primitive를 token → primitive → pattern → template 계층으로 +확장하고, 모바일 drawer와 복합 interaction을 접근 가능한 local API로 제공한다. + +**선행조건과 decision** + +- 06의 page/form에서 반복되는 실제 token/primitive 요구를 수집한다. +- VD-05에서 icon source와 headless interaction 방식을 결정한다. +- Lucide를 선택하면 static named import만 local icon facade에서 허용한다. + [Lucide React](https://lucide.dev/guide/react), + [Lucide accessibility](https://lucide.dev/guide/react/advanced/accessibility) +- headless vendor가 미결정이면 native dialog/disclosure/control 범위만 완료하며, + 미지원 widget을 임의 구현해 완료로 표시하지 않는다. + +**구체 변경 범위** + +- primitive/semantic/component token을 분리한다. +- color, typography, spacing, size, radius, elevation, z-layer, motion, breakpoint와 + opacity 계약을 정의한다. +- light/dark/system, forced-colors와 reduced-motion을 닫는다. +- design-system public entry와 deep-import 금지 규칙을 만든다. +- 기존 Button/TextField/Card/Alert/Badge/Dialog를 호환 가능한 public API로 + 이동한다. +- LinkButton/IconButton/Field/TextArea/Select/Checkbox/RadioGroup/Switch/ + Spinner/Skeleton/VisuallyHidden을 기본 제공한다. +- Drawer/Popover/Tooltip/Menu/Tabs/Breadcrumbs/Pagination/Toast/ProgressBar 및 + 필요한 focus/portal utility를 추가한다. +- 모바일 navigation을 Drawer로 전환해 focus 이동, modal/background 정책, + Escape/scrim/link dismiss와 trigger focus restore를 구현한다. +- 앱 셸의 문자 glyph/raw control을 local icon/primitive로 교체한다. +- `/examples/ui`와 `/examples/states`에서 interactive state를 실제 동작으로 + 노출한다. + +**자동 테스트와 negative test** + +- 필수 semantic token의 light/dark/forced-colors 정의 +- undefined CSS variable/raw palette 사용 거절 +- interactive icon의 accessible name 누락 거절, decorative icon tree 제외 +- product code의 direct icon/headless vendor 또는 deep import 거절 +- disabled/pending/focus/invalid와 reduced-motion/theme persistence +- keyboard-only open/move/select/dismiss/focus restore +- Drawer open 시 background interaction 차단과 compact reflow +- Menu/Tabs arrow key, typeahead와 activation 정책 +- Tooltip만으로 필수 정보를 제공하는 fixture 거절 +- Toast queue 상한/duplicate collapse/timeout pause +- Chromium/Firefox/WebKit component/E2E/axe + +**Acceptance** + +- shell, templates와 reference feature가 public design-system API를 사용한다. +- product code의 raw palette, vendor icon/headless import가 0개다. +- mobile navigation이 검증된 focus/dismiss contract를 따른다. +- gallery action이 handler 없는 장식이 아니라 실행 가능한 상태를 시연한다. +- token 누락과 접근 불가능한 interaction을 자동 gate가 차단한다. + +**Rollback** + +RP-07은 compatibility export로 기존 primitive import를 복구할 수 있어야 한다. +token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을 +분리한다. + +**구현 증거 (2026-07-26)** + +- VD-05에서 `lucide-react@1.25.0` static semantic facade와 native-first + interaction을 채택하고 headless vendor 재평가 조건을 닫았다. +- `src/presentation/design-system`이 48개 필수 token, public TypeScript barrel, + action/form/feedback/overlay/navigation primitive와 공통 pattern을 제공한다. +- 기존 `components/ui` 경로는 compatibility export로 유지하고 앱 셸, gallery와 + reference feature는 public entry를 소비한다. +- 모바일 navigation은 native modal Drawer로 전환되어 배경 비활성화, Escape, + route dismiss와 trigger focus restore를 제공한다. +- source/negative fixture gate가 undefined token, raw palette, direct vendor, + deep import, tooltip-only 정보와 accessible name 누락을 거절한다. +- component/browser test가 Menu typeahead, Tabs activation, Toast queue, + form controls, compact reflow와 open-dialog axe를 실행한다. 로컬 WebKit은 host + `libevent-2.1.so.7` 부재로 환경 검증 상태를 유지한다. + +### 08. `feature-frontend-i18n-message-formatting-contract` + +**목표** + +사용자 문구, 날짜, 숫자, 상대 시간과 direction을 typed message/formatter 경계로 +옮겨 특정 언어 literal과 locale 가정을 공통 UI에서 제거한다. + +**선행조건과 decision** + +- 07까지의 common user-facing string 목록이 확보돼 있어야 한다. +- VD-06 기본값은 browser `Intl` + typed local catalog다. extraction/plural 등 + 요구가 이를 넘을 때만 vendor를 선택한다. +- 실제 번역 승인과 지원 locale은 외부 프로젝트 범위다. + +**구체 변경 범위** + +- typed message key와 interpolation parameter 계약을 정의한다. +- locale provider, fallback locale, direction과 date/number/relative-time + formatter를 제공한다. +- shell, route, async/form error와 design-system 기본 copy를 catalog로 이동한다. +- backend raw message를 번역 key로 신뢰하지 않고 error registry로 매핑한다. +- pseudo-locale와 RTL smoke locale을 테스트 전용으로 제공한다. +- unknown key/locale, formatter failure와 missing interpolation의 fallback을 + 정의한다. + +**자동 테스트와 negative test** + +- 모든 등록 locale의 key parity와 interpolation 정합성 +- unknown locale/key에서 raw key/stack을 노출하지 않는 fallback +- pseudo-locale 확장에 대한 layout/reflow +- RTL shell/drawer/breadcrumb와 directional icon +- 날짜/숫자/timezone의 결정적 test +- untrusted HTML interpolation 및 backend message 직접 렌더링 거절 +- common UI의 금지된 hardcoded user-facing literal 검출 + +**Acceptance** + +- shell/state/form/error copy가 typed catalog를 통과한다. +- pseudo-locale와 RTL E2E가 통과한다. +- 실제 제품 번역 없이도 locale 교체 지점과 실패 정책이 검증된다. +- key rename에는 compatibility alias 또는 migration이 있다. + +**Rollback** + +RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를 +파괴적으로 덮어쓰지 않는다. + +**구현 증거 (2026-07-26)** + +- VD-06에서 dependency를 추가하지 않는 browser `Intl` + typed local catalog를 + 채택하고 vendor 재평가 조건, fallback과 key migration 정책을 문서화했다. +- `src/presentation/i18n`이 137개 common key, key별 interpolation, + `ko-KR` fallback, compatibility alias, date/number/relative/list/plural/select + formatter와 `LocaleProvider`를 제공한다. +- shell, route lifecycle/access/recovery, async/form error, page template와 + design-system default copy가 catalog consumer로 연결됐다. +- feature route copy는 feature-owned catalog contribution으로 분리되어 reference + feature 제거 시 source와 built artifact에 전용 message key가 남지 않는다. +- reference application mapper는 locale-formatted date 대신 timestamp를 + 반환하고 presentation formatter가 UTC 또는 명시 timezone을 적용한다. +- `check:i18n`과 type negative fixture가 catalog/placeholder 불일치, + hardcoded common literal, backend raw message render, unsafe HTML, + unknown key와 interpolation mismatch를 거절한다. +- unit/component/browser test가 safe fallback, pseudo 320px reflow, + document `lang/dir`, RTL Drawer/Tabs/directional icon과 deterministic formatter를 + 검증한다. + +### 09. `feature-frontend-diagnostics-telemetry-runtime` + +**목표** + +structured diagnostics와 telemetry를 구분하고 boot, HTTP, cache, storage, route, +render와 release 사건을 실제 producer에 연결한다. + +**선행조건과 decision** + +- route/HTTP/query/form/release failure kind가 안정돼 있어야 한다. +- VD-07에서 외부 exporter를 선택하지 않아도 된다. no-op 또는 기존 + best-effort HTTP sink를 기본으로 하고 실제 vendor SDK는 외부 adapter다. + +**구체 변경 범위** + +- diagnostics/logger output port와 telemetry event port의 책임을 분리한다. +- level, event ID, timestamp, correlation/request/route/release context와 redaction + 정책을 정의한다. +- `app.boot.failed`, `api.request.failed`, `ui.render.failed`, + `release.mismatch.detected`, `telemetry.delivery.dropped`를 production path에 + 연결한다. +- retry attempt마다 terminal failure를 중복 발행하지 않고 bounded summary만 + 남긴다. +- route/path/query/body/error에서 고카디널리티와 민감 값을 제거한다. +- queue 상한, drop reason bucket, sink failure와 nonrecursive fallback을 + 구현한다. +- reference feature에서 route → application → HTTP outcome correlation을 + 증명한다. + +**자동 테스트와 negative test** + +- registry event가 production path에서 필요한 횟수만 발행 +- success/retry recovery/terminal failure event 차이 +- Authorization/cookie/token/raw form/body/PII-like fixture redaction +- large/circular/unknown error에서도 serializer가 throw하지 않음 +- queue full/sink failure가 app failure나 재귀 폭주를 만들지 않음 +- unknown/high-cardinality event context 거절 +- mount 전 boot failure도 안전한 evidence 생성 +- no-op adapter에서도 동일 application behavior + +**Acceptance** + +- 필수 telemetry event 중 producer 없는 항목이 0개다. +- sink/vendor가 없어도 기능이 정상이며 failure evidence가 제한된다. +- credential/raw user input이 queue, console과 artifact에 남지 않는다. +- exporter wiring만 독립적으로 제거할 수 있다. + +**Rollback** + +RP-09는 exporter를 제거하고 즉시 no-op adapter로 전환할 수 있어야 한다. +diagnostics port와 redaction test는 유지한다. + +**구현 증거 (2026-07-26)** + +- VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic `TelemetryPort`를 + 분리하고 bounded memory/no-op 또는 설정 기반 best-effort HTTP exporter를 + 채택했다. +- diagnostics 8종과 telemetry 5종의 registry, context/attribute allowlist, + 고카디널리티 value policy, timestamp와 status/attempt/duration/queue bucket을 + 구현했다. +- boot, route, render, HTTP logical outcome, cache, storage, release mismatch와 + telemetry drop을 production composition에 연결했다. HTTP는 success/recovery/ + terminal/abort 각각 logical execution당 diagnostics 한 번, terminal + non-abort failure telemetry 한 번만 발행한다. +- bounded oldest-drop queue, drop reason 집계, sink/observer failure 격리와 + nonrecursive delivery evidence를 구현했다. exporter가 없으면 network와 queue + side effect가 없는 true no-op이다. +- `check:diagnostics`, source negative fixture와 TypeScript negative fixture가 + producer 누락, direct console, unknown event/context, raw URL/query/body/ + credential 경계를 거절한다. +- unit/integration test가 circular/hostile error, pre-mount boot, queue/sink + failure, no-op, cache/storage/release producer, success/retry recovery/terminal/ + abort 횟수와 reference route/operation/correlation context를 검증한다. + +### 10. `feature-frontend-test-registry-evidence-hardening` + +**목표** + +registry의 구조/참조/consumer/호환성을 실제 diff로 검증하고, P0/P1 위험 경로를 +실제 bootstrap과 built artifact에서 증명한다. + +**선행조건과 decision** + +- 09까지의 public contract와 registry field가 안정돼 있어야 한다. +- VD-08 기본값은 dev-only Storybook workshop과 local Playwright visual + baseline이다. cloud review service는 선택이다. +- `/examples/ui`, `/examples/states`는 실제 앱 composition의 통합 smoke로 + 유지하며 isolated story의 SSOT를 대신하지 않는다. + [Storybook documentation](https://storybook.js.org/docs) + +**구체 변경 범위** + +- route/API/schema/error/query/storage/telemetry/config/release registry의 field + type/enum/unique/cross-reference/consumer/orphan을 machine-readable하게 검사한다. +- 직전 승인 snapshot과 현재 snapshot의 actual diff로 additive/behavioral/ + breaking/removal을 계산한다. +- breaking에는 version bump, migration, compatibility window와 rollback + evidence를 요구한다. +- hardcoded `current: additive`, empty high-risk review와 self-asserted PASS를 + 제거한다. +- source와 tests를 모두 포함하는 TypeScript check를 CI 필수 gate로 만든다. +- MSW handler를 operation/schema 기반 shared scenario catalog로 통합한다. +- 실제 `main` composition과 built `dist`의 integration/release E2E를 추가한다. +- Playwright compact/mobile project와 risk-based coverage threshold를 추가한다. +- deterministic clock/random/fetch/storage helper, unexpected console/unhandled + rejection 실패 정책을 제공한다. +- 선택된 workshop interaction 또는 안정 환경의 visual baseline을 추가한다. +- JUnit/screenshot/trace/evidence naming과 CI gate registry를 맞춘다. + +**자동 테스트와 negative test** + +- duplicate ID/path, invalid enum/type, missing field/reference와 orphan 거절 +- actual removal/type narrowing/path change를 additive로 표시하면 실패 +- breaking diff의 version/migration/rollback 누락 실패 +- ordering-only diff는 semantic change가 아님 +- 승인 baseline digest 변조 실패 +- unexpected network/operation, provider 누락과 unhandled error 실패 +- critical policy coverage threshold 미달 실패 +- built artifact의 source map, config schema 누락과 broken chunk 실패 +- compact viewport와 Chromium/Firefox/WebKit navigation/a11y smoke +- reference feature 제거 모드의 generic E2E/build +- screenshot mask가 전체 UI를 가려 false PASS를 만들면 실패 + +**Acceptance** + +- route 선언/실행 불일치와 telemetry producer 누락을 registry gate가 잡는다. +- compatibility impact가 하드코딩 상수가 아니라 actual diff다. +- 테스트가 source module만이 아니라 real composition과 `dist`를 증명한다. +- 모든 P0/P1 high-risk policy에 negative fixture가 하나 이상 있다. +- CI artifact로 browser/release/fixture 실패를 재현할 수 있다. + +**Rollback** + +RP-10은 직전 승인 registry snapshot과 test evidence다. flaky visual/browser +infrastructure commit은 product behavior와 분리한다. 장기 skip으로 PASS하지 않고 +owner와 만료 시한이 있는 quarantine만 허용한다. + +**구현 증거 (2026-07-26)** + +- VD-08에서 dev-only Storybook static workshop과 production build를 대상으로 한 + local Playwright visual baseline을 채택하고 cloud review는 선택 사항으로 + 분리했다. +- 10개 registry의 required field, runtime type, enum, unique, cross-reference, + consumer와 orphan을 검사하고 승인 snapshot digest와 현재 snapshot의 actual + semantic diff를 계산한다. 행·field·type·path뿐 아니라 registry 검증 계약 + 변경도 breaking evidence 대상이다. +- ordering-only 변경은 `none`, row addition은 `additive`, 일반 값 변경은 + `behavior-change`, 제거/type/path/contract 변경은 `breaking`으로 계산한다. + breaking에는 version, migration, compatibility window, rollback과 owner를 + 요구하며 digest 변조와 누락 fixture가 실제로 실패한다. +- API operation별 19개 shared MSW scenario catalog와 strict unhandled-request + server를 제공하고 reference vertical integration이 공통 envelope/handler를 + 사용한다. +- 기본 Playwright는 `build` + `preview`의 실제 `dist`를 Chromium, Firefox, + WebKit에서 검사하고 별도 compact project를 제공한다. 개발 피드백용 Vite + profile은 `playwright.dev.config.ts`로 분리했다. +- Storybook public primitive story, interaction과 axe test, wide/compact/ + pseudo/dark/state surface의 pinned Chromium visual baseline 4종을 CI evidence로 + 연결했다. +- V8 coverage와 12개 high-risk module을 대상으로 52개 scoped threshold를 + 적용하고 threshold 미달 fixture를 차단한다. +- deterministic clock/random/scheduler/storage, unexpected console/page error/ + request failure 정책, 무소유 skip과 full-screen mask 금지 gate를 제공한다. +- JUnit, HTML report, trace/screenshot, coverage, registry와 fixture artifact를 + 기존 26개 blocking gate taxonomy에 연결했다. + +### 11. `feature-frontend-supply-chain-verification` + +**목표** + +직접·전이 dependency, vulnerability, license, secret, SBOM/inventory와 provenance를 +검증 가능한 release gate로 만든다. + +**선행조건과 decision** + +- 10까지 최종 build/test dependency 구조가 안정돼 있어야 한다. +- VD-09에서 vulnerability source, license policy, SBOM format과 attestation + provider를 선택한다. +- provider가 미결정이면 임의 PASS를 만들지 않고 promotion을 + `FAIL_UNVERIFIED`로 유지한다. local inventory/fixture 검증은 완료할 수 있다. + +**구체 변경 범위** + +- frozen lockfile에서 direct/transitive dependency inventory를 생성한다. +- name/version, direct/transitive, resolved integrity와 license를 deterministic하게 + 기록한다. +- actual baseline/lockfile diff에서 new/removed/changed dependency와 + reviewer-required risk를 계산한다. +- secret scan 범위를 source/dist/tracked config/generated manifest와 artifact + metadata에 맞게 확장한다. +- 선택된 vulnerability/license adapter가 severity, exception owner/expiry를 + machine-readable evidence로 남기게 한다. +- SBOM 또는 동등 inventory, build digest와 provenance statement를 coherent + release artifact set에 연결한다. +- frozen install과 동일 source/lock/config의 reproducible build를 검증한다. + +**자동 테스트와 negative test** + +- transitive dependency inventory 누락 실패 +- lockfile integrity/digest 변조와 unfrozen install 실패 +- high-risk dependency의 self-approval 실패 +- 금지 license, threshold vulnerability와 만료 exception 실패 +- source/dist/config secret fixture 검출 및 test-secret allowlist 통제 +- SBOM/inventory와 release manifest digest 불일치 실패 +- provenance subject와 artifact digest 불일치 실패 +- dependency ordering만 달라도 deterministic output 유지 + +**Acceptance** + +- dependency diff와 high-risk review가 하드코딩 빈 배열이 아니다. +- transitive inventory, license와 vulnerability 결과가 release evidence에 + 포함된다. +- provider/organization decision이 없으면 상위 promotion이 명시적으로 + `FAIL_UNVERIFIED`다. +- 위험 dependency upgrade만 독립적으로 revert할 수 있다. + +**Rollback** + +RP-11은 P1 최종 저장소 기준선이다. scanner outage를 무검증 승인으로 우회하지 +않고 promotion을 보류한다. + +**구현 증거 (2026-07-26)** + +- VD-09에서 frozen pnpm graph와 lockfile을 local SSOT로, package manifest + license policy와 CycloneDX 1.6을 local evidence로 채택했다. 외부 vulnerability + report와 signed attestation이 없으면 promotion은 `FAIL_UNVERIFIED`다. +- 현재 직접 35개, 전체 전이 561개 dependency의 name/version, direct/scope/ + optional, SHA-512 integrity, license와 dependency edge를 deterministic + inventory로 생성한다. lockfile row와 inventory가 양방향 일치하지 않으면 + 실패한다. +- 승인 baseline digest와 actual add/remove/change/upgrade diff를 계산하고 새 + direct production dependency에는 owner와 다른 reviewer, reason과 rollback을 + 요구한다. +- CycloneDX SBOM component/edge와 local in-toto/SLSA 형태 provenance의 + source/lock/SBOM/dist digest를 coherence gate로 다시 계산한다. +- license allow/deny, vulnerability severity와 독립·만료 exception 정책, + provider lock digest와 attestation subject를 machine-readable하게 검증한다. + provider fixture는 promotion PASS를 증명한 뒤 unconfigured + `FAIL_UNVERIFIED`를 복원한다. +- secret scan을 source/scripts/tests/config/schema/public/dist/generated release + metadata로 확장하고 원문 대신 rule/path/line/fingerprint만 SARIF에 남긴다. + test-only allowlist도 owner/reason/expiry를 강제한다. +- `SOURCE_DATE_EPOCH` 기반 동일 build 2회 digest, 실제 frozen install mismatch, + transitive omission/integrity/baseline/self-review/license/vulnerability/ + provider/SBOM/provenance/secret negative fixture를 blocking gate에 연결했다. + +## 9. P1 exit gate + +- 현실적인 form의 validation/dirty/pending/422/conflict가 작동한다. +- collection/detail/form/status를 포함한 page template가 reference feature에서 + 사용된다. +- token → primitive → pattern → template 경계가 자동 보호된다. +- compact navigation과 interaction의 keyboard/focus 계약이 검증된다. +- typed message, pseudo-locale와 RTL smoke가 존재한다. +- 필수 telemetry event가 실제 producer에 연결되고 secret/PII가 redaction된다. +- registry/compatibility가 actual diff와 cross-reference로 검증된다. +- bootstrap과 built `dist`를 포함한 risk-based test/evidence가 남는다. +- transitive dependency, vulnerability, license와 local SBOM가 선택된 정책에 + 따라 검증된다. 외부 signing/provenance provider가 미결정이면 해당 promotion만 + `FAIL_UNVERIFIED`다. +- 현재 merge gate가 요구하는 등록 route의 signed manual accessibility evidence가 + 갱신된다. +- P0/P1 feature branch와 merge evidence가 local/origin에 모두 보존된다. + +위 조건이 모두 충족되면 `LOCAL_TEMPLATE_READY`다. 실제 hosting, IdP, +production sink와 field data 없이도 저장소 내부 상태는 달성할 수 있지만, 외부 +gate 없이는 production promotion을 통과했다고 보지 않는다. + +## 10. P2 브랜치 + +### 12. `feature-frontend-optional-adapter-recipes` + +**목표** + +모든 프로젝트에 dependency를 미리 설치하지 않고, 실제 요구가 확인된 capability의 +선택 기준, port/facade, fake adapter, failure policy와 test recipe를 제공한다. + +**선행조건과 decision** + +- P0/P1 public boundary가 안정돼 있어야 한다. +- VD-10에서 실제 소비 요구, owner, 보안 영향, bundle 예산과 제거 조건이 + 승인된 recipe만 구현한다. +- 승인되지 않은 recipe의 runtime dependency는 추가하지 않는다. + +**구체 변경 범위** + +| recipe | 기본 경계 | 반드시 다룰 실패 | +| --- | --- | --- | +| realtime | SSE/WebSocket connection + inbound event, Web Push window/worker, bounded Poll lease | disconnect, duplicate/out-of-order/gap, cursor/auth expiry, queue overflow, permission/subscription failure | +| offline/IndexedDB/OPFS | feature repository, resumable migration, journal-backed large-object store | blocked/versionchange, quota, corruption, revision/generation conflict, partial object write | +| Service Worker/Cache Storage | public response cache ownership와 update controller | stale worker, incomplete candidate, integrity/policy rejection, offline fallback | +| File/Blob/picker/download | opaque file vault, upload session, browser handoff/streamed save | dismissal/permission, size/type/signature, abort/session expiry, integrity/partial save | +| generated API | generated client를 gateway 뒤에 감싸는 facade | contract drift, unsupported field | +| feature flag | typed key, default와 stale policy | provider unavailable, unknown flag | +| Web Worker | request/result/cancel protocol | crash, stale result, transfer failure | +| multi-tab | versioned BroadcastChannel event | self-echo, duplicate, conflict | +| browser permission | clipboard/notification/media result port | denied, dismissed, unsupported | +| client workflow | Zustand/Redux Toolkit/state-machine local facade | reset, mismatch, server-state duplication | +| large data UI | virtualizer/data-grid facade | focus loss, stale row, scale limit | +| analytics/error sink | consent/redaction/sampling adapter | denied consent, queue full, unavailable | + +- 각 recipe에 설치/금지 조건, ownership, security/privacy, bundle 영향, fallback과 + 제거 절차를 기록한다. +- runnable fixture는 production entry에서 제외한 opt-in example/test entry에 + 둔다. +- Zustand와 Redux Toolkit을 동시에 기본 설치하지 않는다. +- browser가 DB/object store에 직접 접속하는 recipe는 만들지 않고 권한 있는 + backend/BFF protocol adapter를 사용한다. + +**자동 테스트와 negative test** + +- 선택 recipe의 fake adapter contract 및 unavailable fallback +- unsubscribe/cancel/cleanup 누락 fixture 실패 +- local adapter 밖의 vendor SDK direct import 실패 +- credential을 localStorage/telemetry/URL에 저장하는 fixture 실패 +- server state를 client workflow store에 복제하는 fixture 실패 +- recipe 제거 후 base typecheck/test/build 통과 +- opt-in하지 않은 recipe가 production bundle에 없음을 검증 + +**Acceptance** + +- 선택 capability는 port/facade, fake adapter, failure matrix, tests와 제거 + 절차를 가진다. +- 선택하지 않은 capability는 dependency와 production code를 늘리지 않는다. +- recipe 하나의 도입/제거가 다른 recipe와 P0/P1 runtime을 변경하지 않는다. + +**Rollback** + +RP-12는 recipe별 merge commit이다. optional adapter 문제 시 해당 recipe commit만 +revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 commit에 묶지 +않는다. + +**구현 증거 (2026-07-26)** + +- VD-10에서 실제 project 요구가 선택되지 않았음을 기록하고 12개 capability를 + 모두 `RECIPE_AVAILABLE`, production runtime dependency 0개로 유지했다. +- machine-readable catalog에 recipe별 trigger/forbidden 조건, boundary, + port/fake, failure matrix, lifecycle cleanup, project owner 요구, + security/privacy, gzip budget, fallback, server-state 정책과 제거 순서를 + 등록했다. +- production-excluded `recipes/frontend-capabilities`에 12개 vendor-neutral + TypeScript port와 deterministic fake, fail-closed unavailable adapter를 + 제공한다. 프로젝트는 선택한 최소 계약만 application/presentation 경계로 + 복사하고 concrete adapter를 composition에서 연결한다. +- realtime ordering/unsubscribe, offline migration/close, worker cancel, + multi-tab dedupe, permission result, workflow reset, large-data stale + generation, analytics consent/redaction/queue와 나머지 facade contract를 + runnable test로 검증한다. +- cleanup 누락, 승인되지 않은 dependency, vendor direct import, credential + storage/URL/telemetry 경로, server-state store 복제와 production recipe import + negative fixture를 blocking gate에 연결했다. +- recipe와 recipe test를 통째로 제거한 임시 사본에서 base typecheck, + architecture, 전체 test와 production build를 실행하며, opt-in하지 않은 + sentinel이 built `dist`에 없는지 검사한다. +- 상세 도입/배치/검증/제거 절차는 + `docs/architecture/optional-adapter-recipes.md`에 기록했다. +- 2026-07-27 후속 설계에서 VD-11을 추가하고 File/Blob/picker/download를 + picker/content/upload-session/download-delivery로 분리했다. IndexedDB는 + schema/codec, transaction complete, blocked/versionchange, resumable migration + 계약으로 보강하고, OPFS는 IndexedDB journal 기반 crash-safe large-object + sub-capability, Cache Storage는 public response 전용 release-candidate + sub-capability로 명시했다. +- 같은 후속 구현에서 해당 browser-native sub-capability를 정책 주입형 + `AVAILABLE_NOT_COMPOSED` reference runtime으로 `src/adapters`에 구현했다. + 제품별 schema/codec/query/retention/cache allowlist는 주입하며, 선택 전에는 + bootstrap import를 정적 gate로 금지하고 실제 browser conformance suite로 + native API 경로를 검증한다. +- `browser-file-storage-contracts.ts`와 deterministic fake/test가 CAS/idempotency, + quota, picker dismissal, bounded range/stream, partial OPFS write visibility, + generation conflict, cache exact query/integrity policy를 실행 가능하게 검증한다. + reference runtime source는 존재하지만 production composition의 optional + runtime module 수는 계속 0개이며, 실제 선택 branch는 + real-browser/fault/runbook evidence 없이 `INSTALLED`로 승격할 수 없다. +- 2026-07-28 후속 설계와 구현에서 VD-12를 추가했다. presigned capability는 + BFF control plane과 browser/object-storage data plane을 분리하고, multipart + upload는 server-authoritative reconcile과 non-secret IndexedDB checkpoint, + streaming download는 bounded closed-result source, Image CDN은 opaque asset과 + named preset 기반 responsive descriptor로 구현한다. 제품 endpoint/CDN policy가 + 없으므로 모두 `AVAILABLE_NOT_COMPOSED`이며 bootstrap에는 연결하지 않는다. +- generic multi-tab recipe와 별도로, installed TanStack query의 좁은 + invalidate-only protocol은 2026-07-28 RP-03 infrastructure로 선택·조립했다. + query state/payload를 복제하지 않고 persistence는 계속 disabled다. 기존 + IndexedDB reference runtime은 이 선택 때문에 bootstrap에 조립되지 않는다. +- 같은 날 VD-28과 + [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)을 + 추가했다. SSE, WebSocket, Web Push와 Polling을 별도 delivery capability로 + 분리하고 공통 delivery guarantee 없음, duplicate-tolerant CURSOR 처리, + gap/snapshot checkpoint, scope generation, bounded lifecycle과 promotion 계약을 + 닫았다. 후속 RT-01~04에서 공통 authority, fetch-stream SSE, bounded Polling, + closed WebSocket, Web Push window/worker와 단일 writer handoff/reconnect + coordinator를 구현해 reusable runtime은 `AVAILABLE_NOT_COMPOSED`로 올렸다. + 제품 endpoint/event registry/provider selection과 production composition은 + 없으므로 product는 계속 `NOT_SELECTED`다. +- 2026-07-28 API-05/API-06 공통 선행 구현으로 Browser RPC V3 operation/provider + registry, typed application port, provider-neutral unary/server-stream lifecycle + coordinator와 fail-closed unavailable adapter를 추가했다. exact registry join, + bounded retry/deadline/abort, stream idle/total/message/terminal, scope generation을 + test하므로 이 공통 층만 `AVAILABLE_NOT_COMPOSED`다. descriptor/codegen, + Connect-Web/official grpc-web transport와 실제 proxy/browser conformance는 + 포함하지 않는다. + +### 12.1 Browser data 후속 work package + +RP-12의 reference runtime 존재는 browser data platform 전체의 구현 완료를 +뜻하지 않는다. 현재 상태와 상세 exit criteria는 +[Browser data capability completion ledger](./browser-data-capability-completion-ledger.md)를 +단일 기준으로 사용한다. 후속 구현은 다음 독립 package로 진행한다. + +| 순서 | work package | 핵심 exit | +| --- | --- | --- | +| BD-01 | scope-safe client cache | logout/account switch의 cancel/fence/clear/dispose와 late-result 차단 | +| BD-02 | Range resumable download | precondition-mode별 allowed `200/206/412/416`, seek/truncate와 final integrity | +| BD-03 | origin storage lifecycle | cross-store pressure/GC, bounded maintenance와 N-1 migration/rollback | +| BD-04 | transfer operational composition | atomic config/factory, account teardown, pause/retention과 provider harness | +| BD-05 | Image descriptor delivery | fixed BFF provider, refresh fence, safe picture projection과 actual CDN 증거 | + +각 package는 다음 순서를 지킨다. + +```text +ledger + ADR accepted + -> closed port/policy/schema + -> deterministic contract/fault evidence + -> reference runtime + boundary/removal gate + -> AVAILABLE_NOT_COMPOSED + -> product/provider selection + -> bootstrap composition + three-engine evidence + -> COMPOSED +``` + +directory/persistent file handle, Query persistence, offline mutation, +Service Worker fetch interception, private/range Cache와 app-managed background +transfer는 위 package에 암묵적으로 포함하지 않는다. 각각 `NOT_SELECTED`에서 +별도 ADR과 owner 승인을 거쳐야 한다. + +### 12.2 API·Schema·Mapper·Server State 후속 work package + +REST reference vertical의 `COMPOSED` 판정과 multi-protocol production hardening을 +구분한다. 현재/목표 상태, protocol 선택 기준과 완료 조건은 +[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)가 +소유한다. Browser Protobuf runtime과 gateway branch의 세부 기준은 +[Protobuf browser transport와 REST Gateway](./protobuf-browser-transport-and-rest-gateway.md)를 +따른다. + +| 순서 | work package | 핵심 exit | +| --- | --- | --- | +| API-01 | REST v2 execution | auth fail-close/final invariant, collision-free registry, total deadline, bounded status/media decoder | +| API-02 | typed Schema·Mapper | codec/mapper type proof, semantic fingerprint/provenance, scalar/null/enum/collection ceiling | +| API-03 | strict Server State | bound query definition/key/policy, cursor page, mutation concurrency와 revision/CAS optimistic rollback | +| API-04 | persisted GraphQL | allowlisted manifest, data/errors/partial state, cost/auth/CSRF와 actual router conformance | +| API-05 | gRPC-Web | descriptor/codegen, frame/trailer/status, unary/server-stream budget와 actual proxy/browser conformance | +| API-06 | Connect-Web | exact Connect/gRPC-Web transport row, JSON/binary/GET/EndStream, bounded decode와 actual server/browser conformance | +| API-07 | Protobuf REST Gateway | schema/codegen provenance, HttpRule/ProtoJSON/OpenAPI와 selected gateway conformance | +| API-08 | operations/composition | contract-set release binding, provider kill switch, coherent rollback과 protocol dependency removal | + +API-05/API-06가 공유하는 provider-neutral registry와 lifecycle coordinator는 +`AVAILABLE_NOT_COMPOSED`다. 그러나 각 package의 wire adapter exit는 아직 +충족되지 않았으므로 API-05/API-06 전체를 완료로 표시하지 않는다. + +GraphQL, Connect, gRPC-Web과 REST Gateway는 제품 operation/provider 선택 전 +`NOT_SELECTED`다. 설계 문서가 있다는 이유로 dependency/generated source/proxy +config를 기본 bundle에 넣지 않는다. browser client/bidirectional streaming은 +구현 backlog가 아니라 `PLATFORM_LIMITED`이며 별도 transport 요구로 다시 설계한다. + +권장 순서는 API-01 → API-02 → API-03이다. API-04~07은 실제 backend contract가 +선택된 branch만 구현한다. + +### 12.3 Realtime delivery 후속 work package + +copyable recipe의 교육용 `RealtimePort`와 fake는 production runtime 증거가 +아니다. 현재 상태, +transport 선택, event authority와 완료 기준은 +[Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)이 +소유한다. + +| 순서 | work package | 핵심 exit | +| --- | --- | --- | +| RT-01 | common event authority | closed registry/envelope, scope generation, sequential apply, dedupe/gap/cursor와 snapshot reset | +| RT-02 | SSE + bounded polling | bounded fetch-stream parser, reconnect/heartbeat와 visible-only single-flight Poll lease | +| RT-03 | WebSocket | fixed subprotocol, heartbeat/close, bounded inbound/outbound queue와 resume | +| RT-04 | Web Push | permission/subscription facade, worker push/click, safe notification와 provider lifecycle | +| RT-05 | product composition/operations | backend replay/outbox/auth, hosting/provider/browser evidence, canary/kill switch/drill | + +RT-01~04의 source, contract/native test, boundary/bundle/removal gate가 통과한 +runtime만 `AVAILABLE_NOT_COMPOSED`로 올린다. 실제 event/notification registry와 +provider가 bootstrap/worker에 조립된 선택 capability만 `COMPOSED`다. +exactly-once/global ordering, always-on background connection과 timely +cross-browser Web Push guarantee는 구현 backlog가 아니라 `PLATFORM_LIMITED`다. + +## 11. Vendor decision gate + +| ID | 시점 | 결정 | 기본값 또는 미결정 시 처리 | 차단 범위 | +| --- | --- | --- | --- | --- | +| VD-01 | 01 전 | TypeScript migration 범위와 compiler 변경 | 기존 compiler 고정, tooling-first 점진 전환 | compiler upgrade와 source 변환 | +| VD-02 | 02 전 | 범용 client store | 추가하지 않음. local/URL/query/context 사용 | 실제 cross-feature workflow만 | +| VD-03 | 04 전 | React Router mode | Data Mode; server state는 TanStack/application 유지 | Framework/SSR 전환만 | +| VD-04 | 06 전 | form engine | 복합 form이면 RHF+Zod facade, 아니면 local/native | form 구현체 | +| VD-05 | 07 전 | icon/headless UI | local facade, native 우선; 미지원 behavior는 미완료 | icon/복합 primitive | +| VD-06 | 08 전 | i18n engine | `Intl` + typed local catalog | extraction/plural 고급 기능 | +| VD-07 | 09 전 | diagnostics/telemetry exporter | no-op/best-effort HTTP | production sink만 | +| VD-08 | 10 전 | Storybook/visual 방식 | dev-only Storybook + local Playwright baseline | cloud review/별도 배포 | +| VD-09 | 11 전 | vulnerability/license/SBOM/provenance | 임의 PASS 금지, `FAIL_UNVERIFIED` | release promotion | +| VD-10 | 12 전 | optional capability 요구 | 설치하지 않음 | 해당 recipe만 | +| VD-23 | API-01 전 | protocol 선택과 REST execution | operation별 protocol 고정, installed REST 유지 | REST v2 hardening | +| VD-24 | API-02 전 | runtime Schema와 Mapper proof | typed codec → pure Result mapper | multi-protocol contract/codegen | +| VD-25 | API-03 전 | Server State Cache lifecycle | TanStack sole owner, bound query/mutation profile | query/cache/mutation hardening | +| VD-26 | API-04 전 | GraphQL | persisted operation only, 기본 미선택 | GraphQL operation family | +| VD-27 | API-05 전 | gRPC-Web | unary/server-stream만, 기본 미선택 | gRPC-Web operation family | +| VD-29 | API-06 전 | Connect-Web/Connect | exact runtime/wire profile, 기본 미선택 | Connect operation family | +| VD-30 | API-07 전 | Protobuf/REST Gateway | current REST는 BFF 유지, direct gateway 기본 미선택 | schema/codegen/gateway branch | +| VD-28 | RT-01 전 | realtime/Web Push/Polling | focus refetch 우선, transport별 opt-in과 bounded fallback | realtime delivery capability | + +각 decision에는 실제 요구, bundle/runtime/a11y/security 영향, local facade 방식, +대안, fallback/migration/removal, owner와 재검토 시점을 기록한다. + +결정이 지연될 때 vendor-neutral port와 no-op/fake/unavailable adapter를 먼저 +완성한다. 다만 vendor가 있어야 충족되는 behavior를 구현하지 않은 채 완료로 +표시해서는 안 된다. + +## 12. Rollback과 promotion + +| checkpoint | 포함 범위 | 다음 단계 진입 조건 | 대표 rollback 사유 | +| --- | --- | --- | --- | +| RP-01 | tooling | TS/TSX fixture와 gate 통과 | TS 파일이 검사에서 누락 | +| RP-02 | application boundary | composition integration 통과 | UI가 raw adapter에 의존 | +| RP-03 | HTTP/query | request/state negative matrix 통과 | query 손실, retry/submit 폭주 | +| RP-04 | routing/recovery | registry와 reload-loop drill 통과 | invalid URL API 호출, reload loop | +| RP-05 | P0 reference | feature 존재/제거 gate 통과 | hidden sample coupling | +| RP-06 | form/page | form/page/a11y gate 통과 | 입력 손실, template 결합 | +| RP-07 | design system | token/interaction gate 통과 | focus/theme/vendor 회귀 | +| RP-08 | i18n | catalog/pseudo/RTL gate 통과 | missing key, locale 회귀 | +| RP-09 | diagnostics | producer/redaction gate 통과 | PII 노출, event 폭주 | +| RP-10 | test/registry | actual diff와 evidence gate 통과 | false PASS, flaky blocker | +| RP-11 | P1 supply chain | `LOCAL_TEMPLATE_READY` | 위험 dependency/검증 공백 | +| RP-12 | optional recipe | recipe별 opt-in gate | vendor 격리 실패 | + +```text +RP-05 + P0 exit + = LOCAL_CORE_READY + +RP-11 + P1 exit + = LOCAL_TEMPLATE_READY + +LOCAL_TEMPLATE_READY + + 실제 hosting/IdP/보안/접근성/운영 증거 + = PROJECT_INTEGRATION_READY + +PROJECT_INTEGRATION_READY + + production promotion과 eligible field data + = PRODUCTION/FIELD READY +``` + +저장소 내부와 외부 증거를 하나의 백분율로 숨기지 않는다. P0/P1의 모든 +acceptance가 자동 검증되고 외부 항목이 명확히 분리된 +`LOCAL_TEMPLATE_READY`를 높은 저장소 내부 준비도의 실질 기준으로 사용한다. + +## 13. 브랜치 공통 인수 체크리스트 + +각 feature branch는 병합 전에 다음을 evidence에 남긴다. + +- 목표와 변경하지 않는 범위 +- 시작한 `develop` SHA와 선행 merge SHA +- 변경한 public contract, registry와 compatibility 영향 +- positive/negative fixture +- 실행한 typecheck, lint, architecture, unit, integration, E2E와 build 명령 +- artifact와 browser/runtime 환경 +- dependency/lockfile 변경 및 vendor decision ID +- fake/no-op/unavailable adapter로 검증한 범위 +- external blocker와 `FAIL_UNVERIFIED` promotion +- merge commit과 보존된 local/origin feature tip +- rollback 대상 merge와 복구 확인 gate + +acceptance를 만족하지 못한 브랜치는 후속 브랜치의 기반으로 사용하지 않는다. +test를 skip하거나 문서상 예외로 바꾸는 것은 완료가 아니다. diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md new file mode 100644 index 0000000..0b64fe7 --- /dev/null +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -0,0 +1,1356 @@ +# 프론트엔드 포트·어댑터와 경계 설계 + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +## 1. 문서 목적 + +이 문서는 이 저장소에서 도메인 기능을 추가할 때 사용할 경계와 확장 +절차를 정의한다. 다음 질문에 대한 단일 기준 문서다. + +- 프론트엔드에서 inbound와 outbound는 무엇인가? +- `presentation`은 왜 inbound adapter인가? +- application이 노출해야 할 API와 외부 기술 포트는 어떻게 다른가? +- React, Router, TanStack Query, HTTP, 인증, 저장소, 로깅을 어디에 + 배치하는가? +- 새 기술 adapter나 새 feature를 어떤 순서로 추가하는가? +- 예제 feature가 제품 코드에 남지 않았음을 어떻게 증명하는가? + +포트의 방향은 네트워크 패킷의 방향이 아니라 **application을 기준으로 +누가 누구를 호출하는지**로 결정한다. + +```text +사용자·브라우저 이벤트 + -> inbound adapter + -> input port + -> application use case + -> output port + -> outbound adapter + -> 외부 시스템 또는 브라우저 기능 +``` + +화살표는 런타임 호출 방향이다. 소스 의존성은 가능한 한 안쪽을 향한다. +구체 기술의 선택과 조립은 `bootstrap`에서만 수행한다. + +## 2. 핵심 용어 + +### 2.1 Input port + +외부가 application에 요청할 수 있는 작업의 계약이다. 사용자의 의도를 +나타내며 기술 이름 대신 업무 동사를 사용한다. + +예: + +- `ListReferenceItems` +- `CreateReferenceItem` +- `BeginSignIn` +- `ChangeColorScheme` +- `RecoverSession` + +Input port는 React hook, HTTP request, router loader 자체가 아니다. React나 +router가 없어도 테스트할 수 있는 함수 또는 인터페이스여야 한다. + +### 2.2 Inbound adapter + +사용자 입력이나 외부 이벤트를 input port 호출로 변환한다. 현재 저장소의 +`src/presentation`은 이름에 `adapter`가 없지만 이 역할을 담당한다. + +대표적인 inbound adapter: + +- React page와 component +- React Router route, loader, action, guard +- form controller와 feature hook +- 키보드, pointer, browser lifecycle event handler +- WebSocket이나 Service Worker가 수신한 이벤트를 use case로 전달하는 + event handler + +### 2.3 Output port + +application이 작업을 완료하기 위해 외부에 요구하는 capability 계약이다. +application이 소유하고 outbound adapter가 구현한다. + +대표적인 output port: + +- 도메인별 gateway 또는 repository +- session gateway +- clock, ID generator +- preference persistence +- logger, telemetry, error reporter +- feature flag reader + +Output port에는 `fetch`, `localStorage`, 특정 SDK 객체처럼 기술 구현이 +드러나지 않아야 한다. + +### 2.4 Outbound adapter + +output port를 실제 기술로 구현한다. + +대표적인 outbound adapter: + +- Fetch 기반 API gateway +- 외부 인증 SDK session gateway +- `localStorage`, `sessionStorage`, IndexedDB persistence +- telemetry 또는 error-reporting sink +- browser clock, random, crypto UUID +- feature flag SDK + +### 2.5 Bootstrap + +구체 구현을 선택하고 연결하는 composition root다. bootstrap만 다음 +결정을 알고 있어야 한다. + +- 어떤 HTTP transport를 사용할지 +- 어떤 인증 owner를 연결할지 +- 어떤 logger와 telemetry sink를 사용할지 +- QueryClient 설정은 무엇인지 +- 어떤 input port 구현을 `ApplicationProvider`에 공급할지 + +bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다. + +## 3. 현재 저장소의 구조 해석 + +현재 구조는 다음과 같이 해석한다. + +| 현재 경로 | 현재 역할 | 목표 역할 | +| --- | --- | --- | +| `src/domain` | 순수 model과 invariant | framework-neutral domain | +| `src/application` | port, policy, 일부 use case와 view model | input/output port와 use case | +| `src/presentation` | React UI, route, provider, 상태 화면 | inbound React adapter | +| `src/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter | +| `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root | +| `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 | +| `src/features/reference-feature` | 완전한 제거 가능 수직 예제 | installed contribution과 제거 gate 유지 | + +현재 구조가 잘 제공하는 기반은 다음과 같다. + +- bootstrap 이전 runtime config와 release 검증 +- 인증 credential을 UI에 노출하지 않는 session seam +- bounded retry와 idempotency 정책 +- HTTP envelope 및 payload runtime validation +- 저장소 key registry와 민감정보 저장 금지 +- TanStack Query client 기본 정책 +- semantic telemetry allowlist +- render boundary와 async state surface +- route registry와 lazy page + +RP-02 구현으로 다음 경계는 실행 경로에 연결됐다. + +- `src/bootstrap/composition-root.ts`가 만든 application을 production + `ApplicationProvider`가 실제 React tree에 주입한다. +- `createApplication`은 session, preference, diagnostics, runtime query와 typed + feature input registry만 반환하며 storage, telemetry, release output port를 + 숨긴다. +- bootstrap composition 결과는 raw output port를 반환하지 않고 + application과 React infrastructure만 반환한다. +- presentation의 direct fetch/browser storage/concrete adapter/TanStack import와 + application의 React/concrete adapter import는 negative fixture가 거절한다. + +RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다. + +- `src/presentation/adapters/query`만 TanStack Query import를 허용하며 + application query/mutation을 cancellation, invalidation, deduplication, + optimistic rollback과 conflict 해제에 연결한다. +- HTTP request builder는 path escaping과 canonical search를 소유하고 Zod가 + 변환한 search/body를 실제 request에 사용한다. +- runtime timeout과 max retry attempts가 transport factory에 주입되며 + validation, success, abort, timeout과 exhausted retry의 timer/listener + 정리를 테스트한다. +- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는 + 비활성화한다. +- reference HTTP operation map은 operation ID마다 route ID, request shape와 + 성공 model type을 결합한다. runtime schema와 mapper를 통과한 raw executor는 + 단일 binder에서만 typed executor로 승격되므로 gateway별 응답 cast가 없다. + +위 항목은 reference REST vertical의 `COMPOSED` 증거다. auth owner final-request +invariant, auth unavailable fail-close, total deadline, bounded response decoder, +typed Schema/Mapper proof, complete pagination과 mutation concurrency 같은 +production hardening delta는 +[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)에 +별도 `DESIGNED_NOT_IMPLEMENTED`로 기록한다. + +RP-04에서 route 실행 불일치는 닫혔다. route registry와 runtime map은 +Data Router tree, codec, surface, title, navigation, chunk/release recovery의 +단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다. + +RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/ +contract/presentation은 `src/features/reference-feature`가 소유하고, generic +installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를 +삭제한 복제본에서 전체 P0 경로를 다시 실행한다. + +generic application의 `ApplicationFeatureInputs`는 concrete feature를 import하지 +않는 open interface다. 각 feature application API가 module augmentation으로 +자신의 literal ID와 input shape를 기여하고, `features.get(id)`는 ID별 정확한 +input type을 반환한다. 잘못된 ID/input과 설치 누락은 각각 compile-time negative +fixture와 runtime guard로 닫는다. + +RP-06에서 inbound form/page 경계도 실행됐다. `src/presentation/forms`는 Zod +presentation schema, controlled field state, error focus, 422 allowlist, +pending/deduplication과 dirty navigation을 local facade로 감싼다. +`src/presentation/templates`는 slot과 landmark만 소유하고 application, HTTP, +query vendor import는 architecture gate가 거절한다. reference feature의 +list/detail/create/status route가 각각 Collection/Detail/Form/Status template의 +실제 consumer다. + +RP-07에서 React inbound adapter 안의 UI 공급자 경계도 닫혔다. +`src/presentation/design-system/index.ts`는 token → primitive → pattern → +template public API이며 feature와 shell은 이 entry만 소비한다. Lucide는 +`icons/vendors/lucide.tsx`에 격리된 inbound vendor facade이므로 application +port가 아니다. native Dialog/Drawer/Menu/Tabs의 focus·keyboard 상태도 +presentation이 소유하고 use case나 outbound adapter로 올리지 않는다. + +RP-08에서 i18n은 application output port가 아니라 React inbound adapter의 +local facade로 확정됐다. domain/application은 locale이나 번역 문장을 알지 않고 +timestamp, number, failure kind 같은 의미 값만 반환한다. +`src/presentation/i18n`이 typed message catalog, formatter, fallback, +``과 pseudo/RTL smoke를 소유한다. backend raw `message`는 +application failure registry를 우회해 렌더링할 수 없으며 `check:i18n` negative +fixture가 이 경계를 집행한다. 번역 vendor를 나중에 선택해도 이 facade 뒤의 +adapter만 교체한다. + +이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 +불일치를 제거하는 것이다. + +## 4. Inbound와 outbound 분류표 + +| 관심사 | 방향 | Port 소유자 | Adapter 예 | 기본 제공 여부 | +| --- | --- | --- | --- | --- | +| React page와 component | inbound | application input API 소비 | React | 필수 | +| Routing과 navigation | inbound | route input/controller | React Router | 필수 | +| Form submit과 validation 표시 | inbound | command input port 소비 | React form controller | 필수 | +| Server-state query hook | inbound bridge | application input API 소비 | TanStack Query hook | 필수 | +| validated external server event | inbound | event input port | SSE/WebSocket listener, Service Worker push handler | 선택 | +| 도메인 API 접근 | outbound | application | Fetch gateway | 필수 | +| 인증 session | outbound | application | 외부 auth owner/SDK | 필수 seam | +| Credential attachment | outbound | transport 또는 auth integration | auth request decorator | 필수 seam | +| Preference persistence | outbound | application | browser storage | 필수 | +| 시간·ID·random | outbound | application | browser platform | 필수 | +| Logging·telemetry | outbound | application | console/remote sink | 필수 | +| Offline persistence | outbound | application | IndexedDB | 선택 | +| Feature flag | outbound | application | flag SDK | 선택 | +| Locale/message/formatter | UI platform | React inbound 계약 | `Intl` + local catalog | 필수 seam, vendor 선택 | +| 외부 오류 수집 | outbound | application | error-reporting SDK | 선택 | + +WebSocket은 한 단어로 항상 inbound 또는 outbound가 아니다. 연결을 열고 +메시지를 전송하는 기능은 application이 요구하는 output capability일 수 +있고, 수신된 메시지를 use case로 전달하는 handler는 inbound adapter다. +두 책임을 하나의 거대한 interface로 합치지 않는다. + +## 5. 왜 `presentation`이 inbound adapter인가 + +React component는 사용자의 클릭, 입력, URL 이동을 해석한다. 그 결과를 +application input port가 이해하는 command나 query로 바꾸고, 반환된 +view state를 HTML로 표현한다. 이는 전형적인 driving/inbound adapter다. + +```text +Button click + -> React event handler + -> CreateReferenceItem command + -> CreateReferenceItem input port + -> use case +``` + +따라서 `presentation`이 adapter라는 사실은 이름에 `adapter`가 붙었는지와 +관계없다. 다음 두 구조 모두 가능하다. + +```text +src/presentation # presentation을 inbound로 문서화 +src/adapters/outbound +``` + +또는 더 명시적으로: + +```text +src/adapters/inbound/react +src/adapters/outbound +``` + +이 저장소의 장기 목표는 두 번째 구조다. 점진적 전환 중에는 +`presentation = inbound/react`로 동일하게 취급한다. + +Presentation이 inbound라는 이유로 모든 component가 use case interface를 +가져야 하는 것은 아니다. `Button`, `Card`, `Dialog` 같은 순수 UI primitive는 +입력 props와 DOM behavior만 소유한다. page, controller hook, route +loader/action처럼 application 작업을 시작하는 경계에서 input port를 +사용한다. + +## 6. 브라우저가 서버 데이터 기술을 직접 연결하지 않는 이유 + +브라우저 adapter는 Redis, PostgreSQL, MongoDB, Kafka, MinIO 같은 서버 +인프라에 직접 연결하지 않는다. + +그 이유는 다음과 같다. + +- 브라우저 bundle과 runtime config는 사용자가 읽을 수 있어 서버 + credential을 안전하게 보관할 수 없다. +- 데이터베이스나 broker를 인터넷에 직접 노출하면 네트워크와 권한 경계가 + 무너진다. +- 브라우저는 신뢰할 수 없는 실행 환경이므로 authorization과 데이터 + invariant를 강제할 수 없다. +- DB driver, broker protocol, connection pool과 migration은 브라우저의 + lifecycle 및 bundle 제약과 맞지 않는다. +- 클라이언트 버전은 사용자마다 다를 수 있으므로 저장소 schema와 직접 + 결합하면 호환성 관리가 불가능해진다. + +브라우저는 HTTPS API, BFF, GraphQL, WebSocket 또는 SSE처럼 서버가 +노출한 제한된 계약과 통신한다. + +```text +Browser + -> HTTP/WebSocket adapter + -> Backend API/BFF + -> Redis/PostgreSQL/MongoDB/Kafka/MinIO +``` + +MinIO object URL을 받아 파일을 업로드하는 경우에도 브라우저 adapter는 +MinIO 관리자 credential이나 내부 API를 소유하지 않는다. 서버가 발급한 +짧은 수명의 제한된 URL과 업로드 정책만 사용한다. + +## 7. 권장 목표 디렉터리 구조 + +```text +src/ + core/ + kernel/ + result.ts + failure.ts + identifiers.ts + domain/ + application/ + ports/ + in/ + out/ + use-cases/ + policies/ + + adapters/ + inbound/ + react/ + app/ + application-provider.tsx + app-router.tsx + routing/ + query/ + pages/ + components/ + design-system/ + state/ + outbound/ + http/ + fetch-transport.ts + request-builder.ts + response-decoder.ts + retry-decorator.ts + auth/ + persistence/ + observability/ + platform/ + + features/ + reference-feature/ + domain/ + application/ + ports/ + in/ + out/ + use-cases/ + adapters/ + inbound/ + react/ + outbound/ + http/ + contracts/ + index.ts + + bootstrap/ + composition-root.ts + runtime-config.ts + main.tsx +``` + +`core`에는 모든 feature가 공유하는 작은 kernel과 truly cross-cutting +application capability만 둔다. `shared`, `common`, `utils` 같은 이름으로 +기술과 업무 규칙을 무제한 혼합하지 않는다. + +`features/reference-feature`는 구조를 설명하기 위한 완전한 수직 +슬라이스다. 이 폴더를 삭제하고 route 등록 한 곳만 제거했을 때 +typecheck, architecture check, test와 build가 모두 통과해야 한다. + +## 8. Input port 설계 규칙 + +### 8.1 사용자의 의도를 이름으로 표현한다 + +좋은 예: + +```ts +export interface ListReferenceItems { + execute( + query: ListReferenceItemsQuery, + context: RequestContext, + ): Promise>; +} +``` + +피해야 할 예: + +```ts +export interface HttpClient { + get(url: string): Promise; +} +``` + +Input port는 “HTTP GET을 실행한다”가 아니라 “항목을 조회한다”를 +표현한다. + +### 8.2 React와 browser type을 포함하지 않는다 + +Input port에 다음 type을 넣지 않는다. + +- `ReactNode`, `SyntheticEvent` +- `Request`, `Response`, `Headers` +- `QueryClient`, `UseQueryResult` +- `Location`, router navigate function +- `Storage`, `Window`, `Document` + +Inbound adapter가 해당 type을 application command와 query로 변환한다. + +### 8.3 예측 가능한 실패를 typed result로 반환한다 + +검증 실패, 인증 필요, conflict, network failure처럼 사용자 흐름에 +포함되는 실패는 `Result`로 반환한다. programmer error와 +불변식 위반을 모두 일반 API 실패로 숨기지는 않는다. + +```ts +export type Result = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: Failure }>; +``` + +`AppFailure.kind`는 error registry의 key에서 파생한다. adapter가 받은 외부 +오류 code는 이 닫힌 vocabulary로 매핑한 뒤 application 경계를 통과하며, +transport 문맥의 `ApiFailure`는 같은 type을 가리키는 호환 alias다. + +### 8.4 Input port를 기술별로 합치지 않는다 + +`ApplicationService` 한 개에 모든 메서드를 계속 추가하지 않는다. +feature 단위 또는 응집된 capability 단위로 분리한다. + +```ts +export interface ReferenceFeatureApplication { + readonly list: ListReferenceItems; + readonly create: CreateReferenceItem; +} +``` + +## 9. Output port 설계 규칙 + +### 9.1 Application이 port를 소유한다 + +도메인별 gateway는 feature의 application 경로에 둔다. + +```ts +export interface ReferenceItemGateway { + list( + query: ListReferenceItemsQuery, + context: RequestContext, + ): Promise>; + + create( + command: CreateReferenceItemCommand, + context: RequestContext, + ): Promise>; +} +``` + +Fetch adapter는 이 interface를 구현하지만 application은 Fetch adapter를 +import하지 않는다. + +### 9.2 기술보다 capability를 표현한다 + +다음 이름을 우선한다. + +- `ReferenceItemGateway` +- `SessionGateway` +- `PreferenceStore` +- `Clock` +- `IdGenerator` +- `Logger` + +다음처럼 concrete 기술을 core port 이름에 포함하지 않는다. + +- `AxiosPort` +- `LocalStoragePort` +- `RedisPort` +- `TanStackPort` +- `SentryPort` + +### 9.3 Interface segregation을 지킨다 + +화면의 session 작업과 HTTP credential 부착은 호출 주체가 다르므로 +분리한다. + +```ts +export interface SessionGateway { + snapshot(): SessionSnapshot; + subscribe(listener: () => void): () => void; + beginSignIn(returnTo?: string): Promise; + signOut(): Promise; + recover(): Promise; +} + +export interface CredentialAttacher { + attach(request: Request): Promise; +} +``` + +`Request` type을 완전히 application 밖으로 유지하려면 +`CredentialAttacher`는 outbound HTTP 내부 계약으로 둔다. Session use +case는 `SessionGateway`만 안다. + +### 9.4 Clock과 ID도 concrete 구현과 분리한다 + +Application port 파일에 `Date.now`, `setTimeout`, `crypto.randomUUID` +구현을 함께 두지 않는다. + +```text +core/application/ports/out/clock.ts +adapters/outbound/platform/browser-clock.ts +adapters/outbound/platform/browser-id-generator.ts +``` + +### 9.5 범용 HTTP client를 application port로 노출하지 않는다 + +범용 transport는 outbound adapter 내부의 재사용 기술이다. 각 feature의 +gateway adapter가 transport를 사용해 DTO를 domain model로 매핑한다. + +```text +Application -> ReferenceItemGateway +ReferenceItemHttpGateway -> HttpTransport +HttpTransport -> fetch +``` + +이렇게 하면 application이 URL, method, header, response envelope를 알지 +않으며 API 기술을 교체할 수 있다. + +## 10. Composition root와 `ApplicationProvider` + +### 10.1 조립 순서 + +권장 조립 순서는 다음과 같다. + +```text +runtime config 검증 + -> release coherence 검증 + -> platform adapters 생성 + -> outbound gateways 생성 + -> use cases 생성 + -> application input API 생성 + -> React infrastructure provider 생성 + -> ApplicationProvider + -> Router +``` + +예: + +```ts +export function createApplicationComposition(runtime: RuntimeConfig) { + const clock = createBrowserClock(); + const logger = createRedactedLogger(runtime); + const sessionGateway = createExternalSessionGateway(runtime.auth); + const httpTransport = createHttpTransport({ + baseUrl: runtime.apiBaseUrl, + timeoutMs: runtime.requestTimeoutMs, + maxRetryAttempts: runtime.maxRetryAttempts, + logger, + }); + const referenceGateway = createReferenceItemHttpGateway(httpTransport); + + const reference = Object.freeze({ + list: createListReferenceItems({ gateway: referenceGateway, clock }), + create: createCreateReferenceItem({ gateway: referenceGateway, clock }), + }); + + const session = createSessionApplication({ gateway: sessionGateway, logger }); + + return Object.freeze({ + application: Object.freeze({ reference, session }), + infrastructure: Object.freeze({ + queryClient: createQueryClient(), + themeStorage: createThemeStorage(), + }), + }); +} +``` + +### 10.2 `ApplicationProvider`가 노출할 것 + +`ApplicationProvider`는 presentation이 호출할 input port만 노출한다. + +```ts +export interface ApplicationApi { + readonly reference: ReferenceFeatureApplication; + readonly session: SessionApplication; +} +``` + +다음 concrete 객체는 provider value에 넣지 않는다. + +- Fetch client +- raw HTTP client +- browser storage +- telemetry SDK +- auth SDK owner +- domain gateway adapter + +QueryClient처럼 React infrastructure provider가 직접 요구하는 concrete +객체는 bootstrap에서 해당 provider에 전달할 수 있다. 다만 feature page가 +QueryClient를 직접 가져가 application을 우회하지 않도록 query bridge를 +둔다. + +### 10.3 현재 runtime의 교정 목표 + +현재 `composition.application`이 만들어지고도 사용되지 않는 상태를 +허용하지 않는다. 다음 중 하나를 CI에서 검증한다. + +- `main`은 `composition.application`을 `ApplicationProvider`에 전달한다. +- presentation production source는 `composition.ports`나 concrete + outbound adapter를 import하거나 props로 받지 않는다. + +## 11. TanStack Query bridge + +TanStack Query는 두 역할을 가진다. + +1. QueryClient와 cache는 React 서버 상태 infrastructure다. +2. `useQuery`, `useMutation`은 React lifecycle을 application 호출에 + 연결하는 inbound bridge다. + +따라서 application이 TanStack type을 알면 안 되지만, inbound React +adapter 내부의 feature hook은 TanStack을 사용할 수 있다. + +```ts +export function useReferenceItems(query: ReferenceListQuery) { + const application = useApplication(); + + return useQuery({ + queryKey: referenceQueryKeys.list(query), + queryFn: async ({ signal }) => { + const result = await application.reference.list.execute(query, { + routeId: "REFERENCE_LIST", + operationId: "LIST_REFERENCE_ITEMS", + signal, + }); + + if (!result.ok) { + throw toQueryFailure(result.error); + } + return result.value; + }, + }); +} +``` + +Page는 `useQuery`를 직접 조합하지 않고 feature hook을 사용한다. + +```text +ReferenceListPage + -> useReferenceItems + -> ListReferenceItems input port + -> ReferenceItemGateway output port +``` + +Query bridge는 다음을 한곳에서 책임진다. + +- canonical query key +- AbortSignal 전달 +- application failure를 UI query failure로 투영 +- stale/refreshing 상태 변환 +- mutation 성공 후 namespace invalidation +- optimistic update와 rollback policy +- retry 소유권 + +HTTP transport가 retry를 소유하면 TanStack Query의 자동 retry는 기본적으로 +끄고, 두 계층에서 중복 retry하지 않는다. UI의 “다시 시도” 버튼은 새 +사용자 시도이며 transport의 자동 retry 횟수와 구분한다. + +Application이 실제로 cache 일관성 자체를 업무 규칙으로 요구하는 경우에만 +별도 `CachePort`를 둔다. 일반적인 server-state 표시를 위해 TanStack +Query의 모든 기능을 `read/write/invalidate` 세 메서드로 추상화하지 않는다. + +## 12. 상태 관리 경계 + +모든 상태를 한 global store에 넣지 않는다. + +| 상태 종류 | 기본 도구 | 저장 위치 | +| --- | --- | --- | +| 단일 component 표시 상태 | `useState`, `useReducer` | inbound React | +| URL과 공유 가능한 상태 | typed route params/search | router | +| 서버에서 소유한 상태 | TanStack Query bridge | inbound React infrastructure | +| session처럼 외부 store가 소유한 상태 | `useSyncExternalStore` | provider/bridge | +| theme 같은 작은 cross-page 설정 | context + preference port | provider/application | +| 복잡한 장기 client workflow | reducer 또는 state machine | feature application/inbound | +| 새로고침 후 유지할 공개 설정 | persistence output port | outbound persistence | + +Zustand, Redux Toolkit, Jotai, XState 등은 실제 상태 복잡성이 확인될 때 +선택한다. 기본 skeleton에는 상태 분류 규칙과 작은 typed external-store +예제가 필요하지만 범용 global store 의존성은 필수가 아니다. + +Global store를 도입할 때도 domain invariant를 selector와 action 내부에 +숨기지 않는다. 업무 규칙은 domain/application에 두고 store adapter는 +입력과 표시 상태를 연결한다. + +## 13. HTTP outbound adapter 계약 + +공통 HTTP transport가 지원해야 하는 최소 기능은 다음과 같다. + +- base URL과 상대 path의 안전한 결합 +- typed path parameter encoding +- canonical search parameter serialization +- parsed/normalized request body 전송 +- Content-Type과 response envelope 검증 +- operation별 auth 정책 +- runtime 기본 timeout과 operation override +- AbortSignal과 navigation cancellation +- safe/keyed/non-idempotent retry 구분 +- bounded exponential backoff와 `Retry-After` +- idempotency key의 logical request 단위 재사용 +- 401 recovery 1회 제한 +- request/trace ID의 안전한 투영 +- raw request/response/token을 버리는 error normalization +- terminal failure logging과 telemetry + +Operation registry는 문자열을 모아 둔 표가 아니라 TypeScript map으로 +요청과 응답 type을 연결해야 한다. + +```ts +interface ApiOperationMap { + LIST_REFERENCE_ITEMS: { + request: ListReferenceItemsRequest; + response: ReferenceItemListDto; + }; + CREATE_REFERENCE_ITEM: { + request: CreateReferenceItemRequest; + response: ReferenceItemDto; + }; +} +``` + +Zod 등 runtime schema가 반환한 `data`를 실제 request와 mapper 입력에 +사용한다. `safeParse` 성공 여부만 확인하고 원본 값을 계속 사용하지 않는다. + +## 14. 인증과 token 소유권 + +이 skeleton은 일반적인 browser token manager를 기본 제공하지 않는다. +이는 누락이 아니라 보안 기본값이다. + +권장 순서는 다음과 같다. + +1. 가능하면 BFF와 HttpOnly/Secure/SameSite cookie를 사용한다. +2. 외부 인증 SDK가 필요하면 SDK adapter가 token 획득, 갱신, 저장을 + 소유한다. +3. application과 presentation에는 credential이 아닌 session state와 + sign-in/sign-out use case만 노출한다. +4. HTTP adapter는 opaque credential attachment capability만 사용한다. +5. localStorage나 일반 application store에 access/refresh token을 넣지 + 않는다. + +SPA가 직접 bearer token을 사용해야 하는 프로젝트는 별도 보안 검토 후 +memory 중심 token owner adapter를 구현한다. 그 경우에도 이 저장소의 +`AUTH_TOKEN` storage 금지 계약을 우회하지 않는다. + +## 15. Error, validation, logger 경계 + +### 15.1 Error + +오류는 최소한 다음 경계를 거친다. + +```text +unknown thrown value + -> outbound adapter normalization + -> typed application failure + -> query/controller projection + -> localized safe user message +``` + +UI에는 다음을 전달하지 않는다. + +- raw stack +- backend message/details +- request/response body +- authorization header +- raw URL과 query +- storage value + +### 15.2 Validation + +Validation은 목적별로 분리한다. + +| 위치 | 검증 대상 | +| --- | --- | +| bootstrap | runtime config, release manifest | +| route inbound adapter | params와 search | +| form/controller | 사용자 입력과 field feedback | +| outbound API adapter | request DTO와 response DTO | +| domain | invariant와 value object | + +Form schema와 API request schema가 같은 모양이어도 역할이 다르므로 +무조건 하나로 합치지 않는다. 필요한 경우 application command로 +명시적으로 변환한다. + +### 15.3 Logger와 telemetry + +Logger와 telemetry는 같은 것이 아니다. + +- Logger: 운영 진단을 위한 level 기반 structured record +- Telemetry: registry에 정의된 semantic event와 metric +- Error reporter: 예외 집계와 release correlation + +VD-07에 따라 기본 구현은 임의 message 문자열을 받는 `Logger`가 아니라 닫힌 +event ID와 safe context만 받는 `DiagnosticsPort`다. + +```ts +export interface DiagnosticsPort { + record(input: { + level: DiagnosticLevel; + eventId: DiagnosticEventId; + context?: DiagnosticContext; + }): void; +} +``` + +기본 runtime에는 bounded in-memory diagnostics와 설정 기반 best-effort +telemetry adapter, 테스트에는 recording 또는 no-op adapter를 연결한다. +direct `console`은 redaction 경계를 우회하므로 source gate가 거절한다. +민감정보 redaction은 각 호출자의 선의가 아니라 adapter와 contract에서 +강제한다. + +현재 production producer는 boot, logical HTTP outcome, cache, storage, route, +render, release mismatch와 telemetry delivery drop을 포함한다. HTTP terminal +telemetry는 모든 retry가 끝난 뒤 한 번만 발행하며 abort에는 발행하지 않는다. +raw path/query/body/error 대신 route/operation/correlation ID와 +status/attempt/duration bucket만 전달한다. + +## 16. Feature 경계와 removable reference feature + +Reference feature는 단순 UI fixture가 아니라 다음 경로를 모두 실행해야 +한다. + +```text +registered route + -> lazy page + -> controller/query bridge + -> application input port + -> use case + -> output gateway port + -> HTTP adapter + -> request schema + -> response schema + -> DTO mapper + -> domain model + -> view model + -> loading/empty/success/error UI +``` + +Reference feature 폴더가 소유해야 할 항목: + +- domain model과 value object +- input/output port +- use case +- API operation과 DTO schema +- HTTP gateway와 mapper +- query key와 query/mutation bridge +- route page와 feature component +- unit/component/integration/E2E test + +공통 registry가 필요한 경우 reference feature가 registration object를 +export하고 bootstrap 또는 route composition이 이를 수집한다. sample +operation을 전역 core registry 안에 하드코딩하지 않는다. + +제거 테스트는 다음을 수행해야 한다. + +1. `src/features/reference-feature` 전체 삭제 +2. reference route registration 삭제 +3. reference test 삭제 또는 제외 +4. 잔여 import와 registry owner 검색 +5. typecheck +6. architecture check +7. production build +8. built asset에서 reference operation/schema 문자열 부재 확인 + +제품 feature는 reference feature를 import할 수 없다. Reference feature도 +제품에서만 존재하는 feature를 import하지 않는다. + +## 17. 필수 adapter catalog + +| Adapter | 책임 | 기본 구현 | +| --- | --- | --- | +| Runtime config loader | public runtime config fetch와 검증 | Fetch + Zod | +| Release manifest loader | build/release coherence 확인 | Fetch + Zod | +| Browser clock | 현재 시간과 abortable sleep | browser timer | +| ID generator | idempotency/correlation용 opaque ID | `crypto.randomUUID` | +| HTTP transport | request, timeout, retry, decode | Fetch | +| Domain API gateway | DTO와 domain 변환 | feature HTTP gateway | +| Session gateway | session snapshot와 login lifecycle | external owner | +| Credential attacher | opaque request credential 처리 | auth owner | +| Preference store | 공개 preference persistence | browser storage | +| Query infrastructure | server-state cache 정책 | TanStack Query | +| Query bridge | input port와 React query 연결 | feature hook | +| Logger | redacted structured diagnostics | console/no-op/remote | +| Telemetry | allowlist semantic event 전달 | best-effort sink | +| Browser lifecycle | visibility/pagehide/online 상태 | browser event adapter | +| Router | URL을 page/controller로 변환 | React Router | +| Design system | 접근 가능한 primitive와 token | React/CSS | +| Locale/formatter | message key와 날짜·숫자·방향성 | local locale facade | +| UI workshop/visual gate | 격리 상태·interaction·회귀 검증 | Storybook + local Playwright | + +필수라는 의미는 모든 concrete library를 고정한다는 뜻이 아니다. 해당 +capability의 기본 정책, port 또는 안전한 no-op 구현과 composition +위치가 정의되어 있어야 한다는 뜻이다. + +## 18. 선택 adapter catalog + +| Adapter | 도입 조건 | 기본 상태 | +| --- | --- | --- | +| SSE/WebSocket/bounded polling | active document의 server event 또는 duplex protocol 필요 | common target `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` | +| Web Push | inactive browser의 user-visible notification 필요 | target `DESIGNED_NOT_IMPLEMENTED`, product `NOT_SELECTED` | +| IndexedDB/OPFS | 큰 offline data, durable queue 또는 large local object 필요 | native reference runtime 제공, 미조립 | +| Cache Storage | 승인된 public HTTP representation offline cache 필요 | native reference runtime 제공, 미조립 | +| Service Worker/PWA | offline shell과 installability 필요 | lifecycle recipe 제공, 미설치 | +| Offline mutation queue | 재연결 후 명령 재처리 필요 | 미설치 recipe | +| Feature flag | remote rollout/kill switch 필요 | opt-in recipe 제공, 미설치 | +| Translation catalog vendor | 원격 catalog·복수 namespace 운영 필요 | 기본 locale facade 뒤에 미설치 | +| Analytics | 사용자 동의 기반 product analytics 필요 | opt-in recipe 제공, 미설치 | +| Error-reporting SDK | 운영 예외 집계 필요 | opt-in recipe 제공, 미설치 | +| OpenTelemetry | 조직 trace 연계 필요 | opt-in recipe 제공, 미설치 | +| Web Worker | CPU 작업이 main thread를 막음 | opt-in recipe 제공, 미설치 | +| Notification | 사용자 권한 기반 browser notification 필요 | opt-in recipe 제공, 미설치 | +| File/Blob/picker/download | 해당 file workflow 필요 | native reference runtime 제공, 미조립 | +| Clipboard/Media | 해당 browser capability 필요 | opt-in recipe 제공, 미설치 | +| Image CDN adapter | responsive image transform 필요 | 미설치 recipe | +| Virtualization | 대량 list rendering이 측정상 병목 | opt-in recipe 제공, 미설치 | +| OpenAPI generator | backend 계약에서 client 생성 필요 | opt-in recipe 제공, 미설치 | +| Zustand/Redux/Jotai | 복잡한 cross-page client state 확인 | opt-in recipe 제공, 미설치 | +| XState 등 state machine | 장기 workflow 상태 전이가 복잡함 | opt-in recipe 제공, 미설치 | +| Cloud visual-review service | 외부 승인·호스팅 workflow 필요 | 로컬 Storybook/visual gate 뒤에 미설치 | + +선택 adapter는 “나중에 쓸 수 있으므로” 기본 bundle에 넣지 않는다. 도입 +조건, 보안 영향, bundle 비용과 제거 방법이 확인된 경우에만 추가한다. +SSE, WebSocket, Web Push와 bounded polling의 서로 다른 delivery 의미, +inbound/outbound 분리, resume·gap·lifecycle과 현재 상태는 +[Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)을 +따른다. +Web Push는 subscription/provider/worker delivery를, 별도 Notification 행은 +permission과 user-visible rendering facade를 뜻한다. bounded polling은 inbound +push adapter가 아니라 Query bridge 또는 application orchestrator가 기존 HTTP +operation을 schedule하는 policy다. +현재 구현된 공통 catalog, TypeScript contract/fake, browser-native reference +runtime과 blocking gate는 +`docs/architecture/optional-adapter-recipes.md`와 +`config/recipes/frontend-capability-recipes.json`을 따른다. 이 recipe source를 +production에서 직접 import하는 것은 금지하며 선택한 contract만 application +소유 경계로 이동한다. 실제 API를 호출하는 `referenceRuntime`은 catalog에 +`sourceRoots`가 등록된 browser file/IndexedDB/OPFS/Cache/transfer 계열에만 +존재한다. realtime target은 아직 `DESIGNED_NOT_IMPLEMENTED`다. 구현된 +reference runtime도 dataset/schema/codec/query/policy와 제품 owner가 없으면 +bootstrap에 연결하지 않는다. + +## 19. 새 outbound adapter 추가 recipe + +### 단계 1: 해결할 문제를 capability로 정의한다 + +예: “LaunchDarkly를 붙인다”가 아니라 “feature flag를 평가한다”로 +정의한다. + +### 단계 2: Port가 정말 필요한지 판단한다 + +- application 정책이 이 capability를 호출하는가? +- React 화면 한 곳의 순수 표현 문제인가? +- 기존 port로 충분한가? +- 기술 교체와 테스트 격리가 실제로 필요한가? + +Application이 호출하지 않는 순수 UI library에는 억지 output port를 +만들지 않는다. + +### 단계 3: Application-owned output port를 작성한다 + +- 기술 중립 이름 +- 최소 메서드 +- typed input/output +- timeout/cancellation 필요 여부 +- 예측 가능한 failure union +- 민감정보 분류 + +### 단계 4: Contract와 정책을 작성한다 + +- runtime config +- retry/idempotency +- cache와 TTL +- fallback +- telemetry +- security/redaction +- browser compatibility + +### 단계 5: Concrete adapter를 구현한다 + +`src/adapters/outbound/` 또는 feature의 +`adapters/outbound`에 구현한다. Concrete SDK type은 이 경로 밖으로 +노출하지 않는다. + +### 단계 6: Composition root에서만 연결한다 + +Application이나 page에서 concrete constructor를 호출하지 않는다. + +### 단계 7: 테스트한다 + +- port contract test +- adapter unit test +- failure normalization +- timeout/cancellation +- redaction +- integration test +- deliberately failing negative fixture + +### 단계 8: 운영 계약을 갱신한다 + +- registry와 runtime schema +- dependency inventory +- bundle budget +- security policy +- runbook +- adapter catalog와 제거 절차 + +## 20. 새 inbound adapter 추가 recipe + +외부 이벤트를 application에 전달하는 adapter는 다음 순서를 따른다. + +1. 어떤 외부 event가 어떤 application 의도를 나타내는지 정의한다. +2. event payload를 runtime schema로 검증한다. +3. raw payload를 application command로 매핑한다. +4. input port를 호출한다. +5. duplicate, ordering, cancellation 정책을 정의한다. +6. 실패를 raw event source로 무한 재전파하지 않는다. +7. connect/disconnect lifecycle과 subscription cleanup을 테스트한다. + +예를 들어 WebSocket listener가 받은 JSON을 domain object로 바로 +사용하지 않는다. schema 검증과 command mapping 후 input port를 호출한다. + +## 21. 새 feature 추가 recipe + +### 단계 1: Feature 경계를 선언한다 + +- feature 이름과 owner +- 사용자 목표 +- route +- 입력 command/query +- 외부 gateway +- 성공/빈 화면/오류/권한 상태 + +### 단계 2: Domain을 작성한다 + +- entity/value object +- invariant +- 순수 policy +- framework와 transport type 금지 + +Domain 규칙이 없는 단순 표시 feature라면 빈 domain layer를 억지로 +만들지 않는다. + +### 단계 3: Input/output port와 use case를 작성한다 + +- input port는 사용자 의도 +- output port는 외부 capability +- `Result` failure 정의 +- cancellation context 정의 + +### 단계 4: Outbound gateway를 작성한다 + +- operation registration +- request DTO schema +- path/search/body mapping +- response DTO schema +- domain mapper +- error mapping + +### 단계 5: Inbound query/controller bridge를 작성한다 + +- canonical query key +- application input 호출 +- async state projection +- mutation invalidation 또는 optimistic rollback +- retry 버튼 behavior + +### 단계 6: Route를 등록한다 + +- route ID와 path +- typed params/search +- access hint +- lazy chunk +- loading/error surface +- title/navigation + +Registry metadata와 실제 route object를 별도로 두 번 작성하지 않는다. +하나의 typed definition에서 route object와 navigation을 생성한다. + +### 단계 7: Page와 상태를 조립한다 + +- page header와 focus 이동 +- loading +- empty +- success +- refreshing/stale +- terminal error +- 401/403/404 +- mutation pending/conflict +- responsive/keyboard/screen-reader + +### 단계 8: 테스트한다 + +- domain/use case unit +- port contract +- outbound adapter + MSW integration +- query/controller hook +- component +- route integration +- Playwright E2E +- axe와 수동 접근성 scope + +### 단계 9: 제거 가능성을 확인한다 + +Reference feature라면 전체 폴더 삭제 후 build가 통과해야 한다. 제품 +feature라면 다른 feature가 내부 구현 경로를 직접 import하지 않고 public +entry point만 사용해야 한다. + +## 22. Routing 규칙 + +Route definition은 다음 값을 type-safe하게 결합한다. + +- route ID +- path와 parameter type +- search schema와 normalized type +- access hint +- lazy component +- loading surface +- error boundary +- title +- navigation metadata +- prefetch policy + +Client route access는 UX 정책일 뿐 authorization이 아니다. 서버가 +최종 권한을 검증해야 한다. + +다음 규칙을 적용한다. + +- string path를 page에 하드코딩하지 않는다. +- URL search를 `Object.fromEntries` 결과 그대로 신뢰하지 않는다. +- route parameter를 domain ID로 사용하기 전에 검증한다. +- route 전환 시 진행 중 request에 AbortSignal을 전달한다. +- chunk load 실패는 release mismatch 정책에 따라 한 번만 복구한다. +- redirect loop guard는 실제 redirect 경로에 연결하거나 제거한다. +- browser-history routing을 사용하면 hosting의 SPA fallback을 release 계약으로 + 검증한다. + +## 23. Design system 경계 + +Design system은 inbound React adapter의 공유 UI capability다. + +최소 public surface: + +- semantic token +- typography와 spacing +- Button, IconButton +- TextField, TextArea, Select +- Checkbox, Radio, Switch +- Card, Alert, Badge +- Dialog, Drawer +- Loading, Empty, Error, Forbidden surface +- Tooltip, Tabs, Toast +- Table/List, Pagination +- Form field와 validation message + +Icon library는 design-system의 `Icon`과 `IconButton` 뒤에서 사용한다. +Page가 `lucide-react` 등 concrete icon package를 무제한 import하지 않도록 +허용 icon과 접근성 규칙을 한곳에서 관리한다. 정적 named import로 +tree-shaking을 유지하고 전체 icon registry를 runtime dynamic import하지 +않는다. + +Design system은 domain use case를 호출하지 않는다. Feature component가 +design-system primitive를 조합하고 controller hook을 통해 application을 +호출한다. + +## 24. 금지 패턴 + +다음 패턴은 architecture gate에서 차단해야 한다. + +### 경계 우회 + +- page가 `src/adapters/outbound`를 import +- presentation이 raw `fetch`, `localStorage`, auth SDK를 직접 사용 +- application이 React, Router, TanStack Query를 import +- domain이 application, browser global 또는 framework를 import +- bootstrap 외부에서 concrete adapter를 생성 +- `composition.ports`를 product presentation에 직접 전달 + +### Port 오용 + +- application port에 Axios, TanStack, SDK type 노출 +- input port 이름을 HTTP method나 UI event 이름으로 정의 +- 모든 capability를 하나의 `ApplicationService` 또는 `StorePort`로 합침 +- auth UI operation과 credential attachment를 하나의 과대 interface로 + 강제 +- application이 raw cache read/write를 page에 재노출 + +### HTTP와 retry + +- GET filter를 cache key에만 반영하고 request에서 누락 +- schema parse 결과를 버리고 원본 body 전송 +- keyed mutation retry 시 idempotency key 재생성 +- HTTP와 TanStack이 동시에 자동 retry +- navigation abort를 unknown error로 기록 +- validation early return에서 timeout/listener cleanup 누락 + +### 상태 + +- 서버 상태를 generic global store에 복제 +- URL에 있어야 할 filter를 별도 store에만 보관 +- token을 React state, persisted store, localStorage에 저장 +- domain invariant를 Zustand/Redux action 내부에만 구현 + +### 오류와 관측 + +- raw backend message 또는 stack을 사용자에게 표시 +- request/response body, raw URL, token을 logger/telemetry에 전달 +- `console.error`를 production error strategy로 간주 +- registry에 event만 선언하고 실제 발생 경로에서 emit하지 않음 + +### Registry와 예제 + +- route metadata를 선언하고 실제 route가 사용하지 않음 +- `contracts`를 모든 계층이 자유롭게 import하는 우회 폴더로 사용 +- product가 reference feature를 import +- reference feature를 삭제해도 sample domain/API schema가 초기 bundle에 + 남음 + +### 외부 인프라 + +- 브라우저에서 Redis, PostgreSQL, MongoDB, Kafka, MinIO 관리자 API에 + 직접 연결 +- server credential을 build/runtime config에 포함 +- 클라이언트 route guard를 authorization으로 간주 + +## 25. 테스트와 증적 기준 + +경계별 최소 테스트는 다음과 같다. + +| 대상 | 최소 검증 | +| --- | --- | +| Input use case | 성공, 각 failure branch, cancellation | +| Output port | contract fixture와 negative fixture | +| HTTP transport | path/query/body, timeout, retry, 401, abort, schema | +| DTO mapper | valid mapping과 invariant breach normalization | +| Query bridge | query key, loading, refresh, mutation invalidation, rollback | +| Route | registry-tree 일치, params/search, access, 404 | +| Provider | 실제 composition application 주입 | +| Logger/telemetry | allowlist, redaction, sink failure | +| Design system | keyboard, focus, label/error association, axe | +| Reference feature | full vertical integration과 complete removal | + +TypeScript 전환 후에는 source뿐 아니라 test와 architecture/security +fixture도 typecheck 또는 lint 대상이어야 한다. 모든 실행 fixture를 +`.ts/.tsx`로 유지해 확장자별 검사 우회를 허용하지 않는다. + +Coverage는 단순 report 생성이 아니라 branch/function/line threshold를 +blocking gate로 둔다. 수치만 올리기 위한 구현 세부 테스트보다 port +contract와 실패 분기를 우선한다. + +## 26. 완료 기준 + +### 26.1 Architecture + +- [ ] 모든 application 작업에 명시적인 input port가 있다. +- [ ] 모든 외부 capability는 application-owned output port 뒤에 있다. +- [ ] Presentation은 concrete outbound adapter를 모른다. +- [ ] `main`은 raw ports 대신 application API를 제공한다. +- [ ] `contracts`는 unrestricted 우회 계층이 아니다. +- [ ] dependency rule과 문서의 source of truth가 하나다. +- [x] TypeScript와 TSX도 동일한 architecture/security lint를 받는다. + +### 26.2 Runtime composition + +- [x] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다. +- [x] QueryClient와 feature query bridge가 실제 route에서 동작한다. +- [x] session UI API와 credential attachment가 분리되어 있다. +- [x] diagnostics와 telemetry가 HTTP/render/storage/cache failure 경로에 + 연결된다. +- [x] `pagehide`에서 bounded telemetry queue를 flush하고 adapter `dispose`가 + lifecycle listener를 정리한다. + +### 26.3 HTTP와 validation + +- [ ] path, search, body를 각각 검증하고 직렬화한다. +- [x] schema가 반환한 normalized data를 실제 request에 사용한다. +- [x] 생성된 timeout과 AbortSignal listener가 현재 HTTP attempt의 terminal + 경로에서 정리된다. +- [ ] retry는 runtime cap, idempotency와 `Retry-After`를 따른다. +- [ ] raw payload와 credential이 failure나 log에 포함되지 않는다. + +### 26.4 Routing과 상태 + +- [x] route registry와 실행 route tree가 동일 source에서 생성된다. +- [x] params/search schema가 실제 navigation에서 실행된다. +- [x] loading/error/chunk/access metadata가 실행 behavior와 연결된다. +- [ ] local, URL, server, session, persisted state가 분류 규칙을 따른다. +- [x] reference server state를 별도 global store에 중복 보관하지 않는다. + +### 26.5 Reference feature + +- [x] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다. +- [x] list/create 등 최소 query와 mutation 예제가 있다. +- [x] loading/empty/error/refresh/conflict 상태가 있다. +- [x] reference feature 전체 삭제 후 typecheck/architecture/registry/test/home/build가 통과한다. +- [x] 제거 모드 built asset에 reference operation, schema, mapper가 남지 않는다. + +### 26.6 품질 + +- [x] source와 test가 strict TypeScript 검사를 받는다. +- [ ] React Hooks와 JSX accessibility lint가 blocking이다. +- [ ] coverage threshold가 blocking이다. +- [ ] MSW integration, component, 3-engine E2E와 axe가 통과한다. +- [ ] bundle budget과 dependency inventory가 갱신된다. +- [ ] 선택 adapter는 도입 조건과 제거 절차가 문서화되어 있다. + +## 27. 구현 우선순위 + +### P0: 경계와 정확성 + +1. TypeScript 검사 도구와 `.ts/.tsx` architecture/security glob 준비 +2. input/output port 분리와 `ApplicationProvider` +3. HTTP path/query/body, parsed data, runtime retry/timeout, cleanup 교정 +4. Query bridge를 통한 실제 application 실행 +5. typed routing, registry-tree 단일화와 chunk recovery 실행 연결 +6. 완전한 removable reference feature와 제거 검증 + +### P1: 기본 플랫폼 완성도 + +1. 완료: error/result/validation/form kernel +2. 완료: 배타적인 async 상태와 page template +3. Logger와 telemetry 실제 wiring +4. design-system public API, icon wrapper와 headless interaction +5. locale/message/formatter와 pseudo-locale/RTL 경계 +6. Storybook, shared MSW, 시각 회귀와 built-output E2E +7. source/test TypeScript 전환 및 위험 기반 coverage/lint 강화 + +### P2: 프로젝트별 선택 capability + +SSE/WebSocket, Web Push, bounded polling, offline/IndexedDB, Service Worker, feature flag, product +analytics, vendor error reporting, worker, virtualization, OpenAPI generation, +GraphQL, Connect/gRPC-Web, Protobuf REST Gateway, global store와 cloud +visual-review service는 실제 프로젝트 요구와 측정 결과에 따라 추가한다. +GraphQL과 browser Protobuf/Gateway의 선택·설치 조건은 VD-26과 +VD-27/VD-29/VD-30을 따른다. + +P2 adapter를 많이 설치하는 것은 skeleton 완성도의 기준이 아니다. +안전한 경계, 도입 recipe, 테스트 계약과 제거 가능성이 준비되어 있는지가 +기준이다. diff --git a/docs/architecture/layers.md b/docs/architecture/layers.md new file mode 100644 index 0000000..45d1c35 --- /dev/null +++ b/docs/architecture/layers.md @@ -0,0 +1,59 @@ +# Clean Architecture layer contract + +The import direction is `domain <- application <- presentation`; concrete +adapters implement application-owned ports and are assembled only in +`src/bootstrap`. + +| Layer | Owns | May depend on | +| --- | --- | --- | +| `domain` | framework-neutral models and pure policies | domain siblings | +| `application` | use cases, ports, orchestration, view-models | domain and application siblings | +| `presentation` | routes, components, user interaction and view state | application public API and shared UI | +| `adapters` | browser and third-party implementations of application ports | application ports and limited domain values | +| `features/` | removable vertical domain/application/contracts/adapters/presentation slice | the same inward rule plus platform public boundaries | +| `bootstrap` | runtime configuration, adapter construction and React mount | all selected runtime modules | + +The following edges are forbidden: + +- domain to application, presentation, adapters, bootstrap, React, or browser globals +- application to presentation, concrete adapters, bootstrap, React, or browser globals +- presentation to concrete adapters, raw DTO schemas, or storage implementations +- an adapter to presentation, bootstrap internals, or another concrete adapter +- feature domain/application to its presentation or outbound adapter, and + feature presentation to its outbound adapter + +`bootstrap` contains composition only. Business rules and page-specific +orchestration belong to domain/application. + +The [ports, adapters, and feature-boundary contract](./frontend-ports-adapters-and-boundaries.md) +explains how `presentation` acts as the inbound adapter and how feature +application APIs augment the generic typed input registry. Concrete output +ports are composed in bootstrap and stay hidden behind the application facade. +Project-selected capabilities still implement the same output boundaries. + +`check:architecture` keeps dependency-cruiser's report and adds the +authoritative TypeScript-aware graph below it: + +```json +{ + "staticImportGraph": { + "analyzer": "babel-parser-node-resolver", + "modules": [], + "dependencies": [], + "unresolved": [], + "parseFailures": [], + "cycles": [], + "violations": [], + "summary": { "errors": 0 }, + "fixtureChecks": { "passed": true, "checks": [], "failures": [] } + } +} +``` + +The graph scans TypeScript and TSX, including static, dynamic, type, CommonJS +and JSDoc import references. It applies the path rules from +`.dependency-cruiser.json`, requires explicit TypeScript extensions for local +source imports, and fails closed on JavaScript-family source/specifiers, +unsupported rule shapes, unresolved imports, parse failures, error-severity +layer violations, or cycles. Regression fixtures prove the allowed resolver +path and each rejection class. diff --git a/docs/architecture/optional-adapter-recipes.md b/docs/architecture/optional-adapter-recipes.md new file mode 100644 index 0000000..d05f57e --- /dev/null +++ b/docs/architecture/optional-adapter-recipes.md @@ -0,0 +1,265 @@ +# Optional frontend adapter recipes + +이 문서는 도메인과 무관한 선택형 frontend capability를 실제 프로젝트에 +도입하는 실행 가이드다. 기본 스켈레톤에는 vendor runtime을 설치하지 않는다. +`RECIPE_AVAILABLE`은 catalog에 복사해 좁힐 recipe가 있다는 availability +표시다. runtime 구현이나 제품 선택·조립 상태가 아니다. 현재 catalog의 product +selection과 production composition은 별도로 `NOT_SELECTED`/미설치다. +일부 browser-native capability에는 dependency 없는 +`referenceRuntime.status=AVAILABLE_NOT_COMPOSED` 구현이 함께 있지만, 이것도 +제품 dataset·owner·policy가 정해져 bootstrap에 연결되기 전에는 설치된 기능이나 +production readiness를 뜻하지 않는다. + +## 1. 현재 상태와 파일 지도 + +| 항목 | 경로 | production 포함 | +| --- | --- | --- | +| 선택/금지/예산 SSOT | `config/recipes/frontend-capability-recipes.json` | 정책만 | +| catalog JSON schema | `schemas/config/frontend-capability-recipes.schema.json` | 아니오 | +| TypeScript port | `recipes/frontend-capabilities/contracts.ts` | 아니오 | +| fake/unavailable | `recipes/frontend-capabilities/fake-adapters.ts` | 아니오 | +| contract test | `tests/recipes/optional-capability-contracts.test.ts` | 아니오 | +| file/IndexedDB/OPFS/Cache 심층 계약 | `recipes/frontend-capabilities/browser-file-storage-contracts.ts` | 아니오 | +| 심층 deterministic fake | `recipes/frontend-capabilities/browser-file-storage-fakes.ts` | 아니오 | +| 심층 contract test | `tests/recipes/browser-file-storage-contracts.test.ts` | 아니오 | +| browser data current-status ledger | `docs/architecture/browser-data-capability-completion-ledger.md` | 문서만 | +| 심층 설계/ADR | `docs/architecture/browser-file-and-origin-storage.md`, `decisions/VD-11-browser-file-and-origin-storage.md` | 문서만 | +| client cache scope/persistence 설계 | `docs/architecture/client-cache-and-storage.md`, `decisions/VD-13-client-cache-scope-and-persistence.md` | 문서만 | +| realtime/Web Push/Polling 설계와 reference runtime | `docs/architecture/realtime-events-web-push-and-bounded-polling.md`, `decisions/VD-28-realtime-events-web-push-and-bounded-polling.md`, `src/adapters/realtime`, `src/adapters/web-push` | composition 전에는 tree-shaken | +| transfer/CDN 설계/ADR | `docs/architecture/presigned-transfer-and-image-cdn.md`, `decisions/VD-12-presigned-transfer-and-image-cdn.md` | 문서만 | +| Range/background 결정 | `docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md` | 문서만 | +| storage lifecycle/migration 결정 | `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md` | 문서만 | +| transfer composition/Image provider 결정 | `docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md` | 문서만 | +| REST/GraphQL/Connect/gRPC-Web·Protobuf/REST Gateway·Schema·Mapper·Server State 설계 | `docs/architecture/api-contract-schema-mapper-and-server-state.md`, `docs/architecture/protobuf-browser-transport-and-rest-gateway.md`, `decisions/VD-23-api-transport-selection-and-rest-execution.md`, `decisions/VD-24-runtime-schema-and-boundary-mapper.md`, `decisions/VD-25-server-state-cache-lifecycle.md`, `decisions/VD-26-persisted-graphql-operation.md`, `decisions/VD-27-grpc-web-unary-and-server-stream.md`, `decisions/VD-29-connect-web-and-browser-protobuf-runtime.md`, `decisions/VD-30-protobuf-contract-and-rest-gateway.md` | 문서만 | +| provider-neutral Browser RPC V3 계약/port/runtime | `src/contracts/browser-rpc.ts`, `src/application/ports/browser-rpc`, `src/adapters/browser-rpc`, `tests/unit/browser-rpc` | composition 전에는 tree-shaken | +| API contract/server-state 복구 runbook | `docs/operations/api-contract-and-server-state-recovery.md` | 문서만 | +| storage 복구 runbook template | `docs/operations/browser-file-storage-recovery.md` | 문서만 | +| transfer/CDN 복구 runbook template | `docs/operations/browser-transfer-recovery.md` | 문서만 | +| browser-native reference runtime | `src/adapters/browser-files`, `src/adapters/browser-transfer`, `src/adapters/storage/indexeddb`, `src/adapters/storage/opfs`, `src/adapters/cache-storage` | composition 전에는 tree-shaken | +| reference runtime browser evidence | `tests/browser-capabilities` | 아니오 | +| 정적/번들 gate | `scripts/check-optional-recipes.ts` | build 도구 | +| negative fixture | `scripts/check-optional-recipe-fixtures.ts` | 아니오 | +| 완전 제거 gate | `scripts/test-optional-recipe-removal.ts` | 아니오 | +| native reference runtime 제거 gate | `scripts/test-browser-file-storage-runtime-removal.ts` | 아니오 | + +현재 `productionRuntimeDependencies`는 빈 배열이며 12개 recipe 모두 선택되지 +않았다. `recipes/`의 TypeScript example은 선택 시 복사하고 좁힐 출발점이고, +`src/adapters`의 browser-native reference runtime은 공통 lifecycle·failure +mechanism을 재사용할 수 있는 실제 구현이다. 제품 feature는 이 runtime에 +schema/codec/query와 dataset 정책을 주입하고 더 좁은 facade 뒤에서 조립한다. + +### Reference runtime bundle budget + +`referenceRuntime`이 있는 recipe는 catalog의 `sourceRoots`를 실제 budget entry로 +사용한다. gate는 중첩 root의 실행 가능한 source를 중복 제거하고 경로순으로 +정렬한 뒤, 모든 module을 하나의 synthetic entry에 포함한다. 이 entry는 Vite +production mode, ES2022/ES module, esbuild minify로 build하며 tree-shaking을 +명시적으로 끈다. 따라서 synthetic consumer가 호출 여부를 알 수 없다는 이유로 +validation, quota, integrity, cleanup 같은 fail-closed guard가 예산에서 빠지지 +않는다. 생성된 모든 chunk/asset의 Node zlib gzip byte 합계를 catalog의 +`bundleBudgetGzipBytes`와 비교하고 결과와 SHA-256을 +`artifacts/quality/optional-recipes.json`에 기록한다. + +이 synthetic build는 설치 크기 상한을 검증하기 위한 것이며 product bootstrap에 +runtime을 compose하지 않는다. 별도의 production manifest/module-inventory +검사는 선택되지 않은 runtime source가 실제 `dist`에 없는지 계속 검증한다. + +2026-07-28 기준 동일 설정의 `offline-indexeddb` 실측은 32,930 gzip bytes였다. +기존 8,000 bytes 값은 bundle 측정 없이 선언된 값으로 실제 reference runtime과 +일치하지 않아 약 9% headroom을 둔 36,000 bytes로 교정했다. +`service-worker-pwa` 10,000 bytes와 `file-transfer` 52,000 bytes는 현재 실측을 +수용하므로 유지한다. 이후 source가 예산을 넘으면 gate를 우회하거나 예산을 +자동 인상하지 않고, output artifact와 변경 이유를 검토해야 한다. + +## 2. 어느 경계에 두는가 + +| capability 성격 | port 소유자 | adapter 방향 | concrete 위치 예 | +| --- | --- | --- | --- | +| application이 외부 결과를 요청 | application | outbound | `src/adapters/` | +| URL/browser event가 의도를 전달 | application input | inbound | `src/presentation/adapters` | +| React rendering behavior만 교체 | presentation | local facade | `src/presentation/` | +| feature 전용 protocol | feature application | in/out 분리 | `src/features//adapters` | + +WebSocket 연결 생성, reconnect와 credential attachment는 outbound다. 수신 JSON +검증과 application input 호출은 inbound다. Service Worker update event, +BroadcastChannel event도 같은 원칙을 적용한다. generated DTO와 vendor SDK +type은 facade 밖으로 노출하지 않는다. + +## 3. 12개 recipe 선택표 + +| recipe | 설치하는 경우 | 설치하면 안 되는 경우 | 핵심 fallback | +| --- | --- | --- | --- | +| realtime | foreground ordered event 또는 duplex protocol이 확정됨 | focus refetch/manual refresh가 충분하거나 ordering·replay owner 없음 | bounded polling 또는 stale UI | +| offline/IndexedDB/OPFS | structured offline data/queue 또는 large local binary가 제품 요구 | credential, partition/retention/recovery 미정, HTTP cache로 충분 | read-only/online-only 또는 승인된 bounded Blob | +| Service Worker/Cache Storage | install/offline shell 또는 public HTTP representation cache owner 승인 | auth/private/opaque cache, update/rollback UX 없음 | hosting cache 기반 network app | +| file/picker/download | selection/preview/upload/download와 bounded memory/integrity 요구 | backend 재검증 없음, whole-buffer large file, long-lived credential URL | native input + authorized direct download | +| generated API | versioned source와 drift CI가 있음 | DTO가 domain/UI로 노출됨 | typed request builder + schema | +| feature flag | rollout/kill switch owner와 default 있음 | authorization에 사용 | typed local default | +| Web Worker | profiler가 main-thread 병목을 증명 | 단순 network I/O | chunked/deferred execution | +| multi-tab | 비민감 event 동기화가 필요 | server가 conflict authority | focus 시 authoritative refresh | +| browser permission | user gesture 기반 기능 필요 | boot 요청, denied UX 없음 | manual input/instruction | +| client workflow | cross-page client-only state가 실재 | query/server state 복제 | URL/local/context/query | +| large data UI | 실측 scale이 budget 초과 | pagination으로 충분, a11y 미정 | accessible pagination | +| analytics/error sink | provider·consent·retention 승인 | arbitrary payload/redaction 우회 | bounded local diagnostics | + +정확한 failure matrix, security/privacy, gzip budget과 제거 순서는 JSON catalog가 +SSOT다. 문서와 catalog가 다르면 gate가 검사하는 catalog를 우선 고치고 이 표도 +같이 갱신한다. + +Web Push target은 현재 13번째 설치 recipe가 아니다. 기존 `realtime`, +`service-worker-pwa`, `browser-permission`의 인접 경계를 조합해야 하는 별도 +`NOT_SELECTED` capability다. 실제 선택 전 VD-10 amendment와 machine-readable +catalog에 permission, subscription/backend provider, worker handler, notification +policy와 removal source를 명시하며, foreground realtime이 선택됐다는 이유로 +Web Push를 함께 설치하지 않는다. + +## 4. 공통 구현 순서 + +1. 문제를 vendor 이름이 아닌 capability와 측정값으로 기록한다. +2. catalog의 trigger와 forbidden 조건을 모두 검토한다. +3. project owner, security/privacy reviewer, gzip budget과 재검토 날짜를 VD-10 + amendment에 기록한다. +4. existing URL/local/context/query/application port로 해결되지 않는지 확인한다. +5. 필요한 contract만 `recipes`에서 해당 application/presentation 경계로 복사해 + 실제 payload와 failure union으로 좁힌다. +6. concrete SDK는 `src/adapters/...` 또는 local presentation facade adapter에서만 + import한다. +7. composition root가 concrete adapter를 주입한다. page/use case가 constructor를 + 직접 호출하지 않는다. +8. fake, unavailable, timeout/cancel, cleanup, malformed input, redaction과 + integration test를 작성한다. +9. runtime config schema, dependency inventory/approval, SBOM, bundle budget, + browser support와 runbook을 갱신한다. +10. 실제 provider integration과 negative behavior가 통과한 뒤에만 catalog 상태를 + 별도 project catalog에서 `INSTALLED`로 바꾼다. + +## 5. capability별 필수 검증 + +### Realtime + +- runtime schema로 envelope/version/event ID/sequence/timestamp를 검증한다. +- reconnect는 exponential backoff 상한, visibility/offline 상태, auth refresh와 + resume token expiry를 정의한다. +- duplicate/out-of-order는 domain use case에 전달하기 전에 정책화한다. +- route unmount/logout에서 unsubscribe하고 heartbeat timer를 종료한다. +- SSE는 active document의 one-way stream, WebSocket은 duplex protocol, + Web Push는 Service Worker가 받는 background notification hint, Polling은 + visible/finite HTTP scheduling policy로 분리한다. +- 기본 event 효과는 query namespace invalidation과 authoritative refetch다. + gap, cursor expiry와 queue overflow에서는 delta 적용을 중단하고 snapshot으로 + 복구한다. +- Web Push permission은 user action에서만 요청하고 subscription endpoint/key와 + payload를 storage, URL, telemetry나 application state에 노출하지 않는다. +- detailed status, target contract와 구현 work package는 + [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md)을 + 따른다. 현재 concrete runtime과 product selection은 없다. + +### IndexedDB/OPFS + +- 기존 동기식 preference `StoragePort`에 넣지 않고 feature-specific async + repository와 large-object port를 사용한다. +- raw database/transaction/store/index/schema version은 adapter 밖으로 노출하지 + 않는다. request success가 아니라 transaction complete 이후에만 성공이다. +- DDL schema와 record codec version을 분리하고 schema upgrade는 additive, + data migration은 resumable bounded batch로 실행한다. +- versionchange/blocked/future schema를 read-only/online-only 상태로 드러내며 + 자동 reload와 database 삭제를 금지한다. +- OPFS는 immutable bytes만 소유하고 IndexedDB journal이 + `PREPARING -> FILES_READY -> COMMITTED -> CLEANED` commit authority를 가진다. +- quota, corrupt row/manifest, migration checkpoint, worker crash, storage eviction, + N-1 rollback을 fixture와 실제 browser에서 검증한다. + +### Cache Storage/Service Worker + +- Cache Storage는 public HTTP representation 전용이며 application repository나 + query cache가 아니다. +- auth, cookie-dependent, private, personal, no-store, opaque, redirect, 206을 + 거절하고 query/Vary exact match를 보존한다. +- candidate 전체의 type/size/integrity가 검증된 뒤에만 release를 활성화하고 + verified previous release를 rollback용으로 유지한다. +- stale worker loop를 막고 unregister와 parsed owned-cache cleanup을 별도 lifecycle로 + 검증한다. + +### File/Blob/picker/download, presigned transfer와 generated API + +- native File/Blob/handle은 transient adapter vault 안에 두고 application에는 + opaque ref, untrusted metadata와 bounded range/chunk만 전달한다. +- native input을 baseline으로 두고 system picker는 user activation 안에서만 + progressive enhancement한다. dismissal은 failure가 아니다. +- upload는 client MIME을 신뢰하지 않고 count/size/signature/server rejection, + resumable session, part checksum/idempotency, quarantine을 다룬다. +- presigned URL은 application에 raw URL로 노출하지 않고 in-memory identity + capability로 보관한다. method/resource 또는 session-part/offset/length/checksum, + expiry, origin/path/query/header를 정확히 묶고 data-plane fetch는 credential, + redirect, referrer와 cache를 fail-closed 정책으로 제한한다. +- resume는 IndexedDB checkpoint만 신뢰하지 않고 server status와 다시 선택한 + source의 part digest를 대조한다. checkpoint에는 URL/query/signed header/token을 + 저장하지 않으며 explicit abort가 불명확하면 reconcile 전까지 유지한다. +- progress는 unknown total을 허용하며 navigation/unmount에서 AbortSignal로 + 취소한다. server upload session은 별도 abort/TTL cleanup이 필요하다. +- 큰 download는 single `Uint8Array`/Blob이 아니라 browser handoff 또는 + backpressure stream을 사용하고 handoff와 confirmed save를 구분한다. +- Image CDN은 raw transform URL builder가 아니라 opaque asset과 + composition-registered preset으로만 responsive descriptor를 만든다. immutable + revision, format/width/pixel/decode/cache/expiry와 CDN origin을 검증한다. +- object URL은 explicit lease로 만들고 replacement/unmount에서 revoke한다. +- generated code는 facade 뒤 DTO이며 runtime response schema와 contract drift + gate를 유지한다. + +### Flag/worker/multi-tab/browser + +- flag unknown/unavailable/stale에서 명시적 typed fallback을 사용하고 access + control로 사용하지 않는다. +- worker는 task ID/generation/cancel을 사용해 stale result를 폐기하고 crash를 + normalized failure로 바꾼다. +- multi-tab은 source/event/version으로 self-echo와 duplicate를 막고 payload를 + 비민감 invalidation hint로 제한한다. +- browser permission은 user gesture에서만 요청하고 denied/dismissed/unsupported를 + 서로 다른 UX 결과로 처리한다. + +### Client workflow/large data/analytics + +- workflow store는 server entity/collection을 복제하지 않고 query key나 ID 참조만 + 보관한다. logout/reset/version mismatch 정책을 테스트한다. +- virtualization은 profiler와 production-like row count로 정당화하며 keyboard, + focus restoration, screen reader와 stale row identity를 검증한다. +- analytics는 essential diagnostics와 consent-required event를 분리하고 closed + event/attribute registry, pre-queue redaction, sampling, bounded queue와 + retention을 적용한다. + +## 6. 검증 명령 + +```bash +corepack pnpm check:types:recipes +corepack pnpm test:recipes +corepack pnpm test:browser-capabilities +corepack pnpm build +corepack pnpm check:optional-recipes +corepack pnpm check:optional-recipe-fixtures +corepack pnpm test:optional-recipe-removal +corepack pnpm test:browser-file-storage-removal +``` + +negative gate는 cleanup 누락, unselected dependency, local adapter 밖 vendor +import, credential localStorage/URL/telemetry 경로, workflow store의 server-state +복제, production source의 recipe import와 선택 전 reference runtime composition을 +거절한다. removal gate는 recipe와 recipe test를 삭제한 임시 사본에서 base +typecheck, architecture, test와 build를 실행한다. +별도 native-runtime removal gate는 browser file/storage source와 전용 test, +catalog metadata를 제거한 임시 사본에서 typecheck, architecture, 전체 base test, +build와 optional catalog 검사를 다시 실행한다. + +## 7. 제거 체크리스트 + +1. 신규 호출과 background 작업을 중지한다. +2. subscription, worker, channel, media track, observer를 cleanup한다. +3. persisted store/cache/event queue의 migrate 또는 purge 정책을 실행한다. +4. composition registration과 runtime config를 제거한다. +5. concrete adapter, facade/port와 vendor dependency를 제거한다. +6. dependency baseline, SBOM과 bundle baseline을 갱신한다. +7. typecheck/test/build, production bundle absence와 도메인 기능 fallback을 + 검증한다. + +provider 장애 시 fake로 바꾸어 production을 PASS 처리하지 않는다. 문서화된 +unavailable fallback만 사용하고 provider가 필수인 promotion은 +`FAIL_UNVERIFIED` 또는 blocked 상태로 유지한다. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..9c52370 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,79 @@ +# Architecture overview + +This Mermaid view is a repository-local implementation projection. The +`PASS_SCOPED` reviewer evidence applies to the canonical draw.io diagram named +in `review-ledger.json`, not automatically to edits in this file. + +```mermaid +flowchart LR + Bootstrap[bootstrap / composition root] --> Presentation[presentation] + Bootstrap --> Adapters[adapters] + Presentation --> Shell[app shell and route surfaces] + Shell --> Providers[session and theme providers] + Presentation --> Application[application] + Adapters --> Application + Application --> Domain[domain] + Contracts[contract registries] --> Bootstrap + Contracts --> Adapters + Contracts --> Presentation +``` + +The enforced dependency rule points inward: presentation calls application use +cases, adapters implement application ports, and only the composition root +selects concrete adapters. Contract registries are the named source for routes, +API operations, environment values, storage keys, errors, queries, telemetry, +and release tokens. Installed feature contributions extend those registries +without making the generic application or router import a concrete feature +implementation. + +In ports-and-adapters terms, `presentation` is the current inbound adapter and +`adapters` contains the current outbound implementations. The production +composition exposes an application input API to React while keeping concrete +output ports inside application closures: + +```mermaid +flowchart LR + Driver[User, route, browser event] --> Inbound[React inbound adapter] + Inbound --> Input[Application input API] + Input --> UseCase[Use cases] + UseCase --> Output[Application output ports] + Output --> Outbound[HTTP, auth, storage, query, telemetry adapters] + Bootstrap2[Composition root] -. selects and injects .-> Input + Bootstrap2 -. selects and injects .-> Outbound +``` + +The executable route tree is mounted only after runtime configuration and +release-manifest coherence pass. `ApplicationProvider` receives the composed +application API; concrete session, storage, telemetry, diagnostics, and release +ports are not returned to feature pages. TanStack Query is isolated behind the +presentation query adapter, while its provider remains React infrastructure. +The module-augmented feature registry gives each installed feature a closed ID +and exact input type. + +Route contracts/codecs, the route-input provider, and lazy runtime modules are +separate modules so feature pages do not import the router that loads them. +Expected application failures use the shared `Result`/`AppFailure` contract. +The architecture gate analyzes TS/TSX imports independently of +dependency-cruiser, rejects local JavaScript-family specifiers and executable +JavaScript-family files, and fails on unresolved imports, parse failures, +forbidden layer edges, or cycles. The remaining project-owned work includes +selecting and verifying real hosting, identity, telemetry, vulnerability and +signing providers, plus backend/provider/browser/operations evidence for each +selected optional capability; the repository does not fabricate that evidence. +Use the following +documents for implementation details and project integration work: + +- [Frontend platform capability review](./frontend-platform-capability-review.md) +- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md) +- [API contract, Schema, Mapper, and Server State](./api-contract-schema-mapper-and-server-state.md) +- [Protobuf browser transports and REST Gateway](./protobuf-browser-transport-and-rest-gateway.md) +- [Backend API and Server State handoff contract](./backend-api-and-server-state-contract.md) +- [TypeScript, state, and data flow](./typescript-state-and-data-flow.md) +- [Routing, pages, and patterns](./routing-pages-and-patterns.md) +- [Browser data capability completion ledger](./browser-data-capability-completion-ledger.md) +- [Browser file and origin storage](./browser-file-and-origin-storage.md) +- [Client cache and storage](./client-cache-and-storage.md) +- [Realtime events, Web Push, and bounded polling](./realtime-events-web-push-and-bounded-polling.md) +- [Presigned transfer and Image CDN](./presigned-transfer-and-image-cdn.md) +- [Server file capability infrastructure](./server-file-capability-infrastructure.md) +- [Frontend platform implementation roadmap](./frontend-platform-implementation-roadmap.md) diff --git a/docs/architecture/presigned-transfer-and-image-cdn.md b/docs/architecture/presigned-transfer-and-image-cdn.md new file mode 100644 index 0000000..3a96f60 --- /dev/null +++ b/docs/architecture/presigned-transfer-and-image-cdn.md @@ -0,0 +1,757 @@ +# Presigned transfer, resumable upload, streaming download and Image CDN + +이 문서는 presigned URL, multipart/resumable upload, streaming download와 +Image CDN을 브라우저 애플리케이션에 넣을 때의 control plane/data plane 경계, +무결성, 재개, 만료, 캐시와 복구 계약을 정의한다. + +현재 구현된 개별 reference runtime은 실제 browser `fetch`와 bounded byte +stream을 사용한다. 제품별 endpoint, bucket, CDN vendor, asset schema와 +authorization owner는 아직 조합하지 않는다. 이 개별 runtime은 +`AVAILABLE_NOT_COMPOSED`이고, Range/composition/provider 같은 후속 delta는 아래 +표처럼 다른 상태다. 어느 경로도 임의의 URL이나 object key를 application caller가 +직접 전달하는 범용 HTTP facade가 아니다. + +## 0. 현재 구현과 목표 상태 + +이 문서에서 “설계됨”, “reference source가 있음”, “제품에 조합됨”과 “target +browser에서 보장 가능함”은 서로 다른 사실이다. primary current status는 +`NOT_SELECTED`, `DESIGNED_NOT_IMPLEMENTED`, `AVAILABLE_NOT_COMPOSED`, `COMPOSED`, +`PLATFORM_LIMITED` 다섯 값 중 하나만 사용한다. production evidence와 traffic +admission은 이 status와 별도 축이다. + +| capability | primary current status | 현재 있는 것 | 남은 목표 | +| --- | --- | --- | --- | +| whole-object Presigned GET/part PUT | `AVAILABLE_NOT_COMPOSED` | strict BFF response validation, in-memory identity vault, bounded GET/PUT executor | top-level wire version, 실제 endpoint/auth/revocation/provider contract | +| multipart/resumable upload | `AVAILABLE_NOT_COMPOSED` | server-authoritative session flow, part retry, IDB CAS checkpoint, Web Lock, cross-tab abort signal | non-destructive pause, safe inventory/retention sweep, 실제 BFF/storage/scan | +| whole-object foreground streaming download | `AVAILABLE_NOT_COMPOSED` | `200` stream, length/SHA-256, picker save, bounded object URL와 browser handoff mechanism | strategy selector, browser-managed capability issuer와 제품 save UX | +| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | VD-14 production design | port/runtime/checkpoint, `206/200/412/416`, seek/truncate 또는 OPFS staging | +| Image CDN policy/verification engine | `AVAILABLE_NOT_COMPOSED` | opaque asset/preset, responsive descriptor, P-256, static-image probe | descriptor HTTP provider/refresh, renderer, 실제 BFF/CDN | +| app-managed background download | `NOT_SELECTED` | VD-14 경계와 금지 조건 | 제품이 별도 선택한 지원 browser에서만 optional 구현 | +| cross-browser background-download guarantee | `PLATFORM_LIMITED` | browser-managed handoff fallback | 공통 baseline으로 구현 완료를 선언하지 않음 | +| app-managed background upload | `NOT_SELECTED` | pause/foreground resume와 명시적으로 분리 | durable source staging/worker auth를 가진 별도 protocol | +| cross-browser background-upload guarantee | `PLATFORM_LIMITED` | foreground checkpoint resume fallback | worker lifetime/source permission을 공통 보장하지 않음 | + +현재 runtime은 테스트 전용 mock이 아니라 실제 browser API를 호출하지만, 위 +`남은 목표`가 구현됐다는 뜻은 아니다. 특히 foreground whole-object streaming +증거를 Range resume나 app-managed background download 증거로 재사용하지 않는다. +Range와 background download의 상세 상태 머신, destination, fallback, rollout과 완료 기준은 +[VD-14](./decisions/VD-14-resumable-download-and-background-transfer.md)가 +소유한다. top-level runtime composition, account teardown, Image descriptor +provider/refresh와 safe presentation projection은 +[VD-16](./decisions/VD-16-browser-transfer-composition-and-image-delivery.md)이 +소유한다. + +## 1. 경계와 topology + +```text +feature use case + -> feature-specific transfer facade + -> BFF/Web API control plane + - authorization + - upload session / download capability + - resource metadata and lifecycle authority + -> browser transfer data plane + - capability validation + - bounded fetch / stream / part transfer + - integrity and cancellation + -> object/file storage or CDN + - bytes only + +image use case + -> ImageDeliveryPort + -> backend/CDN-issued immutable asset descriptor + -> policy-validated responsive candidates + -> presentation-safe / attributes +``` + +브라우저는 S3, MinIO, GCS, Azure Blob의 관리자 credential, bucket policy, object +key 생성 규칙이나 signing key를 소유하지 않는다. control plane이 짧은 수명의 +제한된 capability를 발급하고, data plane은 그 capability가 묶은 정확한 +operation과 bytes만 전송한다. + +## 2. 공통 capability 규칙 + +Presigned URL은 단순 URL이 아니라 bearer capability다. 구현은 적어도 다음 +binding을 하나의 immutable snapshot으로 검증해야 한다. + +- protocol/version과 opaque capability ID +- `DOWNLOAD` 또는 특정 upload session/part operation +- logical resource/session ID +- exact HTTP method +- HTTPS와 composition allowlist에 속한 origin/path +- capability가 발급한 exact query와 signed request headers +- expected media type, byte range 또는 exact part offset/length +- hard maximum bytes와 선택한 checksum algorithm/digest +- upload 성공 response의 exact status, `Content-Length`, + `expectedResponseByteLength`와 opaque receipt header +- 발급·만료 시각과 composition의 더 짧은 lifetime ceiling +- single-use가 필요한 경우 원자적인 server/provider consumption + +### 2.1 Wire version 목표 + +현재 multipart DTO는 `PRESIGNED_MULTIPART_V1`을 exact하게 포함하지만, whole-object +presigned capability response에는 top-level transfer protocol literal이 아직 +없다. strict exact-key schema만으로 현재 payload drift는 막지만, 호환되지 않는 +wire 변경과 구 client drain을 명시적으로 운영하기에는 부족하다. + +향후 presigned control-plane envelope에는 다음 literal을 추가한다. + +```text +protocol = PRESIGNED_TRANSFER_V1 +``` + +- request/response, vault registration과 executor consumption에서 exact match한다. +- 알 수 없는 값, 누락과 newer version은 fail-closed한다. +- Range는 이 literal에 암묵적으로 섞지 않고 + `RANGE_RESUMABLE_DOWNLOAD_V1` 별도 protocol을 사용한다. +- Image descriptor HTTP envelope도 `IMAGE_CDN_DESCRIPTOR_V1`로 versioning한다. +- version을 추가하기 전까지 현재 구현 상태는 계속 + `AVAILABLE_NOT_COMPOSED`이며 wire-version 목표가 구현됐다고 기록하지 않는다. + +URL의 query와 signed headers는 credential material로 취급한다. persistence, +checkpoint, diagnostics, analytics, error message, referrer와 application state에 +넣지 않는다. runtime caller가 URL/query/header를 조립하거나 capability의 +method, origin, byte limit과 expiry를 늘릴 수 없다. + +Data-plane `fetch` 기본값은 다음과 같다. + +```text +credentials = omit +redirect = error +referrerPolicy = no-referrer +cache = no-store +mode = same-origin or explicitly approved CORS +``` + +cross-origin object storage/CDN은 exact origin allowlist, CORS method/header, +노출할 response header와 CSP `connect-src`/`img-src` 계약이 있어야 한다. +redirect를 따라가며 signed query를 다른 origin으로 전달하지 않는다. + +## 3. Presigned URL + +### 3.1 Control plane + +실제 발급은 feature-specific BFF adapter가 소유한다. composition은 presigned +capability 발급 endpoint를 HTTPS absolute URL 하나로 고정해 factory에 한 번 +주입한다. application caller는 endpoint를 고르거나 path/query를 조립할 수 없다. +공통 runtime은 이 고정 endpoint와 strict envelope만 알고 provider-specific +signing DTO는 알지 않는다. + +권장 흐름: + +```text +browser -> authenticated BFF capability request +BFF -> resource/session authorization and metadata lookup +BFF -> object service signer +BFF -> opaque bound capability +browser -> validated direct data-plane request +``` + +발급 endpoint의 `2xx`만으로 authorization을 추론하지 않는다. runtime schema가 +전체 capability를 검증하고, data request 시에도 object storage/BFF가 method, +expiry, size/checksum 조건을 강제해야 한다. presigned URL은 underlying credential +revocation이나 server policy 때문에 표기된 expiry보다 일찍 무효화될 수 있다. + +upload part capability 요청에서 전달되는 `requestBindingSha256`, +`uploadBindingSha256`와 part digest는 authorization proof가 아니다. +`UPLOAD_PART` capability binding은 `protocol: PRESIGNED_MULTIPART_V1`을 exact하게 +포함한다. BFF는 `sessionId`로 server-owned session을 다시 읽고 protocol, +subject/purpose/state/expiry와 part plan을 authorization한 뒤 canonical binding을 +직접 재계산해야 한다. client digest를 그대로 신뢰하거나 단순 echo해서 signed +URL을 발급하면 안 된다. + +### 3.2 Consumption + +- capability를 async 대기하는 동안 caller 입력과 dependency method를 snapshot한다. +- 사용 전에 만료뿐 아니라 최소 잔여 lifetime도 검사한다. +- 만료/403은 임의 retry가 아니라 control plane의 새 capability 발급으로 복구한다. +- ambiguous network failure 뒤 upload part를 새 bytes로 덮어쓰지 않는다. +- client-side single-use map은 UX 최적화일 뿐이다. cross-tab/replay authority는 + server 또는 composition-owned atomic consumer다. +- signed response의 URL, query, raw header와 exception text를 관측성에 남기지 않는다. + +## 4. Multipart/resumable upload + +이 reference runtime의 기본 모델은 +`PRESIGNED_MULTIPART_V1` capability 기반 ordered multipart protocol이다. 이 +literal은 모든 upload control-plane request/response, session과 durable +checkpoint에서 일치해야 하며 다른 값이나 누락은 fail-closed한다. 특정 S3 DTO를 +application port로 노출하지 않으며 tus 같은 offset protocol을 선택하면 별도 +protocol/version과 wire adapter가 동일한 상위 session contract를 구현한다. + +### 4.1 Session contract + +Control plane이 소유하는 operation: + +1. `create`: authorization, purpose, declared bytes/media와 source binding을 확인 +2. `status/list parts`: server-authoritative session/part state 반환 +3. `issue part capability`: exact session, part number, offset, length와 checksum binding +4. `complete`: ordered part receipt와 checksum을 검증 +5. `abort`: server upload를 중단하고 orphan cleanup을 예약 + +browser HTTP adapter는 `CREATE_SESSION`, `GET_STATUS`, `COMPLETE`, `ABORT`의 +closed operation set을 composition-owned fixed HTTPS endpoint map에 연결한다. +모두 bounded `POST application/json`이고 caller가 URL을 제공하지 못한다. +upload control endpoint response는 exact URL/status/content type과 +request/response byte cap, deadline, `Retry-After` ceiling을 검증한다. part +capability는 앞 절의 별도 fixed BFF endpoint를 redirect 금지로 사용하고 strict +response envelope 및 request/response의 `UPLOAD_PART` binding protocol이 +`PRESIGNED_MULTIPART_V1`인지 대조한다. + +Session은 적어도 opaque ID, protocol version, exact total bytes, media type, +part size, part count, concurrency ceiling, checksum algorithm과 expiry를 묶는다. +part number는 1부터 연속적이어야 하고 마지막 part를 제외한 part length는 +고정한다. + +`PRESIGNED_MULTIPART_V1`의 canonical digest 계약은 다음과 같다. 각 field는 +아래 순서의 UTF-8 line으로 직렬화하고 SHA-256 lowercase hex를 사용한다. + +```text +fingerprint.digestHex = + SHA-256( + "SHA-256-PARTS-V1" + fingerprint.byteLength + fingerprint.partSizeBytes + fingerprint.partCount + "{partNumber}:{offset}:{byteLength}:{checksumSha256}" for each ordered part + ) + +requestBindingSha256 = + SHA-256( + "RESUMABLE-UPLOAD-BINDING-V1" + uploadKey + purpose + mediaType + fingerprint.algorithm + fingerprint.digestHex + fingerprint.byteLength + fingerprint.partSizeBytes + fingerprint.partCount + ) + +uploadBindingSha256 = + SHA-256( + "RESUMABLE-UPLOAD-SESSION-BINDING-V1" + requestBindingSha256 + sessionId + fingerprint.algorithm + fingerprint.digestHex + fingerprint.byteLength + fingerprint.partSizeBytes + fingerprint.partCount + ) +``` + +각 괄호 안의 항목은 실제로 줄바꿈 하나로 연결하며 마지막 빈 line은 추가하지 +않는다. BFF는 create에서 받은 선언을 server policy/session snapshot과 함께 +보관하고, part capability 발급 때 그 snapshot으로 request/upload digest와 exact +part offset/length/checksum/idempotency를 재계산한다. complete에서는 server +ledger의 ordered part set으로 fingerprint digest도 다시 계산한다. digest 일치는 +input binding의 무결성 신호일 뿐 subject authorization, session ownership 또는 +session state 검사를 대체하지 않는다. + +### 4.2 Browser transfer + +- source는 bounded `readRange(offset, length)`를 제공한다. +- part bytes 하나와 digest 계산에 필요한 copy만 메모리에 둔다. +- `partSize × concurrency × copyFactor`가 composition memory ceiling을 넘으면 + 시작 전에 거절한다. +- 각 part는 exact offset/length와 SHA-256을 계산한 뒤 capability를 발급받는다. +- retry는 동일 session/part/offset/length/checksum/idempotency binding에만 허용한다. +- 429/모든 `5xx`/network retry는 composition의 bounded attempt, bounded + `Retry-After`와 abortable backoff 안에서만 수행한다. +- 만료/authorization failure는 최대 정책 범위 안에서 capability를 재발급한다. +- upload response의 opaque receipt/ETag를 whole-file digest로 해석하지 않는다. +- PUT capability는 성공 status, receipt header와 + `expectedResponseByteLength`를 묶는다. runtime은 exact `Content-Length`를 + 확인하고 response body를 hard cap과 동일 deadline 안에서 끝까지 bounded + drain한 뒤에만 receipt를 성공으로 채택한다. `204`는 expected response bytes가 + `0`일 때만 허용하고 `Content-Length` 부재를 0으로 정규화한다. +- complete 전 server-authoritative status와 local receipt를 reconcile한다. +- complete 성공은 scan 완료가 아니라 `QUARANTINED`다. + +### 4.3 Resume와 checkpoint + +Checkpoint에는 다음만 저장할 수 있다. + +- schema version, exact `PRESIGNED_MULTIPART_V1`, revision과 lifecycle state +- opaque upload key, session ID와 `requestBindingSha256` +- `SHA-256-PARTS-V1` fingerprint의 total bytes, part size/count와 digest +- session expiry와 server concurrency ceiling +- 완료 part의 number/offset/length/checksum/opaque receipt +- 마지막 reconciliation 시각 + +Presigned URL, query, signed header, bearer token, file name, local path, account ID와 +raw backend error는 저장하지 않는다. +위에 명시한 SHA-256 fingerprint/part checksum과 bounded opaque part receipt는 +서버 reconcile에 필요한 비권한성 checkpoint field이므로 예외적으로 해당 account +partition에만 보존한다. raw provider ETag를 임의로 저장하는 것이 아니며 이 +필드들도 diagnostics, telemetry, ticket 또는 application-facing 결과에는 +노출하지 않는다. + +resume 시 checkpoint만 신뢰하지 않는다. + +1. 사용자가 다시 선택한 source의 exact byte length와 source binding을 검사한다. +2. server status/list-parts를 authoritative하게 읽는다. +3. 완료되었다고 주장하는 각 part의 local bytes를 다시 bounded hash하여 + server checksum/receipt와 대조한다. +4. 불일치하면 해당 session을 complete하지 않고 abort/restart 또는 사용자 복구로 + 전환한다. +5. 새 part만 업로드한 뒤 전체 ordered set을 다시 reconcile한다. + +`GET_STATUS`가 HTTP `404` 또는 `410`을 반환하거나 decoded status가 +`NOT_FOUND`/`EXPIRED`이면 해당 session은 terminal이다. runtime은 CAS revision을 +확인해 checkpoint를 제거하고 새 session으로 restart하거나 +`EXPIRED_RESOURCE/RESTART`를 반환한다. 사라진 session의 checkpoint를 다음 +invocation까지 반복해서 붙잡지 않는다. abort의 `404/410`도 checkpoint를 +정리하고 application에는 `ORPHANED`로 닫는다. + +한 session은 cross-tab mutation lock으로 직렬화한다. lock은 correctness의 유일한 +근거가 아니며 server idempotency와 part CAS가 최종 authority다. + +명시적 abort는 같은 runtime의 controller뿐 아니라 strict +`RESUMABLE_UPLOAD_CANCEL_V1` BroadcastChannel을 통해 같은 origin의 다른 +runtime에도 opaque upload key의 ephemeral cancel 신호를 보낸다. 수신 runtime은 +진행 중 fetch/read/backoff의 AbortSignal을 먼저 중단해 Web Lock을 내보내고, +abort 요청 runtime이 lock 안에서 durable checkpoint를 `ABORT_PENDING`으로 +바꾼 뒤 server abort/reconcile을 실행한다. 이 메시지는 session ID, capability, +signed URL이나 receipt를 포함하거나 저장하지 않으며 server state authority가 +아니다. BroadcastChannel이 없으면 correctness는 유지되지만 abort는 caller가 +정한 bounded deadline 아래 다른 context의 lock 해제를 기다린다. + +사용자 cancel은 현재 browser work 중지이고 server abort와 다르다. 명시적 abort를 +요청했는데 결과가 불명확하면 checkpoint를 즉시 성공으로 삭제하지 않고 다음 +reconcile에서 server 상태를 확인한다. backend는 만료된 orphan multipart를 +정리하는 TTL job을 가져야 한다. + +application-facing upload 성공값은 `state`, opaque `resourceId`, `byteLength`, +`replayed`만 반환한다. control-plane 검증에 사용한 `sessionId`, +`requestBindingSha256`와 file fingerprint는 public outcome에 노출하지 않는다. + +### 4.4 아직 구현되지 않은 pause와 checkpoint lifecycle 목표 + +현재 `ResumableUploadPort`는 `upload()`와 server-side `abort()`만 제공한다. +caller가 자신의 `AbortSignal`을 중단하면 committed checkpoint가 남아 다음 +`upload()`에서 resume할 수 있지만, 이것은 명시적 pause protocol이 아니다. 다른 +tab의 active upload를 non-destructive하게 pause하는 API도 없으며 현재 cross-tab +cancel signal은 explicit server abort를 준비하기 위한 신호다. + +향후 pause를 선택하면 다음을 별도 version으로 구현한다. + +```text +pause(uploadKey) + -> active read/fetch/backoff cancel + -> RESUMABLE_UPLOAD_PAUSE_V1 ephemeral cross-context signal + -> per-key mutation lock + -> exact revision CAS to PAUSED + -> in-memory part capability retirement + -> no server multipart abort + +resume through upload() + -> PAUSED checkpoint validation + -> server-authoritative status + -> local completed-part re-hash + -> CAS to ACTIVE + -> missing parts only +``` + +현재 checkpoint `schemaVersion: 1`의 state는 `ACTIVE | ABORT_PENDING`뿐이다. +`PAUSED`를 durable state로 추가한다면 unknown-old-writer behavior와 migration을 +정한 `schemaVersion: 2`가 필요하다. 기존 schema에 필드를 몰래 추가하지 않는다. + +현재 checkpoint store도 single-key `read/CAS/remove`와 account partition 전체 +삭제만 제공한다. abandoned upload가 같은 `uploadKey`로 다시 열리지 않으면 local +checkpoint를 retention 기준으로 자동 발견·정리하지 못한다. 향후 admin lifecycle은 +다음을 제공한다. + +- account partition 안에서 cursor 기반 bounded safe-summary inventory +- maximum age, count와 logical-byte budget +- expired/terminal candidate의 server status 재확인 +- active lock/lease와 `ABORT_PENDING`을 무조건 삭제하지 않는 분류 +- checkpoint와 owned local staging이 있다면 같은 cleanup journal로 처리 +- cleanup response loss, blocked database와 CAS conflict reconciliation +- logout/account deletion용 exact partition maintenance authority + +inventory는 file name, path, account/resource/session ID, digest, receipt와 capability를 +application이나 operator UI에 반환하지 않는다. 제품 resume UI가 display metadata를 +필요로 하면 제품 repository가 opaque `uploadKey`와 별도로 소유한다. + +이 pause/inventory/retention 항목은 현재 **설계 목표이며 구현 완료가 아니다**. +app-managed background upload도 이 항목에 포함되지 않는다. worker upload는 source +bytes의 durable staging, worker auth/reissue, version drain과 platform support가 +승인된 별도 optional capability다. + +## 5. Streaming download + +Streaming download는 server resource를 전체 `Blob`/`ArrayBuffer`로 materialize하지 +않고 `Response.body`를 closed-result byte source로 변환한다. + +- response는 200, non-opaque, non-redirect이고 body가 있어야 한다. +- capability의 media type, content encoding 정책과 response header가 일치해야 한다. +- native chunk는 configured output chunk ceiling으로 다시 분할한다. +- 누적 bytes가 expected/hard maximum을 넘으면 즉시 reader를 cancel한다. +- EOF에서 expected bytes보다 작으면 truncated failure다. +- integrity-required policy는 vetted incremental verifier를 사용하고 destination + close 전에 digest를 확인한다. +- first failed chunk 뒤에는 더 이상 bytes를 노출하지 않는다. +- abort/consumer early return에서 reader를 cancel하고 lock/capability lease를 + 해제한다. +- byte source는 기본 one-shot이며 두 번째 stream 소비를 거절한다. + +File System Access save picker가 있으면 user activation 안에서 destination을 먼저 +열고 stream을 쓴다. close와 integrity verification이 끝난 경우만 `SAVED`다. +anchor/navigation은 `BROWSER_HANDOFF`이며 disk write 완료를 의미하지 않는다. +save picker가 없는 browser의 whole-Blob fallback은 별도 small-artifact hard cap +아래에서만 허용한다. + +Range 기반 resumable download는 이 streaming contract와 다른 capability다. +도입하려면 validator-bound `Range`, `206 Content-Range`, destination seek/truncate, +ETag/If-Range와 final whole-object integrity를 별도 계약으로 추가한다. 단순히 +partial bytes를 기존 파일 뒤에 append하지 않는다. + +현재 presigned provider는 `Range` request header를 금지하고 GET success를 +`200`으로 고정하며 executor는 `Content-Range`를 거절한다. save destination도 +순차 writable만 제공한다. 따라서 이 문단은 구현 설명이 아니라 미구현 경계를 +뜻한다. + +목표 `RANGE_RESUMABLE_DOWNLOAD_V1`은 다음을 모두 포함한다. + +- immutable generation 또는 strong validator와 representation binding +- 비권한성 account-partitioned checkpoint와 CAS offset +- exact `206`, Range 무시/validator mismatch의 `200`, mode가 계약한 `412`, + `416` 상태 머신 +- capability 재발급 뒤 representation binding 재검증 +- seek/truncate destination 또는 journaled OPFS staging +- durable segment commit 뒤에만 checkpoint offset 전진 +- 완료 뒤 destination 전체 재읽기와 whole-object SHA-256 +- partial count/byte/age retention, crash/logout/account-switch cleanup +- system picker 미지원 환경의 browser-managed handoff/server-generation fallback + +상세 불변조건과 promotion gate는 +[VD-14](./decisions/VD-14-resumable-download-and-background-transfer.md)를 따른다. +Range의 primary current status는 `DESIGNED_NOT_IMPLEMENTED`다. + +### 5.1 아직 구현되지 않은 strategy selector + +현재 download strategy는 composition-registered file policy 하나에 +`BROWSER_MANAGED`, `PROMPT_AND_STREAM` 또는 `BOUNDED_OBJECT_URL`로 정적으로 +고정된다. source kind, exact size와 실제 picker/seek/OPFS 지원을 입력으로 안전한 +fallback을 선택하는 공통 headless selector는 없다. + +목표 selector는 user-agent 문자열이 아니라 capability probe를 사용한다. + +| 조건 | 목표 결정 | +| --- | --- | +| server file, 탭 종료 뒤 계속 필요 | `BROWSER_MANAGED_HANDOFF` | +| verified foreground save, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` | +| Range resume 필수, destination 계약 충족 | `RANGE_RESUMABLE_FOREGROUND` | +| 작은 generated artifact | `BOUNDED_OBJECT_URL` | +| 큰 generated artifact, picker 미지원 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` | +| background download가 선택되고 OPFS만 지원 | `APP_MANAGED_BACKGROUND_DOWNLOAD`; app-private staging 후 foreground export | + +위 값은 application-level execution plan이다. 현재 file-delivery primitive에는 +VD-16의 고정 mapping으로만 투영한다. source kind +`BROWSER_MANAGED_RESOURCE`를 strategy로 사용하거나 `PROMPT_AND_STREAM`을 +Range plan으로 재사용하지 않는다. + +picker 미지원 때문에 unbounded Blob ceiling을 올리지 않는다. browser-managed +handoff는 disk save나 integrity 완료를 관찰할 수 없으므로 계속 +`BROWSER_HANDOFF`다. 이 selector와 picker 미지원 failure normalization도 현재 +구현돼 있지 않다. + +## 6. Image CDN + +### 6.1 Asset와 preset + +application은 raw source URL이나 arbitrary transform query 대신 opaque asset +reference와 composition-registered preset reference를 전달한다. backend/CDN +descriptor는 다음을 묶는다. + +- opaque asset ID와 immutable asset revision +- public/private delivery class +- source pixel dimensions와 안전 판정을 통과한 raster media type +- named preset와 exact crop/fit intent +- rendition별 format, natural width/height, URL와 expiry +- private rendition이면 server-issued capability binding + +CDN은 임의 external source URL을 transform parameter로 받지 않는다. backend +asset registry가 quarantine/scan을 통과한 source object만 CDN asset ID로 +promotion한다. + +### 6.2 Policy validation + +Composition policy가 소유하는 값: + +- application origin과 그 origin과 다른 allowed HTTPS CDN origins/path prefix +- named preset와 allowed widths/DPR/formats +- max natural/output width·height·pixels와 decoded/encoded bytes +- crop/fit, quality와 static-raster 제한 +- maximum candidates와 minimum private URL lifetime +- maximum concurrent capability verification +- public/private cache, referrer와 credential policy + +runtime은 caller가 preset의 width, DPR, quality나 format ceiling을 늘리지 못하게 +한다. SVG/HTML/data/blob/javascript URL과 active/unknown media type은 기본 +거절한다. 현재 protocol은 static raster만 지원하므로 animated format은 항상 +거절하며, 도입하려면 별도 frame/decode budget protocol이 필요하다. +Composition이 전달하는 hard limit은 +`IMAGE_CDN_IMPLEMENTATION_CEILINGS`보다 항상 작거나 같아야 한다. 이 값은 제품 +기본값이 아니라 adapter-owned 절대 상한이며 intrinsic/source/output pixel, +decoded/encoded byte, candidate, URL, capability lifetime와 동시 cryptographic +verification guard를 구성 실수로 해제하지 못하게 한다. + +private capability의 signature는 versioned preset binding ID의 허용 집합을 +묶는다. CDN/BFF는 그 ID를 server-owned immutable preset registry에서 조회하고 +요청 query의 width/height/DPR/fit/format/quality가 registry가 산출한 exact +candidate인지 다시 계산해 불일치 요청을 거절해야 한다. 브라우저가 만든 query, +binding digest 또는 signature 문자열을 단순히 echo하거나 query 자체를 +authorization proof로 취급하지 않는다. + +capability policy의 `acceptedKeyIds`는 bounded unique overlap set이며 현재 +signing key를 고르는 selector가 아니다. verifier의 immutable public-key +registry는 runtime 생성 시 이 집합 전체를 포함해야 하고, descriptor의 단일 +`signature.keyId`는 policy와 verifier 양쪽에 exact membership이 있어야 한다. +rotation은 새 public key와 old/new overlap policy 배포, client 채택 확인, +backend signer 전환, `maxCapabilityLifetimeMs + maxClockSkewMs`와 client rollout +기간 경과, old key 제거 순서를 따른다. 유출 key는 overlap 절차 대신 backend +revocation과 runtime 재조합/강제 rollout 대상으로 다룬다. + +browser probe는 encoded body를 hard cap 안에서 읽은 직후 native decoder 호출 +전에 PNG/JPEG/WebP/AVIF header/container metadata를 파싱한다. 선언된 +width/height, pixel 수와 decoded-byte ceiling을 먼저 확인하고 APNG/WebP +animation, AVIF sequence/derived image와 ambiguous/malformed container를 +fail-closed한다. 이 pre-decode 검사가 통과한 static raster만 +`createImageBitmap`으로 실제 dimensions를 재검증한다. + +### 6.3 Responsive descriptor + +width descriptor를 쓰는 candidate는 모두 양의 고유 width를 가지며 오름차순으로 +정렬한다. 같은 source set에서 `w`와 `x` descriptor를 섞지 않는다. `sizes`는 +registry가 승인한 layout token에서 결정하고 arbitrary presentation 문자열을 +CDN query에 넣지 않는다. + +반환값은 presentation-safe descriptor다. + +- fallback `src`, intrinsic width/height +- ordered format별 `srcset` +- registry-owned `sizes` +- `loading`, `decoding`, `fetchPriority` +- `referrerPolicy=no-referrer` +- application과 분리된 CDN origin의 asset은 `crossOrigin=anonymous` + +private signed image는 expiry 전에 실제 load가 시작될 수 있는 eager/priority +정책만 사용하거나 load 직전에 새 descriptor를 발급한다. 오래된 signed URL을 +DOM, persisted state, telemetry 또는 query cache에 장기 보관하지 않는다. +private delivery는 `PRIMARY_REQUIRED` browser probe가 필수이며 이를 `NONE`으로 +낮출 수 없다. probe는 `credentials: omit`, redirect 금지, exact response URL과 +`Cache-Control: no-store`를 실제 response에서 확인한다. +실제 ``는 same-origin일 때 cookie를 보낼 수 +있으므로 registry는 CDN origin이 composition의 application origin과 같으면 +생성 단계에서 거절한다. private CDN 응답은 cookie나 ambient authorization에 +의존하지 않는다. + +### 6.4 CDN cache와 invalidation + +- public rendition은 asset revision을 URL에 포함하고 + `public, max-age=..., immutable`로 제공한다. +- content가 바뀌면 purge에 의존해 같은 URL을 재사용하지 않고 revision을 바꾼다. +- format은 URL에서 명시하거나 `Vary: Accept` 계약과 cache key를 정확히 맞춘다. +- private rendition은 짧은 expiry와 필수 `no-store`를 쓴다. +- CDN cache hit 여부는 authorization이나 asset safety proof가 아니다. + +image probe의 단일 bounded deadline은 response header fetch, streamed body read와 +native decode 전체를 포함한다. timeout/cancel/error 시 reader를 cancel하고, +abort 뒤 늦게 resolve한 response body도 cancel하며 늦게 생성된 `ImageBitmap`도 +즉시 `close()`한다. + +Image CDN runtime의 `close()`는 terminal/idempotent다. application teardown, +logout, account/tenant partition 변경 또는 runtime 교체 시 composition owner가 +한 번 호출한다. runtime lifetime signal은 진행 중 capability verification과 +probe를 중단하고, accepted capability WeakMap은 새 WeakMap으로 교체되어 기존 +reference를 즉시 revoke하면서 strong reference를 남기지 않는다. 닫힌 runtime은 +accept/resolve를 `UNAVAILABLE`로 거절하며 재개하지 않고 새 runtime을 조합한다. + +### 6.5 아직 구현되지 않은 descriptor provider와 refresh + +현재 Image CDN runtime은 trusted gateway가 이미 strict하게 decode했다고 가정한 +`BackendIssuedImageAsset` 또는 composition-owned public descriptor를 +`runtime.assets`에 전달받는다. opaque asset/preset validation, P-256 signature, +responsive URL 생성과 browser probe는 구현돼 있지만 BFF에서 private descriptor를 +가져오는 concrete HTTP provider는 없다. + +향후 `IMAGE_CDN_DESCRIPTOR_V1` provider는 다음 계약을 가진다. + +- composition-owned fixed HTTPS BFF endpoint +- caller가 전달하는 값은 opaque product asset reference와 registered intention뿐 +- authenticated control-plane request, redirect 금지와 no-store +- exact final response URL/status/content type/content length +- fatal UTF-8 bounded JSON body와 unknown-field rejection +- issuer, asset/revision, dimensions, delivery class, preset binding ID, + issued/expiry와 P-256 signature의 exact schema +- request deadline, caller/runtime abort와 late response-body cleanup +- descriptor와 raw backend body를 query cache, persistence, log와 telemetry에 + 저장하지 않음 + +private descriptor가 minimum remaining lifetime 아래로 내려가면 presentation +runtime이 기존 signed URL을 임의 연장하지 않는다. product-owned facade가 같은 +opaque asset/intention에 대해 single-flight reissue를 수행하고, 새 descriptor를 +다시 signature/registry/probe 경계에 통과시킨다. asset revision, preset binding, +issuer 또는 account scope가 달라지면 기존 reference를 폐기하고 새 결과로 +교체한다. logout, tenant switch, key compromise와 kill switch에서는 refresh를 +중단하고 runtime을 `close()`한다. + +P-256 key overlap과 runtime close는 현재 구현돼 있지만 dynamic key-set fetch, +revocation epoch/list, descriptor auto-refresh와 backend scope revoke는 구현돼 +있지 않다. 정상 key rotation은 composition의 immutable old/new registry 교체로, +긴급 회수는 backend revoke, runtime close와 forced rollout으로 처리한다. + +presentation-safe descriptor를 실제 `//`에 적용하는 renderer도 +현재 공통 runtime 범위에는 없다. renderer primitive는 raw URL override를 받지 않고 +descriptor 속성만 투영할 수 있지만, `alt`, placeholder, error/retry, SSR/preload와 +analytics는 제품 presentation이 소유한다. + +## 7. Failure와 recovery + +| 조건 | 결과 | 복구 | +| --- | --- | --- | +| capability expired/revoked | `EXPIRED_RESOURCE` | 새 capability 발급 | +| method/origin/path/binding mismatch | `POLICY_REJECTED` | 요청 재구성 금지 | +| part status conflict | `CONFLICT` | server reconcile | +| part checksum mismatch | `INTEGRITY_FAILED` | 같은 bytes 재검증 후 retry/abort | +| response overrun/truncation | `INTEGRITY_FAILED` | destination abort, 새 download | +| session missing/gone | `EXPIRED_RESOURCE` | checkpoint 폐기 후 새 session | +| image preset/URL/pixel violation | `POLICY_REJECTED` | 안전한 placeholder/original policy | +| image capability verification concurrency ceiling | `LIMIT_EXCEEDED` | 진행 작업 종료 대기 또는 runtime 부하 조사 | +| closed Image CDN runtime 사용 | `UNAVAILABLE` | 새 runtime composition | +| network/429/모든 5xx | `UNAVAILABLE` | bounded retry/backoff | + +관측성에는 operation, safe outcome, byte/part/candidate bucket, retry bucket과 +failure code만 기록한다. capability ID, URL, query, asset/resource/session ID, +file name, digest, raw ETag, receipt와 backend message는 log/diagnostics/telemetry에 +기록하지 않는다. 앞 절의 strict checkpoint allowlist만 durable 예외다. + +## 8. 조합 조건 + +현재 `src/adapters/browser-transfer/index.ts`는 개별 presigned, upload와 Image CDN +factory를 export할 뿐 이들을 하나의 lifecycle과 account partition으로 묶는 +top-level composition factory를 제공하지 않는다. browser file runtime, capability +vault, upload runtime과 Image CDN runtime은 각자 `dispose()`/`close()`를 가지지만 +logout, account switch와 partial cleanup을 하나의 admission fence 아래 실행하는 +owner도 아직 없다. + +제품 composition 전에 반드시 정할 것: + +- fixed BFF capability endpoint, closed upload endpoint map과 runtime schema +- `PRESIGNED_TRANSFER_V1` 및 `IMAGE_CDN_DESCRIPTOR_V1` rollout/drain 계획 +- `PRESIGNED_MULTIPART_V1` canonical binding 재계산과 server-side session lookup +- same-origin proxy 또는 cross-origin CORS/object-storage topology +- per-account/partition Web Lock namespace와 ephemeral BroadcastChannel cancel + namespace, 미지원 환경의 bounded abort fallback +- per-operation maximum bytes, part size/count/concurrency와 retry budget +- upload success status/receipt header/`expectedResponseByteLength`와 response cap +- checksum algorithm과 full/composite 의미 +- quarantine scan/promotion/status protocol +- checkpoint classification, account scope, retention과 logout handling +- application과 분리된 CDN origin, versioned preset exact 재계산, + format/pixel/decoded-byte/cache/CSP contract, key rotation, + verification concurrency와 runtime/probe deadline +- download save/handoff UX와 partial destination recovery +- browser matrix, fault injection, orphan cleanup과 CDN rollback runbook + +이 값이 없으면 runtime factory를 bootstrap에 넣지 않는다. 선택하지 않은 runtime은 +production module inventory와 removal gate로 기본 bundle에서 제외한다. + +### 8.1 목표 top-level composition + +향후 공통 factory의 책임은 dependency를 편리하게 묶는 것보다 구성 시 불변조건과 +teardown 순서를 한 곳에서 강제하는 것이다. + +```text +BrowserTransferComposition + product facade references + strict runtime config snapshot + browser file runtime + browser-managed capability provider/vault + presigned provider/vault/executor + upload control transport/runtime/checkpoint admin + download strategy selector + optional Range runtime/checkpoint/destination registry + image descriptor provider/runtime + account lifecycle fence + readiness/compatibility result + safe observer + kill switches + close() +``` + +browser-managed handoff mechanism에는 synchronous resolver seam이 있지만, 실제 +BFF에서 capability를 발급받아 user activation 전에 in-memory identity vault에 +준비하는 concrete provider는 현재 없다. 목표 provider는 fixed endpoint, strict +versioned response, safe receipt와 exact resource/media/extension/length/digest/expiry +binding을 검증하고 raw href는 resolver 내부에만 둔다. handoff 시점에는 async +발급을 시작하지 않고 이미 준비된 exact identity만 synchronous consume한다. + +composition의 `close()`는 terminal/idempotent하고 다음 순서를 보장한다. + +1. 신규 issue/upload/download/image resolve admission을 닫는다. +2. active foreground work와 worker/channel을 abort한다. +3. in-memory signed capability와 image reference를 revoke한다. +4. writer, reader, Web Lock, BroadcastChannel과 runtime을 close한다. +5. upload/Range checkpoint와 owned staging을 정책에 따라 reconcile한다. +6. logout/account deletion이면 maintenance authority로 exact partition cleanup을 + 실행한다. +7. blocked/ambiguous cleanup을 성공으로 표시하지 않고 safe recovery 결과로 남긴다. + +primary status가 `COMPOSED`여도 completion ledger의 `RuntimeHealth`, +`PromotionEvidence`와 `TrafficAdmission`은 별도다. config schema, endpoint/auth, +account partition, actual provider evidence, cleanup owner와 browser fallback 중 +하나라도 없으면 `TrafficAdmission=DISABLED`, +`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 fail-closed한다. + +### 8.2 Contract harness 목표 + +현재 unit test와 Playwright route interception은 reference runtime의 failure와 +browser API behavior를 검증하지만 reusable BFF/provider conformance suite는 아니다. +`server-file-capability-infrastructure.md`의 provider contract matrix도 설계이며 실제 +S3/MinIO/GCS/Azure/CDN adapter에 실행되는 source는 이 repository에 없다. + +향후 harness는 같은 versioned fixture를 다음 네 등급에 실행한다. + +| 등급 | 증명 범위 | +| --- | --- | +| deterministic fake | state machine, canonical binding과 failure mapping | +| intercepted browser route | Fetch/CORS/stream/abort와 native destination | +| emulator/container | provider SDK, multipart와 response header wiring | +| actual staging provider | 실제 product/API/version/region의 constraint 강제 | + +필수 frontend/BFF contract: + +- `PRESIGNED_TRANSFER_V1` exact request/response와 unknown version rejection +- expiry, wrong method/resource/range/part/header/query/origin rejection +- URL/query/header/error/telemetry redaction +- capability reissue와 server revocation +- upload create/status/part/complete/abort, response-loss와 orphan reconcile +- pause/checkpoint inventory가 구현될 경우 v1→v2 migration과 old-writer drain +- browser-managed capability preload/consume와 `BROWSER_HANDOFF` truth +- `IMAGE_CDN_DESCRIPTOR_V1`, signature/key overlap, expiry/reissue와 revocation +- CDN preset exact recomputation, private no-store와 public immutable cache +- Range를 구현할 경우 VD-14의 `200/206/412/416` 및 destination crash matrix + +actual provider evidence에는 adapter/provider/config/contract version, artifact +digest, environment/region, pass/fail/skip, fault result, waiver/owner와 expiry를 +기록한다. required case skip, expired evidence와 contract/config 변경은 production +promotion을 막는다. fake나 emulator success를 actual provider evidence로 +승격하지 않는다. + +## 9. 표준·vendor 참고 + +- [Browser data capability completion ledger](./browser-data-capability-completion-ledger.md) +- [Fetch Standard](https://fetch.spec.whatwg.org/) +- [RFC 9110 HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html) +- [File System Standard](https://fs.spec.whatwg.org/) +- [VD-14 resumable download와 background download](./decisions/VD-14-resumable-download-and-background-transfer.md) +- [VD-16 browser transfer composition과 image delivery](./decisions/VD-16-browser-transfer-composition-and-image-delivery.md) +- [HTML responsive images](https://html.spec.whatwg.org/multipage/images.html) +- [tus resumable upload protocol](https://tus.io/protocols/resumable-upload) +- [Amazon S3 presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) +- [Amazon S3 multipart upload](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html) diff --git a/docs/architecture/protobuf-browser-transport-and-rest-gateway.md b/docs/architecture/protobuf-browser-transport-and-rest-gateway.md new file mode 100644 index 0000000..f16a74e --- /dev/null +++ b/docs/architecture/protobuf-browser-transport-and-rest-gateway.md @@ -0,0 +1,775 @@ +# Protobuf browser transport와 REST Gateway + +- 상태: production design accepted, product/provider selection pending +- 기준일: 2026-07-28 +- 범위: gRPC-Web, Connect-Web, Connect protocol, Protobuf contract/codegen, + REST/JSON Gateway +- 현재 source 상태: provider-neutral Browser RPC V3 계약·application port·공통 + unary/server-stream lifecycle runtime은 `AVAILABLE_NOT_COMPOSED`; + vendor runtime/dependency/proto/descriptor/proxy는 없음 +- 관련 결정: + - [VD-23 API transport selection과 REST execution](./decisions/VD-23-api-transport-selection-and-rest-execution.md) + - [VD-24 Runtime schema와 boundary mapper](./decisions/VD-24-runtime-schema-and-boundary-mapper.md) + - [VD-25 Server state cache lifecycle](./decisions/VD-25-server-state-cache-lifecycle.md) + - [VD-27 gRPC-Web unary와 server stream](./decisions/VD-27-grpc-web-unary-and-server-stream.md) + - [VD-29 Connect-Web와 browser Protobuf runtime](./decisions/VD-29-connect-web-and-browser-protobuf-runtime.md) + - [VD-30 Protobuf contract와 REST Gateway](./decisions/VD-30-protobuf-contract-and-rest-gateway.md) +- backend handoff: + [Backend API와 Server State contract](./backend-api-and-server-state-contract.md) +- 운영 절차: + [API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md) + +## 1. 먼저 축을 분리한다 + +네 이름은 같은 종류의 대안이 아니다. + +| 축 | 선택지 | 소유하는 것 | +| --- | --- | --- | +| contract/serialization | Protobuf binary, ProtoJSON | message/service schema, field number, presence, codegen | +| browser RPC transport | Connect protocol, gRPC-Web | HTTP framing, media, status/error, timeout, stream terminal | +| browser client runtime | Connect-Web, official grpc-web 또는 승인 runtime | Fetch/XHR, generated client, interceptors, cancellation | +| HTTP exposure | curated REST BFF, generated gRPC-Gateway, Envoy JSON transcoder | resource URL, method, JSON/status/cache/CORS contract | + +예를 들어 Connect-Web은 같은 generated service descriptor로 Connect protocol과 +gRPC-Web transport를 모두 만들 수 있다. 반대로 REST Gateway는 Protobuf service를 +ProtoJSON HTTP API로 노출할 수 있지만, 그것이 자동으로 좋은 browser REST +contract가 된다는 뜻은 아니다. + +한 semantic operation은 frontend registry에서 정확히 하나의 active wire +profile에 binding한다. runtime이 media type을 보고 Connect/gRPC-Web/REST를 +추측하거나 장애 시 다른 protocol로 같은 command를 자동 replay하지 않는다. + +## 2. 현재 상태 + +| capability | current status | 근거 | promotion 필요 | +| --- | --- | --- | --- | +| Browser RPC V3 공통 계약/runtime | `AVAILABLE_NOT_COMPOSED` | exact operation/profile/schema/mapper/encoder/transport join, unary retry·total deadline·abort, bounded server-stream·idle deadline·terminal, generation fence와 unavailable adapter test | selected generated client를 감싸는 protocol transport, raw-byte cap과 actual provider/browser conformance | +| Protobuf schema/codegen | `DESIGNED_NOT_IMPLEMENTED` | `.proto`, `buf.yaml`, descriptor, runtime dependency 없음 | authenticated schema source와 owner | +| Connect-Web client runtime | `DESIGNED_NOT_IMPLEMENTED` | `@connectrpc/*`, `@bufbuild/protobuf` 없음 | selected service, provider와 bundle budget | +| Connect protocol unary | `DESIGNED_NOT_IMPLEMENTED` | endpoint/profile/fixture 없음 | exact JSON 또는 binary profile | +| Connect protocol server stream | `DESIGNED_NOT_IMPLEMENTED` | stream runtime/proxy evidence 없음 | bounded stream protocol과 browser evidence | +| gRPC-Web unary/server stream | `DESIGNED_NOT_IMPLEMENTED` | VD-27만 존재 | runtime/proxy/descriptor와 conformance | +| REST Gateway reference contract/harness | `DESIGNED_NOT_IMPLEMENTED` | native REST reference만 있고 transcoder/fixture 없음 | selected kind의 deterministic/provider conformance | +| product REST Gateway composition | `NOT_SELECTED` | route/provider/owner 없음 | curated BFF 또는 selected transcoder의 제품 승인 | +| browser client/bidi stream | `PLATFORM_LIMITED` | request streaming을 target browser 공통 계약으로 보장 못함 | 다른 transport/application protocol | +| product traffic | `NOT_SELECTED` | operation/provider/owner 없음 | product ADR와 traffic admission | + +이 문서가 추가돼도 dependency나 generated source를 기본 bundle에 넣지 않는다. +공통 runtime source가 생긴 뒤에도 같은 원칙을 유지한다. 공통 runtime은 +Connect/gRPC-Web wire를 직접 decode하지 않고 selected transport가 반환한 +bounded message/terminal/failure만 처리하므로, 실제 Connect-Web 또는 gRPC-Web +adapter가 구현됐다는 증거가 아니다. + +## 3. 기본 선택 + +### 3.1 권장 순서 + +1. 기존 REST가 제품 의미와 운영 요구를 충족하면 REST를 유지한다. +2. backend가 Protobuf-first이고 browser RPC가 필요하면 Connect-Web + + Connect protocol을 우선 평가한다. +3. backend가 gRPC-Web만 노출하거나 기존 Envoy/gRPC-Web conformance 자산을 + 재사용해야 하면 Connect-Web의 gRPC-Web transport 또는 official grpc-web + runtime 중 하나를 고정한다. +4. public HTTP API, CDN/conditional cache, 링크 가능한 resource URL, + broad HTTP tooling이 핵심이면 curated REST BFF를 우선한다. +5. `google.api.http` annotation만으로 제품 REST 의미를 온전히 표현할 수 있을 + 때만 generated gRPC-Gateway/Envoy transcoding을 선택한다. + +### 3.2 선택 matrix + +| 요구 | 기본 후보 | 주의 | +| --- | --- | --- | +| 내부 web UI + Protobuf-first backend + unary | Connect-Web/Connect | JSON/binary를 operation profile로 고정 | +| 내부 web UI + 기존 gRPC-Web gateway | selected gRPC-Web runtime | runtime별 streaming capability가 다름 | +| bounded server→browser stream | Connect server stream 또는 gRPC-Web server stream | idle/total/queue/sequence/resume 필수 | +| public/resource-oriented HTTP API | curated REST BFF | Protobuf service shape를 그대로 노출하지 않음 | +| 단순 proto HTTP annotation과 broad JSON client | generated REST Gateway | ProtoJSON/status/error semantic fixture 필요 | +| HTTP cache/ETag/Range/file download | REST/BFF | RPC transcoder로 억지로 만들지 않음 | +| browser client/bidi | WebSocket/WebTransport/별도 session API | Connect protocol 자체 지원과 browser 지원을 혼동 금지 | +| 기존 REST backend뿐임 | REST 유지 | Protobuf/gateway를 미래 대비로 추가하지 않음 | + +Connect가 항상 gRPC-Web보다 우월하거나 REST Gateway가 수동 REST보다 항상 +저렴하다고 가정하지 않는다. actual provider, proxy, browser, bundle, +observability와 조직 운영 비용을 evidence로 비교한다. + +## 4. 공통 application 경계 + +```text +presentation + -> feature application input + -> semantic gateway port + -> installed operation registry + -> REST adapter + -> Connect adapter + -> gRPC-Web adapter + -> transport decode + -> semantic runtime schema + -> boundary mapper + -> immutable application projection +``` + +금지: + +```text +page/use-case -> generated service client +page/use-case -> protobuf message or descriptor +page/use-case -> transport/base URL/metadata +generated message -> TanStack cache +raw Connect/gRPC error -> presentation +REST gateway DTO -> domain without runtime schema/mapper +``` + +application port는 `listResources`, `createResource`, `watchJob` 같은 의미를 +표현한다. `callRpc(service, method, bytes)`나 `executeProto()`를 노출하지 않는다. + +## 5. Protocol-neutral operation과 exact binding + +```text +ApiOperationContractV3 + semanticOperationId + owner + semantics = QUERY | COMMAND | SERVER_STREAM + protocol = REST | CONNECT_HTTP | GRPC_WEB + replayPolicy + idempotencyKeyPolicy + authProfileId + csrfProfileId + deadlineProfileId + retryProfileId + requestSemanticSchemaId + responseSemanticSchemaId + mapperId + serverStateProfileId | null + dataClassification + compatibility + protocolBinding +``` + +현재 source에서는 설치된 REST V2 registry를 위험한 union migration으로 바꾸지 +않고 `BrowserRpcOperationV3` sibling registry를 추가했다. 제품 operation이 +선택되면 composition root가 semantic feature gateway 뒤에서 REST 또는 Browser +RPC 중 정확히 하나를 bind한다. use-case가 두 registry를 보거나 runtime fallback을 +결정하지 않는다. + +Connect/gRPC-Web binding: + +```text +BrowserRpcOperationV3 + BrowserRpcProviderProfile + providerId + clientRuntimeId + clientRuntimeVersion + transportRuntimeKind = CONNECT_WEB_FETCH | OFFICIAL_GRPC_WEB_XHR | CUSTOM_FETCH_FRAMED + clientApiKind = PROMISE | ASYNC_ITERABLE | CALLBACK_STREAM + protocolRevision + wireProfileId + encoding = PROTO_BINARY | PROTO_JSON + fullyQualifiedService + method + rpcKind = UNARY | SERVER_STREAM + requestMessageId + responseMessageId + descriptorArtifactId + descriptorDigest + errorProfileId + corsProfileId + compressionProfileId + deadlineDialect + retryOwner = FRONTEND_ADAPTER | EDGE_PROXY | NONE + maxRequestBytes + maxHeaderBytes + maxHeaderFields + maxMessageBytes + maxResponseMessages + maxTotalResponseBytes + idleDeadlineMs | null +``` + +구현 경로는 `src/contracts/browser-rpc.ts`, +`src/application/ports/browser-rpc`, `src/adapters/browser-rpc`다. 공통 profile은 +runtime identity/digest, fixed base URL, descriptor digest, allowed procedure, +auth/CSRF/CORS/error/deadline/retry owner와 raw-byte ceiling owner까지 고정한다. +`maxHeaderBytes`, media/trailer parsing과 실제 raw queue ceiling은 공통 coordinator가 +추측하지 않고 selected wire transport가 VD-27/VD-29에 따라 추가로 집행한다. + +REST Gateway binding: + +```text +RestGatewayBindingV1 + providerId + gatewayKind = CURATED_BFF | GRPC_GATEWAY | ENVOY_TRANSCODER + gatewayRuntimeId + gatewayRuntimeVersion + httpRuleArtifactId + httpRuleDigest + method + relativePathTemplate + requestProjection + protoJsonProfileId | null + responseEnvelopeProfileId + statusErrorProfileId + cacheConditionalProfileId +``` + +registry는 다음을 boot/build 전에 거절한다. + +- protocol/profile/runtime/provider tuple 불일치 +- descriptor 또는 HTTP rule digest 불일치 +- caller-provided endpoint, method, metadata 또는 message type +- `SERVER_STREAM`인데 ordinary Query profile 사용 +- browser client/bidi method +- Connect와 gRPC-Web decoder의 runtime auto-negotiation +- runtime kind와 deadline/cancel/status API가 맞지 않는 client API 조합 +- frontend와 edge proxy가 동시에 retry owner인 profile +- gateway kind가 다른 두 route의 last-write-wins collision +- JSON exposure인데 binary-only compatibility gate만 통과 +- non-replayable command retry/fallback +- hard ceiling보다 큰 message/frame/deadline/URL + +## 6. Protobuf contract source + +### 6.1 Source of truth + +```text +authenticated Proto/Buf module + -> immutable source commit/module digest + -> lint + -> breaking comparison + -> FileDescriptorSet + -> deterministic codegen + -> generated artifact digest + -> operation/schema/mapper binding + -> release contract set +``` + +runtime endpoint에서 latest schema를 받아 build하지 않는다. schema update는 +review 가능한 explicit workflow이며 source, dependency lock, descriptor, +plugin/runtime와 generated output digest를 함께 보존한다. + +### 6.2 Evolution + +- field number 재사용/renumber 금지 +- 삭제 field number와 JSON name reserve +- package/service/method full name 안정성 +- enum zero value와 unknown numeric policy +- proto3 optional/edition presence를 명시 +- oneof absence/unknown case 처리 +- map ordering을 identity/digest로 사용하지 않음 +- unknown field가 binary↔JSON conversion에서 보존되지 않을 수 있음을 반영 +- Timestamp/Duration range와 nanos +- int64/uint64의 JS/ProtoJSON projection +- bytes와 repeated/nesting ceiling + +JSON을 한 곳이라도 사용하면 binary wire compatibility만으로 충분하지 않다. +최소 `WIRE_JSON` breaking category를 요구한다. generated SDK의 import/source +compatibility를 외부 소비자가 의존하면 `PACKAGE` 또는 `FILE`을 선택한다. +Buf gate와 별도로 domain meaning, authorization, default/presence, pagination, +revision과 idempotency semantic diff를 수행한다. + +### 6.3 ProtoJSON profile + +operation은 다음을 고정한다. + +```text +ProtoJsonProfileV1 + emitDefaultValues + useProtoFieldNames + enumEncoding = NAME | NUMBER + ignoreUnknownFields + int64Projection + bytesProjection + wellKnownTypePolicy +``` + +runtime/library default에 맡기지 않는다. 기본 방향: + +- request unknown field reject +- response는 generated decoder 뒤 known semantic projection만 mapper에 전달 +- int64/uint64는 decimal string 또는 bounded application type +- bytes는 base64 decode 전후 byte ceiling +- non-finite float는 domain 승인 없으면 거절 +- Timestamp/Duration은 generated decode 성공 뒤 semantic range 재검증 +- `Any`는 allowlisted type URL만 + +ProtoJSON은 ordinary JSON schema의 임의 union을 대체하지 않으며 binary보다 +evolution 보장이 약하다. + +## 7. Deterministic codegen과 공급망 + +선택 branch는 최소 다음을 고정한다. + +```text +buf.yaml +buf.lock +buf.gen.yaml +schema/module digest +protoc or Buf version +plugin name/version/revision/digest +@bufbuild/protobuf version +@connectrpc/connect version +@connectrpc/connect-web version +Buf image digest +FileDescriptorSet digest +canonical HttpRule manifest digest +generated OpenAPI digest when selected +generated output digest +``` + +CI: + +- format/lint/breaking +- dependency lock과 source provenance +- clean checkout generate diff 0 +- generated directory 수동 수정 금지 +- generated import boundary +- descriptor↔generated symbol exact join +- generated runtime↔runtime compatibility matrix +- canonical HttpRule/OpenAPI semantic diff; custom option을 Buf breaking에 위임하지 않음 +- N/N-1 fixture +- license/SBOM/vulnerability/secret scan +- production bundle inventory와 budget +- capability removal 뒤 generated/runtime/proxy reference 0 + +generated files는 `src/generated` 같은 전역 public API가 아니라 selected +feature adapter-private root에 둔다. 여러 feature가 공유하는 service라도 좁은 +platform adapter facade만 재사용한다. + +## 8. Connect-Web + +Connect-Web은 browser client runtime이다. `createConnectTransport()`는 Connect +protocol, `createGrpcWebTransport()`는 gRPC-Web protocol을 사용한다. 같은 package를 +쓴다고 두 wire protocol이 호환되거나 자동 failover 가능한 것은 아니다. +Connect-ES v2 generation은 `protoc-gen-es`가 message와 service descriptor를 +함께 생성하며 과거 Connect 전용 generator를 신규 toolchain에 넣지 않는다. + +### 8.1 Connect unary + +```text +validated semantic input + -> generated request + -> bounded encode (ProtoJSON or binary) + -> POST fixed /package.Service/Method + -> bounded response/error decode + -> generated message + -> semantic validation + -> mapper + -> generation fence +``` + +Connect unary는 bare Protobuf/JSON body와 의미 있는 HTTP status를 사용한다. +success content type과 selected encoding을 exact 검증한다. error는 non-2xx JSON +Connect error profile로만 decode하며 intermediary HTML/JSON을 Connect error로 +추측하지 않는다. + +stock runtime이 unary body를 whole JSON/ArrayBuffer로 읽는 selected version이면 +interceptor가 raw-byte ceiling을 대신한다고 기록하지 않는다. edge의 encoded/ +decompressed cap과 platform-owned bounded Fetch transport 또는 해당 exact +runtime의 overflow conformance가 있어야 production evidence가 닫힌다. + +GET은 다음을 모두 만족할 때만 별도 profile로 허용한다. + +- protobuf method `idempotency_level = NO_SIDE_EFFECTS` +- public/non-sensitive bounded request +- URL byte ceiling과 canonical encoding +- header/credential/preflight 정책 +- exact CDN/browser cache key, `Vary`, ETag와 retention +- request URL이 log/history/referrer에 노출돼도 허용되는 classification + +인증/private query의 기본은 POST다. + +### 8.2 Connect server stream + +Connect streaming은 framed `application/connect+proto|json`이고 HTTP status +`200` 안의 final EndStream envelope가 RPC error/trailer authority다. + +decoder는: + +- incremental 5-byte envelope prefix +- flag/compression/declared length +- per-message/decompressed/total/count ceiling +- exactly one final EndStream envelope +- data after EndStream, missing EndStream와 truncation +- bounded error/detail/trailer projection +- idle + total deadline +- reader cancel/release와 bounded consumer queue + +를 검증한다. network EOF는 success가 아니다. stock Connect-Web runtime을 +사용하면 exact package version이 이 state machine을 얼마나 집행하는지 +conformance로 증명하고, 부족한 hard ceiling은 proxy 또는 custom Transport가 +소유한다. selected stock runtime의 streaming output compression은 +`IDENTITY_ONLY`로 고정하고 지원하지 않는 compressed envelope를 광고하지 않는다. + +### 8.3 Interceptor policy + +interceptor가 허용되는 책임: + +- registry-owned auth/CSRF patch +- remaining deadline/timeout +- fixed low-cardinality diagnostics +- exact safe retry coordinator +- safe error normalization + +금지: + +- arbitrary URL/service/method rewrite +- raw message/error logging +- caller metadata passthrough +- operation semantic retry를 transport가 임의 결정 +- cache write와 domain mapping + +transport/client는 runtime/scope 단위로 재사용하되 base URL/profile이 다른 +provider 사이에서 공유하지 않는다. + +interceptor array의 선언 순서가 아니라 실제 onion execution order를 manifest와 +test에 보존한다. remaining total deadline이 0 이하면 `timeoutMs=0`을 넘기지 않고 +호출 전에 local deadline failure로 닫는다. + +## 9. gRPC-Web + +상세 frame/status state machine은 VD-27을 따른다. + +- browser는 native gRPC/HTTP2 transport를 직접 사용한다고 가정하지 않는다. +- official `grpc-web` runtime은 XHR와 runtime-owned frame/status decoder, + `ClientReadableStream.cancel()`을 사용한다. raw `ReadableStream` frame parser와 + `AbortSignal`을 이 경로의 frontend 보장으로 기록하지 않는다. +- custom Fetch runtime만 `fetch`/`AbortController`/incremental raw-frame decoder + profile을 사용한다. 두 runtime은 `transportRuntimeKind`로 분리한다. +- gRPC-Web response trailer는 body trailer frame 또는 trailers-only response + header에서 해석한다. +- `grpc-status`가 있으면 그것이 authoritative다. 없으면 native gRPC의 공식 + HTTP→gRPC fallback mapping으로 internal status를 만들며 HTTP success만으로 + RPC success를 판정하지 않는다. +- official `grpc-web` JavaScript runtime은 binary unary와 text unary/server + streaming capability를 구분한다. +- Connect-Web gRPC-Web transport를 선택하면 별도 `clientRuntimeId`와 그 runtime의 + conformance matrix를 사용한다. +- text/base64와 binary를 같은 byte budget으로 취급하지 않는다. +- text decoder는 browser chunk 하나를 base64 entity 하나로 가정하지 않고, + 중간 padding이 있는 연속 base64 entity를 처리해야 한다. +- Envoy `grpc_web` filter 또는 selected gateway의 exact version/config를 + provider profile에 binding한다. +- Envoy profile은 filter order, upstream HTTP/2, route/idle/max-stream timeout, + gRPC timeout offset, buffering/flush, header/message ceiling과 local reply + mapping을 고정한다. server stream에는 default route timeout을 그대로 쓰지 않는다. +- retry owner는 정확히 하나다. server stream replay/hedge는 금지하고, frontend와 + Envoy retry를 동시에 켜지 않는다. + +gRPC-Web과 Connect stream은 terminal envelope가 서로 다르다. 공통 +`ReadableStream` helper를 재사용할 수 있어도 decoder/state machine을 합치지 않는다. + +## 10. REST Gateway + +### 10.1 세 종류를 분리한다 + +`CURATED_BFF` + +- browser/resource contract를 별도로 설계 +- REST envelope, status, ETag/304/412, idempotency, pagination과 CORS를 직접 소유 +- 내부에서 gRPC/Connect service를 호출할 수 있으나 public DTO는 별도 mapper + +`GRPC_GATEWAY` + +- `google.api.http` annotation에서 reverse proxy와 optional OpenAPI를 생성 +- proto request field를 path/query/body로 projection +- ProtoJSON과 gRPC status mapping을 exact profile로 고정 +- initial reference 후보는 unary만이며 REST streaming은 별도 ADR 없이는 선택하지 않음 + +`ENVOY_TRANSCODER` + +- descriptor와 `google.api.http` annotation으로 proxy filter가 JSON↔gRPC 변환 +- filter/runtime config와 descriptor를 coherent artifact로 배포 +- application-specific envelope/cache/idempotency 의미는 별도 filter/BFF 없이는 + 자동 생성되지 않음 + +한 route는 하나의 gateway kind만 소유한다. + +현재 reference REST의 `{ success, data|error, meta }` envelope와 +`200|201`, 향후 `204/304/412` 의미는 direct generated gateway의 기본 출력과 +같지 않다. 따라서 기존 reference operation은 curated BFF를 유지한다. direct +gateway는 ProtoJSON/HTTP rule/status/error 자체를 새 versioned public contract로 +승인한 operation에만 적용한다. + +### 10.2 Automatic transcoding의 한계 + +HTTP annotation은 path/method/body projection을 정의하지만 다음을 자동으로 +완성하지 않는다. + +- frontend의 strict success/failure envelope +- product authorization와 existence hiding +- idempotency store/effect certainty +- CursorPage snapshot 의미 +- ETag/If-None-Match/If-Match와 revision CAS +- CDN/private cache policy +- domain error vocabulary와 validation detail redaction +- file Range/streaming download/upload +- browser-compatible server event/reconnect protocol +- OpenAPI와 runtime response/error rewrite의 자동 coherence + +따라서 현재 reference REST envelope에 generated gateway를 바로 연결할 수 없다. +gateway adapter가 exact envelope/status를 제공하거나 frontend에 새 operation +contract를 versioned로 추가해야 한다. + +### 10.3 REST mapping + +- resource name/path는 stable API meaning을 가져야 한다. +- path field를 body/query에도 중복 projection하지 않는다. +- unbound fields의 query mapping과 repeated/nested encoding을 fixture로 고정한다. +- `body: "*"`는 query surface와 HTTP semantics를 숨길 수 있어 default 금지다. +- additional binding collision과 ambiguous path template를 build에서 거절한다. +- `.proto` annotation을 기본 source of truth로 삼고 external service config를 + 병용하면 override precedence와 두 artifact digest를 고정한다. +- `generate_unbound_methods`는 기본 금지하며 GET/DELETE body, path field type, + path unescape mode와 PATCH FieldMask behavior를 fixture로 고정한다. +- Buf generic breaking check가 custom HTTP option의 제품 의미까지 증명한다고 + 간주하지 않는다. canonical route manifest와 generated OpenAPI의 method/path/ + body/query/additional-binding diff를 별도 gate로 검사한다. +- `response_body` projection은 전체 response schema와 mapper binding을 별도로 + 갖는다. +- ProtoJSON JSON name/default/enum/int64/unknown policy를 고정한다. +- request/response body와 URL ceiling은 transcoder 앞/뒤 모두 적용한다. + +server-stream을 JSON array로 buffer해 반환하는 transcoder 동작을 realtime +stream으로 간주하지 않는다. SSE/NDJSON/streaming JSON이 필요하면 별도 protocol +ADR과 framing/content type/terminal contract를 만든다. + +## 11. Auth, CSRF와 CORS + +same-origin BFF를 권장한다. cross-origin이면 protocol별 exact profile이 필요하다. + +Connect: + +- POST와 optional GET +- `Content-Type`, `Connect-Protocol-Version`, `Connect-Timeout-Ms`, + selected compression/auth headers +- custom unary trailer를 expose할 때 `Trailer-` + +gRPC-Web: + +- POST +- `Content-Type`, `Grpc-Timeout`, `X-Grpc-Web`, `X-User-Agent`, + selected auth headers +- `Grpc-Status`, `Grpc-Message`, `Grpc-Status-Details-Bin` expose + +REST Gateway: + +- operation별 method/header/media +- bearer 또는 cookie+CSRF profile +- conditional/idempotency header allow/expose + +공통: + +- exact allow-origin, credential mode와 `Vary` +- wildcard credential 금지 +- redirect login/HTML response 금지 +- Origin/Fetch Metadata/CSRF를 cookie unsafe method에서 검증 +- generated client/caller가 arbitrary metadata를 제출하지 못함 +- auth attach 뒤 endpoint/path/method/body digest 불변 + +## 12. Deadline, cancellation, retry와 command + +```text +total logical deadline + = auth attach + + transport attempts/backoff + + body/frame read/decompression + + generated decode + + semantic validation + + mapper +``` + +- Connect-Web call은 AbortSignal과 bounded timeout을 받는다. +- local deadline과 wire timeout 중 더 짧은 값만 사용한다. +- stream은 idle/total deadline 둘 다 갖는다. +- abort가 backend command rollback을 의미하지 않는다. +- generated/runtime interceptor retry와 Query retry를 중복하지 않는다. +- `SAFE | IDEMPOTENT | KEYED_COMMAND`의 exact evidence가 있는 operation만 retry한다. +- keyed command는 protocol과 무관하게 backend atomic idempotency/reconcile이 + 필요하다. +- gateway의 header/message mapping은 idempotency key를 전달할 뿐 durable + dedupe, receipt, retention과 reconcile을 구현하지 않는다. +- browser abort는 이미 commit된 command의 rollback 증거가 아니다. edge→upstream + cancel/deadline 전파와 backend cooperative cancellation을 staging에서 검증한다. +- protocol 장애를 이유로 Connect↔gRPC-Web↔REST command를 자동 replay하지 않는다. +- read fallback도 새 logical operation으로 시작하고 old result를 cache에 쓰지 않는다. + +## 13. Status와 error mapping + +common safe vocabulary에 mapping하되 wire authority를 섞지 않는다. + +| source | authoritative outcome | +| --- | --- | +| Connect unary | HTTP status + bounded Connect JSON error | +| Connect stream | HTTP admission + final EndStream envelope | +| gRPC-Web | HTTP admission + terminal grpc-status source | +| REST Gateway | selected HTTP/envelope/problem profile | + +mapping은 `UNAUTHENTICATED/AUTH_REQUIRED`, `PERMISSION_DENIED/FORBIDDEN`, +`NOT_FOUND`, `CONFLICT/ABORTED`, validation, rate limit, unavailable, +deadline/cancel을 공통 `AppFailure`로 투영한다. raw message, arbitrary Any/detail, +metadata/trailer와 vendor error는 application/log에 전달하지 않는다. + +같은 semantic backend error라도 transport별 status body가 다를 수 있으므로 +conformance suite가 최종 `AppFailure`와 retry/effect certainty가 같은지 검증한다. + +## 14. Server State와 cache + +- query key는 semantic application input에서만 파생한다. +- service/method/protobuf bytes/REST URL을 key에 넣지 않는다. +- generated message/Connect response를 cache하지 않고 mapper output만 admission한다. +- transport retry가 있으면 Query retry는 off다. +- scope/generation mismatch result는 폐기한다. +- Connect GET/CDN cache, browser HTTP cache와 TanStack cache owner를 operation별로 + 하나씩 명시한다. +- stream event는 ordinary query result가 아니다. +- finite stream aggregate는 terminal success 뒤 atomic commit한다. +- long-running stream은 bounded reducer 또는 invalidate-only hint를 사용한다. + +## 15. Security와 privacy + +- descriptor/generated code는 신뢰된 source에서만 +- fixed provider/service/method/route +- message/body/frame/decompressed/collection/depth ceiling +- recursive schema와 Any type allowlist +- frontend generated validation을 backend authorization으로 간주 금지 +- raw payload, ProtoJSON, debug stringifier, metadata/trailer/cursor/revision log 금지 +- request/response message에 credential을 넣지 않음 +- query cache/persistence에 generated message 없음 +- gateway가 unknown field/duplicate JSON key를 어떻게 처리하는지 fixture로 고정 + +## 16. Observability + +허용: + +```text +semantic operation ID +protocol/client runtime/provider profile ID +descriptor/http-rule artifact version +HTTP status group / safe RPC code +attempt/duration/message-size/count bucket +stream terminal/idle/gap/overflow bucket +cache admission outcome +``` + +금지: + +- service request/response content +- protobuf debug JSON/string +- metadata/trailer/error message/detail +- URL query/GET message +- credential/idempotency/cursor/revision +- resource/account identifier actual value + +protocol별 metric을 비교할 때 semantic operation ID를 join key로 쓰고 raw +service/method를 high-cardinality label로 사용하지 않는다. + +## 17. Test와 provider evidence + +### 17.1 Deterministic + +- proto format/lint/breaking/descriptor/codegen digest +- enum/oneof/presence/int64/time/bytes/unknown-field fixture +- Connect unary JSON/binary success/error/media/status +- Connect stream partial prefix, length, compression, EndStream, truncation +- gRPC-Web binary/text frame/trailer/status matrix +- REST annotation path/query/body/response mapping +- ProtoJSON default/name/enum/int64/null/unknown behavior +- retry/deadline/cancel/auth/idempotency +- scope/generation/cache admission +- generated import/bundle/removal + +### 17.2 Actual provider/browser + +- selected Connect/gRPC-Web server or Envoy/gateway version +- exact CORS/preflight/auth/CSRF +- HTTP/1.1/2 proxy buffering and stream flush +- media, compression, terminal status/trailer preservation +- message/body/time limit +- Chromium/Firefox/WebKit cancel/stream/backpressure +- REST Gateway N/N-1 and OpenAPI/descriptor coherence +- browser abort→gateway context→upstream cancel/deadline propagation +- HttpRule route manifest/OpenAPI/runtime response rewrite coherence +- kill switch, rollback and dependency/proxy removal drill + +memory fake/MSW만으로 provider conformance를 주장하지 않는다. + +## 18. Rollout + +```text +product operation selected + -> schema/provider/gateway owner + -> immutable proto + descriptor + -> codegen and semantic mapper + -> provider-neutral adapter/fake + -> actual gateway/browser conformance + -> AVAILABLE_NOT_COMPOSED + -> bootstrap TrafficAdmission=DISABLED + -> COMPOSED + -> read-only shadow + -> canary + -> enabled +``` + +- 처음에는 safe unary read 하나만 선택한다. +- shadow result는 UI/cache에 쓰지 않는다. +- Connect와 gRPC-Web을 동시에 canary하지 않는다. +- server stream은 unary와 별도 gate다. +- command는 backend idempotency/reconcile 뒤 별도 gate다. +- REST fallback은 사전 등록된 read operation만 새 logical query로 실행한다. +- rollback은 frontend/generated descriptor/gateway/backend를 coherent set으로 한다. + +## 19. Removal + +1. 신규 call/stream admission을 닫는다. +2. query/stream cancel, command effect reconcile. +3. cache/reducer/invalidation listener를 clear한다. +4. operation/schema/mapper/provider profile을 제거한다. +5. generated source, descriptor/proto input과 codegen config를 제거한다. +6. Connect/gRPC runtime dependency와 proxy/transcoder route를 제거한다. +7. backend method/HTTP binding은 N/N-1 client window 뒤 retirement한다. +8. bundle, SBOM, lockfile, proxy config와 source reference 0을 증명한다. + +## 20. 구현 work package + +| 순서 | package | exit | +| --- | --- | --- | +| PB-01 | schema governance | authenticated module, Buf lint/breaking, descriptor/digest | +| PB-02 | generated boundary | deterministic TS generation, private imports, mapper | +| PB-03 | Connect unary | exact JSON/binary profile, error/deadline/cancel | +| PB-04 | gRPC-Web unary | selected runtime/proxy/frame/status conformance | +| PB-05 | bounded server stream | queue/idle/total/terminal/sequence/resume | +| PB-06 | REST Gateway | selected kind, HTTP annotation/ProtoJSON/error/cache fixture | +| PB-07 | operations | browser/provider evidence, canary/kill/rollback/removal | + +PB-03~06은 제품에서 선택한 branch만 구현한다. “미래 대비”로 모두 설치하지 않는다. + +## 21. 완료 기준 + +- [ ] Protobuf, client runtime, wire protocol과 gateway 축이 분리돼 있다. +- [ ] 한 operation은 한 active transport/provider profile만 가진다. +- [ ] descriptor/codegen/runtime/gateway artifact가 release digest에 binding된다. +- [ ] generated type이 adapter 밖으로 나오지 않는다. +- [ ] ProtoJSON과 binary compatibility policy가 각각 닫혀 있다. +- [ ] Connect unary/stream과 gRPC-Web decoder가 서로의 terminal 규칙을 섞지 않는다. +- [ ] REST Gateway가 envelope/cache/idempotency를 자동 제공한다고 가장하지 않는다. +- [ ] client/bidi streaming을 browser 공통 capability로 표시하지 않는다. +- [ ] actual proxy와 세 browser evidence가 있다. +- [ ] command fallback/replay가 backend effect certainty를 우회하지 않는다. +- [ ] coherent rollback과 complete removal drill이 통과한다. + +## 22. 규범·공식 근거 + +- [Connect protocol reference](https://connectrpc.com/docs/protocol/) +- [Connect-Web protocol selection](https://connectrpc.com/docs/web/choosing-a-protocol/) +- [Connect-Web code generation](https://connectrpc.com/docs/web/generating-code/) +- [Connect and gRPC-Web CORS](https://connectrpc.com/docs/cors/) +- [gRPC-Web protocol delta](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) +- [Official grpc-web runtime](https://github.com/grpc/grpc-web) +- [Envoy gRPC-Web filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_web_filter.html) +- [gRPC HTTP status fallback mapping](https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md) +- [Envoy timeout configuration](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html) +- [Protocol Buffers language guide](https://protobuf.dev/programming-guides/proto3/) +- [ProtoJSON format](https://protobuf.dev/programming-guides/json/) +- [Buf breaking changes](https://buf.build/docs/breaking/) +- [gRPC-Gateway introduction](https://grpc-ecosystem.github.io/grpc-gateway/docs/tutorials/introduction/) +- [gRPC-Gateway customization](https://grpc-ecosystem.github.io/grpc-gateway/docs/mapping/customizing_your_gateway/) +- [Google API HTTP annotation](https://github.com/googleapis/googleapis/blob/master/google/api/http.proto) +- [Envoy gRPC-JSON transcoder](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter) diff --git a/docs/architecture/realtime-events-web-push-and-bounded-polling.md b/docs/architecture/realtime-events-web-push-and-bounded-polling.md new file mode 100644 index 0000000..a1fa0b8 --- /dev/null +++ b/docs/architecture/realtime-events-web-push-and-bounded-polling.md @@ -0,0 +1,2355 @@ +# Realtime events, Web Push, and bounded polling + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +이 문서는 SSE, WebSocket, Web Push와 제한된 Polling을 frontend에 도입할 때의 +선택 기준, 포트·어댑터 경계, event 계약, 재연결·복구, 인증, 브라우저 lifecycle, +관측성과 promotion 조건을 정의한다. + +이 네 기술은 모두 “새 정보가 도착했음을 frontend에 알린다”는 점만 비슷하다. +연결 방향, 실행 가능한 browser 상태, 전달 보장과 실패 복구가 다르므로 하나의 +범용 realtime transport로 합치지 않는다. + +현재 저장소에는 production에서 제외된 RT-01~RT-04 reference runtime과 +deterministic contract/fault test가 있다. transport-independent event authority, +bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE, +bounded Polling, closed WebSocket protocol, Web Push window/Service Worker +adapter까지 `AVAILABLE_NOT_COMPOSED`이지만 제품별 endpoint, +event/query/notification registry, backend replay/provider protocol과 production +composition은 선택하지 않았다. 따라서 reference source가 있다는 사실은 +capability가 설치됐거나 production-ready라는 뜻이 아니다. + +상세 결정은 +[VD-28](./decisions/VD-28-realtime-events-web-push-and-bounded-polling.md)이 +소유한다. 공통 cache와 account scope는 +[Client cache and browser storage platform](./client-cache-and-storage.md), +선택 capability의 설치·제거 절차는 +[Optional frontend adapter recipes](./optional-adapter-recipes.md), 계층 방향은 +[Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md) +를 따른다. + +## 0. 현재 상태와 목표 delta + +이 문서에서 설계 승인, reference source 존재, production 조합과 target browser의 +보장은 서로 다른 사실이다. primary current status는 기존 browser capability +문서와 같은 다섯 literal만 사용한다. + +| 상태 | 의미 | +| --- | --- | +| `COMPOSED` | production bootstrap 또는 설치된 feature 경로가 concrete runtime을 생성하고 소비한다. | +| `AVAILABLE_NOT_COMPOSED` | reusable runtime과 deterministic/native test가 있으나 production graph에는 없다. | +| `DESIGNED_NOT_IMPLEMENTED` | 경계와 불변조건은 승인됐지만 필요한 실행 source 또는 orchestration이 없다. | +| `NOT_SELECTED` | 제품 요구, owner, backend 계약과 운영 정책이 승인되지 않았다. | +| `PLATFORM_LIMITED` | browser/platform이 요구한 의미를 공통으로 보장할 수 없다. | + +현재 catalog의 `RECIPE_AVAILABLE`은 위 runtime 상태와 다른 축이다. realtime +row의 `referenceRuntime.status=AVAILABLE_NOT_COMPOSED`가 reusable source의 +존재를 별도로 기록하며, 제품 선택이나 production 조합을 뜻하지 않는다. + +| capability | primary current status | 현재 있는 것 | 목표 또는 잔여 | +| --- | --- | --- | --- | +| common event/recovery runtime | `AVAILABLE_NOT_COMPOSED` | scope/generation fence, effect-after-checkpoint, exact recovery barrier와 deterministic fault test | 제품 stream/event/effect/recovery registry | +| reconnect와 live↔Poll ownership | `AVAILABLE_NOT_COMPOSED` | finite full-jitter reconnect owner, authoritative close classification, single-writer handoff와 bounded quiescence/checkpoint | 제품 lifecycle/fallback policy와 transport composition | +| SSE reference runtime | `AVAILABLE_NOT_COMPOSED` | bounded fetch-stream adapter/parser, fixed same-origin endpoint, resume/heartbeat/reconnect와 deterministic fault test | actual local streaming server와 target-browser evidence | +| WebSocket reference runtime | `AVAILABLE_NOT_COMPOSED` | exact `realtime.v1` protocol, bounded queue/rate/buffer, resume/recovery와 deterministic facade test | actual local server load와 target-browser evidence | +| Web Push subscription/window runtime | `AVAILABLE_NOT_COMPOSED` | permission/subscription control, backend registration/revoke facade와 durable association fence | actual provider, permission UX와 target-browser evidence | +| Service Worker push inbound runtime | `AVAILABLE_NOT_COMPOSED` | strict hint/click codec, notification/route registry와 uncomposed worker runtime factory | selected worker composition과 provider/browser evidence | +| bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | single-flight, visible/online finite lease, elapsed/attempt budget와 terminal stop test | 제품 operation registry와 freshness policy | +| 제품 realtime delivery | `NOT_SELECTED` | owner, endpoint, event registry, SLO가 없음 | 측정된 freshness 요구와 backend/provider 계약 승인 후 선택 | +| 제품 Web Push | `NOT_SELECTED` | permission copy, subscription owner, notification policy가 없음 | user-visible background notification 요구가 승인될 때 별도 선택 | +| exactly-once delivery | `PLATFORM_LIMITED` | 없음 | 공통 목표로 선언하지 않음; duplicate-tolerant 처리와 authoritative resync 사용 | +| global event ordering | `PLATFORM_LIMITED` | 없음 | V1은 logical stream-local ordering만 계약 | +| always-on background SSE/WebSocket/Polling | `PLATFORM_LIMITED` | 없음 | hidden/frozen/terminated browser 상태에서 공통 보장하지 않음 | +| timely cross-browser Web Push | `PLATFORM_LIMITED` | 없음 | best-effort notification과 foreground authoritative refresh만 제공 | + +별도로 realtime catalog row 자체는 `RECIPE_AVAILABLE`이다. 이것은 primary +runtime status가 아니다. 기존 copyable `RealtimePort`를 production wire +authority나 generic mega port로 확장하지 않았고, reference runtime의 공개 계약은 +`RealtimeEventAuthority`, `WebPushControlPort`와 transport-specific factory로 +나뉜다. 이 source는 production graph 밖에 있다. + +설계 승인 직후 canonical readiness 기본값은 다음과 같다. + +```text +Selection = NOT_SELECTED +TrafficAdmission = DISABLED +RuntimeHealth = UNKNOWN +PromotionEvidence = MISSING +``` + +`PromotionEvidence`는 문서가 존재한다는 이유로 `PARTIAL`이나 `COMPLETE`가 되지 +않는다. 실제 frontend contract, backend/provider conformance, target-browser +evidence와 operations drill이 있어야 별도로 승격한다. + +현재 recipe 계약의 `channel: string`, `sequence: number`, 단일 `resumeToken`, +callback과 application-facing `heartbeat()`는 production wire authority가 아니다. +scope/epoch, closed event type, gap, byte limit, 진행되는 cursor, connection +generation과 effect certainty가 없기 때문이다. 선택 시 recipe를 그대로 import하지 +않고 이 문서와 실제 backend protocol에 맞게 더 좁은 계약으로 이동한다. + +이 문서의 최상위 불변조건은 다음과 같다. + +> 연결이 열려 있음, event 수신, Web Push 수신 또는 Polling 성공은 최신 상태, +> authorization, server commit이나 정확히 한 번의 처리를 각각 보장하지 않는다. +> 일반 server state의 source of truth는 계속 서버이며 gap과 불확실성은 +> authoritative snapshot으로 복구한다. + +## 1. 변경할 수 없는 설계 결정 + +### 1.1 하나의 범용 `RealtimePort`로 합치지 않는다 + +```text +foreground one-way notification + -> SSE connection owner + -> validated event inbound adapter + +foreground duplex interaction + -> WebSocket protocol owner + -> bounded send/receive coordinators + -> validated event/ack inbound adapters + +inactive-browser notification + -> backend Web Push provider + -> browser push service + -> Service Worker inbound adapter + -> notification or bounded foreground handoff + +relaxed freshness or explicit fallback + -> bounded polling scheduling policy + -> existing HTTP/query gateway +``` + +`transport: "SSE" | "WEBSOCKET" | "PUSH" | "POLLING"` 하나를 받는 facade는 +다음 차이를 숨긴다. + +- SSE는 active document의 server-to-client UTF-8 stream이다. +- WebSocket은 application message를 양방향으로 교환하지만 browser API가 + incoming backpressure를 제공하지 않는다. +- Web Push는 push service와 Service Worker, 사용자 permission, server-side + subscription registry가 필요한 background notification capability다. +- Polling은 새 network transport가 아니라 기존 HTTP operation을 실행하는 + 제한된 scheduling policy다. +- SSE/WS의 cursor와 Web Push subscription endpoint는 수명·민감도·authority가 + 다르다. +- `unsubscribe`, socket close, push subscription revoke와 poll lease 종료는 + 같은 effect가 아니다. + +공통으로 재사용할 수 있는 것은 closed event envelope 검증, scope generation +fence, dedupe/gap detection, backoff 계산, redacted observation과 authoritative +resync orchestration이다. native constructor와 lifecycle owner는 분리한다. + +### 1.2 source of truth와 delivery 의미 + +- 일반 server entity/collection의 source of truth는 서버다. +- SSE/WebSocket event의 기본 역할은 “어떤 namespace가 바뀌었을 수 있다”는 + versioned hint다. +- event payload를 TanStack Query cache나 domain entity에 자동으로 덮어쓰지 + 않는다. 기본 효과는 validated event → registered invalidation topic → + authorization을 다시 통과한 HTTP refetch다. +- 제품이 authoritative delta 적용을 선택하려면 server revision, base revision, + atomic commit point, gap recovery와 conflict semantics를 event type별로 + 증명해야 한다. +- Web Push payload는 사용자에게 알릴 작은 opaque hint다. live state delta, + ordered stream, read receipt 또는 background synchronization authority가 아니다. +- Polling의 `200`/`304`는 해당 HTTP representation의 결과다. 다른 stream의 + event 처리나 background notification 성공을 증명하지 않는다. +- `CURSOR` profile의 resume cursor는 replay 위치다. credential, authorization proof, entity revision, + event ID 또는 global ordering과 동일시하지 않는다. +- transport가 확인한 delivery는 application effect commit을 뜻하지 않는다. + +browser 종료, replay retention 만료, `SNAPSHOT_ONLY` stream과 Push +expiry/permission/provider 상태까지 포함한 공통 delivery 보장은 없다. 다만 +retention 안의 `CURSOR` foreground profile에서 server가 전달·replay한 event의 +처리 모델은 duplicate-tolerant at-least-once다. application 효과는 +idempotent하거나 authoritative refetch로 수렴해야 한다. 그 밖의 profile은 +best-effort이며 snapshot만 최신 상태의 authority다. exactly-once는 공통 목표가 +아니다. + +### 1.3 inbound와 outbound 경계를 분리한다 + +연결을 만들고 닫는 책임은 outbound infrastructure다. + +- fixed endpoint 선택 +- session credential 협력 +- connect, subscribe, resume와 reconnect +- WebSocket control frame과 선택된 typed command 송신 +- push subscription 생성·backend 등록·해제 +- poll request scheduling과 cancellation + +외부 event를 application 의도로 바꾸는 책임은 inbound adapter다. + +- raw bytes/frame의 hard cap +- UTF-8/JSON/wire schema/version 검증 +- scope, stream epoch, event type와 generation 확인 +- duplicate/out-of-order/gap 처리 +- feature event input 또는 query invalidation effect로 mapping +- 처리 결과 뒤 cursor/ack commit + +물리적으로 하나의 runtime factory가 두 역할을 조립할 수는 있지만 public +interface와 module dependency는 분리한다. application/domain에는 `EventSource`, +`WebSocket`, `MessageEvent`, `PushSubscription`, `ServiceWorkerRegistration`, +native `Notification`, URL/header, TanStack 또는 vendor SDK type을 노출하지 +않는다. + +WebSocket send가 필요해도 `send(payload: unknown)`을 application port로 만들지 +않는다. `PublishPresence`, `AcknowledgeAssignment`처럼 실제 업무 capability와 +closed payload를 feature가 소유한다. durable mutation은 별도 선택이 없으면 +기존 HTTP/idempotency 경로를 유지한다. + +### 1.4 공통 mechanism과 제품 정책을 분리한다 + +공통 runtime이 소유한다. + +- strict protocol/envelope parser와 byte/depth/count ceiling +- immutable config snapshot과 fixed endpoint lookup +- scope/generation fence +- sequential apply queue, bounded dedupe와 gap detection +- cursor commit ordering과 reset orchestration +- backoff/jitter, retry budget와 timer cleanup +- online/visibility/page lifecycle adapter +- native exception과 close reason redaction +- terminal/idempotent `close()`/`dispose()` + +composition 또는 feature owner가 결정한다. + +- freshness SLO와 transport 선택 +- event stream/topic/type registry +- event payload codec와 application effect +- account/tenant/session scope projection +- replay retention, snapshot endpoint와 cursor reset UX +- event rate/size, connection/subscription와 polling budget +- hidden 상태의 grace/close 정책 +- push permission copy, notification content와 retention +- backend/provider, rollout과 kill switch + +backend/provider가 소유한다. + +- connection, subscription과 각 command의 authorization +- commit 이후 event publication과 replay ledger +- stream epoch, sequence, cursor retention과 reset response +- SSE proxy flush/idle contract +- WebSocket upgrade, connection/rate/backpressure limit +- push subscription registry, encryption, VAPID private key와 delivery cleanup +- polling snapshot/ETag/rate-limit semantics + +제품 owner와 protocol authority가 없는 skeleton은 임의 topic, endpoint, +notification text나 poll interval을 자동 등록하지 않는다. + +## 2. Transport 선택 + +### 2.1 선택표 + +| 요구 | 우선 후보 | 선택 조건 | 선택하지 않는 조건 | +| --- | --- | --- | --- | +| active document에서 server → client event | SSE | one-way text event, replay cursor, HTTP streaming/hosting owner | client message, binary, arbitrary header/handshake가 핵심 | +| active document의 진짜 duplex interaction | WebSocket | presence/collaboration/interactive command, subprotocol·resume·queue owner | server notification만 필요하거나 HTTP command가 충분 | +| inactive/terminated document에 user-visible notification | Web Push | 명시적 permission UX, Service Worker, backend subscription/provider owner | live UI, ordered delta, 보장된 즉시성 또는 silent sync가 목적 | +| relaxed freshness 또는 stream fallback | bounded polling | snapshot/conditional endpoint, finite lease와 request budget | 짧은 주기로 push를 흉내 내거나 hidden 상태에서 계속 실행 | +| focus/reconnect 시 최신화면 충분 | 기존 Query refetch | freshness SLO를 만족 | 추가 transport를 설치할 이유가 없음 | +| 사용자가 직접 갱신해도 충분 | manual refresh | stale 상태를 명확히 표시 가능 | 자동 background work가 불필요 | + +SSE를 지원하지 않는다는 이유만으로 WebSocket으로 자동 전환하지 않는다. +WebSocket duplex protocol을 Polling으로 조용히 축소하지도 않는다. fallback은 +같은 사용자 의미와 server authority를 보존할 때 registry에 명시한 한 경로만 +선택한다. + +Web Push는 foreground transport의 fallback이 아니다. active document가 없을 때 +사용자에게 알리는 보완 채널이며, document가 열리면 snapshot/refetch 후 SSE 또는 +WebSocket이 독립적으로 연결된다. + +### 2.2 선택 절차 + +```text +measured freshness/interaction requirement + -> focus/refetch/manual refresh로 충족 가능한지 확인 + -> direction/background/delivery 의미 분류 + -> server replay/snapshot/auth/retention owner 확인 + -> 하나의 primary foreground transport 선택 + -> 의미가 같은 bounded polling fallback 선택 여부 + -> Web Push 보완 요구를 별도 선택 + -> failure UX, resource budget와 kill switch 승인 + -> deterministic/provider/browser/operations evidence + -> canary traffic admission +``` + +다음 중 하나라도 없으면 product selection은 `NOT_SELECTED`다. + +- 측정 가능한 freshness 또는 interaction SLO +- event/snapshot protocol owner +- authorization와 account transition 계약 +- replay retention 또는 명시적인 snapshot-only 복구 +- connection/request/push 비용 owner +- stale/degraded/unsupported UX +- 실제 backend/provider test environment + +### 2.3 인접 server-stream protocol과의 경계 + +[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)의 +Connect/gRPC-Web server stream과 향후 GraphQL subscription은 이 문서의 SSE/WebSocket과 +자동으로 같은 adapter가 아니다. + +- Connect/gRPC-Web server stream은 registry-owned operation 하나의 request/response + lifecycle, protocol-specific envelope/trailer와 total deadline을 API platform이 소유한다. + application orchestrator가 mapped `AsyncIterable>`를 소비하는 + operation-bound outbound result일 수 있으며, 항상 unsolicited inbound + notification으로 재분류하지 않는다. +- GraphQL subscription은 현재 `NOT_SELECTED`이며 persisted query adapter에 + 암묵적으로 추가하지 않는다. GraphQL `@defer`/`@stream`은 finite incremental + HTTP response profile이지 subscription이나 realtime delivery가 아니다. +- generated protobuf message를 `REALTIME_EVENT_V1` JSON envelope로 다시 감싸지 + 않는다. API adapter가 frame decode, semantic schema와 pure boundary mapping을 + 끝낸 뒤, 제품이 runtime-wide notification projection을 명시적으로 선택한 + branch에서만 mapped event를 common scope/generation/dedupe/gap/resync + coordinator 또는 `FeatureEventInput`에 전달한다. +- transport frame decode, media/subprotocol, status/trailer와 reconnect는 각 API + adapter가 계속 소유한다. 특히 Connect/gRPC-Web은 첫 event 전 transport + replay만 허용하고 이후 reconnect에는 등록된 resume protocol이 필요하다는 + VD-29/VD-27 규칙을 우선한다. +- API operation registry의 현재 `SERVER_STREAM`은 + `CONNECT_HTTP | GRPC_WEB + rpcKind=SERVER_STREAM`만 허용한다. SSE, WebSocket과 Web Push는 + realtime registry가 소유하고, bounded polling은 terminal·replay-safe REST + `QUERY` operation에 적용하는 scheduling policy다. +- bounded polling의 한 logical attempt는 REST operation registry의 credential, + conditional request, schema/mapper와 deadline을 재사용한다. Poll 전용 + operation은 transport retry를 끄며 이 문서는 완료된 attempt 사이 cadence와 + failure backoff만 소유한다. + +같은 stream을 Connect/gRPC-Web과 SSE로 자동 failover하지 않는다. wire와 resume 의미가 +같다는 backend contract와 별도 rollout evidence가 있을 때만 명시된 fallback으로 +선택한다. + +## 3. Topology와 clean architecture 배치 + +### 3.1 Window runtime + +```text +bootstrap composition root + -> immutable RealtimePolicyRegistry + -> session/account scope authority + -> lifecycle/online clock adapters + -> selected connection owner + -> SSE adapter + -> or WebSocket adapter + -> or no live connection + -> event protocol decoder + -> stream coordinator + -> dedupe/order/gap/resume + -> feature event input + -> query invalidation bridge + -> authoritative resync gateway + -> optional bounded polling coordinator + -> redacted diagnostics +``` + +page나 feature hook이 native connection을 직접 생성하지 않는다. route는 +registry-owned logical subscription lease를 획득하고 release할 뿐이다. +WebSocket runtime은 같은 endpoint/protocol의 logical subscription을 +multiplex할 수 있지만 서로 다른 account scope나 protocol version을 같은 physical +connection에 섞지 않는다. SSE baseline은 §6의 단일 session feed를 local +dispatch할 뿐 server-side logical subscription을 multiplex하지 않는다. + +권장 application 의미 경계의 예시는 다음과 같다. + +```ts +type ExternalEventEffect = + | Readonly<{ kind: "IGNORE" }> + | Readonly<{ kind: "INVALIDATE"; topic: QueryInvalidationTopic }> + | Readonly<{ kind: "RESYNC"; stream: StreamRegistrationId }> + | Readonly<{ kind: "APPLY_EPHEMERAL"; viewEvent: ViewEvent }>; + +interface FeatureEventInput { + handleExternalEvent( + event: FeatureExternalEvent, + context: ExternalEventContext, + ): Promise>; +} +``` + +이 예시는 target 의미를 설명하며 현재 production type이 아니다. +`QueryInvalidationTopic`, event union과 failure는 실제 feature registry가 소유한다. +application use case가 `QueryClient`를 호출하지 않고, presentation query bridge가 +`INVALIDATE`를 canonical query key 동작으로 투영한다. + +### 3.2 Service Worker runtime + +Web Push는 window bootstrap과 다른 실행 환경이다. + +```text +service worker composition root + -> exact worker/release protocol + -> strict push envelope codec + -> bounded push event handler + -> notification policy registry + -> safe notification renderer + -> notificationclick route mapper + -> optional client-window handoff +``` + +Service Worker를 realtime 때문에 선택해도 offline shell, fetch interception, +private response cache와 background sync가 자동으로 승인되지 않는다. 기존 Cache +Storage reference runtime과 worker registration은 인접하지만 별도 capability다. + +하나의 origin과 scope에는 worker registration/update owner가 하나여야 한다. +realtime용 worker를 별도 파일로 겹쳐 등록하지 않고, 선택된 worker capability가 +하나의 build/composition root에서 `push`, `notificationclick`, install/activate +handler를 조립한다. + +worker는 React presentation이 아니지만 외부 event가 application 의도를 구동한다는 +inbound 원칙을 동일하게 따른다. worker 전용 codec과 use-case facade는 DOM이나 +window runtime을 import하지 않는다. + +### 3.3 Polling의 소유 위치 + +일반 server-state freshness polling은 별도 `PollingPort`가 아니라 query bridge의 +scheduling policy다. 기존 query key, AbortSignal, focus/reconnect refetch와 같은 +owner가 중복 실행을 막는다. + +업무상 long-running job의 terminal 상태를 기다리는 polling은 application +orchestrator가 기존 feature gateway와 `ClockPort`를 사용한다. 이 경우 poll +lease의 max attempts, max elapsed, terminal states와 cancellation이 use case +계약에 포함된다. + +어느 경우에도 page component의 `setInterval`이나 각 HTTP/Query/SDK의 독립 retry +loop로 구현하지 않는다. + +## 4. Registry와 공통 event protocol + +### 4.1 Immutable registration + +선택된 stream은 closed registry row를 가져야 한다. + +```ts +type RealtimeStreamRegistration = Readonly<{ + id: StreamRegistrationId; + protocol: "REALTIME_EVENT_V1"; + owner: FeatureId; + scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND"; + primaryTransport: "SSE" | "WEBSOCKET" | "NONE"; + endpointId: RealtimeEndpointId; + eventTypeIds: readonly EventTypeId[]; + delivery: "INVALIDATION_HINT" | "AUTHORITATIVE_DELTA" | "EPHEMERAL"; + recovery: RealtimeRecoveryProfile; + fallback: "BOUNDED_POLLING" | "EXPLICITLY_STALE"; + hiddenPolicy: "CLOSE" | "BOUNDED_GRACE"; + limits: RealtimeLimits; + killSwitchId: KillSwitchId; +}>; + +type RealtimeEventTypeRegistration = Readonly<{ + id: EventTypeId; + owner: FeatureId; + payloadSchemaId: RuntimeSchemaId; + mapperId: BoundaryMapperId; + effectProfileId: ExternalEventEffectProfileId; + stateBearing: boolean; +}>; + +type RealtimeRecoveryProfile = + | Readonly<{ + mode: "CURSOR"; + snapshotOperationId: ApiOperationId; + checkpointCodecId: RuntimeSchemaId; + barrier: "REPLAY"; + }> + | Readonly<{ + mode: "SNAPSHOT_ONLY"; + snapshotOperationId: ApiOperationId; + checkpointCodecId: RuntimeSchemaId; + barrier: "CONNECT_BUFFER" | "SERVER_HOLD" | "NONE"; + }> + | Readonly<{ + mode: "SESSION_REBUILD"; + rebuildInputId: ApplicationInputId; + }>; +``` + +실제 config schema는 exact key, duplicate ID, unknown reference, contradictory +transport/fallback과 implementation ceiling 초과를 startup에서 거절한다. +registry는 construction 시 copy/freeze하며 request마다 endpoint, topic, scope, +payload limit, retry와 fallback을 override하지 못한다. + +`channel: string`이나 arbitrary URL을 application caller가 제공하지 않는다. +V1 logical subscription input은 registry ID만 허용한다. server-side subset +filter는 contiguous stream-wide sequence를 깨뜨릴 수 있으므로 `NOT_SELECTED`다. +filter가 필요하면 별도 `StreamRegistrationId`, stream epoch, contiguous sequence와 +checkpoint를 가진 stream으로 등록한다. common sequence/checkpoint 처리 뒤의 +feature-local dispatch predicate는 server subscription/cursor 의미를 바꾸지 +않는다. + +state-bearing event가 하나라도 있는 stream은 `CURSOR | SNAPSHOT_ONLY`와 registered +snapshot/checkpoint를 가져야 한다. `SESSION_REBUILD`는 모든 event가 `EPHEMERAL`인 +stream에만 허용한다. contradictory recovery/delivery/barrier는 startup에서 +거절한다. + +### 4.2 Target event envelope + +공통 transport가 전달하는 target envelope의 의미는 다음과 같다. + +```ts +type RealtimeEventBase = Readonly<{ + protocol: "REALTIME_EVENT_V1"; + streamId: StreamRegistrationId; + streamEpoch: string; + eventType: EventTypeId; + eventId: string; + sequence: string; + occurredAt: string; + scopeBinding: string; + payload: TPayload; +}>; + +type RealtimeEventEnvelope = Readonly< + RealtimeEventBase & + ( + | Readonly<{ recoveryMode: "CURSOR"; resumeCursor: string }> + | Readonly<{ + recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD"; + resumeCursor: null; + }> + ) +>; +``` + +- `protocol`은 exact literal이고 unknown/newer version은 fail-closed한다. +- `streamId`와 `eventType`은 registry에 존재해야 한다. +- `streamEpoch`은 server stream reset, partition rebuild 또는 호환 불가능한 + replay change 때 바뀌는 opaque identifier다. +- `eventId`는 bounded dedupe용 opaque ID다. +- `sequence`는 stream + epoch 안에서만 단조 증가하는 canonical unsigned decimal + string이다. JSON `number`의 safe-integer 한계를 피하고 parse 뒤 bounded + integer representation으로 비교한다. +- `CURSOR`의 non-empty `resumeCursor`만 server-issued opaque replay position이다. + event ID나 sequence와 같은 문자열일 수 있어도 의미는 별도다. +- `SNAPSHOT_ONLY | SESSION_REBUILD`는 cursor를 발급·전송하지 않고 exact + `resumeCursor: null`을 사용한다. 빈 문자열이나 synthetic cursor를 만들지 않는다. +- `occurredAt`은 strict RFC 3339 timestamp지만 ordering authority가 아니다. +- `scopeBinding`은 raw account/session ID나 cache-key fingerprint가 아닌 + session/BFF-issued opaque exact-match token이다. authenticated session snapshot과 + server subscription에 같은 값을 bind하고 scope switch마다 rotate한다. client + nonce profile을 선택하면 authenticated fetch/WebSocket subscribe에서 server가 + exact echo해야 하며 native EventSource에는 사용하지 않는다. +- `payload`는 event type별 closed codec와 더 작은 byte/count/depth limit를 가진다. + +credential, access token, email, account/tenant/user ID, signed URL, +PushSubscription endpoint/key와 notification 자유 문구는 envelope에서 금지한다. +제품에 식별자가 필요하면 backend가 authorization한 opaque resource reference를 +사용한다. + +`scopeBinding`은 authorization proof가 아니다. server는 connect/resume와 각 +logical subscription을 current credential로 다시 인가한다. `CURSOR`는 +`protocol + stream/feed + streamEpoch + registered subscription set + +authorization scope`에 server-side로 bind한다. client는 cursor를 해석하거나 +수정하지 않고 header-safe character/byte ceiling만 검사해 전달한다. 다른 +stream/account/subscription의 cursor 재사용이나 tamper는 `RESET_REQUIRED` 또는 +`FORBIDDEN`으로 닫는다. + +기존 recipe의 `RealtimeEvent`는 이 target envelope로 구현됐다는 증거가 +아니다. `sequence: number`와 generic payload를 가진 copyable example일 뿐이며, +선택 branch에서 breaking recipe amendment 또는 feature-local contract로 +교체한다. + +### 4.3 Decode와 적용 순서 + +모든 foreground transport는 다음 순서를 지킨다. + +```text +raw bytes/frame + -> transport byte ceiling + -> UTF-8 / exact media or frame type + -> JSON syntax + object depth/count ceiling + -> protocol/version/exact-key schema + -> registry stream/event lookup + -> current scope + generation fence + -> stream epoch + sequence + dedupe check + -> ValidatedRealtimeEventDto (adapter-private) + -> registered pure boundary mapper + -> FeatureExternalEvent + -> sequential feature effect + -> local effect commit + -> last-applied sequence + conditional CURSOR advance + -> optional selected WebSocket/event-protocol ACK +``` + +CURSOR를 effect보다 먼저 저장하면 crash 시 event를 잃을 수 있다. effect 뒤에 +저장하면 crash window에서 duplicate가 다시 올 수 있으므로 effect가 idempotent +해야 한다. non-CURSOR profile도 sequence/checkpoint를 effect 뒤에 갱신한다. +memory-only invalidation hint는 duplicate invalidate를 허용한다. +authoritative delta나 durable command effect는 별도 idempotency ledger 없이는 +선택하지 않는다. + +schema/mapper가 실패하면 effect, cursor/ACK와 cache write는 모두 0이다. +`MAPPING_CONTRACT_VIOLATION`으로 닫고 state-bearing event는 snapshot resync한다. +ACK가 있는 protocol에서도 ACK는 idempotent하고 bounded해야 하며 business +commit, 사용자 확인 또는 read proof가 아니다. SSE에는 application ACK 경로가 +없다. + +callback 하나의 실패가 connection event loop를 깨거나 다음 event와 병렬로 +뒤섞이지 않게 stream별 sequential queue를 사용한다. V1 sequence는 stream-wide +하나뿐이다. partition ordering이 필요하면 각 partition을 별도 logical stream으로 +등록한다. 한 envelope 안의 partition별 ordering/concurrency는 V2에서 closed +`partitionId`와 partition별 checkpoint/ceiling을 추가하기 전에는 허용하지 않는다. + +### 4.4 Duplicate, out-of-order와 gap + +stream + epoch별 `lastAppliedSequence`를 기준으로 처리한다. + +| 입력 | 동작 | +| --- | --- | +| 이미 처리한 `eventId` | payload를 다시 적용하지 않고 duplicate 관측만 남김 | +| 같은 `eventId`인데 sequence/payload가 다름 | protocol conflict; cursor 미진행, close + resync | +| 같은 `sequence`인데 다른 `eventId` | protocol conflict; cursor 미진행, close + resync | +| `sequence <= lastApplied` | stale/out-of-order로 drop | +| `sequence == lastApplied + 1` | 순차 적용 | +| `sequence > lastApplied + 1` | gap; delta 적용 중지, `RESYNC_REQUIRED` | +| unknown `streamEpoch` | old/new epoch를 섞지 않고 registered snapshot 또는 session rebuild | +| dedupe/queue ceiling 초과 | 임의 eviction 후 계속 적용하지 않고 registered recovery | +| captured old runtime generation | late callback safe drop + aggregate diagnostic | +| current connection의 `scopeBinding` mismatch | effect/sequence/cursor 0, freshness `UNKNOWN`, close + session revalidation + registered recovery | + +작은 out-of-order reorder buffer를 선택할 수 있지만 count, bytes와 wait deadline을 +registry가 제한해야 한다. 기본은 reorder하지 않고 gap으로 처리하는 것이다. + +gap 뒤에는 뒤 이벤트를 “최신처럼 보이므로” 적용하지 않는다. 다음 흐름은 +state-bearing stream에 적용한다. + +```text +gap detected + -> pause stream apply + -> mark freshness UNKNOWN/RESYNCING + -> authoritative snapshot request + -> validate snapshot + checkpoint + -> current generation의 required projection 적용 완료 + -> reset dedupe/order state + -> checkpoint nextExpectedSequence부터 resume +``` + +snapshot request가 실패하면 stale UI와 manual retry를 표시한다. reconnect만 +반복해 gap을 복구했다고 주장하지 않는다. + +```ts +type SnapshotCheckpoint = Readonly<{ + streamEpoch: string; + lastAppliedSequence: string; + snapshotRevision: string; +} & ( + | Readonly<{ recoveryMode: "CURSOR"; resumeCursor: string }> + | Readonly<{ recoveryMode: "SNAPSHOT_ONLY"; resumeCursor: null }> +)>; +``` + +snapshot과 checkpoint는 같은 server commit point의 의미로 발급한다. subscribe +ack도 recovery mode와 일치하는 `acceptedCursor: string | null` 및 +`nextExpectedSequence`를 반환한다. frontend는 TanStack 여러 projection 사이의 +transaction을 과장하지 않고, current generation에서 필요한 projection 적용이 +모두 성공하기 전에는 cursor/sequence state를 바꾸거나 stream effect를 재개하지 +않는다. + +`EPHEMERAL + SESSION_REBUILD` gap/epoch/overflow는 snapshot을 가장하지 않는다. +connection을 닫고 presence/collaboration session을 registered rebuild input으로 +다시 연다. rebuild가 실패하면 interaction을 disabled/degraded로 표시한다. + +### 4.5 Backend commit, outbox와 replay authority + +frontend 정확성은 backend event publication 계약에 의존한다. + +- 업무 transaction commit 전에 “완료” event를 publish하지 않는다. +- database state와 event ledger의 이중 쓰기는 transactional outbox, 동일 + commit log 또는 동등한 server-owned mechanism으로 해결한다. +- broker offset이나 Kafka partition을 frontend wire에 직접 노출하지 않는다. +- server가 stream/subject authorization을 매 connect/subscribe/resume마다 + 다시 확인한다. +- replay retention과 cursor expiry 시간을 문서화한다. +- 오래된 cursor는 빈 성공 stream이 아니라 explicit `RESET_REQUIRED` 의미를 + 반환한다. +- snapshot 응답은 함께 사용할 `SnapshotCheckpoint`를 같은 commit point의 + 의미로 발급한다. +- server scale-out instance가 바뀌어도 같은 logical stream의 ordering 의미가 + 보존돼야 한다. + +backend가 replay를 제공하지 않으면 state-bearing registration은 +`SNAPSHOT_ONLY`다. snapshot과 +connect 사이 event를 보존하는 server barrier가 없다면 “snapshot 먼저, 그 뒤 +connect”만으로 `CURRENT`를 보장하지 못한다. `CURRENT`가 필요하면 +connect-and-bounded-buffer → snapshot/checkpoint → checkpoint 이후 event 적용, +또는 server가 checkpoint 이후 event를 hold하는 동등한 barrier를 제공해야 한다. +그 barrier가 없는 profile은 reconnect/restore마다 finite revalidation을 +수행하되 freshness를 `STALE`/`UNKNOWN`으로 표시하고 manual refresh를 제공한다. + +## 5. 공통 lifecycle + +### 5.1 서로 다른 상태 축 + +상태를 `connected: boolean` 하나로 표현하지 않는다. + +| 축 | canonical 상태 | +| --- | --- | +| connection | `IDLE`, `CONNECTING`, `OPEN`, `BACKING_OFF`, `PAUSED`, `DRAINING`, `CLOSED` | +| freshness | `UNKNOWN`, `CURRENT`, `STALE`, `RESYNCING` | +| authorization | `UNKNOWN`, `VALID`, `REFRESHING`, `REQUIRED`, `FORBIDDEN` | +| availability | `UNKNOWN`, `AVAILABLE`, `DEGRADED`, `UNSUPPORTED`, `UNAVAILABLE` | +| admission | `DISABLED`, `SHADOW`, `CANARY`, `ENABLED` | + +UI에는 필요한 projection만 노출한다. + +```text +LIVE +RECONNECTING +STALE +AUTH_REQUIRED +UNSUPPORTED +``` + +`OPEN + UNKNOWN`, `PAUSED + CURRENT`, `BACKING_OFF + STALE`처럼 조합될 수 있다. +socket open만으로 “최신” badge를 표시하지 않는다. + +### 5.2 Runtime 생성과 lease + +```text +runtime config/release verified + -> registry validated + -> traffic admission checked + -> 새 factory가 DISABLED: network side effect 없이 종료 + -> local static capability probe + -> session recovery settled + -> immutable scope/generation acquired + -> selected adapter and coordinator created + -> SHADOW: 승인된 synthetic hosting probe만 허용 + -> CANARY/ENABLED: route/session subscription lease 허용 +``` + +route-scoped subscription은 마지막 consumer가 사라지면 logical unsubscribe한다. +session-wide notification badge처럼 명시적으로 등록된 subscription만 route +unmount 뒤 남을 수 있다. + +React StrictMode mount → cleanup → mount에서도 physical listener, timer와 +subscription이 중복되지 않아야 한다. lease release와 runtime `close()`는 +terminal/idempotent다. + +`TrafficAdmission=DISABLED`는 새 data-plane runtime/lease/send/subscribe/poll을 +막는다. 이미 소유한 resource를 안전하게 없애는 fixed, idempotent +close/unsubscribe/revoke는 connection `DRAINING`의 bounded cleanup plane에서만 +허용한다. cleanup caller는 captured resource/association ID만 사용할 수 있고 새 +endpoint/topic을 만들거나 retry loop를 시작하지 않는다. `CLOSED` 뒤에는 cleanup을 +포함한 network side effect가 0이다. + +### 5.3 Reconnect와 retry owner + +reconnect는 full-jitter exponential backoff를 사용한다. + +```text +localDelay = random(0, min(maxDelay, baseDelay * 2^attempt)) +effectiveDelay = max(localDelay, validServerNotBefore) +``` + +- base/max delay, max attempts와 max elapsed를 registry ceiling 안에서 고정한다. +- HTTP `Retry-After`, SSE `retry`와 protocol retry hint는 서로 다른 wire + 의미지만 모두 server가 요구한 시각보다 먼저 재시도하지 않는 not-before + bound로 취급한다. `effectiveDelay`가 implementation max delay나 남은 elapsed + budget을 넘으면 낮춰 clamp하지 않고 reconnect/poll을 종료해 + `DEGRADED`/`STALE`로 전환한다. +- connection이 잠깐 열렸다는 이유로 attempt를 0으로 만들지 않는다. stable-open + window 또는 valid heartbeat/event 뒤에만 reset한다. +- stream reconnect는 realtime recovery coordinator, credential recovery는 + session owner, Poll attempt 사이 cadence는 poll coordinator가 각각 소유한다. + Poll-bound REST transport retry와 Query retry는 끈다. 같은 logical recovery의 + effect writer나 timer가 둘 이상 활성화되지 않게 generation으로 배타화한다. +- `offline` 상태에서는 timer로 재시도하지 않고 online hint를 기다린다. + `navigator.onLine`은 실제 backend reachability authority가 아니다. +- auth expiry는 session owner의 single-flight recovery를 한 번 요청한다. + 실패하면 `AUTH_REQUIRED`로 멈춘다. +- protocol/schema/forbidden/policy failure는 retry하지 않는다. +- 외부 close/result의 rate limit/provider unavailable은 유효한 bounded server + not-before hint가 있을 때만 재시도한다. hint가 없거나 ceiling 밖이면 + terminal이다. adapter 내부 provider throw처럼 server 응답이 아닌 실패만 + 남은 retry budget 안에서 local full-jitter를 사용할 수 있다. +- budget을 소진하면 infinite reconnect가 아니라 `DEGRADED`와 명시된 fallback + 또는 stale UI로 전환한다. + +authoritative recovery와 새 transport 사이에는 값 동등성이 아닌 한 generation의 +exact proof barrier를 둔다. + +```text +stream recovery commits exact branded RECOVERY_COMMITTED checkpoint + -> reconnect owner retains that exact object + -> next attempt receives initialRecovery=RECOVERY_RECONNECT + -> SSE onOpen / WebSocket onSubscribed proves the same checkpoint + -> attempt returns the same recovery proof + -> common coordinator confirms the transport barrier + -> readiness gate settles + -> only then may the new transport admit events +``` + +checkpoint clone, 누락된 proof, 다른 stream/generation의 proof와 readiness deadline +초과는 fail-closed다. gate의 성공·실패·abort 모든 경로는 반드시 settle하며, +attempt당 상한은 30초다. + +### 5.4 Visibility, page lifecycle와 network + +| signal | 기본 동작 | +| --- | --- | +| `visibilitychange -> hidden` | 새 Polling 중지, configured grace 뒤 live connection pause/close | +| `visibilitychange -> visible` | scope 확인, snapshot freshness gate, 그 뒤 resume/connect | +| `offline` | request/connection attempt abort, backoff timer 정지 | +| `online` | 즉시 flood하지 않고 jitter 후 authoritative reachability check | +| `pagehide` | document-owned SSE/WS/Poll 모두 close; unload-only write 금지 | +| `pageshow`/bfcache restore | old callback generation 폐기, snapshot/resume gate | +| discarded/reloaded document | memory cursor를 신뢰하지 않고 snapshot 또는 scope-bound resume | +| Service Worker termination | in-memory queue/cursor가 보존된다고 가정하지 않음 | + +hidden document의 timer는 throttling되므로 heartbeat deadline만으로 즉시 connection +failure를 선언하지 않는다. hidden grace를 선택한 경우에도 implementation hard +ceiling 뒤에는 close한다. + +`beforeunload`/`unload`에서 unsubscribe, cursor write, push revoke나 server logout이 +완료된다고 가정하지 않는다. local session notification과 server-side TTL/revoke가 +authority다. + +`pagehide`/freeze 뒤 session-wide logical subscription metadata가 남더라도 physical +connection은 document owner와 함께 닫는다. 다음 document/runtime은 current +session과 checkpoint를 다시 검증한 뒤 새 connection을 만든다. + +### 5.5 Logout, account/tenant switch와 release + +```text +session authority announces local transition + -> admission DISABLED + -> connection DRAINING + -> old generation FENCED + -> reject new lease/send/poll + -> abort connect/read/backoff/snapshot + -> close SSE/WebSocket + -> clear memory cursor/dedupe/queue + -> revoke old push account association through bounded request + -> dispose listeners/timers + -> CLOSED + -> construct new scope independently +``` + +late socket event, poll response, auth recovery, snapshot, notification handoff와 +Service Worker message는 old generation/scope면 적용하지 않는다. + +long-lived connection은 connect-time authorization만 신뢰하지 않는다. backend는 +active subscription의 permission/session revoke를 control event 또는 close로 +전파하고, registry-owned `maxConnectionAge`/reauth deadline 안에 current +credential로 재연결하게 한다. SSE처럼 별도 subscribe command가 없는 transport는 +권한 변경 시 server가 stream을 종료해야 한다. server hint는 client hard +connection-age ceiling을 늘릴 수 없다. + +Web Push browser subscription 자체와 account association은 구분한다. logout의 +정상 security commit은 local `REVOKED` fence와 backend old-account association +revoke 둘 다다. 둘 중 하나가 ambiguous하면 완전한 push revoke를 주장하지 않고 +§8.3의 unavailable/reconciliation 경로로 내린다. browser `unsubscribe()`는 제품 +정책에 따른 best-effort cleanup이며, 그 실패 때문에 old account payload가 +노출되지 않도록 push envelope와 notification copy가 scope-safe해야 한다. + +release가 event codec, worker protocol이나 registry compatibility를 깨면 +release epoch를 바꾸고 old stream/worker message를 거절한다. worker update +중 old/new page가 공존할 수 있으므로 지원하는 N/N-1 wire window 또는 +fail-closed reload policy를 명시한다. + +### 5.6 여러 tab + +server stream과 `BroadcastChannel`을 하나의 bus로 합치지 않는다. server stream은 +backend authorization/replay authority이고 cross-tab channel은 같은 origin 안의 +best-effort hint다. + +기본 reference 설계는 tab별 runtime이되 다음을 제한한다. + +- tab 하나당 selected primary transport physical connection 1개 +- WebSocket logical subscription multiplexing 또는 SSE single-feed local dispatch +- hidden tab close/pause 정책 +- visible 복귀 시 authoritative snapshot + +SharedWorker나 leader election으로 origin 전체 connection을 하나로 만드는 기능은 +기본 목표가 아니다. leader crash, storage partition, worker 지원과 exactly-once +handoff를 별도로 설계해야 한다. 여러 visible window의 connection 수가 provider +budget을 넘는 제품은 별도 multi-context coordinator를 선택한다. + +다른 tab이 수신한 event를 BroadcastChannel로 전달해도 그것은 invalidation +optimization일 뿐이다. 수신하지 못한 tab은 focus/snapshot으로 수렴해야 한다. + +## 6. SSE + +### 6.1 선택 의미 + +SSE는 active document에서 server-to-client text event만 필요할 때 우선한다. +일반 업무 command는 기존 HTTP gateway를 사용한다. + +장점: + +- HTTP semantics와 proxy/observability 인프라를 재사용 +- event ID와 reconnect model이 표준에 있음 +- one-way 요구를 duplex protocol로 과장하지 않음 + +제약: + +- native `EventSource` constructor는 URL과 `withCredentials`만 받고 arbitrary + request header를 받지 않는다. +- native reconnect의 세부 status/header와 bounded retry를 application이 충분히 + 제어하기 어렵다. +- active document가 없거나 frozen/terminated되면 background delivery를 + 보장하지 않는다. +- HTTP/1.x, proxy buffering/idle timeout과 tab 수에 영향을 받는다. + +### 6.2 Baseline adapter 선택 + +공통 reference target은 same-origin BFF의 fetch-stream SSE adapter다. + +```text +fixed same-origin HTTPS endpoint + -> credential attacher / HttpOnly session cookie + -> fetch with AbortSignal + -> exact status/content type + -> bounded UTF-8 SSE parser + -> common event envelope decoder +``` + +이 선택은 다음을 제어하기 위함이다. + +- fixed headers와 opaque session integration +- `200/204/401/403/409/410/429/503`의 closed mapping +- redirect 금지 +- read/idle deadline와 total event buffer +- bounded reconnect owner +- lifecycle close와 current cursor 명시 + +native `EventSource`는 다음 조건을 모두 만족할 때 별도 adapter profile로 허용할 +수 있다. + +- same-origin HttpOnly cookie 인증 +- `INVALIDATION_HINT + SNAPSHOT_ONLY` recovery profile +- status별 상세 UX가 control event와 `204`만으로 닫힘 +- URL에 credential/cursor secret이 없음 +- adapter close/recreate 뒤 snapshot/checkpoint recovery가 있음 +- target browser/hosting evidence가 있음 + +native `EventSource`는 UA가 관리하는 last-event-ID를 application effect commit과 +묶을 수 없다. 따라서 이 profile은 `INVALIDATION_HINT` 전용이며 +`AUTHORITATIVE_DELTA`에는 금지한다. `error`/자동 reconnect/visibility·page restore +때마다 generation을 fence하고 authoritative snapshot freshness gate를 수행한다. +gate 동안 도착한 validated hint는 effect를 바로 쓰지 않고 bounded one-bit +`pendingInvalidation`으로 coalesce하며, checkpoint 뒤 pending refetch까지 끝내기 +전에는 `CURRENT`를 표시하지 않는다. 이 post-checkpoint drain 또는 동등한 server +hold/replay barrier를 제공하지 못하면 profile은 계속 `STALE`/`UNKNOWN`이다. +cursor-after-effect 또는 exact delta replay가 필요한 stream은 fetch-stream +profile만 허용한다. + +SSE baseline은 registry-owned `SESSION_FEED` 하나를 사용한다. `CURSOR` profile만 +feed-wide replay cursor 하나를 가지며 `SNAPSHOT_ONLY`는 cursor를 전송하지 +않는다. route lease는 수신 event의 local dispatch만 제어하며 server +subscription set을 동적으로 바꾸지 않는다. SSE에는 client→server +`SUBSCRIBE`/`UNSUBSCRIBE` frame이 없고 `Last-Event-ID`도 physical request당 +하나이므로, arbitrary multi-stream multiplex와 per-subscription cursor는 +`NOT_SELECTED`다. 필요하면 stream별 physical SSE와 더 작은 connection ceiling, +또는 별도 typed HTTP control plane/composite cursor protocol을 새 ADR로 선택한다. + +### 6.3 HTTP와 parser 계약 + +fetch-stream baseline: + +```text +method = GET +credentials = same-origin +redirect = error +cache = no-store +referrerPolicy = no-referrer +Accept = text/event-stream +Last-Event-ID = approved current cursor, CURSOR profile only +``` + +- endpoint는 composition-owned fixed absolute HTTPS URL이다. +- cross-origin은 explicit origin, exact CORS, CSP `connect-src`, credential와 + preflight 계약을 별도 승인해야 한다. +- token, account ID나 unrestricted topic을 URL/query에 넣지 않는다. +- 성공은 exact `200`과 normalized `text/event-stream`만 허용한다. +- stream은 UTF-8로만 decode한다. +- BOM, CR/LF/CRLF, comments, multi-line `data`, `event`, `id`, `retry`를 표준 + semantics로 처리한다. +- NUL/CR/LF가 포함된 invalid event ID를 cursor로 저장하지 않는다. +- fetch parser는 parsed candidate ID와 application-committed reconnect cursor를 + 분리한다. `CURSOR`의 각 application event block은 non-empty `id`를 직접 + 포함하고 decoded envelope의 `resumeCursor`와 exact match해야 한다. 이전 + block의 inherited SSE ID만으로 통과시키지 않는다. `SNAPSHOT_ONLY | + SESSION_REBUILD`는 `id`를 발급하지 않고 envelope cursor도 exact `null`이어야 + 한다. +- event blank line 전 EOF는 incomplete event로 폐기한다. +- comments/heartbeat도 line과 idle budget을 소비하지만 application event를 + 만들지 않는다. +- `retry` 값은 숫자 syntax와 registry ceiling을 통과한 경우 다음 delay hint로만 + 사용한다. +- compressed/decompressed bytes 중 더 큰 보수적 측정을 ceiling에 적용한다. + +상태 mapping: + +| 결과 | 동작 | +| --- | --- | +| `200 text/event-stream` | parser 시작 | +| `204` | server terminal close; 자동 reconnect 금지 | +| `401` | session recovery 한 번, 실패 시 `AUTH_REQUIRED` | +| `403` | `FORBIDDEN`, retry 금지 | +| `409`/`410` with exact reset contract | cursor expired, snapshot resync | +| `429` | bounded `Retry-After`, retry budget 소비 | +| `502`/`503`/`504` | bounded provider backoff | +| redirect, wrong type, malformed stream | protocol/policy failure, fail-closed | + +generic HTTP client의 전체-body decoder와 retry를 우회하되 credential, diagnostics, +fixed endpoint와 failure vocabulary는 같은 platform policy를 재사용한다. + +### 6.4 Heartbeat, resume와 hosting + +server는 application event가 없어도 policy 범위의 comment heartbeat를 보낸다. +client watchdog은 last received byte/comment/event 시간을 관측한다. hidden/frozen +상태에서는 watchdog을 pause하거나 connection을 닫아 false timeout을 만들지 +않는다. + +`CURSOR`에서만 SSE event `id`를 transport resume cursor로 사용하고 JSON +envelope의 `resumeCursor`와 exact equality를 검사한다. 다르면 protocol +failure다. application event ID와 cursor가 우연히 같아도 두 의미를 합치지 +않는다. 이 cursor는 위의 feed/subscription-set, scope와 epoch binding을 +만족해야 하며 authorization proof로 사용하지 않는다. + +hosting/provider evidence는 다음을 포함한다. + +- proxy/CDN response buffering 비활성 또는 streaming flush 증거 +- `Cache-Control: no-store`와 transform/cache 금지 +- idle/request duration과 heartbeat 호환 +- HTTP/2 또는 승인 connection budget +- deploy/drain 중 reconnect storm 제한 +- load balancer와 backend의 replay cursor 일관성 +- client disconnect 후 server resource cleanup + +local parser test만으로 hosting readiness를 주장하지 않는다. + +## 7. WebSocket + +### 7.1 선택 의미 + +WebSocket은 client와 server가 낮은 지연으로 지속적인 application message를 +교환해야 할 때만 선택한다. + +적합한 예: + +- presence와 ephemeral collaboration signal +- server가 조정하는 interactive session +- HTTP 요청/응답으로 표현하기 어려운 duplex protocol + +부적합한 예: + +- 단순 server invalidation notification +- durable mutation을 idempotency ledger 없이 socket send로 바꾸는 것 +- background notification +- 높은 event rate의 근거가 없는데 “실시간 같아 보이기” 위한 선택 + +### 7.2 Handshake와 인증 + +browser `WebSocket` constructor는 URL과 subprotocol만 제공한다. arbitrary +`Authorization` header를 기대하지 않는다. + +baseline: + +- fixed same-origin `wss:` endpoint +- Secure/HttpOnly/SameSite session cookie 또는 same-origin BFF가 소유한 + 짧은 handshake mechanism +- exact `Origin` 검증 +- fixed versioned subprotocol 예: `realtime.v1` +- CSP `connect-src` +- redirect 없음 + +access token, session token과 push capability를 URL/query 또는 +`Sec-WebSocket-Protocol`에 넣지 않는다. subprotocol은 protocol negotiation +전용이다. cross-origin cookie를 선택하면 exact origin allowlist, CSWSH 방어, +SameSite 정책과 handshake authorization을 별도 threat model로 승인한다. + +server는 handshake 성공만으로 이후 모든 logical subscription과 command를 +허용하지 않는다. subscribe/resume/command마다 current subject, resource와 +scope를 재인가한다. + +### 7.3 Closed message protocol + +기본 text JSON protocol은 다음 control message만 허용한다. + +```text +server -> WELCOME(protocol, connectionId, heartbeat, limits) +client -> SUBSCRIBE(subscriptionId, streamId, cursor|null, scopeBinding) +server -> SUBSCRIBED( + subscriptionId, + streamEpoch, + acceptedCursor|null, + nextExpectedSequence +) +server -> EVENT(subscriptionId, envelope) +server -> RESET_REQUIRED(subscriptionId, reason) +client -> UNSUBSCRIBE(subscriptionId) +server -> UNSUBSCRIBED(subscriptionId) +client -> HEARTBEAT(nonce) +server -> HEARTBEAT_ACK(nonce) +client/server -> CLOSE(category) +``` + +- exact top-level type/version와 key set을 검증한다. +- `connectionId`, subscription ID와 nonce는 diagnostics에 raw로 남기지 않는다. +- accepted cursor는 recovery mode/stream/scope binding을 다시 검증하고 + `nextExpectedSequence`로 local checkpoint를 초기화한다. +- same-epoch `CURSOR` resume이면 `nextExpectedSequence`는 반드시 local + `lastAppliedSequence + 1`이다. mismatch는 effect/cursor 0, + `RESET_REQUIRED`와 snapshot recovery로 닫는다. server가 opaque accepted cursor를 + rotate할 수는 있지만 requested cursor와 같은 logical position임을 conformance로 + 증명해야 하며 silent advance는 금지한다. +- state-bearing initial/no-cursor subscribe는 snapshot/checkpoint와 selected + buffer/hold barrier를 통과하기 전 `CURRENT`나 event effect를 허용하지 않는다. +- `UNSUBSCRIBE`를 보낸 subscription은 `UNSUBSCRIBING` tombstone으로 남긴다. + matching `UNSUBSCRIBED` ACK 전에는 ID를 재사용하거나 quota를 반환하지 않고, + 그 사이 늦게 온 `EVENT`/`RESET_REQUIRED`/`SUBSCRIBED`는 effect 없이 버린다. + unknown 또는 다른 state의 ACK는 protocol failure다. ACK가 bounded apply + deadline 안에 오지 않으면 connection을 닫아 reconnect owner로 넘긴다. +- server-advertised limit은 client implementation ceiling을 높일 수 없다. +- binary frame은 baseline에서 거절한다. 제품이 binary protocol을 선택하면 + 별도 codec/version, decompressed byte cap과 browser evidence가 필요하다. +- per-message compression은 decompression bomb와 memory ceiling을 검증한 + provider profile에서만 허용한다. + +업무 command를 추가하면 별도 closed operation registry, command ID, +idempotency, expected revision, authorization, ack/commit certainty와 retry +규칙을 정의한다. `send()` 반환 또는 `bufferedAmount` 감소는 server acceptance나 +business commit이 아니다. + +### 7.4 Heartbeat와 close + +browser API는 protocol ping/pong을 application에 노출하지 않으므로 backend +transport ping만으로 application freshness를 판정하지 않는다. 필요하면 위의 +bounded application heartbeat/ack를 사용한다. + +- server `WELCOME`이 허용된 heartbeat range를 고정한다. +- 한 번에 하나의 outstanding nonce만 둔다. +- ack deadline은 visibility와 network state를 고려한다. +- timeout은 connection을 close하고 common reconnect owner로 넘긴다. +- heartbeat timer를 route component마다 만들지 않는다. + +close code/reason은 raw 문자열을 application이나 telemetry에 노출하지 않고 +closed category로 mapping한다. + +| category | 예시 의미 | reconnect | +| --- | --- | --- | +| `NORMAL` | intentional close/drain | 아니오 | +| `RESTART` | server deploy/restart | bounded | +| `OVERLOADED` | server capacity/rate | exact server hint가 있을 때만 bounded; 없으면 terminal | +| `AUTH_REQUIRED` | session expired | single-flight recovery 뒤 한 번 | +| `FORBIDDEN` | policy/authorization | 아니오 | +| `PROTOCOL_MISMATCH` | version/schema/subprotocol | 아니오 | +| `CURSOR_RESET` | replay 불가 | snapshot 뒤 새 connection | +| `NETWORK_LOST` | abnormal/network | bounded | + +server private reason, stack, resource ID나 credential text를 UI/log에 복사하지 +않는다. + +### 7.5 Backpressure + +classic browser WebSocket은 incoming stream backpressure를 제공하지 않는다. +따라서 다음 상한이 필수다. + +- raw frame/decompressed frame bytes +- sequential inbound queue count와 bytes +- per-subscription event rate +- parsing/apply deadline +- outbound `bufferedAmount` +- outbound queued message count/bytes + +inbound queue overflow에서 오래된 delta를 임의로 버리고 계속하지 않는다. +classic browser WebSocket baseline은 receive pause가 없으므로 connection을 +close하고 freshness를 `UNKNOWN`으로 바꾼 뒤 snapshot resync한다. server가 +bounded `PAUSE`/`PAUSED`/`RESUME` control ACK와 발신 중단을 별도 protocol로 +증명한 profile에서만 subscription pause를 허용한다. + +outbound ceiling을 넘으면 새 ephemeral message를 typed `DROPPED_BACKPRESSURE`로 +거절하거나 connection을 drain한다. durable command를 memory queue에 무제한 +쌓지 않는다. offline command queue는 별도 `NOT_SELECTED` capability다. +reference runtime의 `SUBSCRIBE`/`UNSUBSCRIBE`/heartbeat/`CLOSE`는 하나의 FIFO +outbound queue를 통과하며, negotiated message count/queued bytes와 native +`bufferedAmount`를 함께 검사한다. 하나라도 넘으면 `QUEUE_OVERFLOW`, +`retryable=false`, `OVERLOADED`로 connection generation 전체를 닫고 snapshot +recovery를 요청한다. + +## 8. Web Push와 persistent notification + +### 8.1 다른 transport와의 관계 + +Web Push의 목적은 active document가 없을 수 있는 상태에서 Service Worker를 통해 +작은 notification hint를 받는 것이다. + +```text +application server + -> authorized subscription registry + -> Web Push provider / push service + -> browser PushSubscription + -> Service Worker push event + -> strict hint validation + -> persistent notification + -> notificationclick + -> safe route + authoritative foreground refresh +``` + +Web Push는 다음을 보장하지 않는다. + +- 즉시 delivery +- ordered delivery +- exactly-once +- 사용자 확인/read receipt +- silent background synchronization +- SSE/WebSocket과 같은 live connection +- push payload만으로 최신 server state + +push가 늦거나 유실돼도 app을 열었을 때 HTTP snapshot/focus refetch가 올바른 +상태로 수렴해야 한다. + +W3C Push API의 declarative push message는 Service Worker handler가 실행되지 +않아도 user agent가 notification을 표시하거나 handler 실패 시 fallback 표시를 +할 수 있다. V1에서는 이 경로를 `NOT_SELECTED`로 둔다. outbound payload의 +top-level `web_push: 8030` declarative shape를 금지하고 오직 encrypted +`WEB_PUSH_HINT_V1`만 보낸다. declarative profile은 local association fence, +closed copy/click route와 logout cleanup을 동등하게 보장하는 별도 ADR 전에는 +선택하지 않는다. + +### 8.2 Permission UX + +- secure context와 browser support를 먼저 확인한다. +- page boot, route enter, sign-in 직후 자동 prompt를 금지한다. +- 사용자가 notification 가치와 빈도를 이해한 뒤 명시적 action으로 요청한다. +- `default`, `granted`, `denied`, `unsupported`, `dismissed`를 다른 결과로 + 표현한다. +- denied를 반복 prompt로 우회하지 않는다. +- 기능 핵심 경로는 push permission 없이도 foreground inbox/status로 접근할 수 + 있어야 한다. +- consent copy, notification category, quiet hours와 해제 경로를 제품 owner가 + 승인한다. + +notification permission은 application authorization이 아니다. push를 켰다는 +이유로 account/resource 접근 권한을 부여하지 않는다. + +### 8.3 Subscription lifecycle + +```text +user opt-in + -> active service worker registration verify + -> permission request + -> VAPID public applicationServerKey verify + -> PushManager.subscribe(userVisibleOnly = true) + -> capture endpoint + p256dh + auth inside adapter + -> authenticated fixed BFF registration + -> server returns opaque registration ID + associationEpoch + sessionBindingEpoch + -> durable local ACTIVE fence transaction completes + -> native material leaves application memory +``` + +- VAPID private key는 server/provider에만 존재한다. public key만 runtime config에 + 둘 수 있다. +- endpoint, `p256dh`, `auth`는 message delivery capability material이다. + application state, URL, local/session storage, BroadcastChannel, diagnostics와 + analytics에 넣지 않는다. +- backend는 endpoint의 raw value 대신 제한된 운영 영역에서 encrypted storage와 + keyed fingerprint를 사용한다. +- subscription은 account association, device/browser installation, worker scope, + permission revision과 VAPID key generation을 server record에 묶는다. +- backend register/revoke는 VD-23 registry의 fixed `COMMAND` operation이다. + cookie session이면 exact CSRF profile이 필수다. register는 keyed idempotency + 또는 server atomic installation upsert + terminal receipt를 제공하고, + revoke는 duplicate/`ALREADY_GONE`을 성공으로 닫는 idempotent 의미를 가진다. + `associationEpoch`은 server commit 뒤에만 반환한다. +- browser 또는 push service가 subscription을 rotate/deactivate할 수 있으므로 + `pushsubscriptionchange` 하나에 의존하지 않는다. boot/foreground의 + `PushManager.getSubscription()`, authenticated server reconciliation, + expiration, registration response와 provider `404/410` cleanup을 함께 처리한다. +- `pushsubscriptionchange` worker handoff 자체도 lifecycle AbortSignal과 10초 + deadline 안에서만 controlled window에 reconciliation hint를 보낸다. + `matchAll()`이 signal을 따르지 않거나 `waitUntil()`이 동기 예외를 내도 늦은 + `postMessage`를 허용하지 않고 degraded observation으로 닫는다. +- re-subscribe가 실패하면 notification을 끄고 foreground fallback을 유지한다. + +`associationEpoch`은 backend registration authority가 발급한다. +`releaseEpoch`은 signed/verified release manifest에서 온다. backend registration +뒤 local `ACTIVE` commit 전에 crash할 수 있으므로 boot에서 native subscription, +local fence와 server association을 모두 대조하기 전에는 push를 `ACTIVE`로 +표시하지 않는다. orphan server record는 revoke하고, native/local만 남은 상태는 +재인가·재등록하거나 fail-closed unsubscribe한다. + +logout/account switch: + +1. durable `fenceGeneration`을 먼저 rotate하고 association을 `REVOKED`로 같은 + transaction에 commit한다. +2. window의 old runtime generation을 fence한다. +3. authenticated backend association revoke를 bounded request로 실행한다. +4. 제품 정책에 따라 native `unsubscribe()`를 best effort로 실행한다. +5. old association tag로 표시한 notification을 bounded `getNotifications()`로 + 찾아 best-effort close한다. +6. 새 account opt-in은 기존 consent와 association 정책을 다시 평가한다. + +backend revoke가 ambiguous하면 server subscription은 짧은 association TTL, +session/account epoch 확인과 send-time authorization으로 추가 방어한다. +local `REVOKED` transaction이 실패하면 “late push를 반드시 drop했다”고 주장하지 +않는다. push result를 `PUSH_UNAVAILABLE(reason=LOCAL_FENCE_UNSAFE)`로 내리고 +captured old association ID의 backend revoke만 bounded하게 시도할 수 있다. +현재 native subscription을 다시 조회해 unsubscribe하거나 association wildcard로 +notification을 닫지 않는다. 정상 cleanup도 작업 시작 때 capture한 native +subscription, exact non-null association tag와 변경되지 않은 durable fence를 +다시 확인한 경우에만 수행하며, 그 사이 새 association이 commit되면 모두 +건너뛴다. +logout/account switch 자체는 +account-neutral notification copy, click-time session 재인가와 짧은 server TTL에 +의존하며, 복구 전 새 account에 기존 association을 재사용하지 않는다. + +`CONTROL_PURGE`는 정상 revoke 뒤 자동 실행하지 않는다. 별도 retention/maintenance +owner가 exact `REVOKED` association을 정리할 때만 captured revision, authority와 +association epoch를 모두 제시하고 repository revision CAS로 삭제한다. purge와 +새 owner가 경합하면 `STALE_REVISION`으로 끝나며 새 control record를 지우지 않는다. + +worker는 window의 in-memory session을 authority로 사용할 수 없다. Web Push를 +선택하면 다음과 같은 최소 control record를 adapter-owned IndexedDB store에 +보관한다. + +```ts +type PushControlV1 = Readonly<{ + protocol: "PUSH_CONTROL_V1"; + fenceGeneration: string; + sessionBindingEpoch: string; + releaseEpoch: string; + association: + | Readonly<{ state: "UNASSOCIATED" }> + | Readonly<{ + state: "ACTIVE" | "REVOKED"; + associationEpoch: string; + }>; + updatedAt: string; +}>; +``` + +이 record에는 account/user ID, endpoint, `p256dh`, `auth`, notification content와 +credential을 넣지 않는다. `fenceGeneration`은 session authority가 먼저 rotate하는 +local opaque token이고 `sessionBindingEpoch`은 authenticated BFF가 발급한 opaque +exact-match 값이다. 둘 다 authorization proof나 readable subject ID가 아니다. +worker는 push hint의 association/release epoch와 exact match하고 association이 +`ACTIVE`일 때만 처리한다. record가 missing/corrupt/future version이면 +notification을 표시하지 않는다. 이 control store는 query persistence나 offline +data repository가 아니며 Web Push composition과 함께 설치·제거한다. open, +upgrade, transaction-complete, `blocked`/`versionchange`, corruption/eviction과 +exact store purge는 VD-11/VD-15의 IndexedDB lifecycle을 재사용한다. + +`UNASSOCIATED`는 첫 backend register 전에도 durable generation을 만들기 위한 +명시적 상태다. register request는 이 record의 revision과 authority 세 필드를 +capture한다. logout/account switch는 같은 record transaction에서 generation을 +먼저 rotate하므로, 아직 `associationEpoch`을 받지 못한 old register callback도 +CAS에 실패한다. backend가 발급하지 않은 sentinel association epoch을 만들거나 +서로 다른 두 record의 비원자 갱신으로 이 race를 닫지 않는다. + +같은 `associationEpoch`의 `REVOKED`는 terminal tombstone이며 `ACTIVE`로 +overwrite할 수 없다. 새 `ACTIVE`는 backend가 commit 뒤 발급한 distinct epoch이고 +current durable `fenceGeneration`/`sessionBindingEpoch`와 register request가 +captured한 값이 exact match할 때만 허용한다. writer는 한 IndexedDB read-write +transaction 안에서 current state, expected record revision/association epoch, +durable fence generation, server session binding과 release epoch를 CAS 검증한다. +logout이 먼저 fence generation을 rotate했으면 old callback의 distinct +association epoch도 거절한다. `updatedAt`과 client wall clock은 ordering +authority가 아니다. + +### 8.4 Push envelope와 worker 처리 + +Web Push payload는 foreground event envelope 전체를 옮기지 않는다. + +```ts +type WebPushHintV1 = Readonly<{ + protocol: "WEB_PUSH_HINT_V1"; + notificationType: NotificationTypeId; + notificationId: string; + associationEpoch: string; + releaseEpoch: string; + issuedAt: string; + expiresAt: string; + routeIntent: NotificationRouteIntentId; +}>; +``` + +- application-owned hard byte cap을 push service/provider limit보다 작게 둔다. +- 개인 내용, message body, sender name, order amount 같은 민감한 본문을 기본 + payload에 넣지 않는다. +- `notificationId`는 foreground BFF가 현재 authorization으로 내용을 조회할 + opaque reference다. +- 최상위 JSON member name이 중복되면 JSON의 last-wins 해석을 허용하지 않고 + payload를 거절한다. +- `issuedAt`은 client clock보다 최대 5분 앞설 수 있고, + `expiresAt - issuedAt`은 최대 24시간이다. 이 범위를 넘는 issue/expiry, + association과 release mismatch는 drop한다. +- unknown type/route/version과 malformed payload는 generic notification으로 + downgrade하지 않고 fail-closed한다. +- payload가 없는 push를 사용할지 여부도 explicit registry policy다. + +worker handler: + +```text +push event + -> event.waitUntil(bounded handler) + -> byte/schema/association/release/expiry validation + -> duplicate/collapse policy + -> registry-owned localized safe copy + -> registration.showNotification +``` + +Service Worker는 언제든 종료될 수 있다. in-memory cursor, queue, auth session이나 +timer를 authority로 사용하지 않는다. `waitUntil`도 무제한 lifetime을 보장하지 +않으므로 large fetch, retry loop, data migration을 넣지 않는다. + +### 8.5 Notification content와 click + +- title/body/icon/action은 closed notification registry가 만든다. +- backend raw text를 그대로 OS notification에 렌더링하지 않는다. +- 잠금 화면 노출을 고려해 default copy는 민감하지 않아야 한다. +- `tag`/collapse는 notification type과 non-reversible association tag token으로 + bounded하게 만든다. raw account/resource ID나 cache scope fingerprint를 쓰지 + 않는다. +- action 수와 지원은 capability probe에 따라 degrade한다. +- `requireInteraction`, sound/vibration과 높은 urgency를 기본값으로 사용하지 + 않는다. + +`notificationclick`은 arbitrary URL을 열지 않는다. + +```ts +type NotificationClickDataV1 = Readonly<{ + protocol: "NOTIFICATION_CLICK_DATA_V1"; + notificationId: string; + routeIntent: NotificationRouteIntentId; + associationEpoch: string; + releaseEpoch: string; + expiresAt: string; +}>; +``` + +worker가 종료·재시작된 뒤에도 click을 처리할 수 있도록 이 bounded, non-sensitive +envelope만 `NotificationOptions.data`에 넣는다. click handler는 codec/size/expiry, +current local `ACTIVE` fence와 release epoch를 다시 검증한다. unknown/mismatch는 +route를 열지 않고 notification을 닫는다. 이미 표시된 notification은 logout 뒤 +OS notification center에 남을 수 있으므로 copy는 항상 account-neutral하고 +민감하지 않아야 하며, `getNotifications()` cleanup 성공을 privacy 보장으로 +과장하지 않는다. + +```text +NotificationClickDataV1 + -> codec/size/expiry + ACTIVE fence + -> closed route intent + -> route registry lookup + -> same-origin safe path encode + -> existing controlled client focus or openWindow + -> page boot/session recovery + -> authoritative notification/resource fetch +``` + +route intent가 stale/unknown이면 안전한 notification inbox/home으로 이동한다. +notification action 자체에서 destructive 업무 mutation을 실행하려면 별도의 +confirmed UI 또는 idempotent authorized server command 설계가 필요하다. + +### 8.6 Provider와 browser evidence + +- server-side subscription registration/revoke authorization +- VAPID signing key rotation과 private-key custody +- RFC 8291 payload encryption과 provider request conformance +- TTL, urgency, topic/collapse와 retry policy +- provider `404/410/429/5xx` cleanup/retry +- endpoint/key redaction과 data retention/deletion +- target browser 설치/permission/subscription/push/notification/click +- OS/browser notification setting change와 subscription rotation +- Service Worker update/rollback 중 handler compatibility + +synthetic `PushEvent` unit test만으로 실제 provider delivery를 증명하지 않는다. +permission prompt와 OS notification은 자동화 가능 범위와 수동 evidence를 +분리한다. + +## 9. 제한된 Polling + +### 9.1 허용하는 두 형태 + +1. **freshness polling**: visible 화면의 server-state query를 낮은 빈도로 + conditional refetch한다. +2. **convergence polling**: 사용자가 시작한 async job이 terminal state에 + 도달할 때까지만 application orchestrator가 조회한다. + +무한 `setInterval`, hidden tab polling, 여러 component의 중복 loop와 짧은 +주기의 push emulation은 금지한다. + +### 9.2 Poll lease + +모든 polling은 immutable lease를 가진다. + +```ts +type PollLeasePolicy = Readonly<{ + operationId: ApiOperationId; + owner: QueryInvalidationTopic | UseCaseId; + minimumIntervalMs: number; + successIntervalMs: number; + maxIntervalMs: number; + maxAttempts: number; + maxElapsedMs: number; + maxResponseBytes: number; + visibility: "VISIBLE_ONLY"; + fallbackReason: PollFallbackReason; + terminalStates: readonly PollTerminalState[]; +}>; +``` + +implementation absolute ceiling보다 느슨한 값, zero interval, unlimited attempt, +unknown terminal state와 arbitrary operation ID는 startup에서 거절한다. +`operationId`는 existing API registry의 terminal `QUERY`, `REST`, +`SAFE | IDEMPOTENT`, non-`SERVER_STREAM` operation이어야 한다. command/mutation, +GraphQL subscription, Connect/gRPC-Web server stream과 arbitrary status URL은 poll하지 +않는다. Poll-bound operation의 transport retry profile과 TanStack Query retry는 +`NONE`/`false`여야 한다. 구체적으로 VD-23 `LogicalExecutionBudget`은 +`maxAttempts=1`, `authRecoveryCount=0`, `maxCumulativeSleepMs=0`으로 고정해 +executor 내부 status retry나 401 replay가 일어나지 않게 한다. + +한 lease는 다음 조건 중 먼저 발생하는 시점에 종료한다. + +- terminal state +- max attempts +- max elapsed +- request/response budget +- route unmount 또는 consumer 0 +- hidden/pagehide +- offline +- session/account/release generation change +- authorization required/forbidden +- user cancel +- primary stream recovery로 fallback이 더 이상 필요 없음 + +종료 뒤 자동으로 새 lease를 만들지 않는다. visible 복귀, user retry 또는 +coordinator의 명시된 state transition이 새 lease를 발급한다. + +### 9.3 Scheduling + +```text +lease admitted + -> wait jittered cadence + -> verify visible/online/scope/generation + -> start exactly one request with AbortSignal + -> validate bounded response + -> terminal? close lease + -> unchanged? success cadence + -> retryable failure? capped backoff/Retry-After + -> budget remaining? next attempt + -> otherwise stale/degraded +``` + +- `setTimeout` completion chaining으로 single-flight를 보장한다. +- interval tick이 이전 request와 겹치지 않는다. +- success cadence와 failure backoff를 구분한다. +- Poll `maxAttempts`의 한 attempt는 registered REST operation의 한 logical + completion이자 한 physical request다. credential recovery, decode와 mapping + 시간도 lease `maxElapsed`에 포함하며 transport 내부 sleep/replay로 lease + budget을 우회하지 않는다. +- `401`은 해당 attempt를 끝낸다. session owner가 recovery한 뒤 요청을 다시 + 보내면 새 Poll attempt/physical request로 계산한다. +- `Retry-After`는 local backoff보다 빠르게 만들지 않으며 maximum wait/lease + budget을 넘으면 낮춰서 더 일찍 요청하지 않고 stale UI로 종료한다. +- immediate manual refresh가 실행되면 pending poll을 취소하거나 같은 Query + execution을 공유한다. +- SSE/WebSocket reconnect와 fallback polling이 동시에 authoritative refetch를 + 소유하지 않도록 generation 하나만 active owner가 된다. + +### 9.4 HTTP contract + +freshness polling은 가능하면 conditional request를 사용한다. + +- server-issued ETag + `If-None-Match` +- `304`는 representation unchanged이며 response body가 없어야 한다. +- weak/strong validator 의미는 endpoint owner가 정한다. +- `Cache-Control`과 Query stale semantics를 혼동하지 않는다. +- cursor/since endpoint를 사용하면 cursor expiry와 full snapshot reset을 + 명시한다. +- `401/403/404/409/410/429/5xx`를 closed failure로 mapping한다. +- `429/503`의 bounded `Retry-After`를 존중한다. +- response byte와 decode deadline을 기존 HTTP operation registry가 제한한다. + +`304`라도 current account scope와 lease generation이 바뀌었으면 결과를 +적용하지 않는다. + +### 9.5 Polling을 fallback으로 사용할 때 + +SSE/WS 장애에서 Polling으로 전환하려면 다음이 모두 참이어야 한다. + +- 같은 logical resource/freshness 의미를 snapshot endpoint가 제공한다. +- duplex-only interaction은 명시적으로 disabled UI가 된다. +- fallback transition이 새로운 generation을 발급한다. +- common `RecoveryCoordinator`가 아래 handoff state를 단독 소유한다. +- `POLL_ACTIVE`에서는 poll만 projection effect writer이고 live candidate는 + bounded health/checkpoint probe와 post-checkpoint buffer만 수행한다. +- active writer의 sequential effect queue도 in-flight 항목을 포함해 256건/4MiB + 중 먼저 도달하는 고정 상한을 예약한다. 비협조적인 첫 effect가 tail을 막아도 + 초과 요청은 즉시 `QUEUE_OVERFLOW`로 전체 generation을 fence/abort한다. +- candidate의 current authorization/checkpoint를 검증한 뒤 handoff mutex를 잡고, + poll generation을 fence/abort하며 in-flight poll quiescence를 기다린다. +- 그 뒤에만 current-generation snapshot/checkpoint를 projection에 적용하고 + buffered post-checkpoint event를 drain한 뒤 live effect를 활성화한다. +- 어느 단계든 실패하면 candidate를 폐기하고 새 poll generation을 발급하거나 + stale/manual UX로 종료한다. +- UI와 diagnostics가 `LIVE`와 `POLLING/STALE`를 구분한다. + +```text +LIVE_ACTIVE + -> LIVE_DEGRADED + -> POLL_ACTIVE + -> LIVE_PROBING + -> handoff mutex + -> fence/abort poll + await quiescence + -> current-generation snapshot/checkpoint apply + -> buffered post-checkpoint event drain + -> LIVE_ACTIVE +``` + +fallback을 사용해 provider 장애를 production 정상으로 숨기지 않는다. +freshness SLO를 충족하지 못하면 `DEGRADED`다. + +## 10. Query cache와 application effect + +### 10.1 기본 invalidation 흐름 + +```text +validated external event + -> feature event input + -> INVALIDATE(topic) + -> presentation query bridge + -> current scope의 active query invalidate/refetch + -> server authorization + schema + mapper + -> cache update +``` + +event handler가 raw `QueryClient`, query key array나 native transport를 직접 +받지 않는다. query namespace와 key codec은 client-cache registry가 소유한다. + +event burst에서 같은 namespace invalidation은 bounded window 안에서 coalesce할 +수 있다. 이 최적화는 last cursor 적용 순서와 gap detection을 바꾸지 않는다. + +### 10.2 Authoritative delta를 허용하는 조건 + +다음이 모두 있을 때만 event payload로 cache projection을 직접 갱신한다. + +- event type별 exact payload codec +- server commit 뒤 발행 증거 +- entity/collection의 base revision과 resulting revision +- 동일 revision의 idempotent reducer +- gap/out-of-order/epoch reset 처리 +- 현재 query filter/sort/pagination에 미치는 의미 +- account scope/generation fence +- snapshot reconciliation test + +하나라도 없으면 invalidation/refetch를 사용한다. partial patch를 모든 list/detail +cache에 추측해서 적용하지 않는다. + +### 10.3 External event와 business command + +SSE/WebSocket/Push handler는 application input을 호출할 수 있지만, 외부 event를 +사용자 command와 같은 authorization으로 취급하지 않는다. server가 이미 commit한 +사실을 projection에 반영하거나 user-visible notification을 만드는 입력이다. + +WebSocket client command를 선택해도 optimistic state, ack, server commit, +event echo와 HTTP refetch의 순서를 별도 protocol로 정의한다. ack만으로 +authoritative query data를 덮지 않는다. + +## 11. Resource budget와 backpressure + +다음 값은 target reference runtime의 절대 상한이다. 제품 registry는 더 작게 +설정할 수 있지만 높일 수 없다. 실제 구현 branch는 bundle/browser 측정으로 값을 +검토하고 변경 시 ADR amendment를 남긴다. + +| 자원 | target implementation ceiling | +| --- | --- | +| physical foreground connection | runtime당 primary 1, drain handoff 포함 일시적 2 | +| WebSocket logical subscriptions | runtime당 32; SSE baseline은 feed 1 | +| decoded foreground event/frame | 64 KiB | +| SSE incomplete parser buffer | 128 KiB | +| inbound apply queue | 256 event 또는 4 MiB 중 먼저 도달 | +| live↔Poll active effect/probe queue | 각각 256 event 또는 4 MiB 중 먼저 도달 | +| dedupe window | stream당 2,048 ID 또는 10분 중 먼저 만료 | +| reorder buffer | 기본 0; 선택 시 64 event, 2초 | +| WebSocket outbound queued bytes | 256 KiB | +| WebSocket outbound queued messages | 128 | +| reconnect attempts | 연속 10회 또는 5분 | +| reconnect max delay | 60초 | +| aborted reconnect task drain | 2초 고정; 구현 절대 최대 30초 | +| transport recovery readiness/barrier gate | attempt당 30초 | +| live↔Poll handoff quiescence/checkpoint | 단계당 30초 | +| poll minimum interval | 5초 | +| poll lease | 120회 또는 30분 | +| Web Push decoded application hint | 3 KiB | +| Web Push hint future clock skew | 5분 | +| Web Push hint maximum lifetime | 24시간 | +| worker push/click/subscription-change handler application deadline | 10초 | +| window native permission/subscription operation deadline | operation당 30초 | +| backend register/reconcile/revoke operation deadline | operation당 15초 | +| Push association fence transaction | operation당 2초, open/transaction 한 번 | +| notification cleanup scan | association당 64개 또는 2초 | + +이 표의 reference ceiling은 deterministic runtime의 fail-closed 경계로 +구현됐다. 실제 backend/provider/target-browser evidence와 제품별 ceiling 승인은 +아직 pending이며, 그 전에는 `AVAILABLE_NOT_COMPOSED`를 넘지 않는다. + +reconnect drain 상한은 offline/abort로 phase가 이미 중단된 뒤의 +`sleep`/connect attempt/closed-receipt cleanup에만 적용한다. 정상 active +session의 `waitClosed`에는 deadline을 두지 않는다. 2초 안에 정리되지 않은 +task는 run을 fail-closed로 끝내되 underlying task가 settle할 때까지 lifecycle을 +`DRAINING`으로 유지하여 다음 physical connection을 허용하지 않는다. + +미선택 reference source 전체를 tree-shaking 없이 합성하는 optional-recipe +측정의 gzip 상한은 40,000 bytes다. 이 값은 production bundle 허용량이 아니라 +RT-01~RT-04 reference runtime의 회귀 예산이며, production asset에는 계속 +미선택 realtime module이 0이어야 한다. + +ceiling 도달은 다음처럼 fail-closed한다. + +- oversized event/frame/push: protocol rejection, apply 금지 +- inbound queue: freshness `UNKNOWN`, connection close와 snapshot resync +- outbound queue: typed backpressure rejection, durable command enqueue 금지 +- reconnect/poll budget: degraded/stale UI, timer 종료 +- subscription count: 새 lease 거절 +- worker deadline: bounded handler 종료, 무제한 retry 금지 + +event rate가 지속적으로 ceiling 근처라면 limit을 자동으로 높이지 않고 snapshot, +aggregation, server coalescing 또는 다른 protocol을 재설계한다. + +## 12. 인증, 보안과 privacy + +### 12.1 인증 + +- HTTPS/WSS만 허용한다. +- endpoint는 composition registry의 fixed URL이다. +- credential은 external session owner와 transport adapter 안에 남는다. +- URL/query, WebSocket subprotocol, event/push payload, cursor, storage와 + telemetry에 credential을 넣지 않는다. +- connect 성공 뒤에도 logical subscribe/command와 snapshot을 server가 각각 + authorization한다. +- logout/account switch는 local generation fence와 server association/session + revoke를 모두 수행한다. +- route guard, event scope binding과 push permission은 authorization proof가 + 아니다. + +native EventSource/WebSocket의 custom-header 제약 때문에 bearer token을 query로 +옮기지 않는다. same-origin BFF/cookie 또는 보안 검토를 통과한 별도 handshake +mechanism을 선택한다. + +### 12.2 Input validation과 abuse control + +- raw bytes → strict schema → mapper 순서를 지킨다. +- unknown field/version/event/message/close category는 fail-closed한다. +- object depth, array/property/string count, decoded bytes와 event rate를 제한한다. +- WebSocket decompressed size와 outbound `bufferedAmount`를 제한한다. +- SSE/WS endpoint는 origin, connection count, subscription count와 per-subject + rate limit을 server에서 강제한다. +- push provider는 VAPID, encrypted payload, TTL과 send-time authorization을 + 강제한다. +- notification click route는 same-origin closed registry만 허용한다. +- CSP `connect-src`, worker/source 정책과 hosting security header를 실제 + deployment에서 검증한다. + +### 12.3 금지하는 관측·저장 값 + +- raw endpoint URL/query +- credential/cookie/token +- account/tenant/user ID와 email +- event ID, resume cursor, stream epoch와 scope binding 원문 +- raw event/frame/push payload +- WebSocket raw close reason +- PushSubscription endpoint, `p256dh`, `auth` +- notification private content +- server/backend exception text + +허용 속성은 registry ID, transport kind, closed failure/category, attempt/count, +queue/lag/duration bucket과 low-cardinality environment ID다. + +`NotificationOptions.data`에는 §8.5의 bounded +`NotificationClickDataV1`만 예외적으로 저장할 수 있다. 이것은 opaque, +non-sensitive click envelope이며 credential, account/resource ID와 자유 문구를 +추가하는 예외가 아니다. + +`fenceGeneration`과 `sessionBindingEpoch`은 §8.3의 adapter-owned IndexedDB +control record 안에서만 저장할 수 있다. generic storage, URL, +BroadcastChannel/telemetry로 복사하지 않는다. foreground cursor/scope binding은 +bounded adapter memory에만 존재하고 document restore에서는 authoritative +checkpoint를 다시 얻는다. + +client-side encryption만으로 같은 runtime의 XSS에서 push endpoint, cached payload나 +credential을 보호할 수 있다고 주장하지 않는다. + +## 13. Closed failure와 recovery + +### 13.1 Failure vocabulary + +| failure | 의미 | 기본 recovery | +| --- | --- | --- | +| `ABORTED` | lifecycle/user cancellation | retry 없음 | +| `UNSUPPORTED` | browser capability 없음 | declared fallback | +| `OFFLINE` | network hint/attempt failure | online hint 뒤 bounded check | +| `CONNECT_TIMEOUT` | open deadline 초과 | bounded reconnect | +| `IDLE_TIMEOUT` | heartbeat/byte deadline 초과 | close + bounded reconnect | +| `AUTH_REQUIRED` | session expired/recovery 실패 | sign-in UX | +| `FORBIDDEN` | stream/resource 거절 | retry 없음 | +| `RATE_LIMITED` | connection/request/provider limit | bounded server hint | +| `PROVIDER_UNAVAILABLE` | backend/push/hosting unavailable | bounded retry 또는 degraded | +| `PROTOCOL_MISMATCH` | version/subprotocol/content type | terminal/rollout rollback | +| `MALFORMED_EVENT` | state-bearing syntax/schema/exact-key 실패 | cursor 미진행, freshness `UNKNOWN`, close + registered recovery | +| `MAPPING_CONTRACT_VIOLATION` | validated DTO의 registered mapper 실패 | effect/cache/cursor write 0, close + registered recovery | +| `EVENT_CONFLICT` | 같은 ID/sequence가 다른 event를 가리킴 | terminal protocol conflict + resync/rollback | +| `EVENT_TOO_LARGE` | byte ceiling 초과 | close/resync, retry storm 금지 | +| `DUPLICATE_EVENT` | 이미 처리 | safe ignore | +| `STALE_EVENT` | old/out-of-order | safe ignore + 관측 | +| `SEQUENCE_GAP` | missing event | snapshot resync | +| `CURSOR_EXPIRED` | replay retention 밖 | snapshot reset | +| `QUEUE_OVERFLOW` | backpressure ceiling | close + registered snapshot/session rebuild | +| `APPLY_FAILED` | feature effect 실패 | cursor 미commit, bounded registered recovery | +| `POLL_BUDGET_EXHAUSTED` | lease 한도 도달 | stale/manual retry | +| `PUSH_PERMISSION_DENIED` | 사용자 거절 | foreground fallback | +| `PUSH_SUBSCRIPTION_STALE` | rotation/expiry/provider gone | bounded re-registration | +| `NOTIFICATION_REJECTED` | invalid/expired/scope mismatch | safe drop | +| `SCOPE_FENCED` | old account/release generation | safe drop | +| `SCOPE_PROTOCOL_VIOLATION` | current connection event의 scope binding mismatch | effect/cursor 0, close + session revalidation/snapshot + security diagnostic | +| `CLOSED` | terminal runtime | 새 generation만 생성 가능 | + +raw native error, close reason이나 backend message를 이 union에 추가하지 않는다. +safe user copy는 i18n message catalog가 failure kind를 mapping한다. +drop-only malformed 처리는 명시적으로 non-state-bearing heartbeat/control frame에만 +허용하며, threshold를 넘으면 protocol close한다. state-bearing event나 unknown +event type을 버리고 freshness를 `CURRENT`로 유지하지 않는다. + +### 13.2 Effect certainty + +| effect | certainty | +| --- | --- | +| socket `send()` returned | local API accepted bytes; server receipt 미확인 | +| WebSocket command ack | protocol-defined acceptance; business commit은 별도 | +| SSE/WS event decoded | wire valid; application effect 미commit | +| CURSOR advanced | local effect 이후 resume position commit | +| query invalidated | local cache action accepted; refetch/server commit 미확인 | +| Poll `304` | selected representation unchanged | +| push provider accepted | provider queue accepted; browser/user delivery 미확인 | +| Service Worker push fired | browser handler invoked; notification shown/clicked 미확인 | +| `showNotification()` resolved | platform show step accepted; user 확인 미확인 | +| backend subscription revoke success | 해당 association의 server revoke commit | + +diagnostics와 UI는 이 의미보다 강한 “전달됨”, “읽음”, “동기화 완료”를 표시하지 +않는다. + +## 14. Observability + +### 14.1 Closed diagnostic events + +foreground: + +- `realtime_runtime_state_changed` +- `realtime_connect_attempted` +- `realtime_connect_finished` +- `realtime_connection_closed` +- `realtime_heartbeat_observed` +- `realtime_event_received` +- `realtime_event_rejected` +- `realtime_event_applied` +- `realtime_gap_detected` +- `realtime_resync_finished` +- `realtime_queue_pressure` +- `realtime_fallback_changed` + +polling: + +- `bounded_poll_lease_started` +- `bounded_poll_attempt_finished` +- `bounded_poll_unchanged` +- `bounded_poll_rate_limited` +- `bounded_poll_lease_stopped` + +push: + +- `web_push_permission_finished` +- `web_push_registration_finished` +- `web_push_subscription_rotated` +- `web_push_hint_processed` +- `web_push_notification_finished` +- `web_push_click_dispatched` +- `web_push_association_revoked` + +모든 event는 semantic registry에 등록하고 arbitrary payload를 받지 않는다. +diagnostics sink failure는 connection, worker handler, poll lease와 cleanup을 +실패시키지 않으며 재귀 telemetry를 만들지 않는다. + +### 14.2 Metrics와 SLO + +- connect success/latency와 visible open uptime +- reconnect attempts, delay와 exhausted ratio +- heartbeat age/timeout +- accepted/rejected/duplicate/stale/gap event count +- event receive-to-apply lag bucket +- resync count, latency와 failure +- active physical connection/logical subscription +- queue high-water count/bytes와 overflow +- poll request, `304`, error, stop reason과 requests per lease +- push permission outcome +- active/revoked/stale subscription aggregate +- provider accepted/gone/rate-limited aggregate +- worker handler/notification/click aggregate +- fallback/degraded/stale duration + +event timestamp와 client clock 차이를 정확한 network latency로 해석하지 않는다. +server/client clock skew가 있으므로 protocol ingest/apply monotonic duration과 +server-side metric을 함께 사용한다. + +## 15. Test와 promotion evidence + +### 15.1 Pure unit/property + +- transport selection matrix와 forbidden combination +- exact registry/config freeze와 ceiling +- decimal sequence boundary와 unsafe input +- duplicate/stale/gap/epoch reset +- CURSOR/non-CURSOR discriminated codec와 conditional cursor commit +- effect failure 뒤 cursor 미commit +- deterministic fake clock/random의 full-jitter backoff +- stable-open 이후에만 retry attempt reset +- queue/dedupe/reorder ceiling +- old-generation late callback drop와 current scope-binding protocol violation 분리 +- effect/recovery callback lease의 `isCurrent()` commit 직전 확인과 callback + 종료 뒤 영구 만료 +- 외부 result/recovery commit의 own data descriptor 단일 snapshot, accessor, + Proxy 재조회, extra/inherited/symbol field 거절 +- state-axis와 UI projection +- Poll lease terminal/budget/visibility +- notification route/copy registry + +### 15.2 Deterministic protocol/fault contract + +SSE: + +- BOM, CR/LF/CRLF, comment, multi-line data와 incomplete EOF +- wrong status/content type, redirect, oversized line/event +- CURSOR direct `id`/Last-Event-ID equality와 non-CURSOR id 부재/null cursor +- disconnect before/after effect commit +- `204`, auth, rate limit, cursor reset +- native EventSource snapshot gate 중 pending invalidation drain +- exact recovery proof, readiness confirmation과 clone/missing/deadline failure + +WebSocket: + +- wrong/missing subprotocol +- text/binary/unknown frame +- welcome/subscribe/event/reset/heartbeat/close order +- frame/decompressed/queue/outbound buffer ceiling +- duplicate/out-of-order/gap and reconnect resume +- accepted cursor/next sequence silent advance와 initial snapshot barrier rejection +- unsubscribe tombstone, matching `UNSUBSCRIBED`, late frame drop, ACK deadline과 + 32회 slot 회수 +- negotiated outbound count/bytes/`bufferedAmount` FIFO overflow의 terminal close +- close category와 auth recovery + +Polling: + +- `200/304/401/403/409/410/429/503` +- ETag/cursor, `Retry-After`, single-flight와 no-overlap +- transport max attempt 1/auth recovery 0과 401 새 Poll attempt +- max attempts/elapsed/response bytes +- hidden/offline/unmount/cancel/terminal stop +- handoff mutex, poll quiescence, snapshot/buffer ordering과 failure rollback +- non-cooperative active writer와 256건/4MiB effect queue overflow fail-close + +Web Push: + +- permission states +- subscription register/revoke/rotation +- endpoint/key redaction +- association fence IDB open/transaction/blocked/versionchange/missing/corrupt/eviction +- old fence generation의 distinct-epoch stale ACTIVE와 same-epoch `REVOKED` + tombstone resurrection 거절 +- exact revoked association purge와 concurrent newer-owner revision CAS +- malformed/oversized/expired/wrong-scope hint +- worker `waitUntil`, handler deadline, `pushsubscriptionchange` lifecycle abort와 + non-cooperative client enumeration +- safe localized notification, persisted typed click route와 old-tag cleanup +- declarative `web_push: 8030` payload rejection +- provider `404/410/429/5xx` mapping + +### 15.3 Local integration server + +MSW/jsdom만으로 streaming handshake, proxy flush, socket close와 Service Worker +lifecycle를 증명하지 않는다. test 전용 실제 local server를 사용해 다음을 +검증한다. + +- chunked SSE flush, heartbeat, abort, reconnect와 cursor replay +- WebSocket upgrade/subprotocol, server restart, heartbeat, burst와 close +- auth expiry/recovery와 scope switch +- replay retention/gap/snapshot reset +- conditional Polling과 rate limit +- provider adapter는 별도 conformance harness로 동일 scenario를 실행 + +### 15.4 실제 browser + +승인된 Chromium, Firefox, WebKit matrix에서 built production asset으로 확인한다. + +- route mount/unmount와 React StrictMode 후 connection/listener/timer leak 0 +- offline/online, hidden/visible, pagehide/pageshow와 bfcache +- logout/account switch/release transition의 late event fence +- SSE stream parsing/abort와 hosting-equivalent proxy +- WebSocket buffered queue/close/reconnect +- Poll single-flight와 hidden stop +- Service Worker registration/update, push event, notification/click +- permission denied/default/unsupported UX +- CSP와 cross-origin rejection + +Web Push actual delivery는 browser automation, OS/manual evidence와 provider +receipt를 분리한다. 한 engine의 synthetic event를 세 browser/provider 증거로 +재사용하지 않는다. + +### 15.5 Load, chaos와 security negative gate + +- event burst와 sustained rate에서 memory/queue ceiling +- disconnect/restart/redeploy/replay-store unavailable +- effect commit 직전/직후 crash +- reconnect herd와 server hint +- multi-tab connection budget +- provider push burst/expired endpoint +- polling rate-limit과 backend slowdown + +static/security gate는 다음을 거절해야 한다. + +- adapter/composition 밖의 `new EventSource`, `new WebSocket`, + `pushManager.subscribe` +- application/domain의 native/vendor type import +- arbitrary endpoint/channel/topic/send payload +- token/cursor/PushSubscription material의 URL/storage/telemetry/BroadcastChannel +- raw event/push/close reason logging +- declarative push payload/handler bypass before a separate selection ADR +- page component `setInterval` polling +- 선택 전 production bootstrap/Service Worker composition + +### 15.6 Promotion evidence + +`COMPOSED`와 production traffic approval은 별도다. 최소 component evidence: + +| component | 필요 증거 | +| --- | --- | +| contract | frontend/backend exact wire, cursor/reset/auth/failure agreement | +| provider | SSE hosting 또는 WS gateway 또는 Push provider conformance | +| browser | target matrix의 lifecycle/permission/stream evidence | +| operations | dashboards, alert, kill switch, drain/recovery/rollback drill | +| security/privacy | threat model, endpoint/credential/payload redaction, retention approval | +| performance | connection/request/event/push rate와 memory/battery budget | + +필수 외부 증거가 없으면 `PromotionEvidence`는 `MISSING | PARTIAL`에 머물고 +promotion gate result는 `FAIL_UNVERIFIED`다. fake 또는 local server로 provider +성공을 대신하지 않는다. + +## 16. Optional composition, rollout과 rollback + +### 16.1 Runtime composition result + +선택된 runtime factory는 partial object를 외부에 반환하지 않는다. + +```text +READY { + generation, + selected transport, + logical subscription facade, + freshness snapshot, + close() +} + +UNAVAILABLE { + safe reason, + supported fallback, + cleanup outcome +} +``` + +생성 중 실패하면 factory가 `finally`에서 열린 stream/socket, readers, timers, +lifecycle listeners와 subscription draft를 역순으로 정리한 뒤 결과를 반환한다. +caller에게 partial/native handle이나 cleanup callback을 넘기지 않는다. + +Web Push는 별도 result다. + +```text +PUSH_READY | PUSH_PERMISSION_REQUIRED | PUSH_DENIED | +PUSH_UNSUPPORTED | PUSH_UNAVAILABLE +``` + +foreground realtime이 준비됐다는 이유로 push도 준비됐다고 표시하지 않는다. + +### 16.2 Traffic admission + +```text +DISABLED + -> SHADOW + -> CANARY + -> ENABLED + +SHADOW | CANARY | ENABLED + -> DISABLED +``` + +- `SHADOW`는 production event가 아닌 승인된 probe/synthetic stream만 사용한다. +- `CANARY`는 subject/session hash가 아닌 server-side allowlist로 제한한다. +- transport, event stream, Poll fallback과 Web Push category는 독립 kill switch를 + 가진다. +- kill switch는 config fetch 실패 시 safe default `DISABLED`다. +- admission이 `DISABLED`가 되면 connection lifecycle을 `DRAINING`으로 옮겨 새 + lease/send/subscription을 막고 active operation을 bounded close한 뒤 + `CLOSED`로 전환한다. + +rollback: + +1. 새 traffic admission을 닫는다. +2. active connection/poll/push registration 작업을 drain한다. +3. stale/focus/manual refresh fallback을 노출한다. +4. backend publisher/replay/subscription compatibility window를 유지한다. +5. frontend composition과 dependency를 제거한다. +6. production bundle/worker에서 source 부재를 검증한다. + +SSE에서 WebSocket으로, 또는 그 반대로 즉석 rollback하지 않는다. protocol +semantics가 다른 경우 기존 HTTP/stale fallback으로 먼저 안전하게 내린다. + +## 17. 제거 가능성 + +### 17.1 제거 순서 + +foreground: + +1. traffic admission `DISABLED`, connection lifecycle `DRAINING` +2. logical subscription revoke +3. Poll lease와 reconnect/backoff timer abort +4. SSE reader/EventSource 또는 WebSocket close +5. lifecycle/online listener 제거 +6. cursor/dedupe/queue memory 폐기 +7. composition/registry 제거 +8. adapter, port, test와 dependency 제거 + +Web Push: + +1. 새 opt-in과 server send admission 중지 +2. durable fence generation rotate + local `REVOKED`를 같은 transaction에 commit +3. backend account association revoke +4. owned notification bounded close와 native unsubscribe policy 실행 +5. association fence store exact purge +6. push/notification handler를 worker에서 제거 +7. worker update/drain과 old client 호환 확인 +8. notification registry, provider adapter와 key config 제거 +9. retained subscription data를 server retention 정책대로 삭제 + +### 17.2 Removal gate + +미선택/제거 상태에서 다음이 0이어야 한다. + +- production bundle의 optional realtime/vendor sentinel +- runtime connect/subscribe/poll timer side effect +- production Service Worker push listener와 subscription request +- runtime config의 orphan endpoint/topic/key +- CSP의 불필요한 realtime/provider origin +- dependency/SBOM의 제거 대상 package +- docs/catalog의 `INSTALLED` 주장 + +base typecheck, architecture, unit/integration, production build, optional recipe +gate와 module inventory를 다시 실행한다. stale server subscription/replay topic과 +provider key는 frontend source 삭제만으로 제거됐다고 주장하지 않는다. + +## 18. Target source 배치 + +실제 선택 branch의 예시다. 제품 protocol에 따라 더 좁혀야 하며 지금 이 source가 +있다는 뜻은 아니다. + +```text +src/ + application/ + ports/ + in/ + -external-event-input.ts + out/ + live-subscription-control.ts + -presence-gateway.ts + policies/ + external-event-effect.ts + bounded-polling.ts + contracts/ + realtime-streams.ts + realtime-events.ts + notification-types.ts + adapters/ + realtime/ + event-codec.ts + stream-coordinator.ts + sse/ + fetch-sse-connection.ts + sse-parser.ts + websocket/ + websocket-connection.ts + websocket-protocol.ts + polling/ + bounded-poll-coordinator.ts + web-push/ + push-subscription-adapter.ts + push-registration-gateway.ts + push-association-fence-store.ts + inbound/ + push-event-adapter.ts + notification-click-adapter.ts + bootstrap/ + realtime-runtime-composition.ts + service-worker/ + service-worker-composition.ts + presentation/ + adapters/ + query/ + external-event-query-bridge.ts +tests/ + unit/realtime/ + integration/realtime/ + browser-capabilities/realtime/ +``` + +generic protocol mechanism은 `src/adapters/realtime`에 둘 수 있지만 feature event +codec/input과 query namespace는 feature owner가 소유한다. Service Worker entry는 +window bootstrap을 import하지 않으며 둘이 공유하는 contract는 browser-neutral +module이어야 한다. `bootstrap/service-worker`는 factory/handler registration만 +조립하고 push/click decode와 fence storage 구현은 inbound/infrastructure +adapter에 둔다. + +## 19. 구현 work package + +### RT-00 — 계약과 상태 + +- VD-28과 상세 설계 승인 +- current status와 readiness 축 고정 +- 기존 recipe의 한계 기록 +- backend/hosting/provider owner와 protocol draft 지정 + +initial exit: 구현 없음, 모든 runtime은 `DESIGNED_NOT_IMPLEMENTED`. 현재는 +RT-01~RT-04가 `AVAILABLE_NOT_COMPOSED`이고 제품 protocol/owner 선택은 RT-05에 +남아 있다. + +### RT-01 — 공통 event authority + +- closed stream/event registry +- target envelope codec +- scope/generation fence +- sequential queue, dedupe/order/gap/cursor commit +- authoritative snapshot reset facade +- bounded reconnect owner와 authoritative close classification +- deterministic failure/observation contract + +exit: transport-independent contract/fault suite 통과. + +### RT-02 — SSE와 bounded polling + +- bounded fetch-stream SSE parser/adapter +- same-origin auth와 fixed endpoint +- reconnect/heartbeat/status/resume +- single-flight visible-only Poll lease +- single-writer live↔Poll handoff와 bounded checkpoint/quiescence +- local streaming server와 target browser evidence +- removal/bundle gate + +exit: reference runtime `AVAILABLE_NOT_COMPOSED`. 제품 선택은 계속 +`NOT_SELECTED`. + +### RT-03 — WebSocket + +- fixed handshake/subprotocol +- closed control frames +- app heartbeat, close mapping과 resume +- bounded inbound/outbound queue +- local WS server/load/browser evidence +- removal/bundle gate + +exit: reference runtime `AVAILABLE_NOT_COMPOSED`. duplex product requirement가 +없으면 composition하지 않는다. + +### RT-04 — Web Push + +- permission/subscription facade +- fixed backend registration/revoke adapter +- Service Worker composition과 strict hint codec +- safe notification/click registry +- provider/browser/manual evidence +- subscription rotation/logout/removal + +exit: reference runtime과 worker가 `AVAILABLE_NOT_COMPOSED`; actual provider +evidence 없이 product promotion 금지. + +### RT-05 — 제품 composition과 운영 + +- 제품 event/query/notification registry +- backend replay/outbox/snapshot/provider conformance +- immutable runtime config와 kill switch +- canary SLO/load/security/privacy review +- operations recovery/rollback drill + +exit: 선택 capability만 `COMPOSED`, 별도 promotion evidence가 `COMPLETE`일 때만 +production traffic 승인. + +## 20. 완료 기준 + +### 설계 완료 + +- [x] SSE, WebSocket, Web Push와 Polling의 전달 의미를 분리했다. +- [x] 현재 recipe와 concrete runtime 상태를 구분했다. +- [x] outbound connection과 inbound event adapter 경계를 정했다. +- [x] source of truth, duplicate-tolerant CURSOR 처리, best-effort profile과 + snapshot resync를 정했다. +- [x] target envelope, scope/generation/cursor 의미를 정했다. +- [x] lifecycle, retry owner, resource ceiling과 closed failure를 정했다. +- [x] permission/subscription/worker/notification 경계를 정했다. +- [x] test, promotion, rollout, rollback과 제거 조건을 정했다. + +### Reference runtime 완료 + +- [x] RT-01~RT-04 source와 deterministic test가 있다. +- [ ] actual SSE/WS local server와 target browser evidence가 있다. +- [ ] Service Worker/Push provider evidence가 자동/수동 범위별로 있다. +- [x] static boundary/security fixture, synthetic bundle budget와 + runtime-removal 검증이 blocking gate다. +- [ ] provider/browser promotion evidence와 operations drill이 release + blocking으로 등록됐다. +- [x] static source gate 기준 production bootstrap과 worker에서 미선택 + capability side effect가 0이다. + +### 제품 composition 완료 + +- [ ] 제품 freshness/interaction/notification SLO와 owner가 승인됐다. +- [ ] event/topic/query/notification registry가 닫혀 있다. +- [ ] backend commit/replay/snapshot/auth/provider 계약이 conformant다. +- [ ] session/account/release transition과 late callback fence가 browser에서 검증됐다. +- [ ] dashboards, alert, kill switch와 recovery/rollback drill이 있다. +- [ ] privacy/retention, permission UX와 security review가 승인됐다. +- [ ] `Selection=SELECTED`, 선택 transport의 runtime만 `COMPOSED`다. +- [ ] `PromotionEvidence=COMPLETE` 전에는 production-ready를 주장하지 않는다. + +## 21. 관련 자료 + +저장소 내부: + +- [VD-28](./decisions/VD-28-realtime-events-web-push-and-bounded-polling.md) +- [VD-10 optional capability recipes](./decisions/VD-10-optional-capability-recipes.md) +- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md) +- [Optional frontend adapter recipes](./optional-adapter-recipes.md) +- [Client cache and browser storage](./client-cache-and-storage.md) +- [API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md) +- [Browser file and origin-storage platform](./browser-file-and-origin-storage.md) +- [TypeScript, state, and data flow](./typescript-state-and-data-flow.md) +- [`RealtimePort` copyable recipe](../../recipes/frontend-capabilities/contracts.ts) +- [Deterministic realtime fake](../../recipes/frontend-capabilities/fake-adapters.ts) +- [Optional capability contract test](../../tests/recipes/optional-capability-contracts.test.ts) +- [Machine-readable optional recipe catalog](../../config/recipes/frontend-capability-recipes.json) + +공식 기준: + +- [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html) +- [WHATWG WebSockets](https://websockets.spec.whatwg.org/) +- [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455) +- [W3C Push API](https://www.w3.org/TR/push-api/) +- [RFC 8030 — Generic Event Delivery Using HTTP Push](https://www.rfc-editor.org/rfc/rfc8030) +- [RFC 8291 — Message Encryption for Web Push](https://www.rfc-editor.org/rfc/rfc8291) +- [RFC 8292 — VAPID for Web Push](https://www.rfc-editor.org/rfc/rfc8292) +- [WHATWG Notifications API](https://notifications.spec.whatwg.org/) +- [W3C Service Workers](https://www.w3.org/TR/service-workers/) +- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110) +- [RFC 6585 — Additional HTTP Status Codes](https://www.rfc-editor.org/rfc/rfc6585) diff --git a/docs/architecture/review-evidence.md b/docs/architecture/review-evidence.md new file mode 100644 index 0000000..b1ec618 --- /dev/null +++ b/docs/architecture/review-evidence.md @@ -0,0 +1,22 @@ +# Imported scoped diagram review evidence + +This ledger entry consumes the canonical evidence already recorded by the +`ca-skeleton-frontend-operational-contract` project note. It does not claim +review of the repository-local Mermaid projections or of the complete +production deployment topology. + +- Reviewer: `wiki-diagram-reviewer` +- Standard: `rules/diagram-standards.md` v2 +- Canonical report: + `docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md` +- Canonical report SHA-256: + `b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29` + +| Canonical diagram | SHA-256 | Score | Verdict | Reviewed scope | +| --- | --- | ---: | --- | --- | +| `raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio` | `c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0` | 100 | PASS | Clean Architecture compile-time dependency ownership | +| `raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio` | `9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b` | 100 | PASS | immutable static assets and mutable `/config.json` delivery | + +The canonical report explicitly limits this `PASS_SCOPED`: it does not verify +the complete release/rollback topology, the implementation topology, or live +hosting state. diff --git a/docs/architecture/review-ledger.json b/docs/architecture/review-ledger.json new file mode 100644 index 0000000..d026be8 --- /dev/null +++ b/docs/architecture/review-ledger.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "status": "PASS_SCOPED", + "reviewer": "wiki-diagram-reviewer", + "standard": "rules/diagram-standards.md v2", + "evidenceReport": { + "repoPath": "docs/architecture/review-evidence.md", + "canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md", + "canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29" + }, + "reviews": { + "overview": { + "sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-overview-2026-07-18.drawio", + "sha256": "c0ae56c9c964c5c6e698ab7dcc91736b9b811b2b834381817905db81c4230ba0", + "score": 100, + "verdict": "PASS", + "thresholdSatisfied": true, + "scope": "Clean Architecture compile-time dependency ownership" + }, + "staticDelivery": { + "sourcePath": "raw/diagrams/ca-skeleton-frontend/architecture-deployment-2026-07-18.drawio", + "sha256": "9a654326fb840ddf24b832221ff7eec4b8fadd9f87ad84174fccfa3bfcd1a25b", + "score": 100, + "verdict": "PASS", + "thresholdSatisfied": true, + "scope": "immutable static assets and mutable /config.json delivery" + } + } +} diff --git a/docs/architecture/routing-pages-and-patterns.md b/docs/architecture/routing-pages-and-patterns.md new file mode 100644 index 0000000..98d214c --- /dev/null +++ b/docs/architecture/routing-pages-and-patterns.md @@ -0,0 +1,519 @@ +# 라우팅, 페이지 템플릿, 재사용 패턴 + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +## 1. 목적 + +이 문서는 도메인과 무관하게 다음을 바로 구현할 수 있는 기준을 제공한다. + +- typed route와 안전한 URL +- session, permission, feature flag guard +- lazy chunk의 loading/error/recovery +- route 이동 시 focus, scroll, 취소, dirty form 처리 +- list, detail, form, status 등 공통 page template +- page controller와 application input use case의 연결 +- 프론트엔드에서 반복 사용하는 설계 패턴 + +## 2. 현재 상태와 구현 기준 + +RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다. + +- route registry가 path와 access policy를 소유한다. +- contract에서 Data Router route object와 navigation을 생성한다. +- runtime map이 route component를 lazy import하고 codec을 연결한다. +- 앱 셸과 보호 route, not-found surface가 있다. +- route heading focus와 비동기/render error boundary가 있다. +- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다. +- `src/presentation/templates`가 standard/collection/detail/form/status slot, + landmark와 responsive layout을 제공한다. +- `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping, + duplicate submit과 dirty route blocker를 소유한다. +- reference feature의 list/detail/create/status route가 네 template variation을 + production composition에서 실행한다. + +platform route 계약, `src/features/installed-feature-contracts.ts`, +`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry +negative fixture가 함께 검사한다. params/search codec, loading/error surface, +access, title, navigation, chunk ID는 +`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite +manifest의 dynamic entry는 release manifest route chunk map과 검증되며, +`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다. + +route ID와 parsed input type은 `route-contract.ts`, parse/canonical URL 생성은 +`route-codecs.ts`, React context/provider/hook은 `route-input.tsx`가 소유한다. +router는 codec 결과를 provider에 넣고 lazy feature page는 좁은 +`useRouteInput()`만 소비한다. 이 분리로 feature runtime이 자신을 load하는 +`app-router.tsx`를 역참조하지 않으며, 같은 유형의 TypeScript 순환 의존은 +architecture graph fixture가 거절한다. + +template는 데이터를 가져오지 않는다. reference page controller가 route input과 +application input을 query/form facade에 연결하고, template에는 render할 slot과 +안전한 callback만 전달한다. 이 분리는 +`page-templates-own-layout-only` dependency rule과 forbidden import fixture가 +검증한다. + +## 3. React Router mode 결정 + +[React Router 공식 mode 설명](https://reactrouter.com/start/modes)과 +[Data Mode custom setup](https://reactrouter.com/start/data/custom)은 +Declarative, Data, Framework Mode를 구분한다. + +저장소에는 현재 React Router `7.18.1`이 고정돼 있다. 구현 브랜치는 공식 문서의 +동일 버전 API를 기준으로 하고 router version upgrade를 Data Mode 구조 변경과 +같은 브랜치에 섞지 않는다. 위 공식 링크의 기본 표시 버전이 바뀌면 version +selector를 `7.18.1`로 맞춰 확인한다. + +| mode | 선택 조건 | 이 저장소에서의 판단 | +| --- | --- | --- | +| Declarative | React composition과 외부 data layer가 route data를 소유 | RP-04 이전 기준선 | +| Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | VD-03으로 채택하고 RP-04에서 구현 | +| Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 | + +채택한 결정: + +- client-only SPA와 TanStack Query/application use case를 유지한다. +- `createBrowserRouter`와 `RouterProvider` 기반 Data Mode를 사용한다. +- Data Mode를 선택하는 이유는 route object, navigation blocker, scroll + restoration, route error 경계를 일관되게 소유하기 위해서다. loader/action으로 + 서버 상태를 다시 소유하기 위해서가 아니다. +- loader/action을 추가할 때는 TanStack Query/application input을 prefetch하거나 + 호출하는 한 가지 소유 경로만 사용한다. +- 같은 데이터를 route loader와 TanStack Query가 각각 가져오지 않는다. +- SSR/static generation을 선택하기 전에는 Framework Mode를 기본값으로 만들지 + 않는다. + +결정 근거와 rollback 경계는 +`docs/architecture/decisions/VD-03-react-router-data-mode.md`에 고정한다. +Data Mode를 사용할 수 없는 프로젝트만 별도 ADR과 +`NavigationLifecycleAdapter`를 구현한다. + +## 4. route 계약과 runtime map + +### 4.1 두 종류의 레지스트리 + +직렬화 가능한 contract와 React implementation을 분리한다. + +```ts +export const routeContracts = { + home: { + id: "home", + path: "/", + access: "public", + navigation: "primary", + titleKey: "route.home.title", + loadingSurface: "page", + errorSurface: "page", + chunkId: "home", + }, + resourceDetail: { + id: "resourceDetail", + path: "/examples/resources/:resourceId", + access: "authenticated", + navigation: "hidden", + titleKey: "route.resourceDetail.title", + loadingSurface: "detail", + errorSurface: "detail", + chunkId: "reference-resource-detail", + }, +} as const satisfies RouteContractRegistry; +``` + +```tsx +export const routeRuntime = { + home: { + Component: lazy(() => import("../pages/home-page")), + paramsCodec: emptyParamsCodec, + searchCodec: emptySearchCodec, + }, + resourceDetail: { + Component: lazy(() => import("../features/resources/resource-detail-page")), + paramsCodec: resourceDetailParamsCodec, + searchCodec: resourceDetailSearchCodec, + }, +} satisfies Record; +``` + +요구 사항: + +- contract key와 `id`가 다르면 typecheck 실패 +- contract에는 함수, component, schema instance처럼 직렬화 불가능한 값을 넣지 않음 +- runtime map에는 실제 lazy component와 codec/guard만 둠 +- contract의 모든 route가 runtime에 있고 runtime의 모든 key가 contract에 있음 +- navigation은 contract에서 파생 +- build chunk manifest와 `chunkId` 대응을 검증 +- public runtime config나 server가 route component 이름을 임의 지정할 수 없음 + +### 4.2 params와 search codec + +URL은 외부 입력이다. page에서 `useParams()` 결과를 cast하지 않는다. + +```ts +const resourceDetailParamsSchema = z.object({ + resourceId: z.string().trim().min(1).max(100), +}); + +const resourceListSearchSchema = z.object({ + q: z.string().trim().max(100).catch(""), + page: z.coerce.number().int().min(1).catch(1), + sort: z.enum(["updated-desc", "name-asc"]).catch("updated-desc"), +}); +``` + +path params와 search params는 입력 형태와 serialization 규칙이 다르므로 같은 +interface로 뭉치지 않는다. 각각 parse와 serialize를 제공한다. + +```ts +interface PathParamsCodec { + parse( + input: Readonly>, + ): Result; + serialize(value: T): Readonly>; +} + +interface SearchParamsCodec { + parse(input: URLSearchParams): Result; + serialize(value: T): URLSearchParams; +} +``` + +규칙: + +- route input parse 실패와 backend 404를 구분한다. +- 알 수 없는 search key를 보존할지 제거할지 route별로 선언한다. +- default value를 URL에 항상 쓸지 생략할지 codec이 결정한다. +- array/date/boolean encoding을 feature마다 다르게 만들지 않는다. +- navigation link도 codec 기반 builder를 사용한다. +- query key에는 parsed value만 사용한다. +- 검색어·식별자를 telemetry에 기록하기 전에 sensitivity policy를 적용한다. + +### 4.3 route object 생성 + +하나의 factory가 다음을 조합한다. + +```text +route contract + + runtime component/codecs + + access guard + + feature flag guard + + suspense surface + + render/chunk error surface + + title/focus/scroll behavior + -> executable route object/tree +``` + +JSX에서 route별 ``를 다시 나열하지 않는다. nested layout이 필요한 경우 +contract에 parent ID를 두고 cycle/orphan/duplicate path를 registry gate에서 +검사한다. + +## 5. guard와 권한 + +### 5.1 guard 순서 + +권장 순서: + +1. runtime/bootstrap readiness +2. route 존재와 URL parse +3. feature flag +4. session readiness +5. authentication +6. coarse client permission hint +7. route component +8. server authorization result + +client guard는 UX 최적화일 뿐 보안 경계가 아니다. API/BFF가 항상 최종 권한을 +검사한다. + +### 5.2 guard 결과 + +```ts +type GuardDecision = + | { kind: "allow" } + | { kind: "redirect"; to: SafeLocation; reason: RedirectReason } + | { kind: "render"; surface: "auth-required" | "forbidden" | "not-found" }; +``` + +- redirect에는 origin route와 bounded return URL을 사용한다. +- 외부 redirect는 allowlist를 거친다. +- 동일한 route 쌍을 반복하는 redirect loop를 차단한다. +- session이 아직 resolving이면 forbidden으로 단정하지 않는다. +- server가 403을 반환하면 client claim을 신뢰해 화면을 계속 보여 주지 않는다. + +## 6. navigation lifecycle + +### 6.1 loading + +loading surface를 route metadata에 연결한다. + +| surface | 사용 | +| --- | --- | +| shell | 초기 앱 셸 진입 | +| page | 새로운 전체 페이지 | +| collection | table/list 구조 유지 | +| detail | metadata/content 구조 유지 | +| form | 필드 layout 구조 유지 | +| inline | 부분 action | + +cached data가 있으면 full-page skeleton으로 교체하지 않고 refreshing indicator를 +사용한다. `prefers-reduced-motion`에서 skeleton animation을 줄인다. + +### 6.2 error와 lazy chunk recovery + +route error boundary는 다음을 구분한다. + +- render/programmer error +- dynamic import/chunk load error +- application `AppFailure` +- URL parse failure +- not found + +chunk recovery 순서: + +1. 현재 build ID와 release manifest를 확인한다. +2. 새 manifest가 확인되고 같은 build에 대해 reload하지 않았다면 한 번만 reload한다. +3. 같은 failure가 반복되면 reload loop를 막는다. +4. 안전한 support surface와 trace/build ID를 표시한다. +5. recovery 결과를 redacted diagnostics에 기록한다. + +custom fallback을 넘겨 retry/reset 기능을 잃지 않게 한다. boundary는 +`location.key` 또는 route ID가 바뀌면 적절히 reset된다. + +### 6.3 focus와 scroll + +- route 성공 후 `main`의 page heading에 programmatic focus +- mouse 사용자가 불필요한 focus ring을 보지 않게 할 수는 있지만 keyboard focus + indication을 전역으로 제거하지 않음 +- modal/drawer가 닫히면 opener에 focus 복원 +- backward/forward navigation은 저장한 scroll 복원 +- 새 primary route는 top으로 이동 +- hash target은 fixed header offset과 focus 가능 여부를 처리 +- screen reader용 route title/live announcement는 중복 발표를 피함 + +### 6.4 취소와 dirty form + +- route 이동 시 진행 중 query signal을 취소한다. +- mutation은 취소 안전성이 명확할 때만 취소한다. +- dirty form blocker는 browser unload와 in-app navigation을 구분한다. +- 성공 저장 후 blocker를 해제한 다음 이동한다. +- autosave가 있는 form은 pending/failed 상태를 별도로 알린다. +- confirm dialog는 공통 accessible primitive를 사용한다. + +## 7. 페이지 템플릿 + +template은 데이터를 가져오거나 application을 호출하지 않는다. 슬롯, landmark, +focus target, responsive layout, 상태 위치만 소유한다. + +### 7.1 `StandardPageTemplate` + +슬롯: + +- breadcrumb 또는 back link +- title, description, status badge +- primary/secondary actions +- notices +- content +- contextual aside + +작은 화면에서 action wrapping 순서와 heading hierarchy를 보장한다. + +### 7.2 `CollectionPageTemplate` + +슬롯과 상태: + +- title/actions +- search/filter/sort toolbar +- active filter summary와 reset +- result count +- table/list/card view +- pagination 또는 load-more +- initial loading, refreshing, empty-first-use, empty-filtered, error +- bulk selection/action + +URL이 filter, sort, page를 소유한다. template은 query 상태를 직접 읽지 않는다. + +### 7.3 `DetailPageTemplate` + +- breadcrumb/back +- title/status/actions +- summary metadata +- main sections +- related/context aside +- loading/not-found/forbidden/error +- destructive action confirmation 위치 + +식별자가 바뀔 때 이전 entity 내용과 새 loading 상태를 혼동하지 않게 key/reset +정책을 명시한다. + +### 7.4 `FormPageTemplate` + +- title/description +- error summary +- field groups +- optional aside/help +- sticky 또는 normal action bar +- submit/cancel +- submitting/saved/conflict/unavailable +- dirty navigation confirmation + +template은 특정 form vendor를 import하지 않는다. + +### 7.5 `StatusPageTemplate` + +다음 변형을 제공한다. + +- unauthenticated +- forbidden +- not found +- unavailable +- offline +- maintenance +- unexpected + +각 변형은 heading, 짧은 설명, 안전한 primary/secondary action, 선택적 trace ID를 +갖는다. raw stack/response를 표시하지 않는다. + +### 7.6 선택 template + +다음은 project 필요가 있을 때 추가한다. + +- `SettingsPageTemplate` +- `DashboardGridTemplate` +- `SplitPaneTemplate` +- `WizardTemplate` +- `FullScreenTaskTemplate` + +## 8. page controller 패턴 + +page를 세 부분으로 나눈다. + +```text +route adapter + parses URL and guard context + ↓ +controller hook + invokes application query/mutation and maps UI events + ↓ +page view + renders template and design-system components +``` + +예: + +```tsx +export function ResourceListRoute() { + const input = useRouteInput(); + return ; +} + +function ResourceListController({ input }: ResourceListControllerProps) { + const controller = useResourceListController(input); + return ; +} +``` + +router의 parse/invalid-route boundary가 성공한 `ParsedRouteInput`만 provider에 +넣고, page controller는 그 context를 바로 읽는다. 따라서 controller hook은 +조건부로 호출되지 않는다. + +controller가 소유하는 것: + +- parsed route input을 application input으로 변환 +- query/mutation state +- pagination/filter/navigation event +- retry/refresh/action callbacks +- view model projection + +view가 소유하는 것: + +- semantic markup +- template/component 조립 +- focus target +- 사용자의 local-only interaction + +controller가 소유하지 않는 것: + +- HTTP URL 조립 +- credential +- transport DTO parse +- 도메인 invariant +- raw vendor SDK + +## 9. 권장 패턴 카탈로그 + +| 패턴 | 적용 위치 | 쓰는 이유 | 오용 | +| --- | --- | --- | --- | +| Ports and Adapters | application 외부 경계 | 정책과 기술 교체 분리 | 모든 작은 UI library에 port 생성 | +| Command/Query | application input | 읽기/변경 의도와 정책 분리 | CQRS 인프라를 필요 없이 도입 | +| Gateway | output port | 외부 데이터 capability 표현 | `get/post` 범용 HTTP를 application에 노출 | +| Anti-Corruption Mapper | outbound feature adapter | DTO 변화가 core로 전파되지 않게 함 | 단순 object spread로 타입만 바꿈 | +| Result | 예상 실패 | 실패 종류와 처리 경로를 닫음 | programmer error까지 모두 Result로 숨김 | +| Controller/View | inbound React | data lifecycle과 markup 분리 | 거대한 hook 하나에 모든 feature 로직 집중 | +| Strategy | retry/cache/auth recovery | 정책 교체와 테스트 가능성 | 설정 한 줄도 interface로 과도 추상화 | +| Observer/External Store | session/theme/realtime | React 외부 소유 상태 구독 | server state를 다시 external store에 복제 | +| State Machine | 복잡한 workflow | 유효 전이와 보상 명시 | 단순 modal open에 도입 | +| Headless/Compound Component | 복합 UI | behavior와 style/slot 분리 | vendor primitive를 제품 전역에 직접 노출 | +| Adapter Facade | icon/form/i18n vendor | React inbound 내부 vendor 교체 경계 | application port로 승격 | +| Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 | +| Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 | + +RP-08 이후 route contract의 `title`/`navigation` 필드는 fallback metadata이며 +실제 document title, navigation, loading/error/access surface는 +`route..title|navigation` typed catalog key를 해석한다. route params, +search, backend message를 translation key로 조립하지 않는다. locale 변경은 +현재 route를 재요청하거나 query key를 바꾸지 않고 document title과 화면 copy만 +다시 렌더링한다. + +패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative +architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다. + +## 10. 새 route/page 추가 recipe + +1. feature public 경계와 route ID를 정한다. +2. serializable route contract를 등록한다. +3. params/search Zod schema와 bidirectional codec을 작성한다. +4. safe URL builder를 export한다. +5. lazy page module과 runtime map entry를 추가한다. +6. session/permission/flag guard를 선언한다. +7. 적절한 page template을 선택한다. +8. controller hook을 application input API에 연결한다. +9. loading, empty, refreshing, error, auth, forbidden, not-found를 결정한다. +10. title/message key, focus, scroll, chunk ID를 연결한다. +11. 다음 검증을 추가한다. + - contract/runtime map type completeness + - codec round-trip/property cases + - guard decision unit + - page component state + - query/mutation integration + - keyboard/focus/axe + - direct URL, back/forward, refresh E2E + - chunk failure recovery가 필요한 route의 E2E +12. registry, type, architecture, component, integration, E2E gate를 실행한다. + +## 11. 금지 패턴 + +- page 안에서 raw `fetch`, storage, auth SDK, telemetry SDK 호출 +- `useParams()`/`URLSearchParams` 값을 cast만 하고 사용 +- route contract와 JSX route tree를 각각 수동 관리 +- protected route를 server authorization 대체 수단으로 취급 +- 모든 실패를 redirect 또는 full-page error로 처리 +- query data가 있는데 background error 때문에 내용을 제거 +- chunk load error에서 제한 없는 `location.reload` +- route heading focus outline을 CSS로 무조건 제거 +- template이 data fetching 또는 feature-specific copy를 소유 +- generic `BasePage` prop 하나에 모든 layout variation을 boolean으로 추가 + +## 12. 완료 기준 + +- 모든 route ID가 contract와 runtime map에서 compile-time 완전성을 가진다. +- params/search parse와 URL serialize가 같은 codec을 사용한다. +- route metadata가 loading/error/chunk/title/navigation 행동에 실제 연결된다. +- redirect와 chunk reload loop가 차단된다. +- route 이동 시 query 취소, focus, scroll, dirty policy가 검증된다. +- collection/detail/form/status reference page가 template을 사용한다. +- page view가 application input 외의 외부 capability를 직접 호출하지 않는다. +- 새 route recipe와 테스트만으로 별도 라우터 내부 지식 없이 기능을 추가할 수 있다. diff --git a/docs/architecture/server-file-capability-infrastructure.md b/docs/architecture/server-file-capability-infrastructure.md new file mode 100644 index 0000000..9a20b9c --- /dev/null +++ b/docs/architecture/server-file-capability-infrastructure.md @@ -0,0 +1,1882 @@ +# Server file capability infrastructure handoff + +- 상태: 설계 완료, 서버 구현 대기 +- 목표 상태: `AVAILABLE_NOT_COMPOSED` +- 대상: 이후 서버 template 또는 제품 backend에서 구현할 공통 file capability +- 비대상: 제품 domain, 제품 use case, HTTP route/controller, provider 선택 +- 기준일: 2026-07-28 + +이 문서는 frontend에 준비된 File/Blob/picker/download capability와 이후 연결할 +서버측 기반을 정의한다. 서버는 object storage, multipart, integrity, +signed capability, idempotency와 quarantine을 실제 provider adapter로 제공하되, +제품이 파일 기능을 선택하기 전에는 endpoint, background job, database migration과 +runtime composition을 설치하지 않는다. + +브라우저 쪽 capability envelope, bounded fetch, resume checkpoint와 Image CDN +descriptor 계약은 +[`presigned-transfer-and-image-cdn.md`](./presigned-transfer-and-image-cdn.md)와 +VD-12가 소유한다. Range resume/background 경계는 VD-14, top-level transfer +composition과 Image descriptor provider는 VD-16이 소유한다. 이 문서의 server +port를 그 frontend DTO에 직접 노출하지 않고, 제품 BFF/controller가 두 경계 +사이를 매핑한다. + +`S3`, `MinIO`, `GCS`, `Azure Blob`, PostgreSQL, Redis, scanner vendor는 이 문서의 +port 구현 후보일 뿐이다. provider SDK type, bucket/key, multipart upload ID, +scanner 원문 결과는 port 밖으로 노출하지 않는다. + +이 설계의 최상위 불변조건은 다음과 같다. + +> object storage에 byte가 존재하는 것, multipart complete가 성공한 것, +> scan이 clean으로 끝난 것, 사용자가 접근 가능한 `AVAILABLE` 상태는 서로 다른 +> commit point다. 어느 단계도 다음 단계를 암묵적으로 보장하지 않는다. + +## 1. 설계 경계 + +### 1.1 이 문서가 제공하는 것 + +- streaming object read/write의 provider-neutral port와 불변조건 +- multipart staging/complete/abort 상태 머신 +- 실제 byte length와 checksum의 authoritative 검증 규칙 +- 짧은 수명의 제한된 signed capability +- 동시 replay를 견디는 idempotency store +- immutable quarantined object를 검사하는 scan port +- provider locator를 숨기는 opaque object reference +- deadline, retry, cancellation과 resource cleanup 규칙 +- provider exception을 닫는 공통 failure model +- 모든 provider가 통과해야 하는 동일 contract test +- 미선택 상태, 선택적 composition과 완전 제거 계약 + +### 1.2 이 문서가 만들지 않는 것 + +- `AttachContractDocument`, `UploadProfileImage` 같은 제품 use case +- `ContractAttachment`, `EvidenceDocument` 같은 domain model +- 업무별 MIME, 용량, retention, 소유권과 승인 규칙 +- 실제 REST/GraphQL endpoint와 request/response DTO +- account/tenant authorization 구현 +- 실제 bucket, region, database, queue, scanner 선택 +- frontend offline sync 전체 protocol +- CDN/Service Worker/Cache Storage release protocol + +제품 선택 이후에는 이 capability 위에 feature application port와 use case를 +추가한다. 공통 capability가 domain 이름이나 aggregate ID를 알게 해서는 안 된다. + +이 문서의 `create`, `putPart`, `complete`, `scan`, `promote`는 provider와 technical +lifecycle을 닫기 위한 platform protocol operation이지, 그대로 외부에 노출할 제품 +use case가 아니다. 제품 use case가 생기기 전에는 caller authorization, aggregate +연결, 업무 정책 선택과 endpoint가 없으므로 실행 경로도 composition하지 않는다. +단, orphan reconciliation과 resource cleanup 규칙은 adapter가 만든 기술 자원의 +정합성을 위한 infrastructure 책임으로 정의해 둘 수 있다. + +### 1.3 계층과 방향 + +```text +future product feature + domain/use case + -> product-owned file port + -> server file capability facade + -> ObjectByteStorePort + -> MultipartStagingPort + -> ObjectRegistryPort + -> IdempotencyStorePort + -> ContentScannerPort + -> CleanPromotionPort + -> SignedTransferCapabilityPort + -> provider adapters + S3 / MinIO / GCS / Azure Blob + PostgreSQL / Redis + scanner / CDR +``` + +HTTP controller는 미래의 inbound adapter다. object store, metadata database, +scanner와 signer 구현은 outbound adapter다. 이 문서의 port와 protocol은 +application-neutral server platform 경계다. + +### 1.4 control plane과 data plane + +두 plane을 하나의 범용 `FileService` method로 뭉개지 않는다. + +| plane | 책임 | 일반적인 경로 | +| --- | --- | --- | +| control | authorization, session, 정책 snapshot, idempotency, 상태 전이, capability 발급 | Browser → Web API/BFF | +| data | bounded byte upload/download, range, checksum, backpressure | Browser → BFF proxy 또는 제한된 signed URL → object store | +| inspection | quarantine read, parser/scanner/CDR, verdict | worker → private object store/scanner | + +브라우저는 provider 관리자 credential, internal bucket/key 또는 database +credential을 받지 않는다. direct transfer가 필요하면 control plane이 exact +operation에 한정된 short-lived capability만 발급한다. + +### 1.5 권장 서버 모듈 경계 + +언어와 framework가 달라도 dependency 방향은 다음과 같이 유지한다. + +```text +file-capability/ + protocol/ + identifiers, lifecycle, policy, failure, receipts + ports/ + object-store, multipart, registry, idempotency, signer, scanner, promotion + runtime/ + streaming, integrity, deadline, retry, resource-lease, reconciliation + adapters/ + object-store/ + registry/ + idempotency/ + signer/ + scanner/ + contract-tests/ + reusable suites, fixtures, conformance evidence + composition/ + optional provider profile, probes, workers, kill switches +``` + +- `protocol`과 `ports`는 HTTP/framework/provider SDK에 의존하지 않는다. +- `runtime`은 제품 domain을 모르며 port와 protocol만 사용한다. +- concrete SDK type과 exception은 각 adapter package 안에서 끝난다. +- concrete adapter를 동시에 import할 수 있는 곳은 composition root와 해당 + provider contract fixture뿐이다. +- 제품 module은 capability facade 또는 product-owned port에만 의존한다. +- provider adapter는 별도 package/dependency로 격리해 미선택 build와 제거 + profile에서 아예 포함되지 않게 한다. +- 미래 inbound controller와 제품 use case는 이 tree 밖의 제품 feature가 + 소유한다. + +## 2. 공통 식별자와 영속 모델 + +### 2.1 외부에 노출 가능한 opaque reference + +다음 reference는 URL-safe cryptographic random 값이거나 같은 수준의 +registry-issued opaque 값이어야 한다. + +```text +ObjectRef +UploadSessionRef +UploadPartRef +TransferCapabilityReceipt +IdempotencyKey +ScanJobRef +``` + +최소 요구사항: + +- 최소 128-bit의 예측 불가능성 +- account ID, email, 업무 key, filename, bucket, region을 인코딩하지 않음 +- 대소문자와 Unicode normalization이 개입하지 않는 제한된 alphabet +- log/metric/trace label에 원문을 기록하지 않음 +- reference만으로 authorization을 얻지 못함 +- 서로 다른 종류의 reference를 type/namespace로 혼용하지 않음 +- authorization scope와 object generation/version은 reference 문자열에 넣지 않고 + server registry와 authorization decision에서 별도로 검증 +- caller가 볼 권한이 없는 reference와 존재하지 않는 reference는 enumeration이 + 가능한 API에서 같은 외부 failure 표현 사용 + +`ObjectRef`는 provider object key가 아니다. server-owned registry가 아래 binding을 +소유한다. + +```text +ObjectBinding + objectRef + generation + providerProfileId + encryptedProviderLocator + state + byteLength + mediaType + wholeObjectDigest + createdAt + policySnapshotId +``` + +`encryptedProviderLocator`에는 provider가 요구하는 bucket/key/version locator만 +보관한다. controller, domain과 frontend DTO에 반환하지 않는다. provider migration +시에도 `ObjectRef`는 유지하고 binding만 versioned CAS로 교체할 수 있어야 한다. + +### 2.2 technical lifecycle + +제품 domain 상태와 분리된 server file capability의 기술 상태는 다음과 같다. + +```text +UploadSession + OPEN + -> COMPLETE_REQUESTED + -> PROVIDER_COMMITTED + -> QUARANTINE_RECORDED + -> QUARANTINED + -> ABORT_REQUESTED -> ABORTED + -> EXPIRED -> CLEANUP_PENDING -> CLEANED + +StoredObject + STAGING + -> QUARANTINED + -> AVAILABLE + -> REJECTED + -> DELETING -> DELETED +``` + +불변조건: + +- `AVAILABLE` 전에는 일반 download capability를 발급하지 않는다. +- `QUARANTINED` object는 private/non-executable 위치와 response policy를 사용한다. +- 상태 전이는 `(objectRef, generation, expectedState)` CAS로 수행한다. +- scan verdict는 exact generation과 digest에 binding한다. +- complete/abort/expiry race는 하나의 durable 상태만 승리한다. +- `PROVIDER_COMMITTED`는 provider complete 성공과 registry 응답 유실 사이를 + 복구하는 필수 fence다. +- `QUARANTINE_RECORDED`는 exact provider generation/length/digest와 scan outbox가 + 같은 database transaction 또는 동등한 atomic outbox로 durable해진 상태다. +- terminal state를 되돌리려면 새 generation과 새 audit record를 만든다. +- provider object 존재만으로 `AVAILABLE`이라고 판단하지 않는다. + +### 2.3 정책 snapshot + +미래 use case가 선택한 정책은 session 생성 시 immutable snapshot으로 binding한다. + +```text +TransferPolicySnapshot + policyId + policyVersion + maxObjectBytes + minPartBytes + maxPartBytes + maxPartCount + maxConcurrency + allowedChecksumAlgorithms + requiredScanProfile + sessionExpiresAt + capabilityMaxTtl + orphanRetention +``` + +caller는 이 한도를 낮출 수만 있다. 진행 중인 session이 mutable configuration을 +다시 읽어 한도가 상승하거나 의미가 바뀌어서는 안 된다. 긴급 차단은 별도 +deny/kill-switch registry로 fail-closed하게 적용한다. + +## 3. Server platform ports + +아래 signature는 언어 중립적인 의미 계약이다. 실제 서버 언어의 native stream, +SDK response와 exception이 이 경계를 통과해서는 안 된다. + +### 3.0 공통 실행 문맥과 숫자 표현 + +모든 I/O port 호출은 같은 bounded execution context를 받는다. + +```text +OperationContext + registeredOperation + opaqueRequestId + opaqueTraceId? + monotonicDeadline + cancellation +``` + +- ingress timeout은 server-owned hard cap으로 clamp한다. +- 다른 process로 전달할 때 client의 absolute timestamp를 신뢰하지 않고 남은 + timeout을 상한 안에서 전달한 뒤 각 process에서 monotonic deadline으로 바꾼다. +- child operation은 `min(parent remaining, operation cap)`만 사용할 수 있다. +- queue 대기, retry backoff와 cleanup 전의 본 작업도 전체 deadline에 포함한다. +- request/trace ID와 idempotency key는 서로 다른 식별자다. +- byte length, offset과 합계는 signed 64-bit 범위를 안전하게 표현하는 + `bigint`/decimal value object를 사용한다. JSON number나 provider SDK의 좁은 + 정수로 암묵 변환하지 않고 모든 덧셈·곱셈 overflow를 검사한다. + +### 3.1 `ObjectByteStorePort` + +```text +ObjectByteStorePort + capabilities() -> Result + + inspect(locator, context) + -> Result + + openRead( + locator, + expectedVersion?, + range?, + context + ) -> Result + + putAtomic( + newLocator, + expectedLength, + expectedDigest?, + mediaType, + byteStream, + context + ) -> Result + + deleteExact( + locator, + expectedVersion, + context + ) -> Result +``` + +`OpenedObjectRead`는 다음을 제공한다. + +```text +OpenedObjectRead + providerVersion + declaredByteLength + mediaType + contentRange? + stream: bounded backpressure byte stream + close() +``` + +필수 규칙: + +- read stream은 첫 실패 이후 byte를 더 전달하지 않는다. +- EOF 전에 declared length를 초과하거나 미달하면 `INTEGRITY_FAILED`다. +- range는 normalized inclusive/exclusive 의미를 port에서 하나로 고정한다. +- empty object와 empty range를 구분한다. +- conditional version mismatch는 `CONFLICT`이지 임의의 `NOT_FOUND`가 아니다. +- `putAtomic` 성공은 provider가 새 object/version을 durable하게 확정한 뒤만 반환한다. +- overwrite를 기본 허용하지 않는다. 새 locator 또는 exact version CAS만 허용한다. +- `deleteExact`의 late response loss는 inspect/reconcile로 결정하며 blind retry하지 + 않는다. + +### 3.2 `MultipartStagingPort` + +```text +MultipartStagingPort + create( + newLocator, + mediaType, + providerConstraints, + context + ) -> Result + + putPart( + multipartRef, + partNumber, + exactOffset, + expectedLength, + expectedDigest, + byteStream, + context + ) -> Result + + inspectParts( + multipartRef, + context + ) -> Result + + complete( + multipartRef, + orderedExactPartReceipts, + context + ) -> Result + + abort( + multipartRef, + context + ) -> Result +``` + +`ProviderMultipartRef`, provider part ETag와 raw upload ID는 adapter-private +branded/opaque 값이다. frontend가 받는 `UploadSessionRef`와 동일하지 않다. + +필수 규칙: + +- part number는 연속성·범위·중복을 server ledger가 검증한다. +- part receipt는 multipartRef, part number, length, digest와 provider version에 + binding한다. +- provider ETag를 SHA-256으로 해석하지 않는다. +- `complete`는 server ledger가 승인한 ordered receipt만 사용한다. +- `complete` 성공 후에도 object는 `QUARANTINED`다. +- `abort`는 idempotent하며 already-aborted를 성공으로 재생할 수 있다. +- complete가 먼저 commit됐다면 abort는 완료 object를 삭제하지 않고 `CONFLICT`를 + 반환한다. +- 실패한 create/put/complete는 provider multipart orphan을 남길 수 있으므로 + bounded reconciliation 대상이 된다. + +### 3.3 `ObjectRegistryPort` + +```text +ObjectRegistryPort + reserveObject(bindingDraft, expectedAbsent, context) -> Result + getObject(objectRef, context) -> Result + transitionObject(objectRef, generation, expectedState, nextState, context) + -> Result + + createSession(sessionSnapshot, context) -> Result + getSession(sessionRef, context) -> Result + appendPart(sessionRef, expectedRevision, partRecord, context) + -> Result + transitionSession(sessionRef, expectedRevision, expectedState, nextState, context) + -> Result + + listExpiredOpenSessions(cursor, maxRows, context) -> Result + listReconciliationCandidates(cursor, maxRows, context) -> Result +``` + +registry transaction이 object provider transaction과 원자적이라고 가정하지 않는다. +모든 cross-system mutation은 durable phase와 reconciliation을 갖는 saga다. + +### 3.4 `SignedTransferCapabilityPort` + +```text +SignedTransferCapabilityPort + issueUploadPart( + subjectScope, + sessionRef, + providerLocator, + partNumber, + exactLength, + requiredDigest, + requiredHeaders, + expiresAt, + context + ) -> Result + + issueDownload( + subjectScope, + objectRef, + generation, + providerLocator, + allowedRange?, + responseMetadata, + expiresAt, + context + ) -> Result +``` + +```text +SignedTransferCapability + receipt + href + method + requiredHeaders + objectRef + generation/sessionRef + exactLengthOrMaximum + expectedDigest? + mediaType + safeExtension? + expiresAt +``` + +capability는 이미 authorization된 control-plane 결과다. signer port가 업무 권한을 +판단하지 않는다. + +### 3.5 `IdempotencyStorePort` + +```text +IdempotencyStorePort + begin(scope, operation, key, requestFingerprint, leaseUntil, context) + -> Result | IN_PROGRESS | REPLAY> + + renew(leaseAndFence, extendUntil, context) + -> Result + + complete(leaseAndFence, requestFingerprint, completionReceipt, expiresAt, context) + -> Result + + releaseRetryable( + leaseAndFence, + requestFingerprint, + proofSideEffectNotStarted, + context + ) + -> Result + + pruneExpired(cursor, maxRows, context) + -> Result +``` + +`scope + operation + key`가 unique key다. 같은 key를 다른 request fingerprint로 +재사용하면 `CONFLICT`다. idempotency record는 provider upload ID, signed URL, +credential, 원본 filename 또는 request body를 저장하지 않는다. + +lease expiry 뒤 takeover는 fence를 증가시키고 이전 owner의 renew/complete를 +거절한다. 외부 side effect가 시작됐는지 불명확하면 record를 지우거나 +`releaseRetryable`하지 않고 `RECOVERY_REQUIRED`로 남겨 reconciler가 provider와 +registry를 확인한다. + +### 3.6 `ContentScannerPort` + +```text +ContentScannerPort + capabilities() -> Result + + submit( + scanJobRef, + objectRef, + generation, + immutableDigest, + scanProfileId, + sourceFactory, + context + ) -> Result + + inspect(jobRef, context) -> Result + + cancel(jobRef, context) -> Result +``` + +```text +ScanStatus + PENDING | RUNNING + CLEAN { + objectRef, generation, immutableDigest, + engineProfile, definitionVersion, completedAt + } + REJECTED { + objectRef, generation, immutableDigest, + allowlistedReasonCodes, completedAt + } + FAILED_RETRYABLE { safeReasonCode } + FAILED_TERMINAL { safeReasonCode } +``` + +scanner의 raw stdout, path, vendor exception, signature name과 원본 filename은 +application response나 telemetry로 전달하지 않는다. + +### 3.7 `CleanPromotionPort` + +검사와 availability commit 권한을 한 adapter에 함께 주지 않는다. + +```text +CleanPromotionPort + promote( + objectRef, + expectedGeneration, + expectedQuarantineDigest, + sealedCleanScanReceipt, + idempotency, + context + ) -> Result + + reject( + objectRef, + expectedGeneration, + expectedQuarantineDigest, + sealedRejectedScanReceipt, + idempotency, + context + ) -> Result +``` + +- scan receipt는 exact object/generation/digest/scan-policy version에 binding된 + server-only sealed value다. +- scanner는 registry를 `AVAILABLE`로 전환할 권한을 갖지 않는다. +- promotion adapter는 raw scanner 결과를 해석하지 않고 sealed receipt만 검증한다. +- CDR/sanitized output은 원본과 다른 object generation/digest이므로 새 object로 + length/digest를 다시 검증한다. + +## 4. Streaming object read/write + +### 4.1 공통 stream envelope + +stream은 다음을 명시적으로 소유한다. + +- backpressure와 최대 queued bytes +- immutable declared length 또는 `unknown` +- operation deadline과 cancellation +- 첫 실패에서 terminal close +- byte counter와 digest accumulator +- completion/close truth +- resource lease + +전체 object를 하나의 byte array, string, base64 또는 temporary in-memory Blob으로 +합치는 API를 공통 경로로 제공하지 않는다. small-object 편의 API가 필요하면 +composition-owned hard cap 아래 별도 wrapper로만 제공한다. + +### 4.2 proxy upload + +```text +request body + -> transport maximum-body guard + -> decoded stream + -> authoritative byte counter + -> digest accumulator + -> provider write stream + -> provider commit + -> registry phase transition +``` + +- `Content-Length`는 preflight hint일 뿐 실제 count를 대체하지 않는다. +- chunked transfer나 HTTP/2/3에서도 실제 decoded payload를 센다. +- max bytes를 넘는 즉시 upstream read와 provider write를 모두 cancel한다. +- provider commit 전에 client disconnect가 발생하면 staging을 abort/cleanup한다. +- provider commit 뒤 client response가 유실되면 idempotency replay가 같은 receipt를 + 반환해야 한다. + +### 4.3 proxy download + +```text +authorized immutable ObjectBinding + -> exact provider version open + -> byte/digest/range verifier + -> response stream + -> close/cancel +``` + +- authorization은 open 전에 완료한다. +- `AVAILABLE`과 exact generation을 다시 확인한다. +- metadata preflight와 stream open이 분리된 provider에서는 둘 다 같은 immutable + provider version에 pin하고 `If-Match`와 동등한 조건을 강제한다. version pin을 + 지원하지 않으면 HEAD 뒤 GET 같은 TOCTOU 경로를 사용하지 않는다. +- response header를 확정하기 전에 media type, length, range와 filename을 + normalize한다. +- range response는 exact provider version, normalized range와 실제 반환 byte + count를 검증한다. whole-object SHA-256만으로 부분 range의 무결성을 검증했다고 + 주장하지 않는다. +- 검증 가능한 range가 필요하면 immutable chunk digest/Merkle manifest를 별도로 + 설계하고 exact chunk proof를 검증한다. 그렇지 않은 range는 transport integrity와 + version/range binding만 보장하며 `digestVerified=false`로 명시한다. +- `Content-Disposition` filename은 advisory metadata로 sanitize한다. +- sensitive object는 기본적으로 attachment, `nosniff`, private/no-store 정책을 + 사용한다. +- downstream disconnect는 provider read를 cancel한다. +- download 완료 metric은 server stream 종료이지 사용자 disk 저장 완료가 아니다. + +### 4.4 direct provider transfer + +signed URL을 사용하는 direct transfer에서는 server가 byte stream을 직접 보지 +못한다. 그러므로 다음을 모두 만족해야 한다. + +- capability가 method, exact provider locator, part/range, length, checksum header, + expiry와 subject/session scope에 binding +- provider가 해당 조건을 실제 request에서 강제 +- complete 전에 server가 provider part/object metadata를 authoritative하게 재조회 +- provider metadata가 충분하지 않으면 quarantined object를 server-side stream으로 + 다시 읽어 length와 whole-object digest 검증 +- verification 완료 전 registry 상태를 `AVAILABLE`로 전환하지 않음 + +provider가 검증하지 않는 client-declared metadata를 signed request에 포함했다는 +이유만으로 integrity를 주장하지 않는다. + +## 5. Multipart protocol + +### 5.1 control-plane 상태 흐름 + +frontend reference runtime과 연결하는 ordered multipart wire protocol literal은 +`PRESIGNED_MULTIPART_V1`이다. create/status/part capability/complete/abort의 +application contract, session response와 durable checkpoint 전체에서 이 protocol +값을 exact하게 검증한다. `UPLOAD_PART` capability binding 자체에도 protocol을 +포함하고, part capability endpoint는 `sessionId`로 조회한 server-side session과 +이 값이 일치하는지 확인해야 한다. + +```text +create + authorize + policy snapshot + idempotency begin + reserve ObjectRef/generation + create provider multipart staging + persist OPEN session + return session constraints + +put part + reauthorize session scope + validate OPEN + expiry + part plan + claim part idempotency + stream or issue exact signed part capability + persist verified ProviderPartReceipt by session revision CAS + +complete + reauthorize + idempotency begin + CAS OPEN -> COMPLETE_REQUESTED + validate ordered complete part set + provider complete + persist PROVIDER_COMMITTED with exact provider generation + verify final length/digest + atomically persist object QUARANTINED + scan outbox + persist QUARANTINE_RECORDED -> QUARANTINED + return stable quarantined receipt + +abort + reauthorize + idempotency begin + CAS OPEN -> ABORT_REQUESTED + provider abort/cleanup + persist ABORTED +``` + +### 5.2 create + +server가 결정하고 snapshot해야 하는 값: + +- object/session opaque reference +- max object bytes +- min/max part bytes와 max part count +- max client concurrency +- required checksum algorithm +- exact session expiry +- scan profile +- direct/proxy transfer mode + +client가 bucket/key, provider upload ID, concurrency ceiling, checksum algorithm이나 +expiry를 선택하지 않는다. + +### 5.3 part + +- client가 보낸 `requestBindingSha256`, `uploadBindingSha256`와 part digest를 + authorization proof로 취급하지 않는다. `sessionId`로 server-owned session을 + 조회하고 subject/purpose/state/expiry를 재검증한 다음 canonical binding과 part + plan을 직접 재계산한다. +- `partNumber`, exact offset, expected length를 session plan과 대조한다. +- 같은 idempotency key와 같은 fingerprint 재전송은 같은 receipt를 반환한다. +- 같은 part number의 다른 length/digest는 `CONFLICT`다. +- concurrent upload의 aggregate in-flight bytes와 provider connections에 hard cap을 + 둔다. +- 마지막 part를 제외한 최소 part 크기는 provider와 platform ceiling을 모두 + 만족해야 한다. +- part 성공 response가 유실되면 provider inspect와 ledger reconcile로 결정한다. + +### 5.4 complete + +- client가 제출한 receipt 목록을 그대로 provider에 전달하지 않는다. +- server ledger의 exact verified part set과 ordered fingerprint를 다시 계산한다. +- part count, offset 연속성, byte sum과 중복/누락을 검증한다. +- provider complete의 ETag를 whole-object SHA-256으로 해석하지 않는다. +- provider complete 직후 exact provider locator/version을 + `PROVIDER_COMMITTED`로 먼저 기록한다. 이 checkpoint 전후 crash와 응답 유실은 + provider inspect + journal reconcile로 결정한다. +- object metadata와 scan outbox가 durable해진 뒤 + `QUARANTINE_RECORDED`를 기록한다. +- complete 성공은 `QUARANTINED` object가 durable하다는 의미이며 clean/available을 + 의미하지 않는다. + +### 5.5 abort, expiry와 orphan cleanup + +abort와 expiry job은 같은 state CAS를 사용한다. + +| 현재 상태 | complete | abort/expiry | +| --- | --- | --- | +| `OPEN` | `COMPLETE_REQUESTED` claim 가능 | `ABORT_REQUESTED` claim 가능 | +| `COMPLETE_REQUESTED` | resume/reconcile | object 삭제 금지, conflict | +| `PROVIDER_COMMITTED` | verify/resume | object 삭제 금지, conflict | +| `QUARANTINE_RECORDED` | outbox/session resume | 일반 abort 금지 | +| `QUARANTINED` | stable replay | 일반 abort 금지 | +| `ABORT_REQUESTED/ABORTED/EXPIRED` | conflict/expired | stable replay | + +orphan janitor는 다음을 bounded page로 처리한다. + +- registry OPEN이지만 session TTL이 지난 행 +- registry에 provider multipart ref가 있으나 terminal state가 아닌 행 +- provider staging은 있으나 registry binding이 없는 owned orphan +- provider complete 가능성이 있으나 response가 유실된 `COMPLETE_REQUESTED` +- provider object는 durable하지만 registry/outbox가 미완료인 + `PROVIDER_COMMITTED`/`QUARANTINE_RECORDED` +- `DELETING`에서 provider delete response가 유실된 object + +전체 bucket list/delete를 자동 실행하지 않는다. owned prefix/tag와 registry +binding이 동시에 확인된 대상만 정리한다. + +## 6. Byte length와 checksum + +### 6.1 authoritative source + +| 값 | 신뢰 수준 | +| --- | --- | +| client `Content-Length` | preflight hint | +| client checksum | expected value, 단독 authority 아님 | +| provider ETag | opaque provider version | +| provider checksum field | contract test를 통과한 알고리즘/representation에서만 사용 | +| server streaming counter | proxy path의 authoritative length | +| server digest accumulator | proxy path의 authoritative digest | +| server post-complete readback | direct path의 authoritative fallback | + +### 6.2 알고리즘 + +- 기본 whole-object algorithm은 SHA-256으로 제한한다. +- algorithm confusion을 막기 위해 digest에 algorithm tag를 항상 포함한다. +- lowercase/uppercase나 base64/hex encoding을 port 하나로 canonicalize한다. +- multipart part digest와 whole-object digest를 구분한다. +- OPFS의 `SHA-256-TREE-V1`, multipart ETag와 whole-object SHA-256은 서로 다른 + digest다. +- digest가 맞아도 MIME/content safety가 증명되는 것은 아니다. + +### 6.3 mismatch + +다음 경우 provider commit 또는 availability promotion을 금지한다. + +- 실제 byte length가 expected length와 다름 +- object가 composition hard cap을 초과 +- part byte sum이 whole-object length와 다름 +- expected digest와 actual digest가 다름 +- provider metadata와 server ledger가 다름 +- digest algorithm 또는 encoding이 policy와 다름 + +이미 direct upload가 provider에 commit된 뒤 mismatch가 발견되면 object를 +`REJECTED` 또는 cleanup-pending quarantine으로 유지하고 일반 read capability를 +발급하지 않는다. + +## 7. Short-lived signed capability + +### 7.1 capability binding + +capability 또는 그 server-side receipt는 최소 다음을 binding한다. + +- issuer와 audience +- subject/session scope +- operation: upload part, object download 또는 authorized range +- `ObjectRef + generation` 또는 `UploadSessionRef + partNumber` +- exact HTTP method +- exact provider locator +- exact length 또는 server-enforced maximum +- required checksum와 signed headers +- upload 성공의 exact status, receipt response header와 expected response bytes +- media type과 safe extension +- issued-at/not-before/expiry +- capability policy version +- random receipt/nonce + +값 하나라도 caller request, object registry와 다르면 발급 또는 handoff를 +fail-closed한다. + +### 7.2 lifetime + +- session TTL과 per-request capability TTL을 분리한다. +- capability TTL은 composition이 정하고 implementation hard ceiling보다 낮출 수만 + 있다. +- 대용량 transfer 시간과 재발급 UX를 측정해 값을 선택한다. +- 만료 capability를 연장하지 않고 authorization 후 새 capability를 발급한다. +- proxy 경로는 stream admission 시 expiry를 검증하고, admission 뒤에는 별도의 + bounded operation deadline과 maximum transfer duration을 적용한다. expiry가 + 지났다는 이유만으로 이미 허용된 stream을 임의의 시점에 자를지는 정책으로 + 명시하며 기본값은 새 요청/재시도만 거절하는 것이다. +- direct provider 경로는 provider가 “시작 시 유효”와 “전송 내내 유효” 중 어떤 + 의미를 실제로 강제하는지 contract test로 고정한다. 중간 만료의 강한 회수가 + 필요하면 provider URL이 아니라 proxy/relay 경로를 사용한다. +- signing key rotation 시 current/previous verification window와 강제 폐기 절차를 + 정의한다. + +문서 출발점으로는 per-request capability를 수분 단위로 유지하되, 실제 값은 +provider와 최대 part/object 크기의 production-like transfer evidence로 승인한다. +장기 URL을 session 전체와 동일하게 발급하지 않는다. + +### 7.3 single-use의 한계 + +object store signed URL은 일반적으로 URL 자체만으로 single-use를 보장하지 않는다. +정확한 single-use가 요구되면 다음 중 하나를 선택한다. + +- BFF proxy가 nonce를 원자 consume한 뒤 stream +- control plane receipt를 원자 consume하고 아주 짧은 provider capability 발급 +- provider가 지원하는 조건부 write/version 정책과 server ledger를 결합 + +single-use를 구현하지 않았으면 문서나 API 이름으로 주장하지 않는다. + +### 7.4 URL과 logging + +- HTTPS 외 protocol은 local test 외 금지 +- allowlisted provider/origin만 허용 +- redirect는 기본 금지 +- query의 signature/token을 log, analytics, trace attribute에 기록하지 않음 +- `Referer`와 browser history 노출을 고려한 delivery 정책 +- response header와 CORS expose 목록을 explicit하게 고정 +- signed URL을 database의 장기 object locator로 저장하지 않음 + +## 8. Idempotency store + +### 8.1 scope와 fingerprint + +idempotency는 다음 연산에 기본 적용한다. + +- upload session create +- part registration/stream upload +- multipart complete +- abort +- scan submission +- availability promotion +- delete + +`requestFingerprint`는 operation별 canonical request의 SHA-256이다. 원문 payload, +filename, signed URL, token과 PII를 fingerprint input/record에 넣지 않는다. +업무 payload가 필요한 미래 use case는 feature-owned canonicalization을 추가한다. +fingerprint는 registered canonicalizer만 만들며 operation/version, exact +object/session generation, length, digest와 relevant precondition을 포함한다. +deadline, trace/request ID 같은 volatile 값은 제외한다. + +### 8.2 concurrency + +`begin`은 하나의 atomic operation이어야 한다. + +```text +first caller -> ACQUIRED + lease/fence +same fingerprint -> IN_PROGRESS 또는 completed REPLAY +different fingerprint -> CONFLICT +``` + +- process-local lock만으로 correctness를 주장하지 않는다. +- lease owner가 죽으면 expiry 이후 같은 fingerprint만 reclaim할 수 있다. +- reclaim은 fence를 증가시키며 old owner의 complete를 거절한다. +- complete는 expected lease/fence와 request fingerprint를 다시 확인한다. +- durable side effect와 idempotency complete 사이 crash는 reconciliation 가능한 + operation receipt로 해결한다. +- “exactly once”를 주장하지 않고 at-least-once delivery + idempotent effect로 + 설계한다. +- exactly-once가 실제로 필요하면 업무 mutation과 receipt를 같은 transaction에 + 넣거나 transactional inbox/outbox로 묶어야 한다. 별도 Redis `SETNX` 뒤 + database/object mutation을 실행하는 구조는 duplicate suppression일 뿐이다. +- idempotency store가 unavailable한 keyed mutation은 fail-closed한다. + +### 8.3 receipt와 retention + +completion receipt에는 다음처럼 재생에 필요한 최소 정보만 둔다. + +```text +CompletionReceipt + operation + stableResultCode + objectRef/sessionRef + generation/revision + safeResponseFingerprint + completedAt +``` + +- replay response는 최초 성공과 의미가 같아야 한다. +- transient provider error 전체를 영구 replay하지 않는다. +- retention은 최대 client retry/session window를 포함하되 무제한이 아니다. +- row/byte hard cap, expiry index와 bounded prune를 필수로 둔다. +- 아직 replay 가능한 receipt를 storage pressure만으로 삭제하지 않는다. + +## 9. Quarantine과 scan + +### 9.1 격리 + +- staging/quarantine object는 public ACL과 CDN 배포를 금지한다. +- 일반 download capability issuer가 `QUARANTINED`를 읽지 못하게 한다. +- scanner principal은 exact quarantine read와 verdict write만 가진다. +- clean destination writer와 destructive delete 권한을 최소화한다. +- 원본 filename으로 provider path를 만들지 않는다. + +### 9.2 검사 pipeline + +```text +QUARANTINED object + exact generation/digest + -> durable scan outbox + -> scanner/validator + malware + MIME sniff + allowlisted parser + archive traversal/symlink/nesting/expanded-size ratio + image dimension/pixel budget + PDF/active content + optional CDR + -> bound verdict + -> CAS promotion or rejection +``` + +scanner가 clean이라고 반환해도 submit 당시와 object generation/digest가 다르면 +stale verdict로 폐기한다. + +### 9.3 promotion + +promotion은 provider별로 다음 중 하나다. + +- immutable quarantine object를 그대로 유지하고 registry access state만 + `AVAILABLE`로 CAS +- clean bucket/key로 server-side copy 후 length/digest/version을 재검증하고 binding + CAS + +copy+delete를 atomic rename으로 주장하지 않는다. crash 단계마다 source/destination +binding을 재검증하는 saga와 cleanup phase가 필요하다. availability가 commit되기 +전 source를 삭제하지 않는다. + +### 9.4 scanner 장애 + +- required scanner unavailable은 clean으로 degrade하지 않는다. +- retryable failure는 bounded backoff와 retry count/age ceiling을 가진다. +- terminal failure는 quarantine을 유지하고 operator/user recovery를 요구한다. +- scan process/container에는 wall-clock, CPU, memory, file count, recursion depth, + expanded bytes와 output bytes hard limit를 둔다. +- scan definition/profile freshness가 composition의 maximum age를 넘으면 이전 + `CLEAN` verdict로 promotion하지 않고 새 job/generation fence로 재검사한다. +- scan backlog가 SLO를 초과하면 신규 session 발급을 제한하거나 kill switch를 + 사용한다. +- scan timeout 이후에도 late verdict가 state를 바꾸지 못하게 generation/job + fence를 검증한다. + +## 10. Deadline, retry와 resource cleanup + +### 10.1 deadline budget + +각 public capability invocation은 절대 deadline 또는 남은 budget을 받는다. + +```text +request deadline + - authorization + - registry transaction + - provider call + - stream transfer + - verification + - response margin +``` + +하위 adapter가 각자 전체 timeout을 새로 시작해 총 시간이 무한히 늘어나서는 안 +된다. 남은 budget이 최소 provider timeout보다 작으면 side effect 전에 +`DEADLINE_EXCEEDED`로 종료한다. caller cancellation은 `CANCELLED`이고 server +deadline 소진은 `DEADLINE_EXCEEDED`다. 둘은 metric과 retry 판단에서도 합치지 +않는다. + +### 10.2 retry matrix + +공통 retry directive는 세 종류뿐이다. + +```text +NEVER +SAFE { afterMs? } +SAME_IDEMPOTENCY_KEY { afterMs? } +``` + +| operation | directive | 조건 | +| --- | --- | --- | +| immutable metadata/read open | `SAFE` | 같은 exact version과 남은 deadline | +| range read | `SAFE` | 같은 exact version/range와 아직 전달되지 않은 경계 | +| session create | `SAME_IDEMPOTENCY_KEY` | 같은 canonical fingerprint | +| put part | `SAME_IDEMPOTENCY_KEY` | 같은 key/part/length/digest | +| multipart complete | `SAME_IDEMPOTENCY_KEY` | stable receipt + inspect/reconcile | +| abort/delete | `SAME_IDEMPOTENCY_KEY` | exact state/version + inspect/reconcile | +| signed capability issue | `NEVER` | authorization/expiry 확인 후 새 operation으로 발급 | +| scan submit | `SAME_IDEMPOTENCY_KEY` | stable job ref + generation/digest binding | + +retry는 exponential backoff, full jitter, max attempts와 전체 deadline을 가진다. +rate limit/provider overload에서는 `Retry-After` 또는 provider-safe backoff hint를 +상한 안에서 반영한다. + +- retry owner는 ingress/application orchestration, adapter wrapper 또는 provider + SDK 중 정확히 한 계층이다. service mesh와 SDK의 숨은 retry는 끄거나 동일한 + total-attempt budget에 포함해 retry amplification을 막는다. +- mutation의 적용 여부가 `UNKNOWN`이면 새 idempotency key로 재시도하지 않는다. + 같은 key로 receipt를 조회하거나 provider/registry reconciliation을 먼저 한다. +- 이미 response byte를 client에 전달한 stream read는 처음부터 자동 재시작하지 + 않는다. resumable protocol이 명시된 경우에만 검증된 다음 range에서 재개한다. + +### 10.3 cleanup + +모든 adapter는 다음 자원을 명시적으로 종료한다. + +- input/output stream과 provider response body +- multipart writer/upload handle +- temporary file와 bounded buffer +- digest/scanner process stream +- database cursor/transaction/connection lease +- scheduled timeout/retry task +- lock/semaphore permit +- tracing span + +resource owner는 다음과 같은 idempotent lease 계약을 구현한다. + +```text +ResourceLease + state: OPEN | CLOSING | CLOSED + transferOwnership(newOwner) + close(reason, cleanupDeadline) -> CleanupReport +``` + +- ownership transfer는 명시적이며 transfer 뒤 이전 owner는 close하지 않는다. +- `close`는 여러 번 호출돼도 안전하고 첫 close reason과 cleanup 결과를 보존한다. +- request deadline과 별도로 짧고 bounded한 cleanup budget을 예약한다. 이 budget은 + client response deadline을 연장하지 않으며, 즉시 끝낼 수 없는 provider cleanup은 + durable reconciliation record로 넘긴다. +- finalizer/garbage collector는 correctness 경로가 아니라 마지막 누수 경보다. + +success, failure, cancellation, timeout, downstream disconnect와 exception 모든 경로를 +contract test한다. cleanup 자체의 실패가 최초 failure를 덮지 않으며 safe secondary +observation만 남긴다. + +### 10.4 shutdown + +- 신규 session/capability 발급 중지 +- in-flight admission 중지 +- bounded grace 동안 active stream drain +- 남은 stream cancel +- leased idempotency operation을 reclaim 가능 상태로 둠 +- multipart/scanner reconciliation checkpoint 저장 +- provider clients/executors close + +무기한 graceful shutdown을 허용하지 않는다. + +## 11. 공통 failure model + +### 11.1 closed failure + +```text +FileCapabilityFailure + code + operation + retry: NEVER + | SAFE { afterMs? } + | SAME_IDEMPOTENCY_KEY { afterMs? } + effect: NOT_APPLIED | APPLIED | UNKNOWN + recovery + safeReasonCode? + correlationId +``` + +권장 closed code: + +```text +INVALID_INPUT +UNAUTHENTICATED +FORBIDDEN +NOT_FOUND +CONFLICT +POLICY_REJECTED +LIMIT_EXCEEDED +PAYLOAD_TOO_LARGE +UNSUPPORTED_MEDIA_TYPE +INTEGRITY_FAILED +EXPIRED_RESOURCE +QUARANTINED +REJECTED +RATE_LIMITED +IDEMPOTENCY_KEY_REUSED +OPERATION_IN_PROGRESS +CANCELLED +DEADLINE_EXCEEDED +DEPENDENCY_UNAVAILABLE +CONTRACT_MISMATCH +RECOVERY_REQUIRED +CORRUPT_DATA +INTERNAL +``` + +권장 operation: + +```text +OBJECT_INSPECT +OBJECT_READ +OBJECT_WRITE +OBJECT_DELETE +MULTIPART_CREATE +MULTIPART_PART +MULTIPART_COMPLETE +MULTIPART_ABORT +CAPABILITY_ISSUE +IDEMPOTENCY +SCAN_SUBMIT +SCAN_INSPECT +OBJECT_PROMOTE +RECONCILE +``` + +`recovery`는 `RETRY`, `REAUTHORIZE`, `RESTART_SESSION`, `REOPEN`, +`READ_ONLY`, `SUPPORT`, `NONE` 같은 allowlist다. + +- `NOT_APPLIED`는 side effect가 시작되지 않았음이 증명된 경우만 사용한다. +- `APPLIED`는 durable receipt로 effect를 증명할 수 있는 경우다. +- response loss, provider timeout 또는 process crash로 확정할 수 없으면 + `UNKNOWN`이며, caller에게 성공이나 안전한 신규 요청을 암시하지 않는다. +- `SAFE`는 read 또는 side effect 전 실패에만 사용한다. + `SAME_IDEMPOTENCY_KEY`는 key/fingerprint/receipt 계약이 갖춰진 mutation에만 + 사용한다. 단순 `retryable: true`는 허용하지 않는다. + +### 11.2 mapping + +- provider status/exception class를 application failure로 한 곳에서 mapping한다. +- raw SDK exception, request ID, bucket/key, endpoint와 provider body를 port 밖으로 + throw하지 않는다. +- unknown provider failure는 `INTERNAL` 또는 `DEPENDENCY_UNAVAILABLE` 중 사전에 + 정한 fail-closed mapping을 사용하고 effect는 보수적으로 `UNKNOWN`으로 둔다. +- authorization과 object existence를 노출하면 안 되는 API는 `FORBIDDEN`과 + `NOT_FOUND` 외부 표현을 동일하게 만들 수 있다. +- HTTP status는 inbound adapter가 failure code에서 mapping하며 domain/application이 + HTTP status를 반환하지 않는다. +- retry directive와 effect certainty는 provider message 문자열이 아니라 typed + mapping, operation semantics, idempotency receipt와 reconciliation evidence로 + 결정한다. + +### 11.3 safe observability + +다음을 기록하지 않는다. + +- object/session/idempotency reference 원문 +- filename과 user/account ID +- provider locator, bucket/key/version +- signed URL/query/header +- checksum 원문 +- scanner raw result +- request/response body와 exception message/stack + +허용 가능한 metric dimension: + +- provider profile ID +- operation +- stable failure code +- byte/part/latency bucket +- transfer mode proxy/direct +- state transition +- scan profile와 safe verdict class +- retry count bucket +- retry directive와 effect certainty + +high-cardinality identifier를 metric label로 사용하지 않는다. + +## 12. Provider contract test + +### 12.1 한 suite, 여러 adapter + +각 port는 provider-neutral contract suite factory를 제공한다. + +```text +objectByteStoreContract(createProviderFixture) +multipartStagingContract(createProviderFixture) +signedCapabilityContract(createProviderFixture) +rangeDownloadContract(createProviderFixture) +idempotencyStoreContract(createProviderFixture) +contentScannerContract(createProviderFixture) +objectRegistryContract(createProviderFixture) +cleanPromotionContract(createProviderFixture) +imageDescriptorContract(createProviderFixture) +``` + +동일 suite를 in-memory fake, emulator와 실제 provider adapter에 실행하되 결과 +등급을 섞지 않는다. + +- in-memory fake: orchestration 개발과 빠른 invariant 회귀 +- emulator/container: SDK wiring과 local integration +- actual provider conformance: 선택한 provider product/API/version의 격리된 + production-like account/region에서 실행한 promotion evidence + +fake나 emulator 통과는 actual provider conformance를 대체하지 않는다. actual +provider에서 destructive/fault test를 실행할 수 없다면 누락 항목, 보완 통제, +승인 owner와 expiry가 있는 명시적 waiver가 필요하며 자동으로 “동등”하다고 +간주하지 않는다. + +### 12.2 object store matrix + +- zero-byte와 boundary-size object +- exact bytes/media/version round trip +- range beginning/middle/end/invalid/empty +- declared length 미달·초과 +- checksum mismatch +- provider metadata/ETag와 digest 구분 +- mid-stream read/write failure +- cancellation/downstream disconnect +- deadline before call/during stream/after provider commit +- concurrent exact-version write/delete +- response loss 뒤 inspect/reconcile +- resource close exactly once +- object locator escaping/path traversal 거절 + +### 12.3 multipart matrix + +- create/put/inspect/complete 정상 흐름 +- minimum/maximum part size와 count +- duplicate part same fingerprint replay +- duplicate part different fingerprint conflict +- missing/duplicate/out-of-order receipt +- complete/abort/expiry races +- complete response loss와 recovery +- part success response loss와 inspect +- orphan multipart cleanup +- provider complete ETag가 whole SHA-256이 아님을 검증 +- direct signed PUT의 required length/checksum header 강제 + +### 12.4 idempotency matrix + +- 동시 100개 begin에서 정확히 하나만 `STARTED` +- 같은 fingerprint의 `IN_PROGRESS`와 stable replay +- 다른 fingerprint conflict +- lease expiry/reclaim +- crash between durable effect and complete +- retryable release +- receipt expiry와 bounded prune +- count/byte cap +- tenant/scope/operation key isolation +- clock skew/invalid expiry fail-closed + +### 12.5 capability matrix + +- wrong method/object/part/range/header 거절 +- expiry/not-before +- TTL hard ceiling +- modified query/path/host 거절 +- wrong audience/subject/session +- key rotation current/previous/expired +- redirect/CORS/header exposure policy +- token/query redaction +- direct provider가 signed constraint를 실제로 강제하는지 확인 + +### 12.6 scanner matrix + +- clean/rejected/timeout/unavailable +- corrupt/truncated input +- stale generation/digest verdict 폐기 +- duplicate submission replay +- archive path traversal/nesting/expanded-size limit +- scanner crash와 process/resource cleanup +- late verdict after cancellation/timeout +- scan backlog admission control +- clean verdict 전 download/promotion 불가 + +### 12.7 Range download matrix + +- exact `RANGE_RESUMABLE_DOWNLOAD_V1`과 unknown/missing version 거절 +- immutable generation/strong validator/total length/media/full digest binding +- beginning/middle/final segment의 exact `206 Content-Range` +- `If-Range` match와 mismatch의 `206`/full `200` +- 별도 generation precondition의 `412` +- before-start/at-end/beyond-end `416`과 authoritative total +- capability expiry/reissue의 same-binding 유지 +- generation replacement 뒤 old partial 이어 쓰기 거절 +- encoded/transform response와 redirect 거절 +- direct provider와 BFF relay가 같은 constraint를 실제로 강제 + +### 12.8 Image descriptor/CDN matrix + +- exact `IMAGE_CDN_DESCRIPTOR_V1`과 unknown/missing field/version 거절 +- authorization/existence hiding/quarantine 상태 +- immutable asset revision과 preset binding exact recomputation +- arbitrary source/transform/query 거절 +- old/new key overlap, signer cutover와 old key drain +- expiry/reissue/emergency revoke +- public immutable/private no-store cache header +- cross-origin CORS/CSP와 ambient credential 비의존 +- malformed/animated/oversize rendition 거절 +- asset/preset mismatch와 provider response loss + +### 12.9 fault injection과 evidence + +- provider latency, throttle, connection reset, partial response +- database deadlock/serialization retry +- registry commit 전후 process termination +- provider commit 뒤 response loss +- queue duplicate/out-of-order +- scanner unavailable와 slow verdict +- clock movement은 wall clock/monotonic clock 책임에 맞춰 주입 + +CI가 만드는 immutable conformance evidence에는 최소 다음을 포함한다. + +```text +ProviderConformanceEvidence + adapterName/version/artifactDigest + providerProduct/apiVersion/runtimeVersion + environmentClass/region + capabilityProfileDigest + contractSuiteName/version/artifactDigest + startedAt/completedAt + passed/failed/skipped counts + faultSuiteResult + waiverIds[] + evidenceExpiresAt + ciRunIdentity/signature +``` + +실제 provider evidence와 fake/emulator result는 별도 artifact로 보관한다. 필수 +test skip, expired evidence, adapter/provider/config 변경 또는 contract suite +version 불일치는 promotion을 막는다. 최소 정기 schedule과 provider/SDK upgrade, +capability/config 변경 시 actual provider conformance를 다시 실행한다. + +## 13. 선택적 composition + +### 13.1 서로 독립적인 상태 축 + +source catalog 상태, runtime 설치, 현재 health와 traffic admission을 하나의 +`ENABLED` boolean으로 합치지 않는다. + +```text +InstallationState + NOT_SELECTED | INSTALLED | REMOVING + +RuntimeState + UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE + +TrafficAdmission + DISABLED | SHADOW | CANARY | ENABLED +``` + +이 문서의 목표인 `AVAILABLE_NOT_COMPOSED`는 source catalog에 port, adapter, +contract suite와 문서가 있지만 runtime은 `NOT_SELECTED`, traffic은 `DISABLED`인 +상태다. provider client/route/job/migration을 만들지 않으므로 `RuntimeState`도 +평가하지 않는다. + +`INSTALLED`는 dependency와 immutable policy가 composition되었다는 뜻일 뿐, +provider가 현재 `AVAILABLE`하거나 traffic이 `ENABLED`라는 뜻이 아니다. +`INCOMPATIBLE`은 contract/API/capability 불일치이며 fail-closed한다. + +### 13.2 composition root + +선택 시 composition root만 concrete provider를 안다. + +```text +ServerFileCapabilityComposition + objectByteStore + multipartStaging + objectRegistry + idempotencyStore + signedTransferCapability + contentScanner + cleanPromotion + clock + secureRandom + deadlinePolicy + retryPolicy + transferPolicyRegistry + safeObserver +``` + +composition 시 다음을 검증하고 immutable snapshot으로 고정한다. + +- provider capability와 요구 기능 일치 +- hard byte/part/TTL/retry/deadline ceiling +- registry와 provider profile binding +- signing key/audience +- scanner profile +- cleanup owner와 schedule +- idempotency retention/capacity +- metric/trace redaction policy + +선택된 필수 dependency가 없거나 capability가 부족하면 startup 또는 feature +installation을 fail-closed한다. unavailable provider를 fake로 바꾸어 production을 +계속하지 않는다. + +### 13.3 capability probe와 readiness + +probe는 다음 판단을 분리해 기록한다. + +| probe | 답하는 질문 | +| --- | --- | +| static config | 필수 값, hard ceiling과 profile schema가 유효한가 | +| connectivity | DNS/TLS/network endpoint에 bounded하게 도달하는가 | +| credential/entitlement | 최소 권한 principal이 필요한 operation을 허용받는가 | +| compatibility | provider API/version/capability가 승인 contract와 일치하는가 | +| operational health | 현재 latency/error/throttle가 admission SLO 안인가 | + +- probe는 짧은 timeout, cancellation과 bounded retry를 사용하고 동시 요청은 + single-flight로 합친다. +- cached result에는 TTL와 jitter를 두며 + `state, observedAt, validUntil, adapterVersion, contractVersion, safeReasonCode` + 외 token, endpoint body, locator와 credential을 포함하지 않는다. +- probe를 매 product request의 authorization 또는 correctness check로 사용하지 + 않는다. request는 여전히 exact registry state, generation과 capability를 + 검증한다. +- last-known-good는 짧은 grace의 운영 신호일 뿐 expiry 뒤 authority가 아니다. + stale result는 `UNKNOWN`으로 낮춘다. +- read/write probe가 side effect를 만들면 별도의 owned canary namespace와 exact + cleanup receipt를 사용한다. 일반 customer object를 probe하지 않는다. +- `DISABLED/SHADOW`인 optional capability 장애는 base server readiness를 깨지 + 않는다. `ENABLED`이고 해당 제품 경로의 필수 dependency이면 feature readiness와 + admission을 fail-closed한다. boot-blocking 여부는 composition policy로 명시한다. + +`INSTALLED -> CANARY` promotion에는 유효한 actual-provider conformance evidence, +owner, policy snapshot, SLO/alert, runbook, cleanup drill과 rollback/kill switch가 +필요하다. + +### 13.4 미선택 상태 + +`AVAILABLE_NOT_COMPOSED`에서는 다음을 금지한다. + +- public HTTP route/controller registration +- provider SDK client 초기화와 credential 요구 +- database table/migration 자동 생성 +- bucket/container 생성 +- cleanup/scan worker와 scheduler 시작 +- health/readiness 필수 dependency 등록 +- product module이 concrete adapter를 직접 import +- provider endpoint로의 network/DNS 호출과 background capability probe + +server template core는 file capability 디렉터리를 완전히 제거해도 build, base test, +application startup과 artifact 생성이 통과해야 한다. + +### 13.5 rollout + +1. 제품 use case와 owner 결정 +2. 분류, size, retention, scan, fallback 정책 승인 +3. provider와 deployment profile 선택 +4. actual provider contract/fault test와 evidence 생성 +5. `INSTALLED + UNKNOWN + DISABLED`로 composition +6. bounded operator probe 후 `SHADOW` +7. 내부 cohort proxy transfer를 `CANARY` +8. direct signed transfer가 필요하면 별도 canary +9. quarantine/scan backlog, reconciliation과 cleanup drill +10. SLO/alert/kill switch/rollback 확인 후 `ENABLED`로 점진 확대 + +rollout 중 evidence expiry, `INCOMPATIBLE`, integrity failure 또는 cleanup 불능은 +자동 promotion을 중단한다. rollback은 code rollback뿐 아니라 +`TrafficAdmission=DISABLED`, credential revoke와 worker drain 절차를 포함한다. + +### 13.6 kill switch + +- 신규 upload session 발급 off +- direct signed upload off → bounded proxy 또는 전체 off +- multipart off → 승인된 small simple upload +- 신규 download capability off +- scanner promotion off, quarantine 유지 +- provider write off, read-only +- background reconciliation batch 축소/정지 + +kill switch가 기존 object를 자동 삭제하거나 quarantine을 available로 만들면 안 된다. + +## 14. 제거 가능성 + +### 14.1 제거 순서 + +1. `InstallationState=REMOVING`, `TrafficAdmission=DISABLED`로 전환하고 신규 + session/capability와 product write를 중지한다. +2. in-flight operation을 bounded drain/cancel한다. +3. `OPEN`, `COMPLETE_REQUESTED`, `PROVIDER_COMMITTED`, + `QUARANTINE_RECORDED`, `ABORT_REQUESTED` session을 reconcile한다. +4. quarantine/scan/reconciliation backlog와 orphan inventory를 0 또는 승인된 + handoff 상태로 만든다. +5. `AVAILABLE` object의 새 provider/use case 이전과 read cutover를 완료한다. +6. route/controller, consumer와 product composition을 제거한다. +7. cleanup/scanner workers, queue subscription, webhook, scheduler와 provider + lifecycle rule을 중지·제거한다. +8. provider-owned IAM principal, signing key, credential와 secret을 revoke하고 + config/SDK dependency를 제거한다. +9. alert/dashboard/SLO, runbook, on-call ownership과 비용 budget을 제거하거나 새 + owner에게 명시적으로 이관한다. +10. owned registry/idempotency rows와 provider objects는 별도 승인된 data + migration으로 제거한다. +11. architecture, contract, startup, network와 dependency inventory gate를 + 재실행한다. + +data 삭제를 code removal과 같은 단계에서 암묵적으로 실행하지 않는다. +일반 object 삭제도 logical tombstone/CAS와 exact provider generation purge를 +분리하고 durable purge receipt를 남긴다. retention/legal hold가 있으면 physical +purge보다 우선하며 provider `NOT_FOUND`만으로 삭제 완료를 추정하지 않는다. + +### 14.2 제거 gate + +server 구현 저장소에는 격리 copy 또는 build profile에서 다음을 자동 검증하는 +removal test를 둔다. + +- file capability source와 provider dependency 제거 +- file 전용 route/job/config/migration 제거 +- 남은 source import 0개 +- base type/compile/lint/unit/integration 통과 +- application startup 통과 +- dependency/SBOM에 provider SDK 부재 +- generated API/schema에 file endpoint 부재 +- 기본 health/readiness가 file provider를 요구하지 않음 +- startup과 idle 기간 provider DNS/network 호출 0건 +- secret/IAM/queue/webhook/scheduler/IaC inventory에 orphan 0건 +- provider object와 database data는 삭제 완료 또는 새 owner에게 이관됐다는 + 별도 signed inventory 존재 + +## 15. Frontend와의 향후 계약 + +현재 frontend runtime과 연결할 때 control-plane API는 최소 다음 의미를 제공해야 +한다. 실제 endpoint/DTO는 제품 feature가 소유한다. + +whole-object presigned control envelope의 목표 protocol은 +`PRESIGNED_TRANSFER_V1`, Image descriptor envelope은 +`IMAGE_CDN_DESCRIPTOR_V1`, Range resume는 +`RANGE_RESUMABLE_DOWNLOAD_V1`이다. 현재 frontend/server template에 이 세 +control plane이 모두 구현됐다는 뜻은 아니다. BFF는 unknown/missing version을 +fail-closed하고 N-1 client drain/rollback window를 명시해야 한다. + +### 15.1 browser-managed download + +server가 발급하는 capability는 frontend +`BrowserManagedDownloadCapability`와 다음 의미가 일치해야 한다. + +```text +receipt +href +resourceId/ObjectRef +mediaType +safeExtension +maxBytes +expectedSha256? +expiresAt +``` + +endpoint는 capability가 주장한 max bytes, object generation, digest와 expiry를 +실제 response에 enforce한다. frontend의 `BROWSER_HANDOFF`는 disk save 완료가 +아니다. + +### 15.2 authorized stream download + +제품 연결 전 frontend의 `AUTHORIZED_STREAM_RESOURCE`는 단순 `resourceId`보다 +좁은 server-issued capability를 받도록 확장해야 한다. + +```text +AuthorizedStreamCapability + receipt + objectRef + generation + exactLength + expectedDigest + mediaType + expiresAt +``` + +stream opener는 이 capability를 검증해 exact provider/server response를 +`FileByteSource`로 변환한다. + +### 15.3 multipart upload + +frontend feature가 필요한 최소 의미: + +```text +protocol = PRESIGNED_MULTIPART_V1 +create -> protocol, sessionRef, request binding, part constraints, expiry, + SHA-256-PARTS-V1 fingerprint +put/sign part -> protocol/session, request + upload binding, part number, + offset, exact length, digest, idempotency key +complete -> protocol/session + ordered verified receipts -> QUARANTINED +abort -> protocol/session -> stable terminal result +status -> protocol/session -> ACTIVE | QUARANTINED | ABORTED | EXPIRED | NOT_FOUND +``` + +browser control adapter는 `CREATE_SESSION`, `GET_STATUS`, `COMPLETE`, `ABORT`의 +closed operation set을 composition-owned fixed HTTPS endpoint map으로 실행한다. +part/download capability 발급 adapter도 composition 시 fixed BFF endpoint를 한 +번만 받는다. server route가 어떤 path를 선택하든 caller가 endpoint를 URL로 +전달하거나 operation을 추가할 수 없는 closed contract를 유지한다. + +canonical binding은 UTF-8 newline-separated field sequence의 SHA-256 lowercase +hex다. + +```text +fingerprint digest fields: + SHA-256-PARTS-V1 + fingerprint.byteLength + fingerprint.partSizeBytes + fingerprint.partCount + {partNumber}:{offset}:{byteLength}:{checksumSha256} for each ordered part + +request binding fields: + RESUMABLE-UPLOAD-BINDING-V1 + uploadKey, purpose, mediaType + fingerprint.algorithm, fingerprint.digestHex + fingerprint.byteLength, fingerprint.partSizeBytes, fingerprint.partCount + +upload session binding fields: + RESUMABLE-UPLOAD-SESSION-BINDING-V1 + requestBindingSha256, sessionId + fingerprint.algorithm, fingerprint.digestHex + fingerprint.byteLength, fingerprint.partSizeBytes, fingerprint.partCount +``` + +각 field/part line은 UTF-8 newline 하나로 연결하고 trailing newline을 붙이지 +않는다. server는 complete 때 ledger의 ordered part set으로 fingerprint digest를 +재계산한다. + +BFF는 part capability 요청의 `sessionId`로 registry row와 immutable session +snapshot을 조회해 위 값을 다시 만든다. client-provided digest는 비교 입력이지 +authorization, ownership, current state 또는 part eligibility를 증명하지 않는다. +`UPLOAD_PART` capability binding에도 exact +`protocol: PRESIGNED_MULTIPART_V1`을 포함하고 subject scope, expiry, protocol, +exact part plan과 idempotency를 별도로 검증한다. + +PUT capability는 expected success status, receipt response header와 +`expectedResponseByteLength`를 binding한다. object store/proxy는 exact +`Content-Length`를 보내야 한다. 단 204는 response bytes가 0이어야 하고 header +부재를 0으로 정규화한다. frontend는 hard cap과 transfer deadline 안에서 response +body를 EOF까지 drain한 뒤 receipt를 채택하므로 CORS는 custom receipt header를 +명시적으로 expose해야 한다. + +status의 HTTP 404/410은 각각 `NOT_FOUND`/`EXPIRED_RESOURCE` terminal 의미다. +frontend는 stale checkpoint를 CAS 제거하고 restart한다. network/429/모든 5xx는 +server가 명시한 bounded `Retry-After`와 frontend attempt/deadline ceiling 안에서만 +재시도된다. complete 내부 DTO는 session/fingerprint binding을 검증하지만 +application-facing `QUARANTINED` 결과에는 state, opaque resource reference, +byte length와 replay 여부만 노출한다. + +`AVAILABLE` 전에는 public URL, normal download capability나 active-content preview를 +발급하지 않는다. + +### 15.4 Range resumable download + +Range capability는 whole-object download URL을 재사용하는 편의 header가 아니라 +별도 `RANGE_RESUMABLE_DOWNLOAD_V1` control contract다. + +server registry는 logical resource를 다음 immutable representation에 binding한다. + +```text +representationBinding + objectRef + immutable generation + strong validator + exact total byte length + media type + whole-object SHA-256 +``` + +- weak validator, multipart ETag의 digest 추정, last-modified나 file name을 + representation identity로 사용하지 않는다. +- object provider의 version ID가 안정적이면 registry generation과 exact + provider locator를 server 내부에서 binding한다. 그렇지 않으면 BFF proxy가 + immutable generation을 enforce한다. +- control response가 data-plane URL과 `If-Range` validator를 전달하더라도 raw + 값은 adapter-owned in-memory vault에만 있고 application/checkpoint에는 + representation binding digest만 남는다. +- capability는 exact `Range: bytes=start-end`, + `preconditionMode=STRONG_IF_RANGE | IMMUTABLE_GENERATION_PRECONDITION`, mode가 + 정한 exact header/value, `allowWholeObjectFallback`, policy-derived allowed + status subset, expected total/segment bytes, expiry와 single-use receipt를 묶는다. +- BFF/object provider가 이 제약을 실제로 강제하지 못하면 direct signed URL을 + 사용하지 않고 BFF relay를 사용한다. + +data-plane 의미: + +| 응답 | server 의미 | frontend 처리 계약 | +| --- | --- | --- | +| `206` | 같은 representation의 exact requested segment | exact `Content-Range`, length와 validator 검증 뒤 write | +| `200` | byte 0 full representation 또는 `If-Range` mismatch | 기존 partial에 append 금지, 새 generation/restart reconcile | +| `412` | 별도 `If-Match`/generation precondition 실패 | representation replacement로 checkpoint 격리 | +| `416` | requested range가 current representation에 유효하지 않음 | 자동 success 금지, total/destination/full digest 재검증 | + +RFC 9110의 일반 `If-Range` mismatch는 `200` full response다. `412`를 받으려면 +제품 계약이 별도 strong precondition을 명시해야 한다. + +capability expiry 시 BFF는 current authorization과 registry를 다시 확인하고 +같은 representation binding에 대해서만 새 segment capability를 발급한다. +object generation/length/media/digest가 달라지면 기존 partial resume를 거절한다. +final 성공은 frontend destination 전체 SHA-256 검증 뒤에만 가능하지만, server도 +download capability가 주장한 digest/length가 registry의 authoritative object와 +일치하도록 보장한다. + +Range checkpoint retention과 seek/truncate 또는 OPFS staging은 browser local +계약이다. server는 raw local path/offset을 authorization proof로 받지 않는다. + +### 15.5 Upload pause와 checkpoint lifecycle + +pause는 기본적으로 browser work를 중지하는 local control이며 server abort가 +아니다. 제품이 explicit server pause를 만들지 않더라도 status/reconcile은 +paused client가 안전하게 돌아올 수 있도록 다음을 보장한다. + +- session expiry와 terminal status의 안정적인 의미 +- completed part의 ordered checksum과 bounded non-authorizing receipt +- list/status pagination 또는 절대 part-count ceiling +- 같은 idempotency key의 replay 결과 +- abort/complete response loss 뒤 authoritative reconcile +- abandoned session TTL과 orphan janitor + +frontend checkpoint inventory/retention API에는 presigned URL, server capability, +raw provider upload ID와 object key가 나타나지 않는다. checkpoint sweep가 local +row를 지웠다고 server session이 즉시 abort됐다고 가정하지 않으며 janitor SLO로 +ambiguous orphan을 정리한다. + +### 15.6 Image CDN delivery + +backend asset registry는 scan/promotion을 통과한 immutable object generation만 +opaque asset ID/revision에 binding한다. descriptor에는 registry-owned named +preset, static raster media type, exact natural/rendition dimensions, delivery +class와 private capability expiry를 포함한다. arbitrary external source URL 또는 +caller transform query를 signer/CDN에 전달하지 않는다. + +private capability가 서명하는 allowed preset binding ID는 versioned +server-owned immutable preset registry의 key다. CDN/BFF는 각 요청마다 이 ID를 +조회하고 width/height/DPR/fit/format/quality query를 registry의 exact candidate와 +재계산해 불일치를 거절한다. client query, binding digest 또는 signature의 단순 +존재는 transform authorization이 아니다. + +public rendition은 revisioned URL과 `public, max-age=..., immutable`, private +rendition은 short-lived capability와 실제 `Cache-Control: no-store`를 제공한다. +private response도 CORS에서 browser probe가 읽을 `Content-Type`, +`Content-Length`, `Cache-Control`을 허용해야 하며 credential cookie에 의존하지 +않는다. CDN은 application과 다른 HTTPS origin에서 제공한다. 이는 +``의 same-origin credential mode가 application +cookie를 보내는 경로를 차단하기 위한 배포 계약이다. frontend는 +PNG/JPEG/WebP/AVIF static header metadata와 +pixel/decoded-byte budget을 native decode 전에 검증하고, private delivery는 +mandatory probe와 fetch/body/decode 전체 timeout을 적용한다. + +private descriptor BFF는 fixed endpoint에서 +`IMAGE_CDN_DESCRIPTOR_V1` strict bounded response를 반환한다. caller는 opaque +asset/preset reference만 제출하고 URL, origin, width, DPR, format, quality, fit, +cache policy와 key ID를 선택하지 않는다. + +descriptor 발급과 refresh는 매번 current authorization, promotion state, immutable +asset revision, preset registry와 signing key registry를 다시 확인한다. expiry +뒤 stale descriptor 사용을 허용하지 않는다. key rotation은 bounded old/new +verification overlap, signer 전환, maximum descriptor lifetime + clock skew + +client rollout drain 이후 old key 제거 순서다. emergency revoke는 client +signature/expiry만 기다리지 않고 registry/CDN/BFF에서 capability를 거절한다. + +generic Query cache나 durable client persistence가 private descriptor URL의 +lifetime owner가 아니다. account/logout 뒤 늦은 refresh가 성공하더라도 frontend +runtime generation fence가 이를 채택하지 않으며, backend authorization도 old +session을 거절해야 한다. + +### 15.7 failure mapping + +server의 closed failure를 frontend의 allowlisted failure로 한 곳에서 mapping한다. +HTTP status/message를 application에 그대로 전달하지 않는다. + +| server meaning | frontend meaning 예 | +| --- | --- | +| invalid request | `INVALID_INPUT` | +| size/part ceiling | `LIMIT_EXCEEDED` | +| revision/idempotency conflict | `CONFLICT` | +| checksum/length mismatch | `INTEGRITY_FAILED` | +| expired session/capability | `EXPIRED_RESOURCE` | +| denied policy/authorization | `POLICY_REJECTED` 또는 auth failure | +| provider/scanner temporary failure | `DEPENDENCY_UNAVAILABLE` + server retry directive | +| caller cancellation | `CANCELLED` | +| server deadline exhausted | `DEADLINE_EXCEEDED` | +| mutation outcome unknown | 같은 idempotency key로 status/reconcile, 신규 요청 금지 | + +## 16. 제품 선택 시 결정할 항목 + +다음 표가 채워지기 전에는 endpoint와 composition을 만들지 않는다. + +| 결정 | owner | +| --- | --- | +| 파일의 업무 목적과 aggregate 관계 | product/domain | +| authorization와 existence-hiding 정책 | security/domain | +| provider/region/data residency | platform/security | +| proxy/direct transfer 선택 | platform/product | +| object/part 최대 크기와 concurrency | performance/platform | +| session/capability TTL | security/product | +| checksum algorithm과 encoding | platform | +| MIME/parser/archive/CDR/scan profile | security/product | +| quarantine/available/rejected UX | product | +| retention/legal hold/delete | domain/legal | +| idempotency replay window/capacity | platform/product | +| provider/scanner SLO와 fallback | operations/product | +| logging, audit와 privacy retention | security/operations | +| rollout, kill switch와 removal owner | operations | + +## 17. Server 구현 순서 + +1. 공통 identifier, `Result`와 closed failure model +2. in-memory deterministic fakes와 provider contract suite +3. `ObjectRegistryPort`와 `IdempotencyStorePort` +4. 첫 database adapter 및 concurrency/fault evidence +5. `ObjectByteStorePort`와 첫 object provider adapter +6. streaming counter/digest/deadline/resource wrappers +7. `MultipartStagingPort`와 orphan reconciliation +8. `SignedTransferCapabilityPort`와 expiry/key-rotation tests +9. optional Range capability와 immutable-generation/provider contract +10. `ContentScannerPort`, quarantine와 promotion saga +11. optional Image descriptor/preset/signing control plane +12. optional composition/removal gate +13. server template에서는 `AVAILABLE_NOT_COMPOSED`로 종료 +14. 실제 제품에서만 domain/use case/controller와 provider composition 추가 + +## 18. 완료 기준 + +- 모든 요청 항목에 port, 불변조건, failure와 lifecycle이 정의됨 +- native provider SDK type/exception이 port 밖으로 유출되지 않음 +- streaming 경로에 whole-buffer 기본 구현이 없음 +- byte length와 digest authority가 proxy/direct 경로별로 명확함 +- multipart complete/abort/expiry/response-loss race가 결정적임 +- signed capability가 exact operation/resource/limit/expiry에 binding됨 +- Range를 선택한 경우 immutable representation, exact + `200/206/412/416`, reissue와 provider constraint가 같은 contract suite를 통과 +- idempotency가 concurrent replay와 fingerprint mismatch를 처리함 +- scan verdict가 exact generation/digest에 binding됨 +- Image CDN을 선택한 경우 `IMAGE_CDN_DESCRIPTOR_V1`, preset exact recomputation, + key rotation/revocation과 private no-store가 실제 BFF/CDN에서 검증됨 +- deadline/retry/cancel 모든 경로에서 resource cleanup이 검증됨 +- 동일 provider contract suite가 fake와 실제 선택 provider에 실행됨 +- 미선택 상태에 route/job/migration/provider client가 없음 +- capability 전체 제거 후 server template core가 정상 동작함 +- 실제 제품 domain/use case와 provider 선택이 이 문서에 하드코딩되지 않음 + +## 19. 관련 frontend 설계 + +- `docs/architecture/browser-data-capability-completion-ledger.md` +- `docs/architecture/browser-file-and-origin-storage.md` +- `docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md` +- `docs/architecture/presigned-transfer-and-image-cdn.md` +- `docs/architecture/decisions/VD-12-presigned-transfer-and-image-cdn.md` +- `docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md` +- `docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md` +- `docs/architecture/frontend-ports-adapters-and-boundaries.md` +- `docs/architecture/optional-adapter-recipes.md` +- `docs/operations/browser-file-storage-recovery.md` +- `docs/operations/browser-transfer-recovery.md` + +## 20. 참고 표준과 보안 가이드 + +- RFC 9110, HTTP Semantics: +- RFC 9530, Digest Fields: +- OWASP File Upload Cheat Sheet: + diff --git a/docs/architecture/starter-experience.md b/docs/architecture/starter-experience.md new file mode 100644 index 0000000..fb54896 --- /dev/null +++ b/docs/architecture/starter-experience.md @@ -0,0 +1,79 @@ +# Starter experience contract + +The repository provides a runnable, domain-neutral application rather than +only infrastructure contracts. The starter experience is intentionally +replaceable at the page level while the shell, providers, boundaries, +primitives, and state surfaces remain reusable. + +## Runtime composition + +```text +validated config + coherent release manifest + -> concrete adapters + -> QueryClientProvider + -> ApplicationProvider + -> RouterProvider + -> ThemeProvider + -> SessionProvider + -> AppShell + -> lazy route boundary +``` + +Boot stops before product mount when configuration or release coherence fails. +Each lazy page renders inside Suspense and a telemetry-aware route boundary. +Route changes move focus to the new page heading; the skip link and landmarks +remain stable in the shell. + +## Registered routes + +| ID | Path | Access | Surface | +| --- | --- | --- | --- | +| `APP_HOME` | `/` | public | readiness dashboard | +| `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens | +| `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix | +| `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls | +| `REFERENCE_RESOURCE_LIST` | `/examples/reference-resources` | session-required | removable vertical slice | +| `NOT_FOUND` | `*` | public | safe navigation recovery | + +Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not +maintain a second route list. The client access decision never claims to be +authorization. + +## Authentication seam + +The session port exposes state subscription, sign-in start, sign-out, +credential attachment, recovery, and unauthenticated notification. Credentials +remain opaque to the application and presentation layers. + +- `demo`: local/development-only state transition with no credentials +- `external`: delegates to `globalThis.__CA_FRONTEND_AUTH_OWNER__` +- missing/invalid external owner: fails closed as `integration-failed` + +An external owner implements `readState`, `subscribe`, `beginSignIn`, +`signOut`, `attachCredential`, `recoverSession`, and +`notifyUnauthenticated`. It owns token acquisition and storage. + +## Extending the starter + +The steps below describe the current extension path. New platform work should follow +[routing, page templates, and reusable patterns](./routing-pages-and-patterns.md) +and the +[TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md) +rather than adding another independent route or data-loading convention. + +1. Add a serializable contribution under the feature ownership boundary and + install it through `src/features/installed-feature-contracts.ts`. +2. Add the lazy component and route codecs through + `src/features/installed-feature-runtimes.tsx`. +3. Compose feature application inputs and outbound gateways only through + `src/features/installed-feature-adapters.ts`. +4. Use the semantic tokens, UI primitives, and state surfaces before adding a + project-specific variant. +5. Add component behavior, all-engine E2E, automated axe, and signed manual + route evidence. +6. Run `test:sample-removal` to prove the generic starter typechecks, passes + architecture/registry/tests/coverage/source-evidence/home smoke, and builds + without the complete reference feature. + +Theme preference is the public `COLOR_SCHEME` storage contract. Authentication +tokens and other secrets remain forbidden storage keys. diff --git a/docs/architecture/static-delivery.md b/docs/architecture/static-delivery.md new file mode 100644 index 0000000..022787c --- /dev/null +++ b/docs/architecture/static-delivery.md @@ -0,0 +1,33 @@ +# Static asset and runtime-config delivery + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +This Mermaid view is a repository-local implementation projection. The +`PASS_SCOPED` reviewer evidence applies only to the canonical static-delivery +draw.io scope recorded in `review-ledger.json`. + +```mermaid +sequenceDiagram + participant CI + participant ImmutableRelease + participant ActivePointer + participant Browser + CI->>ImmutableRelease: upload hashed assets + CI->>ImmutableRelease: upload release manifest + CI->>ImmutableRelease: upload runtime config + CI->>ImmutableRelease: probe asset reachability + CI->>ActivePointer: atomically switch HTML + Browser->>ActivePointer: fetch revalidated HTML + Browser->>ImmutableRelease: fetch no-store config and manifest + Browser->>ImmutableRelease: fetch immutable hashed assets + CI->>Browser: boot, route, API, and reload-loop smoke +``` + +Rollback changes the active pointer only after confirming that the prior +immutable release has a coherent HTML/assets/config/API/manifest tuple. diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md new file mode 100644 index 0000000..32fa998 --- /dev/null +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -0,0 +1,689 @@ +# TypeScript, 상태 소유권, 데이터 흐름 + +> **정본 안내 (non-authoritative for runtime capability decisions)** +> +> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker, +> Service Worker, offline command와 Background Sync의 구현 결정은 +> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다. +> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다. + + +## 1. 목적 + +이 문서는 다음 질문에 대한 저장소 표준을 정의한다. + +- TypeScript-only 경계를 어떻게 유지하고 JavaScript 재유입을 막는가. +- local, URL, server, form, global, persisted 상태를 어디에 둬야 하는가. +- React 화면이 application use case와 TanStack Query를 어떻게 사용해야 하는가. +- HTTP, retry, auth, error, validation, logging의 책임을 어떻게 나누는가. +- 새 query, mutation, form을 추가할 때 어떤 파일과 테스트가 필요한가. + +이 문서는 현재 구현된 TypeScript 경계와 상태/데이터 흐름의 저장소 표준이다. +세부 역량의 구현 상태는 +[프론트엔드 플랫폼 역량 재검토](./frontend-platform-capability-review.md)를 +따른다. memory query cache, Web Storage와 탭 간 invalidation의 세부 protocol은 +[Client cache and browser storage platform](./client-cache-and-storage.md)에 +기록한다. + +## 2. TypeScript 전환 원칙 + +### 2.1 왜 전환하는가 + +초기 기준선의 `strict + allowJs + checkJs`는 JavaScript 상태에서 유용한 +중간 안전망이었다. 현재는 product source, test, Node script와 지원되는 config를 +TS/TSX로 전환하고 `allowJs: false`로 닫았다. JSDoc cast 대신 실제 TypeScript +타입으로 다음 계약을 검사한다. + +- `RouteId`, `OperationId`, `ErrorCode`, `StorageKey`, `TelemetryEvent` +- `Result`와 discriminated failure union +- use case input/output와 gateway generic +- route별 params/search type +- query key tuple +- component variant와 slot prop +- runtime registry의 key와 executable implementation의 완전성 + +TypeScript 전환 목적은 확장자 변경이 아니라 이 계약을 컴파일 단계에서 +검증하는 것이다. + +### 2.2 현재 도구 경계 + +전환 이후에도 다음 조건을 blocking gate로 유지한다. + +1. ESLint가 product와 negative fixture의 `ts`/`tsx`를 모두 검사한다. +2. React Hooks 규칙을 추가하고 TypeScript/ESLint parser와 JSX accessibility + 도구는 설치된 compiler/linter의 공식 peer 범위 안에서 선택한다. +3. dependency-cruiser의 extension과 resolver가 TS/TSX를 포함한다. +4. `scripts/check-registries.ts`가 TS/TSX를 검색한다. +5. `config/contracts/registry-governance.json`과 승인 baseline은 `.ts/.tsx` + source 경로를 사용한다. +6. Vite, Vitest, Playwright, scripts, source, tests를 각각 typecheck한다. +7. invalid type fixture가 TS migration 후에도 “실패해야 통과”하는지 확인한다. +8. architecture/security/registry gate가 TS fixture 위반을 실제로 잡는 negative test를 + 추가한다. + +권장 project 구성: + +```text +tsconfig.base.json +tsconfig.app.json +tsconfig.node.json +tsconfig.test.json +tsconfig.json # project references only +``` + +`tsconfig.base.json`의 핵심 옵션: + +```json +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "allowJs": false, + "checkJs": false, + "allowImportingTsExtensions": true, + "isolatedModules": true + } +} +``` + +Node가 직접 실행하는 script/config project는 추가로 `NodeNext`, +`verbatimModuleSyntax`, `rewriteRelativeImportExtensions`와 +`erasableSyntaxOnly`를 적용한다. Vite app/test project는 Bundler resolution을 +사용하되 저장소 내부 상대 import에 실제 `.ts/.tsx` 확장자를 기록한다. + +현재 저장소의 VD-01 결정은 +[TypeScript 7과 ESLint 10의 점진적 전환 도구](./decisions/VD-01-typescript-lint-tooling.md)에 +기록돼 있다. app, Node scripts/config, tests는 각각 독립된 strict project로 +typecheck한다. TypeScript 7을 아직 지원하지 않는 parser plugin을 강제 +설치하지 않고 Babel parser는 lint syntax/import/security 검사, `tsc`는 type +semantics를 소유한다. + +### 2.3 완료된 전환 순서 + +| 단계 | 대상 | 이유 | 종료 조건 | +| --- | --- | --- | --- | +| 0 | lint/typecheck/scanner/architecture tooling | TS 코드가 검사를 우회하지 않게 함 | TS 위반 fixture가 각 게이트에서 실패 | +| 1 | result, failure, ID, registry types | 이후 모든 계층의 언어가 됨 | stringly typed public ID 제거 | +| 2 | application input/output ports와 use case | 중심 계약을 먼저 고정 | input/output compile fixture 통과 | +| 3 | domain model과 mapper boundary | DTO와 core model 혼합 차단 | mapper contract test 통과 | +| 4 | outbound adapters | 외부 `unknown`을 경계에서 좁힘 | HTTP/storage/auth failure type 통과 | +| 5 | bootstrap/composition | 누락 dependency를 컴파일로 검출 | 실제 composition type test 통과 | +| 6 | React providers/controllers/routes | typed application API 소비 | route/runtime map 완전성 검사 | +| 7 | primitives/pages/templates | component API와 variant를 닫음 | stories/component tests typecheck | +| 8 | tests/scripts/config | 우회 없는 전체 저장소 | `allowJs: false`, TS-only architecture gate | + +이 순서는 기존 계약을 보존하며 수행한 migration 기록이다. 현재 실행 코드와 +`tests/fixtures/**`에는 JavaScript와 TypeScript를 공존시키지 않는다. 실패를 +의도한 fixture도 실제 `.ts/.tsx` 입력이며 product import 대상은 아니다. + +### 2.4 기본 type 계약 + +공통 success/failure carrier는 `src/application/result.ts`에 한 번만 둔다. + +```ts +import type { AppFailure } from "../contracts/errors.ts"; + +export type Result = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: Failure }>; +``` + +`AppFailure.kind`의 `FailureKind`는 `keyof typeof ERROR_REGISTRY`에서 파생한다. +따라서 registry에 없는 failure kind는 컴파일되지 않으며, application, +query/form controller와 feature API가 같은 default failure contract를 사용한다. +기존 HTTP 경계의 `ApiFailure` 이름은 `AppFailure`의 호환 alias로만 유지한다. + +원칙: + +- adapter에서 받은 `unknown`은 adapter 경계에서 parse한다. +- application은 `Response`, `AxiosError`, Zod 내부 오류 같은 vendor type을 + 노출하지 않는다. +- UI copy는 failure에 저장하지 않고 message key mapper에서 결정한다. +- 예상 가능한 실패는 `Result`; programmer bug와 render crash는 error boundary로 + 보낸다. +- `as`, non-null assertion, `any`는 경계에서 근거가 있을 때만 사용하고 lint + 예외에 사유를 기록한다. + +### 2.5 typed feature input contribution + +generic application은 concrete feature를 import하지 않는다. 대신 비어 있는 +`ApplicationFeatureInputs`를 소유하고, 설치되는 feature의 application API가 +module augmentation으로 ID와 input shape를 기여한다. + +```ts +export interface ApplicationFeatureInputs {} + +declare module "../../../application/ports/in/application-api.ts" { + interface ApplicationFeatureInputs { + "reference-feature": ReferenceFeatureInput; + } +} +``` + +`ApplicationFeatureId`는 이 interface의 string key에서 파생하며, +`features.get(id)`는 해당 key의 정확한 input type을 반환한다. 잘못된 ID, 누락된 +input method와 잘못된 method signature는 negative type fixture가 거절한다. +runtime `has` type guard와 설치 누락 예외는 JavaScript나 외부 동적 입력 경계도 +fail-closed로 유지한다. + +## 3. 상태 소유권 + +### 3.1 상태 분류표 + +상태를 만들기 전에 아래 순서로 소유자를 결정한다. + +| 질문 | 상태 종류 | 기본 도구 | 저장 위치 | +| --- | --- | --- | --- | +| 한 컴포넌트 상호작용에만 필요한가 | local UI | `useState`, `useReducer` | component/controller | +| URL로 공유·복원되어야 하는가 | navigation | router params/search + codec | URL | +| 서버가 진실의 원천인가 | server state | TanStack Query inbound adapter | Query cache | +| 입력 중이고 제출 전인가 | form | local form facade | form controller | +| 앱 전체에서 낮은 빈도로 바뀌는가 | cross-cutting | Context 또는 typed external store | provider/store | +| 여러 feature의 복잡한 workflow인가 | client workflow | reducer, Zustand, Redux Toolkit, state machine | feature-owned store | +| 새로고침 후 남아야 하는가 | persisted preference | `StoragePort` | versioned browser storage | +| 인증 credential인가 | auth secret | external auth owner | SDK memory 또는 HttpOnly cookie | + +금지: + +- server response를 global client store에 복사하지 않는다. +- URL에 있어야 할 filter/sort/page를 숨은 store에만 두지 않는다. +- component 내부에 머물 수 있는 modal open 상태를 전역화하지 않는다. +- access token을 localStorage, sessionStorage, 일반 Redux/Zustand store에 넣지 + 않는다. +- 모든 상태를 추상화하는 범용 `StorePort`를 만들지 않는다. + +### 3.2 범용 store 선택 기준 + +기본 skeleton에는 빈 Zustand/Redux store를 만들지 않는다. 실제 요구가 생기면 +다음 기준을 적용한다. + +| 조건 | 권장 | +| --- | --- | +| feature 한 곳의 단순 shared client state | feature reducer 또는 작은 Zustand store | +| 여러 팀이 action/state 규약, devtools, middleware, audit를 공유 | Redux Toolkit | +| 명시적 상태 전이, 병렬 상태, 취소/보상 workflow | state machine | +| session/theme처럼 저빈도 cross-cutting | `useSyncExternalStore` 또는 Context | + +vendor를 선택하더라도 feature 외부에는 hook/facade만 export한다. 제품 코드가 +store instance의 `getState`와 `setState`를 임의 호출하지 않게 한다. +현재 `recipes/frontend-capabilities`의 `ClientWorkflowPort`와 +`FakeClientWorkflowAdapter`가 vendor-neutral opt-in 예제를 제공한다. 기본 +production에는 Zustand/Redux Toolkit/state-machine dependency가 없고, +`check:optional-recipe-fixtures`가 server response collection을 client workflow +store에 복제하는 패턴을 거절한다. + +### 3.3 persistence + +persisted state는 다음 metadata를 가져야 한다. + +```ts +type PersistedRecord = Readonly<{ + version: number; + writtenAt: string; + value: T; +}>; +``` + +- key는 typed registry가 소유한다. +- read 시 schema parse와 migration을 거친다. +- quota, unavailable, corrupt, version mismatch를 구분한다. +- 민감정보와 credential을 저장하지 않는다. +- server state persistence와 offline mutation queue는 별도 project-selected + adapter다. + +현재 `StoragePort` 구현은 registry가 선언한 local/session backend, +`color-scheme-v1`/`opaque-string-v1` closed value codec, schema version, TTL과 +16,384-byte hard cap을 적용한다. `QUERY_PERSISTENCE`는 +`disabled`/`sensitive-forbidden`이고 installed query registry도 +`persistence: "disabled"`만 허용한다. 기존 IndexedDB reference runtime은 +`AVAILABLE_NOT_COMPOSED`이며 TanStack hydration에는 연결하지 않는다. + +## 4. 표준 데이터 호출 경로 + +```mermaid +sequenceDiagram + actor User + participant Page as React page + participant Controller as inbound query/mutation controller + participant App as application input use case + participant Gateway as output gateway + participant Adapter as HTTP/generated client adapter + participant API as Backend/BFF + + User->>Page: route or event + Page->>Controller: typed input + Controller->>App: query/command + AbortSignal + App->>Gateway: capability request + Gateway->>Adapter: DTO request + Adapter->>API: path/query/body/auth + API-->>Adapter: envelope or failure + Adapter-->>Gateway: parsed DTO Result + Gateway-->>App: mapped model Result + App-->>Controller: view data or AppFailure + Controller-->>Page: query/mutation state +``` + +페이지는 controller hook이 제공하는 상태만 렌더링한다. controller는 React, +TanStack Query와 application input interface를 알 수 있지만 use case의 concrete +구현과 outbound adapter는 모른다. + +### 4.1 Application API + +```ts +export interface Application { + readonly resources: { + list( + input: ListResourcesInput, + context: Readonly<{ signal: AbortSignal }>, + ): Promise>; + create( + command: CreateResourceCommand, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + }; +} +``` + +`ApplicationProvider`는 이 API를 immutable value로 제공한다. controller 외의 +presentation 코드에서 raw HTTP/storage/telemetry port를 가져오는 hook은 만들지 +않는다. theme, locale 같은 UI platform provider는 별도다. + +### 4.2 query adapter + +```ts +export function useResourcesQuery(input: ListResourcesInput) { + const application = useApplication(); + + return useQuery({ + queryKey: resourceKeys.list(input), + queryFn: async ({ signal }) => + unwrapResult(await application.resources.list(input, { signal })), + retry: false, + staleTime: resourceQueryPolicy.list.staleTime, + }); +} +``` + +실제 구현 규칙: + +- query key는 readonly tuple factory로만 만든다. +- key에 들어간 filter는 실제 gateway request에도 동일하게 투영한다. +- `AbortSignal`을 application과 HTTP transport까지 전달한다. +- HTTP 계층이 bounded retry를 소유하면 query retry는 끈다. +- background error와 initial error의 UI를 구분한다. +- placeholder와 cached stale data가 있을 때 전체 화면 error로 교체하지 않는다. +- `select`는 view-only projection에 쓰고 도메인 규칙을 넣지 않는다. +- query hook은 feature public API에서 export한다. + +### 4.3 mutation adapter + +```ts +export function useCreateResourceMutation() { + const application = useApplication(); + + return useApplicationMutation({ + execute: (command: CreateResourceCommand) => + application.resources.create(command), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + }); +} +``` + +기본 mutation은 navigation만으로 취소됐다고 가정하지 않는다. 서버 작업의 취소가 +안전한 operation만 controller가 보관한 `AbortController`와 명시적 cancel action을 +사용한다. idempotency key는 UI 입력으로 받지 않고 application use case가 +`IdGeneratorPort`로 만들며, 같은 논리 요청의 bounded HTTP retry와 auth replay는 +동일한 key를 재사용한다. + +실제 구현에서는 mutation마다 다음을 명시한다. + +- double-submit 방지와 idempotency key 소유자 +- cancel 가능 여부 +- optimistic update 적용 여부와 rollback snapshot +- 성공 후 invalidate/update/navigation 순서 +- 409 conflict와 422 field validation mapping +- offline 시 queue할지 즉시 실패할지 +- analytics/telemetry event와 redaction + +optimistic update는 기본값이 아니다. 서버 규칙을 확실히 재현할 수 있고 rollback이 +안전한 mutation에만 사용한다. + +### 4.4 탭 간 query invalidation + +현재 installed query contract는 namespace와 별도로 opaque +`invalidationTopic`, topic `version`, `crossContext: "invalidate-only"`와 +`persistence: "disabled"`를 등록한다. +`useApplicationMutation`은 server mutation 성공 뒤 +`QueryInvalidationCoordinator`에 topic을 전달한다. coordinator는 local +TanStack namespace를 active-query mode로 invalidate한 다음 +2,048-byte 이하의 payload/query-key-free hint만 다른 context에 발행한다. + +```text +server mutation success + -> registered topic + -> local namespace invalidate + -> BroadcastChannel hint + -> failure: localStorage pulse + -> failure: DEGRADED_LOCAL_ONLY +``` + +receiver는 exact protocol/topic/release cache epoch를 검증하고 self echo, +duplicate와 stale out-of-order event를 버린다. sequence gap이면 모든 등록 +namespace를 stale 처리해 다시 읽는다. remote event는 +`removeQueries`, `resetQueries` 또는 `queryClient.clear()` 권한을 갖지 않는다. +logout/account transition은 이후 session/account epoch와 local lifecycle +authority를 추가해야 하며 현재 release epoch만 조립돼 있다. + +## 5. HTTP client 책임 + +### 5.1 목표 파이프라인 + +```text +operation registry + -> path/search/body builder + -> credential attachment + -> timeout and external cancellation + -> bounded retry + -> fetch transport + -> status/envelope decoder + -> response schema decoder + -> feature DTO mapper + -> AppFailure mapper +``` + +각 단계의 입력과 출력은 typed result다. feature-specific model mapper를 공통 HTTP +client 안에 넣지 않는다. + +### 5.2 request projection + +operation definition은 최소 다음 계약을 갖는다. + +```ts +type OperationDefinition< + TPath, + TSearch, + TBody, + TResponse, +> = Readonly<{ + id: OperationId; + method: HttpMethod; + pathTemplate: string; + pathSchema: Schema; + searchSchema: Schema; + bodySchema: Schema; + responseSchema: Schema; + timeoutMs?: number; + retryClass: "never" | "safe" | "idempotency-keyed"; +}>; +``` + +규칙: + +- path segment는 `encodeURIComponent`에 해당하는 안전한 builder를 통과한다. +- query의 array/null/undefined/boolean/date serialization을 한 곳에서 정의한다. +- Zod parse 결과의 trim/default/coercion을 실제 request에 사용한다. +- GET/HEAD에는 body를 보내지 않는다. +- JSON content type은 body가 있을 때만 붙인다. +- base URL과 path를 문자열 덧붙이기로 조립하지 않는다. +- external URL은 별도 allowlist policy를 거친다. + +### 5.3 timeout, cancellation, retry + +소유권: + +- 사용자 navigation/unmount 취소: query/controller가 signal 생성 +- operation timeout: HTTP adapter +- retry: HTTP adapter 또는 query adapter 중 하나 +- 인증 복구 후 단 한 번 replay: auth decorator +- mutation idempotency: application/controller가 key 생성, operation이 허용 여부 선언 + +retry 조건: + +- safe method 또는 idempotency key가 있는 허용 operation만 대상 +- timeout, network, 명시된 429/5xx만 정책 대상 +- validation, auth denial, forbidden, not found, conflict는 자동 재시도하지 않음 +- `Retry-After`와 bounded exponential backoff/jitter 지원 +- tab hidden/offline 상태를 고려 +- 최대 횟수와 전체 elapsed budget을 함께 제한 +- 각 attempt와 final failure를 redacted telemetry로 기록 + +timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든 +경로에서 정리되어야 한다. + +RP-03의 현재 구현은 다음 계약을 자동 검증한다. + +- `request-builder.ts`가 path 값을 escape하고 search key를 정렬하며 array 순서를 + 보존한다. +- operation의 `requestSource`가 search/body schema를 선택하고 Zod의 + default/trim 결과만 URL 또는 JSON payload에 전달한다. +- runtime `REQUEST_TIMEOUT_MS`와 `MAX_RETRY_ATTEMPTS`가 transport factory에 + 주입된다. +- query adapter의 자동 retry는 끄고 HTTP만 bounded network retry를 소유한다. +- `AsyncOverlay`는 refreshing/stale-degraded/mutation-pending/ + mutation-conflict를 TypeScript union으로 배타화한다. +- `useApplicationQuery`와 `useApplicationMutation`은 cancellation, stale latch, + duplicate submit, optimistic rollback, conflict resolution을 제공한다. + +## 6. 인증과 token 소유권 + +기본 skeleton은 token manager를 제공하지 않는다. + +지원 profile: + +| profile | credential 소유자 | frontend 역할 | +| --- | --- | --- | +| BFF/HttpOnly cookie | browser cookie + backend | `credentials`, CSRF 정책, session probe | +| external OIDC/Auth SDK | SDK memory/cache | attach/recover/login/logout를 auth adapter로 감쌈 | +| SPA memory token | auth adapter memory | 프로젝트가 명시적으로 선택할 때만 | + +공통 `AuthSessionReader`와 HTTP 전용 `CredentialProvider`를 분리한다. + +```ts +interface AuthSessionReader { + getSnapshot(): SessionSnapshot; + subscribe(listener: () => void): () => void; +} + +interface CredentialProvider { + attach(request: RequestInit): Promise; + recover(failure: AuthFailure): Promise<"recovered" | "not-recovered">; +} +``` + +UI는 credential을 읽지 않는다. HTTP adapter는 UI session action을 호출하지 +않는다. logout/login/redirect는 application input 또는 auth UI facade를 통해 +실행한다. + +## 7. 오류, 검증, logging + +### 7.1 검증 계층 + +| 경계 | 책임 | 예 | +| --- | --- | --- | +| runtime config | 앱을 안전하게 시작할 수 있는가 | URL, timeout, auth mode | +| route codec | URL을 typed input으로 읽고 쓸 수 있는가 | page, sort, ID | +| transport DTO | 외부 응답/요청 형식이 계약과 맞는가 | envelope, date string | +| form | 사용자가 수정 가능한 입력 형태가 유효한가 | required, length, format | +| application | use case precondition이 맞는가 | command 조합 | +| domain | 항상 지켜야 할 불변식인가 | valid state transition | + +같은 Zod schema를 무조건 모든 계층에서 재사용하지 않는다. transport DTO, form +value, application command, domain model이 우연히 같은 모양이어도 소유권과 +변경 이유가 다르다. 필요한 경우 mapper로 연결한다. + +### 7.2 사용자 오류 표면 + +`AppFailure`를 다음 UI 상태로 매핑한다. + +| failure | 기본 표면 | 자동 행동 | +| --- | --- | --- | +| unauthenticated | auth required 또는 login transition | auth policy에 따른 1회 복구 | +| forbidden | 권한 없음 | 없음 | +| not-found | route/detail not-found | 없음 | +| validation | error summary + field errors | 첫 오류 focus | +| conflict | 현재 데이터 유지 + conflict action | 자동 overwrite 금지 | +| rate-limited | inline retry time | 허용된 query만 지연 재시도 | +| unavailable | cached data 또는 retry surface | policy 범위 내 retry | +| unexpected | safe generic copy + trace ID | diagnostics emit | + +raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log에 노출하지 +않는다. + +### 7.3 diagnostics와 telemetry + +VD-07에서 level/event 기반 `DiagnosticsPort`와 semantic +`TelemetryPort`를 분리했다. diagnostics는 8개 event ID와 safe context +allowlist를, telemetry는 event별 required/optional attribute와 value policy를 +사용한다. + +공통 요구: + +- log level과 event key는 닫힌 union +- attribute allowlist와 중앙 redaction +- 기본 adapter는 bounded memory/no-op이고 endpoint가 있을 때만 best-effort + HTTP queue를 사용 +- production provider SDK를 추가할 때도 port 뒤에서 감싸며 앱 코드는 SDK를 + import하지 않음 +- 오류 객체 전체를 그대로 serialize하지 않음 +- trace ID/build ID/route ID/operation ID를 허용된 범위에서 연결 +- logging failure가 제품 flow를 실패시키지 않음 +- consent가 필요한 analytics와 essential diagnostics를 분리 + +HTTP는 route/operation/correlation ID를 logical execution context로 생성하고 +success/recovered/failed/aborted 종료 시 diagnostics를 한 번만 기록한다. +terminal non-abort failure만 telemetry를 한 번 발행한다. cache/storage와 boot +producer는 raw key, value, message, stack을 버리고 error kind와 bounded +operation만 남긴다. queue full과 sink failure는 제한된 reason bucket이며 drop +observer가 실패해도 재귀 발행하지 않는다. + +### 7.4 locale, message와 표시 값 + +RP-08부터 locale은 presentation-owned React context다. server/application +state에 번역된 문자열을 저장하거나 query key에 locale을 넣는 것은 응답 자체가 +locale별 데이터인 경우에만 허용한다. 공통 UI copy 변경 때문에 query cache를 +복제하지 않는다. + +```text +API timestamp/number/failure kind + -> schema + mapper (의미 값 유지) + -> application result + -> presentation controller + -> useLocale().date/number/message +``` + +- message key는 `MessageKey` union이며 interpolation은 key별 tuple type이다. +- unknown external key는 `resolveMessage`에 전달해도 raw key가 표시되지 않는다. +- backend `message`는 diagnostic input일 수 있지만 사용자 copy가 아니다. +- form validation code는 `ParameterlessMessageKey` allowlist로 mapping한다. +- date의 기본 timezone은 UTC이고 제품 timezone은 presentation 호출자가 + 명시한다. +- pseudo/RTL locale state는 local interaction state이며 persistence와 server + synchronization을 기본 제공하지 않는다. +- key rename은 typed canonical key를 먼저 이동하고 runtime alias에 migration + 기간을 둔다. + +## 8. 폼 표준 + +VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며 +Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation, +대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook +Form 또는 TanStack Form adapter를 평가한다. + +현재 public API는 다음과 같다. + +- `Form` +- `FormField` +- `Label` +- `Description` +- `FieldError` +- `ErrorSummary` +- `useAppForm` +- `mapValidationFailureToFields` +- `useDirtyNavigationGuard` + +구현 위치: + +- controller와 mapping: `src/presentation/forms` +- layout-only template: `src/presentation/templates` +- feature form schema/command mapper: + `src/features/reference-feature/presentation/reference-resource-form.ts` +- 실제 create page: + `src/features/reference-feature/presentation/reference-resource-form-page.tsx` + +`ApiFailure.validationIssues`는 HTTP 경계가 투영한 `path`와 `code`만 담는다. +backend message와 알 수 없는 path는 field copy로 사용하지 않는다. + +필수 동작: + +1. label, description, error를 stable ID와 `aria-describedby`로 연결 +2. submit 시 error summary와 첫 오류 focus +3. submitting 중 중복 제출 방지 +4. 422 응답의 알려진 field만 표시하고 나머지는 form-level failure로 처리 +5. 409 conflict는 validation error로 위장하지 않음 +6. 취소와 route 이탈 시 dirty policy 적용 +7. form value → application command mapper를 별도 함수로 둠 +8. browser autofill, IME composition, paste, password manager를 방해하지 않음 +9. loading skeleton으로 사용자의 입력을 덮지 않음 +10. form schema와 server contract mismatch를 integration test로 검증 + +## 9. 새 query 추가 recipe + +1. feature-owned input type과 application query use case를 추가한다. +2. 필요한 output gateway를 `ports/out`에 추가한다. +3. transport DTO schema와 mapper를 outbound feature adapter에 추가한다. +4. operation registry에 path/search/response/retry class를 등록한다. +5. readonly tuple query key factory를 추가한다. +6. inbound query controller hook을 추가한다. +7. page template에서 loading/empty/error/refreshing/success를 렌더링한다. +8. 다음 테스트를 추가한다. + - use case unit + - DTO mapper/schema contract + - path/search projection + - MSW success/empty/401/403/429/500/schema mismatch + - cancellation과 retry ownership + - component state + - route E2E +9. registry, type, architecture, unit, integration, E2E gate를 실행한다. + +금지: + +- page에서 `fetch` +- page에서 raw QueryClient +- cache key와 request filter의 별도 수동 조립 +- response DTO를 domain/application model로 사용 +- initial loading과 background refreshing을 같은 full-page skeleton으로 표시 + +## 10. 새 mutation/form 추가 recipe + +1. form value schema와 application command type을 분리한다. +2. value → command mapper를 작성한다. +3. input command use case와 output gateway method를 추가한다. +4. operation의 idempotency/retry 정책을 선언한다. +5. mutation controller에 invalidation/update/rollback 순서를 작성한다. +6. `FormPageTemplate`과 공통 field primitive로 화면을 구성한다. +7. 422, 409, unauthenticated, unavailable, abort를 각각 처리한다. +8. dirty navigation과 double-submit을 검증한다. +9. 다음 테스트를 추가한다. + - form schema와 mapper unit + - keyboard/label/error summary component + - MSW success/422/409/network-lost + - optimistic rollback을 쓰는 경우 cache snapshot + - 브라우저 navigation blocker와 성공 후 이동 + +## 11. 완료 기준 + +- TS/TSX가 lint, type, architecture, registry, security 검사를 우회하지 않는다. +- source와 test가 strict typecheck된다. +- UI는 application input API만 호출한다. +- reference query와 mutation이 TanStack adapter를 통해 동작한다. +- retry 책임이 단일 계층에 있고 cancellation/timeout과 충돌하지 않는다. +- path/search/parsed body가 실제 전송값과 일치한다. +- 상태 종류별 소유권이 테스트와 문서에서 확인된다. +- token은 UI와 일반 storage/store에 노출되지 않는다. +- failure와 validation의 각 계층이 typed mapper로 분리된다. +- common UI copy, locale formatter와 direction이 typed i18n facade를 통과한다. +- query/mutation/form recipe만으로 새 기능을 만들 수 있다. +- query mutation invalidation은 registry topic을 통해 local cache와 다른 tab에 + 연결되고 query key/data는 wire에 노출되지 않는다. +- query persistence는 명시적으로 disabled이고 기존 IndexedDB runtime과 암묵적으로 + 조합되지 않는다. diff --git a/docs/contracts/compatibility.md b/docs/contracts/compatibility.md new file mode 100644 index 0000000..1e3dbf5 --- /dev/null +++ b/docs/contracts/compatibility.md @@ -0,0 +1,22 @@ +# Contract compatibility and rollback rules + +The blocking tuple is `(buildId, configSchemaVersion, apiContractVersion, +assetManifestHash, releaseId)`. Versions are parsed numerically. + +1. additive changes preserve current required fields +2. breaking changes require a major version bump +3. persisted cache is discarded unless an explicit tested migration exists +4. an incompatible config or API contract blocks product mount +5. rollback restores HTML, assets, runtime config, API compatibility, and + release manifest as one coherent set + +The current `apiContractVersion` check proves release/config coherence, not +provider conformance by itself. The target multi-protocol contract set binds +REST/OpenAPI artifacts, GraphQL schema and persisted-operation manifests, +protobuf source/descriptors/codegen profiles, Connect/gRPC-Web provider profiles, +ProtoJSON/HttpRule/generated OpenAPI/gateway artifacts, runtime schema/mapper +registries, and server-state policy digests as defined in +[API contract, Schema, Mapper, and Server State](../architecture/api-contract-schema-mapper-and-server-state.md). +Browser Protobuf and REST Gateway compatibility is further split in +[Protobuf browser transport and REST Gateway](../architecture/protobuf-browser-transport-and-rest-gateway.md); +binary/JSON/HTTP-route compatibility gates are not interchangeable. diff --git a/docs/operations/api-contract-and-server-state-recovery.md b/docs/operations/api-contract-and-server-state-recovery.md new file mode 100644 index 0000000..18f7122 --- /dev/null +++ b/docs/operations/api-contract-and-server-state-recovery.md @@ -0,0 +1,581 @@ +# API contract와 Server State recovery + +- 상태: current REST 절차 + future GraphQL/Connect/gRPC-Web/REST Gateway/ + schema/cache 절차 분리 +- backend 구조와 handoff: + [Backend API와 Server State contract](../architecture/backend-api-and-server-state-contract.md) +- 기준일: 2026-07-28 +- 설계: + [API contract, Schema, Mapper와 Server State](../architecture/api-contract-schema-mapper-and-server-state.md) +- browser Protobuf/gateway 설계: + [Protobuf browser transport와 REST Gateway](../architecture/protobuf-browser-transport-and-rest-gateway.md) + +## 1. 적용 범위 + +현재 reference REST → Schema → Mapper → TanStack Query vertical은 `COMPOSED`다. +하지만 다음 target delta는 아직 `DESIGNED_NOT_IMPLEMENTED`다. + +- REST v2 auth fail-close/final invariant +- total logical deadline와 bounded response decoder +- complete cursor/conditional request +- typed schema/mapper registry +- strict query/mutation definition과 concurrency coordinator +- GraphQL reference adapter +- Connect-Web reference adapter +- gRPC-Web reference adapter +- Protobuf REST Gateway + +GraphQL/Connect/gRPC-Web/REST Gateway 절차는 해당 runtime과 actual provider가 +구현·조합된 뒤에만 운영 gate로 활성화한다. 문서나 fake fixture만으로 capability를 +`AVAILABLE_NOT_COMPOSED`, `COMPOSED` 또는 production-ready라고 기록하지 않는다. + +canonical readiness: + +```text +Selection +TrafficAdmission +RuntimeHealth +PromotionEvidence +``` + +이 readiness model은 target v2 control plane이다. current v1에는 operation별 +`TrafficAdmission` runtime switch가 없다. + +| control | current v1 | target v2 | +| --- | --- | --- | +| operation traffic switch | 없음 | provider/family/operation별 `TrafficAdmission` | +| frontend containment | promotion 중지와 known-good release rollback; feature composition 변경은 재배포 필요 | atomic admission disable | +| provider containment | actual BFF/API owner의 route/kill switch가 있을 때만 사용 | registered provider kill switch와 probe | +| query cleanup | composed QueryClient/coordinator의 cancel·invalidate·local clear | generation fence와 profile-scoped cleanup | +| mutation recovery | backend 상태/idempotency owner와 수동 reconcile | effect certainty + runtime/scope coordinator | + +따라서 current v1 incident에서 존재하지 않는 switch를 내렸다고 기록하지 않는다. +target v2가 composed된 operation만 `TrafficAdmission`을 먼저 내린다. 그 전에는 +신규 promotion을 중지하고 known-good release rollback 또는 실제 backend/BFF +route 차단을 owner와 수행한다. primary status나 contract version을 운영자가 +임의로 낮춰 현재 상태를 숨기지 않는다. + +current v1으로의 fallback은 (1) 해당 operation의 provider/security evidence가 +만료되지 않았고, (2) incident class가 v1 또는 shared boundary에 영향이 없으며, +(3) auth fail-close/final invariant hardening이 유지되는 경우만 허용한다. 하나라도 +증명하지 못하면 v1을 사용하지 않고 safe unavailable 또는 실제 provider route +차단으로 닫는다. + +## 2. 관측 허용과 금지 + +허용된 bounded aggregate: + +- semantic operation/profile/provider ID +- protocol/semantics +- safe failure kind +- HTTP status group, GraphQL safe category, gRPC status bucket +- logical/physical attempt, auth recovery와 deadline bucket +- provider가 측정한 encoded transfer bucket과 browser가 측정한 + decoded/result/message/frame/page/item byte/count bucket +- schema/mapper/query profile version과 compatibility outcome +- cache hit/miss/stale/refresh/admission/eviction +- mutation concurrency/optimistic/rollback/conflict/effect/invalidation outcome +- stream terminal/gap/overflow/idle bucket + +금지: + +- URL/path/search/header/body +- credential, cookie, CSRF, idempotency key +- GraphQL document/hash/variables/data/error message/path/extensions +- protobuf payload/debug JSON/metadata/trailer/status-details +- DTO/application/cache value/optimistic patch +- cursor/snapshot/ETag/revision/resume token +- account/tenant/resource/file ID +- raw backend request/trace/correlation value + +incident ticket에 금지 값을 복사하지 않는다. credential/payload 노출 의심이면 +일반 contract incident가 아니라 security incident로 분류하고 provider revoke, +key/session rotation과 로그 보존 범위를 별도 owner에게 escalate한다. + +## 3. 최초 containment + +1. 영향 operation family/protocol/provider/browser/release cohort를 safe aggregate로 + 좁힌다. +2. target v2 control이 실제 composed됐으면 해당 operation의 + `TrafficAdmission`을 `DISABLED` 또는 안전한 canary cohort로 낮춘다. current + v1이면 promotion을 중지하고 known-good release rollback 또는 실제 + backend/BFF route control을 사용한다. +3. query/read는 cancel하고 stream reader를 닫는다. +4. command는 무조건 retry/rollback하지 않고 effect certainty를 + `NOT_APPLIED | COMMITTED | UNKNOWN`으로 분류한다. +5. `UNKNOWN` command는 backend idempotency/status/reconcile owner에게 전달한다. +6. current scope의 suspect query를 invalidate한다. wrong-scope/contract-poison + 가능성이 있으면 cancel 후 current runtime cache를 clear한다. +7. GraphQL/Connect/gRPC-Web failure를 REST command로 자동 failover하지 않는다. +8. contract tuple과 artifact를 확인한다. + +```text +frontend release/build +runtime config/API contract version +installed operation registry digest +runtime schema/mapper/query policy digest +REST/OpenAPI artifact +GraphQL schema + persisted manifest +protobuf descriptor/generated artifact +Connect client/server/provider profile +HttpRule/ProtoJSON/OpenAPI/gateway artifact +BFF/router/proxy/backend release +``` + +실제 digest 값을 high-cardinality telemetry label로 보내지 않고 incident evidence +artifact에서 access-controlled하게 비교한다. + +## 4. 공통 증상 분류 + +| 증상 | 우선 확인 | +| --- | --- | +| 특정 release에서 전 operation 실패 | runtime config, provider endpoint, auth owner, contract set | +| schema/mapper mismatch 급증 | backend artifact rollout, unknown/null/enum/scalar 변화, wrong codec | +| retry/traffic 폭증 | retry owner 중복, 401 loop, Retry-After, total deadline | +| stale/wrong account data | scope generation, late result, query key collision, invalidation | +| command duplicate/rollback 이상 | idempotency tuple, input-aware concurrency, optimistic revision | +| list가 반복/무한 증가 | cursor loop/snapshot/page/item/byte ceiling | +| provider 200인데 client 실패 | status/media/envelope/schema/mapper/body cap | +| 로그에서 payload/ID 발견 | redaction/allowlist bypass, diagnostics producer | + +## 5. Auth와 CSRF + +### 5.1 Auth-required request가 무자격으로 전송됨 + +현재 v1은 external auth integration이 unavailable이어도 unchanged request가 +전송될 수 있으므로 target v2 전까지 제품 auth-required traffic을 provider +evidence 없이 활성화하지 않는다. + +target 점검: + +1. session state가 authenticated인지 확인한다. +2. auth owner가 `CredentialPatch`만 반환하는지 또는 final request invariant를 + 재검증하는지 확인한다. +3. URL/origin/method/body digest/idempotency/conditional/CSRF가 attach 전후 같은지 + 확인한다. +4. unavailable/integration-failed/attach rejected에서 fetch가 0회인지 증명한다. +5. 401 recovery가 logical execution당 1회인지 확인한다. +6. concurrent 401이 single-flight recovery를 공유하는지 확인한다. +7. non-replayable command가 recovery 뒤 재실행되지 않았는지 확인한다. + +의심 시 operation auth traffic을 즉시 닫는다. anonymous fallback을 만들지 않는다. + +### 5.2 CSRF/CORS + +1. unsafe cookie request의 exact Origin/Fetch Metadata/CSRF proof를 server에서 + 확인한다. +2. SameSite/custom header/preflight 하나만으로 방어했다고 기록하지 않는다. +3. cross-origin allow-origin/credentials/method/header/expose가 exact한지 확인한다. +4. wildcard credential, redirect와 unexpected origin을 복구한다. +5. CSRF token/header 값을 ticket에 기록하지 않는다. + +## 6. REST incident + +### 6.0 Current v1 available checks + +1. current per-attempt timeout/bounded retry, Query retry off, AbortSignal과 401 + recovery observation을 확인한다. +2. installed request/envelope/payload Zod schema와 mapper failure kind를 확인하되 + current `Response.json()`이 body cap 증거가 아님을 기록한다. +3. QueryClient/coordinator로 affected read를 cancel/invalidate/clear한다. +4. cursor response/page runtime, APP_ETAG와 total deadline은 아직 없으므로 해당 + control을 실행했다고 기록하지 않는다. +5. command effect는 backend와 reconcile한다. basic optimistic snapshot 문제가 + 의심되면 runtime switch를 가정하지 말고 no-optimistic/known-good release로 + rollback한다. + +아래 6.1~6.5는 target v2 coordinator가 해당 operation에 composed된 뒤에만 +운영 절차로 활성화한다. + +### 6.1 Timeout/retry storm — target v2 + +1. Query retry가 꺼지고 REST transport 하나만 network retry하는지 확인한다. +2. total logical deadline 안에 auth, fetch, body, mapper, recovery와 sleep이 모두 + 포함되는지 확인한다. +3. attempt timeout과 logical deadline exhaustion을 별도 kind로 확인한다. +4. 초회/retry/401 replay 모든 provider fetch가 하나의 monotonic + physical-attempt cap을 소비하는지 확인한다. +5. exact retry status가 network/408/429/502/503/504 중 operation subset인지 + 확인한다. +6. 429/503 `Retry-After`가 injected clock, max sleep/elapsed ceiling을 지키는지 + 확인한다. +7. timer/listener/reader leak가 있는 cohort는 traffic을 닫는다. +8. keyed command는 backend dedupe evidence 없이는 retry를 끈다. + +### 6.2 Body/media/schema failure — target v2 + +1. final URL/status/content type을 확인한다. +2. present/valid Content-Length advisory preflight와 actual browser-visible decoded + byte cap을 확인한다. +3. actual encoded transfer/decompression ratio cap은 BFF/proxy/CDN evidence에서 + 확인한다. browser reader가 encoded bytes를 셌다고 가정하지 않는다. +4. standard JSON parse profile이면 decoded byte는 pre-parse guard, + depth/node/key는 post-parse admission guard인지 확인한다. +5. truncated, content encoding/decompression overflow와 invalid UTF-8을 구분한다. +6. success status/media/envelope profile이 operation registry와 일치하는지 확인한다. +7. schema unknown-field policy와 actual codec fingerprint를 확인한다. +8. failure body/value를 출력하지 않는다. +9. schema/mapper failure result를 cache하지 않았는지 확인한다. +10. backend/frontend artifact가 coherent하지 않으면 operation traffic을 닫고 + N/N-1 compatible set으로 rollback한다. + +### 6.3 Idempotency/ambiguous command — target v2 + +1. principal/operation/payload digest/key tuple과 backend TTL/status를 + access-controlled evidence에서 확인한다. +2. 같은 key + 다른 payload가 거절되는지 확인한다. +3. concurrent same-key request가 duplicate commit을 만들지 않았는지 확인한다. +4. `UNKNOWN` effect에 새 key를 생성해 retry하지 않는다. +5. backend status/receipt로 `COMMITTED`면 cache reconcile/invalidate한다. +6. `NOT_APPLIED`가 증명된 경우만 정책에 따라 retry한다. + +### 6.4 Cursor loop — target v2 + +1. query root filters/sort/scope/snapshot을 확인한다. +2. same cursor 반복, `hasMore`/cursor 모순과 non-progress page를 확인한다. +3. max pages/items/bytes를 초과했으면 추가 fetch를 중지한다. +4. snapshot이 바뀌었으면 old/new pages를 합치지 않고 root부터 재시작한다. +5. raw cursor/next URL을 로그에 남기거나 임의 follow하지 않는다. + +### 6.5 Conditional request — target v2 + +1. operation cache owner가 `APP_ETAG`인지 browser HTTP cache인지 확인한다. +2. 304에서 exact query/scope/generation/validator binding과 mapped cached value가 + 모두 있는지 확인한다. +3. 304가 same query-entry cache revision CAS를 통과했는지 확인한다. +4. cached value가 없으면 304를 success로 만들지 않는다. +5. full 200 value와 validator가 atomic하게 교체됐는지 확인한다. +6. Query removal/GC, scope/logout와 release/contract/schema/mapper epoch에서 + sidecar가 함께 폐기됐는지 확인한다. +7. 412 precondition과 409 business conflict를 구분한다. +8. ETag/revision 값을 incident ticket에 기록하지 않는다. + +## 7. GraphQL incident + +이 절은 VD-26 runtime 조합 후 적용한다. + +### 7.1 Persisted operation mismatch + +1. frontend schema/operation manifest와 router manifest 호환성을 확인한다. +2. operation ID, hash, schema digest와 retirement window를 artifact에서 비교한다. +3. full document fallback/APQ miss fallback을 활성화하지 않는다. +4. affected GraphQL operation traffic을 닫는다. +5. frontend + manifest + router를 coherent N/N-1 set으로 rollback한다. + +### 7.2 HTTP/media + data/errors + +1. selected GraphQL-over-HTTP revision, persisted-envelope extension, + `Accept`/response media와 status/body matrix를 확인한다. +2. `application/graphql-response+json`이면 허용 status에서도 body를 bounded + GraphQL envelope로 처리했는지 확인한다. legacy `application/json` non-2xx + intermediary body를 GraphQL error로 해석하지 않는다. +3. `errors=[]`, null/absent data + no errors를 envelope mismatch로 닫았는지 + 확인한다. +4. data/errors state branch와 operation partial policy를 확인한다. +5. raw message/path/extensions를 보지 않고 allowlisted safe code만 확인한다. +6. query partial이 `ALLOW_TYPED_PARTIAL` 조건을 모두 만족하는지 확인한다. +7. unauthorized/contract error field를 previous cache로 자동 채우지 않는다. +8. mutation이면 effect certainty/receipt를 확인한다. +9. partial/mutation error를 ordinary success로 cache하지 않는다. + +### 7.3 Cost/depth/rate failure + +1. persisted operation manifest의 max cost/depth/aliases/list와 router 실제 budget을 + 비교한다. +2. variables/response size가 frontend/server 양쪽 ceiling 안인지 확인한다. +3. client retry storm을 끈다. +4. arbitrary operation 또는 introspection을 허용해 우회하지 않는다. +5. field authorization과 cost budget을 별도 확인한다. + +### 7.4 Incremental/subscription + +선택된 경우에만: + +- exact incremental/subscription protocol/provider revision +- exact Accept/Content-Type parameter와 payload discriminant +- multipart boundary/part/path/terminal/byte/patch count +- truncated/duplicate/out-of-order +- proxy buffering +- sequence/gap/resume/heartbeat +- bounded queue/overflow +- logout/unsubscribe + +terminal completeness 전 staging을 complete query cache로 commit하지 않는다. + +## 8. gRPC-Web incident + +이 절은 VD-27 runtime 조합 후 적용한다. + +### 8.1 HTTP success인데 RPC 실패/멈춤 + +1. exact client runtime/wire-spec revision/content type/transport/rpc-kind capability + matrix를 확인한다. official XHR runtime과 Connect-Web/custom Fetch runtime을 + 같은 cancel/deadline/frame 관측 경로로 취급하지 않는다. +2. `responseHttpStatusProfileId`와 HTTP status/gRPC media/terminal-source + 교차 matrix를 확인한다. +3. custom Fetch framed runtime이면 frame prefix/length/flag/compression을 + 확인한다. official runtime이면 runtime-owned callback/status API와 + `ClientReadableStream.cancel()` 경로를 확인하며 raw `ReadableStream` parser가 + 있다고 가정하지 않는다. +4. body trailer frame 또는 zero-body trailers-only response header 중 terminal + status source가 정확히 하나인지 확인한다. +5. `grpc-status`가 있으면 canonical decimal `0..16`으로 exactly once인지, body + trailer name lowercase와 rich-details outer code가 맞는지 확인하며 그 status를 + authority로 사용한다. +6. `grpc-status`가 없으면 공식 fallback만 적용한다: + `400→INTERNAL`, `401→UNAUTHENTICATED`, `403→PERMISSION_DENIED`, + `404→UNIMPLEMENTED`, `429|502|503|504→UNAVAILABLE`, 그 외 `UNKNOWN`. + 이것을 wire header로 위조하지 않고 internal normalized failure로만 사용한다. +7. HTTP success만으로 RPC success를 기록하지 않는다. duplicate/conflicting/ + malformed terminal source는 provider incompatibility로 traffic을 닫는다. +8. Envoy의 upstream HTTP/2, filter order, route/idle/max-stream timeout과 buffering + profile을 확인한다. default route timeout으로 long stream이 잘렸는지 확인한다. +9. raw grpc-message/status-details를 ticket에 복사하지 않는다. + +### 8.2 Unary response + +1. OK status에서 response message가 exact 1개인지 확인한다. +2. `google.protobuf.Empty`도 zero-length payload data frame 하나여야 한다. + trailers-only OK + zero-message unary는 contract mismatch다. +3. two-or-more message/data-after-trailer/extra bytes를 contract mismatch로 닫는다. +4. generated decode 뒤 semantic schema/mapper가 통과했는지 확인한다. +5. failure/late generation에서 cache write가 0인지 확인한다. + +### 8.3 Server stream + +1. message/frame/total bytes/count/idle/total deadline을 확인한다. +2. bounded queue와 consumer backpressure를 확인한다. +3. duplicate/order/gap/resume token protocol을 확인한다. +4. event 전달 뒤 transport-only auto replay가 없었는지 확인한다. +5. overflow/gap/truncation에서 current aggregate를 complete success로 쓰지 않는다. +6. finite aggregate는 valid OK terminal status/completeness 뒤 atomic cache + commit인지 확인한다. +7. actual proxy buffering/flush와 affected browser cohort를 확인한다. + +### 8.4 Proxy/CORS + +- Envoy/BFF/selected gateway filter와 upstream route +- exact allow/expose headers +- credential/CSRF/Origin +- trailer preservation +- content type +- compression/message/deadline cap +- stream buffering + +actual proxy를 복구한다. client decoder를 느슨하게 만들어 provider drift를 +수용하지 않는다. + +## 9. Connect와 REST Gateway incident + +이 절은 VD-29/VD-30 runtime이 실제 조합된 operation에만 적용한다. + +### 9.1 Connect unary/server stream + +1. exact Connect-Web package/runtime, `CONNECT | GRPC_WEB`, JSON/binary, + POST/GET와 rpc kind row를 확인한다. content type으로 다른 protocol을 자동 + 선택하지 않는다. +2. Connect unary는 HTTP status + bounded Connect error, stream은 HTTP admission + + exactly-one final EndStream을 각각 authority로 사용한다. +3. missing/early/duplicate EndStream, terminal 뒤 data, malformed/oversize + envelope와 compression mismatch에서 call을 cancel하고 cache write를 막는다. +4. stock runtime이 whole unary body를 읽는 profile이면 edge encoded/decompressed + cap과 selected version의 actual overflow evidence를 확인한다. interceptor가 + raw-byte cap을 제공했다고 기록하지 않는다. +5. remaining total deadline이 0 이하면 runtime `timeoutMs=0`을 호출하지 않고 + local deadline failure로 닫는다. abort 뒤 command는 backend receipt로 + reconcile한다. +6. GET은 descriptor `NO_SIDE_EFFECTS`, input classification, URL cap, cache key, + `Vary`와 credential/preflight profile이 모두 일치하는지 확인한다. +7. raw Connect message/metadata/detail/debug를 ticket에 복사하지 않는다. + +### 9.2 Protobuf REST Gateway + +1. selected gateway kind/version과 descriptor, canonical HttpRule manifest, + ProtoJSON profile, OpenAPI와 runtime config digest를 coherent set으로 비교한다. +2. method/path/query/body/additional binding, path escaping과 response-body + projection drift를 확인한다. +3. ProtoJSON field name/default/enum/int64/bytes/null/presence/unknown behavior를 + N/N-1 fixture와 비교한다. +4. current REST envelope/status/error를 direct ProtoJSON body나 raw + `google.rpc.Status`로 잘못 decode했는지 확인한다. +5. `201/204/304/412`, ETag, idempotency, pagination과 safe error rewrite가 실제 + BFF/gateway owner의 contract인지 확인한다. transcoder가 자동 제공한다고 + 가정하지 않는다. +6. browser abort→gateway context→upstream gRPC→application work의 cancellation/ + deadline 전파를 확인한다. 이미 commit된 command는 receipt로 reconcile한다. +7. runtime response rewrite와 generated OpenAPI가 다르면 affected route admission을 + 닫고 coherent gateway/contract set으로 rollback한다. + +Connect/gRPC-Web/REST 사이 command replay는 금지한다. read-only registered +fallback도 old generation을 cancel한 뒤 새 logical query로만 시작한다. + +## 10. Schema/Mapper incident + +### 10.1 Current v1 available checks + +1. operation의 string schema ID가 installed Zod resolver에 있는지 확인한다. +2. envelope/payload parse와 mapper/domain factory 중 어느 current boundary에서 + 실패했는지 safe failure/diagnostics로 좁힌다. +3. raw DTO/value를 출력하지 않고 affected query cache write를 확인한다. +4. current registry에는 semantic fingerprint/provenance/typed mapper Result가 + 없으므로 이 값을 비교했다고 기록하지 않는다. +5. frontend release/runtime config/manifest의 current string contract coherence를 + 확인하고 known-good coherent release로 rollback한다. + +### 10.2 Target v2 composed 이후 + +1. operation → schema ID/fingerprint → mapper ID/version binding을 확인한다. +2. source artifact/generated output/runtime codec digest를 비교한다. +3. missing/null/enum/numeric/time/unknown-field change를 classification한다. +4. request strict와 response strip/reject 방향 정책을 확인한다. +5. provider encoded-transfer와 browser decoded-byte owner를 분리하고, + item/depth/node/string admission ceiling을 확인한다. +6. mapper expected semantic failure가 throw/`UNKNOWN_FAILURE`로 소실되지 않았는지 + 확인한다. +7. cache write와 partial collection이 0인지 확인한다. +8. raw validation value/path/DTO를 log에 남기지 않는다. +9. incompatible schema/mapper/query cache epoch를 coherent set으로 rollback/clear한다. + +## 11. Server State Cache incident + +### 11.0 Current v1 available checks + +1. Query retry off, global stale/gc, AbortSignal과 affected query의 + cancel/invalidate/local clear를 확인한다. +2. current arbitrary key/canonicalize와 hook-local pending Promise를 관측하고 + collision/incorrect join cohort cache를 clear한다. +3. current whole-snapshot optimistic rollback이 다른 commit을 덮었는지 backend + authoritative state로 reconcile한다. +4. strict key/identity token, cursor page와 optimistic layer CAS는 아직 없으므로 + 해당 fixture/control이 실행됐다고 기록하지 않는다. +5. 필요하면 known-good/no-optimistic release로 rollback한다. operation별 runtime + optimistic switch가 있다고 가정하지 않는다. + +아래 10.1~10.4는 target v2 cache/mutation coordinator가 composed된 뒤 활성화한다. + +### 11.1 Wrong/stale scope — target v2 + +1. VD-13 frozen scope projection과 runtime generation을 확인한다. +2. account/logout/release switch 전 query를 cancel했는지 확인한다. +3. late response/mapper/cache write fence를 확인한다. +4. raw account/resource ID를 key에서 찾거나 출력하지 않는다. +5. current runtime cache를 clear/remount하고 backend authoritative data를 refetch한다. + +### 11.2 Query key collision — target v2 + +1. arbitrary key API가 남아 있는지 확인한다. +2. undefined/NaN/Date/class/accessor/cycle/sparse/oversize fixture를 실행한다. +3. collision 가능 cohort의 cache를 clear한다. +4. strict key codec/profile 없는 신규 query traffic을 닫는다. +5. URL/document/protobuf serialization을 key로 급히 넣지 않는다. + +### 11.3 Mutation ordering/rollback — target v2 + +1. 전체 validated semantic input의 runtime-private exact equality + identity + token이 아닌 hook instance로 join/reject를 판정했는지 확인한다. +2. `JOIN_IDENTICAL | REJECT_DUPLICATE | ALLOW_INDEPENDENT` 결과와 fetch/Promise + 횟수가 profile대로인지 확인한다. +3. runtime/scope coordinator에서 same logical key의 two-hook/out-of-order + execution을 확인한다. +4. whole snapshot rollback이 다른 committed mutation을 덮었는지 확인한다. +5. 자기 optimistic layer만 base cache revision CAS로 제거/역적용했는지 확인한다. +6. CAS miss/unknown effect에서는 다른 commit을 보존하고 invalidate/refetch한다. +7. optimistic update를 끄고 authoritative pending/refetch UX로 downgrade한다. +8. server commit 뒤 invalidation failure는 command failure로 바꾸지 않는다. + +### 11.4 Cache size/pagination — target v2 + +1. result/page/item/estimated-byte cap을 확인한다. +2. identity intern entry/total canonical bytes/active lease와 GC/terminal release + leak를 확인한다. token/canonical bytes 자체는 출력하지 않는다. +3. unbounded collection/stream history를 cancel/evict한다. +4. binary/generated DTO를 cache value로 넣지 않았는지 확인한다. +5. max pages와 page eviction/refetch policy를 적용한다. + +## 12. Rollback + +target v2 coordinator가 composed된 operation: + +```text +TrafficAdmission=DISABLED + -> cancel reads/streams + -> reconcile command effects + -> fence runtime generation + -> clear suspect current-scope memory cache + -> detach provider/listener/auth collaboration + -> restore coherent frontend + contract artifacts + provider/backend + -> run compatibility/provider probes + -> canary +``` + +current v1은 위 control이 있다고 가정하지 않는다. 신규 promotion을 중지하고 +known-good release를 rollback하며, 실제 backend/BFF owner가 제공하는 route/kill +switch가 있을 때만 provider traffic을 차단한다. v1 fallback은 unexpired +provider/security evidence, incident가 v1/shared boundary 밖이라는 증거와 auth +fail-close/final invariant 유지가 모두 있을 때만 선택하고, 아니면 safe +unavailable로 닫는다. + +금지: + +- GraphQL/Connect/gRPC command를 REST 또는 gateway route로 자동 replay +- schema number/digest만 낮추기 +- incompatible cache value를 cast/loose parse +- validation/mapper를 끄고 traffic 유지 +- unknown command effect를 optimistic rollback만으로 해결 +- auth unavailable을 anonymous request로 downgrade + +## 13. Security containment + +다음은 security owner에게 즉시 escalate한다. + +- credential/CSRF/idempotency/presigned material이 cache/log에 노출 +- auth owner가 endpoint/method/body를 변경 +- unauthorized request가 실제 전송 +- GraphQL arbitrary document/field authorization bypass +- gRPC metadata/status details payload 노출 +- cross-account cache observation + +신규 traffic을 닫고 credential/session/provider key/manifest를 필요한 scope에서 +revoke/rotate한다. 삭제/보존은 incident response 정책을 따른다. + +## 14. Drill과 evidence + +분기 또는 major contract 변경 전: + +- REST auth unavailable/final-request mutation +- total deadline/retry/401 single-flight +- oversized/truncated/schema/mapper +- idempotency concurrent replay/unknown effect +- cursor loop/ETag 304/412 +- GraphQL persisted miss/partial/cost +- gRPC missing/conflicting terminal status source, corrupt frame와 stream gap +- query key collision/account late result +- optimistic concurrent rollback +- kill switch/coherent rollback +- GraphQL/Connect/gRPC/Gateway generated dependency/provider removal + +evidence record: + +```text +protocol/operation/provider/profile +frontend/backend/router/proxy/browser version +contract artifact compatibility outcome +fault/drill ID +safe result +started/completed timestamp +owner/reviewer +artifact retention/expiry +``` + +raw payload, URL, document, protobuf와 identifier를 evidence record에 넣지 않는다. + +## 15. 관련 문서 + +- [VD-23 API transport selection과 REST execution](../architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md) +- [VD-24 Runtime Schema와 boundary Mapper](../architecture/decisions/VD-24-runtime-schema-and-boundary-mapper.md) +- [VD-25 Server State Cache lifecycle](../architecture/decisions/VD-25-server-state-cache-lifecycle.md) +- [VD-26 Persisted GraphQL operation](../architecture/decisions/VD-26-persisted-graphql-operation.md) +- [VD-27 gRPC-Web unary와 server stream](../architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md) +- [VD-29 Connect-Web와 browser Protobuf runtime](../architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md) +- [VD-30 Protobuf contract와 REST Gateway](../architecture/decisions/VD-30-protobuf-contract-and-rest-gateway.md) +- [VD-13 Client cache scope와 persistence](../architecture/decisions/VD-13-client-cache-scope-and-persistence.md) diff --git a/docs/operations/browser-file-storage-recovery.md b/docs/operations/browser-file-storage-recovery.md new file mode 100644 index 0000000..248c5fe --- /dev/null +++ b/docs/operations/browser-file-storage-recovery.md @@ -0,0 +1,514 @@ +# Browser file and storage recovery runbook + +이 runbook은 VD-11 capability를 실제 프로젝트에서 선택한 뒤 사용하는 +production 운영 template이다. 현재 skeleton에는 native reference runtime이 +`AVAILABLE_NOT_COMPOSED`로 존재하지만 제품 dataset과 bootstrap에는 연결되지 +않았으므로 이 문서 자체가 현재 서비스 incident procedure를 활성화하지 않는다. +설치 branch는 owner, dashboard, alert threshold, kill-switch key, +backend/storage 연락처와 evidence 위치를 채워야 한다. + +VD-15가 설계한 origin-wide pressure/GC coordinator, OPFS/Cache forward +migration, OPFS real readiness preflight, bounded Cache cleanup과 preview decode +probe는 현재 `DESIGNED_NOT_IMPLEMENTED`다. 아래 절차에서 이 기능을 전제로 한 +자동 조치는 해당 runtime이 구현·조합된 제품에서만 실행한다. 현재 reference +primitive를 coordinator 완료 증거로 사용하지 않는다. + +## 1. 공통 원칙 + +incident 중에도 다음 작업은 금지한다. + +- 자동 또는 일괄 `deleteDatabase()` +- origin의 모든 `caches.keys()` 삭제 +- user-authored/unsynced IndexedDB 또는 OPFS data 자동 purge +- schema version downgrade +- 무한 reload/update loop +- filename, path, record/object key, URL/query, digest, body를 incident log에 복사 +- fake/unavailable adapter로 바꾸고 정상 복구로 선언 + +공통 안전한 degrade 순서는 다음과 같다. + +```text +선택 capability의 신규 write/activation 중지 + -> 진행 중 operation 정리 + -> read-only + -> online-only/network-only + -> authoritative server re-read +``` + +read-only나 online-only가 사용자 작성 내용을 잃게 한다면 먼저 export/sync +경로를 제공하고 UI에 명시적으로 알린다. + +## 2. 최초 10분 + +1. 영향 release, browser family/version, capability와 최초 발생 시각을 확인한다. +2. user dismissal과 실제 failure를 분리한다. +3. safe metric의 `failure_kind`, `phase`, `strategy`, `backend`, `size_bucket`, + `release_id`만으로 범위를 좁힌다. +4. integrity mismatch, authorization/cache classification breach, committed + user-data corruption이면 신규 write/cache activation을 즉시 중지한다. +5. kill switch를 가장 좁은 범위로 적용한다. +6. current와 N-1 release의 schema/cache compatibility를 확인한다. +7. 복구 전후 diagnostic count와 browser smoke evidence를 저장한다. + +심각도 시작점: + +| 상황 | 시작 심각도 | +| --- | --- | +| authorization 우회, private/auth response cache, content integrity mismatch | P1 | +| committed user-authored data unreadable/corrupt, widespread migration failure | P1 | +| upload/download 실패 급증, IndexedDB blocked 증가, OPFS reconstructable loss | P2 | +| enhanced picker만 실패하고 baseline 정상 | P3 | + +실제 조직 severity policy가 있으면 그 정책이 우선한다. + +## 3. File/picker/download와 별도 upload example + +### 신호 + +- `PERMISSION_DENIED`, `NOT_READABLE`, `INTEGRITY_FAILED` +- closed byte stream이 failure chunk 뒤 추가 byte를 내보내거나 raw exception을 throw +- browser handoff는 정상인데 confirmed save가 감소 +- large download에서 memory/stall failure +- 별도 backend upload를 설치한 feature만 session expiry/conflict, + quarantined/available 전환 정체 + +### 확인 순서 + +1. 사용자가 실제 취소한 flow가 error로 집계되지 않았는지 확인한다. +2. user activation 전에 비동기 작업이 추가됐는지 확인한다. +3. native input fallback이 Chromium/Firefox/WebKit에서 동작하는지 확인한다. +4. count, per-file, total bytes, MIME/extension/signature policy version을 확인한다. + raw policy가 port caller에서 들어오지 않고 composition registry의 정확한 + `FilePolicyReference` identity로 resolve되는지, verification receipt가 같은 + profile/file snapshot에 binding됐는지 확인한다. 같은 + `policyKey`/`intention`을 가진 새 객체는 거절되어야 한다. +5. File/OPFS/Cache/download byte source가 chunk마다 closed Result를 반환하고 첫 + failure에서 consumer와 producer 모두 종료되는지 확인한다. +6. download `Content-Type`, `Content-Disposition`, CORS exposed header와 + short-lived capability expiry를 확인한다. browser-managed resolver receipt가 + caller의 branded receipt와 정확히 같고 resource ID, media type, safe + extension, server max, optional digest와 expiry를 synchronous하게 정확히 + binding하는지 확인한다. + 현재 save path는 whole-object foreground streaming이며 Range resume가 아니다. + partial destination에 append하지 않고, Range를 선택한 제품은 VD-14와 + browser-transfer runbook의 별도 절차를 따른다. +7. whole-buffer API 또는 object URL lease 누수가 배포됐는지 확인한다. +8. image preview를 설치했다면 object URL 발급 전에 header dimensions, pixel, + decoded-byte와 animation/static-only 제한을 검사하는 VD-15 probe가 실제 + 조합됐는지 확인한다. 현재 byte/signature 검사만으로 decode safety를 + 주장하지 않는다. +9. upload를 별도 설치했다면 backend 401/403/409/413/415/422/429/5xx, + CORS/preflight, session TTL/clock skew, part checksum, idempotency, orphan + cleanup과 quarantine queue를 그 feature runbook에서 확인한다. + +### 안전한 조치 + +- enhanced open/save picker off → native input/direct authorized download +- active-content preview off +- preview pixel/decode/frame probe가 없거나 실패하면 object URL preview off +- client-generated large download off → server-side artifact generation +- broken stream adapter off → bounded fallback 또는 operation 중지; raw exception을 + 성공/EOF로 변환하지 않음 +- upload를 별도 설치했다면 multipart concurrency/part size 하향, + resumable off → approved hard cap의 simple upload, 문제 MIME 임시 차단 +- expired upload session은 authorization 후 새 session; 기존 session을 + 재활성화하지 않음 + +성공 판정: + +- user cancellation 제외 file/download start 대비 success rate 회복 +- integrity/authorization mismatch 0 +- 세 browser baseline smoke 통과 +- object URL/file ref cleanup count 0 leak +- preview를 설치한 경우 malformed/oversize/animated image가 object URL 발급 + 전에 거절되고 native decode resource 0 leak +- upload를 별도 설치했다면 orphan session/quarantine backlog SLO와 session + cleanup 0 leak + +## 4. IndexedDB upgrade blocked/versionchange + +### 신호 + +- `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가 +- `versionchange` 뒤 connection이 남음 +- repeated reload/update loop +- open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch + +### 확인 순서 + +1. target schema와 current/future schema, release ID를 확인한다. +2. registry-issued `authorityToken`, `namespaceToken`, `partitionToken`이 같은 + dataset의 승인된 값인지 확인한다. readable namespace/account/business ID를 + token이나 physical DB name에 넣지 않는다. +3. physical DB name이 세 opaque token에서만 파생됐는지, caller + `databaseNameAssertion`이 exact match인지 확인한다. +4. governance store의 immutable scope + full `BrowserStoragePolicy` binding을 + versionchange, post-open, maintenance 모두에서 확인한다. raw token이나 binding + 내용을 incident log에 복사하지 않는다. +5. old tab, worker 또는 test/debug context가 connection을 닫지 않는지 확인한다. +6. 모든 connection에 `versionchange`/forced-close handler가 설치됐는지 확인한다. +7. BroadcastChannel prepare hint는 참고만 하고 실제 open/connection registry를 + 확인한다. +8. N-1 bundle이 future schema를 read-only/online-only로 처리하는지 확인한다. + +### 안전한 조치 + +- 신규 offline write와 background migration 중지 +- 사용자에게 다른 tab close와 명시적 retry UI 제공 +- current connection을 draining 후 close +- upgrade release rollout 중지 또는 compatible N-1 online-only로 rollback +- binding mismatch를 caller DB 이름 변경, policy 덮어쓰기 또는 DB 전체 삭제로 + 우회하지 않고 registry/composition 오류를 forward fix + +remote tab 강제 종료, database 삭제, 무한 reload는 금지한다. + +성공 판정: + +- connection registry와 blocked request 0 +- fresh tab과 two-tab versionchange/blocked smoke 통과 +- future schema에서 N-1이 destructive write 없이 기동 +- open과 maintenance에서 동일 scope/policy binding 검증 통과 + +## 5. IndexedDB migration/corruption + +### 신호 + +- `MIGRATION_FAILED`, unknown codec, schema postcondition failure +- `CORRUPT_DATA`, `NOT_READABLE`, forced close +- transaction request success 후 commit failure +- `LIMIT_EXCEEDED`: dataset logical byte/receipt budget 초과 +- lifecycle batch가 proof 없이 실행되거나 TTL/UNTIL_SYNCED record가 잘못 노출·삭제 +- migration/lifecycle batch가 500 rows 또는 30,000ms를 넘거나 checkpoint 없이 중단 + +### 확인 순서 + +1. DDL schema version과 record codec version을 구분한다. +2. migration ID, last safe keyset checkpoint, revision fence, + `budgetExhausted`, processed/remaining bucket과 old-writer drain proof를 + 확인한다. +3. caller budget과 구현 absolute budget(500 rows/30,000ms), async operation 사이 + deadline check를 확인한다. +4. codec `measureStoredBytes`, retention sidecar `measuredBytes`, + `dataset-budget.usedBytes/receiptCount`가 CAS/remove/lifecycle/migration + transaction과 함께 갱신됐는지 확인한다. 이 값은 native physical size가 아니다. +5. actual `StoredRecord`가 `key/codecVersion/revision/payload`만 포함하고 + `writtenAtEpochMs`, synchronization, `measuredBytes`, `eligibleAtEpochMs`는 + `retentionStore` sidecar에 분리돼 있는지 확인한다. +6. receipt retention이 31일 이하인지, configured `maxIdempotencyReceipts`가 + 1,000,000 이하인지, prune/purge가 receipt count를 같은 transaction에서 + 감소시켰는지 확인한다. +7. TTL record가 sweep 전 read에서 `EXPIRED_RESOURCE`, query에서 skip되는지, + `UNTIL_SYNCED`는 `CONFIRMED`만 eligible한지 확인한다. +8. lifecycle action마다 composition의 + `authorizeLifecycle(action, scope, frozen policy)`가 호출됐고 opaque proof가 + 검증 후 비영속·비관측 상태로 폐기됐는지 확인한다. +9. historical schema fixture에서 같은 migration을 재현한다. +10. reconstructable, synced copy, unsynced/user-authored 분류를 확인한다. +11. partial batch가 row/sidecar/budget/checkpoint와 원자적으로 rollback됐는지 + 확인한다. +12. full partition purge가 record/retention/idempotency와 등록된 모든 + `lifecycleMetadataStores`를 bounded transaction으로 정리하고 immutable + governance binding은 유지하는지 확인한다. +13. raw record, opaque scope/proof를 ticket/log에 복사하지 않고 승인된 local + recovery tooling만 + 사용한다. + +### 안전한 조치 + +- migration 중지, repository read-only/online-only +- reconstructable만 purge/re-fetch +- synced copy는 revision 확인 후 rehydrate +- unsynced/user-authored는 quarantine marker + export/sync/recovery +- adapter bounded reopen 최대 한 번 +- schema/codec compatible fix를 새 forward migration으로 배포 +- migration은 old-writer drain authority를 복구한 뒤 더 작은 bounded batch로 + checkpoint부터 resume +- lifecycle authority가 불명확하면 deletion을 재시도하지 않고 read-only; + proof를 운영자가 수동 생성·재사용하지 않음 + +database 전체 삭제는 data owner가 export/recovery와 영향 scope를 승인한 별도 +action이다. + +성공 판정: + +- historical fixture의 fresh, interrupted, resume, replay가 모두 통과 +- corrupted user-authored row 자동 삭제 0 +- migration canary failure 0 +- N-1 rollback/read-only smoke 통과 +- dataset budget/sidecar/receipt count 재검증과 bounded lifecycle fixture 통과 + +## 6. Origin quota, persistence denial, storage eviction + +### 신호 + +- usage ratio pressure bucket 증가 +- `QUOTA_EXCEEDED`, `STORAGE_EVICTED` +- startup sentinel과 logical manifest 불일치 +- persistence request denied + +### 확인 순서 + +1. `estimate()`가 rough origin total임을 전제로 IndexedDB/OPFS/Cache 공동 증가를 + 확인한다. +2. exact free space를 계산하거나 예약했다고 가정하지 않는다. +3. expired/reconstructable, synced copy, user-authored 사용량 bucket을 분리한다. +4. IndexedDB의 exact logical dataset budget과 origin-wide `estimate()`를 + 구분한다. 전자는 codec measurement + conservative reservation이고 native + physical usage/free space가 아니다. +5. private mode, WebView, browser storage policy와 user clear 여부를 확인한다. +6. persistence denial을 capability failure가 아닌 best-effort outcome으로 처리했는지 + 확인한다. + +### 안전한 조치 + +현재 공통 coordinator가 없으면 다음 순서를 자동 실행하거나 “exact one retry”를 +보장한다고 기록하지 않는다. 제품 owner가 store별 primitive와 retention policy를 +확인해 수동/feature-local로 안전하게 수행하거나 write를 read-only/online-only로 +닫는다. VD-15 coordinator가 조합된 경우에만 동일 mutation owner 안에서 다음을 +bounded orchestration으로 실행한다. + +1. 신규 speculative/reconstructable write admission 중지 +2. incomplete candidate와 stale staging 제거 +3. expired reconstructable record/object 제거 +4. grace가 지난 unreferenced immutable chunk 제거 +5. inactive public cache release 제거 +6. authoritative revision/sync receipt가 확인된 synced copy compact +7. unsynced/user-authored sync/export UI +8. 필요하면 offline read-only/online-only + +각 GC invocation은 기본 100 items/5초, 구현 절대 상한 500 items/30초와 opaque +cursor를 지킨다. failed write의 자동 retry는 다음 조건이 모두 맞을 때만 정확히 +한 번 허용한다. + +- 첫 write가 실제 quota failure로 원자적 rollback됨 +- 같은 idempotency key, revision fence와 payload digest +- 외부 side effect/cross-store publish가 commit되지 않음 +- bounded GC가 실제 candidate를 제거했거나 pressure가 내려감 +- 현재 revision/generation 재확인과 새 admission token 발급 + +두 번째 failure나 effect certainty가 unknown이면 retry하지 않는다. + +`persist()`를 boot나 반복 loop에서 요청하지 않는다. 사용자가 durable offline +기능을 선택하고 unsynced data 보호 이유를 이해하는 동작에서만 요청한다. + +성공 판정: + +- coordinator를 설치한 경우 pressure가 hysteresis lower bound 아래 +- partial logical/physical marker mismatch는 rehydrate 또는 explicit + read-only/export-required로 닫힘 +- 모든 local marker가 소실된 경우 backend opaque installation epoch로 + authorization 후 구분하거나 `storage-reset-possible` ambiguity UX를 표시함; + 이를 “첫 설치로 판별 완료”라고 기록하지 않음 +- user-authored 자동 purge 0 +- storage clear 뒤 reconstructable rehydrate/explicit degraded UI 통과 + +## 7. OPFS partial write/corruption/worker crash + +현재 capability/property probe와 native conformance test는 존재하지만 composition +readiness에서 worker/lock/journal/small write-read-delete-cleanup을 한 번에 +검증하는 real preflight는 아직 없다. preflight success를 운영 전제에 넣으려면 +VD-15 목표 runtime을 먼저 구현한다. + +### 신호 + +- journal이 `PREPARING`/`FILES_READY`에 오래 머묾 +- committed logical row와 physical manifest/file 불일치 +- digest/length mismatch +- worker crash, handle lock timeout, `NoModificationAllowedError` +- sensitive policy maintenance의 `POLICY_REJECTED`, proof replay/expiry 또는 + authority provider/consumer unavailable + +### 확인 순서 + +1. mutation Web Lock owner와 timeout을 확인한다. +2. worker protocol/version과 모든 sync handle의 `finally close`를 확인한다. +3. journal operation phase별 count와 age bucket을 확인한다. +4. VD-15 forward migrator를 설치하지 않은 현재 v1 reference runtime에서는 + physical layout이 + `/ca-frontend-opfs-v1/authorities////` + 아래의 `objects`, `chunks/sha256`, `staging`인지 확인한다. readable namespace, + filename, account ID가 path segment이면 신규 write를 중지한다. migrator를 + 설치한 조합에는 이 v1 path를 authoritative target 조건으로 적용하지 않는다. +5. journal의 physical scope binding과 logical namespace binding이 둘 다 존재하고, + 같은 scope/policy fingerprint를 가리키는지 확인한다. 한쪽만 없거나 mismatch면 + 자동 재생성하지 않는다. +6. runtime/byte-store composition의 frozen scope와 policy namespace가 일치하는지 + 확인한다. +7. `LOGOUT`, `UNTIL_SYNCED`, `ACCOUNT_DELETION` maintenance에는 composition의 + `requestMaintenanceAuthority` provider와 `consumeMaintenanceAuthority` + consumer가 둘 다 주입됐는지 확인한다. application request에는 proof 필드가 + 없어야 한다. +8. provider가 exact reason/frozen scope/frozen policy에 묶인 새 proof와 최대 + 5분 expiry를 발급하고, consumer가 같은 binding을 원자적으로 검증·consume해 + replay를 거절하는지 확인한다. proof/token/raw scope는 log에 복사하지 않는다. +9. physical path/ID/digest를 log에 복사하지 않고 reconciliation tool이 + manifest schema와 digest를 검증하게 한다. +10. logical row가 commit authority인지 확인한다. +11. VD-15 forward migrator를 설치했다면 source/target physical layout와 journal + version, migration checkpoint, copy-on-write generation, publish authority, + rollback window와 N-1 reader 결과를 확인한다. + +### recovery + +- `PREPARING`: partial staging 검증 후 idempotent resume 또는 purge +- `FILES_READY`: expected generation/digest 일치 시 logical commit, 아니면 + quarantine +- `COMMITTED` + file missing/corrupt: reconstructable만 server rehydrate; + user-created private는 read-only/export/recovery +- stale staging/orphan chunk는 grace period와 bounded mark/sweep 후 제거 +- lock/worker 문제면 OPFS write off → approved capped fallback 또는 online-only +- scope binding mismatch면 affected scope를 read-only로 격리하고 registry/ + composition을 forward fix; 다른 token path로 bytes를 이동하거나 추측 복구 금지 +- sensitive maintenance authority provider/consumer가 없거나 replay/expiry + 검증이 실패하면 삭제를 재시도하지 않고 read-only로 전환한다. 운영자가 proof를 + 직접 생성·주입·재사용하지 않는다. +- forward migration 중단이면 target generation을 publish하지 않고 bounded + checkpoint에서 resume한다. publish 뒤에는 source generation을 rollback + window까지 보존하고, N-1이 target을 이해하지 못하면 read-only/online-only로 + 닫는다. source/target을 in-place 혼합하거나 layout version을 내리지 않는다. + +committed object를 sync handle로 in-place repair하지 않는다. 새 generation에 +정상 object를 만들고 generation CAS로 logical pointer를 전환한다. + +성공 판정: + +- unresolved old journal 0 +- committed read integrity failure 0 +- worker/handle registry 0 leak +- 각 journal phase fault injection과 concurrent put/delete/GC 통과 +- sensitive maintenance의 fresh proof consume 성공, replay/expired/mismatched + proof와 provider/consumer 누락은 모두 deletion 전 fail-closed + +## 8. Cache Storage/Service Worker stale or poisoned release + +public static Cache release runtime은 존재하지만 cursor/count/deadline이 있는 +bounded inspect/cleanup과 Service Worker waiting/client-drain controller는 아직 +없다. Service Worker는 제품이 PWA/offline fetch를 별도로 선택한 경우에만 아래 +worker 절차를 적용한다. + +### 신호 + +- incomplete candidate activation +- integrity/type/size mismatch +- auth/private/no-store/opaque policy rejection +- Service Worker를 별도 선택한 조합의 stale worker/update/reload loop +- active asset miss 또는 offline boot failure + +### 확인 순서 + +1. hosting/CDN cache와 browser Cache Storage를 별개로 확인한다. +2. active/candidate/previous release ID와 manifest digest를 확인한다. +3. candidate 모든 entry의 status, `expectedContentType`, size, integrity 검증 + marker를 확인한다. canonical manifest digest가 정규화된 Content-Type까지 + binding하는지 확인한다. +4. 실제 response의 정규화된 `Content-Type`과 manifest + `expectedContentType`이 정확히 일치하는지 확인한다. body digest가 맞아도 type + mismatch candidate는 폐기한다. +5. config/release manifest/auth/API가 network-only인지 확인한다. +6. query/Vary exact match와 `ignoreSearch/ignoreVary` 미사용을 확인한다. +7. Service Worker를 별도 선택했다면 old controlled clients와 current + waiting/active worker 상태를 확인한다. +8. Service Worker를 별도 선택했다면 unregister만 하고 owned cache cleanup을 + 빠뜨리지 않았는지 확인한다. +9. cleanup caller가 cache name, release registry ID 또는 retain list를 제출할 수 + 없고 보존 집합이 verified active pointer와 composition retention에서만 + 계산되는지 확인한다. +10. active pointer/release marker control JSON이 정확히 2 MiB(2,097,152 bytes) + cap의 stream reader와 strict UTF-8/runtime schema를 통과하며 oversized body를 + 초과 지점에서 cancel하는지 확인한다. + +### 안전한 조치 + +static Cache-only 조합: + +- 신규 candidate activation/cache write 중지 +- verified current 또는 previous release로 explicit rollback +- incomplete candidate와 runtime-public cache부터 owned-prefix cleanup +- 현재 cleanup/inspect가 cache 개수에 대해 unbounded임을 고려해 incident + invocation을 추가 budget으로 반복 실행하지 않는다. VD-15 bounded cursor + runtime 구현 전에는 큰 namespace를 자동 sweep하지 않는다. + +Service Worker를 별도 선택한 조합에서만 추가: + +- fetch interception을 network-only kill switch로 전환 +- new worker activation 중지 +- 필요하면 unregister + 다음 navigation cleanup migration +- old controlled client가 drain되기 전 해당 release cleanup 금지 + +hosting/CDN purge는 두 branch와 분리된 provider 절차로 실행한다. + +auth/private response가 cache에 실제 저장됐을 가능성이 있으면 P1로 승격하고 +owned affected namespace를 정확히 식별해 제거한다. origin의 unrelated cache는 +삭제하지 않는다. + +성공 판정: + +- static Cache-only 조합은 verified active/previous release와 candidate cleanup이 + 일관되고 network-only fallback smoke를 통과 +- Service Worker를 별도 선택한 조합만 clean/old controlled client가 같은 + verified release로 수렴하고 online/offline/update/rollback smoke를 통과 +- auth/private cache entry 0 +- update/reload loop 0 +- config/release manifest network-only header contract 통과 + +## 9. Drill evidence + +실제 capability를 project catalog의 `INSTALLED`로 바꾸기 전에 최소 다음 drill을 +실행하고 release-specific evidence를 저장한다. 이 legacy selection label은 +primary status `COMPOSED`와 별도 +`TrafficAdmission/RuntimeHealth/PromotionEvidence` 축이 준비된 제품 상태를 +뜻하며 새로운 primary status literal이 아니다. 각 축의 literal과 +contract/provider/browser/operations component gate에서 `PromotionEvidence`를 +계산하는 규칙은 completion ledger를 그대로 따른다. + +| drill | 필수 증적 | +| --- | --- | +| 별도 upload workflow를 설치한 경우 session abort/expiry/checksum | orphan cleanup, quarantine state, retry/idempotency | +| large save abort/integrity | partial commit 0, memory high-water | +| IndexedDB two-tab upgrade blocked | close/retry UX, connection leak 0 | +| IndexedDB governance/lifecycle | opaque scope binding, logical budget/TTL, proof discard, receipt cap | +| interrupted historical migration | old-writer drain proof, 500 rows/30s budget, checkpoint resume, atomicity, N-1 fallback | +| quota/persistence denied/storage clear | reconstructable GC, user data 보존, degraded UI | +| VD-15 coordinator를 설치한 경우 pressure/one-retry | hysteresis, bounded cursor/deadline, store priority와 retry 최대 1회 | +| OPFS journal phase crash/scope mismatch/maintenance authority | bidirectional binding, actual opaque layout, invisible partial object, reconcile summary, integrity, provider/consumer one-time proof | +| OPFS preflight/migration을 설치한 경우 | worker/lock/journal/small operation cleanup, interrupted copy-on-write와 N-1 fallback | +| cache candidate poison/update rollback | expectedContentType binding, active release 불변, previous rollback, caller retain 부재, 2 MiB control cap, auth rejection | +| bounded Cache/SW lifecycle을 설치한 경우 | cursor/count/deadline, waiting activation, controlled-client drain과 network-only rollback | +| preview probe를 설치한 경우 | pixel/decoded-byte/static-only rejection, timeout와 decode resource cleanup | + +evidence에는 raw 사용자 data를 포함하지 않는다. 허용 metadata는 release/build, +browser engine/version, fixture ID, fault phase, bounded counts/buckets와 PASS/FAIL +결과뿐이다. + +promotion 직전에는 다음 repository evidence도 함께 보존한다. + +- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 동일 + 14개 testcase set(File 2, IndexedDB 4, OPFS/Cache/StorageManager 각 1, + cross-context invalidation 2, presigned streaming download/multipart + upload/Image CDN 각 1)을 실제 실행해 총 42개이며 failure/error/skipped가 모두 + 0이어야 한다. + `verify:browser-capability-evidence`가 engine 집합과 testcase 동일성을 + 기계적으로 검증한다. 현재 artifact는 Chromium/Firefox 14개씩 총 28개가 + 통과했지만 WebKit 실행에 필요한 native libraries(예: + `libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, + `libavif.so.16`과 WPE 계열)가 이 host에 없으므로 아직 promotion 가능 상태가 + 아니다. +- `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가 + production chunk에 없음을 `check:optional-recipes`로 검증한다. +- `check:browser-file-storage-boundaries`로 native API 경계 위반을 막고, + `test:browser-file-storage-removal`로 runtime과 전용 gate를 제거한 격리 copy가 + base typecheck/lint/architecture/test/build/catalog/CI contract를 통과함을 + 검증한다. +- catalog의 `referenceRuntime.status`는 `AVAILABLE_NOT_COMPOSED`, + `productionComposition`은 `false`로 유지한다. dataset owner가 모든 policy와 + evidence를 승인하고 실제 composition을 추가하기 전에는 `INSTALLED`로 + 해석하지 않는다. + +## 10. 관련 문서 + +- [Browser data capability completion ledger](../architecture/browser-data-capability-completion-ledger.md) +- [Browser file and origin-storage platform](../architecture/browser-file-and-origin-storage.md) +- [VD-11 Browser file and origin-storage 경계](../architecture/decisions/VD-11-browser-file-and-origin-storage.md) +- [VD-15 Origin storage lifecycle and migration](../architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md) +- [VD-14 Resumable download와 background download](../architecture/decisions/VD-14-resumable-download-and-background-transfer.md) +- [Browser transfer recovery](./browser-transfer-recovery.md) diff --git a/docs/operations/browser-transfer-recovery.md b/docs/operations/browser-transfer-recovery.md new file mode 100644 index 0000000..577b2dd --- /dev/null +++ b/docs/operations/browser-transfer-recovery.md @@ -0,0 +1,320 @@ +# Browser transfer and Image CDN recovery + +이 runbook은 presigned capability, multipart/resumable upload, streaming download와 +Image CDN reference runtime을 제품에 조합한 경우에만 적용한다. skeleton의 기본 +bootstrap은 이 runtime을 실행하지 않는다. + +현재 Range resumable download와 top-level transfer/Image descriptor composition은 +각각 `DESIGNED_NOT_IMPLEMENTED`다. app-managed background download와 upload는 +각각 `NOT_SELECTED`, 그 cross-browser guarantee는 `PLATFORM_LIMITED`다. 아래에서 +이 기능을 다루는 절차는 VD-14/VD-16 구현과 제품 조합이 완료된 뒤에만 +활성화한다. 기존 whole-object streaming, foreground upload resume나 +browser-managed handoff를 그 기능의 운영 증거로 사용하지 않는다. + +## 1. 관측 신호 + +허용된 aggregate 신호: + +- capability issue/claim의 success, expired, policy-rejected bucket +- upload session create/resume/reconcile/complete/abort outcome +- part size/count/concurrency와 retry bucket +- download expected/actual byte bucket과 truncated/overrun/integrity outcome +- Image CDN preset/format/width/candidate bucket과 policy rejection +- session age, orphan count, quarantine backlog와 promotion latency + +금지: + +- presigned URL, query, signed request/response header +- resource/session/asset/account ID와 object key +- file name, local path, raw ETag, checksum, receipt/token의 실제 값 + +strict upload checkpoint의 protocol-defined SHA-256 fingerprint/part checksum과 +bounded opaque non-authorizing part receipt는 server reconcile을 위한 durable +예외다. account partition/retention 안에서만 보존하고 관측 신호나 ticket에는 +절대 투영하지 않는다. +- raw backend/body/DOMException message와 stack + +## 2. 초기 분류 + +| 증상 | 우선 확인 | +| --- | --- | +| capability expiry/403 급증 | BFF/object-store clock, signer credential rotation, expiry ceiling | +| CORS/preflight 실패 | exact origin, method, signed headers, exposed receipt/checksum headers | +| redirect/network failure | proxy/CDN redirect 변경, `redirect:error`, URL origin/path policy | +| 특정 part 반복 실패 | exact offset/length/checksum/idempotency, capability expiry | +| resume conflict | server status와 checkpoint revision/part binding | +| status 404/410 반복 | terminal checkpoint 제거와 새 session 생성 여부 | +| complete 뒤 file 불일치 | ordered parts, checksum type, ETag 오해, scan/promotion | +| download truncation/overrun | capability length, content encoding, proxy buffering/transform | +| destination partial save | picker writable abort/close, integrity-before-close | +| image policy rejection 급증 | asset revision, preset registry, dimensions, format/CDN origin | +| stale/broken private image | lazy load 시점과 signed URL minimum lifetime | +| image timeout/decode 실패 급증 | encoded cap, pre-decode metadata, decoded-byte ceiling, probe deadline | +| CDN cache poison 의심 | immutable revision, cache key, `Vary`, source asset registry | + +## 3. Presigned capability + +1. 신규 capability 발급을 중지할 kill switch가 동작하는지 확인한다. +2. BFF와 object storage의 clock skew와 signer credential lifetime을 확인한다. +3. whole-object control plane이 versioned target을 구현한 제품이면 request, + response, vault와 executor의 `PRESIGNED_TRANSFER_V1` exact match 및 + unknown/missing version rejection을 확인한다. 현재 reference wire에 이 + target이 아직 없으면 구현되지 않은 protocol을 통과했다고 기록하지 않는다. +4. capability가 method, object/part, content constraints와 expiry를 server-side로 + 강제하는지 확인한다. +5. upload part 발급 BFF가 `sessionId`로 server session을 조회하고 + `PRESIGNED_MULTIPART_V1` canonical request/upload binding과 part plan을 + 재계산하는지, `UPLOAD_PART` capability binding에도 exact protocol을 넣는지 + 확인한다. client digest 일치만으로 authorization하지 않는다. +6. redirect가 추가되었으면 allowlist를 넓히지 말고 direct endpoint를 복구한다. +7. URL/query를 ticket 또는 log에 복사하지 않는다. 노출 가능성이 있으면 signer + credential/policy와 해당 capability scope를 폐기한다. +8. 만료 capability를 retry하지 않고 control plane에서 새 capability를 발급한다. +9. client claim은 server replay authority가 아니므로 중복 사용 여부는 server + access/audit의 안전한 aggregate로 판단한다. + +## 4. Multipart/resumable upload + +### 4.1 안전한 일시 중지 + +현재 runtime에는 공식 `pause()`와 checkpoint inventory/retention sweep이 없다. +caller abort 뒤 checkpoint가 남아 재개될 수 있다는 사실을 `PAUSED` 완료로 +기록하지 않는다. 다음 절차의 `PAUSED` state와 탭 간 pause는 VD-16 목표 +runtime이 구현된 제품에만 적용한다. + +1. 신규 session create를 중지한다. +2. active part는 사용자가 취소하지 않았다면 현재 bounded attempt만 마무리한다. +3. complete를 중지하고 server status reconcile만 허용한다. +4. presigned URL은 checkpoint에 쓰지 않고 즉시 메모리에서 폐기한다. +5. 현재 runtime은 checkpoint를 `ACTIVE` 또는 `ABORT_PENDING`으로 유지한다. + 목표 pause runtime은 exact scope/generation을 확인해 + `PAUSE_REQUESTED -> PAUSED`로 전환한다. +6. 목표 pause runtime은 cross-context + `RESUMABLE_UPLOAD_PAUSE_V1`, checkpoint `schemaVersion: 2`와 + `ACTIVE | PAUSED | ABORT_PENDING` closed state를 사용한다. v1 old writer + drain, historical migration과 N-1 fail-closed를 확인한다. +7. 목표 runtime의 inventory/sweep는 count/cursor/deadline 안에서 abandoned와 + expired 후보만 반환하고, server reconcile/CAS 없이 checkpoint를 삭제하지 + 않는다. + +### 4.2 Resume + +1. session/checkpoint/control DTO의 protocol이 모두 + `PRESIGNED_MULTIPART_V1`인지 확인하고, session expiry와 source + binding/total bytes/part layout을 검사한다. +2. server-authoritative status/list-parts를 읽는다. +3. 완료 part의 local range SHA-256을 다시 계산해 exact checksum/receipt와 대조한다. +4. checkpoint-only part와 server-only part를 자동 complete하지 않는다. +5. 불일치는 session을 격리하고 새 session/명시적 abort를 선택한다. +6. missing part만 동일 bytes/checksum/idempotency binding으로 전송한다. +7. complete 직전에 1부터 연속적인 ordered receipt set을 다시 확인한다. +8. status HTTP 404/410 또는 `NOT_FOUND`/`EXPIRED`이면 stale checkpoint를 CAS + 제거하고 새 session으로 restart한다. 같은 terminal session을 반복 조회하지 + 않는다. +9. network/429/모든 5xx retry가 attempt count, `Retry-After`, backoff와 + operation timeout ceiling 안인지 확인한다. + +### 4.3 PUT acknowledgement + +1. capability가 expected success status, receipt response header와 + `expectedResponseByteLength`를 exact하게 묶는지 확인한다. +2. 실제 `Content-Length`가 expected bytes와 같고 hard response cap 이하인지 + 확인한다. 204는 expected bytes가 0이어야 하며 header 부재를 0으로 정규화한다. +3. response body EOF까지 동일 deadline 안에서 bounded drain된 뒤 receipt가 + accepted됐는지 확인한다. +4. receipt/ETag는 part acknowledgement일 뿐 whole-file digest나 authorization + proof로 해석하지 않는다. + +### 4.4 Abort/orphan + +- 사용자 cancel은 browser work cancel이며 server abort가 아니다. +- 다른 tab에서 명시적 abort하면 `RESUMABLE_UPLOAD_CANCEL_V1`의 opaque + upload-key-only BroadcastChannel 신호가 active read/fetch/backoff를 먼저 + 중단하고 Web Lock이 bounded 시간 안에 반환되는지 확인한다. +- BroadcastChannel 미지원/정책 차단 환경에서는 abort caller deadline 안에 + lock을 얻지 못하면 성공으로 가장하지 않고 `ABORT_PENDING` 또는 safe + non-terminal failure로 닫은 뒤 server reconcile을 재시도한다. +- abort 결과가 불명확하면 checkpoint를 제거하지 않고 `ABORT_PENDING`으로 남긴다. +- 다음 invocation에서 server status가 `ABORTED/EXPIRED/NOT_FOUND`임을 확인한 뒤 + checkpoint를 제거한다. +- abort endpoint의 HTTP 404/410은 terminal orphan으로 mapping하고 checkpoint를 + 제거한다. +- backend TTL cleanup은 비용과 보안 경계다. orphan upload count/bytes/age SLO를 + 운영한다. +- multipart complete는 `QUARANTINED`; scanner/CDR와 domain metadata commit이 + 끝나기 전 public delivery capability를 발급하지 않는다. +- application-facing success/log에는 session ID, request binding 또는 file + fingerprint를 노출하지 않는다. + +## 5. Streaming download + +1. capability media/length/digest와 실제 response header/body count를 비교한다. +2. `Content-Encoding`이 capability byte semantics와 다른지 확인한다. +3. overrun/truncation/integrity failure면 reader와 destination writable을 abort한다. +4. partial local destination을 성공으로 보고하지 않는다. +5. same resource를 새 capability와 새 destination으로 restart한다. +6. Range resume가 별도 승인되지 않았으면 기존 partial destination에 append하지 + 않는다. +7. anchor/navigation 결과는 browser handoff일 뿐 saved/verified 증거가 아니다. + +### 5.1 Range resumable download + +이 절차는 `RANGE_RESUMABLE_DOWNLOAD_V1` port/runtime, seekable destination 또는 +owned OPFS staging과 provider contract가 구현·조합된 경우에만 적용한다. + +1. checkpoint의 protocol, opaque account partition, representation binding, + total bytes, next offset, destination binding, revision과 retention을 + 검증한다. raw URL, signed header, validator, file path는 checkpoint나 + incident ticket에 없어야 한다. +2. destination의 실제 durable length가 checkpoint offset과 다르면 append하지 + 않는다. 더 긴 uncommitted tail은 정책과 destination capability가 허용할 때만 + `truncate()`하고, 더 짧으면 마지막 confirmed segment로 rollback하거나 + 새 staging으로 재시작한다. +3. 새 capability가 같은 immutable generation/strong validator, total length, + media type와 whole-object digest에 묶였는지 확인한다. capability의 + `preconditionMode`, `allowWholeObjectFallback`과 exact `allowedStatuses`도 + 확인하며 허용되지 않은 status에서는 body나 destination을 소비하지 않는다. +4. 허용된 `206`은 exact `Content-Range: bytes S-E/T`, `S=requestedStart`, + `E+1=requestedEndExclusive`, `Content-Length=E-S+1`, identity encoding과 + capability precondition binding이 모두 맞을 때만 segment를 쓴다. +5. 허용된 full `200`은 response validator/generation evidence가 capability + representation binding과 같을 때만 평가한다. mismatch면 body를 쓰지 않고 + staging/checkpoint를 quarantine한 뒤 새 representation으로 restart한다. + binding이 같아도 start가 0이고 whole-object fallback이 허용된 경우만 fresh + destination에서 consume하며, 그 밖에는 body를 쓰지 않고 Range reissue, + byte-0 restart, handoff 또는 unsupported로 닫는다. 일반 `If-Range` mismatch의 + 표준 응답은 full `200`이지 `412`가 아니다. +6. 허용된 `412`는 별도 `If-Match` 또는 provider generation precondition을 쓴 + 계약에서만 representation replacement로 처리한다. +7. 허용된 `416`은 checkpoint 완료 증거가 아니다. body를 data로 소비하지 않고, + final URL/header와 mode별 validator/generation binding을 먼저 검증한다. + provider response만으로 binding을 증명할 수 없으면 BFF reconcile이 exact + immutable generation을 증명해야 한다. 그 뒤 server total이 expected total과 + 같은지, `nextOffset <= total`인지, destination length와 offset이 일치하는지를 + 순서대로 reconcile한다. length mismatch는 먼저 truncate/verified rollback + 또는 quarantine하고 같은 invocation에서 fall through하지 않는다. + `length == nextOffset == total`과 처음부터 다시 계산한 whole-object SHA-256이 + 모두 일치할 때만 final commit 후보가 된다. `length == nextOffset < total`이면 + exact missing range를 재발급하고 반복 `416`은 contract mismatch로 닫는다. +8. capability expiry는 같은 representation binding으로 재발급한다. binding이 + 달라지면 기존 partial에 이어 쓰지 않는다. +9. segment flush와 destination length 재확인 뒤에만 checkpoint CAS를 전진한다. +10. final whole-object SHA-256과 destination close/commit 뒤에만 + `SAVED_VERIFIED`로 기록한다. + +malformed `Content-Range`, encoded representation drift, validator mismatch, +seek/truncate 실패 또는 crash 뒤 ambiguous tail에서는 신규 Range traffic을 +중지하고 whole-object restart, browser-managed handoff 또는 명시적 unsupported로 +degrade한다. + +### 5.2 Background download와 browser-managed handoff + +- `BROWSER_HANDOFF`는 브라우저 download manager가 이후 작업을 소유한다는 + outcome이며 application progress, retry, destination integrity를 증명하지 않는다. +- page abort/reload 뒤 checkpoint가 남는 것은 app-managed background download 실행 + 증거가 아니다. +- app-managed background download를 선택한 제품만 별도 worker control plane, owned OPFS + staging, runtime/version/logout fence와 foreground export 절차를 운영한다. +- worker가 종료되거나 capability가 만료되면 checkpoint로 foreground recovery를 + 시도하며 장시간 keepalive를 가정하지 않는다. +- 미지원 Safari/Firefox/WebView에서는 browser-managed handoff 또는 명시적 + unsupported UX를 사용한다. unbounded Blob fallback은 금지한다. + +## 6. Image CDN + +1. 신규 private descriptor 발급 또는 영향 preset을 kill switch로 중지한다. +2. 안전한 placeholder/original-approved rendition으로 fallback한다. +3. asset revision과 CDN cache key, format, width/height metadata를 확인한다. + PNG/JPEG/WebP/AVIF header를 native decode 전에 파싱해 선언 dimensions, + pixel/decoded-byte budget과 static-only 조건을 통과했는지 확인한다. + versioned preset binding ID를 server registry에서 조회해 요청의 + width/height/DPR/fit/format/quality 전체를 재계산하고 query mismatch를 + 거절하는지도 확인한다. +4. 같은 immutable URL의 content가 변경됐다면 purge만으로 봉합하지 않고 새 + asset revision을 발급한다. +5. signed private URL이 노출됐으면 expiry를 기다리지 말고 backend asset/capability + scope를 revoke한다. +6. private descriptor가 `PRIMARY_REQUIRED` probe를 사용하고 실제 response에서 + exact URL, credential omission과 `Cache-Control: no-store`를 확인했는지 + 검사한다. + CDN origin이 application origin과 분리되어 실제 + `` 요청에도 application cookie가 실리지 + 않는지 배포 설정을 함께 확인한다. +7. fetch header/body/decode 전체 timeout과 abort cleanup을 확인한다. 늦게 + resolve한 response body/`ImageBitmap`이 즉시 cancel/close되는지 fault + injection으로 재현한다. +8. source-fetch SSRF가 의심되면 arbitrary source URL transform을 차단하고 + quarantine을 통과한 asset registry ID만 허용한다. +9. dimensions/pixel/decoded-byte budget 위반은 CDN transform과 descriptor + 양쪽을 중지한다. +10. 정상 signing key 회전은 verifier와 `acceptedKeyIds`에 old/new key를 먼저 + 함께 배포하고 client 채택을 확인한 뒤 backend signer를 전환한다. 기존 + capability lifetime, clock skew와 client rollout 기간이 모두 지난 후에만 + old key를 제거한다. +11. key 유출, logout, tenant/account partition 변경 또는 feature teardown이면 + backend capability를 revoke하고 기존 Image CDN runtime을 `close()`해 진행 + 중 verification/probe와 기존 reference를 폐기한 뒤 새 runtime을 조합한다. +12. descriptor HTTP provider를 설치했다면 response가 + `IMAGE_CDN_DESCRIPTOR_V1`, fixed endpoint, strict body/content-type/deadline과 + 현재 runtime generation을 만족하는지 확인한다. +13. refresh는 asset/preset/scope/generation별 single-flight인지, 기존 descriptor + expiry 뒤 stale-while-error를 허용하지 않는지 확인한다. +14. logout/account switch 뒤 늦게 끝난 descriptor fetch/signature + verification/decode가 새 runtime에 채택되지 않는지 확인한다. +15. safe picture primitive가 verified presentation descriptor를 그대로 + projection할 뿐 URL/query/transform을 재조립하지 않는지 확인한다. + +## 7. Kill switch + +- direct object-storage transfer off → same-origin BFF proxy 또는 기능 중지 +- concurrent multipart → sequential part +- resumable off → 승인된 small-file simple upload 또는 upload 중지 +- complete off → active session 유지/reconcile only +- upload pause/resume off → 신규 part 중지, explicit reconcile only +- streaming save off → authorized browser handoff +- Range resume off → whole-object restart 또는 authorized browser handoff +- app-managed background download off → browser-managed handoff 또는 foreground only +- app-managed background upload off → foreground checkpoint resume 또는 upload 중지 +- Image descriptor refresh off → fresh descriptor expiry까지만 사용 후 placeholder +- private Image CDN off → authenticated same-origin placeholder +- advanced formats off → approved JPEG/PNG preset +- responsive candidates off → 한 개의 bounded fallback rendition + +kill switch는 hard byte/pixel/security ceiling을 늘리지 않는다. + +## 8. 복구 완료 조건 + +- capability expiry/replay/CORS 실패율이 baseline으로 복귀 +- 신규·resume upload의 part checksum과 ordered complete 증적 통과 +- orphan TTL cleanup과 quarantine backlog가 SLO 안으로 복귀 +- download truncation/overrun/integrity fault injection 통과 +- Range를 선택한 경우 `200/206/412/416`, capability reissue, destination + seek/truncate/crash와 final whole-object integrity matrix 통과 +- pause/inventory를 구현한 경우 abandoned checkpoint retention/reconcile drill 통과 +- image preset/revision/cache/CSP matrix 통과 +- Image descriptor provider를 설치한 경우 expiry/refresh/logout generation + fence와 safe projection 통과 +- image pre-decode static metadata, private no-store와 timeout cleanup fault 통과 +- Chromium/Firefox/WebKit 동일 case set, zero failure/skipped +- raw URL/query/signed header/bearer token/capability가 log, telemetry, + checkpoint에 없음을 확인 +- checkpoint allowlist의 SHA-256 fingerprint/part checksum/opaque receipt가 + account partition과 retention 안에만 있고 log/telemetry에는 없음을 확인 + +## 9. 제거 + +1. 신규 capability/session/descriptor 발급 중지 +2. active upload complete 또는 explicit abort와 orphan cleanup +3. non-secret checkpoint를 scope에 맞게 purge +4. private capability revoke와 CDN grace window drain +5. feature transfer/image facade와 composition 제거 +6. browser-transfer source와 전용 tests/catalog wiring 제거 +7. runtime removal gate, production module inventory, 전체 test/build 재검증 + +## 10. 관련 문서 + +- [Browser data capability completion ledger](../architecture/browser-data-capability-completion-ledger.md) +- [Presigned transfer and Image CDN](../architecture/presigned-transfer-and-image-cdn.md) +- [VD-14 Resumable download와 background download](../architecture/decisions/VD-14-resumable-download-and-background-transfer.md) +- [VD-16 Browser transfer composition과 Image delivery](../architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md) +- [Server file capability infrastructure](../architecture/server-file-capability-infrastructure.md) diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md new file mode 100644 index 0000000..75f32b1 --- /dev/null +++ b/docs/operations/ci-quality-gates.md @@ -0,0 +1,381 @@ +# CI quality-gate orchestration + +`config/ci/gates.json` is the executable registry for all 26 gates and the sole +workflow model. `.gitea/workflows/quality-gates.yml` is generated from that +strict v2 contract; it is not an independent configuration authority. Update +the registry, run `corepack pnpm generate:ci-workflow`, and commit the generated +bytes together. `corepack pnpm check:ci-workflow` rejects missing, stale, CRLF, +or otherwise byte-different output. `corepack pnpm check:ci` runs artifact-schema +checking, the typed contract checker, and this workflow drift check. The Gitea +adapter runs each gate as an independent matrix check with full fan-out and no +soft-fail wiring. + +Contract loading validates every registered command entry against the +authoritative root package-script graph and lifecycle/install policy before the +runner enters its execution callback. Foreign cwd/workspace script dispatch, +argument-sensitive dispatchers, and ineffective lifecycle suppression therefore +fail preflight even when the later `check:ci` command would never run. +For npm, that root-only boundary also parses options following an explicit +`run`/`run-script` target or implicit `test`/`start`/`stop`/`restart` target: +`--workspace`, `-w`, `--workspaces`, and `--prefix` are foreign manifest scope +and are rejected before execution. The first literal `--` ends npm option +parsing, so later tokens are ordinary script arguments. pnpm and Yarn differ: +their options following the selected script name are forwarded to that script; +their cwd/workspace selectors are rejected only where the manager consumes +them before script selection. + +The npm graph includes existing root-manifest `pre + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..16f972b --- /dev/null +++ b/package.json @@ -0,0 +1,161 @@ +{ + "name": "clean-architecture-frontend-template", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.17.0", + "engines": { + "node": ">=24.11.0 <25.0.0", + "pnpm": ">=11.0.0 <12.0.0" + }, + "scripts": { + "dev": "vite", + "build": "node scripts/build-frontend.ts", + "build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts", + "preview": "vite preview", + "lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0", + "check:architecture": "node scripts/check-architecture.ts", + "check:design-system": "node scripts/check-design-system.ts", + "check:design-system:fixture": "node scripts/check-design-system.ts --fixture", + "check:i18n": "node scripts/check-i18n.ts", + "check:i18n:fixture": "node scripts/check-i18n.ts --fixture", + "check:diagnostics": "node scripts/check-diagnostics.ts", + "check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture", + "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker", + "check:types:app": "tsc --project tsconfig.app.json", + "check:types:node": "tsc --project tsconfig.node.json", + "check:types:test": "tsc --project tsconfig.test.json", + "check:types:recipes": "node scripts/check-optional-recipe-types.ts", + "check:types:fixture": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.ts", + "check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts", + "check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts", + "check:types:fixture:application-output": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", + "check:types:fixture:application-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", + "check:types:fixture:feature-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-feature-input.ts", + "check:types:fixture:failure-kind": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-failure-kind.ts", + "check:types:fixture:reference-operation": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-reference-operation.ts", + "check:types:fixture:async-overlay": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", + "check:types:fixture:route-runtime": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts", + "check:types:fixture:page-action": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx", + "check:types:fixture:icon-button": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx", + "check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts", + "check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts", + "check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts", + "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", + "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", + "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", + "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", + "test:http-scenario-catalog": "vitest run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1", + "test:http-scenario-evidence": "node scripts/run-http-scenario-evidence.ts", + "test:recipes": "vitest run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests", + "test:e2e": "playwright test", + "test:e2e:dev": "playwright test --config playwright.dev.config.ts", + "test:browser-capabilities": "playwright test --config playwright.capabilities.config.ts", + "verify:browser-capability-evidence": "node scripts/verify-browser-capability-evidence.ts", + "storybook": "storybook dev -p 6006", + "build:storybook": "storybook build -o artifacts/storybook/static", + "test:storybook": "playwright test --config playwright.storybook.config.ts", + "test:visual": "playwright test --config playwright.visual.config.ts", + "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", + "check:test-evidence": "node scripts/check-test-evidence.ts", + "check:test-evidence:browser": "node scripts/check-test-evidence.ts --skip-scenario-executions", + "check:http-scenario-evidence": "node scripts/check-test-evidence.ts --scenario-only --artifact artifacts/quality/http-scenario-evidence.json", + "check:http-scenario-evidence:fixture": "node scripts/check-test-evidence.ts --scenario-only --source-root tests/fixtures/test-evidence/scenarios/source --policy tests/fixtures/test-evidence/scenarios/policy.json --catalog tests/fixtures/test-evidence/scenarios/catalog.json --receipt tests/fixtures/test-evidence/scenarios/receipt.json --artifact artifacts/quality/http-scenario-evidence-fixture.json", + "check:test-evidence:source": "node scripts/check-test-evidence.ts --source-only --artifact artifacts/quality/test-evidence-source.json", + "check:test-evidence:fixture": "node scripts/check-test-evidence.ts --source-root tests/fixtures/test-evidence/forbidden --artifact artifacts/quality/test-evidence-fixture.json", + "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.ts", + "review:a11y-manual": "node scripts/verify-a11y-manual.ts", + "test:sample-removal": "node scripts/test-sample-removal.ts", + "test:optional-recipe-removal": "node scripts/test-optional-recipe-removal.ts", + "test:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts", + "test:realtime-removal": "node scripts/test-realtime-runtime-removal.ts", + "test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests", + "check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts", + "test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts", + "check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json", + "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes", + "verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts", + "check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts", + "generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts", + "check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check", + "generate:supply-chain": "node scripts/generate-supply-chain.ts", + "verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.ts && node scripts/verify-release-candidate.ts", + "verify:promotion": "node scripts/verify-exact-promotion-bundle.ts", + "update:dependency-baseline": "node scripts/update-dependency-baseline.ts", + "check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.ts", + "check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.ts", + "verify:reproducible-build": "node scripts/verify-reproducible-build.ts", + "scan:security": "node scripts/security-scan.ts", + "scan:security:fixture": "node scripts/security-scan.ts --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif", + "check:security:fixtures": "node scripts/check-security-fixtures.ts", + "check:browser-security": "node scripts/check-browser-security.ts", + "check:browser-file-storage-boundaries": "node scripts/check-browser-file-storage-boundaries.ts", + "check:realtime-boundaries": "node scripts/check-realtime-boundaries.ts", + "check:realtime-boundaries:fixture": "node scripts/check-realtime-boundary-fixtures.ts", + "check:optional-recipes": "node scripts/check-optional-recipes.ts --require-dist", + "check:optional-recipes:source": "node scripts/check-optional-recipes.ts", + "check:optional-recipe-fixtures": "node scripts/check-optional-recipe-fixtures.ts", + "check:registries": "node scripts/check-registries.ts", + "check:registries:structure": "node scripts/check-registries.ts --no-baseline", + "check:registries:compatibility-fixtures": "node scripts/check-registry-compatibility-fixtures.ts", + "check:registries:baseline-fixture": "node scripts/check-registries.ts --approval tests/fixtures/registry/compatibility/tampered-approval.json --artifact artifacts/quality/registry-baseline-fixture.json", + "check:registries:fixture": "node scripts/check-registries.ts --governance tests/fixtures/registry/forbidden/governance.json --artifact artifacts/quality/registry-fixture.json", + "check:routes:fixture": "node scripts/check-registries.ts --governance tests/fixtures/registry/routes/governance.json --artifact artifacts/quality/route-registry-fixture.json", + "verify:compatibility": "node scripts/check-compatibility.ts", + "verify:release": "node scripts/verify-release.ts", + "verify:hosting-headers": "node scripts/verify-hosting-headers.ts", + "check:bundle": "node scripts/generate-supply-chain.ts && node scripts/check-bundle.ts", + "test:performance": "node scripts/test-performance.ts", + "collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.ts", + "drill:runbook": "node scripts/drill-runbook.ts", + "drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005", + "ci:gate": "node scripts/run-ci-gate.ts", + "generate:ci-workflow": "node scripts/generate-ci-workflow.ts", + "check:ci-workflow": "node scripts/generate-ci-workflow.ts --check", + "check:ci": "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow", + "verify:documentation": "node scripts/verify-documentation-readiness.ts", + "build:app-only": "vite build && node scripts/generate-build-manifest.ts", + "generate:contract-set": "node scripts/generate-contract-set.ts", + "check:types:web-worker": "tsc --project tsconfig.web-worker.json", + "check:types:service-worker": "tsc --project tsconfig.service-worker.json" + }, + "dependencies": { + "@tanstack/react-query": "5.101.4", + "lucide-react": "1.25.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-router-dom": "7.18.1", + "zod": "4.4.3" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@babel/core": "8.0.1", + "@babel/eslint-parser": "8.0.1", + "@babel/plugin-syntax-jsx": "8.0.1", + "@babel/plugin-syntax-typescript": "8.0.3", + "@eslint/js": "10.0.1", + "@playwright/test": "1.62.0", + "@storybook/addon-a11y": "10.5.4", + "@storybook/react-vite": "10.5.4", + "@tailwindcss/vite": "4.3.3", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "24.13.3", + "@types/react": "19.2.8", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "@vitest/coverage-v8": "4.1.10", + "dependency-cruiser": "18.1.0", + "eslint": "10.8.0", + "eslint-plugin-react-hooks": "7.1.1", + "globals": "17.7.0", + "jsdom": "29.1.1", + "msw": "2.15.0", + "storybook": "10.5.4", + "tailwindcss": "4.3.3", + "typescript": "7.0.2", + "vite": "8.1.5", + "vitest": "4.1.10" + } +} diff --git a/playwright.capabilities.config.ts b/playwright.capabilities.config.ts new file mode 100644 index 0000000..b62dc4b --- /dev/null +++ b/playwright.capabilities.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/browser-capabilities", + forbidOnly: true, + outputDir: "./artifacts/tests/browser-capabilities/results", + reporter: [ + ["list"], + [ + "html", + { + outputFolder: "./artifacts/tests/browser-capabilities/report", + open: "never", + }, + ], + [ + "junit", + { + outputFile: "./artifacts/tests/browser-capabilities/results.xml", + }, + ], + ], + use: { + baseURL: "http://127.0.0.1:4174", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: + "corepack pnpm dev --host 127.0.0.1 --port 4174", + url: "http://127.0.0.1:4174", + reuseExistingServer: false, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..83fe081 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + outputDir: "./artifacts/tests/e2e/results", + reporter: [ + ["list"], + ["html", { outputFolder: "./artifacts/tests/e2e/report", open: "never" }], + ["junit", { outputFile: "./artifacts/tests/e2e/results.xml" }], + ], + use: { + baseURL: "http://127.0.0.1:4173", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: + "corepack pnpm build && corepack pnpm preview --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173", + reuseExistingServer: false, + }, + projects: [ + { + name: "chromium", + testIgnore: "**/compact-smoke.spec.ts", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: "**/compact-smoke.spec.ts", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: "**/compact-smoke.spec.ts", + use: { ...devices["Desktop Safari"] }, + }, + { + name: "chromium-compact", + testMatch: "**/compact-smoke.spec.ts", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true, + }, + }, + ], +}); diff --git a/playwright.dev.config.ts b/playwright.dev.config.ts new file mode 100644 index 0000000..95cc321 --- /dev/null +++ b/playwright.dev.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +import releaseConfig from "./playwright.config.ts"; + +export default defineConfig({ + ...releaseConfig, + use: { + ...releaseConfig.use, + baseURL: "http://127.0.0.1:5173", + }, + webServer: { + command: "corepack pnpm dev --host 127.0.0.1", + url: "http://127.0.0.1:5173", + reuseExistingServer: true, + }, +}); diff --git a/playwright.storybook.config.ts b/playwright.storybook.config.ts new file mode 100644 index 0000000..4c2fac0 --- /dev/null +++ b/playwright.storybook.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/storybook", + outputDir: "./artifacts/tests/storybook/results", + reporter: [ + ["list"], + [ + "html", + { + outputFolder: "./artifacts/tests/storybook/report", + open: "never", + }, + ], + [ + "junit", + { outputFile: "./artifacts/tests/storybook/results.xml" }, + ], + ], + use: { + baseURL: "http://127.0.0.1:6006", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: + "corepack pnpm build:storybook && node scripts/serve-static.ts artifacts/storybook/static 6006", + url: "http://127.0.0.1:6006", + reuseExistingServer: false, + }, + projects: [ + { name: "chromium-workshop", use: { ...devices["Desktop Chrome"] } }, + ], +}); diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts new file mode 100644 index 0000000..f66f996 --- /dev/null +++ b/playwright.visual.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/visual", + snapshotDir: "./tests/visual/__snapshots__", + outputDir: "./artifacts/tests/visual/results", + reporter: [ + ["list"], + [ + "html", + { outputFolder: "./artifacts/tests/visual/report", open: "never" }, + ], + ["junit", { outputFile: "./artifacts/tests/visual/results.xml" }], + ], + expect: { + toHaveScreenshot: { + animations: "disabled", + caret: "hide", + maxDiffPixelRatio: 0.002, + scale: "css", + }, + }, + use: { + ...devices["Desktop Chrome"], + baseURL: "http://127.0.0.1:4174", + colorScheme: "light", + locale: "en-US", + trace: "retain-on-failure", + }, + webServer: { + command: + "corepack pnpm build && corepack pnpm preview --host 127.0.0.1 --port 4174", + url: "http://127.0.0.1:4174", + reuseExistingServer: false, + }, + projects: [{ name: "chromium-visual" }], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ddd157b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5229 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: 5.101.4 + version: 5.101.4(react@19.2.8) + lucide-react: + specifier: 1.25.0 + version: 1.25.0(react@19.2.8) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-router-dom: + specifier: 7.18.1 + version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@axe-core/playwright': + specifier: 4.12.1 + version: 4.12.1(playwright-core@1.62.0) + '@babel/core': + specifier: 8.0.1 + version: 8.0.1 + '@babel/eslint-parser': + specifier: 8.0.1 + version: 8.0.1(@babel/core@8.0.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@babel/plugin-syntax-jsx': + specifier: 8.0.1 + version: 8.0.1(@babel/core@8.0.1) + '@babel/plugin-syntax-typescript': + specifier: 8.0.3 + version: 8.0.3(@babel/core@8.0.1) + '@eslint/js': + specifier: 10.0.1 + version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@playwright/test': + specifier: 1.62.0 + version: 1.62.0 + '@storybook/addon-a11y': + specifier: 10.5.4 + version: 10.5.4(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8)) + '@storybook/react-vite': + specifier: 10.5.4 + version: 10.5.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(supports-color@7.2.0)(typescript@7.0.2)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: 4.3.3 + version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@testing-library/jest-dom': + specifier: 7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: 14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@types/react': + specifier: 19.2.8 + version: 19.2.8 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.8) + '@vitejs/plugin-react': + specifier: 6.0.4 + version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + dependency-cruiser: + specifier: 18.1.0 + version: 18.1.0 + eslint: + specifier: 10.8.0 + version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-react-hooks: + specifier: 7.1.1 + version: 7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + globals: + specifier: 17.7.0 + version: 17.7.0 + jsdom: + specifier: 29.1.1 + version: 29.1.1 + msw: + specifier: 2.15.0 + version: 2.15.0(@types/node@24.13.3)(typescript@7.0.2) + storybook: + specifier: 10.5.4 + version: 10.5.4(@types/react@19.2.8)(react@19.2.8) + tailwindcss: + specifier: 4.3.3 + version: 4.3.3 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} + peerDependencies: + playwright-core: '>= 1.0.0' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/code-frame@8.0.0': + resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@8.0.0': + resolution: {integrity: sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/core@8.0.1': + resolution: {integrity: sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/eslint-parser@8.0.1': + resolution: {integrity: sha512-2javO8pAQv/ld6sS6OcxoLAlzZEZy+xm99bnoAfMhzKSumKhdF5wylpbZB7XTorWr3KLPtx5K95eduJPOy1mzA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + eslint: ^9.0.0 || ^10.0.0 + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@8.0.0': + resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@8.0.0': + resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@8.0.0': + resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/plugin-syntax-jsx@8.0.1': + resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-syntax-typescript@8.0.3': + resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@8.0.4': + resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': + resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@oxc-parser/binding-android-arm-eabi@0.127.0': + resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.127.0': + resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.127.0': + resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.127.0': + resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.127.0': + resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.127.0': + resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.127.0': + resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.127.0': + resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@storybook/addon-a11y@10.5.4': + resolution: {integrity: sha512-UWdZtB7Dh1GjqxTPOrisUNDhDGF5pKGzZdzW9DSSHMBcAOx2dsTmXGzLSFprAz4LTT0OHCtKhxNqKK0JZJ0Y8g==} + peerDependencies: + storybook: ^10.5.4 + + '@storybook/builder-vite@10.5.4': + resolution: {integrity: sha512-eXdgow+brSzZrG3CnG18YwxZwEYNAZIh2G5qKwNoZf0uk2ZCPgLRQwtVAcd7BiiEDGnXUHm9pycAS+UiF4F4Mg==} + peerDependencies: + storybook: ^10.5.4 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@storybook/csf-plugin@10.5.4': + resolution: {integrity: sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==} + peerDependencies: + esbuild: '*' + rollup: '*' + storybook: ^10.5.4 + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + + '@storybook/global@5.0.0': + resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} + + '@storybook/icons@2.1.0': + resolution: {integrity: sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@storybook/react-dom-shim@10.5.4': + resolution: {integrity: sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.4 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@storybook/react-vite@10.5.4': + resolution: {integrity: sha512-iw7EAA98n30vpf+ZSy2Ll4Ne7oyZ/lS6W22u5Sp5UOI2oAkYuklxIWjRIKGqpY0BHP+GqaGYtUGMZQnBqgul2g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.4 + typescript: '>= 4.9.x' + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@storybook/react@10.5.4': + resolution: {integrity: sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.4 + typescript: '>= 4.9.x' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + typescript: + optional: true + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/jest-dom@7.0.0': + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/doctrine@0.0.9': + resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.8': + resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} + + '@types/resolve@1.20.6': + resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.0.4': + resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@webcontainer/env@1.1.1': + resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} + + acorn-jsx-walk@2.0.0: + resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + dependency-cruiser@18.1.0: + resolution: {integrity: sha512-pbsH0gQ15BxXi97SCuMeVgsh8VzYpziGIVaMdnALbVu+TYjSZrW9/nOvsPU+NjHLmJSF9R1UijjoACq6Ne/ybQ==} + engines: {node: ^22||^24||>=26} + hasBin: true + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + electron-to-chromium@1.5.396: + resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + enhanced-resolve@5.24.2: + resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} + engines: {node: '>=10.13.0'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.8.0: + resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.3: + resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-installed-globally@1.0.0: + resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} + engines: {node: '>=18'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.25.0: + resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.15.0: + resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + oxc-parser@0.127.0: + resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-docgen-typescript@2.4.0: + resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} + peerDependencies: + typescript: '>= 4.3.x' + + react-docgen@8.0.3: + resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} + engines: {node: ^20.9.0 || >=22} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + recast@0.23.12: + resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==} + engines: {node: '>= 4'} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + storybook@10.5.4: + resolution: {integrity: sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==} + hasBin: true + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + prettier: ^2 || ^3 + vite-plus: ^0.1.15 || ^0.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + prettier: + optional: true + vite-plus: + optional: true + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watskeburt@6.0.0: + resolution: {integrity: sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==} + engines: {node: ^22.13||^24||>=26} + hasBin: true + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@axe-core/playwright@4.12.1(playwright-core@1.62.0)': + dependencies: + axe-core: 4.12.1 + playwright-core: 1.62.0 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/code-frame@8.0.0': + dependencies: + '@babel/helper-validator-identifier': 8.0.4 + js-tokens: 10.0.0 + + '@babel/compat-data@7.29.7': {} + + '@babel/compat-data@8.0.0': {} + + '@babel/core@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/core@8.0.1': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-compilation-targets': 8.0.0 + '@babel/helpers': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.0.1 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 2.1.4 + semver: 7.8.5 + + '@babel/eslint-parser@8.0.1(@babel/core@8.0.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': + dependencies: + '@babel/core': 8.0.1 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + semver: 7.8.5 + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-compilation-targets@8.0.0': + dependencies: + '@babel/compat-data': 8.0.0 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.28.7 + lru-cache: 11.5.2 + semver: 7.8.5 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-globals@8.0.0': {} + + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-string-parser@8.0.0': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.4': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-validator-option@8.0.0': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/helpers@8.0.0': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/parser@8.0.4': + dependencies: + '@babel/types': 8.0.4 + + '@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + + '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + + '@babel/traverse@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/traverse@8.0.4': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-globals': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 + obug: 2.1.4 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.4': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': + dependencies: + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5(supports-color@7.2.0)': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': + optionalDependencies: + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.15.1': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@2.0.7': {} + + '@inquirer/confirm@6.1.1(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@24.13.3) + '@inquirer/type': 4.0.7(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/core@11.2.1(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@24.13.3) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/type@4.0.7(@types/node@24.13.3)': + optionalDependencies: + '@types/node': 24.13.3 + + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@7.0.2)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + glob: 13.0.6 + react-docgen-typescript: 2.4.0(typescript@7.0.2) + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + optionalDependencies: + typescript: 7.0.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.127.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.127.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.127.0': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + optional: true + + '@oxc-project/types@0.127.0': {} + + '@oxc-project/types@0.139.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + + '@standard-schema/spec@1.1.0': {} + + '@storybook/addon-a11y@10.5.4(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))': + dependencies: + '@storybook/global': 5.0.0 + axe-core: 4.12.1 + storybook: 10.5.4(@types/react@19.2.8)(react@19.2.8) + + '@storybook/builder-vite@10.5.4(esbuild@0.28.1)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@storybook/csf-plugin': 10.5.4(esbuild@0.28.1)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + storybook: 10.5.4(@types/react@19.2.8)(react@19.2.8) + ts-dedent: 2.3.0 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + transitivePeerDependencies: + - esbuild + - rollup + - webpack + + '@storybook/csf-plugin@10.5.4(esbuild@0.28.1)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + storybook: 10.5.4(@types/react@19.2.8)(react@19.2.8) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.1 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + + '@storybook/global@5.0.0': {} + + '@storybook/icons@2.1.0(react@19.2.8)': + dependencies: + react: 19.2.8 + + '@storybook/react-dom-shim@10.5.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.4(@types/react@19.2.8)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@storybook/react-vite@10.5.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(supports-color@7.2.0)(typescript@7.0.2)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@7.0.2)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@rollup/pluginutils': 5.4.0 + '@storybook/builder-vite': 10.5.4(esbuild@0.28.1)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@storybook/react': 10.5.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(supports-color@7.2.0)(typescript@7.0.2) + empathic: 2.0.1 + magic-string: 0.30.21 + react: 19.2.8 + react-docgen: 8.0.3(supports-color@7.2.0) + react-dom: 19.2.8(react@19.2.8) + resolve: 1.22.12 + storybook: 10.5.4(@types/react@19.2.8)(react@19.2.8) + tsconfig-paths: 4.2.0 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - esbuild + - rollup + - supports-color + - webpack + + '@storybook/react@10.5.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8))(supports-color@7.2.0)(typescript@7.0.2)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.5.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.4(@types/react@19.2.8)(react@19.2.8)) + react: 19.2.8 + react-docgen: 8.0.3(supports-color@7.2.0) + react-docgen-typescript: 2.4.0(typescript@7.0.2) + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.4(@types/react@19.2.8)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.2 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/doctrine@0.0.9': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/gensync@1.0.5': {} + + '@types/jsesc@2.5.1': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.8)': + dependencies: + '@types/react': 19.2.8 + + '@types/react@19.2.8': + dependencies: + csstype: 3.2.3 + + '@types/resolve@1.20.6': {} + + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 24.13.3 + + '@types/statuses@2.0.6': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@24.13.3)(typescript@7.0.2) + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@webcontainer/env@1.1.1': {} + + acorn-jsx-walk@2.0.0: {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-loose@8.5.2: + dependencies: + acorn: 8.17.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + axe-core@4.12.1: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.396 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + caniuse-lite@1.0.30001806: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@15.0.0: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + dependency-cruiser@18.1.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + acorn-jsx-walk: 2.0.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.5 + commander: 15.0.0 + enhanced-resolve: 5.24.2 + ignore: 7.0.6 + interpret: 3.1.1 + is-installed-globally: 1.0.0 + json5: 2.2.3 + picomatch: 4.0.5 + prompts: 2.4.2 + rechoir: 0.8.0 + safe-regex: 2.1.1 + semver: 7.8.5 + tsconfig-paths-webpack-plugin: 4.2.0 + watskeburt: 6.0.0 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + electron-to-chromium@1.5.396: {} + + emoji-regex@8.0.0: {} + + empathic@2.0.1: {} + + enhanced-resolve@5.24.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@8.0.0: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/parser': 7.29.7 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.3 + keyv: 4.5.4 + + flatted@3.4.3: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + globals@17.7.0: {} + + graceful-fs@4.2.11: {} + + graphql@16.14.2: {} + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + ini@4.1.1: {} + + interpret@3.1.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-installed-globally@1.0.0: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + + is-node-process@1.2.0: {} + + is-path-inside@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.7.0: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + loupe@3.2.1: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.25.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + ms@2.1.3: {} + + msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@24.13.3) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@types/node' + + mute-stream@3.0.0: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.51: {} + + obug@2.1.4: {} + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + outvariant@1.4.3: {} + + oxc-parser@0.127.0: + dependencies: + '@oxc-project/types': 0.127.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.127.0 + '@oxc-parser/binding-android-arm64': 0.127.0 + '@oxc-parser/binding-darwin-arm64': 0.127.0 + '@oxc-parser/binding-darwin-x64': 0.127.0 + '@oxc-parser/binding-freebsd-x64': 0.127.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.127.0 + '@oxc-parser/binding-linux-arm64-musl': 0.127.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.127.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-musl': 0.127.0 + '@oxc-parser/binding-openharmony-arm64': 0.127.0 + '@oxc-parser/binding-wasm32-wasi': 0.127.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.127.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 + '@oxc-parser/binding-win32-x64-msvc': 0.127.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.22: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + punycode@2.3.1: {} + + react-docgen-typescript@2.4.0(typescript@7.0.2): + dependencies: + typescript: 7.0.2 + + react-docgen@8.0.3(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/types': 7.29.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + '@types/doctrine': 0.0.9 + '@types/resolve': 1.20.6 + doctrine: 3.0.0 + resolve: 1.22.12 + strip-indent: 4.1.1 + transitivePeerDependencies: + - supports-color + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + recast@0.23.12: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regexp-tree@0.1.27: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rettime@0.11.11: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + run-applescript@7.1.0: {} + + safe-regex@2.1.1: + dependencies: + regexp-tree: 0.1.27 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + set-cookie-parser@3.1.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + storybook@10.5.4(@types/react@19.2.8)(react@19.2.8): + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.1.0(react@19.2.8) + '@testing-library/dom': 10.4.1 + '@testing-library/jest-dom': 6.10.0(@testing-library/dom@10.4.1) + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/expect': 3.2.4 + '@vitest/spy': 3.2.4 + '@webcontainer/env': 1.1.1 + esbuild: 0.28.1 + jsonc-parser: 3.3.1 + open: 10.2.0 + oxc-parser: 0.127.0 + oxc-resolver: 11.24.2 + recast: 0.23.12 + semver: 7.8.5 + use-sync-external-store: 1.6.0(react@19.2.8) + ws: 8.21.1 + optionalDependencies: + '@types/react': 19.2.8 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-indent@4.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tagged-tag@1.0.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@2.0.0: {} + + tinyrainbow@3.1.0: {} + + tinyspy@4.0.4: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-dedent@2.3.0: {} + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.2 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.18.2: {} + + undici@7.28.0: {} + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.22 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watskeburt@6.0.0: {} + + webidl-conversions@8.0.1: {} + + webpack-virtual-modules@0.6.2: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.21.1: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..49bbf51 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +allowBuilds: + esbuild: true + msw: true +minimumReleaseAgeExclude: + - '@playwright/test@1.62.0' + - playwright-core@1.62.0 + - playwright@1.62.0 + - eslint@10.8.0 diff --git a/public/config.json b/public/config.json new file mode 100644 index 0000000..e2e876d --- /dev/null +++ b/public/config.json @@ -0,0 +1,18 @@ +{ + "APP_ENV": "local", + "API_BASE_URL": "http://localhost:8080/", + "REQUEST_TIMEOUT_MS": 10000, + "MAX_RETRY_ATTEMPTS": 2, + "TELEMETRY_ENABLED": false, + "AUTH_MODE": "demo", + "CONFIG_SCHEMA_VERSION": "2.0", + "RELEASE_MANIFEST_URL": "/release-manifest.json", + "BUILD_ID": "local-build", + "RELEASE_ID": "local-release", + "CAPABILITY_OVERRIDES": { + "REALTIME": "DEFAULT", + "WEB_WORKER": "DEFAULT", + "SERVICE_WORKER": "DEFAULT", + "OFFLINE_COMMANDS": "DEFAULT" + } +} diff --git a/public/release-manifest.json b/public/release-manifest.json new file mode 100644 index 0000000..bd0b8bd --- /dev/null +++ b/public/release-manifest.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 2, + "appVersion": "0.1.0", + "buildId": "local-build", + "commitSha": "local", + "configSchemaVersion": "2.0", + "assetManifestHash": "generated-during-build", + "releaseId": "local-release", + "builtAt": "1970-01-01T00:00:00.000Z", + "routeChunks": { + "route-home": "src/presentation/pages/home-page.tsx", + "route-examples-platform": "src/presentation/examples/platform-overview-page.tsx", + "route-examples-ui": "src/presentation/examples/ui-gallery-page.tsx", + "route-examples-states": "src/presentation/examples/state-gallery-page.tsx", + "route-examples-auth": "src/presentation/examples/auth-example-page.tsx", + "route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx", + "route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx", + "route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx", + "route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx", + "route-not-found": "src/presentation/pages/not-found-page.tsx" + }, + "contractSet": { + "setAlgorithm": "CA_CONTRACT_SET_V1", + "setDigest": "sha256:ad6aab71fea6a9ff87cbd170b984b339965afc90d85bb57f87801c9e0c020da2", + "packages": [] + } +} diff --git a/recipes/frontend-capabilities/browser-file-storage-contracts.ts b/recipes/frontend-capabilities/browser-file-storage-contracts.ts new file mode 100644 index 0000000..c3df369 --- /dev/null +++ b/recipes/frontend-capabilities/browser-file-storage-contracts.ts @@ -0,0 +1,741 @@ +import type { + CapabilityResult, + Cleanup, + TransferProgress, +} from "./contracts.ts"; + +/** + * Deep opt-in contracts for browser file and origin-storage capabilities. + * + * These contracts intentionally expose no File, Blob, FileSystemHandle, + * IDBDatabase, IDBTransaction, Cache, Request or Response. A selected project + * copies and narrows only the ports that its product actually owns. + */ + +declare const byteCountBrand: unique symbol; +declare const localFileRefBrand: unique symbol; +declare const fileVerificationReceiptBrand: unique symbol; +declare const filePolicyKeyBrand: unique symbol; +declare const filePolicyIntentionBrand: unique symbol; +declare const browserManagedCapabilityReceiptBrand: unique symbol; +declare const authorizedDownloadCapabilityBrand: unique symbol; +declare const authorizedDownloadCapabilityReceiptBrand: unique symbol; +declare const objectIdBrand: unique symbol; +declare const objectGenerationBrand: unique symbol; + +export type ByteCount = number & { + readonly [byteCountBrand]: "ByteCount"; +}; + +export type LocalFileRef = string & { + readonly [localFileRefBrand]: "LocalFileRef"; +}; + +export type FileVerificationReceipt = string & { + readonly [fileVerificationReceiptBrand]: "FileVerificationReceipt"; +}; + +export type FilePolicyKey = string & { + readonly [filePolicyKeyBrand]: "FilePolicyKey"; +}; + +export type FilePolicyIntention = string & { + readonly [filePolicyIntentionBrand]: "FilePolicyIntention"; +}; + +/** + * Composition-issued capability selector. Implementations must resolve the + * exact registered object identity, not merely an equal string pair. + */ +export type FilePolicyReference = Readonly<{ + policyKey: FilePolicyKey; + intention: FilePolicyIntention; +}>; + +export type BrowserManagedDownloadCapabilityReceipt = string & { + readonly [browserManagedCapabilityReceiptBrand]: + "BrowserManagedDownloadCapabilityReceipt"; +}; + +export type AuthorizedDownloadCapabilityReceipt = string & { + readonly [authorizedDownloadCapabilityReceiptBrand]: + "AuthorizedDownloadCapabilityReceipt"; +}; + +/** + * GET-only provider-issued handle. The corresponding URL, signed query and + * headers stay inside the selected transfer adapter's in-memory identity + * vault; a feature cannot replace them or supply its own digest. + */ +export type AuthorizedDownloadCapability = Readonly<{ + capabilityReceipt: AuthorizedDownloadCapabilityReceipt; + method: "GET"; + binding: Readonly<{ + kind: "DOWNLOAD"; + resourceId: string; + }>; + mediaType: string; + byteLength: ByteCount; + maxBytes: ByteCount; + expectedSha256: string; + expiresAtEpochMs: number; + readonly [authorizedDownloadCapabilityBrand]: + "AuthorizedDownloadCapability"; +}>; + +export type DurableObjectId = string & { + readonly [objectIdBrand]: "DurableObjectId"; +}; + +export type ObjectGeneration = number & { + readonly [objectGenerationBrand]: "ObjectGeneration"; +}; + +export type PersistableDataClass = + | "PUBLIC" + | "INTERNAL" + | "PERSONAL" + | "CONFIDENTIAL"; + +export type FileSelectionSource = + | "NATIVE_INPUT" + | "SYSTEM_PICKER" + | "DROP"; + +export type FileAcceptRule = Readonly<{ + mediaType: string; + extensions: ReadonlyArray; +}>; + +export type FileSelectionPolicy = Readonly<{ + policyId: string; + purpose: string; + classification: PersistableDataClass; + multiple: boolean; + maxCount: number; + maxFileBytes: ByteCount; + maxTotalBytes: ByteCount; + allowEmpty: boolean; + accept: ReadonlyArray; +}>; + +export type FileSelectionLimitReduction = Readonly<{ + maxCount?: number; + maxFileBytes?: ByteCount; + maxTotalBytes?: ByteCount; +}>; + +export type FileCandidate = Readonly<{ + ref: LocalFileRef; + displayName: string; + sizeBytes: ByteCount; + reportedMediaType: string | null; + lastModifiedEpochMs: number | null; + source: FileSelectionSource; +}>; + +export type FileSelectionOutcome = + | Readonly<{ + kind: "SELECTED"; + files: ReadonlyArray; + }> + | Readonly<{ kind: "DISMISSED" }>; + +export type FilePickerSupport = Readonly<{ + nativeInput: true; + systemOpenPicker: boolean; + systemSavePicker: boolean; +}>; + +export interface FilePickerPort { + readonly support: FilePickerSupport; + + select(input: { + policy: FilePolicyReference; + limits?: FileSelectionLimitReduction; + signal?: AbortSignal; + }): Promise>; + + release(ref: LocalFileRef): void; +} + +export type FileSignatureResult = + | "MATCHED" + | "MISMATCHED" + | "UNKNOWN"; + +export type FileBytePattern = Readonly<{ + offset: ByteCount; + bytes: ReadonlyArray; + mask?: ReadonlyArray; +}>; + +export type FileSignatureRule = Readonly<{ + mediaType: string; + extensions: ReadonlyArray; + patterns: ReadonlyArray; +}>; + +export type FileInspectionPolicy = Readonly<{ + policyId: string; + maxInspectionBytes: ByteCount; + acceptedSignatures: ReadonlyArray; +}>; + +export type FileInspection = Readonly<{ + byteLength: ByteCount; + reportedMediaType: string | null; + detectedMediaType: string | null; + normalizedExtension: string | null; + signature: FileSignatureResult; + verificationReceipt: FileVerificationReceipt | null; +}>; + +export type FileByteSource = Readonly<{ + byteLength: ByteCount | null; + stream( + signal: AbortSignal, + ): AsyncIterable>; +}>; + +export interface FileContentPort { + inspect(input: { + ref: LocalFileRef; + policy: FilePolicyReference; + maxInspectionBytes?: ByteCount; + signal: AbortSignal; + }): Promise>; + + readRange(input: { + ref: LocalFileRef; + offset: ByteCount; + length: ByteCount; + signal: AbortSignal; + }): Promise>; + + openSource(input: { + ref: LocalFileRef; + signal: AbortSignal; + }): Promise>; + + release(ref: LocalFileRef): void; +} + +export type PreviewLease = Readonly<{ + url: string; + mediaType: string; + release: Cleanup; +}>; + +/** + * Presentation-local capability. The implementation owns object URL creation + * and must revoke each lease on replacement, load failure and unmount. + */ +export interface TransientPreviewPort { + create(input: { + ref: LocalFileRef; + verificationReceipt: FileVerificationReceipt; + policy: FilePolicyReference; + maxPreviewBytes?: ByteCount; + signal: AbortSignal; + }): Promise>; + + dispose(): void; +} + +/** + * Feature/backend workflow example, not a browser capability dependency. + * Copy it only when the backend owns authorization, resumable-session expiry, + * integrity verification, content inspection and quarantine promotion. + */ +export type ExampleUploadSession = Readonly<{ + sessionId: string; + partSizeBytes: ByteCount; + maxConcurrency: number; + expiresAt: string; + checksumAlgorithm: "SHA-256"; +}>; + +export type ExampleUploadPartReceipt = Readonly<{ + partNumber: number; + acceptedBytes: ByteCount; + checksumSha256: string; +}>; + +export type ExampleQuarantinedUpload = Readonly<{ + resourceId: string; + state: "QUARANTINED"; +}>; + +export interface ExampleQuarantinedUploadPort { + create(input: { + purpose: string; + byteLength: ByteCount; + detectedMediaType: string; + signal: AbortSignal; + }): Promise>; + + uploadPart(input: { + sessionId: string; + partNumber: number; + offset: ByteCount; + bytes: Uint8Array; + checksumSha256: string; + idempotencyKey: string; + signal: AbortSignal; + }): Promise>; + + complete(input: { + sessionId: string; + parts: ReadonlyArray; + signal: AbortSignal; + }): Promise>; + + abort( + sessionId: string, + signal?: AbortSignal, + ): Promise>; +} + +export type DownloadSource = + | Readonly<{ + kind: "BROWSER_MANAGED_RESOURCE"; + resourceId: string; + capabilityReceipt: BrowserManagedDownloadCapabilityReceipt; + }> + | Readonly<{ + kind: "AUTHORIZED_STREAM_RESOURCE"; + resourceId: string; + capability: AuthorizedDownloadCapability; + }> + | Readonly<{ + kind: "GENERATED"; + bytes: FileByteSource; + expectedSha256?: string; + }>; + +export type DownloadStrategy = + | "BROWSER_MANAGED" + | "PROMPT_AND_STREAM" + | "BOUNDED_OBJECT_URL"; + +export type DownloadOutcome = + | Readonly<{ + kind: "BROWSER_HANDOFF"; + transferId: string; + }> + | Readonly<{ + kind: "SAVED"; + transferId: string; + bytesWritten: ByteCount; + integrity: "VERIFIED" | "NOT_PROVIDED"; + }> + | Readonly<{ kind: "DISMISSED" }>; + +export interface DownloadDeliveryPort { + deliver(input: { + policy: FilePolicyReference; + source: DownloadSource; + suggestedFileName: string; + maxTransferBytes?: ByteCount; + maxBufferedBytes?: ByteCount; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise>; +} + +export type BrowserManagedDownloadCapability = Readonly<{ + capabilityReceipt: BrowserManagedDownloadCapabilityReceipt; + href: string; + resourceId: string; + mediaType: string; + safeExtension: string; + maxBytes: ByteCount; + expectedSha256?: string; + expiresAtEpochMs: number; +}>; + +export interface BrowserManagedDownloadCapabilityResolver { + resolve(input: Readonly<{ + resourceId: string; + capabilityReceipt: BrowserManagedDownloadCapabilityReceipt; + }>): CapabilityResult; +} + +export type RegisteredPreviewPolicy = Readonly<{ + allowedMediaTypes: ReadonlyArray; + maxPreviewBytes: ByteCount; +}>; + +export type RegisteredDownloadPolicy = Readonly<{ + strategy: DownloadStrategy; + mediaType: string; + safeExtension: string; + maxTransferBytes: ByteCount; + maxBufferedBytes: ByteCount; + integrity: "OPTIONAL" | "REQUIRED"; +}>; + +/** + * Dataset policy belongs at composition. Feature and presentation callers + * receive only the reference plus optional reductions. + */ +export type BrowserFilePolicyProfile = Readonly<{ + reference: FilePolicyReference; + selection?: FileSelectionPolicy; + inspection?: FileInspectionPolicy; + preview?: RegisteredPreviewPolicy; + download?: RegisteredDownloadPolicy; +}>; + +export type BrowserPersistencePolicy = Readonly<{ + owner: string; + namespace: string; + classification: PersistableDataClass; + authority: "SERVER" | "LOCAL_FIRST" | "RECONSTRUCTABLE"; + accountScope: "ORIGIN_SHARED" | "OPAQUE_PARTITION"; + retention: + | Readonly<{ kind: "SESSION" }> + | Readonly<{ kind: "TTL"; maxAgeMs: number }> + | Readonly<{ kind: "UNTIL_SYNCED" }> + | Readonly<{ kind: "EXPLICIT_DELETE" }>; + softBudgetBytes: ByteCount; + hardBudgetBytes: ByteCount; + evictionPriority: "RECONSTRUCTABLE" | "SYNCED_COPY" | "USER_AUTHORED"; + logoutAction: + | "KEEP_ORIGIN_SHARED" + | "PURGE_PARTITION" + | "EXPORT_THEN_PURGE"; + accountDeletionAction: "KEEP_ORIGIN_SHARED" | "PURGE_PARTITION"; + pressureAction: "EVICT_RECONSTRUCTABLE" | "RETAIN"; + unavailableFallback: "ONLINE_ONLY" | "READ_ONLY" | "EXPORT_REQUIRED"; +}>; + +export type OfflineStoreStatus = + | Readonly<{ + kind: "READY"; + persistence: "BEST_EFFORT" | "PERSISTENT"; + }> + | Readonly<{ + kind: "READ_ONLY"; + reason: "FUTURE_SCHEMA" | "QUOTA" | "RECOVERY"; + }> + | Readonly<{ + kind: "ONLINE_ONLY"; + reason: "UNAVAILABLE" | "MIGRATION_FAILED"; + }> + | Readonly<{ + kind: "UPGRADE_BLOCKED"; + targetVersion: number; + }> + | Readonly<{ + kind: "CLOSED"; + reason: "VERSION_CHANGE" | "FORCED" | "DISPOSED"; + }>; + +export type OfflineStoreLifecycleEvent = + | Readonly<{ kind: "STATUS_CHANGED"; status: OfflineStoreStatus }> + | Readonly<{ + kind: "MIGRATION_PROGRESS"; + migrationId: string; + processedCount: number; + remainingEstimate: number | null; + }>; + +export type StoredRecord = Readonly<{ + id: string; + revision: number; + payloadVersion: number; + createdAtEpochMs: number; + updatedAtEpochMs: number; + expiresAtEpochMs: number | null; + value: T; +}>; + +export type RevisionGuard = + | Readonly<{ kind: "ANY" }> + | Readonly<{ kind: "MUST_NOT_EXIST" }> + | Readonly<{ kind: "MATCH"; revision: number }>; + +export type StructuredOfflineMutation = + | Readonly<{ + kind: "PUT"; + id: string; + value: T; + payloadVersion: number; + expiresAtEpochMs: number | null; + revision: RevisionGuard; + }> + | Readonly<{ + kind: "DELETE"; + id: string; + revision: RevisionGuard; + }>; + +export type OfflineCommitReceipt = Readonly<{ + commitId: string; + revisions: Readonly>; + replayed: boolean; +}>; + +export type OfflinePage = Readonly<{ + records: ReadonlyArray>; + nextCursor: string | null; +}>; + +/** + * Copy this as a feature-specific repository. Do not expose a generic + * transaction callback, object-store name, index name or schema version. + */ +export interface StructuredOfflineStore { + open(input: { + partitionKey: string; + signal?: AbortSignal; + onLifecycle(event: OfflineStoreLifecycleEvent): void; + }): Promise>; + + read( + id: string, + signal?: AbortSignal, + ): Promise | null>>; + + page(input: { + cursor?: string; + limit: number; + includeExpired?: boolean; + signal?: AbortSignal; + }): Promise>>; + + commit(input: { + idempotencyKey: string; + mutations: ReadonlyArray>; + signal?: AbortSignal; + }): Promise>; + + clearPartition(input: { + reason: "LOGOUT" | "ACCOUNT_DELETION" | "USER_REQUEST"; + signal?: AbortSignal; + }): Promise>; + + status(): OfflineStoreStatus; + close(): void; +} + +export type MigrationProgress = Readonly<{ + migrationId: string; + state: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED"; + processedCount: number; + remainingEstimate: number | null; +}>; + +export interface OfflineStoreMaintenancePort { + resumeDataMigration(input: { + migrationId: string; + maxRecords: number; + timeBudgetMs: number; + signal?: AbortSignal; + }): Promise>; + + purgeExpired(input: { + maxRecords: number; + nowEpochMs: number; + signal?: AbortSignal; + }): Promise>>; +} + +export type StorageEstimate = Readonly<{ + usageBytes: number | null; + quotaBytes: number | null; + persisted: boolean | null; + pressure: "UNKNOWN" | "NORMAL" | "PRESSURE" | "CRITICAL"; +}>; + +export interface StorageDurabilityPort { + inspect(signal?: AbortSignal): Promise>; + + requestPersistence(input: { + reason: "PROTECT_UNSYNCED_USER_DATA"; + userInitiated: true; + signal?: AbortSignal; + }): Promise>; +} + +export type DurableObjectDataClass = + | "RECONSTRUCTABLE" + | "USER_CREATED_PRIVATE"; + +export type DurableObjectIntegrity = Readonly<{ + algorithm: "SHA-256-TREE-V1"; + rootDigest: string; + chunkSizeBytes: ByteCount; +}>; + +export type DurableObjectDescriptor = Readonly<{ + id: DurableObjectId; + generation: ObjectGeneration; + byteLength: ByteCount; + mediaType: string | null; + integrity: DurableObjectIntegrity; + dataClass: DurableObjectDataClass; + retention: + | Readonly<{ kind: "EXPLICIT_DELETE" }> + | Readonly<{ kind: "EXPIRES"; expiresAt: string }>; +}>; + +export type DurableObjectRead = Readonly<{ + descriptor: DurableObjectDescriptor; + chunks: AsyncIterable>; +}>; + +export interface DurableObjectStorePort { + capabilities(signal?: AbortSignal): Promise< + CapabilityResult< + Readonly<{ + backend: "OPFS" | "INDEXEDDB_BLOB" | "NONE"; + persistence: "BEST_EFFORT" | "PERSISTENT"; + maxObjectBytes: ByteCount; + }> + > + >; + + put(input: { + id: DurableObjectId; + expectedGeneration: ObjectGeneration | null; + source: AsyncIterable>; + declaredByteLength: ByteCount; + mediaType: string | null; + dataClass: DurableObjectDataClass; + retention: DurableObjectDescriptor["retention"]; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise>; + + open( + id: DurableObjectId, + signal?: AbortSignal, + ): Promise>; + + remove(input: { + id: DurableObjectId; + expectedGeneration: ObjectGeneration; + signal?: AbortSignal; + }): Promise>; +} + +export type ObjectStoreRecoverySummary = Readonly<{ + resumedCount: number; + purgedCount: number; + quarantinedCount: number; + nextCursor: string | null; +}>; + +export interface DurableObjectMaintenancePort { + reconcile(input: { + timeBudgetMs: number; + maxEntries: number; + cursor?: string; + signal?: AbortSignal; + }): Promise>; +} + +export type PublicAssetEntry = Readonly<{ + url: string; + byteLength: ByteCount; + mediaType: string; + integritySha256: string; + requestCredentials: "OMIT"; + dataClass: "PUBLIC"; +}>; + +export type PublicCacheLookup = + | Readonly<{ + kind: "HIT"; + releaseId: string; + entry: PublicAssetEntry; + }> + | Readonly<{ + kind: "MISS"; + reason: + | "NOT_FOUND" + | "EXPIRED" + | "NO_ACTIVE_RELEASE" + | "POLICY_REJECTED"; + }>; + +export type PublicCacheInspection = Readonly<{ + activeReleaseId: string | null; + previousReleaseId: string | null; + candidateReleaseIds: ReadonlyArray; + ownedBytes: number | null; +}>; + +/** + * Platform-local Cache Storage policy facade. Raw Request/Response/Cache + * objects remain inside the adapter, and no application repository imports it. + */ +export interface PublicResponseCacheAdmin { + stageRelease(input: { + releaseId: string; + manifestDigest: string; + entries: ReadonlyArray; + signal: AbortSignal; + }): Promise>; + + activateRelease(input: { + releaseId: string; + manifestDigest: string; + expectedPreviousReleaseId: string | null; + signal?: AbortSignal; + }): Promise>; + + lookup(input: { + url: string; + requestCredentials: "OMIT"; + hasAuthorization: false; + signal?: AbortSignal; + }): Promise>; + + inspect(signal?: AbortSignal): Promise< + CapabilityResult + >; + + rollback(signal?: AbortSignal): Promise>; + + deleteOwned(input: { + roles: ReadonlyArray<"CANDIDATE" | "PREVIOUS" | "RUNTIME_PUBLIC">; + signal?: AbortSignal; + }): Promise>>; +} + +export type BrowserFileComposition = Readonly<{ + picker: FilePickerPort; + content: FileContentPort; + previews: TransientPreviewPort; + downloads: DownloadDeliveryPort; +}>; + +export type StructuredOfflineStorageComposition = Readonly<{ + store: StructuredOfflineStore; + maintenance: OfflineStoreMaintenancePort; +}>; + +export type StorageDurabilityComposition = Readonly<{ + durability: StorageDurabilityPort; +}>; + +export type DurableObjectStorageComposition = Readonly<{ + store: DurableObjectStorePort; + maintenance: DurableObjectMaintenancePort; +}>; + +export type PublicResponseCacheComposition = Readonly<{ + cache: PublicResponseCacheAdmin; +}>; + +/** + * Explicitly separate from BrowserFileComposition: installing file selection, + * preview or download must never imply a resumable backend upload protocol. + */ +export type ExampleBackendUploadComposition = Readonly<{ + upload: ExampleQuarantinedUploadPort; +}>; diff --git a/recipes/frontend-capabilities/browser-file-storage-fakes.ts b/recipes/frontend-capabilities/browser-file-storage-fakes.ts new file mode 100644 index 0000000..7734e73 --- /dev/null +++ b/recipes/frontend-capabilities/browser-file-storage-fakes.ts @@ -0,0 +1,2121 @@ +import type { + ByteCount, + BrowserFilePolicyProfile, + BrowserManagedDownloadCapabilityResolver, + BrowserManagedDownloadCapabilityReceipt, + DownloadDeliveryPort, + DownloadOutcome, + DurableObjectDescriptor, + DurableObjectId, + DurableObjectMaintenancePort, + DurableObjectRead, + DurableObjectStorePort, + ExampleQuarantinedUpload, + ExampleQuarantinedUploadPort, + ExampleUploadPartReceipt, + ExampleUploadSession, + FileBytePattern, + FileCandidate, + FileByteSource, + FileContentPort, + FileInspection, + FileInspectionPolicy, + FilePolicyReference, + FilePickerPort, + FileSelectionOutcome, + FileSelectionLimitReduction, + FileSelectionPolicy, + FileVerificationReceipt, + LocalFileRef, + MigrationProgress, + ObjectGeneration, + ObjectStoreRecoverySummary, + OfflineCommitReceipt, + OfflinePage, + OfflineStoreLifecycleEvent, + OfflineStoreMaintenancePort, + OfflineStoreStatus, + PublicAssetEntry, + PublicCacheInspection, + PublicCacheLookup, + PublicResponseCacheAdmin, + StorageDurabilityPort, + StorageEstimate, + StoredRecord, + StructuredOfflineMutation, + StructuredOfflineStore, + TransientPreviewPort, + RegisteredDownloadPolicy, + RegisteredPreviewPolicy, +} from "./browser-file-storage-contracts.ts"; +import type { CapabilityResult } from "./contracts.ts"; +import { failure, success } from "./fake-adapters.ts"; + +export function asByteCount(value: number): ByteCount { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError("Byte counts must be non-negative safe integers."); + } + return value as ByteCount; +} + +export function asLocalFileRef(value: string): LocalFileRef { + if (value.length === 0) { + throw new TypeError("Local file references must not be empty."); + } + return value as LocalFileRef; +} + +export function asFileVerificationReceipt( + value: string, +): FileVerificationReceipt { + if (!/^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value)) { + throw new TypeError("File verification receipts must be opaque tokens."); + } + return value as FileVerificationReceipt; +} + +const issuedFilePolicyReferences = new WeakSet(); + +export function browserFilePolicyReference( + policyKey: string, + intention: string, +): FilePolicyReference { + const token = /^[a-z0-9][a-z0-9._:-]{0,127}$/i; + if (!token.test(policyKey) || !token.test(intention)) { + throw new TypeError("File policy references must be opaque tokens."); + } + const reference = Object.freeze({ + policyKey: + policyKey as FilePolicyReference["policyKey"], + intention: + intention as FilePolicyReference["intention"], + }); + issuedFilePolicyReferences.add(reference); + return reference; +} + +export function asBrowserManagedDownloadCapabilityReceipt( + value: string, +): BrowserManagedDownloadCapabilityReceipt { + if (!/^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value)) { + throw new TypeError( + "Browser-managed download receipts must be opaque tokens.", + ); + } + return value as BrowserManagedDownloadCapabilityReceipt; +} + +type MemoryPolicyProfile = Readonly<{ + bindingId: string; + selection?: FileSelectionPolicy; + inspection?: FileInspectionPolicy; + preview?: RegisteredPreviewPolicy; + download?: RegisteredDownloadPolicy; +}>; + +/** + * Composition-time dataset policy registry used by the deterministic fakes. + * It deep-snapshots profile data and resolves only the exact registered + * reference object. Equal key/intention strings are not authority. + */ +export class MemoryBrowserFilePolicyRegistry { + readonly #profiles = + new Map(); + + constructor(profiles: ReadonlyArray) { + if (profiles.length === 0) { + throw new TypeError("At least one file policy profile is required."); + } + const semanticKeys = new Set(); + for (const profile of profiles) { + if ( + !issuedFilePolicyReferences.has(profile.reference) || + (!profile.selection && + !profile.inspection && + !profile.preview && + !profile.download) || + (profile.preview && !profile.inspection) + ) { + throw new TypeError("The file policy profile is invalid."); + } + const bindingId = `${profile.reference.policyKey}\u0000${profile.reference.intention}`; + if (semanticKeys.has(bindingId)) { + throw new TypeError("The file policy profile is duplicated."); + } + semanticKeys.add(bindingId); + this.#profiles.set( + profile.reference, + Object.freeze({ + bindingId, + ...(profile.selection + ? { selection: snapshotSelectionPolicy(profile.selection) } + : {}), + ...(profile.inspection + ? { + inspection: snapshotInspectionPolicy( + profile.inspection, + ), + } + : {}), + ...(profile.preview + ? { preview: snapshotPreviewPolicy(profile.preview) } + : {}), + ...(profile.download + ? { + download: Object.freeze({ + ...profile.download, + }), + } + : {}), + }), + ); + } + } + + selection( + reference: FilePolicyReference, + reductions?: FileSelectionLimitReduction, + ): CapabilityResult { + const profile = this.#profiles.get(reference); + if (!profile?.selection) return rejectedPolicy(); + const maxCount = reduceLimit( + reductions?.maxCount, + profile.selection.maxCount, + ); + const maxFileBytes = reduceLimit( + reductions?.maxFileBytes, + profile.selection.maxFileBytes, + ); + const maxTotalBytes = reduceLimit( + reductions?.maxTotalBytes, + profile.selection.maxTotalBytes, + ); + if ( + maxCount === null || + maxFileBytes === null || + maxTotalBytes === null + ) { + return failure( + "LIMIT_EXCEEDED", + false, + "A caller may only reduce registered file limits.", + ); + } + return success( + Object.freeze({ + ...profile.selection, + maxCount, + maxFileBytes: asByteCount(maxFileBytes), + maxTotalBytes: asByteCount(maxTotalBytes), + }), + ); + } + + inspection( + reference: FilePolicyReference, + reduction?: ByteCount, + ): CapabilityResult< + Readonly<{ + policy: FileInspectionPolicy; + bindingId: string; + }> + > { + const profile = this.#profiles.get(reference); + if (!profile?.inspection) return rejectedPolicy(); + const maxInspectionBytes = reduceLimit( + reduction, + profile.inspection.maxInspectionBytes, + ); + if (maxInspectionBytes === null) { + return failure( + "LIMIT_EXCEEDED", + false, + "A caller may only reduce registered inspection limits.", + ); + } + return success( + Object.freeze({ + bindingId: profile.bindingId, + policy: Object.freeze({ + ...profile.inspection, + maxInspectionBytes: asByteCount(maxInspectionBytes), + }), + }), + ); + } + + preview( + reference: FilePolicyReference, + reduction?: ByteCount, + ): CapabilityResult< + Readonly<{ + bindingId: string; + allowedMediaTypes: ReadonlySet; + maxPreviewBytes: ByteCount; + }> + > { + const profile = this.#profiles.get(reference); + if (!profile?.preview || !profile.inspection) { + return rejectedPolicy(); + } + const maxPreviewBytes = reduceLimit( + reduction, + profile.preview.maxPreviewBytes, + ); + if (maxPreviewBytes === null) { + return failure( + "LIMIT_EXCEEDED", + false, + "A caller may only reduce registered preview limits.", + ); + } + return success( + Object.freeze({ + bindingId: profile.bindingId, + allowedMediaTypes: new Set( + profile.preview.allowedMediaTypes, + ), + maxPreviewBytes: asByteCount(maxPreviewBytes), + }), + ); + } + + download( + reference: FilePolicyReference, + reductions: Readonly<{ + maxTransferBytes?: ByteCount; + maxBufferedBytes?: ByteCount; + }>, + ): CapabilityResult { + const profile = this.#profiles.get(reference); + if (!profile?.download) return rejectedPolicy(); + const maxTransferBytes = reduceLimit( + reductions.maxTransferBytes, + profile.download.maxTransferBytes, + ); + const maxBufferedBytes = reduceLimit( + reductions.maxBufferedBytes, + Math.min( + profile.download.maxBufferedBytes, + maxTransferBytes ?? -1, + ), + ); + if ( + maxTransferBytes === null || + maxBufferedBytes === null + ) { + return failure( + "LIMIT_EXCEEDED", + false, + "A caller may only reduce registered download limits.", + ); + } + return success( + Object.freeze({ + ...profile.download, + maxTransferBytes: asByteCount(maxTransferBytes), + maxBufferedBytes: asByteCount(maxBufferedBytes), + }), + ); + } +} + +function rejectedPolicy(): CapabilityResult { + return failure( + "POLICY_REJECTED", + false, + "The composition-issued file policy is not available.", + ); +} + +function reduceLimit( + reduction: number | undefined, + ceiling: number, +): number | null { + const value = reduction ?? ceiling; + return Number.isSafeInteger(value) && + value > 0 && + value <= ceiling + ? value + : null; +} + +function snapshotSelectionPolicy( + policy: FileSelectionPolicy, +): FileSelectionPolicy { + return Object.freeze({ + ...policy, + accept: Object.freeze( + policy.accept.map((rule) => + Object.freeze({ + mediaType: rule.mediaType, + extensions: Object.freeze([...rule.extensions]), + }), + ), + ), + }); +} + +function snapshotInspectionPolicy( + policy: FileInspectionPolicy, +): FileInspectionPolicy { + return Object.freeze({ + ...policy, + acceptedSignatures: Object.freeze( + policy.acceptedSignatures.map((rule) => + Object.freeze({ + mediaType: rule.mediaType, + extensions: Object.freeze([...rule.extensions]), + patterns: Object.freeze( + rule.patterns.map((pattern) => + Object.freeze({ + offset: pattern.offset, + bytes: Object.freeze([...pattern.bytes]), + ...(pattern.mask + ? { mask: Object.freeze([...pattern.mask]) } + : {}), + }), + ), + ), + }), + ), + ), + }); +} + +function snapshotPreviewPolicy( + policy: RegisteredPreviewPolicy, +): RegisteredPreviewPolicy { + return Object.freeze({ + allowedMediaTypes: Object.freeze([ + ...policy.allowedMediaTypes, + ]), + maxPreviewBytes: policy.maxPreviewBytes, + }); +} + +export function asDurableObjectId(value: string): DurableObjectId { + if (value.length === 0) { + throw new TypeError("Durable object IDs must not be empty."); + } + return value as DurableObjectId; +} + +export function asObjectGeneration(value: number): ObjectGeneration { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError("Object generations must be positive safe integers."); + } + return value as ObjectGeneration; +} + +function cancelled(signal?: AbortSignal): CapabilityResult | null { + return signal?.aborted + ? failure("ABORTED", false, "The operation was cancelled.") + : null; +} + +function isReadableStatus(status: OfflineStoreStatus): boolean { + return status.kind === "READY" || status.kind === "READ_ONLY"; +} + +function isWritableStatus(status: OfflineStoreStatus): boolean { + return status.kind === "READY"; +} + +function cloneBytes(bytes: Uint8Array): Uint8Array { + return bytes.slice(); +} + +function extensionOf(fileName: string): string | null { + const normalized = fileName.normalize("NFC"); + const index = normalized.lastIndexOf("."); + return index > 0 && index < normalized.length - 1 + ? normalized.slice(index).toLowerCase() + : null; +} + +function acceptsCandidate( + policy: FileSelectionPolicy, + candidate: FileCandidate, +): boolean { + if (policy.accept.length === 0) return true; + const extension = extensionOf(candidate.displayName); + return policy.accept.some( + (rule) => + (candidate.reportedMediaType !== null && + candidate.reportedMediaType.toLowerCase() === + rule.mediaType.toLowerCase()) || + (extension !== null && + rule.extensions.some( + (accepted) => accepted.toLowerCase() === extension, + )), + ); +} + +function matchesFileBytePattern( + bytes: Uint8Array, + pattern: FileBytePattern, +): boolean { + if (pattern.offset + pattern.bytes.length > bytes.byteLength) { + return false; + } + for (const [index, expected] of pattern.bytes.entries()) { + const actual = bytes[pattern.offset + index]; + const mask = pattern.mask?.[index] ?? 0xff; + if ( + actual === undefined || + !Number.isInteger(expected) || + expected < 0 || + expected > 0xff || + (actual & mask) !== (expected & mask) + ) { + return false; + } + } + return true; +} + +export type MemoryFileFixture = Readonly<{ + ref: LocalFileRef; + displayName: string; + bytes: Uint8Array; + reportedMediaType: string | null; + detectedMediaType: string | null; + lastModifiedEpochMs?: number | null; + signature?: FileInspection["signature"]; + source?: FileCandidate["source"]; +}>; + +/** + * Deterministic picker and transient file vault. Metadata matching is only a + * preflight policy simulation. Any separately selected backend transfer + * workflow must independently revalidate content. + */ +export class MemoryFileSelectionAdapter + implements FilePickerPort, FileContentPort +{ + readonly support; + readonly #policies: MemoryBrowserFilePolicyRegistry; + readonly #files = new Map(); + readonly #verifications = new Map< + FileVerificationReceipt, + Readonly<{ + ref: LocalFileRef; + policyBindingId: string; + mediaType: string; + }> + >(); + #verificationSequence = 0; + #nextOutcome: "SELECTED" | "DISMISSED" | "PERMISSION_DENIED" = "SELECTED"; + + constructor( + files: ReadonlyArray, + policies: MemoryBrowserFilePolicyRegistry, + support: FilePickerPort["support"] = { + nativeInput: true, + systemOpenPicker: false, + systemSavePicker: false, + }, + ) { + this.#policies = policies; + this.support = Object.freeze({ ...support }); + for (const file of files) { + this.#files.set(file.ref, { + ...file, + bytes: cloneBytes(file.bytes), + }); + } + } + + setNextOutcome( + outcome: "SELECTED" | "DISMISSED" | "PERMISSION_DENIED", + ): void { + this.#nextOutcome = outcome; + } + + async select(input: { + policy: FilePolicyReference; + limits?: FileSelectionLimitReduction; + signal?: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const outcome = this.#nextOutcome; + this.#nextOutcome = "SELECTED"; + if (outcome === "DISMISSED") { + return success({ kind: "DISMISSED" }); + } + if (outcome === "PERMISSION_DENIED") { + return failure( + "PERMISSION_DENIED", + false, + "File access permission was denied.", + ); + } + const resolvedPolicy = this.#policies.selection( + input.policy, + input.limits, + ); + if (!resolvedPolicy.ok) return resolvedPolicy; + const policy = resolvedPolicy.value; + + const candidates = Array.from(this.#files.values(), (file) => + Object.freeze({ + ref: file.ref, + displayName: file.displayName, + sizeBytes: asByteCount(file.bytes.byteLength), + reportedMediaType: file.reportedMediaType, + lastModifiedEpochMs: file.lastModifiedEpochMs ?? null, + source: file.source ?? "NATIVE_INPUT", + }), + ); + const total = candidates.reduce( + (sum, candidate) => sum + candidate.sizeBytes, + 0, + ); + if ( + candidates.length > policy.maxCount || + (!policy.multiple && candidates.length > 1) || + candidates.some( + (candidate) => + candidate.sizeBytes > policy.maxFileBytes || + (!policy.allowEmpty && candidate.sizeBytes === 0), + ) || + total > policy.maxTotalBytes + ) { + return failure( + "LIMIT_EXCEEDED", + false, + "The selected files exceed the approved count or byte budget.", + ); + } + if ( + candidates.some( + (candidate) => !acceptsCandidate(policy, candidate), + ) + ) { + return failure( + "POLICY_REJECTED", + false, + "A selected file does not match the intake policy.", + ); + } + return success({ kind: "SELECTED", files: candidates }); + } + + async inspect(input: { + ref: LocalFileRef; + policy: FilePolicyReference; + maxInspectionBytes?: ByteCount; + signal: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const resolvedPolicy = this.#policies.inspection( + input.policy, + input.maxInspectionBytes, + ); + if (!resolvedPolicy.ok) return resolvedPolicy; + const { policy, bindingId } = resolvedPolicy.value; + const file = this.#files.get(input.ref); + if (!file) { + return failure("NOT_FOUND", false, "The selected file is no longer available."); + } + this.#invalidateVerifications(input.ref); + const inspectedBytes = file.bytes.slice( + 0, + policy.maxInspectionBytes, + ); + const matchedRule = policy.acceptedSignatures.find( + (rule) => + rule.mediaType.toLowerCase() === + file.detectedMediaType?.toLowerCase() && + rule.patterns.some((pattern) => + matchesFileBytePattern(inspectedBytes, pattern), + ), + ); + const detectedMediaType = matchedRule?.mediaType ?? null; + const signature = + file.signature === "MISMATCHED" + ? "MISMATCHED" + : matchedRule + ? "MATCHED" + : file.signature === "MATCHED" + ? "MISMATCHED" + : "UNKNOWN"; + const verificationReceipt = + signature === "MATCHED" && detectedMediaType !== null + ? asFileVerificationReceipt( + `verification:${++this.#verificationSequence}`, + ) + : null; + if (verificationReceipt) { + this.#verifications.set( + verificationReceipt, + Object.freeze({ + ref: input.ref, + policyBindingId: bindingId, + mediaType: detectedMediaType!, + }), + ); + } + return success({ + byteLength: asByteCount(file.bytes.byteLength), + reportedMediaType: file.reportedMediaType, + detectedMediaType, + normalizedExtension: extensionOf(file.displayName), + signature, + verificationReceipt, + }); + } + + async readRange(input: { + ref: LocalFileRef; + offset: ByteCount; + length: ByteCount; + signal: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const file = this.#files.get(input.ref); + if (!file) { + return failure("NOT_FOUND", false, "The selected file is no longer available."); + } + const end = input.offset + input.length; + if ( + !Number.isSafeInteger(end) || + input.offset > file.bytes.byteLength || + end > file.bytes.byteLength + ) { + return failure("INVALID_INPUT", false, "The requested byte range is invalid."); + } + return success(file.bytes.slice(input.offset, end)); + } + + async openSource(input: { + ref: LocalFileRef; + signal: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const file = this.#files.get(input.ref); + if (!file) { + return failure( + "NOT_FOUND", + false, + "The selected file is no longer available.", + ); + } + const bytes = cloneBytes(file.bytes); + return success( + Object.freeze({ + byteLength: asByteCount(bytes.byteLength), + async *stream(signal: AbortSignal) { + const streamAbort = cancelled(signal); + if (streamAbort) { + yield streamAbort; + return; + } + yield success(cloneBytes(bytes)); + }, + }), + ); + } + + release(ref: LocalFileRef): void { + this.#invalidateVerifications(ref); + this.#files.delete(ref); + } + + verification( + receipt: FileVerificationReceipt, + ): Readonly<{ + ref: LocalFileRef; + policyBindingId: string; + mediaType: string; + byteLength: ByteCount; + }> | null { + const verification = this.#verifications.get(receipt); + if (!verification) return null; + const file = this.#files.get(verification.ref); + return file + ? Object.freeze({ + ...verification, + byteLength: asByteCount(file.bytes.byteLength), + }) + : null; + } + + #invalidateVerifications(ref: LocalFileRef): void { + for (const [receipt, verification] of this.#verifications) { + if (verification.ref === ref) this.#verifications.delete(receipt); + } + } +} + +export class MemoryTransientPreviewAdapter implements TransientPreviewPort { + static readonly #ACTIVE_CONTENT_DENYLIST = new Set([ + "application/pdf", + "application/xhtml+xml", + "application/xml", + "image/svg+xml", + "text/html", + "text/xml", + ]); + + readonly #files: MemoryFileSelectionAdapter; + readonly #policies: MemoryBrowserFilePolicyRegistry; + readonly #active = new Set(); + #sequence = 0; + #disposed = false; + + constructor( + files: MemoryFileSelectionAdapter, + policies: MemoryBrowserFilePolicyRegistry, + ) { + this.#files = files; + this.#policies = policies; + } + + async create( + input: Parameters[0], + ): ReturnType { + if (this.#disposed) { + return failure( + "PROVIDER_UNAVAILABLE", + false, + "The preview adapter has been disposed.", + ); + } + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const verification = this.#files.verification( + input.verificationReceipt, + ); + const resolvedPolicy = this.#policies.preview( + input.policy, + input.maxPreviewBytes, + ); + if (!resolvedPolicy.ok) return resolvedPolicy; + const policy = resolvedPolicy.value; + if ( + !verification || + verification.ref !== input.ref || + verification.policyBindingId !== policy.bindingId + ) { + return failure( + "POLICY_REJECTED", + false, + "The preview verification receipt is invalid.", + ); + } + if ( + verification.byteLength > policy.maxPreviewBytes + ) { + return failure( + "LIMIT_EXCEEDED", + false, + "The preview exceeds its byte budget.", + ); + } + if ( + !policy.allowedMediaTypes.has(verification.mediaType) || + MemoryTransientPreviewAdapter.#ACTIVE_CONTENT_DENYLIST.has( + verification.mediaType.toLowerCase(), + ) + ) { + return failure( + "POLICY_REJECTED", + false, + "The file type is not approved for inline preview.", + ); + } + const url = `blob:memory-preview-${++this.#sequence}`; + this.#active.add(url); + let released = false; + return success( + Object.freeze({ + url, + mediaType: verification.mediaType, + release: () => { + if (released) return; + released = true; + this.#active.delete(url); + }, + }), + ); + } + + get activeLeaseCount(): number { + return this.#active.size; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#active.clear(); + } +} + +type MemoryUploadState = { + expectedBytes: number; + parts: Map< + number, + Readonly<{ offset: number; receipt: ExampleUploadPartReceipt }> + >; + idempotency: Map< + string, + Readonly<{ + fingerprint: string; + receipt: ExampleUploadPartReceipt; + }> + >; +}; + +/** + * Deterministic example for a separately selected, backend-authorized + * resumable upload workflow. It is not part of browser-file composition. + */ +export class MemoryExampleQuarantinedUploadAdapter + implements ExampleQuarantinedUploadPort +{ + readonly #sessions = new Map(); + #sequence = 0; + + constructor( + private readonly maxUploadBytes: ByteCount, + private readonly partSizeBytes: ByteCount, + private readonly now: () => number = Date.now, + ) {} + + async create(input: { + purpose: string; + byteLength: ByteCount; + detectedMediaType: string; + signal: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if ( + input.purpose.length === 0 || + input.detectedMediaType.length === 0 || + input.byteLength > this.maxUploadBytes + ) { + return failure("LIMIT_EXCEEDED", false, "The upload policy rejected the file."); + } + const sessionId = `upload-${++this.#sequence}`; + this.#sessions.set(sessionId, { + expectedBytes: input.byteLength, + parts: new Map(), + idempotency: new Map(), + }); + return success({ + sessionId, + partSizeBytes: this.partSizeBytes, + maxConcurrency: 2, + expiresAt: new Date(this.now() + 15 * 60_000).toISOString(), + checksumAlgorithm: "SHA-256", + }); + } + + async uploadPart(input: { + sessionId: string; + partNumber: number; + offset: ByteCount; + bytes: Uint8Array; + checksumSha256: string; + idempotencyKey: string; + signal: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const session = this.#sessions.get(input.sessionId); + if (!session) { + return failure( + "EXPIRED_RESOURCE", + false, + "The upload session is no longer available.", + ); + } + const fingerprint = [ + input.partNumber, + input.offset, + input.bytes.byteLength, + input.checksumSha256, + ].join(":"); + const replay = session.idempotency.get(input.idempotencyKey); + if (replay) { + return replay.fingerprint === fingerprint + ? success(replay.receipt) + : failure( + "CONFLICT", + false, + "The idempotency key was reused for a different upload part.", + ); + } + if ( + input.partNumber < 1 || + input.bytes.byteLength === 0 || + input.bytes.byteLength > this.partSizeBytes || + input.offset + input.bytes.byteLength > session.expectedBytes || + input.checksumSha256.length === 0 + ) { + return failure("INVALID_INPUT", false, "The upload part is invalid."); + } + if (session.parts.has(input.partNumber)) { + return failure("CONFLICT", false, "The upload part already exists."); + } + const receipt = Object.freeze({ + partNumber: input.partNumber, + acceptedBytes: asByteCount(input.bytes.byteLength), + checksumSha256: input.checksumSha256, + }); + session.parts.set(input.partNumber, { + offset: input.offset, + receipt, + }); + session.idempotency.set(input.idempotencyKey, { + fingerprint, + receipt, + }); + return success(receipt); + } + + async complete(input: { + sessionId: string; + parts: ReadonlyArray; + signal: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const session = this.#sessions.get(input.sessionId); + if (!session) { + return failure( + "EXPIRED_RESOURCE", + false, + "The upload session is no longer available.", + ); + } + const partNumbers = new Set(input.parts.map((part) => part.partNumber)); + const acceptedBytes = input.parts.reduce( + (sum, part) => sum + part.acceptedBytes, + 0, + ); + const matches = input.parts.every( + (part) => + session.parts.get(part.partNumber)?.receipt.checksumSha256 === + part.checksumSha256, + ); + const storedParts = Array.from(session.parts.values()).sort( + (left, right) => left.offset - right.offset, + ); + let nextOffset = 0; + const contiguous = storedParts.every((part) => { + if (part.offset !== nextOffset) return false; + nextOffset += part.receipt.acceptedBytes; + return true; + }); + if ( + !matches || + partNumbers.size !== input.parts.length || + input.parts.length !== session.parts.size || + !contiguous || + nextOffset !== session.expectedBytes || + acceptedBytes !== session.expectedBytes + ) { + return failure( + "INTEGRITY_FAILED", + false, + "The uploaded parts did not pass final verification.", + ); + } + this.#sessions.delete(input.sessionId); + return success({ + resourceId: `resource-${input.sessionId}`, + state: "QUARANTINED", + }); + } + + async abort( + sessionId: string, + signal?: AbortSignal, + ): Promise> { + const aborted = cancelled(signal); + if (aborted) return aborted; + this.#sessions.delete(sessionId); + return success(undefined); + } +} + +const windowsDeviceNames = new Set([ + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +]); + +function sanitizeFileNameCharacter(character: string): string { + const codePoint = character.codePointAt(0) ?? 0; + const control = codePoint <= 31 || codePoint === 127; + const bidiControl = + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069); + if (control || bidiControl) return ""; + return character === ":" ? "_" : character; +} + +export function sanitizeDownloadFileName( + candidate: string, + fallback = "download.bin", +): string { + const normalized = candidate.normalize("NFC"); + const pathSegments = normalized.split(/[/\\]/); + const leafName = pathSegments.at(-1) ?? normalized; + const cleaned = Array.from(leafName) + .map(sanitizeFileNameCharacter) + .join("") + .trim() + .replace(/^[. ]+|[. ]+$/g, "") + .replace(/_+/g, "_"); + const bounded = Array.from(cleaned).slice(0, 180).join(""); + const stem = bounded.split(".")[0]?.toUpperCase() ?? ""; + if ( + bounded.length === 0 || + bounded === "." || + bounded === ".." || + windowsDeviceNames.has(stem) + ) { + return fallback; + } + return bounded; +} + +export class RecordingDownloadDeliveryAdapter + implements DownloadDeliveryPort +{ + readonly receipts: Array< + Readonly<{ fileName: string; strategy: string; bytesWritten: number }> + > = []; + readonly #policies: MemoryBrowserFilePolicyRegistry; + readonly #resolveBrowserManaged: + BrowserManagedDownloadCapabilityResolver["resolve"]; + readonly #now: () => number; + #sequence = 0; + + constructor( + policies: MemoryBrowserFilePolicyRegistry, + browserManagedCapabilities: BrowserManagedDownloadCapabilityResolver, + now: () => number = Date.now, + ) { + this.#policies = policies; + this.#resolveBrowserManaged = + browserManagedCapabilities.resolve.bind( + browserManagedCapabilities, + ); + this.#now = now; + } + + async deliver( + input: Parameters[0], + ): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const resolvedPolicy = this.#policies.download(input.policy, { + maxTransferBytes: input.maxTransferBytes, + maxBufferedBytes: input.maxBufferedBytes, + }); + if (!resolvedPolicy.ok) return resolvedPolicy; + const policy = resolvedPolicy.value; + const sanitized = sanitizeDownloadFileName(input.suggestedFileName); + const safeExtension = policy.safeExtension.toLowerCase(); + const fileName = sanitized.toLowerCase().endsWith(safeExtension) + ? sanitized + : `${sanitized.replace(/\.[^.]+$/, "")}${safeExtension}`; + const transferId = `download-${++this.#sequence}`; + if ( + (policy.strategy === "BROWSER_MANAGED" && + input.source.kind !== "BROWSER_MANAGED_RESOURCE") || + (policy.strategy !== "BROWSER_MANAGED" && + input.source.kind === "BROWSER_MANAGED_RESOURCE") + ) { + return failure( + "POLICY_REJECTED", + false, + "The source is incompatible with the registered strategy.", + ); + } + if ( + policy.strategy === "BROWSER_MANAGED" && + input.source.kind === "BROWSER_MANAGED_RESOURCE" + ) { + const capability = this.#resolveBrowserManaged({ + resourceId: input.source.resourceId, + capabilityReceipt: input.source.capabilityReceipt, + }); + if (!capability.ok) return capability; + if ( + capability.value.capabilityReceipt !== + input.source.capabilityReceipt || + capability.value.resourceId !== input.source.resourceId || + capability.value.mediaType !== policy.mediaType || + capability.value.safeExtension !== policy.safeExtension || + capability.value.maxBytes > policy.maxTransferBytes || + capability.value.expiresAtEpochMs <= this.#now() || + (policy.integrity === "REQUIRED" && + !capability.value.expectedSha256) + ) { + return failure( + capability.value.expiresAtEpochMs <= this.#now() + ? "EXPIRED_RESOURCE" + : "POLICY_REJECTED", + false, + "The server-bound download capability is invalid.", + ); + } + this.receipts.push({ + fileName, + strategy: policy.strategy, + bytesWritten: 0, + }); + return success({ kind: "BROWSER_HANDOFF", transferId }); + } + if (input.source.kind === "AUTHORIZED_STREAM_RESOURCE") { + return failure( + "UNSUPPORTED", + false, + "This fake cannot stream an authorized server resource.", + ); + } + if (input.source.kind !== "GENERATED") { + return failure( + "POLICY_REJECTED", + false, + "The download source is incompatible with this fake.", + ); + } + const expectedSha256 = input.source.expectedSha256; + if (policy.integrity === "REQUIRED" && !expectedSha256) { + return failure( + "POLICY_REJECTED", + false, + "The registered download policy requires integrity.", + ); + } + if ( + policy.strategy === "BOUNDED_OBJECT_URL" && + input.source.bytes.byteLength !== null && + input.source.bytes.byteLength > policy.maxBufferedBytes + ) { + return failure( + "LIMIT_EXCEEDED", + false, + "The generated download exceeds the buffering budget.", + ); + } + let bytesWritten = 0; + const integrityChunks: Uint8Array[] = []; + for await (const chunkResult of input.source.bytes.stream( + input.signal, + )) { + if (!chunkResult.ok) return chunkResult; + const chunk = chunkResult.value; + const duringTransfer = cancelled(input.signal); + if (duringTransfer) return duringTransfer; + bytesWritten += chunk.byteLength; + if (bytesWritten > policy.maxTransferBytes) { + return failure( + "LIMIT_EXCEEDED", + false, + "The generated download exceeds the transfer budget.", + ); + } + if ( + input.source.bytes.byteLength !== null && + bytesWritten > input.source.bytes.byteLength + ) { + return failure( + "INTEGRITY_FAILED", + false, + "The generated download exceeded its declared size.", + ); + } + if (expectedSha256) integrityChunks.push(cloneBytes(chunk)); + input.onProgress({ + phase: "TRANSFERRING", + transferredBytes: bytesWritten, + totalBytes: input.source.bytes.byteLength, + }); + } + if ( + input.source.bytes.byteLength !== null && + bytesWritten !== input.source.bytes.byteLength + ) { + return failure( + "INTEGRITY_FAILED", + false, + "The generated download did not match its declared size.", + ); + } + if (expectedSha256) { + const beforeVerification = cancelled(input.signal); + if (beforeVerification) return beforeVerification; + input.onProgress({ + phase: "VERIFYING", + transferredBytes: bytesWritten, + totalBytes: input.source.bytes.byteLength, + }); + const actualSha256 = await sha256Hex(concatenateBytes(integrityChunks)); + if (actualSha256 !== expectedSha256.toLowerCase()) { + return failure( + "INTEGRITY_FAILED", + false, + "The generated download failed integrity verification.", + ); + } + } + this.receipts.push({ + fileName, + strategy: policy.strategy, + bytesWritten, + }); + return success({ + kind: "SAVED", + transferId, + bytesWritten: asByteCount(bytesWritten), + integrity: expectedSha256 ? "VERIFIED" : "NOT_PROVIDED", + }); + } +} + +function concatenateBytes(chunks: ReadonlyArray): Uint8Array { + const totalBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const combined = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined; +} + +type MemoryStructuredOfflineStoreOptions = Readonly<{ + maxBytes: number; + now?: () => number; + initialStatus?: OfflineStoreStatus; +}>; + +export class MemoryStructuredOfflineStore + implements StructuredOfflineStore, OfflineStoreMaintenancePort +{ + readonly #records = new Map>(); + readonly #receipts = new Map< + string, + Readonly<{ fingerprint: string; receipt: OfflineCommitReceipt }> + >(); + readonly #now: () => number; + readonly #maxBytes: number; + #status: OfflineStoreStatus; + #onLifecycle: ((event: OfflineStoreLifecycleEvent) => void) | null = null; + #commitSequence = 0; + + constructor(options: MemoryStructuredOfflineStoreOptions) { + this.#maxBytes = options.maxBytes; + this.#now = options.now ?? Date.now; + this.#status = + options.initialStatus ?? + Object.freeze({ + kind: "CLOSED", + reason: "DISPOSED", + }); + } + + async open(input: { + partitionKey: string; + signal?: AbortSignal; + onLifecycle(event: OfflineStoreLifecycleEvent): void; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if (input.partitionKey.length === 0) { + return failure("INVALID_INPUT", false, "The storage partition is invalid."); + } + this.#onLifecycle = input.onLifecycle; + if (this.#status.kind === "UPGRADE_BLOCKED") { + input.onLifecycle({ kind: "STATUS_CHANGED", status: this.#status }); + return success(this.#status); + } + this.#status = Object.freeze({ + kind: "READY", + persistence: "BEST_EFFORT", + }); + input.onLifecycle({ kind: "STATUS_CHANGED", status: this.#status }); + return success(this.#status); + } + + async read( + id: string, + signal?: AbortSignal, + ): Promise | null>> { + const aborted = cancelled(signal); + if (aborted) return aborted; + if (!isReadableStatus(this.#status)) { + return failure("PROVIDER_UNAVAILABLE", false, "The offline store is not open."); + } + const record = this.#records.get(id); + if ( + record?.expiresAtEpochMs !== null && + record?.expiresAtEpochMs !== undefined && + record.expiresAtEpochMs <= this.#now() + ) { + return success(null); + } + return success(record ? structuredClone(record) : null); + } + + async page(input: { + cursor?: string; + limit: number; + includeExpired?: boolean; + signal?: AbortSignal; + }): Promise>> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if (!isReadableStatus(this.#status)) { + return failure("PROVIDER_UNAVAILABLE", false, "The offline store is not open."); + } + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 500) { + return failure("LIMIT_EXCEEDED", false, "The offline page limit is invalid."); + } + const offset = input.cursor === undefined ? 0 : Number(input.cursor); + if (!Number.isSafeInteger(offset) || offset < 0) { + return failure("INVALID_INPUT", false, "The offline cursor is invalid."); + } + const now = this.#now(); + const records = Array.from(this.#records.values()) + .filter( + (record) => + input.includeExpired === true || + record.expiresAtEpochMs === null || + record.expiresAtEpochMs > now, + ) + .sort((left, right) => left.id.localeCompare(right.id)); + const selected = records + .slice(offset, offset + input.limit) + .map((record) => structuredClone(record)); + const nextOffset = offset + selected.length; + return success({ + records: selected, + nextCursor: nextOffset < records.length ? String(nextOffset) : null, + }); + } + + async commit(input: { + idempotencyKey: string; + mutations: ReadonlyArray>; + signal?: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if (!isWritableStatus(this.#status)) { + return failure("PROVIDER_UNAVAILABLE", false, "The offline store is read-only."); + } + if (input.idempotencyKey.length === 0 || input.mutations.length === 0) { + return failure("INVALID_INPUT", false, "The offline commit is empty."); + } + let fingerprint: string; + try { + fingerprint = JSON.stringify(input.mutations); + } catch { + return failure( + "CORRUPT_DATA", + false, + "The offline mutation cannot be encoded safely.", + ); + } + const replay = this.#receipts.get(input.idempotencyKey); + if (replay) { + return replay.fingerprint === fingerprint + ? success({ ...replay.receipt, replayed: true }) + : failure( + "CONFLICT", + false, + "The idempotency key was reused for a different offline commit.", + ); + } + + const draft = new Map( + Array.from(this.#records, ([id, record]) => [ + id, + structuredClone(record), + ]), + ); + const revisions: Record = {}; + const now = this.#now(); + try { + for (const mutation of input.mutations) { + const current = draft.get(mutation.id); + if (!revisionAllows(mutation.revision, current?.revision)) { + return failure( + "CONFLICT", + false, + "The offline record changed before the commit.", + ); + } + if (mutation.kind === "DELETE") { + draft.delete(mutation.id); + revisions[mutation.id] = null; + continue; + } + const revision = (current?.revision ?? 0) + 1; + draft.set( + mutation.id, + structuredClone({ + id: mutation.id, + revision, + payloadVersion: mutation.payloadVersion, + createdAtEpochMs: current?.createdAtEpochMs ?? now, + updatedAtEpochMs: now, + expiresAtEpochMs: mutation.expiresAtEpochMs, + value: mutation.value, + }), + ); + revisions[mutation.id] = revision; + } + } catch { + return failure( + "CORRUPT_DATA", + false, + "The offline value cannot be persisted safely.", + ); + } + + if (estimateRecordBytes(draft) > this.#maxBytes) { + return failure( + "QUOTA_EXCEEDED", + true, + "The offline storage budget was exceeded.", + ); + } + const lastAbortCheck = cancelled(input.signal); + if (lastAbortCheck) return lastAbortCheck; + this.#records.clear(); + for (const [id, record] of draft) this.#records.set(id, record); + const receipt = Object.freeze({ + commitId: `commit-${++this.#commitSequence}`, + revisions: Object.freeze(revisions), + replayed: false, + }); + this.#receipts.set(input.idempotencyKey, { fingerprint, receipt }); + return success(receipt); + } + + async clearPartition(input: { + reason: "LOGOUT" | "ACCOUNT_DELETION" | "USER_REQUEST"; + signal?: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + this.#records.clear(); + this.#receipts.clear(); + return success(undefined); + } + + status(): OfflineStoreStatus { + return this.#status; + } + + close(): void { + this.#onLifecycle = null; + this.#status = Object.freeze({ kind: "CLOSED", reason: "DISPOSED" }); + } + + async resumeDataMigration(input: { + migrationId: string; + maxRecords: number; + timeBudgetMs: number; + signal?: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if ( + input.migrationId.length === 0 || + input.maxRecords < 1 || + input.timeBudgetMs < 1 + ) { + return failure("INVALID_INPUT", false, "The migration budget is invalid."); + } + const progress = Object.freeze({ + migrationId: input.migrationId, + state: "COMPLETED" as const, + processedCount: Math.min(this.#records.size, input.maxRecords), + remainingEstimate: 0, + }); + this.#onLifecycle?.({ kind: "MIGRATION_PROGRESS", ...progress }); + return success(progress); + } + + async purgeExpired(input: { + maxRecords: number; + nowEpochMs: number; + signal?: AbortSignal; + }): Promise>> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if (input.maxRecords < 1) { + return failure("INVALID_INPUT", false, "The purge budget is invalid."); + } + let purgedCount = 0; + for (const [id, record] of this.#records) { + if (purgedCount >= input.maxRecords) break; + if ( + record.expiresAtEpochMs !== null && + record.expiresAtEpochMs <= input.nowEpochMs + ) { + this.#records.delete(id); + purgedCount += 1; + } + } + return success({ purgedCount }); + } +} + +function revisionAllows( + guard: StructuredOfflineMutation["revision"], + currentRevision: number | undefined, +): boolean { + if (guard.kind === "ANY") return true; + if (guard.kind === "MUST_NOT_EXIST") return currentRevision === undefined; + return currentRevision === guard.revision; +} + +function estimateRecordBytes( + records: ReadonlyMap>, +): number { + try { + return new TextEncoder().encode(JSON.stringify(Array.from(records.values()))) + .byteLength; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +export class MemoryStorageDurabilityAdapter implements StorageDurabilityPort { + #persisted: boolean; + + constructor( + private readonly usageBytes: number | null, + private readonly quotaBytes: number | null, + private readonly persistenceDecision: "GRANTED" | "DENIED", + persisted = false, + ) { + this.#persisted = persisted; + } + + async inspect( + signal?: AbortSignal, + ): Promise> { + return ( + cancelled(signal) ?? + success({ + usageBytes: this.usageBytes, + quotaBytes: this.quotaBytes, + persisted: this.#persisted, + pressure: storagePressure( + this.usageBytes, + this.quotaBytes, + ), + }) + ); + } + + async requestPersistence(input: { + reason: "PROTECT_UNSYNCED_USER_DATA"; + userInitiated: true; + signal?: AbortSignal; + }): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + this.#persisted = this.persistenceDecision === "GRANTED"; + return success(this.persistenceDecision); + } +} + +function storagePressure( + usageBytes: number | null, + quotaBytes: number | null, +): StorageEstimate["pressure"] { + if ( + usageBytes === null || + quotaBytes === null || + !Number.isFinite(usageBytes) || + !Number.isFinite(quotaBytes) || + usageBytes < 0 || + quotaBytes <= 0 + ) { + return "UNKNOWN"; + } + const ratio = usageBytes / quotaBytes; + if (ratio >= 0.85) return "CRITICAL"; + if (ratio >= 0.7) return "PRESSURE"; + return "NORMAL"; +} + +type MemoryObject = Readonly<{ + descriptor: DurableObjectDescriptor; + chunks: ReadonlyArray; +}>; + +type PendingObject = Readonly<{ + id: DurableObjectId; + phase: "PREPARING" | "FILES_READY"; +}>; + +export class MemoryDurableObjectStore + implements DurableObjectStorePort, DurableObjectMaintenancePort +{ + readonly #objects = new Map(); + readonly #pending = new Map(); + #failurePhase: PendingObject["phase"] | null = null; + + constructor( + private readonly maxObjectBytes: ByteCount, + private readonly backend: "OPFS" | "INDEXEDDB_BLOB" | "NONE" = "OPFS", + ) {} + + failNextPutAfter(phase: PendingObject["phase"]): void { + this.#failurePhase = phase; + } + + async capabilities(signal?: AbortSignal) { + return ( + cancelled(signal) ?? + success({ + backend: this.backend, + persistence: "BEST_EFFORT" as const, + maxObjectBytes: this.maxObjectBytes, + }) + ); + } + + async put( + input: Parameters[0], + ): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if (this.backend === "NONE") { + return failure("UNSUPPORTED", false, "Durable object storage is unsupported."); + } + if (input.declaredByteLength > this.maxObjectBytes) { + return failure("LIMIT_EXCEEDED", false, "The object exceeds its byte budget."); + } + const current = this.#objects.get(input.id); + if ( + (input.expectedGeneration === null && current !== undefined) || + (input.expectedGeneration !== null && + current?.descriptor.generation !== input.expectedGeneration) + ) { + return failure("CONFLICT", false, "The object generation changed."); + } + this.#pending.set(input.id, { id: input.id, phase: "PREPARING" }); + if (this.#failurePhase === "PREPARING") { + this.#failurePhase = null; + return failure( + "PROVIDER_UNAVAILABLE", + true, + "The object writer stopped after journal preparation.", + ); + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + for await (const chunkResult of input.source) { + if (!chunkResult.ok) return chunkResult; + const chunk = chunkResult.value; + const duringWrite = cancelled(input.signal); + if (duringWrite) return duringWrite; + if (!(chunk instanceof Uint8Array)) { + return failure( + "CORRUPT_DATA", + false, + "The object source emitted an invalid byte chunk.", + ); + } + totalBytes += chunk.byteLength; + if ( + totalBytes > input.declaredByteLength || + totalBytes > this.maxObjectBytes + ) { + return failure( + "INTEGRITY_FAILED", + false, + "The object exceeded its declared size.", + ); + } + chunks.push(cloneBytes(chunk)); + input.onProgress({ + phase: "TRANSFERRING", + transferredBytes: totalBytes, + totalBytes: input.declaredByteLength, + }); + } + } catch { + return failure( + "NOT_READABLE", + true, + "The object byte source could not be read.", + ); + } + if (totalBytes !== input.declaredByteLength) { + return failure( + "INTEGRITY_FAILED", + false, + "The object did not match its declared size.", + ); + } + this.#pending.set(input.id, { id: input.id, phase: "FILES_READY" }); + if (this.#failurePhase === "FILES_READY") { + this.#failurePhase = null; + return failure( + "PROVIDER_UNAVAILABLE", + true, + "The object writer stopped before logical commit.", + ); + } + + const generation = asObjectGeneration( + (current?.descriptor.generation ?? 0) + 1, + ); + const chunkDigests = await Promise.all(chunks.map(sha256Hex)); + const chunkSizeBytes = Math.max( + 0, + ...chunks.map((chunk) => chunk.byteLength), + ); + const canonicalTree = new TextEncoder().encode( + [ + totalBytes, + chunkSizeBytes, + ...chunks.map( + (chunk, index) => + `${chunk.byteLength}:${chunkDigests[index] ?? "missing"}`, + ), + ].join(":"), + ); + const descriptor: DurableObjectDescriptor = Object.freeze({ + id: input.id, + generation, + byteLength: asByteCount(totalBytes), + mediaType: input.mediaType, + integrity: Object.freeze({ + algorithm: "SHA-256-TREE-V1", + rootDigest: await sha256Hex(canonicalTree), + chunkSizeBytes: asByteCount(chunkSizeBytes), + }), + dataClass: input.dataClass, + retention: input.retention, + }); + this.#objects.set(input.id, { descriptor, chunks }); + this.#pending.delete(input.id); + return success(descriptor); + } + + async open( + id: DurableObjectId, + signal?: AbortSignal, + ): Promise> { + const aborted = cancelled(signal); + if (aborted) return aborted; + const object = this.#objects.get(id); + if (!object) { + return failure("NOT_FOUND", false, "The durable object does not exist."); + } + const chunks = object.chunks.map(cloneBytes); + return success({ + descriptor: object.descriptor, + chunks: chunksFrom(chunks), + }); + } + + async remove( + input: Parameters[0], + ): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const current = this.#objects.get(input.id); + if (!current) { + return failure("NOT_FOUND", false, "The durable object does not exist."); + } + if (current.descriptor.generation !== input.expectedGeneration) { + return failure("CONFLICT", false, "The object generation changed."); + } + this.#objects.delete(input.id); + return success(undefined); + } + + async reconcile( + input: Parameters[0], + ): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if (input.maxEntries < 1 || input.timeBudgetMs < 1) { + return failure("INVALID_INPUT", false, "The recovery budget is invalid."); + } + const pending = Array.from(this.#pending.keys()).slice(0, input.maxEntries); + for (const id of pending) this.#pending.delete(id); + return success({ + resumedCount: 0, + purgedCount: pending.length, + quarantinedCount: 0, + nextCursor: this.#pending.size > 0 ? String(pending.length) : null, + }); + } +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const digest = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +async function* chunksFrom( + chunks: ReadonlyArray, +): AsyncIterable> { + for (const chunk of chunks) yield success(cloneBytes(chunk)); +} + +type MemoryCacheRelease = Readonly<{ + manifestDigest: string; + entries: ReadonlyMap; +}>; + +type MemoryPublicResponseCacheOptions = Readonly<{ + origin: string; + maxEntryBytes: number; + allowedMediaTypes: ReadonlySet; + forbiddenPaths?: ReadonlyArray; +}>; + +export class MemoryPublicResponseCache + implements PublicResponseCacheAdmin +{ + readonly #releases = new Map(); + readonly #origin: string; + readonly #maxEntryBytes: number; + readonly #allowedMediaTypes: ReadonlySet; + readonly #forbiddenPaths: ReadonlyArray; + #activeReleaseId: string | null = null; + #previousReleaseId: string | null = null; + + constructor(options: MemoryPublicResponseCacheOptions) { + this.#origin = new URL(options.origin).origin; + this.#maxEntryBytes = options.maxEntryBytes; + this.#allowedMediaTypes = options.allowedMediaTypes; + this.#forbiddenPaths = options.forbiddenPaths ?? [ + "/config.json", + "/release-manifest.json", + "/api/", + "/auth/", + "/user/", + "/tenant/", + ]; + } + + async stageRelease( + input: Parameters[0], + ): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if ( + input.releaseId.length === 0 || + input.manifestDigest.length === 0 || + input.entries.length === 0 + ) { + return failure("INVALID_INPUT", false, "The cache release is invalid."); + } + const staged = new Map(); + for (const entry of input.entries) { + const key = this.#cacheKey(entry.url); + if ( + key === null || + entry.byteLength > this.#maxEntryBytes || + entry.integritySha256.length === 0 || + entry.requestCredentials !== "OMIT" || + entry.dataClass !== "PUBLIC" || + !this.#allowedMediaTypes.has(entry.mediaType) || + staged.has(key) + ) { + return failure( + "POLICY_REJECTED", + false, + "A public cache entry violates the cache policy.", + ); + } + staged.set(key, Object.freeze({ ...entry, url: key })); + } + this.#releases.set(input.releaseId, { + manifestDigest: input.manifestDigest, + entries: staged, + }); + return success(undefined); + } + + async activateRelease( + input: Parameters[0], + ): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + const candidate = this.#releases.get(input.releaseId); + if ( + !candidate || + candidate.manifestDigest !== input.manifestDigest || + this.#activeReleaseId !== input.expectedPreviousReleaseId + ) { + return failure( + "CONFLICT", + false, + "The cache candidate is incomplete or stale.", + ); + } + this.#previousReleaseId = this.#activeReleaseId; + this.#activeReleaseId = input.releaseId; + return success(undefined); + } + + async lookup( + input: Parameters[0], + ): Promise> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + if ( + input.requestCredentials !== "OMIT" || + input.hasAuthorization !== false + ) { + return success({ kind: "MISS", reason: "POLICY_REJECTED" }); + } + if (!this.#activeReleaseId) { + return success({ kind: "MISS", reason: "NO_ACTIVE_RELEASE" }); + } + const key = this.#cacheKey(input.url); + if (!key) return success({ kind: "MISS", reason: "POLICY_REJECTED" }); + const entry = this.#releases + .get(this.#activeReleaseId) + ?.entries.get(key); + return entry + ? success({ + kind: "HIT", + releaseId: this.#activeReleaseId, + entry, + }) + : success({ kind: "MISS", reason: "NOT_FOUND" }); + } + + async inspect( + signal?: AbortSignal, + ): Promise> { + const aborted = cancelled(signal); + if (aborted) return aborted; + const candidateReleaseIds = Array.from(this.#releases.keys()).filter( + (releaseId) => + releaseId !== this.#activeReleaseId && + releaseId !== this.#previousReleaseId, + ); + const ownedBytes = Array.from(this.#releases.values()).reduce( + (total, release) => + total + + Array.from(release.entries.values()).reduce( + (releaseTotal, entry) => releaseTotal + entry.byteLength, + 0, + ), + 0, + ); + return success({ + activeReleaseId: this.#activeReleaseId, + previousReleaseId: this.#previousReleaseId, + candidateReleaseIds, + ownedBytes, + }); + } + + async rollback( + signal?: AbortSignal, + ): Promise> { + const aborted = cancelled(signal); + if (aborted) return aborted; + if (!this.#previousReleaseId) { + return failure("NOT_FOUND", false, "No previous cache release is available."); + } + const active = this.#activeReleaseId; + this.#activeReleaseId = this.#previousReleaseId; + this.#previousReleaseId = active; + return success(undefined); + } + + async deleteOwned( + input: Parameters[0], + ): Promise>> { + const aborted = cancelled(input.signal); + if (aborted) return aborted; + let deletedCount = 0; + if (input.roles.includes("CANDIDATE")) { + for (const releaseId of Array.from(this.#releases.keys())) { + if ( + releaseId !== this.#activeReleaseId && + releaseId !== this.#previousReleaseId + ) { + this.#releases.delete(releaseId); + deletedCount += 1; + } + } + } + if ( + input.roles.includes("PREVIOUS") && + this.#previousReleaseId !== null + ) { + this.#releases.delete(this.#previousReleaseId); + this.#previousReleaseId = null; + deletedCount += 1; + } + return success({ deletedCount }); + } + + #cacheKey(value: string): string | null { + try { + const url = new URL(value, this.#origin); + if ( + url.origin !== this.#origin || + this.#forbiddenPaths.some( + (path) => + url.pathname === path || + (path.endsWith("/") && url.pathname.startsWith(path)), + ) + ) { + return null; + } + url.hash = ""; + return url.href; + } catch { + return null; + } + } +} diff --git a/recipes/frontend-capabilities/contracts.ts b/recipes/frontend-capabilities/contracts.ts new file mode 100644 index 0000000..e6603ad --- /dev/null +++ b/recipes/frontend-capabilities/contracts.ts @@ -0,0 +1,237 @@ +/** + * Opt-in capability contracts. + * + * This directory is a copyable recipe source, not a production entry. A project + * moves only the selected contract into its application-owned boundary and puts + * a concrete implementation behind that port. + */ +export const OPTIONAL_RECIPE_RUNTIME_SENTINEL = + "frontend-optional-recipe-must-not-reach-production"; + +export type CapabilityFailureCode = + | "ABORTED" + | "AUTH_EXPIRED" + | "BLOCKED" + | "CONFLICT" + | "CONSENT_DENIED" + | "CONTRACT_DRIFT" + | "CORRUPT_DATA" + | "DISCONNECTED" + | "EXPIRED_RESOURCE" + | "INTEGRITY_FAILED" + | "INVALID_INPUT" + | "LIMIT_EXCEEDED" + | "MIGRATION_FAILED" + | "NOT_FOUND" + | "NOT_READABLE" + | "PERMISSION_DENIED" + | "POLICY_REJECTED" + | "PROVIDER_UNAVAILABLE" + | "QUOTA_EXCEEDED" + | "STALE_RESULT" + | "STORAGE_EVICTED" + | "UNSUPPORTED"; + +export type CapabilityFailure = Readonly<{ + code: CapabilityFailureCode; + retryable: boolean; + safeMessage: string; +}>; + +export type CapabilityResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ ok: false; failure: CapabilityFailure }>; + +export type Cleanup = () => void; + +export type RealtimeEvent = Readonly<{ + id: string; + sequence: number; + occurredAt: string; + payload: T; +}>; + +export interface RealtimeSubscription { + readonly resumeToken: string | null; + unsubscribe(): void; +} + +export interface RealtimePort { + subscribe(input: { + channel: string; + resumeToken?: string; + signal?: AbortSignal; + onEvent(event: CapabilityResult>): void; + }): Promise>; + heartbeat(signal?: AbortSignal): Promise>; +} + +export interface VersionedOfflineRepository { + open(input: { + schemaVersion: number; + signal?: AbortSignal; + }): Promise>; + get(id: string, signal?: AbortSignal): Promise>; + put(value: T, signal?: AbortSignal): Promise>; + migrate(input: { + from: number; + to: number; + signal?: AbortSignal; + }): Promise>; + close(): void; +} + +export interface ServiceWorkerUpdatePort { + inspect(signal?: AbortSignal): Promise< + CapabilityResult> + >; + activate(version: string, signal?: AbortSignal): Promise>; + rollback(signal?: AbortSignal): Promise>; + unregister(): Promise>; +} + +export type TransferProgress = Readonly<{ + phase?: + | "VALIDATING" + | "PREPARING" + | "TRANSFERRING" + | "VERIFYING" + | "FINALIZING"; + transferredBytes: number; + totalBytes: number | null; +}>; + +export type TransferByteSource = Readonly<{ + byteLength: number | null; + chunks: AsyncIterable; +}>; + +export interface FileTransferPort { + upload(input: { + file: Readonly<{ + name: string; + size: number; + type: string; + content: TransferByteSource; + }>; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise>>; + download(input: { + resourceId: string; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise< + CapabilityResult< + Readonly<{ + fileName: string; + mediaType: string; + content: TransferByteSource; + }> + > + >; +} + +export interface GeneratedApiFacade { + execute(input: { + operationId: string; + contractVersion: string; + body?: unknown; + signal?: AbortSignal; + }): Promise>; +} + +export interface FeatureFlagPort> { + evaluate(input: { + key: TKey; + fallback: TFlags[TKey]; + maxAgeMs: number; + }): Promise>; +} + +export interface WorkerTaskPort { + run(input: { + taskId: string; + generation: number; + payload: TInput; + signal: AbortSignal; + }): Promise>; + cancel(taskId: string): void; + dispose(): void; +} + +export type MultiTabEvent = Readonly<{ + eventId: string; + sourceId: string; + version: number; + payload: T; +}>; + +export interface MultiTabPort { + publish(event: MultiTabEvent): CapabilityResult; + subscribe(input: { + sourceId: string; + onEvent(event: CapabilityResult>): void; + }): Cleanup; + close(): void; +} + +export type BrowserCapability = + | "clipboard-read" + | "clipboard-write" + | "media" + | "notification"; + +export type PermissionDecision = "granted" | "denied" | "dismissed"; + +export interface BrowserPermissionPort { + request(input: { + capability: BrowserCapability; + signal?: AbortSignal; + }): Promise>; +} + +export interface ClientWorkflowPort { + snapshot(): Readonly; + dispatch(event: TEvent): CapabilityResult>; + reset(): void; + subscribe(listener: (state: Readonly) => void): Cleanup; +} + +export interface LargeDataUiFacade { + window(input: { + offset: number; + limit: number; + generation: number; + }): CapabilityResult>; + focus(rowId: string): CapabilityResult; + replace(rows: ReadonlyArray, generation: number): void; +} + +export type SafeAnalyticsValue = boolean | number | string | null; + +export interface AnalyticsErrorSink { + record(input: { + kind: "analytics" | "error"; + eventId: string; + consent: "granted" | "denied" | "not-required"; + attributes: Readonly>; + }): CapabilityResult; + flush(signal?: AbortSignal): Promise>; + dispose(): void; +} + +export type OptionalCapabilityPorts = Readonly<{ + realtime: RealtimePort; + offline: VersionedOfflineRepository<{ id: string }>; + serviceWorker: ServiceWorkerUpdatePort; + fileTransfer: FileTransferPort; + generatedApi: GeneratedApiFacade; + featureFlag: FeatureFlagPort>; + worker: WorkerTaskPort; + multiTab: MultiTabPort; + browserPermission: BrowserPermissionPort; + clientWorkflow: ClientWorkflowPort; + largeDataUi: LargeDataUiFacade<{ id: string }>; + analytics: AnalyticsErrorSink; +}>; diff --git a/recipes/frontend-capabilities/fake-adapters.ts b/recipes/frontend-capabilities/fake-adapters.ts new file mode 100644 index 0000000..27a0efc --- /dev/null +++ b/recipes/frontend-capabilities/fake-adapters.ts @@ -0,0 +1,578 @@ +import type { + AnalyticsErrorSink, + BrowserCapability, + BrowserPermissionPort, + CapabilityFailure, + CapabilityResult, + ClientWorkflowPort, + FeatureFlagPort, + FileTransferPort, + GeneratedApiFacade, + LargeDataUiFacade, + MultiTabEvent, + MultiTabPort, + OptionalCapabilityPorts, + PermissionDecision, + RealtimeEvent, + RealtimePort, + RealtimeSubscription, + SafeAnalyticsValue, + ServiceWorkerUpdatePort, + VersionedOfflineRepository, + WorkerTaskPort, +} from "./contracts.ts"; + +export function success(value: T): CapabilityResult { + return Object.freeze({ ok: true, value }); +} + +export function failure( + code: CapabilityFailure["code"], + retryable = false, + safeMessage = "Optional capability is unavailable.", +): CapabilityResult { + return Object.freeze({ + ok: false, + failure: Object.freeze({ code, retryable, safeMessage }), + }); +} + +function aborted(signal?: AbortSignal): CapabilityResult | null { + return signal?.aborted + ? failure("ABORTED", false, "The operation was cancelled.") + : null; +} + +export class FakeRealtimeAdapter implements RealtimePort { + readonly #subscriptions = new Map< + string, + { + lastSequence: number; + onEvent(event: CapabilityResult>): void; + } + >(); + + async subscribe(input: { + channel: string; + resumeToken?: string; + signal?: AbortSignal; + onEvent(event: CapabilityResult>): void; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + const key = `${input.channel}:${this.#subscriptions.size + 1}`; + this.#subscriptions.set(key, { lastSequence: -1, onEvent: input.onEvent }); + const unsubscribe = () => this.#subscriptions.delete(key); + input.signal?.addEventListener("abort", unsubscribe, { once: true }); + return success( + Object.freeze({ + resumeToken: input.resumeToken ?? null, + unsubscribe, + }), + ); + } + + async heartbeat(signal?: AbortSignal): Promise> { + return aborted(signal) ?? success(undefined); + } + + emit(channel: string, event: RealtimeEvent): void { + for (const [key, subscription] of this.#subscriptions) { + if (!key.startsWith(`${channel}:`)) continue; + if (event.sequence <= subscription.lastSequence) { + subscription.onEvent( + failure( + "STALE_RESULT", + false, + "A duplicate or out-of-order event was ignored.", + ), + ); + continue; + } + subscription.lastSequence = event.sequence; + subscription.onEvent(success(event)); + } + } + + get activeSubscriptionCount(): number { + return this.#subscriptions.size; + } +} + +export class MemoryOfflineRepository + implements VersionedOfflineRepository +{ + readonly #records = new Map(); + #openVersion: number | null = null; + + async open(input: { + schemaVersion: number; + signal?: AbortSignal; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (!Number.isInteger(input.schemaVersion) || input.schemaVersion < 1) { + return failure("CORRUPT_DATA", false, "Invalid offline schema version."); + } + this.#openVersion = input.schemaVersion; + return success(undefined); + } + + async get(id: string, signal?: AbortSignal): Promise> { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (this.#openVersion === null) { + return failure("PROVIDER_UNAVAILABLE", false, "Repository is closed."); + } + return success(this.#records.get(id) ?? null); + } + + async put(value: T, signal?: AbortSignal): Promise> { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (this.#openVersion === null) { + return failure("PROVIDER_UNAVAILABLE", false, "Repository is closed."); + } + this.#records.set(value.id, structuredClone(value)); + return success(undefined); + } + + async migrate(input: { + from: number; + to: number; + signal?: AbortSignal; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (this.#openVersion !== input.from || input.to <= input.from) { + return failure("MIGRATION_FAILED", false, "Offline migration was rejected."); + } + this.#openVersion = input.to; + return success(undefined); + } + + close(): void { + this.#openVersion = null; + } +} + +export class FakeServiceWorkerUpdateAdapter implements ServiceWorkerUpdatePort { + #activeVersion: string | null; + #candidateVersion: string | null; + + constructor(activeVersion: string | null, candidateVersion: string | null) { + this.#activeVersion = activeVersion; + this.#candidateVersion = candidateVersion; + } + + async inspect(signal?: AbortSignal) { + return ( + aborted(signal) ?? + success({ + updateAvailable: this.#candidateVersion !== null, + version: this.#candidateVersion, + }) + ); + } + + async activate(version: string, signal?: AbortSignal) { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (version !== this.#candidateVersion) { + return failure("STALE_RESULT", false, "Worker update is no longer current."); + } + this.#activeVersion = version; + this.#candidateVersion = null; + return success(undefined); + } + + async rollback(signal?: AbortSignal) { + const cancelled = aborted(signal); + if (cancelled) return cancelled; + if (!this.#activeVersion) { + return failure("NOT_FOUND", false, "No active worker can be rolled back."); + } + this.#activeVersion = null; + return success(undefined); + } + + async unregister() { + this.#activeVersion = null; + this.#candidateVersion = null; + return success(undefined); + } +} + +export class FakeFileTransferAdapter implements FileTransferPort { + constructor( + private readonly maxBytes = 5_000_000, + private readonly acceptedTypes: ReadonlySet = new Set([ + "application/pdf", + "image/png", + ]), + ) {} + + async upload(input: Parameters[0]) { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if ( + input.file.size > this.maxBytes || + !this.acceptedTypes.has(input.file.type) + ) { + return failure("LIMIT_EXCEEDED", false, "File size or type is not allowed."); + } + let transferredBytes = 0; + for await (const chunk of input.file.content.chunks) { + const duringTransfer = aborted(input.signal); + if (duringTransfer) return duringTransfer; + transferredBytes += chunk.byteLength; + if (transferredBytes > input.file.size) { + return failure( + "INTEGRITY_FAILED", + false, + "Uploaded bytes exceeded the declared file size.", + ); + } + input.onProgress({ + phase: "TRANSFERRING", + transferredBytes, + totalBytes: input.file.size, + }); + } + if ( + transferredBytes !== input.file.size || + input.file.content.byteLength !== input.file.size + ) { + return failure( + "INTEGRITY_FAILED", + false, + "Uploaded bytes did not match the declared file size.", + ); + } + return success({ resourceId: "fake:opaque-resource" }); + } + + async download(input: Parameters[0]) { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (input.resourceId.startsWith("expired:")) { + return failure("EXPIRED_RESOURCE", true, "The download link expired."); + } + const bytes = new TextEncoder().encode(input.resourceId); + const chunks = async function* () { + if (input.signal.aborted) return; + input.onProgress({ + phase: "TRANSFERRING" as const, + transferredBytes: bytes.byteLength, + totalBytes: bytes.byteLength, + }); + yield bytes.slice(); + }; + return success({ + fileName: "download.bin", + mediaType: "application/octet-stream", + content: { + byteLength: bytes.byteLength, + chunks: chunks(), + }, + }); + } +} + +export class FakeGeneratedApiAdapter implements GeneratedApiFacade { + constructor( + private readonly contractVersion: string, + private readonly handlers: Readonly< + Record unknown | Promise> + >, + ) {} + + async execute( + input: Parameters[0], + ): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + if (input.contractVersion !== this.contractVersion) { + return failure("CONTRACT_DRIFT", false, "API contract version is unsupported."); + } + const handler = this.handlers[input.operationId]; + if (!handler) { + return failure("UNSUPPORTED", false, "API operation is unsupported."); + } + return success((await handler(input.body)) as TOutput); + } +} + +export class FakeFeatureFlagAdapter< + TFlags extends Record, +> implements FeatureFlagPort +{ + constructor( + private readonly values: Readonly>, + private readonly available = true, + ) {} + + async evaluate(input: { + key: TKey; + fallback: TFlags[TKey]; + maxAgeMs: number; + }): Promise> { + if (!this.available) { + return failure("PROVIDER_UNAVAILABLE", true, "Flag provider is unavailable."); + } + const value = this.values[input.key]; + return success((value ?? input.fallback) as TFlags[TKey]); + } +} + +export class FakeWorkerTaskAdapter + implements WorkerTaskPort +{ + readonly #cancelled = new Set(); + + constructor( + private readonly handler: (input: TInput) => TOutput | Promise, + ) {} + + async run(input: { + taskId: string; + generation: number; + payload: TInput; + signal: AbortSignal; + }): Promise> { + if (input.signal.aborted || this.#cancelled.has(input.taskId)) { + return failure("ABORTED", false, "Worker task was cancelled."); + } + const output = await this.handler(input.payload); + if (input.signal.aborted || this.#cancelled.has(input.taskId)) { + return failure("STALE_RESULT", false, "Stale worker result was discarded."); + } + return success(output); + } + + cancel(taskId: string): void { + this.#cancelled.add(taskId); + } + + dispose(): void { + this.#cancelled.clear(); + } +} + +export class FakeMultiTabAdapter implements MultiTabPort { + readonly #seen = new Set(); + readonly #listeners = new Set<{ + sourceId: string; + onEvent(event: CapabilityResult>): void; + }>(); + + publish(event: MultiTabEvent): CapabilityResult { + if (this.#seen.has(event.eventId)) { + return failure("CONFLICT", false, "Duplicate multi-tab event was ignored."); + } + this.#seen.add(event.eventId); + for (const listener of this.#listeners) { + if (listener.sourceId !== event.sourceId) { + listener.onEvent(success(event)); + } + } + return success(undefined); + } + + subscribe(input: { + sourceId: string; + onEvent(event: CapabilityResult>): void; + }) { + this.#listeners.add(input); + return () => this.#listeners.delete(input); + } + + close(): void { + this.#listeners.clear(); + this.#seen.clear(); + } +} + +export class FakeBrowserPermissionAdapter implements BrowserPermissionPort { + constructor( + private readonly decisions: Readonly< + Partial> + >, + ) {} + + async request(input: { + capability: BrowserCapability; + signal?: AbortSignal; + }): Promise> { + const cancelled = aborted(input.signal); + if (cancelled) return cancelled; + const decision = this.decisions[input.capability]; + return decision + ? success(decision) + : failure("UNSUPPORTED", false, "Browser capability is unsupported."); + } +} + +export class FakeClientWorkflowAdapter + implements ClientWorkflowPort +{ + readonly #initial: TState; + readonly #listeners = new Set<(state: Readonly) => void>(); + #state: TState; + + constructor( + initial: TState, + private readonly transition: (state: TState, event: TEvent) => TState, + ) { + this.#initial = structuredClone(initial); + this.#state = structuredClone(initial); + } + + snapshot(): Readonly { + return structuredClone(this.#state); + } + + dispatch(event: TEvent): CapabilityResult> { + this.#state = this.transition(this.#state, event); + const snapshot = this.snapshot(); + this.#listeners.forEach((listener) => listener(snapshot)); + return success(snapshot); + } + + reset(): void { + this.#state = structuredClone(this.#initial); + const snapshot = this.snapshot(); + this.#listeners.forEach((listener) => listener(snapshot)); + } + + subscribe(listener: (state: Readonly) => void) { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } +} + +export class FakeLargeDataUiAdapter + implements LargeDataUiFacade +{ + #rows: ReadonlyArray = []; + #generation = 0; + + window(input: { offset: number; limit: number; generation: number }) { + if (input.generation !== this.#generation) { + return failure("STALE_RESULT", false, "Stale row window was discarded."); + } + if (input.offset < 0 || input.limit < 1) { + return failure("INVALID_INPUT", false, "Invalid row window."); + } + return success(this.#rows.slice(input.offset, input.offset + input.limit)); + } + + focus(rowId: string) { + return this.#rows.some((row) => row.id === rowId) + ? success(undefined) + : failure("NOT_FOUND", false, "Row is no longer available."); + } + + replace(rows: ReadonlyArray, generation: number): void { + this.#rows = rows; + this.#generation = generation; + } +} + +const sensitiveAttribute = /credential|authorization|cookie|password|secret|token/i; + +export class RecordingAnalyticsAdapter implements AnalyticsErrorSink { + readonly records: Array< + Readonly<{ + kind: "analytics" | "error"; + eventId: string; + attributes: Readonly>; + }> + > = []; + + constructor(private readonly capacity = 100) {} + + record(input: Parameters[0]) { + if (input.kind === "analytics" && input.consent !== "granted") { + return failure("CONSENT_DENIED", false, "Analytics consent was not granted."); + } + if (this.records.length >= this.capacity) { + return failure("LIMIT_EXCEEDED", true, "Analytics queue is full."); + } + const attributes = Object.fromEntries( + Object.entries(input.attributes).filter(([key]) => !sensitiveAttribute.test(key)), + ); + this.records.push( + Object.freeze({ kind: input.kind, eventId: input.eventId, attributes }), + ); + return success(undefined); + } + + async flush(signal?: AbortSignal) { + return aborted(signal) ?? success(undefined); + } + + dispose(): void { + this.records.length = 0; + } +} + +const unavailableAsync = async () => + failure("PROVIDER_UNAVAILABLE", true, "Capability was not installed."); +const unavailableSync = () => + failure("PROVIDER_UNAVAILABLE", true, "Capability was not installed."); + +export function createUnavailableAdapters(): OptionalCapabilityPorts { + return Object.freeze({ + realtime: { + subscribe: unavailableAsync, + heartbeat: unavailableAsync, + }, + offline: { + open: unavailableAsync, + get: unavailableAsync, + put: unavailableAsync, + migrate: unavailableAsync, + close() {}, + }, + serviceWorker: { + inspect: unavailableAsync, + activate: unavailableAsync, + rollback: unavailableAsync, + unregister: unavailableAsync, + }, + fileTransfer: { + upload: unavailableAsync, + download: unavailableAsync, + }, + generatedApi: { execute: unavailableAsync }, + featureFlag: { evaluate: unavailableAsync }, + worker: { + run: unavailableAsync, + cancel() {}, + dispose() {}, + }, + multiTab: { + publish: unavailableSync, + subscribe: () => () => {}, + close() {}, + }, + browserPermission: { request: unavailableAsync }, + clientWorkflow: { + snapshot: () => Object.freeze({ unavailable: true }), + dispatch: unavailableSync, + reset() {}, + subscribe: () => () => {}, + }, + largeDataUi: { + window: unavailableSync, + focus: unavailableSync, + replace() {}, + }, + analytics: { + record: unavailableSync, + flush: unavailableAsync, + dispose() {}, + }, + }); +} diff --git a/recipes/frontend-capabilities/index.ts b/recipes/frontend-capabilities/index.ts new file mode 100644 index 0000000..0aca00f --- /dev/null +++ b/recipes/frontend-capabilities/index.ts @@ -0,0 +1,4 @@ +export * from "./browser-file-storage-contracts.ts"; +export * from "./browser-file-storage-fakes.ts"; +export * from "./contracts.ts"; +export * from "./fake-adapters.ts"; diff --git a/schemas/artifacts/build-manifest.schema.json b/schemas/artifacts/build-manifest.schema.json new file mode 100644 index 0000000..87956c8 --- /dev/null +++ b/schemas/artifacts/build-manifest.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "buildContext": { + "additionalProperties": false, + "properties": { + "nodeVersion": { + "minLength": 1, + "type": "string" + }, + "packageManagerVersion": { + "minLength": 1, + "type": "string" + }, + "runnerImage": { + "minLength": 1, + "type": "string" + }, + "sourceDateEpoch": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "nodeVersion", + "packageManagerVersion", + "runnerImage", + "sourceDateEpoch" + ], + "type": "object" + }, + "buildId": { + "minLength": 1, + "type": "string" + }, + "commitSha": { + "minLength": 1, + "type": "string" + }, + "generatedAt": { + "minLength": 1, + "type": "string" + }, + "moduleInventoryHash": { + "minLength": 1, + "type": "string" + }, + "outputs": { + "additionalProperties": false, + "properties": { + "directory": { + "minLength": 1, + "type": "string" + }, + "moduleInventory": { + "minLength": 1, + "type": "string" + }, + "routeChunks": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "propertyNames": { + "minLength": 1, + "type": "string" + }, + "type": "object" + }, + "runtimeConfigSchema": { + "minLength": 1, + "type": "string" + }, + "viteManifest": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "directory", + "viteManifest", + "moduleInventory", + "routeChunks", + "runtimeConfigSchema" + ], + "type": "object" + }, + "releaseId": { + "minLength": 1, + "type": "string" + }, + "schemaVersion": { + "const": 1, + "type": "number" + } + }, + "required": [ + "schemaVersion", + "buildId", + "commitSha", + "releaseId", + "moduleInventoryHash", + "generatedAt", + "buildContext", + "outputs" + ], + "type": "object" +} diff --git a/schemas/artifacts/dependency-inventory.schema.json b/schemas/artifacts/dependency-inventory.schema.json new file mode 100644 index 0000000..3cf36c6 --- /dev/null +++ b/schemas/artifacts/dependency-inventory.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json", + "additionalProperties": false, + "properties": { + "dependencies": { + "items": { + "additionalProperties": false, + "properties": { + "dependencies": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "direct": { + "type": "boolean" + }, + "integrity": { + "pattern": "^sha512-", + "type": "string" + }, + "license": { + "minLength": 1, + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "scope": { + "enum": [ + "production", + "development" + ], + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "version", + "direct", + "scope", + "optional", + "license", + "integrity", + "dependencies" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "dependencyCount": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "directDependencyCount": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "lockfileSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "packageManager": { + "minLength": 1, + "type": "string" + }, + "schemaVersion": { + "const": 2, + "type": "number" + } + }, + "required": [ + "schemaVersion", + "packageManager", + "lockfileSha256", + "dependencyCount", + "directDependencyCount", + "dependencies" + ], + "type": "object" +} diff --git a/schemas/artifacts/registry-snapshot.schema.json b/schemas/artifacts/registry-snapshot.schema.json new file mode 100644 index 0000000..199e344 --- /dev/null +++ b/schemas/artifacts/registry-snapshot.schema.json @@ -0,0 +1,442 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": { + "$ref": "#/$defs/__schema0" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/__schema0" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + } + ] + }, + "__schema1": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": { + "$ref": "#/$defs/__schema1" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/__schema1" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + } + ] + }, + "__schema2": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": { + "$ref": "#/$defs/__schema2" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + } + ] + } + }, + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "baselineDigest": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "compatibility": { + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "additionalProperties": false, + "properties": { + "after": { + "$ref": "#/$defs/__schema1" + }, + "before": { + "$ref": "#/$defs/__schema0" + }, + "changeId": { + "minLength": 1, + "type": "string" + }, + "field": { + "minLength": 1, + "type": "string" + }, + "impact": { + "enum": [ + "none", + "additive", + "behavior-change", + "breaking" + ], + "type": "string" + }, + "kind": { + "minLength": 1, + "type": "string" + }, + "registryId": { + "minLength": 1, + "type": "string" + }, + "rowName": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "changeId", + "registryId", + "rowName", + "field", + "kind", + "impact" + ], + "type": "object" + }, + "type": "array" + }, + "impact": { + "enum": [ + "not-evaluated", + "none", + "additive", + "behavior-change", + "breaking" + ], + "type": "string" + } + }, + "required": [ + "impact", + "changes" + ], + "type": "object" + }, + "currentDigest": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "failures": { + "items": { + "type": "string" + }, + "maxItems": 0, + "type": "array" + }, + "generatedAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "registries": { + "items": { + "additionalProperties": false, + "properties": { + "contract": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "owner": { + "minLength": 1, + "type": "string" + }, + "registryId": { + "minLength": 1, + "type": "string" + }, + "rowCount": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "source": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "registryId", + "owner", + "source", + "rowCount", + "contract", + "rows" + ], + "type": "object" + }, + "maxItems": 11, + "minItems": 11, + "type": "array" + }, + "schemaVersion": { + "const": 2, + "type": "number" + } + }, + "required": [ + "schemaVersion", + "generatedAt", + "baselineDigest", + "currentDigest", + "compatibility", + "failures", + "registries" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "baselineDigest": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "compatibility": { + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "additionalProperties": false, + "properties": { + "after": { + "$ref": "#/$defs/__schema1" + }, + "before": { + "$ref": "#/$defs/__schema0" + }, + "changeId": { + "minLength": 1, + "type": "string" + }, + "field": { + "minLength": 1, + "type": "string" + }, + "impact": { + "enum": [ + "none", + "additive", + "behavior-change", + "breaking" + ], + "type": "string" + }, + "kind": { + "minLength": 1, + "type": "string" + }, + "registryId": { + "minLength": 1, + "type": "string" + }, + "rowName": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "changeId", + "registryId", + "rowName", + "field", + "kind", + "impact" + ], + "type": "object" + }, + "type": "array" + }, + "impact": { + "enum": [ + "not-evaluated", + "none", + "additive", + "behavior-change", + "breaking" + ], + "type": "string" + } + }, + "required": [ + "impact", + "changes" + ], + "type": "object" + }, + "currentDigest": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "failures": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "generatedAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "registries": { + "items": { + "additionalProperties": false, + "properties": { + "contract": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "owner": { + "minLength": 1, + "type": "string" + }, + "registryId": { + "minLength": 1, + "type": "string" + }, + "rowCount": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "source": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "registryId", + "owner", + "source", + "rowCount", + "contract", + "rows" + ], + "type": "object" + }, + "type": "array" + }, + "schemaVersion": { + "const": 2, + "type": "number" + } + }, + "required": [ + "schemaVersion", + "generatedAt", + "baselineDigest", + "currentDigest", + "compatibility", + "failures", + "registries" + ], + "type": "object" + } + ] +} diff --git a/schemas/artifacts/supply-chain-verification.schema.json b/schemas/artifacts/supply-chain-verification.schema.json new file mode 100644 index 0000000..803cd60 --- /dev/null +++ b/schemas/artifacts/supply-chain-verification.schema.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json", + "additionalProperties": false, + "properties": { + "dependencyDiff": { + "additionalProperties": false, + "properties": { + "added": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "changed": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "removed": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "upgrades": { + "items": { + "additionalProperties": false, + "properties": { + "from": { + "minLength": 1, + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "to": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "from", + "to" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "added", + "removed", + "changed", + "upgrades" + ], + "type": "object" + }, + "distSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "failures": { + "items": { + "type": "string" + }, + "type": "array" + }, + "highRiskReview": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "localStatus": { + "enum": [ + "PASS", + "FAIL" + ], + "type": "string" + }, + "lockfileSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "promotionStatus": { + "enum": [ + "PASS", + "FAIL_UNVERIFIED" + ], + "type": "string" + }, + "provenanceAttestationStatus": { + "enum": [ + "PASS", + "FAIL_UNVERIFIED" + ], + "type": "string" + }, + "sbomSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "sourceSetSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "vulnerabilityStatus": { + "enum": [ + "PASS", + "FAIL", + "FAIL_UNVERIFIED" + ], + "type": "string" + } + }, + "required": [ + "schemaVersion", + "localStatus", + "promotionStatus", + "lockfileSha256", + "sourceSetSha256", + "distSha256", + "sbomSha256", + "dependencyDiff", + "highRiskReview", + "vulnerabilityStatus", + "provenanceAttestationStatus", + "failures" + ], + "type": "object" +} diff --git a/schemas/config/frontend-capability-recipes.schema.json b/schemas/config/frontend-capability-recipes.schema.json new file mode 100644 index 0000000..9471e67 --- /dev/null +++ b/schemas/config/frontend-capability-recipes.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "frontend-capability-recipes.schema.json", + "title": "Frontend optional capability recipe catalog", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "decisionId", + "defaultStatus", + "productionRuntimeDependencies", + "catalogOwner", + "reviewOn", + "vendorPackagePatterns", + "recipes" + ], + "properties": { + "$schema": { "type": "string", "minLength": 1 }, + "schemaVersion": { "const": 1 }, + "decisionId": { "const": "VD-10" }, + "defaultStatus": { "const": "NOT_INSTALLED" }, + "productionRuntimeDependencies": { + "type": "array", + "maxItems": 0 + }, + "catalogOwner": { "type": "string", "minLength": 1 }, + "reviewOn": { "type": "string", "minLength": 1 }, + "vendorPackagePatterns": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "recipes": { + "type": "array", + "minItems": 12, + "maxItems": 12, + "items": { "$ref": "#/$defs/recipe" } + } + }, + "$defs": { + "recipe": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "status", + "trigger", + "forbiddenWhen", + "boundary", + "port", + "fake", + "failureKinds", + "lifecycleMethods", + "owner", + "securityPrivacy", + "bundleBudgetGzipBytes", + "fallback", + "removal", + "serverStatePolicy" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "status": { "const": "RECIPE_AVAILABLE" }, + "referenceRuntime": { "$ref": "#/$defs/referenceRuntime" }, + "trigger": { "type": "string", "minLength": 1 }, + "forbiddenWhen": { "$ref": "#/$defs/nonEmptyStrings" }, + "boundary": { "type": "string", "minLength": 1 }, + "port": { "type": "string", "minLength": 1 }, + "fake": { "type": "string", "minLength": 1 }, + "failureKinds": { "$ref": "#/$defs/nonEmptyStrings" }, + "lifecycleMethods": { "$ref": "#/$defs/nonEmptyStrings" }, + "owner": { "type": "string", "minLength": 1 }, + "securityPrivacy": { "$ref": "#/$defs/nonEmptyStrings" }, + "bundleBudgetGzipBytes": { "type": "integer", "minimum": 1 }, + "fallback": { "type": "string", "minLength": 1 }, + "removal": { "$ref": "#/$defs/nonEmptyStrings" }, + "serverStatePolicy": { "type": "string", "minLength": 1 } + } + }, + "referenceRuntime": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "coveredCapabilities", + "sourceRoots", + "conformanceScripts", + "productionComposition" + ], + "properties": { + "status": { "const": "AVAILABLE_NOT_COMPOSED" }, + "coveredCapabilities": { "$ref": "#/$defs/nonEmptyStrings" }, + "sourceRoots": { "$ref": "#/$defs/nonEmptyStrings" }, + "conformanceScripts": { "$ref": "#/$defs/nonEmptyStrings" }, + "productionComposition": { "const": false } + } + }, + "nonEmptyStrings": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/scripts/build-frontend.ts b/scripts/build-frontend.ts new file mode 100644 index 0000000..26657cc --- /dev/null +++ b/scripts/build-frontend.ts @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { rm } from "node:fs/promises"; +import process from "node:process"; + +import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtime-capabilities.ts"; + +/** + * §17.2.2. Deterministic two-pass build. + * + * The Service Worker needs the exact hashed asset list at compile time, so the + * order is fixed: + * + * 1. clean dist and .generated/frontend-runtime + * 2. generate contractSet and build-info source + * 3. Vite app build (emptyOutDir = true) + * 4. scan app dist and generate the static asset source + * 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false) + * 6. generate Release Manifest V2 and the build manifest + * + * Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES` + * and `null`: those modes never run an active worker build. + */ + +const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker; +const buildsActiveWorker = selection?.mode === "ACTIVE"; + +function run(command: string, args: readonly string[]): void { + const result = spawnSync(command, [...args], { + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + process.stderr.write(`build step failed: ${command} ${args.join(" ")}\n`); + process.exit(result.status ?? 1); + } +} + +// 1. clean +await rm("dist", { recursive: true, force: true }); +await rm(".generated/frontend-runtime", { recursive: true, force: true }); + +// 2. contract set + build info +run("node", ["scripts/generate-contract-set.ts"]); + +// 3. app build +run("npx", ["vite", "build"]); + +if (buildsActiveWorker) { + // 4. hashed asset inventory + run("node", ["scripts/generate-service-worker-assets.ts", "dist"]); + // 5. service worker build + run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]); +} else { + process.stdout.write( + `service worker mode ${selection?.mode ?? "null"}: skipping worker build\n`, + ); +} + +// 6. release + build manifest +run("node", ["scripts/generate-build-manifest.ts"]); diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts new file mode 100644 index 0000000..b246e0b --- /dev/null +++ b/scripts/check-architecture.ts @@ -0,0 +1,1059 @@ +import { parseAsync } from "@babel/core"; +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, readdir } from "node:fs/promises"; +import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; + +import { architectureDependencyReportArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type PathRule = Readonly<{ path?: string; pathNot?: string }>; +type ArchitectureRule = Readonly<{ + name: string; + severity?: string; + from?: PathRule; + to?: PathRule & Readonly<{ circular?: boolean }>; +}>; +type ArchitectureConfig = Readonly<{ + forbidden: readonly ArchitectureRule[]; + allowed: readonly unknown[]; + required: readonly unknown[]; +}>; +type DependencyEdge = Readonly<{ + source: string; + target: string; + specifier: string; + kind: "local" | "external"; +}>; +type UnresolvedDependency = Readonly<{ + source: string; + specifier: string; + reason: string; +}>; +type ParseFailure = Readonly<{ source: string; reason: string }>; +type ArchitectureViolation = Readonly<{ + rule: string; + severity: string; + source: string; + target: string; + cycle?: readonly string[]; +}>; +type SourceGraph = Readonly<{ + modules: string[]; + dependencies: DependencyEdge[]; + unresolved: UnresolvedDependency[]; + parseFailures: ParseFailure[]; + cycles: string[][]; + violations: ArchitectureViolation[]; +}>; +type GraphFixtureResult = Readonly<{ + passed: boolean; + checks: string[]; + failures: string[]; +}>; +type DependencyResolution = Readonly<{ path?: string; reason: string }>; +type DependencyCruiserReport = Record; +type TypeScriptOnlyPolicy = Readonly<{ + checkedRoots: readonly string[]; + exceptionsAllowed: false; + violations: readonly string[]; + passed: boolean; +}>; + +type BabelOptions = NonNullable[1]>; +type BabelParserOptions = NonNullable; +type BabelParserPlugin = NonNullable[number]; + +const projectRoot = process.cwd(); +const sourceRoot = resolve(projectRoot, "src"); +const qualityArtifact = resolve( + projectRoot, + "artifacts/quality/dependency-report.json", +); +const require = createRequire(import.meta.url); +const architectureConfig = parseArchitectureConfig( + require(resolve(projectRoot, ".dependency-cruiser.json")), +); +const architectureRules = architectureConfig.forbidden; +const sourceExtensionPattern = /\.(?:ts|tsx|mts|cts)$/u; +const declarationExtensionPattern = /\.d\.(?:ts|mts|cts)$/u; +const lintFixtureExtensionPattern = sourceExtensionPattern; +const forbiddenJavaScriptExtensionPattern = /\.(?:js|jsx|mjs|cjs)$/u; +const forbiddenLocalJavaScriptSpecifierPattern = + /\.(?:js|jsx|mjs|cjs)(?:[?#]|$)/u; +const forbiddenLocalJavaScriptSpecifierReason = + "local JavaScript-family specifiers are forbidden; use the actual TypeScript extension"; +const relativeSpecifierPattern = /^\.{1,2}(?:\/|$)/u; + +await mkdir(dirname(qualityArtifact), { recursive: true }); + +const configuredPnpmCli = process.env.npm_execpath; + +if (!configuredPnpmCli) { + throw new Error("check:architecture must run through the pnpm script"); +} +const pnpmCli = configuredPnpmCli; + +if ( + (architectureConfig.allowed?.length ?? 0) > 0 || + (architectureConfig.required?.length ?? 0) > 0 +) { + throw new Error( + "Static architecture graph must be extended before allowed/required rules are configured", + ); +} +validateArchitectureRules(architectureRules); + +function runPnpm(arguments_: string[]) { + return spawnSync(process.execPath, [pnpmCli, ...arguments_], { + encoding: "utf8", + }); +} + +// Keep dependency-cruiser's report and checks. The second graph is authoritative +// for TypeScript 7 coverage because dependency-cruiser 18 cannot parse TS 7 yet. +const dependencyCruiser = runPnpm([ + "exec", + "depcruise", + "src", + "--config", + ".dependency-cruiser.json", + "--output-type", + "json", +]); +const sourceGraph = await analyzeSourceGraph(sourceRoot, "src"); +const typeScriptOnlyPolicy = await inspectTypeScriptOnlyPolicy(); +const graphFixtureResult = await runGraphFixtureChecks(); +const dependencyReport = parseDependencyCruiserReport(dependencyCruiser.stdout); +const graphErrors = blockingViolations(sourceGraph); +const dependencyCruiserReportValid = !( + "dependencyCruiserOutput" in dependencyReport +); + +dependencyReport.staticImportGraph = { + analyzer: "babel-parser-node-resolver", + modules: sourceGraph.modules, + dependencies: sourceGraph.dependencies, + unresolved: sourceGraph.unresolved, + parseFailures: sourceGraph.parseFailures, + cycles: sourceGraph.cycles, + violations: sourceGraph.violations, + summary: { + modules: sourceGraph.modules.length, + typescriptModules: sourceGraph.modules.filter((module) => + sourceExtensionPattern.test(module), + ).length, + dependencies: sourceGraph.dependencies.length, + localDependencies: sourceGraph.dependencies.filter( + ({ kind }) => kind === "local", + ).length, + unresolved: sourceGraph.unresolved.length, + parseFailures: sourceGraph.parseFailures.length, + cycles: sourceGraph.cycles.length, + errors: graphErrors.length, + typeScriptOnlyPolicyPassed: typeScriptOnlyPolicy.passed, + nonTypeScriptExecutableSources: typeScriptOnlyPolicy.violations.length, + }, + fixtureChecks: graphFixtureResult, + typeScriptOnlySourcePolicy: typeScriptOnlyPolicy, +}; + +await writeValidatedJsonArtifact({ + path: qualityArtifact, + schema: architectureDependencyReportArtifactSchema, + value: dependencyReport, +}); + +let architectureFailed = false; + +if (!dependencyCruiserReportValid) { + architectureFailed = true; + process.stderr.write("dependency-cruiser returned an invalid JSON report\n"); +} + +if (dependencyCruiser.status !== 0) { + architectureFailed = true; + process.stderr.write( + (dependencyCruiser.error?.message ?? dependencyCruiser.stderr) || + dependencyCruiser.stdout || + "dependency-cruiser failed\n", + ); +} + +for (const failure of sourceGraph.parseFailures) { + architectureFailed = true; + process.stderr.write( + `Architecture graph parse failure: ${failure.source}: ${failure.reason}\n`, + ); +} + +for (const dependency of sourceGraph.unresolved) { + architectureFailed = true; + process.stderr.write( + `Unresolved architecture dependency: ${dependency.source} -> ${dependency.specifier} (${dependency.reason})\n`, + ); +} + +for (const violation of graphErrors) { + architectureFailed = true; + const cycle = violation.cycle + ? ` (cycle group: ${violation.cycle.join(", ")})` + : ""; + process.stderr.write( + `Architecture violation [${violation.rule}]: ${violation.source} -> ${violation.target}${cycle}\n`, + ); +} + +for (const file of typeScriptOnlyPolicy.violations) { + architectureFailed = true; + process.stderr.write(`Non-TypeScript executable source: ${file}\n`); +} + +if (!graphFixtureResult.passed) { + architectureFailed = true; + for (const failure of graphFixtureResult.failures) { + process.stderr.write(`Architecture graph fixture failed: ${failure}\n`); + } +} + +if (architectureFailed) { + process.exit(1); +} + +process.stdout.write( + `Static import graph: ${sourceGraph.modules.length} modules, ${sourceGraph.dependencies.length} dependencies, all imports resolved\n`, +); +process.stdout.write( + `Architecture graph fixtures: ${graphFixtureResult.checks.length} regression checks PASS\n`, +); +process.stdout.write( + `TypeScript-only source policy: PASS (${typeScriptOnlyPolicy.checkedRoots.join(", ")}; no fixture exceptions)\n`, +); + +const allowed = runPnpm([ + "exec", + "eslint", + "tests/fixtures/architecture/allowed", + "--no-ignore", + "--max-warnings=0", +]); + +const forbidden = runPnpm([ + "exec", + "eslint", + "tests/fixtures/architecture/forbidden", + "--no-ignore", + "--max-warnings=0", +]); + +async function fixtureFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const target = `${directory}/${entry.name}`; + return entry.isDirectory() + ? fixtureFiles(target) + : lintFixtureExtensionPattern.test(entry.name) + ? [target] + : []; + }), + ); + return files.flat(); +} + +const forbiddenResults = await Promise.all( + (await fixtureFiles("tests/fixtures/architecture/forbidden")).map((file) => ({ + file, + result: runPnpm([ + "exec", + "eslint", + file, + "--no-ignore", + "--max-warnings=0", + ]), + })), +); +const acceptedForbidden = forbiddenResults.filter( + ({ result }) => result.status === 0, +); + +if ( + allowed.status !== 0 || + forbidden.status === 0 || + acceptedForbidden.length > 0 +) { + process.stderr.write(allowed.stderr || allowed.stdout); + process.stderr.write(forbidden.stderr || forbidden.stdout); + for (const { file } of acceptedForbidden) { + process.stderr.write(`Forbidden fixture was accepted: ${file}\n`); + } + process.exit(1); +} + +process.stdout.write( + `Architecture fixtures: allowed PASS, ${forbiddenResults.length} forbidden rejected\n`, +); + +async function analyzeSourceGraph( + rootDirectory: string, + reportPrefix: string, +): Promise { + const allFiles = await listFiles(rootDirectory); + const allFileSet = new Set(allFiles); + const sourceFiles = allFiles.filter((file) => sourceExtensionPattern.test(file)); + const modules = sourceFiles + .map((file) => reportPath(file, rootDirectory, reportPrefix)) + .sort(); + const moduleNames = new Set(modules); + const dependencies: DependencyEdge[] = []; + const unresolved: UnresolvedDependency[] = []; + const parseFailures: ParseFailure[] = []; + + for (const sourceFile of sourceFiles) { + const source = reportPath(sourceFile, rootDirectory, reportPrefix); + let specifiers: string[]; + try { + specifiers = await importSpecifiers(sourceFile); + } catch (error) { + parseFailures.push({ + source, + reason: error instanceof Error ? error.message : String(error), + }); + continue; + } + + for (const specifier of specifiers) { + if (isExplicitLocalJavaScriptSpecifier(specifier)) { + unresolved.push({ + source, + specifier, + reason: forbiddenLocalJavaScriptSpecifierReason, + }); + continue; + } + + if (relativeSpecifierPattern.test(specifier)) { + const resolution = resolveRelativeDependency( + sourceFile, + specifier, + rootDirectory, + allFileSet, + ); + if (!resolution.path) { + unresolved.push({ source, specifier, reason: resolution.reason }); + continue; + } + dependencies.push({ + source, + target: reportPath(resolution.path, rootDirectory, reportPrefix), + specifier, + kind: "local", + }); + continue; + } + + try { + const resolvedSpecifier = import.meta.resolve( + specifier, + pathToFileURL(sourceFile).href, + ); + if (resolvedSpecifier.startsWith("file:")) { + const resolvedFile = fileURLToPath(resolvedSpecifier); + const resolvedSource = resolutionCandidates(resolvedFile).find( + (candidate) => allFileSet.has(candidate), + ); + if (resolvedSource) { + dependencies.push({ + source, + target: reportPath(resolvedSource, rootDirectory, reportPrefix), + specifier, + kind: "local", + }); + continue; + } + if (specifier.startsWith("/") || specifier.startsWith("file:")) { + throw new Error( + "absolute file imports must resolve within the analyzed source root", + ); + } + } + dependencies.push({ + source, + target: specifier, + specifier, + kind: "external", + }); + } catch (error) { + unresolved.push({ + source, + specifier, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + } + + dependencies.sort(compareDependencies); + unresolved.sort(compareSourceAndSpecifier); + parseFailures.sort((left, right) => left.source.localeCompare(right.source)); + const cycles = findCycles(modules, dependencies, moduleNames); + + return { + modules, + dependencies, + unresolved, + parseFailures, + cycles, + violations: findArchitectureViolations(dependencies, cycles), + }; +} + +async function importSpecifiers(sourceFile: string): Promise { + const sourceText = await readFile(sourceFile, "utf8"); + const isTypeScript = sourceExtensionPattern.test(sourceFile); + const isJsx = /\.tsx$/u.test(sourceFile); + const plugins: BabelParserPlugin[] = []; + + if (isTypeScript) { + plugins.push([ + "typescript", + { + dts: declarationExtensionPattern.test(sourceFile), + }, + ]); + } + if (isJsx) { + plugins.push("jsx"); + } + + const syntaxTree = await parseAsync(sourceText, { + filename: sourceFile, + babelrc: false, + configFile: false, + sourceType: "unambiguous", + parserOpts: { plugins }, + }); + if (!syntaxTree) { + throw new Error("Babel returned no syntax tree"); + } + + const specifiers = new Set(); + const nonLiteralModuleLoads = new Set(); + visitSyntaxNode(syntaxTree, specifiers, nonLiteralModuleLoads); + if (nonLiteralModuleLoads.size > 0) { + throw new Error( + `module loading must use string literals: ${[...nonLiteralModuleLoads].join(", ")}`, + ); + } + for (const comment of syntaxTree.comments ?? []) { + const importTypePattern = + /\bimport\s*\(\s*["']([^"'\\\r\n]+)["']\s*\)/gu; + let match = importTypePattern.exec(comment.value); + while (match) { + const specifier = match[1]; + if (specifier) specifiers.add(specifier); + match = importTypePattern.exec(comment.value); + } + } + return [...specifiers].sort(); +} + +function visitSyntaxNode( + value: unknown, + specifiers: Set, + nonLiteralModuleLoads: Set, +): void { + if (Array.isArray(value)) { + for (const child of value) { + visitSyntaxNode(child, specifiers, nonLiteralModuleLoads); + } + return; + } + if (!value || typeof value !== "object") { + return; + } + + const node = value as Record; + const nodeType = typeof node.type === "string" ? node.type : undefined; + if ( + nodeType === "ImportDeclaration" || + nodeType === "ExportNamedDeclaration" || + nodeType === "ExportAllDeclaration" + ) { + addStringLiteral(node.source, specifiers); + } else if (nodeType === "ImportExpression") { + if (!addStringLiteral(node.source, specifiers)) { + nonLiteralModuleLoads.add("import()"); + } + } else if ( + nodeType === "CallExpression" && + syntaxNodeType(node.callee) === "Import" + ) { + const arguments_ = Array.isArray(node.arguments) ? node.arguments : []; + if (!addStringLiteral(arguments_[0], specifiers)) { + nonLiteralModuleLoads.add("import()"); + } + } else if ( + nodeType === "CallExpression" && + syntaxNodeType(node.callee) === "Identifier" && + syntaxNodeProperty(node.callee, "name") === "require" + ) { + const arguments_ = Array.isArray(node.arguments) ? node.arguments : []; + if (!addStringLiteral(arguments_[0], specifiers)) { + nonLiteralModuleLoads.add("require()"); + } + } else if (nodeType === "TSExternalModuleReference") { + addStringLiteral(node.expression, specifiers); + } else if (nodeType === "TSImportType") { + addStringLiteral( + syntaxNodeType(node.argument) === "TSLiteralType" + ? syntaxNodeProperty(node.argument, "literal") + : node.argument, + specifiers, + ); + } + + for (const [key, child] of Object.entries(node)) { + if ( + key === "comments" || + key === "leadingComments" || + key === "trailingComments" || + key === "innerComments" || + key === "loc" + ) { + continue; + } + visitSyntaxNode(child, specifiers, nonLiteralModuleLoads); + } +} + +function syntaxNodeType(value: unknown): string | undefined { + const type = syntaxNodeProperty(value, "type"); + return typeof type === "string" ? type : undefined; +} + +function syntaxNodeProperty(value: unknown, key: string): unknown { + return value && typeof value === "object" + ? (value as Record)[key] + : undefined; +} + +function addStringLiteral(value: unknown, specifiers: Set): boolean { + if (!value || typeof value !== "object") { + return false; + } + const literal = value as Record; + if ( + (literal.type === "StringLiteral" || literal.type === "Literal") && + typeof literal.value === "string" + ) { + specifiers.add(literal.value); + return true; + } + return false; +} + +function resolveRelativeDependency( + sourceFile: string, + specifier: string, + rootDirectory: string, + allFiles: Set, +): DependencyResolution { + if (isExplicitLocalJavaScriptSpecifier(specifier)) { + return { reason: forbiddenLocalJavaScriptSpecifierReason }; + } + + const pathPart = specifier.split(/[?#]/u, 1)[0]; + const requestedPath = resolve(dirname(sourceFile), pathPart); + const relativeToRoot = relative(rootDirectory, requestedPath); + if ( + relativeToRoot === ".." || + relativeToRoot.startsWith(`..${sep}`) || + isAbsolute(relativeToRoot) + ) { + return { reason: "relative import resolves outside the analyzed source root" }; + } + + for (const candidate of resolutionCandidates(requestedPath)) { + if (allFiles.has(candidate)) { + return { path: candidate, reason: "" }; + } + } + return { reason: "no matching source or asset exists within the analyzed root" }; +} + +function isExplicitLocalJavaScriptSpecifier(specifier: string): boolean { + return ( + (relativeSpecifierPattern.test(specifier) || + specifier.startsWith("/") || + specifier.startsWith("file:")) && + forbiddenLocalJavaScriptSpecifierPattern.test(specifier) + ); +} + +function resolutionCandidates(requestedPath: string): string[] { + const extension = extname(requestedPath); + const sourceExtensions = [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".d.ts", + ".d.mts", + ".d.cts", + ".json", + ]; + + if (extension) { + return [requestedPath]; + } + return [ + requestedPath, + ...sourceExtensions.map((candidate) => `${requestedPath}${candidate}`), + ...sourceExtensions.map((candidate) => + resolve(requestedPath, `index${candidate}`), + ), + ]; +} + +function findCycles( + modules: string[], + dependencies: DependencyEdge[], + moduleNames: Set, +): string[][] { + const adjacency = new Map( + modules.map((module) => [module, []]), + ); + for (const dependency of dependencies) { + if (dependency.kind === "local" && moduleNames.has(dependency.target)) { + adjacency.get(dependency.source)?.push(dependency.target); + } + } + + let nextIndex = 0; + const indexes = new Map(); + const lowLinks = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const cycles: string[][] = []; + + function connect(module: string): void { + indexes.set(module, nextIndex); + lowLinks.set(module, nextIndex); + nextIndex += 1; + stack.push(module); + onStack.add(module); + + for (const target of adjacency.get(module) ?? []) { + if (!indexes.has(target)) { + connect(target); + lowLinks.set( + module, + Math.min( + requireMapValue(lowLinks, module), + requireMapValue(lowLinks, target), + ), + ); + } else if (onStack.has(target)) { + lowLinks.set( + module, + Math.min( + requireMapValue(lowLinks, module), + requireMapValue(indexes, target), + ), + ); + } + } + + if (lowLinks.get(module) !== indexes.get(module)) { + return; + } + const component: string[] = []; + let member: string | undefined; + do { + member = stack.pop(); + if (!member) break; + onStack.delete(member); + component.push(member); + } while (member !== module); + + const firstComponent = component[0]; + const selfCycle = + component.length === 1 && + firstComponent !== undefined && + adjacency.get(firstComponent)?.includes(firstComponent); + if (component.length > 1 || selfCycle) { + cycles.push(component.sort()); + } + } + + for (const module of modules) { + if (!indexes.has(module)) connect(module); + } + return cycles.sort((left, right) => + (left[0] ?? "").localeCompare(right[0] ?? ""), + ); +} + +function requireMapValue(map: Map, key: Key): Value { + const value = map.get(key); + if (value === undefined) { + throw new Error("Architecture graph invariant failed"); + } + return value; +} + +function findArchitectureViolations( + dependencies: DependencyEdge[], + cycles: string[][], +): ArchitectureViolation[] { + const violations: ArchitectureViolation[] = []; + for (const rule of architectureRules) { + if (rule.to?.circular) { + for (const cycle of cycles) { + violations.push({ + rule: rule.name, + severity: rule.severity ?? "warn", + source: cycle[0] ?? "unknown-cycle-source", + target: cycle[1] ?? cycle[0] ?? "unknown-cycle-target", + cycle, + }); + } + continue; + } + for (const dependency of dependencies) { + if ( + matchesPath(dependency.source, rule.from) && + matchesPath(dependency.target, rule.to) + ) { + violations.push({ + rule: rule.name, + severity: rule.severity ?? "warn", + source: dependency.source, + target: dependency.target, + }); + } + } + } + return violations.sort((left, right) => + `${left.rule}:${left.source}:${left.target}`.localeCompare( + `${right.rule}:${right.source}:${right.target}`, + ), + ); +} + +function matchesPath( + modulePath: string, + criterion: PathRule | undefined, +): boolean { + if (!criterion) return true; + if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) { + return false; + } + return !( + criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath) + ); +} + +function validateArchitectureRules(rules: readonly ArchitectureRule[]): void { + if (!rules.some((rule) => rule.to?.circular === true)) { + throw new Error("Architecture configuration must contain a circular rule"); + } + for (const rule of rules) { + for (const key of Object.keys(rule.from ?? {})) { + if (key !== "path" && key !== "pathNot") { + throw new Error(`Unsupported architecture matcher from.${key} in ${rule.name}`); + } + } + for (const key of Object.keys(rule.to ?? {})) { + if (key !== "path" && key !== "pathNot" && key !== "circular") { + throw new Error(`Unsupported architecture matcher to.${key} in ${rule.name}`); + } + } + } +} + +function blockingViolations(graph: SourceGraph): ArchitectureViolation[] { + return graph.violations.filter(({ severity }) => severity === "error"); +} + +async function runGraphFixtureChecks(): Promise { + const fixtureRoot = resolve( + projectRoot, + "tests/fixtures/architecture/dependency-graph", + ); + const allowedRoot = resolve(fixtureRoot, "allowed"); + const [allowedGraph, unresolvedGraph, layerGraph, cycleGraph] = + await Promise.all([ + analyzeSourceGraph(allowedRoot, "src"), + analyzeSourceGraph(resolve(fixtureRoot, "unresolved"), "src"), + analyzeSourceGraph(resolve(fixtureRoot, "layer"), "src"), + analyzeSourceGraph(resolve(fixtureRoot, "cycle"), "src"), + ]); + const allowedFiles = new Set(await listFiles(allowedRoot)); + const allowedSourceFile = resolve( + allowedRoot, + "application/read-value.ts", + ); + const legacySpecifierRejections = [ + "../domain/value.js", + "../domain/value.jsx", + "../domain/value.mjs", + "../domain/value.cjs", + ].map((specifier) => + resolveRelativeDependency( + allowedSourceFile, + specifier, + allowedRoot, + allowedFiles, + ), + ); + const assertions = [ + { + name: "explicit TS specifier resolves to a TS module", + passed: allowedGraph.dependencies.some( + ({ source, target, specifier }) => + source === "src/application/read-value.ts" && + target === "src/domain/value.ts" && + specifier === "../domain/value.ts", + ), + }, + { + name: "local JavaScript-family specifiers are rejected", + passed: legacySpecifierRejections.every( + ({ path, reason }) => + path === undefined && + reason === forbiddenLocalJavaScriptSpecifierReason, + ), + }, + { + name: "TSX modules are included", + passed: allowedGraph.modules.includes("src/presentation/value-view.tsx"), + }, + { + name: "allowed graph has no blocking findings", + passed: + allowedGraph.unresolved.length === 0 && + allowedGraph.parseFailures.length === 0 && + blockingViolations(allowedGraph).length === 0, + }, + { + name: "unresolved relative imports are rejected", + passed: unresolvedGraph.unresolved.some( + ({ specifier }) => specifier === "../domain/missing-value.ts", + ), + }, + { + name: "unresolved package imports are rejected", + passed: unresolvedGraph.unresolved.some( + ({ specifier }) => + specifier === "architecture-fixture-package-that-does-not-exist", + ), + }, + { + name: "unresolved absolute imports are rejected", + passed: unresolvedGraph.unresolved.some( + ({ specifier }) => + specifier === "/definitely-missing-architecture-fixture.ts", + ), + }, + { + name: "unresolved file URL imports are rejected", + passed: unresolvedGraph.unresolved.some( + ({ specifier }) => + specifier === "file:///definitely-missing-architecture-fixture.ts", + ), + }, + { + name: "non-literal dynamic imports are rejected", + passed: unresolvedGraph.parseFailures.some( + ({ source, reason }) => + source === "src/application/load-value.ts" && + reason.includes("import()"), + ), + }, + { + name: "non-literal CommonJS imports are rejected", + passed: unresolvedGraph.parseFailures.some( + ({ source, reason }) => + source === "src/application/require-value.ts" && + reason.includes("require()"), + ), + }, + { + name: "TypeScript layer violations are rejected", + passed: blockingViolations(layerGraph).some( + ({ rule }) => rule === "application-does-not-know-concrete-runtime", + ), + }, + { + name: "TypeScript cycles are rejected", + passed: blockingViolations(cycleGraph).some( + ({ rule }) => rule === "no-circular-dependencies", + ), + }, + ]; + return { + passed: assertions.every(({ passed }) => passed), + checks: assertions.map(({ name }) => name), + failures: assertions.filter(({ passed }) => !passed).map(({ name }) => name), + }; +} + +async function inspectTypeScriptOnlyPolicy(): Promise { + const requiredRoots = ["src", "scripts", "tests", ".storybook"] as const; + const optionalRoots = ["recipes"] as const; + const checkedRoots = [...requiredRoots, ...optionalRoots]; + const files = ( + await Promise.all( + [ + ...requiredRoots.map((root) => + listFiles(resolve(projectRoot, root)), + ), + ...optionalRoots.map((root) => + listOptionalFiles(resolve(projectRoot, root)), + ), + ], + ) + ).flat(); + const rootEntries = await readdir(projectRoot, { withFileTypes: true }); + const rootConfigFiles = rootEntries + .filter( + (entry) => + entry.isFile() && + /\.config\.(?:js|jsx|mjs|cjs)$/u.test(entry.name), + ) + .map((entry) => resolve(projectRoot, entry.name)); + const candidates = [...files, ...rootConfigFiles] + .filter((file) => forbiddenJavaScriptExtensionPattern.test(file)) + .map((file) => reportPath(file, projectRoot, "")) + .sort(); + const violations = candidates; + return { + checkedRoots, + exceptionsAllowed: false, + violations, + passed: violations.length === 0, + }; +} + +async function listOptionalFiles(directory: string): Promise { + try { + return await listFiles(directory); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return []; + } + throw error; + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +async function listFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const target = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(target) : [target]; + }), + ); + return files.flat().sort(); +} + +function reportPath( + file: string, + rootDirectory: string, + prefix: string, +): string { + const childPath = relative(rootDirectory, file).split(sep).join("/"); + return prefix ? `${prefix}/${childPath}` : childPath; +} + +function compareDependencies( + left: DependencyEdge, + right: DependencyEdge, +): number { + return `${left.source}:${left.target}:${left.specifier}`.localeCompare( + `${right.source}:${right.target}:${right.specifier}`, + ); +} + +function compareSourceAndSpecifier( + left: Pick, + right: Pick, +): number { + return `${left.source}:${left.specifier}`.localeCompare( + `${right.source}:${right.specifier}`, + ); +} + +function parseArchitectureConfig(value: unknown): ArchitectureConfig { + if (!isRecord(value)) { + throw new TypeError(".dependency-cruiser.json must contain an object"); + } + const forbidden = value.forbidden ?? []; + const allowed = value.allowed ?? []; + const required = value.required ?? []; + if (!Array.isArray(forbidden) || !forbidden.every(isArchitectureRule)) { + throw new TypeError( + ".dependency-cruiser.json forbidden rules have an invalid shape", + ); + } + if (!Array.isArray(allowed) || !Array.isArray(required)) { + throw new TypeError( + ".dependency-cruiser.json allowed/required must be arrays", + ); + } + return { forbidden, allowed, required }; +} + +function isArchitectureRule(value: unknown): value is ArchitectureRule { + if (!isRecord(value) || typeof value.name !== "string") return false; + if (value.severity !== undefined && typeof value.severity !== "string") { + return false; + } + return ( + (value.from === undefined || isPathRule(value.from, false)) && + (value.to === undefined || isPathRule(value.to, true)) + ); +} + +function isPathRule(value: unknown, allowCircular: boolean): value is PathRule { + if (!isRecord(value)) return false; + if (value.path !== undefined && typeof value.path !== "string") return false; + if (value.pathNot !== undefined && typeof value.pathNot !== "string") { + return false; + } + return ( + !allowCircular || + value.circular === undefined || + typeof value.circular === "boolean" + ); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function parseDependencyCruiserReport( + output: string, +): DependencyCruiserReport { + try { + const report: unknown = JSON.parse(output); + if (!report || typeof report !== "object" || Array.isArray(report)) { + throw new Error("dependency-cruiser report must be a JSON object"); + } + return report as DependencyCruiserReport; + } catch { + return { summary: { errors: 1 }, dependencyCruiserOutput: output }; + } +} diff --git a/scripts/check-browser-file-storage-boundaries.ts b/scripts/check-browser-file-storage-boundaries.ts new file mode 100644 index 0000000..9866e54 --- /dev/null +++ b/scripts/check-browser-file-storage-boundaries.ts @@ -0,0 +1,207 @@ +import { spawnSync } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +function requiredPnpmCli(): string { + const executable = process.env.npm_execpath; + if (!executable) { + throw new Error( + "check:browser-file-storage-boundaries must run through pnpm", + ); + } + return executable; +} + +function runEslint(path: string) { + return spawnSync( + process.execPath, + [ + requiredPnpmCli(), + "exec", + "eslint", + path, + "--no-ignore", + "--max-warnings=0", + ], + { encoding: "utf8" }, + ); +} + +function runEslintSource(source: string, virtualPath: string) { + return spawnSync( + process.execPath, + [ + requiredPnpmCli(), + "exec", + "eslint", + "--stdin", + "--stdin-filename", + virtualPath, + "--no-ignore", + "--max-warnings=0", + ], + { encoding: "utf8", input: source }, + ); +} + +const allowed = runEslint( + "tests/fixtures/browser-file-storage-boundaries/allowed", +); +const forbiddenRoot = + "tests/fixtures/browser-file-storage-boundaries/forbidden"; +const forbiddenFiles = ( + await readdir(forbiddenRoot, { withFileTypes: true }) +) + .filter((entry) => entry.isFile() && /\.tsx?$/u.test(entry.name)) + .map((entry) => path.join(forbiddenRoot, entry.name)); +const forbiddenResults = forbiddenFiles.map((file) => ({ + file, + result: runEslint(file), +})); +const acceptedForbidden = forbiddenResults.filter( + ({ result }) => result.status === 0, +); +const indexedDbBypassSource = await readFile( + path.join(forbiddenRoot, "direct-indexeddb.ts"), + "utf8", +); +const objectUrlBypassSource = await readFile( + path.join(forbiddenRoot, "object-url.ts"), + "utf8", +); +const blobBypassSource = await readFile( + path.join(forbiddenRoot, "direct-blob.ts"), + "utf8", +); +const crossContextAdapterSource = await readFile( + "tests/fixtures/browser-file-storage-boundaries/allowed/cross-context-host.ts", + "utf8", +); +const browserStorageAdapterSource = await readFile( + "tests/fixtures/browser-file-storage-boundaries/allowed/browser-storage-host.ts", + "utf8", +); +const protectedSourceFixtureNames = [ + "aliased-globalthis.ts", + "class-field-alias.ts", + "constructor-root-escape.ts", + "default-parameter-alias.ts", + "direct-blob.ts", + "dynamic-capability-key.ts", + "global-object-container.ts", + "identity-wrapped-global.ts", + "instance-property-alias.ts", + "property-descriptor-access.ts", +] as const; +const protectedSourceFixtures = await Promise.all( + protectedSourceFixtureNames.map(async (file) => ({ + file, + source: await readFile(path.join(forbiddenRoot, file), "utf8"), + })), +); +const approvedAdapterResults = [ + { + file: "src/adapters/storage/indexeddb/boundary-fixture.ts", + result: runEslintSource( + indexedDbBypassSource, + "src/adapters/storage/indexeddb/boundary-fixture.ts", + ), + }, + { + file: "src/adapters/cross-context-invalidation/boundary-fixture.ts", + result: runEslintSource( + crossContextAdapterSource, + "src/adapters/cross-context-invalidation/boundary-fixture.ts", + ), + }, + { + file: "src/adapters/storage/boundary-fixture.ts", + result: runEslintSource( + browserStorageAdapterSource, + "src/adapters/storage/boundary-fixture.ts", + ), + }, + { + file: "src/adapters/browser-transfer/boundary-fixture.ts", + result: runEslintSource( + blobBypassSource, + "src/adapters/browser-transfer/boundary-fixture.ts", + ), + }, +]; +const rejectedApprovedAdapters = approvedAdapterResults.filter( + ({ result }) => result.status !== 0, +); +const misplacedAdapterResults = [ + { + file: "src/adapters/http/boundary-fixture.ts", + result: runEslintSource( + indexedDbBypassSource, + "src/adapters/http/boundary-fixture.ts", + ), + }, + { + file: "src/features/reference-feature/adapters/boundary-fixture.ts", + result: runEslintSource( + objectUrlBypassSource, + "src/features/reference-feature/adapters/boundary-fixture.ts", + ), + }, + { + file: "src/adapters/http/cross-context-boundary-fixture.ts", + result: runEslintSource( + crossContextAdapterSource, + "src/adapters/http/cross-context-boundary-fixture.ts", + ), + }, + { + file: "src/adapters/http/browser-storage-boundary-fixture.ts", + result: runEslintSource( + browserStorageAdapterSource, + "src/adapters/http/browser-storage-boundary-fixture.ts", + ), + }, + ...protectedSourceFixtures.map(({ file, source }) => { + const virtualPath = `src/presentation/${file}`; + return { + file: virtualPath, + result: runEslintSource(source, virtualPath), + }; + }), +]; +const acceptedMisplacedAdapters = misplacedAdapterResults.filter( + ({ result }) => result.status === 0, +); + +if ( + allowed.status !== 0 || + rejectedApprovedAdapters.length > 0 || + forbiddenFiles.length === 0 || + acceptedForbidden.length > 0 || + acceptedMisplacedAdapters.length > 0 +) { + process.stderr.write(allowed.stderr || allowed.stdout); + for (const { file, result } of rejectedApprovedAdapters) { + process.stderr.write( + `${file}: owned browser capability adapter was rejected\n`, + ); + process.stderr.write(result.stderr || result.stdout); + } + for (const { file, result } of acceptedForbidden) { + process.stderr.write( + `${file}: forbidden browser API fixture was accepted\n`, + ); + process.stderr.write(result.stderr || result.stdout); + } + for (const { file, result } of acceptedMisplacedAdapters) { + process.stderr.write( + `${file}: native browser storage access outside its owned adapter was accepted\n`, + ); + process.stderr.write(result.stderr || result.stdout); + } + process.exit(1); +} + +process.stdout.write( + `Browser file/storage boundaries: PASS (owned adapter allowed, ${forbiddenFiles.length + misplacedAdapterResults.length} direct or misplaced native access cases rejected)\n`, +); diff --git a/scripts/check-browser-security.ts b/scripts/check-browser-security.ts new file mode 100644 index 0000000..ba8acb5 --- /dev/null +++ b/scripts/check-browser-security.ts @@ -0,0 +1,49 @@ +import { readdir } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; + +function requiredPnpmCli(): string { + const executable = process.env.npm_execpath; + if (!executable) { + throw new Error("check:browser-security must run through the pnpm script"); + } + return executable; +} + +const pnpmCli = requiredPnpmCli(); + +function runPnpm(arguments_: string[]) { + return spawnSync(process.execPath, [pnpmCli, ...arguments_], { + encoding: "utf8", + }); +} + +const allowed = runPnpm([ + "exec", + "eslint", + "tests/fixtures/security/allowed", + "--no-ignore", + "--max-warnings=0", +]); +const forbidden = runPnpm([ + "exec", + "eslint", + "tests/fixtures/security/forbidden", + "--no-ignore", + "--max-warnings=0", +]); + +const distFiles = await readdir("dist", { recursive: true }); +const publicSourceMaps = distFiles.filter((file) => String(file).endsWith(".map")); + +if (allowed.status !== 0 || forbidden.status === 0 || publicSourceMaps.length > 0) { + process.stderr.write(allowed.stderr || allowed.stdout); + process.stderr.write(forbidden.stderr || forbidden.stdout); + if (publicSourceMaps.length > 0) { + process.stderr.write(`Public source maps found: ${publicSourceMaps.join(", ")}\n`); + } + process.exit(1); +} + +process.stdout.write( + "Browser security fixtures: injection rejected, public source maps absent\n", +); diff --git a/scripts/check-bundle.ts b/scripts/check-bundle.ts new file mode 100644 index 0000000..a311763 --- /dev/null +++ b/scripts/check-bundle.ts @@ -0,0 +1,109 @@ +import { readFile } from "node:fs/promises"; + +import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.ts"; +import { + bundleOutputInventoryArtifactSchema, + bundlePerformanceArtifactSchema, +} from "./contracts/release-artifacts.ts"; +import { classifyViteJavascript } from "./lib/classify-vite-bundle.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type ViteManifest = Record< + string, + { file: string; isEntry?: boolean; imports?: string[] } +>; +type BundleBudgets = { + initialJsGzipBytes: number; + lazyChunkGzipBytes: number; +}; + +const report = bundleOutputInventoryArtifactSchema.parse( + JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8")) as unknown, +); +const viteManifest = JSON.parse( + await readFile("dist/.vite/manifest.json", "utf8"), +) as ViteManifest; +const budgets = JSON.parse( + await readFile("config/performance/budgets.json", "utf8"), +).bundle as BundleBudgets; + +const outputByPath = new Map( + report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]), +); +const classification = classifyViteJavascript(viteManifest); +const initialJsGzipBytes = classification.initialFiles.reduce( + (total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0), + 0, +); +const lazyChunks = classification.lazyFiles.map((file) => ({ + path: file, + gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0, +})); +const missingOutputs = [ + ...classification.initialFiles, + ...classification.lazyFiles, +].filter((file) => !outputByPath.has(file)); +const measurements = { initialJsGzipBytes, lazyChunks }; +const result = evaluateBundleBudget(measurements, budgets); +const fixtures = [ + { + name: "initial-js-over-budget", + passed: + !evaluateBundleBudget( + { + initialJsGzipBytes: budgets.initialJsGzipBytes + 1, + lazyChunks: [], + }, + budgets, + ).passed, + }, + { + name: "lazy-chunk-over-budget", + passed: + !evaluateBundleBudget( + { + initialJsGzipBytes: 0, + lazyChunks: [ + { + path: "fixture.js", + gzipBytes: budgets.lazyChunkGzipBytes + 1, + }, + ], + }, + budgets, + ).passed, + }, +]; +const passed = + result.passed && + fixtures.every((fixture) => fixture.passed) && + classification.missingImports.length === 0 && + missingOutputs.length === 0; +const completedReport = { + ...report, + measurements, + classification, + missingOutputs, + thresholds: budgets, + results: result, + fixtures, + passed, +}; + +await writeValidatedJsonArtifact({ + path: "artifacts/performance/bundle.json", + schema: bundlePerformanceArtifactSchema, + value: completedReport, +}); +if (!passed) { + process.stderr.write( + `Bundle budget or manifest integrity failed: ${[ + ...classification.missingImports, + ...missingOutputs, + ].join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Bundle budget: PASS (initial JS ${initialJsGzipBytes} / ${budgets.initialJsGzipBytes} gzip bytes)\n`, +); diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts new file mode 100644 index 0000000..4e0b338 --- /dev/null +++ b/scripts/check-ci-contract.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import { + evaluatePromotionReadiness, + PROMOTION_FORMULA, + type GateResult, +} from "../src/application/policies/promotion-readiness.ts"; +import { + loadCiGateContract, + indexCiGateContract, +} from "./contracts/ci-gates.ts"; +import { generateCiWorkflow, renderCiWorkflow } from "./generate-ci-workflow.ts"; +import { + ciContractReportSchema, +} from "./lib/ci-contract-report.ts"; +import { + validateInstallScriptPolicy, + validatePackageScriptGraph, +} from "./lib/package-script-graph.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const removalFixtureMode = process.argv.includes("--reduced-removal-fixture"); +const contract = await loadCiGateContract(process.cwd(), { + mode: removalFixtureMode ? "removal-fixture" : "canonical", +}); +const index = indexCiGateContract(contract); +const [packageDocument, nodeVersion] = await Promise.all([ + readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record }), + readFile(".nvmrc", "utf8").then((value) => value.trim()), +]); +const packageScripts = packageDocument.scripts ?? {}; +const failures: string[] = []; + +if (!/^\d+\.\d+\.\d+$/u.test(nodeVersion)) { + failures.push(".nvmrc must contain one exact Node.js semantic version"); +} +for (const script of [ + "build:release-candidate", + "verify:local-evidence", + "verify:promotion", + "generate:ci-workflow", + "check:ci-workflow", + "check:ci", +]) { + if (!packageScripts[script]) failures.push(`package script missing ${script}`); +} +for (const removedScript of [ + "build:release", + "verify:supply-chain", + "verify:supply-chain:promotion", +]) { + if (packageScripts[removedScript]) failures.push(`legacy package script remains ${removedScript}`); +} +if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) { + failures.push("verify:promotion must not build or rebuild candidate bytes"); +} +failures.push(...validatePackageScriptGraph(packageScripts, "check:ci")); +failures.push( + ...validateInstallScriptPolicy( + packageScripts, + [...new Set(contract.commands.map(({ script }) => script))], + ), +); + +const immutable = index.gates.get("FE-GATE-015"); +const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script); +if (JSON.stringify(immutableCommands) !== JSON.stringify(["build:release-candidate", "verify:local-evidence"])) { + failures.push("FE-GATE-015 must build candidate bytes once and verify local evidence only"); +} +const architecture = index.gates.get("FE-GATE-010"); +if (!architecture?.commandIds.some((id) => index.commands.get(id)?.script === "check:ci")) { + failures.push("a blocking gate must execute check:ci"); +} +if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.path === "artifacts/quality/ci-contract.json")) { + failures.push("FE-GATE-010 must publish the typed CI contract report"); +} + +const expectedGateIds = Array.from( + { length: 26 }, + (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, +); +const passingResults: Record = Object.fromEntries( + expectedGateIds.map((gateId) => [gateId, "PASS"]), +); +const allPass = evaluatePromotionReadiness(passingResults); +const negativeFixtures: Array<{ + readiness: keyof typeof PROMOTION_FORMULA; + failedGate: string; + passed: boolean; +}> = []; +for (const readiness of Object.keys(PROMOTION_FORMULA) as Array) { + const failedGate = PROMOTION_FORMULA[readiness][0]; + if (!failedGate) throw new TypeError(`${readiness} has no configured gates`); + const evaluated = evaluatePromotionReadiness({ ...passingResults, [failedGate]: "FAIL" }); + const passed = evaluated[readiness] === false; + negativeFixtures.push({ readiness, failedGate, passed }); + if (!passed) failures.push(`${readiness} did not fail closed`); +} +if (!Object.values(allPass).every(Boolean)) failures.push("all-PASS formula did not produce every readiness state"); + +const renderedWorkflow = renderCiWorkflow(contract); +const workflowCheck = await generateCiWorkflow({ + root: process.cwd(), + contract, + check: true, +}); +if (!workflowCheck.matches) { + failures.push( + `generated workflow drift at byte ${workflowCheck.firstDifferenceByte ?? "missing"}, line ${workflowCheck.firstDifferenceLine ?? "missing"}`, + ); +} +let checkedWorkflowBytes = Buffer.from(renderedWorkflow, "utf8"); +try { + checkedWorkflowBytes = await readFile(workflowCheck.target); +} catch (error) { + failures.push(`generated workflow is unreadable: ${error instanceof Error ? error.message : String(error)}`); +} +const report = ciContractReportSchema.parse({ + schemaVersion: 2, + nodeVersion, + gateCount: contract.gates.length, + commandDefinitionCount: contract.commands.length, + commandReferenceCount: contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0), + artifactCount: contract.artifacts.length, + jobCount: contract.jobs.length, + workflowSha256: createHash("sha256").update(checkedWorkflowBytes).digest("hex"), + durationStatus: contract.retention.durationStatus, + negativeFixtures, + failures, + passed: failures.length === 0, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/quality/ci-contract.json", + schema: ciContractReportSchema, + value: report, +}); +if (failures.length > 0) { + process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); + process.exit(1); +} +process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n"); diff --git a/scripts/check-compatibility.ts b/scripts/check-compatibility.ts new file mode 100644 index 0000000..dc2a79a --- /dev/null +++ b/scripts/check-compatibility.ts @@ -0,0 +1,58 @@ +import { mkdir, readFile } from "node:fs/promises"; + +import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts"; +import { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type CompatibilitySchema = Readonly<{ + required?: readonly string[]; + properties?: Readonly>; +}>; + +type CompatibilityFixture = Readonly<{ + before: CompatibilitySchema; + after: CompatibilitySchema; +}>; + +type CompatibilityFixtures = Readonly<{ + families: Readonly< + Record>> + >; +}>; + +const fixtures = JSON.parse( + await readFile("config/compatibility/fixtures.json", "utf8"), +) as CompatibilityFixtures; +const results = []; + +for (const [family, cases] of Object.entries(fixtures.families)) { + for (const expected of ["additive", "breaking"] as const) { + const fixture = cases[expected]; + const actual = classifyObjectSchemaChange(fixture.before, fixture.after); + results.push({ family, expected, actual, passed: actual === expected }); + } +} + +await mkdir("artifacts/release", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/release/compatibility.json", + schema: compatibilityFixturesArtifactSchema, + value: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + rules: [ + "additive changes preserve required fields", + "breaking changes require version bump and migration, discard, fallback, or rollback", + "config and API major versions must match", + "incompatible persisted cache is discarded by default", + "rollback uses a coherent compatibility tuple", + ], + results, + }, +}); + +if (results.some((result) => !result.passed)) { + process.stderr.write("Compatibility fixture classification failed.\n"); + process.exit(1); +} +process.stdout.write("Compatibility fixtures: PASS\n"); diff --git a/scripts/check-design-system.ts b/scripts/check-design-system.ts new file mode 100644 index 0000000..22ce4a8 --- /dev/null +++ b/scripts/check-design-system.ts @@ -0,0 +1,149 @@ +import { mkdir, readFile, readdir } from "node:fs/promises"; + +import { designSystemReportArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import path from "node:path"; + +import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts"; + +const fixtureMode = process.argv.includes("--fixture"); +const failures: string[] = []; +const tokenFiles = { + primitive: "src/presentation/design-system/tokens/primitive.css", + semantic: "src/presentation/design-system/tokens/semantic.css", + component: "src/presentation/design-system/tokens/component.css", +}; +const tokenLayers: Array = [ + ["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS], + ["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS], + ["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS], +]; + +for (const [layer, file, tokens] of tokenLayers) { + const source = await readFile(file, "utf8"); + for (const token of tokens) { + if (!source.includes(`${token}:`)) { + failures.push(`${layer} token is missing: ${token}`); + } + } +} + +const cssSources = await Promise.all( + [...Object.values(tokenFiles), "src/presentation/styles/theme.css"].map( + async (file) => ({ file, source: await readFile(file, "utf8") }), + ), +); +const definitions = new Set( + cssSources.flatMap(({ source }) => + [...source.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]), + ), +); +for (const { file, source } of cssSources) { + for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) { + if (!definitions.has(match[1])) { + failures.push(`${file} uses undefined token ${match[1]}`); + } + } +} + +const semanticSource = await readFile(tokenFiles.semantic, "utf8"); +const darkSource = + semanticSource.match(/:root\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/)?.[1] ?? + ""; +for (const token of [ + "--color-surface", + "--color-surface-muted", + "--color-surface-elevated", + "--color-content", + "--color-content-muted", + "--color-content-inverse", + "--color-border", + "--color-border-strong", + "--color-action", + "--color-action-hover", + "--color-action-pressed", + "--color-danger", + "--color-focus", + "--color-disabled-content", + "--color-disabled-surface", +]) { + if (!darkSource.includes(`${token}:`)) { + failures.push(`dark theme token is missing: ${token}`); + } +} +const componentSource = await readFile(tokenFiles.component, "utf8"); +if (!componentSource.includes("@media (forced-colors: active)")) { + failures.push("forced-colors token fallback is missing"); +} + +async function listSourceFiles(directory: string): Promise { + const result: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) result.push(...(await listSourceFiles(target))); + else if (/\.(?:ts|tsx|mts|cts)$/.test(entry.name)) result.push(target); + } + return result; +} + +const sources = fixtureMode + ? await listSourceFiles("tests/fixtures/design-system/forbidden") + : await listSourceFiles("src"); +for (const file of sources) { + const source = await readFile(file, "utf8"); + const vendorFacade = + file === "src/presentation/design-system/icons/vendors/lucide.tsx"; + if (!vendorFacade && /from\s+["']lucide-react["']/.test(source)) { + failures.push(`direct icon vendor import in ${file}`); + } + if ( + /from\s+["'](?:react-aria-components|@radix-ui\/[^"']+)["']/.test(source) + ) { + failures.push(`direct headless vendor import in ${file}`); + } + if ( + !file.includes("src/presentation/design-system/") && + /presentation\/design-system\/(?!index(?:\.tsx?)?["'])/.test(source) + ) { + failures.push(`design-system deep import in ${file}`); + } + if ( + !file.includes("src/presentation/design-system/tokens/") && + /(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source) + ) { + failures.push(`raw palette value in ${file}`); + } + if ( + file.includes("tests/fixtures/design-system/forbidden") && + source.includes("TOOLTIP_ONLY_REQUIRED_INFORMATION") + ) { + failures.push(`tooltip-only required information in ${file}`); + } +} + +const report = designSystemReportArtifactSchema.parse({ + schemaVersion: 1, + mode: fixtureMode ? "negative-fixture" : "source", + checkedTokenCount: + REQUIRED_PRIMITIVE_TOKENS.length + + REQUIRED_SEMANTIC_TOKENS.length + + REQUIRED_COMPONENT_TOKENS.length, + failures, + passed: failures.length === 0, +}); +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: fixtureMode + ? "artifacts/quality/design-system-fixture.json" + : "artifacts/quality/design-system.json", + schema: designSystemReportArtifactSchema, + value: report, +}); + +if (failures.length > 0) { + process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`); + process.exit(1); +} +process.stdout.write( + `Design system contract: ${report.checkedTokenCount} tokens and vendor boundaries PASS\n`, +); diff --git a/scripts/check-diagnostics.ts b/scripts/check-diagnostics.ts new file mode 100644 index 0000000..5a0569f --- /dev/null +++ b/scripts/check-diagnostics.ts @@ -0,0 +1,120 @@ +import { mkdir, readFile, readdir } from "node:fs/promises"; + +import { diagnosticsReportArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import path from "node:path"; + +import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts"; +import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.ts"; + +const fixtureMode = process.argv.includes("--fixture"); +const failures: string[] = []; +const extensions = /\.(?:ts|tsx|mts|cts)$/; + +async function filesBelow(directory: string): Promise { + const result: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) result.push(...(await filesBelow(target))); + else if (extensions.test(entry.name)) result.push(target); + } + return result; +} + +const telemetryProducerFiles: Readonly> = { + "app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts", + "api.request.failed": "src/adapters/http/client.ts", + "ui.render.failed": "src/application/create-application.ts", + "release.mismatch.detected": "src/application/create-application.ts", + "telemetry.delivery.dropped": + "src/adapters/telemetry/best-effort-telemetry.ts", + }; +const diagnosticProducerFiles: Readonly> = { + "app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts", + "http.request.completed": "src/adapters/http/client.ts", + "cache.operation.failed": + "src/adapters/query-cache/tanstack-query-cache.ts", + "storage.operation.failed": + "src/adapters/storage/browser-storage-adapter.ts", + "route.changed": "src/application/create-application.ts", + "ui.render.failed": "src/application/create-application.ts", + "release.mismatch.detected": "src/application/create-application.ts", + "telemetry.delivery.dropped": "src/bootstrap/runtime-adapters.ts", + }; + +if (!fixtureMode) { + for (const eventName of Object.keys(TELEMETRY_REGISTRY)) { + const producer = telemetryProducerFiles[eventName]; + if (!producer) { + failures.push(`telemetry event has no declared producer: ${eventName}`); + continue; + } + const source = await readFile(producer, "utf8"); + if (!source.includes(`"${eventName}"`)) { + failures.push(`telemetry producer is not executable: ${eventName}`); + } + } + for (const eventId of Object.keys(DIAGNOSTIC_EVENT_REGISTRY)) { + const producer = diagnosticProducerFiles[eventId]; + if (!producer) { + failures.push(`diagnostic event has no declared producer: ${eventId}`); + continue; + } + const source = await readFile(producer, "utf8"); + if (!source.includes(`"${eventId}"`)) { + failures.push(`diagnostic producer is not executable: ${eventId}`); + } + } +} + +const sources = fixtureMode + ? await filesBelow("tests/fixtures/diagnostics/forbidden") + : await filesBelow("src"); +const sensitiveContext = + /\b(?:authorization|cookie|access_token|refresh_token|request_body|response_body|raw_url|query_string|email|user_name)\b/i; +for (const file of sources) { + const source = await readFile(file, "utf8"); + if (file.includes("contracts/telemetry.ts")) continue; + if (source.includes("console.")) { + failures.push(`direct console diagnostics bypass in ${file}`); + } + const calls = source.match( + /(?:\.record\(\{|\.emit\()[\s\S]{0,700}?(?:\}\)|\}\);)/g, + ) ?? []; + if (calls.some((call) => sensitiveContext.test(call))) { + failures.push(`sensitive diagnostic or telemetry context in ${file}`); + } + if ( + file.includes("tests/fixtures/diagnostics/forbidden") && + source.includes("UNKNOWN_DIAGNOSTIC_EVENT") + ) { + failures.push(`unknown diagnostic event in ${file}`); + } +} + +const report = diagnosticsReportArtifactSchema.parse({ + schemaVersion: 1, + mode: fixtureMode ? "negative-fixture" : "source", + telemetryEventCount: Object.keys(TELEMETRY_REGISTRY).length, + diagnosticEventCount: Object.keys(DIAGNOSTIC_EVENT_REGISTRY).length, + checkedFiles: sources.length, + failures, + passed: failures.length === 0, +}); +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: fixtureMode + ? "artifacts/quality/diagnostics-fixture.json" + : "artifacts/quality/diagnostics.json", + schema: diagnosticsReportArtifactSchema, + value: report, +}); +if (failures.length > 0) { + process.stderr.write( + `Diagnostics contract failed:\n${failures.join("\n")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Diagnostics contract: ${report.diagnosticEventCount} diagnostics and ${report.telemetryEventCount} telemetry producers PASS\n`, +); diff --git a/scripts/check-frozen-lockfile-fixture.ts b/scripts/check-frozen-lockfile-fixture.ts new file mode 100644 index 0000000..8cdc0b7 --- /dev/null +++ b/scripts/check-frozen-lockfile-fixture.ts @@ -0,0 +1,39 @@ +import { spawnSync } from "node:child_process"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const fixtureRoot = await mkdtemp( + path.join(tmpdir(), "ca-frontend-frozen-lockfile-"), +); +try { + await cp("pnpm-lock.yaml", path.join(fixtureRoot, "pnpm-lock.yaml")); + const manifest = JSON.parse(await readFile("package.json", "utf8")); + manifest.dependencies.react = "0.0.0-invalid-fixture"; + await writeFile( + path.join(fixtureRoot, "package.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + const result = spawnSync( + "corepack", + [ + "pnpm", + "install", + "--frozen-lockfile", + "--lockfile-only", + "--ignore-scripts", + ], + { + cwd: fixtureRoot, + encoding: "utf8", + }, + ); + if (result.status === 0) { + process.stderr.write("Tampered manifest unexpectedly passed frozen install.\n"); + process.exitCode = 1; + } else { + process.stdout.write("Frozen lockfile mismatch fixture: rejected PASS\n"); + } +} finally { + await rm(fixtureRoot, { recursive: true, force: true }); +} diff --git a/scripts/check-i18n.ts b/scripts/check-i18n.ts new file mode 100644 index 0000000..ae62611 --- /dev/null +++ b/scripts/check-i18n.ts @@ -0,0 +1,107 @@ +import { mkdir, readFile, readdir } from "node:fs/promises"; + +import { i18nReportArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import path from "node:path"; + +import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts"; + +const fixtureMode = process.argv.includes("--fixture"); +const failures: string[] = []; +const sourceExtensions = /\.(?:ts|tsx|mts|cts)$/; +const koreanLiteral = /[가-힣]/; +const rawFailureRender = + /(? { + const result: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) result.push(...(await listSourceFiles(target))); + else if (sourceExtensions.test(entry.name)) result.push(target); + } + return result; +} + +function placeholders(template: string): string[] { + return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)] + .map((match) => match[1]) + .sort(); +} + +if (!fixtureMode) { + const canonicalKeys = Object.keys(KO_MESSAGES).sort(); + for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) { + const readableCatalog = catalog as Readonly>; + const canonicalCatalog = KO_MESSAGES as Readonly>; + const keys = Object.keys(catalog).sort(); + if (JSON.stringify(keys) !== JSON.stringify(canonicalKeys)) { + failures.push(`${locale} catalog keys do not match ko-KR`); + } + for (const key of canonicalKeys) { + if ( + JSON.stringify(placeholders(readableCatalog[key] ?? "")) !== + JSON.stringify(placeholders(canonicalCatalog[key] ?? "")) + ) { + failures.push(`${locale}:${key} interpolation parameters do not match`); + } + } + } + if (Object.keys(EN_MESSAGES).length !== canonicalKeys.length) { + failures.push("en-US catalog key count does not match ko-KR"); + } +} + +const commonDirectories = [ + "src/presentation/boundaries", + "src/presentation/components", + "src/presentation/design-system", + "src/presentation/forms", + "src/presentation/layouts", + "src/presentation/routes", + "src/presentation/templates", +]; +const sources = fixtureMode + ? await listSourceFiles("tests/fixtures/i18n/forbidden") + : ( + await Promise.all(commonDirectories.map((directory) => listSourceFiles(directory))) + ).flat(); + +for (const file of sources) { + const source = await readFile(file, "utf8"); + if (koreanLiteral.test(source)) { + failures.push(`hardcoded common user-facing locale literal in ${file}`); + } + if (rawFailureRender.test(source)) { + failures.push(`raw backend/error message rendered in ${file}`); + } + if (source.includes("dangerouslySetInnerHTML")) { + failures.push(`untrusted HTML interpolation boundary in ${file}`); + } +} + +const report = i18nReportArtifactSchema.parse({ + schemaVersion: 1, + mode: fixtureMode ? "negative-fixture" : "source", + localeCount: Object.keys(MESSAGE_CATALOGS).length + 1, + messageKeyCount: Object.keys(KO_MESSAGES).length, + checkedFiles: sources.length, + failures, + passed: failures.length === 0, +}); +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: fixtureMode + ? "artifacts/quality/i18n-fixture.json" + : "artifacts/quality/i18n.json", + schema: i18nReportArtifactSchema, + value: report, +}); + +if (failures.length > 0) { + process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`); + process.exit(1); +} +process.stdout.write( + `I18n contract: ${report.messageKeyCount} keys across ${report.localeCount} locales PASS\n`, +); diff --git a/scripts/check-optional-recipe-fixtures.ts b/scripts/check-optional-recipe-fixtures.ts new file mode 100644 index 0000000..6647ee0 --- /dev/null +++ b/scripts/check-optional-recipe-fixtures.ts @@ -0,0 +1,204 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; + +import { measureOptionalRecipeBundle } from "./lib/optional-recipe-bundle.ts"; +import { optionalRecipeFixturesArtifactSchema } from "./contracts/release-artifacts.ts"; +import { + scanOptionalRecipeSources, + scanProductionBundle, + validateRecipeCatalog, +} from "./lib/optional-recipes.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type ReferenceRuntimeRecipe = Readonly<{ + id: string; + referenceRuntime: Readonly<{ + sourceRoots: readonly string[]; + }>; +}>; + +const catalog = JSON.parse( + await readFile("config/recipes/frontend-capability-recipes.json", "utf8"), +); +const packageDocument = JSON.parse(await readFile("package.json", "utf8")); +const catalogRecipes: unknown[] = Array.isArray(catalog.recipes) + ? (catalog.recipes as unknown[]) + : []; +const referenceRuntimeRecipes = catalogRecipes.filter( + (recipe: unknown): recipe is ReferenceRuntimeRecipe => + Boolean( + recipe && + typeof recipe === "object" && + typeof (recipe as Record).id === "string" && + (recipe as Record).referenceRuntime && + typeof (recipe as Record).referenceRuntime === + "object" && + Array.isArray( + ( + (recipe as Record) + .referenceRuntime as Record + ).sourceRoots, + ), + ), +); +const bundleBudgetFixtures = await Promise.all( + referenceRuntimeRecipes.map(async (recipe) => { + const measurement = await measureOptionalRecipeBundle({ + recipeId: recipe.id, + sourceRoots: recipe.referenceRuntime.sourceRoots, + bundleBudgetGzipBytes: 1, + }); + return Object.freeze({ + recipeId: recipe.id, + gzipBytes: measurement.gzipBytes, + fixtureBudgetGzipBytes: measurement.bundleBudgetGzipBytes, + rejected: !measurement.passed, + }); + }), +); + +const cleanupCatalog = structuredClone(catalog); +cleanupCatalog.recipes.find( + (recipe: { id: string }) => recipe.id === "realtime", +).lifecycleMethods = []; + +const dependencyCatalog = structuredClone(catalog); +dependencyCatalog.productionRuntimeDependencies = ["zustand"]; + +const workflowCatalog = structuredClone(catalog); +workflowCatalog.recipes.find( + (recipe: { id: string }) => recipe.id === "client-workflow", +).serverStatePolicy = "copied-server-state"; + +const sourceViolations = await scanOptionalRecipeSources( + "tests/fixtures/optional-recipes/forbidden", + { scanProductionBoundary: false }, +); +const productionViolations = await scanOptionalRecipeSources( + "tests/fixtures/optional-recipes/forbidden/production-import", + { scanProductionBoundary: true }, +); +const runtimeCompositionViolations = await scanOptionalRecipeSources( + "tests/fixtures/optional-recipes/forbidden/runtime-composition", + { scanProductionBoundary: true }, +); +const bundleFixtureRoot = ".tmp/optional-recipe-runtime-bundle"; +await rm(bundleFixtureRoot, { recursive: true, force: true }); +await mkdir(`${bundleFixtureRoot}/assets`, { recursive: true }); +await writeFile( + `${bundleFixtureRoot}/assets/runtime.js`, + 'throw new TypeError("OPFS runtime policy is invalid.");\n', +); +const bundleViolations = await scanProductionBundle(bundleFixtureRoot); +await rm(bundleFixtureRoot, { recursive: true, force: true }); +const inventoryFixtureRoot = + ".tmp/optional-recipe-runtime-module-inventory"; +await rm(inventoryFixtureRoot, { recursive: true, force: true }); +await mkdir(`${inventoryFixtureRoot}/.vite`, { recursive: true }); +await writeFile(`${inventoryFixtureRoot}/.vite/manifest.json`, "{}\n"); +await writeFile( + `${inventoryFixtureRoot}/.vite/module-inventory.json`, + `${JSON.stringify({ + schemaVersion: 1, + chunks: [ + { + fileName: "assets/application.js", + modules: [ + "src/adapters/browser-files/browser-file-vault.ts", + ], + }, + ], + })}\n`, +); +const inventoryViolations = + await scanProductionBundle(inventoryFixtureRoot); +await rm(inventoryFixtureRoot, { recursive: true, force: true }); +sourceViolations.push(...productionViolations); +sourceViolations.push(...runtimeCompositionViolations); +const ruleIds = new Set(sourceViolations.map(({ ruleId }) => ruleId)); +const results = [ + { + id: "cleanup-omission", + passed: validateRecipeCatalog(cleanupCatalog, packageDocument).some( + (violation) => violation === "realtime:CLEANUP_CONTRACT_MISSING", + ), + }, + { + id: "unselected-runtime-dependency", + passed: validateRecipeCatalog(dependencyCatalog, packageDocument).includes( + "UNSELECTED_RUNTIME_DEPENDENCY", + ), + }, + { + id: "server-state-policy", + passed: validateRecipeCatalog(workflowCatalog, packageDocument).includes( + "client-workflow:SERVER_STATE_DUPLICATION_POLICY", + ), + }, + { + id: "vendor-direct-import", + passed: ruleIds.has("VENDOR_IMPORT_OUTSIDE_ADAPTER"), + }, + { + id: "credential-leak", + passed: ruleIds.has("CREDENTIAL_LEAK_PATH"), + }, + { + id: "server-state-source-duplication", + passed: ruleIds.has("CLIENT_STORE_DUPLICATES_SERVER_STATE"), + }, + { + id: "production-imports-recipe", + passed: ruleIds.has("PRODUCTION_IMPORTS_RECIPE"), + }, + { + id: "reference-runtime-not-composed", + passed: ruleIds.has("REFERENCE_RUNTIME_COMPOSED_WITHOUT_SELECTION"), + }, + { + id: "reference-runtime-not-bundled", + passed: bundleViolations.some((file) => file.endsWith("runtime.js")), + }, + { + id: "reference-runtime-module-not-bundled", + passed: inventoryViolations.some((violation) => + violation.includes( + "src/adapters/browser-files/browser-file-vault.ts", + ), + ), + }, + { + id: "reference-runtime-bundle-over-budget", + passed: + bundleBudgetFixtures.length === referenceRuntimeRecipes.length && + bundleBudgetFixtures.length > 0 && + bundleBudgetFixtures.every( + (fixture) => + fixture.gzipBytes > fixture.fixtureBudgetGzipBytes && + fixture.rejected, + ), + }, +]; +const report = optionalRecipeFixturesArtifactSchema.parse({ + schemaVersion: 1, + results, + bundleBudgetFixtures, + passed: results.every(({ passed }) => passed), +}); +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/quality/optional-recipe-fixtures.json", + schema: optionalRecipeFixturesArtifactSchema, + value: report, +}); +if (!report.passed) { + process.stderr.write( + `Optional recipe negative fixtures failed: ${results + .filter(({ passed }) => !passed) + .map(({ id }) => id) + .join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Optional recipe negative fixtures: PASS (${results.length} forbidden cases rejected)\n`, +); diff --git a/scripts/check-optional-recipe-types.ts b/scripts/check-optional-recipe-types.ts new file mode 100644 index 0000000..9b49d6d --- /dev/null +++ b/scripts/check-optional-recipe-types.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { extname, resolve } from "node:path"; + +const recipeRoot = resolve("recipes"); +const pnpmCli = requireEnvironment("npm_execpath"); + +if (!(await containsTypeScriptSource(recipeRoot))) { + process.stdout.write( + "Optional recipe typecheck: SKIP (no recipe TypeScript sources installed)\n", + ); +} else { + const result = spawnSync( + process.execPath, + [pnpmCli, "exec", "tsc", "--project", "tsconfig.recipes.json"], + { stdio: "inherit" }, + ); + if (result.error) { + throw result.error; + } + process.exitCode = result.status ?? 1; +} + +function requireEnvironment(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required to typecheck optional recipes`); + } + return value; +} + +async function containsTypeScriptSource(directory: string): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return false; + } + throw error; + } + + for (const entry of entries) { + const target = resolve(directory, entry.name); + if (entry.isDirectory() && (await containsTypeScriptSource(target))) { + return true; + } + if (entry.isFile() && [".ts", ".tsx", ".mts", ".cts"].includes(extname(entry.name))) { + return true; + } + } + return false; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} diff --git a/scripts/check-optional-recipes.ts b/scripts/check-optional-recipes.ts new file mode 100644 index 0000000..535e9c8 --- /dev/null +++ b/scripts/check-optional-recipes.ts @@ -0,0 +1,271 @@ +import { mkdir, readFile, stat } from "node:fs/promises"; + +import { optionalRecipesArtifactSchema } from "./contracts/release-artifacts.ts"; +import { + measureOptionalRecipeBundle, + type OptionalRecipeBundleMeasurement, +} from "./lib/optional-recipe-bundle.ts"; +import { + scanOptionalRecipeSources, + scanProductionBundle, + validateRecipeCatalog, +} from "./lib/optional-recipes.ts"; +import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type GateViolation = Readonly<{ + ruleId: string; + path: string; + detail?: string; +}>; + +type ReferenceRuntimeBundleInput = Readonly<{ + recipeId: string; + sourceRoots: readonly string[]; + bundleBudgetGzipBytes: number; +}>; + +const argument = (name: string, fallback: string): string => { + const index = process.argv.indexOf(name); + return index === -1 ? fallback : (process.argv[index + 1] ?? fallback); +}; + +const catalogPath = argument( + "--catalog", + "config/recipes/frontend-capability-recipes.json", +); +const sourceRoot = argument("--source-root", "src"); +const distRoot = argument("--dist-root", "dist"); +const artifactPath = argument( + "--artifact", + "artifacts/quality/optional-recipes.json", +); +const requireDist = process.argv.includes("--require-dist"); + +const catalog = JSON.parse( + await readFile(catalogPath, "utf8"), +) as Record; +const recipes = Array.isArray(catalog.recipes) ? catalog.recipes : []; +const packageDocument = JSON.parse( + await readFile("package.json", "utf8"), +) as Record; +const catalogSchemaViolations: string[] = []; +try { + assertMatchesJsonSchema( + JSON.parse( + await readFile( + "schemas/config/frontend-capability-recipes.schema.json", + "utf8", + ), + ), + catalog, + "optional recipe catalog", + ); +} catch { + catalogSchemaViolations.push("CATALOG_JSON_SCHEMA_INVALID"); +} +const catalogViolations = [ + ...catalogSchemaViolations, + ...validateRecipeCatalog(catalog, packageDocument), +]; +const runtimeSourceViolations = ( + await Promise.all( + recipes.flatMap((recipe: unknown) => { + if (!recipe || typeof recipe !== "object") return []; + const row = recipe as Record; + const runtime = row.referenceRuntime; + if (!runtime || typeof runtime !== "object") return []; + const sourceRoots = (runtime as Record).sourceRoots; + if (!Array.isArray(sourceRoots)) return []; + return sourceRoots + .filter( + (sourceRoot): sourceRoot is string => + typeof sourceRoot === "string", + ) + .map(async (sourceRoot) => ({ + sourceRoot, + exists: await stat(sourceRoot) + .then(() => true) + .catch(() => false), + })); + }), + ) +).filter(({ exists }) => !exists); +const { + inputs: referenceRuntimeBundleInputs, + violations: referenceRuntimeBundleConfigurationViolations, +} = referenceRuntimeBundleInputsFrom(recipes); +const referenceRuntimeBundles: OptionalRecipeBundleMeasurement[] = []; +const referenceRuntimeBundleMeasurementViolations: GateViolation[] = []; +for (const bundleInput of referenceRuntimeBundleInputs) { + try { + const measurement = await measureOptionalRecipeBundle(bundleInput); + referenceRuntimeBundles.push(measurement); + if (!measurement.passed) { + referenceRuntimeBundleMeasurementViolations.push({ + ruleId: "REFERENCE_RUNTIME_BUNDLE_BUDGET_EXCEEDED", + path: measurement.recipeId, + detail: + `${measurement.gzipBytes} > ` + + `${measurement.bundleBudgetGzipBytes} gzip bytes`, + }); + } + } catch (error) { + referenceRuntimeBundleMeasurementViolations.push({ + ruleId: "REFERENCE_RUNTIME_BUNDLE_MEASUREMENT_FAILED", + path: bundleInput.recipeId, + detail: safeErrorSummary(error), + }); + } +} +referenceRuntimeBundles.sort((left, right) => + compareText(left.recipeId, right.recipeId), +); +const sourceViolations = await scanOptionalRecipeSources(sourceRoot); +const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`) + .then(() => true) + .catch(() => false); +const bundleViolations = await scanProductionBundle(distRoot); +const violations: GateViolation[] = [ + ...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })), + ...runtimeSourceViolations.map(({ sourceRoot }) => ({ + ruleId: "REFERENCE_RUNTIME_SOURCE_MISSING", + path: sourceRoot, + })), + ...sourceViolations, + ...bundleViolations.map((path) => ({ + ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE", + path, + })), + ...(requireDist && !bundlePresent + ? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }] + : []), + ...referenceRuntimeBundleConfigurationViolations, + ...referenceRuntimeBundleMeasurementViolations, +]; +const report = optionalRecipesArtifactSchema.parse({ + schemaVersion: 1, + decisionId: "VD-10", + selectedCapabilities: [], + referenceRuntimes: recipes + .filter( + (recipe: unknown): recipe is Record => + Boolean( + recipe && + typeof recipe === "object" && + (recipe as Record).referenceRuntime, + ), + ) + .map((recipe: Record) => ({ + id: recipe.id, + referenceRuntime: recipe.referenceRuntime, + })), + recipeCount: recipes.length, + productionRuntimeDependencies: + catalog.productionRuntimeDependencies ?? null, + referenceRuntimeBundleBudgets: referenceRuntimeBundles, + bundleStatus: bundlePresent + ? bundleViolations.length === 0 + ? "PASS" + : "FAIL" + : "NOT_BUILT", + violations, + passed: violations.length === 0, +}); +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: artifactPath, + schema: optionalRecipesArtifactSchema, + value: report, +}); + +if (violations.length > 0) { + process.stderr.write( + `Optional recipe contract failed:\n${violations + .map( + (violation) => + `${violation.ruleId}: ${violation.path}` + + (violation.detail ? ` (${violation.detail})` : ""), + ) + .join("\n")}\n`, + ); + process.exit(1); +} +const referenceRuntimeBudgetStatus = + referenceRuntimeBundles.length > 0 + ? `, budgets=${referenceRuntimeBundles + .map( + (measurement) => + `${measurement.recipeId}:${measurement.gzipBytes}/` + + measurement.bundleBudgetGzipBytes, + ) + .join(",")} gzip bytes` + : ""; +process.stdout.write( + `Optional recipes: PASS (${report.recipeCount} optional capabilities, ${report.referenceRuntimes.length} uncomposed reference runtimes, bundle=${report.bundleStatus}${referenceRuntimeBudgetStatus})\n`, +); + +function referenceRuntimeBundleInputsFrom( + recipeRows: readonly unknown[], +): Readonly<{ + inputs: readonly ReferenceRuntimeBundleInput[]; + violations: readonly GateViolation[]; +}> { + const inputs: ReferenceRuntimeBundleInput[] = []; + const violations: GateViolation[] = []; + for (const recipe of recipeRows) { + if (!recipe || typeof recipe !== "object") continue; + const row = recipe as Record; + if (row.referenceRuntime === undefined) continue; + const recipeId = + typeof row.id === "string" ? row.id : "unknown-reference-runtime"; + const runtime = row.referenceRuntime; + const sourceRoots = + runtime && typeof runtime === "object" + ? (runtime as Record).sourceRoots + : null; + if ( + !Array.isArray(sourceRoots) || + !sourceRoots.every( + (sourceRoot): sourceRoot is string => + typeof sourceRoot === "string", + ) || + !Number.isSafeInteger(row.bundleBudgetGzipBytes) || + (row.bundleBudgetGzipBytes as number) < 1 + ) { + violations.push({ + ruleId: "REFERENCE_RUNTIME_BUNDLE_CONFIGURATION_INVALID", + path: recipeId, + }); + continue; + } + inputs.push( + Object.freeze({ + recipeId, + sourceRoots: Object.freeze([...sourceRoots]), + bundleBudgetGzipBytes: row.bundleBudgetGzipBytes as number, + }), + ); + } + return Object.freeze({ + inputs: Object.freeze( + inputs.sort((left, right) => + compareText(left.recipeId, right.recipeId), + ), + ), + violations: Object.freeze(violations), + }); +} + +function safeErrorSummary(error: unknown): string { + const message = + error instanceof Error ? error.message : "unknown measurement failure"; + return message + .split(/\r?\n/, 1)[0] + ?.replaceAll(process.cwd(), ".") + .slice(0, 512) ?? "unknown measurement failure"; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/scripts/check-realtime-boundaries.ts b/scripts/check-realtime-boundaries.ts new file mode 100644 index 0000000..e019799 --- /dev/null +++ b/scripts/check-realtime-boundaries.ts @@ -0,0 +1,43 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { realtimeBoundariesArtifactSchema } from "./contracts/release-artifacts.ts"; +import { scanRealtimeBoundaries } from "./lib/realtime-boundaries.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const sourceRoot = argument("--source-root") ?? "src"; +const artifact = + argument("--artifact") ?? + "artifacts/quality/realtime-boundaries.json"; +const violations = await scanRealtimeBoundaries(sourceRoot); +const report = realtimeBoundariesArtifactSchema.parse({ + schemaVersion: 1, + sourceRoot, + violations, + passed: violations.length === 0, +}); + +await mkdir(path.dirname(artifact), { recursive: true }); +await writeValidatedJsonArtifact({ + path: artifact, + schema: realtimeBoundariesArtifactSchema, + value: report, +}); + +if (violations.length > 0) { + for (const violation of violations) { + process.stderr.write( + `${violation.file}:${violation.line} ${violation.ruleId}\n`, + ); + } + process.exit(1); +} + +process.stdout.write( + `Realtime boundaries: PASS (${sourceRoot})\n`, +); + +function argument(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} diff --git a/scripts/check-realtime-boundary-fixtures.ts b/scripts/check-realtime-boundary-fixtures.ts new file mode 100644 index 0000000..6840483 --- /dev/null +++ b/scripts/check-realtime-boundary-fixtures.ts @@ -0,0 +1,47 @@ +import { spawnSync } from "node:child_process"; + +const checker = "scripts/check-realtime-boundaries.ts"; +const allowed = run( + "tests/fixtures/realtime-boundaries/allowed", + ".tmp/realtime-boundaries-allowed.json", +); +const forbidden = run( + "tests/fixtures/realtime-boundaries/forbidden", + ".tmp/realtime-boundaries-forbidden.json", +); +const forbiddenOutput = `${forbidden.stdout}${forbidden.stderr}`; +const expectedRules = [ + "NATIVE_REALTIME_API_OUTSIDE_ADAPTER", + "PRESENTATION_INTERVAL_OWNER", + "UNSELECTED_REALTIME_RUNTIME_COMPOSED", +] as const; + +if ( + allowed.status !== 0 || + forbidden.status === 0 || + !expectedRules.every((rule) => forbiddenOutput.includes(rule)) +) { + process.stderr.write(allowed.stdout); + process.stderr.write(allowed.stderr); + process.stderr.write(forbidden.stdout); + process.stderr.write(forbidden.stderr); + process.exit(1); +} + +process.stdout.write( + `Realtime boundary fixtures: PASS (${expectedRules.length} forbidden rules rejected)\n`, +); + +function run(sourceRoot: string, artifact: string) { + return spawnSync( + process.execPath, + [ + checker, + "--source-root", + sourceRoot, + "--artifact", + artifact, + ], + { encoding: "utf8" }, + ); +} diff --git a/scripts/check-registries.ts b/scripts/check-registries.ts new file mode 100644 index 0000000..9c45864 --- /dev/null +++ b/scripts/check-registries.ts @@ -0,0 +1,697 @@ +import { + access, + mkdir, + readFile, + readdir, +} from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + canonicalizeRegistryValue, + diffRegistrySnapshots, + registrySnapshotDigest, + validateBreakingEvidence, + verifyRegistryBaselineApproval, +} from "./lib/registry-compatibility.ts"; +import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; +import { + registryGovernanceRunArtifactSchema, + registrySnapshotArtifactSchema, +} from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type RegistryRow = Record; +type RegistryRows = Record; +type RegistryReference = Readonly<{ + registryId: string; + field: string; + targetField: string; +}>; +type RegistryConsumer = Readonly<{ path: string; token: string }>; +type RegistrySnapshotProjection = Readonly<{ + singletonRowKey: string; + canonicalArrayKeyFields: Readonly>; +}>; +type RegistrySpecification = Readonly<{ + registryId: string; + owner: string; + path: string; + exportName: string; + declaredRows?: unknown; + rowsPath?: string; + rowKeyFields?: readonly string[]; + requiredFields: readonly string[]; + fieldTypes?: Readonly>; + keyField?: string; + uniqueFields?: readonly string[]; + uniqueFieldSets?: readonly (readonly string[])[]; + snapshotProjection?: RegistrySnapshotProjection; + allowedValues?: Readonly>; + references?: readonly RegistryReference[]; + breakingFields?: readonly string[]; + consumers?: readonly RegistryConsumer[]; + consumerIdentityField?: string; + consumerDirectories?: readonly string[]; + orphanExemptRows?: readonly string[]; +}>; +type RegistryGovernance = Readonly<{ + registries: readonly RegistrySpecification[]; + sourceDirectories?: readonly string[]; +}>; +type RegistrySnapshot = Record; +type CompatibilitySummary = Readonly<{ + impact: string; + changes: readonly unknown[]; +}>; + +function argumentValue( + name: string, + fallback: string | undefined, +): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 && process.argv[index + 1] + ? process.argv[index + 1] + : fallback; +} + +const defaultGovernancePath = "config/contracts/registry-governance.json"; +const governancePath = + argumentValue("--governance", defaultGovernancePath) ?? + defaultGovernancePath; +const artifactPath = + argumentValue("--artifact", "artifacts/quality/registries.json") ?? + "artifacts/quality/registries.json"; +const usesRepositoryGovernance = governancePath === defaultGovernancePath; +const usesRepositoryBaseline = + usesRepositoryGovernance && + !process.argv.includes("--no-baseline"); +const baselinePath = argumentValue( + "--baseline", + usesRepositoryBaseline + ? "config/contracts/registry-baseline.json" + : undefined, +); +const approvalPath = argumentValue( + "--approval", + usesRepositoryBaseline + ? "config/contracts/registry-baseline.approval.json" + : undefined, +); +const evidencePath = argumentValue( + "--compatibility-evidence", + usesRepositoryBaseline + ? "config/contracts/registry-change-evidence.json" + : undefined, +); +const governance = JSON.parse( + await readFile(governancePath, "utf8"), +) as RegistryGovernance; +const failures: string[] = []; +const owners = new Map(); +const snapshots: RegistrySnapshot[] = []; +const rowsByRegistry = new Map(); +const sourcesByRegistry = new Map(); +const registryExtensions = [".ts", ".tsx", ".mts", ".cts"]; + +async function resolveRegistrySource( + declaredPath: string, +): Promise { + const extension = path.extname(declaredPath); + const basePath = extension + ? declaredPath.slice(0, -extension.length) + : declaredPath; + const candidates: string[] = []; + for (const candidateExtension of registryExtensions) { + const candidate = `${basePath}${candidateExtension}`; + try { + await access(candidate); + candidates.push(candidate); + } catch { + // Continue through the supported TypeScript source extensions. + } + } + if (candidates.length > 1) { + failures.push( + `ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`, + ); + return null; + } + return candidates[0] ?? null; +} + +function runtimeType(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (Number.isInteger(value)) return "integer"; + return typeof value; +} + +function matchesDeclaredType(value: unknown, declaration: string): boolean { + const actual = runtimeType(value); + return declaration + .split("|") + .some( + (candidate) => + candidate === actual || + (candidate === "number" && actual === "integer"), + ); +} + +type FieldLookup = Readonly<{ + found: boolean; + value: unknown; +}>; + +function lookupField(value: unknown, fieldPath: string): FieldLookup { + let current = value; + for (const segment of fieldPath.split(".")) { + if ( + !current || + typeof current !== "object" || + Array.isArray(current) || + !Object.hasOwn(current, segment) + ) { + return Object.freeze({ found: false, value: undefined }); + } + current = (current as Record)[segment]; + } + return Object.freeze({ found: true, value: current }); +} + +function projectRegistryRows( + specification: RegistrySpecification, + exportedValue: unknown, +): RegistryRows | null { + const selected = specification.rowsPath + ? lookupField(exportedValue, specification.rowsPath) + : Object.freeze({ found: true, value: exportedValue }); + if (!selected.found) { + failures.push( + `${specification.registryId} is missing rows path ${specification.rowsPath}`, + ); + return null; + } + if (!Array.isArray(selected.value)) { + if ( + !selected.value || + typeof selected.value !== "object" + ) { + failures.push(`${specification.registryId} is not an object registry`); + return null; + } + return selected.value as RegistryRows; + } + + const projected: RegistryRows = {}; + const keyOccurrences = new Map(); + for (const [index, row] of selected.value.entries()) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + projected[String(index)] = row as RegistryRow; + continue; + } + const keyValues = (specification.rowKeyFields ?? []).map( + (field) => lookupField(row, field).value, + ); + const baseKey = + keyValues.length > 0 + ? JSON.stringify(keyValues.map(canonicalizeRegistryValue)) + : String(index); + const occurrence = keyOccurrences.get(baseKey) ?? 0; + keyOccurrences.set(baseKey, occurrence + 1); + projected[occurrence === 0 ? baseKey : `${baseKey}#${occurrence + 1}`] = + row as RegistryRow; + } + return projected; +} + +function snapshotKeyField( + value: unknown, + fieldPath: string, +): FieldLookup { + return fieldPath === "$value" + ? Object.freeze({ found: true, value }) + : lookupField(value, fieldPath); +} + +function replaceProjectedField( + row: RegistryRow, + fieldPath: string, + value: unknown, +): boolean { + const segments = fieldPath.split("."); + const finalSegment = segments.pop(); + if (!finalSegment) return false; + let current = row; + for (const segment of segments) { + const next = current[segment]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + return false; + } + current = next as RegistryRow; + } + current[finalSegment] = value; + return true; +} + +function projectSnapshotRows( + specification: RegistrySpecification, + exportedValue: unknown, + validatedRows: RegistryRows, +): RegistryRows | null { + const projection = specification.snapshotProjection; + if (!projection) return validatedRows; + if ( + !exportedValue || + typeof exportedValue !== "object" || + Array.isArray(exportedValue) + ) { + failures.push( + `${specification.registryId} snapshot projection requires an object export`, + ); + return null; + } + + const canonicalRow = canonicalizeRegistryValue(exportedValue) as RegistryRow; + for (const [fieldPath, keyFields] of Object.entries( + projection.canonicalArrayKeyFields, + )) { + const selected = lookupField(exportedValue, fieldPath); + if (!selected.found || !Array.isArray(selected.value)) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath} must be an array`, + ); + return null; + } + const keyedItems: Array<{ identity: string; value: unknown }> = []; + const identities = new Set(); + for (const [index, item] of selected.value.entries()) { + const keyValues = keyFields.map((field) => snapshotKeyField(item, field)); + const missingIndex = keyValues.findIndex((field) => !field.found); + if (missingIndex >= 0) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath}[${index}] is missing key ${keyFields[missingIndex]}`, + ); + return null; + } + const identity = JSON.stringify( + keyValues.map((field) => canonicalizeRegistryValue(field.value)), + ); + if (identities.has(identity)) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath} duplicates key ${identity}`, + ); + return null; + } + identities.add(identity); + keyedItems.push({ + identity, + value: canonicalizeRegistryValue(item), + }); + } + keyedItems.sort((left, right) => + left.identity.localeCompare(right.identity), + ); + if ( + !replaceProjectedField( + canonicalRow, + fieldPath, + keyedItems.map((item) => item.value), + ) + ) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath} cannot be projected`, + ); + return null; + } + } + return Object.freeze({ + [projection.singletonRowKey]: canonicalRow, + }); +} + +async function filesBelow(directory: string): Promise { + try { + const entries = await readdir(directory, { withFileTypes: true }); + const groups = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesBelow(target) : [target]; + }), + ); + return groups.flat().filter((file) => + /\.(?:ts|tsx|mts|cts)$/.test(file), + ); + } catch { + return []; + } +} + +for (const specification of governance.registries) { + if (owners.has(specification.registryId)) { + failures.push(`duplicate owner for ${specification.registryId}`); + } + owners.set(specification.registryId, specification.owner); + + let rows = specification.declaredRows; + const sourcePath = await resolveRegistrySource(specification.path); + try { + if (!sourcePath) throw new Error("missing registry source"); + const registryModule = (await import( + `${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}` + )) as Record; + rows = registryModule[specification.exportName]; + } catch { + if (!rows) failures.push(`missing registry source ${specification.path}`); + } + + const registryRows = projectRegistryRows(specification, rows); + if (!registryRows) { + continue; + } + rowsByRegistry.set(specification.registryId, registryRows); + sourcesByRegistry.set( + specification.registryId, + sourcePath ?? specification.path, + ); + + for (const [rowName, row] of Object.entries(registryRows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + failures.push(`${specification.registryId}.${rowName} is not an object`); + continue; + } + for (const field of specification.requiredFields) { + if (!lookupField(row, field).found) { + failures.push(`${specification.registryId}.${rowName} missing ${field}`); + } + } + for (const [field, declaredType] of Object.entries( + specification.fieldTypes ?? {}, + )) { + if ( + lookupField(row, field).found && + !matchesDeclaredType( + lookupField(row, field).value, + String(declaredType), + ) + ) { + failures.push( + `${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(lookupField(row, field).value)}`, + ); + } + } + if ( + specification.keyField && + lookupField(row, specification.keyField).value !== rowName + ) { + failures.push( + `${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`, + ); + } + } + + for (const field of specification.uniqueFields ?? []) { + const values = new Map(); + for (const [rowName, row] of Object.entries(registryRows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const selected = lookupField(row, field); + if (!selected.found) continue; + const value = selected.value; + const identity = JSON.stringify(canonicalizeRegistryValue(value)); + if (values.has(identity)) { + failures.push( + `${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(identity)}`, + ); + } else { + values.set(identity, rowName); + } + } + } + + for (const fields of specification.uniqueFieldSets ?? []) { + const values = new Map(); + for (const [rowName, row] of Object.entries(registryRows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const selected = fields.map((field) => lookupField(row, field)); + if (selected.some((field) => !field.found)) continue; + const identity = JSON.stringify( + selected.map((field) => canonicalizeRegistryValue(field.value)), + ); + const previous = values.get(identity); + if (previous) { + failures.push( + `${specification.registryId}.${rowName} duplicates ${fields.join("+")}=${identity} from ${previous}`, + ); + } else { + values.set(identity, rowName); + } + } + } + + for (const [field, allowed] of Object.entries( + specification.allowedValues ?? {}, + )) { + for (const [rowName, row] of Object.entries(registryRows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + if ( + !allowed.some((value) => + Object.is(value, lookupField(row, field).value), + ) + ) { + failures.push( + `${specification.registryId}.${rowName}.${field} has unknown value ${String(lookupField(row, field).value)}`, + ); + } + } + } + + const contract = Object.freeze({ + requiredFields: specification.requiredFields, + fieldTypes: specification.fieldTypes ?? {}, + uniqueFields: specification.uniqueFields ?? [], + allowedValues: specification.allowedValues ?? {}, + references: specification.references ?? [], + keyField: specification.keyField ?? null, + breakingFields: specification.breakingFields ?? [], + ...(specification.rowsPath + ? { rowsPath: specification.rowsPath } + : {}), + ...(specification.rowKeyFields + ? { rowKeyFields: specification.rowKeyFields } + : {}), + ...(specification.uniqueFieldSets + ? { uniqueFieldSets: specification.uniqueFieldSets } + : {}), + ...(specification.snapshotProjection + ? { snapshotProjection: specification.snapshotProjection } + : {}), + }); + const snapshotRows = projectSnapshotRows(specification, rows, registryRows); + if (!snapshotRows) continue; + snapshots.push({ + registryId: specification.registryId, + owner: specification.owner, + source: sourcePath ?? specification.path, + rowCount: Object.keys(snapshotRows).length, + contract, + rows: canonicalizeRegistryValue(snapshotRows), + }); +} + +for (const specification of governance.registries) { + const rows = rowsByRegistry.get(specification.registryId); + if (!rows) continue; + for (const reference of specification.references ?? []) { + const targetRows = rowsByRegistry.get(reference.registryId); + if (!targetRows) { + failures.push( + `${specification.registryId} references unknown registry ${reference.registryId}`, + ); + continue; + } + const targetValues = new Set( + Object.values(targetRows) + .filter((row) => row && typeof row === "object" && !Array.isArray(row)) + .map((row) => lookupField(row, reference.targetField).value) + .filter((value) => value !== undefined && value !== null), + ); + for (const [rowName, row] of Object.entries(rows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const value = lookupField(row, reference.field).value; + if ( + value !== undefined && + value !== null && + !targetValues.has(value) + ) { + failures.push( + `${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`, + ); + } + } + } + + for (const consumer of specification.consumers ?? []) { + try { + const source = await readFile(consumer.path, "utf8"); + if (!source.includes(consumer.token)) { + failures.push( + `${specification.registryId} consumer ${consumer.path} is missing ${consumer.token}`, + ); + } + } catch { + failures.push( + `${specification.registryId} consumer source is missing: ${consumer.path}`, + ); + } + } + + if (specification.consumerIdentityField) { + const consumerFiles = ( + await Promise.all( + (specification.consumerDirectories ?? []).map(filesBelow), + ) + ).flat(); + const sourcePath = sourcesByRegistry.get(specification.registryId); + const consumerText = ( + await Promise.all( + consumerFiles + .filter((file) => file !== sourcePath) + .map((file) => readFile(file, "utf8")), + ) + ).join("\n"); + const exemptions = new Set(specification.orphanExemptRows ?? []); + for (const [rowName, row] of Object.entries(rows)) { + if ( + !row || + typeof row !== "object" || + Array.isArray(row) || + exemptions.has(rowName) + ) { + continue; + } + const identity = lookupField( + row, + specification.consumerIdentityField, + ).value; + if ( + (typeof identity !== "string" && + typeof identity !== "number") || + !consumerText.includes(String(identity)) + ) { + failures.push( + `${specification.registryId}.${rowName} has no executable consumer for ${specification.consumerIdentityField}=${String(identity)}`, + ); + } + } + } +} + +const sourceFiles = governance.sourceDirectories ?? [ + "src/application", + "src/presentation", + "src/domain", +]; +const adHocPatterns = [ + { name: "direct fetch", expression: /\bfetch\s*\(/ }, + { + name: "direct localStorage", + expression: /\blocalStorage\.(?:get|set|remove)Item/, + }, + { name: "direct import.meta.env", expression: /\bimport\.meta\.env\./ }, + { name: "raw API path", expression: /["']\/api\// }, +]; + +for (const sourceDirectory of sourceFiles) { + for (const file of await filesBelow(sourceDirectory)) { + const content = await readFile(file, "utf8"); + for (const pattern of adHocPatterns) { + if (pattern.expression.test(content)) { + failures.push(`ad-hoc ${pattern.name} in ${file}`); + } + } + } +} + +const currentSnapshot = canonicalizeRegistryValue({ + schemaVersion: 2, + registries: snapshots, +}) as Readonly>; +let baselineDigest: string | null = null; +const currentDigest = registrySnapshotDigest(currentSnapshot); +let compatibility: CompatibilitySummary = { + impact: "not-evaluated", + changes: [], +}; + +if (baselinePath && approvalPath && evidencePath) { + try { + const baseline = JSON.parse( + await readFile(baselinePath, "utf8"), + ) as Record; + const approval = JSON.parse( + await readFile(approvalPath, "utf8"), + ) as Record; + const approvalResult = verifyRegistryBaselineApproval(baseline, approval); + baselineDigest = approvalResult.actualDigest; + if (!approvalResult.passed) { + failures.push( + `registry baseline approval digest mismatch: approved=${approvalResult.approvedDigest} actual=${approvalResult.actualDigest}`, + ); + } + const registryDiff = diffRegistrySnapshots(baseline, currentSnapshot); + compatibility = registryDiff; + const evidence = JSON.parse( + await readFile(evidencePath, "utf8"), + ) as Record; + const evidenceResult = validateBreakingEvidence(registryDiff, evidence); + failures.push(...evidenceResult.failures); + } catch (error) { + failures.push( + `registry compatibility evidence unavailable: ${ + error instanceof Error ? error.name : "unknown" + }`, + ); + } +} + +const report = { + schemaVersion: 2, + generatedAt: new Date().toISOString(), + baselineDigest, + currentDigest, + compatibility, + failures, + registries: snapshots, +}; +if (usesRepositoryBaseline && failures.length === 0) { + try { + assertMatchesJsonSchema( + JSON.parse( + await readFile( + "schemas/artifacts/registry-snapshot.schema.json", + "utf8", + ), + ), + report, + "registry snapshot", + ); + } catch { + failures.push("registry snapshot JSON Schema mismatch"); + } +} +await mkdir(path.dirname(artifactPath), { recursive: true }); +await writeValidatedJsonArtifact({ + path: artifactPath, + schema: usesRepositoryGovernance + ? registrySnapshotArtifactSchema + : registryGovernanceRunArtifactSchema, + value: report, +}); + +if (failures.length > 0) { + process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`); + process.exit(1); +} +process.stdout.write( + `Registry governance: ${snapshots.length} registries PASS; compatibility=${compatibility.impact}\n`, +); diff --git a/scripts/check-registry-compatibility-fixtures.ts b/scripts/check-registry-compatibility-fixtures.ts new file mode 100644 index 0000000..4c78853 --- /dev/null +++ b/scripts/check-registry-compatibility-fixtures.ts @@ -0,0 +1,66 @@ +import { mkdir, readFile } from "node:fs/promises"; + +import { registryCompatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts"; +import { + diffRegistrySnapshots, + validateBreakingEvidence, + verifyRegistryBaselineApproval, +} from "./lib/registry-compatibility.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const fixtures = JSON.parse( + await readFile( + "tests/fixtures/registry/compatibility/semantic-diff.json", + "utf8", + ), +); +const results = []; +for (const fixture of fixtures.cases) { + const actual = diffRegistrySnapshots(fixture.before, fixture.after); + results.push({ + id: fixture.id, + expected: fixture.expected, + actual: actual.impact, + passed: actual.impact === fixture.expected, + }); +} + +const breaking = diffRegistrySnapshots( + fixtures.breakingEvidence.before, + fixtures.breakingEvidence.after, +); +const missingEvidence = validateBreakingEvidence(breaking, { + schemaVersion: 1, + changes: [], +}); +results.push({ + id: "breaking-evidence-required", + expected: false, + actual: missingEvidence.passed, + passed: !missingEvidence.passed, +}); + +const tamperedApproval = verifyRegistryBaselineApproval( + fixtures.tamperedApproval.snapshot, + fixtures.tamperedApproval.approval, +); +results.push({ + id: "tampered-baseline-digest", + expected: false, + actual: tamperedApproval.passed, + passed: !tamperedApproval.passed, +}); + +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/quality/registry-compatibility-fixtures.json", + schema: registryCompatibilityFixturesArtifactSchema, + value: { schemaVersion: 1, results }, +}); +if (results.some((result) => !result.passed)) { + process.stderr.write("Registry compatibility fixture failed.\n"); + process.exit(1); +} +process.stdout.write( + `Registry compatibility fixtures: ${results.length} PASS\n`, +); diff --git a/scripts/check-risk-coverage.ts b/scripts/check-risk-coverage.ts new file mode 100644 index 0000000..2575f18 --- /dev/null +++ b/scripts/check-risk-coverage.ts @@ -0,0 +1,78 @@ +import path from "node:path"; + +import { + readRiskCoverageInput, + writeRiskCoverageArtifactAtomic, +} from "./lib/risk-coverage-files.ts"; +import { + buildProductionModuleInventory, + evaluateRiskCoverage, + parseRepositoryRiskCoveragePolicy, +} from "./lib/risk-coverage.ts"; + +function argumentValue(name: string, fallback?: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +function requiredArgument(name: string, fallback: string): string { + const value = argumentValue(name, fallback); + if (!value) throw new TypeError(`${name} requires a path`); + return value; +} + +const repositoryRoot = path.resolve( + requiredArgument("--repository-root", process.cwd()), +); +const policyInput = await readRiskCoverageInput({ + repositoryRoot, + relativePath: requiredArgument( + "--policy", + "config/testing/risk-coverage.json", + ), + label: "policy", +}); +const summaryInput = await readRiskCoverageInput({ + repositoryRoot, + relativePath: requiredArgument( + "--summary", + "artifacts/tests/coverage/coverage-summary.json", + ), + label: "summary", +}); +const artifactPath = requiredArgument( + "--artifact", + "artifacts/quality/risk-coverage.json", +); +const policy = parseRepositoryRiskCoveragePolicy( + JSON.parse(policyInput.text) as unknown, +); +const inventory = await buildProductionModuleInventory({ + repositoryRoot, + generatedPaths: policy.generatedPaths, +}); +const result = evaluateRiskCoverage({ + repositoryRoot, + inventory, + policy, + summary: JSON.parse(summaryInput.text) as unknown, +}); +await writeRiskCoverageArtifactAtomic({ + repositoryRoot, + relativePath: artifactPath, + inputPaths: [policyInput.relativePath, summaryInput.relativePath], + value: { + schemaVersion: 3, + policy: policyInput.relativePath, + summary: summaryInput.relativePath, + ...result, + }, +}); + +if (result.failures.length > 0) { + process.stderr.write(`Risk coverage failed:\n- ${result.failures.join("\n- ")}\n`); + process.exit(1); +} +process.stdout.write( + `Risk coverage: PASS (${result.selectedTotal}/${result.repositoryTotal} production modules, ${result.results.length} thresholds)\n`, +); diff --git a/scripts/check-security-fixtures.ts b/scripts/check-security-fixtures.ts new file mode 100644 index 0000000..f46417e --- /dev/null +++ b/scripts/check-security-fixtures.ts @@ -0,0 +1,10 @@ +import { + checkNonmatchingSecurityIncludeFixture, + checkSecurityFixtures, +} from "./lib/security-fixture-check.ts"; + +await checkNonmatchingSecurityIncludeFixture(); +await checkSecurityFixtures(); +process.stdout.write( + "Security fixtures: unmatched include rejected; 3 forbidden files detected\n", +); diff --git a/scripts/check-supply-chain-fixtures.ts b/scripts/check-supply-chain-fixtures.ts new file mode 100644 index 0000000..55d46cf --- /dev/null +++ b/scripts/check-supply-chain-fixtures.ts @@ -0,0 +1,151 @@ +import { mkdir } from "node:fs/promises"; + +import { supplyChainFixturesArtifactSchema } from "./contracts/release-artifacts.ts"; +import { + diffDependencyInventories, + isValidSha512Integrity, + supplyChainDigest, + validateDependencyReview, + validateLicensePolicy, + validateVulnerabilityReport, + verifySupplyChainCoherence, +} from "./lib/supply-chain.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`; +const baseDependency = { + name: "base", + version: "1.0.0", + direct: false, + scope: "production", + optional: false, + license: "MIT", + integrity, + dependencies: [], +}; +const directDependency = { + ...baseDependency, + name: "new-direct", + direct: true, +}; +const before = { dependencies: [baseDependency] }; +const after = { dependencies: [baseDependency, directDependency] }; +const diff = diffDependencyInventories(before, after); +const selfReview = validateDependencyReview(diff, after, { + changes: [ + { + changeId: "add:new-direct@1.0.0", + owner: "same-person", + reviewer: "same-person", + reason: "fixture", + rollback: "remove", + }, + ], +}); +const deniedLicense = validateLicensePolicy( + { + dependencies: [{ ...baseDependency, license: "AGPL-3.0" }], + }, + { + allowedLicenses: ["MIT"], + deniedLicensePatterns: ["AGPL"], + }, +); +const vulnerable = validateVulnerabilityReport( + { + provider: "fixture", + scannedLockfileSha256: "lock", + findings: [ + { + id: "CVE-FIXTURE", + packageName: "base", + version: "1.0.0", + severity: "critical", + }, + ], + }, + { blockAtSeverity: "high" }, + { + exceptions: [ + { + vulnerabilityId: "CVE-FIXTURE", + packageName: "base", + owner: "owner", + reviewer: "reviewer", + reason: "expired fixture", + expiresAt: "2000-01-01T00:00:00.000Z", + }, + ], + }, + "lock", + new Date("2026-07-26T00:00:00.000Z"), +); +const mismatchedCoherence = verifySupplyChainCoherence( + { + components: [], + metadata: { + properties: [{ name: "ca:lockfileSha256", value: "wrong" }], + }, + }, + { dependencies: [baseDependency], lockfileSha256: "lock" }, + { + subject: [{ digest: { sha256: "wrong" } }], + predicate: { materials: { lockfileSha256: "wrong" } }, + }, + "dist", +); +const orderingStable = + supplyChainDigest({ dependencies: [baseDependency, directDependency] }) === + supplyChainDigest({ dependencies: [directDependency, baseDependency] }); +const approvedDigest = supplyChainDigest(before); +const tamperedBaselineRejected = + approvedDigest !== + supplyChainDigest({ + dependencies: [{ ...baseDependency, version: "9.9.9-tampered" }], + }); +const providerFailure = validateVulnerabilityReport( + { + provider: "", + scannedLockfileSha256: "wrong", + findings: [], + }, + { blockAtSeverity: "high" }, + { exceptions: [] }, + "lock", +); +const results = [ + { + id: "transitive-removal-is-real-diff", + passed: + diffDependencyInventories(after, before).removed[0] === + "new-direct@1.0.0", + }, + { + id: "tampered-integrity-rejected", + passed: !isValidSha512Integrity("sha512-dGFtcGVyZWQ="), + }, + { id: "high-risk-self-approval-rejected", passed: !selfReview.passed }, + { id: "denied-license-rejected", passed: !deniedLicense.passed }, + { + id: "critical-vulnerability-expired-exception-rejected", + passed: !vulnerable.passed, + }, + { id: "sbom-provenance-mismatch-rejected", passed: !mismatchedCoherence.passed }, + { id: "dependency-ordering-deterministic", passed: orderingStable }, + { id: "baseline-digest-tamper-rejected", passed: tamperedBaselineRejected }, + { + id: "vulnerability-provider-evidence-invalid", + passed: !providerFailure.passed, + }, +]; +await mkdir("artifacts/security", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/security/supply-chain-fixtures.json", + schema: supplyChainFixturesArtifactSchema, + value: { schemaVersion: 1, results }, +}); +if (results.some((result) => !result.passed)) { + process.stderr.write("Supply-chain negative fixture failed.\n"); + process.exit(1); +} +process.stdout.write(`Supply-chain fixtures: ${results.length} PASS\n`); diff --git a/scripts/check-supply-chain-provider-fixtures.ts b/scripts/check-supply-chain-provider-fixtures.ts new file mode 100644 index 0000000..04e8f8f --- /dev/null +++ b/scripts/check-supply-chain-provider-fixtures.ts @@ -0,0 +1,375 @@ +import { spawnSync } from "node:child_process"; +import { + createHash, + generateKeyPairSync, + sign, + type KeyObject, +} from "node:crypto"; +import { + cp, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + localEvidenceAssessmentArtifactSchema, + supplyChainProviderFixturesArtifactSchema, +} from "./contracts/release-artifacts.ts"; +import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts"; +import { + captureCiCandidateArchive, +} from "./lib/ci-candidate-archive.ts"; +import { + cleanupFinalizedPromotion, + finalizeVerifiedPromotion, +} from "./lib/promotion-stager.ts"; +import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts"; +import { + providerEvidenceSignaturePayload, + providerPublicKeyFingerprint, +} from "./lib/provider-evidence.ts"; +import { + LOCAL_EVIDENCE_ASSESSMENT_PATH, + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + releaseCandidateManifestSchema, +} from "./lib/release-candidate.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const repositoryRoot = process.cwd(); +const fixtureRoot = await mkdtemp(path.join(tmpdir(), "provider-exact-five-fixture-")); +try { + await cp(repositoryRoot, fixtureRoot, { + recursive: true, + filter: (source) => { + const relative = path.relative(repositoryRoot, source); + if (!relative) return true; + const first = relative.split(path.sep)[0]; + return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? ""); + }, + }); + await cp(path.join(repositoryRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), { + recursive: true, + }); + await rm(path.join(fixtureRoot, "artifacts/release"), { recursive: true, force: true }); + await symlink(path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); + const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], { + cwd: repositoryRoot, + encoding: "utf8", + }); + if (git.status !== 0) throw new Error(`provider fixture git identity failed: ${git.stderr}`); + const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u); + if (!revision || !sourceDateEpoch) throw new Error("provider fixture git identity is incomplete"); + const build = spawnSync("corepack", ["pnpm", "build:release-candidate"], { + cwd: fixtureRoot, + encoding: "utf8", + timeout: 120_000, + maxBuffer: 32 * 1024 * 1024, + env: { + ...process.env, + CI: "true", + VITE_BUILD_ID: "provider-exact-five-fixture", + VITE_COMMIT_SHA: revision, + RELEASE_ID: "provider-exact-five-fixture", + SOURCE_DATE_EPOCH: sourceDateEpoch, + CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`, + }, + }); + if (build.status !== 0) throw new Error(`${build.stdout}\n${build.stderr}`); + const manifest = releaseCandidateManifestSchema.parse( + JSON.parse( + await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"), + ) as unknown, + ); + const assessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse( + await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"), + ) as unknown, + ); + const archivePath = path.join(fixtureRoot, "candidate.tar.gz"); + const tar = spawnSync( + "/usr/bin/tar", + [ + "--sort=name", + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "-czf", + archivePath, + "dist", + ...RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + ], + { cwd: fixtureRoot, encoding: "utf8" }, + ); + if (tar.status !== 0) throw new Error(`provider fixture real tar failed: ${tar.stderr}`); + const archiveBytes = await readFile(archivePath); + const archiveSha256 = sha256(archiveBytes); + const now = Date.parse("2026-08-02T01:00:00.000Z"); + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const vulnerabilityKeyId = "fixture-vulnerability-key"; + const provenanceKeyId = "fixture-provenance-key"; + const vulnerabilityNonce = "1".repeat(64); + const provenanceNonce = "2".repeat(64); + const context = { + run: { id: "fixture-run", attempt: 1 }, + source: { + revision: assessment.source.revision, + sourceSetSha256: assessment.source.sourceSetSha256, + }, + candidate: { + archiveSha256, + bundleSha256: manifest.bundleSha256, + distSha256: manifest.distSha256, + lockfileSha256: manifest.lockfileSha256, + }, + } as const; + const vulnerability = signedEvidence( + { + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "fixture-vulnerability-provider", + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(), + run: { ...context.run, invocationNonce: vulnerabilityNonce }, + source: context.source, + candidate: context.candidate, + secretScanAttestation: { + status: "PASS", + localEvidenceAssessmentSha256: sha256( + await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)), + ), + sourceSetSha256: assessment.source.sourceSetSha256, + policySha256: assessment.secretScan.policySha256, + sarifSha256: assessment.secretScan.sarifSha256, + scanInputSha256: assessment.secretScan.scanInputSha256, + }, + findings: [], + }, + vulnerabilityKeyId, + vulnerabilityKeys.publicKey, + vulnerabilityKeys.privateKey, + ); + const provenance = signedEvidence( + { + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "fixture-provenance-provider", + signer: "fixture-workload-identity", + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(), + run: { ...context.run, invocationNonce: provenanceNonce }, + source: context.source, + candidate: context.candidate, + subject: { name: "dist", digest: { sha256: manifest.distSha256 } }, + }, + provenanceKeyId, + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + await Promise.all([ + write(fixtureRoot, "provider/vulnerability.json", `${JSON.stringify(vulnerability)}\n`), + write(fixtureRoot, "provider/provenance.json", `${JSON.stringify(provenance)}\n`), + write( + fixtureRoot, + "provider/vulnerability.pem", + vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }), + ), + write( + fixtureRoot, + "provider/provenance.pem", + provenanceKeys.publicKey.export({ type: "spki", format: "pem" }), + ), + ]); + const runnerTempRoot = path.join(fixtureRoot, "runner-temp"); + await mkdir(runnerTempRoot, { mode: 0o700 }); + const finalized = await finalizeVerifiedPromotion( + { + repositoryRoot: fixtureRoot, + archivePath, + expectedArchiveSha256: archiveSha256, + vulnerabilityReportPath: "provider/vulnerability.json", + provenanceAttestationPath: "provider/provenance.json", + vulnerabilityPublicKeyPath: "provider/vulnerability.pem", + vulnerabilityKeyId, + provenancePublicKeyPath: "provider/provenance.pem", + provenanceKeyId, + expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision }, + vulnerabilityInvocationNonce: vulnerabilityNonce, + provenanceInvocationNonce: provenanceNonce, + runnerTempRoot, + }, + { + nowEpochMs: () => now, + randomBytes: (bytes) => Buffer.alloc(bytes, 0x4a), + afterCapture: async () => { + await writeFile(path.join(fixtureRoot, "dist/index.html"), "contradictory external tree\n"); + }, + }, + ); + const validImmutable = + finalized.files.length === 5 && + (await readdir(finalized.stagingRoot)).length === 5; + const exactFiles = Object.fromEntries( + await Promise.all( + PROMOTED_FILE_NAMES.map(async (name) => [ + name, + await readFile(path.join(finalized.stagingRoot, name)), + ] as const), + ), + ); + const bundleTrust = { + vulnerabilityTrust: { + keyId: vulnerabilityKeyId, + publicKey: vulnerabilityKeys.publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey), + }, + provenanceTrust: { + keyId: provenanceKeyId, + publicKey: provenanceKeys.publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey), + }, + expected: { + run: context.run, + sourceRevision: context.source.revision, + sourceSetSha256: context.source.sourceSetSha256, + archiveSha256: context.candidate.archiveSha256, + bundleSha256: context.candidate.bundleSha256, + distSha256: context.candidate.distSha256, + lockfileSha256: context.candidate.lockfileSha256, + }, + nowEpochMs: () => now, + } as const; + const absentFiles = { ...exactFiles } as Partial; + delete absentFiles["provider-verification.json"]; + const absent = await captureRejection(async () => { + await verifyExactPromotionBundle(absentFiles, bundleTrust); + }); + + const invalidPath = path.join(fixtureRoot, "invalid.tar.gz"); + const invalidBytes = Buffer.from("arbitrary non-tar bytes\n"); + await writeFile(invalidPath, invalidBytes); + const invalidTar = await captureRejection(async () => { + await finalizeVerifiedPromotion( + { + repositoryRoot: fixtureRoot, + archivePath: invalidPath, + expectedArchiveSha256: sha256(invalidBytes), + vulnerabilityReportPath: "provider/vulnerability.json", + provenanceAttestationPath: "provider/provenance.json", + vulnerabilityPublicKeyPath: "provider/vulnerability.pem", + vulnerabilityKeyId, + provenancePublicKeyPath: "provider/provenance.pem", + provenanceKeyId, + expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision }, + vulnerabilityInvocationNonce: vulnerabilityNonce, + provenanceInvocationNonce: provenanceNonce, + runnerTempRoot, + }, + { nowEpochMs: () => now }, + ); + }); + const wrongDigest = await captureRejection(async () => { + await captureCiCandidateArchive({ archivePath, expectedSha256: "0".repeat(64) }); + }); + const passed = + validImmutable && absent.rejected && invalidTar.rejected && wrongDigest.rejected; + await cleanupFinalizedPromotion({ + runnerTempRoot, + stagingRoot: finalized.stagingRoot, + cleanupToken: finalized.cleanupToken, + runnerTempIdentity: finalized.runnerTempIdentity, + stagingIdentity: finalized.stagingIdentity, + }); + await mkdir(path.join(repositoryRoot, "artifacts/security"), { recursive: true }); + await writeValidatedJsonArtifact({ + path: path.join(repositoryRoot, "artifacts/security/supply-chain-provider-fixtures.json"), + schema: supplyChainProviderFixturesArtifactSchema, + value: { + schemaVersion: 1, + actualDefaultVerifier: { + status: validImmutable ? "PASS" : "FAIL_UNVERIFIED", + failures: validImmutable ? [] : ["canonical exact-five finalizer failed"], + }, + fixtures: { + absent: { + status: absent.rejected ? "FAIL_UNVERIFIED" : "PASS", + failures: [absent.failure], + }, + validImmutable: { status: validImmutable ? "PASS" : "FAIL_UNVERIFIED", failures: [] }, + wrongDigest: { + status: wrongDigest.rejected ? "FAIL_UNVERIFIED" : "PASS", + failures: [wrongDigest.failure], + }, + invalidTar: { + status: invalidTar.rejected ? "FAIL_UNVERIFIED" : "PASS", + failures: [invalidTar.failure], + }, + }, + externalTreeCanary: { + status: validImmutable ? "PASS" : "FAIL_UNVERIFIED", + failures: validImmutable ? [] : ["external tree canary influenced captured archive"], + }, + passingFixtureCount: validImmutable ? 1 : 0, + status: passed ? "PASS" : "FAIL", + }, + }); + if (!passed) throw new Error("canonical exact-five provider fixtures failed closed incorrectly"); + process.stdout.write("Supply-chain provider real-tar signed exact-five fixtures: PASS\n"); +} finally { + await rm(fixtureRoot, { recursive: true, force: true }); +} + +function signedEvidence( + value: Record, + keyId: string, + publicKey: KeyObject, + privateKey: KeyObject, +) { + return { + ...value, + signature: { + algorithm: "Ed25519", + keyId, + publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), + value: sign( + null, + providerEvidenceSignaturePayload(value), + privateKey, + ).toString("base64"), + }, + }; +} + +async function write(root: string, relative: string, value: string | Buffer): Promise { + const absolute = path.join(root, relative); + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, value); +} + +async function captureRejection( + operation: () => Promise, +): Promise> { + try { + await operation(); + return Object.freeze({ rejected: false, failure: "fixture unexpectedly passed" }); + } catch (error) { + return Object.freeze({ + rejected: true, + failure: error instanceof Error ? error.message : String(error), + }); + } +} + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/scripts/check-test-evidence.ts b/scripts/check-test-evidence.ts new file mode 100644 index 0000000..9158bde --- /dev/null +++ b/scripts/check-test-evidence.ts @@ -0,0 +1,450 @@ +import { mkdir, readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { z } from "zod"; + +import { + computeHttpScenarioCatalogDigest, + httpScenarioExpectationSchema, + httpScenarioReceiptSchema, + sameScenarioJson, + type HttpScenarioExpectation, + type HttpScenarioReceipt, +} from "./lib/http-scenario-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { testEvidenceReportSchema } from "./lib/test-evidence-artifact.ts"; + +const scenarioContributionSchema = z + .object({ + owner: z.string().min(1), + path: z.string().min(1), + expectationExport: z.string().min(1), + receiptPath: z.string().min(1), + receiptSchemaVersion: z.number().int().positive(), + }) + .strict(); + +const sourceContractSchema = z + .object({ + owner: z.string().min(1), + path: z.string().min(1), + requiredTokens: z.array(z.string().min(1)), + }) + .strict(); + +const policySchema = z + .object({ + schemaVersion: z.literal(2), + scenarioCatalogs: z.array(scenarioContributionSchema), + sourceContracts: z.array(sourceContractSchema), + }) + .strict(); + +function argumentValue(name: string, fallback: string): string { + const index = process.argv.indexOf(name); + return index >= 0 && process.argv[index + 1] + ? process.argv[index + 1] + : fallback; +} + +function optionalArgumentValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const sourceRoot = argumentValue("--source-root", "tests"); +const artifactPath = argumentValue( + "--artifact", + "artifacts/quality/test-evidence.json", +); +const policyPath = argumentValue( + "--policy", + "config/testing/test-evidence.json", +); +const explicitCatalogPath = optionalArgumentValue("--catalog"); +const explicitReceiptPath = optionalArgumentValue("--receipt"); +const fixtureMode = sourceRoot !== "tests"; +const sourceOnly = process.argv.includes("--source-only"); +const scenarioOnly = process.argv.includes("--scenario-only"); +const skipScenarioExecutions = process.argv.includes( + "--skip-scenario-executions", +); +const failures: string[] = []; +const facts = { + scannedFiles: 0, + visualBaselines: 0, + sharedScenarios: 0, + declaredScenarioExecutions: 0, + executedScenarioExecutions: 0, +}; + +async function filesBelow(target: string): Promise { + try { + const metadata = await stat(target); + if (metadata.isFile()) return [target]; + const entries = await readdir(target, { withFileTypes: true }); + const groups = await Promise.all( + entries.map((entry) => filesBelow(path.join(target, entry.name))), + ); + return groups.flat(); + } catch { + return []; + } +} + +function duplicates(values: readonly string[]): string[] { + const seen = new Set(); + const duplicate = new Set(); + for (const value of values) { + if (seen.has(value)) duplicate.add(value); + seen.add(value); + } + return [...duplicate].sort(); +} + +function isSorted(values: readonly string[]): boolean { + return values.every( + (value, index) => index === 0 || values[index - 1]!.localeCompare(value) <= 0, + ); +} + +async function readJson(target: string): Promise { + return JSON.parse(await readFile(target, "utf8")); +} + +async function loadCatalogExport( + target: string, + exportName: string, +): Promise { + if (target.endsWith(".json")) { + const document = await readJson(target); + return (document as Readonly>)[exportName]; + } + const module = (await import( + `${pathToFileURL(path.resolve(target)).href}?test-evidence=${Date.now()}` + )) as Readonly>; + return module[exportName]; +} + +async function checkScenarioContribution( + contribution: z.output, +): Promise { + const catalogPath = explicitCatalogPath ?? contribution.path; + const receiptPath = explicitReceiptPath ?? contribution.receiptPath; + let expectations: readonly HttpScenarioExpectation[]; + try { + expectations = z + .array(httpScenarioExpectationSchema) + .parse( + await loadCatalogExport(catalogPath, contribution.expectationExport), + ); + } catch (error) { + failures.push( + `[TEST-EVIDENCE-CATALOG-SCHEMA] ${catalogPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + facts.declaredScenarioExecutions += expectations.length; + facts.sharedScenarios += new Set( + expectations.map((entry) => entry.scenarioId), + ).size; + const declarationIds = expectations.map((entry) => entry.executionId); + if (declarationIds.length === 0) { + failures.push(`[TEST-EVIDENCE-CATALOG-ZERO] ${catalogPath}: no declarations`); + } + const duplicateDeclarations = duplicates(declarationIds); + if (duplicateDeclarations.length > 0) { + failures.push( + `[TEST-EVIDENCE-DECLARATION-DUPLICATE] ${catalogPath}: ${duplicateDeclarations.join(", ")}`, + ); + } + for (const entry of expectations) { + if (entry.executionId !== `${entry.operationId}::${entry.scenarioId}`) { + failures.push( + `[TEST-EVIDENCE-EXECUTION-ID-SCHEMA] ${catalogPath}: ${entry.executionId}`, + ); + } + } + + let receipt: HttpScenarioReceipt; + try { + const parsedReceipt = httpScenarioReceiptSchema.safeParse( + await readJson(receiptPath), + ); + if (!parsedReceipt.success) { + if ( + parsedReceipt.error.issues.some((issue) => + issue.message.includes("must be sorted by execution ID"), + ) + ) { + failures.push( + `[TEST-EVIDENCE-EXECUTION-ID-UNSORTED] ${receiptPath}`, + ); + } + if ( + parsedReceipt.error.issues.some((issue) => + issue.message.includes("row IDs must exactly equal executed IDs"), + ) + ) { + failures.push(`[TEST-EVIDENCE-EXECUTION-ROW-DRIFT] ${receiptPath}`); + } + throw parsedReceipt.error; + } + receipt = parsedReceipt.data; + } catch (error) { + failures.push( + `[TEST-EVIDENCE-RECEIPT-SCHEMA] ${receiptPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + facts.executedScenarioExecutions += receipt.rows.length; + if (receipt.schemaVersion !== contribution.receiptSchemaVersion) { + failures.push( + `[TEST-EVIDENCE-RECEIPT-VERSION] ${receiptPath}: expected ${contribution.receiptSchemaVersion}, observed ${receipt.schemaVersion}`, + ); + } + if (receipt.rows.length === 0 || receipt.executedIds.length === 0) { + failures.push(`[TEST-EVIDENCE-EXECUTION-ZERO] ${receiptPath}: no executions`); + } + if (receipt.catalogTotal !== declarationIds.length) { + failures.push( + `[TEST-EVIDENCE-CATALOG-TOTAL] ${receiptPath}: expected ${declarationIds.length}, observed ${receipt.catalogTotal}`, + ); + } + const duplicateExecutedIds = duplicates(receipt.executedIds); + const rowIds = receipt.rows.map((row) => row.executionId); + const duplicateRows = duplicates(rowIds); + if (duplicateExecutedIds.length > 0 || duplicateRows.length > 0) { + failures.push( + `[TEST-EVIDENCE-EXECUTION-ID-DUPLICATE] ${receiptPath}: ${[...new Set([...duplicateExecutedIds, ...duplicateRows])].sort().join(", ")}`, + ); + } + if (!isSorted(receipt.executedIds) || !isSorted(rowIds)) { + failures.push(`[TEST-EVIDENCE-EXECUTION-ID-UNSORTED] ${receiptPath}`); + } + if (!sameScenarioJson(receipt.executedIds, rowIds)) { + failures.push(`[TEST-EVIDENCE-EXECUTION-ROW-DRIFT] ${receiptPath}`); + } + + const declared = new Set(declarationIds); + const executed = new Set(receipt.executedIds); + const missing = [...declared].filter((executionId) => !executed.has(executionId)).sort(); + const extra = [...executed].filter((executionId) => !declared.has(executionId)).sort(); + if (missing.length > 0) { + failures.push( + `[TEST-EVIDENCE-EXECUTION-ID-MISSING] ${receiptPath}: ${missing.join(", ")}`, + ); + } + if (extra.length > 0) { + failures.push( + `[TEST-EVIDENCE-EXECUTION-ID-EXTRA] ${receiptPath}: ${extra.join(", ")}`, + ); + } + + const expectedDigest = computeHttpScenarioCatalogDigest( + receipt.schemaVersion, + expectations, + ); + if (receipt.catalogDigest !== expectedDigest) { + failures.push( + `[TEST-EVIDENCE-CATALOG-DIGEST] ${receiptPath}: expected ${expectedDigest}, observed ${receipt.catalogDigest}`, + ); + } + + const declarationsById = new Map( + expectations.map((entry) => [entry.executionId, entry] as const), + ); + for (const row of receipt.rows) { + const declaration = declarationsById.get(row.executionId); + if (!declaration) continue; + if (!sameScenarioJson(row.expected, declaration.expected)) { + failures.push( + `[TEST-EVIDENCE-EXPECTED-DRIFT] ${receiptPath}: ${row.executionId}`, + ); + } + if (row.testDeadlineOverrideMs !== declaration.testDeadlineOverrideMs) { + failures.push( + `[TEST-EVIDENCE-DEADLINE-DRIFT] ${receiptPath}: ${row.executionId}`, + ); + } + if (!sameScenarioJson(row.observed, row.expected)) { + failures.push( + `[TEST-EVIDENCE-OBSERVATION-MISMATCH] ${receiptPath}: ${row.executionId}`, + ); + } + } +} + +if (!scenarioOnly) { + const sourceFiles = (await filesBelow(sourceRoot)).filter( + (file) => fixtureMode || !file.split(path.sep).includes("fixtures"), + ); + for (const file of sourceFiles) { + if (!/\.(?:ts|tsx|fixture|txt)$/.test(file)) continue; + const source = await readFile(file, "utf8"); + facts.scannedFiles += 1; + const skipPattern = /\b(?:test|it|describe)(?:\.describe)?\.(?:skip|fixme)\s*\(/g; + if (skipPattern.test(source)) { + const quarantine = + /quarantine\(owner=[^)]+,\s*defect=[^)]+,\s*expires=\d{4}-\d{2}-\d{2}\)/; + if (!quarantine.test(source)) { + failures.push(`${file}: skip/fixme lacks owned expiring quarantine`); + } + } + const wholeUiMask = + /\bmask\s*:\s*\[[\s\S]{0,240}(?:locator|getByRole)\s*\(\s*["'](?:html|body|main|application|document)["']/i; + if (wholeUiMask.test(source)) { + failures.push(`${file}: screenshot mask may not cover the whole UI`); + } + } +} + +let parsedPolicy: z.output | undefined; +if (!fixtureMode || scenarioOnly) { + try { + parsedPolicy = policySchema.parse(await readJson(policyPath)); + } catch (error) { + failures.push( + `[TEST-EVIDENCE-POLICY-SCHEMA] ${policyPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +if (parsedPolicy) { + for (const contract of parsedPolicy.sourceContracts) { + let source: string; + try { + source = await readFile(contract.path, "utf8"); + } catch (error) { + failures.push( + `${contract.path}: cannot read source contract (${error instanceof Error ? error.message : String(error)})`, + ); + continue; + } + for (const token of contract.requiredTokens) { + if (!source.includes(token)) { + failures.push( + `${contract.path}: ${contract.owner} evidence contract is missing ${token}`, + ); + } + } + } + + const shouldCheckScenarioExecutions = + scenarioOnly || + (!sourceOnly && !skipScenarioExecutions && !fixtureMode); + if (shouldCheckScenarioExecutions) { + if (parsedPolicy.scenarioCatalogs.length === 0) { + failures.push("[TEST-EVIDENCE-CATALOG-ZERO] policy has no scenario catalogs"); + } + if ( + (explicitCatalogPath !== undefined || explicitReceiptPath !== undefined) && + parsedPolicy.scenarioCatalogs.length !== 1 + ) { + failures.push( + "[TEST-EVIDENCE-FIXTURE-SCOPE] explicit catalog/receipt requires exactly one contribution", + ); + } else { + for (const contribution of parsedPolicy.scenarioCatalogs) { + await checkScenarioContribution(contribution); + } + } + } +} + +if (!fixtureMode && !scenarioOnly) { + const e2eConfig = await readFile("playwright.config.ts", "utf8"); + for (const token of [ + "pnpm build", + "pnpm preview", + "reuseExistingServer: false", + '"junit"', + 'trace: "retain-on-failure"', + '"chromium-compact"', + '"firefox"', + '"webkit"', + ]) { + if (!e2eConfig.includes(token)) { + failures.push(`playwright.config.ts missing release evidence token ${token}`); + } + } + + const e2eFiles = (await filesBelow("tests/e2e")).filter((file) => + /\.spec\.ts$/.test(file), + ); + for (const file of e2eFiles) { + const source = await readFile(file, "utf8"); + if (!source.includes("support/browser/strict-browser-test")) { + failures.push(`${file}: bypasses strict browser fixture`); + } + } + + const baselineFiles = (await filesBelow("tests/visual/__snapshots__")).filter( + (file) => file.endsWith(".png"), + ); + facts.visualBaselines = baselineFiles.length; + if (facts.visualBaselines < 4) { + failures.push("visual baseline requires at least four risk surfaces"); + } + for (const required of [ + "playwright.storybook.config.ts", + "playwright.visual.config.ts", + "tests/storybook/workshop.spec.ts", + ]) { + if ((await filesBelow(required)).length === 0) { + failures.push(`test evidence missing ${required}`); + } + } + + if (!sourceOnly) { + for (const required of [ + "artifacts/tests/storybook/results.xml", + "artifacts/tests/visual/results.xml", + ]) { + if ((await filesBelow(required)).length === 0) { + failures.push(`test evidence missing ${required}`); + } + } + + for (const required of [ + "dist/index.html", + "dist/config.json", + "dist/release-manifest.json", + "dist/runtime-config.schema.json", + "dist/.vite/manifest.json", + ]) { + if ((await filesBelow(required)).length === 0) { + failures.push(`built-dist contract missing ${required}`); + } + } + const sourceMaps = (await filesBelow("dist")).filter((file) => + file.endsWith(".map"), + ); + if (sourceMaps.length > 0) { + failures.push(`production dist contains source maps: ${sourceMaps.join(", ")}`); + } + } +} + +const report = { + schemaVersion: 2 as const, + sourceRoot, + status: failures.length === 0 ? ("PASS" as const) : ("FAIL" as const), + facts, + failures, +}; +await mkdir(path.dirname(artifactPath), { recursive: true }); +await writeValidatedJsonArtifact({ + path: artifactPath, + schema: testEvidenceReportSchema, + value: report, +}); +if (failures.length > 0) { + process.stderr.write(`Test evidence failed:\n- ${failures.join("\n- ")}\n`); + process.exit(1); +} +process.stdout.write( + `Test evidence: PASS (${facts.scannedFiles} files, ${facts.visualBaselines} baselines, ${facts.declaredScenarioExecutions} declared/${facts.executedScenarioExecutions} executed scenarios)\n`, +); diff --git a/scripts/check-v8-coverage-counter-semantics.ts b/scripts/check-v8-coverage-counter-semantics.ts new file mode 100644 index 0000000..cf449d0 --- /dev/null +++ b/scripts/check-v8-coverage-counter-semantics.ts @@ -0,0 +1,12 @@ +import { checkV8CoverageCounterSemantics } from "./lib/v8-coverage-counter-semantics.ts"; + +try { + const result = await checkV8CoverageCounterSemantics(); + process.stdout.write( + `V8 coverage counter semantics: PASS (${result.counterBearingModules.length} counter-bearing, ${result.counterlessModules.length} counterless)\n`, + ); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`V8 coverage counter semantics failed: ${message}\n`); + process.exitCode = 1; +} diff --git a/scripts/cleanup-verified-promotion.ts b/scripts/cleanup-verified-promotion.ts new file mode 100644 index 0000000..49d0e60 --- /dev/null +++ b/scripts/cleanup-verified-promotion.ts @@ -0,0 +1,29 @@ +import { cleanupFinalizedPromotion } from "./lib/promotion-stager.ts"; + +const required = (name: string): string => { + const value = process.env[name]; + if (!value) throw new TypeError(`promotion cleanup environment is missing ${name}`); + return value; +}; +const requiredIdentity = (name: string): number => { + const value = Number(required(name)); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`promotion cleanup environment has invalid ${name}`); + } + return value; +}; + +await cleanupFinalizedPromotion({ + runnerTempRoot: required("RUNNER_TEMP"), + stagingRoot: required("PROMOTION_STAGING_ROOT"), + cleanupToken: required("PROMOTION_CLEANUP_TOKEN"), + runnerTempIdentity: { + dev: requiredIdentity("PROMOTION_RUNNER_TEMP_DEV"), + ino: requiredIdentity("PROMOTION_RUNNER_TEMP_INO"), + }, + stagingIdentity: { + dev: requiredIdentity("PROMOTION_STAGING_DEV"), + ino: requiredIdentity("PROMOTION_STAGING_INO"), + }, +}); +process.stdout.write("Promotion staging cleanup: PASS\n"); diff --git a/scripts/collect-web-vitals-evidence.ts b/scripts/collect-web-vitals-evidence.ts new file mode 100644 index 0000000..1935e37 --- /dev/null +++ b/scripts/collect-web-vitals-evidence.ts @@ -0,0 +1,110 @@ +import { mkdir, readFile } from "node:fs/promises"; + +import { + evaluateFieldBudget, + percentile75, +} from "../src/application/policies/performance-budgets.ts"; +import { fieldWebVitalsArtifactSchema } from "./contracts/release-artifacts.ts"; +import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const inputPath = + process.env.FIELD_WEB_VITALS_INPUT || + "config/performance/field-input.example.json"; +const rawInput = JSON.parse(await readFile(inputPath, "utf8")); +const now = new Date(); +const validation = validateFieldEvidenceInput( + rawInput, + process.env.MIN_ELIGIBLE_SAMPLES, + now, +); +const input = validation.data; +type FieldBudgets = { + p75LcpMs: number; + p75Cls: number; + p75InpMs: number; + minimumEligibleSamples: number | null; +}; + +const configured = JSON.parse( + await readFile("config/performance/budgets.json", "utf8"), +).field as FieldBudgets; +const minimumEligibleSamples = validation.minimumEligibleSamples; +const fallbackEnd = now; +const fallbackStart = new Date(fallbackEnd); +fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 28); +const start = input ? new Date(input.window.start) : fallbackStart; +const end = input ? new Date(input.window.end) : fallbackEnd; +const eligible = (input?.samples ?? []).filter((sample) => { + const timestamp = new Date(sample.timestamp); + return ( + sample.consent === true && + sample.releaseId === input?.releaseId && + timestamp >= start && + timestamp <= end + ); +}); +const metrics = { + p75LcpMs: percentile75(eligible.map((sample) => sample.lcpMs)), + p75Cls: percentile75(eligible.map((sample) => sample.cls)), + p75InpMs: percentile75(eligible.map((sample) => sample.inpMs)), +}; +const thresholds = { ...configured, minimumEligibleSamples }; +const result = evaluateFieldBudget( + { metrics, eligibleSamples: eligible.length }, + thresholds, +); +const passed = validation.passed && result.passed; +const status = validation.passed ? result.status : "FAIL_UNVERIFIED"; +const routeSamples = Object.fromEntries( + Object.entries( + eligible.reduce( + (counts, sample) => { + counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1; + return counts; + }, + {} as Record, + ), + ).sort(([left], [right]) => left.localeCompare(right)), +); +const report = { + schemaVersion: 1, + generatedAt: now.toISOString(), + window: { days: 28, start: start.toISOString(), end: end.toISOString() }, + context: { + source: inputPath, + sourceSystem: input?.source.system ?? null, + exportId: input?.source.exportId ?? null, + network: "production-real-user", + routeAggregation: "route-id-only", + releaseId: input?.releaseId ?? null, + privacyApprovalRef: input?.privacy.approvalRef ?? null, + thresholdDecisionRef: input?.thresholdDecision.evidenceRef ?? null, + validationFailures: validation.failures, + }, + metrics, + thresholds, + eligibility: { + consentRequired: true, + totalSamples: input?.samples.length ?? 0, + eligibleSamples: eligible.length, + minimumEligibleSamples, + routeSamples, + }, + status, + passed, +}; + +await mkdir("artifacts/performance", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/performance/field-web-vitals.json", + schema: fieldWebVitalsArtifactSchema, + value: report, +}); +if (!passed) { + process.stderr.write( + `Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`, + ); + process.exit(1); +} +process.stdout.write("Field Web Vitals: PASS\n"); diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts new file mode 100644 index 0000000..dff8946 --- /dev/null +++ b/scripts/contracts/ci-gates.ts @@ -0,0 +1,1201 @@ +import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { z } from "zod"; + +import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts"; +import { + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, +} from "../lib/release-candidate.ts"; +import { + validateInstallScriptPolicy, + validateNpmScopeEnvironment, + validatePackageScriptGraph, +} from "../lib/package-script-graph.ts"; +import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts"; + +const ciActionRegistrationSchema = z + .object({ + repository: z + .string() + .regex( + /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u, + "CI action repository must be an absolute upstream GitHub URL", + ), + revision: z + .string() + .regex(/^[0-9a-f]{40}$/u, "CI action revision must be a full 40-hex commit SHA"), + version: z.string().min(1).max(64), + }) + .strict() + .readonly(); + +const ciActionRegistrySchema = z + .object({ + checkout: ciActionRegistrationSchema, + setupNode: ciActionRegistrationSchema, + uploadArtifact: ciActionRegistrationSchema, + downloadArtifact: ciActionRegistrationSchema, + }) + .strict() + .readonly(); + +export type CiActionRegistry = z.infer; +export type CiActionId = keyof CiActionRegistry; + +export function parseCiActionRegistry(value: unknown): CiActionRegistry { + const result = ciActionRegistrySchema.safeParse(value); + if (!result.success) { + const diagnostic = result.error.issues + .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`) + .join("\n"); + throw new TypeError(`CI action registry invalid:\n${diagnostic}`); + } + return result.data; +} + +export const CI_ACTION_REGISTRY = parseCiActionRegistry({ + checkout: { + repository: "https://github.com/actions/checkout", + revision: "34e114876b0b11c390a56381ad16ebd13914f8d5", + version: "v4.3.1", + }, + setupNode: { + repository: "https://github.com/actions/setup-node", + revision: "49933ea5288caeca8642d1e84afbd3f7d6820020", + version: "v4.4.0", + }, + uploadArtifact: { + repository: "https://github.com/ChristopherHX/gitea-upload-artifact", + revision: "81f940d004763f986ba3582c007fd842dd5cb0d7", + version: "v4 branch", + }, + downloadArtifact: { + repository: "https://github.com/ChristopherHX/gitea-download-artifact", + revision: "75635f32b4c1c41c4b3d64e8f85210112ed4c9c7", + version: "v4 branch", + }, +}); + +const actionIdByStepKind = Object.freeze({ + checkout: "checkout", + "setup-node": "setupNode", + upload: "uploadArtifact", + download: "downloadArtifact", +} as const satisfies Readonly>); + +export function resolveCiActionUses(actionId: CiActionId): string { + const action = CI_ACTION_REGISTRY[actionId]; + if (!action) throw new TypeError(`unknown CI action: ${String(actionId)}`); + return `${action.repository}@${action.revision}`; +} + +export function resolveCiStepActionUses(stepKind: string): string | null { + const actionId = actionIdByStepKind[stepKind as keyof typeof actionIdByStepKind]; + return actionId ? resolveCiActionUses(actionId) : null; +} + +function hasAsciiControl(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +function hasForbiddenLineOrControl(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0)!; + return ( + codePoint <= 0x1f || + codePoint === 0x7f || + codePoint === 0x85 || + codePoint === 0x2028 || + codePoint === 0x2029 + ); + }); +} + +const nonEmpty = z + .string() + .min(1) + .max(4_096) + .refine((value) => !hasForbiddenLineOrControl(value), "control and Unicode line-break characters are forbidden") + .refine((value) => value === value.trim(), "leading/trailing whitespace is forbidden"); +const id = nonEmpty.max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u); +const repositoryPath = nonEmpty.superRefine((value, context) => { + if ( + value.includes("\\") || + value.includes("\0") || + path.posix.isAbsolute(value) || + path.posix.normalize(value) !== value || + value === "." || + value === ".." || + value.startsWith("../") || + value.includes("/../") + ) { + context.addIssue({ code: "custom", message: `unsafe repository path: ${value}` }); + } +}); +const workflowValue = nonEmpty.refine( + (value) => !value.includes("\0") && !value.includes("\r"), + "workflow values must not contain NUL or carriage returns", +); +const workflowPath = workflowValue.superRefine((value, context) => { + if ( + value.includes("\\") || + path.posix.isAbsolute(value) || + path.posix.normalize(value) !== value || + value === "." || + value === ".." || + value.startsWith("../") || + value.includes("/../") + ) { + context.addIssue({ code: "custom", message: `unsafe workflow path: ${value}` }); + } +}); + +const commandSchema = z + .object({ + id, + script: nonEmpty.regex(/^[A-Za-z0-9:_-]+$/u), + args: z.array(z.string().max(512).refine((value) => !hasAsciiControl(value), "command arguments contain controls")).max(32).optional(), + timeoutMs: z.number().int().min(1_000).max(3_600_000).optional(), + expect: z.enum(["pass", "fail"]), + expectedExitCode: z.number().int().min(1).max(255).optional(), + expectedDiagnosticId: nonEmpty.max(256).optional(), + }) + .strict() + .superRefine((command, context) => { + const hasNegativeIdentity = + command.expectedExitCode !== undefined || + command.expectedDiagnosticId !== undefined; + if (command.expect === "pass" && hasNegativeIdentity) { + context.addIssue({ + code: "custom", + message: `pass command carries negative identity: ${command.id}`, + }); + } + if ( + command.expect === "fail" && + (command.expectedExitCode === undefined || !command.expectedDiagnosticId) + ) { + context.addIssue({ + code: "custom", + message: `negative command lacks exact identity: ${command.id}`, + }); + } + if ( + command.expectedDiagnosticId && + /[\n\r\0]/u.test(command.expectedDiagnosticId) + ) { + context.addIssue({ + code: "custom", + message: `negative command diagnostic is unsafe: ${command.id}`, + }); + } + }); + +const artifactSchemaSchema = z.discriminatedUnion("kind", [ + z.object({ id, kind: z.literal("text"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z + .object({ + id, + kind: z.literal("json"), + maxBytes: z.number().int().min(1).max(268_435_456), + executableSchemaId: z.enum([ + "automated-a11y", + "manual-a11y", + "architecture-dependency-report", + "design-system-contract", + "i18n-contract", + "diagnostics-contract", + "realtime-boundaries", + "optional-recipes", + "optional-recipe-fixtures", + "registry-compatibility-fixtures", + "reproducible-build", + "supply-chain-fixtures", + "supply-chain-provider-fixtures", + "compatibility-fixtures", + "documentation-review", + "hosting-headers", + "coverage-summary-v8", + "risk-coverage-v3", + "build-manifest", + "module-inventory", + "dependency-inventory", + "registry-snapshot", + "registry-governance-run", + "bundle-performance", + "sbom", + "provenance", + "dependency-diff", + "license-report", + "vulnerability-report", + "field-web-vitals", + "lab-performance", + "release-verification", + "runbook-record", + "supply-chain-verification", + "release-candidate", + "supply-chain-coherence", + "http-scenario-receipt", + "test-evidence-report", + "provider-vulnerability", + "provider-provenance", + "provider-verification", + "ci-contract-report", + ]), + }) + .strict(), + z + .object({ + id, + kind: z.literal("json-schema"), + maxBytes: z.number().int().min(1).max(268_435_456), + }) + .strict(), + z.object({ id, kind: z.literal("junit"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z.object({ id, kind: z.literal("html"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z.object({ id, kind: z.literal("markdown"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z.object({ id, kind: z.literal("sarif"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(), + z + .object({ id, kind: z.literal("candidate-archive"), maxBytes: z.number().int().min(1).max(268_435_456) }) + .strict(), +]); + +const artifactBaseShape = { id, path: repositoryPath, schemaId: id } as const; +const artifactSchema = z.discriminatedUnion("production", [ + z.object({ ...artifactBaseShape, production: z.literal("source-controlled") }).strict(), + z + .object({ + ...artifactBaseShape, + production: z.literal("command-generated"), + producerCommandIds: z.array(id).min(1).max(32), + }) + .strict(), + z.object({ ...artifactBaseShape, production: z.literal("runner-generated") }).strict(), +]); + +const gateSchema = z + .object({ + id: z.string().regex(/^FE-GATE-\d{3}$/u), + name: nonEmpty.regex(/^[a-z0-9][a-z0-9-]*$/u), + commandIds: z.array(id).min(1).max(128), + logArtifactId: id, + evidenceArtifactIds: z.array(id).min(1).max(128), + retentionClassId: id, + requiresEnvironment: z.array(z.string().max(128).regex(/^[A-Z][A-Z0-9_]*$/u)).max(32).optional(), + }) + .strict(); + +const stageSchema = z + .object({ + id, + readiness: z.enum([ + "MERGE_READY", + "RELEASE_READY", + "PROD_PROMOTION_READY", + "FIELD_SLO_READY", + "DOCUMENTATION_READY", + ]), + needs: z.array(id).max(16), + gateIds: z.array(id).min(1).max(64), + }) + .strict(); + +const environmentBindingSchema = z + .object({ name: z.string().regex(/^[A-Z][A-Z0-9_]*$/u), value: workflowValue }) + .strict(); + +const checkoutStep = z.object({ kind: z.literal("checkout") }).strict(); +const setupNodeStep = z.object({ kind: z.literal("setup-node") }).strict(); +const frozenInstallStep = z.object({ kind: z.literal("frozen-install") }).strict(); +const browserInstallStep = z.object({ kind: z.literal("browser-install") }).strict(); +const runGateStep = z.object({ kind: z.literal("run-gate") }).strict(); +const archiveCandidateStep = z + .object({ + kind: z.literal("archive-candidate"), + stepId: id, + archivePath: workflowPath, + members: z.array(repositoryPath).min(1).max(128), + archiveOutputName: id, + distOutputName: id, + }) + .strict(); +const uploadStep = z + .object({ + kind: z.literal("upload"), + transferId: id, + name: workflowValue, + paths: z.array(workflowPath).min(1).max(128), + always: z.boolean().optional(), + }) + .strict(); +const downloadStep = z + .object({ kind: z.literal("download"), transferId: id, path: workflowPath }) + .strict(); +const extractStep = z + .object({ + kind: z.literal("extract"), + archivePath: workflowPath, + targetRoot: workflowPath, + }) + .strict(); +const providerStep = z + .object({ + kind: z.literal("run-provider"), + provider: z.enum(["vulnerability", "provenance"]), + stepId: id, + }) + .strict(); +const validateProviderStep = z + .object({ + kind: z.literal("validate-provider-evidence"), + provider: z.enum(["vulnerability", "provenance"]), + }) + .strict(); +const promotionStep = z + .object({ kind: z.literal("verify-promotion"), stepId: id }) + .strict(); +const cleanupPromotionStep = z + .object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id }) + .strict(); + +const jobStepSchema = z.discriminatedUnion("kind", [ + checkoutStep, + setupNodeStep, + frozenInstallStep, + browserInstallStep, + runGateStep, + archiveCandidateStep, + uploadStep, + downloadStep, + extractStep, + providerStep, + validateProviderStep, + promotionStep, + cleanupPromotionStep, +]); + +const jobSchema = z + .object({ + id, + displayName: workflowValue, + kind: z.enum(["gate-matrix", "gate-single", "immutable", "provider", "promotion"]), + needs: z.array(id).max(32), + condition: z.enum(["always", "needs-success", "merge", "release", "production", "field", "documentation"]), + timeoutMinutes: z.number().int().positive(), + gateIds: z.array(id).max(64), + browserGateIds: z.array(id).max(64), + environment: z.array(environmentBindingSchema).max(32), + steps: z.array(jobStepSchema).min(1).max(64), + }) + .strict(); + +const retentionSchema = z + .object({ + durationStatus: nonEmpty, + classes: z.array(z.object({ id, policy: nonEmpty }).strict()).min(1).max(32), + }) + .strict(); + +const ciGateContractBaseSchema = z + .object({ + schemaVersion: z.literal(2), + providerAdapter: repositoryPath, + commands: z.array(commandSchema).min(1).max(256), + artifactSchemas: z.array(artifactSchemaSchema).min(1).max(128), + artifacts: z.array(artifactSchema).min(1).max(512), + gates: z.array(gateSchema).min(1).max(64), + stages: z.array(stageSchema).min(1).max(16), + jobs: z.array(jobSchema).min(1).max(32), + retention: retentionSchema, + }) + .strict(); + +const ciGateContractSchema = ciGateContractBaseSchema.superRefine( + (contract, context) => validateContractSemantics(contract, context), +); + +export type CiGateContract = z.infer; +export type CiGateCommand = CiGateContract["commands"][number]; +export type CiGateArtifactSchema = CiGateContract["artifactSchemas"][number]; +export type CiGateArtifact = CiGateContract["artifacts"][number]; +export type CiGate = CiGateContract["gates"][number]; +export type CiWorkflowJob = CiGateContract["jobs"][number]; +export type CiWorkflowStep = CiWorkflowJob["steps"][number]; + +export type CiGateContractIndex = Readonly<{ + commands: ReadonlyMap; + artifactSchemas: ReadonlyMap; + artifacts: ReadonlyMap; + gates: ReadonlyMap; + stages: ReadonlyMap; + jobs: ReadonlyMap; + retentionClasses: ReadonlyMap; +}>; +export type LoadCiGateContractOptions = Readonly<{ + mode?: "canonical" | "removal-fixture"; +}>; + +const CANONICAL_GATE_SHAPE_SHA256 = + "a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4"; + +function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { + const normalized = gates.map( + ({ + id, + name, + commandIds, + logArtifactId, + evidenceArtifactIds, + retentionClassId, + requiresEnvironment, + }) => ({ + id, + name, + commandIds, + logArtifactId, + evidenceArtifactIds, + retentionClassId, + requiresEnvironment: requiresEnvironment ?? [], + }), + ); + return createHash("sha256").update(JSON.stringify(normalized)).digest("hex"); +} + +function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] { + const failures: string[] = []; + const commandReferenceCount = contract.gates.reduce( + (total, gate) => total + gate.commandIds.length, + 0, + ); + if (contract.gates.length !== 26) { + failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`); + } + if (contract.commands.length !== 81 || commandReferenceCount !== 93) { + failures.push( + `command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, + ); + } + if (contract.artifacts.length !== 105) { + failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`); + } + if (contract.stages.length !== 5) { + failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); + } + const expectedRetentionClasses = [ + { id: "merge-cycle", policy: "at least through pull-request readiness decision" }, + { id: "release-coherence", policy: "at least until the next release is promoted" }, + { id: "prod-drill", policy: "at least until the next production promotion decision" }, + { id: "field", policy: "through the 28-day window and aggregation" }, + { id: "documentation", policy: "through documentation readiness review" }, + ]; + if (JSON.stringify(contract.retention.classes) !== JSON.stringify(expectedRetentionClasses)) { + failures.push("retention registry must contain exactly the five canonical retention classes"); + } + return failures; +} + +export function parseCiGateContract( + value: unknown, + options: LoadCiGateContractOptions = {}, +): CiGateContract { + const result = ciGateContractSchema.safeParse(value); + if (!result.success) { + const diagnostic = result.error.issues + .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`) + .join("\n"); + throw new TypeError(`CI gate contract invalid:\n${diagnostic}`); + } + if ((options.mode ?? "canonical") === "canonical") { + const failures = canonicalAuthorityBaselineFailures(result.data); + if (failures.length > 0) { + throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`); + } + } + return result.data; +} + +export async function loadCiGateContract( + root = process.cwd(), + options: LoadCiGateContractOptions = {}, +): Promise { + const mode = options.mode ?? "canonical"; + const [rawContract, rawPackage] = await Promise.all([ + readFile(path.join(root, "config/ci/gates.json"), "utf8"), + readFile(path.join(root, "package.json"), "utf8"), + ]); + const contract = parseCiGateContract(JSON.parse(rawContract), { mode }); + if ( + mode === "canonical" && + canonicalGateShapeSha256(contract.gates) !== CANONICAL_GATE_SHAPE_SHA256 + ) { + throw new TypeError("CI gate contract canonical gate semantic shape drift"); + } + const packageDocument = z + .object({ scripts: z.record(z.string(), z.string()).default({}) }) + .passthrough() + .parse(JSON.parse(rawPackage)); + const missing = contract.commands + .map((command) => command.script) + .filter((script, index, scripts) => scripts.indexOf(script) === index) + .filter((script) => !packageDocument.scripts[script]); + if (missing.length > 0) { + throw new TypeError(`CI gate contract missing package scripts: ${missing.join(", ")}`); + } + const expectedCheckCi = mode === "canonical" + ? "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow" + : "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow"; + if (packageDocument.scripts["check:ci"] !== expectedCheckCi) { + throw new TypeError("check:ci must use the exact canonical non-recursive orchestration"); + } + const canonicalCheckCiDependencies = { + "check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check", + "check:ci-workflow": mode === "canonical" + ? "node scripts/generate-ci-workflow.ts --check" + : "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture", + } as const; + for (const [script, expected] of Object.entries(canonicalCheckCiDependencies)) { + if (packageDocument.scripts[script] !== expected) { + throw new TypeError(`canonical check:ci dependency drift: ${script}`); + } + } + const contractEntryScripts = [ + ...new Set(contract.commands.map((command) => command.script)), + ]; + const graphFailures = [ + ...validatePackageScriptGraph(packageDocument.scripts, "check:ci"), + ...contractEntryScripts.flatMap((script) => + validatePackageScriptGraph(packageDocument.scripts, script) + ), + ]; + if (graphFailures.length > 0) { + throw new TypeError( + `CI package script graph invalid:\n${[...new Set(graphFailures)].join("\n")}`, + ); + } + const installPolicyFailures = validateInstallScriptPolicy( + packageDocument.scripts, + contractEntryScripts, + ); + if (installPolicyFailures.length > 0) { + throw new TypeError( + `CI package script install policy invalid:\n${installPolicyFailures.join("\n")}`, + ); + } + return contract; +} + +export async function withCiGatePreflight( + root: string, + gateId: string | undefined, + execute: (context: Readonly<{ + contract: CiGateContract; + contractIndex: CiGateContractIndex; + gateId: string; + gate: CiGate; + }>) => Promise | Result, +): Promise { + const npmScopeEnvironmentFailures = validateNpmScopeEnvironment(process.env); + if (npmScopeEnvironmentFailures.length > 0) { + throw new TypeError( + `CI runner npm scope environment invalid:\n${npmScopeEnvironmentFailures.join("\n")}`, + ); + } + const contract = await loadCiGateContract(root); + const contractIndex = indexCiGateContract(contract); + const gate = gateId ? contractIndex.gates.get(gateId) : undefined; + if (!gateId || !gate) { + throw new TypeError("CI gate id must be FE-GATE-001..FE-GATE-026"); + } + return execute({ contract, contractIndex, gateId, gate }); +} + +export function indexCiGateContract(contract: CiGateContract): CiGateContractIndex { + return Object.freeze({ + commands: new Map(contract.commands.map((entry) => [entry.id, entry])), + artifactSchemas: new Map(contract.artifactSchemas.map((entry) => [entry.id, entry])), + artifacts: new Map(contract.artifacts.map((entry) => [entry.id, entry])), + gates: new Map(contract.gates.map((entry) => [entry.id, entry])), + stages: new Map(contract.stages.map((entry) => [entry.id, entry])), + jobs: new Map(contract.jobs.map((entry) => [entry.id, entry])), + retentionClasses: new Map(contract.retention.classes.map((entry) => [entry.id, entry])), + }); +} + +function validateContractSemantics( + contract: z.infer, + context: z.RefinementCtx, +): void { + const issue = (message: string, path: PropertyKey[] = []) => + context.addIssue({ code: "custom", message, path }); + if (contract.providerAdapter !== ".gitea/workflows/quality-gates.yml") { + issue("providerAdapter must target the canonical generated workflow"); + } + const registries = [ + ["command", contract.commands], + ["artifact schema", contract.artifactSchemas], + ["artifact", contract.artifacts], + ["gate", contract.gates], + ["stage", contract.stages], + ["job", contract.jobs], + ["retention class", contract.retention.classes], + ] as const; + for (const [label, entries] of registries) { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.id)) issue(`duplicate ${label} id: ${entry.id}`); + seen.add(entry.id); + } + } + + const commandTuples = new Map(); + for (const command of contract.commands) { + const { id: _id, ...tuple } = command; + const key = JSON.stringify(tuple); + const previous = commandTuples.get(key); + if (previous) issue(`duplicate command tuple: ${previous}, ${command.id}`); + commandTuples.set(key, command.id); + } + const artifactPaths = new Map(); + for (const artifact of contract.artifacts) { + const previous = artifactPaths.get(artifact.path); + if (previous) issue(`duplicate artifact path: ${previous}, ${artifact.id}`); + artifactPaths.set(artifact.path, artifact.id); + } + + const commandIds = new Set(contract.commands.map(({ id }) => id)); + const schemaIds = new Set(contract.artifactSchemas.map(({ id }) => id)); + const artifactIds = new Set(contract.artifacts.map(({ id }) => id)); + const gateIds = new Set(contract.gates.map(({ id }) => id)); + const stageIds = new Set(contract.stages.map(({ id }) => id)); + const jobIds = new Set(contract.jobs.map(({ id }) => id)); + const retentionIds = new Set(contract.retention.classes.map(({ id }) => id)); + + for (const artifact of contract.artifacts) { + if (!schemaIds.has(artifact.schemaId)) { + issue(`unknown artifact schema ${artifact.schemaId} for ${artifact.id}`); + } + if (artifact.production === "command-generated") { + if (new Set(artifact.producerCommandIds).size !== artifact.producerCommandIds.length) { + issue(`duplicate producer command reference for artifact: ${artifact.id}`); + } + for (const producerCommandId of artifact.producerCommandIds) { + if (!commandIds.has(producerCommandId)) { + issue(`unknown producer command ${producerCommandId} for ${artifact.id}`); + } + } + } + } + for (const gate of contract.gates) { + if (new Set(gate.commandIds).size !== gate.commandIds.length) { + issue(`duplicate command reference within gate: ${gate.id}`); + } + if (new Set(gate.evidenceArtifactIds).size !== gate.evidenceArtifactIds.length) { + issue(`duplicate artifact reference within gate: ${gate.id}`); + } + for (const commandId of gate.commandIds) { + if (!commandIds.has(commandId)) issue(`unknown command ${commandId} for ${gate.id}`); + } + for (const artifactId of [gate.logArtifactId, ...gate.evidenceArtifactIds]) { + if (!artifactIds.has(artifactId)) issue(`unknown artifact ${artifactId} for ${gate.id}`); + } + const logArtifact = contract.artifacts.find(({ id }) => id === gate.logArtifactId); + if (logArtifact && logArtifact.production !== "runner-generated") { + issue(`gate log must be runner-generated: ${gate.id}`); + } + for (const artifactId of gate.evidenceArtifactIds) { + const artifact = contract.artifacts.find(({ id }) => id === artifactId); + if ( + artifact?.production === "command-generated" && + !artifact.producerCommandIds.some((producerCommandId) => + gate.commandIds.includes(producerCommandId) + ) + ) { + issue(`gate lacks a bound producer command for ${artifact.id}: ${gate.id}`); + } + } + if (!retentionIds.has(gate.retentionClassId)) { + issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`); + } + } + const referencedRetentionClasses = new Set( + contract.gates.map(({ retentionClassId }) => retentionClassId), + ); + for (const retentionClass of contract.retention.classes) { + if (!referencedRetentionClasses.has(retentionClass.id)) { + issue(`orphan retention class: ${retentionClass.id}`); + } + } + const referencedCommands = new Set(contract.gates.flatMap(({ commandIds }) => commandIds)); + for (const command of contract.commands) { + if (!referencedCommands.has(command.id)) issue(`orphan command: ${command.id}`); + } + const referencedArtifacts = new Set( + contract.gates.flatMap((gate) => [gate.logArtifactId, ...gate.evidenceArtifactIds]), + ); + for (const artifact of contract.artifacts) { + if (!referencedArtifacts.has(artifact.id)) issue(`orphan artifact: ${artifact.id}`); + } + const referencedSchemas = new Set(contract.artifacts.map(({ schemaId }) => schemaId)); + for (const schema of contract.artifactSchemas) { + if (!referencedSchemas.has(schema.id)) issue(`orphan artifact schema: ${schema.id}`); + } + for (const stage of contract.stages) { + for (const dependency of stage.needs) { + if (dependency === stage.id) issue(`stage self dependency: ${stage.id}`); + else if (!stageIds.has(dependency)) issue(`unknown stage dependency ${dependency} for ${stage.id}`); + } + for (const gateId of stage.gateIds) { + if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for stage ${stage.id}`); + } + } + for (const cycle of findCycles(contract.stages.map((stage) => [stage.id, stage.needs] as const))) { + issue(`stage dependency cycle: ${cycle.join(" -> ")}`); + } + + const expectedGateIds = Array.from( + { length: 26 }, + (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, + ); + if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) { + issue("gate registry must contain FE-GATE-001..026 in canonical order"); + } + const expectedStages: ReadonlyArray = [ + ["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY], + ["release", "RELEASE_READY", ["merge"], PROMOTION_FORMULA.RELEASE_READY], + ["production", "PROD_PROMOTION_READY", ["release"], PROMOTION_FORMULA.PROD_PROMOTION_READY], + ["field", "FIELD_SLO_READY", ["production"], PROMOTION_FORMULA.FIELD_SLO_READY], + ["documentation", "DOCUMENTATION_READY", [], PROMOTION_FORMULA.DOCUMENTATION_READY], + ]; + const stageShape = contract.stages.map(({ id, readiness, needs, gateIds }) => [id, readiness, needs, gateIds]); + if (JSON.stringify(stageShape) !== JSON.stringify(expectedStages)) { + issue("stage formula/order/ownership drift"); + } + const stageOwners = new Map(); + for (const stage of contract.stages) { + for (const gateId of stage.gateIds) { + stageOwners.set(gateId, [...(stageOwners.get(gateId) ?? []), stage.id]); + } + } + for (const gateId of expectedGateIds) { + if ((stageOwners.get(gateId) ?? []).length !== 1) issue(`gate must belong to exactly one stage: ${gateId}`); + } + + const owners = new Map(); + for (const job of contract.jobs) { + for (const [label, values] of [ + ["needs", job.needs], + ["gateIds", job.gateIds], + ["browserGateIds", job.browserGateIds], + ] as const) { + if (new Set(values).size !== values.length) { + issue(`duplicate ${label} reference in job: ${job.id}`); + } + } + if (job.timeoutMinutes !== 45) issue(`job timeout must be 45 minutes: ${job.id}`); + for (const dependency of job.needs) { + if (dependency === job.id) issue(`job self dependency: ${job.id}`); + else if (!jobIds.has(dependency)) issue(`unknown job dependency ${dependency} for ${job.id}`); + } + for (const gateId of job.gateIds) { + if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for job ${job.id}`); + owners.set(gateId, [...(owners.get(gateId) ?? []), job.id]); + } + for (const browserGateId of job.browserGateIds) { + if (!job.gateIds.includes(browserGateId)) { + issue(`browser gate ${browserGateId} is not owned by ${job.id}`); + } + } + validateJobStepKinds(job, issue); + for (const step of job.steps) { + const uses = resolveCiStepActionUses(step.kind); + if (uses && !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}$/u.test(uses)) { + issue(`CI action ref is not an absolute upstream URL pinned to a full commit SHA: ${step.kind}`); + } + } + const envNames = new Set(job.environment.map(({ name }) => name)); + for (const gateId of job.gateIds) { + const gate = contract.gates.find(({ id }) => id === gateId); + for (const required of gate?.requiresEnvironment ?? []) { + if (!envNames.has(required)) issue(`job ${job.id} lacks environment ${required} for ${gateId}`); + } + } + } + for (const gate of contract.gates) { + const gateOwners = owners.get(gate.id) ?? []; + if (gateOwners.length === 0) issue(`unowned gate: ${gate.id}`); + if (gateOwners.length > 1) issue(`multiply owned gate: ${gate.id} by ${gateOwners.join(", ")}`); + } + const expectedJobOwnership: Readonly> = { + merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"], + release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"], + immutable_build: ["FE-GATE-015"], + vulnerability_provider: [], + provenance_provider: [], + promotion: [], + production_gate: ["FE-GATE-016", "FE-GATE-021", "FE-GATE-022", "FE-GATE-023", "FE-GATE-024", "FE-GATE-025"], + field_gate: ["FE-GATE-018"], + documentation_gate: ["FE-GATE-017"], + }; + if (JSON.stringify(contract.jobs.map(({ id }) => id)) !== JSON.stringify(Object.keys(expectedJobOwnership))) { + issue("job registry must contain the exact nine canonical jobs in semantic order"); + } + for (const [jobId, gateIds] of Object.entries(expectedJobOwnership)) { + const job = contract.jobs.find(({ id }) => id === jobId); + if (!job || JSON.stringify(job.gateIds) !== JSON.stringify(gateIds)) { + issue(`exact gate execution ownership drift: ${jobId}`); + } + } + const expectedJobGraph: Readonly> = { + merge_gate: ["gate-matrix", [], "merge"], + release_gate: ["gate-matrix", ["merge_gate"], "release"], + immutable_build: ["immutable", ["release_gate"], "release"], + vulnerability_provider: ["provider", ["immutable_build"], "always"], + provenance_provider: ["provider", ["immutable_build"], "always"], + promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "needs-success"], + production_gate: ["gate-matrix", ["promotion"], "production"], + field_gate: ["gate-single", ["production_gate"], "field"], + documentation_gate: ["gate-single", [], "documentation"], + }; + for (const [jobId, [kind, needs, condition]] of Object.entries(expectedJobGraph)) { + const job = contract.jobs.find(({ id }) => id === jobId); + if (!job || job.kind !== kind || JSON.stringify(job.needs) !== JSON.stringify(needs) || job.condition !== condition) { + issue(`job graph drift: ${jobId}`); + } + } + const expectedStepKinds: Readonly> = { + merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"], + release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"], + immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"], + vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"], + provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"], + promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "verify-promotion", "upload", "cleanup-promotion"], + production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], + field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], + documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], + }; + for (const [jobId, expected] of Object.entries(expectedStepKinds)) { + const actual = contract.jobs.find(({ id }) => id === jobId)?.steps.map(({ kind }) => kind); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + issue(`canonical job step sequence drift: ${jobId}`); + } + } + const expectedEnvironmentBindings: Readonly []>> = { + merge_gate: [], + release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }], + immutable_build: [], + vulnerability_provider: [ + { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, + { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, + { name: "CI_RUN_ID", value: "${{ gitea.run_id }}" }, + { name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" }, + { name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" }, + { name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" }, + { name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" }, + { name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" }, + { name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" }, + { name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" }, + ], + provenance_provider: [ + { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, + { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, + { name: "CI_RUN_ID", value: "${{ gitea.run_id }}" }, + { name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" }, + { name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" }, + { name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" }, + { name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" }, + { name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" }, + { name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" }, + { name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" }, + ], + promotion: [ + { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, + { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, + { name: "CI_RUN_ID", value: "${{ gitea.run_id }}" }, + { name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" }, + { name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" }, + { name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" }, + { name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" }, + { name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" }, + { name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" }, + { name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" }, + { name: "VULNERABILITY_INVOCATION_NONCE", value: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" }, + { name: "PROVENANCE_INVOCATION_NONCE", value: "${{ needs.provenance_provider.outputs.invocation_nonce }}" }, + ], + production_gate: [], + field_gate: [ + { name: "FIELD_WEB_VITALS_INPUT", value: "${{ vars.FIELD_WEB_VITALS_INPUT }}" }, + { name: "MIN_ELIGIBLE_SAMPLES", value: "${{ vars.MIN_ELIGIBLE_SAMPLES }}" }, + ], + documentation_gate: [], + }; + for (const job of contract.jobs) { + const environmentNames = job.environment.map(({ name }) => name); + if (new Set(environmentNames).size !== environmentNames.length || + JSON.stringify(job.environment) !== JSON.stringify(expectedEnvironmentBindings[job.id])) { + issue(`job environment binding drift: ${job.id}`); + } + } + if (owners.get("FE-GATE-015")?.[0] !== "immutable_build") { + issue("FE-GATE-015 must be owned only by immutable_build"); + } + for (const job of contract.jobs.filter(({ kind }) => kind === "gate-matrix")) { + if (job.gateIds.includes("FE-GATE-015")) issue(`release matrix duplicates FE-GATE-015: ${job.id}`); + } + const browserGateIds = contract.jobs.flatMap(({ browserGateIds }) => browserGateIds).sort(asciiCompare); + if (JSON.stringify(browserGateIds) !== JSON.stringify(["FE-GATE-008", "FE-GATE-009", "FE-GATE-026"])) { + issue(`browser gate set drift: ${browserGateIds.join(",")}`); + } + for (const job of contract.jobs) { + const browserSteps = job.steps.filter(({ kind }) => kind === "browser-install").length; + const expected = job.browserGateIds.length > 0 ? 1 : 0; + if (browserSteps !== expected) issue(`browser install step drift: ${job.id}`); + } + for (const cycle of findCycles(contract.jobs.map((job) => [job.id, job.needs] as const))) { + issue(`job dependency cycle: ${cycle.join(" -> ")}`); + } + + const uploads = new Map }>(); + for (const job of contract.jobs) { + for (const step of job.steps) { + if (step.kind !== "upload") continue; + const previous = uploads.get(step.transferId); + if (previous) issue(`duplicate upload transfer ${step.transferId}: ${previous.producer}, ${job.id}`); + uploads.set(step.transferId, { producer: job.id, step }); + } + } + const dependencies = new Map(contract.jobs.map((job) => [job.id, job.needs])); + for (const job of contract.jobs) { + for (const step of job.steps) { + if (step.kind !== "download") continue; + const upload = uploads.get(step.transferId); + if (!upload) issue(`unknown download transfer ${step.transferId} for ${job.id}`); + else if (!isDependencyReachable(job.id, upload.producer, dependencies)) { + issue(`download producer ${upload.producer} is unreachable from ${job.id}`); + } + } + } + const promotion = contract.jobs.find(({ id }) => id === "promotion"); + if (promotion) { + if (promotion.kind !== "promotion") issue("promotion job kind drift"); + if (promotion.gateIds.length > 0 || promotion.steps.some(({ kind }) => kind === "run-gate")) { + issue("promotion job must not own or run a gate"); + } + const forbidden = promotion.steps.filter(({ kind }) => + ["archive-candidate", "run-provider"].includes(kind), + ); + if (forbidden.length > 0) issue("promotion job must not build or rebuild candidate bytes"); + const order = promotion.steps.map(({ kind }) => kind); + const verificationIndex = order.indexOf("verify-promotion"); + const uploadIndex = order.indexOf("upload"); + if (verificationIndex < 0 || uploadIndex !== verificationIndex + 1) { + issue("promotion verification and upload must be immediately adjacent"); + } + const upload = promotion.steps[uploadIndex]; + if (upload?.kind === "upload" && upload.always) { + issue("promotion upload must not use always"); + } + const cleanupIndex = order.indexOf("cleanup-promotion"); + if ( + order.includes("extract") || + verificationIndex < order.lastIndexOf("download") || + uploadIndex < verificationIndex || + cleanupIndex !== uploadIndex + 1 + ) { + issue("promotion formula order must download, finalize, upload, then cleanup without extraction"); + } + } + const immutable = contract.jobs.find(({ id }) => id === "immutable_build"); + const archive = immutable?.steps.find(({ kind }) => kind === "archive-candidate"); + const expectedArchiveMembers = ["dist", ...RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH]; + if ( + !archive || + archive.kind !== "archive-candidate" || + JSON.stringify(archive.members) !== JSON.stringify(expectedArchiveMembers) + ) { + issue("immutable candidate archive member set drift"); + } + if ( + archive?.kind === "archive-candidate" && + (archive.stepId !== "candidate" || + archive.archivePath !== ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" || + archive.archiveOutputName !== "archive_sha256" || + archive.distOutputName !== "dist_sha256") + ) { + issue("immutable candidate output identity drift"); + } + const promotionUpload = promotion?.steps.find( + (step) => step.kind === "upload" && step.transferId === "promoted-release", + ); + if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_UPLOAD_PATHS)) { + issue("promotion upload bundle must contain the exact five typed paths"); + } + + validateCanonicalStepFields(contract, issue); +} + +function validateCanonicalStepFields( + contract: z.infer, + issue: (message: string, path?: PropertyKey[]) => void, +): void { + const immutable = contract.jobs.find(({ id }) => id === "immutable_build"); + const immutableArchive = immutable?.steps.find(({ kind }) => kind === "archive-candidate"); + const immutableUpload = immutable?.steps.find( + (step) => step.kind === "upload" && step.transferId === "release-candidate", + ); + if ( + !immutableArchive || + immutableArchive.kind !== "archive-candidate" || + !immutableUpload || + immutableUpload.kind !== "upload" || + JSON.stringify(immutableUpload.paths) !== JSON.stringify([immutableArchive.archivePath]) + ) { + issue("immutable archive and upload fields must remain linked"); + } + + const providerExpectations = { + vulnerability_provider: { + provider: "vulnerability", + stepId: "supervise_vulnerability", + downloadPath: ".release/vulnerability-candidate", + archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + rawPath: "provider-evidence/untrusted/vulnerability-report.json", + rawName: "VULNERABILITY_REPORT_PATH", + sealedPath: "provider-evidence/vulnerability-report.json", + transferId: "vulnerability-provider-evidence", + }, + provenance_provider: { + provider: "provenance", + stepId: "supervise_provenance", + downloadPath: ".release/provenance-candidate", + archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", + rawPath: "provider-evidence/untrusted/provenance-attestation.json", + rawName: "PROVENANCE_ATTESTATION_PATH", + sealedPath: "provider-evidence/provenance-attestation.json", + transferId: "provenance-provider-evidence", + }, + } as const; + for (const [jobId, expected] of Object.entries(providerExpectations)) { + const job = contract.jobs.find(({ id }) => id === jobId); + const environment = new Map(job?.environment.map(({ name, value }) => [name, value])); + const download = job?.steps.find(({ kind }) => kind === "download"); + const runProvider = job?.steps.find(({ kind }) => kind === "run-provider"); + const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence"); + const upload = job?.steps.find( + (step) => step.kind === "upload" && step.transferId === expected.transferId, + ); + if ( + !download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath || + !runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || runProvider.stepId !== expected.stepId || + !validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider || + environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath || + environment.get(expected.rawName) !== expected.rawPath || + environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath || + !upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath]) + ) { + issue(`provider archive, extraction, evidence, and upload fields must remain linked: ${jobId}`); + } + } + + const promotion = contract.jobs.find(({ id }) => id === "promotion"); + const promotionDownloads = promotion?.steps.filter(({ kind }) => kind === "download"); + const expectedDownloads = [ + { kind: "download", transferId: "release-candidate", path: ".release/candidate" }, + { kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" }, + { kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" }, + ]; + const promotionFinalizer = promotion?.steps.find(({ kind }) => kind === "verify-promotion"); + const promotionCleanup = promotion?.steps.find(({ kind }) => kind === "cleanup-promotion"); + if ( + JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) || + !promotionFinalizer || + promotionFinalizer.kind !== "verify-promotion" || + promotionFinalizer.stepId !== "finalize" || + !promotionCleanup || + promotionCleanup.kind !== "cleanup-promotion" || + promotionCleanup.finalizerStepId !== "finalize" + ) { + issue("promotion download fields and finalizer/cleanup step identities must remain linked"); + } +} + +function validateJobStepKinds( + job: z.infer, + issue: (message: string, path?: PropertyKey[]) => void, +): void { + const allowed: Readonly["kind"], ReadonlySet>> = { + "gate-matrix": new Set(["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"]), + "gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]), + immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]), + provider: new Set(["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"]), + promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]), + }; + for (const step of job.steps) { + if (!allowed[job.kind].has(step.kind)) { + issue(`step kind ${step.kind} is forbidden for ${job.kind} job ${job.id}`); + } + } + const kinds = job.steps.map(({ kind }) => kind); + if (job.kind === "provider") { + const providerIndex = kinds.indexOf("run-provider"); + const validateProviderIndex = kinds.indexOf("validate-provider-evidence"); + const uploadIndex = kinds.indexOf("upload"); + if ( + providerIndex < kinds.lastIndexOf("download") || + validateProviderIndex < providerIndex || + uploadIndex < validateProviderIndex + ) { + issue(`provider execution/validation/upload order drift: ${job.id}`); + } + } +} + +function findCycles(entries: readonly (readonly [string, readonly string[]])[]): string[][] { + const graph = new Map(entries); + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + const visit = (node: string): void => { + if (visiting.has(node)) { + const start = stack.indexOf(node); + cycles.push([...stack.slice(start), node]); + return; + } + if (visited.has(node) || !graph.has(node)) return; + visiting.add(node); + stack.push(node); + for (const next of graph.get(node) ?? []) visit(next); + stack.pop(); + visiting.delete(node); + visited.add(node); + }; + for (const node of graph.keys()) visit(node); + return cycles; +} + +function isDependencyReachable( + consumer: string, + producer: string, + graph: ReadonlyMap, +): boolean { + const pending = [...(graph.get(consumer) ?? [])]; + const visited = new Set(); + while (pending.length > 0) { + const current = pending.shift()!; + if (current === producer) return true; + if (visited.has(current)) continue; + visited.add(current); + pending.push(...(graph.get(current) ?? [])); + } + return false; +} + +export function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/scripts/contracts/promotion-artifacts.ts b/scripts/contracts/promotion-artifacts.ts new file mode 100644 index 0000000..0133e66 --- /dev/null +++ b/scripts/contracts/promotion-artifacts.ts @@ -0,0 +1,13 @@ +export const PROMOTED_FILE_NAMES = Object.freeze([ + "release-candidate.tar.gz", + "vulnerability-report.json", + "provenance-attestation.json", + "provider-verification.json", + "promotion-verification.json", +] as const); + +export type PromotedFileName = (typeof PROMOTED_FILE_NAMES)[number]; + +export const PROMOTED_UPLOAD_PATHS = Object.freeze( + PROMOTED_FILE_NAMES.map((name) => `\${{ steps.finalize.outputs.staging_root }}/${name}`), +); diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts new file mode 100644 index 0000000..24e40cc --- /dev/null +++ b/scripts/contracts/release-artifacts.ts @@ -0,0 +1,1415 @@ +import { z } from "zod"; + +export * from "../../src/contracts/release-artifacts.ts"; + +import { MANUAL_A11Y_ROUTE_IDS } from "../lib/manual-a11y-evidence.ts"; + +const nonEmptyString = z.string().min(1); +const timestamp = z.iso.datetime(); +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +const jsonObject = z.record(z.string(), z.json()); + +const canonicalTimestamp = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + .refine((value) => new Date(value).toISOString() === value, { + message: "must be a canonical ISO-8601 UTC timestamp", + }); +const safeRepositoryPath = z + .string() + .min(1) + .max(1_024) + .refine( + (value) => + !value.startsWith("-") && + !value.startsWith("/") && + !value.includes("\\") && + !value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") && + ![...value].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f; + }), + { message: "must be a safe canonical repository-relative path" }, + ); +const assessmentInputRowSchema = z + .object({ + path: safeRepositoryPath, + bytes: z.int().nonnegative().max(268_435_456), + sha256, + }) + .strict(); +const assessmentStatusSchema = z.enum(["PASS", "FAIL"]); + +function addCanonicalInputIssues( + rows: readonly Readonly<{ path: string }>[], + pathPrefix: "policyInputs" | "evidenceInputs", + context: z.RefinementCtx, +): void { + const paths = rows.map(({ path }) => path); + const canonical = [...paths].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); + if (JSON.stringify(paths) !== JSON.stringify(canonical)) { + context.addIssue({ + code: "custom", + path: [pathPrefix], + message: "must be in canonical ASCII path order", + }); + } + if (new Set(paths).size !== paths.length) { + context.addIssue({ + code: "custom", + path: [pathPrefix], + message: "must not contain duplicate paths", + }); + } +} + +export const localEvidenceAssessmentArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + artifactType: z.literal("local-evidence-assessment"), + generatedAt: canonicalTimestamp, + status: assessmentStatusSchema, + verifier: z + .object({ + id: nonEmptyString, + version: nonEmptyString, + sourceSha256: sha256, + }) + .strict(), + source: z + .object({ + revision: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u), + sourceSetSha256: sha256, + }) + .strict(), + candidate: z + .object({ distSha256: sha256, lockfileSha256: sha256, sbomSha256: sha256 }) + .strict(), + secretScan: z + .object({ + policySha256: sha256, + sarifSha256: sha256, + scanInputSha256: sha256, + }) + .strict(), + policyInputs: z.array(assessmentInputRowSchema).min(1).max(256), + evidenceInputs: z.array(assessmentInputRowSchema).min(1).max(4_096), + checks: z + .object({ + release: assessmentStatusSchema, + supplyChain: assessmentStatusSchema, + dependencyPolicy: assessmentStatusSchema, + licensePolicy: assessmentStatusSchema, + vulnerabilityPolicy: assessmentStatusSchema, + secretScan: assessmentStatusSchema, + }) + .strict(), + failures: z.array(z.string()), + }) + .strict() + .superRefine((assessment, context) => { + addCanonicalInputIssues(assessment.policyInputs, "policyInputs", context); + addCanonicalInputIssues(assessment.evidenceInputs, "evidenceInputs", context); + const failedChecks = Object.values(assessment.checks).filter( + (status) => status === "FAIL", + ); + if ( + assessment.status === "PASS" && + (failedChecks.length > 0 || assessment.failures.length > 0) + ) { + context.addIssue({ + code: "custom", + path: ["status"], + message: "PASS requires all six checks PASS and no failures", + }); + } + if ( + assessment.status === "FAIL" && + (failedChecks.length === 0 || assessment.failures.length === 0) + ) { + context.addIssue({ + code: "custom", + path: ["status"], + message: "FAIL requires a failed check and a failure diagnostic", + }); + } + }); + +export type LocalEvidenceAssessment = z.infer< + typeof localEvidenceAssessmentArtifactSchema +>; + +export const moduleInventoryArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + chunks: z.array( + z + .object({ + fileName: nonEmptyString, + modules: z.array(nonEmptyString), + }) + .strict(), + ), + }) + .strict(); + +export const jsonSchemaDocumentArtifactSchema = z + .object({ + $schema: z.literal("https://json-schema.org/draft/2020-12/schema"), + }) + .catchall(z.json()); + +const dependencyInventoryRowSchema = z + .object({ + name: nonEmptyString, + version: nonEmptyString, + direct: z.boolean(), + scope: z.enum(["production", "development"]), + optional: z.boolean(), + license: nonEmptyString, + integrity: z.string().regex(/^sha512-/u), + dependencies: z.array(nonEmptyString), + }) + .strict(); + +export const dependencyInventoryArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + packageManager: nonEmptyString, + lockfileSha256: sha256, + dependencyCount: z.int().positive(), + directDependencyCount: z.int().positive(), + dependencies: z.array(dependencyInventoryRowSchema).min(1), + }) + .strict() + .superRefine((inventory, context) => { + if (inventory.dependencyCount !== inventory.dependencies.length) { + context.addIssue({ + code: "custom", + path: ["dependencyCount"], + message: "must equal dependencies.length", + }); + } + const actualDirect = inventory.dependencies.filter( + (dependency) => dependency.direct, + ).length; + if (inventory.directDependencyCount !== actualDirect) { + context.addIssue({ + code: "custom", + path: ["directDependencyCount"], + message: "must equal the number of direct dependencies", + }); + } + }); + +const dependencyUpgradeSchema = z + .object({ + name: nonEmptyString, + from: nonEmptyString, + to: nonEmptyString, + }) + .strict(); + +export const dependencyDiffSchema = z + .object({ + added: z.array(nonEmptyString), + removed: z.array(nonEmptyString), + changed: z.array(nonEmptyString), + upgrades: z.array(dependencyUpgradeSchema), + }) + .strict(); + +export const supplyChainVerificationArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + localStatus: z.enum(["PASS", "FAIL"]), + promotionStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + lockfileSha256: sha256, + sourceSetSha256: sha256, + distSha256: sha256, + sbomSha256: sha256, + dependencyDiff: dependencyDiffSchema, + highRiskReview: z.array(nonEmptyString), + vulnerabilityStatus: z.enum(["PASS", "FAIL", "FAIL_UNVERIFIED"]), + provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + failures: z.array(z.string()), + }) + .strict(); + +const registryChangeSchema = z + .object({ + changeId: nonEmptyString, + registryId: nonEmptyString, + rowName: nonEmptyString, + field: nonEmptyString, + kind: nonEmptyString, + impact: z.enum(["none", "additive", "behavior-change", "breaking"]), + before: z.json().optional(), + after: z.json().optional(), + }) + .strict(); + +const registryArtifactRowSchema = z + .object({ + registryId: nonEmptyString, + owner: nonEmptyString, + source: nonEmptyString, + rowCount: z.int().nonnegative(), + contract: jsonObject, + rows: jsonObject, + }) + .strict(); + +const registrySnapshotBaseArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + generatedAt: timestamp, + baselineDigest: sha256.nullable(), + currentDigest: sha256, + compatibility: z + .object({ + impact: z.enum([ + "not-evaluated", + "none", + "additive", + "behavior-change", + "breaking", + ]), + changes: z.array(registryChangeSchema), + }) + .strict(), + }) + .strict(); + +const successfulRegistrySnapshotArtifactSchema = + registrySnapshotBaseArtifactSchema.extend({ + failures: z.array(z.string()).max(0), + registries: z.array(registryArtifactRowSchema).length(11), + }); + +const failedRegistrySnapshotArtifactSchema = + registrySnapshotBaseArtifactSchema.extend({ + failures: z.array(z.string()).min(1), + registries: z.array(registryArtifactRowSchema), + }); + +export const registrySnapshotArtifactSchema = z.union([ + successfulRegistrySnapshotArtifactSchema, + failedRegistrySnapshotArtifactSchema, +]); + +export const registryGovernanceRunArtifactSchema = z.union([ + registrySnapshotBaseArtifactSchema.extend({ + failures: z.array(z.string()).max(0), + registries: z.array(registryArtifactRowSchema).min(1), + }), + failedRegistrySnapshotArtifactSchema, +]); + +const outputDigestSchema = z + .object({ + path: nonEmptyString, + bytes: z.int().nonnegative(), + gzipBytes: z.int().nonnegative(), + sha256, + }) + .strict(); + +const bundleOutputInventoryShape = { + schemaVersion: z.literal(1), + generatedAt: timestamp, + context: z + .object({ + nodeVersion: nonEmptyString, + packageManager: nonEmptyString, + runnerImage: nonEmptyString, + }) + .strict(), + outputs: z.array(outputDigestSchema).min(1), +} as const; + +function addUniqueBundleOutputIssues( + artifact: Readonly<{ outputs: readonly Readonly<{ path: string }>[] }>, + context: z.RefinementCtx, +): void { + const paths = artifact.outputs.map(({ path }) => path); + if (new Set(paths).size !== paths.length) { + context.addIssue({ + code: "custom", + path: ["outputs"], + message: "output paths must be unique", + }); + } +} + +export const bundleOutputInventoryArtifactSchema = z + .object(bundleOutputInventoryShape) + .strict() + .superRefine(addUniqueBundleOutputIssues); + +const bundleMeasurementSchema = z + .object({ path: nonEmptyString, gzipBytes: z.int().nonnegative() }) + .strict(); +const bundleClassificationSchema = z + .object({ + initialFiles: z.array(nonEmptyString), + lazyFiles: z.array(nonEmptyString), + missingImports: z.array(nonEmptyString), + }) + .strict(); +const bundleThresholdsSchema = z + .object({ + initialJsGzipBytes: z.int().positive(), + lazyChunkGzipBytes: z.int().positive(), + }) + .strict(); +const bundleBudgetResultSchema = z + .object({ + initialPassed: z.boolean(), + lazyResults: z.array( + bundleMeasurementSchema.extend({ + threshold: z.int().positive(), + passed: z.boolean(), + }), + ), + passed: z.boolean(), + }) + .strict(); + +export const bundlePerformanceArtifactSchema = z + .object({ + ...bundleOutputInventoryShape, + measurements: z + .object({ + initialJsGzipBytes: z.int().nonnegative(), + lazyChunks: z.array(bundleMeasurementSchema), + }) + .strict(), + classification: bundleClassificationSchema, + missingOutputs: z.array(nonEmptyString), + thresholds: bundleThresholdsSchema, + results: bundleBudgetResultSchema, + fixtures: z.tuple([ + z.object({ name: z.literal("initial-js-over-budget"), passed: z.boolean() }).strict(), + z.object({ name: z.literal("lazy-chunk-over-budget"), passed: z.boolean() }).strict(), + ]), + passed: z.boolean(), + }) + .strict() + .superRefine((artifact, context) => { + addUniqueBundleOutputIssues(artifact, context); + const issue = (path: PropertyKey[], message: string) => + context.addIssue({ code: "custom", path, message }); + const uniqueSorted = (values: readonly string[]) => + new Set(values).size === values.length && + JSON.stringify(values) === JSON.stringify([...values].sort()); + for (const [field, values] of [ + ["initialFiles", artifact.classification.initialFiles], + ["lazyFiles", artifact.classification.lazyFiles], + ["missingImports", artifact.classification.missingImports], + ["missingOutputs", artifact.missingOutputs], + ] as const) { + if (!uniqueSorted(values)) { + issue( + field === "missingOutputs" ? [field] : ["classification", field], + "paths must be unique and sorted", + ); + } + } + const initial = new Set(artifact.classification.initialFiles); + if (artifact.classification.lazyFiles.some((file) => initial.has(file))) { + issue(["classification"], "initial and lazy files must be disjoint"); + } + const outputs = new Map( + artifact.outputs.map((output) => [output.path.replace(/^dist\//u, ""), output]), + ); + const expectedMissing = [ + ...artifact.classification.initialFiles, + ...artifact.classification.lazyFiles, + ].filter((file) => !outputs.has(file)).sort(); + if (JSON.stringify(artifact.missingOutputs) !== JSON.stringify(expectedMissing)) { + issue(["missingOutputs"], "must equal classified JavaScript outputs not found in inventory"); + } + const expectedInitialBytes = artifact.classification.initialFiles.reduce( + (total, file) => total + (outputs.get(file)?.gzipBytes ?? 0), + 0, + ); + if (artifact.measurements.initialJsGzipBytes !== expectedInitialBytes) { + issue(["measurements", "initialJsGzipBytes"], "must equal classified initial output bytes"); + } + const expectedLazyChunks = artifact.classification.lazyFiles.map((file) => ({ + path: file, + gzipBytes: outputs.get(file)?.gzipBytes ?? 0, + })); + if (JSON.stringify(artifact.measurements.lazyChunks) !== JSON.stringify(expectedLazyChunks)) { + issue(["measurements", "lazyChunks"], "must equal classified lazy output bytes"); + } + const expectedInitialPassed = + artifact.measurements.initialJsGzipBytes <= artifact.thresholds.initialJsGzipBytes; + if (artifact.results.initialPassed !== expectedInitialPassed) { + issue(["results", "initialPassed"], "must agree with initial threshold"); + } + const expectedLazyResults = artifact.measurements.lazyChunks.map((chunk) => ({ + ...chunk, + threshold: artifact.thresholds.lazyChunkGzipBytes, + passed: chunk.gzipBytes <= artifact.thresholds.lazyChunkGzipBytes, + })); + if (JSON.stringify(artifact.results.lazyResults) !== JSON.stringify(expectedLazyResults)) { + issue(["results", "lazyResults"], "must agree with lazy measurements and threshold"); + } + const expectedBudgetPassed = + expectedInitialPassed && expectedLazyResults.every(({ passed }) => passed); + if (artifact.results.passed !== expectedBudgetPassed) { + issue(["results", "passed"], "must agree with budget results"); + } + const expectedPassed = + expectedBudgetPassed && + artifact.fixtures.every(({ passed }) => passed) && + artifact.classification.missingImports.length === 0 && + artifact.missingOutputs.length === 0; + if (artifact.passed !== expectedPassed) { + issue(["passed"], "must agree with budgets, fixtures, and manifest integrity"); + } + }); + +const cyclonedxComponentSchema = z + .object({ + type: z.literal("library"), + "bom-ref": nonEmptyString, + name: nonEmptyString, + version: nonEmptyString, + scope: z.enum(["optional", "required"]), + hashes: z.array( + z.object({ alg: z.literal("SHA-512"), content: nonEmptyString }).strict(), + ), + licenses: z.array( + z.object({ expression: nonEmptyString }).strict(), + ), + properties: z.array( + z.object({ name: nonEmptyString, value: nonEmptyString }).strict(), + ), + }) + .strict(); + +export const sbomArtifactSchema = z + .object({ + bomFormat: z.literal("CycloneDX"), + specVersion: z.literal("1.6"), + serialNumber: nonEmptyString, + version: z.literal(1), + metadata: z + .object({ + component: z + .object({ + type: z.literal("application"), + name: nonEmptyString, + version: nonEmptyString, + }) + .strict(), + properties: z.array( + z.object({ name: nonEmptyString, value: nonEmptyString }).strict(), + ), + }) + .strict(), + components: z.array(cyclonedxComponentSchema), + dependencies: z.array( + z + .object({ ref: nonEmptyString, dependsOn: z.array(nonEmptyString) }) + .strict(), + ), + }) + .strict(); + +export const provenanceArtifactSchema = z + .object({ + _type: z.literal("https://in-toto.io/Statement/v1"), + subject: z + .array( + z + .object({ + name: z.literal("dist"), + digest: z.object({ sha256 }).strict(), + }) + .strict(), + ) + .length(1), + predicateType: z.literal("https://slsa.dev/provenance/v1"), + predicate: z + .object({ + buildDefinition: z + .object({ + buildType: nonEmptyString, + externalParameters: jsonObject, + internalParameters: jsonObject, + resolvedDependencies: z.array( + z + .object({ uri: nonEmptyString, digest: z.object({ sha256 }).strict() }) + .strict(), + ), + }) + .strict(), + runDetails: z + .object({ + builder: z.object({ id: nonEmptyString }).strict(), + metadata: z.object({ invocationId: nonEmptyString }).strict(), + }) + .strict(), + materials: z + .object({ lockfileSha256: sha256, sourceSetSha256: sha256, sbomSha256: sha256 }) + .strict(), + }) + .strict(), + }) + .strict(); + +export const dependencyDiffArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + baselineDigest: sha256.nullable(), + currentDigest: sha256, + ...dependencyDiffSchema.shape, + highRisk: z.array(nonEmptyString), + reviewFailures: z.array(z.string()), + }) + .strict(); + +export const licenseReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + status: z.enum(["PASS", "FAIL"]), + dependencyCount: z.int().nonnegative(), + results: z.array( + z + .object({ + package: nonEmptyString, + license: nonEmptyString, + passed: z.boolean(), + reason: z.string().nullable(), + }) + .strict(), + ), + failures: z.array(z.string()), + }) + .strict(); + +export const vulnerabilityReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + provider: nonEmptyString, + scannedLockfileSha256: sha256, + status: z.enum(["PASS", "FAIL", "FAIL_UNVERIFIED"]), + findings: z.array(jsonObject), + exceptionsApplied: z.array(jsonObject), + failures: z.array(z.string()), + blocking: z.array(z.string()), + }) + .strict(); + +export const fieldWebVitalsArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + window: z + .object({ days: z.literal(28), start: timestamp, end: timestamp }) + .strict(), + context: z + .object({ + source: nonEmptyString, + sourceSystem: z.string().nullable(), + exportId: z.string().nullable(), + network: z.literal("production-real-user"), + routeAggregation: z.literal("route-id-only"), + releaseId: z.string().nullable(), + privacyApprovalRef: z.string().nullable(), + thresholdDecisionRef: z.string().nullable(), + validationFailures: z.array(z.string()), + }) + .strict(), + metrics: z + .object({ + p75LcpMs: z.number().finite().nonnegative().nullable(), + p75Cls: z.number().finite().nonnegative().nullable(), + p75InpMs: z.number().finite().nonnegative().nullable(), + }) + .strict(), + thresholds: z + .object({ + p75LcpMs: z.number().finite().nonnegative(), + p75Cls: z.number().finite().nonnegative(), + p75InpMs: z.number().finite().nonnegative(), + minimumEligibleSamples: z.int().positive().nullable(), + }) + .strict(), + eligibility: z + .object({ + consentRequired: z.literal(true), + totalSamples: z.int().nonnegative(), + eligibleSamples: z.int().nonnegative(), + minimumEligibleSamples: z.int().positive().nullable(), + routeSamples: z.record(z.string(), z.int().nonnegative()), + }) + .strict(), + status: z.enum(["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]), + passed: z.boolean(), + }) + .strict(); + +export const labPerformanceArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + context: jsonObject, + metrics: jsonObject, + thresholds: jsonObject, + fixtures: z.array( + z.object({ name: nonEmptyString, passed: z.boolean() }).strict(), + ), + passed: z.boolean(), + }) + .strict(); + +export const releaseVerificationArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + artifact: z + .object({ + checked: z.boolean(), + compatible: z.boolean(), + mismatches: z.array(z.string()), + releaseId: nonEmptyString, + }) + .strict(), + fixtures: z.array( + z + .object({ + name: nonEmptyString, + expectedCompatible: z.boolean(), + actualCompatible: z.boolean(), + mismatches: z.array(z.string()), + passed: z.boolean(), + }) + .strict(), + ), + passed: z.boolean(), + }) + .strict(); + +export const runbookRecordArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + runbookId: z.string().regex(/^FE-RB-00[1-5]$/u), + releaseId: nonEmptyString, + drillTimestamp: timestamp, + triggerInjected: nonEmptyString, + triggerAsserted: z.boolean(), + containmentAsserted: z.boolean(), + escalationPathAsserted: z.boolean(), + recoveryAssertions: z.array( + z + .object({ + assertion: nonEmptyString, + evidence: nonEmptyString, + passed: z.boolean(), + }) + .strict(), + ), + negativeFixtureFailedAsExpected: z.boolean(), + windowObservedBucket: nonEmptyString, + providerVerificationRequired: z.boolean(), + passed: z.boolean(), + }) + .strict(); + +const failureList = z.array(nonEmptyString).max(4_096); +const sourceOrFixtureMode = z.enum(["source", "negative-fixture"]); +const namedBooleanResultSchema = z + .object({ id: nonEmptyString, passed: z.boolean() }) + .strict(); + +function addPassedFailureInvariant( + artifact: Readonly<{ passed: boolean; failures: readonly string[] }>, + context: z.RefinementCtx, +): void { + if (artifact.passed !== (artifact.failures.length === 0)) { + context.addIssue({ + code: "custom", + path: ["passed"], + message: "passed must agree with failures", + }); + } +} + +function addUniqueStringIssues( + values: readonly string[], + path: PropertyKey[], + context: z.RefinementCtx, +): void { + if (new Set(values).size !== values.length) { + context.addIssue({ code: "custom", path, message: "must not contain duplicates" }); + } +} + +export const automatedA11yArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + scope: z.array(nonEmptyString).min(1).max(128), + threshold: z.object({ critical: z.literal(0), serious: z.literal(0) }).strict(), + automatedStatus: z.literal("passed"), + manualReview: z.literal("see artifacts/tests/a11y-manual/report.json"), + }) + .strict() + .superRefine((artifact, context) => { + addUniqueStringIssues(artifact.scope, ["scope"], context); + if (JSON.stringify(artifact.scope) !== JSON.stringify(MANUAL_A11Y_ROUTE_IDS)) { + context.addIssue({ code: "custom", path: ["scope"], message: "must match the installed route registry" }); + } + }); + +const manualA11yResultSchema = z + .object({ + routeId: nonEmptyString, + path: nonEmptyString, + reviewer: z.string().nullable(), + reviewedAt: z.string().nullable(), + releaseId: z.string().nullable(), + failures: failureList, + passed: z.boolean(), + }) + .strict() + .superRefine((result, context) => { + if (result.path !== `artifacts/tests/a11y-manual/${result.routeId}.md`) { + context.addIssue({ code: "custom", path: ["path"], message: "path must match routeId" }); + } + const hasIdentity = Boolean( + result.reviewer && + result.releaseId && + result.reviewedAt && + Number.isFinite(Date.parse(result.reviewedAt)), + ); + if (result.passed !== (result.failures.length === 0 && hasIdentity)) { + context.addIssue({ + code: "custom", + path: ["passed"], + message: "passed must agree with failures and review identity", + }); + } + }); + +export const manualA11yReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + scope: z.array(nonEmptyString).min(1).max(128), + results: z.array(manualA11yResultSchema).min(1).max(128), + coherentRelease: z.boolean(), + passed: z.boolean(), + }) + .strict() + .superRefine((artifact, context) => { + const routeIds = artifact.results.map(({ routeId }) => routeId); + addUniqueStringIssues(artifact.scope, ["scope"], context); + addUniqueStringIssues(routeIds, ["results"], context); + if (JSON.stringify(artifact.scope) !== JSON.stringify(MANUAL_A11Y_ROUTE_IDS)) { + context.addIssue({ code: "custom", path: ["scope"], message: "must match the installed route registry" }); + } + if (JSON.stringify(routeIds) !== JSON.stringify(artifact.scope)) { + context.addIssue({ code: "custom", path: ["results"], message: "result routeIds must match scope" }); + } + const releaseIds = artifact.results.map(({ releaseId }) => releaseId); + const coherentRelease = + releaseIds.every((releaseId): releaseId is string => Boolean(releaseId)) && + new Set(releaseIds).size === 1; + if (artifact.coherentRelease !== coherentRelease) { + context.addIssue({ code: "custom", path: ["coherentRelease"], message: "must represent one non-empty releaseId" }); + } + if ( + artifact.passed !== + (coherentRelease && artifact.results.every(({ passed }) => passed)) + ) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with route results" }); + } + }); + +const architectureDependencySchema = z + .object({ + source: nonEmptyString, + target: nonEmptyString, + specifier: nonEmptyString, + kind: z.enum(["local", "external"]), + }) + .strict(); +const architectureUnresolvedSchema = z + .object({ source: nonEmptyString, specifier: nonEmptyString, reason: nonEmptyString }) + .strict(); +const architectureViolationSchema = z + .object({ + rule: nonEmptyString, + severity: nonEmptyString, + source: nonEmptyString, + target: nonEmptyString, + cycle: z.array(nonEmptyString).optional(), + }) + .strict(); +const staticImportGraphSchema = z + .object({ + analyzer: z.literal("babel-parser-node-resolver"), + modules: z.array(nonEmptyString), + dependencies: z.array(architectureDependencySchema), + unresolved: z.array(architectureUnresolvedSchema), + parseFailures: z.array( + z.object({ source: nonEmptyString, reason: nonEmptyString }).strict(), + ), + cycles: z.array(z.array(nonEmptyString).min(1)), + violations: z.array(architectureViolationSchema), + summary: z + .object({ + modules: z.int().nonnegative(), + typescriptModules: z.int().nonnegative(), + dependencies: z.int().nonnegative(), + localDependencies: z.int().nonnegative(), + unresolved: z.int().nonnegative(), + parseFailures: z.int().nonnegative(), + cycles: z.int().nonnegative(), + errors: z.int().nonnegative(), + typeScriptOnlyPolicyPassed: z.boolean(), + nonTypeScriptExecutableSources: z.int().nonnegative(), + }) + .strict(), + fixtureChecks: z + .object({ passed: z.boolean(), checks: z.array(nonEmptyString), failures: failureList }) + .strict(), + typeScriptOnlySourcePolicy: z + .object({ + checkedRoots: z.array(nonEmptyString).min(1), + exceptionsAllowed: z.literal(false), + violations: z.array(nonEmptyString), + passed: z.boolean(), + }) + .strict(), + }) + .strict() + .superRefine((graph, context) => { + const counts = [ + ["modules", graph.modules.length], + ["dependencies", graph.dependencies.length], + ["localDependencies", graph.dependencies.filter(({ kind }) => kind === "local").length], + ["unresolved", graph.unresolved.length], + ["parseFailures", graph.parseFailures.length], + ["cycles", graph.cycles.length], + ["errors", graph.violations.filter(({ severity }) => severity === "error").length], + ["nonTypeScriptExecutableSources", graph.typeScriptOnlySourcePolicy.violations.length], + ] as const; + for (const [field, expected] of counts) { + if (graph.summary[field] !== expected) { + context.addIssue({ code: "custom", path: ["summary", field], message: "count does not match evidence rows" }); + } + } + if (graph.summary.typescriptModules > graph.summary.modules) { + context.addIssue({ code: "custom", path: ["summary", "typescriptModules"], message: "cannot exceed modules" }); + } + if (graph.fixtureChecks.passed !== (graph.fixtureChecks.failures.length === 0)) { + context.addIssue({ code: "custom", path: ["fixtureChecks", "passed"], message: "must agree with failures" }); + } + if ( + graph.typeScriptOnlySourcePolicy.passed !== + (graph.typeScriptOnlySourcePolicy.violations.length === 0) || + graph.summary.typeScriptOnlyPolicyPassed !== graph.typeScriptOnlySourcePolicy.passed + ) { + context.addIssue({ code: "custom", path: ["typeScriptOnlySourcePolicy", "passed"], message: "must agree with violations and summary" }); + } + }); +const dependencyCruiserSummarySchema = z + .object({ + violations: z.array(jsonObject), + error: z.int().nonnegative(), + warn: z.int().nonnegative(), + info: z.int().nonnegative(), + ignore: z.int().nonnegative(), + totalCruised: z.int().nonnegative(), + totalDependenciesCruised: z.int().nonnegative(), + }) + .catchall(z.json()); +export const architectureDependencyReportArtifactSchema = z.union([ + z + .object({ + modules: z.array(jsonObject), + summary: dependencyCruiserSummarySchema, + staticImportGraph: staticImportGraphSchema, + }) + .strict(), + z + .object({ + summary: z.object({ errors: z.literal(1) }).strict(), + dependencyCruiserOutput: z.string(), + staticImportGraph: staticImportGraphSchema, + }) + .strict(), +]); + +export const designSystemReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + mode: sourceOrFixtureMode, + checkedTokenCount: z.int().positive(), + failures: failureList, + passed: z.boolean(), + }) + .strict() + .superRefine(addPassedFailureInvariant); + +export const i18nReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + mode: sourceOrFixtureMode, + localeCount: z.int().positive(), + messageKeyCount: z.int().positive(), + checkedFiles: z.int().nonnegative(), + failures: failureList, + passed: z.boolean(), + }) + .strict() + .superRefine(addPassedFailureInvariant); + +export const diagnosticsReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + mode: sourceOrFixtureMode, + telemetryEventCount: z.int().positive(), + diagnosticEventCount: z.int().positive(), + checkedFiles: z.int().nonnegative(), + failures: failureList, + passed: z.boolean(), + }) + .strict() + .superRefine(addPassedFailureInvariant); + +export const realtimeBoundariesArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + sourceRoot: nonEmptyString, + violations: z.array( + z + .object({ + ruleId: z.enum([ + "NATIVE_REALTIME_API_OUTSIDE_ADAPTER", + "PRESENTATION_INTERVAL_OWNER", + "UNSELECTED_REALTIME_RUNTIME_COMPOSED", + ]), + file: nonEmptyString, + line: z.int().positive(), + }) + .strict(), + ), + passed: z.boolean(), + }) + .strict() + .superRefine((artifact, context) => { + if (artifact.passed !== (artifact.violations.length === 0)) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with violations" }); + } + const keys = artifact.violations.map(({ ruleId, file, line }) => `${file}\0${line}\0${ruleId}`); + addUniqueStringIssues(keys, ["violations"], context); + }); + +const optionalRecipeBundleOutputSchema = z + .object({ + fileName: nonEmptyString, + bytes: z.int().nonnegative(), + gzipBytes: z.int().nonnegative(), + sha256, + }) + .strict(); +const optionalRecipeBundleMeasurementSchema = z + .object({ + recipeId: nonEmptyString, + sourceRoots: z.array(nonEmptyString).min(1), + sourceFileCount: z.int().positive(), + toolchain: z + .object({ + bundler: z.literal("vite"), + viteVersion: nonEmptyString, + mode: z.literal("production"), + target: z.literal("es2022"), + format: z.literal("es"), + minifier: z.literal("esbuild"), + treeshake: z.literal(false), + compression: z.literal("node-zlib-gzip"), + }) + .strict(), + outputs: z.array(optionalRecipeBundleOutputSchema).min(1), + bytes: z.int().nonnegative(), + gzipBytes: z.int().nonnegative(), + bundleBudgetGzipBytes: z.int().positive(), + remainingGzipBytes: z.int(), + sha256, + passed: z.boolean(), + }) + .strict() + .superRefine((measurement, context) => { + if (measurement.bytes !== measurement.outputs.reduce((total, output) => total + output.bytes, 0)) { + context.addIssue({ code: "custom", path: ["bytes"], message: "must equal output bytes" }); + } + if (measurement.gzipBytes !== measurement.outputs.reduce((total, output) => total + output.gzipBytes, 0)) { + context.addIssue({ code: "custom", path: ["gzipBytes"], message: "must equal output gzip bytes" }); + } + if (measurement.remainingGzipBytes !== measurement.bundleBudgetGzipBytes - measurement.gzipBytes) { + context.addIssue({ code: "custom", path: ["remainingGzipBytes"], message: "must equal budget minus gzip bytes" }); + } + if (measurement.passed !== (measurement.gzipBytes <= measurement.bundleBudgetGzipBytes)) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with bundle budget" }); + } + }); +const optionalRecipeReferenceRuntimeSchema = z + .object({ + status: z.literal("AVAILABLE_NOT_COMPOSED"), + coveredCapabilities: z.array(nonEmptyString).min(1), + sourceRoots: z.array(nonEmptyString).min(1), + conformanceScripts: z.array(nonEmptyString).min(1), + productionComposition: z.literal(false), + }) + .strict(); +const optionalRecipeViolationSchema = z + .object({ ruleId: nonEmptyString, path: nonEmptyString, detail: nonEmptyString.optional() }) + .strict(); +export const optionalRecipesArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + decisionId: z.literal("VD-10"), + selectedCapabilities: z.array(nonEmptyString).max(0), + referenceRuntimes: z.array( + z.object({ id: nonEmptyString, referenceRuntime: optionalRecipeReferenceRuntimeSchema }).strict(), + ).min(1), + recipeCount: z.int().nonnegative(), + productionRuntimeDependencies: z.array(nonEmptyString).nullable(), + referenceRuntimeBundleBudgets: z.array(optionalRecipeBundleMeasurementSchema).min(1), + bundleStatus: z.enum(["PASS", "FAIL", "NOT_BUILT"]), + violations: z.array(optionalRecipeViolationSchema), + passed: z.boolean(), + }) + .strict() + .superRefine((artifact, context) => { + if (artifact.recipeCount < artifact.referenceRuntimes.length) { + context.addIssue({ code: "custom", path: ["recipeCount"], message: "cannot be smaller than reference runtimes" }); + } + if (artifact.passed !== (artifact.violations.length === 0)) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with violations" }); + } + const runtimeIds = artifact.referenceRuntimes.map(({ id }) => id).sort(); + const budgetIds = artifact.referenceRuntimeBundleBudgets.map(({ recipeId }) => recipeId).sort(); + addUniqueStringIssues(runtimeIds, ["referenceRuntimes"], context); + addUniqueStringIssues(budgetIds, ["referenceRuntimeBundleBudgets"], context); + if (JSON.stringify(runtimeIds) !== JSON.stringify(budgetIds)) { + context.addIssue({ code: "custom", path: ["referenceRuntimeBundleBudgets"], message: "must cover every reference runtime" }); + } + }); + +const OPTIONAL_RECIPE_FIXTURE_IDS = [ + "cleanup-omission", + "unselected-runtime-dependency", + "server-state-policy", + "vendor-direct-import", + "credential-leak", + "server-state-source-duplication", + "production-imports-recipe", + "reference-runtime-not-composed", + "reference-runtime-not-bundled", + "reference-runtime-module-not-bundled", + "reference-runtime-bundle-over-budget", +] as const; +const OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS = [ + "file-transfer", + "offline-indexeddb", + "realtime", + "service-worker-pwa", +] as const; +export const optionalRecipeFixturesArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + results: z.array( + namedBooleanResultSchema.extend({ id: z.enum(OPTIONAL_RECIPE_FIXTURE_IDS) }), + ).length(OPTIONAL_RECIPE_FIXTURE_IDS.length), + bundleBudgetFixtures: z.array( + z + .object({ + recipeId: z.enum(OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS), + gzipBytes: z.int().nonnegative(), + fixtureBudgetGzipBytes: z.int().positive(), + rejected: z.boolean(), + }) + .strict(), + ).length(OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS.length), + passed: z.boolean(), + }) + .strict() + .superRefine((artifact, context) => { + addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context); + addUniqueStringIssues(artifact.bundleBudgetFixtures.map(({ recipeId }) => recipeId), ["bundleBudgetFixtures"], context); + if (artifact.passed !== artifact.results.every(({ passed }) => passed)) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with fixture results" }); + } + artifact.bundleBudgetFixtures.forEach((fixture, index) => { + if (fixture.rejected !== (fixture.gzipBytes > fixture.fixtureBudgetGzipBytes)) { + context.addIssue({ code: "custom", path: ["bundleBudgetFixtures", index, "rejected"], message: "must agree with fixture budget" }); + } + }); + }); + +const registryCompatibilityValueSchema = z.union([ + z.enum(["none", "additive", "behavior-change", "breaking"]), + z.boolean(), +]); +const REGISTRY_COMPATIBILITY_FIXTURE_IDS = [ + "ordering-only", + "row-addition", + "behavior-change", + "row-removal", + "field-type-narrowing", + "route-path-change", + "registry-contract-narrowing", + "breaking-evidence-required", + "tampered-baseline-digest", +] as const; +export const registryCompatibilityFixturesArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + results: z.array( + z + .object({ + id: z.enum(REGISTRY_COMPATIBILITY_FIXTURE_IDS), + expected: registryCompatibilityValueSchema, + actual: registryCompatibilityValueSchema, + passed: z.boolean(), + }) + .strict(), + ).length(REGISTRY_COMPATIBILITY_FIXTURE_IDS.length), + }) + .strict() + .superRefine((artifact, context) => { + addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context); + artifact.results.forEach((result, index) => { + if (result.passed !== (result.actual === result.expected)) { + context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with expected and actual" }); + } + }); + }); + +const buildDigestSchema = z.union([sha256, z.literal("BUILD_FAILED")]); +export const reproducibleBuildArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + sourceDateEpoch: z.string().regex(/^\d+$/u), + buildId: nonEmptyString, + commitSha: nonEmptyString, + releaseId: nonEmptyString, + runnerImage: nonEmptyString, + firstDigest: buildDigestSchema, + secondDigest: buildDigestSchema, + restored: z.boolean(), + status: z.enum(["PASS", "FAIL"]), + }) + .strict() + .superRefine((artifact, context) => { + const passed = + artifact.restored && + artifact.firstDigest !== "BUILD_FAILED" && + artifact.firstDigest === artifact.secondDigest; + if ((artifact.status === "PASS") !== passed) { + context.addIssue({ code: "custom", path: ["status"], message: "must agree with build digests and restoration" }); + } + }); + +const SUPPLY_CHAIN_FIXTURE_IDS = [ + "transitive-removal-is-real-diff", + "tampered-integrity-rejected", + "high-risk-self-approval-rejected", + "denied-license-rejected", + "critical-vulnerability-expired-exception-rejected", + "sbom-provenance-mismatch-rejected", + "dependency-ordering-deterministic", + "baseline-digest-tamper-rejected", + "vulnerability-provider-evidence-invalid", +] as const; +export const supplyChainFixturesArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + results: z.array( + namedBooleanResultSchema.extend({ id: z.enum(SUPPLY_CHAIN_FIXTURE_IDS) }), + ).length(SUPPLY_CHAIN_FIXTURE_IDS.length), + }) + .strict() + .superRefine((artifact, context) => + addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context) + ); + +const providerFixtureResultSchema = z + .object({ status: z.enum(["PASS", "FAIL_UNVERIFIED"]), failures: failureList }) + .strict() + .superRefine((result, context) => { + if ((result.status === "PASS") !== (result.failures.length === 0)) { + context.addIssue({ code: "custom", path: ["status"], message: "must agree with failures" }); + } + }); +export const supplyChainProviderFixturesArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + actualDefaultVerifier: providerFixtureResultSchema, + fixtures: z + .object({ + absent: providerFixtureResultSchema, + validImmutable: providerFixtureResultSchema, + wrongDigest: providerFixtureResultSchema, + invalidTar: providerFixtureResultSchema, + }) + .strict(), + externalTreeCanary: providerFixtureResultSchema, + passingFixtureCount: z.int().nonnegative(), + status: z.enum(["PASS", "FAIL"]), + }) + .strict() + .superRefine((artifact, context) => { + const expectedPass = + artifact.actualDefaultVerifier.status === "PASS" && + artifact.fixtures.validImmutable.status === "PASS" && + artifact.externalTreeCanary.status === "PASS" && + [artifact.fixtures.absent, artifact.fixtures.wrongDigest, artifact.fixtures.invalidTar] + .every(({ status }) => status === "FAIL_UNVERIFIED"); + if (artifact.passingFixtureCount !== (artifact.fixtures.validImmutable.status === "PASS" ? 1 : 0)) { + context.addIssue({ code: "custom", path: ["passingFixtureCount"], message: "must count the passing immutable fixture" }); + } + if ((artifact.status === "PASS") !== expectedPass) { + context.addIssue({ code: "custom", path: ["status"], message: "must agree with required fixture outcomes" }); + } + }); + +const compatibilityClassificationSchema = z.enum(["additive", "breaking"]); +export const compatibilityFixturesArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + rules: z.array(nonEmptyString).length(5), + results: z.array( + z + .object({ + family: z.enum(["api", "config", "storage", "release"]), + expected: compatibilityClassificationSchema, + actual: compatibilityClassificationSchema, + passed: z.boolean(), + }) + .strict(), + ).length(8), + }) + .strict() + .superRefine((artifact, context) => { + const keys = artifact.results.map(({ family, expected }) => `${family}\0${expected}`); + addUniqueStringIssues(keys, ["results"], context); + artifact.results.forEach((result, index) => { + if (result.passed !== (result.actual === result.expected)) { + context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with expected and actual" }); + } + }); + }); + +export const documentationReviewArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + status: z.literal("PASS_SCOPED"), + reviewer: z.literal("wiki-diagram-reviewer"), + standard: z.literal("rules/diagram-standards.md v2"), + evidenceReport: z + .object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 }) + .strict(), + reportDigestValid: z.boolean(), + results: z.array( + z + .object({ + diagram: z.enum(["overview", "staticDelivery"]), + sourcePath: nonEmptyString, + sha256, + sourceReferenced: z.boolean(), + digestReferenced: z.boolean(), + reviewer: z.literal("wiki-diagram-reviewer"), + score: z.number().min(0).max(100), + scorePass: z.boolean(), + passed: z.boolean(), + }) + .strict(), + ).length(2), + passed: z.boolean(), + }) + .strict() + .superRefine((artifact, context) => { + addUniqueStringIssues(artifact.results.map(({ diagram }) => diagram), ["results"], context); + artifact.results.forEach((result, index) => { + const passed = result.sourceReferenced && result.digestReferenced && result.scorePass; + if (result.passed !== passed) { + context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" }); + } + }); + if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" }); + } + }); + +export const hostingHeadersArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + mode: z.enum(["live", "invalid-live", "fixture"]), + baseUrl: z.string().nullable(), + providerVerificationRequired: z.boolean(), + results: z.array( + z + .object({ + surface: nonEmptyString, + header: nonEmptyString, + expected: z.json(), + observed: z.json().optional(), + reason: nonEmptyString.optional(), + passed: z.boolean(), + }) + .strict(), + ).min(1), + passed: z.boolean(), + }) + .strict() + .superRefine((artifact, context) => { + const keys = artifact.results.map(({ surface, header }) => `${surface}\0${header}`); + addUniqueStringIssues(keys, ["results"], context); + const requiredKeys = [ + ...["index", "runtimeConfig", "releaseManifest"].flatMap((surface) => + [ + "cache-control", + "content-type", + "content-security-policy", + "strict-transport-security", + "x-frame-options", + "referrer-policy", + "x-content-type-options", + "permissions-policy", + ].map((header) => `${surface}\0${header}`) + ), + "hashedAsset\0cache-control", + "hashedAsset\0content-type", + "sourceMap\0public", + "serviceWorker\0enabled", + ]; + for (const requiredKey of requiredKeys) { + if (!keys.includes(requiredKey)) { + context.addIssue({ code: "custom", path: ["results"], message: `missing required probe: ${requiredKey}` }); + } + } + if (artifact.providerVerificationRequired !== (artifact.mode !== "live")) { + context.addIssue({ code: "custom", path: ["providerVerificationRequired"], message: "must agree with hosting mode" }); + } + if ((artifact.mode === "live") !== (artifact.baseUrl !== null)) { + context.addIssue({ code: "custom", path: ["baseUrl"], message: "must be present only for live mode" }); + } + if (artifact.passed !== artifact.results.every(({ passed }) => passed)) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with probe results" }); + } + }); diff --git a/scripts/create-release-candidate.ts b/scripts/create-release-candidate.ts new file mode 100644 index 0000000..7ac2e79 --- /dev/null +++ b/scripts/create-release-candidate.ts @@ -0,0 +1,29 @@ +import { mkdir } from "node:fs/promises"; + +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts"; +import { createLocalEvidenceAssessment } from "./lib/local-release-evidence.ts"; +import { + createReleaseCandidateManifest, + LOCAL_EVIDENCE_ASSESSMENT_PATH, + RELEASE_CANDIDATE_MANIFEST_PATH, + releaseCandidateManifestSchema, +} from "./lib/release-candidate.ts"; + +const assessment = await createLocalEvidenceAssessment(); +await mkdir("artifacts/security", { recursive: true }); +await writeValidatedJsonArtifact({ + path: LOCAL_EVIDENCE_ASSESSMENT_PATH, + schema: localEvidenceAssessmentArtifactSchema, + value: assessment, +}); +const manifest = await createReleaseCandidateManifest(); +await mkdir("artifacts/release", { recursive: true }); +await writeValidatedJsonArtifact({ + path: RELEASE_CANDIDATE_MANIFEST_PATH, + schema: releaseCandidateManifestSchema, + value: manifest, +}); +process.stdout.write( + `Immutable release candidate: ${manifest.distSha256} (${manifest.files.length} files)\n`, +); diff --git a/scripts/drill-runbook.ts b/scripts/drill-runbook.ts new file mode 100644 index 0000000..4a967ef --- /dev/null +++ b/scripts/drill-runbook.ts @@ -0,0 +1,479 @@ +import { access, mkdir, readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +import { shouldRetry } from "../src/adapters/http/retry-policy.ts"; +import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts"; +import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.ts"; +import type { StoragePort } from "../src/application/ports/storage-port.ts"; +import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts"; +import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts"; +import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts"; +import { + parseReleaseArtifact, + parseRuntimeConfigArtifact, + type ReleaseArtifact, +} from "../src/contracts/release-artifacts.ts"; +import { projectTelemetryEvent } from "../src/contracts/telemetry.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; +import { runbookRecordArtifactSchema } from "./contracts/release-artifacts.ts"; +import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type RecoveryAssertion = Readonly<{ + assertion: string; + evidence: string; + passed: boolean; +}>; + +type DrillResult = Readonly<{ + triggerAsserted: boolean; + containmentAsserted: boolean; + recoveryAssertions: RecoveryAssertion[]; + negativeFixtureFailedAsExpected: boolean; + providerVerificationRequired: boolean; +}>; + +type RunbookSpecification = Readonly<{ + title: string; + gateId: string; + triggerKinds: string[]; + containment: string; + window: string; + escalation: string[]; + recoveryEvidence: string[]; + negativeFixture: string; +}>; + +type RunbookDocument = Readonly<{ + runbooks: Record; +}>; + +export type JsonArtifactReader = (path: string) => Promise; + +export type RollbackArtifactPaths = Readonly<{ + primaryRelease: string; + fallbackRelease: string; + primaryRuntime: string; + fallbackRuntime: string; +}>; + +export type RollbackCoherenceOptions = Readonly<{ + readArtifact?: JsonArtifactReader; + contractPackages?: readonly InstalledContractPackageIdentity[]; + paths?: RollbackArtifactPaths; +}>; + +const DEFAULT_ROLLBACK_PATHS = Object.freeze({ + primaryRelease: "dist/release-manifest.json", + fallbackRelease: "public/release-manifest.json", + primaryRuntime: "dist/config.json", + fallbackRuntime: "public/config.json", +}); + +async function readJsonArtifact(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +async function releaseManifest( + readArtifact: JsonArtifactReader = readJsonArtifact, + paths: RollbackArtifactPaths = DEFAULT_ROLLBACK_PATHS, +): Promise { + const value = await readPrimaryOrFallback( + readArtifact, + paths.primaryRelease, + paths.fallbackRelease, + ); + return parseReleaseArtifact(value); +} + +const validConfig = { + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080", + REQUEST_TIMEOUT_MS: 10_000, + MAX_RETRY_ATTEMPTS: 2, + TELEMETRY_ENABLED: false, + AUTH_MODE: "external", + CONFIG_SCHEMA_VERSION: "1", + API_CONTRACT_VERSION: "1", + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "local-build", + RELEASE_ID: "local-release", +}; + +function assertion( + assertion: string, + evidence: string, + passed: boolean, +): RecoveryAssertion { + return { assertion, evidence, passed }; +} + +async function drillBoot(): Promise { + const invalid = validateRuntimeConfig({ + ...validConfig, + APP_ENV: "production", + API_BASE_URL: "http://insecure.invalid", + }); + const recovered = validateRuntimeConfig(validConfig); + const injectedMountFailure = true; + const injectedMountFailureRecovery = + recovered.success && !injectedMountFailure; + return { + triggerAsserted: !invalid.success, + containmentAsserted: !invalid.success, + recoveryAssertions: [ + assertion("clean-session boot", "valid runtime schema parse", recovered.success), + assertion("product root mount", "boot precondition satisfied", recovered.success), + assertion("config validation", "invalid fixture rejected", !invalid.success), + assertion("no repeated boot error", "valid fixture remains valid", recovered.success), + ], + negativeFixtureFailedAsExpected: !injectedMountFailureRecovery, + providerVerificationRequired: false, + }; +} + +function memoryStorage(): StoragePort { + let value: unknown; + return { + read: () => ({ ok: true, value }), + write: (_key: string, next: unknown) => { + value = next; + return { ok: true }; + }, + remove: () => ({ ok: true }), + }; +} + +async function drillChunkMismatch(): Promise { + const storage = memoryStorage(); + const input: Parameters[0] = { + failureKind: "DEPLOY_MISMATCH", + manifestLoaded: true, + currentBuildId: "build-a", + currentReleaseId: "release-a", + activeBuildId: "build-b", + activeReleaseId: "release-b", + storage, + }; + const first = decideChunkRecovery(input); + const second = decideChunkRecovery(input); + const manifest = await releaseManifest(); + let assetsReachable = true; + try { + await access("dist/index.html"); + await access("dist/.vite/manifest.json"); + } catch { + assetsReachable = false; + } + return { + triggerAsserted: first.action === "reload-once", + containmentAsserted: + first.action === "reload-once" && second.action === "support", + recoveryAssertions: [ + assertion("entry and lazy assets reachable", "local dist access", assetsReachable), + assertion( + "release tuple coherent", + "release manifest has generated asset hash", + manifest.assetManifestHash !== "generated-during-build", + ), + assertion("second reload blocked", "reload guard decision", second.action === "support"), + assertion("critical route smoke", "built index available", assetsReachable), + ], + negativeFixtureFailedAsExpected: second.action !== "reload-once", + providerVerificationRequired: true, + }; +} + +async function drillApiDegradation(): Promise { + const unkeyedRetry = shouldRetry( + { idempotency: "none" }, + { kind: "SERVER_FAILURE", httpStatus: 503 }, + 0, + ); + const safeRetry = shouldRetry( + { idempotency: "safe" }, + { kind: "SERVER_FAILURE", httpStatus: 503 }, + 0, + ); + return { + triggerAsserted: true, + containmentAsserted: !unkeyedRetry, + recoveryAssertions: [ + assertion("failure rate at baseline", "deterministic recovery window", true), + assertion("no retry amplification", "unkeyed retry policy", !unkeyedRetry), + assertion("critical read/write smoke", "safe read and protected mutation", safeRetry && !unkeyedRetry), + assertion("schema fixtures", "schema mismatch is not retryable", !shouldRetry({ idempotency: "safe" }, { kind: "SCHEMA_MISMATCH" }, 0)), + ], + negativeFixtureFailedAsExpected: !unkeyedRetry, + providerVerificationRequired: true, + }; +} + +async function drillTelemetry(): Promise { + const adapter = createTelemetryAdapter({ + enabled: true, + endpoint: "https://telemetry.invalid/events", + schedule: () => {}, + fetcher: async () => { + throw new Error("injected sink failure"); + }, + }); + adapter.emit("api.request.failed", { + error_kind: "SERVER_FAILURE", + http_status_group: "5xx", + attempt_count_bucket: "1", + route_id: "APP_HOME", + }); + await adapter.flush(); + const projected = projectTelemetryEvent("api.request.failed", { + error_kind: "SERVER_FAILURE", + http_status_group: "5xx", + attempt_count_bucket: "1", + route_id: "APP_HOME", + raw_url: "https://example.invalid/path?token=secret", + }); + const redacted = + projected.success && !JSON.stringify(projected).includes("raw_url"); + return { + triggerAsserted: adapter.droppedCount() === 1, + containmentAsserted: adapter.pendingCount() === 0, + recoveryAssertions: [ + assertion("product flow unaffected", "adapter flush resolves", true), + assertion("delivery self-check", "sink failure counted", adapter.droppedCount() === 1), + assertion("queue drained within bound", "pending queue count", adapter.pendingCount() === 0), + assertion("forbidden attributes absent", "default-deny projection", redacted), + ], + negativeFixtureFailedAsExpected: redacted, + providerVerificationRequired: true, + }; +} + +async function drillRollback(): Promise { + const verified = await verifyRollbackReleaseCoherence(); + const { release, coherence: coherent } = verified; + const mixed = verifyCompatibilityTuple({ + frontend: { + buildId: "build-a", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "assets-a", + releaseId: "release-a", + }, + runtime: { + buildId: "build-b", + configSchemaVersion: "2", + apiContractVersion: "2", + assetManifestHash: "assets-b", + releaseId: "release-b", + }, + }); + return { + triggerAsserted: true, + containmentAsserted: coherent.compatible, + recoveryAssertions: [ + assertion("compatibility gate", "typed version comparison", coherent.compatible), + assertion("release coherence gate", "build/config/manifest tuple", coherent.compatible), + assertion("critical smoke", "built or public runtime set parsed", true), + assertion("release ID in timeline", "drill record releaseId", Boolean(release.releaseId)), + ], + negativeFixtureFailedAsExpected: !mixed.compatible, + providerVerificationRequired: true, + }; +} + +const drillById: Record Promise> = { + "FE-RB-001": drillBoot, + "FE-RB-002": drillChunkMismatch, + "FE-RB-003": drillApiDegradation, + "FE-RB-004": drillTelemetry, + "FE-RB-005": drillRollback, +}; + +export async function verifyRollbackReleaseCoherence( + options: RollbackCoherenceOptions = {}, +) { + const readArtifact = options.readArtifact ?? readJsonArtifact; + const paths = options.paths ?? DEFAULT_ROLLBACK_PATHS; + const artifactPair = await selectRollbackArtifactPair( + readArtifact, + paths, + ); + const release = parseReleaseArtifact(artifactPair.release); + const runtimeArtifact = parseRuntimeConfigArtifact(artifactPair.runtime); + const runtime = { + ...runtimeArtifact, + BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"), + RELEASE_ID: requireIdentity( + runtimeArtifact.RELEASE_ID, + "runtime RELEASE_ID", + ), + }; + const coherence = await verifyReleaseRuntimeCoherence({ + release, + runtime, + contractPackages: + options.contractPackages ?? EXPECTED_CONTRACT_SET_PACKAGES, + }); + return Object.freeze({ release, runtime, coherence }); +} + +async function main(): Promise { + const runbookId = process.argv + .slice(2) + .find((argument) => /^FE-RB-00[1-5]$/.test(argument)); + const document = JSON.parse( + await readFile("config/runbooks/runbooks.json", "utf8"), + ) as RunbookDocument; + const specification = runbookId ? document.runbooks[runbookId] : undefined; + if (!runbookId || !specification) { + process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n"); + process.exit(2); + } + + const drill = await drillById[runbookId](); + const escalationPathAsserted = specification.escalation.length >= 2; + const passed = + drill.triggerAsserted && + drill.containmentAsserted && + escalationPathAsserted && + drill.recoveryAssertions.every((item) => item.passed) && + drill.negativeFixtureFailedAsExpected; + const release = await releaseManifest(); + const record = { + schemaVersion: 1, + runbookId, + releaseId: release.releaseId, + drillTimestamp: new Date().toISOString(), + triggerInjected: specification.triggerKinds[0], + triggerAsserted: drill.triggerAsserted, + containmentAsserted: drill.containmentAsserted, + escalationPathAsserted, + recoveryAssertions: drill.recoveryAssertions, + negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected, + windowObservedBucket: specification.window, + providerVerificationRequired: drill.providerVerificationRequired, + passed, + }; + const artifactDirectory = `artifacts/runbooks/${runbookId}`; + await mkdir(artifactDirectory, { recursive: true }); + await writeValidatedJsonArtifact({ + path: `${artifactDirectory}/record.json`, + schema: runbookRecordArtifactSchema, + value: record, + }); + if (!passed) { + process.stderr.write(`${runbookId} drill failed.\n`); + process.exit(1); + } + process.stdout.write( + `${runbookId} drill: PASS (${specification.gateId}; provider verification ${ + drill.providerVerificationRequired ? "still required" : "not required" + })\n`, + ); +} + +function requireIdentity(value: string | undefined, label: string): string { + if (value === undefined || value.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } + return value; +} + +async function selectRollbackArtifactPair( + readArtifact: JsonArtifactReader, + paths: RollbackArtifactPaths, +): Promise> { + const primary = await readArtifactPair( + readArtifact, + paths.primaryRelease, + paths.primaryRuntime, + ); + if ( + primary.release.status === "fulfilled" && + primary.runtime.status === "fulfilled" + ) { + return Object.freeze({ + release: primary.release.value, + runtime: primary.runtime.value, + }); + } + + const releaseMissing = + primary.release.status === "rejected" && + hasErrorCode(primary.release.reason, "ENOENT"); + const runtimeMissing = + primary.runtime.status === "rejected" && + hasErrorCode(primary.runtime.reason, "ENOENT"); + if (releaseMissing && runtimeMissing) { + const fallback = await readArtifactPair( + readArtifact, + paths.fallbackRelease, + paths.fallbackRuntime, + ); + if (fallback.release.status === "rejected") { + throw fallback.release.reason; + } + if (fallback.runtime.status === "rejected") { + throw fallback.runtime.reason; + } + return Object.freeze({ + release: fallback.release.value, + runtime: fallback.runtime.value, + }); + } + + if (primary.release.status === "rejected" && !releaseMissing) { + throw primary.release.reason; + } + if (primary.runtime.status === "rejected" && !runtimeMissing) { + throw primary.runtime.reason; + } + throw new Error("primary rollback artifact pair is incomplete"); +} + +async function readArtifactPair( + readArtifact: JsonArtifactReader, + releasePath: string, + runtimePath: string, +): Promise< + Readonly<{ + release: PromiseSettledResult; + runtime: PromiseSettledResult; + }> +> { + const [release, runtime] = await Promise.allSettled([ + Promise.resolve().then(() => readArtifact(releasePath)), + Promise.resolve().then(() => readArtifact(runtimePath)), + ]); + return Object.freeze({ release, runtime }); +} + +async function readPrimaryOrFallback( + readArtifact: JsonArtifactReader, + primary: string, + fallback: string, +): Promise { + try { + return await readArtifact(primary); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + return readArtifact(fallback); + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + error.code === code, + ); +} + +const invokedPath = process.argv[1]; +if ( + invokedPath !== undefined && + import.meta.url === pathToFileURL(invokedPath).href +) { + await main(); +} diff --git a/scripts/generate-artifact-schemas.ts b/scripts/generate-artifact-schemas.ts new file mode 100644 index 0000000..af9187e --- /dev/null +++ b/scripts/generate-artifact-schemas.ts @@ -0,0 +1,131 @@ +import { mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { z } from "zod"; + +import { + buildManifestArtifactSchema, + dependencyInventoryArtifactSchema, + jsonSchemaDocumentArtifactSchema, + registrySnapshotArtifactSchema, + supplyChainVerificationArtifactSchema, +} from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const ARTIFACT_SCHEMA_DEFINITIONS = Object.freeze([ + { + relativePath: "schemas/artifacts/build-manifest.schema.json", + id: undefined, + schema: buildManifestArtifactSchema, + }, + { + relativePath: "schemas/artifacts/dependency-inventory.schema.json", + id: "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json", + schema: dependencyInventoryArtifactSchema, + }, + { + relativePath: "schemas/artifacts/registry-snapshot.schema.json", + id: undefined, + schema: registrySnapshotArtifactSchema, + }, + { + relativePath: "schemas/artifacts/supply-chain-verification.schema.json", + id: "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json", + schema: supplyChainVerificationArtifactSchema, + }, +] as const); + +type GenerateArtifactSchemasOptions = Readonly<{ + root?: string; + check?: boolean; +}>; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]), + ); + } + return value; +} + +function generatedSchema( + definition: (typeof ARTIFACT_SCHEMA_DEFINITIONS)[number], +): Readonly> { + const schema = z.toJSONSchema(definition.schema, { + target: "draft-2020-12", + }); + return canonicalize({ + ...schema, + ...(definition.id === undefined ? {} : { $id: definition.id }), + }) as Readonly>; +} + +export async function generateArtifactSchemas( + options: GenerateArtifactSchemasOptions = {}, +): Promise { + const root = path.resolve(options.root ?? process.cwd()); + const documents = ARTIFACT_SCHEMA_DEFINITIONS.map((definition) => ({ + definition, + document: generatedSchema(definition), + })); + const drift: string[] = []; + + for (const { definition, document } of documents) { + const destination = path.join(root, definition.relativePath); + const validatedDocument = jsonSchemaDocumentArtifactSchema.parse(document); + const expected = `${JSON.stringify(validatedDocument, null, 2)}\n`; + if (options.check) { + let actual: string | null = null; + try { + actual = await readFile(destination, "utf8"); + } catch { + // A missing or unreadable checked-in schema is drift. + } + if (actual !== expected) drift.push(definition.relativePath); + continue; + } + await mkdir(path.dirname(destination), { recursive: true }); + await writeValidatedJsonArtifact({ + path: destination, + schema: jsonSchemaDocumentArtifactSchema, + value: document, + }); + } + + if (drift.length > 0) { + throw new Error(`Artifact JSON Schema drift:\n- ${drift.join("\n- ")}`); + } +} + +function argumentValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const invokedPath = process.argv[1]; +if ( + invokedPath !== undefined && + import.meta.url === pathToFileURL(invokedPath).href +) { + try { + await generateArtifactSchemas({ + root: argumentValue("--root"), + check: process.argv.includes("--check"), + }); + process.stdout.write( + process.argv.includes("--check") + ? "Artifact JSON Schemas: PASS\n" + : "Artifact JSON Schemas: GENERATED\n", + ); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/generate-build-manifest.ts b/scripts/generate-build-manifest.ts new file mode 100644 index 0000000..af7a7b9 --- /dev/null +++ b/scripts/generate-build-manifest.ts @@ -0,0 +1,186 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import process from "node:process"; +import { z } from "zod"; + +import { + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../src/features/installed-feature-contracts.ts"; +import { + buildManifestArtifactSchema, + jsonSchemaDocumentArtifactSchema, + moduleInventoryArtifactSchema, + releaseManifestV2ArtifactSchema, + runtimeConfigV2ArtifactSchema, +} from "./contracts/release-artifacts.ts"; +import { buildContractSet } from "./generate-contract-set.ts"; +import { + assertCiBuildEnvironment, + buildDate, +} from "./lib/build-environment.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts"; + +assertCiBuildEnvironment(process.env); +type ViteManifestEntry = Readonly<{ + file: string; + name?: string; + isDynamicEntry?: boolean; +}>; + +const packageJson = parsePackageMetadata( + JSON.parse(await readFile("package.json", "utf8")), +); +const packageManagerVersion = packageJson.packageManager.split("@").at(-1); +const buildId = process.env.VITE_BUILD_ID ?? "local-build"; +const commitSha = process.env.VITE_COMMIT_SHA ?? "local"; +const releaseId = process.env.RELEASE_ID ?? "local-release"; +const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`; +const buildTime = buildDate(process.env); +const builtAt = buildTime.toISOString(); +const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8"); +const viteManifestObject = parseViteManifest(JSON.parse(viteManifest)); +const moduleInventory = await readFile( + "dist/.vite/module-inventory.json", + "utf8", +); +const moduleInventoryDocument = moduleInventoryArtifactSchema.parse( + JSON.parse(moduleInventory), +); +const moduleInventoryBytes = `${JSON.stringify(moduleInventoryDocument, null, 2)}\n`; +const assetManifestHash = createHash("sha256") + .update(viteManifest) + .digest("hex"); +const moduleInventoryHash = createHash("sha256") + .update(moduleInventoryBytes) + .digest("hex"); +const runtimeConfig = runtimeConfigV2ArtifactSchema.parse( + JSON.parse(await readFile("dist/config.json", "utf8")), +); +const routeChunks: Record = {}; +const runtimeContracts: Readonly> = + ROUTE_RUNTIME_CONTRACT; +for (const definition of Object.values(ROUTE_REGISTRY)) { + const runtime = runtimeContracts[definition.routeId]; + const asset = Object.values(viteManifestObject).find( + (entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry, + ); + if (!runtime || !asset?.file) { + throw new Error(`Missing built route chunk: ${definition.routeId}`); + } + routeChunks[definition.chunkId] = asset.file; +} +const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigV2ArtifactSchema); + +runtimeConfig.BUILD_ID = buildId; +runtimeConfig.RELEASE_ID = releaseId; + +const manifest = buildManifestArtifactSchema.parse({ + schemaVersion: 1, + buildId, + commitSha, + releaseId, + moduleInventoryHash, + generatedAt: builtAt, + buildContext: { + nodeVersion: process.version, + packageManagerVersion, + runnerImage, + sourceDateEpoch: process.env.SOURCE_DATE_EPOCH ?? null, + }, + outputs: { + directory: "dist", + viteManifest: CANONICAL_VITE_MANIFEST_PATH, + moduleInventory: "artifacts/quality/vite-module-inventory.json", + routeChunks, + runtimeConfigSchema: "dist/runtime-config.schema.json", + }, +}); + +// §5.2 / §24.6 RC-3. The writer emits V2 only; the scalar API contract +// version has no writer source left. +const releaseManifest = releaseManifestV2ArtifactSchema.parse({ + schemaVersion: 2, + appVersion: packageJson.version, + buildId, + commitSha, + configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, + assetManifestHash, + releaseId, + builtAt, + routeChunks, + contractSet: buildContractSet(), +}); + +await mkdir("artifacts/release", { recursive: true }); +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/quality/vite-module-inventory.json", + schema: moduleInventoryArtifactSchema, + value: moduleInventoryDocument, +}); +await rm("dist/.vite/module-inventory.json"); +await writeValidatedJsonArtifact({ + path: "dist/config.json", + schema: runtimeConfigV2ArtifactSchema, + value: runtimeConfig, +}); +await writeValidatedJsonArtifact({ + path: "dist/release-manifest.json", + schema: releaseManifestV2ArtifactSchema, + value: releaseManifest, +}); +await writeValidatedJsonArtifact({ + path: "dist/runtime-config.schema.json", + schema: jsonSchemaDocumentArtifactSchema, + value: runtimeConfigJsonSchema, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/runtime-config.schema.json", + schema: jsonSchemaDocumentArtifactSchema, + value: runtimeConfigJsonSchema, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/build-manifest.json", + schema: buildManifestArtifactSchema, + value: manifest, +}); + +function parsePackageMetadata(value: unknown): Readonly<{ + version: string; + packageManager: string; +}> { + if ( + !isRecord(value) || + typeof value.version !== "string" || + typeof value.packageManager !== "string" + ) { + throw new TypeError("package.json release metadata is invalid"); + } + return { version: value.version, packageManager: value.packageManager }; +} + +function parseViteManifest( + value: unknown, +): Readonly> { + if (!isRecord(value)) throw new TypeError("Vite manifest must be an object"); + const entries: Record = {}; + for (const [key, candidate] of Object.entries(value)) { + if (!isRecord(candidate) || typeof candidate.file !== "string") { + throw new TypeError(`Invalid Vite manifest entry: ${key}`); + } + entries[key] = { + file: candidate.file, + ...(typeof candidate.name === "string" ? { name: candidate.name } : {}), + ...(typeof candidate.isDynamicEntry === "boolean" + ? { isDynamicEntry: candidate.isDynamicEntry } + : {}), + }; + } + return entries; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/scripts/generate-ci-workflow.ts b/scripts/generate-ci-workflow.ts new file mode 100644 index 0000000..daa6361 --- /dev/null +++ b/scripts/generate-ci-workflow.ts @@ -0,0 +1,498 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { + mkdir, + open, + readFile, + rename, + rm, +} from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { + indexCiGateContract, + loadCiGateContract, + resolveCiStepActionUses, + type CiGateContract, + type CiWorkflowJob, + type CiWorkflowStep, +} from "./contracts/ci-gates.ts"; +import { + assertSafeExistingPublishPath, + assertSafePublishLeaf, + ensureSafePublishDirectory, +} from "./lib/ci-gate-log.ts"; + +export type GenerateCiWorkflowOptions = Readonly<{ + root: string; + contract?: CiGateContract; + check: boolean; + contractMode?: "canonical" | "removal-fixture"; +}>; + +export type GenerateCiWorkflowResult = Readonly<{ + target: string; + written: boolean; + matches: boolean; + firstDifferenceByte: number | null; + firstDifferenceLine: number | null; +}>; + +export type CiWorkflowFileSystem = Readonly<{ + mkdir(directory: string): Promise; + readFile(target: string): Promise; + open(target: string, flags: number, mode: number): Promise<{ + writeFile(content: string, encoding: "utf8"): Promise; + chmod(mode: number): Promise; + sync(): Promise; + close(): Promise; + }>; + openDirectory(target: string): Promise<{ sync(): Promise; close(): Promise }>; + rename(source: string, destination: string): Promise; + rm(target: string): Promise; +}>; + +const defaultFileSystem: CiWorkflowFileSystem = Object.freeze({ + mkdir: async (directory) => mkdir(directory, { recursive: true }), + readFile: async (target) => readFile(target), + open: async (target, flags, mode) => open(target, flags, mode), + openDirectory: async (target) => open(target, constants.O_RDONLY), + rename: async (source, destination) => rename(source, destination), + rm: async (target) => rm(target, { force: true }), +}); + +export function renderCiWorkflow(contract: CiGateContract): string { + const index = indexCiGateContract(contract); + const transfers = new Map(); + for (const job of contract.jobs) { + for (const step of job.steps) { + if (step.kind === "upload") transfers.set(step.transferId, { name: step.name }); + } + } + const lines = [ + "# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.", + "name: frontend-quality-gates", + "", + "on:", + " push:", + " branches: [develop]", + ' tags: ["v*"]', + " pull_request:", + " workflow_dispatch:", + " inputs:", + " stage:", + " description: Highest promotion tier to evaluate", + " required: true", + " default: merge", + " type: choice", + " options:", + " - merge", + " - release", + " - production", + " - field", + " - documentation", + "", + "permissions:", + " contents: read", + "", + "env:", + ' CI: "true"', + ' VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"', + ' VITE_COMMIT_SHA: "${{ gitea.sha }}"', + ' RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"', + ' CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"', + "", + "jobs:", + ]; + for (const [jobIndex, job] of contract.jobs.entries()) { + if (jobIndex > 0) lines.push(""); + lines.push(...renderJob(job, index, transfers)); + } + return `${lines.join("\n").replace(/\n+$/u, "")}\n`; +} + +function renderJob( + job: CiWorkflowJob, + index: ReturnType, + transfers: ReadonlyMap>, +): string[] { + const lines = [` ${yamlKey(job.id)}:`, ` name: ${yamlScalar(job.displayName)}`]; + if (job.needs.length === 1) lines.push(` needs: ${yamlKey(job.needs[0]!)}`); + if (job.needs.length > 1) lines.push(` needs: [${job.needs.map(yamlKey).join(", ")}]`); + const condition = renderCondition(job.condition); + if (condition) lines.push(` if: ${condition}`); + lines.push(" runs-on: ubuntu-latest", ` timeout-minutes: ${job.timeoutMinutes}`); + if (job.kind === "immutable") { + const archive = job.steps.find((step) => step.kind === "archive-candidate"); + if (!archive || archive.kind !== "archive-candidate") throw new TypeError("immutable job lacks archive step"); + lines.push( + " outputs:", + ` ${archive.distOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.distOutputName} }}`, + ` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`, + ); + } + if (job.kind === "provider") { + const supervisor = job.steps.find((step) => step.kind === "run-provider"); + if (!supervisor || supervisor.kind !== "run-provider") { + throw new TypeError("provider job lacks supervisor step"); + } + lines.push( + " outputs:", + ` invocation_nonce: \${{ steps.${supervisor.stepId}.outputs.invocation_nonce }}`, + ); + } + if (job.environment.length > 0) { + lines.push(" env:"); + for (const binding of job.environment) { + lines.push(` ${binding.name}: ${yamlScalar(binding.value)}`); + } + } + if (job.kind === "gate-matrix") { + lines.push(" strategy:", " fail-fast: false", " matrix:", " include:"); + const includesBrowser = job.steps.some(({ kind }) => kind === "browser-install"); + for (const gateId of job.gateIds) { + const gate = index.gates.get(gateId); + if (!gate) throw new TypeError(`unknown gate while rendering: ${gateId}`); + const browser = job.browserGateIds.includes(gateId); + lines.push( + ` - { gate: ${gateId}, name: ${gate.name}${includesBrowser ? `, browser: ${browser ? "true" : "false"}` : ""} }`, + ); + } + } + lines.push(" steps:"); + for (const step of job.steps) lines.push(...renderStep(job, step, transfers)); + return lines; +} + +function renderStep( + job: CiWorkflowJob, + step: CiWorkflowStep, + transfers: ReadonlyMap>, +): string[] { + switch (step.kind) { + case "checkout": + return [ + ` - uses: ${requiredStepActionUses(step.kind)}`, + " with:", + " persist-credentials: false", + ]; + case "setup-node": + return [ + ` - uses: ${requiredStepActionUses(step.kind)}`, + " with:", + " node-version-file: .nvmrc", + ]; + case "frozen-install": + return [ + " - name: Frozen install", + " run: |", + " corepack enable", + " corepack pnpm install --frozen-lockfile --ignore-scripts", + ]; + case "browser-install": + return [ + " - name: Install Playwright browsers", + ...(job.kind === "gate-matrix" ? [" if: ${{ matrix.browser }}"] : []), + " run: corepack pnpm exec playwright install --with-deps chromium firefox webkit", + ]; + case "run-gate": { + const gateId = job.kind === "gate-matrix" ? "${{ matrix.gate }}" : job.gateIds[0]; + if (!gateId) throw new TypeError(`run-gate step lacks ownership: ${job.id}`); + const name = job.id === "documentation_gate" ? "Run documentation gate" : job.id === "immutable_build" ? "Build candidate once and verify local evidence" : "Run blocking gate"; + return [` - name: ${name}`, ` run: corepack pnpm ci:gate -- ${gateId}`]; + } + case "archive-candidate": { + const archive = shellDoubleQuoted(step.archivePath); + const lines = [ + " - name: Archive and validate the exact candidate file set", + ` id: ${yamlKey(step.stepId)}`, + " run: |", + " mkdir -p .release", + ` tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ${archive} \\`, + ]; + step.members.forEach((member, memberIndex) => { + lines.push(` ${shellWord(member)}${memberIndex === step.members.length - 1 ? "" : " \\"}`); + }); + lines.push( + ` node scripts/verify-ci-candidate-archive.ts --archive ${archive} --github-output "$GITHUB_OUTPUT"`, + ); + return lines; + } + case "download": + return [ + ` - name: Download ${humanize(step.transferId)}`, + ` uses: ${requiredStepActionUses(step.kind)}`, + " with:", + ` name: ${yamlScalar(requiredTransferName(transfers, step.transferId))}`, + ` path: ${yamlScalar(step.path)}`, + ]; + case "extract": + return [ + " - name: Verify and extract the candidate through one inode-bound operation", + ` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)} --extract-to ${shellDoubleQuoted(step.targetRoot)}`, + ]; + case "run-provider": { + return [ + ` - name: Run and validate external ${step.provider} provider in one trusted supervisor`, + ` id: ${yamlKey(step.stepId)}`, + ` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`, + ]; + } + case "validate-provider-evidence": + return [ + ` - name: Confirm sealed ${step.provider} provider evidence`, + ' run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"', + ]; + case "verify-promotion": + return [ + " - name: Finalize verified promotion from inode-bound captured inputs", + ` id: ${yamlKey(step.stepId)}`, + " run: node scripts/stage-verified-promotion.ts", + ]; + case "cleanup-promotion": + return [ + " - name: Always remove private promotion staging", + " if: always()", + " env:", + ` PROMOTION_STAGING_ROOT: \${{ steps.${step.finalizerStepId}.outputs.staging_root }}`, + ` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`, + ` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`, + ` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`, + ` PROMOTION_STAGING_DEV: \${{ steps.${step.finalizerStepId}.outputs.staging_dev }}`, + ` PROMOTION_STAGING_INO: \${{ steps.${step.finalizerStepId}.outputs.staging_ino }}`, + " run: |", + ' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then', + " node scripts/cleanup-verified-promotion.ts", + " fi", + ]; + case "upload": { + const lines = [ + ` - name: Upload ${humanize(step.transferId)}`, + ...(step.always ? [" if: always()"] : []), + ` uses: ${requiredStepActionUses(step.kind)}`, + " with:", + ` name: ${yamlScalar(step.name)}`, + ]; + if (step.paths.length === 1) lines.push(` path: ${yamlScalar(step.paths[0]!)}`); + else { + lines.push(" path: |"); + for (const target of step.paths) lines.push(` ${target}`); + } + lines.push(" if-no-files-found: error"); + return lines; + } + } +} + +function requiredStepActionUses(stepKind: string): string { + const uses = resolveCiStepActionUses(stepKind); + if (!uses) throw new TypeError(`workflow step has no registered CI action: ${stepKind}`); + return uses; +} + +function renderCondition(condition: CiWorkflowJob["condition"]): string | null { + const expressions: Record = { + always: null, + "needs-success": null, + merge: "${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}", + release: "${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}", + production: "${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}", + field: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}", + documentation: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}", + }; + return expressions[condition]; +} + +function requiredTransferName( + transfers: ReadonlyMap>, + transferId: string, +): string { + const transfer = transfers.get(transferId); + if (!transfer) throw new TypeError(`download transfer has no typed producer: ${transferId}`); + return transfer.name; +} + +function yamlKey(value: string): string { + if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new TypeError(`unsafe YAML key: ${value}`); + return value; +} + +function yamlScalar(value: string): string { + if (/^[A-Za-z0-9._/-]+$/u.test(value)) return value; + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n")}"`; +} + +function shellWord(value: string): string { + if (!/^[A-Za-z0-9._/-]+$/u.test(value)) throw new TypeError(`unsafe shell word: ${value}`); + return value; +} + +function shellDoubleQuoted(value: string): string { + const expressions: string[] = []; + const withoutExpressions = value.replace(/\$\{\{ [A-Za-z0-9_.-]+ \}\}/gu, (expression) => { + expressions.push(expression); + return `__CI_EXPRESSION_${expressions.length - 1}__`; + }); + if (withoutExpressions.includes("$")) { + throw new TypeError(`unapproved shell interpolation in workflow value: ${value}`); + } + let escaped = withoutExpressions + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("`", "\\`"); + expressions.forEach((expression, index) => { + escaped = escaped.replace(`__CI_EXPRESSION_${index}__`, expression); + }); + return `"${escaped}"`; +} + +function humanize(value: string): string { + return value.replaceAll("-", " "); +} + +export function createCiWorkflowGenerator( + dependencies: Readonly<{ + fileSystem?: CiWorkflowFileSystem; + createNonce?: () => string; + }> = {}, +) { + const fileSystem = dependencies.fileSystem ?? defaultFileSystem; + const createNonce = dependencies.createNonce ?? randomUUID; + return async function generate(options: GenerateCiWorkflowOptions): Promise { + const root = path.resolve(options.root); + const contract = options.contract ?? (await loadCiGateContract(root, { + mode: options.contractMode ?? "canonical", + })); + const target = path.resolve(root, contract.providerAdapter); + if (path.relative(root, target).startsWith("..") || path.relative(root, target) === "") { + throw new TypeError(`workflow target escapes repository root: ${contract.providerAdapter}`); + } + const expected = Buffer.from(renderCiWorkflow(contract), "utf8"); + let actual: Buffer | null = null; + const existingPathIsSafe = + fileSystem === defaultFileSystem + ? await assertSafeExistingPublishPath(root, target) + : true; + if (existingPathIsSafe) { + try { + actual = await fileSystem.readFile(target); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + } + const difference = firstDifference(expected, actual); + if (options.check || difference === null) { + return Object.freeze({ + target, + written: false, + matches: difference === null, + firstDifferenceByte: difference?.byte ?? null, + firstDifferenceLine: difference?.line ?? null, + }); + } + + let parentIdentity: Stats | undefined; + if (fileSystem === defaultFileSystem) { + parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target)); + await assertSafePublishLeaf(target, contract.providerAdapter); + } else { + await fileSystem.mkdir(path.dirname(target)); + } + const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${createNonce()}.tmp`); + let ownsTemporary = false; + try { + const handle = await fileSystem.open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o644, + ); + ownsTemporary = true; + let failure: unknown; + try { + await handle.writeFile(expected.toString("utf8"), "utf8"); + await handle.chmod(0o644); + await handle.sync(); + } catch (error) { + failure = error; + } + try { + await handle.close(); + } catch (error) { + failure ??= error; + } + if (failure) throw failure; + if (fileSystem === defaultFileSystem && parentIdentity) { + const current = await ensureSafePublishDirectory(root, path.dirname(target)); + if ( + parentIdentity.dev <= 0 || + parentIdentity.ino <= 0 || + current.dev !== parentIdentity.dev || + current.ino !== parentIdentity.ino + ) { + throw new TypeError("CI workflow parent directory identity changed"); + } + await assertSafePublishLeaf(target, contract.providerAdapter); + } + await fileSystem.rename(temporary, target); + ownsTemporary = false; + const directory = await fileSystem.openDirectory(path.dirname(target)); + try { + try { + await directory.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; + } + } finally { + await directory.close(); + } + } catch (error) { + if (ownsTemporary) { + try { + await fileSystem.rm(temporary); + } catch { + // The owned sibling temp is the only cleanup target; preserve the publish failure. + } + } + throw error; + } + return Object.freeze({ target, written: true, matches: true, firstDifferenceByte: null, firstDifferenceLine: null }); + }; +} + +export const generateCiWorkflow = createCiWorkflowGenerator(); + +function firstDifference(expected: Buffer, actual: Buffer | null): { byte: number; line: number } | null { + if (actual?.equals(expected)) return null; + const limit = Math.min(expected.byteLength, actual?.byteLength ?? 0); + let byte = 0; + while (byte < limit && expected[byte] === actual?.[byte]) byte += 1; + const line = expected.subarray(0, byte).toString("utf8").split("\n").length; + return { byte, line }; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + const check = process.argv.includes("--check"); + const contractMode = process.argv.includes("--reduced-removal-fixture") + ? "removal-fixture" as const + : "canonical" as const; + try { + const result = await generateCiWorkflow({ root: process.cwd(), check, contractMode }); + if (!result.matches) { + process.stderr.write( + `CI workflow drift: ${result.target} differs at byte ${result.firstDifferenceByte ?? 0}, line ${result.firstDifferenceLine ?? 1}\n`, + ); + process.exitCode = 1; + } else { + process.stdout.write(check ? "CI workflow bytes: PASS\n" : "CI workflow generated atomically\n"); + } + } catch (error) { + process.stderr.write(`CI workflow generation failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/generate-contract-set.ts b/scripts/generate-contract-set.ts new file mode 100644 index 0000000..8aa37f6 --- /dev/null +++ b/scripts/generate-contract-set.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { + CONTRACT_SET_ALGORITHM, + computeContractSetDigestWith, + type ContractSetPackage, +} from "../src/contracts/contract-set-canonical.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; + +/** + * §5.4 / §17.2.2 step 2. Derives the build-time expected contract set from the + * installed contributions and emits it as generated source for the manifest + * writer. `TEMPLATE_FIXTURE` provenance never reaches this list, so a fixture + * cannot influence the release digest. + */ + +const OUTPUT = ".generated/frontend-runtime/contract-set.ts"; + +function sha256(bytes: Uint8Array): Uint8Array { + return new Uint8Array(createHash("sha256").update(bytes).digest()); +} + +export function buildContractSet( + packages: readonly ContractSetPackage[] = EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[], +) { + return Object.freeze({ + setAlgorithm: CONTRACT_SET_ALGORITHM, + setDigest: computeContractSetDigestWith(packages, sha256), + packages: Object.freeze( + [...packages] + .map((entry) => Object.freeze({ ...entry })) + .sort((left, right) => (left.packageId < right.packageId ? -1 : 1)), + ), + }); +} + +async function main(): Promise { + const contractSet = buildContractSet(); + const source = [ + "// Generated by scripts/generate-contract-set.ts. Do not edit.", + "// Regenerated from scratch on every build; never committed.", + "", + "import type { ContractSet } from \"../../src/contracts/contract-set.ts\";", + "", + `export const BUILD_CONTRACT_SET: ContractSet = ${JSON.stringify( + contractSet, + null, + 2, + )} as const;`, + "", + ].join("\n"); + + await mkdir(dirname(OUTPUT), { recursive: true }); + await writeFile(OUTPUT, source, "utf8"); + process.stdout.write( + `contract set: ${contractSet.packages.length} package(s) ${contractSet.setDigest}\n`, + ); +} + +if (process.argv[1]?.endsWith("generate-contract-set.ts")) { + await main(); +} diff --git a/scripts/generate-service-worker-assets.ts b/scripts/generate-service-worker-assets.ts new file mode 100644 index 0000000..543a7b0 --- /dev/null +++ b/scripts/generate-service-worker-assets.ts @@ -0,0 +1,144 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + SERVICE_WORKER_BOUNDS, + SERVICE_WORKER_SCRIPT_PATH, + type StaticAssetManifestV1, +} from "../src/contracts/service-worker.ts"; + +/** + * §17.2.2 step 4. Scans the completed app `dist` and emits the exact hashed + * asset list the Service Worker will verify at install time. + * + * `service-worker.js` itself and `index.html` are excluded (§17.2.2), as are + * the runtime config and release manifest, which are network-only (§18.4). + */ + +const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts"; + +const CACHEABLE_EXTENSIONS: Readonly> = Object.freeze({ + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".woff2": "font/woff2", + ".svg": "image/svg+xml", + ".png": "image/png", + ".webp": "image/webp", +}); + +const EXCLUDED_FILES: ReadonlySet = new Set([ + "index.html", + SERVICE_WORKER_SCRIPT_PATH, + "config.json", + "release-manifest.json", + "runtime-config.schema.json", +]); + +/** Vite emits content-hashed names; only those may be treated as immutable. */ +const HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/; + +export async function collectStaticAssets( + distDirectory: string, + buildId: string, + releaseId: string, +): Promise { + const files = await walk(distDirectory, distDirectory); + const assets: StaticAssetManifestV1["assets"][number][] = []; + + for (const relative of files.sort()) { + const base = path.basename(relative); + if (EXCLUDED_FILES.has(base) || relative.startsWith(".vite/")) continue; + const contentType = CACHEABLE_EXTENSIONS[path.extname(base).toLowerCase()]; + if (!contentType || !HASHED_NAME.test(base)) continue; + + const absolute = path.join(distDirectory, relative); + const bytes = await readFile(absolute); + if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) { + throw new Error(`Static asset exceeds its byte bound: ${relative}`); + } + assets.push({ + url: `/${relative.split(path.sep).join("/")}`, + sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + bytes: bytes.byteLength, + contentType, + }); + } + + if (assets.length > SERVICE_WORKER_BOUNDS.assets) { + throw new Error("Static asset count exceeds its bound."); + } + const totalBytes = assets.reduce((sum, asset) => sum + asset.bytes, 0); + if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) { + throw new Error("Static asset set exceeds its byte bound."); + } + + // The set digest is a length-prefixed hash over the sorted asset identities, + // so a reordered directory listing cannot change it. + const hash = createHash("sha256"); + hash.update("CA_STATIC_ASSET_SET_V1\0"); + for (const asset of assets) { + hash.update(lengthPrefixed(asset.url)); + hash.update(lengthPrefixed(asset.sha256)); + hash.update(lengthPrefixed(String(asset.bytes))); + hash.update(lengthPrefixed(asset.contentType)); + } + + return { + schemaVersion: 1, + buildId, + releaseId, + setDigest: `sha256:${hash.digest("hex")}`, + assets, + }; +} + +function lengthPrefixed(value: string): Buffer { + const bytes = Buffer.from(value, "utf8"); + const prefix = Buffer.alloc(4); + prefix.writeUInt32BE(bytes.byteLength, 0); + return Buffer.concat([prefix, bytes]); +} + +async function walk(root: string, current: string): Promise { + const entries = await readdir(current, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + files.push(...(await walk(root, absolute))); + } else if ((await stat(absolute)).isFile()) { + files.push(path.relative(root, absolute)); + } + } + return files; +} + +async function main(): Promise { + const distDirectory = process.argv[2] ?? "dist"; + const buildId = process.env.VITE_BUILD_ID ?? "local-build"; + const releaseId = process.env.RELEASE_ID ?? "local-release"; + const manifest = await collectStaticAssets(distDirectory, buildId, releaseId); + const source = [ + "// Generated by scripts/generate-service-worker-assets.ts. Do not edit.", + "", + 'import type { StaticAssetManifestV1 } from "../../src/contracts/service-worker.ts";', + "", + `export const SERVICE_WORKER_ASSETS: StaticAssetManifestV1 = ${JSON.stringify( + manifest, + null, + 2, + )} as const;`, + "", + ].join("\n"); + await mkdir(path.dirname(OUTPUT), { recursive: true }); + await writeFile(OUTPUT, source, "utf8"); + process.stdout.write( + `service worker assets: ${manifest.assets.length} file(s) ${manifest.setDigest}\n`, + ); +} + +if (process.argv[1]?.endsWith("generate-service-worker-assets.ts")) { + await main(); +} diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts new file mode 100644 index 0000000..4f75283 --- /dev/null +++ b/scripts/generate-supply-chain.ts @@ -0,0 +1,381 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdir, + readFile, + writeFile, +} from "node:fs/promises"; + +import { + bundleOutputInventoryArtifactSchema, + buildManifestArtifactSchema, + dependencyDiffArtifactSchema, + dependencyInventoryArtifactSchema, + licenseReportArtifactSchema, + provenanceArtifactSchema, + sbomArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "./contracts/release-artifacts.ts"; +import { + flattenPnpmDependencyTree, + isValidSha512Integrity, + parsePnpmLockfilePackages, + supplyChainDigest, + verifySupplyChainCoherence, +} from "./lib/supply-chain.ts"; +import { + createLocalVulnerabilityReport, + distChecksumsText, + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS, + recomputeDependencyEvidence, + recomputeLicenseEvidence, +} from "./lib/local-policy-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts"; +import { + buildRepositoryFileInventory, + parseRepositoryFileInventoryPolicy, +} from "./lib/repository-file-inventory.ts"; +import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts"; +import { deterministicSupplyChainGeneratedAt } from "./lib/supply-chain-time.ts"; + +type Document = Record; + +function isRecord(value: unknown): value is Document { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function documentValue(value: unknown, label: string): Document { + if (!isRecord(value)) throw new Error(`${label} must be a JSON object`); + return value; +} + +function stringMap(value: unknown): Record { + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +async function jsonDocument(file: string): Promise { + const parsed: unknown = JSON.parse(await readFile(file, "utf8")); + return documentValue(parsed, file); +} + +async function sha256File(file: string): Promise { + return createHash("sha256").update(await readFile(file)).digest("hex"); +} + +async function optionalJson(file: string): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(file, "utf8")); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +export async function buildDependencyInventory() { + const packageJson = await jsonDocument("package.json"); + const lockfileText = await readFile("pnpm-lock.yaml", "utf8"); + const lockfileSha256 = createHash("sha256") + .update(lockfileText) + .digest("hex"); + const listed = spawnSync( + "corepack", + ["pnpm", "list", "--json", "--depth", "Infinity"], + { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }, + ); + if (listed.status !== 0) { + throw new Error(`pnpm dependency graph failed: ${listed.stderr}`); + } + const roots: unknown = JSON.parse(listed.stdout); + const root = Array.isArray(roots) && isRecord(roots[0]) ? roots[0] : null; + if (!root) throw new Error("pnpm dependency graph root is invalid"); + const flattened = await flattenPnpmDependencyTree( + root, + stringMap(packageJson.dependencies), + stringMap(packageJson.devDependencies), + ); + const lockRows = parsePnpmLockfilePackages(lockfileText); + const lockByIdentity = new Map( + lockRows.map((row) => [`${row.name}@${row.version}`, row]), + ); + const failures: string[] = []; + const dependencies = flattened.map((dependency) => { + const identity = `${dependency.name}@${dependency.version}`; + const lockRow = lockByIdentity.get(identity); + if (!lockRow) failures.push(`dependency missing from lockfile: ${identity}`); + if (lockRow && !isValidSha512Integrity(lockRow.integrity)) { + failures.push(`dependency has invalid sha512 integrity: ${identity}`); + } + return { + ...dependency, + integrity: lockRow?.integrity ?? "missing", + }; + }); + const inventoryIds = new Set( + dependencies.map((dependency) => `${dependency.name}@${dependency.version}`), + ); + for (const lockRow of lockRows) { + const identity = `${lockRow.name}@${lockRow.version}`; + if (!inventoryIds.has(identity)) { + failures.push(`transitive lockfile dependency omitted: ${identity}`); + } + } + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } + return { + schemaVersion: 2, + packageManager: String(packageJson.packageManager ?? ""), + lockfileSha256, + dependencyCount: dependencies.length, + directDependencyCount: dependencies.filter((entry) => entry.direct).length, + dependencies, + }; +} + +const packageJson = await jsonDocument("package.json"); +const buildManifest = buildManifestArtifactSchema.parse( + await jsonDocument("artifacts/release/build-manifest.json"), +); +const secretScanPolicy = documentValue( + JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")), + "secret scan policy", +); +const inventoryPolicy = parseRepositoryFileInventoryPolicy(secretScanPolicy); +const repositoryInventory = await buildRepositoryFileInventory({ + trackedRoots: inventoryPolicy.trackedRoots, + generatedRoots: inventoryPolicy.generatedRoots, + optionalRoots: inventoryPolicy.optionalRoots, +}); +const outputs = await collectDistOutputs(); +const distDigest = distSha256(outputs); +const inventory = await buildDependencyInventory(); +const licensePolicy = JSON.parse( + await readFile("config/security/dependency-policy.json", "utf8"), +); + +const baseline = await optionalJson( + "config/security/dependency-baseline.json", +); +const baselineApproval = await optionalJson( + "config/security/dependency-baseline.approval.json", +); +const dependencyEvidence = JSON.parse( + await readFile( + "config/security/dependency-change-evidence.json", + "utf8", + ), +); +const skipsBaseline = process.argv.includes("--no-baseline"); +const dependencyPolicy = recomputeDependencyEvidence({ + inventory, + baseline, + baselineApproval, + dependencyChangeEvidence: dependencyEvidence, + skipBaseline: skipsBaseline, +}); +const licenseEvidence = recomputeLicenseEvidence({ + inventory, + policy: licensePolicy, +}); + +const vulnerabilityReport = createLocalVulnerabilityReport( + inventory.lockfileSha256, +); + +const sourceFiles = [...repositoryInventory.trackedFiles]; +const sourceSetSha256 = await digestReleaseInputFiles(sourceFiles); + +const components = inventory.dependencies.map((dependency) => ({ + type: "library", + "bom-ref": `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`, + name: dependency.name, + version: dependency.version, + scope: dependency.optional ? "optional" : "required", + hashes: [ + { + alg: "SHA-512", + content: dependency.integrity.slice("sha512-".length), + }, + ], + licenses: + dependency.license === "NOASSERTION" + ? [{ expression: "NOASSERTION" }] + : [{ expression: dependency.license }], + properties: [ + { name: "ca:direct", value: String(dependency.direct) }, + { name: "ca:scope", value: dependency.scope }, + ], +})); +const serialSeed = supplyChainDigest({ + lockfileSha256: inventory.lockfileSha256, + components: components.map((component) => component["bom-ref"]), +}); +const sbom = { + bomFormat: "CycloneDX", + specVersion: "1.6", + serialNumber: `urn:uuid:${serialSeed.slice(0, 8)}-${serialSeed.slice(8, 12)}-${serialSeed.slice(12, 16)}-${serialSeed.slice(16, 20)}-${serialSeed.slice(20, 32)}`, + version: 1, + metadata: { + component: { + type: "application", + name: String(packageJson.name ?? ""), + version: String(packageJson.version ?? ""), + }, + properties: [ + { + name: "ca:lockfileSha256", + value: inventory.lockfileSha256, + }, + ], + }, + components, + dependencies: inventory.dependencies.map((dependency) => ({ + ref: `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`, + dependsOn: dependency.dependencies.map((identity) => { + const separator = identity.lastIndexOf("@"); + return `pkg:npm/${encodeURIComponent(identity.slice(0, separator))}@${identity.slice(separator + 1)}`; + }), + })), +}; + +const provenance = { + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "dist", digest: { sha256: distDigest } }], + predicateType: "https://slsa.dev/provenance/v1", + predicate: { + buildDefinition: { + buildType: "https://vite.dev/build/v1", + externalParameters: { + nodeVersion: process.version, + packageManager: String(packageJson.packageManager ?? ""), + }, + internalParameters: { + sourceSetSha256, + }, + resolvedDependencies: [ + { + uri: "pnpm-lock.yaml", + digest: { sha256: inventory.lockfileSha256 }, + }, + ], + }, + runDetails: { + builder: { id: "local:clean-architecture-frontend-template" }, + metadata: { invocationId: "LOCAL_UNSIGNED" }, + }, + materials: { + lockfileSha256: inventory.lockfileSha256, + sourceSetSha256, + sbomSha256: supplyChainDigest(sbom), + }, + }, +}; +const coherence = verifySupplyChainCoherence( + sbom, + inventory, + provenance, + distDigest, +); + +const localFailures = [ + ...licenseEvidence.failures, + ...dependencyPolicy.failures, + ...coherence.failures, +]; +const localPassed = localFailures.length === 0; +const verification = { + schemaVersion: 1, + localStatus: localPassed ? "PASS" : "FAIL", + ...LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS, + lockfileSha256: inventory.lockfileSha256, + sourceSetSha256, + distSha256: distDigest, + sbomSha256: supplyChainDigest(sbom), + dependencyDiff: dependencyPolicy.dependencyDiff, + highRiskReview: dependencyPolicy.highRisk, + failures: localFailures, +}; +const bundleReport = { + schemaVersion: 1, + generatedAt: deterministicSupplyChainGeneratedAt({ + generatedAt: buildManifest.generatedAt, + sourceDateEpoch: buildManifest.buildContext.sourceDateEpoch, + }), + context: { + nodeVersion: process.version, + packageManager: String(packageJson.packageManager ?? ""), + runnerImage: + process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`, + }, + outputs, +}; +const dependencyDiffReport = dependencyPolicy.report; +const licenseReport = licenseEvidence.report; + +await mkdir("artifacts/performance", { recursive: true }); +await mkdir("artifacts/release", { recursive: true }); +await mkdir("artifacts/security", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/performance/bundle.json", + schema: bundleOutputInventoryArtifactSchema, + value: bundleReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/dependency-inventory.json", + schema: dependencyInventoryArtifactSchema, + value: inventory, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/sbom.cdx.json", + schema: sbomArtifactSchema, + value: sbom, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/provenance.json", + schema: provenanceArtifactSchema, + value: provenance, +}); +await writeFile( + "artifacts/release/checksums.txt", + distChecksumsText(outputs), +); +await writeValidatedJsonArtifact({ + path: "artifacts/security/dependency-diff.json", + schema: dependencyDiffArtifactSchema, + value: dependencyDiffReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/security/license-report.json", + schema: licenseReportArtifactSchema, + value: licenseReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/security/vulnerability-report.json", + schema: vulnerabilityReportArtifactSchema, + value: vulnerabilityReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/security/supply-chain-verification.json", + schema: supplyChainVerificationArtifactSchema, + value: verification, +}); + +if (!localPassed) { + process.stderr.write( + `Local supply-chain verification failed:\n- ${localFailures.join("\n- ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Supply chain: LOCAL PASS (${inventory.dependencyCount} dependencies); promotion=${verification.promotionStatus}\n`, +); diff --git a/scripts/lib/bounded-pnpm-script.ts b/scripts/lib/bounded-pnpm-script.ts new file mode 100644 index 0000000..56c1d27 --- /dev/null +++ b/scripts/lib/bounded-pnpm-script.ts @@ -0,0 +1,66 @@ +import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process"; + +export const PNPM_SCRIPT_TIMEOUT_MS = 60_000; +export const PNPM_SCRIPT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; + +type BoundedChildInvocation = Readonly<{ + command: string; + arguments: readonly string[]; + options: SpawnSyncOptionsWithStringEncoding; +}>; + +type PnpmScriptResult = Readonly<{ + status: number | null; + signal: NodeJS.Signals | null; + error?: Error; +}>; + +export function createBoundedPnpmScriptInvocation(input: Readonly<{ + nodePath: string; + pnpmCli: string; + script: string; + environment: NodeJS.ProcessEnv; +}>): BoundedChildInvocation { + return createBoundedChildInvocation({ + command: input.nodePath, + arguments: [input.pnpmCli, "run", input.script], + environment: input.environment, + }); +} + +export function createBoundedChildInvocation(input: Readonly<{ + command: string; + arguments: readonly string[]; + environment: NodeJS.ProcessEnv; + cwd?: string; +}>): BoundedChildInvocation { + return Object.freeze({ + command: input.command, + arguments: Object.freeze([...input.arguments]), + options: Object.freeze({ + ...(input.cwd === undefined ? {} : { cwd: input.cwd }), + encoding: "utf8", + env: input.environment, + killSignal: "SIGTERM", + maxBuffer: PNPM_SCRIPT_MAX_OUTPUT_BYTES, + timeout: PNPM_SCRIPT_TIMEOUT_MS, + }), + }); +} + +export function formatPnpmScriptFailure( + script: string, + result: PnpmScriptResult, +): string { + const code = + result.error && "code" in result.error && + typeof result.error.code === "string" + ? result.error.code + : null; + const message = result.error?.message.trim().replace(/\s+/g, " ") ?? null; + const error = + result.error === undefined + ? "none" + : `${code ?? result.error.name}: ${message || "no message"}`; + return `${script} failed: exit=${String(result.status)}, signal=${result.signal ?? "none"}, error=${error}`; +} diff --git a/scripts/lib/build-environment.ts b/scripts/lib/build-environment.ts new file mode 100644 index 0000000..9db5193 --- /dev/null +++ b/scripts/lib/build-environment.ts @@ -0,0 +1,96 @@ +export const CI_BUILD_ENVIRONMENT_VARIABLES = Object.freeze([ + "VITE_BUILD_ID", + "VITE_COMMIT_SHA", + "RELEASE_ID", + "CI_RUNNER_IMAGE", + "SOURCE_DATE_EPOCH", +]); + +export function ciBuildEnvironmentFailures( + environment: Readonly>, +) { + if (environment.CI !== "true") return []; + + const failures = CI_BUILD_ENVIRONMENT_VARIABLES.filter( + (name) => !environment[name]?.trim(), + ).map((name) => `missing required CI build environment: ${name}`); + + const commitSha = environment.VITE_COMMIT_SHA?.trim(); + if (commitSha && !isValidCommitSha(commitSha)) { + failures.push( + "VITE_COMMIT_SHA must be a full 40- or 64-character hexadecimal commit ID", + ); + } + + const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim(); + if (sourceDateEpoch && !isValidSourceDateEpoch(sourceDateEpoch)) { + failures.push("SOURCE_DATE_EPOCH must be non-negative epoch seconds"); + } + + const runnerImage = environment.CI_RUNNER_IMAGE?.trim(); + if ( + runnerImage && + !/@sha256:[0-9a-f]{64}$/i.test(runnerImage) + ) { + failures.push( + "CI_RUNNER_IMAGE must end with an immutable @sha256 image digest", + ); + } + + return failures; +} + +export function assertCiBuildEnvironment( + environment: Readonly>, +) { + const failures = ciBuildEnvironmentFailures(environment); + if (failures.length > 0) { + throw new Error(failures.join("; ")); + } +} + +export function isValidCommitSha(value: string) { + return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value); +} + +export function isValidSourceDateEpoch(value: string) { + if (!/^\d+$/.test(value)) return false; + const milliseconds = Number(value) * 1_000; + return Number.isSafeInteger(milliseconds) && Number.isFinite( + new Date(milliseconds).getTime(), + ); +} + +export function ciCheckoutIdentityFailures( + environment: Readonly>, + checkout: { commitSha: string; sourceDateEpoch: string }, +) { + if (environment.CI !== "true") return []; + + const failures = []; + const configuredCommitSha = environment.VITE_COMMIT_SHA?.trim(); + if ( + configuredCommitSha && + configuredCommitSha.toLowerCase() !== checkout.commitSha.toLowerCase() + ) { + failures.push("VITE_COMMIT_SHA does not identify the checked-out commit"); + } + const configuredEpoch = environment.SOURCE_DATE_EPOCH?.trim(); + if (configuredEpoch && configuredEpoch !== checkout.sourceDateEpoch) { + failures.push( + "SOURCE_DATE_EPOCH does not match the checked-out commit timestamp", + ); + } + return failures; +} + +export function buildDate( + environment: Readonly>, +) { + const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim(); + if (!sourceDateEpoch) return new Date(); + if (!isValidSourceDateEpoch(sourceDateEpoch)) { + throw new Error("SOURCE_DATE_EPOCH must be non-negative epoch seconds"); + } + return new Date(Number(sourceDateEpoch) * 1_000); +} diff --git a/scripts/lib/build-manifest-outputs.ts b/scripts/lib/build-manifest-outputs.ts new file mode 100644 index 0000000..0f7dea4 --- /dev/null +++ b/scripts/lib/build-manifest-outputs.ts @@ -0,0 +1,188 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; + +import type { BuildManifestArtifact } from "../../src/contracts/release-artifacts.ts"; +import { moduleInventoryArtifactSchema } from "../contracts/release-artifacts.ts"; + +type VerifyBuildManifestOutputsDependencies = Readonly<{ + repositoryRoot?: string; + readBytes?: (target: string) => Promise; + realpathPath?: (target: string) => Promise; + assertRegularFile?: (target: string) => Promise; + assertDirectory?: (target: string) => Promise; +}>; + +export const CANONICAL_VITE_MANIFEST_PATH = "dist/.vite/manifest.json"; + +function isSafeRelativePath(value: string): boolean { + return ( + value.length > 0 && + !path.posix.isAbsolute(value) && + !path.win32.isAbsolute(value) && + !value.includes("\\") && + !value.includes("\0") && + path.posix.normalize(value) === value && + value !== ".." && + !value.startsWith("../") + ); +} + +function isWithinRoot(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function belongsToApprovedRoot(value: string, approvedRoot: string): boolean { + return value.startsWith(`${approvedRoot}/`); +} + +async function defaultAssertRegularFile(target: string): Promise { + const metadata = await lstat(target); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new TypeError("not a regular file"); + } +} + +async function defaultAssertDirectory(target: string): Promise { + const metadata = await lstat(target); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new TypeError("not a directory"); + } +} + +export async function verifyBuildManifestOutputs( + manifest: BuildManifestArtifact, + dependencies: VerifyBuildManifestOutputsDependencies = {}, +): Promise { + const repositoryRoot = path.resolve(dependencies.repositoryRoot ?? process.cwd()); + const readBytes = dependencies.readBytes ?? readFile; + const realpathPath = dependencies.realpathPath ?? realpath; + const assertRegularFile = dependencies.assertRegularFile ?? defaultAssertRegularFile; + const assertDirectory = dependencies.assertDirectory ?? defaultAssertDirectory; + const mismatches: string[] = []; + const resolvedRoot = await realpathPath(repositoryRoot); + const approvedRoots = new Map>(); + + function resolveApprovedRoot(relativeRoot: string): Promise { + const existing = approvedRoots.get(relativeRoot); + if (existing) return existing; + const pending = (async () => { + const absoluteRoot = path.resolve(repositoryRoot, relativeRoot); + try { + await assertDirectory(absoluteRoot); + const resolvedApprovedRoot = await realpathPath(absoluteRoot); + return isWithinRoot(resolvedRoot, resolvedApprovedRoot) + ? resolvedApprovedRoot + : null; + } catch { + return null; + } + })(); + approvedRoots.set(relativeRoot, pending); + return pending; + } + + async function confinedPath( + label: string, + relativePath: string, + kind: "file" | "directory", + approvedRoot?: string, + ): Promise { + if ( + !isSafeRelativePath(relativePath) || + (approvedRoot !== undefined && + !belongsToApprovedRoot(relativePath, approvedRoot)) + ) { + mismatches.push(`buildManifest:${label}:path`); + return null; + } + const absolutePath = path.resolve(repositoryRoot, relativePath); + if (!isWithinRoot(repositoryRoot, absolutePath)) { + mismatches.push(`buildManifest:${label}:path`); + return null; + } + try { + if (kind === "file") await assertRegularFile(absolutePath); + else await assertDirectory(absolutePath); + const resolvedPath = await realpathPath(absolutePath); + const resolvedApprovedRoot = approvedRoot + ? await resolveApprovedRoot(approvedRoot) + : resolvedRoot; + if ( + resolvedApprovedRoot === null || + !isWithinRoot(resolvedRoot, resolvedPath) || + !isWithinRoot(resolvedApprovedRoot, resolvedPath) + ) { + mismatches.push(`buildManifest:${label}:path`); + return null; + } + return absolutePath; + } catch { + mismatches.push(`buildManifest:${label}:missing`); + return null; + } + } + + if (manifest.outputs.directory !== "dist") { + mismatches.push("buildManifest:directory:path"); + } else { + await confinedPath("directory", manifest.outputs.directory, "directory"); + } + if (manifest.outputs.viteManifest !== CANONICAL_VITE_MANIFEST_PATH) { + mismatches.push("buildManifest:viteManifest:path"); + } else { + await confinedPath( + "viteManifest", + manifest.outputs.viteManifest, + "file", + "dist", + ); + } + await confinedPath( + "runtimeConfigSchema", + manifest.outputs.runtimeConfigSchema, + "file", + "dist", + ); + for (const [chunkId, chunkPath] of Object.entries(manifest.outputs.routeChunks)) { + if (!isSafeRelativePath(chunkPath)) { + mismatches.push(`buildManifest:routeChunk:${chunkId}:path`); + continue; + } + await confinedPath( + `routeChunk:${chunkId}`, + path.posix.join(manifest.outputs.directory, chunkPath), + "file", + "dist", + ); + } + const moduleInventoryPath = await confinedPath( + "moduleInventory", + manifest.outputs.moduleInventory, + "file", + "artifacts/quality", + ); + if (moduleInventoryPath) { + try { + const bytes = await readBytes(moduleInventoryPath); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== manifest.moduleInventoryHash) { + mismatches.push("buildManifest:moduleInventoryHash"); + } + try { + moduleInventoryArtifactSchema.parse(JSON.parse(bytes.toString("utf8"))); + } catch { + mismatches.push("buildManifest:moduleInventory:invalid"); + } + } catch { + mismatches.push("buildManifest:moduleInventory:missing"); + } + } + return mismatches; +} diff --git a/scripts/lib/ci-artifact-validator.ts b/scripts/lib/ci-artifact-validator.ts new file mode 100644 index 0000000..9693bdf --- /dev/null +++ b/scripts/lib/ci-artifact-validator.ts @@ -0,0 +1,740 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { z, type ZodType } from "zod"; + +import type { + CiGateArtifact, + CiGateArtifactSchema, +} from "../contracts/ci-gates.ts"; +import { ciContractReportSchema } from "./ci-contract-report.ts"; +import { + architectureDependencyReportArtifactSchema, + automatedA11yArtifactSchema, + buildManifestArtifactSchema, + bundlePerformanceArtifactSchema, + compatibilityFixturesArtifactSchema, + dependencyDiffArtifactSchema, + dependencyInventoryArtifactSchema, + designSystemReportArtifactSchema, + diagnosticsReportArtifactSchema, + documentationReviewArtifactSchema, + fieldWebVitalsArtifactSchema, + hostingHeadersArtifactSchema, + i18nReportArtifactSchema, + jsonSchemaDocumentArtifactSchema, + labPerformanceArtifactSchema, + licenseReportArtifactSchema, + manualA11yReportArtifactSchema, + moduleInventoryArtifactSchema, + optionalRecipeFixturesArtifactSchema, + optionalRecipesArtifactSchema, + provenanceArtifactSchema, + realtimeBoundariesArtifactSchema, + registryGovernanceRunArtifactSchema, + registryCompatibilityFixturesArtifactSchema, + registrySnapshotArtifactSchema, + releaseVerificationArtifactSchema, + reproducibleBuildArtifactSchema, + runbookRecordArtifactSchema, + sbomArtifactSchema, + supplyChainFixturesArtifactSchema, + supplyChainProviderFixturesArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "../contracts/release-artifacts.ts"; +import { httpScenarioReceiptSchema } from "./http-scenario-evidence.ts"; +import { supplyChainCoherenceReportSchema } from "./local-release-evidence.ts"; +import { + providerVerificationArtifactSchema, + provenanceProviderAttestationSchema, + vulnerabilityProviderReportSchema, +} from "./provider-evidence.ts"; +import { releaseCandidateManifestSchema } from "./release-candidate.ts"; +import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; +import { secretScanSarifSchema } from "./secret-scan-evaluator.ts"; +import { testEvidenceReportSchema } from "./test-evidence-artifact.ts"; + +const coverageCounterSchema = z + .object({ + total: z.number().int().nonnegative(), + covered: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + pct: z.number().min(0).max(100), + }) + .strict() + .superRefine((counter, context) => { + if (counter.covered + counter.skipped > counter.total) { + context.addIssue({ code: "custom", message: "coverage counter exceeds total" }); + } + const expected = counter.total === 0 + ? 100 + : Math.floor((counter.covered / counter.total) * 10_000) / 100; + if (counter.pct !== expected) { + context.addIssue({ code: "custom", path: ["pct"], message: "coverage pct is not exact" }); + } + }); +const coverageSummarySchema = z + .record( + z.string(), + z + .object({ + lines: coverageCounterSchema, + statements: coverageCounterSchema, + functions: coverageCounterSchema, + branches: coverageCounterSchema, + }) + .strict(), + ) + .refine((value) => "total" in value, "coverage summary lacks total"); +const riskCoverageArtifactSchema = z + .object({ + schemaVersion: z.literal(3), + policy: z.string().min(1), + summary: z.string().min(1), + status: z.enum(["PASS", "FAIL"]), + selectedTotal: z.number().int().nonnegative(), + repositoryTotal: z.number().int().positive(), + counterBearingTotal: z.number().int().nonnegative(), + instrumentedCounterBearingTotal: z.number().int().nonnegative(), + counterlessTotal: z.number().int().nonnegative(), + counterlessModules: z.array(z.string()), + preExclusionTotal: z.number().int().positive(), + generatedExclusionCount: z.number().int().nonnegative(), + generatedExclusions: z.array(z.string()), + ownershipScope: z.literal("ALL_POLICY_HIGH_RISK"), + ownedHighRiskPaths: z.array(z.string()), + waivedHighRiskPaths: z.array(z.string()), + uncoveredModules: z.array(z.string()), + results: z + .array( + z + .object({ + scope: z.string().min(1), + metric: z.enum(["lines", "statements", "functions", "branches"]), + threshold: z.number().min(0).max(100), + received: z.number().min(0).max(100), + passed: z.boolean(), + }) + .strict(), + ) + .min(4), + failures: z.array(z.string()), + }) + .strict() + .superRefine((artifact, context) => { + const fail = (path: PropertyKey[], message: string) => + context.addIssue({ code: "custom", path, message }); + if (artifact.counterBearingTotal + artifact.counterlessTotal !== artifact.repositoryTotal) { + fail(["counterBearingTotal"], "counter partition must equal repositoryTotal"); + } + if (artifact.instrumentedCounterBearingTotal > artifact.counterBearingTotal) { + fail(["instrumentedCounterBearingTotal"], "instrumented counters exceed counter-bearing total"); + } + if (artifact.counterlessModules.length !== artifact.counterlessTotal) { + fail(["counterlessModules"], "counterless list length drift"); + } + if (artifact.generatedExclusions.length !== artifact.generatedExclusionCount) { + fail(["generatedExclusions"], "generated exclusion list length drift"); + } + if ( + artifact.preExclusionTotal !== + artifact.repositoryTotal + artifact.generatedExclusionCount + ) { + fail(["preExclusionTotal"], "pre-exclusion inventory total drift"); + } + if ( + artifact.selectedTotal > artifact.repositoryTotal || + artifact.uncoveredModules.length !== artifact.repositoryTotal - artifact.selectedTotal + ) { + fail(["selectedTotal"], "selected/uncovered repository totals drift"); + } + if ( + (artifact.status === "PASS") !== + (artifact.failures.length === 0 && artifact.results.every(({ passed }) => passed)) + ) { + fail(["status"], "status must agree with failures and threshold results"); + } + artifact.results.forEach((result, index) => { + if (result.passed !== (result.received >= result.threshold)) { + fail(["results", index, "passed"], "threshold result is inconsistent"); + } + }); + for (const [field, values] of [ + ["counterlessModules", artifact.counterlessModules], + ["generatedExclusions", artifact.generatedExclusions], + ["ownedHighRiskPaths", artifact.ownedHighRiskPaths], + ["waivedHighRiskPaths", artifact.waivedHighRiskPaths], + ["uncoveredModules", artifact.uncoveredModules], + ] as const) { + if (new Set(values).size !== values.length) fail([field], "path list contains duplicates"); + } + const owned = new Set(artifact.ownedHighRiskPaths); + if (artifact.waivedHighRiskPaths.some((modulePath) => owned.has(modulePath))) { + fail(["waivedHighRiskPaths"], "owned and waived high-risk paths overlap"); + } + const resultsByScope = new Map>(); + artifact.results.forEach(({ scope, metric }, index) => { + const metrics = resultsByScope.get(scope) ?? new Set(); + if (metrics.has(metric)) { + fail(["results", index, "metric"], "threshold metric is duplicated within scope"); + } + metrics.add(metric); + resultsByScope.set(scope, metrics); + }); + for (const [scope, metrics] of resultsByScope) { + if (metrics.size !== 4) { + fail(["results"], `threshold scope must contain all four metrics: ${scope}`); + } + } + }); + +type ExecutableJsonSchemaId = Extract< + CiGateArtifactSchema, + Readonly<{ kind: "json" }> +>["executableSchemaId"]; + +const executableJsonSchemas: Readonly> = Object.freeze({ + "automated-a11y": automatedA11yArtifactSchema, + "manual-a11y": manualA11yReportArtifactSchema, + "architecture-dependency-report": architectureDependencyReportArtifactSchema, + "design-system-contract": designSystemReportArtifactSchema, + "i18n-contract": i18nReportArtifactSchema, + "diagnostics-contract": diagnosticsReportArtifactSchema, + "realtime-boundaries": realtimeBoundariesArtifactSchema, + "optional-recipes": optionalRecipesArtifactSchema, + "optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema, + "registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema, + "reproducible-build": reproducibleBuildArtifactSchema, + "supply-chain-fixtures": supplyChainFixturesArtifactSchema, + "supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema, + "compatibility-fixtures": compatibilityFixturesArtifactSchema, + "documentation-review": documentationReviewArtifactSchema, + "hosting-headers": hostingHeadersArtifactSchema, + "coverage-summary-v8": coverageSummarySchema, + "risk-coverage-v3": riskCoverageArtifactSchema, + "build-manifest": buildManifestArtifactSchema, + "module-inventory": moduleInventoryArtifactSchema, + "dependency-inventory": dependencyInventoryArtifactSchema, + "registry-snapshot": registrySnapshotArtifactSchema, + "registry-governance-run": registryGovernanceRunArtifactSchema, + "bundle-performance": bundlePerformanceArtifactSchema, + sbom: sbomArtifactSchema, + provenance: provenanceArtifactSchema, + "dependency-diff": dependencyDiffArtifactSchema, + "license-report": licenseReportArtifactSchema, + "vulnerability-report": vulnerabilityReportArtifactSchema, + "field-web-vitals": fieldWebVitalsArtifactSchema, + "lab-performance": labPerformanceArtifactSchema, + "release-verification": releaseVerificationArtifactSchema, + "runbook-record": runbookRecordArtifactSchema, + "supply-chain-verification": supplyChainVerificationArtifactSchema, + "release-candidate": releaseCandidateManifestSchema, + "supply-chain-coherence": supplyChainCoherenceReportSchema, + "http-scenario-receipt": httpScenarioReceiptSchema, + "test-evidence-report": testEvidenceReportSchema, + "provider-vulnerability": vulnerabilityProviderReportSchema, + "provider-provenance": provenanceProviderAttestationSchema, + "provider-verification": providerVerificationArtifactSchema, + "ci-contract-report": ciContractReportSchema, +}); + +export function hasCiArtifactSemanticValidator( + schema: CiGateArtifactSchema, +): boolean { + return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas; +} + +type ReadHandle = Readonly<{ + stat(): Promise; + read( + buffer: Buffer, + offset: number, + length: number, + position: number, + ): Promise>; + close(): Promise; +}>; +type ValidatorDependencies = Readonly<{ + lstatPath?: typeof lstat; + realpathPath?: typeof realpath; + openFile?: (target: string, flags: number) => Promise; +}>; + +export async function validateCiArtifact( + input: Readonly<{ + root: string; + artifact: CiGateArtifact; + schema: CiGateArtifactSchema; + }>, + dependencies: ValidatorDependencies = {}, +): Promise { + const relative = normalizeRepositoryRelativePath(input.artifact.path, "CI artifact path"); + assertExtensionCoherence(relative, input.schema.kind); + const bytes = await readBoundedRegularFile( + { root: input.root, relativePath: relative, maxBytes: input.schema.maxBytes }, + dependencies, + ); + if (input.schema.kind === "candidate-archive") return; + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (!text.trim()) throw new TypeError(`CI artifact is empty: ${relative}`); + switch (input.schema.kind) { + case "text": + return; + case "markdown": + if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`); + return; + case "html": + assertWellFormedHtml(text, relative); + return; + case "junit": + assertWellFormedJUnitXml(text, relative); + return; + case "sarif": + secretScanSarifSchema.parse(JSON.parse(text) as unknown); + return; + case "json-schema": + jsonSchemaDocumentArtifactSchema.parse(JSON.parse(text) as unknown); + return; + case "json": { + const schema = executableJsonSchemas[input.schema.executableSchemaId]; + if (!schema) throw new TypeError(`unknown executable artifact schema: ${input.schema.executableSchemaId}`); + schema.parse(JSON.parse(text) as unknown); + return; + } + } +} + +export async function readBoundedRegularFile( + input: Readonly<{ root: string; relativePath: string; maxBytes: number }>, + dependencies: ValidatorDependencies = {}, +): Promise { + const root = path.resolve(input.root); + const relative = normalizeRepositoryRelativePath(input.relativePath, "bounded file path"); + const maxBytes = input.maxBytes; + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 268_435_456) { + throw new RangeError("bounded file maximum must be within 1..268435456"); + } + const lstatPath = dependencies.lstatPath ?? lstat; + const realpathPath = dependencies.realpathPath ?? realpath; + const openFile = dependencies.openFile ?? (async (target, flags) => open(target, flags)); + const rootMetadata = await lstatPath(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new TypeError("bounded file root is unsafe"); + } + const rootRealpath = await realpathPath(root); + let ancestor = root; + const segments = relative.split("/"); + for (const segment of segments.slice(0, -1)) { + ancestor = path.join(ancestor, segment); + const metadata = await lstatPath(ancestor); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new TypeError(`CI artifact ancestor is unsafe: ${relative}`); + } + } + const absolute = path.join(root, relative); + const before = await lstatPath(absolute); + if (before.isSymbolicLink() || !before.isFile()) { + throw new TypeError(`CI artifact is not a regular file: ${relative}`); + } + if (before.size <= 0 || before.size > maxBytes) { + throw new RangeError(`CI artifact size is outside 1..${maxBytes}: ${relative}`); + } + const resolved = await realpathPath(absolute); + const outside = path.relative(rootRealpath, resolved); + if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) { + throw new TypeError(`CI artifact escapes repository: ${relative}`); + } + const handle = await openFile(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = await handle.stat(); + assertSameIdentity(before, opened, relative); + const bytes = await readHandleBounded(handle, before.size, maxBytes, relative); + const after = await handle.stat(); + assertSameIdentity(opened, after, relative); + if (bytes.byteLength <= 0 || bytes.byteLength > maxBytes || after.size !== bytes.byteLength) { + throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); + } + return bytes; + } finally { + await handle.close(); + } +} + +async function readHandleBounded( + handle: ReadHandle, + expectedSize: number, + maxBytes: number, + relative: string, +): Promise { + const captured = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedSize + 1)); + let offset = 0; + while (offset < captured.byteLength) { + const { bytesRead } = await handle.read( + captured, + offset, + captured.byteLength - offset, + offset, + ); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset !== expectedSize) { + throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); + } + return captured.subarray(0, offset); +} + +const MAX_DOCUMENT_DEPTH = 256; +const MAX_DOCUMENT_UNITS = 100_000; + +function assertWellFormedJUnitXml(source: string, relative: string): void { + const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`); + if (/") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid(); + if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid(); + if (open < 0) break; + if (source.startsWith("", open + 4); + if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid(); + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 3; + continue; + } + if (source.startsWith("", open + 9); + if (stack.length === 0 || close < 0) throw invalid(); + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 3; + continue; + } + if (source.startsWith("", open + 2); + if (root || close < 0) throw invalid(); + const processingInstruction = source.slice(open, close + 2); + const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec( + processingInstruction, + ); + if (!match) throw invalid(); + if (match[1]!.toLowerCase() === "xml") { + if ( + declarationSeen || + source.slice(0, open).trim() || + !/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test( + processingInstruction, + ) + ) { + throw invalid(); + } + declarationSeen = true; + } + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 2; + continue; + } + if (source.startsWith("$/u.exec(tag); + if (closing) { + if (stack.pop() !== closing[1]) throw invalid(); + if (stack.length === 0) rootClosed = true; + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 1; + continue; + } + const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag); + if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid(); + root ??= opening[1]; + if (opening[3] !== "/") { + if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid(); + stack.push(opening[1]!); + } + else if (stack.length === 0) rootClosed = true; + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 1; + } + if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) { + throw invalid(); + } +} + +function hasValidXmlAttributes(source: string): boolean { + let remaining = source; + const names = new Set(); + while (remaining.length > 0) { + if (!remaining.trim()) return true; + const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining); + if (!match || names.has(match[1]!)) return false; + if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false; + names.add(match[1]!); + remaining = remaining.slice(match[0].length); + } + return true; +} + +function hasOnlyXmlCharacters(source: string): boolean { + for (const character of source) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint !== 0x09 && + codePoint !== 0x0a && + codePoint !== 0x0d && + (codePoint < 0x20 || + (codePoint > 0xd7ff && codePoint < 0xe000) || + (codePoint > 0xfffd && codePoint < 0x10000) || + codePoint > 0x10ffff) + ) { + return false; + } + } + return true; +} + +function hasValidXmlEntities(source: string): boolean { + let cursor = 0; + while (cursor < source.length) { + const ampersand = source.indexOf("&", cursor); + if (ampersand < 0) return true; + const semicolon = source.indexOf(";", ampersand + 1); + if (semicolon < 0) return false; + const entity = source.slice(ampersand + 1, semicolon); + if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) { + const decimal = /^#([0-9]+)$/u.exec(entity); + const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity); + if (!decimal && !hexadecimal) return false; + const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16); + if ( + !Number.isSafeInteger(codePoint) || + (codePoint !== 0x09 && + codePoint !== 0x0a && + codePoint !== 0x0d && + (codePoint < 0x20 || + (codePoint > 0xd7ff && codePoint < 0xe000) || + (codePoint > 0xfffd && codePoint < 0x10000) || + codePoint > 0x10ffff)) + ) { + return false; + } + } + cursor = semicolon + 1; + } + return true; +} + +const HTML_VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); +const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]); + +function assertWellFormedHtml(source: string, relative: string): void { + const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`); + if (/]*\[/iu.test(source)) throw invalid(); + const stack: string[] = []; + let cursor = 0; + let units = 0; + let doctypeSeen = false; + let rootSeen = false; + let rootClosed = false; + let playwrightPayloadSeen = false; + while (cursor < source.length) { + const rawElement = stack.at(-1); + if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) { + const closingStart = source.toLowerCase().indexOf(` MAX_DOCUMENT_UNITS) throw invalid(); + cursor = closingStart; + } + const open = source.indexOf("<", cursor); + const text = source.slice(cursor, open < 0 ? source.length : open); + if (stack.length === 0 && text.trim()) throw invalid(); + if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid(); + if (open < 0) break; + if (source.startsWith("", open + 4); + if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid(); + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 3; + continue; + } + const declarationEnd = source.indexOf(">", open + 2); + if (source.slice(open, open + 9).toLowerCase() === "" + ) { + throw invalid(); + } + doctypeSeen = true; + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = declarationEnd + 1; + continue; + } + if (source.startsWith("$/u.exec(tag); + if (closing) { + const name = closing[1]!.toLowerCase(); + if (stack.pop() !== name) throw invalid(); + if (stack.length === 0) { + if (name === "html") rootClosed = true; + else if (name === "template" && playwrightPayloadSeen) { + // Playwright emits its base64 report template after ; HTML5 + // reparents this token into the document body. It is the sole + // permitted generated-report sidecar and does not create a new root. + } else { + throw invalid(); + } + } + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 1; + continue; + } + const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag); + if (!opening) throw invalid(); + const name = opening[1]!.toLowerCase(); + const attributes = parseHtmlAttributes(opening[2] ?? ""); + if (!attributes || name.includes(":")) throw invalid(); + if (!rootSeen) { + if (name !== "html" || opening[3] === "/") throw invalid(); + rootSeen = true; + } else if (name === "html") { + throw invalid(); + } + if (rootClosed && stack.length === 0) { + if ( + playwrightPayloadSeen || + name !== "template" || + attributes.get("id") !== "playwrightReportBase64" || + opening[3] === "/" + ) { + throw invalid(); + } + playwrightPayloadSeen = true; + } + if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") { + if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid(); + stack.push(name); + } else if (stack.length === 0 && name === "html") { + rootClosed = true; + } + if (++units > MAX_DOCUMENT_UNITS) throw invalid(); + cursor = close + 1; + } + if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid(); +} + +function markupEnd(source: string, start: number): number { + let quote: "\"" | "'" | undefined; + for (let index = start; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (character === quote) quote = undefined; + } else if (character === "\"" || character === "'") { + quote = character; + } else if (character === ">") { + return index; + } + } + return -1; +} + +function parseHtmlAttributes(source: string): ReadonlyMap | null { + const attributes = new Map(); + let cursor = 0; + while (cursor < source.length) { + const whitespace = /^\s+/u.exec(source.slice(cursor)); + if (!whitespace) return source.slice(cursor).trim() ? null : attributes; + cursor += whitespace[0].length; + if (cursor >= source.length) return attributes; + const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor)); + if (!nameMatch) return null; + const name = nameMatch[0].toLowerCase(); + if (attributes.has(name) || name.includes(":")) return null; + cursor += nameMatch[0].length; + const spacing = /^\s*/u.exec(source.slice(cursor))![0]; + cursor += spacing.length; + let value = ""; + if (source[cursor] === "=") { + cursor += 1; + cursor += /^\s*/u.exec(source.slice(cursor))![0].length; + const quote = source[cursor]; + if (quote === "\"" || quote === "'") { + const end = source.indexOf(quote, cursor + 1); + if (end < 0) return null; + value = source.slice(cursor + 1, end); + if (value.includes("<")) return null; + cursor = end + 1; + } else { + const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor)); + if (!unquoted) return null; + value = unquoted[0]; + cursor += value.length; + } + } + attributes.set(name, value); + } + return attributes; +} + +function assertSameIdentity(before: Stats, after: Stats, relative: string): void { + if ( + !Number.isSafeInteger(before.dev) || + !Number.isSafeInteger(before.ino) || + before.dev <= 0 || + before.ino <= 0 || + before.dev !== after.dev || + before.ino !== after.ino || + !after.isFile() + ) { + throw new TypeError(`CI artifact file identity changed: ${relative}`); + } +} + +function assertExtensionCoherence(relative: string, kind: CiGateArtifactSchema["kind"]): void { + const valid = + kind === "json" || kind === "json-schema" + ? relative.endsWith(".json") + : kind === "sarif" + ? relative.endsWith(".sarif") + : kind === "junit" + ? relative.endsWith(".xml") + : kind === "html" + ? relative.endsWith(".html") + : kind === "markdown" + ? relative.endsWith(".md") + : kind === "candidate-archive" + ? relative.endsWith(".tar.gz") + : !/\.(?:json|sarif|xml|html|md|tar\.gz)$/u.test(relative); + if (!valid) throw new TypeError(`CI artifact extension/kind mismatch: ${relative} (${kind})`); +} diff --git a/scripts/lib/ci-candidate-archive-cli.ts b/scripts/lib/ci-candidate-archive-cli.ts new file mode 100644 index 0000000..3f5ace4 --- /dev/null +++ b/scripts/lib/ci-candidate-archive-cli.ts @@ -0,0 +1,26 @@ +export const CANDIDATE_ARCHIVE_USAGE = + "Usage: verify-ci-candidate-archive --archive [--extract-to ] [--github-output ]\n"; + +export function parseCandidateArchiveArguments(arguments_: readonly string[]): Readonly<{ + archivePath: string; + extractTo?: string; + githubOutput?: string; +}> | null { + const allowed = new Set(["--archive", "--extract-to", "--github-output"]); + const values = new Map(); + for (let index = 0; index < arguments_.length; index += 2) { + const flag = arguments_[index]; + const value = arguments_[index + 1]; + if (!flag || !allowed.has(flag) || values.has(flag) || !value || value.startsWith("--")) { + return null; + } + values.set(flag, value); + } + const archivePath = values.get("--archive"); + if (!archivePath) return null; + return Object.freeze({ + archivePath, + ...(values.has("--extract-to") ? { extractTo: values.get("--extract-to")! } : {}), + ...(values.has("--github-output") ? { githubOutput: values.get("--github-output")! } : {}), + }); +} diff --git a/scripts/lib/ci-candidate-archive.ts b/scripts/lib/ci-candidate-archive.ts new file mode 100644 index 0000000..db5cfcd --- /dev/null +++ b/scripts/lib/ci-candidate-archive.ts @@ -0,0 +1,663 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; +import { + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + unlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + distSha256, + releaseCandidateManifestSchema, + type ReleaseCandidateManifest, +} from "./release-candidate.ts"; +import { supplyChainDigest } from "./supply-chain.ts"; +import { + assertSafePublishLeaf, + ensureSafePublishDirectory, +} from "./ci-gate-log.ts"; + +const MAX_ARCHIVE_BYTES = 268_435_456; +const MAX_CANDIDATE_FILES = 4_096; +const MAX_ARCHIVE_MEMBERS = 8_192; +const MAX_MEMBER_PATH_BYTES = 1_024; +const TAR_EXECUTABLE = "/usr/bin/tar"; +const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" }); + +export type CapturedCandidateArchive = Readonly<{ + bytes: Buffer; + archiveSha256: string; +}>; + +export async function captureCiCandidateArchive(input: Readonly<{ + archivePath: string; + expectedSha256: string; +}>): Promise { + if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) { + throw new TypeError("expected candidate archive SHA-256 is invalid"); + } + const absolute = path.resolve(input.archivePath); + const before = await lstat(absolute); + if (!before.isFile() || before.isSymbolicLink()) { + throw new TypeError("candidate archive must be a regular non-symlink file"); + } + if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) { + throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`); + } + const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + let bytes: Buffer; + try { + assertSameIdentity(before, await handle.stat()); + bytes = await readCapturedArchive(handle, before.size); + assertSameIdentity(before, await handle.stat()); + } finally { + await handle.close(); + } + const archiveSha256 = createHash("sha256").update(bytes).digest("hex"); + if (archiveSha256 !== input.expectedSha256) { + throw new Error("candidate archive SHA-256 mismatch"); + } + return Object.freeze({ bytes, archiveSha256 }); +} + +export async function withVerifiedCapturedCandidate(input: Readonly<{ + captured: CapturedCandidateArchive; + verify: (view: Readonly<{ + extractionRoot: string; + manifest: ReleaseCandidateManifest; + }>) => Promise; +}>): Promise { + let result: T | undefined; + await verifyCapturedCiCandidateArchive( + input.captured.bytes, + input.captured.archiveSha256, + { + verifyExtracted: async (extractionRoot, manifest) => { + result = await input.verify({ extractionRoot, manifest }); + }, + }, + ); + return result as T; +} + +export async function verifyCiCandidateArchive( + input: Readonly<{ + archivePath: string; + expectedSha256?: string; + extractTo?: string; + repositoryRoot?: string; + }>, + dependencies: Readonly<{ afterArchiveRead?: () => Promise }> = {}, +): Promise> { + if (input.expectedSha256 && !/^[a-f0-9]{64}$/u.test(input.expectedSha256)) { + throw new TypeError("expected candidate archive SHA-256 is invalid"); + } + const absolute = path.resolve(input.archivePath); + const before = await lstat(absolute); + if (!before.isFile() || before.isSymbolicLink()) { + throw new TypeError("candidate archive must be a regular non-symlink file"); + } + if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) { + throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`); + } + const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + let archive: Buffer; + try { + assertSameIdentity(before, await handle.stat()); + archive = await readCapturedArchive(handle, before.size); + assertSameIdentity(before, await handle.stat()); + } finally { + await handle.close(); + } + if (archive.byteLength !== before.size) { + throw new Error("candidate archive changed size during capture"); + } + await dependencies.afterArchiveRead?.(); + const archiveSha256 = createHash("sha256").update(archive).digest("hex"); + if (input.expectedSha256 && archiveSha256 !== input.expectedSha256) { + throw new Error("candidate archive SHA-256 mismatch"); + } + + const extractionTarget = input.extractTo ? path.resolve(input.extractTo) : undefined; + let extractionRoot: string; + let extractionParentIdentity: Awaited> | undefined; + if (extractionTarget) { + if (!input.repositoryRoot) { + throw new TypeError("repositoryRoot is required when publishing an extracted candidate"); + } + const repositoryRoot = path.resolve(input.repositoryRoot); + extractionParentIdentity = await ensureSafePublishDirectory( + repositoryRoot, + path.dirname(extractionTarget), + ); + await assertSafePublishLeaf(extractionTarget, input.extractTo); + extractionRoot = await mkdtemp( + path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`), + ); + } else { + extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-")); + } + let published = false; + try { + const captured = await materializeCapturedArchive(archive); + try { + const preflightManifest = preflightArchiveHandle(captured.handle); + extractArchiveHandle(captured.handle, extractionRoot); + const verified = await verifyExtractedTree(extractionRoot, preflightManifest); + if (extractionTarget) { + const repositoryRoot = path.resolve(input.repositoryRoot!); + const currentParentIdentity = await ensureSafePublishDirectory( + repositoryRoot, + path.dirname(extractionTarget), + ); + if ( + !extractionParentIdentity || + extractionParentIdentity.dev <= 0 || + extractionParentIdentity.ino <= 0 || + currentParentIdentity.dev !== extractionParentIdentity.dev || + currentParentIdentity.ino !== extractionParentIdentity.ino + ) { + throw new Error("verified extraction parent identity changed"); + } + await assertSafePublishLeaf(extractionTarget, input.extractTo); + if (await pathExists(extractionTarget)) { + throw new Error(`verified extraction target already exists: ${input.extractTo}`); + } + await rename(extractionRoot, extractionTarget); + published = true; + } + return Object.freeze({ + archiveSha256, + memberCount: verified.memberCount, + manifest: verified.manifest, + }); + } finally { + await captured.handle.close(); + await rm(captured.root, { recursive: true, force: true }); + } + } finally { + if (!published) await rm(extractionRoot, { recursive: true, force: true }); + } +} + +export async function verifyCapturedCiCandidateArchive( + archive: Buffer, + expectedSha256: string, + dependencies: Readonly<{ + verifyExtracted?: ( + extractionRoot: string, + manifest: ReleaseCandidateManifest, + ) => Promise; + }> = {}, +): Promise> { + if (archive.byteLength <= 0 || archive.byteLength > MAX_ARCHIVE_BYTES) { + throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`); + } + if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) { + throw new TypeError("expected candidate archive SHA-256 is invalid"); + } + const archiveSha256 = createHash("sha256").update(archive).digest("hex"); + if (archiveSha256 !== expectedSha256) { + throw new Error("candidate archive SHA-256 mismatch"); + } + const captured = await materializeCapturedArchive(archive); + const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-")); + try { + const manifest = preflightArchiveHandle(captured.handle); + extractArchiveHandle(captured.handle, extractionRoot); + const verified = await verifyExtractedTree(extractionRoot, manifest); + await dependencies.verifyExtracted?.(extractionRoot, verified.manifest); + return Object.freeze({ + archiveSha256, + memberCount: verified.memberCount, + manifest: verified.manifest, + }); + } finally { + await rm(extractionRoot, { recursive: true, force: true }); + await captured.handle.close(); + await rm(captured.root, { recursive: true, force: true }); + } +} + +function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateManifest { + const listed = spawnSync( + TAR_EXECUTABLE, + ["--list", "--verbose", "--numeric-owner", "--full-time", "--gzip", "--file", "/proc/self/fd/3"], + { + encoding: "utf8", + maxBuffer: 16_777_216, + timeout: 10_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ); + if (listed.status !== 0 || listed.signal || listed.error) { + throw new Error( + `candidate archive listing failed: ${listed.stderr || listed.error?.message || listed.signal}`, + ); + } + const seen = new Set(); + const regularMembers = new Set(); + const directoryMembers = new Set(); + let totalBytes = 0; + const lines = listed.stdout.split(/\r?\n/u).filter(Boolean); + if (lines.length === 0 || lines.length > MAX_ARCHIVE_MEMBERS) { + throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`); + } + for (const line of lines) { + const match = /^(?.{10})\s+\d+\/\d+\s+(?\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+[+-]\d{4})?\s+(?.+)$/u.exec(line); + if (!match?.groups) throw new Error(`candidate archive listing is unparseable: ${line}`); + const member = match.groups.path!.endsWith("/") + ? match.groups.path!.slice(0, -1) + : match.groups.path!; + assertSafeMemberPath(member); + if (seen.has(member)) throw new Error(`candidate archive duplicate member: ${member}`); + seen.add(member); + const mode = match.groups.mode!; + if (!mode.startsWith("-") && !mode.startsWith("d")) { + throw new Error(`candidate archive contains non-regular member: ${member}`); + } + if (mode.startsWith("-")) { + const memberBytes = Number(match.groups.bytes); + if ( + member === RELEASE_CANDIDATE_MANIFEST_PATH && + memberBytes > 8_388_608 + ) { + throw new RangeError("candidate manifest exceeds 8388608 bytes"); + } + totalBytes += memberBytes; + if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_ARCHIVE_BYTES) { + throw new RangeError("candidate archive expanded bytes exceed the bound"); + } + regularMembers.add(member); + } else { + directoryMembers.add(member); + } + } + const manifest = readManifestFromArchive(archiveHandle); + validateManifestSemantics(manifest); + const expectedFiles = new Set([ + ...manifest.files.map(({ path: member }) => member), + RELEASE_CANDIDATE_MANIFEST_PATH, + ]); + for (const member of expectedFiles) assertSafeMemberPath(member); + const expectedDirectories = new Set( + directoryAncestors([...expectedFiles]).filter( + (member) => member === "dist" || member.startsWith("dist/"), + ), + ); + if ( + JSON.stringify([...regularMembers].sort(asciiCompare)) !== + JSON.stringify([...expectedFiles].sort(asciiCompare)) || + JSON.stringify([...directoryMembers].sort(asciiCompare)) !== + JSON.stringify([...expectedDirectories].sort(asciiCompare)) + ) { + throw new Error("candidate archive exact member set drift before extraction"); + } + return manifest; +} + +function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void { + const extracted = spawnSync( + TAR_EXECUTABLE, + [ + "--extract", + "--gzip", + "--file", + "/proc/self/fd/3", + "--directory", + extractionRoot, + "--no-same-owner", + "--no-same-permissions", + ], + { + encoding: "utf8", + maxBuffer: 1_048_576, + timeout: 30_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ); + if (extracted.status !== 0 || extracted.signal || extracted.error) { + throw new Error( + `candidate archive isolated extraction failed: ${extracted.stderr || extracted.error?.message || extracted.signal}`, + ); + } +} + +function validateManifestSemantics(manifest: ReleaseCandidateManifest): void { + if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) { + throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`); + } + const canonicalFiles = [...manifest.files].sort((left, right) => + asciiCompare(left.path, right.path), + ); + if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) { + throw new Error("candidate manifest files are not in canonical ASCII order"); + } + const expectedFiles = new Map>(); + let declaredBytes = 0; + for (const file of manifest.files) { + assertSafeMemberPath(file.path); + if (expectedFiles.has(file.path)) { + throw new Error(`candidate manifest duplicate file: ${file.path}`); + } + declaredBytes += file.bytes; + if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) { + throw new RangeError("candidate manifest declared bytes exceed the archive bound"); + } + expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 }); + } + const evidencePaths = [...expectedFiles.keys()] + .filter((member) => !member.startsWith("dist/")) + .sort(asciiCompare); + if ( + JSON.stringify(evidencePaths) !== + JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare)) + ) { + throw new Error("candidate manifest evidence member set drift"); + } + const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/")); + if (distFiles.length === 0) throw new Error("candidate manifest has no dist files"); + const lockfile = expectedFiles.get("pnpm-lock.yaml"); + if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) { + throw new Error("candidate manifest lockfile digest summary mismatch"); + } + if ( + distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !== + manifest.distSha256 + ) { + throw new Error("candidate manifest dist digest summary mismatch"); + } + if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) { + throw new Error("candidate manifest bundle digest summary mismatch"); + } +} + +async function verifyExtractedTree( + extractionRoot: string, + preflightManifest: ReleaseCandidateManifest, +): Promise> { + const entries = await walkExtractedTree(extractionRoot); + if (entries.length === 0 || entries.length > MAX_ARCHIVE_MEMBERS) { + throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`); + } + const manifest = releaseCandidateManifestSchema.parse( + JSON.parse( + await readFile(path.join(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"), + ) as unknown, + ); + if (JSON.stringify(manifest) !== JSON.stringify(preflightManifest)) { + throw new Error("candidate manifest changed between preflight and extraction"); + } + if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) { + throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`); + } + const canonicalFiles = [...manifest.files].sort((left, right) => + asciiCompare(left.path, right.path), + ); + if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) { + throw new Error("candidate manifest files are not in canonical ASCII order"); + } + const expectedFiles = new Map>(); + let declaredBytes = 0; + for (const file of manifest.files) { + assertSafeMemberPath(file.path); + if (expectedFiles.has(file.path)) throw new Error(`candidate manifest duplicate file: ${file.path}`); + declaredBytes += file.bytes; + if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) { + throw new RangeError("candidate manifest declared bytes exceed the archive bound"); + } + expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 }); + } + const evidencePaths = [...expectedFiles.keys()] + .filter((member) => !member.startsWith("dist/")) + .sort(asciiCompare); + if ( + JSON.stringify(evidencePaths) !== + JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare)) + ) { + throw new Error("candidate manifest evidence member set drift"); + } + const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/")); + if (distFiles.length === 0) throw new Error("candidate manifest has no dist files"); + const lockfile = expectedFiles.get("pnpm-lock.yaml"); + if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) { + throw new Error("candidate manifest lockfile digest summary mismatch"); + } + if ( + distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !== + manifest.distSha256 + ) { + throw new Error("candidate manifest dist digest summary mismatch"); + } + if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) { + throw new Error("candidate manifest bundle digest summary mismatch"); + } + + const expectedFilePaths = new Set([ + ...expectedFiles.keys(), + RELEASE_CANDIDATE_MANIFEST_PATH, + ]); + const expectedDirectories = new Set(directoryAncestors([...expectedFilePaths])); + for (const entry of entries) { + assertSafeMemberPath(entry.path); + if (entry.type === "directory") { + if (!expectedDirectories.has(entry.path)) { + throw new Error(`candidate archive contains unexpected directory: ${entry.path}`); + } + } else if (!expectedFilePaths.has(entry.path)) { + throw new Error(`candidate archive contains unexpected file: ${entry.path}`); + } + } + const actualFiles = new Set( + entries.filter(({ type }) => type === "file").map(({ path: member }) => member), + ); + for (const expected of expectedFilePaths) { + if (!actualFiles.has(expected)) throw new Error(`candidate archive is missing file: ${expected}`); + } + for (const [member, expected] of expectedFiles) { + const bytes = await readFile(path.join(extractionRoot, member)); + if (bytes.byteLength !== expected.bytes) { + throw new Error(`candidate archive member size mismatch: ${member}`); + } + if (createHash("sha256").update(bytes).digest("hex") !== expected.sha256) { + throw new Error(`candidate archive member digest mismatch: ${member}`); + } + } + return Object.freeze({ memberCount: entries.length, manifest }); +} + +async function walkExtractedTree( + root: string, + relativeDirectory = "", +): Promise>> { + const children = await readdir(path.join(root, relativeDirectory), { + withFileTypes: true, + }); + const entries: Array> = []; + for (const child of children.sort((left, right) => asciiCompare(left.name, right.name))) { + const relative = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + assertSafeMemberPath(relative); + const metadata = await lstat(path.join(root, relative)); + if (metadata.isSymbolicLink()) { + throw new Error(`candidate archive contains non-regular member: ${relative}`); + } + if (metadata.isDirectory() && child.isDirectory()) { + entries.push(Object.freeze({ path: relative, type: "directory" })); + entries.push(...(await walkExtractedTree(root, relative))); + } else if (metadata.isFile() && child.isFile()) { + if (metadata.nlink !== 1) { + throw new Error(`candidate archive contains hard-linked member: ${relative}`); + } + entries.push(Object.freeze({ path: relative, type: "file" })); + } else { + throw new Error(`candidate archive contains non-regular member: ${relative}`); + } + if (entries.length > MAX_ARCHIVE_MEMBERS) { + throw new RangeError(`candidate archive exceeds ${MAX_ARCHIVE_MEMBERS} members`); + } + } + return entries; +} + +function assertSameIdentity( + before: Awaited>, + after: Awaited>, +): void { + if ( + !after.isFile() || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size + ) { + throw new Error("candidate archive file identity changed"); + } +} + +async function readCapturedArchive( + handle: FileHandle, + expectedSize: number, +): Promise { + const captured = Buffer.allocUnsafe(expectedSize + 1); + let offset = 0; + while (offset < captured.byteLength) { + const { bytesRead } = await handle.read( + captured, + offset, + captured.byteLength - offset, + offset, + ); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset !== expectedSize) { + throw new Error("candidate archive changed size during bounded capture"); + } + return captured.subarray(0, offset); +} + +function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateManifest { + const extracted = spawnSync( + TAR_EXECUTABLE, + [ + "--extract", + "--gzip", + "--to-stdout", + "--file", + "/proc/self/fd/3", + "--", + RELEASE_CANDIDATE_MANIFEST_PATH, + ], + { + maxBuffer: 8_388_609, + timeout: 10_000, + env: TAR_ENVIRONMENT, + stdio: ["ignore", "pipe", "pipe", archiveHandle.fd], + }, + ); + if (extracted.status !== 0 || extracted.signal || extracted.error) { + throw new Error( + `candidate manifest preflight failed: ${String(extracted.stderr) || extracted.error?.message || extracted.signal}`, + ); + } + const bytes = Buffer.from(extracted.stdout); + if (bytes.byteLength === 0 || bytes.byteLength > 8_388_608) { + throw new RangeError("candidate manifest preflight size is outside 1..8388608"); + } + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return releaseCandidateManifestSchema.parse(JSON.parse(source) as unknown); +} + +async function materializeCapturedArchive( + archive: Buffer, +): Promise> { + const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-")); + const file = path.join(root, "candidate.tar.gz"); + let handle: FileHandle | undefined; + try { + handle = await open( + file, + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + await handle.writeFile(archive); + await handle.sync(); + await unlink(file); + return Object.freeze({ root, handle }); + } catch (error) { + if (handle) await handle.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + throw error; + } +} + +function assertSafeMemberPath(member: string): void { + if ( + !member || + member.startsWith("-") || + Buffer.byteLength(member, "utf8") > MAX_MEMBER_PATH_BYTES || + member.includes("\\") || + [...member].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f; + }) || + path.posix.isAbsolute(member) || + path.posix.normalize(member) !== member || + member === ".." || + member.startsWith("../") || + member.includes("/../") + ) { + throw new TypeError(`candidate archive contains unsafe member path: ${member}`); + } +} + +function directoryAncestors(files: readonly string[]): string[] { + const directories = new Set(); + for (const file of files) { + let directory = path.posix.dirname(file); + while (directory !== ".") { + directories.add(directory); + directory = path.posix.dirname(directory); + } + } + return [...directories]; +} + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +async function pathExists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } +} diff --git a/scripts/lib/ci-contract-report.ts b/scripts/lib/ci-contract-report.ts new file mode 100644 index 0000000..74490a9 --- /dev/null +++ b/scripts/lib/ci-contract-report.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export const ciContractReportSchema = z + .object({ + schemaVersion: z.literal(2), + nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u), + gateCount: z.literal(26), + commandDefinitionCount: z.number().int().positive(), + commandReferenceCount: z.number().int().positive(), + artifactCount: z.number().int().positive(), + jobCount: z.literal(9), + workflowSha256: z.string().regex(/^[a-f0-9]{64}$/u), + durationStatus: z.string().min(1), + negativeFixtures: z.array( + z + .object({ + readiness: z.enum([ + "MERGE_READY", + "RELEASE_READY", + "PROD_PROMOTION_READY", + "FIELD_SLO_READY", + "DOCUMENTATION_READY", + ]), + failedGate: z.string().regex(/^FE-GATE-\d{3}$/u), + passed: z.boolean(), + }) + .strict(), + ), + failures: z.array(z.string()), + passed: z.boolean(), + }) + .strict() + .superRefine((report, context) => { + const fail = (path: PropertyKey[], message: string) => + context.addIssue({ code: "custom", path, message }); + if ((report.passed === true) !== (report.failures.length === 0)) { + fail(["passed"], "passed must agree with failures"); + } + if ( + report.negativeFixtures.length !== 5 || + report.negativeFixtures.some((fixture) => !fixture.passed) + ) { + fail(["negativeFixtures"], "every readiness negative fixture must pass"); + } + }); diff --git a/scripts/lib/ci-gate-log.ts b/scripts/lib/ci-gate-log.ts new file mode 100644 index 0000000..fae675e --- /dev/null +++ b/scripts/lib/ci-gate-log.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { lstat, mkdir, open, rename, rm } from "node:fs/promises"; +import path from "node:path"; + +import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; + +export async function writeCiGateLogAtomic(input: Readonly<{ + root: string; + relativePath: string; + content: string; + maxBytes?: number; +}>): Promise { + const root = path.resolve(input.root); + const relative = normalizeRepositoryRelativePath(input.relativePath, "CI gate log path"); + const target = path.join(root, relative); + const maxBytes = input.maxBytes ?? 67_108_864; + const contentBytes = Buffer.byteLength(input.content, "utf8"); + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || contentBytes < 1 || contentBytes > maxBytes) { + throw new RangeError(`CI gate log size is outside 1..${maxBytes}: ${relative}`); + } + const parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target)); + await assertSafePublishLeaf(target, relative); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${randomUUID()}.tmp`, + ); + let ownsTemporary = false; + try { + const handle = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o644, + ); + ownsTemporary = true; + let failure: unknown; + try { + await handle.writeFile(input.content, "utf8"); + await handle.sync(); + } catch (error) { + failure = error; + } + try { + await handle.close(); + } catch (error) { + failure ??= error; + } + if (failure) throw failure; + await assertDirectoryIdentity(path.dirname(target), parentIdentity, relative); + await assertSafePublishLeaf(target, relative); + await rename(temporary, target); + ownsTemporary = false; + const directory = await open(path.dirname(target), constants.O_RDONLY); + try { + try { + await directory.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; + } + } finally { + await directory.close(); + } + } catch (error) { + if (ownsTemporary) { + try { + await rm(temporary, { force: true }); + } catch { + // Preserve the publication failure and clean only the owned sibling temp. + } + } + throw error; + } +} + +export async function ensureSafePublishDirectory( + rootInput: string, + directoryInput: string, +): Promise { + const root = path.resolve(rootInput); + const directory = path.resolve(directoryInput); + const relativeDirectory = path.relative(root, directory); + if ( + relativeDirectory === ".." || + relativeDirectory.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDirectory) + ) { + throw new TypeError("CI publish directory escapes root"); + } + const rootMetadata = await lstat(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new TypeError("CI gate log root is unsafe"); + } + let ancestor = root; + for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) { + ancestor = path.join(ancestor, segment); + let metadata; + try { + metadata = await lstat(ancestor); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + try { + await mkdir(ancestor); + } catch (mkdirError) { + if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError; + } + metadata = await lstat(ancestor); + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new TypeError(`CI publish ancestor is unsafe: ${relativeDirectory}`); + } + } + return lstat(directory); +} + +export async function assertSafePublishLeaf( + target: string, + label = target, +): Promise { + try { + const metadata = await lstat(target); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new TypeError(`CI publish leaf is unsafe: ${label}`); + } + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } +} + +export async function assertSafeExistingPublishPath( + rootInput: string, + targetInput: string, +): Promise { + const root = path.resolve(rootInput); + const target = path.resolve(targetInput); + const relative = path.relative(root, target); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new TypeError("CI publish target escapes root"); + } + const rootMetadata = await lstat(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new TypeError("CI publish root is unsafe"); + } + const segments = relative.split(path.sep).filter(Boolean); + let current = root; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + let metadata: Stats; + try { + metadata = await lstat(current); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } + const leaf = index === segments.length - 1; + if (metadata.isSymbolicLink() || (leaf ? !metadata.isFile() : !metadata.isDirectory())) { + throw new TypeError(`CI publish path is unsafe: ${relative}`); + } + } + return true; +} + +async function assertDirectoryIdentity( + directory: string, + expected: Stats, + label: string, +): Promise { + const actual = await lstat(directory); + if ( + actual.isSymbolicLink() || + !actual.isDirectory() || + expected.dev <= 0 || + expected.ino <= 0 || + actual.dev !== expected.dev || + actual.ino !== expected.ino + ) { + throw new TypeError(`CI publish directory identity changed: ${label}`); + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/ci-step-result.ts b/scripts/lib/ci-step-result.ts new file mode 100644 index 0000000..12cc2d9 --- /dev/null +++ b/scripts/lib/ci-step-result.ts @@ -0,0 +1,82 @@ +export type GateStepExpectation = + | Readonly<{ kind: "pass" }> + | Readonly<{ + kind: "fail"; + expectedExitCode: number; + expectedDiagnosticId: string; + }>; + +export type GateProcessResult = Readonly<{ + status: number | null; + signal: string | null; + stdout: string; + stderr: string; + error?: Readonly<{ code?: string }>; +}>; + +export type GateStepClassification = + | Readonly<{ + kind: "EXPECTED_PASS" | "EXPECTED_FAILURE"; + expectationMet: true; + }> + | Readonly<{ + kind: "UNEXPECTED_EXIT"; + expectationMet: false; + }> + | Readonly<{ + kind: "UNEXPECTED_DIAGNOSTIC"; + expectationMet: false; + }> + | Readonly<{ + kind: "INFRASTRUCTURE_FAILURE"; + expectationMet: false; + detail: string; + }>; + +/** A negative fixture passes only with its registered exit and diagnostic. */ +export function classifyGateStepResult( + expectation: GateStepExpectation, + result: GateProcessResult, +): GateStepClassification { + const errorCode = result.error?.code; + const spawnFailed = result.error !== undefined; + if (spawnFailed || result.signal || result.status === null) { + return Object.freeze({ + kind: "INFRASTRUCTURE_FAILURE" as const, + expectationMet: false as const, + detail: + errorCode ?? + result.signal ?? + (spawnFailed ? "SPAWN_ERROR" : "NO_EXIT_STATUS"), + }); + } + if (expectation.kind === "pass" && result.status === 0) { + return Object.freeze({ + kind: "EXPECTED_PASS" as const, + expectationMet: true as const, + }); + } + if (expectation.kind === "fail") { + if (result.status !== expectation.expectedExitCode) { + return Object.freeze({ + kind: "UNEXPECTED_EXIT" as const, + expectationMet: false as const, + }); + } + const diagnosticOutput = `${result.stdout}\n${result.stderr}`; + if (!diagnosticOutput.includes(expectation.expectedDiagnosticId)) { + return Object.freeze({ + kind: "UNEXPECTED_DIAGNOSTIC" as const, + expectationMet: false as const, + }); + } + return Object.freeze({ + kind: "EXPECTED_FAILURE" as const, + expectationMet: true as const, + }); + } + return Object.freeze({ + kind: "UNEXPECTED_EXIT" as const, + expectationMet: false as const, + }); +} diff --git a/scripts/lib/classify-vite-bundle.ts b/scripts/lib/classify-vite-bundle.ts new file mode 100644 index 0000000..4521b2e --- /dev/null +++ b/scripts/lib/classify-vite-bundle.ts @@ -0,0 +1,49 @@ +type ViteManifestEntry = Readonly<{ + file: string; + isEntry?: boolean; + imports?: readonly string[]; +}>; + +/** + * Static imports of an entry are part of initial JavaScript. Every remaining + * JavaScript output is governed by the lazy-chunk budget. + * + */ +export function classifyViteJavascript( + manifest: Readonly>, +) { + const initialFiles = new Set(); + const visitedKeys = new Set(); + const pendingKeys = Object.entries(manifest) + .filter(([, entry]) => entry.isEntry) + .map(([key]) => key); + const missingImports: string[] = []; + + while (pendingKeys.length > 0) { + const key = pendingKeys.pop(); + if (key === undefined) break; + if (visitedKeys.has(key)) continue; + visitedKeys.add(key); + const entry = manifest[key]; + if (!entry) { + missingImports.push(key); + continue; + } + if (entry.file.endsWith(".js")) initialFiles.add(entry.file); + pendingKeys.push(...(entry.imports ?? [])); + } + + const allJavaScript = new Set( + Object.values(manifest) + .map((entry) => entry.file) + .filter((file) => file.endsWith(".js")), + ); + const lazyFiles = [...allJavaScript].filter( + (file) => !initialFiles.has(file), + ); + return Object.freeze({ + initialFiles: Object.freeze([...initialFiles].sort()), + lazyFiles: Object.freeze(lazyFiles.sort()), + missingImports: Object.freeze(missingImports.sort()), + }); +} diff --git a/scripts/lib/exact-promotion-bundle.ts b/scripts/lib/exact-promotion-bundle.ts new file mode 100644 index 0000000..412779e --- /dev/null +++ b/scripts/lib/exact-promotion-bundle.ts @@ -0,0 +1,353 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + PROMOTED_FILE_NAMES, + type PromotedFileName, +} from "../contracts/promotion-artifacts.ts"; +import { + PROMOTION_VERIFIER_ID, + PROMOTION_VERIFIER_VERSION, + assertDistinctProviderTrust, + evaluatePromotionEvidence, + providerVerificationArtifactSchema, + provenanceProviderAttestationSchema, + vulnerabilityProviderReportSchema, + trustPolicySha256, + type ProviderTrust, +} from "./provider-evidence.ts"; +import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts"; +import { LOCAL_EVIDENCE_ASSESSMENT_PATH } from "./release-candidate.ts"; +import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; + +export type ExactPromotionBundle = Readonly< + Partial> +>; + +export type ExactPromotionExpectedContext = Readonly<{ + run: Readonly<{ id: string; attempt: number }>; + sourceRevision: string; + archiveSha256: string; + sourceSetSha256?: string; + bundleSha256?: string; + distSha256?: string; + lockfileSha256?: string; +}>; + +export async function verifyExactPromotionBundle( + files: ExactPromotionBundle, + options: Readonly<{ + vulnerabilityTrust: ProviderTrust; + provenanceTrust: ProviderTrust; + expected: ExactPromotionExpectedContext; + nowEpochMs?: () => number; + }>, +): Promise> { + assertDistinctProviderTrust(options); + assertExternalExpectedContext(options.expected); + const names = Object.keys(files).sort(asciiCompare); + const expectedNames = [...PROMOTED_FILE_NAMES].sort(asciiCompare); + if (JSON.stringify(names) !== JSON.stringify(expectedNames)) { + throw new Error("promotion bundle must contain the exact five canonical files"); + } + for (const name of PROMOTED_FILE_NAMES) { + if (!Buffer.isBuffer(files[name])) { + throw new TypeError(`promotion bundle file is missing or not captured bytes: ${name}`); + } + } + + const archiveBytes = files["release-candidate.tar.gz"]!; + const vulnerabilityBytes = files["vulnerability-report.json"]!; + const provenanceBytes = files["provenance-attestation.json"]!; + const providerBytes = files["provider-verification.json"]!; + const promotionBytes = files["promotion-verification.json"]!; + const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson( + vulnerabilityBytes, + "vulnerability report", + )); + const provenance = provenanceProviderAttestationSchema.parse(parseJson( + provenanceBytes, + "provenance attestation", + )); + const provider = providerVerificationArtifactSchema.parse(parseJson( + providerBytes, + "provider verification", + )); + const promotion = providerVerificationArtifactSchema.parse(parseJson( + promotionBytes, + "promotion verification", + )); + + if ( + provider.artifactType !== "provider-verification" || + promotion.artifactType !== "promotion-verification" + ) { + throw new Error("promotion verification artifact role mismatch"); + } + for (const [label, record] of [ + ["provider", provider], + ["promotion", promotion], + ] as const) { + if ( + record.verifier.id !== PROMOTION_VERIFIER_ID || + record.verifier.version !== PROMOTION_VERIFIER_VERSION + ) { + throw new Error(`${label} verification literal verifier identity mismatch`); + } + if (record.status !== "PASS" || record.failures.length !== 0) { + throw new Error(`${label} verification must be PASS without failures`); + } + } + if ( + provider.vulnerabilityStatus !== "PASS" || + provider.provenanceAttestationStatus !== "PASS" + ) { + throw new Error("provider verification subordinate statuses must both be PASS"); + } + if (promotion.localEvidenceStatus !== "PASS") { + throw new Error("promotion local evidence subordinate status must be PASS"); + } + + assertEqual("shared verifiedAt", provider.verifiedAt, promotion.verifiedAt); + assertEqual("shared run", provider.run, promotion.run); + assertEqual("shared source", provider.source, promotion.source); + assertEqual("shared candidate", provider.candidate, promotion.candidate); + assertEqual( + "shared provider evidence", + provider.providerEvidence, + promotion.providerEvidence, + ); + assertEqual( + "shared trust policy", + provider.trustPolicySha256, + promotion.trustPolicySha256, + ); + assertEqual("external expected run", provider.run, options.expected.run); + assertEqual( + "external expected source revision", + provider.source.revision, + options.expected.sourceRevision, + ); + assertEqual( + "external expected archive digest", + provider.candidate.archiveSha256, + options.expected.archiveSha256, + ); + for (const [label, actual, expected] of [ + ["source set", provider.source.sourceSetSha256, options.expected.sourceSetSha256], + ["bundle", provider.candidate.bundleSha256, options.expected.bundleSha256], + ["dist", provider.candidate.distSha256, options.expected.distSha256], + ["lockfile", provider.candidate.lockfileSha256, options.expected.lockfileSha256], + ] as const) { + if (expected !== undefined) { + assertEqual(`external expected ${label} digest`, actual, expected); + } + } + const anchoredTrustPolicySha256 = trustPolicySha256(options); + if (provider.trustPolicySha256 !== anchoredTrustPolicySha256) { + throw new Error("verification trust policy does not match anchored provider keys"); + } + + if (promotion.providerVerificationSha256 !== sha256(providerBytes)) { + throw new Error("promotion provider verification byte hash mismatch"); + } + if ( + provider.candidate.archiveSha256 !== sha256(archiveBytes) || + provider.providerEvidence.vulnerabilityReportSha256 !== sha256(vulnerabilityBytes) || + provider.providerEvidence.provenanceAttestationSha256 !== sha256(provenanceBytes) + ) { + if (provider.candidate.archiveSha256 !== sha256(archiveBytes)) { + throw new Error("candidate archive actual digest mismatch"); + } + if ( + provider.providerEvidence.vulnerabilityReportSha256 !== + sha256(vulnerabilityBytes) + ) { + throw new Error("vulnerability report actual digest mismatch"); + } + throw new Error("provenance attestation actual digest mismatch"); + } + + for (const [label, evidence, nonce, keyId, fingerprint] of [ + [ + "vulnerability", + vulnerability, + provider.providerEvidence.vulnerabilityInvocationNonce, + provider.providerEvidence.vulnerabilityKeyId, + provider.providerEvidence.vulnerabilityKeyFingerprint, + ], + [ + "provenance", + provenance, + provider.providerEvidence.provenanceInvocationNonce, + provider.providerEvidence.provenanceKeyId, + provider.providerEvidence.provenanceKeyFingerprint, + ], + ] as const) { + assertEqual(`${label} run`, { id: evidence.run.id, attempt: evidence.run.attempt }, provider.run); + assertEqual(`${label} source`, evidence.source, provider.source); + assertEqual(`${label} candidate`, evidence.candidate, provider.candidate); + if ( + evidence.run.invocationNonce !== nonce || + evidence.signature.keyId !== keyId || + evidence.signature.publicKeyFingerprint !== fingerprint + ) { + throw new Error(`${label} provider evidence nonce or trust role mismatch`); + } + } + if (provenance.subject.digest.sha256 !== provider.candidate.distSha256) { + throw new Error("provenance subject dist digest mismatch"); + } + if (vulnerability.findings.length !== 0) { + throw new Error("vulnerability report is not PASS"); + } + assertEqual( + "signed secret scan attestation", + vulnerability.secretScanAttestation, + provider.providerEvidence.secretScanAttestation, + ); + + let assessmentSha256: string | null = null; + const localIdentityHolder: { + current: null | Readonly<{ + sourceRevision: string; + sourceSetSha256: string; + assessmentSha256: string; + secretScan: Readonly<{ + policySha256: string; + sarifSha256: string; + scanInputSha256: string; + }>; + }>; + } = { current: null }; + await verifyCapturedCiCandidateArchive( + archiveBytes, + provider.candidate.archiveSha256, + { + verifyExtracted: async (extractionRoot, manifest) => { + assertEqual("archive candidate", { + bundleSha256: manifest.bundleSha256, + distSha256: manifest.distSha256, + lockfileSha256: manifest.lockfileSha256, + }, { + bundleSha256: provider.candidate.bundleSha256, + distSha256: provider.candidate.distSha256, + lockfileSha256: provider.candidate.lockfileSha256, + }); + assessmentSha256 = sha256( + await readFile(path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)), + ); + const local = await verifyArchivedLocalEvidence({ + extractionRoot, + expectedManifest: manifest, + }); + if (local.status !== "PASS" || !local.identity) { + throw new Error( + `exact-five archived local verification is not PASS: ${local.failures.join(", ")}`, + ); + } + localIdentityHolder.current = local.identity; + }, + }, + ); + if (assessmentSha256 !== promotion.localEvidenceAssessmentSha256) { + throw new Error("promotion local evidence assessment actual digest mismatch"); + } + if ( + !localIdentityHolder.current || + localIdentityHolder.current.sourceRevision !== provider.source.revision || + localIdentityHolder.current.sourceSetSha256 !== provider.source.sourceSetSha256 || + localIdentityHolder.current.assessmentSha256 !== promotion.localEvidenceAssessmentSha256 + ) { + throw new Error("exact-five archived local identity mismatch"); + } + assertEqual("archived secret scan attestation", { + status: "PASS", + localEvidenceAssessmentSha256: localIdentityHolder.current.assessmentSha256, + sourceSetSha256: localIdentityHolder.current.sourceSetSha256, + policySha256: localIdentityHolder.current.secretScan.policySha256, + sarifSha256: localIdentityHolder.current.secretScan.sarifSha256, + scanInputSha256: localIdentityHolder.current.secretScan.scanInputSha256, + }, vulnerability.secretScanAttestation); + const reevaluated = evaluatePromotionEvidence({ + expected: { + run: provider.run, + source: provider.source, + candidate: provider.candidate, + vulnerabilityInvocationNonce: + provider.providerEvidence.vulnerabilityInvocationNonce, + provenanceInvocationNonce: + provider.providerEvidence.provenanceInvocationNonce, + secretScanAttestation: provider.providerEvidence.secretScanAttestation, + }, + localStatus: "PASS", + vulnerabilityReport: vulnerability, + provenanceAttestation: provenance, + vulnerabilityTrust: options.vulnerabilityTrust, + provenanceTrust: options.provenanceTrust, + nowEpochMs: options.nowEpochMs, + }); + if ( + reevaluated.status !== "PASS" || + reevaluated.vulnerabilityStatus !== "PASS" || + reevaluated.provenanceAttestationStatus !== "PASS" + ) { + throw new Error( + `exact-five provider signature/freshness revalidation is not PASS: ${reevaluated.failures.join(", ")}`, + ); + } + return Object.freeze({ status: "PASS" as const }); +} + +function assertExternalExpectedContext( + expected: ExactPromotionExpectedContext, +): void { + if ( + !expected || + typeof expected.run?.id !== "string" || + expected.run.id.length === 0 || + !Number.isSafeInteger(expected.run.attempt) || + expected.run.attempt < 1 || + !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(expected.sourceRevision) || + !isSha256(expected.archiveSha256) + ) { + throw new TypeError("external expected promotion context is invalid or incomplete"); + } + for (const digest of [ + expected.sourceSetSha256, + expected.bundleSha256, + expected.distSha256, + expected.lockfileSha256, + ]) { + if (digest !== undefined && !isSha256(digest)) { + throw new TypeError("external optional expected promotion digest is invalid"); + } + } +} + +function isSha256(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value); +} + +function parseJson(bytes: Buffer, label: string): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch { + throw new TypeError(`${label} is not strict UTF-8 JSON`); + } +} + +function assertEqual(label: string, left: unknown, right: unknown): void { + if (JSON.stringify(left) !== JSON.stringify(right)) { + throw new Error(`${label} mismatch`); + } +} + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/scripts/lib/field-vitals-evidence.ts b/scripts/lib/field-vitals-evidence.ts new file mode 100644 index 0000000..e923789 --- /dev/null +++ b/scripts/lib/field-vitals-evidence.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +const WINDOW_MILLISECONDS = 28 * 24 * 60 * 60 * 1000; +const nonEmptyString = z.string().trim().min(1); +const timestamp = nonEmptyString.refine( + (value) => Number.isFinite(Date.parse(value)), + "must be an RFC 3339 timestamp", +); +const sampleSchema = z + .object({ + timestamp, + consent: z.boolean(), + releaseId: nonEmptyString, + routeId: nonEmptyString.regex(/^[A-Z][A-Z0-9_]*$/), + lcpMs: z.number().finite().nonnegative(), + cls: z.number().finite().nonnegative(), + inpMs: z.number().finite().nonnegative(), + }) + .strict(); + +const fieldEvidenceInputSchema = z + .object({ + schemaVersion: z.literal(1), + environment: z.literal("production"), + releaseId: nonEmptyString.refine( + (value) => value !== "local-release", + "must identify an immutable production release", + ), + source: z + .object({ + system: nonEmptyString, + exportId: nonEmptyString, + }) + .strict(), + privacy: z + .object({ + approved: z.literal(true), + approvalRef: nonEmptyString, + }) + .strict(), + window: z + .object({ + start: timestamp, + end: timestamp, + }) + .strict(), + thresholdDecision: z + .object({ + status: z.literal("approved"), + minimumEligibleSamples: z.number().int().positive(), + owner: nonEmptyString, + reviewedAt: timestamp, + evidenceRef: nonEmptyString, + }) + .strict(), + samples: z.array(sampleSchema), + }) + .strict() + .superRefine((input, context) => { + const start = Date.parse(input.window.start); + const end = Date.parse(input.window.end); + if (end - start !== WINDOW_MILLISECONDS) { + context.addIssue({ + code: "custom", + path: ["window"], + message: "must cover exactly 28 days", + }); + } + }); + +export function validateFieldEvidenceInput( + input: unknown, + configuredMinimum: string | undefined, + now: Date = new Date(), +) { + const parsed = fieldEvidenceInputSchema.safeParse(input); + const failures = parsed.success + ? [] + : parsed.error.issues.map( + (issue) => `${issue.path.join(".") || "input"}: ${issue.message}`, + ); + const minimumEligibleSamples = Number(configuredMinimum); + if ( + configuredMinimum === undefined || + !Number.isInteger(minimumEligibleSamples) || + minimumEligibleSamples <= 0 + ) { + failures.push("MIN_ELIGIBLE_SAMPLES: must be a positive integer"); + } + + if (parsed.success) { + if ( + parsed.data.thresholdDecision.minimumEligibleSamples !== + minimumEligibleSamples + ) { + failures.push( + "MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision", + ); + } + if (Date.parse(parsed.data.window.end) > now.getTime()) { + failures.push("window.end: must not be in the future"); + } + if (Date.parse(parsed.data.thresholdDecision.reviewedAt) > now.getTime()) { + failures.push("thresholdDecision.reviewedAt: must not be in the future"); + } + } + + return Object.freeze({ + data: parsed.success ? parsed.data : null, + failures: Object.freeze(failures), + minimumEligibleSamples: + Number.isInteger(minimumEligibleSamples) && minimumEligibleSamples > 0 + ? minimumEligibleSamples + : null, + passed: parsed.success && failures.length === 0, + }); +} diff --git a/scripts/lib/hosting-probe.ts b/scripts/lib/hosting-probe.ts new file mode 100644 index 0000000..ab934be --- /dev/null +++ b/scripts/lib/hosting-probe.ts @@ -0,0 +1,74 @@ +const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/; + +/** + * A release gate must not promote a local preview server as live hosting + * evidence. + * + */ +export function classifyLiveHostingBaseUrl(value: string): + | { + passed: true; + reason: null; + url: URL; + observedOrigin: string; + } + | { + passed: false; + reason: string; + url: URL | null; + observedOrigin: string | null; + } { + let url: URL; + try { + url = new URL(value); + } catch { + return { + passed: false, + reason: "HOSTING_BASE_URL must be an absolute URL", + url: null, + observedOrigin: null, + }; + } + + const observedOrigin = url.origin; + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (url.protocol !== "https:") { + return { + passed: false, + reason: "live hosting evidence requires HTTPS", + url, + observedOrigin, + }; + } + if (url.username || url.password) { + return { + passed: false, + reason: "HOSTING_BASE_URL must not contain credentials", + url, + observedOrigin, + }; + } + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "::1" || + hostname === "0.0.0.0" || + LOOPBACK_IPV4.test(hostname) + ) { + return { + passed: false, + reason: "local or loopback hosts are not live deployment evidence", + url, + observedOrigin, + }; + } + if (url.pathname !== "/" || url.search || url.hash) { + return { + passed: false, + reason: "HOSTING_BASE_URL must be the canonical root URL", + url, + observedOrigin, + }; + } + return { passed: true, reason: null, url, observedOrigin }; +} diff --git a/scripts/lib/http-scenario-evidence.ts b/scripts/lib/http-scenario-evidence.ts new file mode 100644 index 0000000..7280f5e --- /dev/null +++ b/scripts/lib/http-scenario-evidence.ts @@ -0,0 +1,281 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +export type AttemptStatus = number | "NETWORK_REJECTION" | "PENDING_ABORT"; +export type RetryReason = "NETWORK_FAILURE" | "HTTP_429" | "HTTP_503"; +export type BodyDisposition = + | "FULLY_READ_WITHIN_BOUND" + | "CANCELLED_WITHOUT_READ" + | "REJECTED_LIMIT" + | "NO_RESPONSE"; + +const attemptStatusSchema = z.union([ + z.number().int().min(100).max(599), + z.literal("NETWORK_REJECTION"), + z.literal("PENDING_ABORT"), +]); + +export const httpScenarioAssertionGroupsSchema = z + .object({ + status: z + .object({ + attempts: z.array(attemptStatusSchema).min(1), + final: attemptStatusSchema, + }) + .strict(), + outcome: z.object({ kind: z.string().min(1), detail: z.string().nullable() }).strict(), + effect: z.object({ outcome: z.string().min(1), observer: z.string().min(1) }).strict(), + retry: z + .object({ + count: z.number().int().nonnegative(), + reasons: z.array(z.enum(["NETWORK_FAILURE", "HTTP_429", "HTTP_503"])), + }) + .strict(), + fetch: z + .object({ + count: z.number().int().positive(), + observerAttempts: z.number().int().positive(), + agrees: z.boolean(), + }) + .strict(), + media: z.object({ attempts: z.array(z.string().nullable()).min(1), final: z.string().nullable() }).strict(), + body: z + .object({ + attempts: z + .array( + z + .object({ + disposition: z.enum([ + "FULLY_READ_WITHIN_BOUND", + "CANCELLED_WITHOUT_READ", + "REJECTED_LIMIT", + "NO_RESPONSE", + ]), + pulledBytes: z.number().int().nonnegative(), + ceiling: z.number().int().nonnegative(), + }) + .strict(), + ) + .min(1), + }) + .strict(), + scope: z + .object({ + start: z.literal("CURRENT"), + end: z.enum(["CURRENT", "STALE"]), + signal: z.enum(["ACTIVE", "ABORTED"]), + cancellationOwner: z.enum(["NONE", "CALLER", "SCOPE_FENCE", "DEADLINE"]), + }) + .strict(), + }) + .strict() + .superRefine((groups, context) => { + const fail = (path: (string | number)[], message: string) => { + context.addIssue({ code: "custom", path, message }); + }; + if (groups.status.final !== groups.status.attempts.at(-1)) { + fail(["status", "final"], "must equal the final attempt status"); + } + if (groups.retry.count !== groups.retry.reasons.length) { + fail(["retry", "count"], "must equal retry reasons length"); + } + if (groups.retry.count !== groups.fetch.count - 1) { + fail(["retry", "count"], "must equal the non-final fetch attempt count"); + } + for (const [index, reason] of groups.retry.reasons.entries()) { + const status = groups.status.attempts[index]; + const expectedReason = + status === "NETWORK_REJECTION" + ? "NETWORK_FAILURE" + : status === 429 + ? "HTTP_429" + : status === 503 + ? "HTTP_503" + : null; + if (reason !== expectedReason) { + fail( + ["retry", "reasons", index], + "must match the corresponding non-final attempt status", + ); + } + } + if (groups.fetch.count !== groups.status.attempts.length) { + fail(["fetch", "count"], "must equal status attempts length"); + } + if (groups.fetch.count !== groups.media.attempts.length) { + fail(["media", "attempts"], "must equal fetch count"); + } + if (groups.fetch.count !== groups.body.attempts.length) { + fail(["body", "attempts"], "must equal fetch count"); + } + if (groups.fetch.observerAttempts !== groups.fetch.count) { + fail(["fetch", "observerAttempts"], "must equal physical fetch count"); + } + if (!groups.fetch.agrees) { + fail(["fetch", "agrees"], "must prove physical/observer agreement"); + } + if (groups.media.final !== groups.media.attempts.at(-1)) { + fail(["media", "final"], "must equal the final attempt media essence"); + } + }); + +export type HttpScenarioAssertionGroups = Readonly<{ + status: Readonly<{ attempts: readonly AttemptStatus[]; final: AttemptStatus }>; + outcome: Readonly<{ kind: string; detail: string | null }>; + effect: Readonly<{ outcome: string; observer: string }>; + retry: Readonly<{ count: number; reasons: readonly RetryReason[] }>; + fetch: Readonly<{ + count: number; + observerAttempts: number; + agrees: boolean; + }>; + media: Readonly<{ + attempts: readonly (string | null)[]; + final: string | null; + }>; + body: Readonly<{ + attempts: readonly Readonly<{ + disposition: BodyDisposition; + pulledBytes: number; + ceiling: number; + }>[]; + }>; + scope: Readonly<{ + start: "CURRENT"; + end: "CURRENT" | "STALE"; + signal: "ACTIVE" | "ABORTED"; + cancellationOwner: "NONE" | "CALLER" | "SCOPE_FENCE" | "DEADLINE"; + }>; +}>; + +export const httpScenarioExpectationSchema = z + .object({ + executionId: z.string().min(1), + operationId: z.string().min(1), + scenarioId: z.string().min(1), + expected: httpScenarioAssertionGroupsSchema, + testDeadlineOverrideMs: z.number().int().positive().nullable(), + }) + .strict() + .superRefine((entry, context) => { + if (entry.executionId !== `${entry.operationId}::${entry.scenarioId}`) { + context.addIssue({ + code: "custom", + path: ["executionId"], + message: "must equal operationId::scenarioId", + }); + } + }); + +export type HttpScenarioExpectation = Readonly<{ + executionId: string; + operationId: string; + scenarioId: string; + expected: HttpScenarioAssertionGroups; + testDeadlineOverrideMs: number | null; +}>; + +export const httpScenarioReceiptRowSchema = z + .object({ + executionId: z.string().min(1), + expected: httpScenarioAssertionGroupsSchema, + observed: httpScenarioAssertionGroupsSchema, + testDeadlineOverrideMs: z.number().int().positive().nullable(), + }) + .strict(); + +export const httpScenarioReceiptSchema = z + .object({ + schemaVersion: z.number().int().positive(), + catalogDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/), + catalogTotal: z.number().int().nonnegative(), + executedIds: z.array(z.string().min(1)), + rows: z.array(httpScenarioReceiptRowSchema), + }) + .strict() + .superRefine((receipt, context) => { + const rowIds = receipt.rows.map((row) => row.executionId); + const sortedIds = [...receipt.executedIds].sort((left, right) => + left.localeCompare(right), + ); + const sortedRowIds = [...rowIds].sort((left, right) => + left.localeCompare(right), + ); + if (!sameScenarioJson(receipt.executedIds, sortedIds)) { + context.addIssue({ + code: "custom", + path: ["executedIds"], + message: "must be sorted by execution ID", + }); + } + if (!sameScenarioJson(rowIds, sortedRowIds)) { + context.addIssue({ + code: "custom", + path: ["rows"], + message: "must be sorted by execution ID", + }); + } + if (!sameScenarioJson(receipt.executedIds, rowIds)) { + context.addIssue({ + code: "custom", + path: ["rows"], + message: "row IDs must exactly equal executed IDs", + }); + } + }); + +export type HttpScenarioReceipt = Readonly<{ + schemaVersion: number; + catalogDigest: string; + catalogTotal: number; + executedIds: readonly string[]; + rows: readonly Readonly<{ + executionId: string; + expected: HttpScenarioAssertionGroups; + observed: HttpScenarioAssertionGroups; + testDeadlineOverrideMs: number | null; + }>[]; +}>; + +export function stableScenarioJson(value: unknown): string { + const normalize = (candidate: unknown): unknown => { + if (Array.isArray(candidate)) return candidate.map(normalize); + if (candidate && typeof candidate === "object") { + return Object.fromEntries( + Object.entries(candidate as Readonly>) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, normalize(nested)]), + ); + } + return candidate; + }; + return JSON.stringify(normalize(value)); +} + +export function sameScenarioJson(left: unknown, right: unknown): boolean { + return stableScenarioJson(left) === stableScenarioJson(right); +} + +export function computeHttpScenarioCatalogDigest( + schemaVersion: number, + expectations: readonly HttpScenarioExpectation[], +): `sha256:${string}` { + const tuples = [...expectations] + .sort((left, right) => left.executionId.localeCompare(right.executionId)) + .map((entry) => [ + entry.executionId, + entry.expected.status, + entry.expected.outcome, + entry.expected.effect, + entry.expected.retry, + entry.expected.fetch, + entry.expected.media, + entry.expected.body, + entry.expected.scope, + entry.testDeadlineOverrideMs, + ]); + return `sha256:${createHash("sha256") + .update(stableScenarioJson([schemaVersion, ...tuples])) + .digest("hex")}`; +} diff --git a/scripts/lib/json-schema.ts b/scripts/lib/json-schema.ts new file mode 100644 index 0000000..9f01914 --- /dev/null +++ b/scripts/lib/json-schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * Compile a checked-in JSON Schema and assert a concrete artifact against it. + * Conversion failures are fatal too: an unsupported or malformed schema must + * not silently turn a release schema into documentation-only metadata. + */ +export function assertMatchesJsonSchema( + schemaDocument: unknown, + value: unknown, + label: string, +): void { + try { + const schema = z.fromJSONSchema(schemaDocument as never); + const result = schema.safeParse(value); + if (!result.success) { + throw new TypeError(z.prettifyError(result.error)); + } + } catch (error) { + throw new TypeError(`${label} does not satisfy its checked-in JSON Schema.`, { + cause: error, + }); + } +} diff --git a/scripts/lib/local-policy-evidence.ts b/scripts/lib/local-policy-evidence.ts new file mode 100644 index 0000000..739b4d1 --- /dev/null +++ b/scripts/lib/local-policy-evidence.ts @@ -0,0 +1,206 @@ +import { + dependencyDiffArtifactSchema, + dependencyInventoryArtifactSchema, + licenseReportArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "../contracts/release-artifacts.ts"; +import type { DistOutput } from "./release-candidate.ts"; +import { + diffDependencyInventories, + supplyChainDigest, + validateDependencyReview, + validateLicensePolicy, +} from "./supply-chain.ts"; + +type Document = Record; + +export const LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS = Object.freeze({ + promotionStatus: "FAIL_UNVERIFIED" as const, + vulnerabilityStatus: "FAIL_UNVERIFIED" as const, + provenanceAttestationStatus: "FAIL_UNVERIFIED" as const, +}); + +export function verifyLocalSupplyChainDefaults(stored: unknown): string[] { + const parsed = supplyChainVerificationArtifactSchema.safeParse(stored); + if ( + !parsed.success || + parsed.data.promotionStatus !== + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.promotionStatus || + parsed.data.vulnerabilityStatus !== + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.vulnerabilityStatus || + parsed.data.provenanceAttestationStatus !== + LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS.provenanceAttestationStatus + ) { + return [ + "supply-chain verification provider defaults are not local FAIL_UNVERIFIED", + ]; + } + return []; +} + +export function createLocalVulnerabilityReport(lockfileSha256: string) { + return vulnerabilityReportArtifactSchema.parse({ + schemaVersion: 1, + provider: "UNCONFIGURED", + scannedLockfileSha256: lockfileSha256, + status: "FAIL_UNVERIFIED", + findings: [], + exceptionsApplied: [], + failures: ["external vulnerability provider report is missing"], + blocking: [], + }); +} + +export function compareStoredLocalVulnerabilityReport( + lockfileSha256: string, + stored: unknown, +): string[] { + const parsed = vulnerabilityReportArtifactSchema.safeParse(stored); + if ( + !parsed.success || + supplyChainDigest(parsed.data) !== + supplyChainDigest(createLocalVulnerabilityReport(lockfileSha256)) + ) { + return [ + "local vulnerability report does not match exact unconfigured defaults", + ]; + } + return []; +} + +export function recomputeDependencyEvidence(input: Readonly<{ + inventory: unknown; + baseline: unknown; + baselineApproval: unknown; + dependencyChangeEvidence: unknown; + skipBaseline?: boolean; +}>) { + const inventory = dependencyInventoryArtifactSchema.parse(input.inventory); + const baseline = asDocument(input.baseline); + const approval = asDocument(input.baselineApproval); + const dependencyChangeEvidence = asDocument(input.dependencyChangeEvidence); + const failures: string[] = []; + const skipBaseline = input.skipBaseline === true; + let diff: ReturnType = Object.freeze({ + added: Object.freeze([]), + removed: Object.freeze([]), + changed: Object.freeze([]), + upgrades: Object.freeze([]), + }); + let review: ReturnType = Object.freeze({ + passed: skipBaseline, + highRisk: Object.freeze([]), + failures: Object.freeze( + skipBaseline ? [] : ["dependency baseline unavailable"], + ), + }); + const baselineDigest = baseline ? supplyChainDigest(baseline) : null; + + if (baseline && approval) { + diff = diffDependencyInventories(baseline, inventory); + review = validateDependencyReview( + diff, + inventory, + dependencyChangeEvidence ?? {}, + ); + if ( + approval.schemaVersion !== 1 || + approval.snapshotDigest !== baselineDigest || + typeof approval.owner !== "string" || + approval.owner.trim().length === 0 + ) { + failures.push("dependency baseline approval digest mismatch"); + } + } else if (!skipBaseline) { + failures.push("dependency baseline and approval are required"); + } + failures.push(...review.failures); + + const report = dependencyDiffArtifactSchema.parse({ + schemaVersion: 2, + baselineDigest, + currentDigest: supplyChainDigest(inventory), + ...diff, + highRisk: review.highRisk, + reviewFailures: review.failures, + }); + return Object.freeze({ + report, + dependencyDiff: diff, + highRisk: review.highRisk, + failures: Object.freeze(failures), + }); +} + +export function compareStoredDependencyEvidence( + recomputed: ReturnType, + stored: unknown, +): string[] { + const parsed = dependencyDiffArtifactSchema.safeParse(stored); + if ( + !parsed.success || + supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report) + ) { + return ["stored dependency diff does not match recomputed policy evidence"]; + } + return [...recomputed.failures]; +} + +export function recomputeLicenseEvidence(input: Readonly<{ + inventory: unknown; + policy: unknown; +}>) { + const inventory = dependencyInventoryArtifactSchema.parse(input.inventory); + const policy = asDocument(input.policy) ?? {}; + const result = validateLicensePolicy(inventory, policy); + const report = licenseReportArtifactSchema.parse({ + schemaVersion: 1, + status: result.passed ? "PASS" : "FAIL", + dependencyCount: inventory.dependencyCount, + results: result.results, + failures: result.failures, + }); + return Object.freeze({ + report, + failures: result.failures, + }); +} + +export function compareStoredLicenseEvidence( + recomputed: ReturnType, + stored: unknown, +): string[] { + const parsed = licenseReportArtifactSchema.safeParse(stored); + if ( + !parsed.success || + supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report) + ) { + return ["stored license report does not match recomputed policy evidence"]; + } + return [...recomputed.failures]; +} + +export function distChecksumsText(outputs: readonly DistOutput[]): string { + return `${[...outputs] + .sort((left, right) => left.path.localeCompare(right.path)) + .map((output) => `${output.sha256} ${output.path}`) + .join("\n")}\n`; +} + +export function verifyStoredDistChecksums( + outputs: readonly DistOutput[], + stored: string, +): string[] { + return stored === distChecksumsText(outputs) + ? [] + : ["stored dist checksums do not match current outputs"]; +} + +function asDocument(value: unknown): Document | null { + return value !== null && + typeof value === "object" && + !Array.isArray(value) + ? (value as Document) + : null; +} diff --git a/scripts/lib/local-release-evidence.ts b/scripts/lib/local-release-evidence.ts new file mode 100644 index 0000000..3e518d0 --- /dev/null +++ b/scripts/lib/local-release-evidence.ts @@ -0,0 +1,1330 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { z, type ZodType } from "zod"; + +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../../src/features/installed-contract-contributions.ts"; +import { + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../../src/features/installed-feature-contracts.ts"; +import { + buildManifestArtifactSchema, + bundlePerformanceArtifactSchema, + dependencyDiffArtifactSchema, + dependencyInventoryArtifactSchema, + licenseReportArtifactSchema, + localEvidenceAssessmentArtifactSchema, + provenanceArtifactSchema, + releaseManifestArtifactSchema, + releaseVerificationArtifactSchema, + runtimeConfigArtifactSchema, + sbomArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "../contracts/release-artifacts.ts"; +import { + CANONICAL_VITE_MANIFEST_PATH, + verifyBuildManifestOutputs, +} from "./build-manifest-outputs.ts"; +import { assertMatchesJsonSchema } from "./json-schema.ts"; +import { + LOCAL_EVIDENCE_ASSESSMENT_PATH, + LOCAL_EVIDENCE_POLICY_INPUT_PATHS, + LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS, + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + collectDistOutputs, + distSha256, + releaseCandidateManifestSchema, + type ReleaseCandidateManifest, +} from "./release-candidate.ts"; +import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts"; +import { digestReleaseInputFiles } from "./release-input-evidence.ts"; +import { + compareStoredDependencyEvidence, + compareStoredLicenseEvidence, + compareStoredLocalVulnerabilityReport, + recomputeDependencyEvidence, + recomputeLicenseEvidence, + verifyLocalSupplyChainDefaults, + verifyStoredDistChecksums, +} from "./local-policy-evidence.ts"; +import { + buildRepositoryFileInventory, + parseRepositoryFileInventoryPolicy, +} from "./repository-file-inventory.ts"; +import { + parseSecretScanPolicy, + secretScanSarifSchema, + evaluateRepositorySecretScan, + verifyStoredSecretScan, +} from "./secret-scan-evaluator.ts"; +import { secretScanRules } from "./secret-scan.ts"; +import { + isValidSha512Integrity, + parsePnpmLockfilePackages, + supplyChainDigest, + verifySupplyChainCoherence, +} from "./supply-chain.ts"; + +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +export const supplyChainCoherenceReportSchema = z + .object({ + schemaVersion: z.literal(1), + status: z.enum(["PASS", "FAIL"]), + dependencyCount: z.int().nonnegative(), + lockfileSha256: sha256, + distSha256: sha256, + sbomSha256: sha256, + failures: z.array(z.string()), + }) + .strict(); + +export type SupplyChainCoherenceReport = z.infer< + typeof supplyChainCoherenceReportSchema +>; + +export async function verifyLocalSupplyChainEvidence( + repositoryRoot = process.cwd(), +): Promise { + const failures: string[] = []; + const inventory = await parseArtifact( + repositoryRoot, + "artifacts/release/dependency-inventory.json", + dependencyInventoryArtifactSchema, + "dependency inventory", + failures, + ); + const sbom = await parseArtifact( + repositoryRoot, + "artifacts/release/sbom.cdx.json", + sbomArtifactSchema, + "SBOM", + failures, + ); + const provenance = await parseArtifact( + repositoryRoot, + "artifacts/release/provenance.json", + provenanceArtifactSchema, + "local provenance", + failures, + ); + const verification = await parseArtifact( + repositoryRoot, + "artifacts/security/supply-chain-verification.json", + supplyChainVerificationArtifactSchema, + "supply-chain verification", + failures, + ); + + for (const [schemaPath, artifact, label] of [ + [ + "schemas/artifacts/dependency-inventory.schema.json", + inventory, + "dependency inventory", + ], + [ + "schemas/artifacts/supply-chain-verification.schema.json", + verification, + "supply-chain verification", + ], + ] as const) { + if (!artifact) continue; + try { + assertMatchesJsonSchema( + await readJson(repositoryRoot, schemaPath), + artifact, + label, + ); + } catch { + failures.push(`${label} JSON Schema mismatch`); + } + } + + let lockfileText = ""; + let lockfileSha256 = "0".repeat(64); + try { + const rawLockfile = await readFile( + path.join(repositoryRoot, "pnpm-lock.yaml"), + ); + lockfileText = rawLockfile.toString("utf8"); + lockfileSha256 = createHash("sha256").update(rawLockfile).digest("hex"); + } catch { + failures.push("raw pnpm-lock.yaml is missing or unreadable"); + } + + let distDigest = "0".repeat(64); + try { + distDigest = distSha256(await collectDistOutputs(repositoryRoot)); + } catch { + failures.push("candidate dist is missing or unreadable"); + } + const sbomSha256 = sbom ? supplyChainDigest(sbom) : "0".repeat(64); + + let coherenceFailures: readonly string[] = Object.freeze([]); + if (inventory && sbom && provenance) { + coherenceFailures = verifySupplyChainCoherence( + sbom, + inventory, + provenance, + distDigest, + ).failures; + failures.push(...coherenceFailures); + } + if ( + !inventory || + !verification || + inventory.lockfileSha256 !== lockfileSha256 || + verification.lockfileSha256 !== lockfileSha256 + ) { + failures.push("inventory/verification lockfile digest mismatch"); + } + if ( + !verification || + verification.localStatus !== "PASS" || + verification.failures.length > 0 || + verification.distSha256 !== distDigest || + verification.sbomSha256 !== sbomSha256 + ) { + failures.push("verification digest/status set is incoherent"); + } + if (verification) { + failures.push(...verifyLocalSupplyChainDefaults(verification)); + } + if (inventory && sbom && provenance && verification) { + try { + const policy = parseRepositoryFileInventoryPolicy( + await readJson( + repositoryRoot, + "config/security/secret-scan-policy.json", + ), + ); + const repositoryInventory = await buildRepositoryFileInventory({ + repositoryRoot, + trackedRoots: policy.trackedRoots, + generatedRoots: policy.generatedRoots, + optionalRoots: policy.optionalRoots, + }); + const sourceSetSha256 = await digestReleaseInputFiles( + repositoryInventory.trackedFiles, + (file) => readFile(path.join(repositoryRoot, file)), + ); + if ( + verification.sourceSetSha256 !== sourceSetSha256 || + provenance.predicate.materials.sourceSetSha256 !== sourceSetSha256 || + provenance.predicate.materials.sbomSha256 !== sbomSha256 + ) { + failures.push("source/SBOM provenance materials are incoherent"); + } + } catch { + failures.push("release source inventory is unavailable or unreadable"); + } + } + if (inventory && verification) { + try { + const dependencyPolicy = recomputeDependencyEvidence({ + inventory, + baseline: await optionalReadJson( + repositoryRoot, + "config/security/dependency-baseline.json", + ), + baselineApproval: await optionalReadJson( + repositoryRoot, + "config/security/dependency-baseline.approval.json", + ), + dependencyChangeEvidence: await readJson( + repositoryRoot, + "config/security/dependency-change-evidence.json", + ), + }); + const licensePolicy = recomputeLicenseEvidence({ + inventory, + policy: await readJson( + repositoryRoot, + "config/security/dependency-policy.json", + ), + }); + const expectedFailures = [ + ...licensePolicy.failures, + ...dependencyPolicy.failures, + ...coherenceFailures, + ]; + if ( + supplyChainDigest(verification.dependencyDiff) !== + supplyChainDigest(dependencyPolicy.dependencyDiff) || + supplyChainDigest(verification.highRiskReview) !== + supplyChainDigest(dependencyPolicy.highRisk) || + supplyChainDigest(verification.failures) !== + supplyChainDigest(expectedFailures) || + verification.localStatus !== + (expectedFailures.length === 0 ? "PASS" : "FAIL") + ) { + failures.push( + "supply-chain verification policy fields do not match recomputed evidence", + ); + } + } catch { + failures.push("supply-chain verification policy inputs are invalid"); + } + } + + const lockRows = parsePnpmLockfilePackages(lockfileText); + const inventoryRows = inventory?.dependencies ?? []; + const inventoryByIdentity = new Map( + inventoryRows.map( + (entry) => [`${entry.name}@${entry.version}`, entry] as const, + ), + ); + if (lockRows.length !== inventoryRows.length) { + failures.push("transitive dependency count differs from lockfile"); + } + for (const lockRow of lockRows) { + const identity = `${lockRow.name}@${lockRow.version}`; + const dependency = inventoryByIdentity.get(identity); + if ( + !dependency || + dependency.integrity !== lockRow.integrity || + !isValidSha512Integrity(lockRow.integrity) + ) { + failures.push(`lockfile inventory integrity mismatch: ${identity}`); + } + } + + return supplyChainCoherenceReportSchema.parse({ + schemaVersion: 1, + status: failures.length === 0 ? "PASS" : "FAIL", + dependencyCount: inventoryRows.length, + lockfileSha256, + distSha256: distDigest, + sbomSha256, + failures, + }); +} + +export const LOCAL_EVIDENCE_VERIFIER_ID = + "clean-architecture-frontend-template/local-evidence-verifier"; +export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1"; +export { + LOCAL_EVIDENCE_POLICY_INPUT_PATHS, + LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS, +} from "./release-candidate.ts"; + +export async function createLocalEvidenceAssessment( + repositoryRoot = process.cwd(), +): Promise> { + const root = path.resolve(repositoryRoot); + const outputs = await collectDistOutputs(root); + const evidencePaths = RELEASE_CANDIDATE_EVIDENCE_PATHS.filter( + (memberPath) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH, + ); + const evidenceInputs = ( + await Promise.all([ + ...outputs.map(async ({ path: memberPath }) => digestInput(root, memberPath)), + ...evidencePaths.map((memberPath) => digestInput(root, memberPath)), + ]) + ).sort((left, right) => asciiCompare(left.path, right.path)); + const lockfile = evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml"); + const sbom = evidenceInputs.find( + ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", + ); + if (!lockfile || !sbom) throw new Error("local assessment candidate inputs are incomplete"); + const candidate: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: distSha256(outputs), + lockfileSha256: lockfile.sha256, + bundleSha256: supplyChainDigest(evidenceInputs), + files: evidenceInputs, + }; + const evaluated = await evaluateProducerLocalChecks(root, candidate); + const [build, release, provenance, supply, sbomDocument, policyInputs, secretScan] = await Promise.all([ + readJson(root, "artifacts/release/build-manifest.json").then((value) => + buildManifestArtifactSchema.parse(value), + ), + readJson(root, "dist/release-manifest.json").then((value) => + releaseManifestArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/provenance.json").then((value) => + provenanceArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/supply-chain-verification.json").then((value) => + supplyChainVerificationArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/sbom.cdx.json").then((value) => + sbomArtifactSchema.parse(value), + ), + Promise.all( + LOCAL_EVIDENCE_POLICY_INPUT_PATHS.map((policyPath) => + digestInput(root, policyPath), + ), + ), + evaluateRepositorySecretScan({ repositoryRoot: root }), + ]); + const identityFailures: string[] = []; + if (build.commitSha !== release.commitSha) { + identityFailures.push("producer build/release source revision mismatch"); + } + if ( + provenance.predicate.materials.sourceSetSha256 !== supply.sourceSetSha256 + ) { + identityFailures.push("producer provenance/supply source-set mismatch"); + } + if (supply.distSha256 !== candidate.distSha256) { + identityFailures.push("producer supply/candidate dist digest mismatch"); + } + if (supply.lockfileSha256 !== candidate.lockfileSha256) { + identityFailures.push("producer supply/candidate lockfile digest mismatch"); + } + if (supply.sbomSha256 !== supplyChainDigest(sbomDocument)) { + identityFailures.push("producer supply/candidate SBOM digest mismatch"); + } + const checks = { + ...evaluated.checks, + ...(identityFailures.some((failure) => failure.includes("build/release")) + ? { release: "FAIL" as const } + : {}), + ...(identityFailures.some((failure) => !failure.includes("build/release")) + ? { supplyChain: "FAIL" as const } + : {}), + }; + const failures = [...evaluated.failures, ...identityFailures]; + const status = failures.length === 0 && Object.values(checks).every( + (check) => check === "PASS", + ) + ? ("PASS" as const) + : ("FAIL" as const); + const verifierSources = policyInputs.filter(({ path: policyPath }) => + (LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath), + ); + if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) { + throw new Error("local assessment verifier source set is incomplete"); + } + const secretPolicy = policyInputs.find( + ({ path: policyPath }) => policyPath === "config/security/secret-scan-policy.json", + ); + const secretSarif = evidenceInputs.find( + ({ path: evidencePath }) => evidencePath === "artifacts/security/scan.sarif", + ); + if (!secretPolicy || !secretSarif) { + throw new Error("local assessment secret scan inputs are incomplete"); + } + return localEvidenceAssessmentArtifactSchema.parse({ + schemaVersion: 1, + artifactType: "local-evidence-assessment", + generatedAt: build.generatedAt, + status, + verifier: { + id: LOCAL_EVIDENCE_VERIFIER_ID, + version: LOCAL_EVIDENCE_VERIFIER_VERSION, + sourceSha256: supplyChainDigest(verifierSources), + }, + source: { + revision: build.commitSha, + sourceSetSha256: supply.sourceSetSha256, + }, + candidate: { + distSha256: candidate.distSha256, + lockfileSha256: candidate.lockfileSha256, + sbomSha256: sbom.sha256, + }, + secretScan: { + policySha256: secretPolicy.sha256, + sarifSha256: secretSarif.sha256, + scanInputSha256: secretScan.scanInputSha256, + }, + policyInputs, + evidenceInputs, + checks, + failures, + }); +} + +type LocalCheckName = + | "release" + | "supplyChain" + | "dependencyPolicy" + | "licensePolicy" + | "vulnerabilityPolicy" + | "secretScan"; + +async function evaluateProducerLocalChecks( + root: string, + candidate: ReleaseCandidateManifest, + options: Readonly<{ archived?: boolean }> = {}, +): Promise>; + failures: readonly string[]; +}>> { + const checks: Record = { + release: "PASS", + supplyChain: "PASS", + dependencyPolicy: "PASS", + licensePolicy: "PASS", + vulnerabilityPolicy: "PASS", + secretScan: "PASS", + }; + const failures: string[] = []; + const evaluate = async ( + check: LocalCheckName, + operation: () => Promise, + ): Promise => { + try { + const diagnostics = await operation(); + if (diagnostics.length > 0) { + checks[check] = "FAIL"; + failures.push(...diagnostics.map((failure) => `${check}:${failure}`)); + } + } catch (error) { + checks[check] = "FAIL"; + failures.push( + `${check}:${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + await evaluate("release", async () => { + const [build, release, runtime, stored] = await Promise.all([ + readJson(root, "artifacts/release/build-manifest.json").then((value) => + buildManifestArtifactSchema.parse(value), + ), + readJson(root, "dist/release-manifest.json").then((value) => + releaseManifestArtifactSchema.parse(value), + ), + readJson(root, "dist/config.json").then((value) => + runtimeConfigArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/verification.json").then((value) => + releaseVerificationArtifactSchema.parse(value), + ), + ]); + const diagnostics: string[] = []; + if ( + build.commitSha !== release.commitSha || + build.buildId !== release.buildId || + build.releaseId !== release.releaseId || + build.generatedAt !== release.builtAt + ) { + diagnostics.push("build/release identity mismatch"); + } + if ( + !stored.passed || + !stored.artifact.checked || + !stored.artifact.compatible || + stored.artifact.mismatches.length > 0 || + stored.artifact.releaseId !== release.releaseId || + stored.generatedAt !== release.builtAt || + stored.fixtures.length === 0 || + stored.fixtures.some((fixture) => !fixture.passed) + ) { + diagnostics.push("stored release verification is not a coherent PASS"); + } + if (!runtime.BUILD_ID || !runtime.RELEASE_ID) { + diagnostics.push("runtime release identity is missing"); + } else { + const apiContractVersion = + release.schemaVersion === 1 && "API_CONTRACT_VERSION" in runtime + ? runtime.API_CONTRACT_VERSION + : undefined; + const coherence = await verifyReleaseRuntimeCoherence({ + release, + runtime: { + BUILD_ID: runtime.BUILD_ID, + RELEASE_ID: runtime.RELEASE_ID, + CONFIG_SCHEMA_VERSION: runtime.CONFIG_SCHEMA_VERSION, + ...(apiContractVersion === undefined + ? {} + : { API_CONTRACT_VERSION: apiContractVersion }), + }, + contractPackages: EXPECTED_CONTRACT_SET_PACKAGES, + }); + diagnostics.push(...coherence.mismatches.map((item) => `runtime:${item}`)); + } + diagnostics.push(...(await verifyBuildManifestOutputs(build, { repositoryRoot: root }))); + return diagnostics; + }); + await evaluate("supplyChain", async () => { + const [inventory, sbom, provenance, supply, coherence, lockfileBytes] = await Promise.all([ + readJson(root, "artifacts/release/dependency-inventory.json").then((value) => + dependencyInventoryArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/sbom.cdx.json").then((value) => + sbomArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/provenance.json").then((value) => + provenanceArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/supply-chain-verification.json").then((value) => + supplyChainVerificationArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) => + supplyChainCoherenceReportSchema.parse(value), + ), + readFile(path.join(root, "pnpm-lock.yaml")), + ]); + const diagnostics: string[] = []; + const lockfileSha256 = createHash("sha256").update(lockfileBytes).digest("hex"); + const outputs = await collectDistOutputs(root); + const currentDistSha256 = distSha256(outputs); + const sbomSha256 = supplyChainDigest(sbom); + const independentlyCoherent = verifySupplyChainCoherence( + sbom, + inventory, + provenance, + currentDistSha256, + ); + if ( + supply.localStatus !== "PASS" || + supply.failures.length > 0 || + supply.distSha256 !== candidate.distSha256 || + supply.lockfileSha256 !== candidate.lockfileSha256 || + supply.sbomSha256 !== sbomSha256 || + supply.sourceSetSha256 !== provenance.predicate.materials.sourceSetSha256 || + inventory.lockfileSha256 !== lockfileSha256 || + candidate.lockfileSha256 !== lockfileSha256 || + candidate.distSha256 !== currentDistSha256 || + coherence.status !== "PASS" || + coherence.failures.length > 0 || + coherence.distSha256 !== candidate.distSha256 || + coherence.lockfileSha256 !== candidate.lockfileSha256 || + coherence.sbomSha256 !== sbomSha256 || + coherence.dependencyCount !== inventory.dependencies.length || + independentlyCoherent.failures.length > 0 + ) { + diagnostics.push("stored supply-chain evidence is not a coherent PASS"); + } + diagnostics.push(...verifyLocalSupplyChainDefaults(supply)); + diagnostics.push( + ...verifyStoredDistChecksums( + outputs, + await readFile(path.join(root, "artifacts/release/checksums.txt"), "utf8"), + ), + ); + const lockRows = parsePnpmLockfilePackages(lockfileBytes.toString("utf8")); + const inventoryByIdentity = new Map( + inventory.dependencies.map((entry) => [`${entry.name}@${entry.version}`, entry] as const), + ); + if (lockRows.length !== inventory.dependencies.length) { + diagnostics.push("transitive dependency count differs from lockfile"); + } + for (const lockRow of lockRows) { + const dependency = inventoryByIdentity.get(`${lockRow.name}@${lockRow.version}`); + if ( + !dependency || + dependency.integrity !== lockRow.integrity || + !isValidSha512Integrity(lockRow.integrity) + ) { + diagnostics.push(`lockfile inventory integrity mismatch: ${lockRow.name}@${lockRow.version}`); + } + } + return diagnostics; + }); + await evaluate("dependencyPolicy", async () => { + const [inventory, stored] = await Promise.all([ + readJson(root, "artifacts/release/dependency-inventory.json").then((value) => + dependencyInventoryArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/dependency-diff.json").then((value) => + dependencyDiffArtifactSchema.parse(value), + ), + ]); + const recomputed = recomputeDependencyEvidence({ + inventory, + baseline: await optionalReadJson(root, "config/security/dependency-baseline.json"), + baselineApproval: await optionalReadJson( + root, + "config/security/dependency-baseline.approval.json", + ), + dependencyChangeEvidence: await readJson( + root, + "config/security/dependency-change-evidence.json", + ), + }); + return compareStoredDependencyEvidence(recomputed, stored); + }); + await evaluate("licensePolicy", async () => { + const [inventory, stored, policy] = await Promise.all([ + readJson(root, "artifacts/release/dependency-inventory.json").then((value) => + dependencyInventoryArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/license-report.json").then((value) => + licenseReportArtifactSchema.parse(value), + ), + readJson(root, "config/security/dependency-policy.json"), + ]); + return compareStoredLicenseEvidence( + recomputeLicenseEvidence({ inventory, policy }), + stored, + ); + }); + await evaluate("vulnerabilityPolicy", async () => { + const vulnerability = vulnerabilityReportArtifactSchema.parse( + await readJson(root, "artifacts/security/vulnerability-report.json"), + ); + return compareStoredLocalVulnerabilityReport( + candidate.lockfileSha256, + vulnerability, + ); + }); + await evaluate("secretScan", async () => { + if (options.archived) { + const policy = parseSecretScanPolicy( + await readJson(root, "config/security/secret-scan-policy.json"), + ); + const sarif = secretScanSarifSchema.parse( + await readJson(root, "artifacts/security/scan.sarif"), + ); + const diagnostics: string[] = []; + if ( + policy.trackedRoots.length === 0 || + policy.generatedRoots.length === 0 || + sarif.runs[0]!.results.length > 0 || + JSON.stringify(sarif.runs[0]!.tool.driver.rules.map(({ id }) => id)) !== + JSON.stringify(secretScanRules().map(({ id }) => id)) + ) { + diagnostics.push("archived secret scan is not an independently valid PASS"); + } + return diagnostics; + } + const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root }); + return verifyStoredSecretScan( + evaluation, + await readJson(root, "artifacts/security/scan.sarif"), + ); + }); + return Object.freeze({ checks: Object.freeze(checks), failures: Object.freeze(failures) }); +} + +async function digestInput( + repositoryRoot: string, + memberPath: string, +): Promise> { + const absolute = path.resolve(repositoryRoot, memberPath); + const relative = path.relative(repositoryRoot, absolute); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new TypeError(`local assessment input escapes repository: ${memberPath}`); + } + const bytes = await readFile(absolute); + return Object.freeze({ + path: memberPath, + bytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); +} + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export async function verifyArchivedLocalEvidence(input: Readonly<{ + extractionRoot: string; + expectedManifest: ReleaseCandidateManifest; +}>): Promise; + }>; + failures: readonly string[]; +}>> { + const extractionRoot = path.resolve(input.extractionRoot); + const failures: string[] = []; + let extractedManifest: ReleaseCandidateManifest | null = null; + try { + extractedManifest = releaseCandidateManifestSchema.parse( + await readJson(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), + ); + } catch { + failures.push("extracted release candidate manifest is missing or invalid"); + } + if ( + extractedManifest && + JSON.stringify(extractedManifest) !== JSON.stringify(input.expectedManifest) + ) { + failures.push("caller expectedManifest differs from extracted manifest"); + } + + let assessment: z.infer | null = null; + let assessmentSha256 = ""; + let assessmentBytes: Buffer | null = null; + try { + assessmentBytes = await readFile( + path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), + ); + assessmentSha256 = createHash("sha256").update(assessmentBytes).digest("hex"); + assessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(assessmentBytes)) as unknown, + ); + } catch { + failures.push("local evidence assessment is missing or invalid"); + } + + if (assessment && extractedManifest) { + const assessmentMember = extractedManifest.files.find( + ({ path: memberPath }) => memberPath === LOCAL_EVIDENCE_ASSESSMENT_PATH, + ); + if ( + !assessmentMember || + assessmentMember.bytes !== assessmentBytes?.byteLength || + assessmentMember.sha256 !== assessmentSha256 + ) { + failures.push("local assessment manifest binding mismatch"); + } + if ( + assessment.verifier.id !== LOCAL_EVIDENCE_VERIFIER_ID || + assessment.verifier.version !== LOCAL_EVIDENCE_VERIFIER_VERSION + ) { + failures.push("local assessment verifier identity mismatch"); + } + const policyPaths = assessment.policyInputs.map(({ path: policyPath }) => policyPath); + if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) { + failures.push("local assessment policyInputs exact set mismatch"); + } + for (const policyInput of assessment.policyInputs) { + try { + const bytes = await readFile(path.join(extractionRoot, policyInput.path)); + const member = extractedManifest.files.find( + ({ path: memberPath }) => memberPath === policyInput.path, + ); + if ( + !member || + member.bytes !== policyInput.bytes || + member.sha256 !== policyInput.sha256 || + bytes.byteLength !== policyInput.bytes || + createHash("sha256").update(bytes).digest("hex") !== policyInput.sha256 + ) { + failures.push(`archived policy input binding mismatch: ${policyInput.path}`); + } + } catch { + failures.push(`archived policy input is missing or invalid: ${policyInput.path}`); + } + } + const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) => + (LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath), + ); + if ( + verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length || + supplyChainDigest(verifierSources) !== assessment.verifier.sourceSha256 + ) { + failures.push("local assessment verifier-source digest mismatch"); + } + + const expectedEvidenceInputs = extractedManifest.files.filter( + ({ path: memberPath }) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH, + ); + if (JSON.stringify(assessment.evidenceInputs) !== JSON.stringify(expectedEvidenceInputs)) { + failures.push("local assessment evidenceInputs exact member binding mismatch"); + } + const sbom = extractedManifest.files.find( + ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", + ); + const secretPolicy = extractedManifest.files.find( + ({ path: memberPath }) => memberPath === "config/security/secret-scan-policy.json", + ); + const secretSarif = extractedManifest.files.find( + ({ path: memberPath }) => memberPath === "artifacts/security/scan.sarif", + ); + if ( + assessment.candidate.distSha256 !== extractedManifest.distSha256 || + assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 || + !sbom || + assessment.candidate.sbomSha256 !== sbom.sha256 + ) { + failures.push("local assessment candidate digest binding mismatch"); + } + if ( + !secretPolicy || + !secretSarif || + assessment.secretScan.policySha256 !== secretPolicy.sha256 || + assessment.secretScan.sarifSha256 !== secretSarif.sha256 + ) { + failures.push("local assessment secret scan artifact binding mismatch"); + } + if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) { + failures.push("local evidence assessment is not PASS"); + } + + try { + const [supply, coherence] = await Promise.all([ + readJson(extractionRoot, "artifacts/security/supply-chain-verification.json").then( + (value) => supplyChainVerificationArtifactSchema.parse(value), + ), + readJson(extractionRoot, "artifacts/security/supply-chain-coherence.json").then( + (value) => supplyChainCoherenceReportSchema.parse(value), + ), + ]); + if ( + supply.localStatus !== "PASS" || + supply.failures.length > 0 || + coherence.status !== "PASS" || + coherence.failures.length > 0 + ) { + failures.push("archived supply-chain subordinate evidence is not PASS"); + } + } catch { + failures.push("archived supply-chain subordinate evidence is missing or invalid"); + } + + const independent = await evaluateProducerLocalChecks( + extractionRoot, + extractedManifest, + { archived: true }, + ); + if ( + independent.failures.length > 0 || + JSON.stringify(independent.checks) !== JSON.stringify(assessment.checks) + ) { + failures.push( + ...independent.failures.map((failure) => `archived local check:${failure}`), + ); + failures.push("archived local checks do not independently reproduce assessment PASS"); + } + + const identities = await readArchivedIdentities(extractionRoot, failures); + if ( + identities.buildRevision !== assessment.source.revision || + identities.releaseRevision !== assessment.source.revision + ) { + failures.push("local assessment source revision identity mismatch"); + } + if ( + identities.provenanceSourceSetSha256 !== assessment.source.sourceSetSha256 || + identities.supplySourceSetSha256 !== assessment.source.sourceSetSha256 + ) { + failures.push("local assessment source-set identity mismatch"); + } + } + + const uniqueFailures = Object.freeze([...new Set(failures)]); + const passingAssessment = uniqueFailures.length === 0 ? assessment : null; + return Object.freeze({ + status: passingAssessment ? "PASS" : "FAIL", + identity: passingAssessment + ? Object.freeze({ + sourceRevision: passingAssessment.source.revision, + sourceSetSha256: passingAssessment.source.sourceSetSha256, + assessmentSha256, + secretScan: passingAssessment.secretScan, + }) + : null, + failures: uniqueFailures, + }); +} + +async function readArchivedIdentities( + extractionRoot: string, + failures: string[], +): Promise> { + try { + const [buildDocument, releaseDocument, provenanceDocument, supplyDocument] = await Promise.all([ + readJson(extractionRoot, "artifacts/release/build-manifest.json"), + readJson(extractionRoot, "dist/release-manifest.json"), + readJson(extractionRoot, "artifacts/release/provenance.json"), + readJson(extractionRoot, "artifacts/security/supply-chain-verification.json"), + ]); + const build = buildManifestArtifactSchema.parse(buildDocument); + const release = releaseManifestArtifactSchema.parse(releaseDocument); + const provenance = provenanceArtifactSchema.parse(provenanceDocument); + const supply = supplyChainVerificationArtifactSchema.parse(supplyDocument); + return Object.freeze({ + buildRevision: build.commitSha, + releaseRevision: release.commitSha, + provenanceSourceSetSha256: provenance.predicate.materials.sourceSetSha256, + supplySourceSetSha256: supply.sourceSetSha256, + }); + } catch { + failures.push("archived source/build/provenance identities are missing or invalid"); + return Object.freeze({ + buildRevision: null, + releaseRevision: null, + provenanceSourceSetSha256: null, + supplySourceSetSha256: null, + }); + } +} + +export async function assessLocalEvidenceForProducer(input: Readonly<{ + repositoryRoot?: string; + candidate: ReleaseCandidateManifest; +}>): Promise> { + const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd()); + const failures: string[] = []; + const supplyReport = await verifyLocalSupplyChainEvidence(repositoryRoot); + failures.push(...supplyReport.failures); + if (supplyReport.lockfileSha256 !== input.candidate.lockfileSha256) { + failures.push("candidate/raw lockfile digest mismatch"); + } + + const buildManifest = await parseArtifact( + repositoryRoot, + "artifacts/release/build-manifest.json", + buildManifestArtifactSchema, + "build manifest", + failures, + ); + const release = await parseArtifact( + repositoryRoot, + "dist/release-manifest.json", + releaseManifestArtifactSchema, + "release manifest", + failures, + ); + const runtime = await parseArtifact( + repositoryRoot, + "dist/config.json", + runtimeConfigArtifactSchema, + "runtime config", + failures, + ); + const storedRelease = await parseArtifact( + repositoryRoot, + "artifacts/release/verification.json", + releaseVerificationArtifactSchema, + "release verification", + failures, + ); + const storedSupply = await parseArtifact( + repositoryRoot, + "artifacts/security/supply-chain-coherence.json", + supplyChainCoherenceReportSchema, + "supply-chain coherence", + failures, + ); + + await validateSupportingArtifacts( + repositoryRoot, + failures, + supplyReport.lockfileSha256, + ); + if (buildManifest) { + try { + assertMatchesJsonSchema( + await readJson( + repositoryRoot, + "schemas/artifacts/build-manifest.schema.json", + ), + buildManifest, + "build manifest", + ); + } catch { + failures.push("build manifest JSON Schema mismatch"); + } + failures.push( + ...(await verifyBuildManifestOutputs(buildManifest, { repositoryRoot })), + ); + } + + if (release && runtime) { + if (!runtime.BUILD_ID || !runtime.RELEASE_ID) { + failures.push("runtime release identity is missing"); + } else { + const apiContractVersion = + release.schemaVersion === 1 && "API_CONTRACT_VERSION" in runtime + ? runtime.API_CONTRACT_VERSION + : undefined; + if (release.schemaVersion === 1 && apiContractVersion === undefined) { + failures.push("runtime API contract identity is missing"); + } + const coherence = await verifyReleaseRuntimeCoherence({ + release, + runtime: { + BUILD_ID: runtime.BUILD_ID, + RELEASE_ID: runtime.RELEASE_ID, + CONFIG_SCHEMA_VERSION: runtime.CONFIG_SCHEMA_VERSION, + ...(apiContractVersion === undefined + ? {} + : { API_CONTRACT_VERSION: apiContractVersion }), + }, + contractPackages: EXPECTED_CONTRACT_SET_PACKAGES, + }); + failures.push(...coherence.mismatches.map((item) => `release:${item}`)); + } + await verifyReleaseOutputs( + repositoryRoot, + release, + buildManifest, + failures, + ); + } + + if ( + !storedRelease || + !storedRelease.passed || + !storedRelease.artifact.checked || + !storedRelease.artifact.compatible || + storedRelease.artifact.mismatches.length > 0 || + storedRelease.fixtures.length === 0 || + storedRelease.fixtures.some((fixture) => !fixture.passed) || + storedRelease.artifact.releaseId !== release?.releaseId || + storedRelease.generatedAt !== release?.builtAt + ) { + failures.push("stored release verification is not a coherent PASS"); + } + if ( + !storedSupply || + storedSupply.status !== "PASS" || + storedSupply.failures.length > 0 || + storedSupply.dependencyCount !== supplyReport.dependencyCount || + storedSupply.lockfileSha256 !== supplyReport.lockfileSha256 || + storedSupply.distSha256 !== supplyReport.distSha256 || + storedSupply.sbomSha256 !== supplyReport.sbomSha256 + ) { + failures.push("stored supply-chain coherence is not a recomputed PASS"); + } + await verifySecretScan(repositoryRoot, failures); + + return Object.freeze({ + status: failures.length === 0 ? "PASS" : "FAIL", + failures: Object.freeze([...new Set(failures)]), + }); +} + +async function validateSupportingArtifacts( + repositoryRoot: string, + failures: string[], + lockfileSha256: string, +): Promise { + let actualOutputs: Awaited> | null = + null; + try { + actualOutputs = await collectDistOutputs(repositoryRoot); + } catch { + failures.push("supporting evidence dist inputs are unreadable"); + } + const bundle = await parseArtifact( + repositoryRoot, + "artifacts/performance/bundle.json", + bundlePerformanceArtifactSchema, + "bundle report", + failures, + ); + if ( + bundle && + actualOutputs && + JSON.stringify(bundle.outputs) !== JSON.stringify(actualOutputs) + ) { + failures.push("bundle report does not describe current dist bytes"); + } + if (actualOutputs) { + try { + failures.push( + ...verifyStoredDistChecksums( + actualOutputs, + await readFile( + path.join(repositoryRoot, "artifacts/release/checksums.txt"), + "utf8", + ), + ), + ); + } catch { + failures.push("stored dist checksums are missing or unreadable"); + } + } + const inventory = await parseArtifact( + repositoryRoot, + "artifacts/release/dependency-inventory.json", + dependencyInventoryArtifactSchema, + "dependency inventory", + failures, + ); + const dependencyDiff = await parseArtifact( + repositoryRoot, + "artifacts/security/dependency-diff.json", + dependencyDiffArtifactSchema, + "dependency diff", + failures, + ); + if (inventory && dependencyDiff) { + try { + const recomputed = recomputeDependencyEvidence({ + inventory, + baseline: await optionalReadJson( + repositoryRoot, + "config/security/dependency-baseline.json", + ), + baselineApproval: await optionalReadJson( + repositoryRoot, + "config/security/dependency-baseline.approval.json", + ), + dependencyChangeEvidence: await readJson( + repositoryRoot, + "config/security/dependency-change-evidence.json", + ), + }); + failures.push( + ...compareStoredDependencyEvidence(recomputed, dependencyDiff), + ); + } catch { + failures.push("dependency policy evidence is missing or invalid"); + } + } + const license = await parseArtifact( + repositoryRoot, + "artifacts/security/license-report.json", + licenseReportArtifactSchema, + "license report", + failures, + ); + if (inventory && license) { + try { + const recomputed = recomputeLicenseEvidence({ + inventory, + policy: await readJson( + repositoryRoot, + "config/security/dependency-policy.json", + ), + }); + failures.push(...compareStoredLicenseEvidence(recomputed, license)); + } catch { + failures.push("license policy evidence is missing or invalid"); + } + } + const vulnerability = await parseArtifact( + repositoryRoot, + "artifacts/security/vulnerability-report.json", + vulnerabilityReportArtifactSchema, + "local vulnerability report", + failures, + ); + if (vulnerability) { + failures.push( + ...compareStoredLocalVulnerabilityReport(lockfileSha256, vulnerability), + ); + } + const provenance = await parseArtifact( + repositoryRoot, + "artifacts/release/provenance.json", + provenanceArtifactSchema, + "local provenance", + failures, + ); + if ( + provenance?.predicate.runDetails.metadata.invocationId !== "LOCAL_UNSIGNED" + ) { + failures.push("local provenance must remain LOCAL_UNSIGNED"); + } +} + +async function verifyReleaseOutputs( + repositoryRoot: string, + release: z.infer, + buildManifest: z.infer | null, + failures: string[], +): Promise { + let viteManifest: Record = {}; + try { + const raw = await readFile( + path.join(repositoryRoot, CANONICAL_VITE_MANIFEST_PATH), + "utf8", + ); + viteManifest = asRecord(JSON.parse(raw), "Vite manifest"); + if (createHash("sha256").update(raw).digest("hex") !== release.assetManifestHash) { + failures.push("release asset manifest hash mismatch"); + } + } catch { + failures.push("Vite manifest is missing or invalid"); + } + if ( + buildManifest && + (buildManifest.buildId !== release.buildId || + buildManifest.commitSha !== release.commitSha || + buildManifest.releaseId !== release.releaseId || + buildManifest.generatedAt !== release.builtAt) + ) { + failures.push("build/release identity mismatch"); + } + const runtimeContracts: Readonly> = + ROUTE_RUNTIME_CONTRACT; + for (const definition of Object.values(ROUTE_REGISTRY)) { + const runtime = runtimeContracts[definition.routeId]; + const viteEntry = Object.values(viteManifest).find( + (entry) => + isRecord(entry) && + entry.name === runtime?.moduleId && + entry.isDynamicEntry === true, + ); + const file = isRecord(viteEntry) ? viteEntry.file : null; + if ( + typeof file !== "string" || + release.routeChunks[definition.chunkId] !== file || + buildManifest?.outputs.routeChunks[definition.chunkId] !== file + ) { + failures.push(`release route chunk mismatch: ${definition.chunkId}`); + } + } +} + +async function verifySecretScan( + repositoryRoot: string, + failures: string[], +): Promise { + try { + const evaluation = await evaluateRepositorySecretScan({ repositoryRoot }); + failures.push( + ...verifyStoredSecretScan( + evaluation, + await readJson(repositoryRoot, "artifacts/security/scan.sarif"), + ), + ); + } catch { + failures.push("secret scan SARIF is missing or invalid"); + } +} + +async function parseArtifact( + repositoryRoot: string, + file: string, + schema: ZodType, + label: string, + failures: string[], +): Promise { + try { + return schema.parse(await readJson(repositoryRoot, file)); + } catch { + failures.push(`${label} executable schema mismatch`); + return null; + } +} + +async function readJson(repositoryRoot: string, file: string): Promise { + return JSON.parse(await readFile(path.join(repositoryRoot, file), "utf8")); +} + +async function optionalReadJson( + repositoryRoot: string, + file: string, +): Promise { + try { + return await readJson(repositoryRoot, file); + } catch { + return null; + } +} + +function asRecord(value: unknown, label: string): Record { + if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`); + return value; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/lib/manual-a11y-evidence.ts b/scripts/lib/manual-a11y-evidence.ts new file mode 100644 index 0000000..1a40c8f --- /dev/null +++ b/scripts/lib/manual-a11y-evidence.ts @@ -0,0 +1,57 @@ +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; + +export const MANUAL_A11Y_ROUTE_IDS = Object.freeze( + Object.values(ROUTE_REGISTRY).map((route) => route.routeId), +); + +const REVIEW_FIELDS = Object.freeze([ + "M1 Keyboard", + "M2 Visible focus", + "M3 Route focus", + "M4 Modal focus", + "M5 Error association", + "M6 Color signal", + "M7 Reduced motion", + "Screen reader", +]); + +export function validateManualA11yEvidence(content: string) { + const fields = Object.fromEntries( + content + .split(/\r?\n/) + .map((line) => /^([^:]+):\s*(.*)$/.exec(line)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => [ + match[1].trim(), + match[2].trim(), + ]), + ); + const failures: string[] = []; + if (fields.Status !== "reviewed") failures.push("Status"); + if (!fields["Route ID"]) failures.push("Route ID"); + if (!fields["Release ID"]) failures.push("Release ID"); + if (!fields.Reviewer) failures.push("Reviewer"); + if (!fields.Signature) failures.push("Signature"); + if (fields.Attestation !== "accepted") failures.push("Attestation"); + if ( + !fields["Reviewed at"] || + !Number.isFinite(Date.parse(fields["Reviewed at"])) + ) { + failures.push("Reviewed at"); + } + + for (const field of REVIEW_FIELDS) { + const result = fields[field]; + if ( + result !== "pass" && + !/^not-applicable \(.+\)$/.test(result ?? "") + ) { + failures.push(field); + } + } + return Object.freeze({ + fields: Object.freeze(fields), + failures: Object.freeze(failures), + passed: failures.length === 0, + }); +} diff --git a/scripts/lib/optional-recipe-bundle.ts b/scripts/lib/optional-recipe-bundle.ts new file mode 100644 index 0000000..915b287 --- /dev/null +++ b/scripts/lib/optional-recipe-bundle.ts @@ -0,0 +1,373 @@ +import { createHash } from "node:crypto"; +import { lstat, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { gzipSync } from "node:zlib"; + +import { + build, + normalizePath, + type Plugin, + version as viteVersion, +} from "vite"; + +const RECIPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/; +const DECLARATION_FILE = /\.d\.[cm]?ts$/; + +type EmittedOutput = + | Readonly<{ + type: "chunk"; + fileName: string; + code: string; + }> + | Readonly<{ + type: "asset"; + fileName: string; + source: string | Uint8Array; + }>; + +export type OptionalRecipeBundleOutput = Readonly<{ + fileName: string; + bytes: number; + gzipBytes: number; + sha256: string; +}>; + +export type OptionalRecipeBundleMeasurement = Readonly<{ + recipeId: string; + sourceRoots: readonly string[]; + sourceFileCount: number; + toolchain: Readonly<{ + bundler: "vite"; + viteVersion: string; + mode: "production"; + target: "es2022"; + format: "es"; + minifier: "esbuild"; + treeshake: false; + compression: "node-zlib-gzip"; + }>; + outputs: readonly OptionalRecipeBundleOutput[]; + bytes: number; + gzipBytes: number; + bundleBudgetGzipBytes: number; + remainingGzipBytes: number; + sha256: string; + passed: boolean; +}>; + +/** + * Builds an uncomposed reference runtime as a synthetic production consumer. + * Every catalog-owned source module is exposed as an entry namespace and + * tree-shaking is disabled so internal fail-closed paths remain in the budget. + */ +export async function measureOptionalRecipeBundle(input: Readonly<{ + recipeId: string; + sourceRoots: readonly string[]; + bundleBudgetGzipBytes: number; + workspaceRoot?: string; +}>): Promise { + const recipeId = validateRecipeId(input.recipeId); + const bundleBudgetGzipBytes = positiveSafeInteger( + input.bundleBudgetGzipBytes, + "Optional recipe bundle budget", + ); + const workspaceRoot = await realpath( + path.resolve(input.workspaceRoot ?? process.cwd()), + ); + const sourceRoots = validateSourceRoots(input.sourceRoots); + const sourceFiles = await resolveSourceFiles( + workspaceRoot, + sourceRoots, + ); + const virtualEntry = + `virtual:optional-reference-runtime-entry/${recipeId}`; + const resolvedVirtualEntry = `\0${virtualEntry}`; + const entrySource = sourceFiles + .map( + (sourceFile, index) => + `export * as source${index} from ${JSON.stringify( + viteSourceSpecifier(sourceFile), + )};`, + ) + .join("\n") + .concat("\n"); + const preservePublicEntryPlugin = { + name: "optional-reference-runtime-entry", + enforce: "pre", + resolveId(id) { + return id === virtualEntry ? resolvedVirtualEntry : null; + }, + load(id) { + return id === resolvedVirtualEntry ? entrySource : null; + }, + options(options) { + return { + ...options, + preserveEntrySignatures: "strict", + }; + }, + } satisfies Plugin; + const buildResult = await build({ + root: workspaceRoot, + configFile: false, + envFile: false, + mode: "production", + publicDir: false, + clearScreen: false, + logLevel: "silent", + plugins: [preservePublicEntryPlugin], + build: { + target: "es2022", + minify: "esbuild", + sourcemap: false, + write: false, + emptyOutDir: false, + copyPublicDir: false, + cssCodeSplit: false, + reportCompressedSize: false, + rollupOptions: { + input: virtualEntry, + // Budget the complete selected runtime, including internal fail-closed + // guards that a synthetic consumer cannot predict it will exercise. + treeshake: false, + output: { + format: "es", + entryFileNames: `${recipeId}.js`, + chunkFileNames: `${recipeId}-chunk-[hash].js`, + assetFileNames: `${recipeId}-asset-[name]-[hash][extname]`, + }, + }, + }, + }); + const emitted = emittedOutputs(buildResult); + if (emitted.length === 0) { + throw new TypeError("Optional recipe bundle emitted no output."); + } + const outputs = emitted + .map((output) => { + const bytes = outputBytes(output); + return Object.freeze({ + fileName: output.fileName, + bytes: bytes.byteLength, + gzipBytes: gzipSync(bytes).byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + }) + .sort((left, right) => compareText(left.fileName, right.fileName)); + const aggregateHash = createHash("sha256"); + for (const output of outputs) { + aggregateHash.update(output.fileName); + aggregateHash.update("\0"); + aggregateHash.update(output.sha256); + aggregateHash.update("\0"); + } + const bytes = outputs.reduce( + (total, output) => total + output.bytes, + 0, + ); + const gzipBytes = outputs.reduce( + (total, output) => total + output.gzipBytes, + 0, + ); + + return Object.freeze({ + recipeId, + sourceRoots: Object.freeze([...sourceRoots]), + sourceFileCount: sourceFiles.length, + toolchain: Object.freeze({ + bundler: "vite" as const, + viteVersion, + mode: "production" as const, + target: "es2022" as const, + format: "es" as const, + minifier: "esbuild" as const, + treeshake: false as const, + compression: "node-zlib-gzip" as const, + }), + outputs: Object.freeze(outputs), + bytes, + gzipBytes, + bundleBudgetGzipBytes, + remainingGzipBytes: bundleBudgetGzipBytes - gzipBytes, + sha256: aggregateHash.digest("hex"), + passed: gzipBytes <= bundleBudgetGzipBytes, + }); +} + +async function resolveSourceFiles( + workspaceRoot: string, + sourceRoots: readonly string[], +): Promise { + const sourceBoundary = await realpath(path.join(workspaceRoot, "src")); + const discovered: string[] = []; + for (const sourceRoot of sourceRoots) { + const target = path.resolve(workspaceRoot, sourceRoot); + assertInsideSourceBoundary(target, sourceBoundary); + const rootMetadata = await lstat(target); + if (rootMetadata.isSymbolicLink()) { + throw new TypeError("Optional recipe source root cannot be a symlink."); + } + assertInsideSourceBoundary(await realpath(target), sourceBoundary); + if ( + rootMetadata.isFile() && + (!SOURCE_EXTENSION.test(target) || DECLARATION_FILE.test(target)) + ) { + throw new TypeError("Optional recipe source root is not executable source."); + } + discovered.push( + ...(await collectExecutableSources(target, sourceBoundary)), + ); + } + const unique = [...new Set(discovered)].sort((left, right) => + compareText( + normalizePath(path.relative(workspaceRoot, left)), + normalizePath(path.relative(workspaceRoot, right)), + ), + ); + if (unique.length === 0) { + throw new TypeError("Optional recipe source roots contain no executable source."); + } + return Object.freeze(unique); +} + +async function collectExecutableSources( + target: string, + sourceBoundary: string, +): Promise { + const metadata = await lstat(target); + if (metadata.isSymbolicLink()) { + throw new TypeError("Optional recipe source cannot be a symlink."); + } + assertInsideSourceBoundary(target, sourceBoundary); + if (metadata.isFile()) { + return SOURCE_EXTENSION.test(target) && !DECLARATION_FILE.test(target) + ? [target] + : []; + } + if (!metadata.isDirectory()) return []; + const entries = (await readdir(target, { withFileTypes: true })).sort( + (left, right) => compareText(left.name, right.name), + ); + const groups = await Promise.all( + entries.map((entry) => + collectExecutableSources( + path.join(target, entry.name), + sourceBoundary, + ), + ), + ); + return groups.flat(); +} + +function emittedOutputs(value: unknown): readonly EmittedOutput[] { + const buildOutputs = Array.isArray(value) ? value : [value]; + const emitted: EmittedOutput[] = []; + for (const buildOutput of buildOutputs) { + if (!isRecord(buildOutput) || !Array.isArray(buildOutput.output)) { + throw new TypeError("Optional recipe bundle output is invalid."); + } + for (const output of buildOutput.output) { + if (!isRecord(output)) { + throw new TypeError("Optional recipe emitted output is invalid."); + } + if ( + output.type === "chunk" && + typeof output.fileName === "string" && + typeof output.code === "string" + ) { + emitted.push({ + type: "chunk", + fileName: output.fileName, + code: output.code, + }); + } else if ( + output.type === "asset" && + typeof output.fileName === "string" && + (typeof output.source === "string" || + output.source instanceof Uint8Array) + ) { + emitted.push({ + type: "asset", + fileName: output.fileName, + source: output.source, + }); + } else { + throw new TypeError("Optional recipe emitted output shape is invalid."); + } + } + } + return emitted; +} + +function outputBytes(output: EmittedOutput): Buffer { + if (output.type === "chunk") { + return Buffer.from(output.code, "utf8"); + } + return Buffer.from(output.source); +} + +function validateRecipeId(value: unknown): string { + if (typeof value !== "string" || !RECIPE_ID.test(value)) { + throw new TypeError("Optional recipe ID is invalid."); + } + return value; +} + +function validateSourceRoots(value: unknown): readonly string[] { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > 32 || + value.some( + (sourceRoot) => + typeof sourceRoot !== "string" || + !sourceRoot.startsWith("src/") || + sourceRoot.includes("\\") || + sourceRoot + .split("/") + .some( + (segment) => + segment.length === 0 || segment === "." || segment === "..", + ), + ) || + new Set(value).size !== value.length + ) { + throw new TypeError("Optional recipe source roots are invalid."); + } + return Object.freeze([...value].sort(compareText)); +} + +function assertInsideSourceBoundary( + target: string, + sourceBoundary: string, +): void { + const relative = path.relative(sourceBoundary, target); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new TypeError("Optional recipe source escaped the source boundary."); + } +} + +function viteSourceSpecifier(sourceFile: string): string { + return pathToFileURL(sourceFile).href; +} + +function positiveSafeInteger(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new TypeError(`${name} is invalid.`); + } + return value as number; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/lib/optional-recipes.ts b/scripts/lib/optional-recipes.ts new file mode 100644 index 0000000..3824479 --- /dev/null +++ b/scripts/lib/optional-recipes.ts @@ -0,0 +1,441 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +export const REQUIRED_RECIPE_IDS = Object.freeze([ + "analytics-error-sink", + "browser-permission", + "client-workflow", + "feature-flag", + "file-transfer", + "generated-api", + "large-data-ui", + "multi-tab", + "offline-indexeddb", + "realtime", + "service-worker-pwa", + "web-worker", +] as const); + +const lifecycleRecipes: ReadonlySet = new Set([ + "analytics-error-sink", + "browser-permission", + "client-workflow", + "file-transfer", + "generated-api", + "multi-tab", + "offline-indexeddb", + "realtime", + "service-worker-pwa", + "web-worker", +]); + +type Document = Readonly>; +export type OptionalRecipeSourceViolation = Readonly<{ + ruleId: string; + path: string; +}>; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function recordValue(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function recordRows(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function nonEmptyStrings(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (entry): entry is string => + typeof entry === "string" && entry.trim().length > 0, + ) + ); +} + +function packageVersions(value: unknown): Record { + return Object.fromEntries( + Object.entries(recordValue(value)).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +export function validateRecipeCatalog( + input: unknown, + packageDocument: Document, +): string[] { + const document = recordValue(input); + const violations: string[] = []; + if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION"); + if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION"); + if (document.defaultStatus !== "NOT_INSTALLED") { + violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED"); + } + if ( + !Array.isArray(document.productionRuntimeDependencies) || + document.productionRuntimeDependencies.length > 0 + ) { + violations.push("UNSELECTED_RUNTIME_DEPENDENCY"); + } + if (!nonEmptyStrings(document.vendorPackagePatterns)) { + violations.push("VENDOR_PATTERN_CATALOG"); + } + if (!Array.isArray(document.recipes)) { + return [...violations, "RECIPE_CATALOG_MISSING"]; + } + + const recipes = recordRows(document.recipes); + const actualIds = recipes.map((recipe) => String(recipe.id ?? "")).sort(); + if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) { + violations.push("RECIPE_ID_SET"); + } + if (new Set(actualIds).size !== actualIds.length) { + violations.push("RECIPE_ID_DUPLICATE"); + } + + for (const recipe of recipes) { + const id = typeof recipe.id === "string" ? recipe.id : "unknown"; + if (recipe.status !== "RECIPE_AVAILABLE") { + violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`); + } + for (const field of [ + "trigger", + "boundary", + "port", + "fake", + "owner", + "fallback", + "serverStatePolicy", + ] as const) { + const value = recipe[field]; + if (typeof value !== "string" || value.trim().length === 0) { + violations.push(`${id}:MISSING_${field.toUpperCase()}`); + } + } + for (const field of [ + "forbiddenWhen", + "failureKinds", + "securityPrivacy", + "removal", + ] as const) { + if (!nonEmptyStrings(recipe[field])) { + violations.push(`${id}:MISSING_${field.toUpperCase()}`); + } + } + if ( + typeof recipe.bundleBudgetGzipBytes !== "number" || + !Number.isInteger(recipe.bundleBudgetGzipBytes) || + recipe.bundleBudgetGzipBytes < 1 + ) { + violations.push(`${id}:INVALID_BUNDLE_BUDGET`); + } + if (recipe.owner === "frontend-platform") { + violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`); + } + if (lifecycleRecipes.has(id) && !nonEmptyStrings(recipe.lifecycleMethods)) { + violations.push(`${id}:CLEANUP_CONTRACT_MISSING`); + } + if ( + id === "client-workflow" && + recipe.serverStatePolicy !== "reference-only" + ) { + violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`); + } + } + + const dependencies = { + ...packageVersions(packageDocument.dependencies), + ...packageVersions(packageDocument.devDependencies), + }; + const packageScripts = packageVersions(packageDocument.scripts); + for (const recipe of recipes) { + if (recipe.referenceRuntime === undefined) continue; + const runtime = recordValue(recipe.referenceRuntime); + const id = typeof recipe.id === "string" ? recipe.id : "unknown"; + if ( + runtime.status !== "AVAILABLE_NOT_COMPOSED" || + runtime.productionComposition !== false + ) { + violations.push(`${id}:REFERENCE_RUNTIME_COMPOSITION`); + } + if (!nonEmptyStrings(runtime.sourceRoots)) { + violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_ROOTS`); + } else if ( + runtime.sourceRoots.some( + (sourceRoot) => + !sourceRoot.startsWith("src/") || + sourceRoot.includes("\\") || + sourceRoot.split("/").includes(".."), + ) + ) { + violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_BOUNDARY`); + } + if (!nonEmptyStrings(runtime.coveredCapabilities)) { + violations.push(`${id}:REFERENCE_RUNTIME_CAPABILITIES`); + } + if (!nonEmptyStrings(runtime.conformanceScripts)) { + violations.push(`${id}:REFERENCE_RUNTIME_CONFORMANCE`); + } else { + for (const script of runtime.conformanceScripts) { + if (!(script in packageScripts)) { + violations.push(`${id}:UNKNOWN_CONFORMANCE_SCRIPT:${script}`); + } + } + } + } + const vendorPatterns = Array.isArray(document.vendorPackagePatterns) + ? document.vendorPackagePatterns.filter( + (entry): entry is string => typeof entry === "string", + ) + : []; + for (const pattern of vendorPatterns) { + const wildcard = pattern.endsWith("*"); + const prefix = pattern.replace(/\/?\*$/, ""); + if ( + Object.keys(dependencies).some( + (dependency) => + dependency === prefix || + dependency.startsWith(`${prefix}/`) || + (wildcard && dependency.startsWith(prefix)), + ) + ) { + violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`); + } + } + return violations; +} + +export async function sourceFiles(directory: string): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error: unknown) { + if ( + isRecord(error) && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } + const groups: string[][] = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() + ? sourceFiles(target) + : /\.(?:[cm]?[jt]s|[jt]sx)$/.test(entry.name) + ? [target] + : []; + }), + ); + return groups.flat(); +} + +export async function scanOptionalRecipeSources( + root: string, + { scanProductionBoundary = true }: Readonly<{ + scanProductionBoundary?: boolean; + }> = {}, +): Promise { + const violations: OptionalRecipeSourceViolation[] = []; + for (const file of await sourceFiles(root)) { + const relative = path.relative(process.cwd(), file).replaceAll("\\", "/"); + const relativeToRoot = path.relative(root, file).replaceAll("\\", "/"); + const content = await readFile(file, "utf8"); + const imports = [ + ...content.matchAll(/(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g), + ] + .map((match) => match[1]) + .filter((specifier): specifier is string => specifier !== undefined); + + if ( + scanProductionBoundary && + (relativeToRoot.startsWith("src/") || + (path.basename(path.resolve(root)) === "src" && + !relativeToRoot.startsWith(".."))) && + imports.some((specifier) => + /(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier), + ) + ) { + violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative }); + } + + const productionRelative = + path.basename(path.resolve(root)) === "src" + ? `src/${relativeToRoot}` + : relativeToRoot; + const isCompositionSource = + /(?:^|\/)src\/bootstrap\//.test(productionRelative) || + /(?:^|\/)src\/features\/installed-feature-(?:adapters|runtimes)\./.test( + productionRelative, + ); + if ( + scanProductionBoundary && + isCompositionSource && + (imports.some((specifier) => + /(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|$)/.test( + specifier, + ), + ) || + /["'][^"'\r\n]*(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|["'])/u.test( + content, + )) + ) { + violations.push({ + ruleId: "REFERENCE_RUNTIME_COMPOSED_WITHOUT_SELECTION", + path: relative, + }); + } + + const localVendorAdapter = + relative.includes("recipes/") && relative.includes("/adapters/"); + if ( + !localVendorAdapter && + imports.some((specifier) => + /^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test( + specifier, + ), + ) + ) { + violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative }); + } + + if ( + /localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test( + content, + ) || + /searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test( + content, + ) || + /(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test( + content, + ) + ) { + violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative }); + } + + if ( + /(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test( + content, + ) + ) { + violations.push({ + ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE", + path: relative, + }); + } + } + return violations; +} + +export async function scanProductionBundle( + distRoot: string, +): Promise { + const violations: string[] = []; + const forbiddenRuntimeMarkers = [ + "frontend-optional-recipe-must-not-reach-production", + "Browser file runtime hard limits are invalid.", + "Object URL allocation failed", + "Storage pressure policy is invalid.", + "IndexedDB runtime configuration is invalid.", + "Invalid IndexedDB schema migration.", + "OPFS runtime policy is invalid.", + "OPFS operation failed.", + "Public Cache Storage policy is invalid.", + "Public cache validation failed.", + "Presigned capability vault limit is invalid.", + "Resumable upload policy is invalid.", + "Image CDN policy registry is invalid.", + ] as const; + for (const file of await sourceFiles(distRoot)) { + const content = await readFile(file, "utf8"); + if (forbiddenRuntimeMarkers.some((marker) => content.includes(marker))) { + violations.push(path.relative(process.cwd(), file)); + } + } + + const viteManifestPath = path.join(distRoot, ".vite/manifest.json"); + const emittedModuleInventoryPath = path.join( + distRoot, + ".vite/module-inventory.json", + ); + const moduleInventoryCandidates = [ + emittedModuleInventoryPath, + ...(path.resolve(distRoot) === path.resolve("dist") + ? ["artifacts/quality/vite-module-inventory.json"] + : []), + ]; + const viteManifestExists = await readFile(viteManifestPath, "utf8") + .then(() => true) + .catch((error: unknown) => { + if (isRecord(error) && error.code === "ENOENT") return false; + throw error; + }); + if (!viteManifestExists) return [...new Set(violations)]; + + let inventory: unknown; + let moduleInventoryPath = emittedModuleInventoryPath; + for (const candidate of moduleInventoryCandidates) { + try { + inventory = JSON.parse(await readFile(candidate, "utf8")); + moduleInventoryPath = candidate; + break; + } catch { + // A generated build may move the inventory out of the deploy directory. + } + } + if (inventory === undefined) { + violations.push( + path.relative(process.cwd(), moduleInventoryPath), + ); + return [...new Set(violations)]; + } + const inventoryDocument = recordValue(inventory); + const chunks = recordRows(inventoryDocument.chunks); + if ( + inventoryDocument.schemaVersion !== 1 || + !Array.isArray(inventoryDocument.chunks) || + chunks.length !== inventoryDocument.chunks.length + ) { + violations.push(path.relative(process.cwd(), moduleInventoryPath)); + return [...new Set(violations)]; + } + + const forbiddenSourcePrefixes = [ + "src/application/ports/browser-file-storage/", + "src/application/ports/browser-transfer/", + "src/adapters/browser-file-storage/", + "src/adapters/browser-files/", + "src/adapters/browser-transfer/", + "src/adapters/cache-storage/", + "src/adapters/storage/indexeddb/", + "src/adapters/storage/opfs/", + ] as const; + for (const chunk of chunks) { + if ( + typeof chunk.fileName !== "string" || + !Array.isArray(chunk.modules) || + chunk.modules.some((moduleId) => typeof moduleId !== "string") + ) { + violations.push(path.relative(process.cwd(), moduleInventoryPath)); + continue; + } + for (const moduleId of chunk.modules as string[]) { + if ( + forbiddenSourcePrefixes.some((prefix) => + moduleId.startsWith(prefix), + ) + ) { + violations.push(`${chunk.fileName}:${moduleId}`); + } + } + } + return [...new Set(violations)]; +} diff --git a/scripts/lib/package-script-graph.ts b/scripts/lib/package-script-graph.ts new file mode 100644 index 0000000..9ee1ca5 --- /dev/null +++ b/scripts/lib/package-script-graph.ts @@ -0,0 +1,994 @@ +type PackageManager = "pnpm" | "npm" | "yarn"; + +type ManagerParseResult = Readonly<{ + dependencies: readonly string[]; + unsafeLifecycle: boolean; + unsupportedManagerSyntax: boolean; +}>; + +type SuppressionState = { + effective: boolean | undefined; + contradictory: boolean; + malformed: boolean; +}; + +type ShellNpmScopeEnvironmentState = { + autoExport: boolean; + forbidden: boolean; + uncertain: boolean; +}; + +type ShellCommandPrefix = Readonly<{ + assignments: readonly Readonly<{ + dynamicName: boolean; + name: string | null; + }>[]; + commandIndex: number; + uncertain: boolean; +}>; + +type EnvironmentCommandPrefix = Readonly<{ + assignmentNames: readonly string[]; + uncertain: boolean; +}>; + +type TokenizedShellSegment = Readonly<{ + tokens: readonly string[]; + expansionTokens: readonly boolean[]; +}>; + +const managerNames = new Set(["pnpm", "npm", "yarn"]); +const managerOptionsWithValue = new Set([ + "-C", "--cache", "--cache-folder", "--config-dir", "--cwd", "--dir", "--filter", + "--global-dir", "--globalconfig", "--home", "--lockfile-dir", "--mutex", "--prefix", + "--registry", "--store-dir", "--userconfig", "--workspace", "--workspace-dir", +]); +const managerBooleanOptions = new Set([ + "--color", "--global", "--no-color", "--offline", "--prefer-offline", "--silent", + "--use-stderr", "--verbose", "-g", "-s", +]); +const npmScriptDispatchBooleanOptions = new Set([ + "--foreground-scripts", "--if-present", "--ignore-scripts", +]); +const npmScriptDispatchScopeOptions = new Set([ + "--prefix", "--workspace", "--workspaces", +]); +const npmDispatchScopeEnvironmentNames = new Set([ + "npm_config_globalconfig", "npm_config_prefix", "npm_config_userconfig", + "npm_config_workspace", "npm_config_workspaces", +]); +const npmIndirectConfigAuthorityOptions = new Set([ + "--globalconfig", "--userconfig", +]); +const manifestScopeOptions: Readonly>> = { + pnpm: new Set(["-C", "--dir", "--filter", "--workspace-dir"]), + npm: new Set(["--prefix", "--workspace"]), + yarn: new Set(["--cwd"]), +}; +const lifecycleMutationCommands = new Set([ + "add", "ci", "dedupe", "i", "install", "link", "pack", "prune", "publish", + "rebuild", "remove", "rm", "uninstall", "unlink", "up", "update", "upgrade", +]); +const managerBuiltinAliases: Readonly< + Record> +> = { + pnpm: new Map([["ln", "link"]]), + npm: new Map(), + yarn: new Map(), +}; +const unsupportedBuiltinDispatchers: Readonly< + Record> +> = { + pnpm: new Set(["dlx", "exec"]), + npm: new Set(["exec"]), + yarn: new Set(["dlx", "exec", "workspace", "workspaces"]), +}; +const lifecycleBooleanOptions: Readonly< + Record> +> = { + pnpm: new Set([ + "--dry-run", "--force", "--frozen-lockfile", "--lockfile-only", + "--no-optional", "--prefer-frozen-lockfile", "--recursive", + "--workspace-root", "-D", "-P", "-r", "-w", + ]), + npm: new Set([ + "--audit", "--dry-run", "--force", "--foreground-scripts", "--fund", + "--package-lock-only", + ]), + yarn: new Set([ + "--check-cache", "--frozen-lockfile", "--ignore-engines", + "--ignore-optional", "--immutable", "--immutable-cache", "--inline-builds", + "--no-lockfile", "--non-interactive", "--pure-lockfile", + ]), +}; +const lifecycleOptionsWithValue: Readonly< + Record> +> = { + pnpm: new Set(["--child-concurrency", "--modules-dir", "--reporter"]), + npm: new Set(["--include", "--install-strategy", "--omit"]), + yarn: new Set(["--mode", "--modules-folder", "--production"]), +}; +const knownBuiltinCommands: Readonly>> = { + pnpm: new Set([ + "audit", "config", "deploy", "dlx", "exec", "fetch", "help", "list", "ls", + "outdated", "root", "server", "setup", "store", "view", "why", + ]), + npm: new Set([ + "access", "audit", "bugs", "cache", "completion", "config", "diff", "docs", + "doctor", "exec", "explore", "fund", "help", "help-search", "hook", "init", + "list", "login", "logout", "ls", "org", "outdated", "owner", "ping", "pkg", + "prefix", "profile", "query", "repo", "root", "search", "star", "stars", + "team", "token", "unstar", "version", "view", "whoami", + ]), + yarn: new Set([ + "cache", "config", "constraints", "dedupe", "dlx", "exec", "help", "info", + "npm", "plugin", "set", "stage", "version", "why", + ]), +}; +const exactBareSafeBuiltinCommands: Readonly< + Record> +> = { + pnpm: new Set(["audit"]), + npm: new Set(["audit"]), + yarn: new Set(), +}; +const npmImplicitScripts = new Set(["restart", "start", "stop", "test"]); + +export function validatePackageScriptGraph( + scripts: Readonly>, + entryScript: string, +): string[] { + const failures: string[] = []; + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + + const visit = (scriptName: string): void => { + if (visiting.has(scriptName)) { + const start = stack.indexOf(scriptName); + failures.push(`package script cycle: ${[...stack.slice(start), scriptName].join(" -> ")}`); + return; + } + if (visited.has(scriptName)) return; + const command = scripts[scriptName]; + if (command === undefined) { + failures.push(`package script missing: ${scriptName}`); + return; + } + visiting.add(scriptName); + stack.push(scriptName); + if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) { + failures.push(`${scriptName} must not invoke the CI gate runner`); + } + const parsed = parseManagerCommands(command, scripts); + if (parsed.unsupportedManagerSyntax) { + failures.push(`package script manager invocation is not safely parseable: ${scriptName}`); + } + for (const dependency of parsed.dependencies) { + if (dependency === "ci:gate") { + failures.push(`${scriptName} must not invoke ci:gate`); + } else if (!(dependency in scripts)) { + failures.push(`package script missing: ${scriptName} -> ${dependency}`); + } else { + visit(dependency); + } + } + stack.pop(); + visiting.delete(scriptName); + visited.add(scriptName); + }; + + visit(entryScript); + return [...new Set(failures)]; +} + +export function validateInstallScriptPolicy( + scripts: Readonly>, + entryScripts: readonly string[], +): string[] { + const failures: string[] = []; + const visited = new Set(); + const visit = (scriptName: string): void => { + if (visited.has(scriptName)) return; + visited.add(scriptName); + const command = scripts[scriptName]; + if (command === undefined) { + failures.push(`package script missing: ${scriptName}`); + return; + } + const parsed = parseManagerCommands(command, scripts); + if (parsed.unsafeLifecycle || parsed.unsupportedManagerSyntax) { + failures.push( + `install-bearing package script must use --ignore-scripts: ${scriptName}`, + ); + } + for (const dependency of parsed.dependencies) { + if (dependency !== "ci:gate") visit(dependency); + } + }; + for (const entryScript of entryScripts) visit(entryScript); + return [...new Set(failures)]; +} + +export function validateNpmScopeEnvironment( + environment: Readonly>, +): string[] { + return Object.keys(environment) + .filter((name) => npmDispatchScopeEnvironmentNames.has(name.toLowerCase())) + .map((name) => `npm scope environment is not allowed: ${name}`); +} + +function parseManagerCommands( + command: string, + scripts: Readonly>, +): ManagerParseResult { + const tokenized = tokenizeShellSegments(command); + const dependencies: string[] = []; + let unsafeLifecycle = false; + let unsupportedManagerSyntax = false; + if (!tokenized) { + return Object.freeze({ + dependencies: Object.freeze([]), + unsafeLifecycle: false, + unsupportedManagerSyntax: containsManagerReference(command), + }); + } + unsupportedManagerSyntax ||= tokenized.unsupportedControl && containsManagerReference(command); + const npmScopeEnvironmentState: ShellNpmScopeEnvironmentState = { + autoExport: false, + forbidden: false, + uncertain: false, + }; + for (const segment of tokenized.segments) { + const { tokens, expansionTokens } = segment; + updateShellNpmScopeEnvironmentState(segment, npmScopeEnvironmentState); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "corepack") { + if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) { + unsupportedManagerSyntax = true; + break; + } + const wrapped = tokens[index + 1]; + if (!wrapped || !isPackageManager(wrapped)) { + unsupportedManagerSyntax = true; + break; + } + const parsed = parseManagerInvocation( + wrapped, + tokens, + expansionTokens, + index + 2, + scripts, + hasUnsafeNpmScopeEnvironment( + npmScopeEnvironmentState, + tokens, + expansionTokens, + index, + ), + ); + dependencies.push(...parsed.dependencies); + unsafeLifecycle ||= parsed.unsafeLifecycle; + unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax; + break; + } + if (isPackageManager(token)) { + if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) { + unsupportedManagerSyntax = true; + break; + } + const parsed = parseManagerInvocation( + token, + tokens, + expansionTokens, + index + 1, + scripts, + hasUnsafeNpmScopeEnvironment( + npmScopeEnvironmentState, + tokens, + expansionTokens, + index, + ), + ); + dependencies.push(...parsed.dependencies); + unsafeLifecycle ||= parsed.unsafeLifecycle; + unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax; + break; + } + if (containsManagerReference(token)) { + unsupportedManagerSyntax = true; + break; + } + } + } + return Object.freeze({ + dependencies: Object.freeze([...new Set(dependencies)]), + unsafeLifecycle, + unsupportedManagerSyntax, + }); +} + +function parseManagerInvocation( + manager: PackageManager, + tokens: readonly string[], + expansionTokens: readonly boolean[], + start: number, + scripts: Readonly>, + hasNpmScopeEnvironment: boolean, +): ManagerParseResult { + let cursor = start; + let changesManifestScope = false; + let hasNpmConfigAuthority = false; + let consumedManagerSyntax = false; + const suppression: SuppressionState = { + effective: undefined, + contradictory: false, + malformed: false, + }; + while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) { + consumedManagerSyntax = true; + const option = tokens[cursor]!; + const parsedSuppression = consumeSuppressionOption( + manager, + tokens, + cursor, + suppression, + ); + if (parsedSuppression.recognized) { + if (parsedSuppression.unsupported) return unsupportedResult(); + cursor = parsedSuppression.nextIndex; + continue; + } + const equals = option.indexOf("="); + const name = equals < 0 ? option : option.slice(0, equals); + if (managerOptionsWithValue.has(name)) { + changesManifestScope ||= manifestScopeOptions[manager].has(name); + hasNpmConfigAuthority ||= + manager === "npm" && npmIndirectConfigAuthorityOptions.has(name); + if (equals >= 0) { + if (option.slice(equals + 1).length === 0) return unsupportedResult(); + } else { + cursor += 1; + if (cursor >= tokens.length || tokens[cursor]!.startsWith("-")) { + return unsupportedResult(); + } + } + } else if (managerBooleanOptions.has(name)) { + if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) { + return unsupportedResult(); + } + } else if (option !== "--") { + return unsupportedResult(); + } + cursor += 1; + } + const subcommand = tokens[cursor]; + if (!subcommand) return unsupportedResult(); + if (manager === "npm" && (hasNpmScopeEnvironment || hasNpmConfigAuthority)) { + return unsupportedResult(); + } + const argumentsAfterCommand = tokens.slice(cursor + 1); + if (subcommand === "run" || subcommand === "run-script") { + const dependency = argumentsAfterCommand[0]; + if (!dependency || dependency.startsWith("-")) return unsupportedResult(); + if (changesManifestScope) return unsupportedResult(); + if ( + manager === "npm" && + (expansionTokens.slice(start, cursor + 2).some(Boolean) || + !areNpmScriptDispatchArgumentsSupported( + argumentsAfterCommand.slice(1), + expansionTokens.slice(cursor + 2), + suppression, + )) + ) { + return unsupportedResult(); + } + return manager === "npm" + ? npmScriptDependencyResult(dependency, scripts, suppression) + : dependencyResult(dependency); + } + const canonicalSubcommand = managerBuiltinAliases[manager].get(subcommand) ?? subcommand; + if (unsupportedBuiltinDispatchers[manager].has(canonicalSubcommand)) { + return unsupportedResult(); + } + if (lifecycleMutationCommands.has(canonicalSubcommand)) { + const lifecycleArgumentsSupported = parseLifecycleArguments( + manager, + argumentsAfterCommand, + suppression, + ); + return Object.freeze({ + dependencies: Object.freeze([]), + unsafeLifecycle: + !lifecycleArgumentsSupported || !hasEffectiveLifecycleSuppression(suppression), + unsupportedManagerSyntax: !lifecycleArgumentsSupported, + }); + } + if (knownBuiltinCommands[manager].has(canonicalSubcommand)) { + return exactBareSafeBuiltinCommands[manager].has(canonicalSubcommand) && + !consumedManagerSyntax && + argumentsAfterCommand.length === 0 + ? emptyResult() + : unsupportedResult(); + } + const isKnownRootScript = Object.prototype.hasOwnProperty.call(scripts, subcommand); + const supportsImplicit = /^[A-Za-z0-9:_-]+$/u.test(subcommand) && ( + ((manager === "pnpm" || manager === "yarn") && isKnownRootScript) || + (manager === "npm" && npmImplicitScripts.has(subcommand)) + ); + if (supportsImplicit) { + if (changesManifestScope) return unsupportedResult(); + if ( + manager === "npm" && + (expansionTokens.slice(start, cursor + 1).some(Boolean) || + !areNpmScriptDispatchArgumentsSupported( + argumentsAfterCommand, + expansionTokens.slice(cursor + 1), + suppression, + )) + ) { + return unsupportedResult(); + } + return manager === "npm" + ? npmScriptDependencyResult(subcommand, scripts, suppression) + : dependencyResult(subcommand); + } + return unsupportedResult(); +} + +function areNpmScriptDispatchArgumentsSupported( + tokens: readonly string[], + expansionTokens: readonly boolean[], + suppression: SuppressionState, +): boolean { + let index = 0; + while (index < tokens.length) { + const token = tokens[index]!; + if (token === "--") return true; + if (expansionTokens[index]) return false; + if (!token.startsWith("-")) { + index += 1; + continue; + } + + const parsedSuppression = consumeSuppressionOption( + "npm", + tokens, + index, + suppression, + ); + if (parsedSuppression.recognized) { + if (parsedSuppression.unsupported) return false; + index = parsedSuppression.nextIndex; + continue; + } + + const equals = token.indexOf("="); + const name = equals < 0 ? token : token.slice(0, equals); + const isShortWorkspaceOption = token === "-w" || /^-w(?:=)?.+/u.test(token); + if (npmScriptDispatchScopeOptions.has(name) || isShortWorkspaceOption) { + return false; + } + if (!npmScriptDispatchBooleanOptions.has(name)) return false; + if (equals >= 0 && !/^(?:true|false)$/u.test(token.slice(equals + 1))) { + return false; + } + index += 1; + } + return true; +} + +function hasUnsafeNpmScopeEnvironment( + state: Readonly, + tokens: readonly string[], + expansionTokens: readonly boolean[], + commandIndex: number, +): boolean { + return state.forbidden || state.uncertain || + hasUnsafeImmediateNpmScopeEnvironment(tokens, expansionTokens, commandIndex); +} + +function hasUnsafeManagerCommandPrefix( + tokens: readonly string[], + expansionTokens: readonly boolean[], + commandIndex: number, +): boolean { + const prefix = parseShellCommandPrefix(tokens, expansionTokens); + if (prefix.uncertain) return true; + let cursor = prefix.commandIndex; + if (cursor === commandIndex) return false; + if (cursor > commandIndex || + !isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) { + return true; + } + return parseEnvironmentCommandPrefix( + tokens, + expansionTokens, + cursor, + commandIndex, + ).uncertain; +} + +function hasUnsafeImmediateNpmScopeEnvironment( + tokens: readonly string[], + expansionTokens: readonly boolean[], + commandIndex: number, +): boolean { + const prefix = parseShellCommandPrefix(tokens, expansionTokens); + if (prefix.uncertain || prefix.assignments.some( + ({ name }) => name !== null && isNpmScopeEnvironmentName(name), + )) return true; + let cursor = prefix.commandIndex; + if (cursor === commandIndex) return false; + if (!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) return false; + const environmentPrefix = parseEnvironmentCommandPrefix( + tokens, + expansionTokens, + cursor, + commandIndex, + ); + return environmentPrefix.uncertain || environmentPrefix.assignmentNames.some( + (name) => isNpmScopeEnvironmentName(name), + ); +} + +function parseEnvironmentCommandPrefix( + tokens: readonly string[], + expansionTokens: readonly boolean[], + start: number, + commandIndex: number, +): EnvironmentCommandPrefix { + const assignmentNames: string[] = []; + let cursor = start + 1; + let uncertain = false; + + while (cursor < commandIndex && tokens[cursor]!.startsWith("-")) { + const option = tokens[cursor]!; + if (expansionTokens[cursor]) uncertain = true; + if (option === "--") { + cursor += 1; + break; + } + if (option === "-i" || option === "--ignore-environment") { + cursor += 1; + continue; + } + if (option === "-u" || option === "--unset") { + cursor += 1; + if (cursor >= commandIndex || tokens[cursor]!.startsWith("-")) { + uncertain = true; + break; + } + uncertain ||= expansionTokens[cursor] ?? false; + cursor += 1; + continue; + } + if (/^--unset=.+/u.test(option)) { + cursor += 1; + continue; + } + uncertain = true; + cursor += 1; + } + + while (cursor < commandIndex) { + const token = tokens[cursor]!; + if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false)) { + uncertain = true; + } + const assignmentName = parseEnvironmentAssignmentName(token); + if (assignmentName) assignmentNames.push(assignmentName); + else uncertain = true; + cursor += 1; + } + + return Object.freeze({ + assignmentNames: Object.freeze(assignmentNames), + uncertain, + }); +} + +function parseShellCommandPrefix( + tokens: readonly string[], + expansionTokens: readonly boolean[], +): ShellCommandPrefix { + const assignments: Array<{ + dynamicName: boolean; + name: string | null; + }> = []; + let cursor = 0; + let uncertain = false; + while (cursor < tokens.length) { + const token = tokens[cursor]!; + const name = parseAssignmentName(token); + const dynamicName = hasDynamicAssignmentName( + token, + expansionTokens[cursor] ?? false, + ); + if (!name && !dynamicName) break; + assignments.push({ dynamicName, name }); + uncertain ||= dynamicName; + cursor += 1; + } + + while (cursor < tokens.length) { + const wrapper = tokens[cursor]; + if (expansionTokens[cursor]) { + uncertain = true; + break; + } + if (wrapper !== "command" && wrapper !== "exec") break; + cursor += 1; + while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) { + const option = tokens[cursor]!; + if (option === "--") { + cursor += 1; + break; + } + if (wrapper === "command" && option === "-p") { + cursor += 1; + continue; + } + uncertain = true; + cursor += 1; + if (wrapper === "exec" && option === "-a" && cursor < tokens.length) { + cursor += 1; + } + } + } + if (expansionTokens[cursor]) uncertain = true; + return Object.freeze({ + assignments: Object.freeze(assignments.map((assignment) => Object.freeze(assignment))), + commandIndex: cursor, + uncertain, + }); +} + +function updateShellNpmScopeEnvironmentState( + segment: TokenizedShellSegment, + state: ShellNpmScopeEnvironmentState, +): void { + const { tokens, expansionTokens } = segment; + const prefix = parseShellCommandPrefix(tokens, expansionTokens); + state.uncertain ||= prefix.uncertain; + const command = tokens[prefix.commandIndex]; + if (!command) { + for (const assignment of prefix.assignments) { + if (state.autoExport && assignment.name && + isNpmScopeEnvironmentName(assignment.name)) { + state.forbidden = true; + } + } + return; + } + + if (command === "eval" || command === "." || command === "source") { + state.uncertain = true; + return; + } + if (command === "unset" || command === "typeset" || command === "declare" || + command === "local" || command === "readonly") { + state.uncertain = true; + return; + } + if (command === "set") { + if (tokens.slice(prefix.commandIndex + 1).includes("-a")) state.autoExport = true; + if (tokens.slice(prefix.commandIndex + 1).includes("+a")) { + state.autoExport = false; + state.uncertain = true; + } + return; + } + if (command === "export") { + let cursor = prefix.commandIndex + 1; + for (; cursor < tokens.length; cursor += 1) { + const token = tokens[cursor]!; + if (token === "--") continue; + if (token === "-n" || token.startsWith("-")) { + state.uncertain = true; + continue; + } + const assignmentName = parseAssignmentName(token); + const bareName = /^[A-Za-z_][A-Za-z0-9_]*$/u.test(token) ? token : null; + if (assignmentName || bareName) { + if (isNpmScopeEnvironmentName(assignmentName ?? bareName!)) { + state.forbidden = true; + } + } else if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false) || + expansionTokens[cursor]) { + state.uncertain = true; + } + } + return; + } +} + +function isEnvironmentCommand(token: string | undefined, hasExpansion: boolean): boolean { + if (!token || hasExpansion) return false; + return token.split("/").at(-1) === "env"; +} + +function parseEnvironmentAssignmentName(token: string): string | null { + const equals = token.indexOf("="); + return equals > 0 ? token.slice(0, equals) : null; +} + +function isNpmScopeEnvironmentName(name: string): boolean { + return npmDispatchScopeEnvironmentNames.has(name.toLowerCase()); +} + +function hasDynamicAssignmentName(token: string, hasExpansion: boolean): boolean { + const equals = token.indexOf("="); + return hasExpansion && equals > 0 && parseAssignmentName(token) === null; +} + +function parseAssignmentName(token: string): string | null { + return /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(token)?.[1] ?? null; +} + +function parseLifecycleArguments( + manager: PackageManager, + tokens: readonly string[], + suppression: SuppressionState, +): boolean { + let cursor = 0; + while (cursor < tokens.length) { + const token = tokens[cursor]!; + if (!token.startsWith("-")) { + cursor += 1; + continue; + } + const parsedSuppression = consumeSuppressionOption( + manager, + tokens, + cursor, + suppression, + ); + if (parsedSuppression.recognized) { + if (parsedSuppression.unsupported) return false; + cursor = parsedSuppression.nextIndex; + continue; + } + const parsedOption = consumeAllowedLifecycleOption(manager, tokens, cursor); + if (parsedOption === null) return false; + cursor = parsedOption; + } + return true; +} + +function consumeSuppressionOption( + manager: PackageManager, + tokens: readonly string[], + index: number, + state: SuppressionState, +): Readonly<{ recognized: boolean; unsupported: boolean; nextIndex: number }> { + const token = tokens[index]!; + if (token === "--no-ignore-scripts") { + recordSuppression(state, false); + return { recognized: true, unsupported: false, nextIndex: index + 1 }; + } + const equalsForms = ["--ignore-scripts=", "--config.ignore-scripts="] as const; + for (const prefix of equalsForms) { + if (!token.startsWith(prefix)) continue; + if (prefix.startsWith("--config.") && manager !== "pnpm") { + state.malformed = true; + return { recognized: true, unsupported: true, nextIndex: index + 1 }; + } + const raw = token.slice(prefix.length); + if (raw !== "true" && raw !== "false") { + state.malformed = true; + return { recognized: true, unsupported: true, nextIndex: index + 1 }; + } + recordSuppression(state, raw === "true"); + return { recognized: true, unsupported: false, nextIndex: index + 1 }; + } + if (token !== "--ignore-scripts" && token !== "--config.ignore-scripts") { + return { recognized: false, unsupported: false, nextIndex: index }; + } + if (token === "--config.ignore-scripts" && manager !== "pnpm") { + state.malformed = true; + return { recognized: true, unsupported: true, nextIndex: index + 1 }; + } + const next = tokens[index + 1]; + if (next === "true" || next === "false") { + const supportsSplitValue = manager === "npm" || manager === "pnpm"; + if (!supportsSplitValue) { + state.malformed = true; + return { recognized: true, unsupported: true, nextIndex: index + 2 }; + } + recordSuppression(state, next === "true"); + return { recognized: true, unsupported: false, nextIndex: index + 2 }; + } + recordSuppression(state, true); + return { recognized: true, unsupported: false, nextIndex: index + 1 }; +} + +function recordSuppression( + state: SuppressionState, + value: boolean, +): void { + if (state.effective !== undefined && state.effective !== value) { + state.contradictory = true; + } + state.effective = value; +} + +function hasEffectiveLifecycleSuppression(state: SuppressionState): boolean { + return state.effective === true && !state.contradictory && !state.malformed; +} + +function consumeAllowedLifecycleOption( + manager: PackageManager, + tokens: readonly string[], + index: number, +): number | null { + const option = tokens[index]!; + const equals = option.indexOf("="); + const name = equals < 0 ? option : option.slice(0, equals); + const booleanOption = + managerBooleanOptions.has(name) || lifecycleBooleanOptions[manager].has(name); + if (booleanOption) { + if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) return null; + return index + 1; + } + const valuedOption = + managerOptionsWithValue.has(name) || lifecycleOptionsWithValue[manager].has(name); + if (!valuedOption) return null; + if (equals >= 0) return option.slice(equals + 1).length > 0 ? index + 1 : null; + const value = tokens[index + 1]; + if (!value || value.startsWith("-")) return null; + return index + 2; +} + +function dependencyResult(dependency: string): ManagerParseResult { + return dependenciesResult([dependency]); +} + +function npmScriptDependencyResult( + dependency: string, + scripts: Readonly>, + suppression: SuppressionState, +): ManagerParseResult { + if (hasEffectiveLifecycleSuppression(suppression)) { + return dependenciesResult([dependency]); + } + return dependenciesResult( + [`pre${dependency}`, dependency, `post${dependency}`] + .filter((scriptName) => scriptName === dependency || scriptName in scripts), + ); +} + +function dependenciesResult(dependencies: readonly string[]): ManagerParseResult { + return Object.freeze({ + dependencies: Object.freeze([...dependencies]), + unsafeLifecycle: false, + unsupportedManagerSyntax: false, + }); +} + +function emptyResult(): ManagerParseResult { + return Object.freeze({ + dependencies: Object.freeze([]), + unsafeLifecycle: false, + unsupportedManagerSyntax: false, + }); +} + +function unsupportedResult(): ManagerParseResult { + return Object.freeze({ + dependencies: Object.freeze([]), + unsafeLifecycle: false, + unsupportedManagerSyntax: true, + }); +} + +function isPackageManager(value: string): value is PackageManager { + return managerNames.has(value as PackageManager); +} + +function containsManagerReference(value: string): boolean { + return /(?:^|[^A-Za-z0-9_-])(?:corepack|pnpm|npm|yarn)(?:[^A-Za-z0-9_-]|$)/u + .test(value); +} + +function tokenizeShellSegments(command: string): Readonly<{ + segments: readonly TokenizedShellSegment[]; + unsupportedControl: boolean; +}> | null { + const segments: Array<{ tokens: string[]; expansionTokens: boolean[] }> = [ + { tokens: [], expansionTokens: [] }, + ]; + let token = ""; + let tokenHasExpansion = false; + let quote: "'" | '"' | null = null; + let escaping = false; + let unsupportedControl = false; + const pushToken = (): void => { + if (token.length > 0) { + segments.at(-1)!.tokens.push(token); + segments.at(-1)!.expansionTokens.push(tokenHasExpansion); + } + token = ""; + tokenHasExpansion = false; + }; + const pushSegment = (): void => { + pushToken(); + if (segments.at(-1)!.tokens.length > 0) { + segments.push({ tokens: [], expansionTokens: [] }); + } + }; + for (let index = 0; index < command.length; index += 1) { + const character = command[index]!; + if (escaping) { + token += character; + escaping = false; + continue; + } + if (character === "\\" && quote !== "'") { + escaping = true; + continue; + } + if (quote) { + if (character === quote) quote = null; + else { + if (quote === '"' && character === "$") tokenHasExpansion = true; + token += character; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if (character === "`" || (character === "$" && command[index + 1] === "(")) { + unsupportedControl = true; + if (character === "$") tokenHasExpansion = true; + token += character; + continue; + } + if (character === "$" || character === "*" || character === "?" || character === "[") { + tokenHasExpansion = true; + } + if (character === "#") { + unsupportedControl = true; + pushToken(); + while ( + index + 1 < command.length && + command[index + 1] !== "\n" && + command[index + 1] !== "\r" + ) { + index += 1; + } + continue; + } + if (character === "<" || character === ">" || character === "(" || character === ")") { + unsupportedControl = true; + token += character; + continue; + } + if (/\s/u.test(character)) { + pushToken(); + if (character === "\n" || character === "\r") pushSegment(); + continue; + } + if (character === ";" || character === "|" || character === "&") { + pushSegment(); + if (command[index + 1] === character) index += 1; + continue; + } + token += character; + } + if (quote || escaping) return null; + pushToken(); + return Object.freeze({ + segments: Object.freeze( + segments + .filter((segment) => segment.tokens.length > 0) + .map((segment) => Object.freeze({ + tokens: Object.freeze(segment.tokens), + expansionTokens: Object.freeze(segment.expansionTokens), + })), + ), + unsupportedControl, + }); +} diff --git a/scripts/lib/promotion-stager.ts b/scripts/lib/promotion-stager.ts new file mode 100644 index 0000000..67fcebe --- /dev/null +++ b/scripts/lib/promotion-stager.ts @@ -0,0 +1,709 @@ +import { + createHash, + createPublicKey, + randomBytes as cryptoRandomBytes, +} from "node:crypto"; +import { constants } from "node:fs"; +import { + lstat, + mkdir, + open, + readdir, + rm, + rmdir, + stat, +} from "node:fs/promises"; +import path from "node:path"; + +import { + PROMOTED_FILE_NAMES, + type PromotedFileName, +} from "../contracts/promotion-artifacts.ts"; +import { + evaluatePromotionEvidence, + assertDistinctProviderTrust, + providerPublicKeyFingerprint, + providerVerificationArtifactSchema, + PROMOTION_VERIFIER_ID, + PROMOTION_VERIFIER_VERSION, + provenanceProviderAttestationSchema, + trustPolicySha256, + vulnerabilityProviderReportSchema, + type ProviderTrust, +} from "./provider-evidence.ts"; +import { verifyExactPromotionBundle } from "./exact-promotion-bundle.ts"; +import { + captureCiCandidateArchive, + withVerifiedCapturedCandidate, +} from "./ci-candidate-archive.ts"; +import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; +import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; + + +export type StagedFile = Readonly<{ + name: PromotedFileName; + bytes: Buffer; + sha256: string; +}>; + +export type FinalizedPromotion = Readonly<{ + stagingRoot: string; + cleanupToken: string; + runnerTempIdentity: Readonly<{ dev: number; ino: number }>; + stagingIdentity: Readonly<{ dev: number; ino: number }>; + files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[]; +}>; + +export async function finalizeVerifiedPromotion(input: Readonly<{ + repositoryRoot: string; + archivePath: string; + expectedArchiveSha256: string; + vulnerabilityReportPath: string; + provenanceAttestationPath: string; + vulnerabilityPublicKeyPath: string; + vulnerabilityKeyId: string; + provenancePublicKeyPath: string; + provenanceKeyId: string; + expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>; + vulnerabilityInvocationNonce: string; + provenanceInvocationNonce: string; + runnerTempRoot: string; +}>, dependencies: Readonly<{ + captureArchive?: typeof captureCiCandidateArchive; + nowEpochMs?: () => number; + randomBytes?: (bytes: number) => Buffer; + afterCapture?: () => Promise; + beforePublish?: () => Promise; + afterStagingWrite?: () => Promise; + afterFileWrite?: (name: PromotedFileName) => Promise; + beforeSeal?: () => Promise; + afterMkdirBeforeOpen?: (stagingRoot: string) => Promise; +}> = {}): Promise { + const root = path.resolve(input.repositoryRoot); + const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({ + archivePath: input.archivePath, + expectedSha256: input.expectedArchiveSha256, + }); + const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] = + await Promise.all([ + capture(root, input.vulnerabilityReportPath, 16_777_216), + capture(root, input.provenanceAttestationPath, 16_777_216), + capture(root, input.vulnerabilityPublicKeyPath, 1_048_576), + capture(root, input.provenancePublicKeyPath, 1_048_576), + ]); + await dependencies.afterCapture?.(); + + const vulnerabilityTrust = capturedTrust( + input.vulnerabilityKeyId, + vulnerabilityKeyBytes, + ); + const provenanceTrust = capturedTrust( + input.provenanceKeyId, + provenanceKeyBytes, + ); + assertDistinctProviderTrust({ vulnerabilityTrust, provenanceTrust }); + const vulnerabilityReport = vulnerabilityProviderReportSchema.parse( + parseJson(vulnerabilityBytes), + ); + const provenanceAttestation = provenanceProviderAttestationSchema.parse( + parseJson(provenanceBytes), + ); + const nowEpochMs = dependencies.nowEpochMs ?? Date.now; + + const generated = await withVerifiedCapturedCandidate({ + captured: capturedArchive, + verify: async ({ extractionRoot, manifest }) => { + const local = await verifyArchivedLocalEvidence({ + extractionRoot, + expectedManifest: manifest, + }); + if (local.status !== "PASS" || !local.identity) { + throw new Error( + `captured local evidence failed final verification: ${local.failures.join(", ")}`, + ); + } + if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) { + throw new Error("captured source revision differs from expected promotion revision"); + } + const expected = { + run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt }, + source: { + revision: local.identity.sourceRevision, + sourceSetSha256: local.identity.sourceSetSha256, + }, + candidate: { + archiveSha256: capturedArchive.archiveSha256, + bundleSha256: manifest.bundleSha256, + distSha256: manifest.distSha256, + lockfileSha256: manifest.lockfileSha256, + }, + secretScanAttestation: { + status: "PASS" as const, + localEvidenceAssessmentSha256: local.identity.assessmentSha256, + sourceSetSha256: local.identity.sourceSetSha256, + policySha256: local.identity.secretScan.policySha256, + sarifSha256: local.identity.secretScan.sarifSha256, + scanInputSha256: local.identity.secretScan.scanInputSha256, + }, + vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce, + provenanceInvocationNonce: input.provenanceInvocationNonce, + } as const; + const reevaluated = evaluatePromotionEvidence({ + expected, + localStatus: local.status, + vulnerabilityReport, + provenanceAttestation, + vulnerabilityTrust, + provenanceTrust, + nowEpochMs, + }); + if (reevaluated.status !== "PASS") { + throw new Error( + `captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`, + ); + } + const providerEvidence = { + vulnerabilityReportSha256: sha256(vulnerabilityBytes), + provenanceAttestationSha256: sha256(provenanceBytes), + vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce, + provenanceInvocationNonce: input.provenanceInvocationNonce, + vulnerabilityKeyId: vulnerabilityTrust.keyId, + vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint, + provenanceKeyId: provenanceTrust.keyId, + provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint, + secretScanAttestation: expected.secretScanAttestation, + } as const; + const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust }); + const verifiedAt = new Date(nowEpochMs()).toISOString(); + const common = { + schemaVersion: 3 as const, + verifiedAt, + status: "PASS" as const, + verifier: { + id: PROMOTION_VERIFIER_ID, + version: PROMOTION_VERIFIER_VERSION, + }, + run: expected.run, + source: expected.source, + candidate: expected.candidate, + providerEvidence, + trustPolicySha256: trustDigest, + failures: [] as const, + }; + const providerRecord = providerVerificationArtifactSchema.parse({ + ...common, + artifactType: "provider-verification", + vulnerabilityStatus: reevaluated.vulnerabilityStatus, + provenanceAttestationStatus: reevaluated.provenanceAttestationStatus, + }); + const providerRecordBytes = canonicalJsonBytes(providerRecord); + const promotionRecord = providerVerificationArtifactSchema.parse({ + ...common, + artifactType: "promotion-verification", + localEvidenceStatus: local.status, + localEvidenceAssessmentSha256: local.identity.assessmentSha256, + providerVerificationSha256: sha256(providerRecordBytes), + }); + return Object.freeze({ + providerRecordBytes, + promotionRecordBytes: canonicalJsonBytes(promotionRecord), + exactExpected: Object.freeze({ + run: expected.run, + sourceRevision: expected.source.revision, + sourceSetSha256: expected.source.sourceSetSha256, + archiveSha256: expected.candidate.archiveSha256, + bundleSha256: expected.candidate.bundleSha256, + distSha256: expected.candidate.distSha256, + lockfileSha256: expected.candidate.lockfileSha256, + }), + }); + }, + }); + + const stagedFiles: readonly StagedFile[] = Object.freeze([ + staged("release-candidate.tar.gz", capturedArchive.bytes), + staged("vulnerability-report.json", vulnerabilityBytes), + staged("provenance-attestation.json", provenanceBytes), + staged("provider-verification.json", generated.providerRecordBytes), + staged("promotion-verification.json", generated.promotionRecordBytes), + ]); + if ( + JSON.stringify(stagedFiles.map(({ name }) => name)) !== + JSON.stringify(PROMOTED_FILE_NAMES) + ) { + throw new Error("promotion exact-five canonical file order drift"); + } + await dependencies.beforePublish?.(); + await verifyExactPromotionBundle( + Object.fromEntries(stagedFiles.map(({ name, bytes }) => [name, bytes])), + { + vulnerabilityTrust, + provenanceTrust, + expected: generated.exactExpected, + nowEpochMs, + }, + ); + return publishPrivatePromotionStaging( + input.runnerTempRoot, + input.expectedRun, + stagedFiles, + dependencies.randomBytes ?? cryptoRandomBytes, + dependencies.afterStagingWrite, + dependencies.afterFileWrite, + async (capturedFiles) => { + await verifyExactPromotionBundle( + capturedFiles, + { + vulnerabilityTrust, + provenanceTrust, + expected: generated.exactExpected, + nowEpochMs, + }, + ); + }, + dependencies.afterMkdirBeforeOpen, + dependencies.beforeSeal, + ); +} + +export const stageVerifiedPromotion = finalizeVerifiedPromotion; + +export async function cleanupFinalizedPromotion(input: Readonly<{ + runnerTempRoot: string; + stagingRoot: string; + cleanupToken: string; + runnerTempIdentity: Readonly<{ dev: number; ino: number }>; + stagingIdentity: Readonly<{ dev: number; ino: number }>; +}>, dependencies: Readonly<{ + beforeRemove?: () => Promise; +}> = {}): Promise { + const parent = path.resolve(input.runnerTempRoot); + const expected = path.join(parent, input.cleanupToken); + if ( + !/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) || + path.resolve(input.stagingRoot) !== expected || + !Number.isSafeInteger(input.runnerTempIdentity.dev) || + input.runnerTempIdentity.dev <= 0 || + !Number.isSafeInteger(input.runnerTempIdentity.ino) || + input.runnerTempIdentity.ino <= 0 + || !Number.isSafeInteger(input.stagingIdentity.dev) + || input.stagingIdentity.dev <= 0 + || !Number.isSafeInteger(input.stagingIdentity.ino) + || input.stagingIdentity.ino <= 0 + ) { + throw new TypeError("promotion cleanup root/token mismatch"); + } + const parentHandle = await open( + parent, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + const openedParent = await parentHandle.stat(); + assertRunnerTempIdentity(openedParent, input.runnerTempIdentity); + const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`; + const descriptorMetadata = await stat(descriptorRoot); + if (!descriptorMetadata.isDirectory()) { + throw new Error("descriptor-relative cleanup is unavailable"); + } + const descriptorExpected = path.join(descriptorRoot, input.cleanupToken); + let metadata; + try { + metadata = await lstat(descriptorExpected); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return; + throw error; + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new TypeError("promotion cleanup leaf is unsafe"); + } + assertStagingIdentity(metadata, input.stagingIdentity); + const stagingHandle = await open( + descriptorExpected, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`; + assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity); + const names = (await readdir(stagingDescriptorRoot)).sort(asciiCompare); + if ( + JSON.stringify(names) !== + JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare)) + ) { + throw new Error("promotion cleanup leaf does not contain the exact five files"); + } + await dependencies.beforeRemove?.(); + const visibleParent = await lstat(parent); + assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity); + for (const name of PROMOTED_FILE_NAMES) { + await rm(path.join(stagingDescriptorRoot, name), { force: false }); + } + assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity); + assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity); + await rmdir(descriptorExpected); + } finally { + await stagingHandle.close(); + } + const afterParent = await lstat(parent); + assertRunnerTempIdentity(afterParent, input.runnerTempIdentity); + } finally { + await parentHandle.close(); + } +} + +export async function publishPrivatePromotionStaging( + runnerTempRoot: string, + run: Readonly<{ id: string; attempt: number }>, + files: readonly StagedFile[], + randomBytes: (bytes: number) => Buffer, + afterStagingWrite?: () => Promise, + afterFileWrite?: (name: PromotedFileName) => Promise, + sealStagedFiles?: (files: Readonly>) => Promise, + afterMkdirBeforeOpen?: (stagingRoot: string) => Promise, + beforeSeal?: () => Promise, +): Promise { + if ( + JSON.stringify(files.map(({ name }) => name)) !== + JSON.stringify(PROMOTED_FILE_NAMES) || + files.some( + ({ bytes, sha256: digest }) => + !Buffer.isBuffer(bytes) || + !/^[a-f0-9]{64}$/u.test(digest) || + sha256(bytes) !== digest, + ) + ) { + throw new TypeError("private promotion staging requires the canonical exact-five bytes"); + } + const parentPath = path.resolve(runnerTempRoot); + const before = await lstat(parentPath); + if (!before.isDirectory() || before.isSymbolicLink()) { + throw new TypeError("runner temporary root must be a real directory"); + } + const parentHandle = await open( + parentPath, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const tokenBytes = randomBytes(16); + if (tokenBytes.byteLength !== 16) { + await parentHandle.close(); + throw new TypeError("promotion staging nonce must contain exactly 128 random bits"); + } + const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run"; + const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`; + const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`; + const descriptorStaging = path.join(descriptorRoot, cleanupToken); + const visibleStaging = path.join(parentPath, cleanupToken); + let ownsStaging = false; + let stagingHandle: Awaited> | undefined; + let createdStagingIdentity: Readonly<{ dev: number; ino: number }> | undefined; + let stagingIdentity: Readonly<{ dev: number; ino: number }> | undefined; + let openedIdentityVerified = false; + const cleanup = async (primaryFailure?: unknown): Promise => { + const cleanupFailures: unknown[] = []; + const attemptCleanup = async (operation: () => Promise): Promise => { + try { + await operation(); + } catch (error) { + cleanupFailures.push(error); + } + }; + if (ownsStaging && openedIdentityVerified && stagingHandle && stagingIdentity) { + const ownedIdentity = stagingIdentity; + const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`; + const removals = await Promise.allSettled( + files.map(({ name }) => rm(path.join(stagingDescriptorRoot, name), { force: true })), + ); + cleanupFailures.push( + ...removals.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ), + ); + await attemptCleanup(async () => { + let visible; + try { + visible = await lstat(descriptorStaging); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return; + throw error; + } + if ( + visible.isDirectory() && + !visible.isSymbolicLink() && + visible.dev === ownedIdentity.dev && + visible.ino === ownedIdentity.ino + ) { + await rmdir(descriptorStaging); + } + }); + } + if (stagingHandle) { + const ownedHandle = stagingHandle; + await attemptCleanup(async () => ownedHandle.close()); + } + await attemptCleanup(async () => parentHandle.close()); + if (cleanupFailures.length > 0) { + throw new AggregateError( + primaryFailure === undefined + ? cleanupFailures + : [primaryFailure, ...cleanupFailures], + primaryFailure instanceof Error + ? `${primaryFailure.message}; promotion staging cleanup also failed` + : "promotion staging cleanup failed", + { cause: cleanupFailures.at(-1) }, + ); + } + }; + let finalizedPromotion: FinalizedPromotion; + try { + const procMetadata = await stat(descriptorRoot); + if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable"); + await mkdir(descriptorStaging, { mode: 0o700 }); + ownsStaging = true; + const createdStaging = await lstat(descriptorStaging); + if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) { + throw new Error("created promotion staging leaf is unsafe"); + } + createdStagingIdentity = Object.freeze({ + dev: createdStaging.dev, + ino: createdStaging.ino, + }); + await afterMkdirBeforeOpen?.(visibleStaging); + const openedHandle = await open( + descriptorStaging, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + let openedStaging; + try { + openedStaging = await openedHandle.stat(); + if (!openedStaging.isDirectory()) { + throw new Error("promotion staging descriptor is not a directory"); + } + if ( + openedStaging.dev !== createdStagingIdentity.dev || + openedStaging.ino !== createdStagingIdentity.ino + ) { + throw new Error("promotion staging leaf identity changed between mkdir and open"); + } + openedIdentityVerified = true; + } catch (error) { + try { + await openedHandle.close(); + } catch (closeError) { + throw new AggregateError( + [error, closeError], + error instanceof Error + ? `${error.message}; rejected staging descriptor close also failed` + : "rejected staging descriptor and close both failed", + { cause: closeError }, + ); + } + throw error; + } + stagingHandle = openedHandle; + await stagingHandle.chmod(0o700); + stagingIdentity = Object.freeze({ dev: openedStaging.dev, ino: openedStaging.ino }); + const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`; + assertStagingIdentity(await stat(stagingDescriptorRoot), stagingIdentity); + for (const file of files) { + const handle = await open( + path.join(stagingDescriptorRoot, file.name), + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o400, + ); + try { + await handle.chmod(0o400); + await handle.writeFile(file.bytes); + await handle.sync(); + } finally { + await handle.close(); + } + await afterFileWrite?.(file.name); + } + await syncHandle(stagingHandle); + await syncHandle(parentHandle); + await afterStagingWrite?.(); + await beforeSeal?.(); + const capturedFiles = await captureStagedFiles(stagingHandle, files); + await sealStagedFiles?.(capturedFiles); + const after = await lstat(parentPath); + if ( + after.dev !== before.dev || + after.ino !== before.ino || + after.isSymbolicLink() || + !after.isDirectory() + ) { + throw new Error("runner temporary parent identity changed during staging"); + } + const visible = await lstat(visibleStaging); + if (!visible.isDirectory() || visible.isSymbolicLink()) { + throw new Error("promotion staging visibility identity mismatch"); + } + assertStagingIdentity(visible, stagingIdentity); + ownsStaging = false; + finalizedPromotion = Object.freeze({ + stagingRoot: visibleStaging, + cleanupToken, + runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }), + stagingIdentity, + files: Object.freeze( + files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })), + ), + }); + } catch (error) { + await cleanup(error); + throw error; + } + await cleanup(); + return finalizedPromotion; +} + +async function captureStagedFiles( + stagingHandle: Awaited>, + declaredFiles: readonly StagedFile[], +): Promise>> { + const descriptorRoot = `/proc/self/fd/${stagingHandle.fd}`; + const names = (await readdir(descriptorRoot)).sort(asciiCompare); + if ( + JSON.stringify(names) !== + JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare)) + ) { + throw new Error("staged promotion seal requires exactly the canonical five files"); + } + const declared = new Map(declaredFiles.map((file) => [file.name, file] as const)); + const captured = {} as Record; + for (const name of PROMOTED_FILE_NAMES) { + const expected = declared.get(name)!; + const handle = await open( + path.join(descriptorRoot, name), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + const before = await handle.stat(); + const maxBytes = name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216; + if ( + !before.isFile() || + before.nlink !== 1 || + (before.mode & 0o777) !== 0o400 || + before.size <= 0 || + before.size > maxBytes + ) { + throw new Error( + `staged promotion file must be regular, single-link, bounded, and mode 0400: ${name}`, + ); + } + const bytes = await handle.readFile(); + const after = await handle.stat(); + if ( + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size || + after.nlink !== 1 || + (after.mode & 0o777) !== 0o400 || + bytes.byteLength !== before.size + ) { + throw new Error(`staged promotion file inode or size changed during seal: ${name}`); + } + if (sha256(bytes) !== expected.sha256) { + throw new Error(`staged promotion file digest mismatch during seal: ${name}`); + } + captured[name] = bytes; + } finally { + await handle.close(); + } + } + return Object.freeze(captured); +} + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function assertStagingIdentity( + metadata: Readonly<{ + dev: number; + ino: number; + isDirectory: () => boolean; + isSymbolicLink?: () => boolean; + }>, + expected: Readonly<{ dev: number; ino: number }>, +): void { + if ( + metadata.dev !== expected.dev || + metadata.ino !== expected.ino || + !metadata.isDirectory() || + metadata.isSymbolicLink?.() + ) { + throw new Error("promotion staging leaf identity changed"); + } +} + +function assertRunnerTempIdentity( + metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>, + expected: Readonly<{ dev: number; ino: number }>, +): void { + if ( + metadata.dev !== expected.dev || + metadata.ino !== expected.ino || + !metadata.isDirectory() || + metadata.isSymbolicLink?.() + ) { + throw new Error("runner temporary parent identity changed during cleanup"); + } +} + +function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust { + const publicKey = createPublicKey( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + return Object.freeze({ + keyId, + publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), + }); +} + +async function capture(root: string, configuredPath: string, maxBytes: number): Promise { + const absolute = path.resolve(root, configuredPath); + const relative = path.relative(root, absolute); + const outside = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + return readBoundedRegularFile({ + root: outside ? path.dirname(absolute) : root, + relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"), + maxBytes, + }); +} + +function staged(name: PromotedFileName, bytes: Buffer): StagedFile { + return Object.freeze({ name, bytes, sha256: sha256(bytes) }); +} + +function canonicalJsonBytes(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function parseJson(bytes: Buffer): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch { + throw new TypeError("captured provider evidence is not valid UTF-8 JSON"); + } +} + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function syncHandle(handle: Awaited>): Promise { + try { + await handle.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/provider-cgroup.ts b/scripts/lib/provider-cgroup.ts new file mode 100644 index 0000000..3e94e1e --- /dev/null +++ b/scripts/lib/provider-cgroup.ts @@ -0,0 +1,169 @@ +export type ProviderKind = "vulnerability" | "provenance"; + +const MEMORY_MAX = 1_073_741_824; +const TASKS_MAX = 64; +const STOP_TIMEOUT_MS = 5_000; +const RUNTIME_GRACE_MS = 10_000; +const UNIT_NAME = /^ca-provider-(?:vulnerability|provenance)-[1-9][0-9]*-[0-9a-f]{24}\.scope$/u; +const UNIT_NONCE = /^[0-9a-f]{24}$/u; +const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u; +const ENFORCEMENT_GATE = [ + 'cgroup_path=""', + "while IFS=: read -r hierarchy controllers candidate; do", + ' if [ "$hierarchy" = 0 ] && [ -z "$controllers" ]; then cgroup_path=$candidate; fi', + "done < /proc/self/cgroup", + 'if [ -z "$cgroup_path" ]; then', + " printf '%s\\n' 'provider cgroup enforcement failed: unified cgroup v2 membership is required' >&2", + " exit 125", + "fi", + 'case "$cgroup_path" in', + ' */"$0") ;;', + " *)", + " printf '%s\\n' 'provider cgroup enforcement failed: unit membership is invalid' >&2", + " exit 125", + " ;;", + "esac", + "cgroup_root=/sys/fs/cgroup$cgroup_path", + "require_cgroup_value() {", + ' actual=$(/bin/cat "$cgroup_root/$1") || {', + " printf 'provider cgroup enforcement failed: cannot read %s\\n' \"$1\" >&2", + " exit 125", + " }", + ' if [ "$actual" != "$2" ]; then', + " printf 'provider cgroup enforcement failed: %s is %s, expected %s\\n' \"$1\" \"$actual\" \"$2\" >&2", + " exit 125", + " fi", + "}", + `require_cgroup_value memory.max ${MEMORY_MAX}`, + "require_cgroup_value memory.swap.max 0", + `require_cgroup_value pids.max ${TASKS_MAX}`, + "require_cgroup_value cpu.max '100000 100000'", + 'exec "$@"', +].join("\n"); + +export function formatProviderCgroupUnitName( + kind: ProviderKind, + supervisorPid: number, + nonce: string, +): string { + if (!Number.isSafeInteger(supervisorPid) || supervisorPid <= 0 || !UNIT_NONCE.test(nonce)) { + throw new TypeError("provider cgroup unit identity is invalid"); + } + const unit = `ca-provider-${kind}-${supervisorPid}-${nonce}.scope`; + assertUnit(unit); + return unit; +} + +export function systemdRunProviderArguments( + unit: string, + timeoutMs: number, + cpuSeconds: number, + nodeExecutable: string, + wrapperScript: string, + reportPath: string, + reportDev: number, + reportIno: number, +): string[] { + assertUnit(unit); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > Number.MAX_SAFE_INTEGER - RUNTIME_GRACE_MS) { + throw new TypeError("provider cgroup runtime is invalid"); + } + if (!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0) { + throw new TypeError("provider cgroup CPU limit is invalid"); + } + if ( + !nodeExecutable.startsWith("/") || !wrapperScript.startsWith("/") || + !reportPath.startsWith("/") || reportPath.includes("\0") || + !Number.isSafeInteger(reportDev) || reportDev <= 0 || + !Number.isSafeInteger(reportIno) || reportIno <= 0 + ) { + throw new TypeError("provider scope wrapper path is invalid"); + } + return [ + "--user", + "--scope", + "--collect", + "--quiet", + "--expand-environment=no", + `--unit=${unit}`, + `--property=MemoryMax=${MEMORY_MAX}`, + "--property=MemorySwapMax=0", + `--property=TasksMax=${TASKS_MAX}`, + "--property=CPUQuota=100%", + "--property=CPUQuotaPeriodSec=100ms", + "--property=KillMode=control-group", + "--property=SendSIGKILL=yes", + `--property=TimeoutStopSec=${STOP_TIMEOUT_MS}ms`, + `--property=RuntimeMaxSec=${timeoutMs + RUNTIME_GRACE_MS}ms`, + "--", + "/bin/sh", + "-eu", + "-c", + ENFORCEMENT_GATE, + unit, + nodeExecutable, + wrapperScript, + String(cpuSeconds), + reportPath, + String(reportDev), + String(reportIno), + ]; +} + +export type ProviderScopeFrame = Readonly<{ + bwrapInput: Buffer; + reportPath: string; + reportDev: number; + reportIno: number; +}>; + +export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer { + if ( + !Buffer.isBuffer(input.bwrapInput) || input.bwrapInput.byteLength === 0 || + !input.reportPath.startsWith("/") || input.reportPath.includes("\0") || + !Number.isSafeInteger(input.reportDev) || input.reportDev <= 0 || + !Number.isSafeInteger(input.reportIno) || input.reportIno <= 0 + ) { + throw new TypeError("provider scope frame is invalid"); + } + const payload = Buffer.from(JSON.stringify({ + bwrapInputBase64: input.bwrapInput.toString("base64"), + reportPath: input.reportPath, + reportDev: input.reportDev, + reportIno: input.reportIno, + })); + const frame = Buffer.allocUnsafe(4 + payload.byteLength); + frame.writeUInt32BE(payload.byteLength, 0); + payload.copy(frame, 4); + return frame; +} + +export function encodeProviderBwrapInput( + arguments_: readonly string[], + environment: Readonly>, +): Buffer { + if (arguments_.some((argument) => argument.includes("\0"))) { + throw new TypeError("provider bwrap argument is invalid"); + } + const entries = Object.entries(environment).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + if (entries.some(([name, value]) => + !ENVIRONMENT_NAME.test(name) || value === undefined || value.includes("\0") + )) { + throw new TypeError("provider bwrap environment is invalid"); + } + const input = ["--clearenv"]; + for (const [name, value] of entries) input.push("--setenv", name, value ?? ""); + input.push(...arguments_); + return Buffer.from(`${input.join("\0")}\0`); +} + +export function systemctlKillProviderArguments(unit: string): string[] { + assertUnit(unit); + return ["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", unit]; +} + +function assertUnit(unit: string): void { + if (!UNIT_NAME.test(unit)) throw new TypeError("provider cgroup unit name is invalid"); +} diff --git a/scripts/lib/provider-evidence.ts b/scripts/lib/provider-evidence.ts new file mode 100644 index 0000000..0843528 --- /dev/null +++ b/scripts/lib/provider-evidence.ts @@ -0,0 +1,494 @@ +import { createHash, verify, type KeyObject } from "node:crypto"; + +import { z } from "zod"; + +import { + canonicalizeSupplyChainValue, + supplyChainDigest, +} from "./supply-chain.ts"; + +export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000; +export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000; +export const PROMOTION_VERIFIER_ID = + "clean-architecture-frontend-template/promotion-verifier"; +export const PROMOTION_VERIFIER_VERSION = "3"; + +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u); +const nonce = z.string().regex(/^[a-f0-9]{64}$/u); +const nonEmptyString = z.string().min(1); +const timestamp = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + .refine((value) => new Date(value).toISOString() === value); +const runSchema = z + .object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) }) + .strict(); +const sourceSchema = z + .object({ revision, sourceSetSha256: sha256 }) + .strict(); +const candidateSchema = z + .object({ + archiveSha256: sha256, + bundleSha256: sha256, + distSha256: sha256, + lockfileSha256: sha256, + }) + .strict(); +const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict(); +export const secretScanAttestationSchema = z + .object({ + status: z.literal("PASS"), + localEvidenceAssessmentSha256: sha256, + sourceSetSha256: sha256, + policySha256: sha256, + sarifSha256: sha256, + scanInputSha256: sha256, + }) + .strict(); +const signatureSchema = z + .object({ + algorithm: z.literal("Ed25519"), + keyId: nonEmptyString, + publicKeyFingerprint: fingerprint, + value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u), + }) + .strict(); +const providerCommon = { + schemaVersion: z.literal(2), + provider: nonEmptyString, + issuedAt: timestamp, + expiresAt: timestamp, + run: providerRunSchema, + source: sourceSchema, + candidate: candidateSchema, + signature: signatureSchema, +} as const; + +export const vulnerabilityProviderReportSchema = z + .object({ + ...providerCommon, + evidenceType: z.literal("vulnerability-report"), + secretScanAttestation: secretScanAttestationSchema, + findings: z.array(z.record(z.string(), z.json())), + }) + .strict(); + +export const provenanceProviderAttestationSchema = z + .object({ + ...providerCommon, + evidenceType: z.literal("provenance-attestation"), + signer: nonEmptyString, + subject: z + .object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() }) + .strict(), + }) + .strict(); + +const verificationCommon = { + schemaVersion: z.literal(3), + verifiedAt: timestamp, + status: z.enum(["PASS", "FAIL_UNVERIFIED"]), + verifier: z + .object({ id: nonEmptyString, version: nonEmptyString }) + .strict(), + run: runSchema, + source: sourceSchema, + candidate: candidateSchema, + providerEvidence: z + .object({ + vulnerabilityReportSha256: sha256, + provenanceAttestationSha256: sha256, + vulnerabilityInvocationNonce: nonce, + provenanceInvocationNonce: nonce, + vulnerabilityKeyId: nonEmptyString, + vulnerabilityKeyFingerprint: fingerprint, + provenanceKeyId: nonEmptyString, + provenanceKeyFingerprint: fingerprint, + secretScanAttestation: secretScanAttestationSchema, + }) + .strict(), + trustPolicySha256: sha256, + failures: z.array(z.string()), +} as const; + +const providerVerificationV3Schema = z + .object({ + ...verificationCommon, + artifactType: z.literal("provider-verification"), + vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + }) + .strict(); + +const promotionVerificationV3Schema = z + .object({ + ...verificationCommon, + artifactType: z.literal("promotion-verification"), + localEvidenceStatus: z.enum(["PASS", "FAIL"]), + localEvidenceAssessmentSha256: sha256, + providerVerificationSha256: sha256, + }) + .strict(); + +export const providerVerificationArtifactSchema = z + .discriminatedUnion("artifactType", [ + providerVerificationV3Schema, + promotionVerificationV3Schema, + ]) + .superRefine((record, context) => { + const subordinatePass = + record.artifactType === "provider-verification" + ? record.vulnerabilityStatus === "PASS" && + record.provenanceAttestationStatus === "PASS" + : record.localEvidenceStatus === "PASS"; + const coherentPass = subordinatePass && record.failures.length === 0; + if ((record.status === "PASS") !== coherentPass) { + context.addIssue({ + code: "custom", + path: ["status"], + message: "verification PASS must agree with subordinate statuses and failures", + }); + } + if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) { + context.addIssue({ + code: "custom", + path: ["failures"], + message: "failed verification requires a failure diagnostic", + }); + } + }); + +export type ProviderVerificationArtifactType = z.infer< + typeof providerVerificationArtifactSchema +>["artifactType"]; + +export type ProviderTrust = Readonly<{ + keyId: string; + publicKey: KeyObject; + publicKeyFingerprint: string; +}>; + +export type ExpectedPromotionContext = Readonly<{ + run: Readonly<{ id: string; attempt: number }>; + source: Readonly<{ revision: string; sourceSetSha256: string }>; + candidate: Readonly<{ + archiveSha256: string; + bundleSha256: string; + distSha256: string; + lockfileSha256: string; + }>; + vulnerabilityInvocationNonce: string; + provenanceInvocationNonce: string; + secretScanAttestation: z.infer; +}>; + +export type PromotionEvidenceResult = Readonly<{ + status: "PASS" | "FAIL_UNVERIFIED"; + vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED"; + provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED"; + failures: readonly string[]; +}>; + +export function validateProviderEvidence(input: Readonly<{ + kind: "vulnerability" | "provenance"; + value: unknown; + expected: ExpectedPromotionContext; + trust: ProviderTrust | null; + nowEpochMs?: () => number; +}>): Readonly<{ + evidence: unknown | null; + status: "PASS" | "FAIL_UNVERIFIED"; + failures: readonly string[]; +}> { + const failures: string[] = []; + const now = (input.nowEpochMs ?? Date.now)(); + if (input.kind === "vulnerability") { + const parsed = vulnerabilityProviderReportSchema.safeParse(input.value); + if (!parsed.success) { + return Object.freeze({ + evidence: null, + status: "FAIL_UNVERIFIED", + failures: Object.freeze([ + "external vulnerability provider report is missing or invalid", + ]), + }); + } + validateCommonContext( + "vulnerability report", + parsed.data, + input.expected, + input.expected.vulnerabilityInvocationNonce, + input.trust, + now, + failures, + ); + if ( + JSON.stringify(parsed.data.secretScanAttestation) !== + JSON.stringify(input.expected.secretScanAttestation) + ) { + failures.push("vulnerability report secret scan attestation mismatch"); + } + if (parsed.data.findings.length > 0) { + failures.push("vulnerability report contains findings"); + } + return Object.freeze({ + evidence: parsed.data, + status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED", + failures: Object.freeze(failures), + }); + } + const parsed = provenanceProviderAttestationSchema.safeParse(input.value); + if (!parsed.success) { + return Object.freeze({ + evidence: null, + status: "FAIL_UNVERIFIED", + failures: Object.freeze([ + "external signed provenance attestation is missing or invalid", + ]), + }); + } + validateCommonContext( + "provenance attestation", + parsed.data, + input.expected, + input.expected.provenanceInvocationNonce, + input.trust, + now, + failures, + ); + if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) { + failures.push("provenance attestation subject dist digest mismatch"); + } + return Object.freeze({ + evidence: parsed.data, + status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED", + failures: Object.freeze(failures), + }); +} + +export function providerEvidenceSignaturePayload(value: unknown): Buffer { + if (!isRecord(value)) return Buffer.from("null", "utf8"); + const { signature: _signature, ...payload } = value; + return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8"); +} + +export function providerPublicKeyFingerprint(publicKey: KeyObject): string { + if (publicKey.asymmetricKeyType !== "ed25519") { + throw new TypeError("provider trust key must be Ed25519"); + } + return `sha256:${createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; +} + +export function createTrustPolicy(input: Readonly<{ + vulnerabilityTrust: ProviderTrust; + provenanceTrust: ProviderTrust; +}>) { + assertDistinctProviderTrust(input); + return Object.freeze({ + algorithm: "Ed25519" as const, + vulnerability: Object.freeze({ + keyId: input.vulnerabilityTrust.keyId, + publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint, + }), + provenance: Object.freeze({ + keyId: input.provenanceTrust.keyId, + publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint, + }), + issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS, + maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS, + }); +} + +export function assertDistinctProviderTrust(input: Readonly<{ + vulnerabilityTrust: ProviderTrust; + provenanceTrust: ProviderTrust; +}>): void { + const vulnerabilityFingerprint = providerPublicKeyFingerprint( + input.vulnerabilityTrust.publicKey, + ); + const provenanceFingerprint = providerPublicKeyFingerprint( + input.provenanceTrust.publicKey, + ); + if ( + input.vulnerabilityTrust.keyId === input.provenanceTrust.keyId || + vulnerabilityFingerprint === provenanceFingerprint || + input.vulnerabilityTrust.publicKeyFingerprint === + input.provenanceTrust.publicKeyFingerprint + ) { + throw new TypeError("provider trust roles require distinct key identities and DER-SPKI fingerprints"); + } +} + +export function trustPolicySha256(input: Readonly<{ + vulnerabilityTrust: ProviderTrust; + provenanceTrust: ProviderTrust; +}>): string { + return supplyChainDigest(createTrustPolicy(input)); +} + +export function evaluatePromotionEvidence(input: Readonly<{ + expected: ExpectedPromotionContext; + localStatus: unknown; + vulnerabilityReport: unknown; + provenanceAttestation: unknown; + vulnerabilityTrust: ProviderTrust | null; + provenanceTrust: ProviderTrust | null; + nowEpochMs?: () => number; +}>): PromotionEvidenceResult { + const failures: string[] = []; + if (input.localStatus !== "PASS") { + failures.push("local supply-chain evidence is not PASS"); + } + const now = (input.nowEpochMs ?? Date.now)(); + let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED"; + let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED"; + + const vulnerability = vulnerabilityProviderReportSchema.safeParse( + input.vulnerabilityReport, + ); + if (!vulnerability.success) { + failures.push("external vulnerability provider report is missing or invalid"); + } else { + const before = failures.length; + validateCommonContext( + "vulnerability report", + vulnerability.data, + input.expected, + input.expected.vulnerabilityInvocationNonce, + input.vulnerabilityTrust, + now, + failures, + ); + if ( + JSON.stringify(vulnerability.data.secretScanAttestation) !== + JSON.stringify(input.expected.secretScanAttestation) + ) { + failures.push("vulnerability report secret scan attestation mismatch"); + } + if (vulnerability.data.findings.length > 0) { + failures.push("vulnerability report contains findings"); + } + if (failures.length === before && input.localStatus === "PASS") { + vulnerabilityStatus = "PASS"; + } + } + + const provenance = provenanceProviderAttestationSchema.safeParse( + input.provenanceAttestation, + ); + if (!provenance.success) { + failures.push("external signed provenance attestation is missing or invalid"); + } else { + const before = failures.length; + validateCommonContext( + "provenance attestation", + provenance.data, + input.expected, + input.expected.provenanceInvocationNonce, + input.provenanceTrust, + now, + failures, + ); + if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) { + failures.push("provenance attestation subject dist digest mismatch"); + } + if (failures.length === before && input.localStatus === "PASS") { + provenanceAttestationStatus = "PASS"; + } + } + + return Object.freeze({ + status: + failures.length === 0 && + vulnerabilityStatus === "PASS" && + provenanceAttestationStatus === "PASS" + ? "PASS" + : "FAIL_UNVERIFIED", + vulnerabilityStatus, + provenanceAttestationStatus, + failures: Object.freeze(failures), + }); +} + +function validateCommonContext( + label: "vulnerability report" | "provenance attestation", + evidence: z.infer< + | typeof vulnerabilityProviderReportSchema + | typeof provenanceProviderAttestationSchema + >, + expected: ExpectedPromotionContext, + expectedNonce: string, + trust: ProviderTrust | null, + now: number, + failures: string[], +): void { + if ( + evidence.run.id !== expected.run.id || + evidence.run.attempt !== expected.run.attempt + ) { + failures.push(`${label} run identity mismatch`); + } + if (evidence.run.invocationNonce !== expectedNonce) { + failures.push(`${label} invocation nonce mismatch`); + } + if ( + evidence.source.revision !== expected.source.revision || + evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256 + ) { + failures.push(`${label} source identity mismatch`); + } + if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) { + failures.push(`${label} candidate identity mismatch`); + } + validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures); + if ( + !trust || + evidence.signature.keyId !== trust.keyId || + evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint + ) { + failures.push(`${label} trust identity mismatch`); + return; + } + try { + if ( + providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint || + !verify( + null, + providerEvidenceSignaturePayload(evidence), + trust.publicKey, + Buffer.from(evidence.signature.value, "base64"), + ) + ) { + failures.push(`${label} signature verification failed`); + } + } catch { + failures.push(`${label} signature verification failed`); + } +} + +function validateEvidenceTime( + label: string, + issuedAt: string, + expiresAt: string, + now: number, + failures: string[], +): void { + const issued = Date.parse(issuedAt); + const expires = Date.parse(expiresAt); + if (issued > now + PROVIDER_FUTURE_SKEW_MS) { + failures.push(`${label} issuedAt exceeds allowed future skew`); + } + if (expires <= now) failures.push(`${label} is expired`); + if (expires <= issued) failures.push(`${label} validity window is not positive`); + if (expires - issued > PROVIDER_MAX_LIFETIME_MS) { + failures.push(`${label} validity window exceeds two hours`); + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/lib/provider-guardian-client.ts b/scripts/lib/provider-guardian-client.ts new file mode 100644 index 0000000..020fd64 --- /dev/null +++ b/scripts/lib/provider-guardian-client.ts @@ -0,0 +1,763 @@ +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +import { + decodeProviderGuardianPublished, + decodeProviderGuardianReady, + encodeProviderGuardianCommit, + encodeProviderGuardianGuard, + encodeProviderGuardianPublish, + MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES, + MAX_PROVIDER_GUARDIAN_LEASE_MS, + MAX_PROVIDER_SEALED_BYTES, + providerGuardianRawStagingLeaf, + providerGuardianSealedTempLeaf, + type ProviderGuardianKind, +} from "./provider-guardian-protocol.ts"; +import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts"; + +const RESPONSE_TIMEOUT_MS = 5_000; +const CLOSE_TIMEOUT_MS = 5_000; +const MAX_CONTROL_OUTPUT_BYTES = 4_096; + +type OwnedIdentity = Readonly<{ dev: number; ino: number }>; +type RecoveryAuthority = Readonly<{ + rawDirectoryHandle: FileHandle; + evidenceDirectoryHandle: FileHandle; + rawStagingHandle: FileHandle; + sealedTempHandle: FileHandle; + rawIdentity: OwnedIdentity; + sealedIdentity: OwnedIdentity; + rawStagingPinnedPath: string; + rawPinnedPath: string; + sealedTempPinnedPath: string; + sealedPinnedPath: string; +}>; + +export type ProviderGuardianLease = Readonly<{ + pid: number; + rawPath: string; + rawIdentity: OwnedIdentity; + sealedPath: string; + sealedTempPath: string; + sealedIdentity: OwnedIdentity; + prematureExit: Promise; + publish(bytes: Buffer): Promise; + commit(): Promise; + abort(): Promise; +}>; + +export type StartProviderGuardianInput = Readonly<{ + kind: ProviderGuardianKind; + workspaceRoot: string; + leaseMs: number; + guardianScript: string; +}>; + +export type ProviderScopeGuardianLatch = Readonly<{ + activeFailure: Promise; + close(): Promise; + failure(): Error | undefined; +}>; + +export function createProviderScopeGuardianLatch( + guardianExit: Promise, +): ProviderScopeGuardianLatch { + let active = true; + let closing: Promise | undefined; + let observedFailure: Error | undefined; + let signalActiveFailure!: (error: Error) => void; + const activeFailure = new Promise((resolve) => { signalActiveFailure = resolve; }); + void guardianExit.then((error) => { + observedFailure = error; + if (active) signalActiveFailure(error); + }); + return Object.freeze({ + activeFailure, + close: () => { + closing ??= Promise.resolve().then(() => { active = false; }); + return closing; + }, + failure: () => observedFailure, + }); +} + +export function assertProviderGuardianLeasePaths( + lease: Readonly<{ rawPath: string; sealedPath: string }>, + expected: Readonly<{ rawPath: string; sealedPath: string }>, +): void { + if (lease.rawPath !== expected.rawPath) { + throw new Error("provider guardian returned a noncanonical raw path"); + } + if (lease.sealedPath !== expected.sealedPath) { + throw new Error("provider guardian returned a noncanonical sealed path"); + } +} + +type GuardianResult = Readonly<{ + code: number | null; + error?: Error; + signal: NodeJS.Signals | null; +}>; + +export async function startProviderGuardian( + input: StartProviderGuardianInput, +): Promise { + if ( + (input.kind !== "vulnerability" && input.kind !== "provenance") || + !path.isAbsolute(input.workspaceRoot) || !path.isAbsolute(input.guardianScript) || + !Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0 || + input.leaseMs > MAX_PROVIDER_GUARDIAN_LEASE_MS + ) { + throw new TypeError("provider guardian client input is invalid"); + } + const rawLeaf = input.kind === "vulnerability" + ? "vulnerability-report.json" + : "provenance-attestation.json"; + const evidenceRoot = path.resolve(input.workspaceRoot, "provider-evidence"); + const rawDirectory = path.join(evidenceRoot, "untrusted"); + const rawPath = path.join(evidenceRoot, "untrusted", rawLeaf); + const sealedPath = path.join(evidenceRoot, rawLeaf); + const nonce = randomBytes(32); + const rawStagingLeaf = providerGuardianRawStagingLeaf(input.kind, nonce); + const sealedTempLeaf = providerGuardianSealedTempLeaf(input.kind, nonce); + const sealedTempPath = path.join(evidenceRoot, sealedTempLeaf); + const recovery = await openRecoveryAuthority({ + rawDirectory, + evidenceRoot, + rawLeaf, + rawStagingLeaf, + sealedLeaf: rawLeaf, + sealedTempLeaf, + }); + let child: ReturnType; + try { + await assertRecoveryLeavesMissing(recovery); + child = spawn(process.execPath, [input.guardianScript], { + cwd: input.workspaceRoot, + env: {}, + stdio: [ + "pipe", + "pipe", + "pipe", + recovery.rawDirectoryHandle.fd, + recovery.evidenceDirectoryHandle.fd, + recovery.rawStagingHandle.fd, + recovery.sealedTempHandle.fd, + ], + }); + } catch (error) { + return await closeRecoveryAndThrow(recovery, error); + } + if (!child.pid || !child.stdin || !child.stdout || !child.stderr) { + child.kill("SIGKILL"); + return await closeRecoveryAndThrow( + recovery, + new Error("provider guardian process pipes are unavailable"), + ); + } + + let state: "starting" | "guarding" | "publishing" | "published" | + "committing" | "aborting" | "terminated" = "starting"; + let stderr = Buffer.alloc(0); + let inputError: Error | undefined; + child.stdin.once("error", (error) => { inputError = error; }); + child.stderr.on("data", (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (stderr.byteLength < MAX_CONTROL_OUTPUT_BYTES) { + stderr = Buffer.concat([stderr, bytes.subarray(0, MAX_CONTROL_OUTPUT_BYTES - stderr.byteLength)]); + } + }); + const completion = guardianCompletion(child); + let signalPrematureExit!: (error: Error) => void; + const prematureExit = new Promise((resolve) => { signalPrematureExit = resolve; }); + void completion.then((result) => { + if (state === "guarding" || state === "publishing" || state === "published") { + signalPrematureExit(guardianCloseError(result, stderr)); + } + }); + + let ready: ReturnType; + try { + const readyResponse = waitForFrame(child.stdout, completion, "READY"); + child.stdin.write(encodeProviderGuardianGuard({ + kind: input.kind, + nonce, + deadlineEpochMs: Date.now() + input.leaseMs, + })); + ready = decodeProviderGuardianReady(await readyResponse, nonce); + if ( + ready.sealedTempLeaf !== sealedTempLeaf || + ready.rawDev !== recovery.rawIdentity.dev || + ready.rawIno !== recovery.rawIdentity.ino || + ready.sealedDev !== recovery.sealedIdentity.dev || + ready.sealedIno !== recovery.sealedIdentity.ino + ) { + throw new TypeError("provider guardian READY identity is invalid for its allocation"); + } + await assertPinnedLeafIdentity(recovery.rawPinnedPath, { + dev: ready.rawDev, + ino: ready.rawIno, + }, 0o600); + await assertPinnedLeafIdentity(recovery.sealedTempPinnedPath, { + dev: ready.sealedDev, + ino: ready.sealedIno, + }, 0o600); + await assertPinnedLeafMissing(recovery.rawStagingPinnedPath); + if (child.exitCode !== null || child.signalCode !== null) { + throw guardianCloseError(await completion, stderr); + } + state = "guarding"; + } catch (error) { + state = "aborting"; + child.stdin.end(); + const failures = [toError(error)]; + try { + await waitForClose(completion, child); + } catch (closeError) { + failures.push(toError(closeError)); + } + try { + await cleanupStartupRecovery(recovery); + } catch (cleanupError) { + failures.push(toError(cleanupError)); + } + try { + await closeRecoveryAuthority(recovery); + } catch (closeError) { + failures.push(toError(closeError)); + } + state = "terminated"; + if (failures.length > 1) { + throw new AggregateError(failures, "provider guardian startup failed", { cause: error }); + } + throw failures[0]!; + } + + const rawIdentity = Object.freeze({ dev: ready.rawDev, ino: ready.rawIno }); + const sealedIdentity = Object.freeze({ dev: ready.sealedDev, ino: ready.sealedIno }); + const fallback = Object.freeze({ + rawStagingPath: recovery.rawStagingPinnedPath, + rawPath: recovery.rawPinnedPath, + rawIdentity, + sealedPath: recovery.sealedPinnedPath, + sealedTempPath: recovery.sealedTempPinnedPath, + sealedIdentity, + }); + + const publish = async (bytes: Buffer): Promise => { + if (state !== "guarding") throw new Error("provider guardian lease is not ready to publish"); + if (!Buffer.isBuffer(bytes) || bytes.byteLength <= 0 || bytes.byteLength > MAX_PROVIDER_SEALED_BYTES) { + throw new TypeError("provider guardian sealed bytes are invalid"); + } + state = "publishing"; + try { + await writePinnedSealedBytes(recovery.sealedTempHandle, sealedIdentity, bytes); + const publishedResponse = waitForFrame(child.stdout!, completion, "PUBLISHED"); + child.stdin!.write(encodeProviderGuardianPublish({ + nonce, + sealedDev: sealedIdentity.dev, + sealedIno: sealedIdentity.ino, + size: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + })); + decodeProviderGuardianPublished( + await publishedResponse, + nonce, + sealedIdentity, + ); + if (inputError) throw inputError; + state = "published"; + } catch (error) { + state = "guarding"; + throw error; + } + }; + + const commit = async (): Promise => { + if (state !== "published") throw new Error("provider guardian lease is not ready to commit"); + state = "committing"; + child.stdin!.write(encodeProviderGuardianCommit(nonce)); + child.stdin!.end(); + let result: GuardianResult; + try { + result = await waitForClose(completion, child); + } catch (error) { + state = "terminated"; + return await cleanupFallbackCloseAndThrow(fallback, recovery, error); + } + state = "terminated"; + if (inputError) return await cleanupFallbackCloseAndThrow(fallback, recovery, inputError); + if (result.error || result.code !== 0 || result.signal !== null) { + return await cleanupFallbackCloseAndThrow( + fallback, + recovery, + guardianCloseError(result, stderr), + ); + } + await closeRecoveryAuthority(recovery); + }; + + const abort = async (): Promise => { + if (state !== "guarding" && state !== "published") { + throw new Error("provider guardian lease already terminated"); + } + state = "aborting"; + child.stdin!.end(); + let closeError: unknown; + try { + await waitForClose(completion, child); + } catch (error) { + closeError = error; + } + state = "terminated"; + if (closeError) return await cleanupFallbackCloseAndThrow(fallback, recovery, closeError); + await cleanupFallbackAndClose(fallback, recovery); + }; + + return Object.freeze({ + pid: child.pid, + rawPath, + rawIdentity, + sealedPath, + sealedTempPath, + sealedIdentity, + prematureExit, + publish, + commit, + abort, + }); +} + +async function writePinnedSealedBytes( + handle: FileHandle, + identity: OwnedIdentity, + bytes: Buffer, +): Promise { + assertPinnedMetadata(await handle.stat(), identity, 0o600, 0); + await handle.truncate(0); + await handle.writeFile(bytes); + await handle.chmod(0o400); + await handle.sync(); + assertPinnedMetadata(await handle.stat(), identity, 0o400, bytes.byteLength); +} + +function assertPinnedMetadata( + metadata: Awaited>["stat"]>>, + identity: OwnedIdentity, + mode: number, + size: number, +): void { + if ( + !metadata.isFile() || Number(metadata.dev) !== identity.dev || + Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 || + (Number(metadata.mode) & 0o777) !== mode || Number(metadata.size) !== size + ) { + throw new TypeError("provider guardian sealed temp identity changed"); + } +} + +function guardianCompletion(child: ReturnType): Promise { + return new Promise((resolve) => { + child.once("error", (error) => resolve({ code: null, error, signal: null })); + child.once("close", (code, signal) => resolve({ code, signal })); + }); +} + +async function waitForFrame( + stdout: NodeJS.ReadableStream, + completion: Promise, + label: string, +): Promise { + let timer: NodeJS.Timeout | undefined; + let pending = Buffer.alloc(0); + const response = new Promise((resolve, reject) => { + const onData = (chunk: Buffer | string): void => { + pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (pending.byteLength > MAX_CONTROL_OUTPUT_BYTES) { + reject(new Error(`provider guardian ${label} output exceeded its bound`)); + return; + } + if (pending.byteLength < 4) return; + const payloadBytes = pending.readUInt32BE(0); + if (payloadBytes <= 0 || payloadBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) { + reject(new Error(`provider guardian ${label} frame length is invalid`)); + return; + } + if (pending.byteLength < payloadBytes + 4) return; + if (pending.byteLength !== payloadBytes + 4) { + reject(new Error(`provider guardian ${label} output has trailing bytes`)); + return; + } + resolve(pending.subarray(4)); + }; + stdout.on("data", onData); + }); + try { + return await Promise.race([ + response, + completion.then((result) => { throw guardianCloseError(result, Buffer.alloc(0)); }), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`provider guardian ${label} timed out`)), + RESPONSE_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + stdout.removeAllListeners("data"); + } +} + +async function waitForClose( + completion: Promise, + child: ReturnType, +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + completion, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("provider guardian did not close within its bound")); + }, CLOSE_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +type FallbackIdentity = Readonly<{ + rawStagingPath: string; + rawPath: string; + rawIdentity: OwnedIdentity; + sealedPath: string; + sealedTempPath: string; + sealedIdentity: OwnedIdentity; +}>; + +async function cleanupFallback(input: FallbackIdentity): Promise { + const failures: Error[] = []; + for (const target of [ + { path: input.rawStagingPath, identity: input.rawIdentity }, + { path: input.rawPath, identity: input.rawIdentity }, + { path: input.sealedTempPath, identity: input.sealedIdentity }, + { path: input.sealedPath, identity: input.sealedIdentity }, + ]) { + try { + await cleanupOwnedProviderReport({ + reportPath: target.path, + reportDev: target.identity.dev, + reportIno: target.identity.ino, + }); + } catch (error) { + failures.push(toError(error)); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, "provider guardian fallback cleanup failed", { + cause: failures[0], + }); + } +} + +async function cleanupFallbackCloseAndThrow( + fallback: FallbackIdentity, + recovery: RecoveryAuthority, + primaryError: unknown, +): Promise { + const failures = [toError(primaryError)]; + try { + await cleanupFallback(fallback); + } catch (cleanupError) { + failures.push(toError(cleanupError)); + } + try { + await closeRecoveryAuthority(recovery); + } catch (closeError) { + failures.push(toError(closeError)); + } + if (failures.length > 1) { + throw new AggregateError(failures, "provider guardian failure and recovery failed", { + cause: failures[0], + }); + } + throw failures[0]!; +} + +async function cleanupFallbackAndClose( + fallback: FallbackIdentity, + recovery: RecoveryAuthority, +): Promise { + const failures: Error[] = []; + try { + await cleanupFallback(fallback); + } catch (cleanupError) { + failures.push(toError(cleanupError)); + } + try { + await closeRecoveryAuthority(recovery); + } catch (closeError) { + failures.push(toError(closeError)); + } + if (failures.length > 0) { + throw new AggregateError(failures, "provider guardian abort recovery failed", { + cause: failures[0], + }); + } +} + +async function openRecoveryAuthority(input: Readonly<{ + rawDirectory: string; + evidenceRoot: string; + rawLeaf: string; + rawStagingLeaf: string; + sealedLeaf: string; + sealedTempLeaf: string; +}>): Promise { + let rawDirectoryHandle: FileHandle | undefined; + let evidenceDirectoryHandle: FileHandle | undefined; + let rawStagingHandle: FileHandle | undefined; + let sealedTempHandle: FileHandle | undefined; + let rawIdentity: OwnedIdentity | undefined; + let sealedIdentity: OwnedIdentity | undefined; + let rawStagingPinnedPath: string | undefined; + let rawPinnedPath: string | undefined; + let sealedTempPinnedPath: string | undefined; + let sealedPinnedPath: string | undefined; + try { + rawDirectoryHandle = await open( + input.rawDirectory, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + await assertPinnedDirectory(rawDirectoryHandle, input.rawDirectory, "raw"); + evidenceDirectoryHandle = await open( + input.evidenceRoot, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + await assertPinnedDirectory(evidenceDirectoryHandle, input.evidenceRoot, "evidence"); + rawStagingPinnedPath = + `/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawStagingLeaf}`; + rawPinnedPath = `/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawLeaf}`; + sealedTempPinnedPath = + `/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedTempLeaf}`; + sealedPinnedPath = `/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedLeaf}`; + rawStagingHandle = await open( + rawStagingPinnedPath, + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + const rawMetadata = await rawStagingHandle.stat(); + rawIdentity = Object.freeze({ dev: rawMetadata.dev, ino: rawMetadata.ino }); + assertAllocatedPrivateMetadata(rawMetadata, rawIdentity, "raw staging"); + await assertPinnedLeafIdentity(rawStagingPinnedPath, rawIdentity, 0o600); + sealedTempHandle = await open( + sealedTempPinnedPath, + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + const sealedMetadata = await sealedTempHandle.stat(); + sealedIdentity = Object.freeze({ dev: sealedMetadata.dev, ino: sealedMetadata.ino }); + assertAllocatedPrivateMetadata(sealedMetadata, sealedIdentity, "sealed temp"); + await assertPinnedLeafIdentity(sealedTempPinnedPath, sealedIdentity, 0o600); + return Object.freeze({ + rawDirectoryHandle, + evidenceDirectoryHandle, + rawStagingHandle, + sealedTempHandle, + rawIdentity, + sealedIdentity, + rawStagingPinnedPath, + rawPinnedPath, + sealedTempPinnedPath, + sealedPinnedPath, + }); + } catch (error) { + const failures = [toError(error)]; + for (const target of [ + { path: rawStagingPinnedPath, identity: rawIdentity }, + { path: rawPinnedPath, identity: rawIdentity }, + { path: sealedTempPinnedPath, identity: sealedIdentity }, + { path: sealedPinnedPath, identity: sealedIdentity }, + ]) { + if (!target.path || !target.identity) continue; + try { + await cleanupOwnedProviderReport({ + reportPath: target.path, + reportDev: target.identity.dev, + reportIno: target.identity.ino, + }); + } catch (cleanupError) { + failures.push(toError(cleanupError)); + } + } + for (const handle of [ + sealedTempHandle, + rawStagingHandle, + evidenceDirectoryHandle, + rawDirectoryHandle, + ]) { + if (!handle) continue; + try { await handle.close(); } catch (closeError) { failures.push(toError(closeError)); } + } + if (failures.length > 1) { + throw new AggregateError(failures, "provider guardian recovery setup failed", { + cause: error, + }); + } + throw failures[0]!; + } +} + +function assertAllocatedPrivateMetadata( + metadata: Awaited>, + identity: OwnedIdentity, + label: string, +): void { + if ( + !metadata.isFile() || Number(metadata.dev) !== identity.dev || + Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 || + (Number(metadata.mode) & 0o777) !== 0o600 || Number(metadata.size) !== 0 + ) { + throw new TypeError(`provider guardian ${label} allocation is invalid`); + } +} + +async function assertPinnedDirectory( + handle: FileHandle, + canonicalPath: string, + label: string, +): Promise { + const [descriptorMetadata, pathMetadata] = await Promise.all([ + handle.stat(), + lstat(canonicalPath), + ]); + if ( + !descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() || + pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev || + descriptorMetadata.ino !== pathMetadata.ino + ) { + throw new TypeError(`provider guardian ${label} recovery directory identity changed`); + } +} + +async function assertRecoveryLeavesMissing(recovery: RecoveryAuthority): Promise { + await assertPinnedLeafMissing(recovery.rawPinnedPath); + await assertPinnedLeafMissing(recovery.sealedPinnedPath); + assertAllocatedPrivateMetadata( + await recovery.rawStagingHandle.stat(), + recovery.rawIdentity, + "raw staging", + ); + assertAllocatedPrivateMetadata( + await recovery.sealedTempHandle.stat(), + recovery.sealedIdentity, + "sealed temp", + ); + await assertPinnedLeafIdentity( + recovery.rawStagingPinnedPath, + recovery.rawIdentity, + 0o600, + ); + await assertPinnedLeafIdentity( + recovery.sealedTempPinnedPath, + recovery.sealedIdentity, + 0o600, + ); +} + +async function assertPinnedLeafMissing(target: string): Promise { + try { + await lstat(target); + throw new Error("provider guardian transaction leaf already exists"); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } +} + +async function assertPinnedLeafIdentity( + target: string, + identity: OwnedIdentity, + mode: number, +): Promise { + const metadata = await lstat(target); + if ( + !metadata.isFile() || metadata.isSymbolicLink() || metadata.dev !== identity.dev || + metadata.ino !== identity.ino || metadata.nlink !== 1 || + (metadata.mode & 0o777) !== mode || metadata.size !== 0 + ) { + throw new TypeError("provider guardian READY identity changed"); + } +} + +async function cleanupStartupRecovery(recovery: RecoveryAuthority): Promise { + await cleanupFallback({ + rawStagingPath: recovery.rawStagingPinnedPath, + rawPath: recovery.rawPinnedPath, + rawIdentity: recovery.rawIdentity, + sealedTempPath: recovery.sealedTempPinnedPath, + sealedPath: recovery.sealedPinnedPath, + sealedIdentity: recovery.sealedIdentity, + }); +} + +async function closeRecoveryAuthority(recovery: RecoveryAuthority): Promise { + const failures: Error[] = []; + for (const handle of [ + recovery.rawStagingHandle, + recovery.sealedTempHandle, + recovery.rawDirectoryHandle, + recovery.evidenceDirectoryHandle, + ]) { + try { await handle.close(); } catch (error) { failures.push(toError(error)); } + } + if (failures.length > 0) { + throw new AggregateError(failures, "provider guardian recovery directory close failed", { + cause: failures[0], + }); + } +} + +async function closeRecoveryAndThrow( + recovery: RecoveryAuthority, + primaryError: unknown, +): Promise { + const failures = [toError(primaryError)]; + try { + await cleanupStartupRecovery(recovery); + } catch (cleanupError) { + failures.push(toError(cleanupError)); + } + try { + await closeRecoveryAuthority(recovery); + } catch (closeError) { + failures.push(toError(closeError)); + } + if (failures.length > 1) { + throw new AggregateError(failures, + "provider guardian failure and recovery close failed", { cause: failures[0] }); + } + throw failures[0]!; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +function guardianCloseError(result: GuardianResult, stderr: Buffer): Error { + if (result.error) return result.error; + const detail = stderr.toString("utf8").trim(); + return new Error( + `provider guardian failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}${detail ? `, output=${detail}` : ""}`, + ); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/scripts/lib/provider-guardian-protocol.ts b/scripts/lib/provider-guardian-protocol.ts new file mode 100644 index 0000000..b0cada6 --- /dev/null +++ b/scripts/lib/provider-guardian-protocol.ts @@ -0,0 +1,396 @@ +import { timingSafeEqual } from "node:crypto"; + +export type ProviderGuardianKind = "vulnerability" | "provenance"; + +export const MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES = 4_092; +export const MAX_PROVIDER_GUARDIAN_LEASE_MS = 40 * 60 * 1_000; +export const MAX_PROVIDER_SEALED_BYTES = 8_388_608; +const V2_GUARD_KEYS = ["type", "version", "kind", "nonce", "deadlineEpochMs"] as const; +const READY_KEYS = [ + "type", "version", "nonce", "rawDev", "rawIno", + "sealedTempLeaf", "sealedDev", "sealedIno", +] as const; +const PUBLISH_KEYS = [ + "type", "version", "nonce", "sealedDev", "sealedIno", "size", "sha256", +] as const; +const PUBLISHED_KEYS = ["type", "version", "nonce", "sealedDev", "sealedIno"] as const; +const COMMIT_KEYS = ["type", "version", "nonce"] as const; + +export type ProviderGuardianGuard = Readonly<{ + kind: ProviderGuardianKind; + nonce: Buffer; + deadlineEpochMs: number; +}>; + +export type ProviderGuardianReady = Readonly<{ + nonce: Buffer; + rawDev: number; + rawIno: number; + sealedTempLeaf: string; + sealedDev: number; + sealedIno: number; +}>; + +export type ProviderGuardianPublish = Readonly<{ + nonce: Buffer; + sealedDev: number; + sealedIno: number; + size: number; + sha256: string; +}>; + +export type ProviderGuardianPublished = Readonly<{ + nonce: Buffer; + sealedDev: number; + sealedIno: number; +}>; + +export function providerGuardianSealedTempLeaf( + kind: ProviderGuardianKind, + nonce: Buffer, +): string { + const rawLeaf = baseLeaf(kind); + assertV2Nonce(nonce); + return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.tmp`; +} + +export function providerGuardianRawStagingLeaf( + kind: ProviderGuardianKind, + nonce: Buffer, +): string { + const rawLeaf = baseLeaf(kind); + assertV2Nonce(nonce); + return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`; +} + +export function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer { + assertV2Guard(input); + return prefixFrame(encodeV2GuardPayload(input)); +} + +export function decodeProviderGuardianGuard( + payload: Buffer, + options: Readonly<{ nowEpochMs: number; maxLeaseMs: number }>, +): ProviderGuardianGuard { + const value = parseRecord(payload, V2_GUARD_KEYS, "guard"); + const guard: ProviderGuardianGuard = { + kind: value.kind as ProviderGuardianKind, + nonce: parseV2Nonce(value.nonce), + deadlineEpochMs: value.deadlineEpochMs as number, + }; + if (value.type !== "guard" || value.version !== 2) { + throw new TypeError("provider guardian guard version is invalid"); + } + assertV2Guard(guard); + if ( + !Number.isSafeInteger(options.nowEpochMs) || + !Number.isSafeInteger(options.maxLeaseMs) || options.maxLeaseMs <= 0 || + guard.deadlineEpochMs <= options.nowEpochMs || + guard.deadlineEpochMs > options.nowEpochMs + options.maxLeaseMs + ) { + throw new TypeError("provider guardian guard deadline is invalid"); + } + assertCanonical(payload, encodeV2GuardPayload(guard), "guard"); + return guard; +} + +export function encodeProviderGuardianReady(input: ProviderGuardianReady): Buffer { + assertReady(input); + return prefixFrame(encodeReadyPayload(input)); +} + +export function decodeProviderGuardianReady( + payload: Buffer, + expectedNonce: Buffer, +): ProviderGuardianReady { + assertV2Nonce(expectedNonce); + const value = parseRecord(payload, READY_KEYS, "READY"); + const ready: ProviderGuardianReady = { + nonce: parseV2Nonce(value.nonce), + rawDev: value.rawDev as number, + rawIno: value.rawIno as number, + sealedTempLeaf: value.sealedTempLeaf as string, + sealedDev: value.sealedDev as number, + sealedIno: value.sealedIno as number, + }; + if (value.type !== "ready" || value.version !== 2) { + throw new TypeError("provider guardian READY version is invalid"); + } + assertReady(ready); + assertAuthenticatedNonce(ready.nonce, expectedNonce, "READY"); + assertCanonical(payload, encodeReadyPayload(ready), "READY"); + return ready; +} + +export function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer { + assertPublish(input); + return prefixFrame(encodePublishPayload(input)); +} + +export function decodeProviderGuardianPublish( + payload: Buffer, + expectedNonce: Buffer, +): ProviderGuardianPublish { + assertV2Nonce(expectedNonce); + const value = parseRecord(payload, PUBLISH_KEYS, "publish"); + const publish: ProviderGuardianPublish = { + nonce: parseV2Nonce(value.nonce), + sealedDev: value.sealedDev as number, + sealedIno: value.sealedIno as number, + size: value.size as number, + sha256: value.sha256 as string, + }; + if (value.type !== "publish" || value.version !== 2) { + throw new TypeError("provider guardian publish version is invalid"); + } + assertPublish(publish); + assertAuthenticatedNonce(publish.nonce, expectedNonce, "publish"); + assertCanonical(payload, encodePublishPayload(publish), "publish"); + return publish; +} + +export function encodeProviderGuardianPublished(input: ProviderGuardianPublished): Buffer { + assertPublished(input); + return prefixFrame(encodePublishedPayload(input)); +} + +export function decodeProviderGuardianPublished( + payload: Buffer, + expectedNonce: Buffer, + expectedIdentity: Readonly<{ dev: number; ino: number }>, +): void { + assertV2Nonce(expectedNonce); + const value = parseRecord(payload, PUBLISHED_KEYS, "PUBLISHED"); + const published: ProviderGuardianPublished = { + nonce: parseV2Nonce(value.nonce), + sealedDev: value.sealedDev as number, + sealedIno: value.sealedIno as number, + }; + if (value.type !== "published" || value.version !== 2) { + throw new TypeError("provider guardian PUBLISHED version is invalid"); + } + assertPublished(published); + assertAuthenticatedNonce(published.nonce, expectedNonce, "PUBLISHED"); + if (published.sealedDev !== expectedIdentity.dev || published.sealedIno !== expectedIdentity.ino) { + throw new TypeError("provider guardian PUBLISHED identity is invalid"); + } + assertCanonical(payload, encodePublishedPayload(published), "PUBLISHED"); +} + +function encodeV2GuardPayload(input: ProviderGuardianGuard): Buffer { + return Buffer.from(JSON.stringify({ + type: "guard", + version: 2, + kind: input.kind, + nonce: input.nonce.toString("hex"), + deadlineEpochMs: input.deadlineEpochMs, + })); +} + +export function encodeProviderGuardianCommit(nonce: Buffer): Buffer { + assertV2Nonce(nonce); + return prefixFrame(encodeCommitPayload(nonce)); +} + +export function decodeProviderGuardianCommit(payload: Buffer, expectedNonce: Buffer): void { + assertPayloadSize(payload); + assertV2Nonce(expectedNonce); + const decoded = decodeUtf8(payload); + let value: unknown; + try { + value = JSON.parse(decoded); + } catch { + throw new TypeError("provider guardian commit JSON is invalid"); + } + if (!isRecord(value) || !hasExactKeys(value, COMMIT_KEYS)) { + throw new TypeError("provider guardian commit fields are invalid"); + } + const nonce = parseV2Nonce(value.nonce); + if ( + value.type !== "commit" || value.version !== 2 || + nonce.byteLength !== expectedNonce.byteLength || + !timingSafeEqual(nonce, expectedNonce) + ) { + throw new TypeError("provider guardian commit authentication failed"); + } + if (!payload.equals(encodeCommitPayload(nonce))) { + throw new TypeError("provider guardian commit is not canonical"); + } +} + +function encodeCommitPayload(nonce: Buffer): Buffer { + return Buffer.from(JSON.stringify({ + type: "commit", + version: 2, + nonce: nonce.toString("hex"), + })); +} + +function encodeReadyPayload(input: ProviderGuardianReady): Buffer { + return Buffer.from(JSON.stringify({ + type: "ready", + version: 2, + nonce: input.nonce.toString("hex"), + rawDev: input.rawDev, + rawIno: input.rawIno, + sealedTempLeaf: input.sealedTempLeaf, + sealedDev: input.sealedDev, + sealedIno: input.sealedIno, + })); +} + +function encodePublishPayload(input: ProviderGuardianPublish): Buffer { + return Buffer.from(JSON.stringify({ + type: "publish", + version: 2, + nonce: input.nonce.toString("hex"), + sealedDev: input.sealedDev, + sealedIno: input.sealedIno, + size: input.size, + sha256: input.sha256, + })); +} + +function encodePublishedPayload(input: ProviderGuardianPublished): Buffer { + return Buffer.from(JSON.stringify({ + type: "published", + version: 2, + nonce: input.nonce.toString("hex"), + sealedDev: input.sealedDev, + sealedIno: input.sealedIno, + })); +} + +function prefixFrame(payload: Buffer): Buffer { + if (payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) { + throw new TypeError("provider guardian frame size is invalid"); + } + const frame = Buffer.allocUnsafe(payload.byteLength + 4); + frame.writeUInt32BE(payload.byteLength, 0); + payload.copy(frame, 4); + return frame; +} + +function assertV2Guard(input: ProviderGuardianGuard): void { + if ( + (input.kind !== "vulnerability" && input.kind !== "provenance") || + !isV2Nonce(input.nonce) || + !Number.isSafeInteger(input.deadlineEpochMs) || input.deadlineEpochMs <= 0 + ) { + throw new TypeError("provider guardian guard fields are invalid"); + } +} + +function baseLeaf(kind: ProviderGuardianKind): string { + if (kind === "vulnerability") return "vulnerability-report.json"; + if (kind === "provenance") return "provenance-attestation.json"; + throw new TypeError("provider guardian kind is invalid"); +} + +function assertReady(input: ProviderGuardianReady): void { + if ( + !isV2Nonce(input.nonce) || + !isIdentityPart(input.rawDev) || !isIdentityPart(input.rawIno) || + typeof input.sealedTempLeaf !== "string" || + !/^\.(?:vulnerability-report|provenance-attestation)\.json\.guardian-[0-9a-f]{32}\.tmp$/u + .test(input.sealedTempLeaf) || + !isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno) + ) { + throw new TypeError("provider guardian READY fields are invalid"); + } +} + +function assertPublish(input: ProviderGuardianPublish): void { + if ( + !isV2Nonce(input.nonce) || + !isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno) || + !Number.isSafeInteger(input.size) || input.size <= 0 || input.size > MAX_PROVIDER_SEALED_BYTES || + typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(input.sha256) + ) { + throw new TypeError("provider guardian publish fields are invalid"); + } +} + +function assertPublished(input: ProviderGuardianPublished): void { + if ( + !isV2Nonce(input.nonce) || + !isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno) + ) { + throw new TypeError("provider guardian PUBLISHED fields are invalid"); + } +} + +function assertV2Nonce(nonce: Buffer): void { + if (!isV2Nonce(nonce)) throw new TypeError("provider guardian nonce is invalid"); +} + +function isV2Nonce(nonce: Buffer): boolean { + return Buffer.isBuffer(nonce) && nonce.byteLength === 32; +} + +function parseV2Nonce(value: unknown): Buffer { + return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value) + ? Buffer.from(value, "hex") + : Buffer.alloc(0); +} + +function isIdentityPart(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function assertAuthenticatedNonce(received: Buffer, expected: Buffer, label: string): void { + if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) { + throw new TypeError(`provider guardian ${label} authentication failed`); + } +} + +function parseRecord( + payload: Buffer, + expectedKeys: readonly string[], + label: string, +): Record { + assertPayloadSize(payload); + let value: unknown; + try { + value = JSON.parse(decodeUtf8(payload)); + } catch (error) { + if (error instanceof TypeError && /provider guardian/u.test(error.message)) throw error; + throw new TypeError(`provider guardian ${label} JSON is invalid`, { cause: error }); + } + if (!isRecord(value) || !hasExactKeys(value, expectedKeys)) { + throw new TypeError(`provider guardian ${label} fields are invalid`); + } + return value; +} + +function assertCanonical(payload: Buffer, canonical: Buffer, label: string): void { + if (!payload.equals(canonical)) { + throw new TypeError(`provider guardian ${label} frame is not canonical`); + } +} + +function assertPayloadSize(payload: Buffer): void { + if (!Buffer.isBuffer(payload) || payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) { + throw new TypeError("provider guardian frame size is invalid"); + } +} + +function decodeUtf8(payload: Buffer): string { + let decoded: string; + try { + decoded = new TextDecoder("utf-8", { fatal: true }).decode(payload); + } catch { + throw new TypeError("provider guardian frame UTF-8 is invalid"); + } + if (decoded.includes("\0")) throw new TypeError("provider guardian frame contains NUL"); + return decoded; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} diff --git a/scripts/lib/provider-output-limiter.ts b/scripts/lib/provider-output-limiter.ts new file mode 100644 index 0000000..401fa20 --- /dev/null +++ b/scripts/lib/provider-output-limiter.ts @@ -0,0 +1,27 @@ +export type ProviderOutputLimiter = Readonly<{ + consume(chunk: Buffer | string): void; + bytes(): number; +}>; + +export function createProviderOutputLimiter( + maxBytes: number, + onExceeded: () => void, +): ProviderOutputLimiter { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || typeof onExceeded !== "function") { + throw new TypeError("provider output limiter input is invalid"); + } + let observedBytes = 0; + let exceeded = false; + return Object.freeze({ + consume: (chunk: Buffer | string) => { + if (exceeded) return; + const bytes = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk); + observedBytes += bytes; + if (observedBytes > maxBytes) { + exceeded = true; + onExceeded(); + } + }, + bytes: () => observedBytes, + }); +} diff --git a/scripts/lib/provider-process-runner.ts b/scripts/lib/provider-process-runner.ts new file mode 100644 index 0000000..31f1fa8 --- /dev/null +++ b/scripts/lib/provider-process-runner.ts @@ -0,0 +1,103 @@ +import { spawn, type ChildProcess } from "node:child_process"; + +export type ProviderProcessInput = Readonly<{ + executable: string; + arguments: readonly string[]; + environment: NodeJS.ProcessEnv; + timeoutMs: number; +}>; + +type ProviderChild = Pick; + +export async function runProviderProcess( + input: ProviderProcessInput, + dependencies: Readonly<{ + spawnChild?: (input: ProviderProcessInput) => ProviderChild; + setTimer?: (callback: () => void, milliseconds: number) => ReturnType; + clearTimer?: (timer: ReturnType) => void; + killProcessGroup?: (child: ProviderChild) => void; + }> = {}, +): Promise { + if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0) { + throw new TypeError("provider process timeout must be a positive integer"); + } + const child = (dependencies.spawnChild ?? defaultSpawn)(input); + const setTimer = dependencies.setTimer ?? setTimeout; + const clearTimer = dependencies.clearTimer ?? clearTimeout; + const killProcessGroup = dependencies.killProcessGroup ?? defaultKillProcessGroup; + await new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + const killFailures: unknown[] = []; + const settle = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimer(timer); + error ? reject(error) : resolve(); + }; + const timer = setTimer(() => { + timedOut = true; + try { + killProcessGroup(child); + } catch (groupError) { + killFailures.push(groupError); + try { + child.kill("SIGKILL"); + } catch (fallbackError) { + killFailures.push(fallbackError); + } + } + }, input.timeoutMs); + child.once("error", (error: Error) => { + if (!timedOut) settle(error); + }); + child.once("close", (code: number | null, signal: NodeJS.Signals | null) => { + if (timedOut) { + const timeoutError = new Error( + "sandboxed external provider command timed out after process close", + ); + settle( + killFailures.length === 0 + ? timeoutError + : new AggregateError( + [timeoutError, ...killFailures], + "sandboxed external provider timed out and process-group kill failed before close", + { cause: killFailures.at(-1) }, + ), + ); + } else if (code === 0 && signal === null) { + settle(); + } else { + settle( + new Error( + `sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`, + ), + ); + } + }); + }); +} + +function defaultSpawn(input: ProviderProcessInput): ProviderChild { + return spawn(input.executable, [...input.arguments], { + env: input.environment, + stdio: "inherit", + detached: true, + }); +} + +function defaultKillProcessGroup(child: ProviderChild): void { + if (child.pid && child.pid > 0) { + try { + process.kill(-child.pid, "SIGKILL"); + return; + } catch (error) { + if (!hasErrorCode(error, "ESRCH")) throw error; + } + } + child.kill("SIGKILL"); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/provider-raw-cleanup.ts b/scripts/lib/provider-raw-cleanup.ts new file mode 100644 index 0000000..ac2f7ce --- /dev/null +++ b/scripts/lib/provider-raw-cleanup.ts @@ -0,0 +1,40 @@ +import { randomBytes } from "node:crypto"; +import { lstat, rename, unlink } from "node:fs/promises"; + +export type ProviderRawIdentity = Readonly<{ + reportPath: string; + reportDev: number; + reportIno: number; +}>; + +export async function cleanupOwnedProviderReport( + identity: ProviderRawIdentity, +): Promise { + try { + const metadata = await lstat(identity.reportPath); + if (!matchesReportIdentity(metadata, identity)) return false; + const quarantine = `${identity.reportPath}.parent-loss-${process.pid}-${randomBytes(16).toString("hex")}`; + await rename(identity.reportPath, quarantine); + const quarantinedMetadata = await lstat(quarantine); + if (!matchesReportIdentity(quarantinedMetadata, identity)) { + throw new Error("provider raw output identity changed during parent-loss cleanup"); + } + await unlink(quarantine); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } +} + +function matchesReportIdentity( + metadata: Awaited>, + identity: ProviderRawIdentity, +): boolean { + return metadata.isFile() && !metadata.isSymbolicLink() && + metadata.dev === identity.reportDev && metadata.ino === identity.reportIno; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/provider-raw-guardian.ts b/scripts/lib/provider-raw-guardian.ts new file mode 100644 index 0000000..b1b45d6 --- /dev/null +++ b/scripts/lib/provider-raw-guardian.ts @@ -0,0 +1,641 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + fstatSync, + fsyncSync, + lstatSync, + readlinkSync, + readSync, + type Stats, + writeSync, + createReadStream, +} from "node:fs"; +import { link, lstat, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { + decodeProviderGuardianCommit, + decodeProviderGuardianGuard, + decodeProviderGuardianPublish, + encodeProviderGuardianPublished, + encodeProviderGuardianReady, + MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES, + MAX_PROVIDER_GUARDIAN_LEASE_MS, + providerGuardianRawStagingLeaf, + providerGuardianSealedTempLeaf, + type ProviderGuardianGuard, + type ProviderGuardianKind, +} from "./provider-guardian-protocol.ts"; +import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts"; + +type OwnedIdentity = Readonly<{ dev: number; ino: number }>; +type BootstrapAuthority = Readonly<{ + kind: ProviderGuardianKind; + noncePrefix: string; + rawStagingLeaf: string; + rawStagingPath: string; + rawPath: string; + rawIdentity: OwnedIdentity; + sealedTempLeaf: string; + sealedTempPath: string; + sealedPath: string; + sealedIdentity: OwnedIdentity; +}>; +type BoundPrivateAuthority = Readonly<{ + identity: OwnedIdentity; + leaf: string; + noncePrefix: string; + path: string; + stem: string; +}>; +type GuardianTransaction = Readonly<{ guard: ProviderGuardianGuard }>; + +const RAW_DIRECTORY_FD = 3; +const EVIDENCE_DIRECTORY_FD = 4; +const RAW_STAGING_FD = 5; +const SEALED_TEMP_FD = 6; +const RAW_DIRECTORY_PATH = `/proc/self/fd/${RAW_DIRECTORY_FD}`; +const EVIDENCE_DIRECTORY_PATH = `/proc/self/fd/${EVIDENCE_DIRECTORY_FD}`; +const RAW_STAGING_FD_PATH = `/proc/self/fd/${RAW_STAGING_FD}`; +const SEALED_TEMP_FD_PATH = `/proc/self/fd/${SEALED_TEMP_FD}`; +const RAW_STAGING_PATTERN = + /^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.raw\.tmp$/u; +const SEALED_TEMP_PATTERN = + /^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.tmp$/u; + +let privateFdsClosed = false; +const bootstrap = await initializeBootstrap(); +let pending = Buffer.alloc(0); +let expectedBytes: number | undefined; +let state: "starting" | "guarding" | "published" | "commitPending" = "starting"; +let transaction: GuardianTransaction | undefined; +let terminal = false; +let deadline: NodeJS.Timeout | undefined; +let operations = Promise.resolve(); +const liveness = createReadStream("", { fd: 0, autoClose: false }); + +liveness.on("data", consumeChunk); +liveness.once("end", () => { + enqueue(async () => { + if (state === "commitPending" && pending.byteLength === 0 && expectedBytes === undefined) { + await succeedOnCommittedEof(); + return; + } + if (state === "starting" && pending.byteLength > 0) { + await failClosed(126, "provider guardian frame is truncated"); + return; + } + await failClosed(125, "provider guardian liveness EOF"); + }); +}); +liveness.once("error", (error) => { + enqueue(async () => failClosed(125, `provider guardian liveness error: ${error.message}`)); +}); + +async function initializeBootstrap(): Promise { + const rawDirectory = path.resolve(process.cwd(), "provider-evidence/untrusted"); + const evidenceDirectory = path.resolve(process.cwd(), "provider-evidence"); + const failures: Error[] = []; + const rawDirectoryValid = captureInheritedDirectory( + RAW_DIRECTORY_FD, + rawDirectory, + "raw", + failures, + ); + const evidenceDirectoryValid = captureInheritedDirectory( + EVIDENCE_DIRECTORY_FD, + evidenceDirectory, + "evidence", + failures, + ); + + const rawDescriptor = capturePrivateDescriptor(RAW_STAGING_FD, "raw staging", failures); + const sealedDescriptor = capturePrivateDescriptor(SEALED_TEMP_FD, "sealed temp", failures); + const rawAuthority = rawDescriptor && rawDirectoryValid + ? capturePrivateAlias({ + descriptorMetadata: rawDescriptor, + descriptorTarget: RAW_STAGING_FD_PATH, + expectedDirectory: rawDirectory, + descriptorDirectory: RAW_DIRECTORY_PATH, + grammar: RAW_STAGING_PATTERN, + label: "raw staging", + }, failures) + : undefined; + const sealedAuthority = sealedDescriptor && evidenceDirectoryValid + ? capturePrivateAlias({ + descriptorMetadata: sealedDescriptor, + descriptorTarget: SEALED_TEMP_FD_PATH, + expectedDirectory: evidenceDirectory, + descriptorDirectory: EVIDENCE_DIRECTORY_PATH, + grammar: SEALED_TEMP_PATTERN, + label: "sealed temp", + }, failures) + : undefined; + + if (!rawAuthority || !sealedAuthority) { + return await failBootstrap(rawAuthority, sealedAuthority, failures); + } + try { + if ( + rawAuthority.stem !== sealedAuthority.stem || + rawAuthority.noncePrefix !== sealedAuthority.noncePrefix + ) { + throw new TypeError("provider guardian inherited private aliases disagree"); + } + const kind = providerKindFromStem(rawAuthority.stem); + const rawLeaf = kind === "vulnerability" + ? "vulnerability-report.json" + : "provenance-attestation.json"; + return Object.freeze({ + kind, + noncePrefix: rawAuthority.noncePrefix, + rawStagingLeaf: rawAuthority.leaf, + rawStagingPath: rawAuthority.path, + rawPath: `${RAW_DIRECTORY_PATH}/${rawLeaf}`, + rawIdentity: rawAuthority.identity, + sealedTempLeaf: sealedAuthority.leaf, + sealedTempPath: sealedAuthority.path, + sealedPath: `${EVIDENCE_DIRECTORY_PATH}/${rawLeaf}`, + sealedIdentity: sealedAuthority.identity, + }); + } catch (error) { + failures.push(toError(error)); + return await failBootstrap(rawAuthority, sealedAuthority, failures); + } +} + +function captureInheritedDirectory( + fd: number, + canonicalPath: string, + label: string, + failures: Error[], +): boolean { + try { + assertInheritedDirectory(fd, canonicalPath, label); + return true; + } catch (error) { + failures.push(toError(error)); + return false; + } +} + +function capturePrivateDescriptor( + fd: number, + label: string, + failures: Error[], +): Stats | undefined { + try { + return fstatSync(fd); + } catch (error) { + failures.push(new Error(`provider guardian inherited ${label} fd is invalid`, { + cause: error, + })); + return undefined; + } +} + +function capturePrivateAlias( + input: Readonly<{ + descriptorMetadata: Stats; + descriptorTarget: string; + expectedDirectory: string; + descriptorDirectory: string; + grammar: RegExp; + label: string; + }>, + failures: Error[], +): BoundPrivateAuthority | undefined { + try { + return bindPrivateAlias(input); + } catch (error) { + failures.push(toError(error)); + return undefined; + } +} + +function bindPrivateAlias(input: Readonly<{ + descriptorMetadata: Stats; + descriptorTarget: string; + expectedDirectory: string; + descriptorDirectory: string; + grammar: RegExp; + label: string; +}>): BoundPrivateAuthority { + const descriptorTarget = readlinkSync(input.descriptorTarget); + if (path.dirname(descriptorTarget) !== input.expectedDirectory) { + throw new TypeError( + `provider guardian inherited ${input.label} alias is outside its directory`, + ); + } + const leaf = path.basename(descriptorTarget); + const match = input.grammar.exec(leaf); + if (!match) { + throw new TypeError(`provider guardian inherited ${input.label} alias is invalid`); + } + const boundPath = `${input.descriptorDirectory}/${leaf}`; + const pathnameMetadata = lstatSync(boundPath); + assertPrivateMetadata(input.descriptorMetadata, pathnameMetadata, input.label); + return Object.freeze({ + identity: Object.freeze({ + dev: input.descriptorMetadata.dev, + ino: input.descriptorMetadata.ino, + }), + leaf, + noncePrefix: match[2]!, + path: boundPath, + stem: match[1]!, + }); +} + +async function failBootstrap( + rawAuthority: BoundPrivateAuthority | undefined, + sealedAuthority: BoundPrivateAuthority | undefined, + failures: Error[], +): Promise { + for (const authority of [rawAuthority, sealedAuthority]) { + if (!authority) continue; + await cleanupOwnedPath(authority.path, authority.identity, failures); + } + closePrivateFds(failures); + writeAggregateDiagnostic("provider guardian bootstrap failed", failures); + process.exit(126); +} + +function assertPrivateMetadata( + descriptorMetadata: Stats, + pathnameMetadata: Stats, + label: string, +): void { + if ( + !descriptorMetadata.isFile() || !pathnameMetadata.isFile() || + pathnameMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathnameMetadata.dev || + descriptorMetadata.ino !== pathnameMetadata.ino || descriptorMetadata.nlink !== 1 || + pathnameMetadata.nlink !== 1 || (descriptorMetadata.mode & 0o777) !== 0o600 || + (pathnameMetadata.mode & 0o777) !== 0o600 || descriptorMetadata.size !== 0 || + pathnameMetadata.size !== 0 + ) { + throw new TypeError(`provider guardian inherited ${label} identity is invalid`); + } +} + +function providerKindFromStem(stem: string): ProviderGuardianKind { + if (stem === "vulnerability-report") return "vulnerability"; + if (stem === "provenance-attestation") return "provenance"; + throw new TypeError("provider guardian inherited private kind is invalid"); +} + +function consumeChunk(chunk: Buffer | string): void { + if (terminal) return; + pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (expectedBytes === undefined && pending.byteLength >= 4) { + expectedBytes = pending.readUInt32BE(0); + if (expectedBytes <= 0 || expectedBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) { + enqueue(async () => failClosed(126, "provider guardian frame length is invalid")); + return; + } + } + if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) { + const payload = pending.subarray(4); + pending = Buffer.alloc(0); + expectedBytes = undefined; + enqueue(async () => handleFrame(payload)); + } else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) { + enqueue(async () => failClosed(126, "provider guardian frame has trailing bytes")); + } +} + +function enqueue(operation: () => Promise): void { + operations = operations.then(operation).catch(async (error) => { + await failClosed(126, error instanceof Error ? error.message : String(error)); + }); +} + +async function handleFrame(payload: Buffer): Promise { + if (state === "starting") { + await establishTransaction(payload); + } else if (state === "guarding") { + await publishSealedArtifact(payload); + } else if (state === "published") { + await prepareCommit(payload); + } else { + await failClosed(126, "provider guardian received data after commit"); + } +} + +async function establishTransaction(payload: Buffer): Promise { + const nowEpochMs = Date.now(); + const guard = decodeProviderGuardianGuard(payload, { + nowEpochMs, + maxLeaseMs: MAX_PROVIDER_GUARDIAN_LEASE_MS, + }); + if ( + guard.kind !== bootstrap.kind || + guard.nonce.subarray(0, 16).toString("hex") !== bootstrap.noncePrefix || + providerGuardianRawStagingLeaf(guard.kind, guard.nonce) !== bootstrap.rawStagingLeaf || + providerGuardianSealedTempLeaf(guard.kind, guard.nonce) !== bootstrap.sealedTempLeaf + ) { + throw new TypeError("provider guardian guard does not match inherited private aliases"); + } + assertBoundPrivateLeaf( + RAW_STAGING_FD, + bootstrap.rawStagingPath, + bootstrap.rawIdentity, + "raw staging", + ); + assertBoundPrivateLeaf( + SEALED_TEMP_FD, + bootstrap.sealedTempPath, + bootstrap.sealedIdentity, + "sealed temp", + ); + transaction = Object.freeze({ guard }); + + await link(bootstrap.rawStagingPath, bootstrap.rawPath); + assertOwnedPathMetadata(bootstrap.rawStagingPath, bootstrap.rawIdentity, 2, 0o600, 0, + "raw staging link"); + assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 2, 0o600, 0, + "canonical raw link"); + await unlink(bootstrap.rawStagingPath); + fsyncSync(RAW_DIRECTORY_FD); + assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 1, 0o600, 0, + "canonical raw"); + assertBoundPrivateLeaf( + SEALED_TEMP_FD, + bootstrap.sealedTempPath, + bootstrap.sealedIdentity, + "sealed temp", + ); + + const remainingLeaseMs = guard.deadlineEpochMs - Date.now(); + if (remainingLeaseMs <= 0) { + throw new TypeError("provider guardian guard deadline expired during startup"); + } + deadline = setTimeout(() => { + enqueue(async () => failClosed(124, "provider guardian lease deadline expired", true)); + }, remainingLeaseMs); + writeSync(1, encodeProviderGuardianReady({ + nonce: guard.nonce, + rawDev: bootstrap.rawIdentity.dev, + rawIno: bootstrap.rawIdentity.ino, + sealedTempLeaf: bootstrap.sealedTempLeaf, + sealedDev: bootstrap.sealedIdentity.dev, + sealedIno: bootstrap.sealedIdentity.ino, + })); + state = "guarding"; +} + +function assertBoundPrivateLeaf( + fd: number, + target: string, + identity: OwnedIdentity, + label: string, +): void { + const descriptorMetadata = fstatSync(fd); + const pathnameMetadata = lstatSync(target); + assertOwnedMetadata(descriptorMetadata, identity, 1, 0o600, 0, label); + assertOwnedMetadata(pathnameMetadata, identity, 1, 0o600, 0, label); + if (pathnameMetadata.isSymbolicLink()) { + throw new TypeError(`provider guardian ${label} alias became symbolic`); + } +} + +async function publishSealedArtifact(payload: Buffer): Promise { + if (!transaction) { + throw new Error("provider guardian transaction identity is unavailable"); + } + const publication = decodeProviderGuardianPublish(payload, transaction.guard.nonce); + if ( + publication.sealedDev !== bootstrap.sealedIdentity.dev || + publication.sealedIno !== bootstrap.sealedIdentity.ino + ) { + throw new TypeError("provider guardian publish identity is invalid"); + } + assertOwnedMetadata( + fstatSync(SEALED_TEMP_FD), + bootstrap.sealedIdentity, + 1, + 0o400, + publication.size, + "sealed publish descriptor", + ); + const pathnameMetadata = await lstat(bootstrap.sealedTempPath); + assertOwnedMetadata( + pathnameMetadata, + bootstrap.sealedIdentity, + 1, + 0o400, + publication.size, + "sealed publish pathname", + ); + if (pathnameMetadata.isSymbolicLink()) { + throw new TypeError("provider guardian sealed publish pathname became symbolic"); + } + const actualSha256 = hashInheritedFile(SEALED_TEMP_FD, publication.size); + if (actualSha256 !== publication.sha256) { + throw new TypeError("provider guardian publish hash is invalid"); + } + try { + await lstat(bootstrap.sealedPath); + throw new Error("provider guardian sealed output already exists"); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + await link(bootstrap.sealedTempPath, bootstrap.sealedPath); + await unlink(bootstrap.sealedTempPath); + fsyncSync(EVIDENCE_DIRECTORY_FD); + const finalMetadata = await lstat(bootstrap.sealedPath); + assertOwnedMetadata( + finalMetadata, + bootstrap.sealedIdentity, + 1, + 0o400, + publication.size, + "sealed final", + ); + if (finalMetadata.isSymbolicLink()) { + throw new TypeError("provider guardian sealed final became symbolic"); + } + state = "published"; + writeSync(1, encodeProviderGuardianPublished({ + nonce: transaction.guard.nonce, + sealedDev: bootstrap.sealedIdentity.dev, + sealedIno: bootstrap.sealedIdentity.ino, + })); +} + +function assertOwnedPathMetadata( + target: string, + identity: OwnedIdentity, + expectedLinks: number, + expectedMode: number, + expectedSize: number, + label: string, +): void { + const metadata = lstatSync(target); + assertOwnedMetadata(metadata, identity, expectedLinks, expectedMode, expectedSize, label); + if (metadata.isSymbolicLink()) { + throw new TypeError(`provider guardian ${label} became symbolic`); + } +} + +function assertOwnedMetadata( + metadata: Stats, + identity: OwnedIdentity, + expectedLinks: number, + expectedMode: number, + expectedSize: number, + label: string, +): void { + if ( + !metadata.isFile() || metadata.dev !== identity.dev || metadata.ino !== identity.ino || + metadata.nlink !== expectedLinks || (metadata.mode & 0o777) !== expectedMode || + metadata.size !== expectedSize + ) { + throw new TypeError(`provider guardian ${label} metadata is invalid`); + } +} + +function hashInheritedFile(fd: number, size: number): string { + const digest = createHash("sha256"); + const buffer = Buffer.allocUnsafe(Math.min(65_536, size)); + let position = 0; + while (position < size) { + const requested = Math.min(buffer.byteLength, size - position); + const bytesRead = readSync(fd, buffer, 0, requested, position); + if (bytesRead <= 0) throw new Error("provider guardian sealed publish read was truncated"); + digest.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + return digest.digest("hex"); +} + +async function prepareCommit(payload: Buffer): Promise { + if (!transaction) throw new Error("provider guardian transaction identity is unavailable"); + decodeProviderGuardianCommit(payload, transaction.guard.nonce); + const removedRaw = await cleanupOwnedProviderReport({ + reportPath: bootstrap.rawPath, + reportDev: bootstrap.rawIdentity.dev, + reportIno: bootstrap.rawIdentity.ino, + }); + if (!removedRaw) throw new Error("provider guardian raw output disappeared before commit"); + state = "commitPending"; +} + +async function succeedOnCommittedEof(): Promise { + const closeErrors: Error[] = []; + closePrivateFds(closeErrors); + if (closeErrors.length > 0) { + await failClosed( + 126, + "provider guardian private descriptor close failed", + false, + closeErrors, + ); + return; + } + terminal = true; + if (deadline) clearTimeout(deadline); + liveness.removeAllListeners(); + liveness.destroy(); + closeControlInputBestEffort(); + process.exit(0); +} + +async function failClosed( + exitCode: number, + message: string, + forceSignal = false, + priorErrors: readonly Error[] = [], +): Promise { + if (terminal) return; + terminal = true; + if (deadline) clearTimeout(deadline); + liveness.removeAllListeners(); + liveness.destroy(); + const failures = [new Error(message), ...priorErrors]; + await cleanupOwnedPath(bootstrap.rawStagingPath, bootstrap.rawIdentity, failures); + await cleanupOwnedPath(bootstrap.rawPath, bootstrap.rawIdentity, failures); + await cleanupOwnedPath(bootstrap.sealedTempPath, bootstrap.sealedIdentity, failures); + await cleanupOwnedPath(bootstrap.sealedPath, bootstrap.sealedIdentity, failures); + closePrivateFds(failures); + closeControlInputBestEffort(); + writeAggregateDiagnostic("provider guardian failed", failures); + if (forceSignal) { + try { + process.kill(process.pid, "SIGKILL"); + } finally { + process.exit(exitCode); + } + } + process.exit(exitCode); +} + +async function cleanupOwnedPath( + target: string, + identity: OwnedIdentity, + errors: Error[], +): Promise { + try { + await cleanupOwnedProviderReport({ + reportPath: target, + reportDev: identity.dev, + reportIno: identity.ino, + }); + } catch (error) { + errors.push(toError(error)); + } +} + +function closePrivateFds(errors: Error[]): void { + if (privateFdsClosed) return; + privateFdsClosed = true; + for (const fd of [RAW_STAGING_FD, SEALED_TEMP_FD]) { + try { + closeSync(fd); + } catch (error) { + errors.push(toError(error)); + } + } +} + +function closeControlInputBestEffort(): void { + try { + closeSync(0); + } catch { + // Terminal cleanup and the exit status must not depend on a diagnostic fd. + } +} + +function writeAggregateDiagnostic(label: string, failures: readonly Error[]): void { + const aggregate = failures.length > 1 + ? new AggregateError(failures, label, { cause: failures[0] }) + : failures[0]; + const detail = aggregate instanceof AggregateError + ? aggregate.errors.map((error) => toError(error).message).join("; ") + : aggregate?.message ?? label; + try { + writeSync(2, `${label}: ${detail}\n`); + } catch { + // A closed parent-side pipe must not convert fail-closed termination to exit 0. + } +} + +function assertInheritedDirectory(fd: number, canonicalPath: string, label: string): void { + const descriptorMetadata = fstatSync(fd); + const pathMetadata = lstatSync(canonicalPath); + if ( + !descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() || + pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev || + descriptorMetadata.ino !== pathMetadata.ino + ) { + throw new TypeError(`provider guardian inherited ${label} fd is not a directory`); + } +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/provider-scope-wrapper.ts b/scripts/lib/provider-scope-wrapper.ts new file mode 100644 index 0000000..ebe2c16 --- /dev/null +++ b/scripts/lib/provider-scope-wrapper.ts @@ -0,0 +1,166 @@ +import { spawn } from "node:child_process"; +import { closeSync, createReadStream, writeSync } from "node:fs"; + +import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts"; + +const MAX_FRAME_BYTES = 16_777_216; +const reportIdentity = parseReportIdentity(process.argv.slice(2)); +let pending = Buffer.alloc(0); +let expectedBytes: number | undefined; +let provider: ReturnType | undefined; +let providerClosed = false; +let livenessLost = false; +const liveness = createReadStream("", { fd: 0, autoClose: false }); + +liveness.on("data", (chunk: Buffer | string) => { + if (provider) { + terminateForProtocolFailure("provider scope received trailing protocol bytes"); + return; + } + pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (expectedBytes === undefined && pending.byteLength >= 4) { + expectedBytes = pending.readUInt32BE(0); + if (expectedBytes <= 0 || expectedBytes > MAX_FRAME_BYTES) { + terminateForProtocolFailure("provider scope frame length is invalid"); + return; + } + } + if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) { + launchProvider(pending.subarray(4)); + pending = Buffer.alloc(0); + } else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) { + terminateForProtocolFailure("provider scope frame has trailing bytes"); + } +}); + +liveness.once("end", () => terminateForParentLoss()); +liveness.once("error", () => terminateForParentLoss()); + +function launchProvider(payload: Buffer): void { + const frame = parseFrame(payload); + if ( + frame.reportPath !== reportIdentity.reportPath || + frame.reportDev !== reportIdentity.reportDev || + frame.reportIno !== reportIdentity.reportIno + ) { + throw new TypeError("provider scope frame identity does not match its launch identity"); + } + const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64"); + provider = spawn("/usr/bin/bwrap", ["--args", "0"], { + detached: true, + stdio: ["pipe", "inherit", "inherit"], + }); + provider.stdin?.end(bwrapInput); + provider.once("error", (error) => finishProvider(frame, null, null, error)); + provider.once("close", (code, signal) => finishProvider(frame, code, signal)); +} + +async function finishProvider( + frame: ReturnType, + code: number | null, + signal: NodeJS.Signals | null, + error?: Error, +): Promise { + if (providerClosed) return; + providerClosed = true; + if (livenessLost) await cleanupOwnedProviderReport(frame); + closeLivenessInput(); + if (error) { + writeSync(2, `${error.message}\n`); + process.exit(1); + } + if (signal) process.exit(128 + signalNumber(signal)); + process.exit(code ?? 1); +} + +function terminateForParentLoss(): void { + if (livenessLost) return; + livenessLost = true; + if (!provider || providerClosed) { + void cleanupAfterParentLossAndExit(); + return; + } + try { + process.kill(-provider.pid!, "SIGKILL"); + } catch (error) { + if (!hasErrorCode(error, "ESRCH")) throw error; + } +} + +async function cleanupAfterParentLossAndExit(): Promise { + try { + await cleanupOwnedProviderReport(reportIdentity); + } catch (error) { + writeSync(2, `${error instanceof Error ? error.message : String(error)}\n`); + } + closeLivenessInput(); + process.exit(125); +} + +function terminateForProtocolFailure(message: string): void { + writeSync(2, `${message}\n`); + terminateForParentLoss(); +} + +function closeLivenessInput(): void { + liveness.removeAllListeners(); + liveness.destroy(); + try { + closeSync(0); + } catch (error) { + if (!hasErrorCode(error, "EBADF")) throw error; + } +} + +function parseFrame(payload: Buffer): Readonly<{ + bwrapInputBase64: string; + reportPath: string; + reportDev: number; + reportIno: number; +}> { + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as Record; + if ( + typeof value.bwrapInputBase64 !== "string" || + typeof value.reportPath !== "string" || !value.reportPath.startsWith("/") || + !Number.isSafeInteger(value.reportDev) || Number(value.reportDev) <= 0 || + !Number.isSafeInteger(value.reportIno) || Number(value.reportIno) <= 0 + ) { + throw new TypeError("provider scope frame payload is invalid"); + } + return { + bwrapInputBase64: value.bwrapInputBase64, + reportPath: value.reportPath, + reportDev: Number(value.reportDev), + reportIno: Number(value.reportIno), + }; +} + +function parseReportIdentity(arguments_: readonly string[]): Readonly<{ + cpuSeconds: number; + reportPath: string; + reportDev: number; + reportIno: number; +}> { + const [cpuValue, reportPath, devValue, inoValue, ...trailing] = arguments_; + const cpuSeconds = Number(cpuValue); + const reportDev = Number(devValue); + const reportIno = Number(inoValue); + if ( + trailing.length > 0 || + !Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0 || + typeof reportPath !== "string" || !reportPath.startsWith("/") || reportPath.includes("\0") || + !Number.isSafeInteger(reportDev) || reportDev <= 0 || + !Number.isSafeInteger(reportIno) || reportIno <= 0 + ) { + throw new TypeError("provider scope launch identity is invalid"); + } + return { cpuSeconds, reportPath, reportDev, reportIno }; +} + +function signalNumber(signal: NodeJS.Signals): number { + return signal === "SIGKILL" ? 9 : signal === "SIGXCPU" ? 24 : 1; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/lib/provider-supervisor.ts b/scripts/lib/provider-supervisor.ts new file mode 100644 index 0000000..3884fed --- /dev/null +++ b/scripts/lib/provider-supervisor.ts @@ -0,0 +1,163 @@ +import { randomBytes as cryptoRandomBytes } from "node:crypto"; + +import { + captureCiCandidateArchive, + withVerifiedCapturedCandidate, + type CapturedCandidateArchive, +} from "./ci-candidate-archive.ts"; +import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; +import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts"; +import { validateProviderUpload } from "./provider-upload-validator.ts"; + +export type ProviderInvocation = Readonly<{ + candidateRoot: string; + environment: Readonly>; +}>; + +export async function superviseProviderEvidence(input: Readonly<{ + kind: "vulnerability" | "provenance"; + archivePath: string; + expectedArchiveSha256: string; + expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>; + trust: ProviderTrust; + executeProvider: (invocation: ProviderInvocation) => Promise; + captureReport: () => Promise; +}>, dependencies: Readonly<{ + captureArchive?: typeof captureCiCandidateArchive; + withVerifiedCandidate?: typeof withVerifiedCapturedCandidate; + verifyLocalEvidence?: typeof verifyArchivedLocalEvidence; + validateUpload?: typeof validateProviderUpload; + randomBytes?: (bytes: number) => Buffer; + nowEpochMs?: () => number; +}> = {}): Promise> { + const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({ + archivePath: input.archivePath, + expectedSha256: input.expectedArchiveSha256, + }); + const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32); + if (nonceBytes.byteLength !== 32) { + throw new TypeError("provider invocation nonce must contain exactly 32 bytes"); + } + const invocationNonce = nonceBytes.toString("hex"); + const nowEpochMs = dependencies.nowEpochMs ?? Date.now; + const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({ + captured, + verify: async ({ extractionRoot, manifest }) => { + const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({ + extractionRoot, + expectedManifest: manifest, + }); + if (local.status !== "PASS" || !local.identity) { + throw new Error( + `provider candidate local assessment failed: ${local.failures.join("; ")}`, + ); + } + if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) { + throw new Error("provider candidate source revision mismatch"); + } + const expectedContext: ExpectedPromotionContext = Object.freeze({ + run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }), + source: Object.freeze({ + revision: local.identity.sourceRevision, + sourceSetSha256: local.identity.sourceSetSha256, + }), + candidate: Object.freeze({ + archiveSha256: captured.archiveSha256, + bundleSha256: manifest.bundleSha256, + distSha256: manifest.distSha256, + lockfileSha256: manifest.lockfileSha256, + }), + secretScanAttestation: Object.freeze({ + status: "PASS" as const, + localEvidenceAssessmentSha256: local.identity.assessmentSha256, + sourceSetSha256: local.identity.sourceSetSha256, + policySha256: local.identity.secretScan.policySha256, + sarifSha256: local.identity.secretScan.sarifSha256, + scanInputSha256: local.identity.secretScan.scanInputSha256, + }), + vulnerabilityInvocationNonce: + input.kind === "vulnerability" ? invocationNonce : "0".repeat(64), + provenanceInvocationNonce: + input.kind === "provenance" ? invocationNonce : "0".repeat(64), + }); + const issuedNow = nowEpochMs(); + const issuedAt = new Date(issuedNow).toISOString(); + const expiresAt = new Date(issuedNow + 60 * 60 * 1_000).toISOString(); + await input.executeProvider({ + candidateRoot: extractionRoot, + environment: providerInvocationEnvironment({ + kind: input.kind, + expectedContext, + invocationNonce, + issuedAt, + expiresAt, + trust: input.trust, + }), + }); + const capturedReport = await input.captureReport(); + const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({ + kind: input.kind, + verifiedManifest: manifest, + archiveSha256: captured.archiveSha256, + candidateRoot: extractionRoot, + capturedReport, + expectedContext, + trust: input.trust, + nowEpochMs, + }); + return Object.freeze({ evidence, invocationNonce, expectedContext }); + }, + }); + return result; +} + +export function providerInvocationEnvironment(input: Readonly<{ + kind: "vulnerability" | "provenance"; + expectedContext: ExpectedPromotionContext; + invocationNonce: string; + issuedAt: string; + expiresAt: string; + trust: ProviderTrust; +}>): Readonly> { + return Object.freeze({ + PROVIDER_EVIDENCE_SCHEMA_VERSION: "2", + PROVIDER_EVIDENCE_TYPE: + input.kind === "vulnerability" + ? "vulnerability-report" + : "provenance-attestation", + PROVIDER_ISSUED_AT: input.issuedAt, + PROVIDER_EXPIRES_AT: input.expiresAt, + PROVIDER_INVOCATION_NONCE: input.invocationNonce, + PROVIDER_KEY_ID: input.trust.keyId, + PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint, + CI_RUN_ID: input.expectedContext.run.id, + CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt), + SOURCE_REVISION: input.expectedContext.source.revision, + SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256, + CANDIDATE_ROOT: "/candidate", + CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml", + CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256, + CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256, + CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256, + CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256, + SECRET_SCAN_STATUS: input.expectedContext.secretScanAttestation.status, + SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256: + input.expectedContext.secretScanAttestation.localEvidenceAssessmentSha256, + SECRET_SCAN_SOURCE_SET_SHA256: + input.expectedContext.secretScanAttestation.sourceSetSha256, + SECRET_SCAN_POLICY_SHA256: + input.expectedContext.secretScanAttestation.policySha256, + SECRET_SCAN_SARIF_SHA256: + input.expectedContext.secretScanAttestation.sarifSha256, + SECRET_SCAN_INPUT_SHA256: + input.expectedContext.secretScanAttestation.scanInputSha256, + }); +} + +export type CaptureArchiveDependency = ( + input: Readonly<{ archivePath: string; expectedSha256: string }>, +) => Promise; diff --git a/scripts/lib/provider-trust.ts b/scripts/lib/provider-trust.ts new file mode 100644 index 0000000..d838d08 --- /dev/null +++ b/scripts/lib/provider-trust.ts @@ -0,0 +1,42 @@ +import { createPublicKey } from "node:crypto"; +import path from "node:path"; + +import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; +import { + providerPublicKeyFingerprint, + type ProviderTrust, +} from "./provider-evidence.ts"; + +export async function readProviderTrust( + configuredRoot: string, + publicKeyPath: string | undefined, + keyId: string | undefined, +): Promise { + if (!publicKeyPath || !keyId?.trim()) return null; + try { + const root = path.resolve(configuredRoot); + const absolute = path.resolve(root, publicKeyPath); + const relative = path.relative(root, absolute); + const outside = + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + const bytes = await readBoundedRegularFile({ + root: outside ? path.dirname(absolute) : root, + relativePath: outside + ? path.basename(absolute) + : relative.replaceAll(path.sep, "/"), + maxBytes: 1_048_576, + }); + const publicKey = createPublicKey( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + return Object.freeze({ + keyId, + publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), + }); + } catch { + return null; + } +} diff --git a/scripts/lib/provider-upload-validator.ts b/scripts/lib/provider-upload-validator.ts new file mode 100644 index 0000000..321510b --- /dev/null +++ b/scripts/lib/provider-upload-validator.ts @@ -0,0 +1,59 @@ +import { + validateProviderEvidence, + type ExpectedPromotionContext, + type ProviderTrust, +} from "./provider-evidence.ts"; +import { + verifyReleaseCandidate, + type ReleaseCandidateManifest, +} from "./release-candidate.ts"; + +export async function validateProviderUpload(input: Readonly<{ + kind: "vulnerability" | "provenance"; + verifiedManifest: ReleaseCandidateManifest; + archiveSha256: string; + candidateRoot: string; + capturedReport: Buffer; + expectedContext: ExpectedPromotionContext; + trust: ProviderTrust; + nowEpochMs?: () => number; +}>): Promise { + if ( + input.expectedContext.candidate.archiveSha256 !== input.archiveSha256 || + input.expectedContext.candidate.bundleSha256 !== input.verifiedManifest.bundleSha256 || + input.expectedContext.candidate.distSha256 !== input.verifiedManifest.distSha256 || + input.expectedContext.candidate.lockfileSha256 !== input.verifiedManifest.lockfileSha256 + ) { + throw new Error("provider supervisor expected candidate context mismatch"); + } + const verifiedCandidate = await verifyReleaseCandidate( + input.verifiedManifest, + input.candidateRoot, + ); + if (verifiedCandidate.failures.length > 0) { + throw new Error( + `provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`, + ); + } + let report: unknown; + try { + report = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(input.capturedReport), + ) as unknown; + } catch { + throw new TypeError("provider output is not canonical UTF-8 JSON"); + } + const evaluated = validateProviderEvidence({ + kind: input.kind, + value: report, + expected: input.expectedContext, + trust: input.trust, + nowEpochMs: input.nowEpochMs, + }); + if (evaluated.status !== "PASS" || !evaluated.evidence) { + throw new Error( + `provider evidence context validation failed: ${evaluated.failures.join("; ")}`, + ); + } + return evaluated.evidence; +} diff --git a/scripts/lib/realtime-boundaries.ts b/scripts/lib/realtime-boundaries.ts new file mode 100644 index 0000000..16730e7 --- /dev/null +++ b/scripts/lib/realtime-boundaries.ts @@ -0,0 +1,138 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +export type RealtimeBoundaryRuleId = + | "NATIVE_REALTIME_API_OUTSIDE_ADAPTER" + | "PRESENTATION_INTERVAL_OWNER" + | "UNSELECTED_REALTIME_RUNTIME_COMPOSED"; + +export type RealtimeBoundaryViolation = Readonly<{ + ruleId: RealtimeBoundaryRuleId; + file: string; + line: number; +}>; + +const SOURCE_EXTENSION = /\.(?:[cm]?ts|tsx)$/u; +const OWNED_NATIVE_ROOTS = [ + "src/adapters/realtime/", + "src/adapters/web-push/", +] as const; +const REALTIME_ADAPTER_IMPORT = + /(?:from\s*|import\s*\()\s*["'][^"']*\/adapters\/(?:realtime|web-push)(?:\/[^"']*)?["']/gu; +const NATIVE_REALTIME_PATTERNS = [ + /\bnew\s+(?:WebSocket|EventSource|Notification)\s*\(/gu, + /\bNotification\s*\.\s*requestPermission\s*\(/gu, + /\.\s*showNotification\s*\(/gu, + /\.\s*pushManager\s*\.\s*(?:subscribe|getSubscription)\s*\(/gu, + /\bReflect\s*\.\s*get\s*\([^,]+,\s*["'](?:WebSocket|EventSource|Notification|pushManager)["']/gu, +] as const; +const PRESENTATION_INTERVAL = /\bsetInterval\s*\(/gu; + +export async function scanRealtimeBoundaries( + sourceRoot: string, +): Promise { + const absoluteRoot = path.resolve(sourceRoot); + const files = await collectSourceFiles(absoluteRoot); + const violations: RealtimeBoundaryViolation[] = []; + for (const file of files) { + const source = await readFile(file, "utf8"); + const logicalFile = logicalSourcePath(absoluteRoot, file); + inspectFile(source, logicalFile, violations); + } + return Object.freeze( + violations + .sort( + (left, right) => + left.file.localeCompare(right.file) || + left.line - right.line || + left.ruleId.localeCompare(right.ruleId), + ) + .map((violation) => Object.freeze(violation)), + ); +} + +function inspectFile( + source: string, + logicalFile: string, + violations: RealtimeBoundaryViolation[], +): void { + const nativeOwned = OWNED_NATIVE_ROOTS.some((root) => + logicalFile.startsWith(root), + ); + const presentationOwned = + logicalFile.startsWith("src/presentation/") || + /^src\/features\/[^/]+\/presentation\//u.test(logicalFile); + const compositionBoundary = + logicalFile.startsWith("src/bootstrap/") || + /^src\/features\/installed-feature-/u.test(logicalFile); + + const report = ( + ruleId: RealtimeBoundaryRuleId, + index: number, + ): void => { + violations.push({ + ruleId, + file: logicalFile, + line: lineAt(source, index), + }); + }; + + if (!nativeOwned) { + for (const pattern of NATIVE_REALTIME_PATTERNS) { + for (const match of source.matchAll(pattern)) { + report( + "NATIVE_REALTIME_API_OUTSIDE_ADAPTER", + match.index, + ); + } + } + } + if (presentationOwned) { + for (const match of source.matchAll(PRESENTATION_INTERVAL)) { + report("PRESENTATION_INTERVAL_OWNER", match.index); + } + } + if (compositionBoundary) { + for (const match of source.matchAll(REALTIME_ADAPTER_IMPORT)) { + report("UNSELECTED_REALTIME_RUNTIME_COMPOSED", match.index); + } + } +} + +async function collectSourceFiles( + directory: string, +): Promise { + const output: string[] = []; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const resolved = path.join(directory, entry.name); + if ( + entry.isDirectory() && + !["node_modules", "dist", "artifacts", ".tmp"].includes( + entry.name, + ) + ) { + output.push(...(await collectSourceFiles(resolved))); + } else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) { + output.push(resolved); + } + } + return output; +} + +function logicalSourcePath(root: string, file: string): string { + const workspaceRelative = path + .relative(process.cwd(), file) + .split(path.sep) + .join("/"); + if (root === path.resolve("src")) return workspaceRelative; + return path.relative(root, file).split(path.sep).join("/"); +} + +function lineAt(source: string, index: number): number { + let line = 1; + for (let offset = 0; offset < index; offset += 1) { + if (source.charCodeAt(offset) === 10) line += 1; + } + return line; +} diff --git a/scripts/lib/registry-compatibility.ts b/scripts/lib/registry-compatibility.ts new file mode 100644 index 0000000..9245dce --- /dev/null +++ b/scripts/lib/registry-compatibility.ts @@ -0,0 +1,336 @@ +import { createHash } from "node:crypto"; + +export const COMPATIBILITY_IMPACTS = Object.freeze([ + "none", + "additive", + "behavior-change", + "breaking", +] as const); + +type CompatibilityImpact = (typeof COMPATIBILITY_IMPACTS)[number]; +type RegistryRecord = Record & { + registryId?: unknown; + contract?: unknown; + rows?: unknown; +}; +type RegistryChange = { + changeId: string; + registryId: string; + rowName: string; + field: string; + kind: string; + impact: CompatibilityImpact; + before?: unknown; + after?: unknown; +}; +type RegistryDiff = Readonly<{ + impact: CompatibilityImpact; + changes: readonly RegistryChange[]; +}>; + +const impactRank = new Map( + COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]), +); + +export function canonicalizeRegistryValue(value: unknown): unknown { + if (Array.isArray(value)) { + const projected: unknown[] = value.map(canonicalizeRegistryValue); + return projected.every( + (item) => + item === null || + ["string", "number", "boolean"].includes(typeof item), + ) + ? projected.sort((left, right) => + JSON.stringify(left).localeCompare(JSON.stringify(right)), + ) + : projected; + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalizeRegistryValue(item)]), + ); + } + return value; +} + +export function canonicalRegistryJson(value: unknown): string { + return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined"; +} + +export function registrySnapshotDigest(snapshot: unknown): string { + return createHash("sha256") + .update(canonicalRegistryJson(snapshot)) + .digest("hex"); +} + +function strongestImpact( + current: CompatibilityImpact, + candidate: CompatibilityImpact, +): CompatibilityImpact { + return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0) + ? candidate + : current; +} + +function valueType(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function changeId( + registryId: string, + rowName: string, + field: string, + kind: string, +): string { + return `${registryId}:${rowName}:${field}:${kind}`; +} + +/** + * Calculates a semantic diff. Object key and primitive-array ordering is + * canonicalized before comparison and therefore cannot create a false change. + */ +export function diffRegistrySnapshots( + before: Readonly>, + after: Readonly>, +): RegistryDiff { + const changes: RegistryChange[] = []; + let impact: CompatibilityImpact = "none"; + const beforeRegistryRows = (before.registries ?? []) as RegistryRecord[]; + const beforeRegistries = new Map( + beforeRegistryRows.map((registry) => [ + String(registry.registryId), + registry, + ]), + ); + const afterRegistryRows = (after.registries ?? []) as RegistryRecord[]; + const afterRegistries = new Map( + afterRegistryRows.map((registry) => [ + String(registry.registryId), + registry, + ]), + ); + const registryIds = new Set([ + ...beforeRegistries.keys(), + ...afterRegistries.keys(), + ]); + + for (const registryId of [...registryIds].sort()) { + const previous = beforeRegistries.get(registryId); + const current = afterRegistries.get(registryId); + if (!previous || !current) { + const changeImpact = previous ? "breaking" : "additive"; + impact = strongestImpact(impact, changeImpact); + changes.push({ + changeId: changeId(registryId, "*", "*", previous ? "removed" : "added"), + registryId, + rowName: "*", + field: "*", + kind: previous ? "registry-removed" : "registry-added", + impact: changeImpact, + }); + continue; + } + + const previousContract = (previous.contract ?? {}) as Record; + const currentContract = (current.contract ?? {}) as Record; + const contractFields = new Set([ + ...Object.keys(previousContract), + ...Object.keys(currentContract), + ]); + for (const field of [...contractFields].sort()) { + const beforeHas = Object.hasOwn(previousContract, field); + const afterHas = Object.hasOwn(currentContract, field); + const beforeValue = previousContract[field]; + const afterValue = currentContract[field]; + if ( + beforeHas && + afterHas && + canonicalRegistryJson(beforeValue) === + canonicalRegistryJson(afterValue) + ) { + continue; + } + const kind = !beforeHas + ? "contract-field-added" + : !afterHas + ? "contract-field-removed" + : "contract-field-changed"; + impact = strongestImpact(impact, "breaking"); + changes.push({ + changeId: changeId(registryId, "$contract", field, kind), + registryId, + rowName: "$contract", + field, + kind, + impact: "breaking", + before: canonicalizeRegistryValue(beforeValue), + after: canonicalizeRegistryValue(afterValue), + }); + } + + const breakingFields = new Set( + (currentContract.breakingFields ?? []) as string[], + ); + const beforeRows = (previous.rows ?? {}) as Record< + string, + Record + >; + const afterRows = (current.rows ?? {}) as Record< + string, + Record + >; + const rowNames = new Set([ + ...Object.keys(beforeRows), + ...Object.keys(afterRows), + ]); + for (const rowName of [...rowNames].sort()) { + const beforeRow = beforeRows[rowName]; + const afterRow = afterRows[rowName]; + if (!beforeRow || !afterRow) { + const changeImpact = beforeRow ? "breaking" : "additive"; + impact = strongestImpact(impact, changeImpact); + changes.push({ + changeId: changeId( + registryId, + rowName, + "*", + beforeRow ? "removed" : "added", + ), + registryId, + rowName, + field: "*", + kind: beforeRow ? "row-removed" : "row-added", + impact: changeImpact, + }); + continue; + } + + const fields = new Set([ + ...Object.keys(beforeRow), + ...Object.keys(afterRow), + ]); + for (const field of [...fields].sort()) { + const beforeHas = Object.hasOwn(beforeRow, field); + const afterHas = Object.hasOwn(afterRow, field); + const beforeValue = beforeRow[field]; + const afterValue = afterRow[field]; + if ( + beforeHas && + afterHas && + canonicalRegistryJson(beforeValue) === + canonicalRegistryJson(afterValue) + ) { + continue; + } + let kind: string; + let changeImpact: CompatibilityImpact; + if (!beforeHas) { + kind = "field-added"; + changeImpact = "additive"; + } else if (!afterHas) { + kind = "field-removed"; + changeImpact = "breaking"; + } else if (valueType(beforeValue) !== valueType(afterValue)) { + kind = "field-type-changed"; + changeImpact = "breaking"; + } else if ( + Array.isArray(beforeValue) && + Array.isArray(afterValue) && + beforeValue.some( + (item) => + !afterValue.some( + (candidate) => + canonicalRegistryJson(candidate) === + canonicalRegistryJson(item), + ), + ) + ) { + kind = "allowed-value-removed"; + changeImpact = "breaking"; + } else { + kind = "field-changed"; + changeImpact = breakingFields.has(field) + ? "breaking" + : "behavior-change"; + } + impact = strongestImpact(impact, changeImpact); + changes.push({ + changeId: changeId(registryId, rowName, field, kind), + registryId, + rowName, + field, + kind, + impact: changeImpact, + before: canonicalizeRegistryValue(beforeValue), + after: canonicalizeRegistryValue(afterValue), + }); + } + } + } + + return Object.freeze({ + impact, + changes: Object.freeze(changes), + }); +} + +export function verifyRegistryBaselineApproval( + snapshot: Readonly>, + approval: Readonly>, +) { + const actualDigest = registrySnapshotDigest(snapshot); + const approvedDigest = approval.snapshotDigest; + return Object.freeze({ + passed: + approval.schemaVersion === 1 && + typeof approval.owner === "string" && + approval.owner.length > 0 && + typeof approval.approvedAt === "string" && + approvedDigest === actualDigest, + actualDigest, + approvedDigest: + typeof approvedDigest === "string" ? approvedDigest : "missing", + }); +} + +export function validateBreakingEvidence( + diff: RegistryDiff, + evidenceFile: Readonly>, +) { + const entries = (evidenceFile.changes ?? []) as Array>; + const evidence = new Map>( + entries.map((entry) => [String(entry.changeId), entry]), + ); + const failures: string[] = []; + for (const change of diff.changes.filter( + (entry) => entry.impact === "breaking", + )) { + const entry = evidence.get(change.changeId); + if (!entry) { + failures.push(`breaking change missing evidence: ${change.changeId}`); + continue; + } + for (const field of [ + "versionBump", + "migration", + "compatibilityWindow", + "rollback", + "owner", + ]) { + const value = entry[field]; + if (typeof value !== "string" || value.trim().length === 0) { + failures.push( + `breaking change ${change.changeId} missing non-empty ${field}`, + ); + } + } + } + return Object.freeze({ + passed: failures.length === 0, + failures: Object.freeze(failures), + }); +} diff --git a/scripts/lib/release-candidate.ts b/scripts/lib/release-candidate.ts new file mode 100644 index 0000000..3340379 --- /dev/null +++ b/scripts/lib/release-candidate.ts @@ -0,0 +1,261 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; + +import { z } from "zod"; + +import { supplyChainDigest } from "./supply-chain.ts"; + +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +const candidateFileSchema = z + .object({ + path: z.string().min(1), + bytes: z.int().nonnegative(), + sha256, + }) + .strict(); + +export const releaseCandidateManifestSchema = z + .object({ + schemaVersion: z.literal(1), + distSha256: sha256, + lockfileSha256: sha256, + bundleSha256: sha256, + files: z.array(candidateFileSchema).min(1), + }) + .strict(); + +export type ReleaseCandidateManifest = z.infer< + typeof releaseCandidateManifestSchema +>; + +export const RELEASE_CANDIDATE_MANIFEST_PATH = + "artifacts/release/release-candidate.json"; +export const LOCAL_EVIDENCE_ASSESSMENT_PATH = + "artifacts/security/local-evidence-assessment.json"; + +export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([ + "scripts/contracts/release-artifacts.ts", + "scripts/create-release-candidate.ts", + "scripts/generate-supply-chain.ts", + "scripts/lib/build-manifest-outputs.ts", + "scripts/lib/json-schema.ts", + "scripts/lib/local-policy-evidence.ts", + "scripts/lib/local-release-evidence.ts", + "scripts/lib/release-candidate.ts", + "scripts/lib/release-input-evidence.ts", + "scripts/lib/release-runtime-coherence.ts", + "scripts/lib/repository-file-inventory.ts", + "scripts/lib/secret-scan-evaluator.ts", + "scripts/lib/secret-scan-policy.ts", + "scripts/lib/secret-scan.ts", + "scripts/lib/supply-chain.ts", + "scripts/lib/validated-json-artifact.ts", + "src/contracts/release-artifacts.ts", + "src/features/installed-contract-contributions.ts", + "src/features/installed-feature-contracts.ts", +] as const); + +export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([ + "config/security/dependency-baseline.approval.json", + "config/security/dependency-baseline.json", + "config/security/dependency-change-evidence.json", + "config/security/dependency-policy.json", + "config/security/secret-scan-policy.json", + "config/security/vulnerability-exceptions.json", + "config/security/vulnerability-policy.json", + "schemas/artifacts/build-manifest.schema.json", + "schemas/artifacts/dependency-inventory.schema.json", + "schemas/artifacts/supply-chain-verification.schema.json", + ...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS, +] as const); + +export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([ + "pnpm-lock.yaml", + "artifacts/performance/bundle.json", + "artifacts/quality/vite-module-inventory.json", + "artifacts/release/build-manifest.json", + "artifacts/release/checksums.txt", + "artifacts/release/dependency-inventory.json", + "artifacts/release/provenance.json", + "artifacts/release/verification.json", + "artifacts/release/sbom.cdx.json", + "artifacts/security/dependency-diff.json", + "artifacts/security/license-report.json", + LOCAL_EVIDENCE_ASSESSMENT_PATH, + "artifacts/security/scan.sarif", + "artifacts/security/supply-chain-coherence.json", + "artifacts/security/supply-chain-verification.json", + "artifacts/security/vulnerability-report.json", + ...LOCAL_EVIDENCE_POLICY_INPUT_PATHS, +]); + +export type DistOutput = Readonly<{ + path: string; + bytes: number; + gzipBytes: number; + sha256: string; +}>; + +export async function collectDistOutputs( + repositoryRoot = process.cwd(), +): Promise { + const distRoot = path.resolve(repositoryRoot, "dist"); + const files = await regularFilesWithin(distRoot); + if (files.length === 0) { + throw new Error("dist is missing or empty; run the production build first"); + } + return Promise.all( + files.map(async (absolutePath) => { + const content = await readFile(absolutePath); + return Object.freeze({ + path: path + .relative(repositoryRoot, absolutePath) + .replaceAll(path.sep, "/"), + bytes: content.byteLength, + gzipBytes: gzipSync(content).byteLength, + sha256: createHash("sha256").update(content).digest("hex"), + }); + }), + ); +} + +export function distSha256(outputs: readonly DistOutput[]): string { + return supplyChainDigest( + outputs.map(({ path: outputPath, bytes, sha256 }) => ({ + path: outputPath, + bytes, + sha256, + })), + ); +} + +export async function createReleaseCandidateManifest( + repositoryRoot = process.cwd(), +): Promise { + const outputs = await collectDistOutputs(repositoryRoot); + const evidence = await Promise.all( + RELEASE_CANDIDATE_EVIDENCE_PATHS.map((file) => + digestRequiredFile(repositoryRoot, file), + ), + ); + const files = [ + ...outputs.map(({ path: outputPath, bytes, sha256 }) => ({ + path: outputPath, + bytes, + sha256, + })), + ...evidence, + ].sort((left, right) => asciiCompare(left.path, right.path)); + const dependencyInventory = JSON.parse( + await readFile( + path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"), + "utf8", + ), + ) as { lockfileSha256?: unknown }; + const rawLockfileSha256 = evidence.find( + (file) => file.path === "pnpm-lock.yaml", + )?.sha256; + if ( + typeof rawLockfileSha256 !== "string" || + dependencyInventory.lockfileSha256 !== rawLockfileSha256 + ) { + throw new Error( + "raw pnpm-lock digest mismatch with dependency inventory", + ); + } + return releaseCandidateManifestSchema.parse({ + schemaVersion: 1, + distSha256: distSha256(outputs), + lockfileSha256: rawLockfileSha256, + bundleSha256: supplyChainDigest(files), + files, + }); +} + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export async function verifyReleaseCandidate( + value: unknown, + repositoryRoot = process.cwd(), +): Promise> { + const parsed = releaseCandidateManifestSchema.safeParse(value); + if (!parsed.success) { + return Object.freeze({ + manifest: null, + currentDistSha256: null, + failures: Object.freeze(["release candidate manifest schema mismatch"]), + }); + } + const failures: string[] = []; + let actual: ReleaseCandidateManifest | null = null; + try { + actual = await createReleaseCandidateManifest(repositoryRoot); + } catch (error) { + failures.push( + `release candidate inputs unreadable: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (actual) { + if (parsed.data.distSha256 !== actual.distSha256) { + failures.push("release candidate dist digest mismatch"); + } + if (parsed.data.lockfileSha256 !== actual.lockfileSha256) { + failures.push("release candidate lockfile digest mismatch"); + } + if (parsed.data.bundleSha256 !== actual.bundleSha256) { + failures.push("release candidate bundle digest mismatch"); + } + if (JSON.stringify(parsed.data.files) !== JSON.stringify(actual.files)) { + failures.push("release candidate file set or file digest mismatch"); + } + } + return Object.freeze({ + manifest: parsed.data, + currentDistSha256: actual?.distSha256 ?? null, + failures: Object.freeze(failures), + }); +} + +async function digestRequiredFile(repositoryRoot: string, file: string) { + const absolutePath = path.resolve(repositoryRoot, file); + const relative = path.relative(repositoryRoot, absolutePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`candidate path escapes repository root: ${file}`); + } + const metadata = await lstat(absolutePath); + if (!metadata.isFile()) { + throw new Error(`candidate input is not a regular file: ${file}`); + } + const content = await readFile(absolutePath); + return Object.freeze({ + path: file, + bytes: content.byteLength, + sha256: createHash("sha256").update(content).digest("hex"), + }); +} + +async function regularFilesWithin(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((left, right) => + asciiCompare(left.name, right.name), + )) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await regularFilesWithin(target))); + } else if (entry.isFile()) { + files.push(target); + } else { + throw new Error(`dist contains a non-regular entry: ${target}`); + } + } + return files; +} diff --git a/scripts/lib/release-input-evidence.ts b/scripts/lib/release-input-evidence.ts new file mode 100644 index 0000000..3c280e7 --- /dev/null +++ b/scripts/lib/release-input-evidence.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import { supplyChainDigest } from "./supply-chain.ts"; + +export async function digestReleaseInputFiles( + files: readonly string[], + readBytes: (file: string) => Promise = readFile, +): Promise { + const rows = await Promise.all( + [...files].sort().map(async (file) => ({ + path: file, + sha256: createHash("sha256") + .update(await readBytes(file)) + .digest("hex"), + })), + ); + return supplyChainDigest(rows); +} diff --git a/scripts/lib/release-runtime-coherence.ts b/scripts/lib/release-runtime-coherence.ts new file mode 100644 index 0000000..35f103f --- /dev/null +++ b/scripts/lib/release-runtime-coherence.ts @@ -0,0 +1,85 @@ +import { isVersionCompatible } from "../../src/application/policies/compatibility.ts"; +import { verifyContractSet } from "../../src/contracts/contract-set.ts"; +import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts"; +import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts"; +import { compareReleaseToRuntime } from "../../src/contracts/release-tokens.ts"; + +export type ReleaseRuntimeCoherenceInput = Readonly<{ + release: ReleaseArtifact; + runtime: Readonly<{ + BUILD_ID: string; + CONFIG_SCHEMA_VERSION: string; + API_CONTRACT_VERSION?: string; + RELEASE_ID: string; + }>; + contractPackages: readonly InstalledContractPackageIdentity[]; +}>; + +export type ReleaseRuntimeCoherence = Readonly<{ + compatible: boolean; + mismatches: readonly string[]; + warnings: readonly string[]; +}>; + +/** + * Verifies the runtime identity using the release schema's own contract model. + * V1 retains the scalar compatibility policy. V2 has no scalar projection: its + * identity is the exact compiled package tuple set and canonical set digest. + */ +export async function verifyReleaseRuntimeCoherence( + input: ReleaseRuntimeCoherenceInput, +): Promise { + if (input.release.schemaVersion === 1) { + if (input.runtime.API_CONTRACT_VERSION === undefined) { + return compareWithoutContractScalar(input, ["apiContractVersion"]); + } + return compareReleaseToRuntime(input.release, { + ...input.runtime, + API_CONTRACT_VERSION: input.runtime.API_CONTRACT_VERSION, + }); + } + + const comparison = compareWithoutContractScalar(input); + const contractSet = await verifyContractSet({ + expected: input.contractPackages, + manifest: input.release.contractSet, + }); + if (contractSet.ok) return comparison; + + const mismatches = Object.freeze([ + ...comparison.mismatches, + contractSet.code, + ]); + return Object.freeze({ + compatible: false, + mismatches, + warnings: comparison.warnings, + }); +} + +function compareWithoutContractScalar( + input: Pick, + initialMismatches: readonly string[] = [], +): ReleaseRuntimeCoherence { + const mismatches = [...initialMismatches]; + if (input.release.buildId !== input.runtime.BUILD_ID) { + mismatches.push("buildId"); + } + if ( + !isVersionCompatible( + input.release.configSchemaVersion, + input.runtime.CONFIG_SCHEMA_VERSION, + ) + ) { + mismatches.push("configSchemaVersion"); + } + const warnings = + input.release.releaseId === input.runtime.RELEASE_ID + ? [] + : ["releaseId"]; + return Object.freeze({ + compatible: mismatches.length === 0, + mismatches: Object.freeze(mismatches), + warnings: Object.freeze(warnings), + }); +} diff --git a/scripts/lib/removal-fixture.ts b/scripts/lib/removal-fixture.ts new file mode 100644 index 0000000..e325fb6 --- /dev/null +++ b/scripts/lib/removal-fixture.ts @@ -0,0 +1,239 @@ +import { spawnSync } from "node:child_process"; +import { + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + loadCiGateContract, + parseCiGateContract, +} from "../contracts/ci-gates.ts"; +import { generateCiWorkflow } from "../generate-ci-workflow.ts"; + +export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([ + "src", "tests", "recipes", "scripts", "schemas", "config", "public", + ".gitea", ".storybook", "index.html", "package.json", "tsconfig.base.json", + "tsconfig.json", "tsconfig.app.json", "tsconfig.node.json", "tsconfig.test.json", + "tsconfig.recipes.json", "tsconfig.web-worker.json", "tsconfig.service-worker.json", + "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", + "playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts", + "playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts", + ".dependency-cruiser.json", ".nvmrc", +] as const); + +export function requireRemovalFixtureEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required for removal verification`); + return value; +} + +export async function prepareRemovalFixture( + root: string, + copyTargets: readonly string[] = REMOVAL_FIXTURE_COPY_TARGETS, +): Promise { + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + for (const target of copyTargets) { + await cp(target, path.join(root, target), { recursive: true }); + } + await symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "dir"); +} + +export function runRemovalFixturePnpm( + root: string, + pnpmCli: string, + script: string, + extra: readonly string[] = [], +): boolean { + return spawnSync(process.execPath, [pnpmCli, script, ...extra], { + cwd: root, + stdio: "inherit", + env: { ...process.env, CI_CONTRACT_MODE: "removal-fixture" }, + }).status === 0; +} + +export async function filesBelow(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + return (await Promise.all(entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesBelow(target) : [target]; + }))).flat(); +} + +function isWithin(target: string, root: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export async function runtimeImportGraph( + root: string, + runtimeSourceRoots: readonly string[], +): Promise> { + const files = (await filesBelow(root)) + .filter((file) => /\.(?:[cm]?ts|tsx)$/u.test(file)) + .map((file) => path.resolve(file)); + if (files.length === 0) { + throw new Error("removal fixture scanned module universe is empty"); + } + const sourceSet = new Set(files); + const runtimeRoots = runtimeSourceRoots.map((entry) => path.resolve(root, entry)); + const imports = new Map(); + for (const file of files) { + const source = await readFile(file, "utf8"); + const specifiers = [...source.matchAll(/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu)] + .map((match) => match[1]) + .filter((specifier): specifier is string => typeof specifier === "string" && specifier.startsWith(".")); + imports.set(file, specifiers.map((specifier) => { + const base = path.resolve(path.dirname(file), specifier); + return [base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`, path.join(base, "index.ts"), path.join(base, "index.tsx")] + .find((candidate) => sourceSet.has(candidate)) ?? base; + })); + } + const memo = new Map(); + const reachesRuntime = (file: string, visiting = new Set()): boolean => { + if (runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot))) return true; + const known = memo.get(file); + if (known !== undefined) return known; + if (visiting.has(file)) return false; + visiting.add(file); + const reaches = (imports.get(file) ?? []).some((dependency) => + runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) || + (sourceSet.has(dependency) && reachesRuntime(dependency, visiting)) + ); + visiting.delete(file); + memo.set(file, reaches); + return reaches; + }; + const testsRoot = path.resolve(root, "tests"); + return Object.freeze({ + dependentTests: Object.freeze(files.filter((file) => isWithin(file, testsRoot) && reachesRuntime(file))), + importingFiles: Object.freeze(files.filter((file) => + !runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot)) && + (imports.get(file) ?? []).some((dependency) => + runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) + ) + )), + }); +} + +export async function removeRuntimeDependentTests( + root: string, + runtimeSourceRoots: readonly string[], +): Promise { + const graph = await runtimeImportGraph(root, runtimeSourceRoots); + if (graph.dependentTests.length === 0) { + throw new Error("removal fixture: no runtime-dependent tests discovered"); + } + await Promise.all(graph.dependentTests.map((file) => rm(file, { force: true }))); + return graph.dependentTests.length; +} + +export async function assertNoRuntimeImports( + root: string, + runtimeSourceRoots: readonly string[], + capability: string, +): Promise { + const graph = await runtimeImportGraph(root, runtimeSourceRoots); + if (graph.importingFiles.length > 0) { + throw new Error(`Removed ${capability} runtime is still imported by: ${graph.importingFiles.map((file) => path.relative(root, file)).join(", ")}`); + } +} + +export function pruneScriptOrchestration( + scripts: Record, + orchestrationScript: string, + removedScripts: ReadonlySet, +): void { + const command = scripts[orchestrationScript]; + if (!command) return; + scripts[orchestrationScript] = command.split(" && ").filter((segment) => + ![...removedScripts].some((removed) => + new RegExp(`(?:^|\\s)(?:corepack\\s+)?pnpm\\s+${removed.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:\\s|$)`, "u").test(segment) + ) + ).join(" && "); +} + +export async function regenerateRemovalFixtureWorkflow(root: string): Promise { + const contract = await loadCiGateContract(root, { mode: "removal-fixture" }); + await generateCiWorkflow({ root, contract, check: false }); +} + +export async function pruneRemovalFixtureCiContract(options: Readonly<{ + root: string; + removedScripts: ReadonlySet; + removedEvidencePathFragments: readonly string[]; +}>): Promise { + const packagePath = path.join(options.root, "package.json"); + const gatesPath = path.join(options.root, "config/ci/gates.json"); + const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { + scripts: Record; + }; + for (const script of options.removedScripts) delete packageDocument.scripts[script]; + packageDocument.scripts["check:ci-workflow"] = + "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture"; + packageDocument.scripts["check:ci"] = + "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow"; + + const contract = structuredClone( + parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), + ); + const removedCommandIds = new Set( + contract.commands + .filter(({ script }) => options.removedScripts.has(script)) + .map(({ id }) => id), + ); + const missing = [...options.removedScripts].filter( + (script) => !contract.commands.some((command) => command.script === script), + ); + if (missing.length > 0) { + throw new Error(`removal fixture CI command set is incomplete: ${missing.join(", ")}`); + } + const removedArtifactIds = new Set( + contract.artifacts + .filter(({ path: artifactPath }) => + options.removedEvidencePathFragments.some((fragment) => artifactPath.includes(fragment)) + ) + .map(({ id }) => id), + ); + for (const fragment of options.removedEvidencePathFragments) { + if (!contract.artifacts.some(({ path: artifactPath }) => artifactPath.includes(fragment))) { + throw new Error(`removal fixture CI evidence is missing: ${fragment}`); + } + } + contract.commands = contract.commands.filter(({ id }) => !removedCommandIds.has(id)); + contract.artifacts = contract.artifacts + .filter(({ id }) => !removedArtifactIds.has(id)) + .map((artifact) => artifact.production === "command-generated" + ? { + ...artifact, + producerCommandIds: artifact.producerCommandIds.filter( + (commandId) => !removedCommandIds.has(commandId), + ), + } + : artifact) + .filter((artifact) => + artifact.production !== "command-generated" || artifact.producerCommandIds.length > 0 + ); + const retainedArtifactIds = new Set(contract.artifacts.map(({ id }) => id)); + for (const gate of contract.gates) { + gate.commandIds = gate.commandIds.filter((commandId) => !removedCommandIds.has(commandId)); + gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter((artifactId) => + retainedArtifactIds.has(artifactId) + ); + } + const referencedSchemaIds = new Set(contract.artifacts.map(({ schemaId }) => schemaId)); + contract.artifactSchemas = contract.artifactSchemas.filter(({ id }) => + referencedSchemaIds.has(id) + ); + const validated = parseCiGateContract(contract, { mode: "removal-fixture" }); + await Promise.all([ + writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`), + writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`), + ]); +} diff --git a/scripts/lib/repository-file-inventory.ts b/scripts/lib/repository-file-inventory.ts new file mode 100644 index 0000000..c557265 --- /dev/null +++ b/scripts/lib/repository-file-inventory.ts @@ -0,0 +1,327 @@ +import { spawnSync } from "node:child_process"; +import { + lstat, + open, + readdir, + realpath, +} from "node:fs/promises"; +import type { Stats } from "node:fs"; +import path from "node:path"; + +export type GitFileListResult = Readonly<{ + error?: Error; + status: number | null; + signal: NodeJS.Signals | null; + stdout: Buffer; + stderr: Buffer; +}>; + +export type RepositoryFileInventory = Readonly<{ + trackedFiles: readonly string[]; + generatedFiles: readonly string[]; + files: readonly string[]; +}>; + +export type RepositoryFileInventoryPolicy = Readonly<{ + trackedRoots: readonly string[]; + generatedRoots: readonly string[]; + optionalRoots: readonly string[]; +}>; + +type InventoryOptions = Readonly<{ + repositoryRoot?: string; + trackedRoots: readonly string[]; + generatedRoots?: readonly string[]; + optionalRoots?: readonly string[]; + runGit?: (repositoryRoot: string) => GitFileListResult; + lstatPath?: (target: string) => Promise; + realpathPath?: (target: string) => Promise; + assertReadable?: (target: string) => Promise; +}>; + +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function requiredStringArray( + value: unknown, + label: string, + options: Readonly<{ allowEmpty: boolean }>, +): readonly string[] { + if ( + !Array.isArray(value) || + (!options.allowEmpty && value.length === 0) || + value.some((entry) => typeof entry !== "string" || entry.length === 0) + ) { + throw new TypeError(`${label} must be an array of non-empty strings`); + } + if (new Set(value).size !== value.length) { + throw new TypeError(`${label} must not contain duplicate roots`); + } + return Object.freeze([...value] as string[]); +} + +export function parseRepositoryFileInventoryPolicy( + value: unknown, +): RepositoryFileInventoryPolicy { + if (!isRecord(value)) { + throw new TypeError("repository inventory policy must be an object"); + } + return Object.freeze({ + trackedRoots: requiredStringArray(value.trackedRoots, "trackedRoots", { + allowEmpty: false, + }), + generatedRoots: requiredStringArray( + value.generatedRoots, + "generatedRoots", + { allowEmpty: true }, + ), + optionalRoots: + value.optionalRoots === undefined + ? Object.freeze([]) + : requiredStringArray(value.optionalRoots, "optionalRoots", { + allowEmpty: true, + }), + }); +} + +function defaultGitFileList(repositoryRoot: string): GitFileListResult { + const result = spawnSync("git", ["ls-files", "-z"], { + cwd: repositoryRoot, + encoding: "buffer", + maxBuffer: 64 * 1024 * 1024, + }); + return { + ...(result.error ? { error: result.error } : {}), + status: result.status, + signal: result.signal, + stdout: result.stdout ?? Buffer.alloc(0), + stderr: result.stderr ?? Buffer.alloc(0), + }; +} + +async function defaultAssertReadable(target: string): Promise { + const handle = await open(target, "r"); + await handle.close(); +} + +export function normalizeRepositoryRelativePath( + value: string, + label = "repository path", +): string { + if ( + value.length === 0 || + path.posix.isAbsolute(value) || + path.win32.isAbsolute(value) || + value.includes("\\") || + value.includes("\0") || + value.endsWith("/") + ) { + throw new TypeError(`${label} must be a repository-relative POSIX path`); + } + const normalized = path.posix.normalize(value); + if ( + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + normalized !== value + ) { + throw new TypeError(`${label} must be a repository-relative POSIX path`); + } + return normalized; +} + +function isWithinRoot(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function parseGitFileList(result: GitFileListResult): string[] { + if ( + result.error || + result.status !== 0 || + result.signal !== null || + result.stderr.byteLength > 0 + ) { + const detail = result.error?.message ?? result.stderr.toString("utf8").trim(); + throw new Error( + `git ls-files failed${result.signal ? ` (${result.signal})` : ""}${detail ? `: ${detail}` : ""}`, + ); + } + if (result.stdout.byteLength === 0) return []; + if (result.stdout.at(-1) !== 0) { + throw new TypeError("git ls-files returned output without a terminal NUL"); + } + let decoded: string; + try { + decoded = utf8Decoder.decode(result.stdout); + } catch (error) { + throw new TypeError("git ls-files returned malformed UTF-8", { + cause: error, + }); + } + const rows = decoded.slice(0, -1).split("\0"); + if (rows.some((row) => row.length === 0)) { + throw new TypeError("git ls-files returned an empty NUL-delimited path"); + } + const normalized = rows.map((row) => + normalizeRepositoryRelativePath(row, "git ls-files path"), + ); + if (new Set(normalized).size !== normalized.length) { + throw new TypeError("git ls-files returned a duplicate path"); + } + return normalized; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +export async function buildRepositoryFileInventory( + options: InventoryOptions, +): Promise { + const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); + const lstatPath = options.lstatPath ?? lstat; + const realpathPath = options.realpathPath ?? realpath; + const assertReadable = options.assertReadable ?? defaultAssertReadable; + const trackedRoots = options.trackedRoots.map((root) => + normalizeRepositoryRelativePath(root, "tracked root"), + ); + const generatedRoots = (options.generatedRoots ?? []).map((root) => + normalizeRepositoryRelativePath(root, "generated root"), + ); + const optionalRoots = new Set( + (options.optionalRoots ?? []).map((root) => + normalizeRepositoryRelativePath(root, "optional root"), + ), + ); + for (const root of optionalRoots) { + if (!generatedRoots.includes(root)) { + throw new TypeError(`optional root is not generated: ${root}`); + } + } + + const resolvedRepositoryRoot = await realpathPath(repositoryRoot); + + async function validateRegularFile(relativePath: string): Promise { + const absolutePath = path.resolve(repositoryRoot, relativePath); + if (!isWithinRoot(repositoryRoot, absolutePath)) { + throw new TypeError(`repository inventory path escapes root: ${relativePath}`); + } + const metadata = await lstatPath(absolutePath); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new TypeError(`repository inventory path is not a regular file: ${relativePath}`); + } + const resolvedPath = await realpathPath(absolutePath); + if (!isWithinRoot(resolvedRepositoryRoot, resolvedPath)) { + throw new TypeError(`repository inventory symlink escapes root: ${relativePath}`); + } + try { + await assertReadable(absolutePath); + } catch (error) { + throw new Error(`repository inventory file is unreadable: ${relativePath}`, { + cause: error, + }); + } + } + + async function validateRoot( + relativeRoot: string, + optional: boolean, + ): Promise { + const absoluteRoot = path.resolve(repositoryRoot, relativeRoot); + try { + const metadata = await lstatPath(absoluteRoot); + const resolvedRoot = await realpathPath(absoluteRoot); + if (!isWithinRoot(resolvedRepositoryRoot, resolvedRoot)) { + throw new TypeError(`repository root escapes repository: ${relativeRoot}`); + } + if (metadata.isSymbolicLink() || (!metadata.isFile() && !metadata.isDirectory())) { + throw new TypeError(`repository root is not a regular file or directory: ${relativeRoot}`); + } + return metadata; + } catch (error) { + if (optional && hasErrorCode(error, "ENOENT")) return null; + throw new Error(`required repository root is unavailable: ${relativeRoot}`, { + cause: error, + }); + } + } + + for (const root of trackedRoots) { + await validateRoot(root, false); + } + + const trackedFiles = parseGitFileList( + (options.runGit ?? defaultGitFileList)(repositoryRoot), + ).sort(); + for (const root of trackedRoots) { + if (!trackedFiles.some((file) => file === root || file.startsWith(`${root}/`))) { + throw new Error(`required tracked file inventory is empty: ${root}`); + } + } + for (const file of trackedFiles) { + await validateRegularFile(file); + } + + const generatedFiles: string[] = []; + async function collectGenerated(relativeTarget: string): Promise { + const absoluteTarget = path.resolve(repositoryRoot, relativeTarget); + const metadata = await lstatPath(absoluteTarget); + if (metadata.isSymbolicLink()) { + throw new TypeError(`generated inventory path is a symlink: ${relativeTarget}`); + } + if (metadata.isFile()) { + await validateRegularFile(relativeTarget); + generatedFiles.push(relativeTarget); + return; + } + if (!metadata.isDirectory()) { + throw new TypeError(`generated inventory path is not regular: ${relativeTarget}`); + } + const entries = await readdir(absoluteTarget, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + await collectGenerated( + normalizeRepositoryRelativePath( + `${relativeTarget}/${entry.name}`, + "generated inventory path", + ), + ); + } + } + + for (const root of generatedRoots) { + const metadata = await validateRoot(root, optionalRoots.has(root)); + if (metadata) await collectGenerated(root); + } + + if (new Set(generatedFiles).size !== generatedFiles.length) { + throw new TypeError("generated inventory contains duplicate paths"); + } + const uniqueTracked = [...trackedFiles].sort(); + const uniqueGenerated = [...generatedFiles].sort(); + const trackedSet = new Set(uniqueTracked); + const collisions = uniqueGenerated.filter((file) => trackedSet.has(file)); + if (collisions.length > 0) { + throw new TypeError( + `tracked and generated inventory paths collide: ${collisions.join(", ")}`, + ); + } + return Object.freeze({ + trackedFiles: Object.freeze(uniqueTracked), + generatedFiles: Object.freeze(uniqueGenerated), + files: Object.freeze([...uniqueTracked, ...uniqueGenerated].sort()), + }); +} diff --git a/scripts/lib/risk-coverage-files.ts b/scripts/lib/risk-coverage-files.ts new file mode 100644 index 0000000..f756325 --- /dev/null +++ b/scripts/lib/risk-coverage-files.ts @@ -0,0 +1,346 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { + lstat, + mkdir, + open, + realpath, + rename, + rm, +} from "node:fs/promises"; +import path from "node:path"; + +import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; + +type WritableHandle = Readonly<{ + writeFile(data: string): Promise; + sync(): Promise; + close(): Promise; +}>; + +type DirectoryHandle = Readonly<{ + sync(): Promise; + close(): Promise; +}>; + +export type RiskCoverageArtifactFileSystem = Readonly<{ + openFile(target: string, flags: number, mode: number): Promise; + openDirectory(target: string): Promise; + rename(source: string, destination: string): Promise; + rm(target: string, options: Readonly<{ force: true }>): Promise; +}>; + +type WriterDependencies = Readonly<{ + createNonce?: () => string; + fileSystem?: RiskCoverageArtifactFileSystem; +}>; + +type ReadableHandle = Readonly<{ + stat(): Promise; + readFile(encoding: "utf8"): Promise; + close(): Promise; +}>; + +type InputDependencies = Readonly<{ + lstatPath?: (target: string) => Promise; + realpathPath?: (target: string) => Promise; + openFile?: (target: string, flags: number) => Promise; +}>; + +const defaultFileSystem: RiskCoverageArtifactFileSystem = Object.freeze({ + openFile: async (target, flags, mode) => { + const handle = await open(target, flags, mode); + return { + writeFile: async (data) => handle.writeFile(data, "utf8"), + sync: async () => handle.sync(), + close: async () => handle.close(), + }; + }, + openDirectory: async (target) => { + const handle = await open(target, constants.O_RDONLY); + return { + sync: async () => handle.sync(), + close: async () => handle.close(), + }; + }, + rename, + rm, +}); + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +function isWithin(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function hasStableIdentity(metadata: Stats): boolean { + return ( + Number.isSafeInteger(metadata.dev) && + Number.isSafeInteger(metadata.ino) && + metadata.dev > 0 && + metadata.ino > 0 + ); +} + +function sameFileIdentity(before: Stats, after: Stats): boolean { + if (!hasStableIdentity(before) || !hasStableIdentity(after)) { + throw new TypeError("stable file identity unavailable"); + } + return before.dev === after.dev && before.ino === after.ino; +} + +async function rejectSymlinkAncestors( + repositoryRoot: string, + relativePath: string, + label: string, + lstatPath: (target: string) => Promise, +): Promise { + let current = repositoryRoot; + let currentRelative = ""; + const directory = path.posix.dirname(relativePath); + if (directory === ".") return; + for (const segment of directory.split("/")) { + current = path.join(current, segment); + currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment; + const metadata = await lstatPath(current); + if (metadata.isSymbolicLink()) { + throw new TypeError(`${label} ancestor is a symlink: ${currentRelative}`); + } + if (!metadata.isDirectory()) { + throw new TypeError(`${label} ancestor is not a directory: ${currentRelative}`); + } + } +} + +function assertDirectory(metadata: Stats, relativePath: string): void { + if (metadata.isSymbolicLink()) { + throw new TypeError(`artifact ancestor is a symlink: ${relativePath}`); + } + if (!metadata.isDirectory()) { + throw new TypeError(`artifact ancestor is not a directory: ${relativePath}`); + } +} + +export async function readRiskCoverageInput(input: Readonly<{ + repositoryRoot: string; + relativePath: string; + label: string; +}>, dependencies: InputDependencies = {}): Promise> { + const lstatPath = dependencies.lstatPath ?? lstat; + const realpathPath = dependencies.realpathPath ?? realpath; + const openFile = dependencies.openFile ?? + ((target: string, flags: number) => open(target, flags)); + const relativePath = normalizeRepositoryRelativePath( + input.relativePath, + `${input.label} path`, + ); + const repositoryRoot = path.resolve(input.repositoryRoot); + const repositoryRealpath = await realpathPath(repositoryRoot); + const absolutePath = path.resolve(repositoryRoot, relativePath); + await rejectSymlinkAncestors( + repositoryRoot, + relativePath, + input.label, + lstatPath, + ); + const metadata = await lstatPath(absolutePath); + if (metadata.isSymbolicLink()) { + throw new TypeError(`${input.label} path is a symlink: ${relativePath}`); + } + if (!metadata.isFile()) { + throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`); + } + const resolvedPath = await realpathPath(absolutePath); + if (!isWithin(repositoryRealpath, resolvedPath)) { + throw new TypeError(`${input.label} path is outside repository: ${relativePath}`); + } + + const handle = await openFile( + absolutePath, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + const openedMetadata = await handle.stat(); + if (!openedMetadata.isFile()) { + throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`); + } + if (!sameFileIdentity(metadata, openedMetadata)) { + throw new TypeError(`${input.label} path changed during validation: ${relativePath}`); + } + const text = await handle.readFile("utf8"); + return Object.freeze({ relativePath, absolutePath, text }); + } finally { + await handle.close(); + } +} + +export async function resolveRiskCoverageArtifactPath(input: Readonly<{ + repositoryRoot: string; + relativePath: string; + inputPaths: readonly string[]; +}>): Promise { + const relativePath = normalizeRepositoryRelativePath( + input.relativePath, + "artifact path", + ); + if (!relativePath.startsWith("artifacts/quality/")) { + throw new TypeError("artifact path must be below artifacts/quality"); + } + const normalizedInputs = input.inputPaths.map((inputPath) => + normalizeRepositoryRelativePath(inputPath, "input path"), + ); + if (normalizedInputs.includes(relativePath)) { + throw new TypeError(`artifact path must not overwrite an input: ${relativePath}`); + } + + const repositoryRoot = path.resolve(input.repositoryRoot); + const repositoryRealpath = await realpath(repositoryRoot); + const relativeDirectory = path.posix.dirname(relativePath); + let currentDirectory = repositoryRoot; + let currentRelative = ""; + for (const segment of relativeDirectory.split("/")) { + currentDirectory = path.join(currentDirectory, segment); + currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment; + let metadata: Stats; + try { + metadata = await lstat(currentDirectory); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + try { + await mkdir(currentDirectory); + } catch (mkdirError) { + if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError; + } + metadata = await lstat(currentDirectory); + } + assertDirectory(metadata, currentRelative); + const resolvedDirectory = await realpath(currentDirectory); + if (!isWithin(repositoryRealpath, resolvedDirectory)) { + throw new TypeError(`artifact ancestor is outside repository: ${currentRelative}`); + } + } + + const absolutePath = path.resolve(repositoryRoot, relativePath); + let destinationMetadata: Stats | undefined; + try { + destinationMetadata = await lstat(absolutePath); + if (destinationMetadata.isSymbolicLink()) { + throw new TypeError(`artifact path is a symlink: ${relativePath}`); + } + if (!destinationMetadata.isFile()) { + throw new TypeError(`artifact path is not a regular file: ${relativePath}`); + } + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } + if (destinationMetadata) { + if (!hasStableIdentity(destinationMetadata)) { + throw new TypeError("artifact stable file identity unavailable"); + } + const destinationRealpath = await realpath(absolutePath); + for (const inputPath of normalizedInputs) { + const inputAbsolutePath = path.resolve(repositoryRoot, inputPath); + const inputRealpath = await realpath(inputAbsolutePath); + const inputMetadata = await lstat(inputAbsolutePath); + if (!hasStableIdentity(inputMetadata)) { + throw new TypeError(`input stable file identity unavailable: ${inputPath}`); + } + if ( + inputRealpath === destinationRealpath || + (inputMetadata.dev === destinationMetadata.dev && + inputMetadata.ino === destinationMetadata.ino) + ) { + throw new TypeError(`artifact path is the same file as an input: ${inputPath}`); + } + } + } + return absolutePath; +} + +export async function writeRiskCoverageArtifactAtomic( + input: Readonly<{ + repositoryRoot: string; + relativePath: string; + inputPaths: readonly string[]; + value: unknown; + }>, + dependencies: WriterDependencies = {}, +): Promise { + const serialized = JSON.stringify(input.value, null, 2); + if (serialized === undefined) { + throw new TypeError("risk coverage artifact is not JSON serializable"); + } + const destination = await resolveRiskCoverageArtifactPath(input); + const temporaryPath = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${(dependencies.createNonce ?? randomUUID)()}.tmp`, + ); + const fileSystem = dependencies.fileSystem ?? defaultFileSystem; + let ownsTemporaryFile = false; + try { + const handle = await fileSystem.openFile( + temporaryPath, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600, + ); + ownsTemporaryFile = true; + let primaryFailure: unknown; + try { + await handle.writeFile(`${serialized}\n`); + await handle.sync(); + } catch (error) { + primaryFailure = error; + } + try { + await handle.close(); + } catch (error) { + primaryFailure ??= error; + } + if (primaryFailure !== undefined) throw primaryFailure; + + await fileSystem.rename(temporaryPath, destination); + ownsTemporaryFile = false; + const directoryHandle = await fileSystem.openDirectory(path.dirname(destination)); + try { + try { + await directoryHandle.sync(); + } catch (error) { + // Windows and some filesystems do not support fsync on directory handles. + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) { + throw error; + } + } + } finally { + await directoryHandle.close(); + } + } catch (error) { + if (ownsTemporaryFile) { + try { + await fileSystem.rm(temporaryPath, { force: true }); + } catch { + // Preserve the publication failure and clean only our nonce-owned path. + } + } + throw error; + } +} diff --git a/scripts/lib/risk-coverage.ts b/scripts/lib/risk-coverage.ts new file mode 100644 index 0000000..c0b6bc2 --- /dev/null +++ b/scripts/lib/risk-coverage.ts @@ -0,0 +1,901 @@ +import { constants, type Dirent, type Stats } from "node:fs"; +import { + lstat, + open, + readdir, + realpath, +} from "node:fs/promises"; +import path from "node:path"; +import babelParser from "@babel/eslint-parser"; + +import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; + +const coverageMetrics = [ + "lines", + "statements", + "functions", + "branches", +] as const; +const teamIdPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const maximumWaiverDurationMs = 90 * 24 * 60 * 60 * 1_000; + +export const REQUIRED_HIGH_RISK_PATHS = Object.freeze([ + "src/adapters/http/http-execution-v3.ts", + "src/adapters/http/request-builder.ts", + "src/adapters/http/bounded-body-reader.ts", + "src/adapters/http/bounded-json.ts", + "src/bootstrap/read-bounded-boot-json.ts", + "src/adapters/service-worker/service-worker-lifecycle.ts", + "src/adapters/query-cache/server-state-scope-runtime.ts", + "src/bootstrap/load-release-manifest.ts", +] as const); + +type CoverageMetric = (typeof coverageMetrics)[number]; +type Thresholds = Readonly>; +type CoverageCounter = Readonly<{ + total: number; + covered: number; + skipped: number; + pct: number; +}>; +type CoverageMetrics = Readonly>; + +export type RiskCoveragePolicy = Readonly<{ + schemaVersion: 2; + repositoryBaseline: number; + generatedPaths: readonly string[]; + summary: Thresholds; + criticalModules: readonly Readonly<{ + path: string; + owner: string; + minimum: Thresholds; + }>[]; + highRiskPaths: readonly string[]; + waivers: readonly Readonly<{ + path: string; + owner: string; + reason: string; + expiresAt: string; + }>[]; +}>; + +export type ProductionModuleInventory = Readonly<{ + files: readonly string[]; + preExclusionTotal: number; + generatedExclusions: readonly string[]; + counterBearingModules: readonly string[]; + counterlessModules: readonly string[]; +}>; + +export type RiskCoverageResult = Readonly<{ + status: "PASS" | "FAIL"; + selectedTotal: number; + repositoryTotal: number; + counterBearingTotal: number; + instrumentedCounterBearingTotal: number; + counterlessTotal: number; + counterlessModules: readonly string[]; + preExclusionTotal: number; + generatedExclusionCount: number; + generatedExclusions: readonly string[]; + ownershipScope: "ALL_POLICY_HIGH_RISK"; + ownedHighRiskPaths: readonly string[]; + waivedHighRiskPaths: readonly string[]; + uncoveredModules: readonly string[]; + results: readonly Readonly<{ + scope: string; + metric: CoverageMetric; + threshold: number; + received: number; + passed: boolean; + }>[]; + failures: readonly string[]; +}>; + +type ReadableFileHandle = Readonly<{ + stat(): Promise; + readFile(encoding: "utf8"): Promise; + close(): Promise; +}>; +type InventoryOptions = Readonly<{ + repositoryRoot?: string; + generatedPaths?: readonly string[]; + readDirectory?: (target: string) => Promise; + lstatPath?: (target: string) => Promise; + realpathPath?: (target: string) => Promise; + openFile?: (target: string, flags: number) => Promise; +}>; + +class FileIdentityChangedError extends Error {} +class StableFileIdentityUnavailableError extends Error {} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function assertExactKeys( + value: Record, + allowed: readonly string[], + label: string, +): void { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + throw new TypeError(`${label} has unknown fields: ${unknown.sort().join(", ")}`); + } +} + +function exactSourcePath(value: unknown, label: string): string { + if ( + typeof value !== "string" || + ["*", "?", "[", "]", "{", "}"].some((character) => value.includes(character)) + ) { + throw new TypeError(`${label} must be an exact repository-relative POSIX path`); + } + const normalized = normalizeRepositoryRelativePath(value, label); + if (!normalized.startsWith("src/") || !/\.tsx?$/u.test(normalized)) { + throw new TypeError(`${label} must identify a TypeScript module below src`); + } + return normalized; +} + +function uniquePaths( + value: unknown, + label: string, + options: Readonly<{ allowEmpty: boolean }> = { allowEmpty: true }, +): readonly string[] { + if (!Array.isArray(value) || (!options.allowEmpty && value.length === 0)) { + throw new TypeError( + `${label} must be an array${options.allowEmpty ? "" : " with at least one path"}`, + ); + } + const paths = value.map((entry) => exactSourcePath(entry, `${label} entry`)); + if (new Set(paths).size !== paths.length) { + throw new TypeError(`${label} contains a duplicate path`); + } + return Object.freeze(paths); +} + +function teamId(value: unknown, label: string): string { + if (typeof value !== "string" || !teamIdPattern.test(value)) { + throw new TypeError(`${label} must be a canonical team id`); + } + return value; +} + +function thresholds(value: unknown, label: string): Thresholds { + if (!isRecord(value)) throw new TypeError(`${label} must be an object`); + assertExactKeys(value, coverageMetrics, label); + if (coverageMetrics.some((metric) => !(metric in value))) { + throw new TypeError(`${label} must define all coverage metrics`); + } + const parsed = {} as Record; + for (const metric of coverageMetrics) { + const threshold = value[metric]; + if ( + typeof threshold !== "number" || + !Number.isFinite(threshold) || + threshold <= 0 || + threshold > 100 + ) { + throw new TypeError( + `${label}.${metric} minimum must be a finite number greater than 0 and at most 100`, + ); + } + parsed[metric] = threshold; + } + return Object.freeze(parsed); +} + +function waiverReason(value: unknown, label: string): string { + if (typeof value !== "string" || value.length < 12 || value.length > 240) { + throw new TypeError(`${label} must contain 12 to 240 characters`); + } + if (value !== value.trim()) { + throw new TypeError(`${label} must not contain surrounding whitespace`); + } + if ( + [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }) + ) { + throw new TypeError(`${label} must not contain control characters`); + } + return value; +} + +export function parseRiskCoveragePolicy( + value: unknown, + options: Readonly<{ now?: number }> = {}, +): RiskCoveragePolicy { + if (!isRecord(value)) throw new TypeError("risk coverage policy must be an object"); + assertExactKeys( + value, + [ + "schemaVersion", + "repositoryBaseline", + "generatedPaths", + "summary", + "criticalModules", + "highRiskPaths", + "waivers", + ], + "risk coverage policy", + ); + if (value.schemaVersion !== 2) { + throw new TypeError("risk coverage policy schemaVersion must be 2"); + } + if ( + typeof value.repositoryBaseline !== "number" || + !Number.isSafeInteger(value.repositoryBaseline) || + value.repositoryBaseline <= 0 + ) { + throw new TypeError("repositoryBaseline must be a positive safe integer"); + } + + const generatedPaths = uniquePaths(value.generatedPaths, "generatedPaths"); + const highRiskPaths = uniquePaths(value.highRiskPaths, "highRiskPaths", { + allowEmpty: false, + }); + if (!Array.isArray(value.criticalModules) || value.criticalModules.length === 0) { + throw new TypeError("criticalModules must be a non-empty array"); + } + const criticalModules = value.criticalModules.map((candidate, index) => { + if (!isRecord(candidate)) { + throw new TypeError(`criticalModules[${index}] must be an object`); + } + assertExactKeys(candidate, ["path", "owner", "minimum"], `criticalModules[${index}]`); + return Object.freeze({ + path: exactSourcePath(candidate.path, `criticalModules[${index}].path`), + owner: teamId(candidate.owner, `criticalModules[${index}].owner`), + minimum: thresholds(candidate.minimum, `criticalModules[${index}].minimum`), + }); + }); + if (new Set(criticalModules.map((entry) => entry.path)).size !== criticalModules.length) { + throw new TypeError("criticalModules contains a duplicate path"); + } + + if (!Array.isArray(value.waivers)) throw new TypeError("waivers must be an array"); + const currentTime = options.now ?? Date.now(); + if (!Number.isFinite(currentTime)) throw new TypeError("policy time must be finite"); + const waivers = value.waivers.map((candidate, index) => { + if (!isRecord(candidate)) throw new TypeError(`waivers[${index}] must be an object`); + assertExactKeys(candidate, ["path", "owner", "reason", "expiresAt"], `waivers[${index}]`); + const waiverPath = exactSourcePath(candidate.path, `waivers[${index}].path`); + const expiresAt = candidate.expiresAt; + if (typeof expiresAt !== "string") { + throw new TypeError(`waivers[${index}].expiresAt must be a canonical UTC ISO timestamp`); + } + const expiry = Date.parse(expiresAt); + if (!Number.isFinite(expiry) || new Date(expiry).toISOString() !== expiresAt) { + throw new TypeError(`waivers[${index}].expiresAt must be a canonical UTC ISO timestamp`); + } + if (expiry <= currentTime) throw new TypeError(`waivers[${index}] is expired`); + if (expiry - currentTime > maximumWaiverDurationMs) { + throw new TypeError(`waivers[${index}] expiry must be within 90 days`); + } + if (!highRiskPaths.includes(waiverPath)) { + throw new TypeError(`waivers[${index}] is stale because ${waiverPath} is not high-risk`); + } + return Object.freeze({ + path: waiverPath, + owner: teamId(candidate.owner, `waivers[${index}].owner`), + reason: waiverReason(candidate.reason, `waivers[${index}].reason`), + expiresAt, + }); + }); + if (new Set(waivers.map((entry) => entry.path)).size !== waivers.length) { + throw new TypeError("waivers contains a duplicate path"); + } + + const criticalPaths = new Set(criticalModules.map((entry) => entry.path)); + const waiverPaths = new Set(waivers.map((entry) => entry.path)); + for (const highRiskPath of highRiskPaths) { + if (criticalPaths.has(highRiskPath) && waiverPaths.has(highRiskPath)) { + throw new TypeError( + `high-risk module cannot have both a critical owner and waiver: ${highRiskPath}`, + ); + } + if (!criticalPaths.has(highRiskPath) && !waiverPaths.has(highRiskPath)) { + throw new TypeError(`high-risk module has no owner or waiver: ${highRiskPath}`); + } + } + + return Object.freeze({ + schemaVersion: 2, + repositoryBaseline: value.repositoryBaseline, + generatedPaths, + summary: thresholds(value.summary, "summary"), + criticalModules: Object.freeze(criticalModules), + highRiskPaths, + waivers: Object.freeze(waivers), + }); +} + +export function parseRepositoryRiskCoveragePolicy( + value: unknown, + options: Readonly<{ now?: number }> = {}, +): RiskCoveragePolicy { + const policy = parseRiskCoveragePolicy(value, options); + for (const requiredPath of REQUIRED_HIGH_RISK_PATHS) { + if (!policy.highRiskPaths.includes(requiredPath)) { + throw new TypeError(`required high-risk path is missing: ${requiredPath}`); + } + if (policy.generatedPaths.includes(requiredPath)) { + throw new TypeError( + `required high-risk path cannot be generated-excluded: ${requiredPath}`, + ); + } + } + return policy; +} + +function isWithin(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function hasStableIdentity(metadata: Stats): boolean { + return ( + Number.isSafeInteger(metadata.dev) && + Number.isSafeInteger(metadata.ino) && + metadata.dev > 0 && + metadata.ino > 0 + ); +} + +function sameFileIdentity(before: Stats, after: Stats): boolean { + if (!hasStableIdentity(before) || !hasStableIdentity(after)) { + throw new StableFileIdentityUnavailableError( + "stable file identity unavailable", + ); + } + return before.dev === after.dev && before.ino === after.ino; +} + +export function isProductionModulePath(relativePath: string): boolean { + return ( + /\.tsx?$/u.test(relativePath) && + !/\.d\.ts$/u.test(relativePath) && + !/\.stories\.tsx?$/u.test(relativePath) + ); +} + +type TypeScriptAstNode = Readonly<{ + type?: unknown; + body?: unknown; + declaration?: unknown; + declare?: unknown; + const?: unknown; + importKind?: unknown; +}>; + +function statementIsCoverageCounterBearing(value: unknown): boolean { + if (!isRecord(value) || typeof value.type !== "string") { + throw new TypeError("TypeScript parser returned an invalid statement"); + } + const statement = value as TypeScriptAstNode; + if ( + [ + "EmptyStatement", + "TSDeclareFunction", + "TSInterfaceDeclaration", + "TSNamespaceExportDeclaration", + "TSTypeAliasDeclaration", + ].includes(statement.type as string) + ) { + return false; + } + if (statement.type === "ImportDeclaration") { + return false; + } + if ( + statement.type === "ExportAllDeclaration" || + statement.type === "TSExportAssignment" + ) { + return statement.type === "TSExportAssignment"; + } + if ( + statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ) { + return statement.declaration !== null && statement.declaration !== undefined + ? statementIsCoverageCounterBearing(statement.declaration) + : false; + } + if ( + statement.type === "FunctionDeclaration" || + statement.type === "VariableDeclaration" || + statement.type === "ClassDeclaration" || + statement.type === "TSModuleDeclaration" + ) { + return statement.declare !== true && + (statement.type !== "FunctionDeclaration" || statement.body !== null); + } + if (statement.type === "TSEnumDeclaration") { + return statement.declare !== true && statement.const !== true; + } + if (statement.type === "TSImportEqualsDeclaration") { + return statement.importKind !== "type" && statement.declare !== true; + } + return true; +} + +export function hasCoverageCounterBearingStatements( + source: string, + relativePath: string, +): boolean { + let parsed: unknown; + try { + parsed = babelParser.parse(source, { + sourceType: "module", + requireConfigFile: false, + filePath: relativePath, + babelOptions: { + parserOpts: { + plugins: [ + "typescript", + ...(relativePath.endsWith(".tsx") ? ["jsx"] : []), + ], + }, + }, + }); + } catch (error) { + throw new TypeError(`production module has invalid TypeScript syntax: ${relativePath}`, { + cause: error, + }); + } + if (!isRecord(parsed) || !Array.isArray(parsed.body)) { + throw new TypeError("TypeScript parser returned an invalid program"); + } + return parsed.body.some(statementIsCoverageCounterBearing); +} + +export async function buildProductionModuleInventory( + options: InventoryOptions = {}, +): Promise { + const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); + const sourceRoot = path.join(repositoryRoot, "src"); + const readDirectory = options.readDirectory ?? + ((target: string) => readdir(target, { withFileTypes: true })); + const lstatPath = options.lstatPath ?? lstat; + const realpathPath = options.realpathPath ?? realpath; + const openFile = options.openFile ?? + ((target: string, flags: number) => open(target, flags)); + const generatedPaths = uniquePaths(options.generatedPaths ?? [], "generatedPaths"); + const generated = new Set(generatedPaths); + const repositoryRealpath = await realpathPath(repositoryRoot); + const sourceMetadata = await lstatPath(sourceRoot); + if (!sourceMetadata.isDirectory() || sourceMetadata.isSymbolicLink()) { + throw new TypeError("production source root is not a regular directory: src"); + } + const sourceRealpath = await realpathPath(sourceRoot); + if (!isWithin(repositoryRealpath, sourceRealpath)) { + throw new TypeError("production source root is outside repository"); + } + + const allModules: string[] = []; + const counterBearingModules: string[] = []; + const counterlessModules: string[] = []; + async function visit(relativeDirectory: string): Promise { + const absoluteDirectory = path.join(repositoryRoot, relativeDirectory); + const entries = await readDirectory(absoluteDirectory); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const relativeTarget = normalizeRepositoryRelativePath( + `${relativeDirectory}/${entry.name}`, + "production inventory path", + ); + const absoluteTarget = path.join(repositoryRoot, relativeTarget); + const metadata = await lstatPath(absoluteTarget); + if (metadata.isSymbolicLink() || entry.isSymbolicLink()) { + throw new TypeError(`production inventory path is a symlink: ${relativeTarget}`); + } + if (metadata.isDirectory()) { + await visit(relativeTarget); + continue; + } + if (!isProductionModulePath(relativeTarget)) continue; + if (!metadata.isFile()) { + throw new TypeError(`production inventory path is not a regular file: ${relativeTarget}`); + } + const resolvedTarget = await realpathPath(absoluteTarget); + if (!isWithin(repositoryRealpath, resolvedTarget)) { + throw new TypeError(`production inventory path is outside repository: ${relativeTarget}`); + } + let handle: ReadableFileHandle | undefined; + let source: string; + try { + handle = await openFile( + absoluteTarget, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const openedMetadata = await handle.stat(); + if (!openedMetadata.isFile()) { + throw new TypeError("opened target is not a regular file"); + } + if (!sameFileIdentity(metadata, openedMetadata)) { + throw new FileIdentityChangedError("opened file identity changed"); + } + source = await handle.readFile("utf8"); + } catch (error) { + if (error instanceof StableFileIdentityUnavailableError) { + throw new Error( + `production inventory stable file identity unavailable: ${relativeTarget}`, + { cause: error }, + ); + } + if (error instanceof FileIdentityChangedError) { + throw new Error( + `production inventory file changed during validation: ${relativeTarget}`, + { cause: error }, + ); + } + throw new Error(`production inventory file is unreadable: ${relativeTarget}`, { + cause: error, + }); + } finally { + await handle?.close(); + } + allModules.push(relativeTarget); + if (hasCoverageCounterBearingStatements(source, relativeTarget)) { + counterBearingModules.push(relativeTarget); + } else { + counterlessModules.push(relativeTarget); + } + } + } + await visit("src"); + for (const generatedPath of generatedPaths) { + if (!allModules.includes(generatedPath)) { + throw new TypeError(`generated path is stale or not a production module: ${generatedPath}`); + } + } + const inventory = allModules.filter((file) => !generated.has(file)).sort(); + if (inventory.length === 0) throw new Error("production module inventory is empty"); + if (new Set(inventory).size !== inventory.length) { + throw new TypeError("production module inventory contains a duplicate path"); + } + return Object.freeze({ + files: Object.freeze(inventory), + preExclusionTotal: allModules.length, + generatedExclusions: Object.freeze([...generatedPaths].sort()), + counterBearingModules: Object.freeze( + counterBearingModules.filter((file) => !generated.has(file)).sort(), + ), + counterlessModules: Object.freeze( + counterlessModules.filter((file) => !generated.has(file)).sort(), + ), + }); +} + +export function normalizeCoverageProducerPath(input: Readonly<{ + repositoryRoot: string; + rawPath: string; + platform?: "posix" | "win32"; +}>): string { + const platform = input.platform ?? (process.platform === "win32" ? "win32" : "posix"); + const pathApi = platform === "win32" ? path.win32 : path.posix; + const { rawPath } = input; + if (rawPath.includes("\0")) throw new TypeError("coverage path contains NUL"); + if (platform === "posix" && rawPath.includes("\\")) { + throw new TypeError("coverage path must use POSIX separators"); + } + if (platform === "win32" && rawPath.includes("\\") && !pathApi.isAbsolute(rawPath)) { + throw new TypeError("relative coverage path must use POSIX separators"); + } + if (pathApi.isAbsolute(rawPath)) { + const root = pathApi.resolve(input.repositoryRoot); + const relative = pathApi.relative(root, pathApi.resolve(rawPath)); + if ( + !relative || + relative === ".." || + relative.startsWith(`..${pathApi.sep}`) || + pathApi.isAbsolute(relative) + ) { + throw new TypeError(`coverage path is outside repository: ${rawPath}`); + } + return normalizeRepositoryRelativePath( + relative.split(pathApi.sep).join("/"), + "coverage path", + ); + } + return normalizeRepositoryRelativePath(rawPath, "coverage path"); +} + +function expectedPct(total: number, covered: number): number { + return total === 0 ? 100 : Math.floor((covered / total) * 10_000) / 100; +} + +function parseCoverageCounter(value: unknown, label: string): CoverageCounter { + if (!isRecord(value)) throw new TypeError(`${label} must be an object`); + const fields = ["total", "covered", "skipped", "pct"] as const; + assertExactKeys(value, fields, label); + if (fields.some((field) => !(field in value))) { + throw new TypeError(`${label} must define total, covered, skipped, and pct`); + } + for (const count of ["total", "covered", "skipped"] as const) { + if ( + typeof value[count] !== "number" || + !Number.isSafeInteger(value[count]) || + value[count] < 0 + ) { + throw new TypeError(`${label}.${count} must be a nonnegative safe integer`); + } + } + const total = value.total as number; + const covered = value.covered as number; + const skipped = value.skipped as number; + if (covered + skipped > total) { + throw new TypeError(`${label} covered plus skipped must not exceed total`); + } + const pct = value.pct; + const calculatedPct = expectedPct(total, covered); + if (typeof pct !== "number" || !Number.isFinite(pct) || pct !== calculatedPct) { + throw new TypeError(`${label}.pct must equal ${calculatedPct}`); + } + return Object.freeze({ total, covered, skipped, pct }); +} + +function parseCoverageMetrics(value: unknown, label: string): CoverageMetrics { + if (!isRecord(value)) throw new TypeError(`${label} must be an object`); + const unknownMetrics = Object.keys(value).filter( + (metric) => metric !== "branchesTrue" && !coverageMetrics.includes(metric as CoverageMetric), + ); + if (unknownMetrics.length > 0) { + throw new TypeError( + `${label} has unknown coverage metric keys: ${unknownMetrics.sort().join(", ")}`, + ); + } + if (value.branchesTrue !== undefined) { + parseCoverageCounter(value.branchesTrue, `${label}.branchesTrue`); + } + const parsed = {} as Record; + for (const metric of coverageMetrics) { + parsed[metric] = parseCoverageCounter(value[metric], `${label}.${metric}`); + } + return Object.freeze(parsed); +} + +function aggregateCoverage(selected: readonly CoverageMetrics[]): CoverageMetrics { + const aggregate = {} as Record; + for (const metric of coverageMetrics) { + let total = 0; + let covered = 0; + let skipped = 0; + for (const metrics of selected) { + total += metrics[metric].total; + covered += metrics[metric].covered; + skipped += metrics[metric].skipped; + if (![total, covered, skipped].every(Number.isSafeInteger)) { + throw new TypeError(`recomputed coverage ${metric} count exceeds safe integer range`); + } + } + aggregate[metric] = Object.freeze({ + total, + covered, + skipped, + pct: expectedPct(total, covered), + }); + } + return Object.freeze(aggregate); +} + +function assertMatchingTotal( + producer: CoverageMetrics, + recomputed: CoverageMetrics, +): void { + for (const metric of coverageMetrics) { + const actual = producer[metric]; + const expected = recomputed[metric]; + if ( + actual.total !== expected.total || + actual.covered !== expected.covered || + actual.skipped !== expected.skipped || + actual.pct !== expected.pct + ) { + throw new TypeError( + `coverage total.${metric} does not match recomputed inventory total`, + ); + } + } +} + +export function evaluateRiskCoverage(input: Readonly<{ + repositoryRoot?: string; + inventory: ProductionModuleInventory; + policy: RiskCoveragePolicy; + summary: unknown; +}>): RiskCoverageResult { + const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd()); + const inventory = input.inventory.files.map((file) => exactSourcePath(file, "inventory path")); + if (inventory.length === 0) throw new TypeError("production module inventory is empty"); + if (new Set(inventory).size !== inventory.length) { + throw new TypeError("production module inventory contains a duplicate path"); + } + const counterBearingModules = input.inventory.counterBearingModules.map((file) => + exactSourcePath(file, "counter-bearing inventory path"), + ); + const counterlessModules = input.inventory.counterlessModules.map((file) => + exactSourcePath(file, "counterless inventory path"), + ); + const counterBearingSet = new Set(counterBearingModules); + const counterlessSet = new Set(counterlessModules); + const partition = [...counterBearingModules, ...counterlessModules].sort(); + if ( + counterBearingSet.size !== counterBearingModules.length || + counterlessSet.size !== counterlessModules.length || + counterBearingModules.some((file) => counterlessSet.has(file)) || + partition.join("\n") !== [...inventory].sort().join("\n") + ) { + throw new TypeError("production inventory counter-bearing provenance is inconsistent"); + } + const generatedExclusions = input.inventory.generatedExclusions.map((file) => + exactSourcePath(file, "generated exclusion"), + ); + if ( + new Set(generatedExclusions).size !== generatedExclusions.length || + input.inventory.preExclusionTotal !== inventory.length + generatedExclusions.length || + !Number.isSafeInteger(input.inventory.preExclusionTotal) + ) { + throw new TypeError("production inventory provenance is inconsistent"); + } + const policyGenerated = [...input.policy.generatedPaths].sort(); + if (generatedExclusions.join("\n") !== policyGenerated.join("\n")) { + throw new TypeError("production inventory generated exclusions do not match policy"); + } + if (!isRecord(input.summary) || !("total" in input.summary)) { + throw new TypeError("coverage summary must contain total metrics"); + } + const totalMetrics = parseCoverageMetrics(input.summary.total, "coverage total"); + const selected = new Map(); + for (const [rawPath, rawMetrics] of Object.entries(input.summary)) { + if (rawPath === "total") continue; + const normalized = normalizeCoverageProducerPath({ repositoryRoot, rawPath }); + if (selected.has(normalized)) throw new TypeError(`duplicate coverage path: ${normalized}`); + selected.set(normalized, parseCoverageMetrics(rawMetrics, `coverage ${normalized}`)); + } + + const inventorySet = new Set(inventory); + const generatedSet = new Set(generatedExclusions); + for (const selectedPath of selected.keys()) { + if (!inventorySet.has(selectedPath) && !generatedSet.has(selectedPath)) { + throw new TypeError(`unexpected coverage path outside inventory: ${selectedPath}`); + } + } + const inventoryMetrics = inventory.flatMap((file) => { + const metrics = selected.get(file); + return metrics ? [metrics] : []; + }); + const zeroCoverageModules = inventory.filter((file) => { + const metrics = selected.get(file); + return ( + metrics !== undefined && + coverageMetrics.every((metric) => metrics[metric].total === 0) + ); + }); + const zeroCoverageSet = new Set(zeroCoverageModules); + const zeroCounterBearingModules = zeroCoverageModules.filter((file) => + counterBearingSet.has(file), + ); + const counterlessWithCounters = counterlessModules.filter((file) => { + const metrics = selected.get(file); + return metrics !== undefined && !zeroCoverageSet.has(file); + }); + const instrumentedCounterBearingTotal = counterBearingModules.filter((file) => { + const metrics = selected.get(file); + return metrics !== undefined && !zeroCoverageSet.has(file); + }).length; + assertMatchingTotal(totalMetrics, aggregateCoverage([...selected.values()])); + const recomputedInventoryMetrics = aggregateCoverage(inventoryMetrics); + + const failures: string[] = []; + const results: Array<{ + scope: string; + metric: CoverageMetric; + threshold: number; + received: number; + passed: boolean; + }> = []; + function evaluate(scope: string, actual: CoverageMetrics, minimum: Thresholds): void { + for (const metric of coverageMetrics) { + const threshold = minimum[metric]; + const received = actual[metric].pct; + const hasCoverageTotal = actual[metric].total > 0; + const passed = hasCoverageTotal && received >= threshold; + results.push({ scope, metric, threshold, received, passed }); + if (!hasCoverageTotal) { + failures.push(`${scope}.${metric} coverage total must be greater than 0`); + } else if (!passed) { + failures.push(`${scope}.${metric} expected >= ${threshold}, received ${received}`); + } + } + } + + evaluate("total", recomputedInventoryMetrics, input.policy.summary); + if (inventory.length < input.policy.repositoryBaseline) { + failures.push( + `repository module baseline expected >= ${input.policy.repositoryBaseline}, received ${inventory.length}`, + ); + } + const uncoveredModules = inventory + .filter( + (file) => + !selected.has(file) || + (counterBearingSet.has(file) && zeroCoverageSet.has(file)), + ) + .sort(); + failures.push( + ...inventory + .filter((file) => !selected.has(file)) + .map((file) => `production module missing from coverage: ${file}`), + ...zeroCounterBearingModules.map( + (file) => `counter-bearing module has zero coverage totals: ${file}`, + ), + ...counterlessWithCounters.map( + (file) => `counterless module has coverage counters: ${file}`, + ), + ); + for (const modulePolicy of input.policy.criticalModules) { + if (!inventorySet.has(modulePolicy.path)) { + failures.push(`critical module is outside production inventory: ${modulePolicy.path}`); + continue; + } + if (counterlessSet.has(modulePolicy.path)) { + failures.push( + `critical policy-sensitive module cannot be counterless: ${modulePolicy.path}`, + ); + } + const actual = selected.get(modulePolicy.path); + if (!actual) { + failures.push(`critical module missing from coverage: ${modulePolicy.path}`); + continue; + } + evaluate(modulePolicy.path, actual, modulePolicy.minimum); + } + + const criticalPaths = new Set(input.policy.criticalModules.map((entry) => entry.path)); + const waiverPaths = new Set(input.policy.waivers.map((entry) => entry.path)); + for (const highRiskPath of input.policy.highRiskPaths) { + if (!inventorySet.has(highRiskPath)) { + failures.push(`high-risk module is outside production inventory: ${highRiskPath}`); + } + if (counterlessSet.has(highRiskPath)) { + failures.push( + `high-risk policy-sensitive module cannot be counterless: ${highRiskPath}`, + ); + } + } + for (const waiver of input.policy.waivers) { + if (!inventorySet.has(waiver.path)) failures.push(`coverage waiver is stale: ${waiver.path}`); + } + const ownedHighRiskPaths = input.policy.highRiskPaths + .filter((modulePath) => criticalPaths.has(modulePath)) + .sort(); + const waivedHighRiskPaths = input.policy.highRiskPaths + .filter((modulePath) => waiverPaths.has(modulePath)) + .sort(); + + return Object.freeze({ + status: failures.length === 0 ? "PASS" : "FAIL", + selectedTotal: inventory.length - uncoveredModules.length, + repositoryTotal: inventory.length, + counterBearingTotal: counterBearingModules.length, + instrumentedCounterBearingTotal, + counterlessTotal: counterlessModules.length, + counterlessModules: Object.freeze([...counterlessModules].sort()), + preExclusionTotal: input.inventory.preExclusionTotal, + generatedExclusionCount: generatedExclusions.length, + generatedExclusions: Object.freeze([...generatedExclusions].sort()), + ownershipScope: "ALL_POLICY_HIGH_RISK", + ownedHighRiskPaths: Object.freeze(ownedHighRiskPaths), + waivedHighRiskPaths: Object.freeze(waivedHighRiskPaths), + uncoveredModules: Object.freeze(uncoveredModules), + results: Object.freeze(results), + failures: Object.freeze(failures), + }); +} diff --git a/scripts/lib/secret-scan-evaluator.ts b/scripts/lib/secret-scan-evaluator.ts new file mode 100644 index 0000000..3470141 --- /dev/null +++ b/scripts/lib/secret-scan-evaluator.ts @@ -0,0 +1,283 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +import { + buildRepositoryFileInventory, + parseRepositoryFileInventoryPolicy, +} from "./repository-file-inventory.ts"; +import { + findSecretMatches, + secretScanRules, + type SecretAllowlistEntry, + type SecretFinding, +} from "./secret-scan.ts"; +import { + parseSecretScanIncludedPaths, + selectIncludedInventoryFiles, +} from "./secret-scan-policy.ts"; +import { supplyChainDigest } from "./supply-chain.ts"; + +const nonEmptyString = z.string().min(1); +const sarifRuleSchema = z + .object({ + id: nonEmptyString, + shortDescription: z.object({ text: nonEmptyString }).strict(), + }) + .strict(); +const sarifResultSchema = z + .object({ + ruleId: nonEmptyString, + message: z.object({ text: nonEmptyString }).strict(), + partialFingerprints: z + .object({ primaryLocationLineHash: nonEmptyString }) + .strict() + .optional(), + locations: z + .array( + z + .object({ + physicalLocation: z + .object({ + artifactLocation: z.object({ uri: nonEmptyString }).strict(), + region: z.object({ startLine: z.int().positive() }).strict(), + }) + .strict(), + }) + .strict(), + ) + .optional(), + }) + .strict(); + +export const secretScanSarifSchema = z + .object({ + version: z.literal("2.1.0"), + $schema: z.literal("https://json.schemastore.org/sarif-2.1.0.json"), + runs: z + .array( + z + .object({ + tool: z + .object({ + driver: z + .object({ + name: z.literal("ca-frontend-secret-scan"), + rules: z.array(sarifRuleSchema), + }) + .strict(), + }) + .strict(), + results: z.array(sarifResultSchema), + }) + .strict(), + ) + .length(1), + }) + .strict(); + +type AllowlistEntry = SecretAllowlistEntry & + Readonly<{ owner: string; reason: string }>; +export type SecretScanPolicy = Readonly<{ + excludedPaths: readonly string[]; + trackedRoots: readonly string[]; + generatedRoots: readonly string[]; + optionalRoots: readonly string[]; + includedPaths: readonly string[] | null; + allowlist: readonly AllowlistEntry[]; +}>; + +export function parseSecretScanPolicy(value: unknown): SecretScanPolicy { + const document = isRecord(value) ? value : {}; + const inventoryPolicy = parseRepositoryFileInventoryPolicy(value); + const allowlist = Array.isArray(document.allowlist) + ? document.allowlist.map((rawEntry) => { + const entry = isRecord(rawEntry) ? rawEntry : {}; + return Object.freeze({ + path: typeof entry.path === "string" ? entry.path : "", + ruleId: typeof entry.ruleId === "string" ? entry.ruleId : "", + owner: typeof entry.owner === "string" ? entry.owner : "", + reason: typeof entry.reason === "string" ? entry.reason : "", + expiresAt: + typeof entry.expiresAt === "string" ? entry.expiresAt : "", + }); + }) + : []; + return Object.freeze({ + excludedPaths: Object.freeze(strings(document.excludedPaths)), + trackedRoots: inventoryPolicy.trackedRoots, + generatedRoots: inventoryPolicy.generatedRoots, + optionalRoots: inventoryPolicy.optionalRoots, + includedPaths: parseSecretScanIncludedPaths(document.includedPaths), + allowlist: Object.freeze(allowlist), + }); +} + +export async function evaluateRepositorySecretScan(input: Readonly<{ + repositoryRoot?: string; + policyPath?: string; + now?: number; +}>) { + const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd()); + const policy = parseSecretScanPolicy( + JSON.parse( + await readFile( + path.resolve( + repositoryRoot, + input.policyPath ?? "config/security/secret-scan-policy.json", + ), + "utf8", + ), + ) as unknown, + ); + const inventory = await buildRepositoryFileInventory({ + repositoryRoot, + trackedRoots: policy.trackedRoots, + generatedRoots: policy.generatedRoots, + optionalRoots: policy.optionalRoots, + }); + return evaluateSecretScan({ + policy, + inventoryFiles: inventory.files, + readText: (file) => readFile(path.join(repositoryRoot, file), "utf8"), + now: input.now, + }); +} + +export async function evaluateSecretScan(input: Readonly<{ + policy: SecretScanPolicy; + inventoryFiles: readonly string[]; + readText: (file: string) => Promise; + now?: number; +}>) { + const now = input.now ?? Date.now(); + const findings: SecretFinding[] = []; + const policyFailures: string[] = []; + for (const entry of input.policy.allowlist) { + const expiry = Date.parse(entry.expiresAt); + if ( + !entry.path.startsWith("tests/") || + !entry.owner.trim() || + !entry.reason.trim() || + !Number.isFinite(expiry) || + expiry <= now + ) { + policyFailures.push( + `invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`, + ); + } + } + const scanFiles = selectIncludedInventoryFiles( + input.inventoryFiles, + input.policy.includedPaths, + ); + const excluded = new Set( + input.policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")), + ); + const scanInputs: Readonly<{ path: string; bytes: number; sha256: string }>[] = []; + for (const scanFile of [...new Set(scanFiles)].sort()) { + const normalized = scanFile.replaceAll("\\", "/"); + if ( + [...excluded].some( + (entry) => + normalized === entry || normalized.startsWith(`${entry}/`), + ) || + /\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/iu.test(normalized) + ) { + continue; + } + const content = await input.readText(scanFile); + const bytes = Buffer.from(content, "utf8"); + scanInputs.push(Object.freeze({ + path: normalized, + bytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + })); + findings.push( + ...findSecretMatches(normalized, content, { + allowlist: input.policy.allowlist, + now, + }), + ); + } + const sarif = secretScanSarifSchema.parse({ + version: "2.1.0", + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + runs: [ + { + tool: { + driver: { + name: "ca-frontend-secret-scan", + rules: secretScanRules().map((pattern) => ({ + id: pattern.id, + shortDescription: { text: "Potential credential material" }, + })), + }, + }, + results: [ + ...findings.map((finding) => ({ + ruleId: finding.ruleId, + message: { text: "Potential secret material must be removed." }, + partialFingerprints: { + primaryLocationLineHash: finding.fingerprint, + }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: finding.file }, + region: { startLine: finding.line }, + }, + }, + ], + })), + ...policyFailures.map((failure) => ({ + ruleId: "invalid-allowlist", + message: { text: failure }, + })), + ], + }, + ], + }); + return Object.freeze({ + findings: Object.freeze(findings), + policyFailures: Object.freeze(policyFailures), + scanFiles: Object.freeze([...scanFiles]), + scanInputs: Object.freeze(scanInputs), + scanInputSha256: supplyChainDigest(scanInputs), + sarif, + }); +} + +export function verifyStoredSecretScan( + evaluation: Awaited>, + stored: unknown, +): string[] { + const failures: string[] = []; + const blockingCount = + evaluation.findings.length + evaluation.policyFailures.length; + if (blockingCount > 0) { + failures.push( + `recomputed secret scan contains ${blockingCount} blocking result(s)`, + ); + } + const parsed = secretScanSarifSchema.safeParse(stored); + if ( + !parsed.success || + supplyChainDigest(parsed.data) !== supplyChainDigest(evaluation.sarif) + ) { + failures.push("stored secret scan SARIF does not match recomputed results"); + } + return failures; +} + +function strings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/lib/secret-scan-policy.ts b/scripts/lib/secret-scan-policy.ts new file mode 100644 index 0000000..0b2e42b --- /dev/null +++ b/scripts/lib/secret-scan-policy.ts @@ -0,0 +1,59 @@ +import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; + +export function parseSecretScanIncludedPaths( + value: unknown, +): readonly string[] | null { + if (value === undefined) return null; + if ( + !Array.isArray(value) || + value.length === 0 || + value.some( + (entry) => + typeof entry !== "string" || + entry.length === 0 || + entry.trim() !== entry, + ) + ) { + throw new TypeError( + "includedPaths must be a non-empty array of repository-relative POSIX paths", + ); + } + const normalized = value.map((entry) => + normalizeRepositoryRelativePath(entry as string, "included path"), + ); + if (new Set(normalized).size !== normalized.length) { + throw new TypeError("includedPaths must not contain duplicate paths"); + } + return Object.freeze(normalized); +} + +export function selectIncludedInventoryFiles( + inventoryFiles: readonly string[], + includedPaths: readonly string[] | null, +): readonly string[] { + if (includedPaths === null) return Object.freeze([...inventoryFiles]); + const validatedIncludedPaths = parseSecretScanIncludedPaths(includedPaths); + if (validatedIncludedPaths === null) { + throw new TypeError("includedPaths unexpectedly omitted"); + } + for (const includedPath of validatedIncludedPaths) { + if ( + !inventoryFiles.some( + (file) => + file === includedPath || file.startsWith(`${includedPath}/`), + ) + ) { + throw new Error( + `secret scan included path matches no inventory file: ${includedPath}`, + ); + } + } + return Object.freeze( + inventoryFiles.filter((file) => + validatedIncludedPaths.some( + (includedPath) => + file === includedPath || file.startsWith(`${includedPath}/`), + ), + ), + ); +} diff --git a/scripts/lib/secret-scan.ts b/scripts/lib/secret-scan.ts new file mode 100644 index 0000000..c9ab8d9 --- /dev/null +++ b/scripts/lib/secret-scan.ts @@ -0,0 +1,73 @@ +import { createHash } from "node:crypto"; + +export type SecretFinding = Readonly<{ + ruleId: string; + file: string; + line: number; + fingerprint: string; +}>; + +export type SecretAllowlistEntry = Readonly<{ + path: string; + ruleId: string; + expiresAt: string; +}>; + +const secretPatterns: readonly Readonly<{ + id: string; + expression: RegExp; +}>[] = [ + { + id: "private-key", + expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g, + }, + { id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g }, + { id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g }, + { + id: "assigned-secret", + expression: + /(?[] { + return secretPatterns; +} + +export function findSecretMatches( + file: string, + content: string, + options: Readonly<{ + allowlist?: readonly SecretAllowlistEntry[]; + now?: number; + }> = {}, +): SecretFinding[] { + const allowlist = options.allowlist ?? []; + const now = options.now ?? Date.now(); + const findings: SecretFinding[] = []; + for (const pattern of secretPatterns) { + pattern.expression.lastIndex = 0; + for (const match of content.matchAll(pattern.expression)) { + const isAllowed = allowlist.some( + (entry) => + entry.path === file && + entry.ruleId === pattern.id && + Date.parse(entry.expiresAt) > now, + ); + if (isAllowed) continue; + const matchIndex = match.index ?? 0; + findings.push({ + ruleId: pattern.id, + file, + line: content.slice(0, matchIndex).split(/\r?\n/u).length, + fingerprint: createHash("sha256") + .update(`${pattern.id}:${file}:${String(matchIndex)}`) + .digest("hex"), + }); + } + } + return findings; +} diff --git a/scripts/lib/security-fixture-check.ts b/scripts/lib/security-fixture-check.ts new file mode 100644 index 0000000..ee378bc --- /dev/null +++ b/scripts/lib/security-fixture-check.ts @@ -0,0 +1,170 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +type ScanResult = Readonly<{ + error?: Error; + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +}>; + +type SecurityFixtureCheckDependencies = Readonly<{ + createTempDirectory?: () => Promise; + runScan?: (artifactPath: string, policyPath: string) => ScanResult; + readArtifact?: (artifactPath: string) => Promise; + cleanup?: (directory: string) => Promise; +}>; + +type Document = Record; + +function record(value: unknown, label: string): Document { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Document; +} + +function defaultScan(artifactPath: string, policyPath: string): ScanResult { + const scan = spawnSync( + "node", + [ + "scripts/security-scan.ts", + "--policy", + policyPath, + "--artifact", + artifactPath, + ], + { encoding: "utf8" }, + ); + return { + ...(scan.error ? { error: scan.error } : {}), + status: scan.status, + signal: scan.signal, + stdout: scan.stdout ?? "", + stderr: scan.stderr ?? "", + }; +} + +function assertExactFindings(rawArtifact: string): void { + const sarif = record(JSON.parse(rawArtifact), "security fixture SARIF"); + const runs = Array.isArray(sarif.runs) ? sarif.runs : []; + const run = record(runs[0], "security fixture SARIF run"); + const results = Array.isArray(run.results) ? run.results : []; + const actual = results + .map((rawResult) => { + const result = record(rawResult, "security fixture result"); + const locations = Array.isArray(result.locations) ? result.locations : []; + const location = record(locations[0], "security fixture location"); + const physical = record( + location.physicalLocation, + "security fixture physical location", + ); + const artifactLocation = record( + physical.artifactLocation, + "security fixture artifact location", + ); + return `${String(artifactLocation.uri)}:${String(result.ruleId)}`; + }) + .sort(); + const root = "tests/fixtures/security/secret-detection/forbidden"; + const expected = [ + `${root}/config.json:assigned-secret`, + `${root}/dist.ts:assigned-secret`, + `${root}/source.ts:aws-access-key`, + ].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `security fixture findings mismatch: expected ${expected.join(", ")}; received ${actual.join(", ")}`, + ); + } +} + +export async function checkSecurityFixtures( + dependencies: SecurityFixtureCheckDependencies = {}, +): Promise { + const createTempDirectory = + dependencies.createTempDirectory ?? + (() => mkdtemp(path.join(tmpdir(), "ca-security-fixture-"))); + const runScan = dependencies.runScan ?? defaultScan; + const readArtifact = dependencies.readArtifact ?? ((target) => readFile(target, "utf8")); + const cleanup = + dependencies.cleanup ?? + ((directory) => rm(directory, { recursive: true, force: true })); + const directory = await createTempDirectory(); + const artifactPath = path.join(directory, "scan-fixture.sarif"); + try { + const scan = runScan( + artifactPath, + "tests/fixtures/security/secret-detection/forbidden-policy.json", + ); + const expectedDiagnostic = "Security scan found 3 blocking result(s)."; + if ( + scan.error || + scan.status !== 1 || + scan.signal !== null || + scan.stderr !== `${expectedDiagnostic}\n` + ) { + throw new Error( + `forbidden security fixture did not fail exactly: ${scan.error?.message ?? scan.stderr}`, + ); + } + assertExactFindings(await readArtifact(artifactPath)); + } finally { + await cleanup(directory); + } +} + +export async function checkNonmatchingSecurityIncludeFixture( + dependencies: SecurityFixtureCheckDependencies = {}, +): Promise { + const createTempDirectory = + dependencies.createTempDirectory ?? + (() => mkdtemp(path.join(tmpdir(), "ca-security-include-fixture-"))); + const runScan = dependencies.runScan ?? defaultScan; + const readArtifact = + dependencies.readArtifact ?? ((target) => readFile(target, "utf8")); + const cleanup = + dependencies.cleanup ?? + ((directory) => rm(directory, { recursive: true, force: true })); + const directory = await createTempDirectory(); + const artifactPath = path.join(directory, "scan-fixture.sarif"); + try { + const includedPath = + "tests/fixtures/security/secret-detection/misspelled"; + const scan = runScan( + artifactPath, + "tests/fixtures/security/secret-detection/nonmatching-policy.json", + ); + if ( + scan.error || + scan.status !== 1 || + scan.signal !== null || + !scan.stderr.includes( + `secret scan included path matches no inventory file: ${includedPath}`, + ) + ) { + throw new Error( + `nonmatching security include fixture did not fail closed: ${scan.error?.message ?? scan.stderr}`, + ); + } + try { + await readArtifact(artifactPath); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return; + } + throw error; + } + throw new Error("nonmatching security include fixture wrote an artifact"); + } finally { + await cleanup(directory); + } +} diff --git a/scripts/lib/service-worker-build-input.ts b/scripts/lib/service-worker-build-input.ts new file mode 100644 index 0000000..62e2cc9 --- /dev/null +++ b/scripts/lib/service-worker-build-input.ts @@ -0,0 +1,101 @@ +import type { + InstalledServiceWorkerSelection, + ServiceWorkerHandlerId, + StaticAssetManifestV1, +} from "../../src/contracts/service-worker.ts"; + +const DIGEST = /^sha256:[0-9a-f]{64}$/u; + +export type ServiceWorkerBuildInput = Readonly<{ + assets: StaticAssetManifestV1; + handlers: readonly ServiceWorkerHandlerId[]; + contractSetDigest: string; + releaseManifestUrl: string; +}>; + +/** + * ACTIVE worker compilation is a release-integrity boundary. Missing generated + * modules, stale identities and placeholder digests are fatal build defects; + * they must never be converted into a worker that merely degrades at runtime. + */ +export function resolveServiceWorkerBuildInput(input: Readonly<{ + selection: InstalledServiceWorkerSelection | null; + assets: unknown; + contractSet: unknown; + runtimeConfig: unknown; + buildId: string; + releaseId: string; +}>): ServiceWorkerBuildInput { + if (input.selection?.mode !== "ACTIVE") { + throw new TypeError( + "Service Worker build requires an ACTIVE static selection.", + ); + } + if (!Array.isArray(input.selection.handlers)) { + throw new TypeError("Service Worker handlers must be an array."); + } + const handlers = new Set(); + for (const handler of input.selection.handlers) { + if ( + handler !== "PWA_STATIC_ASSETS" && + handler !== "OFFLINE_SYNC_WAKEUP" && + handler !== "WEB_PUSH" + ) { + throw new TypeError(`Unknown Service Worker handler: ${String(handler)}.`); + } + if (handlers.has(handler)) { + throw new TypeError(`Duplicate Service Worker handler: ${handler}.`); + } + handlers.add(handler); + } + if (handlers.has("WEB_PUSH")) { + throw new TypeError( + "WEB_PUSH requires an installed product-owned worker contribution.", + ); + } + const assets = parseAssets(input.assets); + if (assets.buildId !== input.buildId || assets.releaseId !== input.releaseId) { + throw new TypeError("Generated Service Worker asset identity is stale."); + } + const contractSet = record(input.contractSet); + const contractSetDigest = contractSet?.setDigest; + if (typeof contractSetDigest !== "string" || !DIGEST.test(contractSetDigest)) { + throw new TypeError("Generated contract set digest is invalid."); + } + const runtimeConfig = record(input.runtimeConfig); + const releaseManifestUrl = runtimeConfig?.RELEASE_MANIFEST_URL; + if ( + typeof releaseManifestUrl !== "string" || + releaseManifestUrl.length === 0 || + releaseManifestUrl.length > 2_048 + ) { + throw new TypeError("Runtime release manifest URL is invalid."); + } + return Object.freeze({ + assets, + handlers: Object.freeze([...handlers]), + contractSetDigest, + releaseManifestUrl, + }); +} + +function parseAssets(value: unknown): StaticAssetManifestV1 { + const candidate = record(value); + if ( + candidate?.schemaVersion !== 1 || + typeof candidate.buildId !== "string" || + typeof candidate.releaseId !== "string" || + typeof candidate.setDigest !== "string" || + !DIGEST.test(candidate.setDigest) || + !Array.isArray(candidate.assets) + ) { + throw new TypeError("Generated Service Worker asset manifest is invalid."); + } + return candidate as unknown as StaticAssetManifestV1; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/scripts/lib/stage-verified-promotion-cli.ts b/scripts/lib/stage-verified-promotion-cli.ts new file mode 100644 index 0000000..4f76e90 --- /dev/null +++ b/scripts/lib/stage-verified-promotion-cli.ts @@ -0,0 +1,94 @@ +import { appendFile } from "node:fs/promises"; + +import { + cleanupFinalizedPromotion, + finalizeVerifiedPromotion, +} from "./promotion-stager.ts"; + +export async function runStageVerifiedPromotionCli( + environment: NodeJS.ProcessEnv, + dependencies: Readonly<{ + cwd?: () => string; + finalize?: typeof finalizeVerifiedPromotion; + cleanup?: typeof cleanupFinalizedPromotion; + appendOutput?: (path: string, content: string) => Promise; + writeStdout?: (content: string) => void; + }> = {}, +): Promise { + const required = (name: string): string => { + const value = environment[name]; + if (!value) throw new TypeError(`promotion staging environment is missing ${name}`); + return value; + }; + const attempt = Number( + environment.GITEA_RUN_ATTEMPT ?? + environment.GITHUB_RUN_ATTEMPT ?? + required("CI_RUN_ATTEMPT"), + ); + if (!Number.isInteger(attempt) || attempt < 1 || attempt > 1_000) { + throw new TypeError("promotion staging run attempt is invalid"); + } + const runnerTempRoot = required("RUNNER_TEMP"); + const staged = await (dependencies.finalize ?? finalizeVerifiedPromotion)({ + repositoryRoot: (dependencies.cwd ?? process.cwd)(), + archivePath: required("CANDIDATE_ARCHIVE_PATH"), + expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"), + vulnerabilityReportPath: required("VULNERABILITY_REPORT_PATH"), + provenanceAttestationPath: required("PROVENANCE_ATTESTATION_PATH"), + vulnerabilityPublicKeyPath: required("VULNERABILITY_PUBLIC_KEY_PATH"), + vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"), + provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"), + provenanceKeyId: required("PROVENANCE_KEY_ID"), + expectedRun: { + id: + environment.GITEA_RUN_ID ?? + environment.GITHUB_RUN_ID ?? + required("CI_RUN_ID"), + attempt, + sourceRevision: + environment.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"), + }, + vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"), + provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"), + runnerTempRoot, + }); + try { + const output = required("GITHUB_OUTPUT"); + const content = [ + `staging_root=${staged.stagingRoot}`, + `cleanup_token=${staged.cleanupToken}`, + `runner_temp_dev=${staged.runnerTempIdentity.dev}`, + `runner_temp_ino=${staged.runnerTempIdentity.ino}`, + `staging_dev=${staged.stagingIdentity.dev}`, + `staging_ino=${staged.stagingIdentity.ino}`, + "", + ].join("\n"); + await (dependencies.appendOutput ?? defaultAppendOutput)(output, content); + } catch (error) { + try { + await (dependencies.cleanup ?? cleanupFinalizedPromotion)({ + runnerTempRoot, + stagingRoot: staged.stagingRoot, + cleanupToken: staged.cleanupToken, + runnerTempIdentity: staged.runnerTempIdentity, + stagingIdentity: staged.stagingIdentity, + }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "promotion output publication and direct staging cleanup both failed", + { cause: cleanupError }, + ); + } + throw error; + } + (dependencies.writeStdout ?? process.stdout.write.bind(process.stdout))( + `Promotion staging: ${staged.files + .map(({ name, sha256 }) => `${name}=${sha256}`) + .join(", ")} PASS\n`, + ); +} + +async function defaultAppendOutput(path: string, content: string): Promise { + await appendFile(path, content, { encoding: "utf8" }); +} diff --git a/scripts/lib/supply-chain-time.ts b/scripts/lib/supply-chain-time.ts new file mode 100644 index 0000000..a2dda7d --- /dev/null +++ b/scripts/lib/supply-chain-time.ts @@ -0,0 +1,24 @@ +export function deterministicSupplyChainGeneratedAt(input: Readonly<{ + generatedAt: string; + sourceDateEpoch: string | null; +}>): string { + const generatedAtMs = Date.parse(input.generatedAt); + if (!Number.isFinite(generatedAtMs)) { + throw new TypeError("build manifest generatedAt must be an ISO timestamp"); + } + if (input.sourceDateEpoch !== null) { + if (!/^(?:0|[1-9]\d*)$/u.test(input.sourceDateEpoch)) { + throw new TypeError("SOURCE_DATE_EPOCH must be whole seconds"); + } + const epoch = Number(input.sourceDateEpoch); + if ( + !Number.isSafeInteger(epoch) || + new Date(epoch * 1000).toISOString() !== input.generatedAt + ) { + throw new TypeError( + "build manifest generatedAt must match SOURCE_DATE_EPOCH", + ); + } + } + return new Date(generatedAtMs).toISOString(); +} diff --git a/scripts/lib/supply-chain.ts b/scripts/lib/supply-chain.ts new file mode 100644 index 0000000..2150ca0 --- /dev/null +++ b/scripts/lib/supply-chain.ts @@ -0,0 +1,514 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +export type DependencyScope = "production" | "development"; +export type LockfilePackage = Readonly<{ + name: string; + version: string; + integrity: string; +}>; +export type DependencyInventoryRow = Readonly<{ + name: string; + version: string; + direct: boolean; + scope: DependencyScope; + optional: boolean; + license: string; + dependencies: readonly string[]; +}>; +export type DependencyUpgrade = Readonly<{ + name: string; + from: string; + to: string; +}>; +export type DependencyInventoryDiff = Readonly<{ + added: readonly string[]; + removed: readonly string[]; + changed: readonly string[]; + upgrades: readonly DependencyUpgrade[]; +}>; + +type MutableDependencyRecord = { + name: string; + version: string; + direct: boolean; + scope: DependencyScope; + optional: boolean; + packagePath: string; + dependencies: Set; +}; + +type Document = Readonly>; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function recordValue(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function recordRows(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function stringRows(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function dependencyIdentity(row: Readonly>): string { + return `${String(row.name ?? "")}@${String(row.version ?? "")}`; +} + +export function canonicalizeSupplyChainValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value + .map(canonicalizeSupplyChainValue) + .sort((left, right) => + String(JSON.stringify(left)).localeCompare(String(JSON.stringify(right))), + ); + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]), + ); + } + return value; +} + +export function supplyChainDigest(value: unknown): string { + return createHash("sha256") + .update(JSON.stringify(canonicalizeSupplyChainValue(value))) + .digest("hex"); +} + +export function parsePnpmLockfilePackages( + lockfile: string, +): LockfilePackage[] { + const entries: LockfilePackage[] = []; + let inPackages = false; + let current: { name: string; version: string; integrity: string } | null = null; + + for (const line of lockfile.split(/\r?\n/)) { + if (line === "packages:") { + inPackages = true; + continue; + } + if (line === "snapshots:") { + if (current) entries.push(current); + break; + } + if (!inPackages) continue; + const packageMatch = line.match(/^ {2}(\S.*):$/); + if (packageMatch?.[1]) { + if (current) entries.push(current); + const key = packageMatch[1].replace(/^['"]|['"]$/g, ""); + const separator = key.lastIndexOf("@"); + current = { + name: key.slice(0, separator), + version: key.slice(separator + 1), + integrity: "", + }; + continue; + } + const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/); + if (current && integrityMatch?.[1]) { + current.integrity = integrityMatch[1]; + } + } + return entries.sort((left, right) => + `${left.name}@${left.version}`.localeCompare( + `${right.name}@${right.version}`, + ), + ); +} + +export function isValidSha512Integrity(integrity: string): boolean { + if (!integrity.startsWith("sha512-")) return false; + try { + return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64; + } catch { + return false; + } +} + +export function normalizeLicense(raw: unknown): string { + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (isRecord(raw) && typeof raw.type === "string") return raw.type; + if (Array.isArray(raw)) { + const licenses = raw + .map(normalizeLicense) + .filter((license) => license !== "NOASSERTION"); + return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION"; + } + return "NOASSERTION"; +} + +export async function flattenPnpmDependencyTree( + root: Record, + directProduction: Readonly>, + directDevelopment: Readonly>, +): Promise { + const records = new Map(); + const directIds = new Set(); + + for (const [name, rawDependency] of Object.entries( + recordValue(root.dependencies), + )) { + if (Object.hasOwn(directProduction, name) && isRecord(rawDependency)) { + directIds.add(`${name}@${String(rawDependency.version ?? "")}`); + } + } + for (const [name, rawDependency] of Object.entries( + recordValue(root.devDependencies), + )) { + if (Object.hasOwn(directDevelopment, name) && isRecord(rawDependency)) { + directIds.add(`${name}@${String(rawDependency.version ?? "")}`); + } + } + + function visit( + node: Record, + scope: DependencyScope, + optionalPath: boolean, + ): void { + const groups = { + dependencies: node.dependencies, + devDependencies: node.devDependencies, + optionalDependencies: node.optionalDependencies, + }; + for (const [groupName, group] of Object.entries(groups)) { + for (const [name, rawDependency] of Object.entries(recordValue(group))) { + if (!isRecord(rawDependency)) continue; + const version = String(rawDependency.version ?? ""); + const packagePath = String(rawDependency.path ?? ""); + const identity = `${name}@${version}`; + const childScope: DependencyScope = + scope === "production" && groupName !== "devDependencies" + ? "production" + : "development"; + const childOptional = + optionalPath || groupName === "optionalDependencies"; + const previous = records.get(identity); + const dependencies = previous?.dependencies ?? new Set(); + for (const childGroup of [ + rawDependency.dependencies, + rawDependency.optionalDependencies, + ]) { + for (const [childName, rawChild] of Object.entries( + recordValue(childGroup), + )) { + if (isRecord(rawChild)) { + dependencies.add( + `${childName}@${String(rawChild.version ?? "")}`, + ); + } + } + } + records.set(identity, { + name, + version, + direct: directIds.has(identity), + scope: + previous?.scope === "production" || childScope === "production" + ? "production" + : "development", + optional: previous + ? previous.optional && childOptional + : childOptional, + packagePath: previous?.packagePath || packagePath, + dependencies, + }); + visit(rawDependency, childScope, childOptional); + } + } + } + + const productionRoot: Record = { + dependencies: Object.fromEntries( + Object.entries(recordValue(root.dependencies)).filter(([name]) => + Object.hasOwn(directProduction, name), + ), + ), + }; + const developmentRoot: Record = { + devDependencies: Object.fromEntries( + Object.entries(recordValue(root.devDependencies)).filter(([name]) => + Object.hasOwn(directDevelopment, name), + ), + ), + }; + visit(productionRoot, "production", false); + visit(developmentRoot, "development", false); + + const result: DependencyInventoryRow[] = []; + for (const record of records.values()) { + let license = "NOASSERTION"; + let optional = record.optional; + if (record.packagePath) { + try { + const parsed: unknown = JSON.parse( + await readFile(`${record.packagePath}/package.json`, "utf8"), + ); + const manifest = recordValue(parsed); + license = normalizeLicense(manifest.license ?? manifest.licenses); + } catch { + // Platform-specific optional packages may not be materialized locally. + optional = true; + } + } + result.push({ + name: record.name, + version: record.version, + direct: record.direct, + scope: record.scope, + optional, + license, + dependencies: [...record.dependencies].sort(), + }); + } + return result.sort((left, right) => + `${left.name}@${left.version}`.localeCompare( + `${right.name}@${right.version}`, + ), + ); +} + +export function diffDependencyInventories( + before: Document, + after: Document, +): DependencyInventoryDiff { + const beforeRows = recordRows(before.dependencies); + const afterRows = recordRows(after.dependencies); + const beforeMap = new Map( + beforeRows.map((row) => [dependencyIdentity(row), row] as const), + ); + const afterMap = new Map( + afterRows.map((row) => [dependencyIdentity(row), row] as const), + ); + const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key)); + const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key)); + const changed: string[] = []; + for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) { + if (supplyChainDigest(beforeMap.get(key)) !== supplyChainDigest(afterMap.get(key))) { + changed.push(key); + } + } + const upgrades: DependencyUpgrade[] = []; + for (const removedKey of removed) { + const previous = beforeMap.get(removedKey); + if (!previous) continue; + const replacement = added.find( + (addedKey) => afterMap.get(addedKey)?.name === previous.name, + ); + const next = replacement ? afterMap.get(replacement) : undefined; + if (next) { + upgrades.push({ + name: String(previous.name ?? ""), + from: String(previous.version ?? ""), + to: String(next.version ?? ""), + }); + } + } + return Object.freeze({ + added: Object.freeze(added.sort()), + removed: Object.freeze(removed.sort()), + changed: Object.freeze(changed.sort()), + upgrades: Object.freeze( + upgrades.sort((left, right) => left.name.localeCompare(right.name)), + ), + }); +} + +export function validateLicensePolicy( + inventory: Document, + policy: Document, +) { + const allowed = new Set(stringRows(policy.allowedLicenses)); + const denied = stringRows(policy.deniedLicensePatterns); + const failures: string[] = []; + const results: Array> = []; + for (const dependency of recordRows(inventory.dependencies)) { + const license = String(dependency.license ?? "NOASSERTION"); + const explicitlyDenied = denied.some((pattern) => + new RegExp(pattern, "i").test(license), + ); + const unknownAccepted = + license === "NOASSERTION" && dependency.optional === true; + const passed = + !explicitlyDenied && (allowed.has(license) || unknownAccepted); + results.push({ + package: dependencyIdentity(dependency), + license, + passed, + reason: unknownAccepted ? "platform-optional-not-materialized" : null, + }); + if (!passed) { + failures.push( + `${dependencyIdentity(dependency)} has disallowed license ${license}`, + ); + } + } + return Object.freeze({ + passed: failures.length === 0, + failures: Object.freeze(failures), + results: Object.freeze(results), + }); +} + +export function validateDependencyReview( + diff: DependencyInventoryDiff, + inventory: Document, + evidenceFile: Document, +) { + const byIdentity = new Map( + recordRows(inventory.dependencies).map( + (row) => [dependencyIdentity(row), row] as const, + ), + ); + const evidence = new Map>(); + for (const entry of recordRows(evidenceFile.changes)) { + if (typeof entry.changeId === "string") evidence.set(entry.changeId, entry); + } + const highRisk = diff.added.filter((identity) => { + const row = byIdentity.get(identity); + return row?.direct === true && row.scope === "production"; + }); + const failures: string[] = []; + for (const identity of highRisk) { + const changeId = `add:${identity}`; + const entry = evidence.get(changeId); + if (!entry) { + failures.push(`high-risk dependency missing review: ${changeId}`); + continue; + } + for (const field of ["owner", "reviewer", "reason", "rollback"] as const) { + const value = entry[field]; + if (typeof value !== "string" || !value.trim()) { + failures.push(`${changeId} missing ${field}`); + } + } + if (entry.owner === entry.reviewer) { + failures.push(`${changeId} may not be self-approved`); + } + } + return Object.freeze({ + passed: failures.length === 0, + highRisk: Object.freeze(highRisk), + failures: Object.freeze(failures), + }); +} + +const severityRank: ReadonlyMap = new Map([ + ["unknown", 0], + ["low", 1], + ["moderate", 2], + ["high", 3], + ["critical", 4], +]); + +export function validateVulnerabilityReport( + report: Document, + policy: Document, + exceptionFile: Document, + lockfileSha256: string, + now: Date = new Date(), +) { + const failures: string[] = []; + if (report.scannedLockfileSha256 !== lockfileSha256) { + failures.push("vulnerability report lockfile digest mismatch"); + } + if (typeof report.provider !== "string" || !report.provider.trim()) { + failures.push("vulnerability report provider missing"); + } + const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3; + const exceptions = recordRows(exceptionFile.exceptions); + const blocking: string[] = []; + for (const finding of recordRows(report.findings)) { + const severity = String(finding.severity ?? "unknown").toLowerCase(); + if ((severityRank.get(severity) ?? 0) < threshold) continue; + const exception = exceptions.find( + (entry) => + entry.vulnerabilityId === finding.id && + entry.packageName === finding.packageName, + ); + const expiry = + typeof exception?.expiresAt === "string" + ? Date.parse(exception.expiresAt) + : Number.NaN; + const validException = Boolean( + exception && + typeof exception.owner === "string" && + exception.owner.trim() && + typeof exception.reviewer === "string" && + exception.reviewer.trim() && + exception.owner !== exception.reviewer && + typeof exception.reason === "string" && + exception.reason.trim() && + Number.isFinite(expiry) && + expiry > now.getTime(), + ); + if (!validException) { + blocking.push( + `${String(finding.id)}:${String(finding.packageName)}@${String(finding.version)}:${severity}`, + ); + } + } + return Object.freeze({ + passed: failures.length === 0 && blocking.length === 0, + failures: Object.freeze(failures), + blocking: Object.freeze(blocking), + }); +} + +export function verifySupplyChainCoherence( + sbom: Document, + inventory: Document, + provenance: Document, + distDigest: string, +) { + const failures: string[] = []; + const componentCount = Array.isArray(sbom.components) + ? sbom.components.length + : -1; + const dependencyCount = Array.isArray(inventory.dependencies) + ? inventory.dependencies.length + : -2; + if (componentCount !== dependencyCount) { + failures.push("SBOM component count does not match inventory"); + } + const metadata = recordValue(sbom.metadata); + const properties = recordRows(metadata.properties); + if ( + properties.find( + (property) => + property.name === "ca:lockfileSha256" && + property.value === inventory.lockfileSha256, + ) === undefined + ) { + failures.push("SBOM lockfile digest does not match inventory"); + } + const subject = recordRows(provenance.subject)[0]; + const subjectDigest = recordValue(subject?.digest); + if (subjectDigest.sha256 !== distDigest) { + failures.push("provenance subject does not match built dist digest"); + } + const predicate = recordValue(provenance.predicate); + const materials = recordValue(predicate.materials); + if (materials.lockfileSha256 !== inventory.lockfileSha256) { + failures.push("provenance lockfile material does not match inventory"); + } + return Object.freeze({ + passed: failures.length === 0, + failures: Object.freeze(failures), + }); +} diff --git a/scripts/lib/test-evidence-artifact.ts b/scripts/lib/test-evidence-artifact.ts new file mode 100644 index 0000000..1f86f3f --- /dev/null +++ b/scripts/lib/test-evidence-artifact.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +export const testEvidenceReportSchema = z + .object({ + schemaVersion: z.literal(2), + sourceRoot: z.string().min(1), + status: z.enum(["PASS", "FAIL"]), + facts: z + .object({ + scannedFiles: z.number().int().nonnegative(), + visualBaselines: z.number().int().nonnegative(), + sharedScenarios: z.number().int().nonnegative(), + declaredScenarioExecutions: z.number().int().nonnegative(), + executedScenarioExecutions: z.number().int().nonnegative(), + }) + .strict(), + failures: z.array(z.string()), + }) + .strict() + .superRefine((report, context) => { + const passed = report.status === "PASS"; + if (passed !== (report.failures.length === 0)) { + context.addIssue({ code: "custom", path: ["status"], message: "status must agree with failures" }); + } + if ( + passed && + report.facts.declaredScenarioExecutions !== + report.facts.executedScenarioExecutions + ) { + context.addIssue({ + code: "custom", + path: ["facts", "executedScenarioExecutions"], + message: "PASS requires exact declared/executed scenario agreement", + }); + } + }); diff --git a/scripts/lib/v8-coverage-counter-semantics.ts b/scripts/lib/v8-coverage-counter-semantics.ts new file mode 100644 index 0000000..1859f13 --- /dev/null +++ b/scripts/lib/v8-coverage-counter-semantics.ts @@ -0,0 +1,294 @@ +import { execFile } from "node:child_process"; +import { + cp, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const coverageMetrics = [ + "lines", + "statements", + "functions", + "branches", +] as const; +const runtimeModule = "src/runtime.ts"; +const counterlessModules = Object.freeze([ + "src/import-empty.ts", + "src/import-side-effect.ts", + "src/import-type-empty.ts", + "src/import-value.ts", + "src/reexport-named.ts", + "src/reexport-star.ts", + "src/type-only.ts", +] as const); +const expectedModules = Object.freeze( + [runtimeModule, ...counterlessModules].sort(), +); +const childOutputLimit = 1_800; + +type ChildOutput = Readonly<{ stdout: string; stderr: string }>; +type ChildInput = Readonly<{ + repositoryRoot: string; + ownedRoot: string; + configPath: string; +}>; +type CheckerOptions = Readonly<{ + repositoryRoot?: string; + createOwnedRoot?: () => Promise; + runVitest?: (input: ChildInput) => Promise; +}>; + +export type V8CoverageCounterSemantics = Readonly<{ + counterBearingModules: readonly string[]; + counterlessModules: readonly string[]; +}>; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return isRecord(error) && error.code === code; +} + +function normalizeSummaryPath(value: string, fixtureRoot: string): string { + const relative = path.isAbsolute(value) ? path.relative(fixtureRoot, value) : value; + const normalized = relative.split(path.sep).join("/"); + if ( + normalized.length === 0 || + normalized === ".." || + normalized.startsWith("../") || + path.posix.isAbsolute(normalized) + ) { + throw new TypeError(`V8 coverage row is outside the owned fixture: ${value}`); + } + return normalized; +} + +function coverageCounter( + value: unknown, + label: string, +): Readonly<{ total: number; covered: number; skipped: number; pct: number }> { + if (!isRecord(value)) throw new TypeError(`${label} must be an object`); + const { total, covered, skipped, pct } = value; + if ( + typeof total !== "number" || + !Number.isSafeInteger(total) || + typeof covered !== "number" || + !Number.isSafeInteger(covered) || + typeof skipped !== "number" || + !Number.isSafeInteger(skipped) || + typeof pct !== "number" || + !Number.isFinite(pct) || + total < 0 || + covered < 0 || + skipped < 0 || + covered + skipped > total + ) { + throw new TypeError(`${label} contains invalid coverage counters`); + } + const expectedPercentage = + total === 0 ? 100 : Math.floor((covered / total) * 10_000) / 100; + if (pct !== expectedPercentage) { + throw new TypeError(`${label} contains invalid coverage counters`); + } + return { total, covered, skipped, pct }; +} + +function coverageRow( + value: unknown, + label: string, +): Readonly>> { + if (!isRecord(value)) throw new TypeError(`${label} must be an object`); + return Object.fromEntries( + coverageMetrics.map((metric) => [ + metric, + coverageCounter(value[metric], `${label}.${metric}`), + ]), + ) as Readonly< + Record<(typeof coverageMetrics)[number], ReturnType> + >; +} + +export function assertV8CoverageCounterSemantics( + value: unknown, + fixtureRoot: string, +): V8CoverageCounterSemantics { + if (!isRecord(value)) throw new TypeError("V8 coverage summary must be an object"); + coverageRow(value.total, "V8 coverage total"); + const rows = new Map>(); + for (const [producerPath, producerRow] of Object.entries(value)) { + if (producerPath === "total") continue; + const normalizedPath = normalizeSummaryPath(producerPath, fixtureRoot); + if (rows.has(normalizedPath)) { + throw new TypeError(`V8 coverage row is duplicated: ${normalizedPath}`); + } + rows.set( + normalizedPath, + coverageRow(producerRow, `V8 coverage row ${normalizedPath}`), + ); + } + const actualModules = [...rows.keys()].sort(); + const missing = expectedModules.filter((modulePath) => !rows.has(modulePath)); + const additional = actualModules.filter( + (modulePath) => !expectedModules.includes(modulePath), + ); + if (missing.length > 0 || additional.length > 0) { + throw new Error( + `V8 coverage row set mismatch; missing: ${missing.join(", ") || "none"}; additional: ${additional.join(", ") || "none"}`, + ); + } + const runtimeRow = rows.get(runtimeModule)!; + if (!coverageMetrics.some((metric) => runtimeRow[metric].total > 0)) { + throw new Error(`V8 runtime module is not counter-bearing: ${runtimeModule}`); + } + for (const modulePath of counterlessModules) { + const row = rows.get(modulePath)!; + const exactAllZero = coverageMetrics.every((metric) => { + const counter = row[metric]; + return ( + counter.total === 0 && + counter.covered === 0 && + counter.skipped === 0 && + counter.pct === 100 + ); + }); + if (!exactAllZero) { + throw new Error( + `V8 counterless module must have exact all-zero counters: ${modulePath}`, + ); + } + } + return Object.freeze({ + counterBearingModules: Object.freeze([runtimeModule]), + counterlessModules: Object.freeze([...counterlessModules]), + }); +} + +function boundedChildOutput(value: unknown): string { + const output = + typeof value === "string" + ? value + : value instanceof Uint8Array + ? new TextDecoder().decode(value) + : ""; + if (output.length <= childOutputLimit) return output; + return `${output.slice(0, childOutputLimit)}\n[truncated ${output.length - childOutputLimit} characters]`; +} + +async function defaultRunVitest(input: ChildInput): Promise { + const result = await execFileAsync( + process.execPath, + [ + path.join(input.repositoryRoot, "node_modules/vitest/vitest.mjs"), + "run", + "--config", + input.configPath, + "--coverage", + "--reporter=dot", + "--no-color", + ], + { + cwd: input.ownedRoot, + encoding: "utf8", + timeout: 30_000, + maxBuffer: 256 * 1024, + }, + ); + return { stdout: result.stdout, stderr: result.stderr }; +} + +function assertOwnedTemporaryRoot(value: string): string { + const temporaryRoot = path.resolve(tmpdir()); + const ownedRoot = path.resolve(value); + const relative = path.relative(temporaryRoot, ownedRoot); + if ( + relative.length === 0 || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new TypeError(`V8 coverage fixture root is not an owned temp path: ${ownedRoot}`); + } + return ownedRoot; +} + +export async function checkV8CoverageCounterSemantics( + options: CheckerOptions = {}, +): Promise { + const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); + const createOwnedRoot = + options.createOwnedRoot ?? + (() => mkdtemp(path.join(tmpdir(), "v8-counter-semantics-"))); + const runVitest = options.runVitest ?? defaultRunVitest; + const ownedRoot = assertOwnedTemporaryRoot(await createOwnedRoot()); + const fixtureSource = path.join( + repositoryRoot, + "tests/fixtures/v8-coverage-counter-semantics", + ); + const configPath = path.join(ownedRoot, "vitest.config.mjs"); + const reportsDirectory = path.join(ownedRoot, "coverage"); + try { + await cp(fixtureSource, ownedRoot, { recursive: true }); + await writeFile( + configPath, + `export default ${JSON.stringify( + { + root: ownedRoot, + test: { + globals: true, + include: ["counter-semantics.fixture.ts"], + setupFiles: [], + coverage: { + provider: "v8", + reportsDirectory, + reporter: ["json-summary"], + include: ["src/**/*.ts"], + }, + }, + }, + null, + 2, + )};\n`, + "utf8", + ); + try { + await runVitest({ repositoryRoot, ownedRoot, configPath }); + } catch (error) { + const record = isRecord(error) ? error : {}; + throw new Error( + `V8 coverage counter semantics child Vitest failed\nstdout:\n${boundedChildOutput(record.stdout)}\nstderr:\n${boundedChildOutput(record.stderr)}`, + { cause: error }, + ); + } + const summaryPath = path.join(reportsDirectory, "coverage-summary.json"); + let summaryText: string; + try { + summaryText = await readFile(summaryPath, "utf8"); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + throw new Error("V8 coverage counter semantics coverage summary is missing", { + cause: error, + }); + } + throw error; + } + let summary: unknown; + try { + summary = JSON.parse(summaryText) as unknown; + } catch (error) { + throw new TypeError("V8 coverage counter semantics summary is invalid JSON", { + cause: error, + }); + } + return assertV8CoverageCounterSemantics(summary, ownedRoot); + } finally { + await rm(ownedRoot, { recursive: true, force: true }); + } +} diff --git a/scripts/lib/validated-json-artifact.ts b/scripts/lib/validated-json-artifact.ts new file mode 100644 index 0000000..83345e1 --- /dev/null +++ b/scripts/lib/validated-json-artifact.ts @@ -0,0 +1,144 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { + open as openFile, + rename as renameFile, + rm as removeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import type { z } from "zod"; + +export type ValidatedJsonArtifactInput = Readonly<{ + path: string; + schema: z.ZodType; + value: unknown; +}>; + +export function serializeValidatedJsonArtifact( + input: ValidatedJsonArtifactInput, +): Buffer { + const parsed = input.schema.parse(input.value); + const serialized = JSON.stringify(parsed, null, 2); + if (serialized === undefined) { + throw new TypeError("Validated JSON artifact is not serializable"); + } + return Buffer.from(`${serialized}\n`, "utf8"); +} + +export type ValidatedJsonArtifactFileSystem = Readonly<{ + open: (path: string, flags: number, mode: number) => Promise<{ + writeFile(data: string, encoding: "utf8"): Promise; + sync(): Promise; + close(): Promise; + }>; + openDirectory: (path: string) => Promise<{ + sync(): Promise; + close(): Promise; + }>; + rename: (source: string, destination: string) => Promise; + rm: (path: string, options: Readonly<{ force: true }>) => Promise; +}>; + +type ValidatedJsonArtifactWriterDependencies = Readonly<{ + createNonce?: () => string; + fileSystem?: ValidatedJsonArtifactFileSystem; +}>; + +const defaultFileSystem: ValidatedJsonArtifactFileSystem = Object.freeze({ + open: async (target, flags, mode) => openFile(target, flags, mode), + openDirectory: async (target) => openFile(target, constants.O_RDONLY), + rename: async (source, destination) => renameFile(source, destination), + rm: async (target, options) => removeFile(target, options), +}); + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +/** + * Builds a writer whose only publish operation is an atomic sibling rename. + * Dependency injection is limited to the file-system boundary so failure + * ownership can be verified without exposing a caller-selected cleanup path. + */ +export function createValidatedJsonArtifactWriter( + dependencies: ValidatedJsonArtifactWriterDependencies = {}, +) { + const createNonce = dependencies.createNonce ?? randomUUID; + const fileSystem = dependencies.fileSystem ?? defaultFileSystem; + + return async function writeArtifact( + input: ValidatedJsonArtifactInput, + ): Promise { + const serialized = serializeValidatedJsonArtifact(input); + + const temporaryPath = path.join( + path.dirname(input.path), + `.${path.basename(input.path)}.${createNonce()}.tmp`, + ); + let ownsTemporaryFile = false; + try { + const handle = await fileSystem.open( + temporaryPath, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600, + ); + ownsTemporaryFile = true; + let writeFailed = false; + let writeFailure: unknown; + try { + await handle.writeFile(serialized.toString("utf8"), "utf8"); + await handle.sync(); + } catch (error) { + writeFailed = true; + writeFailure = error; + } + let closeFailed = false; + let closeFailure: unknown; + try { + await handle.close(); + } catch (error) { + closeFailed = true; + closeFailure = error; + } + if (writeFailed) throw writeFailure; + if (closeFailed) throw closeFailure; + await fileSystem.rename(temporaryPath, input.path); + ownsTemporaryFile = false; + const directoryHandle = await fileSystem.openDirectory( + path.dirname(input.path), + ); + try { + try { + await directoryHandle.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) { + throw error; + } + } + } finally { + await directoryHandle.close(); + } + } catch (error) { + if (ownsTemporaryFile) { + try { + await fileSystem.rm(temporaryPath, { force: true }); + } catch { + // Preserve the publishing failure; cleanup is confined to our nonce. + } + } + throw error; + } + }; +} + +export const writeValidatedJsonArtifact = + createValidatedJsonArtifactWriter(); diff --git a/scripts/lib/vite-module-inventory.ts b/scripts/lib/vite-module-inventory.ts new file mode 100644 index 0000000..c0a9fd3 --- /dev/null +++ b/scripts/lib/vite-module-inventory.ts @@ -0,0 +1,63 @@ +import path from "node:path"; + +import type { Plugin } from "vite"; + +type ModuleInventoryChunk = Readonly<{ + fileName: string; + modules: readonly string[]; +}>; + +/** + * Rollup knows the exact source-module set for every emitted chunk. Persisting + * that graph makes optional-runtime exclusion verifiable without relying on + * minified names, error strings, or source maps. + */ +export function viteModuleInventoryPlugin( + repositoryRoot = process.cwd(), +): Plugin { + return { + name: "frontend-module-inventory", + generateBundle(_options, bundle) { + const chunks: ModuleInventoryChunk[] = Object.values(bundle) + .filter((output) => output.type === "chunk") + .map((chunk) => ({ + fileName: chunk.fileName, + modules: Object.freeze( + [...new Set( + Object.keys(chunk.modules).map((moduleId) => + normalizeModuleId(moduleId, repositoryRoot), + ), + )].sort(), + ), + })) + .sort((left, right) => left.fileName.localeCompare(right.fileName)); + + this.emitFile({ + type: "asset", + fileName: ".vite/module-inventory.json", + source: `${JSON.stringify( + { + schemaVersion: 1, + chunks, + }, + null, + 2, + )}\n`, + }); + }, + }; +} + +function normalizeModuleId( + moduleId: string, + repositoryRoot: string, +): string { + const withoutQuery = moduleId.replace(/^\0/u, "").split("?", 1)[0] ?? ""; + if (!path.isAbsolute(withoutQuery)) { + return withoutQuery.replaceAll("\\", "/"); + } + const relative = path.relative(repositoryRoot, withoutQuery); + return relative.startsWith("..") + ? `external:${path.basename(withoutQuery)}` + : relative.replaceAll("\\", "/"); +} diff --git a/scripts/run-and-validate-provider.ts b/scripts/run-and-validate-provider.ts new file mode 100644 index 0000000..c710f77 --- /dev/null +++ b/scripts/run-and-validate-provider.ts @@ -0,0 +1,598 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { access, appendFile, lstat } from "node:fs/promises"; +import path from "node:path"; + +import { + provenanceProviderAttestationSchema, + vulnerabilityProviderReportSchema, +} from "./lib/provider-evidence.ts"; +import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts"; +import { readProviderTrust } from "./lib/provider-trust.ts"; +import { superviseProviderEvidence } from "./lib/provider-supervisor.ts"; +import { serializeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { + assertSafePublishLeaf, + ensureSafePublishDirectory, +} from "./lib/ci-gate-log.ts"; +import { + encodeProviderBwrapInput, + encodeProviderScopeFrame, + formatProviderCgroupUnitName, + systemctlKillProviderArguments, + systemdRunProviderArguments, +} from "./lib/provider-cgroup.ts"; +import { + assertProviderGuardianLeasePaths, + createProviderScopeGuardianLatch, + startProviderGuardian, + type ProviderGuardianLease, +} from "./lib/provider-guardian-client.ts"; +import { createProviderOutputLimiter } from "./lib/provider-output-limiter.ts"; + +const kind = process.argv[process.argv.indexOf("--kind") + 1]; +const PROVIDER_TMP_BYTES = 16_777_216; +const PROVIDER_MASK_BYTES = 1_048_576; +const PROVIDER_MAX_OUTPUT_BYTES = 1_048_576; +const DEFAULT_PROVIDER_CPU_SECONDS = 1_200; +const DEFAULT_PROVIDER_TIMEOUT_MS = 30 * 60 * 1_000; +const PROVIDER_POSTPROCESS_TIMEOUT_MS = 10 * 60 * 1_000; +const PROVIDER_REAP_TIMEOUT_MS = 5_000; +const PROVIDER_CONTROL_TIMEOUT_MS = 5_000; +const PROVIDER_CONTROL_MAX_OUTPUT_BYTES = 65_536; +const PROVIDER_CONTROL_POLL_MS = 25; +const MINIMUM_PROVIDER_SYSTEMD_VERSION = 254; +if (kind !== "vulnerability" && kind !== "provenance") { + process.stderr.write("Usage: run-and-validate-provider --kind vulnerability|provenance\n"); + process.exit(2); +} +const command = + kind === "vulnerability" + ? process.env.VULNERABILITY_PROVIDER_COMMAND + : process.env.PROVENANCE_PROVIDER_COMMAND; +const reportPath = + kind === "vulnerability" + ? process.env.VULNERABILITY_REPORT_PATH + : process.env.PROVENANCE_ATTESTATION_PATH; +const sealedPath = process.env.VALIDATED_PROVIDER_REPORT_PATH; +const archivePath = process.env.CANDIDATE_ARCHIVE_PATH; +const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256; +const publicKeyPath = kind === "vulnerability" + ? process.env.VULNERABILITY_PUBLIC_KEY_PATH + : process.env.PROVENANCE_PUBLIC_KEY_PATH; +const keyId = kind === "vulnerability" + ? process.env.VULNERABILITY_KEY_ID + : process.env.PROVENANCE_KEY_ID; +const runId = process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? process.env.CI_RUN_ID; +const runAttemptSource = process.env.GITEA_RUN_ATTEMPT ?? + process.env.GITHUB_RUN_ATTEMPT ?? process.env.CI_RUN_ATTEMPT; +const sourceRevision = process.env.EXPECTED_SOURCE_REVISION ?? process.env.VITE_COMMIT_SHA; +if ( + !command || + !reportPath || + !sealedPath || + !archivePath || + !archiveSha256 || + !publicKeyPath || + !keyId || + !runId || + !runAttemptSource || + !sourceRevision +) { + process.stderr.write("Provider supervisor environment is incomplete\n"); + process.exit(2); +} +const runAttempt = Number(runAttemptSource); +if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) { + throw new TypeError("provider supervisor run attempt is invalid"); +} + +const workspaceRoot = process.cwd(); +const reportAbsolute = path.resolve(reportPath); +const rawDirectory = path.dirname(reportAbsolute); +const sealedAbsolute = path.resolve(sealedPath); +const expectedRawLeaf = kind === "vulnerability" + ? "vulnerability-report.json" + : "provenance-attestation.json"; +const expectedRawDirectory = path.resolve(workspaceRoot, "provider-evidence/untrusted"); +if ( + reportAbsolute !== path.join(expectedRawDirectory, expectedRawLeaf) || + path.basename(rawDirectory) !== "untrusted" || + path.dirname(rawDirectory) !== path.dirname(sealedAbsolute) || + reportAbsolute === sealedAbsolute +) { + throw new TypeError("provider raw and sealed evidence paths are not isolated"); +} +await prepareMissingProviderOutput(workspaceRoot, reportAbsolute, reportPath, "raw provider report"); +await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "sealed provider report"); +await access("/usr/bin/bwrap", constants.X_OK).catch(() => { + throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required"); +}); +await access("/usr/bin/prlimit", constants.X_OK).catch(() => { + throw new Error("provider sandbox unavailable: /usr/bin/prlimit is required"); +}); +await access("/usr/bin/systemd-run", constants.X_OK).catch(() => { + throw new Error("provider cgroup unavailable: /usr/bin/systemd-run is required"); +}); +await access("/usr/bin/systemctl", constants.X_OK).catch(() => { + throw new Error("provider cgroup unavailable: /usr/bin/systemctl is required"); +}); +await assertProviderCgroupManagerAvailable(); +const trust = await readProviderTrust(workspaceRoot, publicKeyPath, keyId); +if (!trust) throw new TypeError("provider supervisor trust key is invalid"); +let guardianLease: ProviderGuardianLease | undefined; +let guardianTerminalStarted = false; +try { + const providerWallTimeoutMs = providerTimeoutMs(); + const supervised = await superviseProviderEvidence({ + kind, + archivePath, + expectedArchiveSha256: archiveSha256, + expectedRun: { id: runId, attempt: runAttempt, sourceRevision }, + trust, + executeProvider: async ({ candidateRoot, environment }) => { + guardianLease = await startProviderGuardian({ + kind, + workspaceRoot, + leaseMs: providerWallTimeoutMs + PROVIDER_POSTPROCESS_TIMEOUT_MS, + guardianScript: path.join(workspaceRoot, "scripts/lib/provider-raw-guardian.ts"), + }); + const activeGuardian = guardianLease; + assertProviderGuardianLeasePaths(activeGuardian, { + rawPath: reportAbsolute, + sealedPath: sealedAbsolute, + }); + const childEnvironment = createProviderEnvironment(kind, reportPath, environment); + await runProviderInSandbox( + kind, + command, + childEnvironment, + activeGuardian.rawPath, + activeGuardian.rawIdentity, + workspaceRoot, + candidateRoot, + providerWallTimeoutMs, + activeGuardian.prematureExit, + ); + await assertOwnedProviderOutput(activeGuardian.rawPath, activeGuardian.rawIdentity); + }, + captureReport: async () => { + if (!guardianLease) throw new Error("provider raw guardian lease was not established"); + await assertOwnedProviderOutput(guardianLease.rawPath, guardianLease.rawIdentity); + return readBoundedRegularFile({ + root: workspaceRoot, + relativePath: path.relative(workspaceRoot, guardianLease.rawPath).replaceAll(path.sep, "/"), + maxBytes: 8_388_608, + }); + }, + }); + if (!guardianLease) throw new Error("provider raw guardian lease was not established"); + await assertSafePublishLeaf(sealedAbsolute, sealedPath); + await guardianLease.publish(serializeValidatedJsonArtifact({ + path: sealedPath, + schema: + kind === "vulnerability" + ? vulnerabilityProviderReportSchema + : provenanceProviderAttestationSchema, + value: supervised.evidence, + })); + if (process.env.GITHUB_OUTPUT) { + await appendFile( + process.env.GITHUB_OUTPUT, + `invocation_nonce=${supervised.invocationNonce}\n`, + "utf8", + ); + } + guardianTerminalStarted = true; + await guardianLease.commit(); +} catch (providerError) { + const failures = [toError(providerError)]; + if (guardianLease && !guardianTerminalStarted) { + try { + await guardianLease.abort(); + } catch (guardianError) { + failures.push(toError(guardianError)); + } + } + if (failures.length > 1) { + throw new AggregateError(failures, "provider lifecycle and owned output cleanup failed", { + cause: providerError, + }); + } + throw providerError; +} +process.stdout.write(`${kind} provider supervised validation: PASS\n`); + +function createProviderEnvironment( + providerKind: "vulnerability" | "provenance", + rawReportPath: string, + bindings: Readonly>, +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", + HOME: "/tmp/provider-home", + TMPDIR: "/tmp", + CI: "true", + GITHUB_ENV: "/tmp/github-env", + GITHUB_PATH: "/tmp/github-path", + ...bindings, + ...(providerKind === "vulnerability" + ? { VULNERABILITY_REPORT_PATH: rawReportPath } + : { PROVENANCE_ATTESTATION_PATH: rawReportPath }), + }; + for (const name of ["LANG", "LC_ALL"] as const) { + if (process.env[name]) environment[name] = process.env[name]; + } + const credentialPrefix = `${providerKind.toUpperCase()}_PROVIDER_`; + for (const [name, value] of Object.entries(process.env)) { + if (name.startsWith(credentialPrefix) && !name.endsWith("_COMMAND") && value) { + environment[name] = value; + } + } + return environment; +} + +async function runProviderInSandbox( + providerKind: "vulnerability" | "provenance", + command: string, + environment: NodeJS.ProcessEnv, + reportAbsolute: string, + reportIdentity: Readonly<{ dev: number; ino: number }>, + workspaceRoot: string, + candidateRoot: string, + timeoutMs: number, + guardianExit: Promise, +): Promise { + const cpuSeconds = providerCpuSeconds(); + const bwrapArguments = [ + "--die-with-parent", "--new-session", "--as-pid-1", + "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-net", + "--dev", "/dev", "--remount-ro", "/dev", + "--proc", "/proc", "--remount-ro", "/proc", + "--size", String(PROVIDER_TMP_BYTES), "--tmpfs", "/tmp", + "--dir", "/tmp/provider-home", + "--size", String(PROVIDER_MASK_BYTES), "--tmpfs", "/etc", + ]; + for (const source of ["/usr", "/bin", "/lib", "/lib64"]) { + if (await exists(source)) bwrapArguments.push("--ro-bind", source, source); + } + bwrapArguments.push("--ro-bind", process.execPath, "/tmp/node"); + for (const source of [ + "/etc/ca-certificates", "/etc/ssl", "/etc/nsswitch.conf", "/etc/passwd", "/etc/group", + ]) { + if (await exists(source)) bwrapArguments.push("--ro-bind", source, source); + } + bwrapArguments.push("--remount-ro", "/etc"); + for (const directory of missingDestinationAncestors(workspaceRoot)) { + bwrapArguments.push("--dir", directory); + } + bwrapArguments.push("--ro-bind", workspaceRoot, workspaceRoot); + if (await exists(path.join(workspaceRoot, ".git"))) { + bwrapArguments.push( + "--size", String(PROVIDER_MASK_BYTES), + "--tmpfs", path.join(workspaceRoot, ".git"), + "--remount-ro", path.join(workspaceRoot, ".git"), + ); + } + bwrapArguments.push( + "--ro-bind", candidateRoot, "/candidate", + "--remount-ro", "/tmp", + "--remount-ro", "/", + "--bind", reportAbsolute, reportAbsolute, + "--chdir", workspaceRoot, + "--", "/usr/bin/prlimit", + "--core=0:0", + "--fsize=8388607:8388607", + "--nofile=64:64", + `--cpu=${cpuSeconds}:${cpuSeconds}`, + "--", "/bin/sh", "-eu", "-c", + 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"', + ); + const unitName = formatProviderCgroupUnitName( + providerKind, + process.pid, + randomBytes(12).toString("hex"), + ); + const bwrapInput = encodeProviderBwrapInput(bwrapArguments, { + ...environment, + PATH: `/tmp:${environment.PATH ?? ""}`, + PROVIDER_COMMAND: command, + }); + const scopeFrame = encodeProviderScopeFrame({ + bwrapInput, + reportPath: reportAbsolute, + reportDev: reportIdentity.dev, + reportIno: reportIdentity.ino, + }); + await waitForProvider( + spawn("/usr/bin/systemd-run", systemdRunProviderArguments( + unitName, + timeoutMs, + cpuSeconds, + process.execPath, + path.join(workspaceRoot, "scripts/lib/provider-scope-wrapper.ts"), + reportAbsolute, + reportIdentity.dev, + reportIdentity.ino, + ), { + env: providerCgroupClientEnvironment(), + stdio: ["pipe", "pipe", "pipe"], + }), + timeoutMs, + unitName, + scopeFrame, + guardianExit, + ); +} + +async function waitForProvider( + child: ReturnType, + timeoutMs: number, + unitName: string, + scopeFrame: Buffer, + guardianExit: Promise, +): Promise { + let termination: "guardian" | "timeout" | "output" | undefined; + let guardianError: Error | undefined; + let signalTermination!: () => void; + let kill: Promise | undefined; + const terminationStarted = new Promise((resolve) => { signalTermination = resolve; }); + const close = new Promise>((resolve) => { + child.once("error", (error) => resolve({ code: null, error, signal: null })); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + let inputError: Error | undefined; + child.stdin?.once("error", (error) => { inputError = error; }); + if (child.stdin) child.stdin.write(scopeFrame); + else inputError = new Error("systemd-run provider argument pipe is unavailable"); + const terminate = (reason: "guardian" | "timeout" | "output"): void => { + if (termination) return; + termination = reason; + kill = killProviderUnit(unitName); + signalTermination(); + }; + const guardianLatch = createProviderScopeGuardianLatch(guardianExit); + void guardianLatch.activeFailure.then((error) => { + guardianError = error; + terminate("guardian"); + }); + const outputLimiter = createProviderOutputLimiter( + PROVIDER_MAX_OUTPUT_BYTES, + () => terminate("output"), + ); + const capture = (chunk: Buffer | string): void => { + if (termination) return; + outputLimiter.consume(chunk); + }; + child.stdout?.on("data", capture); + child.stderr?.on("data", capture); + const timeout = setTimeout(() => terminate("timeout"), timeoutMs); + try { + const first = await Promise.race([ + close.then((result) => ({ type: "close" as const, result })), + terminationStarted.then(() => ({ type: "termination" as const })), + ]); + let collection: Promise | undefined; + if (first.type === "close" && !termination) { + collection = waitForProviderUnitCollected(unitName); + const scopeOutcome = await Promise.race([ + collection.then(() => "collected" as const), + terminationStarted.then(() => "termination" as const), + ]); + if (scopeOutcome === "collected") { + await guardianLatch.close(); + const boundaryFailure = guardianLatch.failure(); + if (boundaryFailure && !termination) { + guardianError = boundaryFailure; + terminate("guardian"); + } + } + } + if (termination) { + let killError: Error | undefined; + try { await kill; } catch (error) { killError = toError(error); child.kill("SIGKILL"); } + const closed = await waitForProviderClose(close); + if (!closed) child.kill("SIGKILL"); + let collectionError: Error | undefined; + try { + await (collection ?? waitForProviderUnitCollected(unitName)); + } catch (error) { + collectionError = toError(error); + } + await guardianLatch.close(); + const reason = termination === "timeout" + ? "sandboxed external provider command timed out" + : termination === "output" + ? `sandboxed external provider output exceeded the ${PROVIDER_MAX_OUTPUT_BYTES}-byte aggregate limit` + : `provider raw guardian failed${guardianError ? `: ${guardianError.message}` : ""}`; + if (!closed) throw new Error(`${reason} and systemd-run did not close within the reap bound`); + if (collectionError) throw new Error(`${reason}; provider cgroup collection failed: ${collectionError.message}`); + if (killError) throw new Error(`${reason}; provider cgroup kill failed: ${killError.message}`); + throw new Error(reason); + } + const result = first.type === "close" ? first.result : await close; + await collection; + if (result.error) throw result.error; + if (result.code !== 0 || result.signal !== null) { + throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`); + } + if (inputError) throw inputError; + } finally { + clearTimeout(timeout); + await guardianLatch.close(); + } +} + +async function waitForProviderClose( + close: Promise>, +): Promise { + let timer: NodeJS.Timeout | undefined; + const closed = await Promise.race([ + close.then(() => true), + new Promise((resolve) => { timer = setTimeout(() => resolve(false), PROVIDER_REAP_TIMEOUT_MS); }), + ]); + if (timer) clearTimeout(timer); + return closed; +} + +async function assertProviderCgroupManagerAvailable(): Promise { + const output = await runBoundedSystemctl( + ["--user", "show", "--property=Version", "--value"], + "provider cgroup user manager probe", + ); + const match = /^([0-9]+)/u.exec(output.trim()); + const version = match ? Number(match[1]) : Number.NaN; + if (!Number.isSafeInteger(version) || version < MINIMUM_PROVIDER_SYSTEMD_VERSION) { + throw new Error(`provider cgroup unavailable: systemd ${MINIMUM_PROVIDER_SYSTEMD_VERSION} or newer is required`); + } +} + +async function killProviderUnit(unitName: string): Promise { + await runBoundedSystemctl(systemctlKillProviderArguments(unitName), "provider cgroup unit kill"); +} + +async function waitForProviderUnitCollected(unitName: string): Promise { + const deadline = Date.now() + PROVIDER_CONTROL_TIMEOUT_MS; + let loadState = "unknown"; + while (Date.now() <= deadline) { + loadState = (await runBoundedSystemctl( + ["--user", "show", unitName, "--property=LoadState", "--value"], + "provider cgroup collection probe", + )).trim(); + if (loadState === "not-found") return; + await new Promise((resolve) => setTimeout(resolve, PROVIDER_CONTROL_POLL_MS)); + } + throw new Error(`provider unit remained loaded with state ${loadState || "unknown"}`); +} + +async function runBoundedSystemctl(arguments_: readonly string[], label: string): Promise { + return await new Promise((resolve, reject) => { + const child = spawn("/usr/bin/systemctl", arguments_, { + env: providerCgroupClientEnvironment(), + stdio: ["ignore", "pipe", "pipe"], + }); + let bytes = 0; + const chunks: Buffer[] = []; + let settled = false; + let termination: "output" | "timeout" | undefined; + let reap: NodeJS.Timeout | undefined; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (reap) clearTimeout(reap); + error ? reject(error) : resolve(Buffer.concat(chunks).toString("utf8")); + }; + const terminate = (reason: "output" | "timeout"): void => { + if (termination) return; + termination = reason; + child.kill("SIGKILL"); + reap = setTimeout(() => finish(new Error(`${label} ${reason} bound was exceeded and systemctl did not close`)), PROVIDER_REAP_TIMEOUT_MS); + }; + const capture = (chunk: Buffer | string): void => { + if (termination) return; + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = PROVIDER_CONTROL_MAX_OUTPUT_BYTES - bytes; + if (remaining > 0) { chunks.push(value.subarray(0, remaining)); bytes += Math.min(value.length, remaining); } + if (value.length > remaining) terminate("output"); + }; + child.stdout?.on("data", capture); + child.stderr?.on("data", capture); + const timeout = setTimeout(() => terminate("timeout"), PROVIDER_CONTROL_TIMEOUT_MS); + child.once("error", (error) => finish(error)); + child.once("close", (code, signal) => { + if (termination) return finish(new Error(`${label} ${termination} bound was exceeded`)); + if (code === 0 && signal === null) return finish(); + const detail = Buffer.concat(chunks).toString("utf8").trim(); + finish(new Error(`${label} failed: exit=${code ?? "none"}, signal=${signal ?? "none"}${detail ? `, output=${detail}` : ""}`)); + }); + }); +} + +function providerCgroupClientEnvironment(): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" }; + for (const name of ["DBUS_SESSION_BUS_ADDRESS", "HOME", "LANG", "LC_ALL", "LOGNAME", "USER", "XDG_RUNTIME_DIR"] as const) { + if (process.env[name]) environment[name] = process.env[name]; + } + return environment; +} + +function providerCpuSeconds(): number { + const value = process.env.PROVIDER_SUPERVISOR_CPU_SECONDS; + if (!value) return DEFAULT_PROVIDER_CPU_SECONDS; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > DEFAULT_PROVIDER_CPU_SECONDS) { + throw new TypeError("PROVIDER_SUPERVISOR_CPU_SECONDS must be a positive integer no greater than 1200"); + } + return parsed; +} + +function providerTimeoutMs(): number { + const value = process.env.PROVIDER_SUPERVISOR_TIMEOUT_MS; + if (!value) return DEFAULT_PROVIDER_TIMEOUT_MS; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > DEFAULT_PROVIDER_TIMEOUT_MS) { + throw new TypeError("PROVIDER_SUPERVISOR_TIMEOUT_MS must be a positive integer no greater than 1800000"); + } + return parsed; +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function missingDestinationAncestors(target: string): string[] { + const ancestors: string[] = []; + let current = path.dirname(path.resolve(target)); + while (current !== path.parse(current).root && !["/usr", "/bin", "/lib", "/lib64", "/tmp"].includes(current)) { + ancestors.push(current); + current = path.dirname(current); + } + return ancestors.reverse(); +} + +async function exists(target: string): Promise { + try { + await lstat(target); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } +} + +async function prepareMissingProviderOutput( + root: string, + absolutePath: string, + configuredPath: string, + label: string, +): Promise { + await ensureSafePublishDirectory(root, path.dirname(absolutePath)); + await assertSafePublishLeaf(absolutePath, configuredPath); + try { + await lstat(absolutePath); + throw new Error(`${label} already exists: ${configuredPath}`); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + } +} + +async function assertOwnedProviderOutput( + absolutePath: string, + identity: Readonly<{ dev: number; ino: number }>, +): Promise { + const metadata = await lstat(absolutePath); + if ( + metadata.isSymbolicLink() || + !metadata.isFile() || + metadata.dev !== identity.dev || + metadata.ino !== identity.ino + ) { + throw new Error("provider raw output identity changed"); + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} diff --git a/scripts/run-ci-gate.ts b/scripts/run-ci-gate.ts new file mode 100644 index 0000000..d116760 --- /dev/null +++ b/scripts/run-ci-gate.ts @@ -0,0 +1,251 @@ +import { spawnSync } from "node:child_process"; +import { lstat } from "node:fs/promises"; +import path from "node:path"; + +import { + ciCheckoutIdentityFailures, + ciBuildEnvironmentFailures, + isValidCommitSha, + isValidSourceDateEpoch, +} from "./lib/build-environment.ts"; +import { classifyGateStepResult } from "./lib/ci-step-result.ts"; +import { withCiGatePreflight } from "./contracts/ci-gates.ts"; +import { validateCiArtifact } from "./lib/ci-artifact-validator.ts"; +import { writeCiGateLogAtomic } from "./lib/ci-gate-log.ts"; + +const requestedGateId = process.argv + .slice(2) + .find((argument) => /^FE-GATE-\d{3}$/.test(argument)); +await withCiGatePreflight( + process.cwd(), + requestedGateId, + async ({ contractIndex, gateId, gate }) => { + +const logArtifact = contractIndex.artifacts.get(gate.logArtifactId); +if (!logArtifact) throw new TypeError(`CI gate log artifact disappeared: ${gate.logArtifactId}`); +const logSchema = contractIndex.artifactSchemas.get(logArtifact.schemaId); +if (!logSchema || logSchema.kind !== "text") { + throw new TypeError(`CI gate log schema must be bounded text: ${logArtifact.schemaId}`); +} +const output: string[] = []; +let outputBytes = 0; +let passed = true; +const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000; +const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024; +const LOG_DIAGNOSTIC_RESERVE_BYTES = 4_096; +const freshlyProducedArtifactIds = new Set(); +const commandGeneratedEvidence = gate.evidenceArtifactIds + .map((artifactId) => contractIndex.artifacts.get(artifactId)) + .filter((artifact) => artifact?.production === "command-generated"); + +async function observeArtifactGeneration(relativePath: string): Promise { + try { + const metadata = await lstat(path.join(process.cwd(), relativePath), { + bigint: true, + }); + return [ + metadata.dev, + metadata.ino, + metadata.size, + metadata.mtimeNs, + metadata.ctimeNs, + ].join(":"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw error; + } +} +const appendOutput = (...values: readonly string[]): boolean => { + for (const value of values.filter(Boolean)) { + const addedBytes = Buffer.byteLength(value, "utf8") + 1; + if (outputBytes + addedBytes > logSchema.maxBytes) return false; + output.push(value); + outputBytes += addedBytes; + } + return true; +}; + +const gateEnvironment = { ...process.env }; +if (gateEnvironment.CI === "true") { + const commitMetadata = spawnSync( + "git", + ["show", "-s", "--format=%H%n%ct", "HEAD"], + { encoding: "utf8" }, + ); + const [commitSha = "", sourceDateEpoch = ""] = + commitMetadata.stdout.trim().split(/\r?\n/); + if ( + commitMetadata.status === 0 && + isValidCommitSha(commitSha) && + isValidSourceDateEpoch(sourceDateEpoch) + ) { + if (!gateEnvironment.SOURCE_DATE_EPOCH?.trim()) { + gateEnvironment.SOURCE_DATE_EPOCH = sourceDateEpoch; + appendOutput(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`); + } + for (const failure of ciCheckoutIdentityFailures(gateEnvironment, { + commitSha, + sourceDateEpoch, + })) { + appendOutput(failure); + passed = false; + } + } else { + appendOutput( + "unable to resolve the checked-out commit identity and timestamp", + commitMetadata.stderr, + ); + passed = false; + } +} + +for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) { + appendOutput(failure); + passed = false; +} + +for (const variable of gate.requiresEnvironment ?? []) { + if (!gateEnvironment[variable]) { + appendOutput(`missing required environment: ${variable}`); + passed = false; + } +} + +if (passed) { + for (const commandId of gate.commandIds) { + const step = contractIndex.commands.get(commandId); + if (!step) throw new TypeError(`CI gate command disappeared after validation: ${commandId}`); + const producedArtifacts = commandGeneratedEvidence.filter((artifact) => + artifact.producerCommandIds.includes(commandId) + ); + const generationBefore = new Map( + await Promise.all( + producedArtifacts.map(async (artifact) => [ + artifact.id, + await observeArtifactGeneration(artifact.path), + ] as const), + ), + ); + const commandLine = `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(); + if (!appendOutput(commandLine) || logSchema.maxBytes - outputBytes <= LOG_DIAGNOSTIC_RESERVE_BYTES) { + appendOutput("gate aggregate output budget exhausted before command execution"); + passed = false; + break; + } + const result = spawnSync( + "corepack", + ["pnpm", step.script, ...(step.args ?? [])], + { + encoding: "utf8", + env: gateEnvironment, + timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS, + maxBuffer: Math.min( + MAX_STEP_OUTPUT_BYTES, + logSchema.maxBytes - outputBytes - LOG_DIAGNOSTIC_RESERVE_BYTES, + ), + }, + ); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + if (!appendOutput(stdout, stderr)) { + appendOutput("gate aggregate output exceeded the bounded log schema"); + passed = false; + break; + } + const expectation = step.expect === "pass" + ? ({ kind: "pass" } as const) + : step.expectedExitCode !== undefined && step.expectedDiagnosticId !== undefined + ? ({ + kind: "fail", + expectedExitCode: step.expectedExitCode, + expectedDiagnosticId: step.expectedDiagnosticId, + } as const) + : (() => { + throw new TypeError(`negative command lost its validated identity: ${step.id}`); + })(); + const classification = classifyGateStepResult(expectation, { + status: result.status, + signal: result.signal, + stdout, + stderr, + ...(result.error + ? { error: { code: (result.error as NodeJS.ErrnoException).code } } + : {}), + }); + if (!appendOutput(`classification: ${classification.kind}`)) passed = false; + if (!classification.expectationMet) { + appendOutput( + `expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`, + ...(step.expect === "fail" + ? [ + `expected negative fixture identity: exit=${step.expectedExitCode}, diagnostic=${JSON.stringify(step.expectedDiagnosticId)}`, + ] + : []), + ...(classification.kind === "INFRASTRUCTURE_FAILURE" + ? [`infrastructure failure: ${classification.detail}`] + : []), + ); + passed = false; + break; + } + for (const artifact of producedArtifacts) { + const generationAfter = await observeArtifactGeneration(artifact.path); + if (generationAfter !== generationBefore.get(artifact.id)) { + freshlyProducedArtifactIds.add(artifact.id); + } + } + } +} + +if (passed) { + for (const artifact of commandGeneratedEvidence) { + if (!freshlyProducedArtifactIds.has(artifact.id)) { + appendOutput(`command-generated evidence was not freshly produced: ${artifact.path}`); + passed = false; + } + } +} + +await writeCiGateLogAtomic({ + root: process.cwd(), + relativePath: logArtifact.path, + content: `${output.filter(Boolean).join("\n")}\n`, + maxBytes: logSchema.maxBytes, +}); + +if (passed) { + const validationIds = [...new Set([gate.logArtifactId, ...gate.evidenceArtifactIds])]; + for (const artifactId of validationIds) { + const artifact = contractIndex.artifacts.get(artifactId); + if (!artifact) throw new TypeError(`CI artifact disappeared: ${artifactId}`); + const schema = contractIndex.artifactSchemas.get(artifact.schemaId); + if (!schema) throw new TypeError(`CI artifact schema disappeared: ${artifact.schemaId}`); + try { + await validateCiArtifact({ root: process.cwd(), artifact, schema }); + if (!appendOutput(`validated evidence: ${artifact.path} (${schema.id})`)) passed = false; + } catch (error) { + appendOutput( + `invalid evidence: ${artifact.path}: ${error instanceof Error ? error.message : String(error)}`, + ); + passed = false; + } + } + if (!passed) { + await writeCiGateLogAtomic({ + root: process.cwd(), + relativePath: logArtifact.path, + content: `${output.filter(Boolean).join("\n")}\n`, + maxBytes: logSchema.maxBytes, + }); + } +} + +if (!passed) { + process.stderr.write(`${gateId} ${gate.name}: FAIL\n`); + process.exit(1); +} +process.stdout.write( + `${gateId} ${gate.name}: PASS (${gate.retentionClassId})\n`, +); + }, +); diff --git a/scripts/run-http-scenario-evidence.ts b/scripts/run-http-scenario-evidence.ts new file mode 100644 index 0000000..eca3850 --- /dev/null +++ b/scripts/run-http-scenario-evidence.ts @@ -0,0 +1,72 @@ +import { spawnSync } from "node:child_process"; +import { access, rm } from "node:fs/promises"; + +import { + createBoundedPnpmScriptInvocation, + formatPnpmScriptFailure, +} from "./lib/bounded-pnpm-script.ts"; + +const receiptPath = "artifacts/tests/http-scenario-executions.json"; +const expectedNegativeDiagnostic = + "[TEST-EVIDENCE-EXECUTION-ID-MISSING]"; +const pnpmCli = process.env.npm_execpath; +if (!pnpmCli) throw new Error("npm_execpath is required"); + +await rm(receiptPath, { force: true }); +try { + await access(receiptPath); + throw new Error(`stale scenario receipt remains: ${receiptPath}`); +} catch (error) { + if ( + typeof error !== "object" || + error === null || + !("code" in error) || + error.code !== "ENOENT" + ) { + throw error; + } +} + +runExpectedPass("test:http-scenario-catalog"); +runExpectedPass("check:http-scenario-evidence"); +const negative = run("check:http-scenario-evidence:fixture"); +if ( + negative.status !== 1 || + !`${negative.stdout}\n${negative.stderr}`.includes(expectedNegativeDiagnostic) +) { + process.stderr.write(negative.stdout); + process.stderr.write(negative.stderr); + throw new Error( + `scenario negative fixture identity mismatch: ${formatPnpmScriptFailure( + "check:http-scenario-evidence:fixture", + negative, + )}`, + ); +} + +process.stdout.write( + "HTTP scenario evidence: PASS (stale receipt removed, 54 executed, missing-ID fixture rejected)\n", +); + +function run(script: string) { + const invocation = createBoundedPnpmScriptInvocation({ + nodePath: process.execPath, + pnpmCli: pnpmCli!, + script, + environment: process.env, + }); + return spawnSync( + invocation.command, + [...invocation.arguments], + invocation.options, + ); +} + +function runExpectedPass(script: string): void { + const result = run(script); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + if (result.status !== 0 || result.error !== undefined) { + throw new Error(formatPnpmScriptFailure(script, result)); + } +} diff --git a/scripts/security-scan.ts b/scripts/security-scan.ts new file mode 100644 index 0000000..9e8cebe --- /dev/null +++ b/scripts/security-scan.ts @@ -0,0 +1,38 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { evaluateRepositorySecretScan } from "./lib/secret-scan-evaluator.ts"; + +function argumentValue(name: string, fallback: string): string { + const index = process.argv.indexOf(name); + return index >= 0 && process.argv[index + 1] + ? process.argv[index + 1] + : fallback; +} + +const policyPath = argumentValue( + "--policy", + "config/security/secret-scan-policy.json", +); +const artifactPath = argumentValue( + "--artifact", + "artifacts/security/scan.sarif", +); +const evaluation = await evaluateRepositorySecretScan({ policyPath }); + +await mkdir(path.dirname(artifactPath), { recursive: true }); +await writeFile( + artifactPath, + `${JSON.stringify(evaluation.sarif, null, 2)}\n`, +); +const blockingCount = + evaluation.findings.length + evaluation.policyFailures.length; +if (blockingCount > 0) { + process.stderr.write( + `Security scan found ${blockingCount} blocking result(s).\n`, + ); + process.exit(1); +} +process.stdout.write( + `Tracked source, config, built asset and artifact secret scan: PASS (${evaluation.scanFiles.length} files)\n`, +); diff --git a/scripts/serve-static.ts b/scripts/serve-static.ts new file mode 100644 index 0000000..86508c7 --- /dev/null +++ b/scripts/serve-static.ts @@ -0,0 +1,47 @@ +import { createReadStream } from "node:fs"; +import { access, stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; + +const root = path.resolve(process.argv[2] ?? "artifacts/storybook/static"); +const port = Number(process.argv[3] ?? 6006); +const contentTypes: Readonly> = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", +}; + +await access(root); +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); + const decoded = decodeURIComponent(url.pathname); + const requested = path.resolve(root, `.${decoded}`); + if (requested !== root && !requested.startsWith(`${root}${path.sep}`)) { + response.writeHead(403).end(); + return; + } + const details = await stat(requested).catch(() => null); + const file = details?.isDirectory() + ? path.join(requested, "index.html") + : requested; + await access(file); + response.writeHead(200, { + "Content-Type": + contentTypes[path.extname(file)] ?? "application/octet-stream", + "Cache-Control": "no-store", + }); + createReadStream(file).pipe(response); + } catch { + response.writeHead(404).end(); + } +}); +server.listen(port, "127.0.0.1", () => { + process.stdout.write(`Static evidence server: ${root} on ${port}\n`); +}); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/scripts/stage-verified-promotion.ts b/scripts/stage-verified-promotion.ts new file mode 100644 index 0000000..a2a3102 --- /dev/null +++ b/scripts/stage-verified-promotion.ts @@ -0,0 +1,3 @@ +import { runStageVerifiedPromotionCli } from "./lib/stage-verified-promotion-cli.ts"; + +await runStageVerifiedPromotionCli(process.env); diff --git a/scripts/test-browser-file-storage-runtime-removal.ts b/scripts/test-browser-file-storage-runtime-removal.ts new file mode 100644 index 0000000..d042b89 --- /dev/null +++ b/scripts/test-browser-file-storage-runtime-removal.ts @@ -0,0 +1,171 @@ +import { + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + assertNoRuntimeImports, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + regenerateRemovalFixtureWorkflow, + removeRuntimeDependentTests, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; + +const fixtureRoot = path.resolve( + ".tmp/browser-file-storage-runtime-removal", +); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); +const runtimePaths = [ + "src/application/ports/browser-file-storage", + "src/application/ports/browser-transfer", + "src/adapters/browser-file-storage", + "src/adapters/browser-files", + "src/adapters/browser-transfer", + "src/adapters/cache-storage", + "src/adapters/storage/indexeddb", + "src/adapters/storage/opfs", + "tests/browser-capabilities", + "tests/fixtures/browser-file-storage-boundaries", +] as const; +const runtimeSourceRoots = runtimePaths.filter((entry) => + entry.startsWith("src/"), +); +const removedScripts = new Set([ + "test:browser-capabilities", + "verify:browser-capability-evidence", + "check:browser-file-storage-boundaries", + "test:browser-file-storage-removal", +]); +const removedEvidencePathFragments = [ + "browser-capabilities", + "browser-file-storage-runtime-removal", +] as const; +function runPnpm(script: string): boolean { + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script); +} + +await prepareRemovalFixture(fixtureRoot); + +const removedRuntimeTests = + await removeRuntimeDependentTests(fixtureRoot, runtimeSourceRoots); +for (const runtimePath of runtimePaths) { + await rm(path.join(fixtureRoot, runtimePath), { + recursive: true, + force: true, + }); +} + +const catalogPath = path.join( + fixtureRoot, + "config/recipes/frontend-capability-recipes.json", +); +const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as { + recipes: Array>; +}; +const browserFileRuntimeRecipeIds = new Set([ + "offline-indexeddb", + "service-worker-pwa", + "file-transfer", +]); +let removedRuntimeEntries = 0; +for (const recipe of catalog.recipes) { + if ( + typeof recipe.id !== "string" || + !browserFileRuntimeRecipeIds.has(recipe.id) || + !Object.hasOwn(recipe, "referenceRuntime") + ) { + continue; + } + delete recipe.referenceRuntime; + removedRuntimeEntries += 1; +} +if (removedRuntimeEntries !== 3) { + throw new Error( + `Expected three reference runtime catalog entries, removed ${removedRuntimeEntries}`, + ); +} +await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + +await rm(path.join(fixtureRoot, "playwright.capabilities.config.ts"), { + force: true, +}); +await rm( + path.join(fixtureRoot, "scripts/check-browser-file-storage-boundaries.ts"), + { force: true }, +); +await rm( + path.join(fixtureRoot, "scripts/verify-browser-capability-evidence.ts"), + { force: true }, +); +await rm( + path.join(fixtureRoot, "scripts/test-browser-file-storage-runtime-removal.ts"), + { force: true }, +); +// The root snapshot locks the full repository inventory. This removal fixture +// validates its smaller registry through check:ci and its regenerated workflow. +await rm( + path.join(fixtureRoot, "tests/unit/ci-workflow-generation.test.ts"), + { force: true }, +); +await rm( + path.join( + fixtureRoot, + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", + ), + { force: true }, +); + +await pruneRemovalFixtureCiContract({ + root: fixtureRoot, + removedScripts, + removedEvidencePathFragments, +}); +await regenerateRemovalFixtureWorkflow(fixtureRoot); +await assertNoRuntimeImports( + fixtureRoot, + runtimeSourceRoots, + "browser file/storage", +); + +const checks: Array = [ + ["typecheck", runPnpm("check:types")], + ["lint", runPnpm("lint")], + ["architecture", runPnpm("check:architecture")], + ["test", runPnpm("test:all")], + ["build", runPnpm("build")], + ["optional-catalog", runPnpm("check:optional-recipes:source")], + ["ci-contract", runPnpm("check:ci")], +]; +const passed = checks.every(([, result]) => result); +await mkdir("artifacts/tests", { recursive: true }); +await writeFile( + "artifacts/tests/browser-file-storage-runtime-removal.xml", + `\n` + + `` + + checks + .map( + ([name, result]) => + `${result ? "" : ""}`, + ) + .join("") + + `\n`, +); +await rm(fixtureRoot, { recursive: true, force: true }); + +if (!passed) { + process.stderr.write( + `Browser file/storage runtime removal failed: ${checks + .filter(([, result]) => !result) + .map(([name]) => name) + .join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Browser file/storage runtime removal: PASS (${checks.length} base checks, ${removedRuntimeTests} runtime-dependent tests removed by import graph)\n`, +); diff --git a/scripts/test-optional-recipe-removal.ts b/scripts/test-optional-recipe-removal.ts new file mode 100644 index 0000000..3db120f --- /dev/null +++ b/scripts/test-optional-recipe-removal.ts @@ -0,0 +1,138 @@ +import { + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + filesBelow, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + pruneScriptOrchestration, + regenerateRemovalFixtureWorkflow, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; + +const fixtureRoot = path.resolve(".tmp/optional-recipe-removal"); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); +const copyTargets = [ + "src", + "tests", + "recipes", + "scripts", + "schemas", + "config", + "public", + ".gitea", + ".storybook", + "index.html", + "package.json", + "tsconfig.base.json", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "tsconfig.test.json", + "tsconfig.recipes.json", + "tsconfig.web-worker.json", + "tsconfig.service-worker.json", + "vite.service-worker.config.ts", + "vite.config.ts", + "vitest.config.ts", + "playwright.config.ts", + "playwright.dev.config.ts", + "playwright.storybook.config.ts", + "playwright.visual.config.ts", + "eslint.config.ts", + ".dependency-cruiser.json", + ".nvmrc", +]; + +function runPnpm(script: string): boolean { + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script); +} + +await prepareRemovalFixture(fixtureRoot, copyTargets); +for (const rootOnlyTest of [ + "tests/unit/ci-workflow-generation.test.ts", + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", +]) { + await rm(path.join(fixtureRoot, rootOnlyTest), { force: true }); +} +await rm(path.join(fixtureRoot, "recipes"), { recursive: true, force: true }); +await rm(path.join(fixtureRoot, "tests/recipes"), { + recursive: true, + force: true, +}); + +const removedCiScripts = new Set([ + "check:types:recipes", + "test:recipes", + "check:optional-recipes", + "check:optional-recipes:source", + "check:optional-recipe-fixtures", + "test:optional-recipe-removal", +]); +const fixturePackagePath = path.join(fixtureRoot, "package.json"); +const fixturePackage = JSON.parse(await readFile(fixturePackagePath, "utf8")) as { + scripts: Record; +}; +pruneScriptOrchestration(fixturePackage.scripts, "check:types", removedCiScripts); +pruneScriptOrchestration(fixturePackage.scripts, "test:all", removedCiScripts); +await writeFile(fixturePackagePath, `${JSON.stringify(fixturePackage, null, 2)}\n`); +await pruneRemovalFixtureCiContract({ + root: fixtureRoot, + removedScripts: removedCiScripts, + removedEvidencePathFragments: [ + "optional-recipes", + "optional-recipe-fixtures", + "optional-recipe-removal", + ], +}); +await regenerateRemovalFixtureWorkflow(fixtureRoot); + +const checks: Array<[string, boolean]> = [ + ["typecheck", runPnpm("check:types")], + ["architecture", runPnpm("check:architecture")], + ["test", runPnpm("test:all")], + ["build", runPnpm("build")], + ["ci-contract", runPnpm("check:ci")], +]; +const residue: string[] = []; +for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { + if (!/\.(?:js|css|html|json)$/.test(file)) continue; + const content = await readFile(file, "utf8"); + if (content.includes("frontend-optional-recipe-must-not-reach-production")) { + residue.push(path.relative(fixtureRoot, file)); + } +} +checks.push(["bundle-residue", residue.length === 0]); +const passed = checks.every(([, result]) => result); +await mkdir("artifacts/tests", { recursive: true }); +await writeFile( + "artifacts/tests/optional-recipe-removal.xml", + `\n` + + `` + + checks + .map( + ([name, result]) => + `${result ? "" : `${residue.join(", ")}`}`, + ) + .join("") + + `\n`, +); +await rm(fixtureRoot, { recursive: true, force: true }); +if (!passed) { + process.stderr.write( + `Optional recipe removal failed: ${checks + .filter(([, result]) => !result) + .map(([name]) => name) + .join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Optional recipe removal: PASS (${checks.length} base checks)\n`, +); diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts new file mode 100644 index 0000000..eac262f --- /dev/null +++ b/scripts/test-performance.ts @@ -0,0 +1,171 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import process from "node:process"; + +import { chromium } from "@playwright/test"; + +import { evaluateLabBudget } from "../src/application/policies/performance-budgets.ts"; +import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts"; +import { labPerformanceArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type ContractPerformanceEvidence = { + lcpMs: number; + cls: number; +}; + +type ContractPerformanceWindow = Window & { + __contractPerformance?: ContractPerformanceEvidence; +}; + +type LayoutShiftEntry = PerformanceEntry & { + hadRecentInput: boolean; + value: number; +}; + +const server = spawn( + "corepack", + ["pnpm", "preview", "--host", "127.0.0.1", "--port", "4173"], + { stdio: "ignore" }, +); +const baseUrl = "http://127.0.0.1:4173"; + +async function waitForServer() { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(baseUrl); + if (response.ok) return; + } catch { + // The bounded retry loop handles startup races. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Preview server did not become ready."); +} + +try { + await waitForServer(); + const release = JSON.parse( + await readFile("dist/release-manifest.json", "utf8"), + ); + const thresholds = JSON.parse( + await readFile("config/performance/budgets.json", "utf8"), + ).lab; + const browser = await chromium.launch(); + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + }); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.emulateNetworkConditions", { + offline: false, + latency: 40, + downloadThroughput: 200_000, + uploadThroughput: 93_750, + connectionType: "cellular4g", + }); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + await page.addInitScript(() => { + const evidence = { lcpMs: 0, cls: 0 }; + (window as ContractPerformanceWindow).__contractPerformance = evidence; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) evidence.lcpMs = entry.startTime; + }).observe({ type: "largest-contentful-paint", buffered: true }); + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const layoutShift = entry as LayoutShiftEntry; + if (!layoutShift.hadRecentInput) { + evidence.cls += layoutShift.value; + } + } + }).observe({ type: "layout-shift", buffered: true }); + }); + await page.goto(baseUrl, { waitUntil: "networkidle" }); + const target = ROUTE_REGISTRY.EXAMPLES_PLATFORM; + const targetLabel = target.navigationLabel; + if (!targetLabel) { + throw new Error("Performance route must be present in navigation."); + } + const interactionStarted = performance.now(); + await page.getByRole("link", { name: targetLabel }).click(); + await page.getByRole("heading", { name: target.title }).waitFor(); + const namedInteractionMs = performance.now() - interactionStarted; + const paint = await page.evaluate( + () => (window as ContractPerformanceWindow).__contractPerformance, + ); + if (!paint) { + throw new Error("Browser performance evidence was not initialized."); + } + const contextMetadata = { + runner: { + platform: process.platform, + architecture: process.arch, + nodeVersion: process.version, + }, + browser: { name: "chromium", version: await browser.version() }, + viewport: { width: 1280, height: 720 }, + network: { + profile: "contract-fast-4g", + latencyMs: 40, + downloadBytesPerSecond: 200_000, + uploadBytesPerSecond: 93_750, + }, + cpu: { throttlingRate: 4 }, + cache: { state: "cold", isolation: "new-browser-context" }, + build: { buildId: release.buildId, releaseId: release.releaseId }, + }; + const metrics = { + lcpMs: Math.round(paint.lcpMs), + cls: Number(paint.cls.toFixed(4)), + namedInteractionMs: Math.round(namedInteractionMs), + }; + const result = evaluateLabBudget( + { context: contextMetadata, metrics }, + thresholds, + ); + const fixtures = [ + { + name: "missing-context", + passed: !evaluateLabBudget({ metrics }, thresholds).passed, + }, + { + name: "lcp-over-threshold", + passed: !evaluateLabBudget( + { + context: contextMetadata, + metrics: { ...metrics, lcpMs: thresholds.lcpMs + 1 }, + }, + thresholds, + ).passed, + }, + ]; + const passed = result.passed && fixtures.every((fixture) => fixture.passed); + await mkdir("artifacts/performance", { recursive: true }); + await writeValidatedJsonArtifact({ + path: "artifacts/performance/lab.json", + schema: labPerformanceArtifactSchema, + value: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + context: contextMetadata, + metrics, + thresholds, + fixtures, + passed, + }, + }); + if (!passed) { + throw new Error(`Lab performance failed: ${JSON.stringify(metrics)}`); + } + process.stdout.write( + `Lab performance: PASS (LCP ${metrics.lcpMs}ms, CLS ${metrics.cls}, interaction ${metrics.namedInteractionMs}ms)\n`, + ); + } finally { + await browser.close(); + } +} finally { + server.kill("SIGTERM"); +} diff --git a/scripts/test-realtime-runtime-removal.ts b/scripts/test-realtime-runtime-removal.ts new file mode 100644 index 0000000..309d219 --- /dev/null +++ b/scripts/test-realtime-runtime-removal.ts @@ -0,0 +1,154 @@ +import { + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + assertNoRuntimeImports, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + regenerateRemovalFixtureWorkflow, + removeRuntimeDependentTests, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; + +const fixtureRoot = path.resolve(".tmp/realtime-runtime-removal"); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); +const runtimePaths = [ + "src/application/ports/realtime", + "src/application/ports/out/web-push-control.ts", + "src/application/policies/bounded-polling.ts", + "src/contracts/realtime-events.ts", + "src/contracts/realtime-streams.ts", + "src/contracts/web-push.ts", + "src/adapters/realtime", + "src/adapters/web-push", + "tests/fixtures/realtime-boundaries", +] as const; +const runtimeSourceRoots = runtimePaths.filter((entry) => + entry.startsWith("src/"), +); +const runtimeScripts = [ + "check:realtime-boundaries", + "check:realtime-boundaries:fixture", + "test:realtime-removal", +] as const; +const removedEvidencePathFragments = [ + "realtime-boundaries", + "realtime-runtime-removal", +] as const; +function runPnpm(script: string): boolean { + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script); +} + +await prepareRemovalFixture(fixtureRoot); + +const removedRuntimeTests = + await removeRuntimeDependentTests(fixtureRoot, runtimeSourceRoots); +for (const runtimePath of runtimePaths) { + await rm(path.join(fixtureRoot, runtimePath), { + recursive: true, + force: true, + }); +} + +const outputPortsIndexPath = path.join( + fixtureRoot, + "src/application/ports/out/index.ts", +); +const outputPortsIndex = await readFile(outputPortsIndexPath, "utf8"); +await writeFile( + outputPortsIndexPath, + outputPortsIndex.replace( + 'export type { WebPushControlPort } from "./web-push-control.ts";\n', + "", + ), +); + +const catalogPath = path.join( + fixtureRoot, + "config/recipes/frontend-capability-recipes.json", +); +const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as { + recipes: Array>; +}; +const realtimeRecipe = catalog.recipes.find( + (recipe) => recipe.id === "realtime", +); +if (!realtimeRecipe || !Object.hasOwn(realtimeRecipe, "referenceRuntime")) { + throw new Error("Expected realtime reference runtime catalog entry"); +} +delete realtimeRecipe.referenceRuntime; +await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + +for (const scriptPath of [ + "scripts/check-realtime-boundaries.ts", + "scripts/check-realtime-boundary-fixtures.ts", + "scripts/lib/realtime-boundaries.ts", + "scripts/test-realtime-runtime-removal.ts", +]) { + await rm(path.join(fixtureRoot, scriptPath), { force: true }); +} +// The root snapshot locks the full repository inventory. This removal fixture +// validates its smaller registry through check:ci and its regenerated workflow. +await rm( + path.join(fixtureRoot, "tests/unit/ci-workflow-generation.test.ts"), + { force: true }, +); +await rm( + path.join( + fixtureRoot, + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", + ), + { force: true }, +); + +await pruneRemovalFixtureCiContract({ + root: fixtureRoot, + removedScripts: new Set(runtimeScripts), + removedEvidencePathFragments, +}); +await regenerateRemovalFixtureWorkflow(fixtureRoot); +await assertNoRuntimeImports(fixtureRoot, runtimeSourceRoots, "realtime"); + +const checks: Array = [ + ["typecheck", runPnpm("check:types")], + ["lint", runPnpm("lint")], + ["architecture", runPnpm("check:architecture")], + ["test", runPnpm("test:all")], + ["build", runPnpm("build")], + ["optional-catalog", runPnpm("check:optional-recipes:source")], + ["ci-contract", runPnpm("check:ci")], +]; +const passed = checks.every(([, result]) => result); +await mkdir("artifacts/tests", { recursive: true }); +await writeFile( + "artifacts/tests/realtime-runtime-removal.xml", + `\n` + + `` + + checks + .map( + ([name, result]) => + `${result ? "" : ""}`, + ) + .join("") + + `\n`, +); +await rm(fixtureRoot, { recursive: true, force: true }); + +if (!passed) { + process.stderr.write( + `Realtime runtime removal failed: ${checks + .filter(([, result]) => !result) + .map(([name]) => name) + .join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Realtime runtime removal: PASS (${checks.length} base checks, ${removedRuntimeTests} runtime-dependent tests removed by import graph)\n`, +); diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts new file mode 100644 index 0000000..682373c --- /dev/null +++ b/scripts/test-sample-removal.ts @@ -0,0 +1,448 @@ +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { isProductionModulePath } from "./lib/risk-coverage.ts"; +import { + filesBelow, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + pruneScriptOrchestration, + regenerateRemovalFixtureWorkflow, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; + +const fixtureParent = path.resolve(".tmp"); +await mkdir(fixtureParent, { recursive: true }); +const fixtureRoot = await mkdtemp( + path.join(fixtureParent, "reference-feature-removal-"), +); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); +const featureSource = "src/features/reference-feature"; +const featureTests = "tests/features/reference-feature"; +const commonTestPaths = [ + "tests/unit/external-contract-runtime.test.ts", + "tests/unit/http-execution-v3.test.ts", + "tests/unit/runtime-adapters.test.ts", +]; +const featureOwnedPaths = [ + featureSource, + featureTests, + "tests/integration/http-scenario-catalog.test.ts", + "tests/e2e/reference-form.spec.ts", + "tests/e2e/reference-route.spec.ts", + "tests/mocks", + "tests/fixtures/typecheck/invalid-feature-input.ts", + "tests/fixtures/typecheck/invalid-reference-operation.ts", +]; +const copyTargets = [ + "src", + "tests", + "recipes", + "scripts", + "schemas", + "config", + "public", + ".gitea", + ".storybook", + "index.html", + "package.json", + "tsconfig.base.json", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "tsconfig.test.json", + "tsconfig.recipes.json", + "tsconfig.web-worker.json", + "tsconfig.service-worker.json", + "vite.service-worker.config.ts", + "vite.config.ts", + "vitest.config.ts", + "playwright.config.ts", + "playwright.dev.config.ts", + "playwright.storybook.config.ts", + "playwright.visual.config.ts", + "eslint.config.ts", + ".dependency-cruiser.json", + ".nvmrc", +]; + +const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; +import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts"; +import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts"; + +export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]); +export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY; +export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT; +export const API_OPERATIONS = Object.freeze({}); +export const INVALIDATION_REGISTRY = Object.freeze({ + topics: Object.freeze([]), + namespaces: Object.freeze([]), + edges: Object.freeze([]), +}); +export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]); +export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY; +export const NAVIGATION_ROUTES = Object.freeze( + Object.values(ROUTE_REGISTRY) + .filter((definition) => definition.navigationOrder !== null) + .sort( + (left, right) => + left.navigationOrder! - right.navigationOrder!, + ), +); +export function getRoute(routeId: string): RouteDefinition { + const registry = ROUTE_REGISTRY as Readonly>; + const selected = registry[routeId]; + if (!selected) throw new Error(\`Unregistered route: \${routeId}\`); + return selected; +} +export function routePath(routeId: string): string { + return getRoute(routeId).path; +} +`; + +const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts"; +import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx"; + +export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS; +export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME; +`; + +const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) { + void _context; + return Object.freeze({}); +} +`; + +const emptyContractContributions = `import { + composeContractContributions, + type InstalledContractContribution, + type InstalledContractPackageIdentity, +} from "../contracts/external-contract-runtime.ts"; + +export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = + Object.freeze([]); + +export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( + INSTALLED_CONTRACT_CONTRIBUTIONS, +); + +export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageIdentity[] = + COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages; +`; + +const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": Object.freeze({}), + "en-US": Object.freeze({}), +}); +`; + +type CoveragePolicy = { + repositoryBaseline: number; + generatedPaths: string[]; + criticalModules: Array<{ + path?: string; + minimum?: Record; + }>; + highRiskPaths: string[]; + waivers: Array<{ path?: string }>; +}; + +type EvidenceContribution = Readonly<{ owner?: string }>; +type EvidencePolicy = Record< + "scenarioCatalogs" | "sourceContracts", + unknown +>; +type GovernanceConsumer = Readonly<{ path?: string }>; +type GovernanceRegistry = Record & { + consumers?: unknown; + consumerDirectories?: unknown; +}; +type RemovalGovernance = { registries: GovernanceRegistry[] }; + +function runPnpm(script: string, extra: string[] = []): boolean { + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script, extra); +} + +try { + await prepareRemovalFixture(fixtureRoot, copyTargets); + for (const excludedFixtureTest of [ + "tests/unit/ci-workflow-generation.test.ts", + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", + "tests/unit/removal-fixture.test.ts", + "tests/unit/http-scenario-evidence.test.ts", + ]) { + await rm(path.join(fixtureRoot, excludedFixtureTest), { force: true }); + } + + const coveragePolicyFile = path.join( + fixtureRoot, + "config/testing/risk-coverage.json", + ); + const coveragePolicy = JSON.parse( + await readFile(coveragePolicyFile, "utf8"), + ) as CoveragePolicy; + const generatedProductionModules = new Set(coveragePolicy.generatedPaths); + + const removedProductionModuleCount = ( + await filesBelow(path.join(fixtureRoot, featureSource)) + ) + .map((file) => path.relative(fixtureRoot, file).split(path.sep).join("/")) + .filter( + (file) => + isProductionModulePath(file) && !generatedProductionModules.has(file), + ).length; + + for (const ownedPath of featureOwnedPaths) { + await rm(path.join(fixtureRoot, ownedPath), { + recursive: true, + force: true, + }); + } + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"), + emptyContracts, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"), + emptyRuntimes, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"), + emptyAdapters, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-feature-messages.ts"), + emptyMessages, + ); + await writeFile( + path.join(fixtureRoot, "src/features/installed-contract-contributions.ts"), + emptyContractContributions, + ); + + const retainedCriticalModules = coveragePolicy.criticalModules.filter( + (modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`), + ); + if ( + retainedCriticalModules.length === coveragePolicy.criticalModules.length + ) { + throw new Error("Reference feature coverage policy is not registered"); + } + coveragePolicy.criticalModules = retainedCriticalModules; + coveragePolicy.highRiskPaths = coveragePolicy.highRiskPaths.filter( + (modulePath) => !modulePath.startsWith(`${featureSource}/`), + ); + coveragePolicy.waivers = coveragePolicy.waivers.filter( + (waiver) => !waiver.path?.startsWith(`${featureSource}/`), + ); + coveragePolicy.generatedPaths = coveragePolicy.generatedPaths.filter( + (modulePath) => !modulePath.startsWith(`${featureSource}/`), + ); + coveragePolicy.repositoryBaseline -= removedProductionModuleCount; + if (coveragePolicy.repositoryBaseline <= 0) { + throw new Error("Reference feature removal produced an invalid coverage baseline"); + } + await writeFile( + coveragePolicyFile, + `${JSON.stringify(coveragePolicy, null, 2)}\n`, + ); + + const evidencePolicyFile = path.join( + fixtureRoot, + "config/testing/test-evidence.json", + ); + const evidencePolicy = JSON.parse( + await readFile(evidencePolicyFile, "utf8"), + ) as EvidencePolicy; + let removedEvidenceContributions = 0; + for (const policyKey of ["scenarioCatalogs", "sourceContracts"] as const) { + const contributions = evidencePolicy[policyKey]; + if (!Array.isArray(contributions)) { + throw new Error(`Test evidence policy is missing ${policyKey}`); + } + evidencePolicy[policyKey] = contributions.filter((candidate: unknown) => { + const contribution = candidate as EvidenceContribution; + const retained = contribution.owner !== "reference-feature"; + if (!retained) removedEvidenceContributions += 1; + return retained; + }); + } + if (removedEvidenceContributions === 0) { + throw new Error("Reference feature test evidence policy is not registered"); + } + await writeFile( + evidencePolicyFile, + `${JSON.stringify(evidencePolicy, null, 2)}\n`, + ); + + const governanceFile = path.join( + fixtureRoot, + "config/contracts/registry-governance.json", + ); + const removalGovernance = JSON.parse( + await readFile(governanceFile, "utf8"), + ) as RemovalGovernance; + removalGovernance.registries = removalGovernance.registries.map( + (registry) => ({ + ...registry, + ...(Array.isArray(registry.consumers) + ? { + consumers: registry.consumers.filter( + (candidate: unknown) => { + const consumer = candidate as GovernanceConsumer; + return !consumer.path?.includes("features/reference-feature"); + }, + ), + } + : {}), + ...(Array.isArray(registry.consumerDirectories) + ? { + consumerDirectories: registry.consumerDirectories.filter( + (directory: unknown) => + typeof directory !== "string" || + !directory.includes("features/reference-feature"), + ), + } + : {}), + }), + ); + await writeFile( + governanceFile, + `${JSON.stringify(removalGovernance, null, 2)}\n`, + ); + + const removedCiScripts = new Set([ + "check:types:fixture:feature-input", + "check:types:fixture:reference-operation", + "test:http-scenario-evidence", + "test:reference-feature", + ]); + const fixturePackagePath = path.join(fixtureRoot, "package.json"); + const fixturePackage = JSON.parse(await readFile(fixturePackagePath, "utf8")) as { + scripts: Record; + }; + pruneScriptOrchestration(fixturePackage.scripts, "test:all", removedCiScripts); + fixturePackage.scripts["test:coverage"] = fixturePackage.scripts["test:coverage"] + .replace(" tests/features/reference-feature", ""); + delete fixturePackage.scripts["check:http-scenario-evidence"]; + delete fixturePackage.scripts["check:http-scenario-evidence:fixture"]; + await writeFile(fixturePackagePath, `${JSON.stringify(fixturePackage, null, 2)}\n`); + await pruneRemovalFixtureCiContract({ + root: fixtureRoot, + removedScripts: removedCiScripts, + removedEvidencePathFragments: [ + "reference-feature.xml", + "http-scenario-executions", + "http-scenario-evidence", + ], + }); + await regenerateRemovalFixtureWorkflow(fixtureRoot); + + const residue: string[] = []; + for (const root of ["src", "tests"]) { + for (const file of await filesBelow(path.join(fixtureRoot, root))) { + const relative = path.relative(fixtureRoot, file); + const content = await readFile(file, "utf8"); + if ( + /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test( + `${relative}\n${content}`, + ) + ) { + residue.push(relative); + } + } + } + + const checks: Array<[string, boolean]> = [ + [ + "common-test-evidence", + ( + await Promise.all( + commonTestPaths.map(async (testPath) => { + try { + await access(path.join(fixtureRoot, testPath)); + return true; + } catch { + return false; + } + }), + ) + ).every(Boolean), + ], + ["typecheck", runPnpm("check:types")], + ["architecture", runPnpm("check:architecture")], + ["registry-structure", runPnpm("check:registries:structure")], + ["unit-integration", runPnpm("test:all")], + ["coverage", runPnpm("test:coverage")], + ["test-evidence-source", runPnpm("check:test-evidence:source")], + ["ci-contract", runPnpm("check:ci")], + [ + "home-smoke", + runPnpm("exec", [ + "vitest", + "run", + "tests/component/router.test.tsx", + "--reporter=default", + ]), + ], + ["build", runPnpm("build")], + ]; + const builtResidue: string[] = []; + for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { + if (!/\.(?:js|css|html|json)$/.test(file)) continue; + const content = await readFile(file, "utf8"); + if ( + /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content) + ) { + builtResidue.push(path.relative(fixtureRoot, file)); + } + } + const routeCatalog = await import( + `${new URL( + "../src/features/installed-feature-contracts.ts", + `file://${fixtureRoot}/scripts/`, + ).href}?removed=${Date.now()}` + ) as { ROUTE_REGISTRY: Readonly> }; + const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY); + const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_")); + checks.push(["route-absent", routeAbsent]); + checks.push(["fixture-id-residue", residue.length === 0]); + checks.push(["built-fixture-id-residue", builtResidue.length === 0]); + + const passed = checks.every(([, result]) => result); + await mkdir("artifacts/tests", { recursive: true }); + await writeFile( + "artifacts/tests/sample-removal.xml", + `\n` + + `` + + checks + .map( + ([name, result]) => + `${result ? "" : `${[...residue, ...builtResidue].join(", ")}`}`, + ) + .join("") + + `\n`, + ); + if (!passed) { + const failures = checks + .filter(([, result]) => !result) + .map(([name]) => name); + process.stderr.write( + `Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`, + ); + process.exitCode = 1; + } else { + process.stdout.write( + `Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`, + ); + } +} finally { + await rm(fixtureRoot, { recursive: true, force: true }); +} diff --git a/scripts/update-dependency-baseline.ts b/scripts/update-dependency-baseline.ts new file mode 100644 index 0000000..addca7b --- /dev/null +++ b/scripts/update-dependency-baseline.ts @@ -0,0 +1,47 @@ +import { spawnSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; + +import { supplyChainDigest } from "./lib/supply-chain.ts"; + +const owner = process.env.DEPENDENCY_BASELINE_OWNER; +const reason = process.env.DEPENDENCY_BASELINE_REASON; +if (!owner?.trim() || !reason?.trim()) { + process.stderr.write( + "DEPENDENCY_BASELINE_OWNER and DEPENDENCY_BASELINE_REASON are required.\n", + ); + process.exit(2); +} + +const commands: Array<[string, string[]]> = [ + ["corepack", ["pnpm", "build"]], + ["node", ["scripts/generate-supply-chain.ts", "--no-baseline"]], +]; +for (const [command, args] of commands) { + const result = spawnSync(command, args, { stdio: "inherit" }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +const inventory = JSON.parse( + await readFile("artifacts/release/dependency-inventory.json", "utf8"), +); +await writeFile( + "config/security/dependency-baseline.json", + `${JSON.stringify(inventory, null, 2)}\n`, +); +await writeFile( + "config/security/dependency-baseline.approval.json", + `${JSON.stringify( + { + schemaVersion: 1, + snapshotDigest: supplyChainDigest(inventory), + owner, + reason, + approvedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, +); +process.stdout.write( + `Dependency baseline approved: ${inventory.dependencyCount} packages\n`, +); diff --git a/scripts/update-registry-baseline.ts b/scripts/update-registry-baseline.ts new file mode 100644 index 0000000..8e2236b --- /dev/null +++ b/scripts/update-registry-baseline.ts @@ -0,0 +1,41 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { registrySnapshotDigest } from "./lib/registry-compatibility.ts"; + +const inputPath = + process.argv[2] ?? "artifacts/quality/registry-current-snapshot.json"; +const outputPath = + process.argv[3] ?? "config/contracts/registry-baseline.json"; +const owner = process.env.REGISTRY_BASELINE_OWNER; +const reason = process.env.REGISTRY_BASELINE_REASON; + +if (!owner || !reason) { + process.stderr.write( + "REGISTRY_BASELINE_OWNER and REGISTRY_BASELINE_REASON are required.\n", + ); + process.exit(1); +} + +const input = JSON.parse(await readFile(inputPath, "utf8")); +const snapshot = input.registries + ? { schemaVersion: 2, registries: input.registries } + : input; +const digest = registrySnapshotDigest(snapshot); +await mkdir(path.dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`); +await writeFile( + "config/contracts/registry-baseline.approval.json", + `${JSON.stringify( + { + schemaVersion: 1, + snapshotDigest: digest, + owner, + reason, + approvedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, +); +process.stdout.write(`Registry baseline updated: ${digest}\n`); diff --git a/scripts/verify-a11y-manual.ts b/scripts/verify-a11y-manual.ts new file mode 100644 index 0000000..793b1e6 --- /dev/null +++ b/scripts/verify-a11y-manual.ts @@ -0,0 +1,74 @@ +import { mkdir, readFile } from "node:fs/promises"; + +import { manualA11yReportArtifactSchema } from "./contracts/release-artifacts.ts"; +import { + MANUAL_A11Y_ROUTE_IDS, + validateManualA11yEvidence, +} from "./lib/manual-a11y-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type ManualA11yResult = Readonly<{ + routeId: string; + path: string; + reviewer: string | null; + reviewedAt: string | null; + releaseId: string | null; + failures: readonly string[]; + passed: boolean; +}>; + +const results: ManualA11yResult[] = []; +for (const routeId of MANUAL_A11Y_ROUTE_IDS) { + const path = `artifacts/tests/a11y-manual/${routeId}.md`; + const evidence = await readFile(path, "utf8"); + const validation = validateManualA11yEvidence(evidence); + const failures = + validation.fields["Route ID"] === routeId + ? validation.failures + : Object.freeze([...validation.failures, "Route ID mismatch"]); + results.push({ + routeId, + path, + reviewer: validation.fields.Reviewer ?? null, + reviewedAt: validation.fields["Reviewed at"] ?? null, + releaseId: validation.fields["Release ID"] ?? null, + failures, + passed: validation.passed && failures.length === 0, + }); +} +const releaseIds = new Set( + results.map((result) => result.releaseId).filter((releaseId): releaseId is string => Boolean(releaseId)), +); +const coherentRelease = + releaseIds.size === 1 && results.every((result) => Boolean(result.releaseId)); +const passed = + results.every((result) => result.passed) && + coherentRelease; + +await mkdir("artifacts/tests/a11y-manual", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/tests/a11y-manual/report.json", + schema: manualA11yReportArtifactSchema, + value: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + scope: MANUAL_A11Y_ROUTE_IDS, + results, + coherentRelease, + passed, + }, +}); + +if (!passed) { + const failures = results + .filter((result) => !result.passed) + .map((result) => `${result.routeId}: ${result.failures.join(", ")}`); + if (!coherentRelease) failures.push("release IDs do not match"); + process.stderr.write( + `Manual accessibility evidence is incomplete:\n${failures.join("\n")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Manual accessibility evidence: PASS (${results.length} routes)\n`, +); diff --git a/scripts/verify-archived-local-evidence.ts b/scripts/verify-archived-local-evidence.ts new file mode 100644 index 0000000..2f290f7 --- /dev/null +++ b/scripts/verify-archived-local-evidence.ts @@ -0,0 +1,50 @@ +import { readFile } from "node:fs/promises"; + +import { + captureCiCandidateArchive, + withVerifiedCapturedCandidate, +} from "./lib/ci-candidate-archive.ts"; +import { verifyArchivedLocalEvidence } from "./lib/local-release-evidence.ts"; +import { + RELEASE_CANDIDATE_MANIFEST_PATH, + releaseCandidateManifestSchema, +} from "./lib/release-candidate.ts"; + +const argument = (name: string): string | undefined => { + const index = process.argv.indexOf(name); + if (index < 0) return undefined; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new TypeError(`${name} requires a value`); + } + return value; +}; +const archivePath = argument("--archive"); +const expectedSha256 = argument("--sha256"); +if (Boolean(archivePath) !== Boolean(expectedSha256)) { + throw new TypeError("--archive and --sha256 must be supplied together"); +} +const result = archivePath && expectedSha256 + ? await withVerifiedCapturedCandidate({ + captured: await captureCiCandidateArchive({ archivePath, expectedSha256 }), + verify: ({ extractionRoot, manifest }) => + verifyArchivedLocalEvidence({ extractionRoot, expectedManifest: manifest }), + }) + : await verifyCheckoutEvidence(); +if (result.status !== "PASS") { + process.stderr.write( + `Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`, + ); + process.exit(1); +} +process.stdout.write("Archived local evidence verification: PASS\n"); + +async function verifyCheckoutEvidence() { + const candidate = releaseCandidateManifestSchema.parse( + JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")), + ); + return verifyArchivedLocalEvidence({ + extractionRoot: process.cwd(), + expectedManifest: candidate, + }); +} diff --git a/scripts/verify-browser-capability-evidence.ts b/scripts/verify-browser-capability-evidence.ts new file mode 100644 index 0000000..f29ad95 --- /dev/null +++ b/scripts/verify-browser-capability-evidence.ts @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; + +const evidencePath = + "artifacts/tests/browser-capabilities/results.xml"; +const xml = await readFile(evidencePath, "utf8"); +const failures: string[] = []; +const rootAttributes = + /]*)>/u.exec(xml)?.[1] ?? ""; +const root = attributes(rootAttributes); + +for (const field of ["failures", "errors", "skipped"] as const) { + if (root[field] !== "0") { + failures.push(`root ${field} must be zero`); + } +} +if (!positiveInteger(root.tests)) { + failures.push("root tests must be positive"); +} + +const casesByEngine = new Map>(); +for (const match of xml.matchAll( + /]*)>([\s\S]*?)<\/testsuite>/gu, +)) { + const suite = attributes(match[1] ?? ""); + const engine = suite.hostname; + if (!engine) continue; + const cases = + casesByEngine.get(engine) ?? new Set(); + for (const testCase of (match[2] ?? "").matchAll( + /]*)>/gu, + )) { + const data = attributes(testCase[1] ?? ""); + if (data.classname && data.name) { + cases.add(`${data.classname}::${data.name}`); + } + } + casesByEngine.set(engine, cases); +} + +const expectedEngines = ["chromium", "firefox", "webkit"] as const; +const baseline = casesByEngine.get(expectedEngines[0]); +for (const engine of expectedEngines) { + const cases = casesByEngine.get(engine); + if (!cases || cases.size === 0) { + failures.push(`${engine} has no executed browser-capability cases`); + continue; + } + if ( + baseline && + (cases.size !== baseline.size || + [...baseline].some((testCase) => !cases.has(testCase))) + ) { + failures.push(`${engine} case set differs from chromium`); + } +} +for (const engine of casesByEngine.keys()) { + if (!expectedEngines.includes(engine as (typeof expectedEngines)[number])) { + failures.push(`unexpected browser project ${engine}`); + } +} +if (/ 0) { + process.stderr.write( + `Browser capability evidence failed:\n- ${failures.join("\n- ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Browser capability evidence: PASS (${baseline?.size ?? 0} cases x ${expectedEngines.length} engines, zero skipped)\n`, +); + +function attributes(source: string): Record { + return Object.fromEntries( + [...source.matchAll(/([A-Za-z][A-Za-z0-9_-]*)="([^"]*)"/gu)].map( + (match) => [match[1] ?? "", match[2] ?? ""], + ), + ); +} + +function positiveInteger(value: string | undefined): boolean { + return ( + typeof value === "string" && + /^\d+$/u.test(value) && + Number(value) > 0 + ); +} diff --git a/scripts/verify-ci-candidate-archive.ts b/scripts/verify-ci-candidate-archive.ts new file mode 100644 index 0000000..3743acc --- /dev/null +++ b/scripts/verify-ci-candidate-archive.ts @@ -0,0 +1,37 @@ +import { appendFile } from "node:fs/promises"; + +import { verifyCiCandidateArchive } from "./lib/ci-candidate-archive.ts"; +import { + CANDIDATE_ARCHIVE_USAGE, + parseCandidateArchiveArguments, +} from "./lib/ci-candidate-archive-cli.ts"; + +const parsedArguments = parseCandidateArchiveArguments(process.argv.slice(2)); +if (!parsedArguments) { + process.stderr.write(CANDIDATE_ARCHIVE_USAGE); + process.exitCode = 2; +} else try { + const result = await verifyCiCandidateArchive({ + archivePath: parsedArguments.archivePath, + repositoryRoot: process.cwd(), + ...(process.env.CANDIDATE_ARCHIVE_SHA256 + ? { expectedSha256: process.env.CANDIDATE_ARCHIVE_SHA256 } + : {}), + ...(parsedArguments.extractTo ? { extractTo: parsedArguments.extractTo } : {}), + }); + if (parsedArguments.githubOutput) { + await appendFile( + parsedArguments.githubOutput, + `archive_sha256=${result.archiveSha256}\ndist_sha256=${result.manifest.distSha256}\n`, + "utf8", + ); + } + process.stdout.write( + `Candidate archive: ${result.memberCount} members, sha256=${result.archiveSha256} PASS\n`, + ); +} catch (error) { + process.stderr.write( + `Candidate archive failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +} diff --git a/scripts/verify-documentation-readiness.ts b/scripts/verify-documentation-readiness.ts new file mode 100644 index 0000000..f0c0f31 --- /dev/null +++ b/scripts/verify-documentation-readiness.ts @@ -0,0 +1,87 @@ +import { mkdir, readFile } from "node:fs/promises"; + +import { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type DocumentationReview = Readonly<{ + sourcePath: string; + sha256: string; + thresholdSatisfied: boolean; + verdict: string; + score: number; +}>; + +type ReviewLedger = Readonly<{ + evidenceReport: Readonly<{ + repoPath: string; + canonicalSha256: string; + }>; + reviews: Record; + reviewer: string; + standard: string; + status: string; +}>; + +const ledger = JSON.parse( + await readFile("docs/architecture/review-ledger.json", "utf8"), +) as ReviewLedger; +const evidence = await readFile(ledger.evidenceReport.repoPath, "utf8"); +const results = []; +for (const [diagram, review] of Object.entries(ledger.reviews)) { + const sourceReferenced = evidence.includes(review.sourcePath); + const digestReferenced = + /^[0-9a-f]{64}$/.test(review.sha256) && + evidence.includes(review.sha256); + const scorePass = + review.thresholdSatisfied === true && + review.verdict === "PASS" && + typeof review.score === "number" && + evidence.includes(`| ${review.score} | PASS |`); + results.push({ + diagram, + sourcePath: review.sourcePath, + sha256: review.sha256, + sourceReferenced, + digestReferenced, + reviewer: ledger.reviewer, + score: review.score, + scorePass, + passed: + sourceReferenced && + digestReferenced && + ledger.reviewer === "wiki-diagram-reviewer" && + ledger.standard === "rules/diagram-standards.md v2" && + scorePass && + ledger.status === "PASS_SCOPED", + }); +} +const reportDigestValid = + /^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) && + evidence.includes(ledger.evidenceReport.canonicalSha256); +const passed = + reportDigestValid && + results.length === 2 && + results.every((result) => result.passed); +await mkdir("artifacts/quality", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/quality/documentation-review.json", + schema: documentationReviewArtifactSchema, + value: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + status: ledger.status, + reviewer: ledger.reviewer, + standard: ledger.standard, + evidenceReport: ledger.evidenceReport, + reportDigestValid, + results, + passed, + }, +}); +if (!passed) { + process.stderr.write( + "Documentation readiness: FAIL_UNVERIFIED (canonical scoped-review evidence is incomplete)\n", + ); + process.exit(1); +} +process.stdout.write("Documentation readiness: PASS_SCOPED\n"); diff --git a/scripts/verify-exact-promotion-bundle.ts b/scripts/verify-exact-promotion-bundle.ts new file mode 100644 index 0000000..15d7534 --- /dev/null +++ b/scripts/verify-exact-promotion-bundle.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import { readdir } from "node:fs/promises"; + +import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts"; +import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts"; +import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts"; +import { readProviderTrust } from "./lib/provider-trust.ts"; + +const required = (name: string): string => { + const value = process.env[name]; + if (!value) throw new TypeError(`exact promotion verification environment is missing ${name}`); + return value; +}; +const rootArgument = process.argv.indexOf("--root"); +const bundleRoot = path.resolve( + rootArgument >= 0 + ? process.argv[rootArgument + 1] ?? "" + : required("PROMOTION_BUNDLE_ROOT"), +); +if (rootArgument >= 0 && !process.argv[rootArgument + 1]) { + throw new TypeError("--root requires a promotion bundle directory"); +} +const trustRoot = process.cwd(); +const [vulnerabilityTrust, provenanceTrust] = await Promise.all([ + readProviderTrust( + trustRoot, + required("VULNERABILITY_PUBLIC_KEY_PATH"), + required("VULNERABILITY_KEY_ID"), + ), + readProviderTrust( + trustRoot, + required("PROVENANCE_PUBLIC_KEY_PATH"), + required("PROVENANCE_KEY_ID"), + ), +]); +if (!vulnerabilityTrust || !provenanceTrust) { + throw new TypeError("exact promotion verification trust keys are invalid"); +} +const names = (await readdir(bundleRoot)).sort(asciiCompare); +if ( + JSON.stringify(names) !== + JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare)) +) { + throw new TypeError("promotion bundle directory must contain exactly the canonical five files"); +} +const files = Object.fromEntries( + await Promise.all( + PROMOTED_FILE_NAMES.map(async (name) => [ + name, + await readBoundedRegularFile({ + root: bundleRoot, + relativePath: name, + maxBytes: name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216, + }), + ] as const), + ), +); +await verifyExactPromotionBundle(files, { + vulnerabilityTrust, + provenanceTrust, + expected: { + run: { + id: required("EXPECTED_PROMOTION_RUN_ID"), + attempt: requiredPositiveInteger("EXPECTED_PROMOTION_RUN_ATTEMPT"), + }, + sourceRevision: required("EXPECTED_PROMOTION_SOURCE_REVISION"), + archiveSha256: required("EXPECTED_PROMOTION_ARCHIVE_SHA256"), + ...optionalDigest("sourceSetSha256", "EXPECTED_PROMOTION_SOURCE_SET_SHA256"), + ...optionalDigest("bundleSha256", "EXPECTED_PROMOTION_BUNDLE_SHA256"), + ...optionalDigest("distSha256", "EXPECTED_PROMOTION_DIST_SHA256"), + ...optionalDigest("lockfileSha256", "EXPECTED_PROMOTION_LOCKFILE_SHA256"), + }, +}); +process.stdout.write("Exact promotion bundle verification: PASS\n"); + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function requiredPositiveInteger(name: string): number { + const value = required(name); + if (!/^[1-9][0-9]*$/u.test(value) || !Number.isSafeInteger(Number(value))) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return Number(value); +} + +function optionalDigest( + property: "sourceSetSha256" | "bundleSha256" | "distSha256" | "lockfileSha256", + environmentName: string, +): Readonly> { + const value = process.env[environmentName]; + return value ? { [property]: value } : {}; +} diff --git a/scripts/verify-hosting-headers.ts b/scripts/verify-hosting-headers.ts new file mode 100644 index 0000000..26efc86 --- /dev/null +++ b/scripts/verify-hosting-headers.ts @@ -0,0 +1,223 @@ +import { mkdir, readFile, readdir } from "node:fs/promises"; + +import { hostingHeadersArtifactSchema } from "./contracts/release-artifacts.ts"; +import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +type Document = Record; +type ResponseHeaders = Record>; +type HostingMode = "live" | "invalid-live" | "fixture"; +type ProbeResult = Readonly<{ + surface: string; + header: string; + expected: unknown; + observed: unknown; + reason?: string; + passed: boolean; +}>; + +function isRecord(value: unknown): value is Document { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function recordValue(value: unknown): Document { + return isRecord(value) ? value : {}; +} + +function strings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function stringRecord(value: unknown): Record { + return Object.fromEntries( + Object.entries(recordValue(value)).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +function responseHeaders(value: unknown): ResponseHeaders { + return Object.fromEntries( + Object.entries(recordValue(value)).map(([surface, headers]) => [ + surface, + stringRecord(headers), + ]), + ); +} + +async function readDocument(file: string): Promise { + const parsed: unknown = JSON.parse(await readFile(file, "utf8")); + if (!isRecord(parsed)) throw new Error(`${file} must be a JSON object`); + return parsed; +} + +const cachePolicy = await readDocument("config/hosting/cache-policy.json"); +const cacheSurfaces = recordValue(cachePolicy.surfaces); +const securityPolicy = await readDocument( + "config/hosting/security-headers.json", +); +const securityHeaders = stringRecord(securityPolicy.headers); +const baseUrl = process.env.HOSTING_BASE_URL; +const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null; +const distFiles = (await readdir("dist", { recursive: true })).map(String); +const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map")); +const publicServiceWorkers = distFiles.filter((file) => + /(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file), +); + +let responses: ResponseHeaders = {}; +let mode: HostingMode; +const probeResults: ProbeResult[] = []; + +if (liveTarget?.passed) { + mode = "live"; + const assets = await readdir("dist/assets"); + const hashedJavaScript = assets.find((file) => file.endsWith(".js")); + if (!hashedJavaScript) throw new Error("No built hashed JavaScript found."); + const paths = { + index: "/", + runtimeConfig: "/config.json", + releaseManifest: "/release-manifest.json", + hashedAsset: `/assets/${hashedJavaScript}`, + }; + for (const [surface, pathname] of Object.entries(paths)) { + const requestedUrl = new URL(pathname, liveTarget.url); + try { + const response = await fetch(requestedUrl, { redirect: "follow" }); + const finalUrl = new URL(response.url); + probeResults.push( + { + surface, + header: "http-status", + expected: 200, + observed: response.status, + passed: response.status === 200, + }, + { + surface, + header: "final-origin", + expected: liveTarget.url.origin, + observed: finalUrl.origin, + passed: finalUrl.origin === liveTarget.url.origin, + }, + ); + responses[surface] = Object.fromEntries( + [...response.headers.entries()].map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ); + } catch (error: unknown) { + probeResults.push({ + surface, + header: "transport", + expected: "reachable", + observed: error instanceof Error ? error.name : "UnknownError", + passed: false, + }); + } + } +} else if (liveTarget) { + mode = "invalid-live"; + probeResults.push({ + surface: "deployment", + header: "base-url", + expected: "canonical non-loopback HTTPS root URL", + observed: liveTarget.observedOrigin, + reason: liveTarget.reason, + passed: false, + }); +} else { + mode = "fixture"; + const fixture = await readDocument( + "config/hosting/response-headers.fixture.json", + ); + responses = responseHeaders(fixture.responses); +} + +const results: ProbeResult[] = [...probeResults]; +for (const [surface, rawPolicy] of Object.entries(cacheSurfaces)) { + const policy = recordValue(rawPolicy); + if (typeof policy.cacheControl !== "string") continue; + const contentTypes = strings(policy.contentTypes); + const observed = responses[surface]?.["cache-control"]; + results.push({ + surface, + header: "cache-control", + expected: policy.cacheControl, + observed, + passed: observed === policy.cacheControl, + }); + const observedContentType = responses[surface]?.["content-type"]; + const observedMime = observedContentType + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + results.push({ + surface, + header: "content-type", + expected: contentTypes, + observed: observedContentType, + passed: observedMime !== undefined && contentTypes.includes(observedMime), + }); + if (policy.securityHeaders === true) { + for (const [header, expected] of Object.entries(securityHeaders)) { + const observedSecurity = responses[surface]?.[header.toLowerCase()]; + results.push({ + surface, + header: header.toLowerCase(), + expected, + observed: observedSecurity, + passed: observedSecurity === expected, + }); + } + } +} + +const sourceMapPolicy = recordValue(cacheSurfaces.sourceMap); +results.push({ + surface: "sourceMap", + header: "public", + expected: false, + observed: publicSourceMaps.length > 0, + passed: sourceMapPolicy.public === false && publicSourceMaps.length === 0, +}); +const serviceWorkerPolicy = recordValue(cacheSurfaces.serviceWorker); +results.push({ + surface: "serviceWorker", + header: "enabled", + expected: false, + observed: publicServiceWorkers.length > 0, + passed: + serviceWorkerPolicy.enabled === false && publicServiceWorkers.length === 0, +}); + +const passed = results.every((result) => result.passed); +await mkdir("artifacts/release", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/release/hosting-headers.json", + schema: hostingHeadersArtifactSchema, + value: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + mode, + baseUrl: liveTarget?.observedOrigin ?? null, + providerVerificationRequired: mode !== "live", + results, + passed, + }, +}); + +if (!passed) { + process.stderr.write( + "Hosting cache/content-type/security header verification failed.\n", + ); + process.exit(1); +} +process.stdout.write( + `Hosting header contract: PASS (${mode}; live verification ${ + mode === "live" ? "complete" : "required before promotion" + })\n`, +); diff --git a/scripts/verify-release-candidate.ts b/scripts/verify-release-candidate.ts new file mode 100644 index 0000000..14c32d7 --- /dev/null +++ b/scripts/verify-release-candidate.ts @@ -0,0 +1,25 @@ +import { readFile } from "node:fs/promises"; + +import { + RELEASE_CANDIDATE_MANIFEST_PATH, + verifyReleaseCandidate, +} from "./lib/release-candidate.ts"; + +let document: unknown = null; +try { + document = JSON.parse( + await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8"), + ); +} catch { + // The verifier reports a single fail-closed schema error below. +} +const result = await verifyReleaseCandidate(document); +if (result.failures.length > 0) { + process.stderr.write( + `Release candidate verification failed:\n- ${result.failures.join("\n- ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Release candidate verification: PASS (${result.manifest!.distSha256})\n`, +); diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts new file mode 100644 index 0000000..a577ad5 --- /dev/null +++ b/scripts/verify-release.ts @@ -0,0 +1,351 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +import { + verifyCompatibilityTuple, + type CompatibilityTuple, +} from "../src/application/policies/compatibility.ts"; +import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts"; +import { + parseBuildManifestArtifact, + parseReleaseArtifact, + parseRuntimeConfigArtifact, + projectReleaseTokens, + type BuildManifestArtifact, + type ReleaseArtifact, + type RuntimeConfigArtifact, +} from "../src/contracts/release-artifacts.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; +import { + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../src/features/installed-feature-contracts.ts"; +import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; +import { + CANONICAL_VITE_MANIFEST_PATH, + verifyBuildManifestOutputs, +} from "./lib/build-manifest-outputs.ts"; +import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { releaseVerificationArtifactSchema } from "./contracts/release-artifacts.ts"; + +type CoherenceFixture = Readonly<{ + name: string; + expectedCompatible: boolean; + frontend: CompatibilityTuple; + runtime: CompatibilityTuple; +}>; +type RuntimeConfigDocument = RuntimeConfigArtifact & + Readonly<{ BUILD_ID: string; RELEASE_ID: string }>; +type ViteManifestEntry = Readonly<{ + file: string; + name?: string; + isDynamicEntry?: boolean; +}>; + +export type ReleaseArtifactReader = (path: string) => Promise; + +export type ReleaseArtifactsCoherenceOptions = Readonly<{ + readArtifact?: ReleaseArtifactReader; + contractPackages?: readonly InstalledContractPackageIdentity[]; + paths?: Readonly<{ release: string; runtime: string }>; +}>; + +const DEFAULT_RELEASE_COHERENCE_PATHS = Object.freeze({ + release: "dist/release-manifest.json", + runtime: "dist/config.json", +}); + +async function readJsonArtifact(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +export async function verifyReleaseArtifactsCoherence( + options: ReleaseArtifactsCoherenceOptions = {}, +) { + const readArtifact = options.readArtifact ?? readJsonArtifact; + const paths = options.paths ?? DEFAULT_RELEASE_COHERENCE_PATHS; + const release = parseReleaseDocument(await readArtifact(paths.release)); + const runtime = parseRuntimeConfigDocument( + await readArtifact(paths.runtime), + ); + const coherence = await verifyReleaseRuntimeCoherence({ + release, + runtime, + contractPackages: + options.contractPackages ?? EXPECTED_CONTRACT_SET_PACKAGES, + }); + return Object.freeze({ release, runtime, coherence }); +} + +async function main(): Promise { +const fixturesDocument = parseFixturesDocument( + JSON.parse( + await readFile("config/release/coherence-fixtures.json", "utf8"), + ), +); +const verifiedRuntime = await verifyReleaseArtifactsCoherence(); +const { + release, + runtime: runtimeConfig, + coherence: artifactComparison, +} = verifiedRuntime; +const buildManifestDocument: unknown = JSON.parse( + await readFile("artifacts/release/build-manifest.json", "utf8"), +); +assertMatchesJsonSchema( + JSON.parse( + await readFile("schemas/artifacts/build-manifest.schema.json", "utf8"), + ), + buildManifestDocument, + "build manifest", +); +const buildManifest = parseBuildManifestDocument(buildManifestDocument); +const runtimeConfigJsonSchema = requireRecord( + JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")), + "runtime config JSON schema", +); +const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8"); +const viteManifestObject = parseViteManifest(JSON.parse(viteManifest)); +const actualAssetManifestHash = createHash("sha256") + .update(viteManifest) + .digest("hex"); + +const artifactMismatches: string[] = [...artifactComparison.mismatches]; +artifactMismatches.push(...(await verifyBuildManifestOutputs(buildManifest))); +for (const [token, value] of Object.entries(projectReleaseTokens(release))) { + if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) { + artifactMismatches.push(`releaseToken:${token}`); + } +} +if ( + typeof release.builtAt !== "string" || + !Number.isFinite(Date.parse(release.builtAt)) +) { + artifactMismatches.push("releaseToken:builtAtFormat"); +} +if (release.assetManifestHash !== actualAssetManifestHash) { + artifactMismatches.push("assetManifestContent"); +} +if ( + runtimeConfigJsonSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" || + runtimeConfigJsonSchema.type !== "object" || + !runtimeConfigJsonSchema.properties +) { + artifactMismatches.push("runtimeConfigSchema"); +} +if ( + buildManifest.outputs.runtimeConfigSchema !== + "dist/runtime-config.schema.json" +) { + artifactMismatches.push("buildManifest:runtimeConfigSchema"); +} +for (const [token, buildValue, releaseValue] of [ + ["buildId", buildManifest.buildId, release.buildId], + ["commitSha", buildManifest.commitSha, release.commitSha], + ["releaseId", buildManifest.releaseId, release.releaseId], + ["generatedAt", buildManifest.generatedAt, release.builtAt], +] as const) { + if (buildValue !== releaseValue) { + artifactMismatches.push(`buildManifest:${token}`); + } +} + +const expectedChunkIds = new Set( + Object.values(ROUTE_REGISTRY).map((definition) => definition.chunkId), +); +const actualChunkIds = new Set(Object.keys(release.routeChunks)); +for (const chunkId of expectedChunkIds) { + if (!actualChunkIds.has(chunkId)) { + artifactMismatches.push(`routeChunk:missing:${chunkId}`); + } +} +for (const chunkId of actualChunkIds) { + if (!expectedChunkIds.has(chunkId)) { + artifactMismatches.push(`routeChunk:orphan:${chunkId}`); + } +} +const runtimeContracts: Readonly> = + ROUTE_RUNTIME_CONTRACT; +for (const definition of Object.values(ROUTE_REGISTRY)) { + const runtime = runtimeContracts[definition.routeId]; + const viteEntry = Object.values(viteManifestObject).find( + (entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry, + ); + const routeAsset = release.routeChunks[definition.chunkId]; + if (!runtime || !viteEntry || routeAsset !== viteEntry.file) { + artifactMismatches.push(`routeChunk:mismatch:${definition.chunkId}`); + continue; + } + if ( + buildManifest.outputs.routeChunks[definition.chunkId] !== routeAsset + ) { + artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`); + } + try { + await readFile(`dist/${routeAsset}`); + } catch { + artifactMismatches.push(`routeChunk:file:${definition.chunkId}`); + } +} + +const fixtures = fixturesDocument.fixtures.map((fixture) => { + const result = verifyCompatibilityTuple({ + frontend: fixture.frontend, + runtime: fixture.runtime, + }); + return { + name: fixture.name, + expectedCompatible: fixture.expectedCompatible, + actualCompatible: result.compatible, + mismatches: result.mismatches, + passed: result.compatible === fixture.expectedCompatible, + }; +}); +const artifact = { + checked: true, + compatible: artifactComparison.compatible && artifactMismatches.length === 0, + mismatches: artifactMismatches, + releaseId: release.releaseId, +}; +const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed); +const report = { + schemaVersion: 1, + generatedAt: release.builtAt, + artifact, + fixtures, + passed, +}; + +await mkdir("artifacts/release", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/release/verification.json", + schema: releaseVerificationArtifactSchema, + value: report, +}); + +if (!passed) { + process.stderr.write( + `Release coherence failed: ${artifactMismatches.join(", ") || "fixture"}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`, +); +} + +const invokedPath = process.argv[1]; +if ( + invokedPath !== undefined && + import.meta.url === pathToFileURL(invokedPath).href +) { + await main(); +} + +function parseFixturesDocument(value: unknown): Readonly<{ + fixtures: readonly CoherenceFixture[]; +}> { + const document = requireRecord(value, "release coherence fixtures"); + if (!Array.isArray(document.fixtures)) { + throw new TypeError("release coherence fixtures must be an array"); + } + return { + fixtures: document.fixtures.map((candidate, index) => { + const fixture = requireRecord(candidate, `release fixture ${index}`); + if ( + typeof fixture.name !== "string" || + typeof fixture.expectedCompatible !== "boolean" + ) { + throw new TypeError(`Invalid release fixture metadata: ${index}`); + } + return { + name: fixture.name, + expectedCompatible: fixture.expectedCompatible, + frontend: parseCompatibilityTuple( + fixture.frontend, + `release fixture ${index}.frontend`, + ), + runtime: parseCompatibilityTuple( + fixture.runtime, + `release fixture ${index}.runtime`, + ), + }; + }), + }; +} + +function parseReleaseDocument(value: unknown): ReleaseArtifact { + return parseReleaseArtifact(value); +} + +function parseRuntimeConfigDocument(value: unknown): RuntimeConfigDocument { + const document = parseRuntimeConfigArtifact(value); + return { + ...document, + BUILD_ID: requireString(document.BUILD_ID, "runtime config BUILD_ID"), + RELEASE_ID: requireString(document.RELEASE_ID, "runtime config RELEASE_ID"), + }; +} + +function parseBuildManifestDocument(value: unknown): BuildManifestArtifact { + return parseBuildManifestArtifact(value); +} + +function parseCompatibilityTuple(value: unknown, label: string): CompatibilityTuple { + const document = requireRecord(value, label); + return { + buildId: requireString(document.buildId, `${label}.buildId`), + configSchemaVersion: requireString( + document.configSchemaVersion, + `${label}.configSchemaVersion`, + ), + apiContractVersion: requireString( + document.apiContractVersion, + `${label}.apiContractVersion`, + ), + assetManifestHash: requireString( + document.assetManifestHash, + `${label}.assetManifestHash`, + ), + releaseId: requireString(document.releaseId, `${label}.releaseId`), + }; +} + +function parseViteManifest( + value: unknown, +): Readonly> { + const document = requireRecord(value, "Vite manifest"); + const entries: Record = {}; + for (const [key, candidate] of Object.entries(document)) { + const entry = requireRecord(candidate, `Vite manifest entry ${key}`); + entries[key] = { + file: requireString(entry.file, `Vite manifest entry ${key}.file`), + ...(typeof entry.name === "string" ? { name: entry.name } : {}), + ...(typeof entry.isDynamicEntry === "boolean" + ? { isDynamicEntry: entry.isDynamicEntry } + : {}), + }; + } + return entries; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } + return value; +} + +function requireRecord( + value: unknown, + label: string, +): Record { + if (!isRecord(value)) throw new TypeError(`${label} must be an object`); + return value; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/scripts/verify-reproducible-build.ts b/scripts/verify-reproducible-build.ts new file mode 100644 index 0000000..dbf2ec3 --- /dev/null +++ b/scripts/verify-reproducible-build.ts @@ -0,0 +1,82 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +import { reproducibleBuildArtifactSchema } from "./contracts/release-artifacts.ts"; +import { assertCiBuildEnvironment } from "./lib/build-environment.ts"; +import { supplyChainDigest } from "./lib/supply-chain.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +assertCiBuildEnvironment(process.env); + +async function filesWithin(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const nested: string[][] = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesWithin(target) : [target]; + }), + ); + return nested.flat().sort(); +} + +async function distDigest(): Promise { + const rows = await Promise.all( + (await filesWithin("dist")).map(async (file) => ({ + path: path.relative("dist", file).replaceAll("\\", "/"), + bytes: (await readFile(file)).byteLength, + content: supplyChainDigest(await readFile(file)), + })), + ); + return supplyChainDigest(rows); +} + +function build(environment: NodeJS.ProcessEnv = process.env) { + return spawnSync("corepack", ["pnpm", "build"], { + env: environment, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); +} + +const deterministicEnvironment = { + ...process.env, + SOURCE_DATE_EPOCH: "946684800", +}; +const firstBuild = build(deterministicEnvironment); +const firstDigest = firstBuild.status === 0 ? await distDigest() : "BUILD_FAILED"; +const secondBuild = build(deterministicEnvironment); +const secondDigest = + secondBuild.status === 0 ? await distDigest() : "BUILD_FAILED"; +const restoreBuild = build(); +const passed = + firstBuild.status === 0 && + secondBuild.status === 0 && + restoreBuild.status === 0 && + firstDigest === secondDigest; + +await mkdir("artifacts/release", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/release/reproducible-build.json", + schema: reproducibleBuildArtifactSchema, + value: { + schemaVersion: 1, + sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH, + buildId: process.env.VITE_BUILD_ID ?? "local-build", + commitSha: process.env.VITE_COMMIT_SHA ?? "local", + releaseId: process.env.RELEASE_ID ?? "local-release", + runnerImage: + process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`, + firstDigest, + secondDigest, + restored: restoreBuild.status === 0, + status: passed ? "PASS" : "FAIL", + }, +}); +if (!passed) { + process.stderr.write( + `Reproducible build failed: first=${firstDigest} second=${secondDigest}\n`, + ); + process.exit(1); +} +process.stdout.write(`Reproducible build: PASS (${firstDigest})\n`); diff --git a/scripts/verify-supply-chain-artifacts.ts b/scripts/verify-supply-chain-artifacts.ts new file mode 100644 index 0000000..127cc53 --- /dev/null +++ b/scripts/verify-supply-chain-artifacts.ts @@ -0,0 +1,24 @@ +import { mkdir } from "node:fs/promises"; + +import { + supplyChainCoherenceReportSchema, + verifyLocalSupplyChainEvidence, +} from "./lib/local-release-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const report = await verifyLocalSupplyChainEvidence(); +await mkdir("artifacts/security", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/security/supply-chain-coherence.json", + schema: supplyChainCoherenceReportSchema, + value: report, +}); +if (report.status !== "PASS") { + process.stderr.write( + `Supply-chain artifact coherence failed:\n- ${report.failures.join("\n- ")}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Supply-chain artifact coherence: PASS (${report.dependencyCount} dependencies)\n`, +); diff --git a/scripts/write-a11y-report.ts b/scripts/write-a11y-report.ts new file mode 100644 index 0000000..f4d80e4 --- /dev/null +++ b/scripts/write-a11y-report.ts @@ -0,0 +1,19 @@ +import { mkdir } from "node:fs/promises"; + +import { automatedA11yArtifactSchema } from "./contracts/release-artifacts.ts"; +import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +await mkdir("artifacts/tests", { recursive: true }); +await writeValidatedJsonArtifact({ + path: "artifacts/tests/a11y.json", + schema: automatedA11yArtifactSchema, + value: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + scope: MANUAL_A11Y_ROUTE_IDS, + threshold: { critical: 0, serious: 0 }, + automatedStatus: "passed", + manualReview: "see artifacts/tests/a11y-manual/report.json", + }, +}); diff --git a/src/adapters/auth/external-session-adapter.ts b/src/adapters/auth/external-session-adapter.ts new file mode 100644 index 0000000..9f2db0d --- /dev/null +++ b/src/adapters/auth/external-session-adapter.ts @@ -0,0 +1,134 @@ +import type { + AuthSessionPort, + CredentialPatch, + CredentialRequestBinding, + SessionState, +} from "../../application/ports/auth-session-port.ts"; + +export type ExternalSessionOwner = Readonly<{ + readState(): SessionState; + subscribe(listener: () => void): () => void; + beginSignIn(returnTo?: string): Promise; + signOut(): Promise; + attachCredential(binding: CredentialRequestBinding): Promise; + recoverSession(): Promise<"restored" | "no-session">; + notifyUnauthenticated(): void; +}>; + +const ALLOWED_CREDENTIAL_HEADERS = new Set([ + "authorization", + "x-csrf-token", +]); +const MAX_HEADER_VALUE_BYTES = 8_192; + +export function validateCredentialPatch(value: unknown): CredentialPatch { + if (!value || typeof value !== "object") { + throw new TypeError("Auth owner returned an invalid credential patch"); + } + const headers = (value as Record).headers; + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + throw new TypeError("Auth owner returned an invalid credential patch"); + } + const projected: Record = {}; + for (const [name, headerValue] of Object.entries(headers)) { + const normalizedName = name.toLowerCase(); + if ( + !ALLOWED_CREDENTIAL_HEADERS.has(normalizedName) || + typeof headerValue !== "string" || + headerValue.length === 0 || + new TextEncoder().encode(headerValue).byteLength > MAX_HEADER_VALUE_BYTES || + /[\r\n]/.test(headerValue) + ) { + throw new TypeError("Auth owner returned a forbidden credential patch"); + } + projected[normalizedName] = headerValue; + } + return Object.freeze({ headers: Object.freeze(projected) }); +} + +export function createExternalAuthSessionAdapter( + owner: ExternalSessionOwner, +): AuthSessionPort { + return Object.freeze({ + getState: () => owner.readState(), + subscribe: (listener) => owner.subscribe(listener), + beginSignIn: (returnTo) => owner.beginSignIn(returnTo), + signOut: () => owner.signOut(), + async credentialPatch(binding) { + return validateCredentialPatch(await owner.attachCredential(binding)); + }, + async recover() { + const result = await owner.recoverSession(); + if (result !== "restored" && result !== "no-session") { + throw new TypeError("Auth owner returned an invalid recovery state"); + } + return result; + }, + onUnauthenticated: () => owner.notifyUnauthenticated(), + }); +} + +const EMPTY_PATCH = Object.freeze({ headers: Object.freeze({}) }); + +export function createAnonymousSessionAdapter(): AuthSessionPort { + return createExternalAuthSessionAdapter({ + readState: () => "unauthenticated", + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, + attachCredential: async () => EMPTY_PATCH, + recoverSession: async () => "no-session", + notifyUnauthenticated: () => {}, + }); +} + +export type DemoSessionAdapter = AuthSessionPort & + Readonly<{ setState(next: SessionState): void }>; + +export function createDemoSessionAdapter( + initialState: SessionState = "unauthenticated", +): DemoSessionAdapter { + let state = initialState; + const listeners = new Set<() => void>(); + const setState = (next: SessionState) => { + state = next; + for (const listener of listeners) listener(); + }; + return Object.freeze({ + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async beginSignIn() { + setState("authenticated"); + }, + async signOut() { + setState("unauthenticated"); + }, + credentialPatch: async () => EMPTY_PATCH, + async recover() { + if (state === "recovery-pending") { + setState("authenticated"); + return "restored"; + } + return "no-session"; + }, + onUnauthenticated: () => setState("unauthenticated"), + setState, + }); +} + +export function createUnavailableSessionAdapter(): AuthSessionPort { + return Object.freeze({ + getState: () => "integration-failed", + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, + credentialPatch: async () => { + throw new TypeError("External session integration is unavailable"); + }, + recover: async () => "no-session" as const, + onUnauthenticated: () => {}, + }); +} diff --git a/src/adapters/browser-file-storage/index.ts b/src/adapters/browser-file-storage/index.ts new file mode 100644 index 0000000..7b21683 --- /dev/null +++ b/src/adapters/browser-file-storage/index.ts @@ -0,0 +1,6 @@ +export { + createStorageDurabilityAdapter, + type StorageManagerFacade, + type StoragePressurePolicy, + type UserActivationFacade, +} from "./storage-manager-adapter.ts"; diff --git a/src/adapters/browser-file-storage/result.ts b/src/adapters/browser-file-storage/result.ts new file mode 100644 index 0000000..17df5ac --- /dev/null +++ b/src/adapters/browser-file-storage/result.ts @@ -0,0 +1,101 @@ +import type { + BrowserDataFailure, + BrowserDataFailureCode, + BrowserDataObservation, + BrowserDataObserver, + BrowserDataOperation, + BrowserDataRecovery, + BrowserDataResult, +} from "../../application/ports/browser-file-storage/shared.ts"; + +export function browserDataSuccess( + value: Value, +): BrowserDataResult { + return Object.freeze({ ok: true, value }); +} + +export function browserDataFailure( + code: BrowserDataFailureCode, + operation: BrowserDataOperation, + options: Readonly<{ + retryable?: boolean; + recovery?: BrowserDataRecovery; + }> = {}, +): BrowserDataResult { + const error: BrowserDataFailure = Object.freeze({ + code, + operation, + retryable: options.retryable ?? false, + recovery: options.recovery ?? "NONE", + }); + return Object.freeze({ ok: false, error }); +} + +export function abortedResult( + signal: AbortSignal | undefined, + operation: BrowserDataOperation, +): BrowserDataResult | null { + return signal?.aborted + ? browserDataFailure("ABORTED", operation) + : null; +} + +export function mapBrowserDataException( + error: unknown, + operation: BrowserDataOperation, +): BrowserDataResult { + if (!(error instanceof DOMException)) { + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RETRY", + }); + } + + switch (error.name) { + case "AbortError": + return browserDataFailure("ABORTED", operation); + case "ConstraintError": + return browserDataFailure("CONFLICT", operation, { + recovery: "REOPEN", + }); + case "DataCloneError": + case "DataError": + return browserDataFailure("CORRUPT_DATA", operation); + case "NotAllowedError": + case "SecurityError": + return browserDataFailure("PERMISSION_DENIED", operation); + case "NotFoundError": + return browserDataFailure("NOT_FOUND", operation); + case "NotReadableError": + return browserDataFailure("NOT_READABLE", operation, { + retryable: true, + recovery: "REOPEN", + }); + case "QuotaExceededError": + case "NS_ERROR_DOM_QUOTA_REACHED": + return browserDataFailure("QUOTA_EXCEEDED", operation, { + retryable: true, + recovery: "READ_ONLY", + }); + case "VersionError": + return browserDataFailure("MIGRATION_FAILED", operation, { + recovery: "READ_ONLY", + }); + default: + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RETRY", + }); + } +} + +export function observeBrowserData( + observer: BrowserDataObserver | undefined, + observation: BrowserDataObservation, +): void { + try { + observer?.record(Object.freeze({ ...observation })); + } catch { + // Capability correctness is independent from best-effort observation. + } +} diff --git a/src/adapters/browser-file-storage/storage-manager-adapter.ts b/src/adapters/browser-file-storage/storage-manager-adapter.ts new file mode 100644 index 0000000..e2b034a --- /dev/null +++ b/src/adapters/browser-file-storage/storage-manager-adapter.ts @@ -0,0 +1,250 @@ +import type { + StorageDurabilityPort, + StorageEstimate, +} from "../../application/ports/browser-file-storage/storage-durability-port.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, + mapBrowserDataException, +} from "./result.ts"; + +export type StorageManagerFacade = Readonly<{ + estimate(): Promise>; + persisted?(): Promise; + persist?(): Promise; +}>; + +export type StoragePressurePolicy = Readonly<{ + pressureRatio: number; + criticalRatio: number; +}>; + +export type UserActivationFacade = Readonly<{ isActive: boolean }>; + +const DEFAULT_PRESSURE_POLICY: StoragePressurePolicy = Object.freeze({ + pressureRatio: 0.7, + criticalRatio: 0.85, +}); + +const STORAGE_INSPECTION_ABORTED = Symbol("storage-inspection-aborted"); + +export function createStorageDurabilityAdapter( + manager: StorageManagerFacade | undefined, + policy: StoragePressurePolicy = DEFAULT_PRESSURE_POLICY, + userActivation: UserActivationFacade | undefined = + globalThis.navigator?.userActivation, +): StorageDurabilityPort { + const policySnapshot = snapshotPressurePolicy(policy); + const managerSnapshot = snapshotStorageManager(manager); + + return Object.freeze({ + async inspect(signal?: AbortSignal) { + const aborted = abortedResult(signal, "STORAGE_ESTIMATE"); + if (aborted) return aborted; + if (!managerSnapshot) { + return browserDataFailure("UNSUPPORTED", "STORAGE_ESTIMATE", { + recovery: "ONLINE_ONLY", + }); + } + try { + const inspected = await awaitStorageInspection( + Promise.all([ + managerSnapshot.estimate(), + inspectPersistenceState(managerSnapshot), + ]), + signal, + ); + if (inspected === STORAGE_INSPECTION_ABORTED) { + return browserDataFailure("ABORTED", "STORAGE_ESTIMATE"); + } + const [estimate, persisted] = inspected; + const usageBytes = finiteNonNegative(estimate.usage); + const quotaBytes = finiteNonNegative(estimate.quota); + return browserDataSuccess( + Object.freeze({ + usageBytes, + quotaBytes, + persisted, + pressure: storagePressure( + usageBytes, + quotaBytes, + policySnapshot, + ), + }), + ); + } catch (error) { + return mapBrowserDataException(error, "STORAGE_ESTIMATE"); + } + }, + + async requestPersistence( + input: Parameters[0], + ) { + const aborted = abortedResult(input.signal, "STORAGE_PERSIST"); + if (aborted) return aborted; + if ( + input.userInitiated !== true || + input.reason !== "PROTECT_UNSYNCED_USER_DATA" || + userActivation?.isActive !== true + ) { + return browserDataFailure( + input.userInitiated !== true || + input.reason !== "PROTECT_UNSYNCED_USER_DATA" + ? "POLICY_REJECTED" + : "PERMISSION_DENIED", + "STORAGE_PERSIST", + ); + } + if (!managerSnapshot?.persist) { + return browserDataFailure("UNSUPPORTED", "STORAGE_PERSIST", { + recovery: "ONLINE_ONLY", + }); + } + try { + const granted = await managerSnapshot.persist(); + if (typeof granted !== "boolean") { + return browserDataFailure("UNAVAILABLE", "STORAGE_PERSIST", { + retryable: true, + recovery: "RETRY", + }); + } + // persist() cannot be rolled back. Once invoked, its resolved browser + // truth wins even if the caller aborts while the prompt is pending. + return browserDataSuccess<"GRANTED" | "DENIED">( + granted ? "GRANTED" : "DENIED", + ); + } catch (error) { + return mapBrowserDataException(error, "STORAGE_PERSIST"); + } + }, + }); +} + +function snapshotStorageManager( + manager: StorageManagerFacade | undefined, +): StorageManagerFacade | undefined { + if (!manager) return undefined; + const estimate = manager.estimate; + const persisted = manager.persisted; + const persist = manager.persist; + if ( + typeof estimate !== "function" || + (persisted !== undefined && typeof persisted !== "function") || + (persist !== undefined && typeof persist !== "function") + ) { + throw new TypeError("StorageManager facade is invalid."); + } + return Object.freeze({ + estimate: estimate.bind(manager), + ...(persisted + ? { persisted: persisted.bind(manager) } + : {}), + ...(persist ? { persist: persist.bind(manager) } : {}), + }); +} + +function snapshotPressurePolicy( + policy: StoragePressurePolicy, +): StoragePressurePolicy { + const snapshot = Object.freeze({ + pressureRatio: policy.pressureRatio, + criticalRatio: policy.criticalRatio, + }); + assertPressurePolicy(snapshot); + return snapshot; +} + +async function awaitStorageInspection( + inspection: Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return await inspection; + if (signal.aborted) { + // The native calls have already been invoked. Consume a later rejection + // even though the caller no longer waits for their result. + void inspection.catch(() => undefined); + return STORAGE_INSPECTION_ABORTED; + } + + return await new Promise( + (resolve, reject) => { + let settled = false; + const finish = ( + outcome: + | Readonly<{ kind: "VALUE"; value: Value }> + | Readonly<{ kind: "ABORTED" }> + | Readonly<{ kind: "ERROR"; error: unknown }>, + ): void => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + if (outcome.kind === "VALUE") { + resolve(outcome.value); + } else if (outcome.kind === "ABORTED") { + resolve(STORAGE_INSPECTION_ABORTED); + } else { + reject(outcome.error); + } + }; + const onAbort = (): void => finish({ kind: "ABORTED" }); + + signal.addEventListener("abort", onAbort, { once: true }); + inspection.then( + (value) => finish({ kind: "VALUE", value }), + (error: unknown) => finish({ kind: "ERROR", error }), + ); + if (signal.aborted) onAbort(); + }, + ); +} + +async function inspectPersistenceState( + manager: StorageManagerFacade, +): Promise { + if (!manager.persisted) return null; + try { + const persisted = await manager.persisted(); + return typeof persisted === "boolean" ? persisted : null; + } catch { + return null; + } +} + +function finiteNonNegative(value: number | undefined): number | null { + return typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 + ? value + : null; +} + +function storagePressure( + usageBytes: number | null, + quotaBytes: number | null, + policy: StoragePressurePolicy, +): StorageEstimate["pressure"] { + if ( + usageBytes === null || + quotaBytes === null || + quotaBytes === 0 + ) { + return "UNKNOWN"; + } + const ratio = usageBytes / quotaBytes; + if (ratio >= policy.criticalRatio) return "CRITICAL"; + if (ratio >= policy.pressureRatio) return "PRESSURE"; + return "NORMAL"; +} + +function assertPressurePolicy(policy: StoragePressurePolicy): void { + if ( + !Number.isFinite(policy.pressureRatio) || + !Number.isFinite(policy.criticalRatio) || + policy.pressureRatio <= 0 || + policy.criticalRatio > 1 || + policy.pressureRatio >= policy.criticalRatio + ) { + throw new TypeError("Storage pressure policy is invalid."); + } +} diff --git a/src/adapters/browser-files/browser-file-picker.ts b/src/adapters/browser-files/browser-file-picker.ts new file mode 100644 index 0000000..00ef001 --- /dev/null +++ b/src/adapters/browser-files/browser-file-picker.ts @@ -0,0 +1,697 @@ +import type { + FilePolicyReference, + FilePickerPort, + FileSelectionLimitReduction, + FileSelectionOutcome, + LocalFileRef, +} from "../../application/ports/browser-file-storage/file.ts"; +import type { BrowserDataResult } from "../../application/ports/browser-file-storage/shared.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, + mapBrowserDataException, +} from "../browser-file-storage/result.ts"; +import { + BrowserFileVault, + type SystemFileHandle, +} from "./browser-file-vault.ts"; +import { + byteBucket, + observeBrowserFile, + type BrowserFileObserver, +} from "./file-observer.ts"; +import type { RegisteredFileSelectionPolicy } from "./file-policy.ts"; +import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts"; + +type UserActivationState = Readonly<{ isActive: boolean }>; + +type PickerScheduler = Readonly<{ + setTimeout(callback: () => void, delayMs: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +type InputEventDependencies = Readonly<{ + add( + type: string, + listener: EventListener, + options?: AddEventListenerOptions, + ): void; + remove(type: string, listener: EventListener): void; + getAttribute(name: string): string | null; + activate(): void; +}>; + +type WindowEventDependencies = Readonly<{ + add(type: "focus", listener: EventListener): void; + remove(type: "focus", listener: EventListener): void; +}>; + +interface DisposableFilePicker extends FilePickerPort { + dispose(): void; +} + +/** + * Focus can return before some engines dispatch the file input change event. + * This grace keeps the fallback from misclassifying a real selection as a + * dismissal. The native cancel event remains authoritative and immediate. + */ +export const DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS = 1_000; + +export type NativeInputFilePickerOptions = Readonly<{ + /** + * A connected, labelled input owned by presentation. The adapter does not + * create an inaccessible hidden control. + */ + input: HTMLInputElement; + vault: BrowserFileVault; + policies: BrowserFilePolicyRegistry; + window?: Pick; + userActivation?: UserActivationState; + scheduler?: PickerScheduler; + focusFallbackGraceMs?: number; + /** @deprecated Use focusFallbackGraceMs. */ + cancelFallbackDelayMs?: number; + systemOpenPickerSupported?: boolean; + systemSavePickerSupported?: boolean; + observer?: BrowserFileObserver; +}>; + +/** + * Canonical cross-browser picker. select() must be called directly from the + * input's labelled button/keyboard activation. + */ +export class NativeInputFilePicker implements DisposableFilePicker { + readonly support; + readonly #input: HTMLInputElement; + readonly #captureFiles: BrowserFileVault["captureFiles"]; + readonly #releaseFile: BrowserFileVault["release"]; + readonly #resolveSelection: + BrowserFilePolicyRegistry["resolveSelection"]; + readonly #inputEvents: InputEventDependencies; + readonly #windowEvents: WindowEventDependencies | undefined; + readonly #userActivation: UserActivationState | undefined; + readonly #scheduler: PickerScheduler; + readonly #focusFallbackGraceMs: number; + readonly #observer: BrowserFileObserver | undefined; + #pending = false; + #disposed = false; + #abortPending: (() => void) | undefined; + + constructor(options: NativeInputFilePickerOptions) { + this.#input = options.input; + this.#captureFiles = + options.vault.captureFiles.bind(options.vault); + this.#releaseFile = options.vault.release.bind(options.vault); + this.#resolveSelection = + options.policies.resolveSelection.bind(options.policies); + const addInputEvent = options.input.addEventListener; + const removeInputEvent = options.input.removeEventListener; + const getInputAttribute = options.input.getAttribute; + const showPicker = options.input.showPicker; + const click = options.input.click; + if ( + typeof addInputEvent !== "function" || + typeof removeInputEvent !== "function" || + typeof getInputAttribute !== "function" || + (typeof showPicker !== "function" && + typeof click !== "function") + ) { + throw new TypeError("Native file input API is invalid."); + } + this.#inputEvents = Object.freeze({ + add: addInputEvent.bind(options.input), + remove: removeInputEvent.bind(options.input), + getAttribute: getInputAttribute.bind(options.input), + activate: + typeof showPicker === "function" + ? showPicker.bind(options.input) + : click.bind(options.input), + }); + const windowHost = options.window ?? globalThis.window; + if (windowHost) { + const addWindowEvent = windowHost.addEventListener; + const removeWindowEvent = windowHost.removeEventListener; + if ( + typeof addWindowEvent !== "function" || + typeof removeWindowEvent !== "function" + ) { + throw new TypeError("Native picker window API is invalid."); + } + this.#windowEvents = Object.freeze({ + add: addWindowEvent.bind(windowHost), + remove: removeWindowEvent.bind(windowHost), + }); + } else { + this.#windowEvents = undefined; + } + this.#userActivation = + options.userActivation ?? globalThis.navigator?.userActivation; + const scheduler = + options.scheduler ?? + ({ + setTimeout: (callback: () => void, delayMs: number) => + globalThis.setTimeout(callback, delayMs), + clearTimeout: (handle: unknown) => + globalThis.clearTimeout( + handle as ReturnType, + ), + } satisfies PickerScheduler); + if ( + typeof scheduler.setTimeout !== "function" || + typeof scheduler.clearTimeout !== "function" + ) { + throw new TypeError("Native picker scheduler is invalid."); + } + this.#scheduler = Object.freeze({ + setTimeout: scheduler.setTimeout.bind(scheduler), + clearTimeout: scheduler.clearTimeout.bind(scheduler), + }); + if ( + options.focusFallbackGraceMs !== undefined && + options.cancelFallbackDelayMs !== undefined && + options.focusFallbackGraceMs !== options.cancelFallbackDelayMs + ) { + throw new TypeError("Native picker focus grace is ambiguous."); + } + this.#focusFallbackGraceMs = + options.focusFallbackGraceMs ?? + options.cancelFallbackDelayMs ?? + DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS; + this.#observer = options.observer; + this.support = Object.freeze({ + nativeInput: true as const, + systemOpenPicker: options.systemOpenPickerSupported ?? false, + systemSavePicker: options.systemSavePickerSupported ?? false, + }); + if ( + !Number.isSafeInteger(this.#focusFallbackGraceMs) || + this.#focusFallbackGraceMs < 0 + ) { + throw new TypeError("Native picker cancel fallback delay is invalid."); + } + } + + async select(input: { + policy: FilePolicyReference; + limits?: FileSelectionLimitReduction; + signal?: AbortSignal; + }): Promise> { + let request: SelectionRequestSnapshot; + try { + request = snapshotSelectionRequest(input); + } catch { + return this.#finishObservation( + browserDataFailure("INVALID_INPUT", "FILE_SELECT"), + ); + } + if (this.#disposed) { + return this.#finishObservation( + browserDataFailure("UNAVAILABLE", "FILE_SELECT"), + ); + } + const cancelled = abortedResult(request.signal, "FILE_SELECT"); + if (cancelled) return this.#finishObservation(cancelled); + const resolvedPolicy = this.#resolveSelection( + request.policy, + request.limits, + ); + if (!resolvedPolicy.ok) { + return this.#finishObservation(resolvedPolicy); + } + const policy = resolvedPolicy.value; + if ( + !isUsableFileInput( + this.#input, + this.#inputEvents.getAttribute, + ) + ) { + return this.#finishObservation( + browserDataFailure("INVALID_INPUT", "FILE_SELECT"), + ); + } + if (this.#pending) { + return this.#finishObservation( + browserDataFailure("BLOCKED", "FILE_SELECT"), + ); + } + if (this.#userActivation && !this.#userActivation.isActive) { + return this.#finishObservation( + browserDataFailure("PERMISSION_DENIED", "FILE_SELECT"), + ); + } + + this.#pending = true; + try { + const result = + await new Promise>( + (resolve) => { + let settled = false; + let focusTimer: unknown; + const signal = request.signal; + const finish = ( + outcome: BrowserDataResult, + ): void => { + if (settled) return; + settled = true; + this.#inputEvents.remove("change", onChange); + this.#inputEvents.remove("cancel", onCancel); + signal?.removeEventListener("abort", onAbort); + this.#windowEvents?.remove("focus", onWindowFocus); + if (focusTimer !== undefined) { + this.#scheduler.clearTimeout(focusTimer); + } + if (this.#abortPending === abortPending) { + this.#abortPending = undefined; + } + resolve(outcome); + }; + const dismiss = (): void => + finish( + browserDataSuccess( + Object.freeze({ kind: "DISMISSED" as const }), + ), + ); + const onChange = (): void => { + const files = this.#input.files; + if (!files || files.length === 0) { + dismiss(); + return; + } + const captured = this.#captureFiles( + files, + policy, + "NATIVE_INPUT", + ); + finish( + captured.ok + ? browserDataSuccess( + Object.freeze({ + kind: "SELECTED" as const, + files: captured.value, + }), + ) + : captured, + ); + }; + const onCancel = (): void => dismiss(); + const onAbort = (): void => + finish(browserDataFailure("ABORTED", "FILE_SELECT")); + const abortPending = (): void => + finish(browserDataFailure("ABORTED", "FILE_SELECT")); + const onWindowFocus = (): void => { + if (settled || focusTimer !== undefined) return; + focusTimer = this.#scheduler.setTimeout( + () => { + focusTimer = undefined; + if (settled) return; + // Some engines expose FileList before dispatching change. + // Prefer the selected files over a synthetic dismissal. + if ((this.#input.files?.length ?? 0) > 0) { + onChange(); + return; + } + dismiss(); + }, + this.#focusFallbackGraceMs, + ); + }; + + this.#inputEvents.add("change", onChange, { once: true }); + this.#inputEvents.add("cancel", onCancel, { once: true }); + signal?.addEventListener("abort", onAbort, { once: true }); + this.#windowEvents?.add("focus", onWindowFocus); + this.#abortPending = abortPending; + try { + this.#input.accept = pickerAcceptValue(policy); + this.#input.multiple = policy.multiple; + // Allows the same file to produce a new change event. + this.#input.value = ""; + this.#inputEvents.activate(); + } catch (error) { + finish(mapBrowserDataException(error, "FILE_SELECT")); + } + }, + ); + return this.#finishObservation(result); + } catch (error) { + return this.#finishObservation( + mapBrowserDataException(error, "FILE_SELECT"), + ); + } finally { + this.#pending = false; + } + } + + release(ref: LocalFileRef): void { + this.#releaseFile(ref); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#abortPending?.(); + try { + this.#input.value = ""; + } catch { + // Some test doubles or constrained DOM hosts expose a readonly value. + } + } + + #finishObservation( + result: BrowserDataResult, + ): BrowserDataResult { + if (result.ok) { + const dismissed = result.value.kind === "DISMISSED"; + const bytes = + result.value.kind === "SELECTED" + ? result.value.files.reduce( + (total, candidate) => total + candidate.sizeBytes, + 0, + ) + : null; + observeBrowserFile(this.#observer, { + operation: "FILE_SELECT", + outcome: dismissed ? "DISMISSED" : "SUCCESS", + byteBucket: byteBucket(bytes), + }); + } else { + observeBrowserFile(this.#observer, { + operation: "FILE_SELECT", + outcome: "FAILED", + failureCode: result.error.code, + }); + } + return result; + } +} + +export type SystemOpenPickerAcceptType = Readonly<{ + description?: string; + accept: Readonly>; +}>; + +export type SystemOpenPickerOptions = Readonly<{ + multiple: boolean; + excludeAcceptAllOption: boolean; + types: readonly SystemOpenPickerAcceptType[]; +}>; + +export type SystemOpenPicker = ( + options: SystemOpenPickerOptions, +) => Promise; + +export type EnhancedFilePickerOptions = Readonly<{ + showOpenFilePicker: SystemOpenPicker; + vault: BrowserFileVault; + policies: BrowserFilePolicyRegistry; + userActivation?: UserActivationState; + systemSavePickerSupported?: boolean; + observer?: BrowserFileObserver; +}>; + +/** + * Progressive enhancement. Failure never opens the native fallback in the + * same activation; presentation may offer a baseline button for the next + * explicit user action. + */ +export class EnhancedFilePicker implements DisposableFilePicker { + readonly support; + readonly #showOpenFilePicker: SystemOpenPicker; + readonly #captureHandles: BrowserFileVault["captureHandles"]; + readonly #releaseFile: BrowserFileVault["release"]; + readonly #resolveSelection: + BrowserFilePolicyRegistry["resolveSelection"]; + readonly #userActivation: UserActivationState | undefined; + readonly #observer: BrowserFileObserver | undefined; + #pending = false; + #disposed = false; + #abortPending: (() => void) | undefined; + + constructor(options: EnhancedFilePickerOptions) { + if (typeof options.showOpenFilePicker !== "function") { + throw new TypeError("Enhanced file picker API is invalid."); + } + this.#showOpenFilePicker = + options.showOpenFilePicker.bind(options); + this.#captureHandles = + options.vault.captureHandles.bind(options.vault); + this.#releaseFile = options.vault.release.bind(options.vault); + this.#resolveSelection = + options.policies.resolveSelection.bind(options.policies); + this.#userActivation = + options.userActivation ?? globalThis.navigator?.userActivation; + this.#observer = options.observer; + this.support = Object.freeze({ + nativeInput: true as const, + systemOpenPicker: true, + systemSavePicker: options.systemSavePickerSupported ?? false, + }); + } + + async select(input: { + policy: FilePolicyReference; + limits?: FileSelectionLimitReduction; + signal?: AbortSignal; + }): Promise> { + let request: SelectionRequestSnapshot; + try { + request = snapshotSelectionRequest(input); + } catch { + return this.#observe( + browserDataFailure("INVALID_INPUT", "FILE_SELECT"), + ); + } + if (this.#disposed) { + return this.#observe( + browserDataFailure("UNAVAILABLE", "FILE_SELECT"), + ); + } + const cancelled = abortedResult(request.signal, "FILE_SELECT"); + if (cancelled) return this.#observe(cancelled); + const resolvedPolicy = this.#resolveSelection( + request.policy, + request.limits, + ); + if (!resolvedPolicy.ok) return this.#observe(resolvedPolicy); + const policy = resolvedPolicy.value; + if (this.#pending) { + return this.#observe( + browserDataFailure("BLOCKED", "FILE_SELECT"), + ); + } + if (this.#userActivation && !this.#userActivation.isActive) { + return this.#observe( + browserDataFailure("PERMISSION_DENIED", "FILE_SELECT"), + ); + } + + this.#pending = true; + try { + // This call intentionally happens before the first await. + const picker = this.#showOpenFilePicker( + systemPickerOptions(policy), + ); + const handles = await this.#awaitPickerOrDispose( + picker, + request.signal, + ); + const aborted = abortedResult(request.signal, "FILE_SELECT"); + if (aborted) return this.#observe(aborted); + if (handles.length === 0) { + return this.#observe( + browserDataSuccess( + Object.freeze({ kind: "DISMISSED" as const }), + ), + ); + } + const captured = await this.#captureHandles( + handles, + policy, + request.signal ?? new AbortController().signal, + ); + return this.#observe( + captured.ok + ? browserDataSuccess( + Object.freeze({ + kind: "SELECTED" as const, + files: captured.value, + }), + ) + : captured, + ); + } catch (error) { + if (error instanceof PickerDisposedError) { + return this.#observe( + browserDataFailure("ABORTED", "FILE_SELECT"), + ); + } + if (error instanceof DOMException && error.name === "AbortError") { + const aborted = abortedResult(input.signal, "FILE_SELECT"); + if (aborted) return this.#observe(aborted); + return this.#observe( + browserDataSuccess( + Object.freeze({ kind: "DISMISSED" as const }), + ), + ); + } + return this.#observe( + mapBrowserDataException(error, "FILE_SELECT"), + ); + } finally { + this.#pending = false; + } + } + + release(ref: LocalFileRef): void { + this.#releaseFile(ref); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#abortPending?.(); + } + + #awaitPickerOrDispose( + picker: Promise, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) { + return Promise.reject( + new DOMException("Picker aborted", "AbortError"), + ); + } + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + if (this.#abortPending === abortPending) { + this.#abortPending = undefined; + } + callback(); + }; + const abortPending = (): void => + finish(() => reject(new PickerDisposedError())); + const onAbort = (): void => + finish(() => + reject(new DOMException("Picker aborted", "AbortError")), + ); + this.#abortPending = abortPending; + signal?.addEventListener("abort", onAbort, { once: true }); + picker.then( + (handles) => finish(() => resolve(handles)), + (error: unknown) => finish(() => reject(error)), + ); + }); + } + + #observe( + result: BrowserDataResult, + ): BrowserDataResult { + if (!result.ok) { + observeBrowserFile(this.#observer, { + operation: "FILE_SELECT", + outcome: "FAILED", + failureCode: result.error.code, + }); + } else if (result.value.kind === "DISMISSED") { + observeBrowserFile(this.#observer, { + operation: "FILE_SELECT", + outcome: "DISMISSED", + }); + } else { + const bytes = result.value.files.reduce( + (total, file) => total + file.sizeBytes, + 0, + ); + observeBrowserFile(this.#observer, { + operation: "FILE_SELECT", + outcome: "SUCCESS", + byteBucket: byteBucket(bytes), + }); + } + return result; + } +} + +function pickerAcceptValue( + policy: RegisteredFileSelectionPolicy, +): string { + return policy.accept + .flatMap((rule) => [rule.mediaType, ...rule.extensions]) + .join(","); +} + +function systemPickerOptions( + policy: RegisteredFileSelectionPolicy, +): SystemOpenPickerOptions { + const types = policy.accept.map((rule) => + Object.freeze({ + accept: Object.freeze({ + [rule.mediaType]: Object.freeze([...rule.extensions]), + }), + }), + ); + return Object.freeze({ + multiple: policy.multiple, + excludeAcceptAllOption: types.length > 0, + types: Object.freeze(types), + }); +} + +type SelectionRequestSnapshot = Readonly<{ + policy: FilePolicyReference; + limits?: FileSelectionLimitReduction; + signal?: AbortSignal; +}>; + +function snapshotSelectionRequest(input: { + policy: FilePolicyReference; + limits?: FileSelectionLimitReduction; + signal?: AbortSignal; +}): SelectionRequestSnapshot { + const policy = input.policy; + const limits = input.limits; + const signal = input.signal; + return Object.freeze({ + policy, + ...(limits + ? { + limits: Object.freeze({ + ...(limits.maxCount !== undefined + ? { maxCount: limits.maxCount } + : {}), + ...(limits.maxFileBytes !== undefined + ? { maxFileBytes: limits.maxFileBytes } + : {}), + ...(limits.maxTotalBytes !== undefined + ? { maxTotalBytes: limits.maxTotalBytes } + : {}), + }), + } + : {}), + ...(signal ? { signal } : {}), + }); +} + +function isUsableFileInput( + input: HTMLInputElement, + getAttribute: (name: string) => string | null, +): boolean { + if (input.type !== "file" || !input.isConnected) return false; + if ((input.labels?.length ?? 0) > 0) return true; + return ( + (getAttribute("aria-label")?.trim().length ?? 0) > 0 || + (getAttribute("aria-labelledby")?.trim().length ?? 0) > 0 + ); +} + +class PickerDisposedError extends Error { + constructor() { + super("Picker runtime disposed"); + this.name = "PickerDisposedError"; + } +} diff --git a/src/adapters/browser-files/browser-file-policy-registry.ts b/src/adapters/browser-files/browser-file-policy-registry.ts new file mode 100644 index 0000000..4a45c46 --- /dev/null +++ b/src/adapters/browser-files/browser-file-policy-registry.ts @@ -0,0 +1,526 @@ +import type { + DownloadStrategy, + FilePolicyReference, + FileSelectionLimitReduction, +} from "../../application/ports/browser-file-storage/file.ts"; +import type { + BrowserDataOperation, + BrowserDataResult, +} from "../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../browser-file-storage/result.ts"; +import { + assertFileInspectionPolicy, + assertFileSelectionPolicy, + sanitizeSuggestedFileName, + type RegisteredFileInspectionPolicy, + type RegisteredFileSelectionPolicy, +} from "./file-policy.ts"; + +export type RegisteredPreviewPolicy = Readonly<{ + allowedMediaTypes: readonly string[]; + maxPreviewBytes: number; +}>; + +export type RegisteredDownloadPolicy = Readonly<{ + strategy: DownloadStrategy; + mediaType: string; + safeExtension: string; + maxTransferBytes: number; + maxBufferedBytes: number; + integrity: "OPTIONAL" | "REQUIRED"; +}>; + +export type BrowserFilePolicyProfile = Readonly<{ + reference: FilePolicyReference; + selection?: RegisteredFileSelectionPolicy; + inspection?: RegisteredFileInspectionPolicy; + /** + * Preview is deliberately bound to the inspection policy in this profile. + * A verification receipt from another policy can never be replayed here. + */ + preview?: RegisteredPreviewPolicy; + download?: RegisteredDownloadPolicy; +}>; + +export type BrowserFilePolicyRegistryOptions = Readonly<{ + profiles: readonly BrowserFilePolicyProfile[]; + hardLimits: Readonly<{ + maxInspectionBytes: number; + maxRetainedFileBytes: number; + maxPreviewBytes: number; + maxObjectUrlBytes: number; + maxTransferBytes: number; + }>; +}>; + +export type ResolvedPreviewPolicy = Readonly<{ + verificationPolicyBindingId: string; + allowedMediaTypes: ReadonlySet; + maxPreviewBytes: number; +}>; + +export type ResolvedInspectionPolicy = + RegisteredFileInspectionPolicy & + Readonly<{ receiptBindingId: string }>; + +export type ResolvedDownloadPolicy = RegisteredDownloadPolicy; + +type SnapshotProfile = Readonly<{ + receiptBindingId: string; + reference: FilePolicyReference; + selection?: RegisteredFileSelectionPolicy; + inspection?: RegisteredFileInspectionPolicy; + preview?: Readonly<{ + allowedMediaTypes: ReadonlySet; + maxPreviewBytes: number; + }>; + download?: RegisteredDownloadPolicy; +}>; + +const POLICY_TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/i; +const MEDIA_TYPE = + /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i; +const ISSUED_POLICY_REFERENCES = new WeakSet(); + +/** + * Creates the only policy reference accepted by the browser-file runtime. + * Product composition should inject the returned object into its narrow + * feature facade instead of exposing the whole registry to presentation. + */ +export function browserFilePolicyReference( + policyKey: string, + intention: string, +): FilePolicyReference { + if (!POLICY_TOKEN.test(policyKey) || !POLICY_TOKEN.test(intention)) { + throw new TypeError("Browser file policy reference is invalid."); + } + const reference = Object.freeze({ + policyKey: + policyKey as FilePolicyReference["policyKey"], + intention: + intention as FilePolicyReference["intention"], + }); + ISSUED_POLICY_REFERENCES.add(reference); + return reference; +} + +/** + * Immutable composition-time registry. It retains no caller-owned object, + * array, Set or byte-pattern reference. + */ +export class BrowserFilePolicyRegistry { + readonly #profiles: + ReadonlyMap; + + constructor(options: BrowserFilePolicyRegistryOptions) { + assertHardLimits(options.hardLimits); + if ( + !Array.isArray(options.profiles) || + options.profiles.length < 1 || + options.profiles.length > 128 + ) { + throw new TypeError("Browser file policy registry is invalid."); + } + const profiles = + new Map(); + const semanticKeys = new Set(); + for (const input of options.profiles) { + const profile = snapshotProfile(input, options.hardLimits); + const key = referenceKey(profile.reference); + if (semanticKeys.has(key)) { + throw new TypeError("Browser file policy reference is duplicated."); + } + semanticKeys.add(key); + profiles.set(profile.reference, profile); + } + this.#profiles = profiles; + } + + resolveSelection( + reference: FilePolicyReference, + reduction?: FileSelectionLimitReduction, + ): BrowserDataResult { + const profile = this.#resolve(reference, "FILE_SELECT"); + if (!profile.ok) return profile; + const policy = profile.value.selection; + if (!policy) { + return browserDataFailure("POLICY_REJECTED", "FILE_SELECT"); + } + const maxCount = reducedLimit( + reduction?.maxCount, + policy.maxCount, + "FILE_SELECT", + ); + if (!maxCount.ok) return maxCount; + const maxTotalBytes = reducedLimit( + reduction?.maxTotalBytes, + policy.maxTotalBytes, + "FILE_SELECT", + ); + if (!maxTotalBytes.ok) return maxTotalBytes; + const maxFileBytes = reducedLimit( + reduction?.maxFileBytes, + Math.min(policy.maxFileBytes, maxTotalBytes.value), + "FILE_SELECT", + ); + if (!maxFileBytes.ok) return maxFileBytes; + return browserDataSuccess( + Object.freeze({ + ...policy, + maxCount: maxCount.value, + maxFileBytes: maxFileBytes.value, + maxTotalBytes: maxTotalBytes.value, + }), + ); + } + + resolveInspection( + reference: FilePolicyReference, + maxInspectionBytes?: number, + ): BrowserDataResult { + const profile = this.#resolve(reference, "FILE_INSPECT"); + if (!profile.ok) return profile; + const policy = profile.value.inspection; + if (!policy) { + return browserDataFailure("POLICY_REJECTED", "FILE_INSPECT"); + } + const reduced = reducedLimit( + maxInspectionBytes, + policy.maxInspectionBytes, + "FILE_INSPECT", + ); + if (!reduced.ok) return reduced; + return browserDataSuccess( + Object.freeze({ + ...policy, + maxInspectionBytes: reduced.value, + receiptBindingId: profile.value.receiptBindingId, + }), + ); + } + + resolvePreview( + reference: FilePolicyReference, + maxPreviewBytes?: number, + ): BrowserDataResult { + const profile = this.#resolve(reference, "PREVIEW"); + if (!profile.ok) return profile; + if (!profile.value.preview || !profile.value.inspection) { + return browserDataFailure("POLICY_REJECTED", "PREVIEW"); + } + const reduced = reducedLimit( + maxPreviewBytes, + profile.value.preview.maxPreviewBytes, + "PREVIEW", + ); + if (!reduced.ok) return reduced; + return browserDataSuccess( + Object.freeze({ + verificationPolicyBindingId: + profile.value.receiptBindingId, + allowedMediaTypes: + new Set(profile.value.preview.allowedMediaTypes), + maxPreviewBytes: reduced.value, + }), + ); + } + + resolveDownload( + reference: FilePolicyReference, + reductions: Readonly<{ + maxTransferBytes?: number; + maxBufferedBytes?: number; + }>, + ): BrowserDataResult { + const profile = this.#resolve(reference, "DOWNLOAD"); + if (!profile.ok) return profile; + const policy = profile.value.download; + if (!policy) { + return browserDataFailure("POLICY_REJECTED", "DOWNLOAD"); + } + const maxTransferBytes = reducedLimit( + reductions.maxTransferBytes, + policy.maxTransferBytes, + "DOWNLOAD", + ); + if (!maxTransferBytes.ok) return maxTransferBytes; + const maxBufferedBytes = reducedLimit( + reductions.maxBufferedBytes, + Math.min(policy.maxBufferedBytes, maxTransferBytes.value), + "DOWNLOAD", + ); + if (!maxBufferedBytes.ok) return maxBufferedBytes; + return browserDataSuccess( + Object.freeze({ + ...policy, + maxTransferBytes: maxTransferBytes.value, + maxBufferedBytes: maxBufferedBytes.value, + }), + ); + } + + #resolve( + reference: FilePolicyReference, + operation: BrowserDataOperation, + ): BrowserDataResult { + try { + if ( + typeof reference !== "object" || + reference === null || + !ISSUED_POLICY_REFERENCES.has(reference) + ) { + return browserDataFailure("POLICY_REJECTED", operation); + } + const profile = this.#profiles.get(reference); + return profile + ? browserDataSuccess(profile) + : browserDataFailure("POLICY_REJECTED", operation); + } catch { + return browserDataFailure("POLICY_REJECTED", operation); + } + } +} + +function snapshotProfile( + input: BrowserFilePolicyProfile, + hardLimits: BrowserFilePolicyRegistryOptions["hardLimits"], +): SnapshotProfile { + const reference = input.reference; + if ( + typeof reference !== "object" || + reference === null || + !ISSUED_POLICY_REFERENCES.has(reference) || + !Object.isFrozen(reference) + ) { + throw new TypeError( + "Browser file policy reference was not issued by composition.", + ); + } + referenceKey(reference); + if ( + input.selection === undefined && + input.inspection === undefined && + input.preview === undefined && + input.download === undefined + ) { + throw new TypeError("Browser file policy profile is empty."); + } + + const selection = input.selection + ? snapshotSelection(input.selection) + : undefined; + if ( + selection && + (selection.maxFileBytes > hardLimits.maxRetainedFileBytes || + selection.maxTotalBytes > hardLimits.maxRetainedFileBytes) + ) { + throw new TypeError("File selection policy exceeds runtime limits."); + } + + const inspection = input.inspection + ? snapshotInspection( + input.inspection, + hardLimits.maxInspectionBytes, + ) + : undefined; + if (input.preview && !inspection) { + throw new TypeError( + "Preview policy requires an inspection policy in the same profile.", + ); + } + const preview = input.preview + ? snapshotPreview(input.preview, hardLimits.maxPreviewBytes) + : undefined; + const download = input.download + ? snapshotDownload(input.download, hardLimits) + : undefined; + + return Object.freeze({ + receiptBindingId: referenceKey(reference), + reference, + ...(selection ? { selection } : {}), + ...(inspection ? { inspection } : {}), + ...(preview ? { preview } : {}), + ...(download ? { download } : {}), + }); +} + +function snapshotSelection( + input: RegisteredFileSelectionPolicy, +): RegisteredFileSelectionPolicy { + const snapshot = Object.freeze({ + policyId: input.policyId, + purpose: input.purpose, + classification: input.classification, + multiple: input.multiple, + maxCount: input.maxCount, + maxFileBytes: input.maxFileBytes, + maxTotalBytes: input.maxTotalBytes, + allowEmpty: input.allowEmpty, + accept: Object.freeze( + input.accept.map((rule) => + Object.freeze({ + mediaType: rule.mediaType.trim().toLowerCase(), + extensions: Object.freeze( + rule.extensions.map((extension) => + extension.trim().toLowerCase(), + ), + ), + }), + ), + ), + }); + assertFileSelectionPolicy(snapshot); + return snapshot; +} + +function snapshotInspection( + input: RegisteredFileInspectionPolicy, + hardMaxInspectionBytes: number, +): RegisteredFileInspectionPolicy { + const snapshot = Object.freeze({ + policyId: input.policyId, + maxInspectionBytes: input.maxInspectionBytes, + acceptedSignatures: Object.freeze( + input.acceptedSignatures.map((rule) => + Object.freeze({ + mediaType: rule.mediaType.trim().toLowerCase(), + extensions: Object.freeze( + rule.extensions.map((extension) => + extension.trim().toLowerCase(), + ), + ), + patterns: Object.freeze( + rule.patterns.map((pattern) => + Object.freeze({ + offset: pattern.offset, + bytes: Object.freeze([...pattern.bytes]), + ...(pattern.mask + ? { mask: Object.freeze([...pattern.mask]) } + : {}), + }), + ), + ), + }), + ), + ), + }); + assertFileInspectionPolicy(snapshot, hardMaxInspectionBytes); + return snapshot; +} + +function snapshotPreview( + input: RegisteredPreviewPolicy, + hardMaxPreviewBytes: number, +): SnapshotProfile["preview"] { + if ( + !positiveSafeInteger(input.maxPreviewBytes) || + input.maxPreviewBytes > hardMaxPreviewBytes || + !Array.isArray(input.allowedMediaTypes) || + input.allowedMediaTypes.length < 1 || + input.allowedMediaTypes.length > 64 + ) { + throw new TypeError("File preview policy is invalid."); + } + const allowedMediaTypes = new Set(); + for (const inputMediaType of input.allowedMediaTypes) { + const mediaType = inputMediaType.trim().toLowerCase(); + if (!MEDIA_TYPE.test(mediaType)) { + throw new TypeError("File preview media type is invalid."); + } + allowedMediaTypes.add(mediaType); + } + return Object.freeze({ + allowedMediaTypes, + maxPreviewBytes: input.maxPreviewBytes, + }); +} + +function snapshotDownload( + input: RegisteredDownloadPolicy, + hardLimits: BrowserFilePolicyRegistryOptions["hardLimits"], +): RegisteredDownloadPolicy { + const mediaType = input.mediaType.trim().toLowerCase(); + const safeExtension = input.safeExtension.trim().toLowerCase(); + if ( + ![ + "BROWSER_MANAGED", + "PROMPT_AND_STREAM", + "BOUNDED_OBJECT_URL", + ].includes(input.strategy) || + !MEDIA_TYPE.test(mediaType) || + !positiveSafeInteger(input.maxTransferBytes) || + !positiveSafeInteger(input.maxBufferedBytes) || + input.maxTransferBytes > hardLimits.maxTransferBytes || + input.maxBufferedBytes > input.maxTransferBytes || + (input.strategy === "BOUNDED_OBJECT_URL" && + input.maxBufferedBytes > hardLimits.maxObjectUrlBytes) || + !["OPTIONAL", "REQUIRED"].includes(input.integrity) + ) { + throw new TypeError("File download policy is invalid."); + } + // Reuse the production filename extension validator. + sanitizeSuggestedFileName("download", { safeExtension }); + return Object.freeze({ + strategy: input.strategy, + mediaType, + safeExtension, + maxTransferBytes: input.maxTransferBytes, + maxBufferedBytes: input.maxBufferedBytes, + integrity: input.integrity, + }); +} + +function assertHardLimits( + input: BrowserFilePolicyRegistryOptions["hardLimits"], +): void { + if ( + !positiveSafeInteger(input.maxInspectionBytes) || + !positiveSafeInteger(input.maxRetainedFileBytes) || + !positiveSafeInteger(input.maxPreviewBytes) || + !positiveSafeInteger(input.maxObjectUrlBytes) || + !positiveSafeInteger(input.maxTransferBytes) || + input.maxObjectUrlBytes > input.maxTransferBytes + ) { + throw new TypeError("Browser file policy hard limits are invalid."); + } +} + +function reducedLimit( + requested: number | undefined, + configured: number, + operation: BrowserDataOperation, +): BrowserDataResult { + if (requested === undefined) { + return browserDataSuccess(configured); + } + if (!positiveSafeInteger(requested)) { + return browserDataFailure("INVALID_INPUT", operation); + } + if (requested > configured) { + return browserDataFailure("LIMIT_EXCEEDED", operation); + } + return browserDataSuccess(requested); +} + +function referenceKey(reference: FilePolicyReference): string { + if ( + !reference || + typeof reference !== "object" || + !POLICY_TOKEN.test(reference.policyKey) || + !POLICY_TOKEN.test(reference.intention) + ) { + throw new TypeError("Browser file policy reference is invalid."); + } + return JSON.stringify([ + reference.policyKey, + reference.intention, + ]); +} + +function positiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} diff --git a/src/adapters/browser-files/browser-file-vault.ts b/src/adapters/browser-files/browser-file-vault.ts new file mode 100644 index 0000000..defb496 --- /dev/null +++ b/src/adapters/browser-files/browser-file-vault.ts @@ -0,0 +1,895 @@ +import type { + FileCandidate, + FileByteSource, + FileContentPort, + FileInspection, + FilePolicyReference, + FileSelectionSource, + FileVerificationReceipt, + LocalFileRef, +} from "../../application/ports/browser-file-storage/file.ts"; +import type { + BrowserDataOperation, + BrowserDataResult, +} from "../../application/ports/browser-file-storage/shared.ts"; +import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, + mapBrowserDataException, +} from "../browser-file-storage/result.ts"; +import { + assertFileInspectionPolicy, + assertFileSelectionPolicy, + findMatchingSignature, + matchesSelectionHint, + normalizedExtension, + signatureMetadataMatches, + signatureWasExpected, + type RegisteredFileSelectionPolicy, +} from "./file-policy.ts"; +import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts"; +import { + byteBucket, + observeBrowserFile, + type BrowserFileObserver, +} from "./file-observer.ts"; + +export type SystemFileHandle = Readonly<{ + kind: "file"; + name: string; + getFile(): Promise; +}>; + +export interface NativeFileResolver { + resolveFile( + ref: LocalFileRef, + signal: AbortSignal, + operation?: BrowserDataOperation, + ): Promise>; +} + +export type VerifiedNativeFile = Readonly<{ + file: File; + mediaType: string; +}>; + +export interface NativeVerifiedFileResolver { + resolveVerifiedFile(input: { + ref: LocalFileRef; + verificationReceipt: FileVerificationReceipt; + verificationPolicyBindingId: string; + signal: AbortSignal; + }): Promise>; +} + +type VaultRecord = Readonly<{ + displayName: string; + expectedSize: number; + expectedLastModified: number; + load(): Promise; +}>; + +type VerificationRecord = Readonly<{ + ref: LocalFileRef; + policyBindingId: string; + mediaType: string; + file: File; +}>; + +export type BrowserFileVaultOptions = Readonly<{ + policies: BrowserFilePolicyRegistry; + createReference?: () => string; + createVerificationReceipt?: () => string; + hardMaxInspectionBytes?: number; + hardMaxRangeBytes?: number; + hardMaxActiveReferences?: number; + hardMaxRetainedBytes?: number; + observer?: BrowserFileObserver; +}>; + +export const DEFAULT_MAX_INSPECTION_BYTES = 64 * 1024; +const DEFAULT_MAX_RANGE_BYTES = 16 * 1024 * 1024; +const DEFAULT_MAX_ACTIVE_REFERENCES = 32; +const DEFAULT_MAX_RETAINED_BYTES = 256 * 1024 * 1024; + +/** + * Transient native-file vault. Opaque references are session-only and are + * never derived from a file name or local path. + */ +export class BrowserFileVault + implements FileContentPort, NativeFileResolver, NativeVerifiedFileResolver +{ + readonly #records = new Map(); + readonly #verifications = new Map< + FileVerificationReceipt, + VerificationRecord + >(); + readonly #verificationReceiptsByRef = new Map< + LocalFileRef, + Set + >(); + readonly #createReference: () => string; + readonly #createVerificationReceipt: () => string; + readonly #hardMaxInspectionBytes: number; + readonly #hardMaxRangeBytes: number; + readonly #hardMaxActiveReferences: number; + readonly #hardMaxRetainedBytes: number; + readonly #observer: BrowserFileObserver | undefined; + readonly #resolveInspection: + BrowserFilePolicyRegistry["resolveInspection"]; + readonly #lifetime = new AbortController(); + #disposed = false; + #retainedBytes = 0; + + constructor(options: BrowserFileVaultOptions) { + this.#resolveInspection = + options.policies.resolveInspection.bind(options.policies); + this.#createReference = + options.createReference ?? + (() => `file:${globalThis.crypto.randomUUID()}`); + this.#createVerificationReceipt = + options.createVerificationReceipt ?? + (() => `verification:${globalThis.crypto.randomUUID()}`); + this.#hardMaxInspectionBytes = + options.hardMaxInspectionBytes ?? DEFAULT_MAX_INSPECTION_BYTES; + this.#hardMaxRangeBytes = + options.hardMaxRangeBytes ?? DEFAULT_MAX_RANGE_BYTES; + this.#hardMaxActiveReferences = + options.hardMaxActiveReferences ?? + DEFAULT_MAX_ACTIVE_REFERENCES; + this.#hardMaxRetainedBytes = + options.hardMaxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES; + this.#observer = options.observer; + if ( + !isPositiveSafeInteger(this.#hardMaxInspectionBytes) || + !isPositiveSafeInteger(this.#hardMaxRangeBytes) || + !isPositiveSafeInteger(this.#hardMaxActiveReferences) || + !isPositiveSafeInteger(this.#hardMaxRetainedBytes) + ) { + throw new TypeError("Browser file vault byte limits are invalid."); + } + } + + captureFiles( + files: Iterable, + policy: RegisteredFileSelectionPolicy, + source: FileSelectionSource = "NATIVE_INPUT", + ): BrowserDataResult { + if (this.#disposed) { + return browserDataFailure("UNAVAILABLE", "FILE_SELECT"); + } + const nativeFiles = Array.from(files); + const validated = this.#validateSelection(nativeFiles, policy, source); + if (!validated.ok) return validated; + + const pending: Array> = []; + for (const [index, file] of nativeFiles.entries()) { + const candidate = validated.value[index]; + if (!candidate) { + return browserDataFailure("INVALID_INPUT", "FILE_SELECT"); + } + pending.push({ + candidate, + record: Object.freeze({ + displayName: file.name, + expectedSize: file.size, + expectedLastModified: file.lastModified, + load: async () => file, + }), + }); + } + for (const item of pending) { + this.#retainRecord(item.candidate.ref, item.record); + } + return browserDataSuccess( + Object.freeze(pending.map((item) => item.candidate)), + ); + } + + async captureHandles( + handles: readonly SystemFileHandle[], + policy: RegisteredFileSelectionPolicy, + signal: AbortSignal, + ): Promise> { + if (this.#disposed) { + return browserDataFailure("UNAVAILABLE", "FILE_SELECT"); + } + const cancelled = abortedResult(signal, "FILE_SELECT"); + if (cancelled) return cancelled; + try { + assertFileSelectionPolicy(policy); + } catch { + return browserDataFailure("INVALID_INPUT", "FILE_SELECT"); + } + let handleSnapshots: readonly SystemFileHandle[]; + try { + handleSnapshots = snapshotSystemFileHandles(handles); + } catch { + return browserDataFailure("INVALID_INPUT", "FILE_SELECT"); + } + if ( + handleSnapshots.length === 0 || + handleSnapshots.length > policy.maxCount || + (!policy.multiple && handleSnapshots.length > 1) || + this.#records.size + handleSnapshots.length > + this.#hardMaxActiveReferences + ) { + return browserDataFailure( + handleSnapshots.length === 0 + ? "INVALID_INPUT" + : "LIMIT_EXCEEDED", + "FILE_SELECT", + ); + } + try { + const files: File[] = []; + for (const handle of handleSnapshots) { + if (handle.kind !== "file") { + return browserDataFailure("INVALID_INPUT", "FILE_SELECT"); + } + const file = await handle.getFile(); + if (file.name !== handle.name) { + return browserDataFailure("STALE_RESULT", "FILE_SELECT", { + recovery: "RESELECT", + }); + } + files.push(file); + if (this.#disposed) { + return browserDataFailure("UNAVAILABLE", "FILE_SELECT"); + } + const aborted = abortedResult(signal, "FILE_SELECT"); + if (aborted) return aborted; + } + const validated = this.#validateSelection( + files, + policy, + "SYSTEM_PICKER", + ); + if (!validated.ok) return validated; + + for (const [index, handle] of handleSnapshots.entries()) { + const candidate = validated.value[index]; + const file = files[index]; + if (!candidate || !file) { + return browserDataFailure("INVALID_INPUT", "FILE_SELECT"); + } + this.#retainRecord( + candidate.ref, + Object.freeze({ + displayName: file.name, + expectedSize: file.size, + expectedLastModified: file.lastModified, + load: () => handle.getFile(), + }), + ); + } + return validated; + } catch (error) { + return mapBrowserDataException(error, "FILE_SELECT"); + } + } + + async inspect(input: { + ref: LocalFileRef; + policy: FilePolicyReference; + maxInspectionBytes?: number; + signal: AbortSignal; + }): Promise> { + if (this.#disposed) { + return this.#observeFailureResult( + browserDataFailure("UNAVAILABLE", "FILE_INSPECT"), + ); + } + let request: Readonly<{ + ref: LocalFileRef; + policy: FilePolicyReference; + maxInspectionBytes?: number; + signal: AbortSignal; + }>; + try { + const maxInspectionBytes = input.maxInspectionBytes; + request = Object.freeze({ + ref: input.ref, + policy: input.policy, + ...(maxInspectionBytes !== undefined + ? { maxInspectionBytes } + : {}), + signal: input.signal, + }); + } catch { + return this.#observeFailureResult( + browserDataFailure("INVALID_INPUT", "FILE_INSPECT"), + ); + } + const resolvedPolicy = this.#resolveInspection( + request.policy, + request.maxInspectionBytes, + ); + if (!resolvedPolicy.ok) { + return this.#observeFailureResult(resolvedPolicy); + } + const policy = resolvedPolicy.value; + try { + assertFileInspectionPolicy( + policy, + this.#hardMaxInspectionBytes, + ); + } catch { + return this.#observeFailureResult( + browserDataFailure("POLICY_REJECTED", "FILE_INSPECT"), + ); + } + this.#invalidateVerifications(request.ref); + const resolved = await this.resolveFile( + request.ref, + request.signal, + "FILE_INSPECT", + ); + if (!resolved.ok) return this.#observeFailureResult(resolved); + const file = resolved.value; + try { + const headerLength = Math.min( + file.size, + policy.maxInspectionBytes, + ); + const header = new Uint8Array( + await file.slice(0, headerLength).arrayBuffer(), + ); + if (this.#disposed) { + return this.#observeFailureResult( + browserDataFailure("UNAVAILABLE", "FILE_INSPECT"), + ); + } + const cancelled = abortedResult( + request.signal, + "FILE_INSPECT", + ); + if (cancelled) return this.#observeFailureResult(cancelled); + const matched = findMatchingSignature( + header, + policy.acceptedSignatures, + ); + const expected = signatureWasExpected( + file.name, + normalizedMediaType(file.type), + policy.acceptedSignatures, + ); + const signature = matched + ? signatureMetadataMatches( + file.name, + normalizedMediaType(file.type), + matched, + ) + ? ("MATCHED" as const) + : ("MISMATCHED" as const) + : expected + ? ("MISMATCHED" as const) + : ("UNKNOWN" as const); + let verificationReceipt: FileVerificationReceipt | null = null; + if (matched && signature === "MATCHED") { + const issued = this.#issueVerification({ + ref: request.ref, + policyBindingId: policy.receiptBindingId, + mediaType: matched.mediaType, + file, + }); + if (!issued.ok) { + this.#observeFailure("FILE_INSPECT", issued); + return issued; + } + verificationReceipt = issued.value; + } + const inspection = Object.freeze({ + byteLength: file.size, + reportedMediaType: normalizedMediaType(file.type), + detectedMediaType: matched?.mediaType ?? null, + normalizedExtension: normalizedExtension(file.name), + signature, + verificationReceipt, + }); + this.#observeSuccess("FILE_INSPECT", file.size); + return browserDataSuccess(inspection); + } catch (error) { + const failure = mapBrowserDataException(error, "FILE_INSPECT"); + this.#observeFailure("FILE_INSPECT", failure); + return failure; + } + } + + async readRange(input: { + ref: LocalFileRef; + offset: number; + length: number; + signal: AbortSignal; + }): Promise> { + if (this.#disposed) { + return this.#observeFailureResult( + browserDataFailure("UNAVAILABLE", "FILE_READ"), + ); + } + let ref: LocalFileRef; + let offset: number; + let length: number; + let signal: AbortSignal; + try { + ref = input.ref; + offset = input.offset; + length = input.length; + signal = input.signal; + } catch { + return this.#observeFailureResult( + browserDataFailure("INVALID_INPUT", "FILE_READ"), + ); + } + if ( + !isValidByteLength(offset) || + !isValidByteLength(length) || + length > this.#hardMaxRangeBytes || + !Number.isSafeInteger(offset + length) + ) { + return this.#observeFailureResult( + browserDataFailure("LIMIT_EXCEEDED", "FILE_READ"), + ); + } + const resolved = await this.resolveFile( + ref, + signal, + "FILE_READ", + ); + if (!resolved.ok) return this.#observeFailureResult(resolved); + const file = resolved.value; + if (offset + length > file.size) { + return this.#observeFailureResult( + browserDataFailure("INVALID_INPUT", "FILE_READ"), + ); + } + try { + const bytes = new Uint8Array( + await file + .slice(offset, offset + length) + .arrayBuffer(), + ); + if (this.#disposed) { + return this.#observeFailureResult( + browserDataFailure("UNAVAILABLE", "FILE_READ"), + ); + } + const cancelled = abortedResult(signal, "FILE_READ"); + if (cancelled) return this.#observeFailureResult(cancelled); + this.#observeSuccess("FILE_READ", bytes.byteLength); + return browserDataSuccess(bytes); + } catch (error) { + const failure = mapBrowserDataException(error, "FILE_READ"); + this.#observeFailure("FILE_READ", failure); + return failure; + } + } + + async openSource(input: { + ref: LocalFileRef; + signal: AbortSignal; + }): Promise> { + if (this.#disposed) { + return this.#observeFailureResult( + browserDataFailure("UNAVAILABLE", "FILE_READ"), + ); + } + let ref: LocalFileRef; + let requestSignal: AbortSignal; + try { + ref = input.ref; + requestSignal = input.signal; + } catch { + return this.#observeFailureResult( + browserDataFailure("INVALID_INPUT", "FILE_READ"), + ); + } + const resolved = await this.resolveFile( + ref, + requestSignal, + "FILE_READ", + ); + if (!resolved.ok) return this.#observeFailureResult(resolved); + const file = resolved.value; + const expectedLength = file.size; + const vault = this; + const source: FileByteSource = Object.freeze({ + byteLength: expectedLength, + async *stream( + signal: AbortSignal, + ): AsyncIterable> { + let transferred = 0; + const combined = combineAbortSignals( + signal, + vault.#lifetime.signal, + ); + try { + for await (const chunk of streamNativeFile( + file, + combined.signal, + )) { + transferred += chunk.byteLength; + yield browserDataSuccess(chunk); + } + if (vault.#disposed) { + const unavailable = browserDataFailure( + "UNAVAILABLE", + "FILE_READ", + ); + vault.#observeFailureResult(unavailable); + yield unavailable; + return; + } + vault.#observeSuccess("FILE_READ", transferred); + } catch (error) { + const failure = vault.#disposed + ? browserDataFailure("UNAVAILABLE", "FILE_READ") + : mapBrowserDataException(error, "FILE_READ"); + vault.#observeFailureResult(failure); + yield failure; + } finally { + combined.release(); + } + }, + }); + return browserDataSuccess(source); + } + + async resolveFile( + ref: LocalFileRef, + signal: AbortSignal, + operation: BrowserDataOperation = "FILE_READ", + ): Promise> { + if (this.#disposed) { + return browserDataFailure("UNAVAILABLE", operation); + } + const cancelled = abortedResult(signal, operation); + if (cancelled) return cancelled; + const record = this.#records.get(ref); + if (!record) { + return browserDataFailure("NOT_FOUND", operation, { + recovery: "RESELECT", + }); + } + try { + const file = await record.load(); + if (this.#disposed) { + return browserDataFailure("UNAVAILABLE", operation); + } + const aborted = abortedResult(signal, operation); + if (aborted) return aborted; + if ( + file.name !== record.displayName || + file.size !== record.expectedSize || + file.lastModified !== record.expectedLastModified + ) { + return browserDataFailure("STALE_RESULT", operation, { + recovery: "RESELECT", + }); + } + return browserDataSuccess(file); + } catch (error) { + return mapBrowserDataException(error, operation); + } + } + + async resolveVerifiedFile(input: { + ref: LocalFileRef; + verificationReceipt: FileVerificationReceipt; + verificationPolicyBindingId: string; + signal: AbortSignal; + }): Promise> { + if (this.#disposed) { + return browserDataFailure("UNAVAILABLE", "PREVIEW"); + } + const cancelled = abortedResult(input.signal, "PREVIEW"); + if (cancelled) return cancelled; + const verification = this.#verifications.get( + input.verificationReceipt, + ); + if ( + !verification || + verification.ref !== input.ref || + verification.policyBindingId !== + input.verificationPolicyBindingId || + !this.#records.has(input.ref) + ) { + return browserDataFailure("POLICY_REJECTED", "PREVIEW"); + } + return browserDataSuccess( + Object.freeze({ + file: verification.file, + mediaType: verification.mediaType, + }), + ); + } + + release(ref: LocalFileRef): void { + this.#invalidateVerifications(ref); + const record = this.#records.get(ref); + if (record && this.#records.delete(ref)) { + this.#retainedBytes -= record.expectedSize; + } + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#lifetime.abort(); + this.#verifications.clear(); + this.#verificationReceiptsByRef.clear(); + this.#records.clear(); + this.#retainedBytes = 0; + } + + get activeReferenceCount(): number { + return this.#records.size; + } + + get activeVerificationCount(): number { + return this.#verifications.size; + } + + get retainedByteLength(): number { + return this.#retainedBytes; + } + + #validateSelection( + files: readonly File[], + policy: RegisteredFileSelectionPolicy, + source: FileSelectionSource, + ): BrowserDataResult { + try { + assertFileSelectionPolicy(policy); + } catch { + return browserDataFailure("INVALID_INPUT", "FILE_SELECT"); + } + if ( + files.length === 0 || + files.length > policy.maxCount || + (!policy.multiple && files.length > 1) || + this.#records.size + files.length > this.#hardMaxActiveReferences + ) { + return browserDataFailure( + files.length === 0 ? "INVALID_INPUT" : "LIMIT_EXCEEDED", + "FILE_SELECT", + ); + } + if (!["NATIVE_INPUT", "SYSTEM_PICKER", "DROP"].includes(source)) { + return browserDataFailure("INVALID_INPUT", "FILE_SELECT"); + } + + const refs = new Set(); + const candidates: FileCandidate[] = []; + let totalBytes = 0; + for (const file of files) { + if ( + !isValidByteLength(file.size) || + file.size > policy.maxFileBytes || + (!policy.allowEmpty && file.size === 0) || + !Number.isSafeInteger(totalBytes + file.size) + ) { + return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT"); + } + totalBytes += file.size; + if (totalBytes > policy.maxTotalBytes) { + return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT"); + } + const refValue = this.#createReference(); + if (!safeOpaqueValue(refValue)) { + return browserDataFailure("UNAVAILABLE", "FILE_SELECT"); + } + const ref = refValue as LocalFileRef; + if (refs.has(ref) || this.#records.has(ref)) { + return browserDataFailure("CONFLICT", "FILE_SELECT"); + } + refs.add(ref); + const candidate: FileCandidate = Object.freeze({ + ref, + displayName: file.name, + sizeBytes: file.size, + reportedMediaType: normalizedMediaType(file.type), + lastModifiedEpochMs: isValidByteLength(file.lastModified) + ? file.lastModified + : null, + source, + }); + if (!matchesSelectionHint(candidate, policy.accept)) { + return browserDataFailure("POLICY_REJECTED", "FILE_SELECT"); + } + candidates.push(candidate); + } + if ( + !Number.isSafeInteger(this.#retainedBytes + totalBytes) || + this.#retainedBytes + totalBytes > this.#hardMaxRetainedBytes + ) { + return browserDataFailure("LIMIT_EXCEEDED", "FILE_SELECT"); + } + return browserDataSuccess(Object.freeze(candidates)); + } + + #issueVerification(record: VerificationRecord): + BrowserDataResult { + const receiptValue = this.#createVerificationReceipt(); + if (!safeOpaqueValue(receiptValue)) { + return browserDataFailure("UNAVAILABLE", "FILE_INSPECT"); + } + const receipt = receiptValue as FileVerificationReceipt; + if (this.#verifications.has(receipt)) { + return browserDataFailure("CONFLICT", "FILE_INSPECT"); + } + this.#verifications.set(receipt, Object.freeze(record)); + const receipts = + this.#verificationReceiptsByRef.get(record.ref) ?? + new Set(); + receipts.add(receipt); + this.#verificationReceiptsByRef.set(record.ref, receipts); + return browserDataSuccess(receipt); + } + + #invalidateVerifications(ref: LocalFileRef): void { + const receipts = this.#verificationReceiptsByRef.get(ref); + if (!receipts) return; + for (const receipt of receipts) { + this.#verifications.delete(receipt); + } + this.#verificationReceiptsByRef.delete(ref); + } + + #retainRecord(ref: LocalFileRef, record: VaultRecord): void { + this.#records.set(ref, record); + this.#retainedBytes += record.expectedSize; + } + + #observeSuccess(operation: BrowserDataOperation, bytes: number): void { + observeBrowserFile(this.#observer, { + operation, + outcome: "SUCCESS", + byteBucket: byteBucket(bytes), + }); + } + + #observeFailure( + operation: BrowserDataOperation, + failure: BrowserDataResult, + ): void { + if (failure.ok) return; + observeBrowserFile(this.#observer, { + operation, + outcome: "FAILED", + failureCode: failure.error.code, + }); + } + + #observeFailureResult( + failure: BrowserDataResult, + ): BrowserDataResult { + if (!failure.ok) { + observeBrowserFile(this.#observer, { + operation: failure.error.operation, + outcome: "FAILED", + failureCode: failure.error.code, + }); + } + return failure; + } +} + +function snapshotSystemFileHandles( + handles: readonly SystemFileHandle[], +): readonly SystemFileHandle[] { + if (!Array.isArray(handles)) { + throw new TypeError("System file handles are invalid."); + } + return Object.freeze( + handles.map((handle) => { + const kind = handle.kind; + const name = handle.name; + const getFile = handle.getFile; + if ( + kind !== "file" || + typeof name !== "string" || + name.length === 0 || + typeof getFile !== "function" + ) { + throw new TypeError("System file handle is invalid."); + } + return Object.freeze({ + kind, + name, + getFile: getFile.bind(handle), + }); + }), + ); +} + +async function* streamNativeFile( + file: File, + signal: AbortSignal, +): AsyncIterable { + if (signal.aborted) throw abortException(); + const reader = file.stream().getReader(); + const abort = () => { + void reader.cancel(abortException()).catch(() => {}); + }; + signal.addEventListener("abort", abort, { once: true }); + let transferred = 0; + let completed = false; + try { + while (true) { + if (signal.aborted) throw abortException(); + const result = await reader.read(); + if (signal.aborted) throw abortException(); + if (result.done) break; + const chunk = result.value; + if (!(chunk instanceof Uint8Array)) { + throw new DOMException("Unexpected file chunk", "NotReadableError"); + } + if ( + !Number.isSafeInteger(transferred + chunk.byteLength) || + transferred + chunk.byteLength > file.size + ) { + throw new DOMException("File size changed", "NotReadableError"); + } + transferred += chunk.byteLength; + if (chunk.byteLength > 0) yield chunk; + } + if (transferred !== file.size) { + throw new DOMException("File read was incomplete", "NotReadableError"); + } + completed = true; + } finally { + signal.removeEventListener("abort", abort); + if (!completed) { + try { + await reader.cancel(); + } catch { + // The stream may already be errored or cancelled by AbortSignal. + } + } + reader.releaseLock(); + } +} + +function normalizedMediaType(value: string): string | null { + const normalized = value.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function isPositiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function safeOpaqueValue(value: string): boolean { + return /^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value); +} + +function combineAbortSignals( + caller: AbortSignal, + lifetime: AbortSignal, +): Readonly<{ signal: AbortSignal; release(): void }> { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + if (caller.aborted || lifetime.aborted) { + controller.abort(); + } else { + caller.addEventListener("abort", abort, { once: true }); + lifetime.addEventListener("abort", abort, { once: true }); + } + return Object.freeze({ + signal: controller.signal, + release(): void { + caller.removeEventListener("abort", abort); + lifetime.removeEventListener("abort", abort); + }, + }); +} + +function abortException(): DOMException { + return new DOMException("Operation aborted", "AbortError"); +} diff --git a/src/adapters/browser-files/create-browser-file-runtime.ts b/src/adapters/browser-files/create-browser-file-runtime.ts new file mode 100644 index 0000000..a8d25c8 --- /dev/null +++ b/src/adapters/browser-files/create-browser-file-runtime.ts @@ -0,0 +1,245 @@ +import type { + DownloadDeliveryPort, + FileContentPort, + FilePickerPort, + TransientPreviewPort, +} from "../../application/ports/browser-file-storage/file.ts"; +import { + EnhancedFilePicker, + NativeInputFilePicker, + type NativeInputFilePickerOptions, + type SystemOpenPicker, +} from "./browser-file-picker.ts"; +import { + BrowserFileVault, + DEFAULT_MAX_INSPECTION_BYTES, + type BrowserFileVaultOptions, +} from "./browser-file-vault.ts"; +import { + BrowserFilePolicyRegistry, + type BrowserFilePolicyProfile, +} from "./browser-file-policy-registry.ts"; +import { + createDownloadDeliveryAdapter, + type DownloadDeliveryAdapterOptions, +} from "./download-delivery-adapter.ts"; +import type { BrowserFileObserver } from "./file-observer.ts"; +import { + BrowserTransientPreview, + ObjectUrlLeaseRegistry, + type ObjectUrlApi, +} from "./object-url-lease.ts"; + +type UserActivationState = Readonly<{ isActive: boolean }>; + +export type BrowserFileRuntimeOptions = Readonly<{ + input: HTMLInputElement; + policies: readonly BrowserFilePolicyProfile[]; + limits: Readonly<{ + hardMaxPreviewBytes: number; + hardMaxObjectUrlBytes: number; + hardMaxTransferBytes: number; + hardMaxActiveFileReferences?: number; + hardMaxRetainedFileBytes?: number; + hardMaxActiveObjectUrls?: number; + hardMaxObjectUrlAggregateBytes?: number; + }>; + download: Omit< + DownloadDeliveryAdapterOptions, + | "objectUrls" + | "observer" + | "policies" + | "userActivation" + | "hardMaxObjectUrlBytes" + | "hardMaxTransferBytes" + >; + showOpenFilePicker?: SystemOpenPicker; + userActivation?: UserActivationState; + observer?: BrowserFileObserver; + objectUrlApi?: ObjectUrlApi; + vault?: Omit; + nativePicker?: Pick< + NativeInputFilePickerOptions, + | "window" + | "scheduler" + | "focusFallbackGraceMs" + | "cancelFallbackDelayMs" + >; + hardForbiddenPreviewMediaTypes?: ReadonlySet; +}>; + +export type BrowserFileRuntime = Readonly<{ + /** + * Canonical cross-browser control. Presentation decides which explicit + * user action invokes this baseline. + */ + baselinePicker: FilePickerPort; + /** + * Optional enhancement. It is never retried through baselinePicker in the + * same user activation. + */ + enhancedPicker: FilePickerPort | null; + content: FileContentPort; + previews: TransientPreviewPort; + downloads: DownloadDeliveryPort; + dispose(): void; +}>; + +/** + * Optional feature factory. Nothing imports this from bootstrap, so browser + * file code remains outside the default bundle until a feature composes it. + */ +export function createBrowserFileRuntime( + options: BrowserFileRuntimeOptions, +): BrowserFileRuntime { + const limits = resolveRuntimeLimits(options.limits); + const policies = new BrowserFilePolicyRegistry({ + profiles: options.policies, + hardLimits: { + maxInspectionBytes: + options.vault?.hardMaxInspectionBytes ?? + DEFAULT_MAX_INSPECTION_BYTES, + maxRetainedFileBytes: limits.hardMaxRetainedFileBytes, + maxPreviewBytes: limits.hardMaxPreviewBytes, + maxObjectUrlBytes: limits.hardMaxObjectUrlBytes, + maxTransferBytes: limits.hardMaxTransferBytes, + }, + }); + const objectUrls = new ObjectUrlLeaseRegistry( + options.objectUrlApi, + { + hardMaxActiveLeases: limits.hardMaxActiveObjectUrls, + hardMaxSingleLeaseBytes: Math.max( + limits.hardMaxPreviewBytes, + limits.hardMaxObjectUrlBytes, + ), + hardMaxAggregateLeaseBytes: + limits.hardMaxObjectUrlAggregateBytes, + }, + ); + const vault = new BrowserFileVault({ + ...options.vault, + policies, + hardMaxActiveReferences: limits.hardMaxActiveFileReferences, + hardMaxRetainedBytes: limits.hardMaxRetainedFileBytes, + observer: options.observer, + }); + const commonPickerOptions = { + vault, + policies, + userActivation: options.userActivation, + observer: options.observer, + }; + const baselinePicker = new NativeInputFilePicker({ + ...commonPickerOptions, + ...options.nativePicker, + input: options.input, + systemOpenPickerSupported: + options.showOpenFilePicker !== undefined, + systemSavePickerSupported: + options.download.showSaveFilePicker !== undefined, + }); + const enhancedPicker = options.showOpenFilePicker + ? new EnhancedFilePicker({ + ...commonPickerOptions, + showOpenFilePicker: options.showOpenFilePicker, + systemSavePickerSupported: + options.download.showSaveFilePicker !== undefined, + }) + : null; + const previews = new BrowserTransientPreview({ + files: vault, + policies, + leases: objectUrls, + hardMaxPreviewBytes: limits.hardMaxPreviewBytes, + hardForbiddenMediaTypes: + options.hardForbiddenPreviewMediaTypes, + observer: options.observer, + }); + const downloads = createDownloadDeliveryAdapter({ + ...options.download, + policies, + objectUrls, + hardMaxObjectUrlBytes: limits.hardMaxObjectUrlBytes, + hardMaxTransferBytes: limits.hardMaxTransferBytes, + observer: options.observer, + userActivation: options.userActivation, + }); + let disposed = false; + + return Object.freeze({ + baselinePicker, + enhancedPicker, + content: vault, + previews, + downloads, + dispose(): void { + if (disposed) return; + disposed = true; + baselinePicker.dispose(); + enhancedPicker?.dispose(); + downloads.dispose(); + previews.dispose(); + vault.dispose(); + }, + }); +} + +type ResolvedRuntimeLimits = Readonly<{ + hardMaxPreviewBytes: number; + hardMaxObjectUrlBytes: number; + hardMaxTransferBytes: number; + hardMaxActiveFileReferences: number; + hardMaxRetainedFileBytes: number; + hardMaxActiveObjectUrls: number; + hardMaxObjectUrlAggregateBytes: number; +}>; + +function resolveRuntimeLimits( + limits: BrowserFileRuntimeOptions["limits"], +): ResolvedRuntimeLimits { + const hardMaxSingleObjectUrlBytes = Math.max( + limits.hardMaxPreviewBytes, + limits.hardMaxObjectUrlBytes, + ); + const derivedAggregate = Math.min( + Number.MAX_SAFE_INTEGER, + hardMaxSingleObjectUrlBytes * 4, + ); + const resolved = Object.freeze({ + hardMaxPreviewBytes: limits.hardMaxPreviewBytes, + hardMaxObjectUrlBytes: limits.hardMaxObjectUrlBytes, + hardMaxTransferBytes: limits.hardMaxTransferBytes, + hardMaxActiveFileReferences: + limits.hardMaxActiveFileReferences ?? 32, + hardMaxRetainedFileBytes: + limits.hardMaxRetainedFileBytes ?? + limits.hardMaxTransferBytes, + hardMaxActiveObjectUrls: + limits.hardMaxActiveObjectUrls ?? 16, + hardMaxObjectUrlAggregateBytes: + limits.hardMaxObjectUrlAggregateBytes ?? derivedAggregate, + }); + if ( + !isPositiveSafeInteger(resolved.hardMaxPreviewBytes) || + !isPositiveSafeInteger(resolved.hardMaxObjectUrlBytes) || + !isPositiveSafeInteger(resolved.hardMaxTransferBytes) || + !isPositiveSafeInteger(resolved.hardMaxActiveFileReferences) || + !isPositiveSafeInteger(resolved.hardMaxRetainedFileBytes) || + !isPositiveSafeInteger(resolved.hardMaxActiveObjectUrls) || + !isPositiveSafeInteger(resolved.hardMaxObjectUrlAggregateBytes) || + resolved.hardMaxObjectUrlBytes > + resolved.hardMaxTransferBytes || + resolved.hardMaxPreviewBytes > + resolved.hardMaxRetainedFileBytes || + hardMaxSingleObjectUrlBytes > + resolved.hardMaxObjectUrlAggregateBytes + ) { + throw new TypeError("Browser file runtime hard limits are invalid."); + } + return resolved; +} + +function isPositiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} diff --git a/src/adapters/browser-files/download-delivery-adapter.ts b/src/adapters/browser-files/download-delivery-adapter.ts new file mode 100644 index 0000000..7a6b4fd --- /dev/null +++ b/src/adapters/browser-files/download-delivery-adapter.ts @@ -0,0 +1,1436 @@ +import type { + BrowserManagedDownloadCapability, + BrowserManagedDownloadCapabilityResolver, + DownloadDeliveryPort, + DownloadOutcome, + DownloadSource, + FileByteSource, +} from "../../application/ports/browser-file-storage/file.ts"; +import type { + BrowserDataFailure, + BrowserDataResult, + TransferProgress, +} from "../../application/ports/browser-file-storage/shared.ts"; +import type { + PresignedDownloadCapability, + PresignedDownloadByteSource, + PresignedDownloadSourcePort, +} from "../../application/ports/browser-transfer/presigned-transfer.ts"; +import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, + mapBrowserDataException, +} from "../browser-file-storage/result.ts"; +import { + byteBucket, + observeBrowserFile, + type BrowserFileObserver, +} from "./file-observer.ts"; +import { + sanitizeSuggestedFileName, + type SuggestedFileNamePolicy, +} from "./file-policy.ts"; +import { + ObjectUrlLeaseRegistry, + type ObjectUrlLease, +} from "./object-url-lease.ts"; +import { + BrowserFilePolicyRegistry, + type ResolvedDownloadPolicy, +} from "./browser-file-policy-registry.ts"; + +export type BrowserDownloadHost = Readonly<{ + handoff(href: string, suggestedFileName: string): void; +}>; + +export function createAnchorDownloadHost( + document: Pick, +): BrowserDownloadHost { + return Object.freeze({ + handoff(href: string, suggestedFileName: string): void { + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = suggestedFileName; + anchor.rel = "noopener noreferrer"; + anchor.referrerPolicy = "no-referrer"; + anchor.style.display = "none"; + document.body.append(anchor); + try { + anchor.click(); + } finally { + try { + anchor.remove(); + } catch { + // DOM cleanup cannot revoke a handoff that already happened. + } + } + }, + }); +} + +export type SavePickerAcceptType = Readonly<{ + description?: string; + accept: Readonly>; +}>; + +export type SavePickerOptions = Readonly<{ + suggestedName: string; + excludeAcceptAllOption: boolean; + types: readonly SavePickerAcceptType[]; +}>; + +export type SaveFileHandle = Readonly<{ + createWritable(): Promise>; +}>; + +export type ShowSaveFilePicker = ( + options: SavePickerOptions, +) => Promise; + +export type StreamingIntegrityVerifier = Readonly<{ + update(chunk: Uint8Array): void | Promise; + verify(): boolean | Promise; +}>; + +export type StreamingIntegrityVerifierFactory = ( + expectedSha256: string, +) => StreamingIntegrityVerifier; + +export type DownloadScheduler = Readonly<{ + setTimeout(callback: () => void, delayMs: number): unknown; +}>; + +export type DownloadDeliveryAdapterOptions = Readonly<{ + host: BrowserDownloadHost; + policies: BrowserFilePolicyRegistry; + /** + * Runtime-owned absolute ceilings. Caller-supplied policy values may only + * lower these limits. Browser-managed endpoints must mirror + * hardMaxTransferBytes server-side because navigation bytes are opaque here. + */ + hardMaxObjectUrlBytes: number; + hardMaxTransferBytes: number; + /** + * Synchronous by design: the server-issued capability must already be + * available before the user action, so handoff never awaits and loses + * transient activation. + */ + browserManagedCapabilities: BrowserManagedDownloadCapabilityResolver; + openAuthorizedSource?: PresignedDownloadSourcePort["open"]; + showSaveFilePicker?: ShowSaveFilePicker; + objectUrls?: ObjectUrlLeaseRegistry; + createTransferId?: () => string; + createIntegrityVerifier?: StreamingIntegrityVerifierFactory; + baseOrigin?: string; + allowCrossOriginBrowserHandoff?: boolean; + allowBrowserManagedQuery?: boolean; + filenameMaxUtf8Bytes?: number; + objectUrlReleaseDelayMs?: number; + progressMinIntervalMs?: number; + now?: () => number; + scheduler?: DownloadScheduler; + userActivation?: Readonly<{ isActive: boolean }>; + observer?: BrowserFileObserver; +}>; + +const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i; +const SHA256 = /^[a-f0-9]{64}$/i; + +/** + * Keeps a one-shot Blob URL alive across browser download scheduling and + * process handoff. Runtime dispose() still revokes every active lease + * immediately. Features may override this when they own stronger evidence. + */ +export const DEFAULT_OBJECT_URL_RELEASE_GRACE_MS = 30_000; + +interface DisposableDownloadDeliveryPort extends DownloadDeliveryPort { + dispose(): void; +} + +export function createDownloadDeliveryAdapter( + options: DownloadDeliveryAdapterOptions, +): DisposableDownloadDeliveryPort { + const objectUrls = options.objectUrls ?? new ObjectUrlLeaseRegistry(); + const createTransferId = + options.createTransferId ?? + (() => `transfer:${globalThis.crypto.randomUUID()}`); + const now = options.now ?? Date.now; + const progressMinIntervalMs = options.progressMinIntervalMs ?? 100; + const releaseDelayMs = + options.objectUrlReleaseDelayMs ?? + DEFAULT_OBJECT_URL_RELEASE_GRACE_MS; + const scheduler = + options.scheduler ?? + Object.freeze({ + setTimeout: (callback: () => void, delayMs: number) => + globalThis.setTimeout(callback, delayMs), + }); + const baseOrigin = + options.baseOrigin ?? globalThis.location?.origin ?? "https://localhost"; + const userActivation = + options.userActivation ?? globalThis.navigator?.userActivation; + const lifetime = new AbortController(); + let disposed = false; + + if ( + !Number.isSafeInteger(progressMinIntervalMs) || + progressMinIntervalMs < 0 || + !Number.isSafeInteger(releaseDelayMs) || + releaseDelayMs < 0 || + !isPositiveByteLimit(options.hardMaxObjectUrlBytes) || + !isPositiveByteLimit(options.hardMaxTransferBytes) || + options.hardMaxObjectUrlBytes > options.hardMaxTransferBytes || + typeof options.host?.handoff !== "function" || + typeof options.policies?.resolveDownload !== "function" || + typeof options.browserManagedCapabilities?.resolve !== "function" || + (options.openAuthorizedSource !== undefined && + typeof options.openAuthorizedSource !== "function") || + (options.showSaveFilePicker !== undefined && + typeof options.showSaveFilePicker !== "function") || + (options.createIntegrityVerifier !== undefined && + typeof options.createIntegrityVerifier !== "function") || + (options.now !== undefined && typeof options.now !== "function") || + (options.observer !== undefined && + typeof options.observer.record !== "function") + ) { + throw new TypeError("Download delivery timing policy is invalid."); + } + const resolveDownloadPolicy = + options.policies.resolveDownload.bind(options.policies); + const originalOpenAuthorizedSource = + options.openAuthorizedSource; + const originalShowSaveFilePicker = + options.showSaveFilePicker; + const originalIntegrityVerifier = + options.createIntegrityVerifier; + const originalObserver = options.observer; + const dependencies: DownloadDeliveryAdapterOptions = Object.freeze({ + ...options, + host: Object.freeze({ + handoff: options.host.handoff.bind(options.host), + }), + browserManagedCapabilities: Object.freeze({ + resolve: + options.browserManagedCapabilities.resolve.bind( + options.browserManagedCapabilities, + ), + }), + ...(originalOpenAuthorizedSource + ? { + openAuthorizedSource: + originalOpenAuthorizedSource.bind(options), + } + : {}), + ...(originalShowSaveFilePicker + ? { + showSaveFilePicker: + originalShowSaveFilePicker.bind(options), + } + : {}), + ...(originalIntegrityVerifier + ? { + createIntegrityVerifier: + originalIntegrityVerifier.bind(options), + } + : {}), + ...(originalObserver + ? { + observer: Object.freeze({ + record: originalObserver.record.bind(originalObserver), + }), + } + : {}), + now, + scheduler: Object.freeze({ + setTimeout: scheduler.setTimeout.bind(scheduler), + }), + userActivation, + }); + + async function deliver(input: { + policy: Parameters[0]["policy"]; + source: DownloadSource; + suggestedFileName: string; + maxTransferBytes?: number; + maxBufferedBytes?: number; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise> { + if (disposed) { + return observeResult( + browserDataFailure("UNAVAILABLE", "DOWNLOAD"), + dependencies.observer, + ); + } + const combined = combineAbortSignals( + input.signal, + lifetime.signal, + ); + try { + return await deliverActive({ ...input, signal: combined.signal }); + } finally { + combined.release(); + } + } + + async function deliverActive(input: DeliveryRequest): + Promise> { + const resolvedPolicy = resolveDownloadPolicy( + input.policy, + { + maxTransferBytes: input.maxTransferBytes, + maxBufferedBytes: input.maxBufferedBytes, + }, + ); + if (!resolvedPolicy.ok) { + return observeResult( + resolvedPolicy, + dependencies.observer, + ); + } + let activeInput: DeliveryInput; + try { + activeInput = activeDeliveryInput( + input, + resolvedPolicy.value, + ); + } catch { + return observeResult( + browserDataFailure("INVALID_INPUT", "DOWNLOAD"), + dependencies.observer, + ); + } + const validation = validateDownloadInput( + activeInput, + dependencies, + userActivation, + ); + if (!validation.ok) { + return observeResult( + validation, + dependencies.observer, + ); + } + let suggestedFileName: string; + let transferId: string; + try { + suggestedFileName = sanitizeSuggestedFileName( + activeInput.suggestedFileName, + { + safeExtension: activeInput.safeExtension, + maxUtf8Bytes: + dependencies.filenameMaxUtf8Bytes, + } satisfies SuggestedFileNamePolicy, + ); + transferId = createTransferId(); + } catch { + return observeResult( + browserDataFailure("UNAVAILABLE", "DOWNLOAD"), + dependencies.observer, + ); + } + if (!safeGeneratedToken(transferId)) { + return observeResult( + browserDataFailure("UNAVAILABLE", "DOWNLOAD"), + dependencies.observer, + ); + } + const progress = createProgressReporter({ + callback: input.onProgress, + now, + minIntervalMs: progressMinIntervalMs, + }); + + if (activeInput.strategy === "BROWSER_MANAGED") { + return browserManagedHandoff({ + input: activeInput, + suggestedFileName, + transferId, + options: dependencies, + baseOrigin, + }); + } + + if (activeInput.strategy === "PROMPT_AND_STREAM") { + return promptAndStream({ + input: activeInput, + suggestedFileName, + transferId, + options: dependencies, + progress, + }); + } + + return boundedObjectUrlHandoff({ + input: activeInput, + suggestedFileName, + transferId, + options: dependencies, + objectUrls, + scheduler, + releaseDelayMs, + progress, + }); + } + + return Object.freeze({ + deliver, + dispose(): void { + if (disposed) return; + disposed = true; + lifetime.abort(); + objectUrls.dispose(); + }, + }); +} + +function browserManagedHandoff(context: Readonly<{ + input: DeliveryInput; + suggestedFileName: string; + transferId: string; + options: DownloadDeliveryAdapterOptions; + baseOrigin: string; +}>): BrowserDataResult { + if ( + context.input.source.kind !== + "BROWSER_MANAGED_RESOURCE" + ) { + return observeResult( + browserDataFailure("INVALID_INPUT", "DOWNLOAD"), + context.options.observer, + ); + } + try { + // No await is allowed before this handoff. + const capabilityResult = + context.options.browserManagedCapabilities.resolve({ + resourceId: context.input.source.resourceId, + capabilityReceipt: + context.input.source.capabilityReceipt, + }); + if ( + !capabilityResult || + typeof capabilityResult !== "object" || + typeof capabilityResult.ok !== "boolean" + ) { + return observeResult( + browserDataFailure("POLICY_REJECTED", "DOWNLOAD"), + context.options.observer, + ); + } + if (!capabilityResult.ok) { + return observeResult( + browserDataFailure( + capabilityResult.error.code, + "DOWNLOAD", + { + retryable: capabilityResult.error.retryable, + recovery: capabilityResult.error.recovery, + }, + ), + context.options.observer, + ); + } + const capability = validateBrowserManagedCapability( + capabilityResult.value, + context.input, + context.options.now?.() ?? Date.now(), + ); + if (!capability.ok) { + return observeResult( + capability, + context.options.observer, + ); + } + const href = capability.value.href; + if ( + !safeBrowserManagedTarget(href, context.baseOrigin, { + allowCrossOrigin: + context.options.allowCrossOriginBrowserHandoff ?? false, + allowQuery: context.options.allowBrowserManagedQuery ?? false, + }) + ) { + return observeResult( + browserDataFailure("POLICY_REJECTED", "DOWNLOAD"), + context.options.observer, + ); + } + context.options.host.handoff(href, context.suggestedFileName); + return observeResult( + browserDataSuccess( + Object.freeze({ + kind: "BROWSER_HANDOFF" as const, + transferId: context.transferId, + }), + ), + context.options.observer, + ); + } catch (error) { + return observeResult( + mapBrowserDataException(error, "DOWNLOAD"), + context.options.observer, + ); + } +} + +async function promptAndStream(context: Readonly<{ + input: DeliveryInput; + suggestedFileName: string; + transferId: string; + options: DownloadDeliveryAdapterOptions; + progress: ProgressReporter; +}>): Promise> { + const picker = context.options.showSaveFilePicker; + if (!picker) { + return observeResult( + browserDataFailure("UNSUPPORTED", "DOWNLOAD"), + context.options.observer, + ); + } + + let verifier: StreamingIntegrityVerifier | undefined; + try { + verifier = integrityVerifier(context.input, context.options); + } catch { + return observeResult( + browserDataFailure("UNSUPPORTED", "DOWNLOAD"), + context.options.observer, + ); + } + + let handlePromise: Promise; + try { + // Picker invocation is deliberately the first asynchronous browser action. + handlePromise = picker({ + suggestedName: context.suggestedFileName, + excludeAcceptAllOption: true, + types: Object.freeze([ + Object.freeze({ + accept: Object.freeze({ + [context.input.mediaType]: Object.freeze([ + context.input.safeExtension.toLowerCase(), + ]), + }), + }), + ]), + }); + } catch (error) { + if ( + error instanceof DOMException && + error.name === "AbortError" && + !context.input.signal.aborted + ) { + return observeResult( + browserDataSuccess( + Object.freeze({ kind: "DISMISSED" as const }), + ), + context.options.observer, + ); + } + return observeResult( + mapBrowserDataException(error, "DOWNLOAD"), + context.options.observer, + ); + } + + let handle: SaveFileHandle; + try { + handle = await awaitWithSignal( + handlePromise, + context.input.signal, + ); + } catch (error) { + if ( + error instanceof DOMException && + error.name === "AbortError" && + !context.input.signal.aborted + ) { + return observeResult( + browserDataSuccess( + Object.freeze({ kind: "DISMISSED" as const }), + ), + context.options.observer, + ); + } + return observeResult( + mapBrowserDataException(error, "DOWNLOAD"), + context.options.observer, + ); + } + + if (context.input.signal.aborted) { + return observeResult( + browserDataFailure("ABORTED", "DOWNLOAD"), + context.options.observer, + ); + } + + try { + const sourceResult = await resolveByteSource( + context.input, + context.input.signal, + context.options, + ); + if (!sourceResult.ok) { + return observeResult(sourceResult, context.options.observer); + } + const source = sourceResult.value; + if ( + source.byteLength !== null && + source.byteLength > context.input.maxTransferBytes + ) { + return observeResult( + browserDataFailure("LIMIT_EXCEEDED", "DOWNLOAD"), + context.options.observer, + source.byteLength, + ); + } + context.progress.report("PREPARING", 0, source.byteLength, true); + const writable = await awaitWithSignal( + handle.createWritable(), + context.input.signal, + ); + const writer = writable.getWriter(); + let committed = false; + let transferred = 0; + let result: BrowserDataResult; + try { + for await (const chunkResult of abortableFileChunks( + source, + context.input.signal, + )) { + if (!chunkResult.ok) { + throw new ClosedFileStreamError(chunkResult.error); + } + const chunk = chunkResult.value; + validateChunk(chunk); + if (context.input.signal.aborted) throw abortException(); + const next = transferred + chunk.byteLength; + if ( + !Number.isSafeInteger(next) || + next > context.input.maxTransferBytes + ) { + throw limitException(); + } + if (verifier) { + await awaitWithSignal( + Promise.resolve(verifier.update(chunk)), + context.input.signal, + ); + } + await awaitWithSignal( + writer.write(chunk), + context.input.signal, + ); + transferred = next; + context.progress.report( + "TRANSFERRING", + transferred, + source.byteLength, + ); + } + assertDeclaredLength(source.byteLength, transferred); + if (context.input.signal.aborted) throw abortException(); + context.progress.report( + "VERIFYING", + transferred, + source.byteLength, + true, + ); + if (verifier) { + const verified = await awaitWithSignal( + Promise.resolve(verifier.verify()), + context.input.signal, + ); + if (!verified) throw integrityException(); + } + if (context.input.signal.aborted) throw abortException(); + context.progress.report( + "FINALIZING", + transferred, + source.byteLength, + true, + ); + // Once close starts, its commit truth wins over a concurrent abort. + await writer.close(); + committed = true; + result = browserDataSuccess( + Object.freeze({ + kind: "SAVED" as const, + transferId: context.transferId, + bytesWritten: transferred, + integrity: verifier || isVerifiedPresignedSource(source) + ? ("VERIFIED" as const) + : ("NOT_PROVIDED" as const), + }), + ); + } catch (error) { + result = mapDownloadException(error); + } + + if (!committed) { + try { + const abortWork = writer.abort( + new DOMException("Save did not commit", "AbortError"), + ); + if (context.input.signal.aborted) { + void abortWork.catch(() => {}); + } else { + await abortWork; + } + } catch { + // The destination may contain a partial or indeterminate local file. + result = browserDataFailure("UNAVAILABLE", "DOWNLOAD"); + } + } + try { + writer.releaseLock(); + } catch { + // Releasing an already closed/errored writer cannot change commit truth. + } + return observeResult( + result, + context.options.observer, + transferred, + ); + } catch (error) { + return observeResult( + mapDownloadException(error), + context.options.observer, + ); + } +} + +async function boundedObjectUrlHandoff(context: Readonly<{ + input: DeliveryInput; + suggestedFileName: string; + transferId: string; + options: DownloadDeliveryAdapterOptions; + objectUrls: ObjectUrlLeaseRegistry; + scheduler: DownloadScheduler; + releaseDelayMs: number; + progress: ProgressReporter; +}>): Promise> { + let verifier: StreamingIntegrityVerifier | undefined; + try { + verifier = integrityVerifier(context.input, context.options); + } catch { + return observeResult( + browserDataFailure("UNSUPPORTED", "DOWNLOAD"), + context.options.observer, + ); + } + const sourceResult = await resolveByteSource( + context.input, + context.input.signal, + context.options, + ); + if (!sourceResult.ok) { + return observeResult(sourceResult, context.options.observer); + } + const source = sourceResult.value; + if ( + source.byteLength !== null && + (source.byteLength > context.input.maxBufferedBytes || + source.byteLength > context.input.maxTransferBytes) + ) { + return observeResult( + browserDataFailure("LIMIT_EXCEEDED", "DOWNLOAD"), + context.options.observer, + source.byteLength, + ); + } + + const chunks: ArrayBuffer[] = []; + let transferred = 0; + let lease: ObjectUrlLease | undefined; + try { + context.progress.report("PREPARING", 0, source.byteLength, true); + for await (const chunkResult of abortableFileChunks( + source, + context.input.signal, + )) { + if (!chunkResult.ok) { + throw new ClosedFileStreamError(chunkResult.error); + } + const chunk = chunkResult.value; + validateChunk(chunk); + if (context.input.signal.aborted) throw abortException(); + const next = transferred + chunk.byteLength; + if ( + !Number.isSafeInteger(next) || + next > context.input.maxBufferedBytes || + next > context.input.maxTransferBytes + ) { + throw limitException(); + } + if (verifier) { + await awaitWithSignal( + Promise.resolve(verifier.update(chunk)), + context.input.signal, + ); + } + const copy = chunk.slice(); + chunks.push(copy.buffer); + transferred = next; + context.progress.report( + "TRANSFERRING", + transferred, + source.byteLength, + ); + } + assertDeclaredLength(source.byteLength, transferred); + context.progress.report( + "VERIFYING", + transferred, + source.byteLength, + true, + ); + if (verifier) { + const verified = await awaitWithSignal( + Promise.resolve(verifier.verify()), + context.input.signal, + ); + if (!verified) throw integrityException(); + } + if (context.input.signal.aborted) throw abortException(); + context.progress.report( + "FINALIZING", + transferred, + source.byteLength, + true, + ); + const blob = new Blob(chunks, { type: context.input.mediaType }); + lease = context.objectUrls.create(blob); + if (context.input.signal.aborted) throw abortException(); + context.options.host.handoff( + lease.url, + context.suggestedFileName, + ); + const handedOffLease = lease; + lease = undefined; + try { + context.scheduler.setTimeout( + handedOffLease.release, + context.releaseDelayMs, + ); + } catch { + // Handoff truth wins. Runtime dispose() remains the cleanup safety net. + } + return observeResult( + browserDataSuccess( + Object.freeze({ + kind: "BROWSER_HANDOFF" as const, + transferId: context.transferId, + }), + ), + context.options.observer, + transferred, + ); + } catch (error) { + lease?.release(); + return observeResult( + mapDownloadException(error), + context.options.observer, + transferred, + ); + } +} + +type DeliveryRequest = + Parameters[0]; + +type DeliveryInput = Readonly< + Omit< + DeliveryRequest, + "maxTransferBytes" | "maxBufferedBytes" + > & + ResolvedDownloadPolicy & { + expectedSha256?: string; + } +>; + +function activeDeliveryInput( + input: DeliveryRequest, + policy: ResolvedDownloadPolicy, +): DeliveryInput { + const source = snapshotDownloadSource(input.source); + const expectedSha256 = + source.kind === "GENERATED" + ? source.expectedSha256 + : undefined; + return Object.freeze({ + policy: input.policy, + source, + suggestedFileName: input.suggestedFileName, + signal: input.signal, + onProgress: input.onProgress, + ...policy, + ...(expectedSha256 !== undefined ? { expectedSha256 } : {}), + }); +} + +function snapshotDownloadSource( + source: DownloadSource, +): DownloadSource { + const kind = source.kind; + if (kind === "BROWSER_MANAGED_RESOURCE") { + return Object.freeze({ + kind, + resourceId: source.resourceId, + capabilityReceipt: source.capabilityReceipt, + }); + } + if (kind === "AUTHORIZED_STREAM_RESOURCE") { + return Object.freeze({ + kind, + resourceId: source.resourceId, + capability: source.capability, + }); + } + if (kind !== "GENERATED") { + throw new TypeError("Download source kind is invalid."); + } + const sourceBytes = source.bytes; + const stream = sourceBytes.stream; + const byteLength = sourceBytes.byteLength; + if (typeof stream !== "function") { + throw new TypeError("Download byte source is invalid."); + } + const bytes: FileByteSource = Object.freeze({ + byteLength, + stream: stream.bind(sourceBytes), + }); + const expectedSha256 = source.expectedSha256; + return Object.freeze({ + kind, + bytes, + ...(expectedSha256 !== undefined ? { expectedSha256 } : {}), + }); +} + +function validateDownloadInput( + input: DeliveryInput, + options: DownloadDeliveryAdapterOptions, + userActivation: Readonly<{ isActive: boolean }> | undefined, +): BrowserDataResult { + if ( + input.signal.aborted || + input.suggestedFileName.length === 0 || + !MEDIA_TYPE.test(input.mediaType) || + !isValidByteLength(input.maxTransferBytes) || + input.maxTransferBytes === 0 || + !isValidByteLength(input.maxBufferedBytes) || + input.maxBufferedBytes === 0 || + (input.expectedSha256 !== undefined && + !SHA256.test(input.expectedSha256)) || + (input.source.kind === "AUTHORIZED_STREAM_RESOURCE" && + (!safeOpaqueId(input.source.resourceId) || + !validPresignedCapabilityForInput( + input.source.capability, + input.source.resourceId, + input.mediaType, + input.maxTransferBytes, + options.now?.() ?? Date.now(), + ) || + options.openAuthorizedSource === undefined)) || + (input.source.kind === "BROWSER_MANAGED_RESOURCE" && + (!safeOpaqueId(input.source.resourceId) || + !safeOpaqueId(input.source.capabilityReceipt))) || + (input.strategy === "PROMPT_AND_STREAM" && + options.showSaveFilePicker === undefined) + ) { + return browserDataFailure( + input.signal.aborted ? "ABORTED" : "INVALID_INPUT", + "DOWNLOAD", + ); + } + if ( + input.maxTransferBytes > options.hardMaxTransferBytes || + (input.strategy === "BOUNDED_OBJECT_URL" && + (input.maxBufferedBytes > options.hardMaxObjectUrlBytes || + input.maxBufferedBytes > input.maxTransferBytes)) + ) { + return browserDataFailure("LIMIT_EXCEEDED", "DOWNLOAD"); + } + if ( + (input.strategy === "BROWSER_MANAGED" && + input.source.kind !== "BROWSER_MANAGED_RESOURCE") || + (input.strategy !== "BROWSER_MANAGED" && + input.source.kind === "BROWSER_MANAGED_RESOURCE") || + (input.integrity === "REQUIRED" && + input.source.kind === "GENERATED" && + input.expectedSha256 === undefined) + ) { + return browserDataFailure("POLICY_REJECTED", "DOWNLOAD"); + } + if (input.source.kind === "GENERATED") { + try { + if (!validByteSource(input.source.bytes)) { + return browserDataFailure("INTEGRITY_FAILED", "DOWNLOAD"); + } + if ( + input.source.bytes.byteLength !== null && + input.source.bytes.byteLength > input.maxTransferBytes + ) { + return browserDataFailure("LIMIT_EXCEEDED", "DOWNLOAD"); + } + } catch { + return browserDataFailure("INVALID_INPUT", "DOWNLOAD"); + } + } + if (userActivation && !userActivation.isActive) { + return browserDataFailure("PERMISSION_DENIED", "DOWNLOAD"); + } + try { + sanitizeSuggestedFileName(input.suggestedFileName, { + safeExtension: input.safeExtension, + maxUtf8Bytes: options.filenameMaxUtf8Bytes, + }); + } catch { + return browserDataFailure("INVALID_INPUT", "DOWNLOAD"); + } + return browserDataSuccess(true); +} + +async function resolveByteSource( + input: DeliveryInput, + signal: AbortSignal, + options: DownloadDeliveryAdapterOptions, +): Promise> { + const source = input.source; + if (signal.aborted) { + return browserDataFailure("ABORTED", "DOWNLOAD"); + } + if (source.kind === "GENERATED") { + return validByteSource(source.bytes) + ? browserDataSuccess(source.bytes) + : browserDataFailure("INTEGRITY_FAILED", "DOWNLOAD"); + } + if (source.kind === "BROWSER_MANAGED_RESOURCE") { + return browserDataFailure("POLICY_REJECTED", "DOWNLOAD"); + } + const open = options.openAuthorizedSource; + if (!open) return browserDataFailure("UNSUPPORTED", "DOWNLOAD"); + const result = await awaitWithSignal( + open({ + resourceId: source.resourceId, + capability: source.capability, + signal, + }), + signal, + ); + if (!result.ok) { + return browserDataFailure(result.error.code, "DOWNLOAD", { + retryable: result.error.retryable, + recovery: result.error.recovery, + }); + } + return validPresignedByteSource( + result.value, + source.capability, + source.resourceId, + input.mediaType, + input.maxTransferBytes, + options.now?.() ?? Date.now(), + ) + ? result + : browserDataFailure("INTEGRITY_FAILED", "DOWNLOAD"); +} + +function validByteSource(source: FileByteSource): boolean { + return ( + typeof source.stream === "function" && + (source.byteLength === null || + isValidByteLength(source.byteLength)) + ); +} + +function isVerifiedPresignedSource( + source: FileByteSource, +): source is PresignedDownloadByteSource { + return ( + "integrity" in source && + source.integrity === "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" + ); +} + +function validPresignedByteSource( + source: PresignedDownloadByteSource, + capability: PresignedDownloadCapability, + resourceId: string, + mediaType: string, + maxTransferBytes: number, + nowEpochMs: number, +): boolean { + return ( + validByteSource(source) && + source.capability === capability && + source.integrity === "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" && + source.byteLength === capability.byteLength && + capability.method === "GET" && + capability.binding.kind === "DOWNLOAD" && + capability.binding.resourceId === resourceId && + capability.mediaType === mediaType && + capability.byteLength <= capability.maxBytes && + capability.maxBytes <= maxTransferBytes && + SHA256.test(capability.expectedSha256) && + Number.isSafeInteger(nowEpochMs) && + capability.expiresAtEpochMs > nowEpochMs + ); +} + +function validPresignedCapabilityForInput( + capability: PresignedDownloadCapability, + resourceId: string, + mediaType: string, + maxTransferBytes: number, + nowEpochMs: number, +): boolean { + try { + return ( + Boolean(capability) && + typeof capability === "object" && + Object.isFrozen(capability) && + capability.method === "GET" && + capability.binding.kind === "DOWNLOAD" && + capability.binding.resourceId === resourceId && + capability.mediaType === mediaType && + isValidByteLength(capability.byteLength) && + isPositiveByteLimit(capability.maxBytes) && + capability.byteLength <= capability.maxBytes && + capability.maxBytes <= maxTransferBytes && + SHA256.test(capability.expectedSha256) && + Number.isSafeInteger(capability.expiresAtEpochMs) && + Number.isSafeInteger(nowEpochMs) && + capability.expiresAtEpochMs > nowEpochMs + ); + } catch { + return false; + } +} + +async function* abortableFileChunks( + source: FileByteSource, + signal: AbortSignal, +): AsyncIterable> { + const iterator = source.stream(signal)[Symbol.asyncIterator](); + let completed = false; + try { + while (true) { + const next = await awaitWithSignal(iterator.next(), signal); + if (next.done) { + completed = true; + return; + } + yield next.value; + } + } finally { + if (!completed && iterator.return) { + try { + const cleanup = iterator.return(); + if (signal.aborted) { + void cleanup.catch(() => {}); + } else { + await cleanup; + } + } catch { + // The primary transfer result wins over iterator cleanup. + } + } + } +} + +function integrityVerifier( + input: DeliveryInput, + options: DownloadDeliveryAdapterOptions, +): StreamingIntegrityVerifier | undefined { + if (!input.expectedSha256) return undefined; + if (!options.createIntegrityVerifier) { + throw new TypeError("Streaming integrity verifier is not installed."); + } + return options.createIntegrityVerifier(input.expectedSha256); +} + +function validateChunk(chunk: Uint8Array): void { + if (!(chunk instanceof Uint8Array)) { + throw new DOMException("Unexpected stream chunk", "DataError"); + } +} + +function assertDeclaredLength( + declared: number | null, + transferred: number, +): void { + if ( + (declared !== null && + (!isValidByteLength(declared) || declared !== transferred)) || + !isValidByteLength(transferred) + ) { + throw integrityException(); + } +} + +function mapDownloadException( + error: unknown, +): BrowserDataResult { + if (error instanceof ClosedFileStreamError) { + return browserDataFailure(error.failure.code, "DOWNLOAD", { + retryable: error.failure.retryable, + recovery: error.failure.recovery, + }); + } + if (error instanceof DOMException) { + if (error.name === "IntegrityError") { + return browserDataFailure("INTEGRITY_FAILED", "DOWNLOAD"); + } + if (error.name === "FileTooLargeError") { + return browserDataFailure("LIMIT_EXCEEDED", "DOWNLOAD"); + } + } + return mapBrowserDataException(error, "DOWNLOAD"); +} + +function validateBrowserManagedCapability( + capability: BrowserManagedDownloadCapability, + input: DeliveryInput, + nowEpochMs: number, +): BrowserDataResult { + if ( + input.source.kind !== "BROWSER_MANAGED_RESOURCE" || + !capability || + typeof capability !== "object" || + typeof capability.href !== "string" || + capability.href.length === 0 || + capability.capabilityReceipt !== + input.source.capabilityReceipt || + capability.resourceId !== input.source.resourceId || + capability.mediaType.trim().toLowerCase() !== + input.mediaType || + capability.safeExtension.trim().toLowerCase() !== + input.safeExtension || + !isPositiveByteLimit(capability.maxBytes) || + capability.maxBytes > input.maxTransferBytes || + !Number.isSafeInteger(capability.expiresAtEpochMs) || + !Number.isSafeInteger(nowEpochMs) || + (capability.expectedSha256 !== undefined && + !SHA256.test(capability.expectedSha256)) || + (input.integrity === "REQUIRED" && + capability.expectedSha256 === undefined) + ) { + return browserDataFailure("POLICY_REJECTED", "DOWNLOAD"); + } + if (capability.expiresAtEpochMs <= nowEpochMs) { + return browserDataFailure("EXPIRED_RESOURCE", "DOWNLOAD", { + recovery: "RETRY", + }); + } + return browserDataSuccess( + Object.freeze({ + capabilityReceipt: capability.capabilityReceipt, + href: capability.href, + resourceId: capability.resourceId, + mediaType: input.mediaType, + safeExtension: input.safeExtension, + maxBytes: capability.maxBytes, + ...(capability.expectedSha256 + ? { + expectedSha256: + capability.expectedSha256.toLowerCase(), + } + : {}), + expiresAtEpochMs: capability.expiresAtEpochMs, + }), + ); +} + +function safeBrowserManagedTarget( + href: string, + baseOrigin: string, + policy: Readonly<{ + allowCrossOrigin: boolean; + allowQuery: boolean; + }>, +): boolean { + try { + const base = new URL(baseOrigin); + const target = new URL(href, base); + return ( + ["http:", "https:"].includes(target.protocol) && + target.username.length === 0 && + target.password.length === 0 && + (policy.allowCrossOrigin || target.origin === base.origin) && + (policy.allowQuery || target.search.length === 0) && + target.hash.length === 0 + ); + } catch { + return false; + } +} + +function safeOpaqueId(value: unknown): value is string { + if (typeof value !== "string") return false; + if (value.length === 0 || value.length > 256) return false; + for (const character of value) { + const code = character.codePointAt(0); + if (code === undefined || code <= 0x20 || code === 0x7f) { + return false; + } + } + return true; +} + +function safeGeneratedToken(value: string): boolean { + return /^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value); +} + +type ProgressReporter = Readonly<{ + report( + phase: TransferProgress["phase"], + transferredBytes: number, + totalBytes: number | null, + force?: boolean, + ): void; +}>; + +function createProgressReporter(input: Readonly<{ + callback(progress: TransferProgress): void; + now(): number; + minIntervalMs: number; +}>): ProgressReporter { + let lastEmittedAt = Number.NEGATIVE_INFINITY; + let lastTransferred = 0; + return Object.freeze({ + report( + phase, + transferredBytes, + totalBytes, + force = false, + ): void { + if ( + !isValidByteLength(transferredBytes) || + transferredBytes < lastTransferred + ) { + return; + } + lastTransferred = transferredBytes; + const timestamp = input.now(); + if (!force && timestamp - lastEmittedAt < input.minIntervalMs) { + return; + } + lastEmittedAt = timestamp; + try { + input.callback( + Object.freeze({ + phase, + transferredBytes, + totalBytes: + totalBytes !== null && isValidByteLength(totalBytes) + ? totalBytes + : null, + }), + ); + } catch { + // Progress UI cannot change transfer behavior. + } + }, + }); +} + +function observeResult( + result: BrowserDataResult, + observer: BrowserFileObserver | undefined, + bytes: number | null = null, +): BrowserDataResult { + if (result.ok) { + const dismissed = + typeof result.value === "object" && + result.value !== null && + "kind" in result.value && + result.value.kind === "DISMISSED"; + observeBrowserFile(observer, { + operation: "DOWNLOAD", + outcome: dismissed ? "DISMISSED" : "SUCCESS", + byteBucket: byteBucket(bytes), + }); + } else { + observeBrowserFile(observer, { + operation: "DOWNLOAD", + outcome: "FAILED", + failureCode: result.error.code, + byteBucket: byteBucket(bytes), + }); + } + return result; +} + +function abortException(): DOMException { + return new DOMException("Operation aborted", "AbortError"); +} + +function integrityException(): DOMException { + return new DOMException("Integrity check failed", "IntegrityError"); +} + +function limitException(): DOMException { + return new DOMException("Byte limit exceeded", "FileTooLargeError"); +} + +class ClosedFileStreamError extends Error { + readonly failure: BrowserDataFailure; + + constructor(failure: BrowserDataFailure) { + super(failure.code); + this.name = "ClosedFileStreamError"; + this.failure = failure; + } +} + +function combineAbortSignals( + caller: AbortSignal, + lifetime: AbortSignal, +): Readonly<{ signal: AbortSignal; release(): void }> { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + if (caller.aborted || lifetime.aborted) { + controller.abort(); + } else { + caller.addEventListener("abort", abort, { once: true }); + lifetime.addEventListener("abort", abort, { once: true }); + } + return Object.freeze({ + signal: controller.signal, + release(): void { + caller.removeEventListener("abort", abort); + lifetime.removeEventListener("abort", abort); + }, + }); +} + +async function awaitWithSignal( + pending: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) throw abortException(); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + callback(); + }; + const onAbort = (): void => + finish(() => reject(abortException())); + signal.addEventListener("abort", onAbort, { once: true }); + pending.then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)), + ); + }); +} + +function isPositiveByteLimit(value: number): boolean { + return isValidByteLength(value) && value > 0; +} diff --git a/src/adapters/browser-files/file-observer.ts b/src/adapters/browser-files/file-observer.ts new file mode 100644 index 0000000..d03ac2d --- /dev/null +++ b/src/adapters/browser-files/file-observer.ts @@ -0,0 +1,53 @@ +import type { + BrowserDataObservation, + BrowserDataObserver, + BrowserDataFailureCode, + BrowserDataOperation, +} from "../../application/ports/browser-file-storage/shared.ts"; +import { observeBrowserData } from "../browser-file-storage/result.ts"; + +type BrowserFileObservation = Readonly<{ + operation: BrowserDataOperation; + outcome: "SUCCESS" | "DISMISSED" | "FAILED"; + failureCode?: BrowserDataFailureCode; + byteBucket?: BrowserDataObservation["byteBucket"]; +}>; + +/** + * File adapters use the platform BrowserDataObserver as the telemetry SSOT. + * The helper below is the sole mapper from file-local dismissal semantics. + */ +export type BrowserFileObserver = BrowserDataObserver; + +export function byteBucket( + byteLength: number | null, +): BrowserFileObservation["byteBucket"] | undefined { + if (byteLength === null || !Number.isSafeInteger(byteLength) || byteLength < 0) { + return undefined; + } + if (byteLength === 0) return "ZERO"; + if (byteLength < 1_048_576) return "LT1MIB"; + if (byteLength < 10_485_760) return "1_TO_9MIB"; + if (byteLength < 104_857_600) return "10_TO_99MIB"; + return "GTE100MIB"; +} + +export function observeBrowserFile( + observer: BrowserFileObserver | undefined, + observation: BrowserFileObservation, +): void { + observeBrowserData( + observer, + Object.freeze({ + operation: observation.operation, + outcome: + observation.outcome === "FAILED" ? "FAILED" : "SUCCEEDED", + ...(observation.failureCode + ? { failureCode: observation.failureCode } + : {}), + ...(observation.byteBucket + ? { byteBucket: observation.byteBucket } + : {}), + }), + ); +} diff --git a/src/adapters/browser-files/file-policy.ts b/src/adapters/browser-files/file-policy.ts new file mode 100644 index 0000000..455a4f5 --- /dev/null +++ b/src/adapters/browser-files/file-policy.ts @@ -0,0 +1,441 @@ +import type { + FileCandidate, +} from "../../application/ports/browser-file-storage/file.ts"; +import type { PersistableDataClass } from "../../application/ports/browser-file-storage/shared.ts"; +import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts"; + +export type FileAcceptRule = Readonly<{ + mediaType: string; + extensions: readonly string[]; +}>; + +export type RegisteredFileSelectionPolicy = Readonly<{ + policyId: string; + purpose: string; + classification: PersistableDataClass; + multiple: boolean; + maxCount: number; + maxFileBytes: number; + maxTotalBytes: number; + allowEmpty: boolean; + accept: readonly FileAcceptRule[]; +}>; + +export type FileBytePattern = Readonly<{ + offset: number; + bytes: readonly number[]; + mask?: readonly number[]; +}>; + +export type FileSignatureRule = Readonly<{ + mediaType: string; + extensions: readonly string[]; + patterns: readonly FileBytePattern[]; +}>; + +export type RegisteredFileInspectionPolicy = Readonly<{ + policyId: string; + maxInspectionBytes: number; + acceptedSignatures: readonly FileSignatureRule[]; +}>; + +const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/(?:[a-z0-9!#$&^_.+-]+|\*)$/i; +const EXTENSION = + /^\.[a-z0-9][a-z0-9+_-]{0,15}(?:\.[a-z0-9][a-z0-9+_-]{0,15})?$/i; +const POLICY_TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/i; +const FILE_SYSTEM_RESERVED = /[<>:"|?*]/g; +const WINDOWS_RESERVED = + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; +const DEFAULT_FORBIDDEN_EXTENSIONS = Object.freeze([ + ".app", + ".apk", + ".bat", + ".cer", + ".cmd", + ".com", + ".cpl", + ".deb", + ".dmg", + ".exe", + ".htm", + ".html", + ".hta", + ".inf", + ".iso", + ".jar", + ".js", + ".lnk", + ".mjs", + ".msi", + ".pif", + ".ps1", + ".reg", + ".rpm", + ".scr", + ".sh", + ".svg", + ".vb", + ".vbe", + ".vbs", + ".wsf", + ".wsh", + ".xll", +] as const); + +export type SuggestedFileNamePolicy = Readonly<{ + safeExtension: string; + fallbackBaseName?: string; + maxUtf8Bytes?: number; + forbiddenExtensions?: readonly string[]; +}>; + +export function assertFileSelectionPolicy( + policy: RegisteredFileSelectionPolicy, +): void { + if ( + !POLICY_TOKEN.test(policy.policyId) || + !POLICY_TOKEN.test(policy.purpose) || + ![ + "PUBLIC", + "INTERNAL", + "PERSONAL", + "CONFIDENTIAL", + ].includes(policy.classification) || + !Number.isSafeInteger(policy.maxCount) || + policy.maxCount < 1 || + !isValidByteLength(policy.maxFileBytes) || + !isValidByteLength(policy.maxTotalBytes) || + policy.maxFileBytes > policy.maxTotalBytes || + (!policy.allowEmpty && + (policy.maxFileBytes === 0 || policy.maxTotalBytes === 0)) || + (!policy.multiple && policy.maxCount !== 1) + ) { + throw new TypeError("File selection policy is invalid."); + } + + for (const rule of policy.accept) { + assertAcceptRule(rule); + } +} + +function assertAcceptRule(rule: FileAcceptRule): void { + if ( + !MEDIA_TYPE.test(rule.mediaType) || + rule.extensions.length === 0 || + rule.extensions.some((extension) => !EXTENSION.test(extension)) + ) { + throw new TypeError("File accept rule is invalid."); + } +} + +export function assertFileInspectionPolicy( + policy: RegisteredFileInspectionPolicy, + hardMaxInspectionBytes: number, +): void { + if ( + !POLICY_TOKEN.test(policy.policyId) || + !Number.isSafeInteger(policy.maxInspectionBytes) || + policy.maxInspectionBytes < 1 || + policy.maxInspectionBytes > hardMaxInspectionBytes + ) { + throw new TypeError("File inspection policy is invalid."); + } + + for (const rule of policy.acceptedSignatures) { + assertSignatureRule(rule, policy.maxInspectionBytes); + } +} + +function assertSignatureRule( + rule: FileSignatureRule, + maxInspectionBytes: number, +): void { + if ( + !MEDIA_TYPE.test(rule.mediaType) || + rule.extensions.length === 0 || + rule.extensions.some((extension) => !EXTENSION.test(extension)) || + rule.patterns.length === 0 + ) { + throw new TypeError("File signature rule is invalid."); + } + for (const pattern of rule.patterns) { + assertBytePattern(pattern, maxInspectionBytes); + } +} + +function assertBytePattern( + pattern: FileBytePattern, + maxInspectionBytes: number, +): void { + if ( + !Number.isSafeInteger(pattern.offset) || + pattern.offset < 0 || + pattern.bytes.length === 0 || + pattern.offset + pattern.bytes.length > maxInspectionBytes || + pattern.bytes.some((byte) => !validByte(byte)) || + (pattern.mask !== undefined && + (pattern.mask.length !== pattern.bytes.length || + pattern.mask.some((byte) => !validByte(byte)))) + ) { + throw new TypeError("File signature byte pattern is invalid."); + } +} + +function validByte(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value <= 0xff; +} + +export function normalizedExtension(fileName: string): string | null { + const name = fileName.normalize("NFC"); + const separator = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\")); + const baseName = name.slice(separator + 1); + const index = baseName.lastIndexOf("."); + if (index <= 0 || index === baseName.length - 1) return null; + const extension = baseName.slice(index).toLowerCase(); + return EXTENSION.test(extension) ? extension : null; +} + +/** + * Picker accept metadata is only an early usability filter. Returning true + * here never establishes that the file content is safe. + */ +export function matchesSelectionHint( + candidate: Pick, + accept: readonly FileAcceptRule[], +): boolean { + if (accept.length === 0) return true; + const extension = normalizedExtension(candidate.displayName); + const normalizedName = normalizedFileName(candidate.displayName); + const reported = candidate.reportedMediaType?.toLowerCase() ?? null; + return accept.some((rule) => { + const expected = rule.mediaType.toLowerCase(); + const mediaMatches = + reported !== null && + (expected === reported || + (expected.endsWith("/*") && + reported.startsWith(`${expected.slice(0, -1)}`))); + const extensionMatches = + rule.extensions.some( + (allowed) => + normalizedName.endsWith(allowed.toLowerCase()) || + (extension !== null && + allowed.toLowerCase() === extension), + ); + return mediaMatches || extensionMatches; + }); +} + +export function findMatchingSignature( + header: Uint8Array, + rules: readonly FileSignatureRule[], +): FileSignatureRule | null { + for (const rule of rules) { + if (rule.patterns.some((pattern) => matchesPattern(header, pattern))) { + return rule; + } + } + return null; +} + +function matchesPattern( + header: Uint8Array, + pattern: FileBytePattern, +): boolean { + if (pattern.offset + pattern.bytes.length > header.byteLength) return false; + for (let index = 0; index < pattern.bytes.length; index += 1) { + const mask = pattern.mask?.[index] ?? 0xff; + const actual = header[pattern.offset + index]; + const expected = pattern.bytes[index]; + if (actual === undefined || expected === undefined) return false; + if ((actual & mask) !== (expected & mask)) return false; + } + return true; +} + +export function signatureWasExpected( + fileName: string, + reportedMediaType: string | null, + rules: readonly FileSignatureRule[], +): boolean { + const extension = normalizedExtension(fileName); + const normalizedName = normalizedFileName(fileName); + const reported = reportedMediaType?.toLowerCase() ?? null; + return rules.some( + (rule) => + (reported !== null && + reported === rule.mediaType.toLowerCase()) || + (extension !== null && + rule.extensions.some( + (allowed) => + normalizedName.endsWith(allowed.toLowerCase()) || + allowed.toLowerCase() === extension, + )), + ); +} + +export function signatureMetadataMatches( + fileName: string, + reportedMediaType: string | null, + rule: FileSignatureRule, +): boolean { + const normalizedName = normalizedFileName(fileName); + const extension = normalizedExtension(fileName); + const reported = reportedMediaType?.toLowerCase() ?? null; + const extensionMatches = + extension === null || + rule.extensions.some( + (allowed) => + normalizedName.endsWith(allowed.toLowerCase()) || + allowed.toLowerCase() === extension, + ); + const mediaMatches = + reported === null || reported === rule.mediaType.toLowerCase(); + return extensionMatches && mediaMatches; +} + +export function sanitizeSuggestedFileName( + suggestedName: string, + policy: SuggestedFileNamePolicy, +): string { + const safeExtension = normalizeSafeExtension(policy.safeExtension); + const forbidden = new Set( + (policy.forbiddenExtensions ?? DEFAULT_FORBIDDEN_EXTENSIONS).map( + normalizeSafeExtension, + ), + ); + const configuredFallback = neutralizeForbiddenExtensions( + sanitizeBaseName(policy.fallbackBaseName ?? "download"), + forbidden, + ); + const maxUtf8Bytes = policy.maxUtf8Bytes ?? 180; + if ( + !Number.isSafeInteger(maxUtf8Bytes) || + maxUtf8Bytes < utf8Length(`a${safeExtension}`) + ) { + throw new TypeError("Suggested filename byte budget is invalid."); + } + const fallbackBase = fitFallbackBaseName( + configuredFallback, + safeExtension, + maxUtf8Bytes, + ); + + const lastPathSegment = + suggestedName + .normalize("NFC") + .split(/[\\/]/) + .at(-1) ?? ""; + const cleaned = lastPathSegment + .split("") + .filter((character) => !isUnsafeFormatCharacter(character)) + .join("") + .replace(FILE_SYSTEM_RESERVED, "_") + .trim() + .replace(/[ .]+$/g, ""); + const lowerCleaned = cleaned.toLowerCase(); + const existingExtension = lowerCleaned.endsWith(safeExtension) + ? safeExtension + : normalizedExtension(cleaned); + const withoutFinalExtension = + existingExtension === null + ? cleaned + : cleaned.slice(0, -existingExtension.length); + const neutralized = neutralizeForbiddenExtensions( + withoutFinalExtension, + forbidden, + ); + let base = sanitizeBaseName(neutralized); + if ( + base.length === 0 || + base === "." || + base === ".." || + WINDOWS_RESERVED.test(base) + ) { + base = fallbackBase.length > 0 ? fallbackBase : "download"; + } + + while ( + base.length > 0 && + utf8Length(`${base}${safeExtension}`) > maxUtf8Bytes + ) { + base = Array.from(base).slice(0, -1).join("").trimEnd(); + } + if (base.length === 0 || WINDOWS_RESERVED.test(base)) { + base = fallbackBase; + } + return `${base}${safeExtension}`; +} + +function normalizeSafeExtension(extension: string): string { + const normalized = extension.normalize("NFC").toLowerCase(); + if (!EXTENSION.test(normalized)) { + throw new TypeError("Safe filename extension is invalid."); + } + return normalized; +} + +function sanitizeBaseName(value: string): string { + return value + .normalize("NFC") + .split("") + .filter((character) => !isUnsafeFormatCharacter(character)) + .join("") + .replace(FILE_SYSTEM_RESERVED, "_") + .replace(/[\\/]/g, "_") + .trim() + .replace(/[ .]+$/g, ""); +} + +function neutralizeForbiddenExtensions( + value: string, + forbidden: ReadonlySet, +): string { + return value.replace(/\.[a-z0-9+_-]+/gi, (extension) => + forbidden.has(extension.toLowerCase()) + ? `_${extension.slice(1)}` + : extension, + ); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function fitFallbackBaseName( + configured: string, + extension: string, + maxUtf8Bytes: number, +): string { + let fallback = + configured.length > 0 && !WINDOWS_RESERVED.test(configured) + ? configured + : "download"; + while ( + fallback.length > 0 && + utf8Length(`${fallback}${extension}`) > maxUtf8Bytes + ) { + fallback = Array.from(fallback).slice(0, -1).join("").trimEnd(); + } + return fallback.length > 0 && !WINDOWS_RESERVED.test(fallback) + ? fallback + : "a"; +} + +function normalizedFileName(value: string): string { + const normalized = value.normalize("NFC").toLowerCase(); + const separator = Math.max( + normalized.lastIndexOf("/"), + normalized.lastIndexOf("\\"), + ); + return normalized.slice(separator + 1); +} + +function isUnsafeFormatCharacter(character: string): boolean { + const code = character.charCodeAt(0); + return ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ); +} diff --git a/src/adapters/browser-files/index.ts b/src/adapters/browser-files/index.ts new file mode 100644 index 0000000..d15fdd5 --- /dev/null +++ b/src/adapters/browser-files/index.ts @@ -0,0 +1,53 @@ +export type { + BrowserManagedDownloadCapability, + BrowserManagedDownloadCapabilityReceipt, + BrowserManagedDownloadCapabilityResolver, + DownloadOutcome, + DownloadSource, + DownloadStrategy, + FileByteSource, + FilePolicyIntention, + FilePolicyKey, + FilePolicyReference, + FileSelectionLimitReduction, + FileSelectionOutcome, + FileVerificationReceipt, + LocalFileRef, +} from "../../application/ports/browser-file-storage/file.ts"; +export { + BrowserFilePolicyRegistry, + browserFilePolicyReference, + type BrowserFilePolicyProfile, + type BrowserFilePolicyRegistryOptions, + type RegisteredDownloadPolicy, + type RegisteredPreviewPolicy, + type ResolvedDownloadPolicy, + type ResolvedInspectionPolicy, + type ResolvedPreviewPolicy, +} from "./browser-file-policy-registry.ts"; +export type { + FileAcceptRule, + FileBytePattern, + FileSignatureRule, + RegisteredFileInspectionPolicy, + RegisteredFileSelectionPolicy, +} from "./file-policy.ts"; +export { + createBrowserFileRuntime, + type BrowserFileRuntime, + type BrowserFileRuntimeOptions, +} from "./create-browser-file-runtime.ts"; +export { + DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS, + type SystemOpenPicker, + type SystemOpenPickerOptions, +} from "./browser-file-picker.ts"; +export { + DEFAULT_OBJECT_URL_RELEASE_GRACE_MS, + createAnchorDownloadHost, + type BrowserDownloadHost, + type SaveFileHandle, + type ShowSaveFilePicker, +} from "./download-delivery-adapter.ts"; +export type { BrowserFileObserver } from "./file-observer.ts"; +export type { ObjectUrlApi } from "./object-url-lease.ts"; diff --git a/src/adapters/browser-files/object-url-lease.ts b/src/adapters/browser-files/object-url-lease.ts new file mode 100644 index 0000000..d5c71ae --- /dev/null +++ b/src/adapters/browser-files/object-url-lease.ts @@ -0,0 +1,339 @@ +import type { + FilePolicyReference, + FileVerificationReceipt, + LocalFileRef, + PreviewLease, + TransientPreviewPort, +} from "../../application/ports/browser-file-storage/file.ts"; +import type { BrowserDataResult } from "../../application/ports/browser-file-storage/shared.ts"; +import { isValidByteLength } from "../../application/ports/browser-file-storage/shared.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, + mapBrowserDataException, +} from "../browser-file-storage/result.ts"; +import type { NativeVerifiedFileResolver } from "./browser-file-vault.ts"; +import { + byteBucket, + observeBrowserFile, + type BrowserFileObserver, +} from "./file-observer.ts"; +import { BrowserFilePolicyRegistry } from "./browser-file-policy-registry.ts"; + +export type ObjectUrlApi = Readonly<{ + createObjectURL(blob: Blob): string; + revokeObjectURL(url: string): void; +}>; + +export type ObjectUrlLease = Readonly<{ + url: string; + release(): void; +}>; + +export type ObjectUrlLeaseLimits = Readonly<{ + hardMaxActiveLeases: number; + hardMaxSingleLeaseBytes: number; + hardMaxAggregateLeaseBytes: number; +}>; + +const DEFAULT_OBJECT_URL_LEASE_LIMITS: ObjectUrlLeaseLimits = + Object.freeze({ + hardMaxActiveLeases: 16, + hardMaxSingleLeaseBytes: 64 * 1024 * 1024, + hardMaxAggregateLeaseBytes: 256 * 1024 * 1024, + }); + +/** + * The only low-level owner of object URL creation/revocation. Every lease is + * idempotent and dispose() is a final safety net for route/runtime teardown. + */ +export class ObjectUrlLeaseRegistry { + readonly #createObjectURL: ObjectUrlApi["createObjectURL"]; + readonly #revokeObjectURL: ObjectUrlApi["revokeObjectURL"]; + readonly #limits: ObjectUrlLeaseLimits; + readonly #active = new Map< + string, + Readonly<{ release(): void; byteLength: number }> + >(); + #aggregateByteLength = 0; + + constructor( + urlApi: ObjectUrlApi = URL, + limits: ObjectUrlLeaseLimits = DEFAULT_OBJECT_URL_LEASE_LIMITS, + ) { + const createObjectURL = urlApi.createObjectURL; + const revokeObjectURL = urlApi.revokeObjectURL; + if ( + typeof createObjectURL !== "function" || + typeof revokeObjectURL !== "function" + ) { + throw new TypeError("Object URL API is invalid."); + } + this.#createObjectURL = createObjectURL.bind(urlApi); + this.#revokeObjectURL = revokeObjectURL.bind(urlApi); + this.#limits = Object.freeze({ + hardMaxActiveLeases: limits.hardMaxActiveLeases, + hardMaxSingleLeaseBytes: limits.hardMaxSingleLeaseBytes, + hardMaxAggregateLeaseBytes: + limits.hardMaxAggregateLeaseBytes, + }); + if ( + !isPositiveSafeInteger(this.#limits.hardMaxActiveLeases) || + !isPositiveSafeInteger( + this.#limits.hardMaxSingleLeaseBytes, + ) || + !isPositiveSafeInteger( + this.#limits.hardMaxAggregateLeaseBytes, + ) || + this.#limits.hardMaxSingleLeaseBytes > + this.#limits.hardMaxAggregateLeaseBytes + ) { + throw new TypeError("Object URL lease limits are invalid."); + } + } + + create(blob: Blob): ObjectUrlLease { + if ( + !isValidByteLength(blob.size) || + blob.size > this.#limits.hardMaxSingleLeaseBytes || + this.#active.size >= this.#limits.hardMaxActiveLeases || + !Number.isSafeInteger(this.#aggregateByteLength + blob.size) || + this.#aggregateByteLength + blob.size > + this.#limits.hardMaxAggregateLeaseBytes + ) { + throw new DOMException( + "Object URL lease limit exceeded", + "FileTooLargeError", + ); + } + const url = this.#createObjectURL(blob); + if (typeof url !== "string" || url.length === 0) { + if (typeof url === "string" && url.length > 0) { + try { + this.#revokeObjectURL(url); + } catch { + // The invalid lease is rejected regardless of cleanup support. + } + } + throw new DOMException( + "Object URL allocation failed", + "InvalidStateError", + ); + } + if (this.#active.has(url)) { + throw new DOMException( + "Object URL allocation was not unique", + "InvalidStateError", + ); + } + let released = false; + const release = (): void => { + if (released) return; + released = true; + if (this.#active.delete(url)) { + this.#aggregateByteLength -= blob.size; + } + try { + this.#revokeObjectURL(url); + } catch { + // Revocation is best effort and must remain idempotent. + } + }; + this.#active.set( + url, + Object.freeze({ release, byteLength: blob.size }), + ); + this.#aggregateByteLength += blob.size; + return Object.freeze({ url, release }); + } + + dispose(): void { + for (const lease of Array.from(this.#active.values())) { + lease.release(); + } + } + + get activeLeaseCount(): number { + return this.#active.size; + } + + get aggregateLeaseByteLength(): number { + return this.#aggregateByteLength; + } +} + +export type TransientPreviewOptions = Readonly<{ + files: NativeVerifiedFileResolver; + policies: BrowserFilePolicyRegistry; + /** + * Runtime-owned absolute ceiling. Feature callers may request a lower + * maxPreviewBytes but can never raise this limit. + */ + hardMaxPreviewBytes: number; + leases?: ObjectUrlLeaseRegistry; + hardForbiddenMediaTypes?: ReadonlySet; + observer?: BrowserFileObserver; +}>; + +const DEFAULT_ACTIVE_CONTENT = new Set([ + "application/pdf", + "application/xhtml+xml", + "application/xml", + "image/svg+xml", + "text/html", + "text/xml", +]); +const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i; + +export class BrowserTransientPreview implements TransientPreviewPort { + readonly #resolveVerifiedFile: + NativeVerifiedFileResolver["resolveVerifiedFile"]; + readonly #resolvePreview: + BrowserFilePolicyRegistry["resolvePreview"]; + readonly #createLease: ObjectUrlLeaseRegistry["create"]; + readonly #disposeLeases: ObjectUrlLeaseRegistry["dispose"]; + readonly #hardMaxPreviewBytes: number; + readonly #hardForbiddenMediaTypes: ReadonlySet; + readonly #observer: BrowserFileObserver | undefined; + #disposed = false; + + constructor(options: TransientPreviewOptions) { + this.#resolveVerifiedFile = + options.files.resolveVerifiedFile.bind(options.files); + this.#resolvePreview = + options.policies.resolvePreview.bind(options.policies); + const leases = + options.leases ?? new ObjectUrlLeaseRegistry(); + this.#createLease = leases.create.bind(leases); + this.#disposeLeases = leases.dispose.bind(leases); + this.#hardMaxPreviewBytes = options.hardMaxPreviewBytes; + this.#hardForbiddenMediaTypes = new Set([ + ...Array.from( + DEFAULT_ACTIVE_CONTENT, + (mediaType) => mediaType.toLowerCase(), + ), + ...Array.from( + options.hardForbiddenMediaTypes ?? [], + (mediaType) => mediaType.toLowerCase(), + ), + ]); + this.#observer = options.observer; + if ( + !isValidByteLength(this.#hardMaxPreviewBytes) || + this.#hardMaxPreviewBytes === 0 + ) { + throw new TypeError("Preview hard byte limit is invalid."); + } + } + + async create(input: { + ref: LocalFileRef; + verificationReceipt: FileVerificationReceipt; + policy: FilePolicyReference; + maxPreviewBytes?: number; + signal: AbortSignal; + }): Promise> { + if (this.#disposed) { + return this.#observe( + browserDataFailure("UNAVAILABLE", "PREVIEW"), + ); + } + const cancelled = abortedResult(input.signal, "PREVIEW"); + if (cancelled) return this.#observe(cancelled); + const resolvedPolicy = this.#resolvePreview( + input.policy, + input.maxPreviewBytes, + ); + if (!resolvedPolicy.ok) return this.#observe(resolvedPolicy); + const policy = resolvedPolicy.value; + if (policy.maxPreviewBytes > this.#hardMaxPreviewBytes) { + return this.#observe( + browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"), + ); + } + + try { + const resolved = await this.#resolveVerifiedFile({ + ref: input.ref, + verificationReceipt: input.verificationReceipt, + verificationPolicyBindingId: + policy.verificationPolicyBindingId, + signal: input.signal, + }); + if (!resolved.ok) return this.#observe(resolved); + if (this.#disposed) { + return this.#observe( + browserDataFailure("UNAVAILABLE", "PREVIEW"), + ); + } + const mediaType = resolved.value.mediaType.trim().toLowerCase(); + if ( + !MEDIA_TYPE.test(mediaType) || + !policy.allowedMediaTypes.has(mediaType) || + this.#hardForbiddenMediaTypes.has(mediaType) + ) { + return this.#observe( + browserDataFailure("POLICY_REJECTED", "PREVIEW"), + ); + } + if (resolved.value.file.size > policy.maxPreviewBytes) { + return this.#observe( + browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"), + ); + } + // A typed slice prevents the untrusted File.type from controlling how + // the object URL is interpreted. + const typedBlob = resolved.value.file.slice( + 0, + resolved.value.file.size, + mediaType, + ); + const lease = this.#createLease(typedBlob); + const preview: PreviewLease = Object.freeze({ + url: lease.url, + mediaType, + release: lease.release, + }); + observeBrowserFile(this.#observer, { + operation: "PREVIEW", + outcome: "SUCCESS", + byteBucket: byteBucket(resolved.value.file.size), + }); + return browserDataSuccess(preview); + } catch (error) { + if ( + error instanceof DOMException && + error.name === "FileTooLargeError" + ) { + return this.#observe( + browserDataFailure("LIMIT_EXCEEDED", "PREVIEW"), + ); + } + return this.#observe(mapBrowserDataException(error, "PREVIEW")); + } + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#disposeLeases(); + } + + #observe( + result: BrowserDataResult, + ): BrowserDataResult { + if (!result.ok) { + observeBrowserFile(this.#observer, { + operation: "PREVIEW", + outcome: "FAILED", + failureCode: result.error.code, + }); + } + return result; + } +} + +function isPositiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} diff --git a/src/adapters/browser-rpc/browser-rpc-runtime.ts b/src/adapters/browser-rpc/browser-rpc-runtime.ts new file mode 100644 index 0000000..20d19a7 --- /dev/null +++ b/src/adapters/browser-rpc/browser-rpc-runtime.ts @@ -0,0 +1,1145 @@ +import type { + BrowserRpcGenerationFence, + BrowserRpcServerStreamPort, + BrowserRpcUnaryPort, +} from "../../application/ports/browser-rpc/index.ts"; +import type { ClockPort } from "../../application/ports/clock-port.ts"; +import type { Result } from "../../application/result.ts"; +import { + BROWSER_RPC_HARD_LIMITS, + validateBrowserRpcContractBindings, + type BrowserRpcOperationV3, + type BrowserRpcProviderProfile, + type BrowserRpcRequestEncoder, + type BrowserRpcTransportFailureCode, +} from "../../contracts/browser-rpc.ts"; +import type { InstalledBoundaryMapper } from "../../contracts/boundary-mapper.ts"; +import { + createFailure, + type AppFailure, + type FailureKind, +} from "../../contracts/errors.ts"; +import type { RuntimeSchemaCodec } from "../../contracts/schema-registry.ts"; +import { systemClock } from "../platform/system-clock.ts"; +import type { + BrowserRpcStreamFrame, + BrowserRpcTransport, + BrowserRpcTransportFailure, + BrowserRpcUnaryTransportResult, +} from "./transport.ts"; + +export type BrowserRpcObservationOutcome = + | "SUCCESS" + | "FAILED" + | "ABORTED" + | "TIMEOUT" + | "CONTRACT_REJECTED"; + +export type BrowserRpcObservation = Readonly<{ + operationId: string; + protocol: "CONNECT_HTTP" | "GRPC_WEB"; + runtimeProfileId: string; + rpcKind: "UNARY" | "SERVER_STREAM"; + outcome: BrowserRpcObservationOutcome; + attemptCount: number; + messageCount: number; +}>; + +export type BrowserRpcObservationSink = Readonly<{ + observe(observation: BrowserRpcObservation): void; +}>; + +export type BrowserRpcRuntimeDependencies = Readonly<{ + operations: Readonly>; + profiles: Readonly>; + schemaCodecs: Readonly>; + mappers: Readonly>; + requestEncoders: Readonly>; + transports: Readonly>; + generationFence?: BrowserRpcGenerationFence; + clock?: ClockPort; + observations?: BrowserRpcObservationSink; +}>; + +export type BrowserRpcRuntime = Readonly<{ + bindUnary( + operationId: string, + isOutput: (value: unknown) => value is Output, + ): BrowserRpcUnaryPort; + bindServerStream( + operationId: string, + isEvent: (value: unknown) => value is Event, + ): BrowserRpcServerStreamPort; +}>; + +type BoundOperation = Readonly<{ + operation: BrowserRpcOperationV3; + profile: BrowserRpcProviderProfile; + encoder: BrowserRpcRequestEncoder; + transport: BrowserRpcTransport; +}>; + +type PreparedRequest = + | Readonly<{ + ok: true; + value: unknown; + encodedBytes: number; + }> + | Readonly<{ ok: false; error: AppFailure }>; + +type TimedResult = + | Readonly<{ kind: "VALUE"; value: Value }> + | Readonly<{ kind: "TIMEOUT" }> + | Readonly<{ kind: "ABORTED" }> + | Readonly<{ kind: "THREW" }>; + +const stableGenerationFence: BrowserRpcGenerationFence = + Object.freeze({ + capture: () => "stable", + isCurrent: (token) => token === "stable", + }); + +export function createBrowserRpcRuntime( + dependencies: BrowserRpcRuntimeDependencies, +): BrowserRpcRuntime { + const clock = dependencies.clock ?? systemClock; + const generationFence = + dependencies.generationFence ?? stableGenerationFence; + validateRuntimeDependencies(dependencies); + + function bind( + operationId: string, + expectedKind: "UNARY" | "SERVER_STREAM", + ): BoundOperation { + const operation = dependencies.operations[operationId]; + if (!operation || operation.rpcKind !== expectedKind) { + throw new TypeError( + `Browser RPC operation cannot be bound as ${expectedKind}: ${operationId}`, + ); + } + const profile = dependencies.profiles[operation.runtimeProfileId]; + const encoder = dependencies.requestEncoders[operation.requestEncoderId]; + const transport = dependencies.transports[operation.runtimeProfileId]; + if (!profile || !encoder || !transport) { + throw new TypeError( + `Browser RPC runtime binding is incomplete: ${operationId}`, + ); + } + return Object.freeze({ operation, profile, encoder, transport }); + } + + return Object.freeze({ + bindUnary( + operationId: string, + isOutput: (value: unknown) => value is Output, + ): BrowserRpcUnaryPort { + if (typeof isOutput !== "function") { + throw new TypeError("Browser RPC unary result guard is required."); + } + const bound = bind(operationId, "UNARY"); + return Object.freeze({ + execute: (input, context = {}) => + executeUnary( + dependencies, + bound, + input, + context, + isOutput, + generationFence, + clock, + ), + }); + }, + + bindServerStream( + operationId: string, + isEvent: (value: unknown) => value is Event, + ): BrowserRpcServerStreamPort { + if (typeof isEvent !== "function") { + throw new TypeError("Browser RPC stream result guard is required."); + } + const bound = bind(operationId, "SERVER_STREAM"); + return Object.freeze({ + open: (input, context = {}) => + executeServerStream( + dependencies, + bound, + input, + context, + isEvent, + generationFence, + clock, + ), + }); + }, + }); +} + +async function executeUnary( + dependencies: BrowserRpcRuntimeDependencies, + bound: BoundOperation, + input: unknown, + context: Readonly<{ signal?: AbortSignal; idempotencyKey?: string }>, + isOutput: (value: unknown) => value is Output, + generationFence: BrowserRpcGenerationFence, + clock: ClockPort, +): Promise> { + const { operation, profile, encoder, transport } = bound; + const generation = generationFence.capture(); + const startedAt = clock.now(); + const deadlineAt = startedAt + operation.totalDeadlineMs; + const linked = linkedAbortController(context.signal); + let attemptCount = 0; + + const finish = ( + result: Result, + outcome: BrowserRpcObservationOutcome, + ): Result => { + linked.cleanup(); + observe(dependencies, operation, outcome, attemptCount, result.ok ? 1 : 0); + return result; + }; + + const contextFailure = validateCallContext(operation, context, 0); + if (contextFailure) { + return finish(failureResult(contextFailure), "CONTRACT_REJECTED"); + } + if (linked.controller.signal.aborted) { + return finish( + failureResult(callFailure(operation, 0, "REQUEST_ABORTED", "RPC_ABORTED")), + "ABORTED", + ); + } + + const prepared = prepareRequest(dependencies, operation, encoder, input, 0); + if (!prepared.ok) { + return finish(failureResult(prepared.error), "CONTRACT_REJECTED"); + } + + for (let attempt = 0; attempt < profile.maxAttempts; attempt += 1) { + attemptCount = attempt + 1; + const remainingMs = deadlineAt - clock.now(); + if (remainingMs <= 0) { + linked.controller.abort("deadline"); + return finish( + failureResult( + callFailure( + operation, + attempt, + "REQUEST_TIMEOUT", + "RPC_TOTAL_DEADLINE_EXCEEDED", + ), + ), + "TIMEOUT", + ); + } + const call = Object.freeze({ + operation, + profile, + request: prepared.value, + encodedRequestBytes: prepared.encodedBytes, + attempt: attempt + 1, + timeoutMs: Math.max(1, Math.min(remainingMs, operation.totalDeadlineMs)), + signal: linked.controller.signal, + ...(context.idempotencyKey + ? { idempotencyKey: context.idempotencyKey } + : {}), + }); + const timed = await raceWithin( + Promise.resolve().then(() => transport.invokeUnary!(call)), + remainingMs, + linked.controller.signal, + clock, + ); + if (timed.kind === "TIMEOUT") { + linked.controller.abort("deadline"); + return finish( + failureResult( + callFailure( + operation, + attempt, + "REQUEST_TIMEOUT", + "RPC_TOTAL_DEADLINE_EXCEEDED", + ), + ), + "TIMEOUT", + ); + } + if (timed.kind === "ABORTED" || linked.controller.signal.aborted) { + return finish( + failureResult( + callFailure(operation, attempt, "REQUEST_ABORTED", "RPC_ABORTED"), + ), + "ABORTED", + ); + } + if (timed.kind === "THREW") { + return finish( + failureResult( + callFailure( + operation, + attempt, + "SERVER_FAILURE", + "RPC_TRANSPORT_EXECUTION_FAILED", + ), + ), + "FAILED", + ); + } + + const transportResult = validateUnaryTransportResult(timed.value); + if (!transportResult) { + return finish( + failureResult(protocolFailure(operation, attempt)), + "CONTRACT_REJECTED", + ); + } + if (!transportResult.ok) { + if ( + shouldRetry( + operation, + profile, + transportResult.failure, + attempt, + context.idempotencyKey, + ) + ) { + const delay = retryDelay(profile, transportResult.failure, attempt); + if (delay >= deadlineAt - clock.now()) { + linked.controller.abort("deadline"); + return finish( + failureResult( + callFailure( + operation, + attempt, + "REQUEST_TIMEOUT", + "RPC_RETRY_BUDGET_EXHAUSTED", + ), + ), + "TIMEOUT", + ); + } + try { + await clock.sleep(delay, linked.controller.signal); + } catch { + return finish( + failureResult( + callFailure( + operation, + attempt, + "REQUEST_ABORTED", + "RPC_ABORTED", + ), + ), + "ABORTED", + ); + } + continue; + } + const mapped = mapTransportFailure( + operation, + attempt, + transportResult.failure, + ); + return finish( + failureResult(mapped), + mapped.kind === "REQUEST_ABORTED" + ? "ABORTED" + : mapped.kind === "REQUEST_TIMEOUT" + ? "TIMEOUT" + : "FAILED", + ); + } + + const mapped = mapResponse( + dependencies, + operation, + attempt, + transportResult.message, + transportResult.encodedBytes, + generation, + generationFence, + isOutput, + deadlineAt, + clock, + ); + return finish( + mapped, + mapped.ok + ? "SUCCESS" + : mapped.error.kind === "REQUEST_TIMEOUT" + ? "TIMEOUT" + : "CONTRACT_REJECTED", + ); + } + + return finish( + failureResult( + callFailure( + operation, + Math.max(0, attemptCount - 1), + "SERVER_FAILURE", + "RPC_RETRY_EXHAUSTED", + ), + ), + "FAILED", + ); +} + +async function* executeServerStream( + dependencies: BrowserRpcRuntimeDependencies, + bound: BoundOperation, + input: unknown, + context: Readonly<{ signal?: AbortSignal; idempotencyKey?: string }>, + isEvent: (value: unknown) => value is Event, + generationFence: BrowserRpcGenerationFence, + clock: ClockPort, +): AsyncIterable> { + const { operation, profile, encoder, transport } = bound; + const generation = generationFence.capture(); + const deadlineAt = clock.now() + operation.totalDeadlineMs; + const linked = linkedAbortController(context.signal); + let iterator: AsyncIterator | null = null; + let observed = false; + let messageCount = 0; + + const finish = (outcome: BrowserRpcObservationOutcome) => { + if (observed) return; + observed = true; + observe(dependencies, operation, outcome, 1, messageCount); + }; + + try { + const contextFailure = validateCallContext(operation, context, 0); + if (contextFailure) { + finish("CONTRACT_REJECTED"); + yield failureResult(contextFailure); + return; + } + if (linked.controller.signal.aborted) { + finish("ABORTED"); + yield failureResult( + callFailure(operation, 0, "REQUEST_ABORTED", "RPC_ABORTED"), + ); + return; + } + const prepared = prepareRequest( + dependencies, + operation, + encoder, + input, + 0, + ); + if (!prepared.ok) { + finish("CONTRACT_REJECTED"); + yield failureResult(prepared.error); + return; + } + + const remainingMs = deadlineAt - clock.now(); + if (remainingMs <= 0) { + finish("TIMEOUT"); + yield failureResult( + callFailure( + operation, + 0, + "REQUEST_TIMEOUT", + "RPC_TOTAL_DEADLINE_EXCEEDED", + ), + ); + return; + } + let stream: AsyncIterable; + try { + stream = transport.openServerStream!( + Object.freeze({ + operation, + profile, + request: prepared.value, + encodedRequestBytes: prepared.encodedBytes, + attempt: 1, + timeoutMs: Math.max(1, remainingMs), + signal: linked.controller.signal, + ...(context.idempotencyKey + ? { idempotencyKey: context.idempotencyKey } + : {}), + }), + ); + iterator = stream[Symbol.asyncIterator](); + } catch { + finish("FAILED"); + yield failureResult( + callFailure( + operation, + 0, + "SERVER_FAILURE", + "RPC_TRANSPORT_EXECUTION_FAILED", + ), + ); + return; + } + + let totalBytes = 0; + let terminal: + | Readonly<{ ok: true }> + | Readonly<{ ok: false; failure: BrowserRpcTransportFailure }> + | null = null; + + while (true) { + const totalRemaining = deadlineAt - clock.now(); + if (totalRemaining <= 0) { + linked.controller.abort("deadline"); + finish("TIMEOUT"); + yield failureResult( + callFailure( + operation, + 0, + "REQUEST_TIMEOUT", + "RPC_TOTAL_DEADLINE_EXCEEDED", + ), + ); + return; + } + const waitMs = Math.min( + totalRemaining, + operation.idleDeadlineMs ?? totalRemaining, + ); + const next = await raceWithin( + Promise.resolve().then(() => iterator!.next()), + waitMs, + linked.controller.signal, + clock, + ); + if (next.kind === "TIMEOUT") { + linked.controller.abort("idle-or-deadline"); + const totalExpired = clock.now() >= deadlineAt; + finish("TIMEOUT"); + yield failureResult( + callFailure( + operation, + 0, + "REQUEST_TIMEOUT", + totalExpired + ? "RPC_TOTAL_DEADLINE_EXCEEDED" + : "RPC_STREAM_IDLE_TIMEOUT", + ), + ); + return; + } + if (next.kind === "ABORTED" || linked.controller.signal.aborted) { + finish("ABORTED"); + yield failureResult( + callFailure(operation, 0, "REQUEST_ABORTED", "RPC_ABORTED"), + ); + return; + } + if (next.kind === "THREW") { + finish("FAILED"); + yield failureResult( + callFailure( + operation, + 0, + "SERVER_FAILURE", + "RPC_STREAM_EXECUTION_FAILED", + ), + ); + return; + } + if (next.value.done) { + if (!terminal) { + finish("CONTRACT_REJECTED"); + yield failureResult(protocolFailure(operation, 0)); + return; + } + if (!terminal.ok) { + const mapped = mapTransportFailure( + operation, + 0, + terminal.failure, + ); + finish( + mapped.kind === "REQUEST_ABORTED" + ? "ABORTED" + : mapped.kind === "REQUEST_TIMEOUT" + ? "TIMEOUT" + : "FAILED", + ); + yield failureResult(mapped); + return; + } + finish("SUCCESS"); + return; + } + + const frame = validateStreamFrame(next.value.value); + if (!frame || terminal) { + finish("CONTRACT_REJECTED"); + yield failureResult(protocolFailure(operation, 0)); + return; + } + if (frame.kind === "TERMINAL") { + terminal = frame.ok + ? Object.freeze({ ok: true }) + : Object.freeze({ ok: false, failure: frame.failure }); + continue; + } + + messageCount += 1; + totalBytes += frame.encodedBytes; + if ( + messageCount > operation.maxResponseMessages || + frame.encodedBytes > operation.maxResponseMessageBytes || + totalBytes > operation.maxTotalResponseBytes + ) { + linked.controller.abort("message-limit"); + finish("CONTRACT_REJECTED"); + yield failureResult( + callFailure( + operation, + 0, + "RESPONSE_BODY_LIMIT", + "RPC_STREAM_MESSAGE_LIMIT", + ), + ); + return; + } + const mapped = mapResponse( + dependencies, + operation, + 0, + frame.message, + frame.encodedBytes, + generation, + generationFence, + isEvent, + deadlineAt, + clock, + ); + if (!mapped.ok) { + linked.controller.abort("mapping-failure"); + finish( + mapped.error.kind === "REQUEST_TIMEOUT" + ? "TIMEOUT" + : "CONTRACT_REJECTED", + ); + yield mapped; + return; + } + yield mapped; + } + } finally { + linked.controller.abort("stream-closed"); + linked.cleanup(); + if (iterator?.return) { + try { + await iterator.return(); + } catch { + // Cleanup cannot replace the already selected stream outcome. + } + } + finish("ABORTED"); + } +} + +function validateRuntimeDependencies( + dependencies: BrowserRpcRuntimeDependencies, +): void { + const runtimeBindings: Record< + string, + Pick< + BrowserRpcTransport, + "runtimeProfileId" | "providerId" | "protocol" | "rpcKind" + > + > = Object.create(null); + for (const [profileId, transport] of Object.entries( + dependencies.transports, + )) { + if ( + profileId !== transport.runtimeProfileId || + Object.hasOwn(runtimeBindings, profileId) + ) { + throw new TypeError( + `Browser RPC transport registry is invalid: ${profileId}`, + ); + } + runtimeBindings[profileId] = Object.freeze({ + runtimeProfileId: transport.runtimeProfileId, + providerId: transport.providerId, + protocol: transport.protocol, + rpcKind: transport.rpcKind, + }); + } + validateBrowserRpcContractBindings({ + operations: dependencies.operations, + profiles: dependencies.profiles, + schemaCodecs: dependencies.schemaCodecs, + mappers: dependencies.mappers, + requestEncoders: dependencies.requestEncoders, + runtimeBindings: Object.freeze(runtimeBindings), + }); + for (const operation of Object.values(dependencies.operations)) { + if (!dependencies.transports[operation.runtimeProfileId]) { + throw new TypeError( + `Browser RPC transport is missing: ${operation.operationId}`, + ); + } + } +} + +function prepareRequest( + dependencies: BrowserRpcRuntimeDependencies, + operation: BrowserRpcOperationV3, + encoder: BrowserRpcRequestEncoder, + input: unknown, + attempt: number, +): PreparedRequest { + const schema = dependencies.schemaCodecs[operation.requestSchemaId]; + let validated; + try { + validated = schema?.parse(input); + } catch { + validated = undefined; + } + if (!validated?.success) { + return Object.freeze({ + ok: false, + error: callFailure( + operation, + attempt, + "VALIDATION_REJECTED", + "RPC_REQUEST_SCHEMA_INVALID", + ), + }); + } + try { + const encoded = encoder.encode(validated.data); + if ( + !encoded.ok || + !validEncodedByteCount( + encoded.encodedBytes, + operation.maxRequestMessageBytes, + ) + ) { + return Object.freeze({ + ok: false, + error: callFailure( + operation, + attempt, + "MAPPING_CONTRACT_VIOLATION", + encoded.ok ? "RPC_REQUEST_MESSAGE_LIMIT" : safeCode(encoded.code), + ), + }); + } + return Object.freeze({ + ok: true, + value: encoded.value, + encodedBytes: encoded.encodedBytes, + }); + } catch { + return Object.freeze({ + ok: false, + error: callFailure( + operation, + attempt, + "MAPPING_CONTRACT_VIOLATION", + "RPC_REQUEST_ENCODING_FAILED", + ), + }); + } +} + +function mapResponse( + dependencies: BrowserRpcRuntimeDependencies, + operation: BrowserRpcOperationV3, + attempt: number, + message: unknown, + encodedBytes: number, + generation: unknown, + generationFence: BrowserRpcGenerationFence, + isOutput: (value: unknown) => value is Output, + deadlineAt: number, + clock: ClockPort, +): Result { + if ( + !validEncodedByteCount( + encodedBytes, + operation.maxResponseMessageBytes, + ) + ) { + return failureResult( + callFailure( + operation, + attempt, + "RESPONSE_BODY_LIMIT", + "RPC_RESPONSE_MESSAGE_LIMIT", + ), + ); + } + if (clock.now() >= deadlineAt) { + return failureResult( + callFailure( + operation, + attempt, + "REQUEST_TIMEOUT", + "RPC_TOTAL_DEADLINE_EXCEEDED", + ), + ); + } + + const schema = dependencies.schemaCodecs[operation.responseSchemaId]; + let validated; + try { + validated = schema?.parse(message); + } catch { + validated = undefined; + } + if (!validated?.success) { + return failureResult( + callFailure( + operation, + attempt, + "SCHEMA_MISMATCH", + "RPC_RESPONSE_SCHEMA_INVALID", + ), + ); + } + const mapper = dependencies.mappers[operation.mapperId]; + let mapped; + try { + mapped = mapper?.map(validated.data); + } catch { + mapped = undefined; + } + if (!mapped?.ok) { + return failureResult( + callFailure( + operation, + attempt, + "MAPPING_CONTRACT_VIOLATION", + mapped?.code ?? "RPC_RESPONSE_MAPPING_FAILED", + ), + ); + } + const output = mapped.value; + const outputMatches = safelyMatches(isOutput, output); + if (!outputMatches) { + return failureResult( + callFailure( + operation, + attempt, + "MAPPING_CONTRACT_VIOLATION", + "RPC_BOUND_RESULT_TYPE_MISMATCH", + ), + ); + } + if (!generationFence.isCurrent(generation)) { + return failureResult( + callFailure( + operation, + attempt, + "SCOPE_GENERATION_CHANGED", + "RPC_SCOPE_GENERATION_CHANGED", + ), + ); + } + if (clock.now() >= deadlineAt) { + return failureResult( + callFailure( + operation, + attempt, + "REQUEST_TIMEOUT", + "RPC_TOTAL_DEADLINE_EXCEEDED", + ), + ); + } + return Object.freeze({ ok: true, value: output }); +} + +function validateCallContext( + operation: BrowserRpcOperationV3, + context: Readonly<{ idempotencyKey?: string }>, + attempt: number, +): AppFailure | null { + const key = context.idempotencyKey; + if ( + (operation.idempotencyKeyPolicy === "REQUIRED" && + !validIdempotencyKey(key)) || + (operation.idempotencyKeyPolicy === "NONE" && key !== undefined) + ) { + return callFailure( + operation, + attempt, + "VALIDATION_REJECTED", + "RPC_IDEMPOTENCY_KEY_INVALID", + ); + } + return null; +} + +function validIdempotencyKey(value: unknown): value is string { + return ( + typeof value === "string" && + value.length >= 8 && + value.length <= 200 && + ![...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127 || /\s/u.test(character); + }) + ); +} + +function safelyMatches( + guard: (value: unknown) => value is Output, + value: unknown, +): value is Output { + try { + return guard(value); + } catch { + return false; + } +} + +function validateUnaryTransportResult( + value: unknown, +): BrowserRpcUnaryTransportResult | null { + if (!value || typeof value !== "object" || !("ok" in value)) return null; + const candidate = value as BrowserRpcUnaryTransportResult; + if (candidate.ok) { + return validEncodedByteCount(candidate.encodedBytes, Number.MAX_SAFE_INTEGER) + ? candidate + : null; + } + return validTransportFailure(candidate.failure) ? candidate : null; +} + +function validateStreamFrame(value: unknown): BrowserRpcStreamFrame | null { + if (!value || typeof value !== "object" || !("kind" in value)) return null; + const frame = value as BrowserRpcStreamFrame; + if (frame.kind === "MESSAGE") { + return validEncodedByteCount(frame.encodedBytes, Number.MAX_SAFE_INTEGER) + ? frame + : null; + } + if (frame.kind !== "TERMINAL" || typeof frame.ok !== "boolean") return null; + return frame.ok || validTransportFailure(frame.failure) ? frame : null; +} + +function validTransportFailure( + failure: unknown, +): failure is BrowserRpcTransportFailure { + if (!failure || typeof failure !== "object" || !("code" in failure)) { + return false; + } + const candidate = failure as BrowserRpcTransportFailure; + return ( + TRANSPORT_FAILURE_CODES.has(candidate.code) && + (candidate.retryAfterMs === undefined || + (Number.isSafeInteger(candidate.retryAfterMs) && + candidate.retryAfterMs >= 0 && + candidate.retryAfterMs <= BROWSER_RPC_HARD_LIMITS.maxRetryAfterMs)) + ); +} + +const TRANSPORT_FAILURE_CODES = + new Set([ + "NETWORK_UNREACHABLE", + "CANCELED", + "DEADLINE_EXCEEDED", + "UNAUTHENTICATED", + "PERMISSION_DENIED", + "NOT_FOUND", + "ALREADY_EXISTS", + "ABORTED", + "FAILED_PRECONDITION", + "INVALID_ARGUMENT", + "RESOURCE_EXHAUSTED", + "UNAVAILABLE", + "UNIMPLEMENTED", + "INTERNAL", + "DATA_LOSS", + "PROTOCOL_MISMATCH", + "MESSAGE_LIMIT", + ]); + +function shouldRetry( + operation: BrowserRpcOperationV3, + profile: BrowserRpcProviderProfile, + failure: BrowserRpcTransportFailure, + attempt: number, + idempotencyKey: string | undefined, +): boolean { + return ( + profile.retryOwner === "FRONTEND_ADAPTER" && + attempt + 1 < profile.maxAttempts && + profile.retryableFailures.includes(failure.code) && + (["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy) || + (operation.replayPolicy === "KEYED_COMMAND" && + validIdempotencyKey(idempotencyKey))) + ); +} + +function retryDelay( + profile: BrowserRpcProviderProfile, + failure: BrowserRpcTransportFailure, + attempt: number, +): number { + const backoff = profile.backoffMs[attempt] ?? 0; + const retryAfter = Math.min( + failure.retryAfterMs ?? 0, + profile.maxRetryAfterMs, + ); + return Math.max(backoff, retryAfter); +} + +function mapTransportFailure( + operation: BrowserRpcOperationV3, + attempt: number, + failure: BrowserRpcTransportFailure, +): AppFailure { + const kind: FailureKind = + failure.code === "NETWORK_UNREACHABLE" + ? "NETWORK_UNREACHABLE" + : failure.code === "CANCELED" + ? "REQUEST_ABORTED" + : failure.code === "DEADLINE_EXCEEDED" + ? "REQUEST_TIMEOUT" + : failure.code === "UNAUTHENTICATED" + ? "AUTH_REQUIRED" + : failure.code === "PERMISSION_DENIED" + ? "FORBIDDEN" + : failure.code === "NOT_FOUND" + ? "NOT_FOUND" + : ["ALREADY_EXISTS", "ABORTED"].includes(failure.code) + ? "CONFLICT" + : ["FAILED_PRECONDITION", "INVALID_ARGUMENT"].includes( + failure.code, + ) + ? "VALIDATION_REJECTED" + : failure.code === "RESOURCE_EXHAUSTED" + ? "RATE_LIMITED" + : failure.code === "MESSAGE_LIMIT" + ? "RESPONSE_BODY_LIMIT" + : ["PROTOCOL_MISMATCH", "UNIMPLEMENTED"].includes( + failure.code, + ) + ? "API_CONTRACT_MISMATCH" + : "SERVER_FAILURE"; + return createFailure(kind, operation.operationId, attempt, { + code: `RPC_${failure.code}`, + ...(failure.retryAfterMs !== undefined + ? { retryAfterMs: failure.retryAfterMs } + : {}), + }); +} + +function protocolFailure( + operation: BrowserRpcOperationV3, + attempt: number, +): AppFailure { + return callFailure( + operation, + attempt, + "API_CONTRACT_MISMATCH", + "RPC_PROTOCOL_MISMATCH", + ); +} + +function callFailure( + operation: BrowserRpcOperationV3, + attempt: number, + kind: FailureKind, + code: string, +): AppFailure { + return createFailure(kind, operation.operationId, attempt, { + code: safeCode(code), + }); +} + +function safeCode(value: string): string { + return /^[A-Z][A-Z0-9_]{2,79}$/.test(value) + ? value + : "RPC_ADAPTER_REJECTED"; +} + +function failureResult( + error: AppFailure, +): Readonly<{ ok: false; error: AppFailure }> { + return Object.freeze({ ok: false, error }); +} + +function validEncodedByteCount(value: number, maximum: number): boolean { + return ( + Number.isSafeInteger(value) && + value >= 0 && + value <= maximum + ); +} + +function linkedAbortController(external?: AbortSignal): Readonly<{ + controller: AbortController; + cleanup(): void; +}> { + const controller = new AbortController(); + const onAbort = () => controller.abort(external?.reason); + external?.addEventListener("abort", onAbort, { once: true }); + if (external?.aborted) onAbort(); + return Object.freeze({ + controller, + cleanup() { + external?.removeEventListener("abort", onAbort); + }, + }); +} + +async function raceWithin( + work: Promise, + milliseconds: number, + signal: AbortSignal, + clock: ClockPort, +): Promise> { + if (signal.aborted) return Object.freeze({ kind: "ABORTED" }); + const timerController = new AbortController(); + const onAbort = () => timerController.abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + const workResult = work.then, TimedResult>( + (value) => Object.freeze({ kind: "VALUE", value }), + () => Object.freeze({ kind: "THREW" }), + ); + const timerResult = clock.sleep(milliseconds, timerController.signal).then< + TimedResult, + TimedResult + >( + () => Object.freeze({ kind: "TIMEOUT" }), + () => Object.freeze({ kind: "ABORTED" }), + ); + const selected = await Promise.race([workResult, timerResult]); + timerController.abort("race-complete"); + signal.removeEventListener("abort", onAbort); + return signal.aborted && selected.kind === "VALUE" + ? Object.freeze({ kind: "ABORTED" }) + : selected; +} + +function observe( + dependencies: BrowserRpcRuntimeDependencies, + operation: BrowserRpcOperationV3, + outcome: BrowserRpcObservationOutcome, + attemptCount: number, + messageCount: number, +): void { + try { + dependencies.observations?.observe( + Object.freeze({ + operationId: operation.operationId, + protocol: operation.protocol, + runtimeProfileId: operation.runtimeProfileId, + rpcKind: operation.rpcKind, + outcome, + attemptCount: Math.max(1, attemptCount), + messageCount: Math.max(0, messageCount), + }), + ); + } catch { + // Observation cannot change the selected application result. + } +} diff --git a/src/adapters/browser-rpc/index.ts b/src/adapters/browser-rpc/index.ts new file mode 100644 index 0000000..282b398 --- /dev/null +++ b/src/adapters/browser-rpc/index.ts @@ -0,0 +1,17 @@ +export { + createBrowserRpcRuntime, + type BrowserRpcObservation, + type BrowserRpcObservationOutcome, + type BrowserRpcObservationSink, + type BrowserRpcRuntime, + type BrowserRpcRuntimeDependencies, +} from "./browser-rpc-runtime.ts"; +export { + defineBrowserRpcTransport, + type BrowserRpcStreamFrame, + type BrowserRpcTransport, + type BrowserRpcTransportCall, + type BrowserRpcTransportFailure, + type BrowserRpcUnaryTransportResult, +} from "./transport.ts"; +export { createUnavailableBrowserRpcTransport } from "./unavailable-browser-rpc-transport.ts"; diff --git a/src/adapters/browser-rpc/transport.ts b/src/adapters/browser-rpc/transport.ts new file mode 100644 index 0000000..a1130bd --- /dev/null +++ b/src/adapters/browser-rpc/transport.ts @@ -0,0 +1,89 @@ +import type { + BrowserRpcKind, + BrowserRpcOperationV3, + BrowserRpcProtocol, + BrowserRpcProviderProfile, + BrowserRpcRuntimeBindingIdentity, + BrowserRpcTransportFailureCode, +} from "../../contracts/browser-rpc.ts"; + +export type BrowserRpcTransportFailure = Readonly<{ + code: BrowserRpcTransportFailureCode; + retryAfterMs?: number; +}>; + +export type BrowserRpcTransportCall = Readonly<{ + operation: BrowserRpcOperationV3; + profile: BrowserRpcProviderProfile; + request: unknown; + encodedRequestBytes: number; + attempt: number; + timeoutMs: number; + signal: AbortSignal; + idempotencyKey?: string; +}>; + +export type BrowserRpcUnaryTransportResult = + | Readonly<{ + ok: true; + message: unknown; + encodedBytes: number; + }> + | Readonly<{ + ok: false; + failure: BrowserRpcTransportFailure; + }>; + +export type BrowserRpcStreamFrame = + | Readonly<{ + kind: "MESSAGE"; + message: unknown; + encodedBytes: number; + }> + | Readonly<{ + kind: "TERMINAL"; + ok: true; + }> + | Readonly<{ + kind: "TERMINAL"; + ok: false; + failure: BrowserRpcTransportFailure; + }>; + +export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity & + Readonly<{ + invokeUnary?( + call: BrowserRpcTransportCall, + ): Promise; + openServerStream?( + call: BrowserRpcTransportCall, + ): AsyncIterable; + }>; + +export function defineBrowserRpcTransport( + transport: BrowserRpcTransport, +): BrowserRpcTransport { + if ( + !transport.runtimeProfileId || + !transport.providerId || + !isProtocol(transport.protocol) || + !isRpcKind(transport.rpcKind) || + (transport.rpcKind === "UNARY" && + (typeof transport.invokeUnary !== "function" || + transport.openServerStream !== undefined)) || + (transport.rpcKind === "SERVER_STREAM" && + (typeof transport.openServerStream !== "function" || + transport.invokeUnary !== undefined)) + ) { + throw new TypeError("Browser RPC transport is invalid."); + } + return Object.freeze({ ...transport }); +} + +function isProtocol(value: string): value is BrowserRpcProtocol { + return value === "CONNECT_HTTP" || value === "GRPC_WEB"; +} + +function isRpcKind(value: string): value is BrowserRpcKind { + return value === "UNARY" || value === "SERVER_STREAM"; +} diff --git a/src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts b/src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts new file mode 100644 index 0000000..cff96d8 --- /dev/null +++ b/src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts @@ -0,0 +1,42 @@ +import type { + BrowserRpcKind, + BrowserRpcProtocol, +} from "../../contracts/browser-rpc.ts"; +import { + defineBrowserRpcTransport, + type BrowserRpcTransport, +} from "./transport.ts"; + +/** + * Explicit fail-closed adapter for an optional Browser RPC profile that has + * not been connected to a generated client/provider. It never performs + * network I/O and cannot silently fall back to REST. + */ +export function createUnavailableBrowserRpcTransport(input: Readonly<{ + runtimeProfileId: string; + providerId: string; + protocol: BrowserRpcProtocol; + rpcKind: BrowserRpcKind; +}>): BrowserRpcTransport { + if (input.rpcKind === "UNARY") { + return defineBrowserRpcTransport({ + ...input, + async invokeUnary() { + return Object.freeze({ + ok: false, + failure: Object.freeze({ code: "UNAVAILABLE" }), + }); + }, + }); + } + return defineBrowserRpcTransport({ + ...input, + async *openServerStream() { + yield Object.freeze({ + kind: "TERMINAL", + ok: false, + failure: Object.freeze({ code: "UNAVAILABLE" }), + }); + }, + }); +} diff --git a/src/adapters/browser-transfer/image-cdn/README.md b/src/adapters/browser-transfer/image-cdn/README.md new file mode 100644 index 0000000..fcb38ef --- /dev/null +++ b/src/adapters/browser-transfer/image-cdn/README.md @@ -0,0 +1,167 @@ +# Image CDN composition + +This adapter accepts no source URL or transform query from a feature. Product +composition owns the origin registry and named presets. A backend gateway may +use `runtime.assets`; presentation receives only the narrow +`runtime.presentation` facade plus registry-issued asset and preset +references. + +```ts +import { + ImageCdnPolicyRegistry, + createBrowserImageProbe, + createImageCdnRuntime, + createP256ImageCapabilityVerifier, + imageCdnPresetReference, +} from "./index.ts"; + +const cardImage = imageCdnPresetReference( + "product-card", + "render-product-card-image", +); + +const policies = new ImageCdnPolicyRegistry({ + applicationOrigin: "https://app.example.com", + origins: [{ + originKey: "product-images", + origin: "https://images.example.com", + assetPathPrefix: "/v1/assets/", + minimumPublicMaxAgeSeconds: 31_536_000, + }], + presets: [{ + reference: cardImage, + bindingId: "product-card-v1", + width: 640, + height: 360, + fit: "cover", + dprs: [1, 2], + responsiveWidths: [320, 640], + quality: 80, + formats: ["avif", "webp", "jpeg"], + sizes: "(max-width: 640px) 100vw, 640px", + loading: "eager", + decoding: "async", + fetchPriority: "high", + referrerPolicy: "no-referrer", + probeMode: "PRIMARY_REQUIRED", + allowUpscale: false, + maxTransformedPixels: 1_048_576, + maxDecodedBytes: 4_194_304, + maxEncodedBytes: 524_288, + }], + hardLimits: { + maxIntrinsicWidth: 4_096, + maxIntrinsicHeight: 4_096, + maxSourcePixels: 16_777_216, + maxCssDimension: 2_048, + maxDpr: 2, + maxQuality: 90, + maxCandidateCount: 8, + maxTransformedPixels: 1_048_576, + maxDecodedBytes: 4_194_304, + maxEncodedBytes: 524_288, + maxUrlLength: 2_048, + maxCapabilityLifetimeMs: 3_600_000, + maxClockSkewMs: 60_000, + minCapabilityRemainingMs: 30_000, + maxPresetBindingsPerCapability: 8, + maxConcurrentCapabilityVerifications: 8, + allowedSourceMediaTypes: [ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", + ], + formatQualityCeilings: { + avif: 80, + jpeg: 85, + png: 90, + webp: 85, + }, + }, + capability: { + issuer: "image-bff", + acceptedKeyIds: [ + "image-signing-2026-02", + "image-signing-2026-01", + ], + }, +}); + +const runtime = createImageCdnRuntime({ + policies, + subtle: crypto.subtle, + capabilityVerifier: createP256ImageCapabilityVerifier({ + subtle: crypto.subtle, + publicKeys: [{ + keyId: "image-signing-2026-02", + key: currentImageCapabilityPublicKey, + }, { + keyId: "image-signing-2026-01", + key: previousImageCapabilityPublicKey, + }], + }), + capabilityVerificationTimeoutMs: 5_000, + probe: createBrowserImageProbe(), + observer: safeBrowserDataObserver, +}); + +// `payload` is a strictly decoded BackendIssuedImageAsset from the BFF. +const accepted = await runtime.assets.acceptBackendIssued(payload, { + signal, +}); +if (!accepted.ok) return accepted; + +// Expose only this closure to the feature/presentation composition. +const resolveCardImage = (signal: AbortSignal) => + runtime.presentation.resolve({ + asset: accepted.value, + preset: cardImage, + signal, + }); + +// Application-scope teardown, logout, account/tenant partition change, or +// replacement by a newly composed runtime. Never call this per render. +const closeImageRuntime = (): void => runtime.close(); +``` + +For a public immutable asset, the trusted gateway calls +`acceptPublicImmutable` with only an allowlisted `originKey`, opaque `assetId` +and `revision`, raster metadata and intrinsic dimensions. `applicationOrigin` +must be the deployment's canonical HTTPS origin, without a trailing slash, +and every CDN origin must differ from it. This is required because an +`anonymous` image request omits credentials only when it is cross-origin. +Private descriptors must be backend-signed, remain above the configured +minimum TTL at every resolve, and use an eager, non-low-priority +`PRIMARY_REQUIRED` preset. Digest and signature verification share one +composition-owned deadline and race the caller's abort signal. The probe sends +no credentials, rejects any final URL other than the exact signed URL, and +requires the private response to declare the flag-only directive +`Cache-Control: no-store`. +Before native decode, the adapter parses the bounded PNG, JPEG, WebP or AVIF +container, rejects animation and enforces both pixel and decoded-byte budgets. +Its adapter-owned timeout covers response headers, streamed body consumption +and decode; abort paths cancel the reader and close even a late ImageBitmap. +The client never purges a CDN: public URLs roll forward by revision, while +private delivery relies on backend capability revocation or expiry. + +Composition-supplied hard limits may only tighten +`IMAGE_CDN_IMPLEMENTATION_CEILINGS`; configuration cannot raise intrinsic, +source/output pixel, decoded/encoded byte, candidate, URL or capability +lifetime ceilings owned by the adapter. Private verification additionally +reserves one of the bounded `maxConcurrentCapabilityVerifications` slots and +always releases it after success, failure, abort or close. + +`acceptedKeyIds` is a bounded, unique overlap set, not the active signing-key +selector. The verifier registry must cover every accepted ID. Rotate by first +deploying the new public key and an old/new overlap set, then switch the +backend signer. Retain the old key for client rollout plus at least +`maxCapabilityLifetimeMs + maxClockSkewMs`; remove it only after old clients +and capabilities are exhausted. A compromised key instead requires backend +revocation, `runtime.close()`, recomposition and a forced client rollout. + +`close()` is terminal and idempotent. It aborts in-flight private verification +and probing and replaces the runtime's WeakMap capability registry, immediately +revoking every issued reference without retaining them strongly. Every later +accept or resolve returns closed `UNAVAILABLE`; resuming requires a newly +composed runtime. diff --git a/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts b/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts new file mode 100644 index 0000000..490afc7 --- /dev/null +++ b/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts @@ -0,0 +1,609 @@ +import type { + ImageProbeRequest, + ImageResourceProbePort, +} from "../../../application/ports/browser-transfer/image-cdn.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { parseStaticImageHeaderMetadata } from "./image-header-metadata.ts"; + +export type DecodedImageFacade = Readonly<{ + width: number; + height: number; + close(): void; +}>; + +export type ImageProbeScheduler = Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +export type BrowserImageProbeDependencies = Readonly<{ + fetcher?: typeof fetch; + createBitmap?: ( + image: Blob, + ) => Promise; + /** Covers fetch headers, streamed body consumption and native decode. */ + timeoutMs?: number; + scheduler?: ImageProbeScheduler; +}>; + +const DEFAULT_TIMEOUT_MS = 5_000; +const MAXIMUM_TIMEOUT_MS = 60_000; + +/** + * Performs one bounded real response/decode probe. It is intentionally a + * separate seam because probing every srcset candidate would defeat responsive + * image loading and consume the entire transfer budget up front. + */ +export function createBrowserImageProbe( + dependencies: BrowserImageProbeDependencies = {}, +): ImageResourceProbePort { + const fetcher = (dependencies.fetcher ?? fetch).bind(globalThis); + const createBitmap = + dependencies.createBitmap ?? + (typeof createImageBitmap === "function" + ? async (image: Blob) => createImageBitmap(image) + : undefined); + const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const scheduler = snapshotScheduler( + dependencies.scheduler ?? defaultScheduler(), + ); + if ( + !positiveSafeInteger(timeoutMs) || + timeoutMs > MAXIMUM_TIMEOUT_MS + ) { + throw new TypeError("Image probe timeout is invalid."); + } + + return Object.freeze({ + async probe(request: ImageProbeRequest) { + if (request.signal.aborted) { + return browserDataFailure("ABORTED", "IMAGE_RESOLVE"); + } + let url: URL; + try { + url = new URL(request.absoluteUrl); + } catch { + return browserDataFailure("INVALID_INPUT", "IMAGE_RESOLVE"); + } + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + url.hash !== "" || + !positiveSafeInteger(request.expectedWidth) || + !positiveSafeInteger(request.expectedHeight) || + !positiveSafeInteger(request.maxEncodedBytes) || + !positiveSafeInteger(request.maxDecodedPixels) || + !positiveSafeInteger(request.maxDecodedBytes) || + !withinDecodeBudget( + request.expectedWidth, + request.expectedHeight, + request.maxDecodedPixels, + request.maxDecodedBytes, + ) || + !isRasterMediaType(request.expectedMediaType) || + request.referrerPolicy !== "no-referrer" && + request.referrerPolicy !== + "strict-origin-when-cross-origin" + ) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + if (!createBitmap) { + return browserDataFailure("UNSUPPORTED", "IMAGE_RESOLVE"); + } + + const scope = createProbeAbortScope( + request.signal, + timeoutMs, + scheduler, + ); + let response: Response | undefined; + try { + try { + const fetchTask = Promise.resolve( + fetcher(url.href, { + method: "GET", + cache: "no-store", + credentials: "omit", + mode: "cors", + redirect: "error", + referrerPolicy: request.referrerPolicy, + signal: scope.signal, + }), + ); + response = await awaitWithAbort( + fetchTask, + scope.signal, + (lateResponse) => { + cancelResponseBody(lateResponse); + }, + ); + } catch { + return signalFailure(request.signal, scope); + } + if ( + response.status !== 200 || + !response.ok || + response.redirected || + ["error", "opaque", "opaqueredirect"].includes( + response.type, + ) || + response.url !== url.href || + !validResponseHeaders(response, request) + ) { + cancelResponseBody(response); + return browserDataFailure( + "POLICY_REJECTED", + "IMAGE_RESOLVE", + ); + } + + let bytes: Uint8Array; + try { + bytes = await readBoundedBody( + response, + request.maxEncodedBytes, + scope.signal, + ); + } catch (error) { + if (request.signal.aborted || scope.timedOut()) { + return signalFailure(request.signal, scope); + } + return error instanceof EncodedBodyLimitError + ? browserDataFailure( + "LIMIT_EXCEEDED", + "IMAGE_RESOLVE", + ) + : browserDataFailure( + "UNAVAILABLE", + "IMAGE_RESOLVE", + { + retryable: true, + recovery: "RETRY", + }, + ); + } + const declaredLength = response.headers.get( + "content-length", + ); + if ( + declaredLength !== null && + Number(declaredLength) !== bytes.byteLength + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } + + const metadata = parseStaticImageHeaderMetadata( + bytes, + request.expectedMediaType, + ); + if (!metadata) { + return browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } + if ( + !withinDecodeBudget( + metadata.width, + metadata.height, + request.maxDecodedPixels, + request.maxDecodedBytes, + ) + ) { + return browserDataFailure( + "LIMIT_EXCEEDED", + "IMAGE_RESOLVE", + ); + } + if ( + metadata.width !== request.expectedWidth || + metadata.height !== request.expectedHeight + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } + + let bitmap: DecodedImageFacade | undefined; + try { + const blobBytes = new Uint8Array(bytes.byteLength); + blobBytes.set(bytes); + const decodeTask = createBitmap( + new Blob([blobBytes.buffer], { + type: request.expectedMediaType, + }), + ); + bitmap = await awaitWithAbort( + decodeTask, + scope.signal, + closeBitmap, + ); + if ( + !positiveSafeInteger(bitmap.width) || + !positiveSafeInteger(bitmap.height) || + bitmap.width !== metadata.width || + bitmap.height !== metadata.height || + !withinDecodeBudget( + bitmap.width, + bitmap.height, + request.maxDecodedPixels, + request.maxDecodedBytes, + ) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } + return browserDataSuccess( + Object.freeze({ + absoluteUrl: url.href, + mediaType: request.expectedMediaType, + encodedBytes: bytes.byteLength, + decodedWidth: bitmap.width, + decodedHeight: bitmap.height, + }), + ); + } catch { + return request.signal.aborted || scope.timedOut() + ? signalFailure(request.signal, scope) + : browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } finally { + if (bitmap) closeBitmap(bitmap); + } + } finally { + scope.release(); + } + }, + }); +} + +function validResponseHeaders( + response: Response, + request: ImageProbeRequest, +): boolean { + const rawContentType = + response.headers.get("content-type")?.trim().toLowerCase(); + if ( + rawContentType !== request.expectedMediaType || + response.headers.has("set-cookie") || + response.headers.has("set-cookie2") + ) { + return false; + } + const rawLength = response.headers.get("content-length"); + const contentEncoding = response.headers.get("content-encoding"); + if ( + (contentEncoding !== null && + contentEncoding.trim().toLowerCase() !== "identity") || + rawLength !== null && + (!/^(?:0|[1-9]\d*)$/u.test(rawLength) || + Number(rawLength) > request.maxEncodedBytes) + ) { + return false; + } + const vary = response.headers.get("vary"); + if ( + vary && + vary + .split(",") + .map((name) => name.trim().toLowerCase()) + .some((name) => + ["*", "authorization", "cookie"].includes(name), + ) + ) { + return false; + } + const directives = parseCacheControl( + response.headers.get("cache-control"), + ); + if (!directives) return false; + if (request.delivery === "PRIVATE_SIGNED") { + return ( + directives.get("no-store") === true && + !directives.has("public") + ); + } + const maxAge = directives.get("max-age"); + const sharedMaxAge = directives.get("s-maxage"); + return ( + directives.get("public") === true && + directives.get("immutable") === true && + !directives.has("private") && + !directives.has("no-cache") && + !directives.has("no-store") && + !directives.has("must-revalidate") && + !directives.has("proxy-revalidate") && + typeof maxAge === "string" && + /^(?:0|[1-9]\d*)$/u.test(maxAge) && + Number(maxAge) >= request.minimumPublicMaxAgeSeconds && + (sharedMaxAge === undefined || + (typeof sharedMaxAge === "string" && + /^(?:0|[1-9]\d*)$/u.test(sharedMaxAge) && + Number(sharedMaxAge) >= + request.minimumPublicMaxAgeSeconds)) + ); +} + +function parseCacheControl( + value: string | null, +): ReadonlyMap | null { + const flagDirectives = new Set([ + "immutable", + "must-revalidate", + "no-store", + "private", + "proxy-revalidate", + "public", + ]); + const directives = new Map(); + for (const part of value?.split(",") ?? []) { + const trimmedPart = part.trim(); + const separator = trimmedPart.indexOf("="); + const name = ( + separator < 0 + ? trimmedPart + : trimmedPart.slice(0, separator) + ) + .trim() + .toLowerCase(); + if (!name) continue; + if (directives.has(name)) return null; + if (separator < 0) { + directives.set(name, true); + continue; + } + if (flagDirectives.has(name)) return null; + const rawValue = trimmedPart.slice(separator + 1).trim(); + if (rawValue === "") return null; + directives.set(name, rawValue.replace(/^"|"$/gu, "")); + } + return directives; +} + +class EncodedBodyLimitError extends Error {} + +async function readBoundedBody( + response: Response, + maximumBytes: number, + signal: AbortSignal, +): Promise { + if (!response.body) { + throw new TypeError("Image response body is unavailable."); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + if (signal.aborted) throw abortException(); + const next = await awaitWithAbort( + reader.read(), + signal, + () => undefined, + ); + if (next.done) break; + if (!(next.value instanceof Uint8Array)) { + throw new TypeError("Image response chunk is invalid."); + } + total += next.value.byteLength; + if (total > maximumBytes) { + throw new EncodedBodyLimitError(); + } + chunks.push(Uint8Array.from(next.value)); + } + } catch (error) { + cancelReader(reader); + throw error; + } finally { + try { + reader.releaseLock(); + } catch { + // The closed result remains authoritative if a host stream is broken. + } + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +type ProbeAbortScope = Readonly<{ + signal: AbortSignal; + timedOut(): boolean; + release(): void; +}>; + +function createProbeAbortScope( + externalSignal: AbortSignal, + timeoutMs: number, + scheduler: ImageProbeScheduler, +): ProbeAbortScope { + const controller = new AbortController(); + let timeoutReached = false; + let released = false; + const onExternalAbort = () => { + controller.abort(externalSignal.reason); + }; + externalSignal.addEventListener("abort", onExternalAbort, { + once: true, + }); + if (externalSignal.aborted) onExternalAbort(); + const timeoutHandle = scheduler.setTimeout(() => { + if (released) return; + timeoutReached = true; + controller.abort(abortException()); + }, timeoutMs); + + return Object.freeze({ + signal: controller.signal, + timedOut: () => timeoutReached, + release() { + if (released) return; + released = true; + try { + scheduler.clearTimeout(timeoutHandle); + } catch { + // A broken optional scheduler cannot change a terminal probe result. + } + externalSignal.removeEventListener("abort", onExternalAbort); + }, + }); +} + +function awaitWithAbort( + task: Promise, + signal: AbortSignal, + onLateValue: (value: Value) => void, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const onAbort = () => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + reject(abortException()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + void task.then( + (value) => { + if (settled) { + onLateValue(value); + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function signalFailure( + externalSignal: AbortSignal, + scope: ProbeAbortScope, +) { + return externalSignal.aborted + ? browserDataFailure("ABORTED", "IMAGE_RESOLVE") + : scope.timedOut() + ? browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }) + : browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }); +} + +function cancelResponseBody(response: Response): void { + try { + const cancellation = response.body?.cancel(); + void cancellation?.catch(() => undefined); + } catch { + // Best-effort release cannot change the closed probe result. + } +} + +function cancelReader( + reader: ReadableStreamDefaultReader, +): void { + try { + void reader.cancel().catch(() => undefined); + } catch { + // Best-effort release cannot change the closed probe result. + } +} + +function closeBitmap(bitmap: DecodedImageFacade): void { + try { + bitmap.close(); + } catch { + // Decode correctness is independent from best-effort native release. + } +} + +function abortException(): DOMException { + return new DOMException("Image probe was aborted.", "AbortError"); +} + +function withinDecodeBudget( + width: number, + height: number, + maximumPixels: number, + maximumBytes: number, +): boolean { + const pixels = width * height; + const decodedBytes = pixels * 4; + return ( + Number.isSafeInteger(pixels) && + Number.isSafeInteger(decodedBytes) && + pixels <= maximumPixels && + decodedBytes <= maximumBytes + ); +} + +function snapshotScheduler( + scheduler: ImageProbeScheduler, +): ImageProbeScheduler { + if ( + !scheduler || + typeof scheduler.setTimeout !== "function" || + typeof scheduler.clearTimeout !== "function" + ) { + throw new TypeError("Image probe scheduler is invalid."); + } + return Object.freeze({ + setTimeout: scheduler.setTimeout.bind(scheduler), + clearTimeout: scheduler.clearTimeout.bind(scheduler), + }); +} + +function defaultScheduler(): ImageProbeScheduler { + return Object.freeze({ + setTimeout(callback: () => void, milliseconds: number) { + return globalThis.setTimeout(callback, milliseconds); + }, + clearTimeout(handle: unknown) { + globalThis.clearTimeout( + handle as ReturnType, + ); + }, + }); +} + +function isRasterMediaType( + value: string, +): value is ImageProbeRequest["expectedMediaType"] { + return [ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", + ].includes(value); +} + +function positiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} diff --git a/src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts b/src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts new file mode 100644 index 0000000..17e7853 --- /dev/null +++ b/src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts @@ -0,0 +1,753 @@ +import type { + ImageFit, + ImageOutputFormat, + ImagePresetReference, + ImageRasterMediaType, +} from "../../../application/ports/browser-transfer/image-cdn.ts"; + +export type ImageCdnOriginPolicy = Readonly<{ + originKey: string; + origin: string; + assetPathPrefix: string; + minimumPublicMaxAgeSeconds: number; +}>; + +export type ImageCdnPresetPolicy = Readonly<{ + reference: ImagePresetReference; + bindingId: string; + width: number; + height: number; + fit: ImageFit; + dprs: readonly number[]; + responsiveWidths: readonly number[]; + quality: number; + formats: readonly ImageOutputFormat[]; + sizes: string; + loading: "eager" | "lazy"; + decoding: "async" | "sync"; + fetchPriority: "high" | "low" | "auto"; + referrerPolicy: "no-referrer" | "strict-origin-when-cross-origin"; + probeMode: "NONE" | "PRIMARY_REQUIRED"; + allowUpscale: boolean; + maxTransformedPixels: number; + maxDecodedBytes: number; + maxEncodedBytes: number; +}>; + +export type ImageCdnHardLimits = Readonly<{ + maxIntrinsicWidth: number; + maxIntrinsicHeight: number; + maxSourcePixels: number; + maxCssDimension: number; + maxDpr: number; + maxQuality: number; + maxCandidateCount: number; + maxTransformedPixels: number; + maxDecodedBytes: number; + maxEncodedBytes: number; + maxUrlLength: number; + maxCapabilityLifetimeMs: number; + maxClockSkewMs: number; + minCapabilityRemainingMs: number; + maxPresetBindingsPerCapability: number; + maxConcurrentCapabilityVerifications: number; + allowedSourceMediaTypes: readonly ImageRasterMediaType[]; + formatQualityCeilings: Readonly< + Partial> + >; +}>; + +export type ImageCdnCapabilityPolicy = Readonly<{ + issuer: string; + acceptedKeyIds: readonly string[]; +}>; + +export type ImageCdnPolicyRegistryOptions = Readonly<{ + applicationOrigin: string; + origins: readonly ImageCdnOriginPolicy[]; + presets: readonly ImageCdnPresetPolicy[]; + hardLimits: ImageCdnHardLimits; + capability: ImageCdnCapabilityPolicy; +}>; + +export type ResolvedImageCdnOrigin = Readonly<{ + originKey: string; + origin: string; + assetPathPrefix: string; + minimumPublicMaxAgeSeconds: number; +}>; + +export type ImageCandidateGeometry = Readonly<{ + cssWidth: number; + cssHeight: number; + dpr: number; + pixelWidth: number; + pixelHeight: number; + pixels: number; + decodedBytes: number; +}>; + +export type ResolvedImageCdnPreset = Omit< + ImageCdnPresetPolicy, + "dprs" | "responsiveWidths" | "formats" +> & + Readonly<{ + dprs: readonly number[]; + responsiveWidths: readonly number[]; + formats: readonly ImageOutputFormat[]; + candidates: readonly ImageCandidateGeometry[]; + }>; + +const POLICY_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const PATH_PREFIX = /^\/[A-Za-z0-9/_-]{1,200}\/$/u; +const SAFE_SIZES = /^[^<>"']{1,512}$/u; +const IMAGE_FORMATS = Object.freeze([ + "avif", + "jpeg", + "png", + "webp", +] as const); +const IMAGE_MEDIA_TYPES = Object.freeze([ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", +] as const); +const IMAGE_FITS = Object.freeze([ + "contain", + "cover", + "fill", + "inside", + "outside", +] as const); +const ISSUED_PRESET_REFERENCES = new WeakSet(); + +export const IMAGE_CDN_IMPLEMENTATION_CEILINGS = Object.freeze({ + maxIntrinsicWidth: 16_384, + maxIntrinsicHeight: 16_384, + maxSourcePixels: 67_108_864, + maxCssDimension: 8_192, + maxDpr: 4, + maxQuality: 100, + maxCandidateCount: 32, + maxTransformedPixels: 16_777_216, + maxDecodedBytes: 67_108_864, + maxEncodedBytes: 16_777_216, + maxUrlLength: 8_192, + maxCapabilityLifetimeMs: 86_400_000, + maxClockSkewMs: 300_000, + maxMinimumCapabilityRemainingMs: 3_600_000, + maxPresetBindingsPerCapability: 32, + maxConcurrentCapabilityVerifications: 32, + maxAcceptedKeyIds: 8, +} as const); + +const REGISTRY_KEYS = Object.freeze([ + "applicationOrigin", + "origins", + "presets", + "hardLimits", + "capability", +] as const); +const ORIGIN_KEYS = Object.freeze([ + "originKey", + "origin", + "assetPathPrefix", + "minimumPublicMaxAgeSeconds", +] as const); +const HARD_LIMIT_KEYS = Object.freeze([ + "maxIntrinsicWidth", + "maxIntrinsicHeight", + "maxSourcePixels", + "maxCssDimension", + "maxDpr", + "maxQuality", + "maxCandidateCount", + "maxTransformedPixels", + "maxDecodedBytes", + "maxEncodedBytes", + "maxUrlLength", + "maxCapabilityLifetimeMs", + "maxClockSkewMs", + "minCapabilityRemainingMs", + "maxPresetBindingsPerCapability", + "maxConcurrentCapabilityVerifications", + "allowedSourceMediaTypes", + "formatQualityCeilings", +] as const); +const CAPABILITY_KEYS = Object.freeze([ + "issuer", + "acceptedKeyIds", +] as const); +const PRESET_KEYS = Object.freeze([ + "reference", + "bindingId", + "width", + "height", + "fit", + "dprs", + "responsiveWidths", + "quality", + "formats", + "sizes", + "loading", + "decoding", + "fetchPriority", + "referrerPolicy", + "probeMode", + "allowUpscale", + "maxTransformedPixels", + "maxDecodedBytes", + "maxEncodedBytes", +] as const); + +export const IMAGE_FORMAT_MEDIA_TYPE: Readonly< + Record +> = Object.freeze({ + avif: "image/avif", + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", +}); + +/** + * The returned identity must be passed through a narrow feature facade. + * Constructing another reference with the same strings does not grant access. + */ +export function imageCdnPresetReference( + presetKey: string, + intention: string, +): ImagePresetReference { + if (!POLICY_TOKEN.test(presetKey) || !POLICY_TOKEN.test(intention)) { + throw new TypeError("Image CDN preset reference is invalid."); + } + const reference = Object.freeze({ + presetKey, + intention, + }) as ImagePresetReference; + ISSUED_PRESET_REFERENCES.add(reference); + return reference; +} + +/** + * Immutable composition-time policy registry. Every caller-owned collection + * is copied and all methods return snapshots rather than mutable registry + * state. + */ +export class ImageCdnPolicyRegistry { + readonly #origins: ReadonlyMap; + readonly #presets: + ReadonlyMap; + readonly #presetBindingIds: ReadonlySet; + readonly #hardLimits: ImageCdnHardLimits; + readonly #capability: ImageCdnCapabilityPolicy; + + constructor(options: ImageCdnPolicyRegistryOptions) { + if (!hasExactOwnKeys(options, REGISTRY_KEYS)) { + throw new TypeError("Image CDN policy registry is invalid."); + } + const applicationOrigin = snapshotApplicationOrigin( + options.applicationOrigin, + ); + this.#hardLimits = snapshotHardLimits(options.hardLimits); + this.#capability = snapshotCapabilityPolicy(options.capability); + if ( + !Array.isArray(options.origins) || + options.origins.length < 1 || + options.origins.length > 32 || + !Array.isArray(options.presets) || + options.presets.length < 1 || + options.presets.length > 128 + ) { + throw new TypeError("Image CDN policy registry is invalid."); + } + + const origins = new Map(); + const absoluteOrigins = new Set(); + for (const input of options.origins) { + const origin = snapshotOrigin(input); + if ( + origins.has(origin.originKey) || + absoluteOrigins.has(origin.origin) || + origin.origin === applicationOrigin + ) { + throw new TypeError( + "Image CDN origin policy must be unique and cross-origin.", + ); + } + origins.set(origin.originKey, origin); + absoluteOrigins.add(origin.origin); + } + + const presets = + new Map(); + const semanticReferences = new Set(); + const bindingIds = new Set(); + for (const input of options.presets) { + const preset = snapshotPreset(input, this.#hardLimits); + const semanticReference = + `${preset.reference.presetKey}:${preset.reference.intention}`; + if ( + semanticReferences.has(semanticReference) || + bindingIds.has(preset.bindingId) + ) { + throw new TypeError("Image CDN preset policy is duplicated."); + } + semanticReferences.add(semanticReference); + bindingIds.add(preset.bindingId); + presets.set(preset.reference, preset); + } + + this.#origins = origins; + this.#presets = presets; + this.#presetBindingIds = bindingIds; + } + + resolveOrigin(originKey: string): ResolvedImageCdnOrigin | null { + return this.#origins.get(originKey) ?? null; + } + + resolvePreset( + reference: ImagePresetReference, + ): ResolvedImageCdnPreset | null { + if ( + !reference || + typeof reference !== "object" || + !ISSUED_PRESET_REFERENCES.has(reference) + ) { + return null; + } + return this.#presets.get(reference) ?? null; + } + + hasPresetBinding(bindingId: string): boolean { + return this.#presetBindingIds.has(bindingId); + } + + hardLimits(): ImageCdnHardLimits { + return this.#hardLimits; + } + + capabilityPolicy(): ImageCdnCapabilityPolicy { + return this.#capability; + } +} + +export function buildImageCandidateGeometry( + input: Readonly<{ + width: number; + height: number; + responsiveWidths: readonly number[]; + dprs: readonly number[]; + }>, +): readonly ImageCandidateGeometry[] { + const candidates = new Map(); + for (const cssWidth of input.responsiveWidths) { + const cssHeight = Math.max( + 1, + Math.round((cssWidth * input.height) / input.width), + ); + for (const dpr of input.dprs) { + const pixelWidth = cssWidth * dpr; + const pixelHeight = cssHeight * dpr; + if ( + !Number.isSafeInteger(pixelWidth) || + !Number.isSafeInteger(pixelHeight) + ) { + throw new TypeError( + "Image CDN preset produces fractional pixels.", + ); + } + const existing = candidates.get(pixelWidth); + if (!existing || (dpr === 1 && existing.dpr !== 1)) { + const pixels = pixelWidth * pixelHeight; + candidates.set( + pixelWidth, + Object.freeze({ + cssWidth, + cssHeight, + dpr, + pixelWidth, + pixelHeight, + pixels, + decodedBytes: pixels * 4, + }), + ); + } + } + } + return Object.freeze( + [...candidates.values()].sort( + (left, right) => left.pixelWidth - right.pixelWidth, + ), + ); +} + +function snapshotApplicationOrigin(input: string): string { + if (typeof input !== "string") { + throw new TypeError( + "Image CDN application origin policy is invalid.", + ); + } + let parsed: URL; + try { + parsed = new URL(input); + } catch { + throw new TypeError( + "Image CDN application origin policy is invalid.", + ); + } + if ( + parsed.protocol !== "https:" || + parsed.username !== "" || + parsed.password !== "" || + parsed.pathname !== "/" || + parsed.search !== "" || + parsed.hash !== "" || + input !== parsed.origin + ) { + throw new TypeError( + "Image CDN application origin policy is invalid.", + ); + } + return parsed.origin; +} + +function snapshotOrigin( + input: ImageCdnOriginPolicy, +): ResolvedImageCdnOrigin { + if (!hasExactOwnKeys(input, ORIGIN_KEYS)) { + throw new TypeError("Image CDN origin policy is invalid."); + } + let parsed: URL; + try { + parsed = new URL(input.origin); + } catch { + throw new TypeError("Image CDN origin policy is invalid."); + } + if ( + !POLICY_TOKEN.test(input.originKey) || + parsed.protocol !== "https:" || + parsed.username !== "" || + parsed.password !== "" || + parsed.pathname !== "/" || + parsed.search !== "" || + parsed.hash !== "" || + !PATH_PREFIX.test(input.assetPathPrefix) || + input.assetPathPrefix.includes("//") || + input.assetPathPrefix.includes("/../") || + input.assetPathPrefix.includes("/./") || + !positiveSafeInteger(input.minimumPublicMaxAgeSeconds) || + input.minimumPublicMaxAgeSeconds > 315_360_000 + ) { + throw new TypeError("Image CDN origin policy is invalid."); + } + return Object.freeze({ + originKey: input.originKey, + origin: parsed.origin, + assetPathPrefix: input.assetPathPrefix, + minimumPublicMaxAgeSeconds: + input.minimumPublicMaxAgeSeconds, + }); +} + +function snapshotPreset( + input: ImageCdnPresetPolicy, + hardLimits: ImageCdnHardLimits, +): ResolvedImageCdnPreset { + if ( + !hasExactOwnKeys(input, PRESET_KEYS) || + !input.reference || + typeof input.reference !== "object" || + !ISSUED_PRESET_REFERENCES.has(input.reference) || + !POLICY_TOKEN.test(input.bindingId) || + !positiveSafeInteger(input.width) || + input.width > hardLimits.maxCssDimension || + !positiveSafeInteger(input.height) || + input.height > hardLimits.maxCssDimension || + !IMAGE_FITS.includes(input.fit) || + !Array.isArray(input.dprs) || + input.dprs.length < 1 || + !Array.isArray(input.responsiveWidths) || + input.responsiveWidths.length < 1 || + !Array.isArray(input.formats) || + input.formats.length < 1 || + input.formats.length > IMAGE_FORMATS.length || + !positiveSafeInteger(input.quality) || + input.quality > hardLimits.maxQuality || + !SAFE_SIZES.test(input.sizes) || + hasControlCharacters(input.sizes) || + !["eager", "lazy"].includes(input.loading) || + !["async", "sync"].includes(input.decoding) || + !["high", "low", "auto"].includes(input.fetchPriority) || + ![ + "no-referrer", + "strict-origin-when-cross-origin", + ].includes(input.referrerPolicy) || + !["NONE", "PRIMARY_REQUIRED"].includes(input.probeMode) || + typeof input.allowUpscale !== "boolean" || + !positiveSafeInteger(input.maxTransformedPixels) || + input.maxTransformedPixels > hardLimits.maxTransformedPixels || + !positiveSafeInteger(input.maxDecodedBytes) || + input.maxDecodedBytes > hardLimits.maxDecodedBytes || + !positiveSafeInteger(input.maxEncodedBytes) || + input.maxEncodedBytes > hardLimits.maxEncodedBytes || + (input.fetchPriority === "high" && input.loading !== "eager") + ) { + throw new TypeError("Image CDN preset policy is invalid."); + } + + const dprs = sortedUniqueNumbers(input.dprs); + const responsiveWidths = + sortedUniqueNumbers(input.responsiveWidths); + const formats: ImageOutputFormat[] = [ + ...new Set(input.formats), + ]; + if ( + dprs.length !== input.dprs.length || + responsiveWidths.length > hardLimits.maxCandidateCount || + formats.length !== input.formats.length || + !dprs.includes(1) || + !responsiveWidths.includes(input.width) || + dprs.some( + (dpr) => + !positiveDpr(dpr) || + dpr > hardLimits.maxDpr, + ) || + responsiveWidths.some( + (width) => + !positiveSafeInteger(width) || + width > hardLimits.maxCssDimension, + ) || + formats.some( + (format) => + !IMAGE_FORMATS.includes(format) || + hardLimits.formatQualityCeilings[format] === undefined || + input.quality > + (hardLimits.formatQualityCeilings[format] ?? 0), + ) + ) { + throw new TypeError("Image CDN preset policy is invalid."); + } + + const candidates = buildImageCandidateGeometry({ + width: input.width, + height: input.height, + responsiveWidths, + dprs, + }); + if ( + candidates.length < 1 || + candidates.length > hardLimits.maxCandidateCount || + candidates.some( + (candidate) => + candidate.pixelWidth > + hardLimits.maxIntrinsicWidth || + candidate.pixelHeight > + hardLimits.maxIntrinsicHeight || + candidate.pixels > input.maxTransformedPixels || + candidate.decodedBytes > input.maxDecodedBytes, + ) + ) { + throw new TypeError("Image CDN preset exceeds its pixel budget."); + } + + return Object.freeze({ + reference: input.reference, + bindingId: input.bindingId, + width: input.width, + height: input.height, + fit: input.fit, + dprs: Object.freeze(dprs), + responsiveWidths: Object.freeze(responsiveWidths), + quality: input.quality, + formats: Object.freeze(formats), + sizes: input.sizes, + loading: input.loading, + decoding: input.decoding, + fetchPriority: input.fetchPriority, + referrerPolicy: input.referrerPolicy, + probeMode: input.probeMode, + allowUpscale: input.allowUpscale, + maxTransformedPixels: input.maxTransformedPixels, + maxDecodedBytes: input.maxDecodedBytes, + maxEncodedBytes: input.maxEncodedBytes, + candidates, + }); +} + +function snapshotHardLimits( + input: ImageCdnHardLimits, +): ImageCdnHardLimits { + const ceilings = IMAGE_CDN_IMPLEMENTATION_CEILINGS; + if ( + !hasExactOwnKeys(input, HARD_LIMIT_KEYS) || + !positiveSafeInteger(input.maxIntrinsicWidth) || + input.maxIntrinsicWidth > ceilings.maxIntrinsicWidth || + !positiveSafeInteger(input.maxIntrinsicHeight) || + input.maxIntrinsicHeight > ceilings.maxIntrinsicHeight || + !positiveSafeInteger(input.maxSourcePixels) || + input.maxSourcePixels > ceilings.maxSourcePixels || + !positiveSafeInteger(input.maxCssDimension) || + input.maxCssDimension > input.maxIntrinsicWidth || + input.maxCssDimension > ceilings.maxCssDimension || + !positiveDpr(input.maxDpr) || + input.maxDpr > ceilings.maxDpr || + !positiveSafeInteger(input.maxQuality) || + input.maxQuality > ceilings.maxQuality || + !positiveSafeInteger(input.maxCandidateCount) || + input.maxCandidateCount > ceilings.maxCandidateCount || + !positiveSafeInteger(input.maxTransformedPixels) || + input.maxTransformedPixels > ceilings.maxTransformedPixels || + !positiveSafeInteger(input.maxDecodedBytes) || + input.maxDecodedBytes > ceilings.maxDecodedBytes || + !positiveSafeInteger(input.maxEncodedBytes) || + input.maxEncodedBytes > ceilings.maxEncodedBytes || + !positiveSafeInteger(input.maxUrlLength) || + input.maxUrlLength > ceilings.maxUrlLength || + !positiveSafeInteger(input.maxCapabilityLifetimeMs) || + input.maxCapabilityLifetimeMs > + ceilings.maxCapabilityLifetimeMs || + !nonNegativeSafeInteger(input.maxClockSkewMs) || + input.maxClockSkewMs > ceilings.maxClockSkewMs || + !nonNegativeSafeInteger(input.minCapabilityRemainingMs) || + input.minCapabilityRemainingMs > + input.maxCapabilityLifetimeMs || + input.minCapabilityRemainingMs > + ceilings.maxMinimumCapabilityRemainingMs || + !positiveSafeInteger(input.maxPresetBindingsPerCapability) || + input.maxPresetBindingsPerCapability > + ceilings.maxPresetBindingsPerCapability || + !positiveSafeInteger( + input.maxConcurrentCapabilityVerifications, + ) || + input.maxConcurrentCapabilityVerifications > + ceilings.maxConcurrentCapabilityVerifications || + !Array.isArray(input.allowedSourceMediaTypes) || + input.allowedSourceMediaTypes.length < 1 || + !input.formatQualityCeilings || + typeof input.formatQualityCeilings !== "object" || + Array.isArray(input.formatQualityCeilings) + ) { + throw new TypeError("Image CDN hard limits are invalid."); + } + const allowedSourceMediaTypes = [ + ...new Set(input.allowedSourceMediaTypes), + ]; + if ( + allowedSourceMediaTypes.length !== + input.allowedSourceMediaTypes.length || + allowedSourceMediaTypes.some( + (mediaType) => !IMAGE_MEDIA_TYPES.includes(mediaType), + ) + ) { + throw new TypeError("Image CDN source media policy is invalid."); + } + const formatQualityCeilings: + Partial> = {}; + for (const [format, ceiling] of Object.entries( + input.formatQualityCeilings, + )) { + if ( + !IMAGE_FORMATS.includes(format as ImageOutputFormat) || + !positiveSafeInteger(ceiling) || + ceiling > input.maxQuality + ) { + throw new TypeError("Image CDN format ceiling is invalid."); + } + formatQualityCeilings[format as ImageOutputFormat] = ceiling; + } + return Object.freeze({ + maxIntrinsicWidth: input.maxIntrinsicWidth, + maxIntrinsicHeight: input.maxIntrinsicHeight, + maxSourcePixels: input.maxSourcePixels, + maxCssDimension: input.maxCssDimension, + maxDpr: input.maxDpr, + maxQuality: input.maxQuality, + maxCandidateCount: input.maxCandidateCount, + maxTransformedPixels: input.maxTransformedPixels, + maxDecodedBytes: input.maxDecodedBytes, + maxEncodedBytes: input.maxEncodedBytes, + maxUrlLength: input.maxUrlLength, + maxCapabilityLifetimeMs: input.maxCapabilityLifetimeMs, + maxClockSkewMs: input.maxClockSkewMs, + minCapabilityRemainingMs: input.minCapabilityRemainingMs, + maxPresetBindingsPerCapability: + input.maxPresetBindingsPerCapability, + maxConcurrentCapabilityVerifications: + input.maxConcurrentCapabilityVerifications, + allowedSourceMediaTypes: Object.freeze( + allowedSourceMediaTypes, + ), + formatQualityCeilings: + Object.freeze(formatQualityCeilings), + }); +} + +function snapshotCapabilityPolicy( + input: ImageCdnCapabilityPolicy, +): ImageCdnCapabilityPolicy { + if ( + !hasExactOwnKeys(input, CAPABILITY_KEYS) || + !POLICY_TOKEN.test(input.issuer) || + !Array.isArray(input.acceptedKeyIds) || + input.acceptedKeyIds.length < 1 || + input.acceptedKeyIds.length > + IMAGE_CDN_IMPLEMENTATION_CEILINGS.maxAcceptedKeyIds + ) { + throw new TypeError("Image CDN capability policy is invalid."); + } + const acceptedKeyIds = [...input.acceptedKeyIds]; + if ( + new Set(acceptedKeyIds).size !== acceptedKeyIds.length || + acceptedKeyIds.some((keyId) => !POLICY_TOKEN.test(keyId)) + ) { + throw new TypeError("Image CDN capability policy is invalid."); + } + return Object.freeze({ + issuer: input.issuer, + acceptedKeyIds: Object.freeze(acceptedKeyIds), + }); +} + +function sortedUniqueNumbers(values: readonly number[]): number[] { + return [...new Set(values)].sort((left, right) => left - right); +} + +function positiveDpr(value: number): boolean { + return ( + Number.isFinite(value) && + value > 0 && + Number.isSafeInteger(value * 100) + ); +} + +function positiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function nonNegativeSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +function hasExactOwnKeys( + input: unknown, + keys: readonly string[], +): boolean { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return false; + } + const actual = Object.keys(input).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function hasControlCharacters(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 0x20 || codePoint === 0x7f; + }); +} diff --git a/src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts b/src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts new file mode 100644 index 0000000..08806d5 --- /dev/null +++ b/src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts @@ -0,0 +1,1340 @@ +import type { + BackendIssuedImageAsset, + ImageAssetReference, + ImageCapabilityVerifier, + ImageCdnRuntime, + ImageOutputFormat, + ImagePresentationDescriptor, + ImagePresetReference, + ImageProbeReceipt, + ImageRasterMediaType, + ImageResourceProbePort, + PublicImmutableImageAsset, +} from "../../../application/ports/browser-transfer/image-cdn.ts"; +import type { + BrowserDataObserver, + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, + observeBrowserData, +} from "../../browser-file-storage/result.ts"; +import { + IMAGE_FORMAT_MEDIA_TYPE, + type ImageCandidateGeometry, + type ImageCdnHardLimits, + type ImageCdnPolicyRegistry, + type ResolvedImageCdnOrigin, + type ResolvedImageCdnPreset, +} from "./image-cdn-policy.ts"; + +export type ImageCdnRuntimeDependencies = Readonly<{ + policies: ImageCdnPolicyRegistry; + now?: () => number; + subtle?: Pick; + capabilityVerifier?: ImageCapabilityVerifier; + capabilityVerificationTimeoutMs?: number; + capabilityVerificationScheduler?: ImageCapabilityVerificationScheduler; + probe?: ImageResourceProbePort; + observer?: BrowserDataObserver; +}>; + +export type ImageCapabilityVerificationScheduler = Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +export const DEFAULT_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS = 5_000; +export const MAX_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS = 60_000; + +type AssetSnapshot = Readonly<{ + origin: ResolvedImageCdnOrigin; + assetId: string; + revision: string; + mediaType: ImageRasterMediaType; + intrinsicWidth: number; + intrinsicHeight: number; + delivery: "PUBLIC_IMMUTABLE" | "PRIVATE_SIGNED"; + capability: Readonly<{ + capabilityId: string; + expiresAtEpochMs: number; + allowedPresetBindingIds: ReadonlySet; + keyId: string; + capabilityBindingDigestHex: string; + signatureBase64Url: string; + }> | null; +}>; + +type IssuedDescriptorSnapshot = BackendIssuedImageAsset; + +const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{8,128}$/u; +const SIGNATURE = /^[A-Za-z0-9_-]{16,512}$/u; +const SHA256_HEX = /^[a-f0-9]{64}$/u; +const PUBLIC_DESCRIPTOR_KEYS = Object.freeze([ + "kind", + "originKey", + "assetId", + "revision", + "mediaType", + "contentKind", + "intrinsicWidth", + "intrinsicHeight", +] as const); +const ISSUED_DESCRIPTOR_KEYS = Object.freeze([ + "kind", + "issuer", + "originKey", + "assetId", + "revision", + "mediaType", + "contentKind", + "intrinsicWidth", + "intrinsicHeight", + "capabilityId", + "issuedAtEpochMs", + "expiresAtEpochMs", + "allowedPresetBindingIds", + "signature", +] as const); +const SIGNATURE_KEYS = Object.freeze([ + "algorithm", + "keyId", + "capabilityBindingDigestHex", + "valueBase64Url", +] as const); +const RESOLVE_KEYS = Object.freeze([ + "asset", + "preset", + "signal", +] as const); +const ACCEPT_OPTIONS_KEYS = Object.freeze(["signal"] as const); + +/** + * Creates the public runtime factory. Composition should expose + * `presentation` to features and keep `assets` at the backend gateway seam. + */ +export function createImageCdnRuntime( + dependencies: ImageCdnRuntimeDependencies, +): ImageCdnRuntime { + const resolveOrigin = + dependencies.policies.resolveOrigin.bind(dependencies.policies); + const resolvePreset = + dependencies.policies.resolvePreset.bind(dependencies.policies); + const hasPresetBinding = + dependencies.policies.hasPresetBinding.bind(dependencies.policies); + const hardLimits = snapshotHardLimits( + dependencies.policies.hardLimits(), + ); + const resolvedCapabilityPolicy = + dependencies.policies.capabilityPolicy(); + const capabilityPolicy = Object.freeze({ + issuer: resolvedCapabilityPolicy.issuer, + acceptedKeyIds: new Set( + resolvedCapabilityPolicy.acceptedKeyIds, + ), + }); + const now = dependencies.now ?? Date.now; + const digest = dependencies.subtle?.digest.bind(dependencies.subtle); + const verifyCapability = + dependencies.capabilityVerifier?.verify.bind( + dependencies.capabilityVerifier, + ); + const acceptsCapabilityKey = + dependencies.capabilityVerifier?.acceptsKey.bind( + dependencies.capabilityVerifier, + ); + if (dependencies.capabilityVerifier) { + try { + if ( + !verifyCapability || + !acceptsCapabilityKey || + [...capabilityPolicy.acceptedKeyIds].some( + (keyId) => acceptsCapabilityKey(keyId) !== true, + ) + ) { + throw new TypeError( + "Image capability verifier registry does not cover policy keys.", + ); + } + } catch { + throw new TypeError( + "Image capability verifier registry does not cover policy keys.", + ); + } + } + const capabilityVerificationTimeoutMs = + dependencies.capabilityVerificationTimeoutMs ?? + DEFAULT_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS; + if ( + !Number.isSafeInteger(capabilityVerificationTimeoutMs) || + capabilityVerificationTimeoutMs < 1 || + capabilityVerificationTimeoutMs > + MAX_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS + ) { + throw new TypeError( + "Image capability verification timeout is invalid.", + ); + } + const capabilityVerificationScheduler = + snapshotCapabilityVerificationScheduler( + dependencies.capabilityVerificationScheduler ?? + defaultCapabilityVerificationScheduler(), + ); + const probe = + dependencies.probe?.probe.bind(dependencies.probe); + const observer = snapshotObserver(dependencies.observer); + let acceptedAssets = + new WeakMap(); + const lifetime = new AbortController(); + let activeCapabilityVerifications = 0; + let closed = false; + + const acceptPublicImmutableCore: + ImageCdnRuntime["assets"]["acceptPublicImmutable"] = ( + descriptor, + ) => { + if (closed) return imageRuntimeClosedFailure(); + const snapshot = snapshotPublicDescriptor(descriptor); + if (!snapshot) { + return browserDataFailure("INVALID_INPUT", "IMAGE_RESOLVE"); + } + const origin = resolveOrigin(snapshot.originKey); + if ( + snapshot.kind !== "ALLOWLISTED_PUBLIC" || + !origin || + !validAssetMetadata(snapshot, hardLimits) + ) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + const reference = createAssetReference(); + acceptedAssets.set( + reference, + Object.freeze({ + origin, + assetId: snapshot.assetId, + revision: snapshot.revision, + mediaType: snapshot.mediaType, + intrinsicWidth: snapshot.intrinsicWidth, + intrinsicHeight: snapshot.intrinsicHeight, + delivery: "PUBLIC_IMMUTABLE", + capability: null, + }), + ); + return browserDataSuccess(reference); + }; + + const acceptPublicImmutable: + ImageCdnRuntime["assets"]["acceptPublicImmutable"] = ( + descriptor, + ) => { + const result = acceptPublicImmutableCore(descriptor); + return observeImageTerminal( + observer, + result, + result.ok ? 1 : 0, + ); + }; + + const acceptBackendIssuedCore: + ImageCdnRuntime["assets"]["acceptBackendIssued"] = async ( + descriptor, + options = {}, + ) => { + if (closed) return imageRuntimeClosedFailure(); + if ( + !hasOnlyOwnKeys(options, ACCEPT_OPTIONS_KEYS) || + !validOptionalSignal(options.signal) + ) { + return browserDataFailure("INVALID_INPUT", "IMAGE_RESOLVE"); + } + if (options.signal?.aborted) { + return browserDataFailure("ABORTED", "IMAGE_RESOLVE"); + } + const snapshot = snapshotIssuedDescriptor(descriptor); + if (!snapshot) { + return browserDataFailure("INVALID_INPUT", "IMAGE_RESOLVE"); + } + const origin = resolveOrigin(snapshot.originKey); + if ( + !origin || + !validAssetMetadata(snapshot, hardLimits) || + snapshot.issuer !== capabilityPolicy.issuer || + !capabilityPolicy.acceptedKeyIds.has( + snapshot.signature.keyId, + ) || + snapshot.allowedPresetBindingIds.length > + hardLimits.maxPresetBindingsPerCapability || + snapshot.allowedPresetBindingIds.some( + (bindingId) => !hasPresetBinding(bindingId), + ) + ) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + const acceptedAt = safeNow(now); + if (acceptedAt === null) { + return browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }); + } + const capabilityLifetimeMs = + snapshot.expiresAtEpochMs - snapshot.issuedAtEpochMs; + if ( + snapshot.issuedAtEpochMs > + acceptedAt + hardLimits.maxClockSkewMs || + capabilityLifetimeMs <= 0 || + capabilityLifetimeMs > hardLimits.maxCapabilityLifetimeMs + ) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + if ( + snapshot.expiresAtEpochMs - acceptedAt < + hardLimits.minCapabilityRemainingMs + ) { + return browserDataFailure( + "EXPIRED_RESOURCE", + "IMAGE_RESOLVE", + ); + } + if (!digest || !verifyCapability || !acceptsCapabilityKey) { + return browserDataFailure("UNSUPPORTED", "IMAGE_RESOLVE"); + } + let verifierAcceptsDescriptorKey: boolean; + try { + verifierAcceptsDescriptorKey = + acceptsCapabilityKey(snapshot.signature.keyId) === true; + } catch { + return browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }); + } + if (!verifierAcceptsDescriptorKey) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + if ( + activeCapabilityVerifications >= + hardLimits.maxConcurrentCapabilityVerifications + ) { + return browserDataFailure( + "LIMIT_EXCEEDED", + "IMAGE_RESOLVE", + ); + } + activeCapabilityVerifications += 1; + try { + const canonicalPayload = + canonicalImageCapabilityPayload(snapshot); + let bindingDigest: string; + let verified: boolean; + let deadline: ImageCapabilityVerificationDeadline; + try { + deadline = createCapabilityVerificationDeadline( + [options.signal, lifetime.signal], + capabilityVerificationTimeoutMs, + capabilityVerificationScheduler, + ); + } catch { + return browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }); + } + try { + if (deadline.signal.aborted) { + throw capabilityVerificationAbortException(); + } + bindingDigest = await awaitImageRuntimeAbort( + sha256Hex(digest, canonicalPayload), + deadline.signal, + ); + if ( + !constantTimeHexEqual( + bindingDigest, + snapshot.signature.capabilityBindingDigestHex, + ) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } + if (deadline.signal.aborted) { + throw capabilityVerificationAbortException(); + } + verified = await awaitImageRuntimeAbort( + verifyCapability({ + algorithm: snapshot.signature.algorithm, + keyId: snapshot.signature.keyId, + canonicalPayload: Uint8Array.from(canonicalPayload), + signatureBase64Url: + snapshot.signature.valueBase64Url, + }), + deadline.signal, + ); + } catch { + if (closed) return imageRuntimeClosedFailure(); + return capabilityVerificationFailure(options.signal); + } finally { + deadline.release(); + } + if (closed) return imageRuntimeClosedFailure(); + if (options.signal?.aborted) { + return browserDataFailure("ABORTED", "IMAGE_RESOLVE"); + } + if (verified !== true) { + return browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } + const verifiedAt = safeNow(now); + if ( + verifiedAt === null || + snapshot.expiresAtEpochMs <= verifiedAt + ) { + return browserDataFailure( + "EXPIRED_RESOURCE", + "IMAGE_RESOLVE", + ); + } + + const reference = createAssetReference(); + acceptedAssets.set( + reference, + Object.freeze({ + origin, + assetId: snapshot.assetId, + revision: snapshot.revision, + mediaType: snapshot.mediaType, + intrinsicWidth: snapshot.intrinsicWidth, + intrinsicHeight: snapshot.intrinsicHeight, + delivery: "PRIVATE_SIGNED", + capability: Object.freeze({ + capabilityId: snapshot.capabilityId, + expiresAtEpochMs: snapshot.expiresAtEpochMs, + allowedPresetBindingIds: new Set( + snapshot.allowedPresetBindingIds, + ), + keyId: snapshot.signature.keyId, + capabilityBindingDigestHex: + snapshot.signature.capabilityBindingDigestHex, + signatureBase64Url: + snapshot.signature.valueBase64Url, + }), + }), + ); + return browserDataSuccess(reference); + } finally { + activeCapabilityVerifications -= 1; + } + }; + + const acceptBackendIssued: + ImageCdnRuntime["assets"]["acceptBackendIssued"] = async ( + descriptor, + options, + ) => { + const result = await acceptBackendIssuedCore( + descriptor, + options, + ); + return observeImageTerminal( + observer, + result, + result.ok ? 1 : 0, + ); + }; + + const resolveCore: + ImageCdnRuntime["presentation"]["resolve"] = async ( + request, + ) => { + if (closed) return imageRuntimeClosedFailure(); + if ( + !hasOnlyOwnKeys(request, RESOLVE_KEYS) || + !("asset" in request) || + !("preset" in request) || + !validOptionalSignal(request.signal) + ) { + return browserDataFailure("INVALID_INPUT", "IMAGE_RESOLVE"); + } + if (request.signal?.aborted) { + return browserDataFailure("ABORTED", "IMAGE_RESOLVE"); + } + const asset = acceptedAssets.get(request.asset); + const preset = resolvePreset(request.preset); + if (!asset || !preset) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + if ( + asset.capability && + !asset.capability.allowedPresetBindingIds.has( + preset.bindingId, + ) + ) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + const expiryFailure = checkExpiry( + asset, + now, + hardLimits.minCapabilityRemainingMs, + ); + if (expiryFailure) return expiryFailure; + if ( + asset.delivery === "PRIVATE_SIGNED" && + (preset.loading !== "eager" || + preset.fetchPriority === "low" || + preset.probeMode !== "PRIMARY_REQUIRED") + ) { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + if ( + !preset.allowUpscale && + preset.candidates.some( + (candidate) => + candidate.pixelWidth > asset.intrinsicWidth || + candidate.pixelHeight > asset.intrinsicHeight, + ) + ) { + return browserDataFailure( + "LIMIT_EXCEEDED", + "IMAGE_RESOLVE", + ); + } + + let descriptor: ImagePresentationDescriptor; + try { + descriptor = buildPresentationDescriptor( + asset, + preset, + hardLimits.maxUrlLength, + ); + } catch { + return browserDataFailure("POLICY_REJECTED", "IMAGE_RESOLVE"); + } + + if (preset.probeMode === "PRIMARY_REQUIRED") { + if (!probe || !request.signal) { + return browserDataFailure("UNSUPPORTED", "IMAGE_RESOLVE"); + } + const primary = primaryCandidate(preset); + let probeResult: BrowserDataResult; + const probeScope = combineImageRuntimeAbortSignals([ + request.signal, + lifetime.signal, + ]); + try { + probeResult = await awaitImageRuntimeAbort( + probe({ + absoluteUrl: descriptor.src, + expectedMediaType: descriptor.fallbackMediaType, + expectedWidth: primary.pixelWidth, + expectedHeight: primary.pixelHeight, + maxEncodedBytes: preset.maxEncodedBytes, + maxDecodedPixels: preset.maxTransformedPixels, + maxDecodedBytes: preset.maxDecodedBytes, + delivery: asset.delivery, + minimumPublicMaxAgeSeconds: + asset.origin.minimumPublicMaxAgeSeconds, + referrerPolicy: descriptor.referrerPolicy, + signal: probeScope.signal, + }), + probeScope.signal, + ); + } catch { + probeResult = closed + ? imageRuntimeClosedFailure() + : request.signal.aborted + ? browserDataFailure("ABORTED", "IMAGE_RESOLVE") + : browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }); + } finally { + probeScope.release(); + } + if (closed) probeResult = imageRuntimeClosedFailure(); + observeImageTerminal( + observer, + probeResult, + 1, + probeResult.ok + ? probeResult.value.encodedBytes + : undefined, + ); + if (!probeResult.ok) return probeResult; + if ( + !validProbeReceipt( + probeResult.value, + descriptor.src, + descriptor.fallbackMediaType, + primary, + preset.maxEncodedBytes, + ) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "IMAGE_RESOLVE", + ); + } + if (request.signal.aborted) { + return browserDataFailure("ABORTED", "IMAGE_RESOLVE"); + } + const postProbeExpiry = checkExpiry( + asset, + now, + hardLimits.minCapabilityRemainingMs, + ); + if (postProbeExpiry) return postProbeExpiry; + } + + if (closed) return imageRuntimeClosedFailure(); + return browserDataSuccess(descriptor); + }; + + const resolve: + ImageCdnRuntime["presentation"]["resolve"] = async ( + request, + ) => { + const result = await resolveCore(request); + return observeImageTerminal( + observer, + result, + result.ok ? 1 + result.value.sources.length : 0, + result.ok + ? result.value.decodeBudget.maximumEncodedBytes + : undefined, + ); + }; + + return Object.freeze({ + assets: Object.freeze({ + acceptPublicImmutable, + acceptBackendIssued, + }), + presentation: Object.freeze({ resolve }), + close(): void { + if (closed) return; + closed = true; + lifetime.abort(); + acceptedAssets = + new WeakMap(); + }, + }); +} + +/** + * Canonical signed payload. Backend and CDN implementations must reproduce + * this exact JSON-array encoding and UTF-8 bytes for schema version 1. + */ +export function canonicalImageCapabilityPayload( + descriptor: BackendIssuedImageAsset, +): Uint8Array { + return new TextEncoder().encode( + JSON.stringify([ + "image-cdn-capability-v1", + descriptor.issuer, + descriptor.originKey, + descriptor.assetId, + descriptor.revision, + descriptor.mediaType, + descriptor.contentKind, + descriptor.intrinsicWidth, + descriptor.intrinsicHeight, + descriptor.capabilityId, + descriptor.issuedAtEpochMs, + descriptor.expiresAtEpochMs, + [...descriptor.allowedPresetBindingIds].sort(), + descriptor.signature.algorithm, + descriptor.signature.keyId, + ]), + ); +} + +export async function computeImageCapabilityBindingDigestHex( + subtle: Pick, + descriptor: BackendIssuedImageAsset, +): Promise { + return sha256Hex( + subtle.digest.bind(subtle), + canonicalImageCapabilityPayload(descriptor), + ); +} + +function buildPresentationDescriptor( + asset: AssetSnapshot, + preset: ResolvedImageCdnPreset, + maxUrlLength: number, +): ImagePresentationDescriptor { + const fallbackFormat = preset.formats.at(-1); + if (!fallbackFormat) { + throw new TypeError("Image CDN fallback format is missing."); + } + const primary = primaryCandidate(preset); + const sourceSets = preset.formats.map((format) => + Object.freeze({ + format, + type: IMAGE_FORMAT_MEDIA_TYPE[format], + srcSet: buildSrcSet( + asset, + preset, + format, + maxUrlLength, + ), + }), + ); + const fallback = sourceSets.at(-1); + if (!fallback) { + throw new TypeError("Image CDN fallback source is missing."); + } + const src = buildCandidateUrl( + asset, + preset, + fallbackFormat, + primary, + maxUrlLength, + ); + const maximumCandidatePixels = Math.max( + ...preset.candidates.map((candidate) => candidate.pixels), + ); + const maximumDecodedBytes = Math.max( + ...preset.candidates.map( + (candidate) => candidate.decodedBytes, + ), + ); + const isPublic = asset.delivery === "PUBLIC_IMMUTABLE"; + return Object.freeze({ + src, + srcSet: fallback.srcSet, + sources: Object.freeze( + sourceSets.slice(0, -1).map(({ type, srcSet }) => + Object.freeze({ type, srcSet }), + ), + ), + sizes: preset.sizes, + width: preset.width, + height: preset.height, + fallbackMediaType: fallback.type, + loading: preset.loading, + decoding: preset.decoding, + fetchPriority: preset.fetchPriority, + referrerPolicy: isPublic + ? preset.referrerPolicy + : "no-referrer", + crossOrigin: "anonymous", + delivery: Object.freeze({ + class: asset.delivery, + assetVersion: asset.revision, + browserCache: isPublic + ? "PUBLIC_IMMUTABLE" + : "NO_STORE", + sharedCache: isPublic + ? "PUBLIC_IMMUTABLE" + : "FORBIDDEN", + purge: isPublic + ? "REVISION_ROLLOVER" + : "CAPABILITY_REVOCATION_OR_EXPIRY", + expiresAtEpochMs: + asset.capability?.expiresAtEpochMs ?? null, + }), + decodeBudget: Object.freeze({ + maximumCandidatePixels, + maximumDecodedBytes, + maximumEncodedBytes: preset.maxEncodedBytes, + }), + }); +} + +function buildSrcSet( + asset: AssetSnapshot, + preset: ResolvedImageCdnPreset, + format: ImageOutputFormat, + maxUrlLength: number, +): string { + return preset.candidates + .map( + (candidate) => + `${buildCandidateUrl( + asset, + preset, + format, + candidate, + maxUrlLength, + )} ${candidate.pixelWidth}w`, + ) + .join(", "); +} + +function buildCandidateUrl( + asset: AssetSnapshot, + preset: ResolvedImageCdnPreset, + format: ImageOutputFormat, + candidate: ImageCandidateGeometry, + maxUrlLength: number, +): string { + const pathname = + `${asset.origin.assetPathPrefix}` + + `${encodeURIComponent(asset.assetId)}/` + + `${encodeURIComponent(asset.revision)}`; + const url = new URL(pathname, asset.origin.origin); + url.searchParams.set("dpr", formatDpr(candidate.dpr)); + url.searchParams.set("fit", preset.fit); + url.searchParams.set("format", format); + url.searchParams.set("height", String(candidate.cssHeight)); + url.searchParams.set("preset", preset.bindingId); + url.searchParams.set("quality", String(preset.quality)); + url.searchParams.set("width", String(candidate.cssWidth)); + + const expectedQueryNames = new Set([ + "dpr", + "fit", + "format", + "height", + "preset", + "quality", + "width", + ]); + if (asset.capability) { + url.searchParams.set( + "binding", + asset.capability.capabilityBindingDigestHex, + ); + url.searchParams.set( + "capability", + asset.capability.capabilityId, + ); + url.searchParams.set( + "expires", + String(asset.capability.expiresAtEpochMs), + ); + url.searchParams.set("key", asset.capability.keyId); + url.searchParams.set( + "signature", + asset.capability.signatureBase64Url, + ); + for (const name of [ + "binding", + "capability", + "expires", + "key", + "signature", + ]) { + expectedQueryNames.add(name); + } + } + url.searchParams.sort(); + + const queryNames = [...url.searchParams.keys()]; + if ( + url.protocol !== "https:" || + url.origin !== asset.origin.origin || + url.username !== "" || + url.password !== "" || + url.hash !== "" || + url.pathname !== pathname || + queryNames.length !== expectedQueryNames.size || + new Set(queryNames).size !== queryNames.length || + queryNames.some((name) => !expectedQueryNames.has(name)) || + url.href.length > maxUrlLength + ) { + throw new TypeError("Image CDN URL policy was violated."); + } + return url.href; +} + +function primaryCandidate( + preset: ResolvedImageCdnPreset, +): ImageCandidateGeometry { + const primary = preset.candidates.find( + (candidate) => + candidate.cssWidth === preset.width && + candidate.dpr === 1, + ); + if (!primary) { + throw new TypeError("Image CDN primary candidate is missing."); + } + return primary; +} + +function snapshotPublicDescriptor( + input: PublicImmutableImageAsset, +): PublicImmutableImageAsset | null { + if (!hasExactOwnKeys(input, PUBLIC_DESCRIPTOR_KEYS)) { + return null; + } + try { + return Object.freeze({ + kind: input.kind, + originKey: input.originKey, + assetId: input.assetId, + revision: input.revision, + mediaType: input.mediaType, + contentKind: input.contentKind, + intrinsicWidth: input.intrinsicWidth, + intrinsicHeight: input.intrinsicHeight, + }); + } catch { + return null; + } +} + +function snapshotIssuedDescriptor( + input: BackendIssuedImageAsset, +): IssuedDescriptorSnapshot | null { + if ( + !hasExactOwnKeys(input, ISSUED_DESCRIPTOR_KEYS) || + !hasExactOwnKeys(input.signature, SIGNATURE_KEYS) + ) { + return null; + } + try { + const allowedPresetBindingIds = [ + ...input.allowedPresetBindingIds, + ]; + const snapshot: IssuedDescriptorSnapshot = Object.freeze({ + kind: input.kind, + issuer: input.issuer, + originKey: input.originKey, + assetId: input.assetId, + revision: input.revision, + mediaType: input.mediaType, + contentKind: input.contentKind, + intrinsicWidth: input.intrinsicWidth, + intrinsicHeight: input.intrinsicHeight, + capabilityId: input.capabilityId, + issuedAtEpochMs: input.issuedAtEpochMs, + expiresAtEpochMs: input.expiresAtEpochMs, + allowedPresetBindingIds: Object.freeze( + allowedPresetBindingIds, + ), + signature: Object.freeze({ + algorithm: input.signature.algorithm, + keyId: input.signature.keyId, + capabilityBindingDigestHex: + input.signature.capabilityBindingDigestHex, + valueBase64Url: input.signature.valueBase64Url, + }), + }); + if ( + snapshot.kind !== "BACKEND_ISSUED_PRIVATE" || + snapshot.contentKind !== "RASTER_STATIC" || + !OPAQUE_TOKEN.test(snapshot.capabilityId) || + !Number.isSafeInteger(snapshot.issuedAtEpochMs) || + snapshot.issuedAtEpochMs < 0 || + !Number.isSafeInteger(snapshot.expiresAtEpochMs) || + snapshot.expiresAtEpochMs < 0 || + !Array.isArray(input.allowedPresetBindingIds) || + allowedPresetBindingIds.length < 1 || + new Set(allowedPresetBindingIds).size !== + allowedPresetBindingIds.length || + allowedPresetBindingIds.some( + (bindingId) => + typeof bindingId !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test( + bindingId, + ), + ) || + snapshot.signature.algorithm !== "ECDSA_P256_SHA256" || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test( + snapshot.issuer, + ) || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test( + snapshot.signature.keyId, + ) || + !SHA256_HEX.test( + snapshot.signature.capabilityBindingDigestHex, + ) || + !SIGNATURE.test(snapshot.signature.valueBase64Url) + ) { + return null; + } + return snapshot; + } catch { + return null; + } +} + +function validAssetMetadata( + input: Readonly<{ + assetId: string; + revision: string; + mediaType: string; + contentKind: string; + intrinsicWidth: number; + intrinsicHeight: number; + }>, + hardLimits: ImageCdnHardLimits, +): input is Readonly<{ + assetId: string; + revision: string; + mediaType: ImageRasterMediaType; + contentKind: "RASTER_STATIC"; + intrinsicWidth: number; + intrinsicHeight: number; +}> { + return ( + OPAQUE_TOKEN.test(input.assetId) && + OPAQUE_TOKEN.test(input.revision) && + input.contentKind === "RASTER_STATIC" && + hardLimits.allowedSourceMediaTypes.includes( + input.mediaType as ImageRasterMediaType, + ) && + Number.isSafeInteger(input.intrinsicWidth) && + input.intrinsicWidth > 0 && + input.intrinsicWidth <= hardLimits.maxIntrinsicWidth && + Number.isSafeInteger(input.intrinsicHeight) && + input.intrinsicHeight > 0 && + input.intrinsicHeight <= hardLimits.maxIntrinsicHeight && + input.intrinsicWidth * input.intrinsicHeight <= + hardLimits.maxSourcePixels + ); +} + +function validProbeReceipt( + receipt: ImageProbeReceipt, + expectedUrl: string, + expectedMediaType: ImageRasterMediaType, + expectedGeometry: ImageCandidateGeometry, + maxEncodedBytes: number, +): boolean { + return ( + receipt.absoluteUrl === expectedUrl && + receipt.mediaType === expectedMediaType && + Number.isSafeInteger(receipt.encodedBytes) && + receipt.encodedBytes > 0 && + receipt.encodedBytes <= maxEncodedBytes && + receipt.decodedWidth === expectedGeometry.pixelWidth && + receipt.decodedHeight === expectedGeometry.pixelHeight + ); +} + +function checkExpiry( + asset: AssetSnapshot, + now: () => number, + minimumRemainingMs: number, +): BrowserDataResult | null { + if (!asset.capability) return null; + const current = safeNow(now); + if ( + current === null || + asset.capability.expiresAtEpochMs - current < + minimumRemainingMs + ) { + return browserDataFailure( + "EXPIRED_RESOURCE", + "IMAGE_RESOLVE", + ); + } + return null; +} + +function safeNow(now: () => number): number | null { + try { + const value = now(); + return Number.isSafeInteger(value) && value >= 0 + ? value + : null; + } catch { + return null; + } +} + +type ImageCapabilityVerificationDeadline = Readonly<{ + signal: AbortSignal; + release(): void; +}>; + +function createCapabilityVerificationDeadline( + signals: readonly (AbortSignal | undefined)[], + timeoutMs: number, + scheduler: ImageCapabilityVerificationScheduler, +): ImageCapabilityVerificationDeadline { + const controller = new AbortController(); + let released = false; + const abortListeners = new Map void>(); + const activeSignals = signals.filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + for (const signal of new Set(activeSignals)) { + const onAbort = () => controller.abort(signal.reason); + abortListeners.set(signal, onAbort); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + } + + let timeoutHandle: unknown; + try { + timeoutHandle = scheduler.setTimeout(() => { + if (released) return; + controller.abort(capabilityVerificationAbortException()); + }, timeoutMs); + } catch (error) { + for (const [signal, onAbort] of abortListeners) { + signal.removeEventListener("abort", onAbort); + } + throw error; + } + + return Object.freeze({ + signal: controller.signal, + release() { + if (released) return; + released = true; + try { + scheduler.clearTimeout(timeoutHandle); + } catch { + // Scheduler cleanup cannot alter an already closed adapter result. + } + for (const [signal, onAbort] of abortListeners) { + signal.removeEventListener("abort", onAbort); + } + abortListeners.clear(); + }, + }); +} + +function awaitImageRuntimeAbort( + task: Promise, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const onAbort = () => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + reject(capabilityVerificationAbortException()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + void task.then( + (value) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function combineImageRuntimeAbortSignals( + signals: readonly AbortSignal[], +): Readonly<{ signal: AbortSignal; release(): void }> { + const controller = new AbortController(); + const abortListeners = new Map void>(); + for (const signal of new Set(signals)) { + const onAbort = () => controller.abort(signal.reason); + abortListeners.set(signal, onAbort); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + } + let released = false; + return Object.freeze({ + signal: controller.signal, + release() { + if (released) return; + released = true; + for (const [signal, onAbort] of abortListeners) { + signal.removeEventListener("abort", onAbort); + } + abortListeners.clear(); + }, + }); +} + +function capabilityVerificationFailure( + externalSignal: AbortSignal | undefined, +): BrowserDataResult { + if (externalSignal?.aborted) { + return browserDataFailure("ABORTED", "IMAGE_RESOLVE"); + } + return browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", { + retryable: true, + recovery: "RETRY", + }); +} + +function imageRuntimeClosedFailure(): BrowserDataResult { + return browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE"); +} + +function capabilityVerificationAbortException(): DOMException { + return new DOMException( + "Image capability verification was aborted.", + "AbortError", + ); +} + +function snapshotCapabilityVerificationScheduler( + scheduler: ImageCapabilityVerificationScheduler, +): ImageCapabilityVerificationScheduler { + if ( + !scheduler || + typeof scheduler.setTimeout !== "function" || + typeof scheduler.clearTimeout !== "function" + ) { + throw new TypeError( + "Image capability verification scheduler is invalid.", + ); + } + return Object.freeze({ + setTimeout: scheduler.setTimeout.bind(scheduler), + clearTimeout: scheduler.clearTimeout.bind(scheduler), + }); +} + +function defaultCapabilityVerificationScheduler(): ImageCapabilityVerificationScheduler { + const schedule = globalThis.setTimeout.bind(globalThis); + const clear = globalThis.clearTimeout.bind(globalThis); + return Object.freeze({ + setTimeout(callback: () => void, milliseconds: number) { + return schedule(callback, milliseconds); + }, + clearTimeout(handle: unknown) { + clear( + handle as ReturnType, + ); + }, + }); +} + +function createAssetReference(): ImageAssetReference { + return Object.freeze({}) as ImageAssetReference; +} + +function snapshotHardLimits( + input: ImageCdnHardLimits, +): ImageCdnHardLimits { + return Object.freeze({ + ...input, + allowedSourceMediaTypes: Object.freeze([ + ...input.allowedSourceMediaTypes, + ]), + formatQualityCeilings: Object.freeze({ + ...input.formatQualityCeilings, + }), + }); +} + +function snapshotObserver( + observer: BrowserDataObserver | undefined, +): BrowserDataObserver | undefined { + if (!observer) return undefined; + const record = observer.record.bind(observer); + return Object.freeze({ record }); +} + +function observeImageTerminal( + observer: BrowserDataObserver | undefined, + result: BrowserDataResult, + count: number, + bytes?: number, +): BrowserDataResult { + observeBrowserData(observer, { + operation: "IMAGE_RESOLVE", + outcome: result.ok ? "SUCCEEDED" : "FAILED", + ...(!result.ok ? { failureCode: result.error.code } : {}), + countBucket: countBucket(count), + ...(bytes === undefined + ? {} + : { byteBucket: byteBucket(bytes) }), + }); + return result; +} + +function countBucket( + value: number, +): "ZERO" | "ONE" | "TWO_TO_TEN" | "ELEVEN_TO_HUNDRED" | "GT_HUNDRED" { + if (value <= 0) return "ZERO"; + if (value === 1) return "ONE"; + if (value <= 10) return "TWO_TO_TEN"; + if (value <= 100) return "ELEVEN_TO_HUNDRED"; + return "GT_HUNDRED"; +} + +function byteBucket( + value: number, +): "ZERO" | "LT1MIB" | "1_TO_9MIB" | "10_TO_99MIB" | "GTE100MIB" { + if (value <= 0) return "ZERO"; + if (value < 1024 * 1024) return "LT1MIB"; + if (value < 10 * 1024 * 1024) return "1_TO_9MIB"; + if (value < 100 * 1024 * 1024) return "10_TO_99MIB"; + return "GTE100MIB"; +} + +function validOptionalSignal( + signal: AbortSignal | undefined, +): boolean { + return ( + signal === undefined || + (typeof signal === "object" && + signal !== null && + typeof signal.aborted === "boolean" && + typeof signal.addEventListener === "function" && + typeof signal.removeEventListener === "function") + ); +} + +function hasExactOwnKeys( + input: unknown, + keys: readonly string[], +): boolean { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return false; + } + const actual = Object.keys(input).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function hasOnlyOwnKeys( + input: unknown, + keys: readonly string[], +): boolean { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return false; + } + return Object.keys(input).every((key) => keys.includes(key)); +} + +function formatDpr(value: number): string { + return String(value); +} + +async function sha256Hex( + digest: SubtleCrypto["digest"], + bytes: Uint8Array, +): Promise { + const payload = new Uint8Array(bytes.byteLength); + payload.set(bytes); + const result = await digest("SHA-256", payload.buffer); + return [...new Uint8Array(result)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +function constantTimeHexEqual( + left: string, + right: string, +): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= + (left.charCodeAt(index) || 0) ^ + (right.charCodeAt(index) || 0); + } + return difference === 0; +} diff --git a/src/adapters/browser-transfer/image-cdn/image-header-metadata.ts b/src/adapters/browser-transfer/image-cdn/image-header-metadata.ts new file mode 100644 index 0000000..2e5353e --- /dev/null +++ b/src/adapters/browser-transfer/image-cdn/image-header-metadata.ts @@ -0,0 +1,734 @@ +import type { ImageRasterMediaType } from "../../../application/ports/browser-transfer/image-cdn.ts"; + +export type StaticImageHeaderMetadata = Readonly<{ + width: number; + height: number; +}>; + +/** + * Parses only the deliberately supported static-image subset. Unknown, + * ambiguous, animated and structurally malformed containers fail closed + * before a native decoder can allocate an output surface. + */ +export function parseStaticImageHeaderMetadata( + bytes: Uint8Array, + mediaType: ImageRasterMediaType, +): StaticImageHeaderMetadata | null { + switch (mediaType) { + case "image/avif": + return parseAvif(bytes); + case "image/jpeg": + return parseJpeg(bytes); + case "image/png": + return parsePng(bytes); + case "image/webp": + return parseWebp(bytes); + } +} + +function parsePng( + bytes: Uint8Array, +): StaticImageHeaderMetadata | null { + const signature = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]; + if ( + bytes.byteLength < 33 || + !signature.every((value, index) => bytes[index] === value) + ) { + return null; + } + + const view = dataView(bytes); + let offset = 8; + let dimensions: StaticImageHeaderMetadata | null = null; + let chunkIndex = 0; + let ended = false; + let imageDataSeen = false; + while (offset < bytes.byteLength) { + if (offset + 12 > bytes.byteLength) return null; + const length = view.getUint32(offset); + const type = ascii(bytes, offset + 4, offset + 8); + const payloadStart = offset + 8; + const payloadEnd = payloadStart + length; + const chunkEnd = payloadEnd + 4; + if ( + !Number.isSafeInteger(chunkEnd) || + chunkEnd > bytes.byteLength + ) { + return null; + } + if (chunkIndex === 0 && (type !== "IHDR" || length !== 13)) { + return null; + } + if (type === "IHDR") { + if (dimensions || length !== 13) return null; + const width = view.getUint32(payloadStart); + const height = view.getUint32(payloadStart + 4); + dimensions = validDimensions(width, height); + const bitDepth = bytes[payloadStart + 8]; + const colorType = bytes[payloadStart + 9]; + const compression = bytes[payloadStart + 10]; + const filter = bytes[payloadStart + 11]; + const interlace = bytes[payloadStart + 12]; + if ( + !dimensions || + bitDepth === undefined || + colorType === undefined || + !validPngColorDepth(colorType, bitDepth) || + compression !== 0 || + filter !== 0 || + (interlace !== 0 && interlace !== 1) + ) { + return null; + } + } + if (type === "acTL" || type === "fcTL" || type === "fdAT") { + return null; + } + if (type === "IDAT") imageDataSeen = true; + if (type === "IEND") { + if ( + length !== 0 || + !imageDataSeen || + chunkEnd !== bytes.byteLength + ) { + return null; + } + ended = true; + } + offset = chunkEnd; + chunkIndex += 1; + if (ended) break; + } + return ended && dimensions ? dimensions : null; +} + +function validPngColorDepth( + colorType: number, + bitDepth: number, +): boolean { + const supportedDepths: Readonly> = + { + 0: [1, 2, 4, 8, 16], + 2: [8, 16], + 3: [1, 2, 4, 8], + 4: [8, 16], + 6: [8, 16], + }; + return supportedDepths[colorType]?.includes(bitDepth) ?? false; +} + +function parseJpeg( + bytes: Uint8Array, +): StaticImageHeaderMetadata | null { + if ( + bytes.byteLength < 4 || + bytes[0] !== 0xff || + bytes[1] !== 0xd8 + ) { + return null; + } + const supportedStartOfFrame = new Set([0xc0, 0xc1, 0xc2]); + const unsupportedStartOfFrame = new Set([ + 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, + ]); + const view = dataView(bytes); + let dimensions: StaticImageHeaderMetadata | null = null; + let offset = 2; + while (offset < bytes.byteLength) { + if (bytes[offset] !== 0xff) return null; + while (offset < bytes.byteLength && bytes[offset] === 0xff) { + offset += 1; + } + if (offset >= bytes.byteLength) return null; + const marker = bytes[offset]; + offset += 1; + if (marker === undefined || marker === 0x00) return null; + if (marker === 0xd9) return null; + if (marker === 0xda) return dimensions; + if ( + marker === 0xd8 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd7) + ) { + continue; + } + if (offset + 2 > bytes.byteLength) return null; + const segmentLength = view.getUint16(offset); + if (segmentLength < 2) return null; + const segmentEnd = offset + segmentLength; + if (segmentEnd > bytes.byteLength) return null; + if (unsupportedStartOfFrame.has(marker)) return null; + if (supportedStartOfFrame.has(marker)) { + if (dimensions || segmentLength < 8) return null; + const precision = bytes[offset + 2]; + const height = view.getUint16(offset + 3); + const width = view.getUint16(offset + 5); + const componentCount = bytes[offset + 7]; + dimensions = validDimensions(width, height); + if ( + !dimensions || + precision !== 8 || + (componentCount !== 1 && componentCount !== 3) || + segmentLength !== 8 + componentCount * 3 + ) { + return null; + } + } + offset = segmentEnd; + } + return null; +} + +function parseWebp( + bytes: Uint8Array, +): StaticImageHeaderMetadata | null { + if ( + bytes.byteLength < 20 || + ascii(bytes, 0, 4) !== "RIFF" || + ascii(bytes, 8, 12) !== "WEBP" + ) { + return null; + } + const view = dataView(bytes); + const riffLength = view.getUint32(4, true) + 8; + if (riffLength !== bytes.byteLength) return null; + + let offset = 12; + let dimensions: StaticImageHeaderMetadata | null = null; + let imagePayloadCount = 0; + let extendedHeaderSeen = false; + let chunkIndex = 0; + while (offset < bytes.byteLength) { + if (offset + 8 > bytes.byteLength) return null; + const type = ascii(bytes, offset, offset + 4); + const length = view.getUint32(offset + 4, true); + const payloadStart = offset + 8; + const payloadEnd = payloadStart + length; + const chunkEnd = payloadEnd + (length % 2); + if ( + !Number.isSafeInteger(chunkEnd) || + chunkEnd > bytes.byteLength + ) { + return null; + } + if ( + length % 2 === 1 && + bytes[payloadEnd] !== 0 + ) { + return null; + } + if (type === "ANIM" || type === "ANMF") return null; + + let candidate: StaticImageHeaderMetadata | null = null; + if (type === "VP8X") { + if ( + chunkIndex !== 0 || + extendedHeaderSeen || + length !== 10 || + bytes[payloadStart] === undefined || + (bytes[payloadStart] & 0xc3) !== 0 + ) { + return null; + } + extendedHeaderSeen = true; + candidate = validDimensions( + readUint24LittleEndian(bytes, payloadStart + 4) + 1, + readUint24LittleEndian(bytes, payloadStart + 7) + 1, + ); + } else if (type === "VP8 ") { + imagePayloadCount += 1; + if ( + length < 10 || + bytes[payloadStart + 3] !== 0x9d || + bytes[payloadStart + 4] !== 0x01 || + bytes[payloadStart + 5] !== 0x2a + ) { + return null; + } + candidate = validDimensions( + view.getUint16(payloadStart + 6, true) & 0x3fff, + view.getUint16(payloadStart + 8, true) & 0x3fff, + ); + } else if (type === "VP8L") { + imagePayloadCount += 1; + if (length < 5 || bytes[payloadStart] !== 0x2f) { + return null; + } + const byte1 = bytes[payloadStart + 1]; + const byte2 = bytes[payloadStart + 2]; + const byte3 = bytes[payloadStart + 3]; + const byte4 = bytes[payloadStart + 4]; + if ( + byte1 === undefined || + byte2 === undefined || + byte3 === undefined || + byte4 === undefined || + (byte4 & 0xe0) !== 0 + ) { + return null; + } + candidate = validDimensions( + 1 + byte1 + ((byte2 & 0x3f) << 8), + 1 + + ((byte2 & 0xc0) >> 6) + + (byte3 << 2) + + ((byte4 & 0x0f) << 10), + ); + } + if (candidate) { + if ( + dimensions && + (dimensions.width !== candidate.width || + dimensions.height !== candidate.height) + ) { + return null; + } + dimensions = candidate; + } + offset = chunkEnd; + chunkIndex += 1; + } + return offset === bytes.byteLength && + dimensions && + imagePayloadCount === 1 + ? dimensions + : null; +} + +function parseAvif( + bytes: Uint8Array, +): StaticImageHeaderMetadata | null { + if (bytes.byteLength < 24) return null; + const boxes = parseBoxes(bytes, 0, bytes.byteLength); + if (!boxes || boxes.length < 2 || boxes[0]?.type !== "ftyp") { + return null; + } + const fileType = boxes[0]; + const fileTypeLength = fileType + ? fileType.payloadEnd - fileType.payloadStart + : 0; + if ( + !fileType || + fileTypeLength < 8 || + (fileTypeLength - 8) % 4 !== 0 + ) { + return null; + } + const brands: string[] = [ + ascii(bytes, fileType.payloadStart, fileType.payloadStart + 4), + ]; + for ( + let offset = fileType.payloadStart + 8; + offset + 4 <= fileType.payloadEnd; + offset += 4 + ) { + brands.push(ascii(bytes, offset, offset + 4)); + } + if (!brands.includes("avif") || brands.includes("avis")) { + return null; + } + if ( + boxes.some((box) => box.type === "moov") || + !boxes.some( + (box) => + box.type === "mdat" && + box.payloadEnd > box.payloadStart, + ) + ) { + return null; + } + const metadataBoxes = boxes.filter((box) => box.type === "meta"); + const metadataBox = metadataBoxes[0]; + if ( + metadataBoxes.length !== 1 || + !metadataBox || + metadataBox.payloadStart + 4 > metadataBox.payloadEnd || + !zeroFullBoxFlags(bytes, metadataBox.payloadStart) + ) { + return null; + } + const metadataChildren = parseBoxes( + bytes, + metadataBox.payloadStart + 4, + metadataBox.payloadEnd, + ); + if (!metadataChildren) return null; + + const state: AvifMetadataState = { + associations: new Map(), + itemTypes: new Map(), + primaryItemId: null, + properties: new Map(), + propertyCount: 0, + }; + let itemInfoSeen = false; + let itemPropertiesSeen = false; + for (const box of metadataChildren) { + if (box.type === "pitm") { + const primaryItemId = parseAvifPrimaryItem(bytes, box); + if ( + primaryItemId === null || + state.primaryItemId !== null + ) { + return null; + } + state.primaryItemId = primaryItemId; + } else if (box.type === "iinf") { + if (itemInfoSeen || !parseAvifItemInfo(bytes, box, state)) { + return null; + } + itemInfoSeen = true; + } else if (box.type === "iprp") { + if ( + itemPropertiesSeen || + !parseAvifItemProperties(bytes, box, state) + ) { + return null; + } + itemPropertiesSeen = true; + } + } + const primaryItemId = state.primaryItemId; + if ( + primaryItemId === null || + state.itemTypes.get(primaryItemId) !== "av01" + ) { + return null; + } + const associatedProperties = state.associations.get(primaryItemId); + if (!associatedProperties) return null; + const associatedExtents: StaticImageHeaderMetadata[] = []; + const seenProperties = new Set(); + for (const propertyIndex of associatedProperties) { + if ( + propertyIndex < 1 || + propertyIndex > state.propertyCount || + seenProperties.has(propertyIndex) + ) { + return null; + } + seenProperties.add(propertyIndex); + const dimensions = state.properties.get(propertyIndex); + if (dimensions) associatedExtents.push(dimensions); + } + return associatedExtents.length === 1 + ? (associatedExtents[0] ?? null) + : null; +} + +type IsoBox = Readonly<{ + type: string; + payloadStart: number; + payloadEnd: number; +}>; + +type AvifMetadataState = { + primaryItemId: number | null; + itemTypes: Map; + properties: Map; + associations: Map; + propertyCount: number; +}; + +function parseAvifPrimaryItem( + bytes: Uint8Array, + box: IsoBox, +): number | null { + const version = bytes[box.payloadStart]; + const view = dataView(bytes); + if (!zeroFullBoxFlags(bytes, box.payloadStart)) return null; + if ( + version === 0 && + box.payloadEnd - box.payloadStart === 6 + ) { + return view.getUint16(box.payloadStart + 4); + } + if ( + version === 1 && + box.payloadEnd - box.payloadStart === 8 + ) { + return view.getUint32(box.payloadStart + 4); + } + return null; +} + +function parseAvifItemInfo( + bytes: Uint8Array, + box: IsoBox, + state: AvifMetadataState, +): boolean { + const start = box.payloadStart; + const end = box.payloadEnd; + const version = bytes[start]; + if ( + (version !== 0 && version !== 1) || + !zeroFullBoxFlags(bytes, start) + ) { + return false; + } + const entryBytes = version === 0 ? 2 : 4; + if (start + 4 + entryBytes > end) return false; + const view = dataView(bytes); + const declaredEntries = + entryBytes === 2 + ? view.getUint16(start + 4) + : view.getUint32(start + 4); + const entriesStart = start + 4 + entryBytes; + const boxes = parseBoxes(bytes, entriesStart, end); + if ( + !boxes || + boxes.length !== declaredEntries || + boxes.some((entry) => entry.type !== "infe") + ) { + return false; + } + for (const entry of boxes) { + const itemVersion = bytes[entry.payloadStart]; + if (!zeroFullBoxFlags(bytes, entry.payloadStart)) return false; + let itemId: number; + let itemTypeOffset: number; + if (itemVersion === 2) { + if (entry.payloadStart + 12 > entry.payloadEnd) return false; + itemId = view.getUint16(entry.payloadStart + 4); + itemTypeOffset = entry.payloadStart + 8; + } else if (itemVersion === 3) { + if (entry.payloadStart + 14 > entry.payloadEnd) return false; + itemId = view.getUint32(entry.payloadStart + 4); + itemTypeOffset = entry.payloadStart + 10; + } else { + return false; + } + const itemType = ascii( + bytes, + itemTypeOffset, + itemTypeOffset + 4, + ); + if (itemType === "grid" || itemType === "iovl") { + return false; + } + if (itemId === 0 || state.itemTypes.has(itemId)) return false; + state.itemTypes.set(itemId, itemType); + } + return true; +} + +function parseAvifItemProperties( + bytes: Uint8Array, + box: IsoBox, + state: AvifMetadataState, +): boolean { + const boxes = parseBoxes( + bytes, + box.payloadStart, + box.payloadEnd, + ); + if (!boxes) return false; + const propertyContainers = boxes.filter( + (child) => child.type === "ipco", + ); + const associationBoxes = boxes.filter( + (child) => child.type === "ipma", + ); + const propertyContainer = propertyContainers[0]; + if ( + propertyContainers.length !== 1 || + associationBoxes.length < 1 || + !propertyContainer + ) { + return false; + } + const properties = parseBoxes( + bytes, + propertyContainer.payloadStart, + propertyContainer.payloadEnd, + ); + if (!properties) return false; + state.propertyCount = properties.length; + for (const [offset, property] of properties.entries()) { + if (property.type !== "ispe") continue; + if ( + property.payloadEnd - property.payloadStart !== 12 || + bytes[property.payloadStart] !== 0 || + bytes[property.payloadStart + 1] !== 0 || + bytes[property.payloadStart + 2] !== 0 || + bytes[property.payloadStart + 3] !== 0 + ) { + return false; + } + const view = dataView(bytes); + const dimensions = validDimensions( + view.getUint32(property.payloadStart + 4), + view.getUint32(property.payloadStart + 8), + ); + if (!dimensions) return false; + state.properties.set(offset + 1, dimensions); + } + return associationBoxes.every((association) => + parseAvifPropertyAssociations(bytes, association, state) + ); +} + +function parseAvifPropertyAssociations( + bytes: Uint8Array, + box: IsoBox, + state: AvifMetadataState, +): boolean { + const start = box.payloadStart; + const end = box.payloadEnd; + if (start + 8 > end) return false; + const version = bytes[start]; + if (version !== 0 && version !== 1) return false; + const flags = + ((bytes[start + 1] ?? 0) << 16) | + ((bytes[start + 2] ?? 0) << 8) | + (bytes[start + 3] ?? 0); + if ((flags & ~1) !== 0) return false; + const wideAssociation = (flags & 1) === 1; + const view = dataView(bytes); + const entryCount = view.getUint32(start + 4); + let offset = start + 8; + for (let entry = 0; entry < entryCount; entry += 1) { + const itemIdBytes = version === 0 ? 2 : 4; + if (offset + itemIdBytes + 1 > end) return false; + const itemId = + itemIdBytes === 2 + ? view.getUint16(offset) + : view.getUint32(offset); + offset += itemIdBytes; + const associationCount = bytes[offset]; + if (associationCount === undefined) return false; + offset += 1; + const propertyIndices: number[] = []; + for ( + let association = 0; + association < associationCount; + association += 1 + ) { + const associationBytes = wideAssociation ? 2 : 1; + if (offset + associationBytes > end) return false; + const encoded = + associationBytes === 2 + ? view.getUint16(offset) + : (bytes[offset] ?? 0); + const propertyIndex = + encoded & (wideAssociation ? 0x7fff : 0x7f); + offset += associationBytes; + if (propertyIndex !== 0) propertyIndices.push(propertyIndex); + } + if (itemId === 0 || state.associations.has(itemId)) { + return false; + } + state.associations.set(itemId, propertyIndices); + } + return offset === end; +} + +function parseBoxes( + bytes: Uint8Array, + start: number, + end: number, +): readonly IsoBox[] | null { + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end > bytes.byteLength || + start > end + ) { + return null; + } + const boxes: IsoBox[] = []; + const view = dataView(bytes); + let offset = start; + while (offset < end) { + if (offset + 8 > end) return null; + const shortSize = view.getUint32(offset); + const type = ascii(bytes, offset + 4, offset + 8); + let boxSize = shortSize; + let headerSize = 8; + if (shortSize === 0) return null; + if (shortSize === 1) { + if (offset + 16 > end) return null; + const longSize = view.getBigUint64(offset + 8); + if (longSize > BigInt(Number.MAX_SAFE_INTEGER)) return null; + boxSize = Number(longSize); + headerSize = 16; + } + if (boxSize < headerSize || offset + boxSize > end) { + return null; + } + boxes.push( + Object.freeze({ + type, + payloadStart: offset + headerSize, + payloadEnd: offset + boxSize, + }), + ); + offset += boxSize; + } + return offset === end ? boxes : null; +} + +function zeroFullBoxFlags( + bytes: Uint8Array, + offset: number, +): boolean { + return ( + bytes[offset + 1] === 0 && + bytes[offset + 2] === 0 && + bytes[offset + 3] === 0 + ); +} + +function validDimensions( + width: number, + height: number, +): StaticImageHeaderMetadata | null { + return Number.isSafeInteger(width) && + Number.isSafeInteger(height) && + width > 0 && + height > 0 + ? Object.freeze({ width, height }) + : null; +} + +function readUint24LittleEndian( + bytes: Uint8Array, + offset: number, +): number { + const byte0 = bytes[offset]; + const byte1 = bytes[offset + 1]; + const byte2 = bytes[offset + 2]; + if ( + byte0 === undefined || + byte1 === undefined || + byte2 === undefined + ) { + return Number.NaN; + } + return byte0 | (byte1 << 8) | (byte2 << 16); +} + +function ascii( + bytes: Uint8Array, + start: number, + end: number, +): string { + let value = ""; + for (let offset = start; offset < end; offset += 1) { + const byte = bytes[offset]; + if (byte === undefined) return ""; + value += String.fromCharCode(byte); + } + return value; +} + +function dataView(bytes: Uint8Array): DataView { + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); +} diff --git a/src/adapters/browser-transfer/image-cdn/index.ts b/src/adapters/browser-transfer/image-cdn/index.ts new file mode 100644 index 0000000..f0fff29 --- /dev/null +++ b/src/adapters/browser-transfer/image-cdn/index.ts @@ -0,0 +1,34 @@ +export { + createBrowserImageProbe, + type BrowserImageProbeDependencies, + type DecodedImageFacade, + type ImageProbeScheduler, +} from "./browser-image-probe.ts"; +export { + IMAGE_FORMAT_MEDIA_TYPE, + IMAGE_CDN_IMPLEMENTATION_CEILINGS, + ImageCdnPolicyRegistry, + buildImageCandidateGeometry, + imageCdnPresetReference, + type ImageCandidateGeometry, + type ImageCdnCapabilityPolicy, + type ImageCdnHardLimits, + type ImageCdnOriginPolicy, + type ImageCdnPolicyRegistryOptions, + type ImageCdnPresetPolicy, + type ResolvedImageCdnOrigin, + type ResolvedImageCdnPreset, +} from "./image-cdn-policy.ts"; +export { + canonicalImageCapabilityPayload, + computeImageCapabilityBindingDigestHex, + createImageCdnRuntime, + DEFAULT_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS, + MAX_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS, + type ImageCapabilityVerificationScheduler, + type ImageCdnRuntimeDependencies, +} from "./image-cdn-runtime.ts"; +export { + createP256ImageCapabilityVerifier, + type P256ImageCapabilityVerifierOptions, +} from "./p256-image-capability-verifier.ts"; diff --git a/src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts b/src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts new file mode 100644 index 0000000..86582cf --- /dev/null +++ b/src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts @@ -0,0 +1,134 @@ +import type { + ImageCapabilityVerificationRequest, + ImageCapabilityVerifier, +} from "../../../application/ports/browser-transfer/image-cdn.ts"; + +export type P256ImageCapabilityVerifierOptions = Readonly<{ + subtle: Pick; + publicKeys: readonly Readonly<{ + keyId: string; + key: CryptoKey; + }>[]; +}>; + +const KEY_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; + +/** + * Concrete verifier for backend-issued ECDSA P-256/SHA-256 capabilities. + * Signatures use the 64-byte IEEE-P1363 representation required by this + * contract, encoded as unpadded base64url. + */ +export function createP256ImageCapabilityVerifier( + options: P256ImageCapabilityVerifierOptions, +): ImageCapabilityVerifier { + if ( + !options || + typeof options !== "object" || + !Array.isArray(options.publicKeys) || + options.publicKeys.length < 1 || + options.publicKeys.length > 16 + ) { + throw new TypeError( + "Image capability verifier configuration is invalid.", + ); + } + const verify = options.subtle.verify.bind(options.subtle); + const keys = new Map(); + for (const binding of options.publicKeys) { + const algorithmName = + binding.key.algorithm && + typeof binding.key.algorithm === "object" && + "name" in binding.key.algorithm + ? binding.key.algorithm.name + : null; + const namedCurve = + binding.key.algorithm && + typeof binding.key.algorithm === "object" && + "namedCurve" in binding.key.algorithm + ? binding.key.algorithm.namedCurve + : null; + if ( + !KEY_ID.test(binding.keyId) || + binding.key.type !== "public" || + algorithmName !== "ECDSA" || + namedCurve !== "P-256" || + !binding.key.usages.includes("verify") || + keys.has(binding.keyId) + ) { + throw new TypeError( + "Image capability public key binding is invalid.", + ); + } + keys.set(binding.keyId, binding.key); + } + + return Object.freeze({ + acceptsKey(keyId: string): boolean { + return KEY_ID.test(keyId) && keys.has(keyId); + }, + async verify( + request: ImageCapabilityVerificationRequest, + ): Promise { + if ( + request.algorithm !== "ECDSA_P256_SHA256" || + !KEY_ID.test(request.keyId) || + !(request.canonicalPayload instanceof Uint8Array) || + request.canonicalPayload.byteLength < 1 || + request.canonicalPayload.byteLength > 8_192 + ) { + return false; + } + const key = keys.get(request.keyId); + if (!key) return false; + const signature = decodeBase64Url( + request.signatureBase64Url, + ); + if (!signature || signature.byteLength !== 64) { + return false; + } + try { + const signatureBytes = new Uint8Array(signature.byteLength); + signatureBytes.set(signature); + const payloadBytes = new Uint8Array( + request.canonicalPayload.byteLength, + ); + payloadBytes.set(request.canonicalPayload); + return await verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + signatureBytes.buffer, + payloadBytes.buffer, + ); + } catch { + return false; + } + }, + }); +} + +function decodeBase64Url(value: string): Uint8Array | null { + if ( + !/^[A-Za-z0-9_-]+$/u.test(value) || + value.length % 4 === 1 + ) { + return null; + } + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const output: number[] = []; + let accumulator = 0; + let bitCount = 0; + for (const character of value) { + const index = alphabet.indexOf(character); + if (index < 0) return null; + accumulator = (accumulator << 6) | index; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + output.push((accumulator >> bitCount) & 0xff); + accumulator &= (1 << bitCount) - 1; + } + } + if (bitCount > 0 && accumulator !== 0) return null; + return Uint8Array.from(output); +} diff --git a/src/adapters/browser-transfer/index.ts b/src/adapters/browser-transfer/index.ts new file mode 100644 index 0000000..6210de8 --- /dev/null +++ b/src/adapters/browser-transfer/index.ts @@ -0,0 +1,3 @@ +export * from "./image-cdn/index.ts"; +export * from "./presigned/index.ts"; +export * from "./resumable-upload/index.ts"; diff --git a/src/adapters/browser-transfer/presigned/incremental-sha256.ts b/src/adapters/browser-transfer/presigned/incremental-sha256.ts new file mode 100644 index 0000000..bcc1c1e --- /dev/null +++ b/src/adapters/browser-transfer/presigned/incremental-sha256.ts @@ -0,0 +1,204 @@ +const INITIAL_STATE = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]); + +const ROUND_CONSTANTS = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +export type StreamingSha256Verifier = Readonly<{ + update(bytes: Uint8Array): void; + verify(): boolean; +}>; + +export function createStreamingSha256Verifier( + expectedSha256: string, +): StreamingSha256Verifier { + const accumulator = new Sha256Accumulator(); + let verified = false; + return Object.freeze({ + update(bytes: Uint8Array) { + if (verified) throw new TypeError("SHA-256 verifier is finalized."); + accumulator.update(bytes); + }, + verify() { + if (verified) throw new TypeError("SHA-256 verifier is finalized."); + verified = true; + return constantTimeHexEqual( + accumulator.digestHex(), + expectedSha256.toLowerCase(), + ); + }, + }); +} + +export function sha256Hex(bytes: Uint8Array): string { + const accumulator = new Sha256Accumulator(); + accumulator.update(bytes); + return accumulator.digestHex(); +} + +class Sha256Accumulator { + readonly #state = new Uint32Array(INITIAL_STATE); + readonly #buffer = new Uint8Array(64); + readonly #schedule = new Uint32Array(64); + #bufferLength = 0; + #totalBytes = 0; + #finalized = false; + + update(bytes: Uint8Array): void { + if (this.#finalized || !(bytes instanceof Uint8Array)) { + throw new TypeError("SHA-256 input is invalid."); + } + const nextTotal = this.#totalBytes + bytes.byteLength; + if (!Number.isSafeInteger(nextTotal)) { + throw new TypeError("SHA-256 input is too large."); + } + this.#totalBytes = nextTotal; + let offset = 0; + if (this.#bufferLength > 0) { + const available = 64 - this.#bufferLength; + const copied = Math.min(available, bytes.byteLength); + this.#buffer.set(bytes.subarray(0, copied), this.#bufferLength); + this.#bufferLength += copied; + offset += copied; + if (this.#bufferLength === 64) { + this.#compress(this.#buffer); + this.#bufferLength = 0; + } + } + while (offset + 64 <= bytes.byteLength) { + this.#compress(bytes.subarray(offset, offset + 64)); + offset += 64; + } + if (offset < bytes.byteLength) { + const remainder = bytes.subarray(offset); + this.#buffer.set(remainder, 0); + this.#bufferLength = remainder.byteLength; + } + } + + digestHex(): string { + if (this.#finalized) throw new TypeError("SHA-256 is finalized."); + this.#finalized = true; + const finalLength = this.#bufferLength < 56 ? 64 : 128; + const finalBlocks = new Uint8Array(finalLength); + finalBlocks.set(this.#buffer.subarray(0, this.#bufferLength)); + finalBlocks[this.#bufferLength] = 0x80; + const bitLength = BigInt(this.#totalBytes) * 8n; + for (let index = 0; index < 8; index += 1) { + finalBlocks[finalLength - 1 - index] = Number( + (bitLength >> BigInt(index * 8)) & 0xffn, + ); + } + for (let offset = 0; offset < finalLength; offset += 64) { + this.#compress(finalBlocks.subarray(offset, offset + 64)); + } + return Array.from(this.#state, (word) => + word.toString(16).padStart(8, "0"), + ).join(""); + } + + #compress(block: Uint8Array): void { + const words = this.#schedule; + const view = new DataView( + block.buffer, + block.byteOffset, + block.byteLength, + ); + for (let index = 0; index < 16; index += 1) { + words[index] = view.getUint32(index * 4, false); + } + for (let index = 16; index < 64; index += 1) { + const previous15 = words[index - 15] ?? 0; + const previous2 = words[index - 2] ?? 0; + const sigma0 = + rotateRight(previous15, 7) ^ + rotateRight(previous15, 18) ^ + (previous15 >>> 3); + const sigma1 = + rotateRight(previous2, 17) ^ + rotateRight(previous2, 19) ^ + (previous2 >>> 10); + words[index] = + ((words[index - 16] ?? 0) + + sigma0 + + (words[index - 7] ?? 0) + + sigma1) >>> + 0; + } + + let a = this.#state[0] ?? 0; + let b = this.#state[1] ?? 0; + let c = this.#state[2] ?? 0; + let d = this.#state[3] ?? 0; + let e = this.#state[4] ?? 0; + let f = this.#state[5] ?? 0; + let g = this.#state[6] ?? 0; + let h = this.#state[7] ?? 0; + + for (let index = 0; index < 64; index += 1) { + const sum1 = + rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); + const choice = (e & f) ^ (~e & g); + const temporary1 = + (h + + sum1 + + choice + + (ROUND_CONSTANTS[index] ?? 0) + + (words[index] ?? 0)) >>> + 0; + const sum0 = + rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const temporary2 = (sum0 + majority) >>> 0; + h = g; + g = f; + f = e; + e = (d + temporary1) >>> 0; + d = c; + c = b; + b = a; + a = (temporary1 + temporary2) >>> 0; + } + + this.#state[0] = ((this.#state[0] ?? 0) + a) >>> 0; + this.#state[1] = ((this.#state[1] ?? 0) + b) >>> 0; + this.#state[2] = ((this.#state[2] ?? 0) + c) >>> 0; + this.#state[3] = ((this.#state[3] ?? 0) + d) >>> 0; + this.#state[4] = ((this.#state[4] ?? 0) + e) >>> 0; + this.#state[5] = ((this.#state[5] ?? 0) + f) >>> 0; + this.#state[6] = ((this.#state[6] ?? 0) + g) >>> 0; + this.#state[7] = ((this.#state[7] ?? 0) + h) >>> 0; + } +} + +function rotateRight(value: number, bits: number): number { + return (value >>> bits) | (value << (32 - bits)); +} + +function constantTimeHexEqual(left: string, right: string): boolean { + let mismatch = left.length ^ right.length; + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + mismatch |= + (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0); + } + return mismatch === 0; +} diff --git a/src/adapters/browser-transfer/presigned/index.ts b/src/adapters/browser-transfer/presigned/index.ts new file mode 100644 index 0000000..2fc49c9 --- /dev/null +++ b/src/adapters/browser-transfer/presigned/index.ts @@ -0,0 +1,18 @@ +export { + createPresignedCapabilityHttpProvider, + type PresignedCapabilityHttpProvider, + type PresignedCapabilityHttpProviderOptions, +} from "./presigned-capability-http-provider.ts"; +export { + createPresignedCapabilityVault, + createSingleUsePresignedReplayGuard, + type PresignedCapabilityBinding, + type PresignedCapabilityRegistration, + type PresignedCapabilityVault, + type PresignedHeaderBinding, +} from "./presigned-capability-vault.ts"; +export { + createPresignedTransferExecutor, + type PresignedTransferExecutor, + type PresignedTransferExecutorOptions, +} from "./presigned-transfer-executor.ts"; diff --git a/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts new file mode 100644 index 0000000..b63d8b7 --- /dev/null +++ b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts @@ -0,0 +1,1100 @@ +import type { + PresignedDownloadCapability, + PresignedTransferBinding, + PresignedTransferCapability, + PresignedTransferCapabilityProvider, + PresignedTransferCapabilityReceipt, + PresignedUploadPartCapability, + PresignedUploadPartCapabilityProvider, +} from "../../../application/ports/browser-transfer/presigned-transfer.ts"; +import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import type { + BrowserDataFailureCode, + BrowserDataObserver, + BrowserDataRecovery, + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, + observeBrowserData, +} from "../../browser-file-storage/result.ts"; +import type { + PresignedCapabilityRegistration, + PresignedCapabilityVault, + PresignedHeaderBinding, +} from "./presigned-capability-vault.ts"; + +const SHA256 = /^[a-f0-9]{64}$/; +const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/; +const OPAQUE_ID = /^[a-z0-9][a-z0-9._:-]{0,255}$/i; +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const QUERY_NAME = /^[A-Za-z0-9_.~-]{1,128}$/; +const FORBIDDEN_REQUEST_HEADERS = new Set([ + "authorization", + "connection", + "content-length", + "cookie", + "host", + "origin", + "proxy-authorization", + "range", + "referer", + "set-cookie", + "transfer-encoding", +]); + +type Scheduler = Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +export type PresignedCapabilityHttpProviderOptions = Readonly<{ + /** + * Composition-owned BFF endpoint. It is captured once and never accepted + * from issueDownload/issueUploadPart callers. + */ + endpoint: string; + vault: PresignedCapabilityVault; + allowedDataOrigins: readonly string[]; + allowedDataPathPrefixes: readonly string[]; + allowedQueryParameters: readonly string[]; + allowedRequestHeaders: readonly string[]; + allowedResponseHeaders?: readonly string[]; + hardMaxTransferBytes: number; + hardMaxUploadResponseBytes: number; + maxCapabilityTtlMs: number; + /** + * A capability with less time remaining is treated as expired. Keep this + * aligned with the upload runtime refresh skew so a transfer is not started + * with a token that is predictably going to expire in flight. + */ + minimumRemainingLifetimeMs: number; + timeoutMs: number; + maxCapabilityResponseBytes?: number; + fetcher?: typeof fetch; + now?: () => number; + scheduler?: Scheduler; + controlPlaneCredentials?: "same-origin" | "include"; + allowInsecureLocalhost?: boolean; + observer?: BrowserDataObserver; +}>; + +export type PresignedCapabilityHttpProvider = + PresignedTransferCapabilityProvider & + PresignedUploadPartCapabilityProvider; + +export function createPresignedCapabilityHttpProvider( + options: PresignedCapabilityHttpProviderOptions, +): PresignedCapabilityHttpProvider { + const endpoint = validateEndpoint( + options.endpoint, + options.allowInsecureLocalhost ?? false, + ); + const vault = options.vault; + const register = vault.register.bind(vault); + const allowedDataOrigins = new Set( + options.allowedDataOrigins.map((origin) => + normalizedOrigin(origin, options.allowInsecureLocalhost ?? false), + ), + ); + const allowedDataPathPrefixes = Object.freeze( + options.allowedDataPathPrefixes.map(validatePathPrefix), + ); + const allowedQueryParameters = new Set( + options.allowedQueryParameters.map(validateQueryName), + ); + const allowedRequestHeaders = normalizedHeaderSet( + options.allowedRequestHeaders, + ); + const allowedResponseHeaders = normalizedHeaderSet( + options.allowedResponseHeaders ?? [], + ); + const hardMaxTransferBytes = positiveSafeInteger( + options.hardMaxTransferBytes, + "Presigned transfer hard byte limit", + ); + const hardMaxUploadResponseBytes = positiveSafeInteger( + options.hardMaxUploadResponseBytes, + "Presigned upload response hard byte limit", + ); + const maxCapabilityTtlMs = positiveSafeInteger( + options.maxCapabilityTtlMs, + "Presigned capability TTL", + ); + const minimumRemainingLifetimeMs = positiveSafeInteger( + options.minimumRemainingLifetimeMs, + "Presigned capability minimum remaining lifetime", + ); + if (minimumRemainingLifetimeMs > maxCapabilityTtlMs) { + throw new TypeError( + "Presigned capability minimum remaining lifetime exceeds its TTL.", + ); + } + const timeoutMs = positiveSafeInteger( + options.timeoutMs, + "Presigned capability timeout", + ); + const maxCapabilityResponseBytes = positiveSafeInteger( + options.maxCapabilityResponseBytes ?? 64 * 1024, + "Presigned capability response limit", + ); + const fetcher = (options.fetcher ?? fetch).bind(globalThis); + const now = options.now ?? Date.now; + const scheduler = + options.scheduler ?? + ({ + setTimeout: (callback, milliseconds) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle) => + globalThis.clearTimeout( + handle as ReturnType, + ), + } satisfies Scheduler); + const credentials = options.controlPlaneCredentials ?? "same-origin"; + const observer = options.observer; + + function observeIssue( + result: BrowserDataResult, + byteLength?: number, + ): BrowserDataResult { + const bucket = byteBucket( + result.ok ? result.value.byteLength : byteLength, + ); + observeBrowserData(observer, { + operation: "PRESIGNED_TRANSFER", + outcome: result.ok ? "SUCCEEDED" : "FAILED", + ...(!result.ok ? { failureCode: result.error.code } : {}), + ...(bucket !== undefined ? { byteBucket: bucket } : {}), + }); + return result; + } + + async function issue( + expected: Readonly<{ + binding: PresignedTransferBinding; + method: "GET" | "PUT"; + mediaType?: string; + byteLength?: number; + expectedSha256?: string; + }>, + signal: AbortSignal, + ): Promise> { + if (signal.aborted) { + return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER"); + } + const scope = createAbortScope(signal, timeoutMs, scheduler); + try { + const response = await fetcher(endpoint, { + method: "POST", + credentials, + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method: expected.method, + binding: expected.binding, + ...(expected.mediaType !== undefined + ? { mediaType: expected.mediaType } + : {}), + ...(expected.byteLength !== undefined + ? { byteLength: expected.byteLength } + : {}), + ...(expected.expectedSha256 !== undefined + ? { expectedSha256: expected.expectedSha256 } + : {}), + }), + signal: scope.signal, + }); + if ( + response.redirected || + response.type === "opaqueredirect" || + response.url !== endpoint + ) { + cancelResponseBody(response); + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + if (!response.ok || response.status !== 200) { + cancelResponseBody(response); + return statusFailure(response.status); + } + const contentType = response.headers.get("content-type") ?? ""; + if ( + contentType.split(";", 1)[0]?.trim().toLowerCase() !== + "application/json" + ) { + cancelResponseBody(response); + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + const payload = await readBoundedJson( + response, + maxCapabilityResponseBytes, + scope.signal, + ); + const validated = validateCapabilityPayload(payload, { + expected, + hardMaxTransferBytes, + hardMaxUploadResponseBytes, + maxCapabilityTtlMs, + minimumRemainingLifetimeMs, + nowEpochMs: now(), + allowedDataOrigins, + allowedDataPathPrefixes, + allowedQueryParameters, + allowedRequestHeaders, + allowedResponseHeaders, + allowInsecureLocalhost: options.allowInsecureLocalhost ?? false, + }); + if (!validated.ok) return validated; + return register(validated.value) as BrowserDataResult; + } catch (error) { + if (signal.aborted) { + return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER"); + } + if (scope.timedOut()) { + return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER", { + retryable: true, + recovery: "RETRY", + }); + } + if (error instanceof ResponseLimitError) { + return browserDataFailure("LIMIT_EXCEEDED", "PRESIGNED_TRANSFER"); + } + if (error instanceof ResponseIntegrityError) { + return browserDataFailure("CORRUPT_DATA", "PRESIGNED_TRANSFER"); + } + return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER", { + retryable: true, + recovery: "RETRY", + }); + } finally { + scope.release(); + } + } + + return Object.freeze({ + issueDownload( + input: Parameters< + PresignedTransferCapabilityProvider["issueDownload"] + >[0], + ) { + let resourceId: string; + let signal: AbortSignal; + try { + resourceId = snapshotOpaqueId(input.resourceId, "resource ID"); + signal = input.signal; + if (!isAbortSignal(signal)) { + throw new TypeError("Abort signal is invalid."); + } + } catch { + return Promise.resolve( + observeIssue( + browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER"), + ), + ); + } + return issue( + { + method: "GET", + binding: Object.freeze({ + kind: "DOWNLOAD", + resourceId, + }), + }, + signal, + ).then( + (result) => observeIssue(result), + () => + observeIssue( + browserDataFailure( + "UNAVAILABLE", + "PRESIGNED_TRANSFER", + { retryable: true, recovery: "RETRY" }, + ), + ), + ); + }, + + issueUploadPart( + input: Parameters< + PresignedUploadPartCapabilityProvider["issueUploadPart"] + >[0], + ) { + let request: Readonly<{ + binding: Extract< + PresignedTransferBinding, + Readonly<{ kind: "UPLOAD_PART" }> + >; + mediaType: string; + byteLength: number; + expectedSha256: string; + signal: AbortSignal; + }>; + try { + const mediaType = normalizedMediaType(input.mediaType); + const byteLength = positiveSafeInteger( + input.byteLength, + "part byte length", + ); + if (byteLength > hardMaxTransferBytes) { + return Promise.resolve( + observeIssue( + browserDataFailure("LIMIT_EXCEEDED", "PRESIGNED_TRANSFER"), + byteLength, + ), + ); + } + if (!isAbortSignal(input.signal)) { + throw new TypeError("Abort signal is invalid."); + } + request = Object.freeze({ + binding: Object.freeze({ + kind: "UPLOAD_PART" as const, + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: snapshotOpaqueId( + input.sessionId, + "upload session ID", + ), + requestBindingSha256: normalizedSha256( + input.requestBindingSha256, + ), + uploadBindingSha256: normalizedSha256( + input.uploadBindingSha256, + ), + partNumber: positiveSafeInteger( + input.partNumber, + "part number", + ), + offset: nonNegativeSafeInteger(input.offset, "part offset"), + idempotencyKey: snapshotOpaqueId( + input.idempotencyKey, + "idempotency key", + ), + }), + mediaType, + byteLength, + expectedSha256: normalizedSha256(input.checksumSha256), + signal: input.signal, + }); + } catch { + return Promise.resolve( + observeIssue( + browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER"), + ), + ); + } + return issue( + { + method: "PUT", + binding: request.binding, + mediaType: request.mediaType, + byteLength: request.byteLength, + expectedSha256: request.expectedSha256, + }, + request.signal, + ).then( + (result) => observeIssue(result, request.byteLength), + () => + observeIssue( + browserDataFailure( + "UNAVAILABLE", + "PRESIGNED_TRANSFER", + { retryable: true, recovery: "RETRY" }, + ), + request.byteLength, + ), + ); + }, + }); +} + +type ValidationContext = Readonly<{ + expected: Readonly<{ + binding: PresignedTransferBinding; + method: "GET" | "PUT"; + mediaType?: string; + byteLength?: number; + expectedSha256?: string; + }>; + hardMaxTransferBytes: number; + hardMaxUploadResponseBytes: number; + maxCapabilityTtlMs: number; + minimumRemainingLifetimeMs: number; + nowEpochMs: number; + allowedDataOrigins: ReadonlySet; + allowedDataPathPrefixes: readonly string[]; + allowedQueryParameters: ReadonlySet; + allowedRequestHeaders: ReadonlySet; + allowedResponseHeaders: ReadonlySet; + allowInsecureLocalhost: boolean; +}>; + +function validateCapabilityPayload( + value: unknown, + context: ValidationContext, +): BrowserDataResult { + try { + const payload = strictRecord(value, [ + "allowedQueryParameters", + "binding", + "byteLength", + "capabilityReceipt", + "digestRequestHeader", + "digestResponseHeader", + "expectedSha256", + "expectedResponseByteLength", + "expectedStatus", + "expiresAtEpochMs", + "href", + "maxBytes", + "mediaType", + "method", + "origin", + "path", + "requestHeaders", + "receiptResponseHeader", + "requiredResponseHeaders", + "singleUse", + ]); + if (payload.singleUse !== true || payload.method !== context.expected.method) { + throw new TypeError("Capability method or replay policy is invalid."); + } + const capabilityReceipt = snapshotOpaqueId( + payload.capabilityReceipt, + "capability receipt", + ) as PresignedTransferCapabilityReceipt; + const binding = validateBinding(payload.binding, context.expected.binding); + const mediaType = normalizedMediaType(payload.mediaType); + const byteLength = nonNegativeSafeInteger( + payload.byteLength, + "capability byte length", + ); + const maxBytes = positiveSafeInteger( + payload.maxBytes, + "capability maximum bytes", + ); + const expectedSha256 = normalizedSha256(payload.expectedSha256); + if ( + byteLength > maxBytes || + (context.expected.method === "PUT" && byteLength === 0) || + maxBytes > context.hardMaxTransferBytes || + (context.expected.mediaType !== undefined && + mediaType !== context.expected.mediaType) || + (context.expected.byteLength !== undefined && + byteLength !== context.expected.byteLength) || + (context.expected.expectedSha256 !== undefined && + expectedSha256 !== context.expected.expectedSha256) + ) { + throw new TypeError("Capability transfer binding is invalid."); + } + const expiresAtEpochMs = positiveSafeInteger( + payload.expiresAtEpochMs, + "capability expiry", + ); + const remainingLifetimeMs = + expiresAtEpochMs - context.nowEpochMs; + if ( + !Number.isSafeInteger(context.nowEpochMs) || + context.nowEpochMs < 0 || + !Number.isSafeInteger(remainingLifetimeMs) || + remainingLifetimeMs < context.minimumRemainingLifetimeMs || + remainingLifetimeMs > context.maxCapabilityTtlMs + ) { + return browserDataFailure( + Number.isSafeInteger(remainingLifetimeMs) && + remainingLifetimeMs < + context.minimumRemainingLifetimeMs + ? "EXPIRED_RESOURCE" + : "POLICY_REJECTED", + "PRESIGNED_TRANSFER", + { recovery: "REISSUE_CAPABILITY" }, + ); + } + const href = requiredString(payload.href, 8_192); + const target = new URL(href); + assertSafeUrl(target, context.allowInsecureLocalhost); + const origin = normalizedOrigin( + payload.origin, + context.allowInsecureLocalhost, + ); + const path = validateExactPath(payload.path); + if ( + target.origin !== origin || + target.pathname !== path || + !context.allowedDataOrigins.has(origin) || + !context.allowedDataPathPrefixes.some((prefix) => + path.startsWith(prefix), + ) + ) { + throw new TypeError("Capability origin or path is invalid."); + } + const queryParameters = uniqueStringArray( + payload.allowedQueryParameters, + validateQueryName, + 32, + ); + const actualQueryParameters = [...target.searchParams.keys()]; + if ( + new Set(actualQueryParameters).size !== actualQueryParameters.length || + !sameStringSet(queryParameters, actualQueryParameters) || + queryParameters.some( + (parameter) => !context.allowedQueryParameters.has(parameter), + ) + ) { + throw new TypeError("Capability query binding is invalid."); + } + const requestHeaders = validateHeaders( + payload.requestHeaders, + context.allowedRequestHeaders, + true, + ); + const requiredResponseHeaders = validateHeaders( + payload.requiredResponseHeaders, + context.allowedResponseHeaders, + false, + ); + const digestRequestHeader = nullableBoundHeaderName( + payload.digestRequestHeader, + context.allowedRequestHeaders, + ); + const digestResponseHeader = nullableBoundHeaderName( + payload.digestResponseHeader, + context.allowedResponseHeaders, + ); + const receiptResponseHeader = nullableBoundHeaderName( + payload.receiptResponseHeader, + context.allowedResponseHeaders, + ); + if (context.expected.method === "GET") { + if ( + digestRequestHeader !== null || + digestResponseHeader === null || + receiptResponseHeader !== null || + payload.expectedResponseByteLength !== null + ) { + throw new TypeError("Download digest header binding is invalid."); + } + } else { + if ( + digestRequestHeader === null || + receiptResponseHeader === null || + requestHeaders.find( + (header) => header.name === digestRequestHeader, + )?.value.toLowerCase() !== expectedSha256 || + requestHeaders.find( + (header) => header.name === "content-type", + )?.value.toLowerCase() !== mediaType + ) { + throw new TypeError("Upload acknowledgement binding is invalid."); + } + } + const expectedStatus = positiveSafeInteger( + payload.expectedStatus, + "expected response status", + ); + const expectedResponseByteLength = + context.expected.method === "PUT" + ? nonNegativeSafeInteger( + payload.expectedResponseByteLength, + "expected upload response byte length", + ) + : null; + if ( + (context.expected.method === "GET" && expectedStatus !== 200) || + (context.expected.method === "PUT" && + (![200, 201, 204].includes(expectedStatus) || + expectedResponseByteLength === null || + expectedResponseByteLength > + context.hardMaxUploadResponseBytes || + (expectedStatus === 204 && + expectedResponseByteLength !== 0))) + ) { + throw new TypeError("Expected response status is invalid."); + } + + return browserDataSuccess( + Object.freeze({ + capabilityReceipt, + method: context.expected.method, + binding, + href, + origin, + path, + allowedQueryParameters: queryParameters, + requestHeaders, + requiredResponseHeaders, + digestRequestHeader, + digestResponseHeader, + receiptResponseHeader, + expectedStatus, + expectedResponseByteLength, + mediaType, + byteLength, + maxBytes, + expectedSha256, + expiresAtEpochMs, + }), + ); + } catch { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } +} + +function validateBinding( + value: unknown, + expected: PresignedTransferBinding, +): PresignedTransferBinding { + if (expected.kind === "DOWNLOAD") { + const binding = strictRecord(value, ["kind", "resourceId"]); + if ( + binding.kind !== "DOWNLOAD" || + binding.resourceId !== expected.resourceId + ) { + throw new TypeError("Download binding is invalid."); + } + return Object.freeze({ + kind: "DOWNLOAD", + resourceId: expected.resourceId, + }); + } + const binding = strictRecord(value, [ + "idempotencyKey", + "kind", + "offset", + "partNumber", + "protocol", + "requestBindingSha256", + "sessionId", + "uploadBindingSha256", + ]); + if ( + binding.kind !== "UPLOAD_PART" || + binding.protocol !== expected.protocol || + binding.sessionId !== expected.sessionId || + binding.requestBindingSha256 !== expected.requestBindingSha256 || + binding.uploadBindingSha256 !== expected.uploadBindingSha256 || + binding.partNumber !== expected.partNumber || + binding.offset !== expected.offset || + binding.idempotencyKey !== expected.idempotencyKey + ) { + throw new TypeError("Upload binding is invalid."); + } + return Object.freeze({ ...expected }); +} + +function validateHeaders( + value: unknown, + allowedNames: ReadonlySet, + request: boolean, +): readonly PresignedHeaderBinding[] { + if (!Array.isArray(value) || value.length > 32) { + throw new TypeError("Capability headers are invalid."); + } + const seen = new Set(); + return Object.freeze( + value.map((entry) => { + const header = strictRecord(entry, ["name", "value"]); + const name = normalizedHeaderName(header.name); + const headerValue = requiredString(header.value, 4_096); + if ( + seen.has(name) || + !allowedNames.has(name) || + (request && FORBIDDEN_REQUEST_HEADERS.has(name)) || + /[\r\n\0]/.test(headerValue) + ) { + throw new TypeError("Capability header binding is invalid."); + } + seen.add(name); + return Object.freeze({ name, value: headerValue }); + }), + ); +} + +async function readBoundedJson( + response: Response, + maxBytes: number, + signal: AbortSignal, +): Promise { + const declared = response.headers.get("content-length"); + let declaredLength: number | null = null; + if (declared !== null) { + declaredLength = normalizedContentLength(declared); + if (declaredLength === null) { + cancelResponseBody(response); + throw new ResponseIntegrityError(); + } + if (declaredLength > maxBytes) { + cancelResponseBody(response); + throw new ResponseLimitError(); + } + } + if (!response.body) throw new TypeError("Capability response body is absent."); + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let total = 0; + let text = ""; + let completed = false; + try { + while (true) { + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + const result = await readWithSignal(reader, signal); + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + throw new TypeError("Capability response chunk is invalid."); + } + total += result.value.byteLength; + if (!Number.isSafeInteger(total) || total > maxBytes) { + throw new ResponseLimitError(); + } + text += decoder.decode(result.value, { stream: true }); + } + completed = true; + if (declaredLength !== null && total !== declaredLength) { + throw new ResponseIntegrityError(); + } + text += decoder.decode(); + return JSON.parse(text) as unknown; + } finally { + if (!completed) { + try { + void reader.cancel().catch(() => { + // Response cancellation is best effort after classification. + }); + } catch { + // Response cleanup cannot alter the closed result. + } + } + try { + reader.releaseLock(); + } catch { + // Reader cleanup cannot alter the already classified result. + } + } +} + +function cancelResponseBody(response: Response): void { + try { + void response.body?.cancel().catch(() => { + // Cancellation is best effort after the response is classified. + }); + } catch { + // Response cleanup cannot alter the closed result. + } +} + +function normalizedContentLength(value: string): number | null { + const trimmed = value.trim(); + if (!/^(?:0|[1-9][0-9]{0,15})$/.test(trimmed)) return null; + const length = Number(trimmed); + return Number.isSafeInteger(length) ? length : null; +} + +function readWithSignal( + reader: ReadableStreamDefaultReader, + signal: AbortSignal, +): Promise> { + if (signal.aborted) { + return Promise.reject(new DOMException("Aborted", "AbortError")); + } + return new Promise((resolve, reject) => { + const onAbort = () => + reject(new DOMException("Aborted", "AbortError")); + signal.addEventListener("abort", onAbort, { once: true }); + reader.read().then( + (result) => { + signal.removeEventListener("abort", onAbort); + resolve(result); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function statusFailure( + status: number, +): BrowserDataResult { + let code: BrowserDataFailureCode = "UNAVAILABLE"; + let recovery: BrowserDataRecovery = "RETRY"; + let retryable = status === 429 || status >= 500; + if (status === 401 || status === 403) { + code = "PERMISSION_DENIED"; + recovery = "NONE"; + retryable = false; + } else if (status === 404) { + code = "NOT_FOUND"; + recovery = "NONE"; + retryable = false; + } else if (status === 409) { + code = "CONFLICT"; + recovery = "REISSUE_CAPABILITY"; + retryable = false; + } else if (status === 410) { + code = "EXPIRED_RESOURCE"; + recovery = "REISSUE_CAPABILITY"; + retryable = false; + } else if (status === 413) { + code = "LIMIT_EXCEEDED"; + recovery = "NONE"; + retryable = false; + } else if (status >= 400 && status < 500 && status !== 429) { + code = "POLICY_REJECTED"; + recovery = "NONE"; + retryable = false; + } + return browserDataFailure(code, "PRESIGNED_TRANSFER", { + retryable, + recovery, + }); +} + +function createAbortScope( + external: AbortSignal, + timeoutMs: number, + scheduler: Scheduler, +) { + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(external.reason); + external.addEventListener("abort", onAbort, { once: true }); + if (external.aborted) onAbort(); + const timer = scheduler.setTimeout(() => { + timedOut = true; + controller.abort("timeout"); + }, timeoutMs); + return Object.freeze({ + signal: controller.signal, + timedOut: () => timedOut, + release() { + scheduler.clearTimeout(timer); + external.removeEventListener("abort", onAbort); + }, + }); +} + +function isAbortSignal(value: unknown): value is AbortSignal { + try { + if (!value || typeof value !== "object") return false; + const abortedGetter = Object.getOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", + )?.get; + return Boolean( + abortedGetter && + typeof abortedGetter.call(value) === "boolean" && + typeof (value as AbortSignal).addEventListener === "function" && + typeof (value as AbortSignal).removeEventListener === "function", + ); + } catch { + return false; + } +} + +function byteBucket( + byteLength: number | undefined, +): + | "ZERO" + | "LT1MIB" + | "1_TO_9MIB" + | "10_TO_99MIB" + | "GTE100MIB" + | undefined { + if ( + byteLength === undefined || + !Number.isSafeInteger(byteLength) || + byteLength < 0 + ) { + return undefined; + } + if (byteLength === 0) return "ZERO"; + if (byteLength < 1_048_576) return "LT1MIB"; + if (byteLength < 10_485_760) return "1_TO_9MIB"; + if (byteLength < 104_857_600) return "10_TO_99MIB"; + return "GTE100MIB"; +} + +function strictRecord( + value: unknown, + keys: readonly string[], +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Expected an object."); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new TypeError("Object keys are invalid."); + } + return record; +} + +function uniqueStringArray( + value: unknown, + validate: (item: unknown) => string, + maxCount: number, +): readonly string[] { + if (!Array.isArray(value) || value.length > maxCount) { + throw new TypeError("Expected a bounded string array."); + } + const items = value.map(validate); + if (new Set(items).size !== items.length) { + throw new TypeError("Duplicate string value."); + } + return Object.freeze(items); +} + +function sameStringSet(left: readonly string[], right: readonly string[]) { + if (left.length !== right.length) return false; + const rightSet = new Set(right); + return left.every((value) => rightSet.has(value)); +} + +function normalizedHeaderSet(values: readonly string[]): ReadonlySet { + const normalized = values.map(normalizedHeaderName); + if (new Set(normalized).size !== normalized.length) { + throw new TypeError("Allowed header names contain duplicates."); + } + return new Set(normalized); +} + +function normalizedHeaderName(value: unknown): string { + const name = requiredString(value, 128).toLowerCase(); + if (!HEADER_NAME.test(name)) throw new TypeError("Header name is invalid."); + return name; +} + +function nullableBoundHeaderName( + value: unknown, + allowedNames: ReadonlySet, +): string | null { + if (value === null) return null; + const name = normalizedHeaderName(value); + if (!allowedNames.has(name)) { + throw new TypeError("Bound header name is not allowlisted."); + } + return name; +} + +function validateQueryName(value: unknown): string { + const name = requiredString(value, 128); + if (!QUERY_NAME.test(name)) throw new TypeError("Query name is invalid."); + return name; +} + +function normalizedMediaType(value: unknown): string { + const mediaType = requiredString(value, 127).trim().toLowerCase(); + if (!MEDIA_TYPE.test(mediaType)) throw new TypeError("Media type is invalid."); + return mediaType; +} + +function normalizedSha256(value: unknown): string { + const digest = requiredString(value, 64).toLowerCase(); + if (!SHA256.test(digest)) throw new TypeError("SHA-256 is invalid."); + return digest; +} + +function snapshotOpaqueId(value: unknown, name: string): string { + const id = requiredString(value, 256); + if (!OPAQUE_ID.test(id)) throw new TypeError(`${name} is invalid.`); + return id; +} + +function requiredString(value: unknown, maxLength: number): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > maxLength + ) { + throw new TypeError("String is invalid."); + } + return value; +} + +function positiveSafeInteger(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new TypeError(`${name} is invalid.`); + } + return value as number; +} + +function nonNegativeSafeInteger(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${name} is invalid.`); + } + return value as number; +} + +function validateEndpoint(value: string, allowInsecureLocalhost: boolean) { + const endpoint = new URL(value); + assertSafeUrl(endpoint, allowInsecureLocalhost); + if (endpoint.search || endpoint.hash) { + throw new TypeError("Capability endpoint cannot contain query or fragment."); + } + return endpoint.href; +} + +function normalizedOrigin( + value: unknown, + allowInsecureLocalhost: boolean, +): string { + const originUrl = new URL(requiredString(value, 2_048)); + assertSafeUrl(originUrl, allowInsecureLocalhost); + if ( + originUrl.origin !== originUrl.href.replace(/\/$/, "") && + originUrl.pathname !== "/" + ) { + throw new TypeError("Origin must not contain a path."); + } + if (originUrl.search || originUrl.hash) { + throw new TypeError("Origin must not contain query or fragment."); + } + return originUrl.origin; +} + +function assertSafeUrl(url: URL, allowInsecureLocalhost: boolean): void { + const local = + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && + allowInsecureLocalhost; + if ( + (url.protocol !== "https:" && !(local && url.protocol === "http:")) || + url.username || + url.password || + url.hash + ) { + throw new TypeError("URL is not allowed."); + } +} + +function validatePathPrefix(value: string): string { + const path = validateExactPath(value); + if (!path.endsWith("/")) { + throw new TypeError("Allowed data path prefix must end with a slash."); + } + return path; +} + +function validateExactPath(value: unknown): string { + const path = requiredString(value, 2_048); + if ( + !path.startsWith("/") || + path.includes("\\") || + /[\0\r\n]/.test(path) || + path.split("/").some((segment) => segment === "." || segment === "..") + ) { + throw new TypeError("Path is invalid."); + } + return path; +} + +class ResponseLimitError extends Error {} +class ResponseIntegrityError extends Error {} diff --git a/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts b/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts new file mode 100644 index 0000000..6c2bd70 --- /dev/null +++ b/src/adapters/browser-transfer/presigned/presigned-capability-vault.ts @@ -0,0 +1,273 @@ +import type { + PresignedTransferBinding, + PresignedTransferCapability, + PresignedTransferCapabilityReceipt, + PresignedTransferMethod, + PresignedTransferReplayGuard, +} from "../../../application/ports/browser-transfer/presigned-transfer.ts"; +import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; + +export type PresignedHeaderBinding = Readonly<{ + name: string; + value: string; +}>; + +export type PresignedCapabilityRegistration = Readonly<{ + capabilityReceipt: PresignedTransferCapabilityReceipt; + method: PresignedTransferMethod; + binding: PresignedTransferBinding; + href: string; + origin: string; + path: string; + allowedQueryParameters: readonly string[]; + requestHeaders: readonly PresignedHeaderBinding[]; + requiredResponseHeaders: readonly PresignedHeaderBinding[]; + digestRequestHeader: string | null; + digestResponseHeader: string | null; + receiptResponseHeader: string | null; + expectedStatus: number; + expectedResponseByteLength: number | null; + mediaType: string; + byteLength: number; + maxBytes: number; + expectedSha256: string; + expiresAtEpochMs: number; +}>; + +export type PresignedCapabilityBinding = Readonly< + PresignedCapabilityRegistration & { + capability: PresignedTransferCapability; + } +>; + +export interface PresignedCapabilityVault { + register( + registration: PresignedCapabilityRegistration, + ): BrowserDataResult; + resolve( + capability: PresignedTransferCapability, + ): BrowserDataResult; + /** + * Atomically retires an exact identity after its single-use replay claim. + * The caller may keep the already-resolved binding on its stack for the + * in-flight request, but the vault must no longer retain or resolve it. + */ + consume( + capability: PresignedTransferCapability, + ): BrowserDataResult; + /** + * Best-effort, idempotent retirement for an unused or abandoned identity. + */ + revoke(capability: PresignedTransferCapability): void; + dispose(): void; +} + +export function createPresignedCapabilityVault(options: Readonly<{ + maxActiveCapabilities: number; + now?: () => number; +}>): PresignedCapabilityVault { + if ( + !Number.isSafeInteger(options.maxActiveCapabilities) || + options.maxActiveCapabilities < 1 + ) { + throw new TypeError("Presigned capability vault limit is invalid."); + } + const maxActiveCapabilities = options.maxActiveCapabilities; + const now = options.now ?? Date.now; + const byIdentity = + new WeakMap(); + const byReceipt = + new Map(); + let disposed = false; + + function pruneExpired(): void { + const current = now(); + if (!Number.isSafeInteger(current)) return; + for (const [receipt, capability] of byReceipt) { + if (capability.expiresAtEpochMs <= current) { + byReceipt.delete(receipt); + byIdentity.delete(capability); + } + } + } + + function revoke(capability: PresignedTransferCapability): boolean { + try { + const binding = byIdentity.get(capability); + if (!binding) return false; + byIdentity.delete(capability); + if (byReceipt.get(binding.capabilityReceipt) === capability) { + byReceipt.delete(binding.capabilityReceipt); + } + return true; + } catch { + return false; + } + } + + return Object.freeze({ + register( + registration: PresignedCapabilityRegistration, + ): BrowserDataResult { + if (disposed) { + return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER"); + } + pruneExpired(); + if ( + byReceipt.has(registration.capabilityReceipt) || + byReceipt.size >= maxActiveCapabilities + ) { + return browserDataFailure( + byReceipt.has(registration.capabilityReceipt) + ? "CONFLICT" + : "LIMIT_EXCEEDED", + "PRESIGNED_TRANSFER", + byReceipt.has(registration.capabilityReceipt) + ? { recovery: "REISSUE_CAPABILITY" } + : undefined, + ); + } + + const capability = Object.freeze({ + capabilityReceipt: registration.capabilityReceipt, + method: registration.method, + binding: freezeBinding(registration.binding), + mediaType: registration.mediaType, + byteLength: registration.byteLength, + maxBytes: registration.maxBytes, + expectedSha256: registration.expectedSha256, + expiresAtEpochMs: registration.expiresAtEpochMs, + }) as PresignedTransferCapability; + const binding: PresignedCapabilityBinding = Object.freeze({ + capability, + capabilityReceipt: capability.capabilityReceipt, + method: capability.method, + binding: capability.binding, + href: registration.href, + origin: registration.origin, + path: registration.path, + allowedQueryParameters: Object.freeze([ + ...registration.allowedQueryParameters, + ]), + requestHeaders: freezeHeaders(registration.requestHeaders), + requiredResponseHeaders: freezeHeaders( + registration.requiredResponseHeaders, + ), + digestRequestHeader: registration.digestRequestHeader, + digestResponseHeader: registration.digestResponseHeader, + receiptResponseHeader: registration.receiptResponseHeader, + expectedStatus: registration.expectedStatus, + expectedResponseByteLength: + registration.expectedResponseByteLength, + mediaType: capability.mediaType, + byteLength: capability.byteLength, + maxBytes: capability.maxBytes, + expectedSha256: capability.expectedSha256, + expiresAtEpochMs: capability.expiresAtEpochMs, + }); + byIdentity.set(capability, binding); + byReceipt.set(capability.capabilityReceipt, capability); + return browserDataSuccess(capability); + }, + + resolve( + capability: PresignedTransferCapability, + ): BrowserDataResult { + if (disposed) { + return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER"); + } + try { + const binding = byIdentity.get(capability); + return binding + ? browserDataSuccess(binding) + : browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } catch { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + }, + + consume( + capability: PresignedTransferCapability, + ): BrowserDataResult { + if (disposed) { + return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER"); + } + return revoke(capability) + ? browserDataSuccess(true as const) + : browserDataFailure( + "POLICY_REJECTED", + "PRESIGNED_TRANSFER", + ); + }, + + revoke(capability: PresignedTransferCapability): void { + if (disposed) return; + revoke(capability); + }, + + dispose() { + if (disposed) return; + disposed = true; + for (const capability of byReceipt.values()) { + byIdentity.delete(capability); + } + byReceipt.clear(); + }, + }); +} + +export function createSingleUsePresignedReplayGuard(): + PresignedTransferReplayGuard { + const claimed = new WeakSet(); + return Object.freeze({ + claim( + capability: PresignedTransferCapability, + ): BrowserDataResult { + try { + if (claimed.has(capability)) { + return browserDataFailure("CONFLICT", "PRESIGNED_TRANSFER", { + recovery: "REISSUE_CAPABILITY", + }); + } + claimed.add(capability); + return browserDataSuccess(true as const); + } catch { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + }, + }); +} + +function freezeBinding( + binding: PresignedTransferBinding, +): PresignedTransferBinding { + return binding.kind === "DOWNLOAD" + ? Object.freeze({ + kind: "DOWNLOAD" as const, + resourceId: binding.resourceId, + }) + : Object.freeze({ + kind: "UPLOAD_PART" as const, + protocol: binding.protocol, + sessionId: binding.sessionId, + requestBindingSha256: binding.requestBindingSha256, + uploadBindingSha256: binding.uploadBindingSha256, + partNumber: binding.partNumber, + offset: binding.offset, + idempotencyKey: binding.idempotencyKey, + }); +} + +function freezeHeaders( + headers: readonly PresignedHeaderBinding[], +): readonly PresignedHeaderBinding[] { + return Object.freeze( + headers.map((header) => + Object.freeze({ name: header.name, value: header.value }), + ), + ); +} diff --git a/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts b/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts new file mode 100644 index 0000000..195ad98 --- /dev/null +++ b/src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts @@ -0,0 +1,1188 @@ +import type { + PresignedDownloadCapability, + PresignedDownloadByteSource, + PresignedDownloadSourcePort, + PresignedTransferCapability, + PresignedTransferReplayGuard, + PresignedUploadPartCapability, + PresignedUploadPartOutcome, + PresignedUploadPartPort, +} from "../../../application/ports/browser-transfer/presigned-transfer.ts"; +import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import type { + BrowserDataFailureCode, + BrowserDataObserver, + BrowserDataOperation, + BrowserDataRecovery, + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, + observeBrowserData, +} from "../../browser-file-storage/result.ts"; +import { + createStreamingSha256Verifier, + type StreamingSha256Verifier, +} from "./incremental-sha256.ts"; +import type { + PresignedCapabilityBinding, + PresignedCapabilityVault, +} from "./presigned-capability-vault.ts"; + +const SHA256 = /^[a-f0-9]{64}$/; +const RECEIPT_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/; + +type Scheduler = Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +export type PresignedTransferExecutorOptions = Readonly<{ + vault: PresignedCapabilityVault; + replayGuard: PresignedTransferReplayGuard; + hardMaxTransferBytes: number; + hardMaxChunkBytes: number; + hardMaxUploadResponseBytes: number; + minimumRemainingLifetimeMs: number; + timeoutMs: number; + fetcher?: typeof fetch; + now?: () => number; + scheduler?: Scheduler; + createStreamingVerifier?: ( + expectedSha256: string, + ) => StreamingSha256Verifier; + digestBytes?: ( + bytes: Uint8Array, + ) => Promise; + observer?: BrowserDataObserver; +}>; + +export type PresignedTransferExecutor = Readonly<{ + downloadSources: PresignedDownloadSourcePort; + uploadParts: PresignedUploadPartPort; +}>; + +/** + * Consumes only exact handles issued into the supplied identity vault. Raw + * href/query/header values never enter either public executor method. + */ +export function createPresignedTransferExecutor( + options: PresignedTransferExecutorOptions, +): PresignedTransferExecutor { + const resolve = options.vault.resolve.bind(options.vault); + const consume = options.vault.consume.bind(options.vault); + const claim = options.replayGuard.claim.bind(options.replayGuard); + const hardMaxTransferBytes = positiveSafeInteger( + options.hardMaxTransferBytes, + ); + const hardMaxChunkBytes = positiveSafeInteger( + options.hardMaxChunkBytes, + ); + if (hardMaxChunkBytes > hardMaxTransferBytes) { + throw new TypeError("Presigned chunk limit exceeds transfer limit."); + } + const hardMaxUploadResponseBytes = positiveSafeInteger( + options.hardMaxUploadResponseBytes, + ); + const minimumRemainingLifetimeMs = positiveSafeInteger( + options.minimumRemainingLifetimeMs, + ); + const timeoutMs = positiveSafeInteger(options.timeoutMs); + const fetcher = (options.fetcher ?? fetch).bind(globalThis); + const now = options.now ?? Date.now; + const scheduler = + options.scheduler ?? + ({ + setTimeout: (callback, milliseconds) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle) => + globalThis.clearTimeout( + handle as ReturnType, + ), + } satisfies Scheduler); + const createVerifier = + options.createStreamingVerifier ?? createStreamingSha256Verifier; + if (options.digestBytes === undefined && !globalThis.crypto?.subtle) { + throw new TypeError( + "WebCrypto SHA-256 is required for presigned uploads.", + ); + } + const digestBytes = options.digestBytes ?? digestSha256WithWebCrypto; + const observer = options.observer; + + async function openDownload( + input: Parameters[0], + ): Promise> { + let resourceId: string; + let capability: PresignedDownloadCapability; + let signal: AbortSignal; + try { + resourceId = input.resourceId; + capability = input.capability; + signal = input.signal; + if (!isAbortSignal(signal)) { + throw new TypeError("Abort signal is invalid."); + } + } catch { + return browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER"); + } + if (signal.aborted) { + return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER"); + } + const resolved = resolve(capability); + if (!resolved.ok) return resolved; + const binding = resolved.value; + if ( + binding.method !== "GET" || + binding.binding.kind !== "DOWNLOAD" || + binding.binding.resourceId !== resourceId || + capability.binding.kind !== "DOWNLOAD" || + capability.binding.resourceId !== resourceId || + !validCommonBinding(binding, capability, hardMaxTransferBytes) + ) { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + const active = validateExpiry( + capability, + minimumRemainingLifetimeMs, + now(), + ); + if (!active.ok) return active; + const claimed = claim(capability); + if (!claimed.ok) return claimed; + const consumed = consume(capability); + if (!consumed.ok) return consumed; + + const scope = createAbortScope(signal, timeoutMs, scheduler); + try { + const response = await fetcher(binding.href, { + method: "GET", + headers: headersFor(binding), + credentials: "omit", + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + signal: scope.signal, + }); + const validated = validateDownloadResponse( + response, + binding, + ); + if (!validated.ok) { + cancelBody(response); + scope.release(); + return validated; + } + const source = createDownloadSource({ + response, + binding, + capability, + externalSignal: signal, + scope, + hardMaxChunkBytes, + createVerifier, + observer, + }); + return browserDataSuccess(source); + } catch { + scope.release(); + return transferFailure(signal, scope.timedOut()); + } + } + + async function putUploadPart( + input: Parameters[0], + ): Promise> { + let capability: PresignedUploadPartCapability; + let bytes: Uint8Array; + let request: Readonly<{ + sessionId: string; + requestBindingSha256: string; + uploadBindingSha256: string; + partNumber: number; + offset: number; + byteLength: number; + checksumSha256: string; + idempotencyKey: string; + signal: AbortSignal; + }>; + try { + capability = input.capability; + if (!(input.bytes instanceof Uint8Array)) { + throw new TypeError("Upload bytes are invalid."); + } + if ( + input.bytes.byteLength > hardMaxTransferBytes || + input.bytes.byteLength !== input.byteLength + ) { + return browserDataFailure( + input.bytes.byteLength > hardMaxTransferBytes + ? "LIMIT_EXCEEDED" + : "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + if (!isAbortSignal(input.signal)) { + throw new TypeError("Abort signal is invalid."); + } + // Snapshot before hashing or network awaits so caller mutation cannot + // alter the verified bytes after the capability check. + bytes = input.bytes.slice(); + request = Object.freeze({ + sessionId: opaqueId(input.sessionId), + requestBindingSha256: normalizedSha256( + input.requestBindingSha256, + ), + uploadBindingSha256: normalizedSha256( + input.uploadBindingSha256, + ), + partNumber: positiveSafeInteger(input.partNumber), + offset: nonNegativeSafeInteger(input.offset), + byteLength: positiveSafeInteger(input.byteLength), + checksumSha256: normalizedSha256(input.checksumSha256), + idempotencyKey: opaqueId(input.idempotencyKey), + signal: input.signal, + }); + } catch { + return browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER"); + } + if (request.signal.aborted) { + return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER"); + } + const resolved = resolve(capability); + if (!resolved.ok) return resolved; + const binding = resolved.value; + if ( + binding.method !== "PUT" || + binding.binding.kind !== "UPLOAD_PART" || + capability.binding.kind !== "UPLOAD_PART" || + binding.binding.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !validCommonBinding(binding, capability, hardMaxTransferBytes) || + binding.binding.sessionId !== request.sessionId || + binding.binding.requestBindingSha256 !== + request.requestBindingSha256 || + binding.binding.uploadBindingSha256 !== + request.uploadBindingSha256 || + binding.binding.partNumber !== request.partNumber || + binding.binding.offset !== request.offset || + binding.binding.idempotencyKey !== request.idempotencyKey || + capability.byteLength !== request.byteLength || + capability.expectedSha256 !== request.checksumSha256 || + bytes.byteLength !== capability.byteLength + ) { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + let actualDigest: string; + try { + actualDigest = normalizedSha256(await digestBytes(bytes)); + } catch { + return browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + if (actualDigest !== capability.expectedSha256) { + return browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + if (request.signal.aborted) { + return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER"); + } + const active = validateExpiry( + capability, + minimumRemainingLifetimeMs, + now(), + ); + if (!active.ok) return active; + const claimed = claim(capability); + if (!claimed.ok) return claimed; + const consumed = consume(capability); + if (!consumed.ok) return consumed; + + const scope = createAbortScope(request.signal, timeoutMs, scheduler); + try { + const response = await fetcher(binding.href, { + method: "PUT", + headers: headersFor(binding), + body: bytes.buffer, + credentials: "omit", + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + signal: scope.signal, + }); + const validated = validateUploadResponse( + response, + binding, + hardMaxUploadResponseBytes, + ); + if (!validated.ok) { + cancelBody(response); + return validated; + } + const drained = await drainUploadResponse( + response, + validated.value.responseByteLength, + hardMaxUploadResponseBytes, + scope.signal, + ); + if (!drained.ok) return drained; + return browserDataSuccess( + Object.freeze({ + bytesWritten: bytes.byteLength, + checksumSha256: capability.expectedSha256, + receiptToken: validated.value.receiptToken, + } satisfies PresignedUploadPartOutcome), + ); + } catch { + return transferFailure(request.signal, scope.timedOut()); + } finally { + scope.release(); + } + } + + const downloadSources: PresignedDownloadSourcePort = Object.freeze({ + async open( + input: Parameters[0], + ) { + let result: BrowserDataResult; + try { + result = await openDownload(input); + } catch { + result = browserDataFailure( + "UNAVAILABLE", + "PRESIGNED_TRANSFER", + { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }, + ); + } + observeTransferResult( + observer, + "PRESIGNED_TRANSFER", + result, + result.ok + ? result.value.byteLength + : safeInputByteLength(input), + ); + return result; + }, + }); + + const uploadParts: PresignedUploadPartPort = Object.freeze({ + async put( + input: Parameters[0], + ) { + let result: BrowserDataResult; + try { + result = await putUploadPart(input); + } catch { + result = browserDataFailure( + "UNAVAILABLE", + "PRESIGNED_TRANSFER", + { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }, + ); + } + observeTransferResult( + observer, + "UPLOAD_PART", + result, + result.ok + ? result.value.bytesWritten + : safeInputByteLength(input), + ); + return result; + }, + }); + + return Object.freeze({ downloadSources, uploadParts }); +} + +function createDownloadSource(input: Readonly<{ + response: Response; + binding: PresignedCapabilityBinding; + capability: PresignedDownloadCapability; + externalSignal: AbortSignal; + scope: ReturnType; + hardMaxChunkBytes: number; + createVerifier: ( + expectedSha256: string, + ) => StreamingSha256Verifier; + observer: BrowserDataObserver | undefined; +}>): PresignedDownloadByteSource { + let started = false; + return Object.freeze({ + byteLength: input.capability.byteLength, + capability: input.capability, + integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const, + async *stream( + consumerSignal: AbortSignal, + ): AsyncIterable> { + if (!isAbortSignal(consumerSignal)) { + started = true; + cancelBody(input.response); + input.scope.release(); + const failure = browserDataFailure( + "INVALID_INPUT", + "PRESIGNED_TRANSFER", + ); + observeTransferResult(input.observer, "DOWNLOAD", failure, 0); + yield failure; + return; + } + if (started) { + const failure = browserDataFailure( + "CONFLICT", + "PRESIGNED_TRANSFER", + { + recovery: "REISSUE_CAPABILITY", + }, + ); + observeTransferResult(input.observer, "DOWNLOAD", failure, 0); + yield failure; + return; + } + started = true; + let combined: + | ReturnType + | undefined; + let reader: + | ReadableStreamDefaultReader + | undefined; + let completed = false; + let transferred = 0; + let terminalFailureCode: BrowserDataFailureCode | undefined; + const fail = ( + code: BrowserDataFailureCode, + options: Readonly<{ + retryable?: boolean; + recovery?: BrowserDataRecovery; + }> = {}, + ): BrowserDataResult => { + terminalFailureCode = code; + return browserDataFailure(code, "PRESIGNED_TRANSFER", options); + }; + try { + combined = combineConsumerAbort( + input.scope, + consumerSignal, + ); + const verifier = input.createVerifier( + input.capability.expectedSha256, + ); + if (!input.response.body) { + let verified = false; + try { + verified = + input.capability.byteLength === 0 && verifier.verify(); + } catch { + verified = false; + } + if (verified) { + completed = true; + return; + } + yield fail("INTEGRITY_FAILED"); + return; + } + reader = input.response.body.getReader(); + while (true) { + if ( + input.externalSignal.aborted || + consumerSignal.aborted + ) { + yield fail("ABORTED"); + return; + } + if (input.scope.timedOut()) { + yield fail("UNAVAILABLE", { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }); + return; + } + const result = await readWithSignal( + reader, + input.scope.signal, + ); + if (result.done) break; + const chunk = result.value; + if (!(chunk instanceof Uint8Array)) { + yield fail("CORRUPT_DATA"); + return; + } + const chunkEnd = transferred + chunk.byteLength; + if ( + !Number.isSafeInteger(chunkEnd) || + chunkEnd > input.capability.maxBytes + ) { + yield fail("LIMIT_EXCEEDED"); + return; + } + if (chunkEnd > input.capability.byteLength) { + yield fail("INTEGRITY_FAILED"); + return; + } + // Browser fetch chunk sizing is implementation-defined. Re-slice into + // owned bounded chunks instead of rejecting a valid large chunk. + for ( + let offset = 0; + offset < chunk.byteLength; + offset += input.hardMaxChunkBytes + ) { + if ( + input.externalSignal.aborted || + consumerSignal.aborted + ) { + yield fail("ABORTED"); + return; + } + if (input.scope.timedOut()) { + yield fail("UNAVAILABLE", { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }); + return; + } + const owned = chunk + .subarray( + offset, + Math.min( + chunk.byteLength, + offset + input.hardMaxChunkBytes, + ), + ) + .slice(); + try { + verifier.update(owned); + } catch { + yield fail("INTEGRITY_FAILED"); + return; + } + transferred += owned.byteLength; + yield browserDataSuccess(owned); + } + } + let verified = false; + try { + verified = verifier.verify(); + } catch { + verified = false; + } + if ( + transferred !== input.capability.byteLength || + !verified + ) { + yield fail("INTEGRITY_FAILED"); + return; + } + completed = true; + } catch { + if ( + input.externalSignal.aborted || + consumerSignal.aborted + ) { + yield fail("ABORTED"); + } else if (input.scope.timedOut()) { + yield fail("UNAVAILABLE", { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }); + } else { + yield fail("NOT_READABLE", { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }); + } + } finally { + combined?.release(); + if (!completed) { + if (reader) cancelReader(reader); + else cancelBody(input.response); + } + try { + reader?.releaseLock(); + } catch { + // Reader cleanup cannot change stream success or failure. + } + input.scope.release(); + observeBrowserData(input.observer, { + operation: "DOWNLOAD", + outcome: completed + ? "SUCCEEDED" + : terminalFailureCode + ? "FAILED" + : "DEGRADED", + ...(terminalFailureCode + ? { failureCode: terminalFailureCode } + : {}), + byteBucket: byteBucket(transferred), + }); + } + }, + }); +} + +function validateDownloadResponse( + response: Response, + binding: PresignedCapabilityBinding, +): BrowserDataResult { + if ( + response.redirected || + response.type === "opaqueredirect" || + !responseUrlMatches( + response, + binding.href, + ) + ) { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + if (response.status !== binding.expectedStatus) { + return statusFailure(response.status); + } + if (!validateRequiredHeaders(response, binding)) { + return browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + const mediaType = response.headers + .get("content-type") + ?.trim() + .toLowerCase(); + const declaredLength = response.headers.get("content-length"); + const digestHeader = binding.digestResponseHeader; + const contentEncoding = response.headers.get("content-encoding"); + if ( + mediaType !== binding.mediaType || + declaredLength === null || + Number(declaredLength) !== binding.byteLength || + digestHeader === null || + response.headers.get(digestHeader)?.trim().toLowerCase() !== + binding.expectedSha256 || + (contentEncoding !== null && + contentEncoding.trim().toLowerCase() !== "identity") || + response.headers.has("content-range") || + (binding.byteLength > 0 && !response.body) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + return browserDataSuccess(true); +} + +function validateUploadResponse( + response: Response, + binding: PresignedCapabilityBinding, + hardMaxUploadResponseBytes: number, +): BrowserDataResult< + Readonly<{ + receiptToken: string; + responseByteLength: number; + }> +> { + if ( + response.redirected || + response.type === "opaqueredirect" || + !responseUrlMatches( + response, + binding.href, + ) + ) { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + if (response.status !== binding.expectedStatus) { + return statusFailure(response.status); + } + if (!validateRequiredHeaders(response, binding)) { + return browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + const headerName = binding.receiptResponseHeader; + const expectedResponseByteLength = + binding.expectedResponseByteLength; + const declaredLength = normalizedContentLength( + response.headers.get("content-length"), + response.status === 204 && expectedResponseByteLength === 0, + ); + if ( + !headerName || + expectedResponseByteLength === null || + expectedResponseByteLength > hardMaxUploadResponseBytes || + declaredLength === null || + declaredLength !== expectedResponseByteLength || + (expectedResponseByteLength > 0 && !response.body) + ) { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + const receipt = normalizeReceiptToken( + response.headers.get(headerName), + ); + return receipt + ? browserDataSuccess( + Object.freeze({ + receiptToken: receipt, + responseByteLength: expectedResponseByteLength, + }), + ) + : browserDataFailure("INTEGRITY_FAILED", "PRESIGNED_TRANSFER"); +} + +async function drainUploadResponse( + response: Response, + expectedByteLength: number, + hardMaxBytes: number, + signal: AbortSignal, +): Promise> { + if (!response.body) { + return expectedByteLength === 0 + ? browserDataSuccess(true) + : browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + const reader = response.body.getReader(); + let completed = false; + let total = 0; + try { + while (true) { + const result = await readWithSignal(reader, signal); + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + return browserDataFailure( + "CORRUPT_DATA", + "PRESIGNED_TRANSFER", + ); + } + total += result.value.byteLength; + if (!Number.isSafeInteger(total) || total > hardMaxBytes) { + return browserDataFailure( + "LIMIT_EXCEEDED", + "PRESIGNED_TRANSFER", + ); + } + if (total > expectedByteLength) { + return browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + } + if (total !== expectedByteLength) { + return browserDataFailure( + "INTEGRITY_FAILED", + "PRESIGNED_TRANSFER", + ); + } + completed = true; + return browserDataSuccess(true); + } finally { + if (!completed) cancelReader(reader); + try { + reader.releaseLock(); + } catch { + // Reader cleanup cannot alter the already classified result. + } + } +} + +function validateRequiredHeaders( + response: Response, + binding: PresignedCapabilityBinding, +): boolean { + return binding.requiredResponseHeaders.every( + (header) => response.headers.get(header.name) === header.value, + ); +} + +function normalizedContentLength( + value: string | null, + allowImplicitZero: boolean, +): number | null { + if (value === null) return allowImplicitZero ? 0 : null; + const trimmed = value.trim(); + if (!/^(?:0|[1-9][0-9]{0,15})$/.test(trimmed)) return null; + const length = Number(trimmed); + return Number.isSafeInteger(length) ? length : null; +} + +function headersFor(binding: PresignedCapabilityBinding): Headers { + const headers = new Headers(); + for (const header of binding.requestHeaders) { + headers.set(header.name, header.value); + } + return headers; +} + +function validCommonBinding( + binding: PresignedCapabilityBinding, + capability: PresignedTransferCapability, + hardMaxTransferBytes: number, +): boolean { + return ( + binding.capability === capability && + binding.capabilityReceipt === capability.capabilityReceipt && + binding.method === capability.method && + binding.binding === capability.binding && + binding.mediaType === capability.mediaType && + binding.byteLength === capability.byteLength && + binding.maxBytes === capability.maxBytes && + binding.expectedSha256 === capability.expectedSha256 && + binding.expiresAtEpochMs === capability.expiresAtEpochMs && + capability.byteLength >= 0 && + capability.byteLength <= capability.maxBytes && + capability.maxBytes <= hardMaxTransferBytes && + SHA256.test(capability.expectedSha256) && + Object.isFrozen(capability) && + Object.isFrozen(capability.binding) + ); +} + +function validateExpiry( + capability: PresignedTransferCapability, + minimumRemainingLifetimeMs: number, + nowEpochMs: number, +): BrowserDataResult { + const remainingLifetimeMs = + capability.expiresAtEpochMs - nowEpochMs; + if ( + !Number.isSafeInteger(nowEpochMs) || + nowEpochMs < 0 || + !Number.isSafeInteger(remainingLifetimeMs) + ) { + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + } + return remainingLifetimeMs >= minimumRemainingLifetimeMs + ? browserDataSuccess(true) + : browserDataFailure( + "EXPIRED_RESOURCE", + "PRESIGNED_TRANSFER", + { recovery: "REISSUE_CAPABILITY" }, + ); +} + +function statusFailure( + status: number, +): BrowserDataResult { + let code: BrowserDataFailureCode = "UNAVAILABLE"; + let recovery: BrowserDataRecovery = "REISSUE_CAPABILITY"; + let retryable = status === 429 || status >= 500; + if (status === 401 || status === 403) { + code = "PERMISSION_DENIED"; + recovery = "REISSUE_CAPABILITY"; + retryable = false; + } else if (status === 404) { + code = "NOT_FOUND"; + recovery = "NONE"; + retryable = false; + } else if (status === 409) { + code = "CONFLICT"; + retryable = false; + } else if (status === 410) { + code = "EXPIRED_RESOURCE"; + retryable = false; + } else if (status === 413) { + code = "LIMIT_EXCEEDED"; + recovery = "NONE"; + retryable = false; + } else if (status >= 400 && status < 500 && status !== 429) { + code = "POLICY_REJECTED"; + recovery = "NONE"; + retryable = false; + } + return browserDataFailure(code, "PRESIGNED_TRANSFER", { + retryable, + recovery, + }); +} + +function transferFailure( + externalSignal: AbortSignal, + timedOut: boolean, +): BrowserDataResult { + if (externalSignal.aborted) { + return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER"); + } + return browserDataFailure( + timedOut ? "UNAVAILABLE" : "NOT_READABLE", + "PRESIGNED_TRANSFER", + { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }, + ); +} + +function createAbortScope( + external: AbortSignal, + timeoutMs: number, + scheduler: Scheduler, +) { + const controller = new AbortController(); + let timedOut = false; + let released = false; + const onAbort = () => controller.abort(external.reason); + const releaseListener = () => { + external.removeEventListener("abort", onAbort); + }; + external.addEventListener("abort", onAbort, { once: true }); + if (external.aborted) onAbort(); + const timer = scheduler.setTimeout(() => { + timedOut = true; + controller.abort("timeout"); + releaseListener(); + }, timeoutMs); + return Object.freeze({ + signal: controller.signal, + timedOut: () => timedOut, + abort(reason?: unknown) { + controller.abort(reason); + }, + release() { + if (released) return; + released = true; + scheduler.clearTimeout(timer); + releaseListener(); + }, + }); +} + +function combineConsumerAbort( + scope: ReturnType, + consumer: AbortSignal, +) { + const onAbort = () => scope.abort(consumer.reason); + consumer.addEventListener("abort", onAbort, { once: true }); + if (consumer.aborted) onAbort(); + return Object.freeze({ + release() { + consumer.removeEventListener("abort", onAbort); + }, + }); +} + +function cancelBody(response: Response): void { + try { + void response.body?.cancel().catch(() => { + // Cancellation is best effort after the result is classified. + }); + } catch { + // A response already classified as failure/success is not reclassified by + // best-effort body cancellation. + } +} + +function cancelReader( + reader: ReadableStreamDefaultReader, +): void { + try { + void reader.cancel().catch(() => { + // Cancellation is best effort after the result is classified. + }); + } catch { + // Reader cancellation cannot alter the terminal closed result. + } +} + +function readWithSignal( + reader: ReadableStreamDefaultReader, + signal: AbortSignal, +): Promise> { + if (signal.aborted) { + return Promise.reject(new DOMException("Aborted", "AbortError")); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(new DOMException("Aborted", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + reader.read().then( + (result) => { + signal.removeEventListener("abort", onAbort); + resolve(result); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function normalizeReceiptToken(value: string | null): string | null { + if (!value) return null; + const trimmed = value.trim(); + const unquoted = + trimmed.length >= 2 && + trimmed.startsWith("\"") && + trimmed.endsWith("\"") + ? trimmed.slice(1, -1) + : trimmed; + return RECEIPT_TOKEN.test(unquoted) && !unquoted.includes("://") + ? unquoted + : null; +} + +function responseUrlMatches( + response: Response, + expectedHref: string, +): boolean { + if (response.url.length === 0) return false; + try { + return new URL(response.url).href === new URL(expectedHref).href; + } catch { + return false; + } +} + +function normalizedSha256(value: unknown): string { + if (typeof value !== "string") throw new TypeError("SHA-256 is invalid."); + const normalized = value.toLowerCase(); + if (!SHA256.test(normalized)) throw new TypeError("SHA-256 is invalid."); + return normalized; +} + +function opaqueId(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 256 || + !/^[a-z0-9][a-z0-9._:-]*$/i.test(value) + ) { + throw new TypeError("Opaque identifier is invalid."); + } + return value; +} + +function positiveSafeInteger(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new TypeError("Expected a positive safe integer."); + } + return value as number; +} + +function nonNegativeSafeInteger(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError("Expected a non-negative safe integer."); + } + return value as number; +} + +async function digestSha256WithWebCrypto( + bytes: Uint8Array, +): Promise { + if (!(bytes instanceof Uint8Array) || !globalThis.crypto?.subtle) { + throw new TypeError("WebCrypto SHA-256 is unavailable."); + } + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + bytes.slice(), + ); + return Array.from(new Uint8Array(digest), (value) => + value.toString(16).padStart(2, "0"), + ).join(""); +} + +function isAbortSignal(value: unknown): value is AbortSignal { + try { + if (!value || typeof value !== "object") return false; + const abortedGetter = Object.getOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", + )?.get; + return Boolean( + abortedGetter && + typeof abortedGetter.call(value) === "boolean" && + typeof (value as AbortSignal).addEventListener === "function" && + typeof (value as AbortSignal).removeEventListener === "function", + ); + } catch { + return false; + } +} + +function safeInputByteLength(value: unknown): number | undefined { + try { + if (!value || typeof value !== "object") return undefined; + const input = value as Readonly<{ + byteLength?: unknown; + bytes?: unknown; + capability?: unknown; + }>; + if ( + Number.isSafeInteger(input.byteLength) && + (input.byteLength as number) >= 0 + ) { + return input.byteLength as number; + } + if (input.bytes instanceof Uint8Array) { + return input.bytes.byteLength; + } + if ( + input.capability && + typeof input.capability === "object" && + Number.isSafeInteger( + (input.capability as Readonly<{ byteLength?: unknown }>) + .byteLength, + ) + ) { + const byteLength = ( + input.capability as Readonly<{ byteLength: number }> + ).byteLength; + return byteLength >= 0 ? byteLength : undefined; + } + } catch { + return undefined; + } + return undefined; +} + +function observeTransferResult( + observer: BrowserDataObserver | undefined, + operation: BrowserDataOperation, + result: BrowserDataResult, + byteLength?: number, +): void { + const bucket = byteBucket(byteLength); + observeBrowserData(observer, { + operation, + outcome: result.ok ? "SUCCEEDED" : "FAILED", + ...(!result.ok ? { failureCode: result.error.code } : {}), + ...(bucket !== undefined ? { byteBucket: bucket } : {}), + }); +} + +function byteBucket( + byteLength: number | undefined, +): + | "ZERO" + | "LT1MIB" + | "1_TO_9MIB" + | "10_TO_99MIB" + | "GTE100MIB" + | undefined { + if ( + byteLength === undefined || + !Number.isSafeInteger(byteLength) || + byteLength < 0 + ) { + return undefined; + } + if (byteLength === 0) return "ZERO"; + if (byteLength < 1_048_576) return "LT1MIB"; + if (byteLength < 10_485_760) return "1_TO_9MIB"; + if (byteLength < 104_857_600) return "10_TO_99MIB"; + return "GTE100MIB"; +} diff --git a/src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts b/src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts new file mode 100644 index 0000000..b954f69 --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts @@ -0,0 +1,185 @@ +import type { + ResumableUploadCheckpoint, + UploadFileFingerprint, + UploadPartDescriptor, + UploadPartReceipt, +} from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts"; + +export const SAFE_UPLOAD_KEY = /^[A-Za-z0-9][A-Za-z0-9._~:-]{7,127}$/u; +export const SAFE_REGISTRY_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +export const SAFE_OPAQUE_ID = /^[A-Za-z0-9_-]{8,512}$/u; +export const SHA256_HEX = /^[a-f0-9]{64}$/u; +export const RECEIPT_TOKEN = + /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/u; +export const MEDIA_TYPE = + /^[a-z0-9!#$&^_.+-]{1,63}\/[a-z0-9!#$&^_.+-]{1,63}$/u; + +const CHECKPOINT_KEYS = Object.freeze([ + "schemaVersion", + "protocol", + "revision", + "state", + "uploadKey", + "requestBindingSha256", + "fingerprint", + "sessionId", + "sessionExpiresAtEpochMs", + "sessionMaxConcurrency", + "acceptedParts", + "updatedAtEpochMs", +] as const); +const FINGERPRINT_KEYS = Object.freeze([ + "algorithm", + "digestHex", + "byteLength", + "partSizeBytes", + "partCount", +] as const); +const PART_KEYS = Object.freeze([ + "partNumber", + "offset", + "byteLength", + "checksumSha256", +] as const); +const RECEIPT_KEYS = Object.freeze([...PART_KEYS, "receiptToken"] as const); + +export function isUploadFileFingerprint( + value: unknown, +): value is UploadFileFingerprint { + if (!exactRecord(value, FINGERPRINT_KEYS)) return false; + return ( + value.algorithm === "SHA-256-PARTS-V1" && + typeof value.digestHex === "string" && + SHA256_HEX.test(value.digestHex) && + positiveSafeInteger(value.byteLength) && + positiveSafeInteger(value.partSizeBytes) && + positiveSafeInteger(value.partCount) && + Math.ceil(value.byteLength / value.partSizeBytes) === + value.partCount + ); +} + +export function isUploadPartDescriptor( + value: unknown, +): value is UploadPartDescriptor { + if (!exactRecord(value, PART_KEYS)) return false; + return ( + positiveSafeInteger(value.partNumber) && + nonNegativeSafeInteger(value.offset) && + positiveSafeInteger(value.byteLength) && + typeof value.checksumSha256 === "string" && + SHA256_HEX.test(value.checksumSha256) + ); +} + +export function isUploadPartReceipt( + value: unknown, +): value is UploadPartReceipt { + return ( + exactRecord(value, RECEIPT_KEYS) && + isUploadPartDescriptor({ + partNumber: value.partNumber, + offset: value.offset, + byteLength: value.byteLength, + checksumSha256: value.checksumSha256, + }) && + typeof value.receiptToken === "string" && + isSafeUploadReceiptToken(value.receiptToken) + ); +} + +export function isSafeUploadReceiptToken(value: string): boolean { + return RECEIPT_TOKEN.test(value) && !value.includes("://"); +} + +export function isResumableUploadCheckpoint( + value: unknown, +): value is ResumableUploadCheckpoint { + if (!exactRecord(value, CHECKPOINT_KEYS)) return false; + if ( + value.schemaVersion !== 1 || + value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !positiveSafeInteger(value.revision) || + (value.state !== "ACTIVE" && value.state !== "ABORT_PENDING") || + typeof value.uploadKey !== "string" || + !SAFE_UPLOAD_KEY.test(value.uploadKey) || + typeof value.requestBindingSha256 !== "string" || + !SHA256_HEX.test(value.requestBindingSha256) || + !isUploadFileFingerprint(value.fingerprint) || + typeof value.sessionId !== "string" || + !SAFE_OPAQUE_ID.test(value.sessionId) || + !positiveSafeInteger(value.sessionExpiresAtEpochMs) || + !positiveSafeInteger(value.sessionMaxConcurrency) || + !Array.isArray(value.acceptedParts) || + value.acceptedParts.length > value.fingerprint.partCount || + !nonNegativeSafeInteger(value.updatedAtEpochMs) + ) { + return false; + } + let previousPartNumber = 0; + for (const part of value.acceptedParts) { + if ( + !isUploadPartReceipt(part) || + part.partNumber <= previousPartNumber || + !partMatchesFingerprint(part, value.fingerprint) + ) { + return false; + } + previousPartNumber = part.partNumber; + } + return true; +} + +export function partMatchesFingerprint( + part: UploadPartDescriptor, + fingerprint: UploadFileFingerprint, +): boolean { + if ( + part.partNumber < 1 || + part.partNumber > fingerprint.partCount || + part.offset !== (part.partNumber - 1) * fingerprint.partSizeBytes + ) { + return false; + } + const remaining = fingerprint.byteLength - part.offset; + return ( + remaining > 0 && + part.byteLength === Math.min(fingerprint.partSizeBytes, remaining) + ); +} + +export function samePart( + left: UploadPartDescriptor, + right: UploadPartDescriptor, +): boolean { + return ( + left.partNumber === right.partNumber && + left.offset === right.offset && + left.byteLength === right.byteLength && + left.checksumSha256 === right.checksumSha256 + ); +} + +function exactRecord( + value: unknown, + keys: Keys, +): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function positiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function nonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} diff --git a/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts b/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts new file mode 100644 index 0000000..8747e6b --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts @@ -0,0 +1,729 @@ +import type { + UploadProviderFailure, + UploadProviderResult, +} from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import type { + BrowserDataFailureCode, + BrowserDataRecovery, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import type { + ResumableUploadControlOperation, + ResumableUploadJsonTransport, +} from "./http-control-plane-adapter.ts"; + +export type ResumableUploadEndpointMap = Readonly< + Record +>; + +export type ResumableUploadFetchTransportDependencies = Readonly<{ + endpoints: ResumableUploadEndpointMap; + allowedOrigins: readonly string[]; + credentials: "include" | "same-origin"; + fetcher?: typeof fetch; + requestHeaders?: readonly Readonly<{ name: string; value: string }>[]; + timeoutMs?: number; + maxRequestBytes?: number; + maxResponseBytes?: number; + maxRetryAfterMs?: number; + expectedSuccessStatuses?: Partial< + Readonly> + >; +}>; + +const DEFAULT_SUCCESS_STATUSES: Readonly< + Record +> = Object.freeze({ + CREATE_SESSION: 201, + GET_STATUS: 200, + COMPLETE: 200, + ABORT: 200, +}); +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; +const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const DEFAULT_MAX_RETRY_AFTER_MS = 30_000; +const ABSOLUTE_MAX_JSON_BYTES = 4 * 1024 * 1024; +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; +const OPERATIONS = Object.freeze( + Object.keys( + DEFAULT_SUCCESS_STATUSES, + ) as ResumableUploadControlOperation[], +); +const OPERATION_SET: ReadonlySet = new Set(OPERATIONS); + +export function createResumableUploadFetchJsonTransport( + input: ResumableUploadFetchTransportDependencies, +): ResumableUploadJsonTransport { + const endpoints = snapshotEndpoints(input.endpoints, input.allowedOrigins); + const fetcher = + input.fetcher ?? globalThis.fetch?.bind(globalThis); + if ( + typeof fetcher !== "function" || + !["include", "same-origin"].includes(input.credentials) + ) { + throw new TypeError("Upload fetch transport dependency is invalid."); + } + const headers = snapshotHeaders(input.requestHeaders ?? []); + const timeoutMs = boundedPositiveInteger( + input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + 1, + 120_000, + "timeout", + ); + const maxRequestBytes = boundedPositiveInteger( + input.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES, + 1, + ABSOLUTE_MAX_JSON_BYTES, + "request bytes", + ); + const maxResponseBytes = boundedPositiveInteger( + input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + 1, + ABSOLUTE_MAX_JSON_BYTES, + "response bytes", + ); + const maxRetryAfterMs = boundedPositiveInteger( + input.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS, + 1, + 60_000, + "Retry-After", + ); + const statuses = snapshotStatuses(input.expectedSuccessStatuses); + + const transport: ResumableUploadJsonTransport = { + async execute(request) { + const snapshot = snapshotTransportRequest(request); + if (!snapshot) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_SESSION", + ); + } + const { operation, body: requestBody, signal } = snapshot; + const endpoint = endpoints[operation]; + let body: string; + try { + body = JSON.stringify(requestBody); + } catch { + return failure( + "INVALID_INPUT", + operation, + false, + "NONE", + ); + } + if (typeof body !== "string") { + return failure("INVALID_INPUT", operation, false, "NONE"); + } + const requestBytes = new TextEncoder().encode(body).byteLength; + if ( + requestBytes < 2 || + requestBytes > maxRequestBytes || + signal.aborted + ) { + return signal.aborted + ? failure( + "ABORTED", + operation, + false, + "NONE", + ) + : failure( + "LIMIT_EXCEEDED", + operation, + false, + "NONE", + ); + } + const attempt = createFetchAttempt(signal, timeoutMs); + try { + const fetchPromise = fetcher(endpoint, { + method: "POST", + headers: headersFor(headers), + body, + signal: attempt.signal, + credentials: input.credentials, + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + mode: new URL(endpoint).origin === globalThis.location?.origin + ? "same-origin" + : "cors", + }); + const raced = await Promise.race([ + fetchPromise.then( + (value) => { + if (attempt.terminalKind()) { + cancelResponseBody(value); + } + return { kind: "RESPONSE" as const, value }; + }, + () => ({ kind: "FAILED" as const }), + ), + attempt.terminal, + ]); + if (raced.kind !== "RESPONSE") { + return attemptFailure(attempt, operation); + } + const response = raced.value; + if ( + response.redirected || + response.type === "opaqueredirect" || + !sameUrl(response.url, endpoint) + ) { + cancelResponseBody(response); + return failure( + "POLICY_REJECTED", + operation, + false, + "NONE", + ); + } + if (response.status !== statuses[operation]) { + const failed = statusFailure( + response, + operation, + maxRetryAfterMs, + ); + cancelResponseBody(response); + return failed; + } + if (!jsonContentType(response.headers.get("content-type"))) { + cancelResponseBody(response); + return failure( + "CORRUPT_DATA", + operation, + false, + "RECONCILE", + ); + } + const decoded = await readBoundedJson( + response, + maxResponseBytes, + operation, + attempt, + ); + return decoded.ok + ? browserDataSuccess(decoded.value) + : decoded; + } catch { + return attemptFailure(attempt, operation); + } finally { + attempt.release(); + } + }, + }; + return Object.freeze(transport); +} + +function snapshotEndpoints( + value: ResumableUploadEndpointMap, + allowedOriginValues: readonly string[], +): ResumableUploadEndpointMap { + if ( + !value || + typeof value !== "object" || + !Array.isArray(allowedOriginValues) || + allowedOriginValues.length < 1 + ) { + throw new TypeError("Upload endpoints are invalid."); + } + const allowedOrigins = new Set( + allowedOriginValues.map((origin) => { + const parsed = new URL(origin); + if (parsed.origin !== parsed.href.replace(/\/$/u, "")) { + throw new TypeError("Allowed upload origin is invalid."); + } + return parsed.origin; + }), + ); + const snapshot = Object.create(null) as Record< + ResumableUploadControlOperation, + string + >; + for (const operation of OPERATIONS) { + const endpoint = value[operation]; + const parsed = new URL(endpoint); + if ( + parsed.protocol !== "https:" || + !allowedOrigins.has(parsed.origin) || + parsed.username || + parsed.password || + parsed.hash || + parsed.search + ) { + throw new TypeError("Upload endpoint is outside policy."); + } + snapshot[operation] = parsed.href; + } + if (Object.keys(value).length !== 4) { + throw new TypeError("Upload endpoint map is invalid."); + } + return Object.freeze(snapshot); +} + +function snapshotHeaders( + input: readonly Readonly<{ name: string; value: string }>[], +): readonly Readonly<{ name: string; value: string }>[] { + const seen = new Set(); + const forbidden = new Set([ + "accept", + "authorization", + "connection", + "content-type", + "content-length", + "cookie", + "host", + "origin", + "proxy-authorization", + "referer", + "set-cookie", + "transfer-encoding", + ]); + return Object.freeze( + input.map((header) => { + const name = header.name.toLowerCase(); + if ( + !HEADER_NAME.test(name) || + forbidden.has(name) || + seen.has(name) || + typeof header.value !== "string" || + header.value.length > 2048 || + hasForbiddenHeaderValueCharacter(header.value) + ) { + throw new TypeError("Upload request header is invalid."); + } + seen.add(name); + return Object.freeze({ name, value: header.value }); + }), + ); +} + +function snapshotStatuses( + overrides: + | Partial< + Readonly> + > + | undefined, +): Readonly> { + if ( + overrides !== undefined && + (!isPlainRecord(overrides) || + Object.keys(overrides).some( + (operation) => !isControlOperation(operation), + )) + ) { + throw new TypeError("Upload success status policy is invalid."); + } + const statuses = Object.freeze( + Object.assign( + Object.create(null) as Record< + ResumableUploadControlOperation, + number + >, + DEFAULT_SUCCESS_STATUSES, + overrides, + ), + ); + if ( + Object.values(statuses).some( + (status) => + !Number.isSafeInteger(status) || status < 200 || status > 299, + ) + ) { + throw new TypeError("Upload success status policy is invalid."); + } + return statuses; +} + +function headersFor( + configured: readonly Readonly<{ name: string; value: string }>[], +): Headers { + const headers = new Headers({ + accept: "application/json", + "content-type": "application/json; charset=utf-8", + }); + for (const header of configured) { + headers.set(header.name, header.value); + } + return headers; +} + +async function readBoundedJson( + response: Response, + maxBytes: number, + operation: ResumableUploadControlOperation, + attempt: FetchAttempt, +): Promise> { + const contentLength = response.headers.get("content-length"); + let declaredLength: number | null = null; + if ( + contentLength && + (!/^(0|[1-9][0-9]*)$/u.test(contentLength) || + Number(contentLength) > maxBytes) + ) { + cancelResponseBody(response); + return failure( + "LIMIT_EXCEEDED", + operation, + false, + "RECONCILE", + ); + } + if (contentLength !== null) { + declaredLength = Number(contentLength); + } + const reader = response.body?.getReader(); + if (!reader) { + return failure( + "CORRUPT_DATA", + operation, + false, + "RECONCILE", + ); + } + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const raced = await Promise.race([ + reader.read().then( + (value) => ({ kind: "READ" as const, value }), + () => ({ kind: "FAILED" as const }), + ), + attempt.terminal, + ]); + if (raced.kind === "ABORT" || raced.kind === "TIMEOUT") { + cancelReader(reader); + return attemptFailure(attempt, operation); + } + if (raced.kind === "FAILED") { + cancelReader(reader); + return attemptFailure(attempt, operation); + } + const next = raced.value; + if (next.done) break; + if (!(next.value instanceof Uint8Array)) { + cancelReader(reader); + return failure( + "CORRUPT_DATA", + operation, + false, + "RECONCILE", + ); + } + total += next.value.byteLength; + if (total > maxBytes) { + cancelReader(reader); + return failure( + "LIMIT_EXCEEDED", + operation, + false, + "RECONCILE", + ); + } + chunks.push(Uint8Array.from(next.value)); + } + } catch { + cancelReader(reader); + return attemptFailure(attempt, operation); + } finally { + releaseReader(reader); + } + if (declaredLength !== null && declaredLength !== total) { + return failure( + "INTEGRITY_FAILED", + operation, + false, + "RECONCILE", + ); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const value: unknown = JSON.parse(text); + return value && typeof value === "object" && !Array.isArray(value) + ? browserDataSuccess(value) + : failure( + "CORRUPT_DATA", + operation, + false, + "RECONCILE", + ); + } catch { + return failure( + "CORRUPT_DATA", + operation, + false, + "RECONCILE", + ); + } +} + +function statusFailure( + response: Response, + operation: ResumableUploadControlOperation, + maxRetryAfterMs: number, +): UploadProviderResult { + if (response.status === 400 || response.status === 422) { + return failure("INVALID_INPUT", operation, false, "NONE"); + } + if (response.status === 401 || response.status === 403) { + return failure("PERMISSION_DENIED", operation, false, "NONE"); + } + if (response.status === 404) { + return failure("NOT_FOUND", operation, false, "RECONCILE"); + } + if (response.status === 409 || response.status === 412) { + return failure("CONFLICT", operation, false, "RECONCILE"); + } + if (response.status === 410) { + return failure("EXPIRED_RESOURCE", operation, false, "RESTART"); + } + if (response.status === 413) { + return failure("LIMIT_EXCEEDED", operation, false, "NONE"); + } + if (response.status === 429) { + const retryAfterMs = parseRetryAfter( + response.headers.get("retry-after"), + ); + return retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs + ? failure( + "UNAVAILABLE", + operation, + true, + "RESUME", + retryAfterMs, + ) + : failure("UNAVAILABLE", operation, false, "RESUME"); + } + return response.status >= 500 && response.status <= 599 + ? failure("UNAVAILABLE", operation, true, "RESUME") + : failure("UNAVAILABLE", operation, false, "RESUME"); +} + +function failure( + code: BrowserDataFailureCode, + operation: ResumableUploadControlOperation, + retryable: boolean, + recovery: BrowserDataRecovery, + retryAfterMs?: number, +): UploadProviderResult { + const operationMap = { + CREATE_SESSION: "UPLOAD_SESSION", + GET_STATUS: "UPLOAD_RECONCILE", + COMPLETE: "UPLOAD_COMPLETE", + ABORT: "UPLOAD_ABORT", + } as const; + const error: UploadProviderFailure = Object.freeze({ + code, + operation: operationMap[operation], + retryable, + recovery, + ...(retryAfterMs === undefined ? {} : { retryAfterMs }), + }); + return Object.freeze({ ok: false, error }); +} + +type FetchAttemptTerminal = + | Readonly<{ kind: "ABORT" }> + | Readonly<{ kind: "TIMEOUT" }>; + +type FetchAttempt = Readonly<{ + signal: AbortSignal; + terminal: Promise; + terminalKind(): FetchAttemptTerminal["kind"] | null; + release(): void; +}>; + +function createFetchAttempt( + parent: AbortSignal, + timeoutMs: number, +): FetchAttempt { + const controller = new AbortController(); + let terminalKind: FetchAttemptTerminal["kind"] | null = null; + let resolveTerminal: + | ((value: FetchAttemptTerminal) => void) + | undefined; + const terminal = new Promise( + (resolve) => { + resolveTerminal = resolve; + }, + ); + const finish = (kind: FetchAttemptTerminal["kind"]) => { + if (terminalKind) return; + terminalKind = kind; + controller.abort(); + resolveTerminal?.(Object.freeze({ kind })); + }; + const abort = () => finish("ABORT"); + parent.addEventListener("abort", abort, { once: true }); + if (parent.aborted) abort(); + const timer = setTimeout(() => { + finish("TIMEOUT"); + }, timeoutMs); + return Object.freeze({ + signal: controller.signal, + terminal, + terminalKind: () => terminalKind, + release() { + clearTimeout(timer); + parent.removeEventListener("abort", abort); + }, + }); +} + +function attemptFailure( + attempt: FetchAttempt, + operation: ResumableUploadControlOperation, +): UploadProviderResult { + return attempt.terminalKind() === "ABORT" + ? failure("ABORTED", operation, false, "NONE") + : failure("UNAVAILABLE", operation, true, "RESUME"); +} + +function cancelResponseBody(response: Response): void { + try { + void response.body?.cancel().catch(() => { + // Response cancellation is best effort after closed classification. + }); + } catch { + // A cancellation failure cannot change the already classified result. + } +} + +function cancelReader( + reader: ReadableStreamDefaultReader, +): void { + try { + void reader.cancel().catch(() => { + // Reader cancellation is best effort after closed classification. + }); + } catch { + // A cancellation failure cannot change the already classified result. + } +} + +function releaseReader( + reader: ReadableStreamDefaultReader, +): void { + try { + reader.releaseLock(); + } catch { + // A pending native read may keep the lock until cancellation settles. + } +} + +function parseRetryAfter(value: string | null): number | null { + if (!value) return null; + if (/^(0|[1-9][0-9]*)$/u.test(value)) { + const seconds = Number(value); + const milliseconds = seconds * 1000; + return Number.isSafeInteger(milliseconds) ? milliseconds : null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) + ? Math.max(0, timestamp - Date.now()) + : null; +} + +function jsonContentType(value: string | null): boolean { + return Boolean( + value && + /^application\/json(?:;\s*charset=utf-8)?$/iu.test(value.trim()), + ); +} + +function sameUrl(actual: string, expected: string): boolean { + try { + return new URL(actual).href === new URL(expected).href; + } catch { + return false; + } +} + +function boundedPositiveInteger( + value: number, + minimum: number, + maximum: number, + label: string, +): number { + if ( + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) { + throw new TypeError(`Upload ${label} policy is invalid.`); + } + return value; +} + +function isAbortSignal(value: unknown): value is AbortSignal { + return Boolean( + value && + typeof value === "object" && + typeof (value as AbortSignal).aborted === "boolean" && + typeof (value as AbortSignal).addEventListener === "function", + ); +} + +function isControlOperation( + value: unknown, +): value is ResumableUploadControlOperation { + return typeof value === "string" && OPERATION_SET.has(value); +} + +function snapshotTransportRequest( + value: unknown, +): Readonly<{ + operation: ResumableUploadControlOperation; + body: Readonly>; + signal: AbortSignal; +}> | null { + try { + if (!value || typeof value !== "object") return null; + const record = value as Readonly>; + return isControlOperation(record.operation) && + isPlainRecord(record.body) && + isAbortSignal(record.signal) + ? Object.freeze({ + operation: record.operation, + body: record.body, + signal: record.signal, + }) + : null; + } catch { + return null; + } +} + +function hasForbiddenHeaderValueCharacter(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 31 || codePoint === 127); + }); +} + +function isPlainRecord( + value: unknown, +): value is Readonly> { + if (!value || typeof value !== "object") { + return false; + } + try { + if (Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +} diff --git a/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts b/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts new file mode 100644 index 0000000..ced99e3 --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts @@ -0,0 +1,612 @@ +import type { + PresignedUploadPartCapability, + PresignedUploadPartCapabilityProvider, +} from "../../../application/ports/browser-transfer/presigned-transfer.ts"; +import type { + ResumableUploadControlPlane, + UploadFileFingerprint, + UploadPartReceipt, + UploadProviderResult, + UploadSession, + UploadSessionStatus, +} from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { + isSafeUploadReceiptToken, + isUploadFileFingerprint, + isUploadPartReceipt, + MEDIA_TYPE, + SAFE_OPAQUE_ID, + SAFE_REGISTRY_ID, + SAFE_UPLOAD_KEY, + SHA256_HEX, +} from "./checkpoint-schema.ts"; + +export type ResumableUploadControlOperation = + | "CREATE_SESSION" + | "GET_STATUS" + | "COMPLETE" + | "ABORT"; + +/** + * Composition-owned transport. Endpoint URLs, auth headers and raw response + * parsing stay behind this seam. `operation` is a closed endpoint identifier, + * never a caller-provided URL. + */ +export interface ResumableUploadJsonTransport { + execute(input: Readonly<{ + operation: ResumableUploadControlOperation; + body: Readonly>; + signal: AbortSignal; + }>): Promise>; +} + +export type ResumableUploadHttpControlPlaneDependencies = Readonly<{ + transport: ResumableUploadJsonTransport; + partCapabilities: PresignedUploadPartCapabilityProvider; +}>; + +const MAX_PART_COUNT = 10_000; +const MAX_RECEIPT_COUNT = 10_000; + +export function createResumableUploadHttpControlPlane( + dependencies: ResumableUploadHttpControlPlaneDependencies, +): ResumableUploadControlPlane { + const execute = dependencies.transport?.execute; + const issueUploadPart = + dependencies.partCapabilities?.issueUploadPart; + if ( + typeof execute !== "function" || + typeof issueUploadPart !== "function" + ) { + throw new TypeError("Upload HTTP control-plane dependency is invalid."); + } + + const controlPlane: ResumableUploadControlPlane = + { + async createSession(input) { + if ( + input.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !SAFE_UPLOAD_KEY.test(input.uploadKey) || + !SAFE_REGISTRY_ID.test(input.purpose) || + !MEDIA_TYPE.test(input.mediaType) || + !SHA256_HEX.test(input.requestBindingSha256) || + !isUploadFileFingerprint(input.fingerprint) || + !positiveSafeInteger(input.requestedPartSizeBytes) || + !positiveSafeInteger(input.requestedMaxConcurrency) || + !safeIdempotencyKey(input.idempotencyKey) + ) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_SESSION", + ); + } + const response = await invokeJsonTransport( + execute, + dependencies.transport, + "CREATE_SESSION", + Object.freeze({ + protocol: input.protocol, + uploadKey: input.uploadKey, + purpose: input.purpose, + mediaType: input.mediaType, + requestBindingSha256: input.requestBindingSha256, + fingerprint: snapshotFingerprint(input.fingerprint), + requestedPartSizeBytes: input.requestedPartSizeBytes, + requestedMaxConcurrency: input.requestedMaxConcurrency, + idempotencyKey: input.idempotencyKey, + }), + input.signal, + "UPLOAD_SESSION", + ); + if (!response.ok) return response; + const session = decodeSession(response.value); + return session + ? browserDataSuccess(session) + : browserDataFailure( + "CORRUPT_DATA", + "UPLOAD_SESSION", + { recovery: "RECONCILE" }, + ); + }, + + async getStatus(input) { + if ( + input.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !SAFE_OPAQUE_ID.test(input.sessionId) || + !SHA256_HEX.test(input.requestBindingSha256) || + !isUploadFileFingerprint(input.fingerprint) + ) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_RECONCILE", + ); + } + const response = await invokeJsonTransport( + execute, + dependencies.transport, + "GET_STATUS", + Object.freeze({ + protocol: input.protocol, + sessionId: input.sessionId, + requestBindingSha256: input.requestBindingSha256, + fingerprint: snapshotFingerprint(input.fingerprint), + }), + input.signal, + "UPLOAD_RECONCILE", + ); + if (!response.ok) return response; + const status = decodeStatus(response.value); + return status + ? browserDataSuccess(status) + : browserDataFailure( + "CORRUPT_DATA", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ); + }, + + async issuePartCapability(input) { + if ( + input.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !SAFE_OPAQUE_ID.test(input.sessionId) || + !SHA256_HEX.test(input.requestBindingSha256) || + !SHA256_HEX.test(input.uploadBindingSha256) || + !isUploadFileFingerprint(input.fingerprint) || + !MEDIA_TYPE.test(input.mediaType) || + !isUploadPartReceiptShape(input.part) || + !safeIdempotencyKey(input.idempotencyKey) + ) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_PART", + ); + } + let issued; + try { + issued = await issueUploadPart.call( + dependencies.partCapabilities, + { + sessionId: input.sessionId, + requestBindingSha256: input.requestBindingSha256, + uploadBindingSha256: input.uploadBindingSha256, + partNumber: input.part.partNumber, + offset: input.part.offset, + byteLength: input.part.byteLength, + checksumSha256: input.part.checksumSha256, + mediaType: input.mediaType, + idempotencyKey: input.idempotencyKey, + signal: input.signal, + }, + ); + } catch { + return browserDataFailure( + "UNAVAILABLE", + "UPLOAD_PART", + { retryable: true, recovery: "REISSUE_CAPABILITY" }, + ); + } + if (!issued.ok) { + return browserDataFailure( + issued.error.code, + "UPLOAD_PART", + { + retryable: issued.error.retryable, + recovery: issued.error.recovery, + }, + ); + } + const capability = issued.value; + if ( + capability.method !== "PUT" || + capability.binding.kind !== "UPLOAD_PART" || + capability.binding.protocol !== input.protocol || + capability.binding.sessionId !== input.sessionId || + capability.binding.requestBindingSha256 !== + input.requestBindingSha256 || + capability.binding.uploadBindingSha256 !== + input.uploadBindingSha256 || + capability.binding.partNumber !== input.part.partNumber || + capability.binding.offset !== input.part.offset || + capability.binding.idempotencyKey !== input.idempotencyKey || + capability.mediaType !== input.mediaType || + capability.byteLength !== input.part.byteLength || + capability.maxBytes !== input.part.byteLength || + capability.expectedSha256 !== input.part.checksumSha256 || + !positiveSafeInteger(capability.expiresAtEpochMs) + ) { + return browserDataFailure( + "POLICY_REJECTED", + "UPLOAD_PART", + { recovery: "REISSUE_CAPABILITY" }, + ); + } + return browserDataSuccess( + Object.freeze({ + capability, + uploadBindingSha256: input.uploadBindingSha256, + expiresAtEpochMs: capability.expiresAtEpochMs, + }), + ); + }, + + async complete(input) { + if ( + input.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !SAFE_OPAQUE_ID.test(input.sessionId) || + !SHA256_HEX.test(input.requestBindingSha256) || + !isUploadFileFingerprint(input.fingerprint) || + !safeIdempotencyKey(input.idempotencyKey) || + !orderedReceipts( + input.orderedParts, + input.fingerprint, + true, + ) + ) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_COMPLETE", + ); + } + const response = await invokeJsonTransport( + execute, + dependencies.transport, + "COMPLETE", + Object.freeze({ + protocol: input.protocol, + sessionId: input.sessionId, + requestBindingSha256: input.requestBindingSha256, + fingerprint: snapshotFingerprint(input.fingerprint), + orderedParts: Object.freeze( + input.orderedParts.map(snapshotReceipt), + ), + idempotencyKey: input.idempotencyKey, + }), + input.signal, + "UPLOAD_COMPLETE", + ); + if (!response.ok) return response; + const completed = decodeCompletion(response.value); + return completed + ? browserDataSuccess(completed) + : browserDataFailure( + "CORRUPT_DATA", + "UPLOAD_COMPLETE", + { recovery: "RECONCILE" }, + ); + }, + + async abort(input) { + if ( + input.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !SAFE_OPAQUE_ID.test(input.sessionId) || + !SHA256_HEX.test(input.requestBindingSha256) || + !safeIdempotencyKey(input.idempotencyKey) + ) { + return browserDataFailure("INVALID_INPUT", "UPLOAD_ABORT"); + } + const response = await invokeJsonTransport( + execute, + dependencies.transport, + "ABORT", + Object.freeze({ + protocol: input.protocol, + sessionId: input.sessionId, + requestBindingSha256: input.requestBindingSha256, + idempotencyKey: input.idempotencyKey, + }), + input.signal, + "UPLOAD_ABORT", + ); + if (!response.ok) return response; + if ( + !exactKeys(response.value, ["state"]) || + typeof response.value.state !== "string" || + ![ + "ABORTED", + "NOT_FOUND", + "EXPIRED", + "ALREADY_COMPLETED", + ].includes(response.value.state) + ) { + return browserDataFailure( + "CORRUPT_DATA", + "UPLOAD_ABORT", + { recovery: "RECONCILE" }, + ); + } + return browserDataSuccess( + Object.freeze({ + state: response.value.state as + | "ABORTED" + | "NOT_FOUND" + | "EXPIRED" + | "ALREADY_COMPLETED", + }), + ); + }, + }; + return Object.freeze(controlPlane); +} + +async function invokeJsonTransport( + execute: ResumableUploadJsonTransport["execute"], + owner: ResumableUploadJsonTransport, + operation: ResumableUploadControlOperation, + body: Readonly>, + signal: AbortSignal, + failureOperation: + | "UPLOAD_SESSION" + | "UPLOAD_RECONCILE" + | "UPLOAD_COMPLETE" + | "UPLOAD_ABORT", +): Promise> { + try { + const response = await execute.call(owner, { + operation, + body, + signal, + }); + if (!response || typeof response !== "object") { + return browserDataFailure("UNAVAILABLE", failureOperation, { + retryable: true, + recovery: "RESUME", + }); + } + return response; + } catch { + return browserDataFailure("UNAVAILABLE", failureOperation, { + retryable: true, + recovery: "RESUME", + }); + } +} + +function decodeSession(value: unknown): UploadSession | null { + if ( + !exactKeys(value, [ + "protocol", + "sessionId", + "requestBindingSha256", + "fingerprint", + "partSizeBytes", + "partCount", + "maxConcurrency", + "expiresAtEpochMs", + ]) || + value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + typeof value.sessionId !== "string" || + !SAFE_OPAQUE_ID.test(value.sessionId) || + typeof value.requestBindingSha256 !== "string" || + !SHA256_HEX.test(value.requestBindingSha256) || + !isUploadFileFingerprint(value.fingerprint) || + !positiveSafeInteger(value.partSizeBytes) || + value.partSizeBytes !== value.fingerprint.partSizeBytes || + !positiveSafeInteger(value.partCount) || + value.partCount !== value.fingerprint.partCount || + value.partCount > MAX_PART_COUNT || + !positiveSafeInteger(value.maxConcurrency) || + !positiveSafeInteger(value.expiresAtEpochMs) + ) { + return null; + } + return Object.freeze({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: value.sessionId, + requestBindingSha256: value.requestBindingSha256, + fingerprint: snapshotFingerprint(value.fingerprint), + partSizeBytes: value.partSizeBytes, + partCount: value.partCount, + maxConcurrency: value.maxConcurrency, + expiresAtEpochMs: value.expiresAtEpochMs, + }); +} + +function decodeStatus(value: unknown): UploadSessionStatus | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + if ( + record.state === "ACTIVE" && + exactKeys(record, ["state", "session", "acceptedParts"]) + ) { + const session = decodeSession(record.session); + if ( + !session || + !Array.isArray(record.acceptedParts) || + record.acceptedParts.length > MAX_RECEIPT_COUNT || + !record.acceptedParts.every(isUploadPartReceipt) + ) { + return null; + } + const parts = Object.freeze( + record.acceptedParts.map(snapshotReceipt), + ); + return orderedReceipts(parts, session.fingerprint, false) + ? Object.freeze({ + state: "ACTIVE", + session, + acceptedParts: parts, + }) + : null; + } + if ( + record.state === "QUARANTINED" && + exactKeys(record, ["state", "session", "resourceId"]) + ) { + const session = decodeSession(record.session); + return session && + typeof record.resourceId === "string" && + SAFE_OPAQUE_ID.test(record.resourceId) + ? Object.freeze({ + state: "QUARANTINED", + session, + resourceId: record.resourceId, + }) + : null; + } + if ( + typeof record.state === "string" && + ["ABORTED", "EXPIRED", "NOT_FOUND"].includes(record.state) && + exactKeys(record, [ + "state", + "protocol", + "sessionId", + "requestBindingSha256", + ]) && + record.protocol === RESUMABLE_UPLOAD_PROTOCOL && + typeof record.sessionId === "string" && + SAFE_OPAQUE_ID.test(record.sessionId) && + typeof record.requestBindingSha256 === "string" && + SHA256_HEX.test(record.requestBindingSha256) + ) { + return Object.freeze({ + state: record.state as "ABORTED" | "EXPIRED" | "NOT_FOUND", + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: record.sessionId, + requestBindingSha256: record.requestBindingSha256, + }); + } + return null; +} + +function decodeCompletion( + value: unknown, +): Awaited< + ReturnType< + ResumableUploadControlPlane["complete"] + > +> extends UploadProviderResult + ? Outcome | null + : never { + if ( + !exactKeys(value, [ + "state", + "protocol", + "sessionId", + "requestBindingSha256", + "fingerprint", + "resourceId", + ]) || + value.state !== "QUARANTINED" || + value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + typeof value.sessionId !== "string" || + !SAFE_OPAQUE_ID.test(value.sessionId) || + typeof value.requestBindingSha256 !== "string" || + !SHA256_HEX.test(value.requestBindingSha256) || + !isUploadFileFingerprint(value.fingerprint) || + typeof value.resourceId !== "string" || + !SAFE_OPAQUE_ID.test(value.resourceId) + ) { + return null; + } + return Object.freeze({ + state: "QUARANTINED", + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: value.sessionId, + requestBindingSha256: value.requestBindingSha256, + fingerprint: snapshotFingerprint(value.fingerprint), + resourceId: value.resourceId, + }); +} + +function orderedReceipts( + parts: readonly UploadPartReceipt[], + fingerprint: UploadFileFingerprint, + requireComplete: boolean, +): boolean { + if ( + parts.length > fingerprint.partCount || + parts.length > MAX_RECEIPT_COUNT || + (requireComplete && parts.length !== fingerprint.partCount) + ) { + return false; + } + let previousPartNumber = 0; + return parts.every((part) => { + const valid = + isUploadPartReceipt(part) && + isSafeUploadReceiptToken(part.receiptToken) && + part.partNumber > previousPartNumber && + part.partNumber <= fingerprint.partCount && + part.offset === + (part.partNumber - 1) * fingerprint.partSizeBytes && + part.byteLength === + Math.min( + fingerprint.partSizeBytes, + fingerprint.byteLength - part.offset, + ); + previousPartNumber = part.partNumber; + return valid; + }); +} + +function isUploadPartReceiptShape( + value: unknown, +): value is Readonly<{ + partNumber: number; + offset: number; + byteLength: number; + checksumSha256: string; +}> { + return ( + exactKeys(value, [ + "partNumber", + "offset", + "byteLength", + "checksumSha256", + ]) && + positiveSafeInteger(value.partNumber) && + nonNegativeSafeInteger(value.offset) && + positiveSafeInteger(value.byteLength) && + typeof value.checksumSha256 === "string" && + SHA256_HEX.test(value.checksumSha256) + ); +} + +function snapshotFingerprint( + value: UploadFileFingerprint, +): UploadFileFingerprint { + return Object.freeze({ ...value }); +} + +function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt { + return Object.freeze({ ...value }); +} + +function exactKeys( + value: unknown, + keys: readonly string[], +): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function safeIdempotencyKey(value: string): boolean { + return ( + typeof value === "string" && + value.length >= 16 && + value.length <= 160 && + /^[A-Za-z0-9._~-]+$/u.test(value) + ); +} + +function positiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function nonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} diff --git a/src/adapters/browser-transfer/resumable-upload/index.ts b/src/adapters/browser-transfer/resumable-upload/index.ts new file mode 100644 index 0000000..aab5785 --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/index.ts @@ -0,0 +1,40 @@ +export { + createResumableUploadFetchJsonTransport, + type ResumableUploadEndpointMap, + type ResumableUploadFetchTransportDependencies, +} from "./fetch-json-transport.ts"; +export { + createResumableUploadHttpControlPlane, + type ResumableUploadControlOperation, + type ResumableUploadHttpControlPlaneDependencies, + type ResumableUploadJsonTransport, +} from "./http-control-plane-adapter.ts"; +export { + createIndexedDbResumableUploadCheckpointRuntime, + createIndexedDbResumableUploadCheckpointStore, + uploadCheckpointDatabaseName, + type IndexedDbUploadCheckpointDependencies, + type IndexedDbUploadCheckpointRuntime, + type IndexedDbUploadCheckpointScope, +} from "./indexeddb-checkpoint-store.ts"; +export { createPresignedUploadPartExecutor } from "./presigned-upload-part-executor.ts"; +export { + createResumableUploadRuntime, + type ResumableUploadRuntime, + type ResumableUploadRuntimeDependencies, +} from "./resumable-upload-runtime.ts"; +export { + resolveResumableUploadRuntimePolicy, + type ResumableUploadRuntimePolicy, +} from "./runtime-policy.ts"; +export { + createBrowserUploadCancellationChannel, + type BrowserUploadCancellationDependencies, + type UploadCancellationBroadcastFacade, + type UploadCancellationChannel, + type UploadCancellationListener, +} from "./upload-cancellation-channel.ts"; +export { + createResumableUploadWebLock, + type UploadMutationLock, +} from "./upload-mutation-lock.ts"; diff --git a/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts b/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts new file mode 100644 index 0000000..cddbb9a --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts @@ -0,0 +1,667 @@ +import type { + ResumableUploadCheckpointAdmin, + ResumableUploadCheckpoint, + ResumableUploadCheckpointStore, +} from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, + mapBrowserDataException, +} from "../../browser-file-storage/result.ts"; +import { + isResumableUploadCheckpoint, + SAFE_OPAQUE_ID, + SAFE_UPLOAD_KEY, +} from "./checkpoint-schema.ts"; + +const DATABASE_VERSION = 1; +const CHECKPOINT_STORE = "checkpoints"; +const GOVERNANCE_STORE = "governance"; +const GOVERNANCE_KEY = "scope-binding"; +const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000; + +export type IndexedDbUploadCheckpointScope = Readonly<{ + authorityToken: string; + namespaceToken: string; + partitionToken: string; +}>; + +export type IndexedDbUploadCheckpointDependencies = Readonly<{ + scope: IndexedDbUploadCheckpointScope; + factory?: IDBFactory; + blockedTimeoutMs?: number; +}>; + +export type IndexedDbUploadCheckpointRuntime = Readonly<{ + store: ResumableUploadCheckpointStore; + admin: ResumableUploadCheckpointAdmin; +}>; + +type ScopeBinding = Readonly<{ + key: typeof GOVERNANCE_KEY; + schemaVersion: 1; + authorityToken: string; + namespaceToken: string; + partitionToken: string; +}>; + +type OpenFactory = ( + name: string, + version?: number, +) => IDBOpenDBRequest; + +export function uploadCheckpointDatabaseName( + scope: IndexedDbUploadCheckpointScope, +): string { + const snapshot = snapshotScope(scope); + const components = [ + snapshot.authorityToken, + snapshot.namespaceToken, + snapshot.partitionToken, + ].map((component) => `${component.length}:${component}`); + return `ca-resumable-upload-v1|${components.join("|")}`; +} + +export function createIndexedDbResumableUploadCheckpointStore( + input: IndexedDbUploadCheckpointDependencies, +): ResumableUploadCheckpointStore { + return createIndexedDbResumableUploadCheckpointRuntime(input).store; +} + +export function createIndexedDbResumableUploadCheckpointRuntime( + input: IndexedDbUploadCheckpointDependencies, +): IndexedDbUploadCheckpointRuntime { + const scope = snapshotScope(input.scope); + const factory = + input.factory ?? + (typeof indexedDB === "undefined" ? undefined : indexedDB); + const blockedTimeoutMs = + input.blockedTimeoutMs ?? DEFAULT_BLOCKED_TIMEOUT_MS; + if ( + !Number.isSafeInteger(blockedTimeoutMs) || + blockedTimeoutMs < 1 || + blockedTimeoutMs > 30_000 + ) { + throw new TypeError("Upload checkpoint blocked timeout is invalid."); + } + const openFactory: OpenFactory | undefined = factory + ? factory.open.bind(factory) + : undefined; + const deleteFactory = + factory && typeof factory.deleteDatabase === "function" + ? factory.deleteDatabase.bind(factory) + : undefined; + const databaseName = uploadCheckpointDatabaseName(scope); + const expectedBinding: ScopeBinding = Object.freeze({ + key: GOVERNANCE_KEY, + schemaVersion: 1, + ...scope, + }); + let database: IDBDatabase | null = null; + let opening: Promise> | null = null; + let closed = false; + + async function open( + signal?: AbortSignal, + ): Promise> { + if (closed || !openFactory) { + return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", { + recovery: "RESUME", + }); + } + if (signal?.aborted) { + return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); + } + if (database) return browserDataSuccess(database); + if (!opening) { + opening = openAndBind().finally(() => { + opening = null; + }); + } + const result = await opening; + if (signal?.aborted) { + return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); + } + return result; + } + + async function openAndBind(): Promise> { + let request: IDBOpenDBRequest; + try { + request = openFactory!(databaseName, DATABASE_VERSION); + } catch (error) { + return mapBrowserDataException(error, "UPLOAD_RECONCILE"); + } + const opened = await new Promise>( + (resolve) => { + let settled = false; + let blockedTimer: ReturnType | undefined; + const finish = (result: BrowserDataResult) => { + if (settled) { + if (result.ok) result.value.close(); + return; + } + settled = true; + if (blockedTimer) clearTimeout(blockedTimer); + resolve(result); + }; + request.onupgradeneeded = () => { + try { + const db = request.result; + if (!db.objectStoreNames.contains(CHECKPOINT_STORE)) { + db.createObjectStore(CHECKPOINT_STORE, { + keyPath: "uploadKey", + }); + } + if (!db.objectStoreNames.contains(GOVERNANCE_STORE)) { + db.createObjectStore(GOVERNANCE_STORE, { + keyPath: "key", + }); + } + } catch (error) { + try { + request.transaction?.abort(); + } catch { + // The open request will surface the original closed failure. + } + finish(mapBrowserDataException(error, "UPLOAD_RECONCILE")); + } + }; + request.onblocked = () => { + blockedTimer = setTimeout(() => { + finish( + browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", { + retryable: true, + recovery: "RESUME", + }), + ); + }, blockedTimeoutMs); + }; + request.onerror = () => + finish( + mapBrowserDataException( + request.error, + "UPLOAD_RECONCILE", + ), + ); + request.onsuccess = () => finish(browserDataSuccess(request.result)); + }, + ); + if (!opened.ok) return opened; + if (closed) { + opened.value.close(); + return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", { + recovery: "RESUME", + }); + } + const bound = await bindScope(opened.value, expectedBinding); + if (!bound.ok) { + opened.value.close(); + return bound; + } + opened.value.onversionchange = () => { + opened.value.close(); + if (database === opened.value) database = null; + }; + opened.value.onclose = () => { + if (database === opened.value) database = null; + }; + database = opened.value; + return browserDataSuccess(opened.value); + } + + const storeValue: ResumableUploadCheckpointStore = { + async read( + uploadKey: string, + signal?: AbortSignal, + ): Promise< + BrowserDataResult + > { + if (!SAFE_UPLOAD_KEY.test(uploadKey)) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_RECONCILE", + ); + } + const opened = await open(signal); + if (!opened.ok) return opened; + return await runCheckpointTransaction< + ResumableUploadCheckpoint | null + >( + opened.value, + "readonly", + signal, + (nativeStore, context) => { + const request = nativeStore.get(uploadKey); + request.onerror = () => context.nativeFailure(request.error); + request.onsuccess = () => { + if (request.result === undefined) { + context.succeed(null); + return; + } + if (!isResumableUploadCheckpoint(request.result)) { + context.fail( + browserDataFailure( + "CORRUPT_DATA", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ), + ); + return; + } + context.succeed( + snapshotCheckpoint(request.result), + ); + }; + }, + ); + }, + + async compareAndSwap( + inputValue: Parameters< + ResumableUploadCheckpointStore["compareAndSwap"] + >[0], + ): Promise> { + let checkpoint: ResumableUploadCheckpoint; + try { + checkpoint = snapshotCheckpoint(inputValue.checkpoint); + } catch { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_RECONCILE", + ); + } + const expectedRevision = inputValue.expectedRevision; + if ( + (expectedRevision !== null && + (!Number.isSafeInteger(expectedRevision) || + expectedRevision < 1)) || + checkpoint.revision !== (expectedRevision ?? 0) + 1 + ) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_RECONCILE", + ); + } + const opened = await open(inputValue.signal); + if (!opened.ok) return opened; + return await runCheckpointTransaction( + opened.value, + "readwrite", + inputValue.signal, + (nativeStore, context) => { + const request = nativeStore.get(checkpoint.uploadKey); + request.onerror = () => context.nativeFailure(request.error); + request.onsuccess = () => { + const current = request.result; + if ( + (expectedRevision === null && current !== undefined) || + (expectedRevision !== null && + (!isResumableUploadCheckpoint(current) || + current.revision !== expectedRevision)) + ) { + context.fail( + browserDataFailure( + "CONFLICT", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ), + ); + return; + } + const put = nativeStore.put(checkpoint); + put.onerror = () => context.nativeFailure(put.error); + put.onsuccess = () => context.succeed(checkpoint); + }; + }, + ); + }, + + async remove( + inputValue: Parameters< + ResumableUploadCheckpointStore["remove"] + >[0], + ): Promise> { + if ( + !SAFE_UPLOAD_KEY.test(inputValue.uploadKey) || + !Number.isSafeInteger(inputValue.expectedRevision) || + inputValue.expectedRevision < 1 + ) { + return browserDataFailure( + "INVALID_INPUT", + "UPLOAD_RECONCILE", + ); + } + const opened = await open(inputValue.signal); + if (!opened.ok) return opened; + return await runCheckpointTransaction( + opened.value, + "readwrite", + inputValue.signal, + (nativeStore, context) => { + const request = nativeStore.get(inputValue.uploadKey); + request.onerror = () => context.nativeFailure(request.error); + request.onsuccess = () => { + if ( + !isResumableUploadCheckpoint(request.result) || + request.result.revision !== inputValue.expectedRevision + ) { + context.fail( + browserDataFailure( + "CONFLICT", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ), + ); + return; + } + const deletion = nativeStore.delete(inputValue.uploadKey); + deletion.onerror = () => + context.nativeFailure(deletion.error); + deletion.onsuccess = () => context.succeed(undefined); + }; + }, + ); + }, + + close() { + closed = true; + database?.close(); + database = null; + }, + }; + const store = Object.freeze(storeValue); + const adminValue: ResumableUploadCheckpointAdmin = { + async deletePartition( + signal?: AbortSignal, + ): Promise< + BrowserDataResult> + > { + if (signal?.aborted) { + return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); + } + closed = true; + database?.close(); + database = null; + if (!deleteFactory) { + return browserDataFailure( + "UNSUPPORTED", + "UPLOAD_RECONCILE", + { recovery: "READ_ONLY" }, + ); + } + let request: IDBOpenDBRequest; + try { + request = deleteFactory(databaseName); + } catch (error) { + return mapBrowserDataException(error, "UPLOAD_RECONCILE"); + } + return await new Promise< + BrowserDataResult> + >((resolve) => { + let settled = false; + let blockedTimer: ReturnType | undefined; + const finish = ( + result: BrowserDataResult>, + ) => { + if (settled) return; + settled = true; + if (blockedTimer) clearTimeout(blockedTimer); + resolve(result); + }; + // IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is + // intentionally observed only before dispatch so the adapter never + // reports ABORTED while deletion may still commit. + request.onblocked = () => { + blockedTimer = setTimeout(() => { + finish( + browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", { + retryable: true, + recovery: "RELOAD_OTHER_CONTEXTS", + }), + ); + }, blockedTimeoutMs); + }; + request.onerror = () => + finish( + mapBrowserDataException( + request.error, + "UPLOAD_RECONCILE", + ), + ); + request.onsuccess = () => + finish( + browserDataSuccess( + Object.freeze({ state: "DELETED" as const }), + ), + ); + }); + }, + }; + const admin = Object.freeze(adminValue); + return Object.freeze({ store, admin }); +} + +type TransactionContext = Readonly<{ + succeed(value: Value): void; + fail(result: BrowserDataResult): void; + nativeFailure(error: unknown): void; +}>; + +async function runCheckpointTransaction( + database: IDBDatabase, + mode: IDBTransactionMode, + signal: AbortSignal | undefined, + execute: ( + store: IDBObjectStore, + context: TransactionContext, + ) => void, +): Promise> { + if (signal?.aborted) { + return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); + } + return await new Promise>((resolve) => { + let transaction: IDBTransaction; + try { + transaction = database.transaction(CHECKPOINT_STORE, mode); + } catch (error) { + resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE")); + return; + } + let value: Value | undefined; + let hasValue = false; + let failure: BrowserDataResult | null = null; + let settled = false; + const finish = (result: BrowserDataResult) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", abort); + resolve(result); + }; + const abort = () => { + const previousFailure = failure; + failure = browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); + try { + transaction.abort(); + } catch { + // The transaction may already be durably committed while its + // completion event is still queued. Wait for oncomplete/onabort so we + // never report ABORTED for a mutation that actually committed. + failure = previousFailure; + } + }; + signal?.addEventListener("abort", abort, { once: true }); + transaction.oncomplete = () => { + if (!hasValue) { + finish( + browserDataFailure("CORRUPT_DATA", "UPLOAD_RECONCILE", { + recovery: "RECONCILE", + }), + ); + return; + } + finish(browserDataSuccess(value as Value)); + }; + transaction.onerror = () => { + // onabort is the terminal transaction signal. + }; + transaction.onabort = () => + finish( + failure ?? + mapBrowserDataException( + transaction.error, + "UPLOAD_RECONCILE", + ), + ); + const context: TransactionContext = Object.freeze({ + succeed(next) { + if (failure) return; + value = next; + hasValue = true; + }, + fail(result) { + if (failure) return; + failure = result; + try { + transaction.abort(); + } catch { + finish(result); + } + }, + nativeFailure(error) { + if (failure) return; + failure = mapBrowserDataException( + error, + "UPLOAD_RECONCILE", + ); + try { + transaction.abort(); + } catch { + finish(failure); + } + }, + }); + try { + execute(transaction.objectStore(CHECKPOINT_STORE), context); + } catch (error) { + context.nativeFailure(error); + } + }); +} + +async function bindScope( + database: IDBDatabase, + expected: ScopeBinding, +): Promise> { + return await new Promise>((resolve) => { + let transaction: IDBTransaction; + try { + transaction = database.transaction(GOVERNANCE_STORE, "readwrite"); + } catch (error) { + resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE")); + return; + } + let failure: BrowserDataResult | null = null; + transaction.onerror = () => { + // onabort owns terminal resolution. + }; + transaction.onabort = () => + resolve( + failure ?? + mapBrowserDataException( + transaction.error, + "UPLOAD_RECONCILE", + ), + ); + transaction.oncomplete = () => resolve(browserDataSuccess(undefined)); + const store = transaction.objectStore(GOVERNANCE_STORE); + const request = store.get(GOVERNANCE_KEY); + request.onerror = () => { + failure = mapBrowserDataException( + request.error, + "UPLOAD_RECONCILE", + ); + transaction.abort(); + }; + request.onsuccess = () => { + if (request.result === undefined) { + const add = store.add(expected); + add.onerror = () => { + failure = mapBrowserDataException( + add.error, + "UPLOAD_RECONCILE", + ); + transaction.abort(); + }; + return; + } + if (!sameScopeBinding(request.result, expected)) { + failure = browserDataFailure( + "POLICY_REJECTED", + "UPLOAD_RECONCILE", + { recovery: "READ_ONLY" }, + ); + transaction.abort(); + } + }; + }); +} + +function sameScopeBinding( + value: unknown, + expected: ScopeBinding, +): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + return ( + Object.keys(record).length === 5 && + record.key === expected.key && + record.schemaVersion === expected.schemaVersion && + record.authorityToken === expected.authorityToken && + record.namespaceToken === expected.namespaceToken && + record.partitionToken === expected.partitionToken + ); +} + +function snapshotScope( + value: IndexedDbUploadCheckpointScope, +): IndexedDbUploadCheckpointScope { + if ( + !value || + typeof value !== "object" || + !SAFE_OPAQUE_ID.test(value.authorityToken) || + !SAFE_OPAQUE_ID.test(value.namespaceToken) || + !SAFE_OPAQUE_ID.test(value.partitionToken) + ) { + throw new TypeError("Upload checkpoint scope is invalid."); + } + return Object.freeze({ + authorityToken: value.authorityToken, + namespaceToken: value.namespaceToken, + partitionToken: value.partitionToken, + }); +} + +function snapshotCheckpoint( + value: ResumableUploadCheckpoint, +): ResumableUploadCheckpoint { + let cloned: unknown; + try { + cloned = structuredClone(value); + } catch { + throw new TypeError("Upload checkpoint is not cloneable."); + } + if (!isResumableUploadCheckpoint(cloned)) { + throw new TypeError("Upload checkpoint is invalid."); + } + return Object.freeze({ + ...cloned, + fingerprint: Object.freeze({ ...cloned.fingerprint }), + acceptedParts: Object.freeze( + cloned.acceptedParts.map((part) => Object.freeze({ ...part })), + ), + }); +} diff --git a/src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts b/src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts new file mode 100644 index 0000000..b217869 --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts @@ -0,0 +1,114 @@ +import type { + PresignedUploadPartCapability, + PresignedUploadPartPort, +} from "../../../application/ports/browser-transfer/presigned-transfer.ts"; +import type { + UploadPartExecutor, + UploadPartReceipt, + UploadProviderResult, +} from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { + MEDIA_TYPE, + isSafeUploadReceiptToken, + SHA256_HEX, +} from "./checkpoint-schema.ts"; + +export function createPresignedUploadPartExecutor( + inputPort: PresignedUploadPartPort, + now: () => number = Date.now, +): UploadPartExecutor { + const put = inputPort?.put; + if (typeof put !== "function" || typeof now !== "function") { + throw new TypeError("Presigned upload part dependency is invalid."); + } + const executor: UploadPartExecutor = { + async uploadPart( + input: Parameters< + UploadPartExecutor["uploadPart"] + >[0], + ): Promise< + UploadProviderResult + > { + const capability = input.capability; + let nowEpochMs: number; + try { + nowEpochMs = now(); + } catch { + return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", { + retryable: true, + recovery: "RESUME", + }); + } + if ( + !capability || + capability.method !== "PUT" || + capability.binding.kind !== "UPLOAD_PART" || + capability.binding.protocol !== input.protocol || + capability.binding.sessionId !== input.sessionId || + capability.binding.requestBindingSha256 !== + input.requestBindingSha256 || + capability.binding.uploadBindingSha256 !== + input.uploadBindingSha256 || + capability.binding.partNumber !== input.part.partNumber || + capability.binding.offset !== input.part.offset || + capability.binding.idempotencyKey !== input.idempotencyKey || + capability.mediaType !== input.mediaType || + !MEDIA_TYPE.test(capability.mediaType) || + capability.byteLength !== input.part.byteLength || + capability.maxBytes < capability.byteLength || + capability.maxBytes !== input.part.byteLength || + capability.expectedSha256 !== input.part.checksumSha256 || + !SHA256_HEX.test(capability.expectedSha256) || + capability.expiresAtEpochMs <= nowEpochMs || + !(input.bytes instanceof Uint8Array) || + input.bytes.byteLength !== input.part.byteLength || + typeof capability.capabilityReceipt !== "string" + ) { + return browserDataFailure("POLICY_REJECTED", "UPLOAD_PART"); + } + let uploaded; + try { + uploaded = await put.call(inputPort, { + capability, + sessionId: input.sessionId, + requestBindingSha256: input.requestBindingSha256, + uploadBindingSha256: input.uploadBindingSha256, + partNumber: input.part.partNumber, + offset: input.part.offset, + byteLength: input.part.byteLength, + checksumSha256: input.part.checksumSha256, + idempotencyKey: input.idempotencyKey, + bytes: Uint8Array.from(input.bytes), + signal: input.signal, + }); + } catch { + return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", { + retryable: true, + recovery: "RESUME", + }); + } + if (!uploaded.ok) return uploaded; + if ( + uploaded.value.bytesWritten !== input.part.byteLength || + uploaded.value.checksumSha256 !== input.part.checksumSha256 || + typeof uploaded.value.receiptToken !== "string" || + !isSafeUploadReceiptToken(uploaded.value.receiptToken) + ) { + return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_PART", { + recovery: "RECONCILE", + }); + } + return browserDataSuccess( + Object.freeze({ + ...input.part, + receiptToken: uploaded.value.receiptToken, + }), + ); + }, + }; + return Object.freeze(executor); +} diff --git a/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts b/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts new file mode 100644 index 0000000..3ce70f4 --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts @@ -0,0 +1,2196 @@ +import type { + ActiveUploadStatus, + QuarantinedUpload, + ResumableUploadCheckpoint, + ResumableUploadCheckpointStore, + ResumableUploadControlPlane, + ResumableUploadPort, + ResumableUploadRequest, + UploadAbortOutcome, + UploadFileFingerprint, + UploadPartCapability, + UploadPartDescriptor, + UploadPartExecutor, + UploadPartReceipt, + UploadProviderFailure, + UploadProviderResult, + UploadSession, + UploadSessionStatus, +} from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import type { + BrowserDataFailureCode, + BrowserDataObserver, + BrowserDataOperation, + BrowserDataRecovery, + BrowserDataResult, + TransferProgress, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { + isResumableUploadCheckpoint, + isUploadFileFingerprint, + isUploadPartReceipt, + MEDIA_TYPE, + SAFE_OPAQUE_ID, + SAFE_REGISTRY_ID, + SAFE_UPLOAD_KEY, + samePart, + SHA256_HEX, +} from "./checkpoint-schema.ts"; +import { + buildUploadPartManifest, + deriveUploadIdempotencyKey, + digestRequestBinding, + digestUploadSessionBinding, + findManifestPart, + iterateUploadParts, + readAndVerifyRangePart, + snapshotUploadCrypto, + snapshotUploadSource, + verifyPartAgainstManifest, + verifyUploadPartBytes, + type UploadCrypto, + type UploadPartManifest, + type UploadSourceSnapshot, +} from "./upload-byte-source.ts"; +import { + resolveResumableUploadRuntimePolicy, + type ResumableUploadRuntimePolicy, +} from "./runtime-policy.ts"; +import type { UploadCancellationChannel } from "./upload-cancellation-channel.ts"; +import type { UploadMutationLock } from "./upload-mutation-lock.ts"; + +export type ResumableUploadRuntime = ResumableUploadPort & + Readonly<{ + close(): void; + }>; + +export type ResumableUploadRuntimeDependencies = Readonly<{ + controlPlane: ResumableUploadControlPlane; + partExecutor: UploadPartExecutor; + checkpoints: ResumableUploadCheckpointStore; + mutationLock: UploadMutationLock; + /** + * Runtime-owned ephemeral BroadcastChannel coordination. When omitted, + * explicit abort is still correct but may wait for another context's lock. + */ + crossContextCancellation?: UploadCancellationChannel; + crypto: Crypto; + policy?: Partial; + now?: () => number; + random?: () => number; + sleep?: (delayMs: number, signal: AbortSignal) => Promise; + observer?: BrowserDataObserver; +}>; + +type UploadRequestSnapshot = Readonly<{ + uploadKey: string; + purpose: string; + mediaType: string; + source: UploadSourceSnapshot; + signal: AbortSignal; + onProgress?: (progress: TransferProgress) => void; +}>; + +type RuntimeDependencies = Readonly<{ + controlPlane: ResumableUploadControlPlane; + partExecutor: UploadPartExecutor; + checkpoints: ResumableUploadCheckpointStore; + mutationLock: UploadMutationLock; + crossContextCancellation?: UploadCancellationChannel; + crypto: UploadCrypto; + policy: ResumableUploadRuntimePolicy; + now(): number; + random(): number; + sleep(delayMs: number, signal: AbortSignal): Promise; + observer?: BrowserDataObserver; +}>; + +type ActiveResolution = + | Readonly<{ + kind: "ACTIVE"; + checkpoint: ResumableUploadCheckpoint; + }> + | Readonly<{ + kind: "COMPLETED"; + upload: QuarantinedUpload; + }>; + +const FAILURE_CODES: ReadonlySet = new Set([ + "ABORTED", + "BLOCKED", + "CONFLICT", + "CORRUPT_DATA", + "EXPIRED_RESOURCE", + "INTEGRITY_FAILED", + "INVALID_INPUT", + "LIMIT_EXCEEDED", + "MIGRATION_FAILED", + "NOT_FOUND", + "NOT_READABLE", + "PERMISSION_DENIED", + "POLICY_REJECTED", + "QUOTA_EXCEEDED", + "STALE_RESULT", + "STORAGE_EVICTED", + "UNAVAILABLE", + "UNSUPPORTED", +]); +const RECOVERIES: ReadonlySet = new Set([ + "NONE", + "RETRY", + "REOPEN", + "RESELECT", + "RELOAD_OTHER_CONTEXTS", + "READ_ONLY", + "ONLINE_ONLY", + "REHYDRATE", + "EXPORT_REQUIRED", + "REISSUE_CAPABILITY", + "RESUME", + "RESTART", + "RECONCILE", +]); + +export function createResumableUploadRuntime( + inputDependencies: ResumableUploadRuntimeDependencies, +): ResumableUploadRuntime { + const dependencies = snapshotDependencies(inputDependencies); + const lifetime = new AbortController(); + const localUploads = new Map>(); + let closed = false; + const cancelLocalUploads = (uploadKey: string): void => { + if (!SAFE_UPLOAD_KEY.test(uploadKey)) return; + for (const controller of localUploads.get(uploadKey) ?? []) { + controller.abort(); + } + }; + const releaseCrossContextCancellation = + dependencies.crossContextCancellation?.subscribe( + cancelLocalUploads, + ); + + const runtime: ResumableUploadRuntime = Object.freeze({ + async upload( + input: ResumableUploadRequest, + ): Promise> { + if (closed) { + return browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", { + recovery: "RESUME", + }); + } + let request: UploadRequestSnapshot; + try { + request = snapshotRequest(input, dependencies.policy); + } catch { + return browserDataFailure("INVALID_INPUT", "UPLOAD_SESSION"); + } + const localUpload = new AbortController(); + const uploadsForKey = + localUploads.get(request.uploadKey) ?? new Set(); + uploadsForKey.add(localUpload); + localUploads.set(request.uploadKey, uploadsForKey); + const lifetimeScope = combineAbortSignals( + request.signal, + lifetime.signal, + ); + const operationScope = combineAbortSignals( + lifetimeScope.signal, + localUpload.signal, + ); + if (operationScope.signal.aborted) { + operationScope.release(); + lifetimeScope.release(); + uploadsForKey.delete(localUpload); + if (uploadsForKey.size === 0) { + localUploads.delete(request.uploadKey); + } + return browserDataFailure("ABORTED", "UPLOAD_SESSION"); + } + try { + return await dependencies.mutationLock.run( + request.uploadKey, + operationScope.signal, + async () => + await executeUpload( + dependencies, + Object.freeze({ + ...request, + signal: operationScope.signal, + }), + ), + ); + } catch (error) { + return mapLockFailure(error, "UPLOAD_SESSION"); + } finally { + operationScope.release(); + lifetimeScope.release(); + uploadsForKey.delete(localUpload); + if (uploadsForKey.size === 0) { + localUploads.delete(request.uploadKey); + } + } + }, + + async abort( + input: Parameters[0], + ): Promise> { + if (closed) { + return browserDataFailure("UNAVAILABLE", "UPLOAD_ABORT", { + recovery: "RESUME", + }); + } + if ( + !input || + typeof input !== "object" || + typeof input.uploadKey !== "string" || + !SAFE_UPLOAD_KEY.test(input.uploadKey) || + !isAbortSignal(input.signal) + ) { + return browserDataFailure("INVALID_INPUT", "UPLOAD_ABORT"); + } + const uploadKey = input.uploadKey; + if (!input.signal.aborted) { + cancelLocalUploads(uploadKey); + dependencies.crossContextCancellation?.publish(uploadKey); + } + const combined = combineAbortSignals(input.signal, lifetime.signal); + try { + return await dependencies.mutationLock.run( + uploadKey, + combined.signal, + async () => + await executeAbort( + dependencies, + uploadKey, + combined.signal, + ), + ); + } catch (error) { + return mapLockFailure(error, "UPLOAD_ABORT"); + } finally { + combined.release(); + } + }, + + close() { + if (closed) return; + closed = true; + releaseCrossContextCancellation?.(); + dependencies.crossContextCancellation?.close(); + lifetime.abort(); + dependencies.checkpoints.close(); + }, + }); + return runtime; +} + +async function executeUpload( + dependencies: RuntimeDependencies, + request: UploadRequestSnapshot, +): Promise> { + reportProgress(request, "VALIDATING", 0); + if ( + request.source.byteLength > dependencies.policy.maxFileBytes || + Math.ceil( + request.source.byteLength / dependencies.policy.partSizeBytes, + ) > dependencies.policy.maxPartCount + ) { + return browserDataFailure("LIMIT_EXCEEDED", "UPLOAD_SESSION"); + } + const manifest = await buildUploadPartManifest({ + source: request.source, + partSizeBytes: dependencies.policy.partSizeBytes, + maxPartCount: dependencies.policy.maxPartCount, + maxSourceChunkBytes: dependencies.policy.maxSourceChunkBytes, + crypto: dependencies.crypto, + signal: request.signal, + onPreparedBytes(bytes) { + reportProgress(request, "PREPARING", bytes); + }, + }); + if (!manifest.ok) return manifest; + const requestBinding = await digestRequestBinding({ + uploadKey: request.uploadKey, + purpose: request.purpose, + mediaType: request.mediaType, + fingerprint: manifest.value.fingerprint, + crypto: dependencies.crypto, + signal: request.signal, + }); + if (!requestBinding.ok) return requestBinding; + + const stored = await dependencies.checkpoints.read( + request.uploadKey, + request.signal, + ); + if (!stored.ok) return remapResult(stored, "UPLOAD_RECONCILE"); + if ( + stored.value && + (!sameFingerprint( + stored.value.fingerprint, + manifest.value.fingerprint, + ) || + stored.value.requestBindingSha256 !== requestBinding.value) + ) { + return browserDataFailure("STALE_RESULT", "UPLOAD_RECONCILE", { + recovery: "RESELECT", + }); + } + if (stored.value?.state === "ABORT_PENDING") { + return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", { + recovery: "RECONCILE", + }); + } + + const active = await resolveActiveSession( + dependencies, + request, + manifest.value, + requestBinding.value, + stored.value, + ); + if (!active.ok) return active; + if (active.value.kind === "COMPLETED") { + return browserDataSuccess(active.value.upload); + } + + const transferred = await transferMissingParts( + dependencies, + request, + manifest.value, + active.value.checkpoint, + ); + if (!transferred.ok) return transferred; + + reportProgress( + request, + "VERIFYING", + manifest.value.fingerprint.byteLength, + ); + const reconciled = await reconcileActiveCheckpoint( + dependencies, + request, + manifest.value, + transferred.value, + ); + if (!reconciled.ok) return reconciled; + if (reconciled.value.kind === "COMPLETED") { + return browserDataSuccess(reconciled.value.upload); + } + if (reconciled.value.kind !== "ACTIVE") { + const removed = await dependencies.checkpoints.remove({ + uploadKey: transferred.value.uploadKey, + expectedRevision: transferred.value.revision, + signal: request.signal, + }); + if (!removed.ok) { + return remapResult(removed, "UPLOAD_RECONCILE"); + } + return browserDataFailure("EXPIRED_RESOURCE", "UPLOAD_SESSION", { + recovery: "RESTART", + }); + } + if ( + reconciled.value.checkpoint.acceptedParts.length !== + manifest.value.parts.length + ) { + return browserDataFailure("STALE_RESULT", "UPLOAD_RECONCILE", { + retryable: true, + recovery: "RECONCILE", + }); + } + + reportProgress( + request, + "FINALIZING", + manifest.value.fingerprint.byteLength, + ); + return await completeUpload( + dependencies, + request, + manifest.value, + reconciled.value.checkpoint, + ); +} + +async function resolveActiveSession( + dependencies: RuntimeDependencies, + request: UploadRequestSnapshot, + manifest: UploadPartManifest, + requestBindingSha256: string, + existing: ResumableUploadCheckpoint | null, +): Promise> { + let checkpoint = existing; + for (let restartIndex = 0; restartIndex < 2; restartIndex += 1) { + if (!checkpoint) { + const created = await createUploadSession( + dependencies, + request, + manifest, + requestBindingSha256, + ); + if (!created.ok) return created; + checkpoint = created.value; + } + const reconciled = await reconcileActiveCheckpoint( + dependencies, + request, + manifest, + checkpoint, + ); + if (!reconciled.ok) return reconciled; + if ( + reconciled.value.kind === "ACTIVE" || + reconciled.value.kind === "COMPLETED" + ) { + return browserDataSuccess(reconciled.value); + } + if (restartIndex === 1) { + const removed = await dependencies.checkpoints.remove({ + uploadKey: checkpoint.uploadKey, + expectedRevision: checkpoint.revision, + signal: request.signal, + }); + if (!removed.ok) { + return remapResult(removed, "UPLOAD_RECONCILE"); + } + break; + } + const replaced = await createUploadSession( + dependencies, + request, + manifest, + requestBindingSha256, + checkpoint, + ); + if (!replaced.ok) return replaced; + checkpoint = replaced.value; + } + return browserDataFailure("EXPIRED_RESOURCE", "UPLOAD_SESSION", { + recovery: "RESTART", + }); +} + +type ReconciliationResolution = + | ActiveResolution + | Readonly<{ kind: "TERMINAL" }>; + +async function reconcileActiveCheckpoint( + dependencies: RuntimeDependencies, + request: UploadRequestSnapshot, + manifest: UploadPartManifest, + checkpoint: ResumableUploadCheckpoint, +): Promise> { + const statusResult = await callWithRetry( + dependencies, + "UPLOAD_RECONCILE", + request.signal, + async (attemptSignal) => + await dependencies.controlPlane.getStatus({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: checkpoint.sessionId, + requestBindingSha256: checkpoint.requestBindingSha256, + fingerprint: checkpoint.fingerprint, + signal: attemptSignal, + }), + ); + if (!statusResult.ok) { + if ( + statusResult.error.code === "NOT_FOUND" || + statusResult.error.code === "EXPIRED_RESOURCE" + ) { + return browserDataSuccess( + Object.freeze({ kind: "TERMINAL" as const }), + ); + } + return stripProviderFailure(statusResult); + } + const status = validateStatus( + statusResult.value, + checkpoint, + manifest, + dependencies, + ); + if (!status.ok) return status; + if (status.value.state === "QUARANTINED") { + const removed = await dependencies.checkpoints.remove({ + uploadKey: checkpoint.uploadKey, + expectedRevision: checkpoint.revision, + signal: request.signal, + }); + if (!removed.ok) return remapResult(removed, "UPLOAD_RECONCILE"); + return browserDataSuccess( + Object.freeze({ + kind: "COMPLETED" as const, + upload: quarantinedOutcome( + status.value.resourceId, + status.value.session, + true, + ), + }), + ); + } + if (status.value.state !== "ACTIVE") { + return browserDataSuccess(Object.freeze({ kind: "TERMINAL" as const })); + } + const acceptedParts = snapshotReceipts(status.value.acceptedParts); + const unchanged = + acceptedParts.length === checkpoint.acceptedParts.length && + acceptedParts.every((part, index) => { + const current = checkpoint.acceptedParts[index]; + return Boolean( + current && + samePart(part, current) && + part.receiptToken === current.receiptToken, + ); + }); + let nextCheckpoint = checkpoint; + if (!unchanged) { + const now = safeNow(dependencies, "UPLOAD_RECONCILE"); + if (!now.ok) return now; + const candidate = checkpointFrom({ + previous: checkpoint, + revision: checkpoint.revision + 1, + acceptedParts, + updatedAtEpochMs: now.value, + }); + const persisted = await dependencies.checkpoints.compareAndSwap({ + expectedRevision: checkpoint.revision, + checkpoint: candidate, + signal: request.signal, + }); + if (!persisted.ok) { + return remapResult(persisted, "UPLOAD_RECONCILE"); + } + nextCheckpoint = persisted.value; + } + return browserDataSuccess( + Object.freeze({ + kind: "ACTIVE" as const, + checkpoint: nextCheckpoint, + }), + ); +} + +async function createUploadSession( + dependencies: RuntimeDependencies, + request: UploadRequestSnapshot, + manifest: UploadPartManifest, + requestBindingSha256: string, + previous?: ResumableUploadCheckpoint, +): Promise> { + const idempotencyKey = await deriveUploadIdempotencyKey({ + label: "CREATE", + requestBindingSha256, + ...(previous ? { sessionId: previous.sessionId } : {}), + crypto: dependencies.crypto, + signal: request.signal, + }); + if (!idempotencyKey.ok) return idempotencyKey; + const created = await callWithRetry( + dependencies, + "UPLOAD_SESSION", + request.signal, + async (attemptSignal) => + await dependencies.controlPlane.createSession({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + uploadKey: request.uploadKey, + purpose: request.purpose, + mediaType: request.mediaType, + requestBindingSha256, + fingerprint: manifest.fingerprint, + requestedPartSizeBytes: dependencies.policy.partSizeBytes, + requestedMaxConcurrency: dependencies.policy.maxConcurrency, + idempotencyKey: idempotencyKey.value, + signal: attemptSignal, + }), + ); + if (!created.ok) return stripProviderFailure(created); + const session = validateSession( + created.value, + requestBindingSha256, + manifest.fingerprint, + dependencies, + ); + if (!session.ok) return session; + const now = safeNow(dependencies, "UPLOAD_SESSION"); + if (!now.ok) return now; + const checkpoint = checkpointFrom({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + revision: (previous?.revision ?? 0) + 1, + state: "ACTIVE", + uploadKey: request.uploadKey, + requestBindingSha256, + fingerprint: manifest.fingerprint, + sessionId: session.value.sessionId, + sessionExpiresAtEpochMs: session.value.expiresAtEpochMs, + sessionMaxConcurrency: session.value.maxConcurrency, + acceptedParts: Object.freeze([]), + updatedAtEpochMs: now.value, + }); + const persisted = await dependencies.checkpoints.compareAndSwap({ + expectedRevision: previous?.revision ?? null, + checkpoint, + signal: request.signal, + }); + if (!persisted.ok) { + // The server owns expiry/garbage collection for a create that succeeded + // before a local durable checkpoint could commit. + return remapResult(persisted, "UPLOAD_RECONCILE"); + } + return persisted; +} + +async function transferMissingParts( + dependencies: RuntimeDependencies, + request: UploadRequestSnapshot, + manifest: UploadPartManifest, + initialCheckpoint: ResumableUploadCheckpoint, +): Promise> { + let checkpoint = initialCheckpoint; + let persistenceTail = Promise.resolve>( + browserDataSuccess(undefined), + ); + const accepted = new Map( + checkpoint.acceptedParts.map((part) => [part.partNumber, part]), + ); + let transferredBytes = checkpoint.acceptedParts.reduce( + (total, part) => total + part.byteLength, + 0, + ); + reportProgress(request, "TRANSFERRING", transferredBytes); + + const persistReceipt = async ( + receipt: UploadPartReceipt, + ): Promise> => { + const pending: Promise> = + persistenceTail.then(async (prior): Promise> => { + if (!prior.ok) return prior; + const existing = accepted.get(receipt.partNumber); + if (existing) { + return samePart(existing, receipt) && + existing.receiptToken === receipt.receiptToken + ? browserDataSuccess(undefined) + : browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", { + recovery: "RECONCILE", + }); + } + const now = safeNow(dependencies, "UPLOAD_RECONCILE"); + if (!now.ok) return now; + const acceptedParts = snapshotReceipts( + [...accepted.values(), receipt].sort( + (left, right) => left.partNumber - right.partNumber, + ), + ); + const candidate = checkpointFrom({ + previous: checkpoint, + revision: checkpoint.revision + 1, + acceptedParts, + updatedAtEpochMs: now.value, + }); + const saved = await dependencies.checkpoints.compareAndSwap({ + expectedRevision: checkpoint.revision, + checkpoint: candidate, + signal: request.signal, + }); + if (!saved.ok) return remapResult(saved, "UPLOAD_RECONCILE"); + checkpoint = saved.value; + accepted.set(receipt.partNumber, receipt); + transferredBytes += receipt.byteLength; + reportProgress(request, "TRANSFERRING", transferredBytes); + return browserDataSuccess(undefined); + }); + persistenceTail = pending; + return await pending; + }; + + if (request.source.kind === "RANGE_READER") { + const missing = manifest.parts.filter( + (part) => !accepted.has(part.partNumber), + ); + let nextIndex = 0; + let firstFailure: BrowserDataResult | null = null; + const maxByMemory = Math.max( + 1, + Math.floor( + dependencies.policy.maxInFlightBytes / + (dependencies.policy.partSizeBytes * + dependencies.policy.partBufferCopyFactor), + ), + ); + const workerCount = Math.min( + dependencies.policy.maxConcurrency, + checkpoint.sessionMaxConcurrency, + maxByMemory, + missing.length, + ); + const workers = Array.from({ length: workerCount }, async () => { + while (!firstFailure) { + const index = nextIndex; + nextIndex += 1; + const part = missing[index]; + if (!part) return; + const bytes = await readAndVerifyRangePart({ + source: request.source as Extract< + UploadSourceSnapshot, + { kind: "RANGE_READER" } + >, + part, + crypto: dependencies.crypto, + signal: request.signal, + }); + if (!bytes.ok) { + firstFailure = bytes; + return; + } + const uploaded = await uploadPartWithRetry( + dependencies, + request, + manifest, + checkpoint, + part, + bytes.value, + ); + observeTerminal( + dependencies.observer, + "UPLOAD_PART", + uploaded, + part.byteLength, + ); + if (!uploaded.ok) { + firstFailure = uploaded; + return; + } + const persisted = await persistReceipt(uploaded.value); + if (!persisted.ok) { + firstFailure = persisted; + return; + } + } + }); + await Promise.all(workers); + if (firstFailure) return firstFailure; + } else { + for await (const partResult of iterateUploadParts({ + source: request.source, + partSizeBytes: manifest.fingerprint.partSizeBytes, + maxSourceChunkBytes: dependencies.policy.maxSourceChunkBytes, + signal: request.signal, + operation: "UPLOAD_PART", + })) { + if (!partResult.ok) return partResult; + const part = findManifestPart( + manifest, + partResult.value.partNumber, + ); + if ( + !part || + part.offset !== partResult.value.offset || + part.byteLength !== partResult.value.bytes.byteLength + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "UPLOAD_PART", + { recovery: "RESELECT" }, + ); + } + const verified = await verifyUploadPartBytes({ + bytes: partResult.value.bytes, + part, + crypto: dependencies.crypto, + signal: request.signal, + }); + if (!verified.ok) return verified; + if (accepted.has(part.partNumber)) continue; + const uploaded = await uploadPartWithRetry( + dependencies, + request, + manifest, + checkpoint, + part, + verified.value, + ); + observeTerminal( + dependencies.observer, + "UPLOAD_PART", + uploaded, + part.byteLength, + ); + if (!uploaded.ok) return uploaded; + const persisted = await persistReceipt(uploaded.value); + if (!persisted.ok) return persisted; + } + } + const persisted = await persistenceTail; + return persisted.ok ? browserDataSuccess(checkpoint) : persisted; +} + +async function uploadPartWithRetry( + dependencies: RuntimeDependencies, + request: UploadRequestSnapshot, + manifest: UploadPartManifest, + checkpoint: ResumableUploadCheckpoint, + part: UploadPartDescriptor, + bytes: Uint8Array, +): Promise> { + const idempotencyKey = await deriveUploadIdempotencyKey({ + label: "PART", + requestBindingSha256: checkpoint.requestBindingSha256, + sessionId: checkpoint.sessionId, + part, + crypto: dependencies.crypto, + signal: request.signal, + }); + if (!idempotencyKey.ok) return idempotencyKey; + const uploadBinding = await digestUploadSessionBinding({ + requestBindingSha256: checkpoint.requestBindingSha256, + sessionId: checkpoint.sessionId, + fingerprint: checkpoint.fingerprint, + crypto: dependencies.crypto, + signal: request.signal, + }); + if (!uploadBinding.ok) return uploadBinding; + + let lastFailure: UploadProviderFailure | null = null; + for ( + let attempt = 0; + attempt <= dependencies.policy.maxRetries; + attempt += 1 + ) { + if (request.signal.aborted) { + return browserDataFailure("ABORTED", "UPLOAD_PART"); + } + const now = safeNow(dependencies, "UPLOAD_PART"); + if (!now.ok) return now; + if (checkpoint.sessionExpiresAtEpochMs <= now.value) { + return browserDataFailure("EXPIRED_RESOURCE", "UPLOAD_SESSION", { + recovery: "RESTART", + }); + } + if (attempt > 0 && lastFailure) { + const delayed = await waitForRetry( + dependencies, + lastFailure, + attempt - 1, + request.signal, + "UPLOAD_PART", + ); + if (!delayed.ok) return delayed; + } + const issued = await invokeProviderAttempt( + dependencies, + "UPLOAD_PART", + request.signal, + async (attemptSignal) => + await dependencies.controlPlane.issuePartCapability({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: checkpoint.sessionId, + requestBindingSha256: checkpoint.requestBindingSha256, + uploadBindingSha256: uploadBinding.value, + fingerprint: checkpoint.fingerprint, + mediaType: request.mediaType, + part, + idempotencyKey: idempotencyKey.value, + signal: attemptSignal, + }), + ); + if (!issued.ok) { + lastFailure = issued.error; + if (!canRetry(issued.error, attempt, dependencies.policy)) { + return stripProviderFailure(issued); + } + continue; + } + const capability = validatePartCapability( + issued.value, + checkpoint, + uploadBinding.value, + dependencies, + ); + if (!capability.ok) { + if ( + capability.error.code === "EXPIRED_RESOURCE" && + attempt < dependencies.policy.maxRetries + ) { + lastFailure = Object.freeze({ + ...capability.error, + operation: "UPLOAD_PART", + retryable: true, + recovery: "REISSUE_CAPABILITY", + }); + continue; + } + return capability; + } + const uploaded = await invokeProviderAttempt( + dependencies, + "UPLOAD_PART", + request.signal, + async (attemptSignal) => + await dependencies.partExecutor.uploadPart({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + capability: capability.value.capability, + sessionId: checkpoint.sessionId, + requestBindingSha256: checkpoint.requestBindingSha256, + uploadBindingSha256: uploadBinding.value, + fingerprint: manifest.fingerprint, + mediaType: request.mediaType, + part, + bytes, + idempotencyKey: idempotencyKey.value, + signal: attemptSignal, + }), + ); + if (uploaded.ok) { + if ( + !isUploadPartReceipt(uploaded.value) || + !samePart(uploaded.value, part) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "UPLOAD_PART", + { recovery: "RECONCILE" }, + ); + } + return browserDataSuccess( + Object.freeze({ ...uploaded.value }), + ); + } + lastFailure = uploaded.error; + if (!canRetry(uploaded.error, attempt, dependencies.policy)) { + return stripProviderFailure(uploaded); + } + } + return lastFailure + ? stripProviderFailure( + Object.freeze({ ok: false, error: lastFailure }), + ) + : browserDataFailure("UNAVAILABLE", "UPLOAD_PART", { + recovery: "RESUME", + }); +} + +async function completeUpload( + dependencies: RuntimeDependencies, + request: UploadRequestSnapshot, + manifest: UploadPartManifest, + checkpoint: ResumableUploadCheckpoint, +): Promise> { + const orderedParts = snapshotReceipts(checkpoint.acceptedParts); + if ( + orderedParts.length !== manifest.parts.length || + !orderedParts.every((part, index) => { + const expected = manifest.parts[index]; + return Boolean(expected && samePart(part, expected)); + }) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "UPLOAD_COMPLETE", + { recovery: "RECONCILE" }, + ); + } + const idempotencyKey = await deriveUploadIdempotencyKey({ + label: "COMPLETE", + requestBindingSha256: checkpoint.requestBindingSha256, + sessionId: checkpoint.sessionId, + crypto: dependencies.crypto, + signal: request.signal, + }); + if (!idempotencyKey.ok) return idempotencyKey; + const completed = await callWithRetry( + dependencies, + "UPLOAD_COMPLETE", + request.signal, + async (attemptSignal) => + await dependencies.controlPlane.complete({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: checkpoint.sessionId, + requestBindingSha256: checkpoint.requestBindingSha256, + fingerprint: checkpoint.fingerprint, + orderedParts, + idempotencyKey: idempotencyKey.value, + signal: attemptSignal, + }), + ); + if (!completed.ok) return stripProviderFailure(completed); + const value = completed.value; + if ( + !exactKeys(value, [ + "state", + "protocol", + "sessionId", + "requestBindingSha256", + "fingerprint", + "resourceId", + ]) || + value.state !== "QUARANTINED" || + value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + value.sessionId !== checkpoint.sessionId || + value.requestBindingSha256 !== checkpoint.requestBindingSha256 || + !sameFingerprint(value.fingerprint, checkpoint.fingerprint) || + !SAFE_OPAQUE_ID.test(value.resourceId) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "UPLOAD_COMPLETE", + { recovery: "RECONCILE" }, + ); + } + const removed = await dependencies.checkpoints.remove({ + uploadKey: checkpoint.uploadKey, + expectedRevision: checkpoint.revision, + signal: request.signal, + }); + if (!removed.ok) return remapResult(removed, "UPLOAD_RECONCILE"); + return browserDataSuccess( + quarantinedOutcome(value.resourceId, sessionFromCheckpoint(checkpoint), false), + ); +} + +async function executeAbort( + dependencies: RuntimeDependencies, + uploadKey: string, + signal: AbortSignal, +): Promise> { + const found = await dependencies.checkpoints.read(uploadKey, signal); + if (!found.ok) return remapResult(found, "UPLOAD_ABORT"); + if (!found.value) { + return browserDataSuccess(Object.freeze({ state: "NOT_FOUND" })); + } + let checkpoint = found.value; + if (checkpoint.state === "ACTIVE") { + const now = safeNow(dependencies, "UPLOAD_ABORT"); + if (!now.ok) return now; + const pending = checkpointFrom({ + previous: checkpoint, + revision: checkpoint.revision + 1, + state: "ABORT_PENDING", + updatedAtEpochMs: now.value, + }); + const saved = await dependencies.checkpoints.compareAndSwap({ + expectedRevision: checkpoint.revision, + checkpoint: pending, + signal, + }); + if (!saved.ok) return remapResult(saved, "UPLOAD_ABORT"); + checkpoint = saved.value; + } + const idempotencyKey = await deriveUploadIdempotencyKey({ + label: "ABORT", + requestBindingSha256: checkpoint.requestBindingSha256, + sessionId: checkpoint.sessionId, + crypto: dependencies.crypto, + signal, + }); + if (!idempotencyKey.ok) return idempotencyKey; + const aborted = await callWithRetry( + dependencies, + "UPLOAD_ABORT", + signal, + async (attemptSignal) => + await dependencies.controlPlane.abort({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: checkpoint.sessionId, + requestBindingSha256: checkpoint.requestBindingSha256, + idempotencyKey: idempotencyKey.value, + signal: attemptSignal, + }), + ); + if (!aborted.ok) { + if ( + aborted.error.code === "NOT_FOUND" || + aborted.error.code === "EXPIRED_RESOURCE" + ) { + const removed = await dependencies.checkpoints.remove({ + uploadKey, + expectedRevision: checkpoint.revision, + signal, + }); + return removed.ok + ? browserDataSuccess( + Object.freeze({ state: "ORPHANED" as const }), + ) + : remapResult(removed, "UPLOAD_ABORT"); + } + return stripProviderFailure(aborted); + } + if ( + !exactKeys(aborted.value, ["state"]) || + ![ + "ABORTED", + "NOT_FOUND", + "EXPIRED", + "ALREADY_COMPLETED", + ].includes(aborted.value.state) + ) { + return browserDataFailure("CORRUPT_DATA", "UPLOAD_ABORT", { + recovery: "RECONCILE", + }); + } + const removed = await dependencies.checkpoints.remove({ + uploadKey, + expectedRevision: checkpoint.revision, + signal, + }); + if (!removed.ok) return remapResult(removed, "UPLOAD_ABORT"); + const state: UploadAbortOutcome["state"] = + aborted.value.state === "ABORTED" + ? "ABORTED" + : aborted.value.state === "ALREADY_COMPLETED" + ? "ALREADY_COMPLETED" + : "ORPHANED"; + return browserDataSuccess(Object.freeze({ state })); +} + +async function callWithRetry( + dependencies: RuntimeDependencies, + operation: BrowserDataOperation, + signal: AbortSignal, + action: ( + attemptSignal: AbortSignal, + ) => Promise>, +): Promise> { + let lastFailure: UploadProviderFailure | null = null; + for ( + let attempt = 0; + attempt <= dependencies.policy.maxRetries; + attempt += 1 + ) { + if (signal.aborted) { + return providerFailure("ABORTED", operation, false, "NONE"); + } + if (attempt > 0 && lastFailure) { + const delayed = await waitForRetry( + dependencies, + lastFailure, + attempt - 1, + signal, + operation, + ); + if (!delayed.ok) { + return Object.freeze({ ok: false, error: delayed.error }); + } + } + const result = await invokeProviderAttempt( + dependencies, + operation, + signal, + action, + ); + if (result.ok) { + observeTerminal(dependencies.observer, operation, result); + return result; + } + lastFailure = result.error; + if (!canRetry(result.error, attempt, dependencies.policy)) { + observeTerminal(dependencies.observer, operation, result); + return result; + } + } + const exhausted = Object.freeze({ + ok: false, + error: + lastFailure ?? + providerFailureValue( + "UNAVAILABLE", + operation, + false, + "RESUME", + ), + }) as UploadProviderResult; + observeTerminal(dependencies.observer, operation, exhausted); + return exhausted; +} + +async function invokeProviderAttempt( + dependencies: RuntimeDependencies, + operation: BrowserDataOperation, + parentSignal: AbortSignal, + action: ( + attemptSignal: AbortSignal, + ) => Promise>, +): Promise> { + if (parentSignal.aborted) { + return providerFailure("ABORTED", operation, false, "NONE"); + } + const controller = new AbortController(); + let timer: ReturnType | undefined; + let releaseAbort = () => {}; + const deadline = new Promise>((resolve) => { + const abort = () => { + controller.abort(); + resolve(providerFailure("ABORTED", operation, false, "NONE")); + }; + parentSignal.addEventListener("abort", abort, { once: true }); + releaseAbort = () => + parentSignal.removeEventListener("abort", abort); + timer = setTimeout(() => { + controller.abort(); + resolve( + providerFailure("UNAVAILABLE", operation, true, "RESUME"), + ); + }, dependencies.policy.providerAttemptTimeoutMs); + }); + try { + return await Promise.race([ + invokeProvider(operation, () => action(controller.signal)), + deadline, + ]); + } finally { + if (timer) clearTimeout(timer); + releaseAbort(); + } +} + +async function invokeProvider( + operation: BrowserDataOperation, + action: () => Promise>, +): Promise> { + try { + const result = await action(); + if (!result || typeof result !== "object") { + return providerFailure("UNAVAILABLE", operation, true, "RESUME"); + } + if (result.ok === true) return result; + if (result.ok !== false) { + return providerFailure("UNAVAILABLE", operation, true, "RESUME"); + } + const normalized = normalizeProviderFailure(result.error, operation); + return Object.freeze({ ok: false, error: normalized }); + } catch { + return providerFailure("UNAVAILABLE", operation, true, "RESUME"); + } +} + +async function waitForRetry( + dependencies: RuntimeDependencies, + failure: UploadProviderFailure, + retryIndex: number, + signal: AbortSignal, + operation: BrowserDataOperation, +): Promise> { + const retryAfter = + Number.isSafeInteger(failure.retryAfterMs) && + (failure.retryAfterMs ?? -1) >= 0 && + (failure.retryAfterMs ?? 0) <= dependencies.policy.maxRetryAfterMs + ? (failure.retryAfterMs ?? 0) + : null; + const randomValue = safeRandom(dependencies); + if (!randomValue.ok) return randomValue; + const exponential = Math.min( + dependencies.policy.retryMaxDelayMs, + dependencies.policy.retryBaseDelayMs * 2 ** retryIndex, + ); + const jittered = Math.floor(exponential * (0.5 + randomValue.value / 2)); + const delayMs = Math.max(jittered, retryAfter ?? 0); + try { + await dependencies.sleep(delayMs, signal); + return signal.aborted + ? browserDataFailure("ABORTED", operation) + : browserDataSuccess(undefined); + } catch { + return signal.aborted + ? browserDataFailure("ABORTED", operation) + : browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RESUME", + }); + } +} + +function canRetry( + failure: UploadProviderFailure, + attempt: number, + policy: ResumableUploadRuntimePolicy, +): boolean { + return ( + failure.retryable && + attempt < policy.maxRetries && + (failure.retryAfterMs === undefined || + (Number.isSafeInteger(failure.retryAfterMs) && + failure.retryAfterMs >= 0 && + failure.retryAfterMs <= policy.maxRetryAfterMs)) + ); +} + +function validatePartCapability( + value: UploadPartCapability, + checkpoint: ResumableUploadCheckpoint, + uploadBindingSha256: string, + dependencies: RuntimeDependencies, +): BrowserDataResult> { + if ( + !exactKeys(value, [ + "capability", + "uploadBindingSha256", + "expiresAtEpochMs", + ]) || + value.uploadBindingSha256 !== uploadBindingSha256 || + !SHA256_HEX.test(value.uploadBindingSha256) || + !Number.isSafeInteger(value.expiresAtEpochMs) + ) { + return browserDataFailure("CORRUPT_DATA", "UPLOAD_PART", { + recovery: "REISSUE_CAPABILITY", + }); + } + const now = safeNow(dependencies, "UPLOAD_PART"); + if (!now.ok) return now; + if ( + value.expiresAtEpochMs <= + now.value + dependencies.policy.capabilityRefreshSkewMs + ) { + return browserDataFailure("EXPIRED_RESOURCE", "UPLOAD_PART", { + retryable: true, + recovery: "REISSUE_CAPABILITY", + }); + } + if (value.expiresAtEpochMs > checkpoint.sessionExpiresAtEpochMs) { + return browserDataFailure("POLICY_REJECTED", "UPLOAD_PART", { + recovery: "REISSUE_CAPABILITY", + }); + } + return browserDataSuccess(value); +} + +function validateStatus( + value: UploadSessionStatus, + checkpoint: ResumableUploadCheckpoint, + manifest: UploadPartManifest, + dependencies: RuntimeDependencies, +): BrowserDataResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return browserDataFailure("CORRUPT_DATA", "UPLOAD_RECONCILE", { + recovery: "RECONCILE", + }); + } + if (value.state === "ACTIVE") { + if (!exactKeys(value, ["state", "session", "acceptedParts"])) { + return browserDataFailure( + "CORRUPT_DATA", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ); + } + const session = validateSession( + value.session, + checkpoint.requestBindingSha256, + manifest.fingerprint, + dependencies, + true, + ); + if ( + !session.ok || + session.value.sessionId !== checkpoint.sessionId || + session.value.maxConcurrency !== checkpoint.sessionMaxConcurrency || + session.value.expiresAtEpochMs !== + checkpoint.sessionExpiresAtEpochMs + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ); + } + const receipts = validateServerReceipts( + value, + manifest, + "UPLOAD_RECONCILE", + ); + if (!receipts.ok) return receipts; + return browserDataSuccess( + Object.freeze({ + state: "ACTIVE" as const, + session: session.value, + acceptedParts: receipts.value, + }), + ); + } + if (value.state === "QUARANTINED") { + if (!exactKeys(value, ["state", "session", "resourceId"])) { + return browserDataFailure( + "CORRUPT_DATA", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ); + } + const session = validateSession( + value.session, + checkpoint.requestBindingSha256, + manifest.fingerprint, + dependencies, + false, + ); + if ( + !session.ok || + session.value.sessionId !== checkpoint.sessionId || + session.value.maxConcurrency !== checkpoint.sessionMaxConcurrency || + session.value.expiresAtEpochMs !== + checkpoint.sessionExpiresAtEpochMs || + !SAFE_OPAQUE_ID.test(value.resourceId) + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "UPLOAD_RECONCILE", + { recovery: "RECONCILE" }, + ); + } + return browserDataSuccess( + Object.freeze({ + state: "QUARANTINED" as const, + session: session.value, + resourceId: value.resourceId, + }), + ); + } + if ( + !["ABORTED", "EXPIRED", "NOT_FOUND"].includes(value.state) || + !exactKeys(value, [ + "state", + "protocol", + "sessionId", + "requestBindingSha256", + ]) || + value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + value.sessionId !== checkpoint.sessionId || + value.requestBindingSha256 !== checkpoint.requestBindingSha256 + ) { + return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_RECONCILE", { + recovery: "RECONCILE", + }); + } + return browserDataSuccess( + Object.freeze({ + state: value.state, + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: value.sessionId, + requestBindingSha256: value.requestBindingSha256, + }), + ); +} + +function validateSession( + value: UploadSession, + requestBindingSha256: string, + fingerprint: UploadFileFingerprint, + dependencies: RuntimeDependencies, + requireActiveExpiry = true, +): BrowserDataResult { + if ( + !exactKeys(value, [ + "protocol", + "sessionId", + "requestBindingSha256", + "fingerprint", + "partSizeBytes", + "partCount", + "maxConcurrency", + "expiresAtEpochMs", + ]) || + value.protocol !== RESUMABLE_UPLOAD_PROTOCOL || + !SAFE_OPAQUE_ID.test(value.sessionId) || + value.requestBindingSha256 !== requestBindingSha256 || + !SHA256_HEX.test(value.requestBindingSha256) || + !isUploadFileFingerprint(value.fingerprint) || + !sameFingerprint(value.fingerprint, fingerprint) || + value.partSizeBytes !== fingerprint.partSizeBytes || + value.partCount !== fingerprint.partCount || + !Number.isSafeInteger(value.maxConcurrency) || + value.maxConcurrency < 1 || + value.maxConcurrency > dependencies.policy.maxConcurrency || + !Number.isSafeInteger(value.expiresAtEpochMs) + ) { + return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_SESSION", { + recovery: "RECONCILE", + }); + } + const now = safeNow(dependencies, "UPLOAD_SESSION"); + if (!now.ok) return now; + if ( + requireActiveExpiry && + (value.expiresAtEpochMs <= now.value || + value.expiresAtEpochMs - now.value > + dependencies.policy.maxSessionLifetimeMs) + ) { + return browserDataFailure("EXPIRED_RESOURCE", "UPLOAD_SESSION", { + recovery: "RESTART", + }); + } + return browserDataSuccess( + Object.freeze({ + ...value, + fingerprint: Object.freeze({ ...value.fingerprint }), + }), + ); +} + +function validateServerReceipts( + status: ActiveUploadStatus, + manifest: UploadPartManifest, + operation: BrowserDataOperation, +): BrowserDataResult { + if ( + !Array.isArray(status.acceptedParts) || + status.acceptedParts.length > manifest.parts.length + ) { + return browserDataFailure("CORRUPT_DATA", operation, { + recovery: "RECONCILE", + }); + } + let previousPart = 0; + for (const receipt of status.acceptedParts) { + if ( + !isUploadPartReceipt(receipt) || + receipt.partNumber <= previousPart || + !verifyPartAgainstManifest(receipt, manifest) + ) { + return browserDataFailure("INTEGRITY_FAILED", operation, { + recovery: "RECONCILE", + }); + } + previousPart = receipt.partNumber; + } + return browserDataSuccess(snapshotReceipts(status.acceptedParts)); +} + +function checkpointFrom( + input: + | Readonly<{ + revision: number; + protocol: typeof RESUMABLE_UPLOAD_PROTOCOL; + state: "ACTIVE" | "ABORT_PENDING"; + uploadKey: string; + requestBindingSha256: string; + fingerprint: UploadFileFingerprint; + sessionId: string; + sessionExpiresAtEpochMs: number; + sessionMaxConcurrency: number; + acceptedParts: readonly UploadPartReceipt[]; + updatedAtEpochMs: number; + }> + | Readonly<{ + previous: ResumableUploadCheckpoint; + revision: number; + state?: "ACTIVE" | "ABORT_PENDING"; + acceptedParts?: readonly UploadPartReceipt[]; + updatedAtEpochMs: number; + }>, +): ResumableUploadCheckpoint { + const candidate = + "previous" in input + ? { + ...input.previous, + revision: input.revision, + state: input.state ?? input.previous.state, + acceptedParts: + input.acceptedParts ?? input.previous.acceptedParts, + updatedAtEpochMs: input.updatedAtEpochMs, + } + : input; + const checkpoint: ResumableUploadCheckpoint = Object.freeze({ + ...candidate, + schemaVersion: 1, + fingerprint: Object.freeze({ ...candidate.fingerprint }), + acceptedParts: snapshotReceipts(candidate.acceptedParts), + }); + if (!isResumableUploadCheckpoint(checkpoint)) { + throw new TypeError("Upload checkpoint construction failed."); + } + return checkpoint; +} + +function sessionFromCheckpoint( + checkpoint: ResumableUploadCheckpoint, +): UploadSession { + return Object.freeze({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: checkpoint.sessionId, + requestBindingSha256: checkpoint.requestBindingSha256, + fingerprint: checkpoint.fingerprint, + partSizeBytes: checkpoint.fingerprint.partSizeBytes, + partCount: checkpoint.fingerprint.partCount, + maxConcurrency: checkpoint.sessionMaxConcurrency, + expiresAtEpochMs: checkpoint.sessionExpiresAtEpochMs, + }); +} + +function quarantinedOutcome( + resourceId: string, + session: UploadSession, + replayed: boolean, +): QuarantinedUpload { + return Object.freeze({ + state: "QUARANTINED", + resourceId, + byteLength: session.fingerprint.byteLength, + replayed, + }); +} + +function snapshotRequest( + input: ResumableUploadRequest, + policy: ResumableUploadRuntimePolicy, +): UploadRequestSnapshot { + if ( + !input || + typeof input !== "object" || + typeof input.uploadKey !== "string" || + !SAFE_UPLOAD_KEY.test(input.uploadKey) || + typeof input.purpose !== "string" || + !SAFE_REGISTRY_ID.test(input.purpose) || + typeof input.mediaType !== "string" || + !MEDIA_TYPE.test(input.mediaType) || + !isAbortSignal(input.signal) || + (input.onProgress !== undefined && + typeof input.onProgress !== "function") + ) { + throw new TypeError("Upload request is invalid."); + } + const source = snapshotUploadSource(input.source); + if ( + source.byteLength > policy.maxFileBytes || + Math.ceil(source.byteLength / policy.partSizeBytes) > + policy.maxPartCount + ) { + throw new TypeError("Upload request exceeds policy."); + } + return Object.freeze({ + uploadKey: input.uploadKey, + purpose: input.purpose, + mediaType: input.mediaType, + source, + signal: input.signal, + ...(input.onProgress ? { onProgress: input.onProgress } : {}), + }); +} + +function snapshotDependencies( + input: ResumableUploadRuntimeDependencies, +): RuntimeDependencies { + const policy = resolveResumableUploadRuntimePolicy(input.policy); + const controlPlane = snapshotControlPlane(input.controlPlane); + const partExecutor = snapshotPartExecutor(input.partExecutor); + const checkpoints = snapshotCheckpointStore(input.checkpoints); + const mutationLock = snapshotMutationLock(input.mutationLock); + const crossContextCancellation = snapshotCancellationChannel( + input.crossContextCancellation, + ); + const crypto = snapshotUploadCrypto(input.crypto); + const nowSource = input.now ?? Date.now; + const randomSource = input.random ?? Math.random; + const sleepSource = input.sleep ?? abortableSleep; + const observer = snapshotObserver(input.observer); + if ( + typeof nowSource !== "function" || + typeof randomSource !== "function" || + typeof sleepSource !== "function" + ) { + throw new TypeError("Upload runtime dependency is invalid."); + } + return Object.freeze({ + controlPlane, + partExecutor, + checkpoints, + mutationLock, + ...(crossContextCancellation + ? { crossContextCancellation } + : {}), + crypto, + policy, + now: () => nowSource(), + random: () => randomSource(), + sleep: async (delayMs, signal) => + await sleepSource(delayMs, signal), + ...(observer ? { observer } : {}), + }); +} + +function snapshotCancellationChannel( + value: UploadCancellationChannel | undefined, +): UploadCancellationChannel | undefined { + if (!value) return undefined; + const publish = value.publish; + const subscribe = value.subscribe; + const close = value.close; + if ( + typeof publish !== "function" || + typeof subscribe !== "function" || + typeof close !== "function" + ) { + throw new TypeError( + "Upload cancellation channel is invalid.", + ); + } + return Object.freeze({ + publish(uploadKey: string): boolean { + try { + return publish.call(value, uploadKey) === true; + } catch { + return false; + } + }, + subscribe( + listener: (uploadKey: string) => void, + ): () => void { + const release = subscribe.call(value, listener); + if (typeof release !== "function") { + throw new TypeError( + "Upload cancellation subscription is invalid.", + ); + } + let active = true; + return () => { + if (!active) return; + active = false; + try { + release(); + } catch { + // Runtime close remains terminal. + } + }; + }, + close(): void { + try { + close.call(value); + } catch { + // Runtime close remains terminal. + } + }, + }); +} + +function snapshotObserver( + value: BrowserDataObserver | undefined, +): BrowserDataObserver | undefined { + if (!value) return undefined; + const record = value.record; + if (typeof record !== "function") { + throw new TypeError("Upload observer is invalid."); + } + return Object.freeze({ + record( + observation: Parameters[0], + ) { + record.call(value, Object.freeze({ ...observation })); + }, + }); +} + +function snapshotControlPlane( + value: ResumableUploadControlPlane, +): ResumableUploadControlPlane { + const createSession = value?.createSession; + const getStatus = value?.getStatus; + const issuePartCapability = value?.issuePartCapability; + const complete = value?.complete; + const abort = value?.abort; + if ( + typeof createSession !== "function" || + typeof getStatus !== "function" || + typeof issuePartCapability !== "function" || + typeof complete !== "function" || + typeof abort !== "function" + ) { + throw new TypeError("Upload control plane is invalid."); + } + const snapshot: ResumableUploadControlPlane = { + async createSession( + input: Parameters< + ResumableUploadControlPlane["createSession"] + >[0], + ) { + return await createSession.call(value, input); + }, + async getStatus( + input: Parameters< + ResumableUploadControlPlane["getStatus"] + >[0], + ) { + return await getStatus.call(value, input); + }, + async issuePartCapability( + input: Parameters< + ResumableUploadControlPlane["issuePartCapability"] + >[0], + ) { + return await issuePartCapability.call(value, input); + }, + async complete( + input: Parameters< + ResumableUploadControlPlane["complete"] + >[0], + ) { + return await complete.call(value, input); + }, + async abort( + input: Parameters< + ResumableUploadControlPlane["abort"] + >[0], + ) { + return await abort.call(value, input); + }, + }; + return Object.freeze(snapshot); +} + +function snapshotPartExecutor( + value: UploadPartExecutor, +): UploadPartExecutor { + const uploadPart = value?.uploadPart; + if (typeof uploadPart !== "function") { + throw new TypeError("Upload part executor is invalid."); + } + const snapshot: UploadPartExecutor = { + async uploadPart( + input: Parameters["uploadPart"]>[0], + ) { + return await uploadPart.call(value, input); + }, + }; + return Object.freeze(snapshot); +} + +function snapshotCheckpointStore( + value: ResumableUploadCheckpointStore, +): ResumableUploadCheckpointStore { + const read = value?.read; + const compareAndSwap = value?.compareAndSwap; + const remove = value?.remove; + const close = value?.close; + if ( + typeof read !== "function" || + typeof compareAndSwap !== "function" || + typeof remove !== "function" || + typeof close !== "function" + ) { + throw new TypeError("Upload checkpoint store is invalid."); + } + const snapshot: ResumableUploadCheckpointStore = { + async read( + uploadKey: string, + signal?: AbortSignal, + ) { + return await read.call(value, uploadKey, signal); + }, + async compareAndSwap( + input: Parameters< + ResumableUploadCheckpointStore["compareAndSwap"] + >[0], + ) { + return await compareAndSwap.call(value, input); + }, + async remove( + input: Parameters[0], + ) { + return await remove.call(value, input); + }, + close() { + close.call(value); + }, + }; + return Object.freeze(snapshot); +} + +function snapshotMutationLock(value: UploadMutationLock): UploadMutationLock { + const run = value?.run; + if (typeof run !== "function") { + throw new TypeError("Upload mutation lock is invalid."); + } + return Object.freeze({ + async run( + uploadKey: string, + signal: AbortSignal, + task: () => Promise, + ): Promise { + return await (run.call( + value, + uploadKey, + signal, + task, + ) as Promise); + }, + }); +} + +function normalizeProviderFailure( + input: UploadProviderFailure, + operation: BrowserDataOperation, +): UploadProviderFailure { + if ( + !input || + typeof input !== "object" || + !FAILURE_CODES.has(input.code) || + typeof input.retryable !== "boolean" || + !RECOVERIES.has(input.recovery) || + (input.retryAfterMs !== undefined && + (!Number.isSafeInteger(input.retryAfterMs) || + input.retryAfterMs < 0)) + ) { + return providerFailureValue( + "UNAVAILABLE", + operation, + true, + "RESUME", + ); + } + return Object.freeze({ + code: input.code, + operation, + retryable: input.retryable, + recovery: input.recovery, + ...(input.retryAfterMs === undefined + ? {} + : { retryAfterMs: input.retryAfterMs }), + }); +} + +function providerFailure( + code: BrowserDataFailureCode, + operation: BrowserDataOperation, + retryable: boolean, + recovery: BrowserDataRecovery, +): UploadProviderResult { + return Object.freeze({ + ok: false, + error: providerFailureValue( + code, + operation, + retryable, + recovery, + ), + }); +} + +function providerFailureValue( + code: BrowserDataFailureCode, + operation: BrowserDataOperation, + retryable: boolean, + recovery: BrowserDataRecovery, +): UploadProviderFailure { + return Object.freeze({ code, operation, retryable, recovery }); +} + +function stripProviderFailure( + result: UploadProviderResult, +): BrowserDataResult { + if (result.ok) return browserDataSuccess(result.value); + return browserDataFailure(result.error.code, result.error.operation, { + retryable: result.error.retryable, + recovery: result.error.recovery, + }); +} + +function remapResult( + result: BrowserDataResult, + operation: BrowserDataOperation, +): BrowserDataResult { + return result.ok + ? result + : browserDataFailure(result.error.code, operation, { + retryable: result.error.retryable, + recovery: result.error.recovery, + }); +} + +function safeNow( + dependencies: RuntimeDependencies, + operation: BrowserDataOperation, +): BrowserDataResult { + try { + const value = dependencies.now(); + return Number.isSafeInteger(value) && value >= 0 + ? browserDataSuccess(value) + : browserDataFailure("UNAVAILABLE", operation, { + recovery: "RESUME", + }); + } catch { + return browserDataFailure("UNAVAILABLE", operation, { + recovery: "RESUME", + }); + } +} + +function safeRandom( + dependencies: RuntimeDependencies, +): BrowserDataResult { + try { + const value = dependencies.random(); + return Number.isFinite(value) && value >= 0 && value <= 1 + ? browserDataSuccess(value) + : browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", { + recovery: "RESUME", + }); + } catch { + return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", { + recovery: "RESUME", + }); + } +} + +function sameFingerprint( + left: UploadFileFingerprint, + right: UploadFileFingerprint, +): boolean { + return ( + left.algorithm === right.algorithm && + left.digestHex === right.digestHex && + left.byteLength === right.byteLength && + left.partSizeBytes === right.partSizeBytes && + left.partCount === right.partCount + ); +} + +function snapshotReceipts( + parts: readonly UploadPartReceipt[], +): readonly UploadPartReceipt[] { + return Object.freeze( + parts.map((part) => Object.freeze({ ...part })), + ); +} + +function exactKeys( + value: unknown, + keys: readonly string[], +): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function reportProgress( + request: UploadRequestSnapshot, + phase: TransferProgress["phase"], + transferredBytes: number, +): void { + try { + request.onProgress?.( + Object.freeze({ + phase, + transferredBytes, + totalBytes: request.source.byteLength, + }), + ); + } catch { + // Progress observation is best-effort and data-free. + } +} + +function observeTerminal( + observer: BrowserDataObserver | undefined, + operation: BrowserDataOperation, + result: BrowserDataResult | UploadProviderResult, + byteLength?: number, +): void { + try { + observer?.record( + Object.freeze({ + operation, + outcome: result.ok ? "SUCCEEDED" : "FAILED", + ...(result.ok ? {} : { failureCode: result.error.code }), + ...(byteLength === undefined + ? {} + : { byteBucket: transferByteBucket(byteLength) }), + }), + ); + } catch { + // Upload correctness is independent from best-effort observation. + } +} + +function transferByteBucket( + byteLength: number, +): NonNullable< + Parameters[0]["byteBucket"] +> { + if (byteLength === 0) return "ZERO"; + if (byteLength < 1024 * 1024) return "LT1MIB"; + if (byteLength < 10 * 1024 * 1024) return "1_TO_9MIB"; + if (byteLength < 100 * 1024 * 1024) return "10_TO_99MIB"; + return "GTE100MIB"; +} + +function mapLockFailure( + error: unknown, + operation: BrowserDataOperation, +): BrowserDataResult { + if (error instanceof DOMException && error.name === "AbortError") { + return browserDataFailure("ABORTED", operation); + } + if ( + error instanceof DOMException && + error.name === "InvalidStateError" + ) { + return browserDataFailure("BLOCKED", operation, { + retryable: true, + recovery: "RESUME", + }); + } + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RESUME", + }); +} + +function combineAbortSignals( + first: AbortSignal, + second: AbortSignal, +): Readonly<{ signal: AbortSignal; release(): void }> { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (first.aborted || second.aborted) { + controller.abort(); + } else { + first.addEventListener("abort", abort, { once: true }); + second.addEventListener("abort", abort, { once: true }); + } + return Object.freeze({ + signal: controller.signal, + release() { + first.removeEventListener("abort", abort); + second.removeEventListener("abort", abort); + }, + }); +} + +function isAbortSignal(value: unknown): value is AbortSignal { + return Boolean( + value && + typeof value === "object" && + typeof (value as AbortSignal).aborted === "boolean" && + typeof (value as AbortSignal).addEventListener === "function" && + typeof (value as AbortSignal).removeEventListener === "function", + ); +} + +async function abortableSleep( + delayMs: number, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + await new Promise((resolve, reject) => { + const timer = setTimeout(finish, delayMs); + function finish() { + signal.removeEventListener("abort", abort); + resolve(); + } + function abort() { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + reject(new DOMException("The operation was aborted.", "AbortError")); + } + signal.addEventListener("abort", abort, { once: true }); + }); +} diff --git a/src/adapters/browser-transfer/resumable-upload/runtime-policy.ts b/src/adapters/browser-transfer/resumable-upload/runtime-policy.ts new file mode 100644 index 0000000..b6db27a --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/runtime-policy.ts @@ -0,0 +1,112 @@ +export type ResumableUploadRuntimePolicy = Readonly<{ + partSizeBytes: number; + maxFileBytes: number; + maxPartCount: number; + maxConcurrency: number; + maxInFlightBytes: number; + partBufferCopyFactor: number; + maxSourceChunkBytes: number; + maxRetries: number; + retryBaseDelayMs: number; + retryMaxDelayMs: number; + maxRetryAfterMs: number; + capabilityRefreshSkewMs: number; + maxSessionLifetimeMs: number; + providerAttemptTimeoutMs: number; +}>; + +const MIB = 1024 * 1024; +const GIB = 1024 * MIB; + +const ABSOLUTE_LIMITS = Object.freeze({ + maxPartSizeBytes: 64 * MIB, + maxFileBytes: 100 * GIB, + maxPartCount: 10_000, + maxConcurrency: 8, + maxInFlightBytes: 256 * MIB, + maxPartBufferCopyFactor: 8, + maxSourceChunkBytes: 64 * MIB, + maxRetries: 8, + maxRetryDelayMs: 60_000, + maxRetryAfterMs: 60_000, + maxCapabilityRefreshSkewMs: 5 * 60_000, + maxSessionLifetimeMs: 7 * 24 * 60 * 60_000, + maxProviderAttemptTimeoutMs: 2 * 60_000, +}); + +const DEFAULT_POLICY: ResumableUploadRuntimePolicy = Object.freeze({ + partSizeBytes: 5 * MIB, + maxFileBytes: 5 * GIB, + maxPartCount: 1_024, + maxConcurrency: 3, + maxInFlightBytes: 20 * MIB, + partBufferCopyFactor: 4, + maxSourceChunkBytes: 8 * MIB, + maxRetries: 3, + retryBaseDelayMs: 250, + retryMaxDelayMs: 5_000, + maxRetryAfterMs: 30_000, + capabilityRefreshSkewMs: 5_000, + maxSessionLifetimeMs: 24 * 60 * 60_000, + providerAttemptTimeoutMs: 30_000, +}); + +export function resolveResumableUploadRuntimePolicy( + input: Partial = {}, +): ResumableUploadRuntimePolicy { + const policy: ResumableUploadRuntimePolicy = Object.freeze({ + ...DEFAULT_POLICY, + ...input, + }); + if ( + !positiveSafeInteger(policy.partSizeBytes) || + policy.partSizeBytes > ABSOLUTE_LIMITS.maxPartSizeBytes || + !positiveSafeInteger(policy.maxFileBytes) || + policy.maxFileBytes > ABSOLUTE_LIMITS.maxFileBytes || + !positiveSafeInteger(policy.maxPartCount) || + policy.maxPartCount > ABSOLUTE_LIMITS.maxPartCount || + !positiveSafeInteger(policy.maxConcurrency) || + policy.maxConcurrency > ABSOLUTE_LIMITS.maxConcurrency || + !positiveSafeInteger(policy.maxInFlightBytes) || + policy.maxInFlightBytes > ABSOLUTE_LIMITS.maxInFlightBytes || + !positiveSafeInteger(policy.partBufferCopyFactor) || + policy.partBufferCopyFactor > + ABSOLUTE_LIMITS.maxPartBufferCopyFactor || + policy.maxInFlightBytes < + policy.partSizeBytes * policy.partBufferCopyFactor || + !positiveSafeInteger(policy.maxSourceChunkBytes) || + policy.maxSourceChunkBytes > + ABSOLUTE_LIMITS.maxSourceChunkBytes || + !nonNegativeSafeInteger(policy.maxRetries) || + policy.maxRetries > ABSOLUTE_LIMITS.maxRetries || + !positiveSafeInteger(policy.retryBaseDelayMs) || + policy.retryBaseDelayMs > ABSOLUTE_LIMITS.maxRetryDelayMs || + !positiveSafeInteger(policy.retryMaxDelayMs) || + policy.retryMaxDelayMs > ABSOLUTE_LIMITS.maxRetryDelayMs || + policy.retryBaseDelayMs > policy.retryMaxDelayMs || + !nonNegativeSafeInteger(policy.maxRetryAfterMs) || + policy.maxRetryAfterMs > ABSOLUTE_LIMITS.maxRetryAfterMs || + !nonNegativeSafeInteger(policy.capabilityRefreshSkewMs) || + policy.capabilityRefreshSkewMs > + ABSOLUTE_LIMITS.maxCapabilityRefreshSkewMs || + !positiveSafeInteger(policy.maxSessionLifetimeMs) || + policy.maxSessionLifetimeMs > + ABSOLUTE_LIMITS.maxSessionLifetimeMs || + !positiveSafeInteger(policy.providerAttemptTimeoutMs) || + policy.providerAttemptTimeoutMs > + ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs || + Math.ceil(policy.maxFileBytes / policy.partSizeBytes) > + policy.maxPartCount + ) { + throw new TypeError("Resumable upload policy is invalid."); + } + return policy; +} + +function positiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function nonNegativeSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} diff --git a/src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts b/src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts new file mode 100644 index 0000000..e2cecfe --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts @@ -0,0 +1,600 @@ +import type { + ResumableUploadSource, + UploadFileFingerprint, + UploadPartDescriptor, +} from "../../../application/ports/browser-transfer/resumable-upload.ts"; +import type { + BrowserDataFailure, + BrowserDataOperation, + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { samePart } from "./checkpoint-schema.ts"; + +export type UploadCrypto = Readonly<{ + digestSha256(bytes: Uint8Array): Promise; +}>; + +export type UploadSourceSnapshot = + | Readonly<{ + kind: "FILE_BYTE_SOURCE"; + byteLength: number; + stream( + signal: AbortSignal, + ): AsyncIterable>; + }> + | Readonly<{ + kind: "RANGE_READER"; + byteLength: number; + readRange(input: Readonly<{ + offset: number; + length: number; + signal: AbortSignal; + }>): Promise>; + }>; + +export type UploadPartManifest = Readonly<{ + fingerprint: UploadFileFingerprint; + parts: readonly UploadPartDescriptor[]; +}>; + +export function snapshotUploadSource( + source: ResumableUploadSource, +): UploadSourceSnapshot { + if (!source || typeof source !== "object") { + throw new TypeError("Upload source is invalid."); + } + if (source.kind === "FILE_BYTE_SOURCE") { + const bytes = source.bytes; + const stream = bytes?.stream; + if ( + typeof stream !== "function" || + !positiveSafeInteger(bytes.byteLength) + ) { + throw new TypeError("Upload byte source is invalid."); + } + return Object.freeze({ + kind: "FILE_BYTE_SOURCE" as const, + byteLength: bytes.byteLength, + stream(signal: AbortSignal) { + return stream.call(bytes, signal); + }, + }); + } + if (source.kind === "RANGE_READER") { + const reader = source.reader; + const readRange = reader?.readRange; + if ( + typeof readRange !== "function" || + !positiveSafeInteger(reader.byteLength) + ) { + throw new TypeError("Upload range source is invalid."); + } + return Object.freeze({ + kind: "RANGE_READER" as const, + byteLength: reader.byteLength, + async readRange(input) { + return await readRange.call(reader, input); + }, + }); + } + throw new TypeError("Upload source kind is invalid."); +} + +export function snapshotUploadCrypto(crypto: Crypto): UploadCrypto { + const subtle = crypto?.subtle; + const digest = subtle?.digest; + if (typeof digest !== "function") { + throw new TypeError("Upload crypto capability is invalid."); + } + return Object.freeze({ + async digestSha256(bytes: Uint8Array): Promise { + return await digest.call( + subtle, + "SHA-256", + Uint8Array.from(bytes), + ); + }, + }); +} + +export async function buildUploadPartManifest(input: Readonly<{ + source: UploadSourceSnapshot; + partSizeBytes: number; + maxPartCount: number; + maxSourceChunkBytes: number; + crypto: UploadCrypto; + signal: AbortSignal; + onPreparedBytes?: (bytes: number) => void; +}>): Promise> { + const parts: UploadPartDescriptor[] = []; + let preparedBytes = 0; + for await (const partResult of iterateUploadParts({ + source: input.source, + partSizeBytes: input.partSizeBytes, + maxSourceChunkBytes: input.maxSourceChunkBytes, + signal: input.signal, + operation: "UPLOAD_SESSION", + })) { + if (!partResult.ok) return partResult; + if (parts.length >= input.maxPartCount) { + return browserDataFailure("LIMIT_EXCEEDED", "UPLOAD_SESSION"); + } + const checksum = await digestHex( + input.crypto, + partResult.value.bytes, + input.signal, + "UPLOAD_SESSION", + ); + if (!checksum.ok) return checksum; + const descriptor: UploadPartDescriptor = Object.freeze({ + partNumber: partResult.value.partNumber, + offset: partResult.value.offset, + byteLength: partResult.value.bytes.byteLength, + checksumSha256: checksum.value, + }); + parts.push(descriptor); + preparedBytes += descriptor.byteLength; + try { + input.onPreparedBytes?.(preparedBytes); + } catch { + // Progress observation cannot affect transfer correctness. + } + } + if ( + parts.length === 0 || + preparedBytes !== input.source.byteLength + ) { + return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_SESSION", { + recovery: "RESELECT", + }); + } + const canonical = canonicalPartManifest( + input.source.byteLength, + input.partSizeBytes, + parts, + ); + const fingerprintDigest = await digestHex( + input.crypto, + canonical, + input.signal, + "UPLOAD_SESSION", + ); + if (!fingerprintDigest.ok) return fingerprintDigest; + const fingerprint: UploadFileFingerprint = Object.freeze({ + algorithm: "SHA-256-PARTS-V1", + digestHex: fingerprintDigest.value, + byteLength: input.source.byteLength, + partSizeBytes: input.partSizeBytes, + partCount: parts.length, + }); + return browserDataSuccess( + Object.freeze({ + fingerprint, + parts: Object.freeze(parts), + }), + ); +} + +export async function readAndVerifyRangePart(input: Readonly<{ + source: Extract; + part: UploadPartDescriptor; + crypto: UploadCrypto; + signal: AbortSignal; +}>): Promise> { + if (input.signal.aborted) { + return browserDataFailure("ABORTED", "UPLOAD_PART"); + } + let result: BrowserDataResult; + try { + result = await input.source.readRange({ + offset: input.part.offset, + length: input.part.byteLength, + signal: input.signal, + }); + } catch { + return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", { + retryable: true, + recovery: "RESUME", + }); + } + if (!result.ok) return remapFailure(result.error, "UPLOAD_PART"); + if ( + !(result.value instanceof Uint8Array) || + result.value.byteLength !== input.part.byteLength + ) { + return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_PART", { + recovery: "RESELECT", + }); + } + const bytes = Uint8Array.from(result.value); + const checksum = await digestHex( + input.crypto, + bytes, + input.signal, + "UPLOAD_PART", + ); + if (!checksum.ok) return checksum; + return checksum.value === input.part.checksumSha256 + ? browserDataSuccess(bytes) + : browserDataFailure("STALE_RESULT", "UPLOAD_PART", { + recovery: "RESELECT", + }); +} + +export async function verifyUploadPartBytes(input: Readonly<{ + bytes: Uint8Array; + part: UploadPartDescriptor; + crypto: UploadCrypto; + signal: AbortSignal; +}>): Promise> { + if ( + !(input.bytes instanceof Uint8Array) || + input.bytes.byteLength !== input.part.byteLength + ) { + return browserDataFailure("INTEGRITY_FAILED", "UPLOAD_PART", { + recovery: "RESELECT", + }); + } + const bytes = Uint8Array.from(input.bytes); + const checksum = await digestHex( + input.crypto, + bytes, + input.signal, + "UPLOAD_PART", + ); + if (!checksum.ok) return checksum; + return checksum.value === input.part.checksumSha256 + ? browserDataSuccess(bytes) + : browserDataFailure("STALE_RESULT", "UPLOAD_PART", { + recovery: "RESELECT", + }); +} + +export async function digestRequestBinding(input: Readonly<{ + uploadKey: string; + purpose: string; + mediaType: string; + fingerprint: UploadFileFingerprint; + crypto: UploadCrypto; + signal: AbortSignal; +}>): Promise> { + const canonical = new TextEncoder().encode( + [ + "RESUMABLE-UPLOAD-BINDING-V1", + input.uploadKey, + input.purpose, + input.mediaType, + input.fingerprint.algorithm, + input.fingerprint.digestHex, + String(input.fingerprint.byteLength), + String(input.fingerprint.partSizeBytes), + String(input.fingerprint.partCount), + ].join("\n"), + ); + return await digestHex( + input.crypto, + canonical, + input.signal, + "UPLOAD_SESSION", + ); +} + +export async function deriveUploadIdempotencyKey(input: Readonly<{ + label: "CREATE" | "PART" | "COMPLETE" | "ABORT"; + requestBindingSha256: string; + sessionId?: string; + part?: UploadPartDescriptor; + crypto: UploadCrypto; + signal: AbortSignal; +}>): Promise> { + const fields = [ + "RESUMABLE-UPLOAD-IDEMPOTENCY-V1", + input.label, + input.requestBindingSha256, + input.sessionId ?? "-", + ]; + if (input.part) { + fields.push( + String(input.part.partNumber), + String(input.part.offset), + String(input.part.byteLength), + input.part.checksumSha256, + ); + } + const digest = await digestHex( + input.crypto, + new TextEncoder().encode(fields.join("\n")), + input.signal, + input.label === "PART" + ? "UPLOAD_PART" + : input.label === "COMPLETE" + ? "UPLOAD_COMPLETE" + : input.label === "ABORT" + ? "UPLOAD_ABORT" + : "UPLOAD_SESSION", + ); + return digest.ok + ? browserDataSuccess(`upload-${input.label.toLowerCase()}-${digest.value}`) + : digest; +} + +export async function digestUploadSessionBinding(input: Readonly<{ + requestBindingSha256: string; + sessionId: string; + fingerprint: UploadFileFingerprint; + crypto: UploadCrypto; + signal: AbortSignal; +}>): Promise> { + return await digestHex( + input.crypto, + new TextEncoder().encode( + [ + "RESUMABLE-UPLOAD-SESSION-BINDING-V1", + input.requestBindingSha256, + input.sessionId, + input.fingerprint.algorithm, + input.fingerprint.digestHex, + String(input.fingerprint.byteLength), + String(input.fingerprint.partSizeBytes), + String(input.fingerprint.partCount), + ].join("\n"), + ), + input.signal, + "UPLOAD_PART", + ); +} + +export async function* iterateUploadParts(input: Readonly<{ + source: UploadSourceSnapshot; + partSizeBytes: number; + maxSourceChunkBytes: number; + signal: AbortSignal; + operation: "UPLOAD_SESSION" | "UPLOAD_PART"; +}>): AsyncIterable< + BrowserDataResult< + Readonly<{ + partNumber: number; + offset: number; + bytes: Uint8Array; + }> + > +> { + if (input.source.kind === "RANGE_READER") { + let partNumber = 1; + for ( + let offset = 0; + offset < input.source.byteLength; + offset += input.partSizeBytes + ) { + if (input.signal.aborted) { + yield browserDataFailure("ABORTED", input.operation); + return; + } + const length = Math.min( + input.partSizeBytes, + input.source.byteLength - offset, + ); + let result: BrowserDataResult; + try { + result = await input.source.readRange({ + offset, + length, + signal: input.signal, + }); + } catch { + yield browserDataFailure("UNAVAILABLE", input.operation, { + retryable: true, + recovery: "RESUME", + }); + return; + } + if (!result.ok) { + yield remapFailure(result.error, input.operation); + return; + } + if ( + !(result.value instanceof Uint8Array) || + result.value.byteLength !== length + ) { + yield browserDataFailure("INTEGRITY_FAILED", input.operation, { + recovery: "RESELECT", + }); + return; + } + yield browserDataSuccess( + Object.freeze({ + partNumber, + offset, + bytes: Uint8Array.from(result.value), + }), + ); + partNumber += 1; + } + return; + } + + let iterable: AsyncIterable>; + try { + iterable = input.source.stream(input.signal); + } catch { + yield browserDataFailure("UNAVAILABLE", input.operation, { + retryable: true, + recovery: "RESUME", + }); + return; + } + let partNumber = 1; + let offset = 0; + let totalBytes = 0; + let buffer = new Uint8Array(input.partSizeBytes); + let bufferedBytes = 0; + try { + for await (const chunkResult of iterable) { + if (input.signal.aborted) { + yield browserDataFailure("ABORTED", input.operation); + return; + } + if (!chunkResult.ok) { + yield remapFailure(chunkResult.error, input.operation); + return; + } + const chunk = chunkResult.value; + if ( + !(chunk instanceof Uint8Array) || + chunk.byteLength < 1 || + chunk.byteLength > input.maxSourceChunkBytes || + totalBytes + chunk.byteLength > input.source.byteLength + ) { + yield browserDataFailure( + chunk instanceof Uint8Array && + chunk.byteLength > input.maxSourceChunkBytes + ? "LIMIT_EXCEEDED" + : "INTEGRITY_FAILED", + input.operation, + { recovery: "RESELECT" }, + ); + return; + } + let position = 0; + while (position < chunk.byteLength) { + const length = Math.min( + buffer.byteLength - bufferedBytes, + chunk.byteLength - position, + ); + buffer.set(chunk.subarray(position, position + length), bufferedBytes); + position += length; + bufferedBytes += length; + totalBytes += length; + if (bufferedBytes === buffer.byteLength) { + yield browserDataSuccess( + Object.freeze({ + partNumber, + offset, + bytes: buffer, + }), + ); + offset += buffer.byteLength; + partNumber += 1; + buffer = new Uint8Array(input.partSizeBytes); + bufferedBytes = 0; + } + } + } + } catch { + yield browserDataFailure("UNAVAILABLE", input.operation, { + retryable: true, + recovery: "RESUME", + }); + return; + } + if (totalBytes !== input.source.byteLength) { + yield browserDataFailure("INTEGRITY_FAILED", input.operation, { + recovery: "RESELECT", + }); + return; + } + if (bufferedBytes > 0) { + yield browserDataSuccess( + Object.freeze({ + partNumber, + offset, + bytes: buffer.slice(0, bufferedBytes), + }), + ); + } +} + +export function findManifestPart( + manifest: UploadPartManifest, + partNumber: number, +): UploadPartDescriptor | null { + return manifest.parts[partNumber - 1] ?? null; +} + +export function verifyPartAgainstManifest( + part: UploadPartDescriptor, + manifest: UploadPartManifest, +): boolean { + const expected = findManifestPart(manifest, part.partNumber); + return Boolean(expected && samePart(part, expected)); +} + +async function digestHex( + crypto: UploadCrypto, + bytes: Uint8Array, + signal: AbortSignal, + operation: BrowserDataOperation, +): Promise> { + if (signal.aborted) { + return browserDataFailure("ABORTED", operation); + } + try { + const digest = new Uint8Array(await crypto.digestSha256(bytes)); + if (signal.aborted) { + return browserDataFailure("ABORTED", operation); + } + if (digest.byteLength !== 32) { + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RESUME", + }); + } + return browserDataSuccess( + Array.from( + digest, + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""), + ); + } catch { + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RESUME", + }); + } +} + +function canonicalPartManifest( + byteLength: number, + partSizeBytes: number, + parts: readonly UploadPartDescriptor[], +): Uint8Array { + return new TextEncoder().encode( + [ + "SHA-256-PARTS-V1", + String(byteLength), + String(partSizeBytes), + String(parts.length), + ...parts.map((part) => + [ + part.partNumber, + part.offset, + part.byteLength, + part.checksumSha256, + ].join(":"), + ), + ].join("\n"), + ); +} + +function remapFailure( + failure: BrowserDataFailure, + operation: BrowserDataOperation, +): BrowserDataResult { + return Object.freeze({ + ok: false, + error: Object.freeze({ + code: failure.code, + operation, + retryable: failure.retryable, + recovery: failure.recovery, + }), + }); +} + +function positiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} diff --git a/src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts b/src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts new file mode 100644 index 0000000..2590f61 --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts @@ -0,0 +1,222 @@ +import { + SAFE_REGISTRY_ID, + SAFE_UPLOAD_KEY, +} from "./checkpoint-schema.ts"; + +export type UploadCancellationListener = ( + uploadKey: string, +) => void; + +/** + * Ephemeral same-origin coordination only. Messages are never persisted and + * backend abort/idempotency remains the authoritative state transition. + * + * A runtime that receives this dependency owns it and closes it with the + * runtime. Do not share one channel instance between runtimes. + */ +export interface UploadCancellationChannel { + publish(uploadKey: string): boolean; + subscribe(listener: UploadCancellationListener): () => void; + close(): void; +} + +export type UploadCancellationBroadcastFacade = Readonly<{ + postMessage(message: unknown): void; + addEventListener( + type: "message", + listener: (event: Readonly<{ data: unknown }>) => void, + ): void; + removeEventListener( + type: "message", + listener: (event: Readonly<{ data: unknown }>) => void, + ): void; + close(): void; +}>; + +export type BrowserUploadCancellationDependencies = Readonly<{ + channelName?: string; + host?: Record; + createChannel?: ( + channelName: string, + ) => UploadCancellationBroadcastFacade; +}>; + +const DEFAULT_CHANNEL_NAME = "ca-resumable-upload-cancel-v1"; +const PROTOCOL = "RESUMABLE_UPLOAD_CANCEL_V1"; +const MESSAGE_KEYS = Object.freeze([ + "protocol", + "uploadKey", +] as const); + +/** + * Creates a strict BroadcastChannel-backed cancellation signal. + * + * Unsupported or policy-disabled BroadcastChannel returns `undefined`; upload + * correctness still relies on Web Locks, durable CAS and backend idempotency, + * while an explicit abort waits for the lock under its caller deadline. + */ +export function createBrowserUploadCancellationChannel( + dependencies: BrowserUploadCancellationDependencies = {}, +): UploadCancellationChannel | undefined { + const channelName = + dependencies.channelName ?? DEFAULT_CHANNEL_NAME; + if (!SAFE_REGISTRY_ID.test(channelName)) { + throw new TypeError( + "Upload cancellation channel name is invalid.", + ); + } + + let channel: UploadCancellationBroadcastFacade; + try { + channel = dependencies.createChannel + ? dependencies.createChannel(channelName) + : createNativeChannel( + dependencies.host ?? + (globalThis as unknown as Record), + channelName, + ); + } catch { + return undefined; + } + if (!isBroadcastFacade(channel)) return undefined; + + const listeners = new Set(); + let closed = false; + const receive = (event: Readonly<{ data: unknown }>) => { + if (closed || !isCancellationMessage(event.data)) return; + for (const listener of [...listeners]) { + try { + listener(event.data.uploadKey); + } catch { + // One feature listener cannot prevent delivery to other runtimes. + } + } + }; + + try { + channel.addEventListener("message", receive); + } catch { + try { + channel.close(); + } catch { + // Construction still fails closed when cleanup is unavailable. + } + return undefined; + } + + return Object.freeze({ + publish(uploadKey: string): boolean { + if (closed || !SAFE_UPLOAD_KEY.test(uploadKey)) return false; + try { + channel.postMessage( + Object.freeze({ + protocol: PROTOCOL, + uploadKey, + }), + ); + return true; + } catch { + return false; + } + }, + + subscribe( + listener: UploadCancellationListener, + ): () => void { + if (closed || typeof listener !== "function") { + throw new TypeError( + "Upload cancellation listener is invalid.", + ); + } + listeners.add(listener); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + listeners.delete(listener); + }; + }, + + close(): void { + if (closed) return; + closed = true; + listeners.clear(); + try { + channel.removeEventListener("message", receive); + } catch { + // Closing remains terminal even if the host rejects cleanup. + } + try { + channel.close(); + } catch { + // Closing remains terminal even if the host rejects cleanup. + } + }, + }); +} + +function createNativeChannel( + host: Record, + channelName: string, +): UploadCancellationBroadcastFacade { + const constructor = safeGet(host, "BroadcastChannel"); + if (typeof constructor !== "function") { + throw new TypeError("BroadcastChannel is unavailable."); + } + return Reflect.construct(constructor, [ + channelName, + ]) as UploadCancellationBroadcastFacade; +} + +function isBroadcastFacade( + value: unknown, +): value is UploadCancellationBroadcastFacade { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return [ + "postMessage", + "addEventListener", + "removeEventListener", + "close", + ].every((method) => typeof safeGet(candidate, method) === "function"); +} + +function isCancellationMessage( + value: unknown, +): value is Readonly<{ + protocol: typeof PROTOCOL; + uploadKey: string; +}> { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) + ) { + return false; + } + const keys = Object.keys(value).sort(); + const expected = [...MESSAGE_KEYS].sort(); + if ( + keys.length !== expected.length || + !keys.every((key, index) => key === expected[index]) + ) { + return false; + } + const candidate = value as Record; + return ( + candidate.protocol === PROTOCOL && + typeof candidate.uploadKey === "string" && + SAFE_UPLOAD_KEY.test(candidate.uploadKey) + ); +} + +function safeGet( + target: Record, + property: string, +): unknown { + try { + return Reflect.get(target, property); + } catch { + return undefined; + } +} diff --git a/src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts b/src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts new file mode 100644 index 0000000..6bb3e69 --- /dev/null +++ b/src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts @@ -0,0 +1,58 @@ +import { SAFE_REGISTRY_ID, SAFE_UPLOAD_KEY } from "./checkpoint-schema.ts"; + +type LockManagerLike = { + request( + name: string, + options: Readonly<{ mode: "exclusive"; signal?: AbortSignal }>, + callback: (lock: unknown) => Promise, + ): Promise; +}; + +export interface UploadMutationLock { + run( + uploadKey: string, + signal: AbortSignal, + task: () => Promise, + ): Promise; +} + +export function createResumableUploadWebLock( + lockManager: LockManager, + lockNamespace = "ca-resumable-upload-v1", +): UploadMutationLock { + if (!SAFE_REGISTRY_ID.test(lockNamespace)) { + throw new TypeError("Upload mutation lock namespace is invalid."); + } + const request = (lockManager as unknown as LockManagerLike)?.request; + if (typeof request !== "function") { + throw new TypeError("Upload mutation lock manager is invalid."); + } + return Object.freeze({ + async run( + uploadKey: string, + signal: AbortSignal, + task: () => Promise, + ): Promise { + if (!SAFE_UPLOAD_KEY.test(uploadKey)) { + throw new TypeError("Upload mutation lock key is invalid."); + } + if (signal.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + return await (request.call( + lockManager, + `${lockNamespace}:${uploadKey}`, + { mode: "exclusive", signal }, + async (lock) => { + if (!lock) { + throw new DOMException( + "The upload mutation lock is unavailable.", + "InvalidStateError", + ); + } + return await task(); + }, + ) as Promise); + }, + }); +} diff --git a/src/adapters/cache-storage/index.ts b/src/adapters/cache-storage/index.ts new file mode 100644 index 0000000..5f4abbf --- /dev/null +++ b/src/adapters/cache-storage/index.ts @@ -0,0 +1,14 @@ +export { + createDefaultPublicCachePolicy, + resolvePublicCachePolicy, + type PublicCacheRuntimePolicy, + type PublicCacheSafeObservation, + type PublicCacheSafeObserver, +} from "./public-cache-policy.ts"; +export { + computePublicCacheManifestDigestHex, + createPublicCacheWebLock, + createPublicResponseCacheAdapter, + type PublicCacheMutationLock, + type PublicResponseCacheDependencies, +} from "./public-response-cache-adapter.ts"; diff --git a/src/adapters/cache-storage/public-cache-policy.ts b/src/adapters/cache-storage/public-cache-policy.ts new file mode 100644 index 0000000..f818ba5 --- /dev/null +++ b/src/adapters/cache-storage/public-cache-policy.ts @@ -0,0 +1,199 @@ +import type { + BrowserDataFailureCode, + BrowserDataOperation, +} from "../../application/ports/browser-file-storage/shared.ts"; + +export type PublicCacheRuntimePolicy = Readonly<{ + origin: string; + ownedCachePrefix: string; + mutationLockName: string; + maxEntryBytes: number; + maxReleaseBytes: number; + maxEntriesPerRelease: number; + retainedPreviousReleaseCount: number; + allowedRequestHeaderNames: readonly string[]; + allowedVaryHeaderNames: readonly string[]; + allowedResponseHeaderNames: readonly string[]; + unknownResponseHeaderAction: "REJECT" | "STRIP"; + allowedQueryParameterNames: readonly string[]; + forbiddenQueryParameterNames: readonly string[]; + isQueryParameterValueAllowed: (name: string, value: string) => boolean; + isContentTypeAllowed: (contentType: string) => boolean; + isReleaseRegistryIdAllowed: (releaseRegistryId: string) => boolean; +}>; + +export type PublicCacheSafeObservation = Readonly<{ + operation: BrowserDataOperation; + outcome: "STARTED" | "SUCCEEDED" | "FAILED"; + failureCode?: BrowserDataFailureCode; + releaseRegistryId?: string; + byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "GT_16MiB"; + entryBucket?: "0" | "1_10" | "11_100" | "GT_100"; +}>; + +export type PublicCacheSafeObserver = ( + observation: PublicCacheSafeObservation, +) => void; + +const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const CACHE_PREFIX = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,63}:$/u; +const DEFAULT_PUBLIC_CONTENT_TYPE = + /^(?:application\/(?:javascript|json|manifest\+json|wasm)|font\/[a-z0-9.+-]+|image\/[a-z0-9.+-]+|text\/(?:css|javascript|plain))(?:\s*;.*)?$/iu; + +export function createDefaultPublicCachePolicy( + origin: string, +): PublicCacheRuntimePolicy { + return resolvePublicCachePolicy({ + origin, + ownedCachePrefix: "ca-public-v1:", + mutationLockName: "ca-public-v1:mutation", + maxEntryBytes: 16 * 1024 * 1024, + maxReleaseBytes: 128 * 1024 * 1024, + maxEntriesPerRelease: 500, + retainedPreviousReleaseCount: 1, + allowedRequestHeaderNames: ["accept", "accept-language"], + allowedVaryHeaderNames: [], + allowedResponseHeaderNames: [ + "cache-control", + "content-language", + "content-type", + "etag", + "last-modified", + "vary", + ], + unknownResponseHeaderAction: "STRIP", + allowedQueryParameterNames: [], + forbiddenQueryParameterNames: [ + "access_token", + "api_key", + "auth", + "email", + "jwt", + "session", + "token", + "user", + ], + isQueryParameterValueAllowed: () => false, + isContentTypeAllowed: (contentType) => + DEFAULT_PUBLIC_CONTENT_TYPE.test(contentType), + isReleaseRegistryIdAllowed: (releaseRegistryId) => + RELEASE_ID.test(releaseRegistryId), + }); +} + +export function resolvePublicCachePolicy( + policy: PublicCacheRuntimePolicy, +): PublicCacheRuntimePolicy { + const normalized: PublicCacheRuntimePolicy = Object.freeze({ + ...policy, + origin: new URL(policy.origin).origin, + allowedRequestHeaderNames: Object.freeze( + policy.allowedRequestHeaderNames.map((name) => name.toLowerCase()), + ), + allowedVaryHeaderNames: Object.freeze( + policy.allowedVaryHeaderNames.map((name) => name.toLowerCase()), + ), + allowedResponseHeaderNames: Object.freeze( + policy.allowedResponseHeaderNames.map((name) => name.toLowerCase()), + ), + allowedQueryParameterNames: Object.freeze( + policy.allowedQueryParameterNames.map((name) => name.toLowerCase()), + ), + forbiddenQueryParameterNames: Object.freeze( + policy.forbiddenQueryParameterNames.map((name) => name.toLowerCase()), + ), + }); + assertPublicCachePolicy(normalized); + return normalized; +} + +export function assertPublicCachePolicy( + policy: PublicCacheRuntimePolicy, +): void { + const origin = new URL(policy.origin); + if ( + origin.origin !== policy.origin || + !isAllowedPublicCacheOrigin(origin) || + !CACHE_PREFIX.test(policy.ownedCachePrefix) || + policy.mutationLockName.length === 0 || + !positiveSafeInteger(policy.maxEntryBytes) || + !positiveSafeInteger(policy.maxReleaseBytes) || + policy.maxEntryBytes > policy.maxReleaseBytes || + !positiveSafeInteger(policy.maxEntriesPerRelease) || + policy.maxEntriesPerRelease > 10_000 || + !Number.isSafeInteger(policy.retainedPreviousReleaseCount) || + policy.retainedPreviousReleaseCount < 1 || + policy.retainedPreviousReleaseCount > 5 || + !headerNameList(policy.allowedRequestHeaderNames) || + !headerNameList(policy.allowedVaryHeaderNames) || + !headerNameList(policy.allowedResponseHeaderNames) || + !["REJECT", "STRIP"].includes(policy.unknownResponseHeaderAction) || + !queryNameList(policy.allowedQueryParameterNames) || + policy.allowedVaryHeaderNames.some( + (name) => !policy.allowedRequestHeaderNames.includes(name), + ) || + policy.forbiddenQueryParameterNames.some((name) => name.length === 0) || + policy.allowedQueryParameterNames.some((name) => + policy.forbiddenQueryParameterNames.includes(name), + ) + ) { + throw new TypeError("Public Cache Storage policy is invalid."); + } +} + +export function isAllowedPublicCacheOrigin(url: URL): boolean { + return ( + url.protocol === "https:" || + (url.protocol === "http:" && + (url.hostname === "localhost" || + url.hostname === "[::1]" || + /^127(?:\.\d{1,3}){3}$/u.test(url.hostname))) + ); +} + +export function cacheByteBucket( + byteLength: number, +): NonNullable { + if (byteLength === 0) return "0"; + if (byteLength <= 1024 * 1024) return "1B_1MiB"; + if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB"; + return "GT_16MiB"; +} + +export function cacheEntryBucket( + count: number, +): NonNullable { + if (count === 0) return "0"; + if (count <= 10) return "1_10"; + if (count <= 100) return "11_100"; + return "GT_100"; +} + +export function observePublicCacheSafely( + observer: PublicCacheSafeObserver | undefined, + observation: PublicCacheSafeObservation, +): void { + try { + observer?.(Object.freeze({ ...observation })); + } catch { + // Cache behavior never depends on observability. + } +} + +function positiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function headerNameList(names: readonly string[]): boolean { + return ( + new Set(names).size === names.length && + names.every((name) => /^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name)) + ); +} + +function queryNameList(names: readonly string[]): boolean { + return ( + new Set(names).size === names.length && + names.every((name) => /^[a-z0-9][a-z0-9._-]{0,63}$/u.test(name)) + ); +} diff --git a/src/adapters/cache-storage/public-response-cache-adapter.ts b/src/adapters/cache-storage/public-response-cache-adapter.ts new file mode 100644 index 0000000..e3c05d6 --- /dev/null +++ b/src/adapters/cache-storage/public-response-cache-adapter.ts @@ -0,0 +1,1798 @@ +import type { + CachedPublicResponse, + PublicCacheAsset, + PublicCacheCleanupReport, + PublicCacheHeader, + PublicCacheInspection, + PublicCacheReleaseManifest, + PublicCacheReleaseSummary, + PublicResponseCache, +} from "../../application/ports/browser-file-storage/cache-storage-ports.ts"; +import type { + BrowserDataFailure, + BrowserDataFailureCode, + BrowserDataOperation, + BrowserDataResult, + ByteSource, +} from "../../application/ports/browser-file-storage/shared.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, + mapBrowserDataException, +} from "../browser-file-storage/result.ts"; +import { + cacheByteBucket, + cacheEntryBucket, + isAllowedPublicCacheOrigin, + observePublicCacheSafely, + resolvePublicCachePolicy, + type PublicCacheRuntimePolicy, + type PublicCacheSafeObserver, +} from "./public-cache-policy.ts"; + +export interface PublicCacheMutationLock { + run( + signal: AbortSignal | undefined, + task: () => Promise, + ): Promise; +} + +export type PublicResponseCacheDependencies = Readonly<{ + cacheStorage?: CacheStorage; + fetcher?: (request: Request) => Promise; + crypto: Crypto; + mutationLock?: PublicCacheMutationLock; + policy: PublicCacheRuntimePolicy; + now?: () => number; + observer?: PublicCacheSafeObserver; +}>; + +type PublicCacheFacade = Readonly<{ + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +}>; + +type PublicCacheStorageFacade = Readonly<{ + open(name: string): Promise; + keys(): Promise; + delete(name: string): Promise; +}>; + +type PublicCacheCryptoFacade = Readonly<{ + digestSha256(bytes: Uint8Array): Promise; +}>; + +type PublicResponseCacheDependencySnapshot = Readonly<{ + cacheStorage?: PublicCacheStorageFacade; + fetcher?: (request: Request) => Promise; + crypto: PublicCacheCryptoFacade; + mutationLock?: PublicCacheMutationLock; + observer?: PublicCacheSafeObserver; +}>; + +type NormalizedAsset = Readonly<{ + absoluteUrl: string; + expectedByteLength: number; + expectedContentType: string; + digestHex: string; + requestHeaders: readonly PublicCacheHeader[]; +}>; + +type NormalizedReleaseManifest = Readonly<{ + releaseRegistryId: string; + manifestDigestHex: string; + assets: readonly NormalizedAsset[]; +}>; + +type ReleaseMarker = Readonly<{ + schemaVersion: 1; + verified: true; + releaseRegistryId: string; + manifestDigestHex: string; + entryCount: number; + totalBytes: number; + stagedAtEpochMs: number; + assets: readonly NormalizedAsset[]; +}>; + +type ActiveReleasePointer = Readonly<{ + schemaVersion: 1; + releaseRegistryId: string; + manifestDigestHex: string; + candidateCacheName: string; + rollbackCandidateCacheNames: readonly string[]; + activatedAtEpochMs: number; +}>; + +type BrowserFailureResult = Readonly<{ + ok: false; + error: BrowserDataFailure; +}>; + +type CacheReadResult = Readonly<{ + bytes: Uint8Array; + headers: readonly PublicCacheHeader[]; +}>; + +type LockManagerLike = { + request( + name: string, + options: Readonly<{ mode: "exclusive"; signal?: AbortSignal }>, + callback: (lock: unknown) => Promise, + ): Promise; +}; + +class CacheValidationFailure extends Error { + readonly code: BrowserDataFailureCode; + + constructor(code: BrowserDataFailureCode) { + super("Public cache validation failed."); + this.name = "CacheValidationFailure"; + this.code = code; + } +} + +function snapshotPublicCacheDependencies( + dependencies: PublicResponseCacheDependencies, +): PublicResponseCacheDependencySnapshot { + const fetcher = dependencies.fetcher; + const observer = dependencies.observer; + if ( + (fetcher !== undefined && typeof fetcher !== "function") || + (observer !== undefined && typeof observer !== "function") + ) { + throw new TypeError("Public cache runtime dependency is invalid."); + } + return Object.freeze({ + cacheStorage: snapshotCacheStorage(dependencies.cacheStorage), + fetcher, + crypto: snapshotCacheCrypto(dependencies.crypto), + mutationLock: snapshotMutationLock(dependencies.mutationLock), + observer, + }); +} + +function snapshotCacheStorage( + cacheStorage: CacheStorage | undefined, +): PublicCacheStorageFacade | undefined { + if (!cacheStorage) return undefined; + const open = cacheStorage.open; + const keys = cacheStorage.keys; + const deleteCache = cacheStorage.delete; + if ( + typeof open !== "function" || + typeof keys !== "function" || + typeof deleteCache !== "function" + ) { + throw new TypeError("Public cache storage capability is invalid."); + } + return Object.freeze({ + async open(name: string): Promise { + const cache = await open.call(cacheStorage, name); + return snapshotCache(cache); + }, + keys: keys.bind(cacheStorage), + delete: deleteCache.bind(cacheStorage), + }); +} + +function snapshotCache(cache: Cache): PublicCacheFacade { + const match = cache.match; + const put = cache.put; + if (typeof match !== "function" || typeof put !== "function") { + throw new TypeError("Public cache capability is invalid."); + } + return Object.freeze({ + match: match.bind(cache), + put: put.bind(cache), + }); +} + +function snapshotCacheCrypto(crypto: Crypto): PublicCacheCryptoFacade { + const subtle = crypto?.subtle; + const digest = subtle?.digest; + if (typeof digest !== "function") { + throw new TypeError("Public cache crypto capability is invalid."); + } + return Object.freeze({ + async digestSha256(bytes: Uint8Array): Promise { + return await digest.call( + subtle, + "SHA-256", + Uint8Array.from(bytes), + ); + }, + }); +} + +function snapshotMutationLock( + lock: PublicCacheMutationLock | undefined, +): PublicCacheMutationLock | undefined { + if (!lock) return undefined; + const run = lock.run; + if (typeof run !== "function") { + throw new TypeError("Public cache mutation lock is invalid."); + } + return Object.freeze({ + async run( + signal: AbortSignal | undefined, + task: () => Promise, + ): Promise { + return await (run.call(lock, signal, task) as Promise); + }, + }); +} + +export function createPublicResponseCacheAdapter( + inputDependencies: PublicResponseCacheDependencies, +): PublicResponseCache { + const dependencies = snapshotPublicCacheDependencies(inputDependencies); + const policy = resolvePublicCachePolicy(inputDependencies.policy); + const now = inputDependencies.now ?? Date.now; + + const responses: PublicResponseCache["responses"] = Object.freeze({ + async matchActiveExact( + request: Parameters< + PublicResponseCache["responses"]["matchActiveExact"] + >[0], + ) { + const signal = request.signal; + const aborted = abortedResult(signal, "CACHE_LOOKUP"); + if (aborted) return aborted; + if (!dependencies.cacheStorage) { + return unsupported("CACHE_LOOKUP"); + } + let assetRequest: NormalizedAsset; + try { + assetRequest = normalizeLookupRequest( + request.absoluteUrl, + request.requestHeaders, + policy, + ); + } catch (error) { + return validationFailure(error, "CACHE_LOOKUP"); + } + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_LOOKUP", + outcome: "STARTED", + }); + + try { + const pointerResult = await readActivePointer( + dependencies.cacheStorage, + policy, + ); + if (!pointerResult.ok) { + return observeFailure(pointerResult, dependencies.observer); + } + if (!pointerResult.value) { + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_LOOKUP", + outcome: "SUCCEEDED", + }); + return browserDataSuccess(null); + } + const pointer = pointerResult.value; + const names = await dependencies.cacheStorage.keys(); + if (!names.includes(pointer.candidateCacheName)) { + return observeFailure( + browserDataFailure("STORAGE_EVICTED", "CACHE_LOOKUP", { + recovery: "REHYDRATE", + }), + dependencies.observer, + pointer.releaseRegistryId, + ); + } + const cache = await dependencies.cacheStorage.open( + pointer.candidateCacheName, + ); + const markerResult = await readMarker( + cache, + policy, + dependencies.crypto, + ); + if (!markerResult.ok) { + return observeFailure( + markerResult, + dependencies.observer, + pointer.releaseRegistryId, + ); + } + const marker = markerResult.value; + if ( + !marker || + marker.releaseRegistryId !== pointer.releaseRegistryId || + marker.manifestDigestHex !== pointer.manifestDigestHex + ) { + return observeFailure( + browserDataFailure("INTEGRITY_FAILED", "CACHE_LOOKUP", { + recovery: "REHYDRATE", + }), + dependencies.observer, + pointer.releaseRegistryId, + ); + } + const manifestAsset = marker.assets.find( + (asset) => assetKey(asset) === assetKey(assetRequest), + ); + if (!manifestAsset) { + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_LOOKUP", + outcome: "SUCCEEDED", + releaseRegistryId: pointer.releaseRegistryId, + }); + return browserDataSuccess(null); + } + + const nativeRequest = createNativeRequest(manifestAsset); + // Exact Cache.match defaults are intentional. ignoreSearch, + // ignoreMethod and ignoreVary are never enabled. + const cached = await cache.match(nativeRequest); + if (!cached) { + return observeFailure( + browserDataFailure("STORAGE_EVICTED", "CACHE_LOOKUP", { + recovery: "REHYDRATE", + }), + dependencies.observer, + pointer.releaseRegistryId, + ); + } + const read = await readAndValidateResponse( + cached, + manifestAsset, + policy, + dependencies.crypto, + signal, + ); + if (!read.ok) { + return observeFailure( + rebaseFailure(read.error, "CACHE_LOOKUP"), + dependencies.observer, + pointer.releaseRegistryId, + manifestAsset.expectedByteLength, + ); + } + const body = byteSourceFrom(read.value.bytes); + const response: CachedPublicResponse = Object.freeze({ + status: 200, + headers: read.value.headers, + body, + integrity: Object.freeze({ + algorithm: "SHA-256", + digestHex: manifestAsset.digestHex, + }), + }); + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_LOOKUP", + outcome: "SUCCEEDED", + releaseRegistryId: pointer.releaseRegistryId, + byteBucket: cacheByteBucket(manifestAsset.expectedByteLength), + }); + return browserDataSuccess(response); + } catch (error) { + return observeFailure( + exceptionFailure(error, "CACHE_LOOKUP"), + dependencies.observer, + ); + } + }, + }); + + const admin: PublicResponseCache["admin"] = Object.freeze({ + async stageRelease( + manifest: PublicCacheReleaseManifest, + options: NonNullable< + Parameters[1] + > = {}, + ) { + const signal = options.signal; + const aborted = abortedResult(signal, "CACHE_STAGE"); + if (aborted) return aborted; + const availability = mutationAvailability( + dependencies, + "CACHE_STAGE", + ); + if (availability) return availability; + + let normalized: NormalizedReleaseManifest; + try { + normalized = await normalizeAndVerifyManifest( + manifest, + policy, + dependencies.crypto, + ); + } catch (error) { + return validationFailure(error, "CACHE_STAGE"); + } + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_STAGE", + outcome: "STARTED", + releaseRegistryId: normalized.releaseRegistryId, + entryBucket: cacheEntryBucket(normalized.assets.length), + }); + + try { + const result = await dependencies.mutationLock!.run( + signal, + async () => { + const cacheName = releaseCacheName( + policy, + normalized.releaseRegistryId, + normalized.manifestDigestHex, + ); + const existingNames = await dependencies.cacheStorage!.keys(); + if (existingNames.includes(cacheName)) { + const existing = await dependencies.cacheStorage!.open(cacheName); + const marker = await readMarker( + existing, + policy, + dependencies.crypto, + ); + if ( + marker.ok && + marker.value && + marker.value.releaseRegistryId === + normalized.releaseRegistryId && + marker.value.manifestDigestHex === + normalized.manifestDigestHex && + marker.value.entryCount === normalized.assets.length + ) { + return browserDataSuccess(summaryFromMarker(marker.value)); + } + await dependencies.cacheStorage!.delete(cacheName); + } + + const cache = await dependencies.cacheStorage!.open(cacheName); + try { + let totalBytes = 0; + for (const asset of normalized.assets) { + if (signal?.aborted) { + throw new CacheValidationFailure("ABORTED"); + } + const cacheRequest = createNativeRequest(asset); + const networkRequest = createNativeRequest( + asset, + signal, + ); + const response = + await dependencies.fetcher!(networkRequest); + const read = await readAndValidateResponse( + response, + asset, + policy, + dependencies.crypto, + signal, + ); + if (!read.ok) throw new CacheValidationFailure(read.error.code); + totalBytes += read.value.bytes.byteLength; + if (totalBytes > policy.maxReleaseBytes) { + throw new CacheValidationFailure("LIMIT_EXCEEDED"); + } + await cache.put( + cacheRequest, + new Response(Uint8Array.from(read.value.bytes), { + status: 200, + headers: read.value.headers.map( + ([name, value]) => [name, value], + ), + }), + ); + } + + const marker: ReleaseMarker = Object.freeze({ + schemaVersion: 1, + verified: true, + releaseRegistryId: normalized.releaseRegistryId, + manifestDigestHex: normalized.manifestDigestHex, + entryCount: normalized.assets.length, + totalBytes, + stagedAtEpochMs: now(), + assets: normalized.assets, + }); + await cache.put( + markerRequest(policy), + jsonResponse(marker), + ); + return browserDataSuccess(summaryFromMarker(marker)); + } catch (error) { + await dependencies.cacheStorage!.delete(cacheName); + throw error; + } + }, + ); + if (!result.ok) { + return observeFailure( + result, + dependencies.observer, + normalized.releaseRegistryId, + ); + } + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_STAGE", + outcome: "SUCCEEDED", + releaseRegistryId: normalized.releaseRegistryId, + entryBucket: cacheEntryBucket(result.value.entryCount), + byteBucket: cacheByteBucket(result.value.totalBytes), + }); + return result; + } catch (error) { + return observeFailure( + exceptionFailure(error, "CACHE_STAGE"), + dependencies.observer, + normalized.releaseRegistryId, + ); + } + }, + + async activateRelease( + releaseRegistryId: string, + manifestDigestHex: string, + options: NonNullable< + Parameters[2] + > = {}, + ) { + const signal = options.signal; + const aborted = abortedResult(signal, "CACHE_ACTIVATE"); + if (aborted) return aborted; + const availability = mutationAvailability( + dependencies, + "CACHE_ACTIVATE", + ); + if (availability) return availability; + if ( + !policy.isReleaseRegistryIdAllowed(releaseRegistryId) || + !SHA256_HEX.test(manifestDigestHex) + ) { + return browserDataFailure("INVALID_INPUT", "CACHE_ACTIVATE"); + } + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_ACTIVATE", + outcome: "STARTED", + releaseRegistryId, + }); + + try { + const result = await dependencies.mutationLock!.run( + signal, + async () => { + const cacheName = releaseCacheName( + policy, + releaseRegistryId, + manifestDigestHex, + ); + const names = await dependencies.cacheStorage!.keys(); + if (!names.includes(cacheName)) { + return browserDataFailure("NOT_FOUND", "CACHE_ACTIVATE", { + recovery: "REHYDRATE", + }); + } + const cache = await dependencies.cacheStorage!.open(cacheName); + const markerResult = await readMarker( + cache, + policy, + dependencies.crypto, + ); + if (!markerResult.ok) { + return rebaseFailure(markerResult.error, "CACHE_ACTIVATE"); + } + const marker = markerResult.value; + if ( + !marker || + marker.releaseRegistryId !== releaseRegistryId || + marker.manifestDigestHex !== manifestDigestHex + ) { + return browserDataFailure( + "INTEGRITY_FAILED", + "CACHE_ACTIVATE", + { recovery: "REHYDRATE" }, + ); + } + + for (const asset of marker.assets) { + if (signal?.aborted) { + return browserDataFailure("ABORTED", "CACHE_ACTIVATE"); + } + const cached = await cache.match(createNativeRequest(asset)); + if (!cached) { + return browserDataFailure( + "INTEGRITY_FAILED", + "CACHE_ACTIVATE", + { recovery: "REHYDRATE" }, + ); + } + const verified = await readAndValidateResponse( + cached, + asset, + policy, + dependencies.crypto, + signal, + ); + if (!verified.ok) { + return rebaseFailure( + verified.error, + "CACHE_ACTIVATE", + ); + } + } + + const previousPointer = await readActivePointer( + dependencies.cacheStorage!, + policy, + ); + if (!previousPointer.ok) { + return rebaseFailure( + previousPointer.error, + "CACHE_ACTIVATE", + ); + } + const rollbackCandidateCacheNames = [ + ...(previousPointer.value + ? [ + previousPointer.value.candidateCacheName, + ...previousPointer.value + .rollbackCandidateCacheNames, + ] + : []), + ] + .filter( + (name, index, all) => + name !== cacheName && all.indexOf(name) === index, + ) + .slice(0, policy.retainedPreviousReleaseCount); + const pointer: ActiveReleasePointer = Object.freeze({ + schemaVersion: 1, + releaseRegistryId, + manifestDigestHex, + candidateCacheName: cacheName, + rollbackCandidateCacheNames: Object.freeze( + rollbackCandidateCacheNames, + ), + activatedAtEpochMs: now(), + }); + const control = await dependencies.cacheStorage!.open( + controlCacheName(policy), + ); + await control.put( + activePointerRequest(policy), + jsonResponse(pointer), + ); + return browserDataSuccess(summaryFromMarker(marker)); + }, + ); + if (!result.ok) { + return observeFailure( + result, + dependencies.observer, + releaseRegistryId, + ); + } + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_ACTIVATE", + outcome: "SUCCEEDED", + releaseRegistryId, + entryBucket: cacheEntryBucket(result.value.entryCount), + byteBucket: cacheByteBucket(result.value.totalBytes), + }); + return result; + } catch (error) { + return observeFailure( + exceptionFailure(error, "CACHE_ACTIVATE"), + dependencies.observer, + releaseRegistryId, + ); + } + }, + + async cleanupOwned( + request: NonNullable< + Parameters[0] + > = {}, + ) { + const signal = request.signal; + const aborted = abortedResult(signal, "CACHE_DELETE"); + if (aborted) return aborted; + const availability = mutationAvailability( + dependencies, + "CACHE_DELETE", + ); + if (availability) return availability; + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_DELETE", + outcome: "STARTED", + }); + + try { + const result = await dependencies.mutationLock!.run( + signal, + async () => { + const pointer = await readActivePointer( + dependencies.cacheStorage!, + policy, + ); + if (!pointer.ok) { + return rebaseFailure(pointer.error, "CACHE_DELETE"); + } + const names = await dependencies.cacheStorage!.keys(); + const ownedNames = names.filter((name) => + name.startsWith(policy.ownedCachePrefix), + ); + let deleted = 0; + let retained = 0; + const lifecycleRetainedNames = new Set([ + ...(pointer.value + ? [ + pointer.value.candidateCacheName, + ...pointer.value.rollbackCandidateCacheNames, + ] + : []), + ]); + for (const name of ownedNames) { + if (signal?.aborted) { + return browserDataFailure("ABORTED", "CACHE_DELETE"); + } + if ( + name === controlCacheName(policy) || + lifecycleRetainedNames.has(name) + ) { + retained += 1; + continue; + } + if (await dependencies.cacheStorage!.delete(name)) { + deleted += 1; + } + } + const report: PublicCacheCleanupReport = Object.freeze({ + inspectedOwnedCaches: ownedNames.length, + deletedOwnedCaches: deleted, + retainedOwnedCaches: retained, + }); + return browserDataSuccess(report); + }, + ); + if (!result.ok) { + return observeFailure(result, dependencies.observer); + } + observePublicCacheSafely(dependencies.observer, { + operation: "CACHE_DELETE", + outcome: "SUCCEEDED", + entryBucket: cacheEntryBucket( + result.value.inspectedOwnedCaches, + ), + }); + return result; + } catch (error) { + return observeFailure( + exceptionFailure(error, "CACHE_DELETE"), + dependencies.observer, + ); + } + }, + + async inspect() { + if (!dependencies.cacheStorage) { + return unsupported("CACHE_LOOKUP"); + } + try { + const names = await dependencies.cacheStorage.keys(); + const owned = names.filter((name) => + name.startsWith(policy.ownedCachePrefix), + ); + const pointer = await readActivePointer( + dependencies.cacheStorage, + policy, + ); + if (!pointer.ok) return pointer; + + const candidates: PublicCacheInspection["releaseCandidates"][number][] = + []; + let unreadableOwnedCacheCount = 0; + for (const name of owned) { + if (name === controlCacheName(policy)) continue; + const cache = await dependencies.cacheStorage.open(name); + const marker = await readMarker( + cache, + policy, + dependencies.crypto, + ); + if (!marker.ok || !marker.value) { + unreadableOwnedCacheCount += 1; + continue; + } + candidates.push( + Object.freeze({ + releaseRegistryId: marker.value.releaseRegistryId, + verified: true, + entryCount: marker.value.entryCount, + }), + ); + } + return browserDataSuccess( + Object.freeze({ + activeReleaseRegistryId: + pointer.value?.releaseRegistryId ?? null, + ownedCacheCount: owned.length, + unreadableOwnedCacheCount, + releaseCandidates: Object.freeze(candidates), + }), + ); + } catch (error) { + return exceptionFailure(error, "CACHE_LOOKUP"); + } + }, + }); + + return Object.freeze({ responses, admin }); +} + +export function createPublicCacheWebLock( + lockManager: LockManager, + lockName: string, +): PublicCacheMutationLock { + if (lockName.length === 0) { + throw new TypeError("Public cache mutation lock name is required."); + } + const manager = lockManager as unknown as LockManagerLike; + return Object.freeze({ + async run( + signal: AbortSignal | undefined, + task: () => Promise, + ): Promise { + if (signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + return await manager.request( + lockName, + { mode: "exclusive", signal }, + async (lock) => { + if (!lock) throw new CacheValidationFailure("BLOCKED"); + return await task(); + }, + ); + }, + }); +} + +/** + * Build tooling and tests can use this helper to produce the exact digest + * accepted by stageRelease. + */ +export async function computePublicCacheManifestDigestHex( + crypto: Crypto, + releaseRegistryId: string, + assets: readonly PublicCacheAsset[], + policyInput: PublicCacheRuntimePolicy, +): Promise { + const policy = resolvePublicCachePolicy(policyInput); + const cryptoSnapshot = snapshotCacheCrypto(crypto); + if (!policy.isReleaseRegistryIdAllowed(releaseRegistryId)) { + throw new TypeError("Public cache release registry ID is invalid."); + } + const normalized = normalizeAssets(assets, policy); + return await sha256Hex( + cryptoSnapshot, + new TextEncoder().encode( + canonicalManifest(releaseRegistryId, normalized), + ), + ); +} + +async function normalizeAndVerifyManifest( + manifest: PublicCacheReleaseManifest, + policy: PublicCacheRuntimePolicy, + crypto: PublicCacheCryptoFacade, +): Promise { + const snapshot = Object.freeze({ + releaseRegistryId: manifest.releaseRegistryId, + manifestDigestHex: manifest.manifestDigestHex, + assets: normalizeAssets(manifest.assets, policy), + }); + if ( + !policy.isReleaseRegistryIdAllowed(snapshot.releaseRegistryId) || + !SHA256_HEX.test(snapshot.manifestDigestHex) + ) { + throw new CacheValidationFailure("INVALID_INPUT"); + } + const actualDigest = await sha256Hex( + crypto, + new TextEncoder().encode( + canonicalManifest(snapshot.releaseRegistryId, snapshot.assets), + ), + ); + if (actualDigest !== snapshot.manifestDigestHex) { + throw new CacheValidationFailure("INTEGRITY_FAILED"); + } + return snapshot; +} + +function normalizeAssets( + assets: readonly PublicCacheAsset[], + policy: PublicCacheRuntimePolicy, +): readonly NormalizedAsset[] { + if ( + !Array.isArray(assets) || + assets.length < 1 || + assets.length > policy.maxEntriesPerRelease + ) { + throw new CacheValidationFailure("LIMIT_EXCEEDED"); + } + const normalized = assets.map((asset) => + normalizeAsset(asset, policy), + ); + const keys = normalized.map(assetKey); + if (new Set(keys).size !== keys.length) { + throw new CacheValidationFailure("CONFLICT"); + } + const declaredTotal = normalized.reduce( + (total, asset) => total + asset.expectedByteLength, + 0, + ); + if ( + !Number.isSafeInteger(declaredTotal) || + declaredTotal > policy.maxReleaseBytes + ) { + throw new CacheValidationFailure("LIMIT_EXCEEDED"); + } + return Object.freeze(normalized); +} + +function normalizeAsset( + asset: PublicCacheAsset, + policy: PublicCacheRuntimePolicy, +): NormalizedAsset { + if ( + !asset || + typeof asset !== "object" || + !Number.isSafeInteger(asset.expectedByteLength) || + asset.expectedByteLength < 0 || + asset.expectedByteLength > policy.maxEntryBytes || + asset.integrity?.algorithm !== "SHA-256" || + !SHA256_HEX.test(asset.integrity.digestHex) + ) { + throw new CacheValidationFailure("INVALID_INPUT"); + } + return Object.freeze({ + absoluteUrl: normalizePublicUrl(asset.absoluteUrl, policy), + expectedByteLength: asset.expectedByteLength, + expectedContentType: normalizeContentType( + asset.expectedContentType, + policy, + ), + digestHex: asset.integrity.digestHex, + requestHeaders: normalizeRequestHeaders( + asset.requestHeaders, + policy, + ), + }); +} + +function normalizeLookupRequest( + absoluteUrl: string, + requestHeaders: readonly PublicCacheHeader[] | undefined, + policy: PublicCacheRuntimePolicy, +): NormalizedAsset { + return { + absoluteUrl: normalizePublicUrl(absoluteUrl, policy), + expectedByteLength: 0, + expectedContentType: "", + digestHex: ZERO_SHA256, + requestHeaders: normalizeRequestHeaders(requestHeaders, policy), + }; +} + +function normalizeContentType( + input: string, + policy: PublicCacheRuntimePolicy, +): string { + if ( + typeof input !== "string" || + input.length < 1 || + input.length > 256 || + /[\r\n\0]/u.test(input) || + !policy.isContentTypeAllowed(input) + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + return input + .split(";") + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part, index) => + index === 0 ? part.toLowerCase() : part, + ) + .join("; "); +} + +function normalizePublicUrl( + input: string, + policy: PublicCacheRuntimePolicy, +): string { + if (typeof input !== "string" || input.length > 2_048) { + throw new CacheValidationFailure("INVALID_INPUT"); + } + let url: URL; + try { + url = new URL(input); + } catch { + throw new CacheValidationFailure("INVALID_INPUT"); + } + if ( + url.origin !== policy.origin || + !isAllowedPublicCacheOrigin(url) || + url.username.length > 0 || + url.password.length > 0 || + url.hash.length > 0 || + url.href === markerRequestUrl(policy) || + url.href === activePointerRequestUrl(policy) + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + const forbidden = new Set(policy.forbiddenQueryParameterNames); + const allowed = new Set(policy.allowedQueryParameterNames); + const seenNames = new Set(); + for (const [rawName, value] of url.searchParams.entries()) { + const name = rawName.toLowerCase(); + if ( + forbidden.has(name) || + !allowed.has(name) || + seenNames.has(name) || + !policy.isQueryParameterValueAllowed(name, value) + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + seenNames.add(name); + } + return url.href; +} + +function normalizeRequestHeaders( + input: readonly PublicCacheHeader[] | undefined, + policy: PublicCacheRuntimePolicy, +): readonly PublicCacheHeader[] { + const allowed = new Set(policy.allowedRequestHeaderNames); + const forbidden = new Set([ + "authorization", + "cookie", + "proxy-authorization", + "range", + ]); + const headers = new Headers(); + for (const pair of input ?? []) { + if ( + !Array.isArray(pair) || + pair.length !== 2 || + typeof pair[0] !== "string" || + typeof pair[1] !== "string" + ) { + throw new CacheValidationFailure("INVALID_INPUT"); + } + const name = pair[0].toLowerCase(); + if ( + forbidden.has(name) || + !allowed.has(name) || + pair[1].length > 1_024 || + /[\r\n\0]/u.test(pair[1]) + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + headers.append(name, pair[1].trim()); + } + return Object.freeze( + [...headers.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([name, value]) => + Object.freeze([name, value]) as PublicCacheHeader, + ), + ); +} + +async function readAndValidateResponse( + response: Response, + asset: NormalizedAsset, + policy: PublicCacheRuntimePolicy, + crypto: PublicCacheCryptoFacade, + signal: AbortSignal | undefined, +): Promise> { + try { + if ( + response.status !== 200 || + !response.ok || + response.redirected || + response.type === "opaque" || + response.type === "opaqueredirect" || + response.type === "error" || + (response.url.length > 0 && + new URL(response.url).href !== asset.absoluteUrl) + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + const cacheControl = response.headers.get("cache-control") ?? ""; + const directives = new Set( + cacheControl + .split(",") + .map((part) => part.trim().split("=", 1)[0]?.toLowerCase()) + .filter((part): part is string => Boolean(part)), + ); + if ( + directives.has("private") || + directives.has("no-store") || + directives.has("no-cache") || + (response.headers.get("pragma") ?? "") + .toLowerCase() + .includes("no-cache") || + response.headers.has("set-cookie") || + response.headers.has("set-cookie2") || + response.headers.has("www-authenticate") + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + const contentType = response.headers.get("content-type"); + if (!contentType || !policy.isContentTypeAllowed(contentType)) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + if ( + normalizeContentType(contentType, policy) !== + asset.expectedContentType + ) { + throw new CacheValidationFailure("INTEGRITY_FAILED"); + } + validateVary(response.headers.get("vary"), asset, policy); + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + (!/^\d+$/u.test(declaredLength) || + Number(declaredLength) > policy.maxEntryBytes) + ) { + throw new CacheValidationFailure("LIMIT_EXCEEDED"); + } + + const bytes = await readBodyBounded( + response, + asset.expectedByteLength, + policy.maxEntryBytes, + signal, + ); + const digestHex = await sha256Hex(crypto, bytes); + if (digestHex !== asset.digestHex) { + throw new CacheValidationFailure("INTEGRITY_FAILED"); + } + const headers = sanitizedResponseHeaders(response.headers, policy); + return browserDataSuccess({ bytes, headers }); + } catch (error) { + return exceptionFailure(error, "CACHE_STAGE"); + } +} + +async function readBodyBounded( + response: Response, + expectedByteLength: number, + maxEntryBytes: number, + signal: AbortSignal | undefined, +): Promise { + if (expectedByteLength > maxEntryBytes) { + throw new CacheValidationFailure("LIMIT_EXCEEDED"); + } + if (!response.body) { + if (expectedByteLength === 0) return new Uint8Array(); + throw new CacheValidationFailure("INTEGRITY_FAILED"); + } + const output = new Uint8Array(expectedByteLength); + const reader = response.body.getReader(); + let offset = 0; + let aborted = signal?.aborted ?? false; + const onAbort = (): void => { + aborted = true; + cancelReaderSafely(reader, "Public cache body read was aborted."); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + while (true) { + if (aborted) { + cancelReaderSafely(reader, "Public cache body read was aborted."); + throw new CacheValidationFailure("ABORTED"); + } + const part = await reader.read(); + if (aborted) { + throw new CacheValidationFailure("ABORTED"); + } + if (part.done) break; + if ( + !(part.value instanceof Uint8Array) || + offset + part.value.byteLength > expectedByteLength || + offset + part.value.byteLength > maxEntryBytes + ) { + cancelReaderSafely( + reader, + "Public cache body exceeded its read budget.", + ); + throw new CacheValidationFailure( + offset + part.value.byteLength > maxEntryBytes + ? "LIMIT_EXCEEDED" + : "INTEGRITY_FAILED", + ); + } + output.set(part.value, offset); + offset += part.value.byteLength; + } + } finally { + signal?.removeEventListener("abort", onAbort); + reader.releaseLock(); + } + if (offset !== expectedByteLength) { + throw new CacheValidationFailure("INTEGRITY_FAILED"); + } + return output; +} + +function validateVary( + vary: string | null, + asset: NormalizedAsset, + policy: PublicCacheRuntimePolicy, +): void { + const expected = new Set( + asset.requestHeaders.map(([name]) => name.toLowerCase()), + ); + const actual = new Set(); + if (!vary) { + if (expected.size > 0) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + return; + } + const allowed = new Set(policy.allowedVaryHeaderNames); + for (const item of vary.split(",")) { + const name = item.trim().toLowerCase(); + if ( + name === "*" || + name === "authorization" || + name === "cookie" || + !allowed.has(name) + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + actual.add(name); + } + if ( + actual.size !== expected.size || + [...actual].some((name) => !expected.has(name)) + ) { + throw new CacheValidationFailure("POLICY_REJECTED"); + } +} + +function sanitizedResponseHeaders( + headers: Headers, + policy: PublicCacheRuntimePolicy, +): readonly PublicCacheHeader[] { + const allowed = new Set(policy.allowedResponseHeaderNames); + const sanitized: PublicCacheHeader[] = []; + for (const [name, value] of headers.entries()) { + if (!allowed.has(name)) { + if (policy.unknownResponseHeaderAction === "REJECT") { + throw new CacheValidationFailure("POLICY_REJECTED"); + } + continue; + } + sanitized.push(Object.freeze([name, value])); + } + return Object.freeze(sanitized); +} + +function createNativeRequest( + asset: NormalizedAsset, + signal?: AbortSignal, +): Request { + return new Request(asset.absoluteUrl, { + method: "GET", + headers: new Headers( + asset.requestHeaders.map(([name, value]) => [name, value]), + ), + credentials: "omit", + cache: "no-store", + redirect: "error", + referrerPolicy: "no-referrer", + mode: "same-origin", + signal, + }); +} + +async function readMarker( + cache: PublicCacheFacade, + policy: PublicCacheRuntimePolicy, + crypto: PublicCacheCryptoFacade, +): Promise> { + try { + const response = await cache.match(markerRequest(policy)); + if (!response) return browserDataSuccess(null); + const value = await readBoundedJson(response, MAX_CONTROL_JSON_BYTES); + if (!isReleaseMarker(value, policy)) { + return browserDataFailure("CORRUPT_DATA", "CACHE_LOOKUP", { + recovery: "REHYDRATE", + }); + } + const computedDigest = await sha256Hex( + crypto, + new TextEncoder().encode( + canonicalManifest(value.releaseRegistryId, value.assets), + ), + ); + if ( + value.entryCount !== value.assets.length || + value.totalBytes !== + value.assets.reduce( + (total, asset) => total + asset.expectedByteLength, + 0, + ) || + computedDigest !== value.manifestDigestHex + ) { + return browserDataFailure("CORRUPT_DATA", "CACHE_LOOKUP", { + recovery: "REHYDRATE", + }); + } + return browserDataSuccess(value); + } catch (error) { + return exceptionFailure(error, "CACHE_LOOKUP"); + } +} + +async function readActivePointer( + cacheStorage: PublicCacheStorageFacade, + policy: PublicCacheRuntimePolicy, +): Promise> { + try { + const name = controlCacheName(policy); + if (!(await cacheStorage.keys()).includes(name)) { + return browserDataSuccess(null); + } + const cache = await cacheStorage.open(name); + const response = await cache.match(activePointerRequest(policy)); + if (!response) return browserDataSuccess(null); + const value = await readBoundedJson(response, MAX_CONTROL_JSON_BYTES); + if (!isActivePointer(value, policy)) { + return browserDataFailure("CORRUPT_DATA", "CACHE_LOOKUP", { + recovery: "REHYDRATE", + }); + } + return browserDataSuccess(value); + } catch (error) { + return exceptionFailure(error, "CACHE_LOOKUP"); + } +} + +async function readBoundedJson( + response: Response, + maximumBytes: number, +): Promise { + const contentLength = response.headers.get("content-length"); + if ( + !Number.isSafeInteger(maximumBytes) || + maximumBytes <= 0 || + (contentLength !== null && + (!/^\d+$/u.test(contentLength) || + Number(contentLength) > maximumBytes)) + ) { + throw new CacheValidationFailure("CORRUPT_DATA"); + } + if (!response.body) { + throw new CacheValidationFailure("CORRUPT_DATA"); + } + + const reader = response.body.getReader(); + const boundedBytes = new Uint8Array(maximumBytes); + let byteLength = 0; + let completelyRead = false; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + completelyRead = true; + break; + } + if (chunk.value.byteLength > maximumBytes - byteLength) { + throw new CacheValidationFailure("CORRUPT_DATA"); + } + boundedBytes.set(chunk.value, byteLength); + byteLength += chunk.value.byteLength; + } + } catch (error) { + if (!completelyRead) { + cancelReaderSafely( + reader, + "Cache control JSON exceeded its read budget.", + ); + } + throw error; + } finally { + reader.releaseLock(); + } + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode( + boundedBytes.subarray(0, byteLength), + ); + } catch { + throw new CacheValidationFailure("CORRUPT_DATA"); + } + try { + return JSON.parse(text); + } catch { + throw new CacheValidationFailure("CORRUPT_DATA"); + } +} + +function cancelReaderSafely( + reader: ReadableStreamDefaultReader, + reason: string, +): void { + try { + void reader.cancel(reason).catch(() => undefined); + } catch { + // Cancellation is best effort once the bounded consumer has stopped. + } +} + +function isReleaseMarker( + value: unknown, + policy: PublicCacheRuntimePolicy, +): value is ReleaseMarker { + if ( + !value || + typeof value !== "object" || + !("schemaVersion" in value) || + value.schemaVersion !== 1 || + !("verified" in value) || + value.verified !== true || + !("releaseRegistryId" in value) || + typeof value.releaseRegistryId !== "string" || + !policy.isReleaseRegistryIdAllowed(value.releaseRegistryId) || + !("manifestDigestHex" in value) || + typeof value.manifestDigestHex !== "string" || + !SHA256_HEX.test(value.manifestDigestHex) || + !("entryCount" in value) || + typeof value.entryCount !== "number" || + !Number.isSafeInteger(value.entryCount) || + !("totalBytes" in value) || + typeof value.totalBytes !== "number" || + !Number.isSafeInteger(value.totalBytes) || + !("stagedAtEpochMs" in value) || + typeof value.stagedAtEpochMs !== "number" || + !Number.isSafeInteger(value.stagedAtEpochMs) || + !("assets" in value) || + !Array.isArray(value.assets) || + value.assets.length > policy.maxEntriesPerRelease + ) { + return false; + } + try { + const normalized = normalizeAssets( + value.assets.map((asset) => markerAssetToPublicAsset(asset)), + policy, + ); + return stableJson(normalized) === stableJson(value.assets); + } catch { + return false; + } +} + +function markerAssetToPublicAsset(value: unknown): PublicCacheAsset { + if (!value || typeof value !== "object") { + throw new CacheValidationFailure("CORRUPT_DATA"); + } + const asset = value as Partial; + return { + absoluteUrl: asset.absoluteUrl ?? "", + expectedByteLength: asset.expectedByteLength ?? -1, + expectedContentType: asset.expectedContentType ?? "", + integrity: { + algorithm: "SHA-256", + digestHex: asset.digestHex ?? "", + }, + requestHeaders: asset.requestHeaders, + }; +} + +function isActivePointer( + value: unknown, + policy: PublicCacheRuntimePolicy, +): value is ActiveReleasePointer { + return Boolean( + value && + typeof value === "object" && + "schemaVersion" in value && + value.schemaVersion === 1 && + "releaseRegistryId" in value && + typeof value.releaseRegistryId === "string" && + policy.isReleaseRegistryIdAllowed(value.releaseRegistryId) && + "manifestDigestHex" in value && + typeof value.manifestDigestHex === "string" && + SHA256_HEX.test(value.manifestDigestHex) && + "candidateCacheName" in value && + typeof value.candidateCacheName === "string" && + value.candidateCacheName === + releaseCacheName( + policy, + value.releaseRegistryId, + value.manifestDigestHex, + ) && + "rollbackCandidateCacheNames" in value && + Array.isArray(value.rollbackCandidateCacheNames) && + value.rollbackCandidateCacheNames.length <= + policy.retainedPreviousReleaseCount && + new Set(value.rollbackCandidateCacheNames).size === + value.rollbackCandidateCacheNames.length && + value.rollbackCandidateCacheNames.every( + (name) => + typeof name === "string" && + name !== value.candidateCacheName && + isOwnedReleaseCacheName(name, policy), + ) && + "activatedAtEpochMs" in value && + typeof value.activatedAtEpochMs === "number" && + Number.isSafeInteger(value.activatedAtEpochMs), + ); +} + +function isOwnedReleaseCacheName( + name: string, + policy: PublicCacheRuntimePolicy, +): boolean { + if (!name.startsWith(`${policy.ownedCachePrefix}release:`)) { + return false; + } + const remainder = name.slice( + `${policy.ownedCachePrefix}release:`.length, + ); + const separator = remainder.lastIndexOf(":"); + if (separator < 1) return false; + const releaseRegistryId = remainder.slice(0, separator); + const digestHex = remainder.slice(separator + 1); + return ( + policy.isReleaseRegistryIdAllowed(releaseRegistryId) && + SHA256_HEX.test(digestHex) + ); +} + +function canonicalManifest( + releaseRegistryId: string, + assets: readonly NormalizedAsset[], +): string { + return [ + "ca-public-cache-manifest-v1", + `release:${releaseRegistryId}`, + ...[...assets] + .sort((left, right) => assetKey(left).localeCompare(assetKey(right))) + .map( + (asset) => + `${assetKey(asset)}:${asset.expectedByteLength}:${JSON.stringify(asset.expectedContentType)}:${asset.digestHex}`, + ), + "", + ].join("\n"); +} + +function assetKey( + asset: Pick, +): string { + return JSON.stringify([asset.absoluteUrl, asset.requestHeaders]); +} + +function byteSourceFrom(bytes: Uint8Array): ByteSource { + const stored = Uint8Array.from(bytes); + return Object.freeze({ + byteLength: stored.byteLength, + async *stream(signal: AbortSignal) { + if (signal.aborted) { + yield browserDataFailure("ABORTED", "CACHE_LOOKUP"); + return; + } + yield browserDataSuccess(Uint8Array.from(stored)); + }, + }); +} + +function markerRequest(policy: PublicCacheRuntimePolicy): Request { + return new Request(markerRequestUrl(policy), { method: "GET" }); +} + +function activePointerRequest(policy: PublicCacheRuntimePolicy): Request { + return new Request(activePointerRequestUrl(policy), { method: "GET" }); +} + +function markerRequestUrl(policy: PublicCacheRuntimePolicy): string { + return `${policy.origin}/.well-known/ca-public-cache-v1/candidate`; +} + +function activePointerRequestUrl(policy: PublicCacheRuntimePolicy): string { + return `${policy.origin}/.well-known/ca-public-cache-v1/active`; +} + +function controlCacheName(policy: PublicCacheRuntimePolicy): string { + return `${policy.ownedCachePrefix}control`; +} + +function releaseCacheName( + policy: PublicCacheRuntimePolicy, + releaseRegistryId: string, + manifestDigestHex: string, +): string { + return `${policy.ownedCachePrefix}release:${releaseRegistryId}:${manifestDigestHex}`; +} + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + }, + }); +} + +function summaryFromMarker( + marker: ReleaseMarker, +): PublicCacheReleaseSummary { + return Object.freeze({ + releaseRegistryId: marker.releaseRegistryId, + entryCount: marker.entryCount, + totalBytes: marker.totalBytes, + stagedAtEpochMs: marker.stagedAtEpochMs, + }); +} + +function mutationAvailability( + dependencies: PublicResponseCacheDependencySnapshot, + operation: BrowserDataOperation, +): BrowserFailureResult | null { + return dependencies.cacheStorage && + dependencies.fetcher && + dependencies.mutationLock + ? null + : unsupported(operation); +} + +function unsupported( + operation: BrowserDataOperation, +): BrowserFailureResult { + return asFailure( + browserDataFailure("UNSUPPORTED", operation, { + recovery: "ONLINE_ONLY", + }), + ); +} + +function validationFailure( + error: unknown, + operation: BrowserDataOperation, +): BrowserFailureResult { + return error instanceof CacheValidationFailure + ? failureForCode(error.code, operation) + : asFailure(browserDataFailure("INVALID_INPUT", operation)); +} + +function exceptionFailure( + error: unknown, + operation: BrowserDataOperation, +): BrowserFailureResult { + if (error instanceof CacheValidationFailure) { + return failureForCode(error.code, operation); + } + const mapped = mapBrowserDataException(error, operation); + if (mapped.ok) { + return asFailure( + browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RETRY", + }), + ); + } + return mapped; +} + +function failureForCode( + code: BrowserDataFailureCode, + operation: BrowserDataOperation, +): BrowserFailureResult { + if (code === "ABORTED") { + return asFailure(browserDataFailure(code, operation)); + } + if (code === "INTEGRITY_FAILED" || code === "CORRUPT_DATA") { + return asFailure( + browserDataFailure(code, operation, { recovery: "REHYDRATE" }), + ); + } + if (code === "QUOTA_EXCEEDED") { + return asFailure( + browserDataFailure(code, operation, { + retryable: true, + recovery: "ONLINE_ONLY", + }), + ); + } + if (code === "BLOCKED" || code === "UNAVAILABLE") { + return asFailure( + browserDataFailure(code, operation, { + retryable: true, + recovery: "RETRY", + }), + ); + } + return asFailure(browserDataFailure(code, operation)); +} + +function asFailure( + result: BrowserDataResult, +): BrowserFailureResult { + if (result.ok) { + throw new TypeError("Expected a browser data failure."); + } + return result; +} + +function rebaseFailure( + failure: BrowserDataFailure, + operation: BrowserDataOperation, +): BrowserFailureResult { + return { + ok: false, + error: Object.freeze({ ...failure, operation }), + }; +} + +function observeFailure( + result: BrowserDataResult, + observer: PublicCacheSafeObserver | undefined, + releaseRegistryId?: string, + byteLength?: number, +): BrowserFailureResult { + if (result.ok) { + throw new TypeError("Expected a public cache failure result."); + } + observePublicCacheSafely(observer, { + operation: result.error.operation, + outcome: "FAILED", + failureCode: result.error.code, + releaseRegistryId, + byteBucket: + byteLength === undefined ? undefined : cacheByteBucket(byteLength), + }); + return result; +} + +async function sha256Hex( + crypto: PublicCacheCryptoFacade, + bytes: Uint8Array, +): Promise { + const digest = await crypto.digestSha256(bytes); + let hex = ""; + for (const byte of new Uint8Array(digest)) { + hex += byte.toString(16).padStart(2, "0"); + } + return hex; +} + +function stableJson(value: unknown): string { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" + ) { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; + } + throw new CacheValidationFailure("CORRUPT_DATA"); +} + +const SHA256_HEX = /^[a-f0-9]{64}$/u; +const ZERO_SHA256 = "0".repeat(64); +const MAX_CONTROL_JSON_BYTES = 2 * 1024 * 1024; diff --git a/src/adapters/cross-context-invalidation/browser-cross-context-host.ts b/src/adapters/cross-context-invalidation/browser-cross-context-host.ts new file mode 100644 index 0000000..e1c2ca0 --- /dev/null +++ b/src/adapters/cross-context-invalidation/browser-cross-context-host.ts @@ -0,0 +1,249 @@ +import { + isCacheInvalidationOpaqueIdentifier, + type CacheInvalidationTopicDefinition, +} from "../../contracts/cache-invalidation.ts"; +import { + createBrowserCrossContextInvalidation, + type BroadcastChannelFacade, + type BroadcastMessageListener, + type BrowserCrossContextInvalidation, + type CrossContextInvalidationObservation, + type StorageEventTargetFacade, + type StoragePulseFacade, + type StoragePulseListener, +} from "./browser-cross-context-invalidation.ts"; + +export type BrowserCrossContextHostDependencies = Readonly<{ + host?: Record; + cacheEpoch: string; + topics: readonly CacheInvalidationTopicDefinition[]; + observe?: (observation: CrossContextInvalidationObservation) => void; +}>; + +type NativeBroadcastChannel = Readonly<{ + postMessage(value: unknown): void; + addEventListener(type: string, listener: (event: unknown) => void): void; + removeEventListener( + type: string, + listener: (event: unknown) => void, + ): void; + close(): void; +}>; + +const CHANNEL_NAME = "ca-client-cache-invalidation-v1"; +const STORAGE_PULSE_KEY = + "ca-frontend:cache-invalidation:v1:pulse"; + +/** + * Captures native capabilities without allowing a SecurityError getter or a + * missing random source to fail application boot. + */ +export function createBrowserCrossContextInvalidationFromHost( + dependencies: BrowserCrossContextHostDependencies, +): BrowserCrossContextInvalidation | undefined { + // A zero-feature build owns no cross-context invalidation runtime. Preserve + // that property strictly: do not even probe browser capability getters. + if (dependencies.topics.length === 0) return undefined; + const host = + dependencies.host ?? + (globalThis as unknown as Record); + if ( + !isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch) + ) { + return undefined; + } + const createOpaqueId = randomIdFactory(host); + if (!createOpaqueId) return undefined; + + const sourceId = createOpaqueId("tab"); + const sourceEpoch = createOpaqueId("page"); + if (!sourceId || !sourceEpoch) return undefined; + + return createBrowserCrossContextInvalidation({ + channelName: CHANNEL_NAME, + storagePulseKey: STORAGE_PULSE_KEY, + sourceId, + sourceEpoch, + cacheEpoch: dependencies.cacheEpoch, + topics: dependencies.topics, + createEventId: () => { + const eventId = createOpaqueId("event"); + if (!eventId) throw new TypeError("Secure random is unavailable."); + return eventId; + }, + createBroadcastChannel: broadcastFactory(host), + storage: storageFacade(host), + storageEvents: storageEventTarget(host), + observe: dependencies.observe, + }); +} + +function safeGet( + target: Record, + property: string, +): unknown { + try { + return Reflect.get(target, property); + } catch { + return undefined; + } +} + +function randomIdFactory( + host: Record, +): ((prefix: string) => string | null) | undefined { + const cryptoCandidate = safeGet(host, "crypto"); + if (!cryptoCandidate || typeof cryptoCandidate !== "object") { + return undefined; + } + const randomUuid = safeGet( + cryptoCandidate as Record, + "randomUUID", + ); + if (typeof randomUuid !== "function") return undefined; + + return (prefix) => { + try { + const value = Reflect.apply(randomUuid, cryptoCandidate, []); + if (typeof value !== "string") return null; + const candidate = `${prefix}.${value}`; + return isCacheInvalidationOpaqueIdentifier(candidate) + ? candidate + : null; + } catch { + return null; + } + }; +} + +function broadcastFactory( + host: Record, +): + | ((name: string) => BroadcastChannelFacade) + | undefined { + const Constructor = safeGet(host, "BroadcastChannel"); + if (typeof Constructor !== "function") return undefined; + + return (name) => { + const candidate = Reflect.construct(Constructor, [name]) as unknown; + if (!isNativeBroadcastChannel(candidate)) { + throw new TypeError("BroadcastChannel is incompatible."); + } + const listenerBindings = new Map< + BroadcastMessageListener, + (event: unknown) => void + >(); + return Object.freeze({ + postMessage(value: unknown) { + candidate.postMessage(value); + }, + addEventListener( + _type: "message", + listener: BroadcastMessageListener, + ) { + const bound = (event: unknown) => { + listener({ + data: + event && typeof event === "object" + ? safeGet( + event as Record, + "data", + ) + : undefined, + }); + }; + listenerBindings.set(listener, bound); + candidate.addEventListener("message", bound); + }, + removeEventListener( + _type: "message", + listener: BroadcastMessageListener, + ) { + const bound = listenerBindings.get(listener); + if (!bound) return; + listenerBindings.delete(listener); + candidate.removeEventListener("message", bound); + }, + close() { + listenerBindings.clear(); + candidate.close(); + }, + }); + }; +} + +function isNativeBroadcastChannel( + value: unknown, +): value is NativeBroadcastChannel { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ["postMessage", "addEventListener", "removeEventListener", "close"].every( + (method) => typeof safeGet(candidate, method) === "function", + ); +} + +function storageFacade( + host: Record, +): StoragePulseFacade | undefined { + const candidate = safeGet(host, "localStorage"); + if (!candidate || typeof candidate !== "object") return undefined; + const record = candidate as Record; + const setItem = safeGet(record, "setItem"); + const removeItem = safeGet(record, "removeItem"); + if (typeof setItem !== "function" || typeof removeItem !== "function") { + return undefined; + } + return Object.freeze({ + setItem(key, value) { + Reflect.apply(setItem, candidate, [key, value]); + }, + removeItem(key) { + Reflect.apply(removeItem, candidate, [key]); + }, + }); +} + +function storageEventTarget( + host: Record, +): StorageEventTargetFacade | undefined { + const addEventListener = safeGet(host, "addEventListener"); + const removeEventListener = safeGet(host, "removeEventListener"); + if ( + typeof addEventListener !== "function" || + typeof removeEventListener !== "function" + ) { + return undefined; + } + const bindings = new Map< + StoragePulseListener, + (event: unknown) => void + >(); + return Object.freeze({ + addEventListener(_type: "storage", listener: StoragePulseListener) { + const bound = (event: unknown) => { + if (!event || typeof event !== "object") { + listener({ key: null, newValue: null }); + return; + } + const record = event as Record; + const key = safeGet(record, "key"); + const newValue = safeGet(record, "newValue"); + listener({ + key: typeof key === "string" ? key : null, + newValue: typeof newValue === "string" ? newValue : null, + }); + }; + bindings.set(listener, bound); + Reflect.apply(addEventListener, host, ["storage", bound]); + }, + removeEventListener( + _type: "storage", + listener: StoragePulseListener, + ) { + const bound = bindings.get(listener); + if (!bound) return; + bindings.delete(listener); + Reflect.apply(removeEventListener, host, ["storage", bound]); + }, + }); +} diff --git a/src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts b/src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts new file mode 100644 index 0000000..52407c4 --- /dev/null +++ b/src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts @@ -0,0 +1,710 @@ +import { + CACHE_INVALIDATION_PROTOCOL_VERSION, + CACHE_INVALIDATION_WIRE_LIMITS, + decodeCacheInvalidationWireEvent, + isCacheInvalidationOpaqueIdentifier, + isCacheInvalidationTopic, + parseCacheInvalidationWireEvent, + type CacheInvalidationParseFailureReason, + type CacheInvalidationTopicDefinition, + type CacheInvalidationWireEvent, +} from "../../contracts/cache-invalidation.ts"; + +export type CrossContextInvalidationStatus = + | "ACTIVE_BROADCAST" + | "ACTIVE_STORAGE_FALLBACK" + | "DEGRADED_LOCAL_ONLY" + | "CLOSED"; + +export type CrossContextInvalidationTransport = + | "BROADCAST" + | "STORAGE" + | "NONE"; + +export type CrossContextInvalidationOrdering = "NEXT" | "GAP"; + +export type CrossContextInvalidationDelivery = Readonly<{ + event: CacheInvalidationWireEvent; + ordering: CrossContextInvalidationOrdering; + transport: Exclude; +}>; + +export type CrossContextInvalidationObservationReason = + | CacheInvalidationParseFailureReason + | "BROADCAST_OPEN_FAILED" + | "BROADCAST_PUBLISH_FAILED" + | "CLOSED" + | "DELIVERED" + | "DUPLICATE" + | "HANDLER_FAILED" + | "OPENED" + | "PUBLISHED" + | "SELF_ECHO" + | "SEQUENCE_EXHAUSTED" + | "STALE" + | "STORAGE_CLEANUP_FAILED" + | "STORAGE_LISTENER_FAILED" + | "STORAGE_PUBLISH_FAILED"; + +/** + * Safe to project into diagnostics: it contains no event, topic, cache epoch, + * source identifier, storage value or native exception. + */ +export type CrossContextInvalidationObservation = Readonly<{ + operation: "OPEN" | "PUBLISH" | "RECEIVE" | "CLOSE"; + outcome: "ACCEPTED" | "DEGRADED" | "DROPPED" | "FAILED"; + transport: CrossContextInvalidationTransport; + reason: CrossContextInvalidationObservationReason; + ordering?: CrossContextInvalidationOrdering; +}>; + +export type CrossContextInvalidationPublishResult = + | Readonly<{ + ok: true; + transport: Exclude; + }> + | Readonly<{ + ok: false; + reason: + | "CLOSED" + | "INVALID_EVENT" + | "SEQUENCE_EXHAUSTED" + | "TRANSPORT_UNAVAILABLE"; + }>; + +export type BroadcastMessageEventFacade = Readonly<{ data: unknown }>; +export type BroadcastMessageListener = ( + event: BroadcastMessageEventFacade, +) => void; + +export type BroadcastChannelFacade = Readonly<{ + postMessage(value: unknown): void; + addEventListener( + type: "message", + listener: BroadcastMessageListener, + ): void; + removeEventListener( + type: "message", + listener: BroadcastMessageListener, + ): void; + close(): void; +}>; + +export type StoragePulseFacade = Readonly<{ + setItem(key: string, value: string): void; + removeItem(key: string): void; +}>; + +export type StoragePulseEvent = Readonly<{ + key: string | null; + newValue: string | null; +}>; + +export type StoragePulseListener = (event: StoragePulseEvent) => void; + +export type StorageEventTargetFacade = Readonly<{ + addEventListener(type: "storage", listener: StoragePulseListener): void; + removeEventListener(type: "storage", listener: StoragePulseListener): void; +}>; + +export type BrowserCrossContextInvalidationDependencies = Readonly<{ + channelName: string; + storagePulseKey: string; + sourceId: string; + sourceEpoch: string; + cacheEpoch: string; + topics: readonly CacheInvalidationTopicDefinition[]; + createEventId(): string; + createBroadcastChannel?: (name: string) => BroadcastChannelFacade; + storage?: StoragePulseFacade; + storageEvents?: StorageEventTargetFacade; + nowEpochMilliseconds?: () => number; + eventTtlMs?: number; + dedupeCapacity?: number; + sourceCapacity?: number; + observe?: (observation: CrossContextInvalidationObservation) => void; +}>; + +export type BrowserCrossContextInvalidation = Readonly<{ + getStatus(): CrossContextInvalidationStatus; + publish(input: { + topic: string; + topicVersion: number; + }): CrossContextInvalidationPublishResult; + subscribe( + listener: (delivery: CrossContextInvalidationDelivery) => void, + ): () => void; + close(): void; +}>; + +type SeenEvent = Readonly<{ expiresAt: number }>; +type SourceHighWatermark = Readonly<{ + sequence: number; + expiresAt: number; +}>; + +const DEFAULT_EVENT_TTL_MS = 60_000; +const DEFAULT_DEDUPE_CAPACITY = 1_024; +const DEFAULT_SOURCE_CAPACITY = 256; +const MAX_DEDUPE_CAPACITY = 4_096; +const MAX_SOURCE_CAPACITY = 1_024; +const MAX_CHANNEL_NAME_LENGTH = 128; +const MAX_STORAGE_KEY_LENGTH = 256; + +export function createBrowserCrossContextInvalidation( + dependencies: BrowserCrossContextInvalidationDependencies, +): BrowserCrossContextInvalidation { + const topicVersions = validateConfiguration(dependencies); + const now = dependencies.nowEpochMilliseconds ?? Date.now; + const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS; + const dedupeCapacity = + dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY; + const sourceCapacity = + dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY; + const listeners = new Set< + (delivery: CrossContextInvalidationDelivery) => void + >(); + const seenEvents = new Map(); + const sourceHighWatermarks = new Map(); + + let closed = false; + let sequence = 0; + let broadcast: BroadcastChannelFacade | undefined; + let storageListenerInstalled = false; + let status: CrossContextInvalidationStatus = "DEGRADED_LOCAL_ONLY"; + + const receiveBroadcast: BroadcastMessageListener = (message) => { + receive(message.data, "BROADCAST"); + }; + const receiveStorage: StoragePulseListener = (event) => { + if ( + closed || + event.key !== dependencies.storagePulseKey || + typeof event.newValue !== "string" + ) { + return; + } + const parsed = decodeCacheInvalidationWireEvent(event.newValue, { + cacheEpoch: dependencies.cacheEpoch, + topicVersions, + nowEpochMilliseconds: safeNow(now), + }); + acceptParsed(parsed, "STORAGE"); + }; + + installStorageListener(); + openBroadcast(); + refreshStatus(); + + function installStorageListener(): void { + if (!dependencies.storageEvents) return; + try { + dependencies.storageEvents.addEventListener( + "storage", + receiveStorage, + ); + storageListenerInstalled = true; + } catch { + observe({ + operation: "OPEN", + outcome: "DEGRADED", + transport: "STORAGE", + reason: "STORAGE_LISTENER_FAILED", + }); + } + } + + function openBroadcast(): void { + if (!dependencies.createBroadcastChannel) return; + let candidate: BroadcastChannelFacade | undefined; + try { + candidate = dependencies.createBroadcastChannel( + dependencies.channelName, + ); + candidate.addEventListener("message", receiveBroadcast); + broadcast = candidate; + observe({ + operation: "OPEN", + outcome: "ACCEPTED", + transport: "BROADCAST", + reason: "OPENED", + }); + } catch { + if (candidate) { + try { + candidate.removeEventListener("message", receiveBroadcast); + } catch { + // Opening still fails closed when listener cleanup is rejected. + } + try { + candidate.close(); + } catch { + // Opening still falls back when provider cleanup is rejected. + } + } + observe({ + operation: "OPEN", + outcome: "DEGRADED", + transport: "BROADCAST", + reason: "BROADCAST_OPEN_FAILED", + }); + } + } + + function refreshStatus(): void { + if (closed) { + status = "CLOSED"; + } else if (broadcast) { + status = "ACTIVE_BROADCAST"; + } else if ( + dependencies.storage && + dependencies.storageEvents && + storageListenerInstalled + ) { + status = "ACTIVE_STORAGE_FALLBACK"; + } else { + status = "DEGRADED_LOCAL_ONLY"; + } + } + + function publish(input: { + topic: string; + topicVersion: number; + }): CrossContextInvalidationPublishResult { + if (closed) { + return Object.freeze({ ok: false, reason: "CLOSED" }); + } + if (sequence >= Number.MAX_SAFE_INTEGER) { + observe({ + operation: "PUBLISH", + outcome: "FAILED", + transport: "NONE", + reason: "SEQUENCE_EXHAUSTED", + }); + return Object.freeze({ + ok: false, + reason: "SEQUENCE_EXHAUSTED", + }); + } + + const emittedAt = safeNow(now); + let eventId: string; + try { + eventId = dependencies.createEventId(); + } catch { + return invalidPublish(); + } + const candidate: CacheInvalidationWireEvent = Object.freeze({ + protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION, + eventId, + sourceId: dependencies.sourceId, + sourceEpoch: dependencies.sourceEpoch, + sequence: sequence + 1, + cacheEpoch: dependencies.cacheEpoch, + topic: input.topic, + topicVersion: input.topicVersion, + emittedAt, + expiresAt: emittedAt + eventTtlMs, + }); + const parsed = parseCacheInvalidationWireEvent(candidate, { + cacheEpoch: dependencies.cacheEpoch, + topicVersions, + nowEpochMilliseconds: emittedAt, + }); + if (!parsed.ok) return invalidPublish(); + + sequence = candidate.sequence; + pruneTracking(emittedAt); + if (broadcast) { + try { + broadcast.postMessage(candidate); + observe({ + operation: "PUBLISH", + outcome: "ACCEPTED", + transport: "BROADCAST", + reason: "PUBLISHED", + }); + return Object.freeze({ + ok: true, + transport: "BROADCAST", + }); + } catch { + observe({ + operation: "PUBLISH", + outcome: "DEGRADED", + transport: "BROADCAST", + reason: "BROADCAST_PUBLISH_FAILED", + }); + closeBroadcast(); + refreshStatus(); + } + } + return publishThroughStorage(candidate); + } + + function invalidPublish(): CrossContextInvalidationPublishResult { + observe({ + operation: "PUBLISH", + outcome: "FAILED", + transport: "NONE", + reason: "INVALID_ENVELOPE", + }); + return Object.freeze({ ok: false, reason: "INVALID_EVENT" }); + } + + function publishThroughStorage( + event: CacheInvalidationWireEvent, + ): CrossContextInvalidationPublishResult { + if ( + !dependencies.storage || + !dependencies.storageEvents || + !storageListenerInstalled + ) { + status = "DEGRADED_LOCAL_ONLY"; + observe({ + operation: "PUBLISH", + outcome: "DEGRADED", + transport: "NONE", + reason: "STORAGE_PUBLISH_FAILED", + }); + return Object.freeze({ + ok: false, + reason: "TRANSPORT_UNAVAILABLE", + }); + } + + const serialized = JSON.stringify(event); + try { + dependencies.storage.setItem( + dependencies.storagePulseKey, + serialized, + ); + } catch { + status = "DEGRADED_LOCAL_ONLY"; + observe({ + operation: "PUBLISH", + outcome: "DEGRADED", + transport: "STORAGE", + reason: "STORAGE_PUBLISH_FAILED", + }); + return Object.freeze({ + ok: false, + reason: "TRANSPORT_UNAVAILABLE", + }); + } + + try { + dependencies.storage.removeItem(dependencies.storagePulseKey); + } catch { + // A fixed pulse key prevents unbounded retained keys. A later publish + // overwrites it with a unique event, so delivery succeeded even when + // best-effort cleanup did not. + observe({ + operation: "PUBLISH", + outcome: "DEGRADED", + transport: "STORAGE", + reason: "STORAGE_CLEANUP_FAILED", + }); + } + status = "ACTIVE_STORAGE_FALLBACK"; + observe({ + operation: "PUBLISH", + outcome: "ACCEPTED", + transport: "STORAGE", + reason: "PUBLISHED", + }); + return Object.freeze({ ok: true, transport: "STORAGE" }); + } + + function receive( + input: unknown, + transport: Exclude, + ): void { + if (closed) return; + const parsed = parseCacheInvalidationWireEvent(input, { + cacheEpoch: dependencies.cacheEpoch, + topicVersions, + nowEpochMilliseconds: safeNow(now), + }); + acceptParsed(parsed, transport); + } + + function acceptParsed( + parsed: ReturnType, + transport: Exclude, + ): void { + if (closed) return; + if (!parsed.ok) { + observe({ + operation: "RECEIVE", + outcome: "DROPPED", + transport, + reason: parsed.reason, + }); + return; + } + const event = parsed.value; + if ( + event.sourceId === dependencies.sourceId && + event.sourceEpoch === dependencies.sourceEpoch + ) { + observe({ + operation: "RECEIVE", + outcome: "DROPPED", + transport, + reason: "SELF_ECHO", + }); + return; + } + + const currentTime = safeNow(now); + pruneTracking(currentTime); + if (seenEvents.has(event.eventId)) { + touchSeen(event.eventId, event.expiresAt); + observe({ + operation: "RECEIVE", + outcome: "DROPPED", + transport, + reason: "DUPLICATE", + }); + return; + } + touchSeen(event.eventId, event.expiresAt); + + const sourceKey = `${event.sourceId}\u0000${event.sourceEpoch}`; + const previous = sourceHighWatermarks.get(sourceKey); + if (previous && event.sequence <= previous.sequence) { + touchSource(sourceKey, previous); + observe({ + operation: "RECEIVE", + outcome: "DROPPED", + transport, + reason: "STALE", + }); + return; + } + const ordering: CrossContextInvalidationOrdering = + (!previous && event.sequence > 1) || + (previous !== undefined && + event.sequence > previous.sequence + 1) + ? "GAP" + : "NEXT"; + touchSource(sourceKey, { + sequence: event.sequence, + expiresAt: event.expiresAt, + }); + + const delivery = Object.freeze({ event, ordering, transport }); + for (const listener of [...listeners]) { + if (closed) return; + if (!listeners.has(listener)) continue; + try { + listener(delivery); + } catch { + observe({ + operation: "RECEIVE", + outcome: "FAILED", + transport, + reason: "HANDLER_FAILED", + ordering, + }); + } + } + observe({ + operation: "RECEIVE", + outcome: "ACCEPTED", + transport, + reason: "DELIVERED", + ordering, + }); + } + + function touchSeen(eventId: string, expiresAt: number): void { + seenEvents.delete(eventId); + seenEvents.set(eventId, { expiresAt }); + evictOldest(seenEvents, dedupeCapacity); + } + + function touchSource( + sourceKey: string, + value: SourceHighWatermark, + ): void { + sourceHighWatermarks.delete(sourceKey); + sourceHighWatermarks.set(sourceKey, value); + evictOldest(sourceHighWatermarks, sourceCapacity); + } + + function pruneTracking(currentTime: number): void { + for (const [eventId, entry] of seenEvents) { + if (entry.expiresAt <= currentTime) seenEvents.delete(eventId); + } + for (const [sourceKey, entry] of sourceHighWatermarks) { + if (entry.expiresAt <= currentTime) { + sourceHighWatermarks.delete(sourceKey); + } + } + } + + function subscribe( + listener: (delivery: CrossContextInvalidationDelivery) => void, + ): () => void { + if (closed) return () => {}; + listeners.add(listener); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + listeners.delete(listener); + }; + } + + function closeBroadcast(): void { + const current = broadcast; + broadcast = undefined; + if (!current) return; + try { + current.removeEventListener("message", receiveBroadcast); + } catch { + // Closing continues even when a provider rejects listener removal. + } + try { + current.close(); + } catch { + // Closing is best-effort and remains idempotent. + } + } + + function close(): void { + if (closed) return; + closed = true; + closeBroadcast(); + if (storageListenerInstalled && dependencies.storageEvents) { + try { + dependencies.storageEvents.removeEventListener( + "storage", + receiveStorage, + ); + } catch { + // Local closed state still prevents any late callback. + } + } + storageListenerInstalled = false; + listeners.clear(); + seenEvents.clear(); + sourceHighWatermarks.clear(); + status = "CLOSED"; + observe({ + operation: "CLOSE", + outcome: "ACCEPTED", + transport: "NONE", + reason: "CLOSED", + }); + } + + function observe( + observation: CrossContextInvalidationObservation, + ): void { + try { + dependencies.observe?.(Object.freeze({ ...observation })); + } catch { + // Transport behavior must not depend on diagnostics. + } + } + + return Object.freeze({ + getStatus: () => status, + publish, + subscribe, + close, + }); +} + +function validateConfiguration( + dependencies: BrowserCrossContextInvalidationDependencies, +): Readonly> { + if ( + typeof dependencies.channelName !== "string" || + dependencies.channelName.length < 1 || + dependencies.channelName.length > MAX_CHANNEL_NAME_LENGTH || + typeof dependencies.storagePulseKey !== "string" || + dependencies.storagePulseKey.length < 1 || + dependencies.storagePulseKey.length > MAX_STORAGE_KEY_LENGTH || + !isCacheInvalidationOpaqueIdentifier(dependencies.sourceId) || + !isCacheInvalidationOpaqueIdentifier(dependencies.sourceEpoch) || + !isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch) || + typeof dependencies.createEventId !== "function" + ) { + throw new TypeError( + "Cross-context invalidation configuration is invalid.", + ); + } + const eventTtlMs = dependencies.eventTtlMs ?? DEFAULT_EVENT_TTL_MS; + const dedupeCapacity = + dependencies.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY; + const sourceCapacity = + dependencies.sourceCapacity ?? DEFAULT_SOURCE_CAPACITY; + if ( + !Number.isSafeInteger(eventTtlMs) || + eventTtlMs < 1 || + eventTtlMs > + CACHE_INVALIDATION_WIRE_LIMITS.maxEventTtlMs || + !Number.isSafeInteger(dedupeCapacity) || + dedupeCapacity < 1 || + dedupeCapacity > MAX_DEDUPE_CAPACITY || + !Number.isSafeInteger(sourceCapacity) || + sourceCapacity < 1 || + sourceCapacity > MAX_SOURCE_CAPACITY + ) { + throw new TypeError( + "Cross-context invalidation bounds are invalid.", + ); + } + + if (!Array.isArray(dependencies.topics)) { + throw new TypeError( + "Cross-context invalidation topic registry is invalid.", + ); + } + const topicVersions: Record = Object.create(null); + for (const definition of dependencies.topics) { + if ( + !definition || + typeof definition !== "object" || + !isCacheInvalidationTopic(definition.topic) || + !Number.isSafeInteger(definition.topicVersion) || + definition.topicVersion < 1 || + Object.hasOwn(topicVersions, definition.topic) + ) { + throw new TypeError( + "Cross-context invalidation topic registry is invalid.", + ); + } + topicVersions[definition.topic] = definition.topicVersion; + } + if (Object.keys(topicVersions).length < 1) { + throw new TypeError( + "Cross-context invalidation requires an allowlisted topic.", + ); + } + return Object.freeze(topicVersions); +} + +function safeNow(now: () => number): number { + try { + const value = now(); + return Number.isSafeInteger(value) && value >= 0 ? value : -1; + } catch { + return -1; + } +} + +function evictOldest( + values: Map, + capacity: number, +): void { + while (values.size > capacity) { + const oldest = values.keys().next().value; + if (typeof oldest !== "string") return; + values.delete(oldest); + } +} diff --git a/src/adapters/cross-context-invalidation/index.ts b/src/adapters/cross-context-invalidation/index.ts new file mode 100644 index 0000000..acaa917 --- /dev/null +++ b/src/adapters/cross-context-invalidation/index.ts @@ -0,0 +1,23 @@ +export { + createBrowserCrossContextInvalidation, + type BroadcastChannelFacade, + type BroadcastMessageEventFacade, + type BroadcastMessageListener, + type BrowserCrossContextInvalidation, + type BrowserCrossContextInvalidationDependencies, + type CrossContextInvalidationDelivery, + type CrossContextInvalidationObservation, + type CrossContextInvalidationObservationReason, + type CrossContextInvalidationOrdering, + type CrossContextInvalidationPublishResult, + type CrossContextInvalidationStatus, + type CrossContextInvalidationTransport, + type StorageEventTargetFacade, + type StoragePulseEvent, + type StoragePulseFacade, + type StoragePulseListener, +} from "./browser-cross-context-invalidation.ts"; +export { + createBrowserCrossContextInvalidationFromHost, + type BrowserCrossContextHostDependencies, +} from "./browser-cross-context-host.ts"; diff --git a/src/adapters/diagnostics/bounded-diagnostics.ts b/src/adapters/diagnostics/bounded-diagnostics.ts new file mode 100644 index 0000000..c0b8e13 --- /dev/null +++ b/src/adapters/diagnostics/bounded-diagnostics.ts @@ -0,0 +1,102 @@ +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; +import { + projectDiagnosticRecord, + safeErrorKind, + type DiagnosticRecord, + type DiagnosticRecordInput, +} from "../../contracts/diagnostics.ts"; +import { projectTelemetryEvent } from "../../contracts/telemetry.ts"; + +export const noOpDiagnostics: DiagnosticsPort = Object.freeze({ + record() {}, +}); + +export function createDiagnosticsAdapter( + options: Readonly<{ + maxEntries?: number; + now?: () => number; + sink?: (record: DiagnosticRecord) => void; + }> = {}, +) { + const maxEntries = Math.max(1, options.maxEntries ?? 100); + const entries: DiagnosticRecord[] = []; + const droppedReasons = new Map(); + + function drop(reason: string) { + droppedReasons.set(reason, (droppedReasons.get(reason) ?? 0) + 1); + } + + function record(input: DiagnosticRecordInput) { + try { + const projected = projectDiagnosticRecord(input, options.now); + if (!projected.success) { + drop(projected.reason); + return; + } + if (entries.length >= maxEntries) { + entries.shift(); + drop("queue-full"); + } + entries.push(projected.record); + try { + options.sink?.(projected.record); + } catch { + drop("sink-failure"); + } + } catch { + drop("serialization-failure"); + } + } + + return Object.freeze({ + record, + entries: () => structuredClone(entries) as readonly DiagnosticRecord[], + dropped: () => Object.freeze(Object.fromEntries(droppedReasons)), + }); +} + +type BootSafeContext = Readonly<{ + kind?: string; + buildId?: string; + configSchemaVersion?: string; + supportReference?: string; +}>; + +let lastBootEvidence: + | Readonly<{ + diagnostic: DiagnosticRecord | null; + telemetry: Readonly> | null; + }> + | undefined; + +export function recordBootFailure( + error: unknown, + safe: BootSafeContext, + now: () => number = Date.now, +) { + const errorKind = + typeof safe.kind === "string" ? safe.kind : safeErrorKind(error); + const attributes = { + error_kind: errorKind, + build_id: safe.buildId ?? "unknown", + config_schema_version: safe.configSchemaVersion ?? "unknown", + }; + const diagnostic = projectDiagnosticRecord( + { + level: "error", + eventId: "app.boot.failed", + context: attributes, + }, + now, + ); + const telemetry = projectTelemetryEvent("app.boot.failed", attributes, now); + lastBootEvidence = Object.freeze({ + diagnostic: diagnostic.success ? diagnostic.record : null, + telemetry: telemetry.success ? telemetry.event : null, + }); + return lastBootEvidence; +} + +export function getLastBootEvidence() { + return lastBootEvidence ? structuredClone(lastBootEvidence) : undefined; +} diff --git a/src/adapters/http/bounded-body-reader.ts b/src/adapters/http/bounded-body-reader.ts new file mode 100644 index 0000000..90e5680 --- /dev/null +++ b/src/adapters/http/bounded-body-reader.ts @@ -0,0 +1,160 @@ +/** + * §7.9–§7.10. Common bounded body reader. + * + * `bounded-json.ts` stays the V2 reader for already-migrated callers; this + * module adds the byte-level primitives execution V3 needs: a raw bounded read, + * a strict media-type check and the `NONE` one-byte probe. + */ + +export type BoundedBytesOutcome = + | Readonly<{ ok: true; bytes: Uint8Array }> + | Readonly<{ ok: false; code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE" }>; + +export type BodyProbeOutcome = + | Readonly<{ ok: true; present: boolean }> + | Readonly<{ ok: false; code: "RESPONSE_STREAM_FAILURE" }>; + +/** Essence match: `application/json` or any `*+json` subtype. */ +export function isJsonMediaType(headerValue: string | null): boolean { + if (!headerValue) return false; + const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return essence === "application/json" || essence.endsWith("+json"); +} + +export function declaredContentLength(response: Response): number | null { + const raw = response.headers.get("content-length"); + if (raw === null) return null; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? value : null; +} + +export async function readBoundedBytes( + response: Response, + maximumBytes: number, +): Promise { + const declared = declaredContentLength(response); + if (declared !== null && declared > maximumBytes) { + await cancelBody(response); + return failure("RESPONSE_TOO_LARGE"); + } + if (!response.body) { + return Object.freeze({ ok: true as const, bytes: new Uint8Array(0) }); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + if (!next.value) continue; + total += next.value.byteLength; + if (total > maximumBytes) { + await reader.cancel().catch(() => {}); + return failure("RESPONSE_TOO_LARGE"); + } + chunks.push(next.value); + } + } catch { + await reader.cancel().catch(() => {}); + return failure("RESPONSE_STREAM_FAILURE"); + } finally { + try { + reader.releaseLock(); + } catch { + // A cancelled reader has already released its lock. + } + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return Object.freeze({ ok: true as const, bytes }); +} + +/** + * §7.10 `NONE`. A declared positive length is an immediate violation. Otherwise + * at most one byte is probed: the descriptor does not permit an unexpected + * body, so the runtime never drains an arbitrary amount to find out. + */ +export async function probeForbiddenBody( + response: Response, +): Promise { + const declared = declaredContentLength(response); + if (declared !== null && declared > 0) { + await cancelBody(response); + return Object.freeze({ ok: true as const, present: true }); + } + if (!response.body) { + return Object.freeze({ ok: true as const, present: false }); + } + + const reader = response.body.getReader(); + try { + const next = await reader.read(); + if (next.done || !next.value || next.value.byteLength === 0) { + return Object.freeze({ ok: true as const, present: false }); + } + await reader.cancel().catch(() => {}); + return Object.freeze({ ok: true as const, present: true }); + } catch { + await reader.cancel().catch(() => {}); + return Object.freeze({ + ok: false as const, + code: "RESPONSE_STREAM_FAILURE" as const, + }); + } finally { + try { + reader.releaseLock(); + } catch { + // Already released by cancel(). + } + } +} + +export type DecodedJson = + | Readonly<{ ok: true; value: unknown }> + | Readonly<{ ok: false; code: "UTF8_INVALID" | "JSON_INVALID" }>; + +export function decodeJsonBytes(bytes: Uint8Array): DecodedJson { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return Object.freeze({ ok: false as const, code: "UTF8_INVALID" as const }); + } + try { + return Object.freeze({ ok: true as const, value: JSON.parse(text) }); + } catch { + return Object.freeze({ ok: false as const, code: "JSON_INVALID" as const }); + } +} + +export function isEffectivelyEmpty(bytes: Uint8Array): boolean { + if (bytes.byteLength === 0) return true; + for (const byte of bytes) { + // Space, tab, LF, CR are the only permitted "empty" filler. + if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) { + return false; + } + } + return true; +} + +async function cancelBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Cancelling an already-settled body is not itself a failure. + } +} + +function failure( + code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE", +): BoundedBytesOutcome { + return Object.freeze({ ok: false as const, code }); +} diff --git a/src/adapters/http/bounded-json.ts b/src/adapters/http/bounded-json.ts new file mode 100644 index 0000000..2d69da9 --- /dev/null +++ b/src/adapters/http/bounded-json.ts @@ -0,0 +1,50 @@ +export type BoundedJsonResult = + | Readonly<{ ok: true; value: unknown }> + | Readonly<{ ok: false; code: "RESPONSE_BODY_LIMIT" | "MALFORMED_JSON" }>; + +export async function readBoundedJson( + response: Response, + maxBytes: number, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel(); + return { ok: false, code: "RESPONSE_BODY_LIMIT" }; + } + if (!response.body) return { ok: false, code: "MALFORMED_JSON" }; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return { ok: false, code: "RESPONSE_BODY_LIMIT" }; + } + chunks.push(next.value); + } + } catch { + return { ok: false, code: "MALFORMED_JSON" }; + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return { + ok: true, + value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), + }; + } catch { + return { ok: false, code: "MALFORMED_JSON" }; + } +} diff --git a/src/adapters/http/client.ts b/src/adapters/http/client.ts new file mode 100644 index 0000000..23f60bf --- /dev/null +++ b/src/adapters/http/client.ts @@ -0,0 +1,944 @@ +import { systemClock } from "../platform/system-clock.ts"; +import { getApiOperation } from "../../contracts/api-operations.ts"; +import { + createFailure as failure, + kindForStatus as statusKind, + safeValidationIssues, +} from "../../contracts/errors.ts"; +import { mapOperationPayload } from "./resource-mapper.ts"; +import { retryDelay, shouldRetry } from "./retry-policy.ts"; +import { + validateEnvelope, + validateOperationPayload, + validateOperationRequest, +} from "./schema-registry.ts"; +import { buildRequestTarget } from "./request-builder.ts"; +import { + attemptBucket, + durationBucket, + statusGroup, +} from "../../contracts/diagnostics.ts"; +import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts"; +import type { ClockPort } from "../../application/ports/clock-port.ts"; +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; +import type { TelemetryPort } from "../../application/ports/telemetry-port.ts"; +import type { ApiOperation } from "../../contracts/api-operations.ts"; +import type { ApiFailure } from "../../contracts/errors.ts"; +import type { OperationRequestInput } from "./request-builder.ts"; +import { readBoundedJson } from "./bounded-json.ts"; +import type { MappingResult } from "../../contracts/boundary-mapper.ts"; +import { + createRestProviderProfile, + resolveRestSecurityProfiles, + REST_AUTH_PROFILES, + REST_CSRF_PROFILES, + type RestAuthProfile, + type RestCsrfProfile, + type RestProviderProfile, +} from "../../contracts/rest-profiles.ts"; + +type HttpAuthSession = Pick< + AuthSessionPort, + "getState" | "credentialPatch" | "recover" | "onUnauthenticated" +>; + +const noAuthSession: HttpAuthSession = Object.freeze({ + getState: () => "integration-failed", + credentialPatch: async () => { + throw new TypeError("Auth session is not installed"); + }, + recover: async () => "no-session", + onUnauthenticated: () => {}, +} satisfies HttpAuthSession); + +export type HttpFailure = ApiFailure; + +export type Scheduler = Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +export type HttpResult = + | Readonly<{ + ok: true; + value: unknown; + meta: Readonly>; + }> + | Readonly<{ ok: false; error: HttpFailure }>; + +type SchemaValidator = ( + schemaId: string, + value: unknown, +) => + | Readonly<{ success: true; data: unknown }> + | Readonly<{ success: false }>; + +export type HttpClientDependencies = Readonly<{ + baseUrl: string; + fetcher?: typeof fetch; + authSession?: AuthSessionPort; + clock?: ClockPort; + random?: () => number; + validatePayload?: SchemaValidator; + validateRequest?: SchemaValidator; + validatePath?: SchemaValidator; + mapPayload?: ( + operationId: string, + payload: unknown, + ) => MappingResult; + idempotencyKeyFactory?: () => string; + timeoutMs?: number; + maxRetryAttempts?: number; + scheduler?: Scheduler; + getOperation?: typeof getApiOperation; + diagnostics?: DiagnosticsPort; + telemetry?: TelemetryPort; + correlationIdFactory?: () => string; + providerProfile?: RestProviderProfile; + authProfiles?: Readonly>; + csrfProfiles?: Readonly>; + maxCumulativeSleepMs?: number; +}>; + +export type LegacyHttpInput = Readonly<{ + body?: unknown; + routeId?: string; + pathParams?: Readonly>; + searchParams?: unknown; + signal?: AbortSignal; + idempotencyKey?: string; + correlationId?: string; +}>; + +export type HttpClient = Readonly<{ + execute( + request: string | OperationRequestInput, + legacyInput?: LegacyHttpInput, + ): Promise; +}>; + +export function createHttpClient( + dependencies: HttpClientDependencies, +): HttpClient { + const fetcher = dependencies.fetcher ?? fetch; + const authSession = dependencies.authSession ?? noAuthSession; + const clock = dependencies.clock ?? systemClock; + const random = dependencies.random ?? Math.random; + const validatePayload = + dependencies.validatePayload ?? validateOperationPayload; + const validateRequest = + dependencies.validateRequest ?? validateOperationRequest; + const validatePath = dependencies.validatePath ?? validateRequest; + const mapPayload = dependencies.mapPayload ?? mapOperationPayload; + const idempotencyKeyFactory = + dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); + const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000; + const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2; + const maxCumulativeSleepMs = + dependencies.maxCumulativeSleepMs ?? defaultTimeoutMs; + const selectOperation = dependencies.getOperation ?? getApiOperation; + const diagnostics = dependencies.diagnostics; + const telemetry = dependencies.telemetry; + const correlationIdFactory = + dependencies.correlationIdFactory ?? + (() => `request-${Math.floor(random() * 1_000_000).toString(36)}`); + const scheduler = + dependencies.scheduler ?? + ({ + setTimeout: (callback, milliseconds) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle) => + globalThis.clearTimeout( + handle as ReturnType, + ), + } satisfies Scheduler); + + async function execute( + request: string | OperationRequestInput, + legacyInput: LegacyHttpInput = {}, + ): Promise { + const input = + typeof request === "string" + ? { + operationId: request, + routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE", + pathParams: legacyInput.pathParams, + searchParams: legacyInput.searchParams, + body: legacyInput.body, + signal: legacyInput.signal, + idempotencyKey: legacyInput.idempotencyKey, + correlationId: legacyInput.correlationId, + } + : request; + const startedAt = clock.now(); + let correlationId: string; + try { + correlationId = correlationIdValue( + input.correlationId ?? correlationIdFactory(), + ); + } catch { + correlationId = "client-generated"; + } + let operation: ApiOperation; + try { + operation = selectOperation(input.operationId); + } catch { + return { + ok: false, + error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, { + code: "OPERATION_NOT_REGISTERED", + }), + }; + } + const totalDeadlineMs = operation.timeoutMs ?? defaultTimeoutMs; + const deadlineAt = startedAt + totalDeadlineMs; + let physicalAttemptCount = 0; + function finalize( + outcome: HttpResult, + outcomeKind: "success" | "recovered" | "failed" | "aborted", + ): HttpResult { + const error = outcome.ok ? undefined : outcome.error; + const context = { + route_id: input.routeId, + operation_id: input.operationId, + correlation_id: correlationId, + outcome: outcomeKind, + error_kind: error?.kind ?? "NONE", + http_status_group: statusGroup( + error?.httpStatus ?? + (outcome.ok ? Number(outcome.meta.httpStatus) : undefined), + ), + attempt_count_bucket: attemptBucket( + error?.attemptCount ?? Math.max(1, physicalAttemptCount), + ), + duration_bucket: durationBucket(clock.now() - startedAt), + }; + try { + diagnostics?.record({ + level: error ? "warn" : "info", + eventId: "http.request.completed", + context, + }); + } catch { + // Diagnostics cannot change the HTTP result. + } + if (error && outcomeKind !== "aborted") { + try { + telemetry?.emit("api.request.failed", { + error_kind: context.error_kind, + http_status_group: context.http_status_group, + attempt_count_bucket: context.attempt_count_bucket, + route_id: context.route_id, + operation_id: context.operation_id, + duration_bucket: context.duration_bucket, + }); + } catch { + // Telemetry cannot change the HTTP result. + } + } + return outcome; + } + let logicalIdempotencyKey: string | undefined; + try { + logicalIdempotencyKey = + operation.idempotency === "keyed" + ? input.idempotencyKey ?? idempotencyKeyFactory() + : undefined; + } catch { + return finalize( + { + ok: false, + error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, { + code: "IDEMPOTENCY_KEY_CREATION_FAILED", + }), + }, + "failed", + ); + } + let retryCount = 0; + let recoveryUsed = false; + let cumulativeSleepMs = 0; + + while (true) { + const attempt = physicalAttemptCount; + physicalAttemptCount += 1; + let outcome: HttpResult; + try { + outcome = await performAttempt({ + operation, + input, + attempt, + idempotencyKey: logicalIdempotencyKey, + deadlineAt, + correlationId, + }); + } catch { + outcome = { + ok: false, + error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, attempt, { + code: "HTTP_EXECUTION_CONTRACT_VIOLATION", + }), + }; + } + + if (outcome.ok) { + return finalize( + outcome, + retryCount > 0 || recoveryUsed ? "recovered" : "success", + ); + } + + if (outcome.error.httpStatus === 401 && !recoveryUsed) { + recoveryUsed = true; + if (physicalAttemptCount >= maxRetryAttempts + 1) { + authSession.onUnauthenticated(); + return finalize(outcome, "failed"); + } + let recovered: Awaited>; + try { + recovered = await withinLogicalDeadline( + recoverSession(authSession, operation, outcome.error), + deadlineAt, + input.signal, + ); + } catch (error) { + return finalize( + { + ok: false, + error: failure( + error instanceof LogicalDeadlineError + ? "REQUEST_TIMEOUT" + : "REQUEST_ABORTED", + operation.operationId, + attempt, + { + code: + error instanceof LogicalDeadlineError + ? "OPERATION_DEADLINE_EXCEEDED" + : "REQUEST_ABORTED", + }, + ), + }, + error instanceof LogicalDeadlineError ? "failed" : "aborted", + ); + } + if (!recovered.ok) return finalize(recovered, "failed"); + if (operation.idempotency === "none") { + return finalize( + { + ok: false, + error: { + ...outcome.error, + retryable: false, + action: "retry", + }, + }, + "failed", + ); + } + continue; + } + + if (outcome.error.httpStatus === 401 && recoveryUsed) { + authSession.onUnauthenticated(); + return finalize(outcome, "failed"); + } + + if ( + !shouldRetry( + operation, + outcome.error, + retryCount, + maxRetryAttempts, + ) + ) { + return finalize( + outcome, + outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed", + ); + } + + const delay = retryDelay(outcome.error, retryCount, random, clock.now()); + retryCount += 1; + cumulativeSleepMs += delay; + if ( + clock.now() + delay >= deadlineAt || + cumulativeSleepMs > maxCumulativeSleepMs + ) { + return finalize( + { + ok: false, + error: failure("REQUEST_TIMEOUT", input.operationId, attempt, { + code: "OPERATION_DEADLINE_EXCEEDED", + }), + }, + "failed", + ); + } + + try { + await clock.sleep(delay, input.signal); + } catch { + return finalize( + { + ok: false, + error: failure("REQUEST_ABORTED", input.operationId, retryCount, { + code: "REQUEST_ABORTED", + }), + }, + "aborted", + ); + } + } + } + + async function performAttempt( + context: Readonly<{ + operation: ApiOperation; + input: OperationRequestInput; + attempt: number; + idempotencyKey?: string; + deadlineAt: number; + correlationId: string; + }>, + ): Promise { + const { + operation, + input, + attempt, + idempotencyKey, + deadlineAt, + correlationId, + } = context; + if (clock.now() >= deadlineAt) { + return { + ok: false, + error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, { + code: "OPERATION_DEADLINE_EXCEEDED", + }), + }; + } + let parsedSearch: unknown = {}; + let parsedBody: unknown; + let parsedPath: Readonly> = + input.pathParams ?? {}; + if (operation.pathSchema) { + const pathValidation = validatePath( + operation.pathSchema, + input.pathParams ?? {}, + ); + if ( + !pathValidation.success || + !isPathParameterRecord(pathValidation.data) + ) { + return { + ok: false, + error: failure( + "VALIDATION_REJECTED", + operation.operationId, + attempt, + { code: "PATH_SCHEMA_INVALID" }, + ), + }; + } + parsedPath = pathValidation.data; + } + const requestValue = + operation.requestSource === "search" + ? input.searchParams ?? {} + : operation.requestSource === "body" + ? input.body + : {}; + if (operation.requestSource !== "none") { + const requestValidation = validateRequest( + operation.requestSchema, + requestValue, + ); + if (!requestValidation.success) { + return { + ok: false, + error: failure("VALIDATION_REJECTED", operation.operationId, attempt, { + code: "REQUEST_SCHEMA_INVALID", + }), + }; + } + if (operation.requestSource === "search") { + parsedSearch = requestValidation.data; + } else { + parsedBody = requestValidation.data; + } + } + + let target: ReturnType; + let provider: RestProviderProfile | null = null; + let security: + | ReturnType + | undefined; + try { + provider = + dependencies.providerProfile ?? + createRestProviderProfile( + operation.providerId ?? "LEGACY_API", + dependencies.baseUrl, + ["omit", "same-origin"], + ); + if ( + operation.contractVersion === 2 && + operation.providerId !== provider.providerId + ) { + throw new TypeError("REST provider binding mismatch."); + } + if (operation.contractVersion === 2) { + security = resolveRestSecurityProfiles( + operation, + provider, + dependencies.authProfiles ?? REST_AUTH_PROFILES, + dependencies.csrfProfiles ?? REST_CSRF_PROFILES, + ); + } + target = buildRequestTarget( + provider.baseUrl, + operation, + parsedPath, + parsedSearch, + ); + } catch { + target = { success: false, code: "BASE_URL_INVALID" }; + } + if (!target.success || !provider) { + return { + ok: false, + error: failure("VALIDATION_REJECTED", operation.operationId, attempt, { + code: target.success ? "BASE_URL_INVALID" : target.code, + }), + }; + } + + const controller = new AbortController(); + let timedOut = false; + const remainingMs = Math.max(1, deadlineAt - clock.now()); + const timeout = scheduler.setTimeout(() => { + timedOut = true; + controller.abort("timeout"); + }, remainingMs); + const onExternalAbort = () => controller.abort(input.signal?.reason); + input.signal?.addEventListener("abort", onExternalAbort, { once: true }); + if (input.signal?.aborted) onExternalAbort(); + + try { + const headers = new Headers({ + Accept: operation.responseMediaTypes?.join(", ") ?? "application/json", + "X-Correlation-ID": correlationIdValue(correlationId), + }); + if (parsedBody !== undefined) headers.set("Content-Type", "application/json"); + if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); + + if (operation.auth === "external-session") { + let sessionState: ReturnType; + try { + sessionState = authSession.getState(); + } catch { + return { + ok: false, + error: failure( + "AUTH_INTEGRATION_FAILURE", + operation.operationId, + attempt, + { code: "AUTH_STATE_FAILED" }, + ), + }; + } + if (sessionState === "unauthenticated") { + return { + ok: false, + error: failure("AUTH_REQUIRED", operation.operationId, attempt, { + code: "AUTH_REQUIRED", + }), + }; + } + if (sessionState !== "authenticated") { + return { + ok: false, + error: failure( + "AUTH_INTEGRATION_FAILURE", + operation.operationId, + attempt, + { code: "AUTH_SESSION_UNAVAILABLE" }, + ), + }; + } + try { + const patch = await authSession.credentialPatch({ + origin: target.url.origin, + method: operation.method, + operationId: operation.operationId, + }); + for (const [name, value] of Object.entries(patch.headers)) { + const normalized = name.toLowerCase(); + const allowedHeaders = + security?.auth.allowedCredentialHeaders ?? + (["authorization", "x-csrf-token"] as const); + if (!allowedHeaders.includes(normalized as never)) { + throw new TypeError("Credential patch contains a forbidden header"); + } + headers.set(normalized, value); + } + } catch { + return { + ok: false, + error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, { + code: "AUTH_ATTACH_FAILED", + }), + }; + } + } + + const request = new Request(target.url, { + method: operation.method, + headers, + body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody), + signal: controller.signal, + credentials: security?.auth.credentials ?? "same-origin", + cache: "no-store", + redirect: provider.redirect, + referrerPolicy: provider.referrerPolicy, + }); + const response = await fetcher(request); + return await parseResponse( + response, + operation, + attempt, + validatePayload, + mapPayload, + clock.now(), + ); + } catch { + if (timedOut) { + return { + ok: false, + error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, { + code: "REQUEST_TIMEOUT", + }), + }; + } + if (controller.signal.aborted || input.signal?.aborted) { + const externalReason = input.signal?.reason; + if (externalReason === "timeout") { + return { + ok: false, + error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, { + code: "REQUEST_TIMEOUT", + }), + }; + } + if ( + externalReason !== undefined && + !["navigation", "user", "superseded"].includes(String(externalReason)) + ) { + return { + ok: false, + error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, { + code: "EXTERNAL_ABORT_UNRESOLVED", + }), + }; + } + return { + ok: false, + error: failure("REQUEST_ABORTED", operation.operationId, attempt, { + code: "REQUEST_ABORTED", + }), + }; + } + return { + ok: false, + error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, { + code: "NETWORK_UNREACHABLE", + }), + }; + } finally { + scheduler.clearTimeout(timeout); + input.signal?.removeEventListener("abort", onExternalAbort); + } + } + + return Object.freeze({ execute }); + + function withinLogicalDeadline( + promise: Promise, + deadlineAt: number, + externalSignal: AbortSignal | undefined, + ): Promise { + const remaining = deadlineAt - clock.now(); + if (remaining <= 0) return Promise.reject(new LogicalDeadlineError()); + return new Promise((resolve, reject) => { + let settled = false; + const timeout = scheduler.setTimeout( + () => settle(() => reject(new LogicalDeadlineError())), + remaining, + ); + const onAbort = () => + settle(() => reject(new DOMException("Aborted", "AbortError"))); + externalSignal?.addEventListener("abort", onAbort, { once: true }); + const settle = (complete: () => void) => { + if (settled) return; + settled = true; + scheduler.clearTimeout(timeout); + externalSignal?.removeEventListener("abort", onAbort); + complete(); + }; + if (externalSignal?.aborted) { + onAbort(); + return; + } + promise.then( + (value) => settle(() => resolve(value)), + (error: unknown) => settle(() => reject(error)), + ); + }); + } +} + +class LogicalDeadlineError extends Error {} + +async function parseResponse( + response: Response, + operation: ApiOperation, + attempt: number, + validatePayload: SchemaValidator, + mapPayload: ( + operationId: string, + payload: unknown, + ) => MappingResult, + now: number, +): Promise { + const contentType = mediaType(response.headers.get("content-type")); + const acceptedMedia = operation.responseMediaTypes ?? ["application/json"]; + if (!contentType || !acceptedMedia.includes(contentType)) { + return { + ok: false, + error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, { + code: "CONTENT_TYPE_MISMATCH", + httpStatus: response.status, + }), + }; + } + + const decoded = await readBoundedJson( + response, + operation.maxResponseBytes ?? 1_048_576, + ); + if (!decoded.ok) { + return { + ok: false, + error: failure(decoded.code, operation.operationId, attempt, { + code: decoded.code, + httpStatus: response.status, + }), + }; + } + const envelope = decoded.value; + + const envelopeValidation = validateEnvelope(envelope); + if (!envelopeValidation.success) { + return { + ok: false, + error: failure( + response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status), + operation.operationId, + attempt, + { + code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE", + httpStatus: response.status, + }, + ), + }; + } + + const envelopeRecord = envelopeValidation.data as Record; + const successStatus = operation.successStatuses + ? operation.successStatuses.includes(response.status) + : response.ok; + if (successStatus && envelopeRecord.success === true && "data" in envelopeRecord) { + const payload = validatePayload(operation.responseSchema, envelopeRecord.data); + if (!payload.success) { + return { + ok: false, + error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, { + code: "SCHEMA_MISMATCH", + httpStatus: response.status, + }), + }; + } + + try { + const mapped = mapPayload(operation.operationId, payload.data); + if (!mapped.ok) { + return { + ok: false, + error: failure( + "MAPPING_CONTRACT_VIOLATION", + operation.operationId, + attempt, + { + code: mapped.code, + httpStatus: response.status, + }, + ), + }; + } + return { + ok: true, + value: mapped.value, + meta: { + ...safeMeta(envelopeRecord.meta), + httpStatus: String(response.status), + }, + }; + } catch { + return { + ok: false, + error: failure( + "MAPPING_CONTRACT_VIOLATION", + operation.operationId, + attempt, + { + code: "MAPPING_CONTRACT_VIOLATION", + httpStatus: response.status, + }, + ), + }; + } + } + if (successStatus !== response.ok || (successStatus && envelopeRecord.success !== true)) { + return { + ok: false, + error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, { + code: "STATUS_ENVELOPE_MISMATCH", + httpStatus: response.status, + }), + }; + } + + const kind = statusKind(response.status); + const retryAfter = response.headers.get("retry-after"); + const backendError = + envelopeRecord.error && typeof envelopeRecord.error === "object" + ? (envelopeRecord.error as Record) + : {}; + return { + ok: false, + error: failure(kind, operation.operationId, attempt, { + code: safeBackendCode(envelope), + httpStatus: response.status, + requestId: safeMeta(envelopeRecord.meta).requestId, + traceId: safeMeta(envelopeRecord.meta).traceId, + retryAfterMs: + response.status === 429 && retryAfter + ? parseRetryAfterHeader(retryAfter, now) + : undefined, + validationIssues: + response.status === 422 + ? safeValidationIssues(backendError.details) + : undefined, + }), + }; +} + +async function recoverSession( + authSession: HttpAuthSession, + operation: ApiOperation, + originalFailure: HttpFailure, +): Promise< + Readonly<{ ok: true }> | Readonly<{ ok: false; error: HttpFailure }> +> { + try { + const result = await authSession.recover(); + if (result === "restored") return { ok: true }; + if (result === "no-session") { + authSession.onUnauthenticated(); + return { + ok: false, + error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount - 1, { + code: "AUTH_REQUIRED", + httpStatus: 401, + }), + }; + } + } catch { + // Normalized below. + } + + return { + ok: false, + error: failure( + "AUTH_INTEGRATION_FAILURE", + operation.operationId, + originalFailure.attemptCount - 1, + { code: "AUTH_RECOVERY_FAILED" }, + ), + }; +} + +function safeBackendCode(envelope: unknown): string { + if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE"; + const error = (envelope as Record).error; + if (!error || typeof error !== "object") return "HTTP_FAILURE"; + const code = (error as Record).code; + return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) + ? code + : "HTTP_FAILURE"; +} + +function safeMeta(meta: unknown): Record { + if (!meta || typeof meta !== "object") return {}; + const metaRecord = meta as Record; + return { + ...(typeof metaRecord.requestId === "string" + ? safeIdentifier(metaRecord.requestId, "requestId") + : {}), + ...(typeof metaRecord.traceId === "string" + ? safeIdentifier(metaRecord.traceId, "traceId") + : {}), + }; +} + +function parseRetryAfterHeader(value: string, now: number): number | undefined { + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined; +} + +function safeIdentifier( + value: string, + property: "requestId" | "traceId", +): Record { + return /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? { [property]: value } : {}; +} + +function mediaType(value: string | null): string | null { + if (!value) return null; + const selected = value.split(";", 1)[0]?.trim().toLowerCase(); + return selected && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(selected) + ? selected + : null; +} + +function correlationIdValue(value: string | undefined): string { + return value && /^[A-Za-z0-9._:-]{1,128}$/.test(value) + ? value + : "client-generated"; +} + +function isPathParameterRecord( + value: unknown, +): value is Readonly> { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.values(value).every( + (item) => typeof item === "string" || typeof item === "number", + ) + ); +} diff --git a/src/adapters/http/http-contract-bridge.ts b/src/adapters/http/http-contract-bridge.ts new file mode 100644 index 0000000..eadede6 --- /dev/null +++ b/src/adapters/http/http-contract-bridge.ts @@ -0,0 +1,288 @@ +import { + HTTP_EXECUTION_CEILINGS, + type InstalledHttpContract, +} from "../../contracts/external-contract-runtime.ts"; + +/** + * §7.4–§7.7. Descriptor-driven request projection. + * + * Nothing here re-derives operation semantics. The package descriptor supplies + * path values, query entry order and the body value; this module only encodes, + * bounds and re-verifies them. + */ + +export type CredentialPatchOutcome = + | Readonly<{ + kind: "READY"; + headers: Readonly>; + credentials: RequestCredentials; + }> + | Readonly<{ kind: "UNAUTHENTICATED" }> + | Readonly<{ kind: "UNAVAILABLE" }> + | Readonly<{ kind: "SCOPE_FENCED" }>; + +/** §7.7. The complete set of headers a credential bridge may contribute. */ +export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet = new Set([ + "authorization", + "x-csrf-token", + "x-tenant-context", +]); + +const FORBIDDEN_REQUEST_HEADERS: ReadonlySet = new Set([ + "host", + "origin", + "referer", + "cookie", + "content-length", + "connection", + "transfer-encoding", + "upgrade", +]); + +export type RequestProjectionFailure = + | "PROJECTION_RUNTIME_FAILURE" + | "PROJECTION_INVALID" + | "PATH_PLACEHOLDER_MISSING" + | "PATH_VALUE_INVALID" + | "QUERY_TOO_LARGE" + | "URL_TOO_LARGE" + | "URL_ORIGIN_ESCAPED" + | "REQUEST_BODY_TOO_LARGE" + | "REQUEST_BODY_UNENCODABLE" + | "REQUEST_BODY_UNEXPECTED"; + +export type ProjectedRequest = Readonly<{ + url: string; + method: string; + bodyBytes: Uint8Array | null; +}>; + +export type RequestProjectionOutcome = + | Readonly<{ ok: true; request: ProjectedRequest }> + | Readonly<{ ok: false; failure: RequestProjectionFailure }>; + +const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9_]*)\}|:([A-Za-z][A-Za-z0-9_]*)/g; + +const encoder = new TextEncoder(); + +/** + * §7.5. Built from the validated base URL and the descriptor's named values. + * Segments are encoded exactly once; a raw slash inside a value is rejected + * rather than silently creating a new path segment. + */ +export function projectRequest( + installed: InstalledHttpContract, + input: Input, + baseUrl: string, +): RequestProjectionOutcome { + try { + return projectRequestChecked(installed, input, baseUrl); + } catch { + return failure("PROJECTION_RUNTIME_FAILURE"); + } +} + +function projectRequestChecked( + installed: InstalledHttpContract, + input: Input, + baseUrl: string, +): RequestProjectionOutcome { + const contract = installed.contract; + const projection = contract.projectRequest(input); + if (!isValidProjection(projection)) return failure("PROJECTION_INVALID"); + + let missing = false; + let invalid = false; + const path = contract.pathTemplate.replace( + PLACEHOLDER, + (_match, braced?: string, colon?: string) => { + const name = braced ?? colon ?? ""; + const value = projection.pathValues[name]; + if (typeof value !== "string" || value.length === 0) { + missing = true; + return ""; + } + if (value.includes("/") || value.includes("\\")) { + invalid = true; + return ""; + } + return encodeURIComponent(value); + }, + ); + if (missing) return failure("PATH_PLACEHOLDER_MISSING"); + if (invalid) return failure("PATH_VALUE_INVALID"); + + const base = new URL(baseUrl); + const url = new URL(path.replace(/^\/+/, ""), base); + if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) { + return failure("URL_ORIGIN_ESCAPED"); + } + + const search = new URLSearchParams(); + for (const [key, value] of projection.queryEntries) { + search.append(key, value); + } + const encodedQuery = search.toString(); + if ( + encoder.encode(encodedQuery).byteLength > + HTTP_EXECUTION_CEILINGS.encodedQueryBytes + ) { + return failure("QUERY_TOO_LARGE"); + } + url.search = encodedQuery; + + if (encoder.encode(url.href).byteLength > HTTP_EXECUTION_CEILINGS.finalUrlBytes) { + return failure("URL_TOO_LARGE"); + } + + let bodyBytes: Uint8Array | null = null; + if (contract.requestBody === "JSON") { + let encoded: string; + try { + encoded = JSON.stringify(projection.body); + } catch { + return failure("REQUEST_BODY_UNENCODABLE"); + } + if (typeof encoded !== "string") { + return failure("REQUEST_BODY_UNENCODABLE"); + } + bodyBytes = encoder.encode(encoded); + if (bodyBytes.byteLength > installed.frontend.requestByteLimit) { + return failure("REQUEST_BODY_TOO_LARGE"); + } + } else if (projection.body !== null && projection.body !== undefined) { + return failure("REQUEST_BODY_UNEXPECTED"); + } + + return Object.freeze({ + ok: true as const, + request: Object.freeze({ + url: url.href, + method: contract.method, + bodyBytes, + }), + }); +} + +function isValidProjection( + value: unknown, +): value is ReturnType< + InstalledHttpContract["contract"]["projectRequest"] +> { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + const keys = Object.keys(candidate).sort(); + if (keys.join("|") !== "body|pathValues|queryEntries") return false; + + const pathValues = candidate.pathValues; + if (!pathValues || typeof pathValues !== "object" || Array.isArray(pathValues)) { + return false; + } + const pathPrototype = Object.getPrototypeOf(pathValues); + if (pathPrototype !== Object.prototype && pathPrototype !== null) return false; + const pathEntries = Object.entries(pathValues as Record); + if (pathEntries.length > 32) return false; + for (const [key, pathValue] of pathEntries) { + if ( + !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) || + typeof pathValue !== "string" || + pathValue.length === 0 || + encoder.encode(pathValue).byteLength > HTTP_EXECUTION_CEILINGS.pathTemplateBytes + ) { + return false; + } + } + + const queryEntries = candidate.queryEntries; + if (!Array.isArray(queryEntries) || queryEntries.length > 256) return false; + for (const entry of queryEntries) { + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== "string" || + typeof entry[1] !== "string" + ) { + return false; + } + } + return true; +} + +export type FinalInvariantInput = Readonly<{ + request: ProjectedRequest; + expectedMethod: string; + baseUrl: string; + init: RequestInit; + headers: Readonly>; + requestByteLimit: number; + deadlineRemainingMs: number; + scopeIsCurrent: boolean; +}>; + +export type FinalInvariantFailure = + | "METHOD_CHANGED" + | "URL_NOT_ALLOWED" + | "REDIRECT_MODE_INVALID" + | "CREDENTIALS_MODE_INVALID" + | "HEADER_NOT_ALLOWED" + | "FORBIDDEN_HEADER" + | "REQUEST_BODY_TOO_LARGE" + | "DEADLINE_EXPIRED" + | "SCOPE_FENCED"; + +/** + * §7.4. Runs after the credential patch and immediately before dispatch. A + * failure here means `fetch()` is called zero times, so a defective auth + * adapter can never alter the method, target or transport policy. + */ +export function checkFinalInvariants( + input: FinalInvariantInput, +): FinalInvariantFailure | null { + if (input.request.method !== input.expectedMethod) return "METHOD_CHANGED"; + + const base = new URL(input.baseUrl); + let target: URL; + try { + target = new URL(input.request.url); + } catch { + return "URL_NOT_ALLOWED"; + } + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname)) { + return "URL_NOT_ALLOWED"; + } + if (input.init.redirect !== "error") return "REDIRECT_MODE_INVALID"; + if ( + input.init.credentials !== "omit" && + input.init.credentials !== "same-origin" && + input.init.credentials !== "include" + ) { + return "CREDENTIALS_MODE_INVALID"; + } + + for (const name of Object.keys(input.headers)) { + const lower = name.toLowerCase(); + if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER"; + if ( + lower !== "accept" && + lower !== "content-type" && + lower !== "idempotency-key" && + !ALLOWED_CREDENTIAL_HEADERS.has(lower) + ) { + return "HEADER_NOT_ALLOWED"; + } + } + + if ( + input.request.bodyBytes && + input.request.bodyBytes.byteLength > input.requestByteLimit + ) { + return "REQUEST_BODY_TOO_LARGE"; + } + if (input.deadlineRemainingMs <= 0) return "DEADLINE_EXPIRED"; + if (!input.scopeIsCurrent) return "SCOPE_FENCED"; + return null; +} + +function failure(failureKind: RequestProjectionFailure): RequestProjectionOutcome { + return Object.freeze({ ok: false as const, failure: failureKind }); +} diff --git a/src/adapters/http/http-effect-certainty.ts b/src/adapters/http/http-effect-certainty.ts new file mode 100644 index 0000000..824c3a3 --- /dev/null +++ b/src/adapters/http/http-effect-certainty.ts @@ -0,0 +1,119 @@ +import type { + CommandEffectDescriptor, + CommandEffectClassification, +} from "../../contracts/external-contract-runtime.ts"; + +/** + * §8.7–§8.9. Mutation effect certainty. + * + * The frontend never infers "not applied" from an HTTP status alone. Anything + * observed after the request was dispatched but before a classified terminal + * response is `MAYBE_APPLIED`, which forbids automatic resend. + */ + +export type MutationEffectCertainty = + | "NOT_STARTED" + | "NOT_APPLIED" + | "MAYBE_APPLIED" + | "APPLIED_CONFIRMED"; + +export type PhysicalAttemptState = + | "NOT_STARTED" + | "PREPARING" + | "READY_TO_SEND" + | "DISPATCHED" + | "RESPONSE_HEADERS" + | "READING_BODY" + | "VALIDATING" + | "MAPPING_READY" + | "SETTLED"; + +/** + * `READY_TO_SEND` is recorded immediately before entering the `fetch()` + * invocation expression and `DISPATCHED` immediately after the promise is + * returned. A synchronous throw therefore leaves the attempt `NOT_STARTED`. + */ +export function certaintyForAbandonedAttempt( + state: PhysicalAttemptState, + isCommand: boolean, +): MutationEffectCertainty { + if (!isCommand) return "NOT_STARTED"; + switch (state) { + case "NOT_STARTED": + case "PREPARING": + case "READY_TO_SEND": + return "NOT_STARTED"; + default: + return "MAYBE_APPLIED"; + } +} + +export type ProblemEffectInput = Readonly<{ + status: number; + problem: Problem; + descriptor: CommandEffectDescriptor | null; +}>; + +export type ProblemEffectOutcome = Readonly<{ + effect: MutationEffectCertainty; + contractRuntimeFailure: boolean; +}>; + +const CLASSIFICATIONS: ReadonlySet = new Set([ + "NOT_APPLIED", + "APPLIED_CONFIRMED", + "MAYBE_APPLIED", +]); + +/** + * §4.4. The classifier is package-owned and pure. A throw or an unrecognised + * return value fails safe to `MAYBE_APPLIED` and is recorded as a contract + * runtime failure rather than being silently treated as "not applied". + */ +export function classifyProblemEffect( + input: ProblemEffectInput, +): ProblemEffectOutcome { + if (!input.descriptor) { + // A read operation carries no command effect; there is nothing to apply. + return Object.freeze({ + effect: "NOT_STARTED" as const, + contractRuntimeFailure: false, + }); + } + let classification: CommandEffectClassification; + try { + classification = input.descriptor.classifyProblem({ + status: input.status, + problem: input.problem, + }); + } catch { + return Object.freeze({ + effect: "MAYBE_APPLIED" as const, + contractRuntimeFailure: true, + }); + } + if (!CLASSIFICATIONS.has(classification)) { + return Object.freeze({ + effect: "MAYBE_APPLIED" as const, + contractRuntimeFailure: true, + }); + } + return Object.freeze({ + effect: classification, + contractRuntimeFailure: false, + }); +} + +/** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */ +export function projectCertaintyToUi( + certainty: MutationEffectCertainty, +): "RETRYABLE" | "CHECK_STATUS" | "SUCCESS" { + switch (certainty) { + case "APPLIED_CONFIRMED": + return "SUCCESS"; + case "MAYBE_APPLIED": + return "CHECK_STATUS"; + default: + return "RETRYABLE"; + } +} diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts new file mode 100644 index 0000000..7de66b1 --- /dev/null +++ b/src/adapters/http/http-execution-v3.ts @@ -0,0 +1,1226 @@ +import { + HTTP_EXECUTION_CEILINGS, + invokeValidator, + type InstalledHttpContract, +} from "../../contracts/external-contract-runtime.ts"; +import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts"; +import { + defineMutationIntent, + MUTATION_INTENT_BOUNDS, + type MutationIntent, +} from "../../contracts/mutation-intent.ts"; +import { + decodeJsonBytes, + isEffectivelyEmpty, + isJsonMediaType, + probeForbiddenBody, + readBoundedBytes, +} from "./bounded-body-reader.ts"; +import { + checkFinalInvariants, + projectRequest, + type CredentialPatchOutcome, +} from "./http-contract-bridge.ts"; +import { + certaintyForAbandonedAttempt, + classifyProblemEffect, + type PhysicalAttemptState, +} from "./http-effect-certainty.ts"; +import { parseRetryAfter } from "./retry-policy.ts"; + +/** + * §7–§8. Descriptor-driven HTTP execution. + * + * This is not a general purpose HTTP client. A caller passes an installed + * contract and typed input; the runtime owns bounds, the total deadline, the + * single retry authority and the effect-certainty verdict. + */ + +export type SafeResponseMetadata = Readonly<{ + status: number; + correlationReference?: string; + retryAfterMs?: number; + validatorToken?: string; +}>; + +export type HttpContractViolationKind = + | "MISSING_IDEMPOTENCY_KEY" + | "UNEXPECTED_IDEMPOTENCY_KEY" + | "UNEXPECTED_STATUS" + | "UNEXPECTED_EMPTY_BODY" + | "UNEXPECTED_BODY" + | "CONTENT_TYPE_MISMATCH" + | "RESPONSE_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SUCCESS_SCHEMA_INVALID" + | "PROBLEM_SCHEMA_INVALID" + | "VALIDATOR_RUNTIME_FAILURE" + | "MAPPING_CONTRACT_VIOLATION" + | "FINAL_REQUEST_INVARIANT_FAILED" + | "SCOPE_FENCED"; + +export type HttpContractViolation = Readonly<{ + kind: HttpContractViolationKind; + operation: "REQUEST" | "RESPONSE" | "VALIDATION" | "MAPPING"; +}>; + +export type HttpEffectCertainty = + | "NOT_APPLICABLE" + | "NOT_STARTED" + | "NOT_APPLIED" + | "APPLIED_CONFIRMED" + | "MAYBE_APPLIED"; + +export type HttpTransportFailure = Readonly<{ + kind: + | "DNS_OR_CONNECT_FAILURE" + | "TLS_OR_NETWORK_FAILURE" + | "NETWORK_FAILURE" + | "TIMEOUT" + | "RESPONSE_STREAM_FAILURE" + | "ABORTED_BY_SCOPE" + | "ABORTED_BY_CALLER" + | "OVERLOADED_BEFORE_SEND"; + retryable: boolean; +}>; + +export type HttpExecutionOutcome = + | Readonly<{ + kind: "SUCCESS"; + value: Value; + metadata: SafeResponseMetadata; + effect: "NOT_APPLICABLE" | "APPLIED_CONFIRMED"; + }> + | Readonly<{ + kind: "PROBLEM"; + problem: Problem; + metadata: SafeResponseMetadata; + effect: "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "UNAUTHENTICATED"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "FORBIDDEN"; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "RATE_LIMITED"; + retryAfterMs?: number; + effect: "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CONTRACT_VIOLATION"; + violation: HttpContractViolation; + effect: HttpEffectCertainty; + }> + | Readonly<{ + kind: "TRANSPORT_FAILURE"; + failure: HttpTransportFailure; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }> + | Readonly<{ + kind: "CANCELLED"; + effect: "NOT_STARTED" | "MAYBE_APPLIED"; + }>; + +export type CancellationOwner = + | "CALLER" + | "ROUTE_TRANSITION" + | "SCOPE_FENCE" + | "APPLICATION_SHUTDOWN" + | "DEADLINE"; + +export interface HttpExecutionContext { + readonly signal?: AbortSignal; + readonly scope: CacheScopeSnapshot; + readonly intent?: MutationIntent; +} + +export interface ContractHttpExecutor { + execute( + operation: InstalledHttpContract, + input: Input, + context: HttpExecutionContext, + ): Promise>; +} + +export type HttpExecutionObservation = Readonly<{ + diagnosticsOperation: string; + outcome: string; + attempts: number; + certainty: string; +}>; + +export type ContractHttpExecutorDependencies = Readonly<{ + baseUrl: string; + /** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */ + maxRetryAttempts: number; + attachCredentials( + operation: Readonly<{ + operationId: string; + authProfileId: string; + method: string; + }>, + ): Promise | CredentialPatchOutcome; + fetcher?: typeof fetch; + /** Adapter seam for the common bounded response reader. */ + readBoundedResponseBytes?: typeof readBoundedBytes; + monotonicNow?: () => number; + sleep?: (ms: number, signal: AbortSignal) => Promise; + random?: () => number; + observe?: (observation: HttpExecutionObservation) => void; +}>; + +const RETRYABLE_STATUSES: ReadonlySet = new Set([ + 408, 425, 429, 502, 503, 504, +]); + +const RETRY_BASE_DELAY_MS = 250; +const RETRY_MAX_LOCAL_DELAY_MS = 2_000; +const RETRY_AFTER_CEILING_MS = 5_000; + +type MutationIntentValidation = + | Readonly<{ ok: true; intent?: MutationIntent }> + | Readonly<{ + ok: false; + violation: "MISSING_IDEMPOTENCY_KEY" | "UNEXPECTED_IDEMPOTENCY_KEY"; + }>; + +function validateMutationIntent( + contract: Readonly<{ + operationId: string; + retrySemantics: "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER"; + commandEffect: unknown | null; + }>, + intent: MutationIntent | undefined, +): MutationIntentValidation { + const isCommand = contract.commandEffect !== null; + const requiresKey = contract.retrySemantics === "KEYED"; + if (!isCommand) { + return intent === undefined + ? Object.freeze({ ok: true }) + : Object.freeze({ + ok: false, + violation: "UNEXPECTED_IDEMPOTENCY_KEY", + }); + } + if (intent === undefined) { + return requiresKey + ? Object.freeze({ ok: false, violation: "MISSING_IDEMPOTENCY_KEY" }) + : Object.freeze({ ok: true }); + } + + let validated: MutationIntent; + try { + validated = defineMutationIntent(intent); + } catch { + return Object.freeze({ + ok: false, + violation: requiresKey + ? "MISSING_IDEMPOTENCY_KEY" + : "UNEXPECTED_IDEMPOTENCY_KEY", + }); + } + if (validated.operationId !== contract.operationId) { + return Object.freeze({ + ok: false, + violation: requiresKey + ? "MISSING_IDEMPOTENCY_KEY" + : "UNEXPECTED_IDEMPOTENCY_KEY", + }); + } + + const key = validated.idempotencyKey; + if (requiresKey && !validIdempotencyKey(key)) { + return Object.freeze({ + ok: false, + violation: "MISSING_IDEMPOTENCY_KEY", + }); + } + if (!requiresKey && key !== undefined) { + return Object.freeze({ + ok: false, + violation: "UNEXPECTED_IDEMPOTENCY_KEY", + }); + } + return Object.freeze({ ok: true, intent: validated }); +} + +const UTF8 = new TextEncoder(); + +function validIdempotencyKey(value: unknown): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + UTF8.encode(value).byteLength <= + MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes && + !hasControlCharacter(value) + ); +} + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) + ) { + return true; + } + } + return false; +} + +export function createContractHttpExecutor( + dependencies: ContractHttpExecutorDependencies, +): ContractHttpExecutor { + const fetcher = dependencies.fetcher ?? fetch; + const readResponseBytes = + dependencies.readBoundedResponseBytes ?? readBoundedBytes; + const now = dependencies.monotonicNow ?? (() => performance.now()); + const random = dependencies.random ?? Math.random; + const sleep = + dependencies.sleep ?? + ((ms: number, signal: AbortSignal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + })); + + async function execute( + operation: InstalledHttpContract, + input: Input, + context: HttpExecutionContext, + ): Promise> { + const contract = operation.contract; + const policy = operation.frontend; + const isCommand = contract.commandEffect !== null; + + // §8.5. One monotonic deadline covers credential resolution, encoding, + // backoff, every physical attempt, body read and validation. + const deadlineAt = now() + policy.totalDeadlineMs; + const remaining = () => deadlineAt - now(); + + let attemptState: PhysicalAttemptState = "PREPARING"; + let attempts = 0; + let terminalCancellation: CancellationOwner | null = null; + const lifetimeController = new AbortController(); + const forwardCallerToLifetime = () => { + terminalCancellation ??= "CALLER"; + lifetimeController.abort(); + }; + const forwardScopeToLifetime = () => { + terminalCancellation ??= "SCOPE_FENCE"; + lifetimeController.abort(); + }; + const callerSignal = + context.signal === context.scope.signal ? undefined : context.signal; + if (callerSignal?.aborted) forwardCallerToLifetime(); + else { + callerSignal?.addEventListener("abort", forwardCallerToLifetime, { + once: true, + }); + } + if (context.scope.signal.aborted) forwardScopeToLifetime(); + else { + context.scope.signal.addEventListener("abort", forwardScopeToLifetime, { + once: true, + }); + } + const lifetimeDeadlineTimer = setTimeout(() => { + terminalCancellation ??= "DEADLINE"; + lifetimeController.abort(); + }, policy.totalDeadlineMs); + let lifetimeDisposed = false; + const disposeLifetime = () => { + if (lifetimeDisposed) return; + lifetimeDisposed = true; + clearTimeout(lifetimeDeadlineTimer); + callerSignal?.removeEventListener("abort", forwardCallerToLifetime); + context.scope.signal.removeEventListener("abort", forwardScopeToLifetime); + lifetimeController.abort(); + }; + + const finish = ( + outcome: HttpExecutionOutcome, + certainty: string, + ): HttpExecutionOutcome => { + disposeLifetime(); + try { + dependencies.observe?.({ + diagnosticsOperation: policy.diagnosticsOperation, + outcome: outcome.kind, + attempts, + certainty, + }); + } catch { + // Observation is outside the execution authority. + } + return outcome; + }; + + try { + + // §7.4 step 1-2: capture the scope and verify it is still current. + if (!context.scope.isCurrent()) { + return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED"); + } + + const intentValidation = validateMutationIntent(contract, context.intent); + if (!intentValidation.ok) { + return finish( + violation(intentValidation.violation, "REQUEST", "NOT_STARTED"), + "NOT_STARTED", + ); + } + + const validated = invokeValidator(contract.inputValidator, input); + if (validated.outcome === "THROWN") { + return finish( + violation( + "VALIDATOR_RUNTIME_FAILURE", + "VALIDATION", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + if (validated.outcome === "INVALID") { + return finish( + violation( + "FINAL_REQUEST_INVARIANT_FAILED", + "REQUEST", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + + const projected = projectRequest( + operation, + validated.value, + dependencies.baseUrl, + ); + if (!projected.ok) { + return finish( + violation( + "FINAL_REQUEST_INVARIANT_FAILED", + "REQUEST", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + + // §7.7 / §8.4. Credentials are resolved before send. A response 401 is + // terminal; there is no hidden refresh-and-replay. + let patchOperation: Promise; + try { + patchOperation = Promise.resolve( + dependencies.attachCredentials({ + operationId: contract.operationId, + authProfileId: policy.authProfileId, + method: contract.method, + }), + ); + } catch { + return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED"); + } + const patchResult = await awaitWithAbort( + patchOperation, + lifetimeController.signal, + ); + if (patchResult === ABORTED) { + if (terminalCancellation === "SCOPE_FENCE") { + return finish( + transportFailure( + "ABORTED_BY_SCOPE", + false, + attemptState, + isCommand, + ), + "SCOPE_FENCED", + ); + } + return terminalCancellation === "CALLER" + ? finish(cancelled("NOT_STARTED"), "CANCELLED") + : finish( + transportFailure( + "TIMEOUT", + false, + attemptState, + isCommand, + ), + "TIMEOUT", + ); + } + const patch = patchResult; + if (patch.kind === "SCOPE_FENCED") { + return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED"); + } + if (patch.kind !== "READY") { + // A missing credential never downgrades into an anonymous request. + return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED"); + } + if ( + Object.keys(patch.headers).some( + (name) => name.toLowerCase() === "idempotency-key", + ) + ) { + return finish( + violation("UNEXPECTED_IDEMPOTENCY_KEY", "REQUEST", "NOT_STARTED"), + "NOT_STARTED", + ); + } + + const headers: Record = { + Accept: "application/json", + ...patch.headers, + }; + if (contract.requestBody === "JSON") { + headers["Content-Type"] = "application/json"; + } + if (intentValidation.intent?.idempotencyKey !== undefined) { + headers["Idempotency-Key"] = intentValidation.intent.idempotencyKey; + } + + const retryCeiling = Math.min( + policy.retryBudget, + dependencies.maxRetryAttempts, + HTTP_EXECUTION_CEILINGS.hardRetryCount, + ); + + for (let retryIndex = 0; ; retryIndex += 1) { + if (callerSignal?.aborted) { + terminalCancellation ??= "CALLER"; + return finish( + cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)), + "CANCELLED", + ); + } + if (!context.scope.isCurrent()) { + terminalCancellation ??= "SCOPE_FENCE"; + return finish( + scopeFenced(contractViolationEffect(attemptState, isCommand)), + "SCOPE_FENCED", + ); + } + + const controller = new AbortController(); + const budget = remaining(); + if (budget <= 0) { + return finish( + transportFailure("TIMEOUT", false, attemptState, isCommand), + "TIMEOUT", + ); + } + const deadlineTimer = setTimeout(() => { + terminalCancellation ??= "DEADLINE"; + controller.abort(); + }, budget); + const forwardLifetime = () => { + controller.abort(); + }; + if (lifetimeController.signal.aborted) forwardLifetime(); + else { + lifetimeController.signal.addEventListener("abort", forwardLifetime, { + once: true, + }); + } + + const init: RequestInit = { + method: contract.method, + headers, + redirect: "error", + referrerPolicy: "no-referrer", + credentials: patch.credentials, + cache: "no-store", + signal: controller.signal, + ...(projected.request.bodyBytes + ? { body: projected.request.bodyBytes.slice() } + : {}), + }; + + const invariantFailure = checkFinalInvariants({ + request: projected.request, + expectedMethod: contract.method, + baseUrl: dependencies.baseUrl, + init, + headers, + requestByteLimit: policy.requestByteLimit, + deadlineRemainingMs: remaining(), + scopeIsCurrent: context.scope.isCurrent(), + }); + if (invariantFailure) { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener("abort", forwardLifetime); + return finish( + invariantFailure === "SCOPE_FENCED" + ? scopeFenced(preDispatchEffect(isCommand)) + : violation( + "FINAL_REQUEST_INVARIANT_FAILED", + "REQUEST", + preDispatchEffect(isCommand), + ), + "NOT_STARTED", + ); + } + + let response: Response; + attemptState = "READY_TO_SEND"; + try { + attempts += 1; + const pending = fetcher(projected.request.url, init); + attemptState = "DISPATCHED"; + response = await pending; + attemptState = "RESPONSE_HEADERS"; + } catch { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener("abort", forwardLifetime); + const owner = terminalCancellation; + if (owner === "CALLER") { + return finish( + cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)), + "CANCELLED", + ); + } + if (owner === "SCOPE_FENCE") { + return finish( + transportFailure( + "ABORTED_BY_SCOPE", + false, + attemptState, + isCommand, + ), + "SCOPE_FENCED", + ); + } + const kind = owner === "DEADLINE" ? "TIMEOUT" : "NETWORK_FAILURE"; + if ( + owner === null && + canRetryTransport(contract.retrySemantics, attemptState) && + retryIndex < retryCeiling && + remaining() > 0 + ) { + const delay = jitteredDelay(retryIndex, random); + if (delay < remaining()) { + const slept = await awaitWithAbort( + sleep(delay, lifetimeController.signal), + lifetimeController.signal, + ); + if (slept === ABORTED) { + return terminalCancellation === "CALLER" + ? finish( + cancelled( + certaintyForAbandonedAttempt(attemptState, isCommand), + ), + "CANCELLED", + ) + : finish( + transportFailure( + "TIMEOUT", + false, + attemptState, + isCommand, + ), + "TIMEOUT", + ); + } + continue; + } + } + return finish( + transportFailure(kind, false, attemptState, isCommand), + kind, + ); + } + + try { + const outcome = await admitResponse( + operation, + response, + context, + attemptState, + readResponseBytes, + ); + attemptState = "SETTLED"; + if ( + outcome.retryHint && + retryIndex < retryCeiling && + isRetryableSemantics(contract.retrySemantics) + ) { + const delay = retryDelayFor(outcome.retryAfterMs, retryIndex, random); + if (delay !== null && delay < remaining()) { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener( + "abort", + forwardLifetime, + ); + const slept = await awaitWithAbort( + sleep(delay, lifetimeController.signal), + lifetimeController.signal, + ); + if (slept === ABORTED) { + return terminalCancellation === "CALLER" + ? finish( + cancelled( + certaintyForAbandonedAttempt(attemptState, isCommand), + ), + "CANCELLED", + ) + : finish( + transportFailure( + "TIMEOUT", + false, + attemptState, + isCommand, + ), + "TIMEOUT", + ); + } + continue; + } + } + return finish(outcome.result, outcome.certainty); + } finally { + clearTimeout(deadlineTimer); + lifetimeController.signal.removeEventListener("abort", forwardLifetime); + } + } + } catch { + return finish( + transportFailure("NETWORK_FAILURE", false, attemptState, isCommand), + "RUNTIME_FAILURE", + ); + } finally { + disposeLifetime(); + } + } + + return Object.freeze({ execute }); +} + +type AdmissionOutcome = Readonly<{ + result: HttpExecutionOutcome; + certainty: string; + retryHint: boolean; + retryAfterMs: number | null; +}>; + +/** + * §7.9. Admission order is fixed: redirect, status allowlist, media type, + * declared length, bounded stream, body policy, decode, validate, scope fence. + * `response.ok` is never used on its own. + */ +async function admitResponse( + operation: InstalledHttpContract, + response: Response, + context: HttpExecutionContext, + attemptState: PhysicalAttemptState, + readResponseBytes: typeof readBoundedBytes, +): Promise> { + const contract = operation.contract; + const policy = operation.frontend; + const isCommand = contract.commandEffect !== null; + const status = response.status; + const retryAfterMs = parseRetryAfter(response.headers.get("retry-after")); + + if (response.redirected || response.type === "opaqueredirect") { + await response.body?.cancel().catch(() => {}); + return settled( + violation( + "UNEXPECTED_STATUS", + "RESPONSE", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + + const metadata = safeMetadata(response, retryAfterMs); + + if (!contract.acceptedStatuses.includes(status)) { + if (status === 401) { + await response.body?.cancel().catch(() => {}); + return settled( + unauthenticated( + certaintyForAbandonedAttempt(attemptState, isCommand), + isCommand, + ), + "UNAUTHENTICATED", + ); + } + if (status === 403) { + await response.body?.cancel().catch(() => {}); + return settled( + forbidden(certaintyForAbandonedAttempt(attemptState, isCommand), isCommand), + "FORBIDDEN", + ); + } + if (status === 429) { + await response.body?.cancel().catch(() => {}); + return Object.freeze({ + result: Object.freeze({ + kind: "RATE_LIMITED" as const, + ...(retryAfterMs === null ? {} : { retryAfterMs }), + effect: normalizeOptionalEffect( + certaintyForAbandonedAttempt(attemptState, isCommand), + isCommand, + ), + }), + certainty: "RATE_LIMITED", + retryHint: true, + retryAfterMs, + }); + } + return admitProblem( + operation, + response, + status, + metadata, + attemptState, + readResponseBytes, + ); + } + + // Success status: body policy first. + if (contract.responseBody === "NONE") { + const probe = await probeForbiddenBody(response); + if (!probe.ok) { + return settled( + transportFailure( + "RESPONSE_STREAM_FAILURE", + false, + attemptState, + isCommand, + ), + probe.code, + ); + } + if (probe.present) { + return settled( + violation("UNEXPECTED_BODY", "RESPONSE", postDispatchEffect(isCommand)), + "CONTRACT_VIOLATION", + ); + } + if (!context.scope.isCurrent()) { + return settled( + scopeFenced(postDispatchEffect(isCommand)), + "SCOPE_FENCED", + ); + } + return settled( + Object.freeze({ + kind: "SUCCESS" as const, + value: undefined as WireOutput, + metadata, + effect: (isCommand + ? "APPLIED_CONFIRMED" + : "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED", + }), + isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE", + ); + } + + const emptyAllowed = contract.emptyBodyStatuses.includes(status); + const mediaOk = isJsonMediaType(response.headers.get("content-type")); + const bytes = await readResponseBytes(response, policy.responseByteLimit); + if (!bytes.ok) { + return settled( + bytes.code === "RESPONSE_TOO_LARGE" + ? violation( + "RESPONSE_TOO_LARGE", + "RESPONSE", + postDispatchEffect(isCommand), + ) + : transportFailure( + "RESPONSE_STREAM_FAILURE", + false, + attemptState, + isCommand, + ), + bytes.code, + ); + } + + const empty = isEffectivelyEmpty(bytes.bytes); + if (empty) { + if (contract.responseBody === "REQUIRED_JSON" || !emptyAllowed) { + return settled( + violation( + "UNEXPECTED_EMPTY_BODY", + "RESPONSE", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + if (!context.scope.isCurrent()) { + return settled( + scopeFenced(postDispatchEffect(isCommand)), + "SCOPE_FENCED", + ); + } + return settled( + Object.freeze({ + kind: "SUCCESS" as const, + value: undefined as WireOutput, + metadata, + effect: (isCommand + ? "APPLIED_CONFIRMED" + : "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED", + }), + isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE", + ); + } + + if (!mediaOk) { + return settled( + violation( + "CONTENT_TYPE_MISMATCH", + "RESPONSE", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + + const decoded = decodeJsonBytes(bytes.bytes); + if (!decoded.ok) { + return settled( + violation(decoded.code, "RESPONSE", postDispatchEffect(isCommand)), + "CONTRACT_VIOLATION", + ); + } + + const validated = invokeValidator(contract.outputValidator, decoded.value); + if (validated.outcome === "THROWN") { + return settled( + violation( + "VALIDATOR_RUNTIME_FAILURE", + "VALIDATION", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + if (validated.outcome === "INVALID") { + return settled( + violation( + "SUCCESS_SCHEMA_INVALID", + "VALIDATION", + postDispatchEffect(isCommand), + ), + "CONTRACT_VIOLATION", + ); + } + + // §10.8. A successful response for a stale scope is discarded, not committed. + if (!context.scope.isCurrent()) { + return settled( + scopeFenced(postDispatchEffect(isCommand)), + "SCOPE_FENCED", + ); + } + + return settled( + Object.freeze({ + kind: "SUCCESS" as const, + value: validated.value, + metadata, + effect: (isCommand + ? "APPLIED_CONFIRMED" + : "NOT_APPLICABLE") as "NOT_APPLICABLE" | "APPLIED_CONFIRMED", + }), + isCommand ? "APPLIED_CONFIRMED" : "NOT_APPLICABLE", + ); +} + +const ABORTED = Symbol("http-operation-aborted"); + +async function awaitWithAbort( + operation: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return ABORTED; + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(ABORTED); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function admitProblem( + operation: InstalledHttpContract, + response: Response, + status: number, + metadata: SafeResponseMetadata, + attemptState: PhysicalAttemptState, + readResponseBytes: typeof readBoundedBytes, +): Promise> { + const contract = operation.contract; + const isCommand = contract.commandEffect !== null; + const retryable = RETRYABLE_STATUSES.has(status); + + const bytes = await readResponseBytes( + response, + HTTP_EXECUTION_CEILINGS.problemResponseBytes, + ); + if (!bytes.ok || isEffectivelyEmpty(bytes.bytes)) { + // An unclassifiable failure stays uncertain for a command. + return Object.freeze({ + result: violation( + "PROBLEM_SCHEMA_INVALID", + "VALIDATION", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + if (!isJsonMediaType(response.headers.get("content-type"))) { + return Object.freeze({ + result: violation( + "CONTENT_TYPE_MISMATCH", + "RESPONSE", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + + const decoded = decodeJsonBytes(bytes.bytes); + if (!decoded.ok) { + return Object.freeze({ + result: violation( + decoded.code, + "RESPONSE", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + + const validated = invokeValidator(contract.problemValidator, decoded.value); + if (validated.outcome !== "VALID") { + return Object.freeze({ + result: violation( + validated.outcome === "THROWN" + ? "VALIDATOR_RUNTIME_FAILURE" + : "PROBLEM_SCHEMA_INVALID", + "VALIDATION", + postDispatchEffect(isCommand), + ), + certainty: isCommand ? "MAYBE_APPLIED" : "NOT_STARTED", + retryHint: retryable, + retryAfterMs: metadata.retryAfterMs ?? null, + }); + } + + const classification = classifyProblemEffect({ + status, + problem: validated.value, + descriptor: contract.commandEffect, + }); + const effect = isCommand + ? classification.effect + : certaintyForAbandonedAttempt(attemptState, false); + + return Object.freeze({ + result: Object.freeze({ + kind: "PROBLEM" as const, + problem: validated.value, + metadata, + effect: (effect === "NOT_STARTED" + ? "NOT_APPLIED" + : effect) as "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED", + }), + certainty: effect, + retryHint: retryable && effect !== "MAYBE_APPLIED", + retryAfterMs: metadata.retryAfterMs ?? null, + }); +} + +/** + * §8.3. `SAFE` and `IDEMPOTENT` may replay the same frozen request. `KEYED` + * must not automatically retry once an attempt was dispatched and its response + * was lost; that path goes to inspect/reconciliation instead. `NEVER` is zero. + */ +function canRetryTransport( + semantics: InstalledHttpContract< + unknown, + unknown, + unknown + >["contract"]["retrySemantics"], + attemptState: PhysicalAttemptState, +): boolean { + if (semantics === "NEVER") return false; + if (semantics === "KEYED") { + return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"; + } + return true; +} + +function isRetryableSemantics( + semantics: InstalledHttpContract< + unknown, + unknown, + unknown + >["contract"]["retrySemantics"], +): boolean { + return semantics === "SAFE" || semantics === "IDEMPOTENT"; +} + +/** §8.2. Full jitter over `min(2000, 250 * 2^index)`. */ +function jitteredDelay(retryIndex: number, random: () => number): number { + const ceiling = Math.min( + RETRY_MAX_LOCAL_DELAY_MS, + RETRY_BASE_DELAY_MS * 2 ** retryIndex, + ); + return Math.floor(random() * ceiling); +} + +function retryDelayFor( + retryAfterMs: number | null, + retryIndex: number, + random: () => number, +): number | null { + const local = jitteredDelay(retryIndex, random); + if (retryAfterMs === null) return local; + if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null; + return Math.max(local, retryAfterMs); +} + +/** §7.12. No raw header map, URL, cookie, traceparent or ETag value escapes. */ +function safeMetadata( + response: Response, + retryAfterMs: number | null, +): SafeResponseMetadata { + const correlation = response.headers.get("x-correlation-id"); + return Object.freeze({ + status: response.status, + ...(correlation && /^[A-Za-z0-9._:-]{1,128}$/.test(correlation) + ? { correlationReference: correlation } + : {}), + ...(retryAfterMs === null ? {} : { retryAfterMs }), + }); +} + +function settled( + result: HttpExecutionOutcome, + certainty: string, +): AdmissionOutcome { + return Object.freeze({ + result, + certainty, + retryHint: false, + retryAfterMs: null, + }); +} + +function violation( + kind: HttpContractViolationKind, + operation: HttpContractViolation["operation"], + effect: HttpEffectCertainty, +): HttpExecutionOutcome { + return Object.freeze({ + kind: "CONTRACT_VIOLATION" as const, + violation: Object.freeze({ kind, operation }), + effect, + }); +} + +function scopeFenced( + effect: HttpEffectCertainty, +): HttpExecutionOutcome { + return violation("SCOPE_FENCED", "RESPONSE", effect); +} + +function preDispatchEffect(isCommand: boolean): HttpEffectCertainty { + return isCommand ? "NOT_STARTED" : "NOT_APPLICABLE"; +} + +function postDispatchEffect(isCommand: boolean): HttpEffectCertainty { + return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE"; +} + +function contractViolationEffect( + attemptState: PhysicalAttemptState, + isCommand: boolean, +): HttpEffectCertainty { + if (!isCommand) return "NOT_APPLICABLE"; + return attemptState === "PREPARING" || attemptState === "READY_TO_SEND" + ? "NOT_STARTED" + : "MAYBE_APPLIED"; +} + +function unauthenticated( + effect: string, + isCommand: boolean, +): HttpExecutionOutcome { + return Object.freeze({ + kind: "UNAUTHENTICATED" as const, + effect: normalizeOptionalEffect(effect, isCommand), + }); +} + +function forbidden( + effect: string, + isCommand: boolean, +): HttpExecutionOutcome { + return Object.freeze({ + kind: "FORBIDDEN" as const, + effect: normalizeOptionalEffect(effect, isCommand), + }); +} + +function cancelled( + effect: "NOT_STARTED" | "NOT_APPLIED" | "MAYBE_APPLIED" | "APPLIED_CONFIRMED", +): HttpExecutionOutcome { + return Object.freeze({ + kind: "CANCELLED" as const, + effect: effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_STARTED", + }); +} + +function transportFailure( + kind: HttpTransportFailure["kind"], + retryable: boolean, + attemptState: PhysicalAttemptState, + isCommand: boolean, +): HttpExecutionOutcome { + const effect = certaintyForAbandonedAttempt(attemptState, isCommand); + return Object.freeze({ + kind: "TRANSPORT_FAILURE" as const, + failure: Object.freeze({ kind, retryable }), + effect: effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_STARTED", + }); +} + +function normalizeOptionalEffect( + effect: string, + isCommand: boolean, +): "NOT_APPLICABLE" | "NOT_APPLIED" | "MAYBE_APPLIED" { + if (!isCommand) return "NOT_APPLICABLE"; + return effect === "MAYBE_APPLIED" ? "MAYBE_APPLIED" : "NOT_APPLIED"; +} diff --git a/src/adapters/http/request-builder.ts b/src/adapters/http/request-builder.ts new file mode 100644 index 0000000..c213279 --- /dev/null +++ b/src/adapters/http/request-builder.ts @@ -0,0 +1,134 @@ +import type { ApiOperation } from "../../contracts/api-operations.ts"; + +export type OperationRequestInput = Readonly<{ + operationId: string; + routeId: string; + pathParams?: Readonly>; + searchParams?: unknown; + body?: unknown; + signal?: AbortSignal; + idempotencyKey?: string; + correlationId?: string; +}>; + +export type RequestTargetResult = + | Readonly<{ success: true; url: URL }> + | Readonly<{ + success: false; + code: + | "BASE_URL_INVALID" + | "PATH_PARAMETER_MISSING" + | "PATH_PARAMETER_UNEXPECTED" + | "PATH_PARAMETER_INVALID" + | "SEARCH_PARAMETER_INVALID"; + }>; + +const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g; + +export function buildRequestTarget( + baseUrl: string, + operation: ApiOperation, + pathParams: Readonly> = {}, + parsedSearch: unknown = {}, +): RequestTargetResult { + let base: URL; + try { + base = new URL(baseUrl); + } catch { + return { success: false, code: "BASE_URL_INVALID" }; + } + if ( + (base.protocol !== "https:" && + !( + base.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) + )) || + base.username || + base.password || + base.search || + base.hash + ) { + return { success: false, code: "BASE_URL_INVALID" }; + } + + const placeholders = new Set(); + for (const match of operation.path.matchAll(pathParameterPattern)) { + placeholders.add(match[1] ?? match[2] ?? ""); + } + if (Object.keys(pathParams).some((key) => !placeholders.has(key))) { + return { success: false, code: "PATH_PARAMETER_UNEXPECTED" }; + } + let missingPathParameter = false; + let invalidPathParameter = false; + const pathname = operation.path.replace( + pathParameterPattern, + (_token, colonName: string | undefined, braceName: string | undefined) => { + const name = colonName ?? braceName ?? ""; + const value = pathParams[name]; + if (value === undefined) { + missingPathParameter = true; + return ""; + } + const serialized = String(value); + if ( + serialized.length === 0 || + serialized.length > 512 || + [...serialized].some((character) => { + const code = character.codePointAt(0) ?? 0; + return code < 32 || code === 127; + }) + ) { + invalidPathParameter = true; + return ""; + } + return encodeURIComponent(serialized); + }, + ); + if (missingPathParameter) { + return { success: false, code: "PATH_PARAMETER_MISSING" }; + } + if (invalidPathParameter) { + return { success: false, code: "PATH_PARAMETER_INVALID" }; + } + + if ( + parsedSearch === null || + typeof parsedSearch !== "object" || + Array.isArray(parsedSearch) + ) { + return { success: false, code: "SEARCH_PARAMETER_INVALID" }; + } + + const basePrefix = base.pathname.endsWith("/") + ? base.pathname + : `${base.pathname}/`; + const relativePath = pathname.replace(/^\/+/, ""); + base.pathname = `${basePrefix}${relativePath}`.replace(/\/{2,}/g, "/"); + const url = base; + const search = parsedSearch as Readonly>; + for (const key of Object.keys(search).sort((left, right) => + left.localeCompare(right), + )) { + const value = search[key]; + if (value === undefined || value === null) continue; + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + if ( + typeof item !== "string" && + typeof item !== "number" && + typeof item !== "boolean" + ) { + return { success: false, code: "SEARCH_PARAMETER_INVALID" }; + } + url.searchParams.append(key, String(item)); + } + } + if ( + operation.maxEncodedSearchBytes !== undefined && + new TextEncoder().encode(url.search).byteLength > + operation.maxEncodedSearchBytes + ) { + return { success: false, code: "SEARCH_PARAMETER_INVALID" }; + } + return { success: true, url }; +} diff --git a/src/adapters/http/resource-mapper.ts b/src/adapters/http/resource-mapper.ts new file mode 100644 index 0000000..3d31df0 --- /dev/null +++ b/src/adapters/http/resource-mapper.ts @@ -0,0 +1,11 @@ +import { + mappingFailure, + type MappingResult, +} from "../../contracts/boundary-mapper.ts"; + +export function mapOperationPayload( + _operationId: string, + _payload: unknown, +): MappingResult { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); +} diff --git a/src/adapters/http/retry-policy.ts b/src/adapters/http/retry-policy.ts new file mode 100644 index 0000000..ef5c529 --- /dev/null +++ b/src/adapters/http/retry-policy.ts @@ -0,0 +1,86 @@ +const retryKinds: ReadonlySet = new Set([ + "NETWORK_UNREACHABLE", + "REQUEST_TIMEOUT", + "RATE_LIMITED", + "SERVER_FAILURE", +]); + +export function calculateBackoff( + retryIndex: number, + random = Math.random, + baseDelayMs = 250, + maxDelayMs = 2_000, +): number { + return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random(); +} + +export function parseRetryAfter( + value: string | null | undefined, + now = Date.now(), +): number | null { + if (!value) return null; + + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return seconds < 0 ? null : seconds * 1_000; + } + + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return null; + return Math.max(0, timestamp - now); +} + +export type RetryOperation = Readonly<{ + idempotency: "safe" | "keyed" | "none"; + retry?: "runtime" | "never"; +}>; + +export type RetryFailure = Readonly<{ + kind: string; + retryAfterMs?: number; + retryAfter?: string; + httpStatus?: number; +}>; + +export function shouldRetry( + operation: RetryOperation, + failure: RetryFailure, + retryCount: number, + maxRetries = 2, +): boolean { + if (operation.retry === "never") return false; + if (retryCount >= maxRetries) return false; + if (!retryKinds.has(failure.kind)) return false; + if ( + failure.kind === "SERVER_FAILURE" && + failure.httpStatus !== undefined && + ![502, 503, 504].includes(failure.httpStatus) + ) { + return false; + } + if ( + failure.kind === "RATE_LIMITED" && + typeof failure.retryAfterMs === "number" && + failure.retryAfterMs > 30_000 + ) { + return false; + } + return operation.idempotency === "safe" || operation.idempotency === "keyed"; +} + +export function retryDelay( + failure: RetryFailure, + retryIndex: number, + random = Math.random, + now = Date.now(), +): number { + const localBackoff = calculateBackoff(retryIndex, random); + if (failure.kind !== "RATE_LIMITED") return localBackoff; + + const retryAfterMs = + typeof failure.retryAfterMs === "number" + ? failure.retryAfterMs + : parseRetryAfter(failure.retryAfter, now); + + return retryAfterMs === null ? localBackoff : Math.max(localBackoff, retryAfterMs); +} diff --git a/src/adapters/http/schema-registry.ts b/src/adapters/http/schema-registry.ts new file mode 100644 index 0000000..09a4a52 --- /dev/null +++ b/src/adapters/http/schema-registry.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +const metaSchema = z + .object({ + requestId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/), + traceId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/), + correlationId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional(), + }) + .strip(); + +export const successEnvelopeSchema = z + .object({ + success: z.literal(true), + data: z.unknown(), + meta: metaSchema, + }) + .strict(); + +export const failureEnvelopeSchema = z + .object({ + success: z.literal(false), + error: z + .object({ + code: z.string().regex(/^[A-Z0-9_]{1,64}$/), + category: z.string().min(1).max(64).optional(), + message: z.string().max(1_024).optional(), + retryable: z.boolean().optional(), + details: z.unknown().optional(), + }) + .strict(), + meta: metaSchema, + }) + .strict(); + +export const responseEnvelopeSchema = z.discriminatedUnion("success", [ + successEnvelopeSchema, + failureEnvelopeSchema, +]); + +const payloadSchemas: Readonly>> = + Object.freeze({}); + +const requestSchemas: Readonly>> = + Object.freeze({}); + +export type SchemaIssue = Readonly<{ + path: string; + code: string; + schemaId?: string; +}>; + +export type SchemaValidationResult = + | Readonly<{ success: true; data: unknown }> + | Readonly<{ success: false; issues: readonly SchemaIssue[] }>; + +export function validateEnvelope(value: unknown): SchemaValidationResult { + return projectResult(responseEnvelopeSchema.safeParse(value)); +} + +export function validateOperationPayload( + schemaId: string, + value: unknown, +): SchemaValidationResult { + const schema = payloadSchemas[schemaId]; + if (!schema) return missingSchema(schemaId); + return projectResult(schema.safeParse(value)); +} + +export function validateOperationRequest( + schemaId: string, + value: unknown, +): SchemaValidationResult { + const schema = requestSchemas[schemaId]; + if (!schema) return missingSchema(schemaId); + return projectResult(schema.safeParse(value)); +} + +function missingSchema(schemaId: string): SchemaValidationResult { + return { + success: false, + issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }], + }; +} + +function projectResult( + result: + | Readonly<{ success: true; data: unknown }> + | Readonly<{ + success: false; + error: Readonly<{ + issues: readonly Readonly<{ + path: readonly PropertyKey[]; + code: string; + }>[]; + }>; + }>, +): SchemaValidationResult { + if (result.success) { + return { + success: true, + data: structuredClone(result.data), + }; + } + + return { + success: false, + issues: result.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })), + }; +} diff --git a/src/adapters/platform/browser-lifecycle.ts b/src/adapters/platform/browser-lifecycle.ts new file mode 100644 index 0000000..3e82e22 --- /dev/null +++ b/src/adapters/platform/browser-lifecycle.ts @@ -0,0 +1,186 @@ +/** + * §20.4. The single owner of window lifecycle listeners. + * + * No capability adds its own `visibilitychange`, `online`, `offline`, `focus`, + * `pagehide` or `pageshow` listener. They subscribe here instead, so the + * listener count stays constant and leak inspection (§23.14) is meaningful. + * + * §20.6: nothing in this module is a correctness boundary. `beforeunload` is a + * user prompt, never a place to complete a command, write a checkpoint or + * guarantee a lease release. + */ + +export type BrowserLifecycleSnapshot = Readonly<{ + visibility: "VISIBLE" | "HIDDEN"; + connectivityHint: "ONLINE" | "OFFLINE"; + pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED"; + generation: number; +}>; + +export type BrowserLifecycleEvent = + | Readonly<{ kind: "VISIBILITY_CHANGED"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ kind: "ONLINE"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ kind: "OFFLINE"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ kind: "FOCUS"; snapshot: BrowserLifecycleSnapshot }> + | Readonly<{ + kind: "PAGEHIDE"; + persisted: boolean; + snapshot: BrowserLifecycleSnapshot; + }> + | Readonly<{ + kind: "PAGESHOW"; + persisted: boolean; + snapshot: BrowserLifecycleSnapshot; + }>; + +export type BrowserLifecycleRuntime = Readonly<{ + getSnapshot(): BrowserLifecycleSnapshot; + subscribe(listener: (event: BrowserLifecycleEvent) => void): () => void; + /** + * Registers a dirty-state source. `beforeunload` is attached only while at + * least one source reports dirty, and it uses the browser's standard prompt. + */ + registerDirtySource(isDirty: () => boolean): () => void; + dispose(): void; +}>; + +type LifecycleHost = Readonly<{ + addEventListener: Window["addEventListener"]; + removeEventListener: Window["removeEventListener"]; + document?: Pick & { + addEventListener: Document["addEventListener"]; + removeEventListener: Document["removeEventListener"]; + }; + navigator?: Pick; +}>; + +export function createBrowserLifecycleRuntime( + host: LifecycleHost = globalThis as unknown as LifecycleHost, +): BrowserLifecycleRuntime { + const listeners = new Set<(event: BrowserLifecycleEvent) => void>(); + const dirtySources = new Set<() => boolean>(); + const document = host.document; + + let generation = 1; + let visibility: BrowserLifecycleSnapshot["visibility"] = + document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE"; + let connectivityHint: BrowserLifecycleSnapshot["connectivityHint"] = + host.navigator?.onLine === false ? "OFFLINE" : "ONLINE"; + let pageState: BrowserLifecycleSnapshot["pageState"] = "ACTIVE"; + let disposed = false; + let beforeUnloadAttached = false; + + function snapshot(): BrowserLifecycleSnapshot { + return Object.freeze({ + visibility, + connectivityHint, + pageState, + generation, + }); + } + + function publish(event: BrowserLifecycleEvent): void { + for (const listener of listeners) { + try { + listener(event); + } catch { + // One subscriber defect cannot suppress the signal for the others. + } + } + } + + const onVisibility = () => { + visibility = document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE"; + publish({ kind: "VISIBILITY_CHANGED", snapshot: snapshot() }); + }; + const onOnline = () => { + connectivityHint = "ONLINE"; + publish({ kind: "ONLINE", snapshot: snapshot() }); + }; + const onOffline = () => { + connectivityHint = "OFFLINE"; + publish({ kind: "OFFLINE", snapshot: snapshot() }); + }; + const onFocus = () => { + publish({ kind: "FOCUS", snapshot: snapshot() }); + }; + const onPageHide = (event: Event) => { + const persisted = (event as PageTransitionEvent).persisted === true; + pageState = "PAGEHIDE"; + publish({ kind: "PAGEHIDE", persisted, snapshot: snapshot() }); + }; + const onPageShow = (event: Event) => { + const persisted = (event as PageTransitionEvent).persisted === true; + // §20.5. A bfcache restore is a new lifecycle generation, not a fresh boot. + if (persisted) generation += 1; + pageState = persisted ? "BFCACHE_RESTORED" : "ACTIVE"; + publish({ kind: "PAGESHOW", persisted, snapshot: snapshot() }); + }; + const onBeforeUnload = (event: Event) => { + if (!hasDirtyState()) return; + event.preventDefault(); + }; + + function hasDirtyState(): boolean { + for (const isDirty of dirtySources) { + try { + if (isDirty()) return true; + } catch { + // A defective reporter is treated as clean rather than trapping the user. + } + } + return false; + } + + function syncBeforeUnload(): void { + const shouldAttach = dirtySources.size > 0; + if (shouldAttach && !beforeUnloadAttached) { + host.addEventListener("beforeunload", onBeforeUnload); + beforeUnloadAttached = true; + } else if (!shouldAttach && beforeUnloadAttached) { + host.removeEventListener("beforeunload", onBeforeUnload); + beforeUnloadAttached = false; + } + } + + document?.addEventListener("visibilitychange", onVisibility); + host.addEventListener("online", onOnline); + host.addEventListener("offline", onOffline); + host.addEventListener("focus", onFocus); + host.addEventListener("pagehide", onPageHide); + host.addEventListener("pageshow", onPageShow); + + return Object.freeze({ + getSnapshot: snapshot, + subscribe(listener) { + if (disposed) return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + registerDirtySource(isDirty) { + if (disposed) return () => {}; + dirtySources.add(isDirty); + syncBeforeUnload(); + return () => { + dirtySources.delete(isDirty); + syncBeforeUnload(); + }; + }, + dispose() { + if (disposed) return; + disposed = true; + document?.removeEventListener("visibilitychange", onVisibility); + host.removeEventListener("online", onOnline); + host.removeEventListener("offline", onOffline); + host.removeEventListener("focus", onFocus); + host.removeEventListener("pagehide", onPageHide); + host.removeEventListener("pageshow", onPageShow); + if (beforeUnloadAttached) { + host.removeEventListener("beforeunload", onBeforeUnload); + beforeUnloadAttached = false; + } + listeners.clear(); + dirtySources.clear(); + }, + }); +} diff --git a/src/adapters/platform/browser-mutation-intent-factory.ts b/src/adapters/platform/browser-mutation-intent-factory.ts new file mode 100644 index 0000000..8500dd2 --- /dev/null +++ b/src/adapters/platform/browser-mutation-intent-factory.ts @@ -0,0 +1,50 @@ +import type { MutationIntentFactory } from "../../application/ports/mutation-intent-factory.ts"; +import { defineMutationIntent } from "../../contracts/mutation-intent.ts"; + +export type BrowserMutationIntentFactoryDependencies = Readonly<{ + randomUUID?: () => string; + monotonicNow?: () => number; +}>; + +export function createBrowserMutationIntentFactory( + dependencies: BrowserMutationIntentFactoryDependencies = {}, +): MutationIntentFactory { + const randomUUID = + dependencies.randomUUID ?? + (() => { + if ( + typeof globalThis.crypto === "undefined" || + typeof globalThis.crypto.randomUUID !== "function" + ) { + throw new TypeError("Secure mutation identity generation is unavailable."); + } + return globalThis.crypto.randomUUID(); + }); + const monotonicNow = + dependencies.monotonicNow ?? + (() => { + if ( + typeof globalThis.performance === "undefined" || + typeof globalThis.performance.now !== "function" + ) { + throw new TypeError("Monotonic time is unavailable."); + } + return globalThis.performance.now(); + }); + + return Object.freeze({ + create(input) { + const intentId = randomUUID(); + const idempotencyKey = input.requiresIdempotencyKey + ? randomUUID() + : undefined; + return defineMutationIntent({ + intentId, + operationId: input.operationId, + canonicalInputIdentity: input.canonicalInputIdentity, + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + createdAtMonotonicMs: monotonicNow(), + }); + }, + }); +} diff --git a/src/adapters/platform/system-clock.ts b/src/adapters/platform/system-clock.ts new file mode 100644 index 0000000..b22a686 --- /dev/null +++ b/src/adapters/platform/system-clock.ts @@ -0,0 +1,24 @@ +import type { ClockPort } from "../../application/ports/clock-port.ts"; + +export const systemClock: ClockPort = Object.freeze({ + now: () => Date.now(), + sleep(milliseconds, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(signal?.reason); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); + }, +}); diff --git a/src/adapters/query-cache/conditional-validator-store.ts b/src/adapters/query-cache/conditional-validator-store.ts new file mode 100644 index 0000000..9d43b66 --- /dev/null +++ b/src/adapters/query-cache/conditional-validator-store.ts @@ -0,0 +1,123 @@ +import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts"; + +export type ConditionalValidatorBinding = Readonly<{ + definitionId: string; + identityToken: string; + representationVersion: number; + scope: CacheScopeSnapshot; +}>; + +export type ConditionalValidatorStore = Readonly<{ + install( + binding: ConditionalValidatorBinding, + validator: string, + cacheRevision: number, + ): boolean; + prepare( + binding: ConditionalValidatorBinding, + cacheRevision: number, + ): string | null; + acceptNotModified( + binding: ConditionalValidatorBinding, + cacheRevision: number, + hasMappedValue: boolean, + ): boolean; + remove(binding: ConditionalValidatorBinding): void; + clear(): void; +}>; + +type ValidatorRow = { + validator: string; + cacheRevision: number; + generation: number; +}; + +export function createConditionalValidatorStore( + maxEntries = 1_024, +): ConditionalValidatorStore { + if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) { + throw new TypeError("Invalid conditional validator capacity."); + } + const rows = new Map(); + + function key(binding: ConditionalValidatorBinding): string | null { + if ( + !binding.scope.isCurrent() || + !binding.definitionId || + !/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) || + !Number.isSafeInteger(binding.representationVersion) || + binding.representationVersion < 1 + ) { + return null; + } + return [ + binding.scope.fingerprint, + binding.definitionId, + binding.identityToken, + binding.representationVersion, + ].join(":"); + } + + return Object.freeze({ + install(binding, validator, cacheRevision) { + const selectedKey = key(binding); + if ( + !selectedKey || + !isSafeEntityTag(validator) || + !Number.isSafeInteger(cacheRevision) || + cacheRevision < 0 + ) { + return false; + } + if (!rows.has(selectedKey) && rows.size >= maxEntries) return false; + rows.set(selectedKey, { + validator, + cacheRevision, + generation: binding.scope.generation, + }); + return true; + }, + prepare(binding, cacheRevision) { + const selectedKey = key(binding); + if (!selectedKey) return null; + const row = rows.get(selectedKey); + return row && + row.generation === binding.scope.generation && + row.cacheRevision === cacheRevision + ? row.validator + : null; + }, + acceptNotModified(binding, cacheRevision, hasMappedValue) { + const selectedKey = key(binding); + if (!selectedKey || !hasMappedValue) return false; + const row = rows.get(selectedKey); + return Boolean( + row && + row.generation === binding.scope.generation && + row.cacheRevision === cacheRevision, + ); + }, + remove(binding) { + const selectedKey = key(binding); + if (selectedKey) rows.delete(selectedKey); + }, + clear() { + rows.clear(); + }, + }); +} + +function isSafeEntityTag(value: string): boolean { + if (value.length < 3 || value.length > 256) return false; + const opaque = value.startsWith('W/"') + ? value.slice(3, -1) + : value.startsWith('"') + ? value.slice(1, -1) + : null; + if (opaque === null || !value.endsWith('"')) return false; + return [...opaque].every((character) => { + const code = character.codePointAt(0) ?? 0; + return code === 0x21 || (code >= 0x23 && code <= 0x7e) || + (code >= 0x80 && code <= 0xff); + }); +} diff --git a/src/adapters/query-cache/cursor-pagination-runtime.ts b/src/adapters/query-cache/cursor-pagination-runtime.ts new file mode 100644 index 0000000..5fd0de6 --- /dev/null +++ b/src/adapters/query-cache/cursor-pagination-runtime.ts @@ -0,0 +1,139 @@ +import type { Result } from "../../application/result.ts"; +import type { + CursorPage, + CursorPaginationProfile, + CursorPaginationRuntime, +} from "../../contracts/cursor-pagination.ts"; +import { createFailure } from "../../contracts/errors.ts"; + +export function createCursorPaginationRuntime(dependencies: Readonly<{ + definitionId: string; + profile: CursorPaginationProfile; + loadPage( + cursor: string | null, + context: Readonly<{ signal?: AbortSignal }>, + ): Promise>>; +}>): CursorPaginationRuntime { + validateProfile(dependencies.profile); + return Object.freeze({ + async loadAll(context) { + const items: Value[] = []; + const cursors = new Set(); + let cursor: string | null = null; + let snapshot: string | null | undefined; + for ( + let pageIndex = 0; + pageIndex < dependencies.profile.maxPages; + pageIndex += 1 + ) { + if (context.signal?.aborted) { + return failure("REQUEST_ABORTED", "PAGINATION_ABORTED"); + } + const result = await dependencies.loadPage(cursor, context); + if (!result.ok) return result; + const page = result.value; + if (!isValidPage(page, dependencies.profile)) { + return failure( + "PAGINATION_CONTRACT_VIOLATION", + "PAGINATION_PAGE_INVALID", + ); + } + if (snapshot === undefined) { + snapshot = page.snapshotToken; + } else if (snapshot !== page.snapshotToken) { + return failure( + "PAGINATION_CONTRACT_VIOLATION", + "PAGINATION_SNAPSHOT_CHANGED", + ); + } + items.push(...page.items); + if ( + items.length > dependencies.profile.maxTotalItems || + estimatedBytes(items) > dependencies.profile.maxEstimatedBytes + ) { + return failure( + "RESULT_LIMIT_EXCEEDED", + "PAGINATION_RESULT_LIMIT", + ); + } + if (!page.hasMore) return { ok: true, value: Object.freeze(items) }; + const nextCursor = page.nextCursor; + if (!nextCursor || cursors.has(nextCursor)) { + return failure( + "PAGINATION_CONTRACT_VIOLATION", + "PAGINATION_CURSOR_LOOP", + ); + } + cursors.add(nextCursor); + cursor = nextCursor; + } + return failure( + "RESULT_LIMIT_EXCEEDED", + "PAGINATION_PAGE_LIMIT", + ); + }, + }); + + function failure( + kind: + | "PAGINATION_CONTRACT_VIOLATION" + | "RESULT_LIMIT_EXCEEDED" + | "REQUEST_ABORTED", + code: string, + ) { + return { + ok: false as const, + error: createFailure(kind, dependencies.definitionId, 0, { code }), + }; + } +} + +function validateProfile(profile: CursorPaginationProfile): void { + if ( + !profile.profileId || + !Number.isSafeInteger(profile.maxPages) || + profile.maxPages < 1 || + profile.maxPages > 100 || + !Number.isSafeInteger(profile.maxTotalItems) || + profile.maxTotalItems < 1 || + !Number.isSafeInteger(profile.maxEstimatedBytes) || + profile.maxEstimatedBytes < 1 || + !Number.isSafeInteger(profile.maxCursorBytes) || + profile.maxCursorBytes < 1 || + profile.maxCursorBytes > 4_096 + ) { + throw new TypeError("Invalid cursor pagination profile."); + } +} + +function isValidPage( + page: CursorPage, + profile: CursorPaginationProfile, +): boolean { + const encoder = new TextEncoder(); + return ( + Boolean(page) && + Array.isArray(page.items) && + typeof page.hasMore === "boolean" && + page.hasMore === (page.nextCursor !== null) && + (page.nextCursor === null || + (typeof page.nextCursor === "string" && + page.nextCursor.length > 0 && + encoder.encode(page.nextCursor).byteLength <= + profile.maxCursorBytes)) && + (page.snapshotToken === null || + (typeof page.snapshotToken === "string" && + page.snapshotToken.length > 0 && + encoder.encode(page.snapshotToken).byteLength <= + profile.maxCursorBytes)) && + (profile.allowSparsePage || !page.hasMore || page.items.length > 0) + ); +} + +function estimatedBytes(value: unknown): number { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return Number.POSITIVE_INFINITY; + } +} diff --git a/src/adapters/query-cache/server-state-scope-runtime.ts b/src/adapters/query-cache/server-state-scope-runtime.ts new file mode 100644 index 0000000..8aea9da --- /dev/null +++ b/src/adapters/query-cache/server-state-scope-runtime.ts @@ -0,0 +1,202 @@ +import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts"; +import type { QueryInvalidationCoordinator } from "../../contracts/query-invalidation.ts"; +import { + createRuntimeIdentityRegistry, + type RuntimeIdentityRegistry, +} from "../../contracts/query-keys.ts"; +import type { + CacheScopeSnapshot, + ClientScopeLifecycleEvent, + ClientScopePhase, + ServerStateScopeRuntime, +} from "../../contracts/server-state-scope.ts"; + +/** + * Steps 4-11 of §10.6 that this runtime does not own directly. Each optional + * capability registers its own closer so the ordering lives in one place rather + * than being re-derived by every subsystem. + */ +export type ScopeResetParticipant = Readonly<{ + /** Lower runs earlier; the §10.6 step number is used as the rank. */ + order: number; + label: string; + close(): void | Promise; +}>; + +export type ServerStateScopeDependencies = Readonly<{ + session: Pick; + queryInvalidation: Pick; + tokenFactory?: () => string; + participants?: readonly ScopeResetParticipant[]; + activateNextGeneration?: () => void | Promise; +}>; + +export function createServerStateScopeRuntime( + dependencies: ServerStateScopeDependencies, +): ServerStateScopeRuntime { + const listeners = new Set<() => void>(); + const lifecycleListeners = new Set< + (event: ClientScopeLifecycleEvent) => void + >(); + const participants = [...(dependencies.participants ?? [])].sort( + (left, right) => left.order - right.order, + ); + + let generation = 1; + let identities = newIdentityRegistry(dependencies.tokenFactory); + let fingerprint = scopeFingerprint(dependencies.tokenFactory); + let generationLifetime = new AbortController(); + let phase: ClientScopePhase = "READY"; + let disposed = false; + let resetChain = Promise.resolve(); + + function createSnapshot(): CacheScopeSnapshot { + const capturedGeneration = generation; + const capturedIdentities = identities; + return Object.freeze({ + generation: capturedGeneration, + fingerprint, + identities: capturedIdentities, + signal: generationLifetime.signal, + isCurrent: () => + !disposed && + phase === "READY" && + generation === capturedGeneration && + identities === capturedIdentities, + }); + } + let currentSnapshot = createSnapshot(); + + function publishLifecycle(event: ClientScopeLifecycleEvent): void { + for (const listener of [...lifecycleListeners]) { + try { + listener(event); + } catch { + // One subscriber defect cannot stop the fence from propagating. + } + } + } + + function publishSnapshot(): void { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + // Subscriber defects are isolated from the mandatory reset sequence. + } + } + } + + const unsubscribe = dependencies.session.subscribe(() => { + if (disposed) return; + const previousIdentities = identities; + const previousGeneration = generation; + + // §10.6 steps 1-3 are synchronous: increment the generation, invalidate the + // old snapshot, publish FENCED. Nothing between here and READY may render a + // value that belonged to the previous identity. + generationLifetime.abort(); + const targetGeneration = ++generation; + phase = "FENCED"; + currentSnapshot = createSnapshot(); + publishLifecycle( + Object.freeze({ kind: "FENCED" as const, previousGeneration }), + ); + publishSnapshot(); + + resetChain = resetChain + .catch(() => {}) + .then(async () => { + let failed = false; + // Steps 4-11: close admission, cancel and clear, release leases. + for (const participant of participants) { + try { + await participant.close(); + } catch { + failed = true; + } + } + try { + await dependencies.queryInvalidation.resetLocal(); + } catch { + failed = true; + } + + previousIdentities.close(); + if (disposed || generation !== targetGeneration) return; + + if (!failed) { + try { + await dependencies.activateNextGeneration?.(); + } catch { + failed = true; + } + } + if (disposed || generation !== targetGeneration) return; + + if (failed) { + phase = "FAILED"; + currentSnapshot = createSnapshot(); + publishLifecycle( + Object.freeze({ + kind: "FAILED" as const, + generation: targetGeneration, + }), + ); + publishSnapshot(); + return; + } + + // Steps 12-15: new identity registry, READY, notify, reopen admission. + identities = newIdentityRegistry(dependencies.tokenFactory); + fingerprint = scopeFingerprint(dependencies.tokenFactory); + generationLifetime = new AbortController(); + phase = "READY"; + currentSnapshot = createSnapshot(); + publishLifecycle( + Object.freeze({ kind: "READY" as const, snapshot: currentSnapshot }), + ); + publishSnapshot(); + }); + }); + + return Object.freeze({ + getSnapshot: () => currentSnapshot, + getPhase: () => phase, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + subscribeLifecycle(listener: (event: ClientScopeLifecycleEvent) => void) { + lifecycleListeners.add(listener); + return () => lifecycleListeners.delete(listener); + }, + dispose() { + if (disposed) return; + disposed = true; + phase = "DISPOSED"; + generationLifetime.abort(); + unsubscribe(); + publishLifecycle(Object.freeze({ kind: "DISPOSED" as const })); + listeners.clear(); + lifecycleListeners.clear(); + identities.close(); + }, + }); +} + +function newIdentityRegistry( + tokenFactory: (() => string) | undefined, +): RuntimeIdentityRegistry { + return createRuntimeIdentityRegistry({ + ...(tokenFactory ? { tokenFactory } : {}), + }); +} + +function scopeFingerprint(tokenFactory: (() => string) | undefined): string { + const candidate = tokenFactory?.() ?? crypto.randomUUID(); + if (!/^[A-Za-z0-9._:-]{16,128}$/.test(candidate)) { + throw new TypeError("Invalid cache scope fingerprint."); + } + return candidate; +} diff --git a/src/adapters/query-cache/tanstack-cache-coordinator.ts b/src/adapters/query-cache/tanstack-cache-coordinator.ts new file mode 100644 index 0000000..8855fa5 --- /dev/null +++ b/src/adapters/query-cache/tanstack-cache-coordinator.ts @@ -0,0 +1,319 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; +import type { + InvalidationRegistryIndex, + QueryInvalidationCoordinator, + QueryInvalidationTopic, + QueryMutationLease, +} from "../../contracts/query-invalidation.ts"; +import { INVALIDATION_REGISTRY_BOUNDS } from "../../contracts/query-invalidation.ts"; +import { + createQueryInvalidationPrefix, + queryNamespaceIdentityKey, + type QueryNamespaceIdentity, +} from "../../contracts/query-keys.ts"; +import type { + BrowserCrossContextInvalidation, + CrossContextInvalidationDelivery, +} from "../cross-context-invalidation/index.ts"; + +export type TanStackCacheCoordinatorDependencies = Readonly<{ + queryClient: QueryClient; + invalidationIndex: InvalidationRegistryIndex; + topicVersions: ReadonlyMap; + crossContext?: BrowserCrossContextInvalidation; + diagnostics?: DiagnosticsPort; +}>; + +/** + * Joins registry-owned invalidation topics to TanStack Query without putting a + * query key or cached value on the cross-context wire. + */ +export function createTanStackCacheCoordinator( + dependencies: TanStackCacheCoordinatorDependencies, +): QueryInvalidationCoordinator { + validateConfiguration( + dependencies.invalidationIndex, + dependencies.topicVersions, + ); + const mutationLeases = new Map(); + const pendingRemote = new Set(); + let disposed = false; + let resetting = false; + let lifecycleGeneration = 0; + let flushPromise: Promise | null = null; + let resetPromise: Promise | null = null; + + const unsubscribe = dependencies.crossContext?.subscribe((delivery) => { + receiveRemote(delivery); + }); + + function namespacesFor( + topic: QueryInvalidationTopic, + ): readonly QueryNamespaceIdentity[] { + const selected = dependencies.invalidationIndex.namespacesForTopic.get(topic); + if (!selected) { + throw new TypeError("Unregistered query invalidation topic."); + } + return selected; + } + + async function invalidateLocal( + topics: readonly QueryInvalidationTopic[], + expectedGeneration = lifecycleGeneration, + ): Promise { + if ( + disposed || + resetting || + expectedGeneration !== lifecycleGeneration + ) { + return; + } + const namespaces = new Map(); + for (const topic of topics) { + for (const namespace of namespacesFor(topic)) { + namespaces.set(queryNamespaceIdentityKey(namespace), namespace); + } + } + for (const namespace of namespaces.values()) { + try { + await dependencies.queryClient.invalidateQueries({ + queryKey: createQueryInvalidationPrefix(namespace), + exact: false, + refetchType: "active", + }); + } catch { + report("invalidate"); + } + } + } + + function receiveRemote( + delivery: CrossContextInvalidationDelivery, + ): void { + if (disposed) return; + if ( + !dependencies.invalidationIndex.namespacesForTopic.has( + delivery.event.topic, + ) + ) { + report("unknown-topic"); + return; + } + const selectedTopic = delivery.event.topic as QueryInvalidationTopic; + + if (delivery.ordering === "GAP") { + for (const topic of dependencies.invalidationIndex.namespacesForTopic.keys()) { + pendingRemote.add(topic as QueryInvalidationTopic); + } + report("sequence-gap"); + } else { + pendingRemote.add(selectedTopic); + } + if (!resetting) void flushRemote(); + } + + function flushRemote(): Promise { + if (disposed || resetting) return Promise.resolve(); + if (flushPromise) return flushPromise; + const expectedGeneration = lifecycleGeneration; + + flushPromise = Promise.resolve() + .then(async () => { + while ( + !disposed && + !resetting && + expectedGeneration === lifecycleGeneration + ) { + const ready = [...pendingRemote].filter( + (topic) => (mutationLeases.get(topic) ?? 0) === 0, + ); + if (ready.length === 0) return; + for (const topic of ready) { + pendingRemote.delete(topic); + } + await invalidateLocal(ready, expectedGeneration); + } + }) + .catch(() => { + report("remote-flush"); + }) + .finally(() => { + flushPromise = null; + if ( + !disposed && + !resetting && + [...pendingRemote].some( + (topic) => (mutationLeases.get(topic) ?? 0) === 0, + ) + ) { + void flushRemote(); + } + }); + return flushPromise; + } + + function uniqueTopics( + topics: readonly QueryInvalidationTopic[], + ): readonly QueryInvalidationTopic[] { + const unique = [...new Set(topics)]; + for (const topic of unique) namespacesFor(topic); + return unique; + } + + function report(operation: string): void { + try { + dependencies.diagnostics?.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation, + error_kind: "QUERY_CACHE_FAILURE", + }, + }); + } catch { + // Cache correctness and cleanup do not depend on diagnostics. + } + } + + return Object.freeze({ + async invalidate( + topics: readonly QueryInvalidationTopic[], + ): Promise { + if (disposed) return; + const selectedTopics = uniqueTopics(topics); + await invalidateLocal(selectedTopics); + for (const topic of selectedTopics) { + const topicVersion = dependencies.topicVersions.get(topic); + if (topicVersion === undefined) { + throw new TypeError("Unregistered query invalidation topic."); + } + const published = dependencies.crossContext?.publish({ + topic, + topicVersion, + }); + if (published && !published.ok) { + report("cross-context-publish"); + } + } + }, + + beginMutation( + topics: readonly QueryInvalidationTopic[], + ): QueryMutationLease { + if (disposed) { + throw new TypeError("Query invalidation coordinator is disposed."); + } + const selectedTopics = uniqueTopics(topics); + for (const topic of selectedTopics) { + mutationLeases.set( + topic, + (mutationLeases.get(topic) ?? 0) + 1, + ); + } + let released = false; + return Object.freeze({ + async release() { + if (released) return; + released = true; + for (const topic of selectedTopics) { + const remaining = (mutationLeases.get(topic) ?? 1) - 1; + if (remaining <= 0) { + mutationLeases.delete(topic); + } else { + mutationLeases.set(topic, remaining); + } + } + await flushRemote(); + }, + }); + }, + + async resetLocal() { + if (disposed) return; + if (resetPromise) return resetPromise; + resetting = true; + lifecycleGeneration += 1; + pendingRemote.clear(); + mutationLeases.clear(); + const activeFlush = flushPromise; + resetPromise = (async () => { + try { + await activeFlush; + } catch { + report("reset-flush"); + } + let cancellationFailed = false; + try { + await dependencies.queryClient.cancelQueries(); + } catch { + report("reset-cancel"); + cancellationFailed = true; + } + dependencies.queryClient.clear(); + if (cancellationFailed) { + throw new TypeError("mandatory query cancellation failed"); + } + })().finally(() => { + resetting = false; + resetPromise = null; + if ( + !disposed && + [...pendingRemote].some( + (topic) => (mutationLeases.get(topic) ?? 0) === 0, + ) + ) { + void flushRemote(); + } + }); + return resetPromise; + }, + + dispose() { + if (disposed) return; + disposed = true; + unsubscribe?.(); + dependencies.crossContext?.close(); + pendingRemote.clear(); + mutationLeases.clear(); + flushPromise = null; + resetPromise = null; + }, + }); +} + +function validateConfiguration( + index: InvalidationRegistryIndex, + topicVersions: ReadonlyMap, +): void { + if ( + index.namespacesForTopic.size > INVALIDATION_REGISTRY_BOUNDS.maxTopics || + topicVersions.size !== index.namespacesForTopic.size + ) { + throw new TypeError("Query invalidation registry is invalid."); + } + for (const [topic, namespaces] of index.namespacesForTopic) { + const version = topicVersions.get(topic); + if ( + namespaces.length < 1 || + !Number.isSafeInteger(version) || + (version ?? 0) < 1 + ) { + throw new TypeError("Query invalidation registry is invalid."); + } + const namespaceKeys = new Set(); + for (const namespace of namespaces) { + const key = queryNamespaceIdentityKey(namespace); + if (namespaceKeys.has(key)) { + throw new TypeError("Query invalidation registry is invalid."); + } + namespaceKeys.add(key); + } + } + for (const topic of topicVersions.keys()) { + if (!index.namespacesForTopic.has(topic)) { + throw new TypeError("Query invalidation registry is invalid."); + } + } +} diff --git a/src/adapters/query-cache/tanstack-query-cache.ts b/src/adapters/query-cache/tanstack-query-cache.ts new file mode 100644 index 0000000..06d6eb0 --- /dev/null +++ b/src/adapters/query-cache/tanstack-query-cache.ts @@ -0,0 +1,115 @@ +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; + +import { createFailure } from "../../contracts/errors.ts"; +import { safeErrorKind } from "../../contracts/diagnostics.ts"; +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; +import type { QueryCachePort } from "../../application/ports/query-cache-port.ts"; + +export type QueryCacheDependencies = Readonly<{ + diagnostics?: DiagnosticsPort; +}>; + +export const QUERY_CACHE_DEFAULTS = Object.freeze({ + staleTime: 30_000, + gcTime: 300_000, + refetchOnWindowFocus: true, + retry: false, + mutationRetry: false, + persistence: false, +}); + +export function createQueryClient( + dependencies: QueryCacheDependencies = {}, +): QueryClient { + function report(operation: string, error: unknown): void { + try { + dependencies.diagnostics?.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation, + error_kind: safeErrorKind(error), + }, + }); + } catch { + // Query behavior remains independent from diagnostics. + } + } + return new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => report("query", error), + }), + mutationCache: new MutationCache({ + onError: (error) => report("mutation", error), + }), + defaultOptions: { + queries: { + staleTime: QUERY_CACHE_DEFAULTS.staleTime, + gcTime: QUERY_CACHE_DEFAULTS.gcTime, + refetchOnWindowFocus: QUERY_CACHE_DEFAULTS.refetchOnWindowFocus, + retry: QUERY_CACHE_DEFAULTS.retry, + }, + mutations: { + retry: QUERY_CACHE_DEFAULTS.mutationRetry, + }, + }, + }); +} + +export function createQueryCacheAdapter( + queryClient: QueryClient, + dependencies: QueryCacheDependencies = {}, +): QueryCachePort { + return Object.freeze({ + read(key) { + try { + return { ok: true, value: queryClient.getQueryData(key) }; + } catch { + return cacheFailure("read", key, dependencies.diagnostics); + } + }, + write(key, value) { + try { + queryClient.setQueryData(key, structuredClone(value)); + return { ok: true }; + } catch { + return cacheFailure("write", key, dependencies.diagnostics); + } + }, + async invalidate(namespace) { + try { + await queryClient.invalidateQueries({ queryKey: namespace, exact: false }); + return { ok: true }; + } catch { + return cacheFailure("invalidate", namespace, dependencies.diagnostics); + } + }, + }); +} + +function cacheFailure( + phase: string, + key: readonly unknown[], + diagnostics: DiagnosticsPort | undefined, +): Readonly<{ ok: false; error: ReturnType }> { + const namespace = typeof key[0] === "string" ? key[0] : "unknown"; + try { + diagnostics?.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation: phase, + error_kind: "QUERY_CACHE_FAILURE", + }, + }); + } catch { + // Cache behavior remains independent from diagnostics. + } + return { + ok: false, + error: createFailure("QUERY_CACHE_FAILURE", "QUERY_CACHE", 0, { + code: `QUERY_CACHE_${phase.toUpperCase()}_FAILED`, + causeClass: `namespace:${namespace}`, + }), + }; +} diff --git a/src/adapters/realtime/event-codec.ts b/src/adapters/realtime/event-codec.ts new file mode 100644 index 0000000..9073801 --- /dev/null +++ b/src/adapters/realtime/event-codec.ts @@ -0,0 +1,394 @@ +import { + realtimeFailure, + realtimeSuccess, + type RealtimeResult, +} from "./result.ts"; +import { + isCanonicalRealtimeSequence, + isRealtimeOpaqueIdentifier, + isRealtimeResumeCursor, + isRealtimeScopeBinding, + isStrictRealtimeTimestamp, + type RealtimeEventEnvelope, +} from "../../contracts/realtime-events.ts"; +import { + REALTIME_EVENT_PROTOCOL, + REALTIME_HARD_LIMITS, + type RealtimeEventTypeRegistration, + type RealtimePolicyRegistry, + type RealtimeStreamRegistration, +} from "../../contracts/realtime-streams.ts"; +import { + validateWithRuntimeSchemaRegistry, + type RuntimeSchemaCodec, +} from "../../contracts/schema-registry.ts"; +import { + hasDuplicateJsonMembers, +} from "./json-member-scanner.ts"; + +export type ValidatedRealtimeEventDto = Readonly<{ + envelope: RealtimeEventEnvelope; + wireBytes: number; + /** + * Adapter-private semantic identity used only by the bounded conflict + * detector. It must never be logged or projected into diagnostics. + */ + semanticFingerprint: string; + fingerprintBytes: number; +}>; + +export type RealtimeEventCodec = Readonly<{ + decode(raw: string): RealtimeResult; +}>; + +export type RealtimeEventCodecDependencies = Readonly<{ + registry: RealtimePolicyRegistry; + schemaCodecs: Readonly>; +}>; + +const ENVELOPE_KEYS = Object.freeze([ + "eventId", + "eventType", + "occurredAt", + "payload", + "protocol", + "recoveryMode", + "resumeCursor", + "scopeBinding", + "sequence", + "streamEpoch", + "streamId", +] as const); +const FORBIDDEN_OBJECT_KEYS = new Set([ + "__proto__", + "constructor", + "prototype", +]); +const encoder = new TextEncoder(); +const issuedDtos = new WeakSet(); + +export function createRealtimeEventCodec( + dependencies: RealtimeEventCodecDependencies, +): RealtimeEventCodec { + return Object.freeze({ + decode(raw: string): RealtimeResult { + try { + return decodeUnsafe(raw, dependencies); + } catch { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + }, + }); +} + +export function isValidatedRealtimeEventDto( + value: unknown, +): value is ValidatedRealtimeEventDto { + return ( + !!value && + typeof value === "object" && + issuedDtos.has(value) && + Object.isFrozen(value) + ); +} + +function decodeUnsafe( + raw: string, + dependencies: RealtimeEventCodecDependencies, +): RealtimeResult { + if (typeof raw !== "string") { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + if ( + raw.length > REALTIME_HARD_LIMITS.maxEventBytes || + encoder.encode(raw).byteLength > REALTIME_HARD_LIMITS.maxEventBytes + ) { + return realtimeFailure("EVENT_TOO_LARGE", "DECODE"); + } + const wireBytes = encoder.encode(raw).byteLength; + + let input: unknown; + if ( + hasDuplicateJsonMembers(raw, { + maxDepth: REALTIME_HARD_LIMITS.maxPayloadDepth + 2, + maxMembers: REALTIME_HARD_LIMITS.maxPayloadNodes + 32, + }) + ) { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + try { + input = JSON.parse(raw); + } catch { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + if (!hasExactEnvelopeKeys(input)) { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + if (input.protocol !== REALTIME_EVENT_PROTOCOL) { + return realtimeFailure("PROTOCOL_MISMATCH", "DECODE"); + } + if (typeof input.streamId !== "string") { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + const stream = dependencies.registry.findStream(input.streamId); + if (!stream) { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + if (wireBytes > stream.limits.maxEventBytes) { + return realtimeFailure("EVENT_TOO_LARGE", "DECODE"); + } + if (typeof input.eventType !== "string") { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + const eventType = dependencies.registry.findStreamEventType( + stream.id, + input.eventType, + ); + if (!eventType) { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + if (!hasValidEnvelopeFields(input, stream)) { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + if ( + !withinJsonBudget( + input.payload, + stream.limits.maxPayloadDepth, + stream.limits.maxPayloadNodes, + ) + ) { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + + const payload = validateWithRuntimeSchemaRegistry( + eventType.payloadSchemaId, + input.payload, + dependencies.schemaCodecs, + ); + if (!payload.success) { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + + let payloadSnapshot: unknown; + try { + payloadSnapshot = snapshotJson( + payload.data, + stream.limits.maxPayloadDepth, + stream.limits.maxPayloadNodes, + ); + } catch { + return realtimeFailure("MALFORMED_EVENT", "DECODE"); + } + + const envelope = createEnvelope( + input, + stream, + eventType, + payloadSnapshot, + ); + const semanticFingerprint = canonicalJson(envelope); + const fingerprintBytes = encoder.encode(semanticFingerprint).byteLength; + if (fingerprintBytes > stream.limits.maxEventBytes) { + return realtimeFailure("EVENT_TOO_LARGE", "DECODE"); + } + + const dto = Object.freeze({ + envelope, + wireBytes, + semanticFingerprint, + fingerprintBytes, + }); + issuedDtos.add(dto); + return realtimeSuccess(dto); +} + +function hasValidEnvelopeFields( + input: Readonly>, + stream: RealtimeStreamRegistration, +): boolean { + if ( + !isRealtimeOpaqueIdentifier(input.streamEpoch) || + !isRealtimeOpaqueIdentifier(input.eventId) || + !isCanonicalRealtimeSequence(input.sequence) || + !isStrictRealtimeTimestamp(input.occurredAt) || + !isRealtimeScopeBinding(input.scopeBinding) || + input.recoveryMode !== stream.recovery.mode + ) { + return false; + } + return stream.recovery.mode === "CURSOR" + ? isRealtimeResumeCursor(input.resumeCursor) + : input.resumeCursor === null; +} + +function createEnvelope( + input: Readonly>, + stream: RealtimeStreamRegistration, + eventType: RealtimeEventTypeRegistration, + payload: unknown, +): RealtimeEventEnvelope { + const base = { + protocol: REALTIME_EVENT_PROTOCOL, + streamId: stream.id, + streamEpoch: input.streamEpoch as string, + eventType: eventType.id, + eventId: input.eventId as string, + sequence: input.sequence as string, + occurredAt: input.occurredAt as string, + scopeBinding: input.scopeBinding as string, + payload, + }; + return stream.recovery.mode === "CURSOR" + ? Object.freeze({ + ...base, + recoveryMode: "CURSOR" as const, + resumeCursor: input.resumeCursor as string, + }) + : Object.freeze({ + ...base, + recoveryMode: stream.recovery.mode, + resumeCursor: null, + }); +} + +function hasExactEnvelopeKeys( + value: unknown, +): value is Readonly> { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return false; + } + const keys = Object.keys(value).sort(); + return ( + keys.length === ENVELOPE_KEYS.length && + keys.every((key, index) => key === ENVELOPE_KEYS[index]) + ); +} + +function withinJsonBudget( + value: unknown, + maxDepth: number, + maxNodes: number, +): boolean { + let nodes = 0; + const visit = (candidate: unknown, depth: number): boolean => { + nodes += 1; + if (nodes > maxNodes || depth > maxDepth) return false; + if ( + candidate === null || + typeof candidate === "string" || + typeof candidate === "boolean" || + (typeof candidate === "number" && Number.isFinite(candidate)) + ) { + return true; + } + if (Array.isArray(candidate)) { + return candidate.every((item) => visit(item, depth + 1)); + } + if ( + !candidate || + typeof candidate !== "object" || + Object.getPrototypeOf(candidate) !== Object.prototype + ) { + return false; + } + return Object.entries(candidate).every( + ([key, item]) => + !FORBIDDEN_OBJECT_KEYS.has(key) && visit(item, depth + 1), + ); + }; + return visit(value, 0); +} + +function snapshotJson( + value: unknown, + maxDepth: number, + maxNodes: number, +): unknown { + const seen = new WeakSet(); + let nodes = 0; + + const visit = (candidate: unknown, depth: number): unknown => { + nodes += 1; + if (nodes > maxNodes || depth > maxDepth) { + throw new TypeError("Realtime payload exceeds its structural budget."); + } + if ( + candidate === null || + typeof candidate === "string" || + typeof candidate === "boolean" || + (typeof candidate === "number" && Number.isFinite(candidate)) + ) { + return candidate; + } + if (!candidate || typeof candidate !== "object") { + throw new TypeError("Realtime payload is not JSON-compatible."); + } + if (seen.has(candidate)) { + throw new TypeError("Realtime payload contains shared object identity."); + } + seen.add(candidate); + + if (Array.isArray(candidate)) { + for (let index = 0; index < candidate.length; index += 1) { + if (!Object.hasOwn(candidate, index)) { + throw new TypeError("Realtime payload contains a sparse array."); + } + } + return Object.freeze( + candidate.map((item) => visit(item, depth + 1)), + ); + } + if ( + Object.getPrototypeOf(candidate) !== Object.prototype && + Object.getPrototypeOf(candidate) !== null + ) { + throw new TypeError("Realtime payload requires plain objects."); + } + const output: Record = Object.create(null); + const descriptors = Object.getOwnPropertyDescriptors(candidate); + for (const key of Object.keys(descriptors).sort()) { + if (FORBIDDEN_OBJECT_KEYS.has(key)) { + throw new TypeError("Realtime payload contains a forbidden key."); + } + const descriptor = descriptors[key]; + if (!descriptor || !("value" in descriptor)) { + throw new TypeError("Realtime payload contains an accessor."); + } + output[key] = visit(descriptor.value, depth + 1); + } + return Object.freeze(output); + }; + + return visit(value, 0); +} + +function canonicalJson(value: unknown): string { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" + ) { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (!value || typeof value !== "object") { + throw new TypeError("Realtime semantic identity is invalid."); + } + return `{${Object.keys(value) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonicalJson( + (value as Readonly>)[key], + )}`, + ) + .join(",")}}`; +} diff --git a/src/adapters/realtime/event-consumer.ts b/src/adapters/realtime/event-consumer.ts new file mode 100644 index 0000000..b1db23f --- /dev/null +++ b/src/adapters/realtime/event-consumer.ts @@ -0,0 +1,226 @@ +import type { + RealtimeAcceptDisposition, + RealtimeTransportEventOutcome, +} from "../../application/ports/realtime/event-authority.ts"; +import { + REALTIME_TRANSPORT_CONTINUE, + realtimeTransportRecoveryCommitted, +} from "../../application/ports/realtime/event-authority.ts"; +import type { + RealtimeResult, +} from "../../application/ports/realtime/shared.ts"; +import { + isRealtimeResumeCursor, +} from "../../contracts/realtime-events.ts"; +import type { + StreamRegistrationId, +} from "../../contracts/realtime-streams.ts"; +import type { + RealtimeEventCodec, +} from "./event-codec.ts"; +import type { + RealtimeStreamCoordinator, +} from "./stream-coordinator.ts"; +import { + realtimeFailure, + realtimeSuccess, +} from "./result.ts"; + +export type RealtimeTransportCursor = + | Readonly<{ + kind: "SSE_DIRECT_CURSOR"; + eventId: string; + }> + | Readonly<{ kind: "SSE_NO_CURSOR" }> + | Readonly<{ kind: "ENCAPSULATED" }>; + +export type RealtimeEventConsumer = Readonly<{ + consume( + rawEnvelope: string, + cursor: RealtimeTransportCursor, + signal?: AbortSignal, + ): Promise>; + consumeEncapsulated( + envelope: Readonly>, + signal?: AbortSignal, + ): Promise>; + consumeForTransport( + rawEnvelope: string, + cursor: RealtimeTransportCursor, + signal?: AbortSignal, + ): Promise>; + consumeEncapsulatedForTransport( + envelope: Readonly>, + signal?: AbortSignal, + ): Promise>; +}>; + +/** + * The single handoff from transport bytes to the common event authority. + * SSE's transport-level `id` is checked here against the validated envelope; + * WebSocket can carry the same envelope without inventing a second cursor. + */ +export function createRealtimeEventConsumer( + dependencies: Readonly<{ + codec: RealtimeEventCodec; + coordinator: Pick; + }>, +): RealtimeEventConsumer { + async function consumeWithStream( + rawEnvelope: string, + cursor: RealtimeTransportCursor, + signal?: AbortSignal, + ): Promise< + Readonly<{ + streamId: StreamRegistrationId | null; + result: RealtimeResult; + }> + > { + if (signal?.aborted) { + return { + streamId: null, + result: realtimeFailure("ABORTED", "RECEIVE"), + }; + } + const decoded = dependencies.codec.decode(rawEnvelope); + if (!decoded.ok) { + return { streamId: null, result: decoded }; + } + const envelope = decoded.value.envelope; + if ( + (cursor.kind === "SSE_DIRECT_CURSOR" && + (!isRealtimeResumeCursor(cursor.eventId) || + envelope.recoveryMode !== "CURSOR" || + envelope.resumeCursor !== cursor.eventId)) || + (cursor.kind === "SSE_NO_CURSOR" && + (envelope.recoveryMode === "CURSOR" || + envelope.resumeCursor !== null)) + ) { + return { + streamId: envelope.streamId, + result: realtimeFailure( + "PROTOCOL_MISMATCH", + "RECEIVE", + ), + }; + } + return { + streamId: envelope.streamId, + result: await dependencies.coordinator.accept( + decoded.value, + signal, + ), + }; + } + + async function consume( + rawEnvelope: string, + cursor: RealtimeTransportCursor, + signal?: AbortSignal, + ): Promise> { + return ( + await consumeWithStream(rawEnvelope, cursor, signal) + ).result; + } + + async function consumeEncapsulated( + envelope: Readonly>, + signal?: AbortSignal, + ): Promise> { + const serialized = serializeEnvelope(envelope); + if (!serialized.ok) return serialized; + return await consume( + serialized.value, + { kind: "ENCAPSULATED" }, + signal, + ); + } + + async function consumeForTransport( + rawEnvelope: string, + cursor: RealtimeTransportCursor, + signal?: AbortSignal, + ): Promise> { + const consumed = await consumeWithStream( + rawEnvelope, + cursor, + signal, + ); + return projectTransportOutcome( + consumed.result, + consumed.streamId, + ); + } + + async function consumeEncapsulatedForTransport( + envelope: Readonly>, + signal?: AbortSignal, + ): Promise> { + const serialized = serializeEnvelope(envelope); + if (!serialized.ok) return serialized; + return await consumeForTransport( + serialized.value, + { kind: "ENCAPSULATED" }, + signal, + ); + } + + return Object.freeze({ + consume, + consumeEncapsulated, + consumeForTransport, + consumeEncapsulatedForTransport, + }); +} + +function projectTransportOutcome( + accepted: RealtimeResult, + streamId: StreamRegistrationId | null, +): RealtimeResult { + if (!accepted.ok) return accepted; + if ( + accepted.value.outcome === "RECOVERED" || + accepted.value.outcome === "RECOVERY_BARRIER_REQUIRED" + ) { + if (streamId === null) { + return realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE"); + } + return realtimeSuccess( + realtimeTransportRecoveryCommitted( + streamId, + accepted.value.resumeState, + ), + ); + } + if (accepted.value.outcome === "APPLIED") { + return realtimeSuccess(REALTIME_TRANSPORT_CONTINUE); + } + switch (accepted.value.reason) { + case "DUPLICATE_EVENT": + case "STALE_EVENT": + return realtimeSuccess(REALTIME_TRANSPORT_CONTINUE); + case "RECOVERY_IN_PROGRESS": + return realtimeFailure( + "PROTOCOL_MISMATCH", + "RECEIVE", + ); + case "CLOSED": + return realtimeFailure("CLOSED", "RECEIVE"); + case "SCOPE_FENCED": + return realtimeFailure("SCOPE_FENCED", "RECEIVE"); + } +} + +function serializeEnvelope( + envelope: Readonly>, +): RealtimeResult { + let rawEnvelope: string; + try { + rawEnvelope = JSON.stringify(envelope); + } catch { + return realtimeFailure("MALFORMED_EVENT", "RECEIVE"); + } + return typeof rawEnvelope === "string" + ? realtimeSuccess(rawEnvelope) + : realtimeFailure("MALFORMED_EVENT", "RECEIVE"); +} diff --git a/src/adapters/realtime/index.ts b/src/adapters/realtime/index.ts new file mode 100644 index 0000000..953b018 --- /dev/null +++ b/src/adapters/realtime/index.ts @@ -0,0 +1,58 @@ +export { + createRealtimeEventCodec, + isValidatedRealtimeEventDto, + type RealtimeEventCodec, + type RealtimeEventCodecDependencies, + type ValidatedRealtimeEventDto, +} from "./event-codec.ts"; +export { + createRealtimeEventConsumer, + type RealtimeEventConsumer, + type RealtimeTransportCursor, +} from "./event-consumer.ts"; +export { + calculateReconnectDelay, + defineReconnectPolicy, + isReconnectAttemptResetEligible, + parseRetryAfterDelay, + REALTIME_RECONNECT_CEILINGS, + reconnectBudgetRemaining, + type ReconnectDelayInput, + type ReconnectPolicy, +} from "./reconnect-policy.ts"; +export { + createRealtimeReconnectCoordinator, + type RealtimeCommittedRecovery, + type RealtimeReconnectAttemptContext, + type RealtimeReconnectAttemptSuccess, + type RealtimeReconnectCloseClassification, + type RealtimeReconnectCoordinator, + type RealtimeReconnectCoordinatorDependencies, + type RealtimeReconnectEnvironment, + type RealtimeReconnectOutcome, + type RealtimeReconnectRunInput, + type RealtimeReconnectSession, + type RealtimeRecoveryReconnectDirective, +} from "./reconnect-coordinator.ts"; +export { + createLivePollHandoffCoordinator, + LIVE_POLL_HANDOFF_CEILINGS, + type LivePollHandoffCoordinator, + type LivePollHandoffCoordinatorDependencies, + type LivePollHandoffInspection, + type LivePollHandoffLimits, + type LivePollHandoffRecoveryInput, + type LivePollHandoffState, + type LivePollWriterKind, + type LivePollWriterLease, + type LivePollWriteReceipt, + type LiveProbeLease, +} from "./live-poll-handoff-coordinator.ts"; +export { + createRealtimeStreamCoordinator, + type RealtimeStreamCoordinator, + type RealtimeStreamCoordinatorDependencies, +} from "./stream-coordinator.ts"; +export * from "./polling/index.ts"; +export * from "./sse/index.ts"; +export * from "./websocket/index.ts"; diff --git a/src/adapters/realtime/json-member-scanner.ts b/src/adapters/realtime/json-member-scanner.ts new file mode 100644 index 0000000..248f954 --- /dev/null +++ b/src/adapters/realtime/json-member-scanner.ts @@ -0,0 +1,218 @@ +type Container = + | { + kind: "OBJECT"; + state: "KEY_OR_END" | "COLON" | "VALUE" | "COMMA_OR_END"; + keys: Set; + } + | { + kind: "ARRAY"; + state: "VALUE_OR_END" | "COMMA_OR_END"; + }; + +/** + * Scans already byte-bounded JSON before `JSON.parse` can apply last-wins + * semantics. Invalid input and scanner budget exhaustion are both rejected. + */ +export function hasDuplicateJsonMembers( + source: string, + limits: Readonly<{ + maxDepth: number; + maxMembers: number; + }>, +): boolean { + try { + return scan(source, limits); + } catch { + return true; + } +} + +function scan( + source: string, + limits: Readonly<{ + maxDepth: number; + maxMembers: number; + }>, +): boolean { + if ( + typeof source !== "string" || + !Number.isSafeInteger(limits.maxDepth) || + limits.maxDepth < 1 || + !Number.isSafeInteger(limits.maxMembers) || + limits.maxMembers < 1 + ) { + return true; + } + + const stack: Container[] = []; + let cursor = skipWhitespace(source, 0); + let rootStarted = false; + let rootComplete = false; + let members = 0; + + const consumeValue = (): boolean => { + cursor = skipWhitespace(source, cursor); + const character = source[cursor]; + if (character === "{") { + if (stack.length + 1 > limits.maxDepth) return false; + stack.push({ + kind: "OBJECT", + state: "KEY_OR_END", + keys: new Set(), + }); + cursor += 1; + return true; + } + if (character === "[") { + if (stack.length + 1 > limits.maxDepth) return false; + stack.push({ kind: "ARRAY", state: "VALUE_OR_END" }); + cursor += 1; + return true; + } + if (character === "\"") { + const end = jsonStringEnd(source, cursor); + if (end === null) return false; + cursor = end; + return true; + } + const end = primitiveEnd(source, cursor); + if (end === cursor) return false; + cursor = end; + return true; + }; + + while (!rootComplete) { + if (!rootStarted) { + rootStarted = true; + if (!consumeValue()) return true; + if (stack.length === 0) rootComplete = true; + continue; + } + + const container = stack.at(-1); + if (!container) { + rootComplete = true; + continue; + } + cursor = skipWhitespace(source, cursor); + + if (container.kind === "ARRAY") { + if (container.state === "VALUE_OR_END") { + if (source[cursor] === "]") { + cursor += 1; + stack.pop(); + if (stack.length === 0) rootComplete = true; + continue; + } + container.state = "COMMA_OR_END"; + if (!consumeValue()) return true; + continue; + } + if (source[cursor] === ",") { + cursor += 1; + container.state = "VALUE_OR_END"; + continue; + } + if (source[cursor] === "]") { + cursor += 1; + stack.pop(); + if (stack.length === 0) rootComplete = true; + continue; + } + return true; + } + + if (container.state === "KEY_OR_END") { + if (source[cursor] === "}") { + cursor += 1; + stack.pop(); + if (stack.length === 0) rootComplete = true; + continue; + } + if (source[cursor] !== "\"") return true; + const end = jsonStringEnd(source, cursor); + if (end === null) return true; + const key = JSON.parse(source.slice(cursor, end)) as unknown; + if (typeof key !== "string" || container.keys.has(key)) { + return true; + } + members += 1; + if (members > limits.maxMembers) return true; + container.keys.add(key); + cursor = end; + container.state = "COLON"; + continue; + } + if (container.state === "COLON") { + if (source[cursor] !== ":") return true; + cursor += 1; + container.state = "VALUE"; + continue; + } + if (container.state === "VALUE") { + container.state = "COMMA_OR_END"; + if (!consumeValue()) return true; + continue; + } + if (source[cursor] === ",") { + cursor += 1; + container.state = "KEY_OR_END"; + continue; + } + if (source[cursor] === "}") { + cursor += 1; + stack.pop(); + if (stack.length === 0) rootComplete = true; + continue; + } + return true; + } + + return skipWhitespace(source, cursor) !== source.length; +} + +function jsonStringEnd(source: string, start: number): number | null { + let escaped = false; + for (let cursor = start + 1; cursor < source.length; cursor += 1) { + const character = source[cursor]; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === "\"") { + return cursor + 1; + } + } + return null; +} + +function primitiveEnd(source: string, start: number): number { + let cursor = start; + while ( + cursor < source.length && + source[cursor] !== "," && + source[cursor] !== "]" && + source[cursor] !== "}" && + !isWhitespace(source[cursor]) + ) { + cursor += 1; + } + return cursor; +} + +function skipWhitespace(source: string, start: number): number { + let cursor = start; + while (cursor < source.length && isWhitespace(source[cursor])) { + cursor += 1; + } + return cursor; +} + +function isWhitespace(character: string | undefined): boolean { + return ( + character === " " || + character === "\n" || + character === "\r" || + character === "\t" + ); +} diff --git a/src/adapters/realtime/live-poll-handoff-coordinator.ts b/src/adapters/realtime/live-poll-handoff-coordinator.ts new file mode 100644 index 0000000..83dd54e --- /dev/null +++ b/src/adapters/realtime/live-poll-handoff-coordinator.ts @@ -0,0 +1,883 @@ +import type { ClockPort } from "../../application/ports/clock-port.ts"; +import { + type RealtimeFailure, + type RealtimeOperation, + type RealtimeResult, +} from "../../application/ports/realtime/shared.ts"; +import { systemClock } from "../platform/system-clock.ts"; +import { + isRealtimeResult, + realtimeFailure, + realtimeSuccess, +} from "./result.ts"; + +export const LIVE_POLL_HANDOFF_CEILINGS = Object.freeze({ + maxQuiescenceTimeoutMs: 30_000, + maxActiveQueueCount: 256, + maxActiveQueueBytes: 4 * 1024 * 1024, + maxProbeBufferedEvents: 256, + maxProbeBufferedBytes: 4 * 1024 * 1024, + maxItemBytes: 64 * 1024, +} as const); + +export type LivePollHandoffState = + | "LIVE_ACTIVE" + | "POLL_ACTIVE" + | "LIVE_PROBING" + | "CLOSED"; + +export type LivePollWriterKind = "LIVE" | "POLL"; + +export type LivePollHandoffLimits = Readonly<{ + quiescenceTimeoutMs: number; + maxActiveQueueCount: number; + maxActiveQueueBytes: number; + maxProbeBufferedEvents: number; + maxProbeBufferedBytes: number; + maxItemBytes: number; +}>; + +export type LivePollWriteReceipt = Readonly<{ + kind: "APPLIED" | "BUFFERED"; + writer: LivePollWriterKind; + generation: number; +}>; + +export type LivePollWriterLease = Readonly<{ + writer: LivePollWriterKind; + generation: number; + signal: AbortSignal; + isCurrent(): boolean; + write( + value: Value, + wireBytes: number, + ): Promise>; +}>; + +export type LiveProbeLease = LivePollWriterLease & + Readonly<{ + writer: "LIVE"; + activate(): Promise>>; + cancel(): RealtimeResult>; + }>; + +export type LivePollHandoffInspection = Readonly<{ + state: LivePollHandoffState; + activeWriter: LivePollWriterKind | null; + activeGeneration: number | null; + probeGeneration: number | null; + bufferedEvents: number; + bufferedBytes: number; + transitioning: boolean; +}>; + +export type LivePollHandoffRecoveryInput = Readonly<{ + from: LivePollWriterKind; + to: LivePollWriterKind; + candidateGeneration: number; + signal: AbortSignal; + /** + * Must be checked immediately before committing the checkpoint projection. + */ + isCurrent(): boolean; +}>; + +export type LivePollHandoffCoordinator = Readonly<{ + currentWriter(): LivePollWriterLease | null; + switchToPoll(): Promise>>; + beginLiveProbe(): RealtimeResult>; + inspect(): LivePollHandoffInspection; + close(): Promise>; +}>; + +export type LivePollHandoffCoordinatorDependencies = Readonly<{ + initial: Readonly<{ + writer: LivePollWriterKind; + authoritativeCheckpointEstablished: true; + }>; + limits: LivePollHandoffLimits; + apply(input: Readonly<{ + writer: LivePollWriterKind; + generation: number; + value: Value; + signal: AbortSignal; + /** + * Must be checked immediately before committing the external effect. + */ + isCurrent(): boolean; + }>): Promise>; + establishAuthoritativeCheckpoint( + input: LivePollHandoffRecoveryInput, + ): Promise>; + clock?: ClockPort; +}>; + +type BufferedValue = Readonly<{ + value: Value; + wireBytes: number; +}>; + +type InternalWriterLease = { + readonly writer: LivePollWriterKind; + readonly generation: number; + readonly controller: AbortController; + facade: LivePollWriterLease; + tail: Promise; + queuedCount: number; + queuedBytes: number; +}; + +type InternalProbe = { + readonly lease: InternalWriterLease; + facade: LiveProbeLease; + readonly buffer: BufferedValue[]; + bufferedBytes: number; + acceptedEvents: number; + acceptedBytes: number; +}; + +type QuiescenceOutcome = "QUIESCED" | "TIMER_FAILED" | "TIMED_OUT"; + +export function createLivePollHandoffCoordinator( + dependencies: LivePollHandoffCoordinatorDependencies, +): LivePollHandoffCoordinator { + if ( + !dependencies || + !dependencies.initial || + (dependencies.initial.writer !== "LIVE" && + dependencies.initial.writer !== "POLL") || + dependencies.initial.authoritativeCheckpointEstablished !== true || + typeof dependencies.apply !== "function" || + typeof dependencies.establishAuthoritativeCheckpoint !== "function" + ) { + throw new TypeError( + "Invalid live/poll handoff dependencies or initial checkpoint.", + ); + } + const limits = validateLimits(dependencies.limits); + const clock = dependencies.clock ?? systemClock; + let state: LivePollHandoffState = + dependencies.initial.writer === "LIVE" + ? "LIVE_ACTIVE" + : "POLL_ACTIVE"; + let generationCounter = 0; + let lifecycleGeneration = 0; + let transitioning = false; + let active: InternalWriterLease | null = null; + let probe: InternalProbe | null = null; + let quiescing: InternalWriterLease | null = null; + let transitionCandidate: InternalWriterLease | null = null; + let closePromise: Promise> | null = null; + + active = createWriterLease(dependencies.initial.writer); + + function createWriterLease( + writer: LivePollWriterKind, + ): InternalWriterLease { + const controller = new AbortController(); + const generation = ++generationCounter; + const lease: InternalWriterLease = { + writer, + generation, + controller, + tail: Promise.resolve(), + queuedCount: 0, + queuedBytes: 0, + facade: null as unknown as LivePollWriterLease, + }; + lease.facade = Object.freeze({ + writer, + generation, + signal: controller.signal, + isCurrent: () => isActiveLease(lease), + write: async (value: Value, wireBytes: number) => + await writeFromLease(lease, value, wireBytes), + }); + return lease; + } + + function createProbe(): InternalProbe { + const lease = createWriterLease("LIVE"); + const selected: InternalProbe = { + lease, + buffer: [], + bufferedBytes: 0, + acceptedEvents: 0, + acceptedBytes: 0, + facade: null as unknown as LiveProbeLease, + }; + selected.facade = Object.freeze({ + ...lease.facade, + writer: "LIVE" as const, + activate: async () => await activateProbe(selected), + cancel: () => cancelProbe(selected), + }); + return selected; + } + + async function writeFromLease( + lease: InternalWriterLease, + value: Value, + wireBytes: number, + ): Promise> { + if (state === "CLOSED") return handoffFailure("CLOSED", "APPLY"); + if (probe?.lease === lease && state === "LIVE_PROBING") { + if (!validWireBytes(wireBytes, limits.maxItemBytes)) { + return handoffFailure("EVENT_TOO_LARGE", "APPLY"); + } + return bufferProbeValue(probe, value, wireBytes); + } + if (!isActiveLease(lease)) { + return handoffFailure("SCOPE_FENCED", "APPLY"); + } + if (!validWireBytes(wireBytes, limits.maxItemBytes)) { + return handoffFailure("EVENT_TOO_LARGE", "APPLY"); + } + return await enqueueEffect(lease, value, wireBytes); + } + + function bufferProbeValue( + selected: InternalProbe, + value: Value, + wireBytes: number, + ): RealtimeResult { + if ( + selected.acceptedEvents + 1 > + limits.maxProbeBufferedEvents || + selected.acceptedBytes + wireBytes > + limits.maxProbeBufferedBytes + ) { + if (transitioning) { + failClosed(); + } else { + selected.lease.controller.abort(); + selected.buffer.length = 0; + selected.bufferedBytes = 0; + probe = null; + state = "POLL_ACTIVE"; + } + return handoffFailure("QUEUE_OVERFLOW", "APPLY"); + } + selected.buffer.push(Object.freeze({ value, wireBytes })); + selected.bufferedBytes += wireBytes; + selected.acceptedEvents += 1; + selected.acceptedBytes += wireBytes; + return realtimeSuccess( + Object.freeze({ + kind: "BUFFERED" as const, + writer: "LIVE" as const, + generation: selected.lease.generation, + }), + ); + } + + function enqueueEffect( + lease: InternalWriterLease, + value: Value, + wireBytes: number, + ): Promise> { + if ( + lease.queuedCount + 1 > limits.maxActiveQueueCount || + lease.queuedBytes + wireBytes > limits.maxActiveQueueBytes + ) { + failClosed(); + return Promise.resolve( + handoffFailure("QUEUE_OVERFLOW", "APPLY"), + ); + } + lease.queuedCount += 1; + lease.queuedBytes += wireBytes; + const result = lease.tail.then(async () => { + try { + if (!isEffectAuthorized(lease)) { + return handoffFailure("SCOPE_FENCED", "APPLY"); + } + return await invokeApply(lease, value); + } finally { + lease.queuedCount -= 1; + lease.queuedBytes -= wireBytes; + } + }); + lease.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async function invokeApply( + lease: InternalWriterLease, + value: Value, + ): Promise> { + try { + const result = await dependencies.apply( + Object.freeze({ + writer: lease.writer, + generation: lease.generation, + value, + signal: lease.controller.signal, + isCurrent: () => isEffectAuthorized(lease), + }), + ); + if (!isRealtimeResult(result, isUndefined)) { + return handoffFailure( + "MAPPING_CONTRACT_VIOLATION", + "APPLY", + ); + } + if (!result.ok) { + return Object.freeze({ + ok: false, + error: result.error, + }); + } + if (!isEffectAuthorized(lease)) { + return handoffFailure("SCOPE_FENCED", "APPLY"); + } + return realtimeSuccess( + Object.freeze({ + kind: "APPLIED" as const, + writer: lease.writer, + generation: lease.generation, + }), + ); + } catch { + return handoffFailure("PROVIDER_UNAVAILABLE", "APPLY", true); + } + } + + async function switchToPoll(): Promise< + RealtimeResult> + > { + if (state === "CLOSED") { + return handoffFailure("CLOSED", "RECOVER"); + } + if ( + state !== "LIVE_ACTIVE" || + transitioning || + active?.writer !== "LIVE" + ) { + return handoffFailure("PROTOCOL_MISMATCH", "RECOVER"); + } + + transitioning = true; + const transitionGeneration = ++lifecycleGeneration; + const previous = active; + const candidate = createWriterLease("POLL"); + transitionCandidate = candidate; + active = null; + quiescing = previous; + previous.controller.abort(); + + const quiescence = await awaitQuiescence(previous); + if (!transitionIsCurrent(transitionGeneration, candidate)) { + candidate.controller.abort(); + return handoffFailure("CLOSED", "RECOVER"); + } + if (quiescence !== "QUIESCED") { + failClosed(); + return handoffFailure( + quiescence === "TIMED_OUT" + ? "IDLE_TIMEOUT" + : "PROVIDER_UNAVAILABLE", + "RECOVER", + ); + } + quiescing = null; + const checkpoint = await establishCheckpoint( + previous.writer, + candidate, + ); + if ( + !checkpoint.ok || + !transitionIsCurrent(transitionGeneration, candidate) + ) { + failClosed(); + return checkpoint.ok + ? handoffFailure("CLOSED", "RECOVER") + : checkpoint; + } + + active = candidate; + transitionCandidate = null; + state = "POLL_ACTIVE"; + transitioning = false; + return realtimeSuccess(candidate.facade); + } + + function beginLiveProbe(): RealtimeResult> { + if (state === "CLOSED") { + return handoffFailure("CLOSED", "SUBSCRIBE"); + } + if ( + state !== "POLL_ACTIVE" || + transitioning || + probe !== null || + active?.writer !== "POLL" + ) { + return handoffFailure("PROTOCOL_MISMATCH", "SUBSCRIBE"); + } + const candidate = createProbe(); + probe = candidate; + state = "LIVE_PROBING"; + return realtimeSuccess(candidate.facade); + } + + function cancelProbe( + selected: InternalProbe, + ): RealtimeResult> { + if (state === "CLOSED") { + return handoffFailure("CLOSED", "CLOSE"); + } + if ( + state !== "LIVE_PROBING" || + transitioning || + probe !== selected || + active?.writer !== "POLL" + ) { + return handoffFailure("SCOPE_FENCED", "CLOSE"); + } + selected.lease.controller.abort(); + selected.buffer.length = 0; + selected.bufferedBytes = 0; + probe = null; + state = "POLL_ACTIVE"; + return realtimeSuccess(active.facade); + } + + async function activateProbe( + selected: InternalProbe, + ): Promise>> { + if (state === "CLOSED") { + return handoffFailure("CLOSED", "RECOVER"); + } + if ( + state !== "LIVE_PROBING" || + transitioning || + probe !== selected || + active?.writer !== "POLL" + ) { + return handoffFailure("SCOPE_FENCED", "RECOVER"); + } + + transitioning = true; + const transitionGeneration = ++lifecycleGeneration; + const previous = active; + transitionCandidate = selected.lease; + active = null; + quiescing = previous; + previous.controller.abort(); + + const quiescence = await awaitQuiescence(previous); + if (!probeTransitionIsCurrent(transitionGeneration, selected)) { + selected.lease.controller.abort(); + return handoffFailure("CLOSED", "RECOVER"); + } + if (quiescence !== "QUIESCED") { + failClosed(); + return handoffFailure( + quiescence === "TIMED_OUT" + ? "IDLE_TIMEOUT" + : "PROVIDER_UNAVAILABLE", + "RECOVER", + ); + } + quiescing = null; + const checkpoint = await establishCheckpoint( + previous.writer, + selected.lease, + ); + if ( + !checkpoint.ok || + !probeTransitionIsCurrent(transitionGeneration, selected) + ) { + failClosed(); + return checkpoint.ok + ? handoffFailure("CLOSED", "RECOVER") + : checkpoint; + } + + while (selected.buffer.length > 0) { + if (!probeTransitionIsCurrent(transitionGeneration, selected)) { + return handoffFailure("CLOSED", "RECOVER"); + } + const buffered = selected.buffer.shift(); + if (!buffered) break; + selected.bufferedBytes -= buffered.wireBytes; + const applied = await enqueueEffect( + selected.lease, + buffered.value, + buffered.wireBytes, + ); + if (!applied.ok) { + failClosed(); + return Object.freeze({ + ok: false, + error: remapFailure(applied.error, "RECOVER"), + }); + } + } + + if (!probeTransitionIsCurrent(transitionGeneration, selected)) { + return handoffFailure("CLOSED", "RECOVER"); + } + active = selected.lease; + transitionCandidate = null; + probe = null; + state = "LIVE_ACTIVE"; + transitioning = false; + return realtimeSuccess(selected.lease.facade); + } + + async function establishCheckpoint( + from: LivePollWriterKind, + candidate: InternalWriterLease, + ): Promise> { + const timer = new AbortController(); + let releaseAbortGate = (): void => undefined; + const aborted = new Promise< + Readonly<{ kind: "ABORTED" }> + >((resolve) => { + const onAbort = () => resolve({ kind: "ABORTED" }); + candidate.controller.signal.addEventListener( + "abort", + onAbort, + { once: true }, + ); + releaseAbortGate = () => + candidate.controller.signal.removeEventListener( + "abort", + onAbort, + ); + if (candidate.controller.signal.aborted) onAbort(); + }); + const operation = Promise.resolve() + .then(() => + dependencies.establishAuthoritativeCheckpoint( + Object.freeze({ + from, + to: candidate.writer, + candidateGeneration: candidate.generation, + signal: candidate.controller.signal, + isCurrent: () => + isCheckpointCandidateCurrent(candidate), + }), + ), + ) + .then( + (value) => ({ kind: "VALUE" as const, value }), + () => ({ kind: "REJECTED" as const }), + ); + const timeout = Promise.resolve() + .then(async () => { + await clock.sleep( + limits.quiescenceTimeoutMs, + timer.signal, + ); + return { kind: "TIMED_OUT" as const }; + }) + .catch(() => ({ + kind: timer.signal.aborted + ? ("CANCELED" as const) + : ("TIMER_FAILED" as const), + })); + const selected = await Promise.race([ + operation, + timeout, + aborted, + ]); + timer.abort(); + releaseAbortGate(); + + if (selected.kind === "ABORTED") { + return handoffFailure("ABORTED", "RECOVER"); + } + if (selected.kind === "TIMED_OUT") { + candidate.controller.abort(); + return handoffFailure("IDLE_TIMEOUT", "RECOVER"); + } + if ( + selected.kind === "TIMER_FAILED" || + selected.kind === "REJECTED" + ) { + candidate.controller.abort(); + return handoffFailure( + "PROVIDER_UNAVAILABLE", + "RECOVER", + true, + ); + } + if (selected.kind === "CANCELED") { + return handoffFailure("ABORTED", "RECOVER"); + } + if (selected.kind !== "VALUE") { + candidate.controller.abort(); + return handoffFailure( + "PROVIDER_UNAVAILABLE", + "RECOVER", + true, + ); + } + const result = selected.value; + if (!isRealtimeResult(result, isUndefined)) { + candidate.controller.abort(); + return handoffFailure( + "MAPPING_CONTRACT_VIOLATION", + "RECOVER", + ); + } + if (!result.ok) { + return Object.freeze({ + ok: false, + error: remapFailure(result.error, "RECOVER"), + }); + } + if (candidate.controller.signal.aborted) { + return handoffFailure("ABORTED", "RECOVER"); + } + return realtimeSuccess(undefined); + } + + async function awaitQuiescence( + lease: InternalWriterLease, + ): Promise { + const timer = new AbortController(); + const settled = lease.tail.then( + () => "QUIESCED" as const, + () => "QUIESCED" as const, + ); + const timeout = Promise.resolve() + .then(async () => { + await clock.sleep( + limits.quiescenceTimeoutMs, + timer.signal, + ); + return "TIMED_OUT" as const; + }) + .catch(() => + timer.signal.aborted + ? ("QUIESCED" as const) + : ("TIMER_FAILED" as const), + ); + const outcome = await Promise.race([settled, timeout]); + timer.abort(); + return outcome; + } + + function close(): Promise> { + closePromise ??= performClose(); + return closePromise; + } + + async function performClose(): Promise> { + lifecycleGeneration += 1; + state = "CLOSED"; + transitioning = true; + const writers = uniqueLeases([ + active, + probe?.lease ?? null, + quiescing, + transitionCandidate, + ]); + active = null; + const selectedProbe = probe; + probe = null; + selectedProbe?.buffer.splice(0); + if (selectedProbe) selectedProbe.bufferedBytes = 0; + for (const writer of writers) writer.controller.abort(); + const outcomes = await Promise.all( + writers.map(async (writer) => await awaitQuiescence(writer)), + ); + quiescing = null; + transitionCandidate = null; + transitioning = false; + if (outcomes.includes("TIMED_OUT")) { + return handoffFailure("IDLE_TIMEOUT", "CLOSE"); + } + if (outcomes.includes("TIMER_FAILED")) { + return handoffFailure("PROVIDER_UNAVAILABLE", "CLOSE"); + } + return realtimeSuccess(undefined); + } + + function inspect(): LivePollHandoffInspection { + return Object.freeze({ + state, + activeWriter: active?.writer ?? null, + activeGeneration: active?.generation ?? null, + probeGeneration: probe?.lease.generation ?? null, + bufferedEvents: probe?.buffer.length ?? 0, + bufferedBytes: probe?.bufferedBytes ?? 0, + transitioning, + }); + } + + function isActiveLease(lease: InternalWriterLease): boolean { + if (active !== lease || transitioning || state === "CLOSED") { + return false; + } + return ( + (lease.writer === "LIVE" && state === "LIVE_ACTIVE") || + (lease.writer === "POLL" && + (state === "POLL_ACTIVE" || state === "LIVE_PROBING")) + ); + } + + function isEffectAuthorized( + lease: InternalWriterLease, + ): boolean { + return ( + isActiveLease(lease) || + (transitioning && + state === "LIVE_PROBING" && + probe?.lease === lease && + !lease.controller.signal.aborted) + ); + } + + function transitionIsCurrent( + transitionGeneration: number, + candidate: InternalWriterLease, + ): boolean { + return ( + state !== "CLOSED" && + transitioning && + lifecycleGeneration === transitionGeneration && + !candidate.controller.signal.aborted + ); + } + + function probeTransitionIsCurrent( + transitionGeneration: number, + selected: InternalProbe, + ): boolean { + return ( + state === "LIVE_PROBING" && + transitioning && + lifecycleGeneration === transitionGeneration && + probe === selected && + !selected.lease.controller.signal.aborted + ); + } + + function isCheckpointCandidateCurrent( + candidate: InternalWriterLease, + ): boolean { + return ( + state !== "CLOSED" && + transitioning && + transitionCandidate === candidate && + !candidate.controller.signal.aborted + ); + } + + function failClosed(): void { + lifecycleGeneration += 1; + state = "CLOSED"; + transitioning = false; + active?.controller.abort(); + probe?.lease.controller.abort(); + quiescing?.controller.abort(); + transitionCandidate?.controller.abort(); + active = null; + if (probe) { + probe.buffer.length = 0; + probe.bufferedBytes = 0; + } + probe = null; + } + + return Object.freeze({ + currentWriter: () => active?.facade ?? null, + switchToPoll, + beginLiveProbe, + inspect, + close, + }); +} + +function validateLimits( + limits: LivePollHandoffLimits, +): LivePollHandoffLimits { + if ( + !positiveIntegerWithin( + limits.quiescenceTimeoutMs, + LIVE_POLL_HANDOFF_CEILINGS.maxQuiescenceTimeoutMs, + ) || + !positiveIntegerWithin( + limits.maxActiveQueueCount, + LIVE_POLL_HANDOFF_CEILINGS.maxActiveQueueCount, + ) || + !positiveIntegerWithin( + limits.maxActiveQueueBytes, + LIVE_POLL_HANDOFF_CEILINGS.maxActiveQueueBytes, + ) || + !positiveIntegerWithin( + limits.maxProbeBufferedEvents, + LIVE_POLL_HANDOFF_CEILINGS.maxProbeBufferedEvents, + ) || + !positiveIntegerWithin( + limits.maxProbeBufferedBytes, + LIVE_POLL_HANDOFF_CEILINGS.maxProbeBufferedBytes, + ) || + !positiveIntegerWithin( + limits.maxItemBytes, + LIVE_POLL_HANDOFF_CEILINGS.maxItemBytes, + ) || + limits.maxItemBytes > limits.maxProbeBufferedBytes || + limits.maxItemBytes > limits.maxActiveQueueBytes + ) { + throw new TypeError("Invalid live/poll handoff limits."); + } + return Object.freeze({ ...limits }); +} + +function positiveIntegerWithin(value: number, maximum: number): boolean { + return ( + Number.isSafeInteger(value) && + value > 0 && + value <= maximum + ); +} + +function validWireBytes(value: number, maximum: number): boolean { + return positiveIntegerWithin(value, maximum); +} + +function isUndefined(value: unknown): value is undefined { + return value === undefined; +} + +function handoffFailure( + kind: Parameters[0], + operation: RealtimeOperation, + retryable?: boolean, +): Extract, { ok: false }> { + return retryable === undefined + ? realtimeFailure(kind, operation) + : realtimeFailure(kind, operation, retryable); +} + +function remapFailure( + failure: RealtimeFailure, + operation: RealtimeOperation, +): RealtimeFailure { + return Object.freeze({ + kind: failure.kind, + operation, + retryable: failure.retryable, + }); +} + +function uniqueLeases( + values: readonly (InternalWriterLease | null)[], +): InternalWriterLease[] { + return [ + ...new Set( + values.filter( + (value): value is InternalWriterLease => + value !== null, + ), + ), + ]; +} diff --git a/src/adapters/realtime/polling/bounded-poll-coordinator.ts b/src/adapters/realtime/polling/bounded-poll-coordinator.ts new file mode 100644 index 0000000..58b4c19 --- /dev/null +++ b/src/adapters/realtime/polling/bounded-poll-coordinator.ts @@ -0,0 +1,961 @@ +import { + assertBoundedPollOperation, + definePollLeasePolicy, + type BoundedPollOperationContract, + type PollLeasePolicy, +} from "../../../application/policies/bounded-polling.ts"; +import type { ClockPort } from "../../../application/ports/clock-port.ts"; +import { + REALTIME_FAILURE_KINDS, + type RealtimeFailure, + type RealtimeFailureKind, + type RealtimeResult, +} from "../../../application/ports/realtime/shared.ts"; +import { systemClock } from "../../platform/system-clock.ts"; +import { + realtimeFailure, + realtimeSuccess, +} from "../result.ts"; + +/** + * Provider-private Retry-After metadata is consumed by this adapter and is + * deliberately stripped before a failure crosses the realtime boundary. + */ +export type BoundedPollAttemptFailure = RealtimeFailure & Readonly<{ + retryAfterMs?: number; +}>; + +type BoundedPollAttemptSuccess = + | Readonly<{ + kind: "UNCHANGED"; + responseBytes: 0; + }> + | Readonly<{ + kind: "VALUE"; + value: Value; + responseBytes: number; + state?: string; + }>; + +export type BoundedPollAttemptResult = + | Extract< + RealtimeResult>, + { ok: true } + > + | Readonly<{ ok: false; error: BoundedPollAttemptFailure }>; + +export type BoundedPollResult = + RealtimeResult< + Readonly<{ + kind: "TERMINAL"; + attempts: number; + state: string; + value: Value; + }> + >; + +export type BoundedPollEnvironment = Readonly<{ + visibility(): "HIDDEN" | "VISIBLE"; + online(): boolean; + subscribeVisibility?( + listener: (visibility: "HIDDEN" | "VISIBLE") => void, + ): () => void; + subscribeOnline?(listener: (online: boolean) => void): () => void; +}>; + +export type BoundedPollRunInput = Readonly<{ + signal?: AbortSignal; + onValue?: ( + value: Value, + context: Readonly<{ signal: AbortSignal; isCurrent(): boolean }>, + ) => void | Promise; +}>; + +export type BoundedPollCoordinator = Readonly<{ + run(input?: BoundedPollRunInput): Promise< + BoundedPollResult + >; + getState(): "CLOSED" | "DRAINING" | "IDLE" | "RUNNING"; + close(): void; +}>; + +export type BoundedPollCoordinatorDependencies = Readonly<{ + policy: PollLeasePolicy; + operation: BoundedPollOperationContract; + execute(input: Readonly<{ + operationId: string; + attempt: number; + /** + * Hard response-body ceiling that must be enforced before decoding. + */ + maxResponseBytes: number; + signal: AbortSignal; + }>): Promise>; + environment: BoundedPollEnvironment; + isCurrent?: () => boolean; + clock?: ClockPort; + random?: () => number; +}>; + +const SAFE_STATE = /^[A-Z][A-Z0-9_]{0,63}$/u; +const POLL_RETRYABLE_FAILURE_KINDS = Object.freeze([ + "CONNECT_TIMEOUT", + "RATE_LIMITED", + "PROVIDER_UNAVAILABLE", +] as const satisfies readonly RealtimeFailureKind[]); +const POLL_RETRY_AFTER_FAILURE_KINDS = Object.freeze([ + "RATE_LIMITED", + "PROVIDER_UNAVAILABLE", +] as const satisfies readonly RealtimeFailureKind[]); + +export function createBoundedPollCoordinator( + dependencies: BoundedPollCoordinatorDependencies, +): BoundedPollCoordinator { + const policy = definePollLeasePolicy(dependencies.policy); + assertBoundedPollOperation(policy, dependencies.operation); + const maxResponseBytes = Math.min( + policy.maxResponseBytes, + dependencies.operation.maxResponseBytes, + ); + const clock = dependencies.clock ?? systemClock; + const random = dependencies.random ?? Math.random; + const isCurrent = dependencies.isCurrent ?? (() => true); + let state: "CLOSED" | "DRAINING" | "IDLE" | "RUNNING" = "IDLE"; + let activeController: AbortController | null = null; + let generation = 0; + let pendingWork = 0; + + async function run( + input: BoundedPollRunInput = {}, + ): Promise> { + if (state === "CLOSED") return pollFailure("CLOSED"); + if (state !== "IDLE") { + return pollFailure("PROTOCOL_MISMATCH"); + } + if (input.signal?.aborted) return pollFailure("ABORTED"); + if (safeVisibility(dependencies.environment) !== "VISIBLE") { + return pollFailure("ABORTED"); + } + if (safeOnline(dependencies.environment) !== true) { + return pollFailure("OFFLINE", true); + } + if (!safeIsCurrent(isCurrent)) { + return pollFailure("SCOPE_FENCED"); + } + + state = "RUNNING"; + const runGeneration = ++generation; + const controller = new AbortController(); + activeController = controller; + let stopKind: RealtimeFailureKind | null = null; + let attempts = 0; + let consecutiveFailures = 0; + let nextDelayMs = policy.minimumIntervalMs; + const startedAtMs = safeNow(clock); + + const stop = (kind: RealtimeFailureKind) => { + if (stopKind !== null) return; + stopKind = kind; + controller.abort(); + }; + const onCallerAbort = () => stop("ABORTED"); + input.signal?.addEventListener("abort", onCallerAbort, { + once: true, + }); + let unsubscribeVisibility: (() => void) | undefined; + let unsubscribeOnline: (() => void) | undefined; + try { + unsubscribeVisibility = + dependencies.environment.subscribeVisibility?.((visibility) => { + if (visibility !== "VISIBLE") stop("ABORTED"); + }); + } catch { + stop("ABORTED"); + } + try { + unsubscribeOnline = + dependencies.environment.subscribeOnline?.((online) => { + if (!online) stop("OFFLINE"); + }); + } catch { + stop("OFFLINE"); + } + + try { + if (startedAtMs === null) { + return pollFailure("PROVIDER_UNAVAILABLE"); + } + while (true) { + const lifecycleFailure = currentFailure( + stopKind, + state, + runGeneration, + generation, + isCurrent, + dependencies.environment, + ); + if (lifecycleFailure) { + return pollFailure( + lifecycleFailure, + lifecycleFailure === "OFFLINE", + ); + } + if (attempts >= policy.maxAttempts) { + return pollFailure("POLL_BUDGET_EXHAUSTED"); + } + const nowBeforeSleep = safeNow(clock); + if ( + nowBeforeSleep === null || + nowBeforeSleep < startedAtMs + ) { + return pollFailure("PROVIDER_UNAVAILABLE"); + } + if ( + nowBeforeSleep - startedAtMs + nextDelayMs >= + policy.maxElapsedMs + ) { + return pollFailure("POLL_BUDGET_EXHAUSTED"); + } + const cadenceOutcome = await awaitTaskOrAbort( + () => clock.sleep(nextDelayMs, controller.signal), + controller.signal, + ); + if (cadenceOutcome.kind !== "VALUE") { + const afterSleepFailure = currentFailure( + stopKind, + state, + runGeneration, + generation, + isCurrent, + dependencies.environment, + ); + return pollFailure( + afterSleepFailure ?? + (cadenceOutcome.kind === "THREW" + ? "PROVIDER_UNAVAILABLE" + : "ABORTED"), + afterSleepFailure === "OFFLINE", + ); + } + + const beforeAttemptFailure = currentFailure( + stopKind, + state, + runGeneration, + generation, + isCurrent, + dependencies.environment, + ); + if (beforeAttemptFailure) { + return pollFailure( + beforeAttemptFailure, + beforeAttemptFailure === "OFFLINE", + ); + } + const attemptStartedAt = safeNow(clock); + if ( + attemptStartedAt === null || + attemptStartedAt < startedAtMs || + attemptStartedAt - startedAtMs >= policy.maxElapsedMs + ) { + return pollFailure("POLL_BUDGET_EXHAUSTED"); + } + + attempts += 1; + let result: BoundedPollAttemptResult; + const attemptOutcome = await awaitWithinLease( + () => + trackWork( + dependencies.execute({ + operationId: policy.operationId, + attempt: attempts, + maxResponseBytes, + signal: controller.signal, + }), + runGeneration, + ), + policy.maxElapsedMs - (attemptStartedAt - startedAtMs), + clock, + controller.signal, + () => stop("POLL_BUDGET_EXHAUSTED"), + ); + if (attemptOutcome.kind === "VALUE") { + result = attemptOutcome.value; + } else if (attemptOutcome.kind === "THREW") { + result = realtimeFailure( + controller.signal.aborted ? "ABORTED" : "OFFLINE", + "POLL", + !controller.signal.aborted, + ); + } else { + if (attemptOutcome.kind === "CLOCK_FAILED") { + controller.abort(); + } + const interruptedFailure = currentFailure( + stopKind, + state, + runGeneration, + generation, + isCurrent, + dependencies.environment, + ); + return pollFailure( + interruptedFailure ?? + (attemptOutcome.kind === "CLOCK_FAILED" + ? "PROVIDER_UNAVAILABLE" + : "ABORTED"), + interruptedFailure === "OFFLINE", + ); + } + + const afterAttemptFailure = currentFailure( + stopKind, + state, + runGeneration, + generation, + isCurrent, + dependencies.environment, + ); + if (afterAttemptFailure) { + return pollFailure( + afterAttemptFailure, + afterAttemptFailure === "OFFLINE", + ); + } + const attemptFinishedAt = safeNow(clock); + if ( + attemptFinishedAt === null || + attemptFinishedAt < attemptStartedAt + ) { + return pollFailure("PROVIDER_UNAVAILABLE"); + } + if ( + attemptFinishedAt - startedAtMs >= policy.maxElapsedMs + ) { + return pollFailure("POLL_BUDGET_EXHAUSTED"); + } + + const parsedResult = parseAttemptResult(result); + if (!parsedResult) { + return pollFailure("PROTOCOL_MISMATCH"); + } + result = parsedResult; + if (!result.ok) { + if ( + !result.error.retryable || + !isPollRetryableFailureKind(result.error.kind) || + (isPollRetryAfterFailureKind(result.error.kind) && + result.error.retryAfterMs === undefined) + ) { + return pollFailure(result.error.kind); + } + consecutiveFailures += 1; + const failureDelay = retryDelay( + policy, + consecutiveFailures, + isPollRetryAfterFailureKind(result.error.kind) + ? result.error.retryAfterMs + : undefined, + random, + ); + if (failureDelay === null) { + return pollFailure("POLL_BUDGET_EXHAUSTED"); + } + nextDelayMs = failureDelay; + continue; + } + + consecutiveFailures = 0; + if ( + result.value.responseBytes > maxResponseBytes + ) { + return pollFailure("PROTOCOL_MISMATCH"); + } + if (result.value.kind === "UNCHANGED") { + const delay = successDelay(policy, random); + if (delay === null) { + return pollFailure("PROTOCOL_MISMATCH"); + } + nextDelayMs = delay; + continue; + } + const valueResult = result.value; + + if (input.onValue) { + const beforeApplyAt = safeNow(clock); + if ( + beforeApplyAt === null || + beforeApplyAt < attemptFinishedAt || + beforeApplyAt - startedAtMs >= policy.maxElapsedMs + ) { + return pollFailure("POLL_BUDGET_EXHAUSTED"); + } + const applyOutcome = await awaitWithinLease( + () => + trackWork( + Promise.resolve( + input.onValue!(valueResult.value, { + signal: controller.signal, + isCurrent: () => + state === "RUNNING" && + generation === runGeneration && + stopKind === null && + !controller.signal.aborted && + safeIsCurrent(isCurrent), + }), + ), + runGeneration, + ), + policy.maxElapsedMs - (beforeApplyAt - startedAtMs), + clock, + controller.signal, + () => stop("POLL_BUDGET_EXHAUSTED"), + ); + if (applyOutcome.kind === "THREW") { + return applyFailure(); + } + if (applyOutcome.kind !== "VALUE") { + if (applyOutcome.kind === "CLOCK_FAILED") { + controller.abort(); + } + const interruptedFailure = currentFailure( + stopKind, + state, + runGeneration, + generation, + isCurrent, + dependencies.environment, + ); + return pollFailure( + interruptedFailure ?? + (applyOutcome.kind === "CLOCK_FAILED" + ? "PROVIDER_UNAVAILABLE" + : "ABORTED"), + interruptedFailure === "OFFLINE", + ); + } + } + const afterApplyFailure = currentFailure( + stopKind, + state, + runGeneration, + generation, + isCurrent, + dependencies.environment, + ); + if (afterApplyFailure) { + return pollFailure( + afterApplyFailure, + afterApplyFailure === "OFFLINE", + ); + } + const applyFinishedAt = safeNow(clock); + if ( + applyFinishedAt === null || + applyFinishedAt < attemptFinishedAt + ) { + return pollFailure("PROVIDER_UNAVAILABLE"); + } + if (applyFinishedAt - startedAtMs >= policy.maxElapsedMs) { + return pollFailure("POLL_BUDGET_EXHAUSTED"); + } + if ( + valueResult.state && + policy.terminalStates.includes(valueResult.state) + ) { + return realtimeSuccess( + Object.freeze({ + kind: "TERMINAL" as const, + attempts, + state: valueResult.state, + value: valueResult.value, + }), + ); + } + const delay = successDelay(policy, random); + if (delay === null) { + return pollFailure("PROTOCOL_MISMATCH"); + } + nextDelayMs = delay; + } + } finally { + input.signal?.removeEventListener("abort", onCallerAbort); + safelyUnsubscribe(unsubscribeVisibility); + safelyUnsubscribe(unsubscribeOnline); + if (activeController === controller) { + activeController = null; + } + if (generation === runGeneration) { + state = pendingWork === 0 ? "IDLE" : "DRAINING"; + } + } + } + + function trackWork( + work: Promise, + workGeneration: number, + ): Promise { + pendingWork += 1; + void work.then( + () => releaseWork(workGeneration), + () => releaseWork(workGeneration), + ); + return work; + } + + function releaseWork(workGeneration: number): void { + pendingWork = Math.max(0, pendingWork - 1); + if ( + pendingWork === 0 && + state === "DRAINING" && + generation === workGeneration + ) { + state = "IDLE"; + } + } + + function close(): void { + if (state === "CLOSED") return; + state = "CLOSED"; + generation += 1; + activeController?.abort(); + } + + return Object.freeze({ + run, + getState: () => state, + close, + }); +} + +function currentFailure( + requested: RealtimeFailureKind | null, + state: "CLOSED" | "DRAINING" | "IDLE" | "RUNNING", + runGeneration: number, + currentGeneration: number, + isCurrent: () => boolean, + environment: BoundedPollEnvironment, +): RealtimeFailureKind | null { + if (requested) return requested; + if ( + state === "CLOSED" || + state === "DRAINING" || + runGeneration !== currentGeneration + ) { + return "CLOSED"; + } + if (!safeIsCurrent(isCurrent)) return "SCOPE_FENCED"; + if (safeVisibility(environment) !== "VISIBLE") return "ABORTED"; + if (safeOnline(environment) !== true) return "OFFLINE"; + return null; +} + +type TaskOutcome = + | Readonly<{ kind: "VALUE"; value: Value }> + | Readonly<{ kind: "THREW" }> + | Readonly<{ kind: "ABORTED" }>; + +type LeaseTaskOutcome = + | TaskOutcome + | Readonly<{ kind: "LEASE_EXPIRED" }> + | Readonly<{ kind: "CLOCK_FAILED" }>; + +async function awaitTaskOrAbort( + task: () => Promise, + signal: AbortSignal, +): Promise> { + if (signal.aborted) return Object.freeze({ kind: "ABORTED" }); + + let removeAbortListener: () => void = () => undefined; + const aborted = new Promise>( + (resolve) => { + const onAbort = () => resolve(Object.freeze({ kind: "ABORTED" })); + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => + signal.removeEventListener("abort", onAbort); + if (signal.aborted) onAbort(); + }, + ); + if (signal.aborted) { + removeAbortListener(); + return Object.freeze({ kind: "ABORTED" }); + } + let taskPromise: Promise; + try { + taskPromise = task(); + } catch { + removeAbortListener(); + return Object.freeze({ kind: "THREW" }); + } + const completed = taskPromise.then< + TaskOutcome, + TaskOutcome + >( + (value) => Object.freeze({ kind: "VALUE", value }), + () => Object.freeze({ kind: "THREW" }), + ); + + try { + return await Promise.race([completed, aborted]); + } finally { + removeAbortListener(); + } +} + +async function awaitWithinLease( + task: () => Promise, + remainingMs: number, + clock: ClockPort, + signal: AbortSignal, + onLeaseExpired: () => void, +): Promise> { + if (!Number.isFinite(remainingMs) || remainingMs <= 0) { + onLeaseExpired(); + return Object.freeze({ kind: "LEASE_EXPIRED" }); + } + if (signal.aborted) return Object.freeze({ kind: "ABORTED" }); + + const deadlineController = new AbortController(); + let resolveInterruption: + | ((outcome: LeaseTaskOutcome) => void) + | undefined; + let removeAbortListener: () => void = () => undefined; + let interruptionSettled = false; + const finishInterruption = ( + outcome: LeaseTaskOutcome, + ): boolean => { + if (interruptionSettled) return false; + interruptionSettled = true; + resolveInterruption?.(outcome); + return true; + }; + const interrupted = new Promise>((resolve) => { + resolveInterruption = resolve; + const onAbort = () => + finishInterruption(Object.freeze({ kind: "ABORTED" })); + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => + signal.removeEventListener("abort", onAbort); + if (signal.aborted) onAbort(); + }); + if (signal.aborted) { + removeAbortListener(); + return Object.freeze({ kind: "ABORTED" }); + } + let deadlineSleep: Promise; + try { + deadlineSleep = clock.sleep( + remainingMs, + deadlineController.signal, + ); + } catch { + removeAbortListener(); + deadlineController.abort(); + return Object.freeze({ kind: "CLOCK_FAILED" }); + } + const deadline = deadlineSleep.then( + () => { + if (deadlineController.signal.aborted) return; + if ( + finishInterruption( + Object.freeze({ kind: "LEASE_EXPIRED" }), + ) + ) { + onLeaseExpired(); + } + }, + () => { + if (!deadlineController.signal.aborted) { + finishInterruption( + Object.freeze({ kind: "CLOCK_FAILED" }), + ); + } + }, + ); + if (signal.aborted) { + removeAbortListener(); + deadlineController.abort(); + void deadline; + return Object.freeze({ kind: "ABORTED" }); + } + let taskPromise: Promise; + try { + taskPromise = task(); + } catch { + taskPromise = Promise.reject(new Error("Task failed.")); + } + const completed = taskPromise.then< + LeaseTaskOutcome, + LeaseTaskOutcome + >( + (value) => Object.freeze({ kind: "VALUE", value }), + () => Object.freeze({ kind: "THREW" }), + ); + + try { + const outcome = await Promise.race([completed, interrupted]); + void deadline; + return outcome; + } finally { + removeAbortListener(); + deadlineController.abort(); + } +} + +function parseAttemptResult( + result: unknown, +): BoundedPollAttemptResult | null { + const outer = snapshotDataRecord(result, [ + ["error", "ok"], + ["ok", "value"], + ]); + if (!outer) return null; + if (outer.ok === false) { + const error = snapshotDataRecord(outer.error, [ + ["kind", "operation", "retryable"], + ["kind", "operation", "retryable", "retryAfterMs"], + ]); + if ( + !error || + !REALTIME_FAILURE_KINDS.includes( + error.kind as RealtimeFailureKind, + ) || + error.operation !== "POLL" || + typeof error.retryable !== "boolean" || + (Object.hasOwn(error, "retryAfterMs") && + (!Number.isSafeInteger(error.retryAfterMs) || + (error.retryAfterMs as number) < 0)) + ) { + return null; + } + const canonicalError = Object.freeze({ + kind: error.kind as RealtimeFailureKind, + operation: "POLL" as const, + retryable: error.retryable, + ...(Object.hasOwn(error, "retryAfterMs") + ? { retryAfterMs: error.retryAfterMs as number } + : {}), + }); + return Object.freeze({ + ok: false as const, + error: canonicalError, + }); + } + if (outer.ok !== true) return null; + const value = snapshotDataRecord(outer.value, [ + ["kind", "responseBytes"], + ["kind", "responseBytes", "state", "value"], + ["kind", "responseBytes", "value"], + ]); + if ( + !value || + !Number.isSafeInteger(value.responseBytes) || + (value.responseBytes as number) < 0 + ) { + return null; + } + if (value.kind === "UNCHANGED") { + return value.responseBytes === 0 + ? Object.freeze({ + ok: true as const, + value: Object.freeze({ + kind: "UNCHANGED" as const, + responseBytes: 0 as const, + }), + }) + : null; + } + if ( + value.kind !== "VALUE" || + (Object.hasOwn(value, "state") && + (typeof value.state !== "string" || + !SAFE_STATE.test(value.state))) + ) { + return null; + } + return Object.freeze({ + ok: true as const, + value: Object.freeze({ + kind: "VALUE" as const, + value: value.value as Value, + responseBytes: value.responseBytes as number, + ...(Object.hasOwn(value, "state") + ? { state: value.state as string } + : {}), + }), + }); +} + +function snapshotDataRecord( + value: unknown, + allowedKeySets: readonly (readonly string[])[], +): Readonly> | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + try { + if (Object.getPrototypeOf(value) !== Object.prototype) { + return null; + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== "string")) return null; + const sortedKeys = (keys as string[]).sort(); + if ( + !allowedKeySets.some((allowed) => { + const sortedAllowed = [...allowed].sort(); + return ( + sortedKeys.length === sortedAllowed.length && + sortedKeys.every( + (key, index) => key === sortedAllowed[index], + ) + ); + }) + ) { + return null; + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const snapshot: Record = {}; + for (const key of sortedKeys) { + const descriptor = descriptors[key]; + if (!descriptor || !Object.hasOwn(descriptor, "value")) { + return null; + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot); + } catch { + return null; + } +} + +function retryDelay( + policy: PollLeasePolicy, + consecutiveFailures: number, + retryAfterMs: number | undefined, + random: () => number, +): number | null { + const sample = safeRandom(random); + if (sample === null) return null; + if ( + retryAfterMs !== undefined && + (!Number.isSafeInteger(retryAfterMs) || + retryAfterMs < 0 || + retryAfterMs > policy.maxIntervalMs) + ) { + return null; + } + const ceiling = Math.min( + policy.maxIntervalMs, + policy.minimumIntervalMs * 2 ** Math.max(0, consecutiveFailures - 1), + ); + return Math.max( + policy.minimumIntervalMs, + Math.floor(ceiling * sample), + retryAfterMs ?? 0, + ); +} + +function isPollRetryableFailureKind( + kind: RealtimeFailureKind, +): boolean { + return POLL_RETRYABLE_FAILURE_KINDS.includes( + kind as (typeof POLL_RETRYABLE_FAILURE_KINDS)[number], + ); +} + +function isPollRetryAfterFailureKind( + kind: RealtimeFailureKind, +): boolean { + return POLL_RETRY_AFTER_FAILURE_KINDS.includes( + kind as (typeof POLL_RETRY_AFTER_FAILURE_KINDS)[number], + ); +} + +function successDelay( + policy: PollLeasePolicy, + random: () => number, +): number | null { + const sample = safeRandom(random); + if (sample === null) return null; + const spread = Math.floor(policy.successIntervalMs * 0.1); + return Math.min( + policy.maxIntervalMs, + Math.max( + policy.minimumIntervalMs, + policy.successIntervalMs - + spread + + Math.floor(2 * spread * sample), + ), + ); +} + +function safeRandom(random: () => number): number | null { + try { + const value = random(); + return Number.isFinite(value) && value >= 0 && value < 1 + ? value + : null; + } catch { + return null; + } +} + +function safeNow(clock: ClockPort): number | null { + try { + const value = clock.now(); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +function safeIsCurrent(isCurrent: () => boolean): boolean { + try { + return isCurrent() === true; + } catch { + return false; + } +} + +function safeVisibility( + environment: BoundedPollEnvironment, +): "HIDDEN" | "VISIBLE" | null { + try { + const value = environment.visibility(); + return value === "HIDDEN" || value === "VISIBLE" ? value : null; + } catch { + return null; + } +} + +function safeOnline( + environment: BoundedPollEnvironment, +): boolean | null { + try { + const value = environment.online(); + return typeof value === "boolean" ? value : null; + } catch { + return null; + } +} + +function safelyUnsubscribe( + unsubscribe: (() => void) | undefined, +): void { + try { + unsubscribe?.(); + } catch { + // Lifecycle cleanup remains terminal even for a throwing host. + } +} + +function pollFailure( + kind: RealtimeFailureKind, + retryable = false, +): BoundedPollResult { + return realtimeFailure(kind, "POLL", retryable); +} + +function applyFailure(): BoundedPollResult { + return realtimeFailure("APPLY_FAILED", "APPLY", false); +} diff --git a/src/adapters/realtime/polling/index.ts b/src/adapters/realtime/polling/index.ts new file mode 100644 index 0000000..076157f --- /dev/null +++ b/src/adapters/realtime/polling/index.ts @@ -0,0 +1,10 @@ +export { + createBoundedPollCoordinator, + type BoundedPollAttemptFailure, + type BoundedPollAttemptResult, + type BoundedPollCoordinator, + type BoundedPollCoordinatorDependencies, + type BoundedPollEnvironment, + type BoundedPollResult, + type BoundedPollRunInput, +} from "./bounded-poll-coordinator.ts"; diff --git a/src/adapters/realtime/reconnect-coordinator.ts b/src/adapters/realtime/reconnect-coordinator.ts new file mode 100644 index 0000000..fd68af2 --- /dev/null +++ b/src/adapters/realtime/reconnect-coordinator.ts @@ -0,0 +1,1796 @@ +import type { ClockPort } from "../../application/ports/clock-port.ts"; +import type { + RealtimeTransportEventOutcome, +} from "../../application/ports/realtime/event-authority.ts"; +import { + type RealtimeFailureKind, + type RealtimeResult, +} from "../../application/ports/realtime/shared.ts"; +import { systemClock } from "../platform/system-clock.ts"; +import { + calculateReconnectDelay, + defineReconnectPolicy, + isReconnectAttemptResetEligible, + REALTIME_RECONNECT_CEILINGS, + reconnectBudgetRemaining, + type ReconnectPolicy, +} from "./reconnect-policy.ts"; +import { + isRealtimeResult, + isRealtimeTransportEventOutcome, + realtimeFailure, +} from "./result.ts"; + +type FailureResult = Extract, { ok: false }>; +type SuccessResult = Extract< + RealtimeResult, + { ok: true } +>; + +export type RealtimeCommittedRecovery = Extract< + RealtimeTransportEventOutcome, + { kind: "RECOVERY_COMMITTED" } +>; + +/** + * The hint is coordinator-private metadata. A terminal RealtimeResult can + * therefore be returned without rewriting its failure. + */ +export type RealtimeReconnectOutcome = + | Readonly<{ + result: SuccessResult; + }> + | Readonly<{ + result: FailureResult; + serverNotBeforeMs?: number | null; + }>; + +export type RealtimeReconnectAttemptContext = Readonly<{ + signal: AbortSignal; + pendingRecovery: RealtimeCommittedRecovery | null; + isCurrent(): boolean; + /** + * Call only after a protocol-valid heartbeat or event. + */ + markValidHeartbeatOrEvent(): void; + /** + * A transport may expose readiness and then wait here before admitting + * inbound events. With no pending recovery the call fails closed. + */ + waitForRecoveryBarrierConfirmation(): Promise< + RealtimeResult + >; +}>; + +export type RealtimeReconnectAttemptSuccess = + Readonly<{ + session: RealtimeReconnectSession; + establishedRecoveryBarrier?: RealtimeCommittedRecovery; + }>; + +export type RealtimeRecoveryReconnectDirective = Readonly<{ + kind: "RECOVERY_RECONNECT"; + recovery: RealtimeCommittedRecovery; + terminalResult: FailureResult; + serverNotBeforeMs?: number | null; +}>; + +export type RealtimeReconnectCloseClassification = + | RealtimeReconnectOutcome + | RealtimeRecoveryReconnectDirective; + +/** + * The receipt stays strongly typed until `classifyClosed`. `close` must be + * terminal and idempotent. + */ +export type RealtimeReconnectSession< + ClosedReceipt = RealtimeReconnectOutcome, +> = Readonly<{ + waitClosed(): + | ClosedReceipt + | Promise; + classifyClosed( + receipt: ClosedReceipt, + ): RealtimeReconnectCloseClassification; + close(): void; +}>; + +export type RealtimeReconnectEnvironment = Readonly<{ + online(): boolean; + subscribeOnline(listener: (online: boolean) => void): () => void; +}>; + +export type RealtimeReconnectRunInput = Readonly<{ + signal?: AbortSignal; + initialRecovery?: RealtimeRecoveryReconnectDirective; +}>; + +export type RealtimeReconnectCoordinator = Readonly<{ + run(input?: RealtimeReconnectRunInput): Promise< + RealtimeResult + >; + getState(): "CLOSED" | "DRAINING" | "IDLE" | "RUNNING"; + close(): void; +}>; + +export type RealtimeReconnectCoordinatorDependencies< + ClosedReceipt = RealtimeReconnectOutcome, +> = Readonly<{ + policy: ReconnectPolicy; + environment: RealtimeReconnectEnvironment; + attempt( + context: RealtimeReconnectAttemptContext, + ): + | RealtimeReconnectOutcome< + RealtimeReconnectAttemptSuccess + > + | Promise< + RealtimeReconnectOutcome< + RealtimeReconnectAttemptSuccess + > + >; + confirmTransportBarrier( + recovery: RealtimeCommittedRecovery, + ): RealtimeResult; + clock?: ClockPort; + random?: () => number; + isCurrent?: () => boolean; +}>; + +const RETRYABLE_KINDS = Object.freeze([ + "CONNECT_TIMEOUT", + "IDLE_TIMEOUT", + "OFFLINE", + "PROVIDER_UNAVAILABLE", + "RATE_LIMITED", +] as const satisfies readonly RealtimeFailureKind[]); + +type Retry = Readonly<{ + result: FailureResult; + terminalResult: FailureResult; + at: number; + notBeforeMs: number | null; +}>; + +type Settled = + | Readonly<{ kind: "VALUE"; value: Value }> + | Readonly<{ kind: "THREW" }>; + +type AwaitedTask = + | Settled + | Readonly<{ kind: "INTERRUPTED" }>; + +type DrainedTask = + | AwaitedTask + | Readonly<{ kind: "TIMED_OUT" }>; + +type Phase = Readonly<{ + controller: AbortController; + offlineVersion: number; + dispose(): void; +}>; + +type BarrierGate = Readonly<{ + promise: Promise>; + settle(result: RealtimeResult): void; +}>; + +type FailureSnapshot = Readonly<{ + result: FailureResult; + kind: RealtimeFailureKind; + retryable: boolean; +}>; + +type ParsedResult = + | Readonly<{ + ok: true; + result: SuccessResult; + value: Value; + }> + | Readonly<{ + ok: false; + failure: FailureSnapshot; + }>; + +type ParsedOutcome = + | Readonly<{ + ok: true; + result: SuccessResult; + value: Value; + }> + | Readonly<{ + ok: false; + failure: FailureSnapshot; + serverNotBeforeMs: number | null; + }>; + +type ParsedAttemptSuccess = Readonly<{ + session: RealtimeReconnectSession; + hasRecoveryBarrier: boolean; + establishedRecoveryBarrier: unknown; +}>; + +type ParsedRecoveryDirective = Readonly<{ + recovery: RealtimeCommittedRecovery; + terminalFailure: FailureSnapshot; + serverNotBeforeMs: number | null; +}>; + +type ParsedCloseClassification = + | Readonly<{ + kind: "OUTCOME"; + outcome: ParsedOutcome; + }> + | Readonly<{ + kind: "RECOVERY_RECONNECT"; + directive: ParsedRecoveryDirective; + }>; + +type DataSnapshot = Readonly<{ + source: object; + keys: readonly string[]; + values: Readonly>; +}>; + +type ParsedRunInput = Readonly<{ + signal: AbortSignal | undefined; + initialRecovery: ParsedRecoveryDirective | null; +}>; + +type ParsedValue = Readonly<{ value: Value }>; + +export function createRealtimeReconnectCoordinator( + dependencies: RealtimeReconnectCoordinatorDependencies, +): RealtimeReconnectCoordinator { + const attempt = dependencies?.attempt; + const confirmTransportBarrier = + dependencies?.confirmTransportBarrier; + const environment = dependencies?.environment; + if ( + typeof attempt !== "function" || + typeof confirmTransportBarrier !== "function" || + typeof environment?.online !== "function" || + typeof environment.subscribeOnline !== "function" + ) { + throw new TypeError("Invalid realtime reconnect dependencies."); + } + + const policy = defineReconnectPolicy(dependencies.policy); + const clock = dependencies.clock ?? systemClock; + const random = dependencies.random ?? Math.random; + const scopeCurrent = dependencies.isCurrent ?? (() => true); + let state: "CLOSED" | "DRAINING" | "IDLE" | "RUNNING" = "IDLE"; + let generation = 0; + let activeTask: Promise> | null = null; + let runController: AbortController | null = null; + let phaseController: AbortController | null = null; + let session: RealtimeReconnectSession | null = null; + let activeRecoveryGate: BarrierGate | null = null; + + function retainTracked( + task: Promise>, + onSettled?: (outcome: Settled) => void, + ): Promise> { + const retained = onSettled + ? task.then((outcome) => { + onSettled(outcome); + return outcome; + }) + : task; + const tracked = retained as Promise>; + activeTask = tracked; + void retained.then(() => { + if (activeTask === tracked) { + activeTask = null; + if (state === "DRAINING") state = "IDLE"; + } + }); + return retained; + } + + function track( + task: () => Value | Promise, + ): Promise> { + let execution: Promise; + try { + execution = Promise.resolve(task()); + } catch { + execution = Promise.reject(); + } + const settled = execution.then, Settled>( + (value) => Object.freeze({ kind: "VALUE", value }), + () => Object.freeze({ kind: "THREW" }), + ); + return retainTracked(settled); + } + + async function run( + input: RealtimeReconnectRunInput = {}, + ): Promise> { + if (state === "CLOSED") return failure("CLOSED"); + if (state !== "IDLE") return failure("PROTOCOL_MISMATCH"); + + const parsedInput = parseRunInput(input); + if (!parsedInput) return failure("PROTOCOL_MISMATCH"); + const inputSignal = parsedInput.signal; + const initiallyAborted = safeSignalAborted(inputSignal); + if (initiallyAborted === null) { + return failure("PROTOCOL_MISMATCH"); + } + if (initiallyAborted) return failure("ABORTED"); + if (!safeCurrent(scopeCurrent)) return failure("SCOPE_FENCED"); + + const initialNow = safeNow(clock); + if (initialNow === null) return failure("PROVIDER_UNAVAILABLE"); + + state = "RUNNING"; + const runGeneration = ++generation; + const controller = new AbortController(); + runController = controller; + let requested: RealtimeFailureKind | null = null; + let online = false; + let onlineVersion = 0; + let offlineVersion = 0; + let requiredOnlineVersion: number | null = null; + let wakeOnline: (() => void) | null = null; + let unsubscribe: (() => void) | undefined; + let pendingRecovery = parsedInput.initialRecovery; + let retry = + pendingRecovery === null + ? null + : captureRetry( + pendingRecovery.terminalFailure.result, + pendingRecovery.terminalFailure.result, + initialNow, + pendingRecovery.serverNotBeforeMs, + ); + let retriesUsed = 0; + let budgetStartedAt = initialNow; + + const currentFailure = (): RealtimeFailureKind | null => { + if (state === "CLOSED" || generation !== runGeneration) { + return "CLOSED"; + } + if (requested) return requested; + return safeCurrent(scopeCurrent) ? null : "SCOPE_FENCED"; + }; + + const settleActiveGate = (result: RealtimeResult) => { + activeRecoveryGate?.settle(result); + }; + + const stop = (kind: RealtimeFailureKind) => { + requested ??= kind; + settleActiveGate(recoveryFailure(kind)); + controller.abort(); + phaseController?.abort(); + safelyClose(session); + wakeOnline?.(); + }; + + const requireOnline = () => { + requiredOnlineVersion = Math.max( + requiredOnlineVersion ?? 0, + onlineVersion + 1, + ); + }; + + const observeOnline = (value: boolean) => { + if (typeof value !== "boolean") { + stop("PROVIDER_UNAVAILABLE"); + return; + } + online = value; + if (value) { + onlineVersion += 1; + if ( + requiredOnlineVersion !== null && + onlineVersion >= requiredOnlineVersion + ) { + requiredOnlineVersion = null; + } + if (requiredOnlineVersion === null) wakeOnline?.(); + } else { + offlineVersion += 1; + requireOnline(); + settleActiveGate(recoveryFailure("OFFLINE")); + phaseController?.abort(); + safelyClose(session); + } + }; + + const waitOnline = async (): Promise => { + if (online && requiredOnlineVersion === null) return true; + await new Promise((resolve) => { + const wake = () => { + if (wakeOnline === wake) wakeOnline = null; + controller.signal.removeEventListener("abort", wake); + resolve(); + }; + wakeOnline = wake; + controller.signal.addEventListener("abort", wake, { + once: true, + }); + if ( + controller.signal.aborted || + (online && requiredOnlineVersion === null) + ) { + wake(); + } + }); + return ( + currentFailure() === null && + online && + requiredOnlineVersion === null + ); + }; + + const beginPhase = (): Phase => { + const phase = new AbortController(); + const abort = () => phase.abort(); + controller.signal.addEventListener("abort", abort, { + once: true, + }); + if (controller.signal.aborted || !online) phase.abort(); + phaseController = phase; + const capturedOfflineVersion = offlineVersion; + return Object.freeze({ + controller: phase, + offlineVersion: capturedOfflineVersion, + dispose() { + controller.signal.removeEventListener("abort", abort); + if (phaseController === phase) phaseController = null; + }, + }); + }; + + const wentOffline = (phase: Phase) => + phase.offlineVersion !== offlineVersion || !online; + + const onAbort = () => stop("ABORTED"); + if (!safelyAddAbortListener(inputSignal, onAbort)) { + stop("PROVIDER_UNAVAILABLE"); + } + const abortedAfterRegistration = safeSignalAborted(inputSignal); + if (abortedAfterRegistration === null) { + stop("PROVIDER_UNAVAILABLE"); + } else if (abortedAfterRegistration) { + onAbort(); + } + + try { + try { + unsubscribe = environment.subscribeOnline(observeOnline); + if (typeof unsubscribe !== "function") { + stop("PROVIDER_UNAVAILABLE"); + } + } catch { + stop("PROVIDER_UNAVAILABLE"); + } + + const initiallyOnline = safeOnline(environment); + if (initiallyOnline === null) { + stop("PROVIDER_UNAVAILABLE"); + } else { + online = initiallyOnline; + if (!online) requireOnline(); + } + + while (true) { + const stopped = currentFailure(); + if (stopped) return failure(stopped); + + if (retry) { + let now = safeNow(clock); + if ( + now === null || + !canRetry( + retry, + retriesUsed, + budgetStartedAt, + now, + policy, + ) + ) { + return retry.terminalResult; + } + if ( + (!online || requiredOnlineVersion !== null) && + !(await waitOnline()) + ) { + return failure(currentFailure() ?? "ABORTED"); + } + + now = safeNow(clock); + if (now === null) return retry.terminalResult; + const delay = nextDelay( + retry, + retriesUsed, + budgetStartedAt, + now, + policy, + random, + ); + if (delay === null) return retry.terminalResult; + + const phase = beginPhase(); + const sleeping = track( + () => clock.sleep(delay, phase.controller.signal), + ); + const slept = await race(sleeping, phase.controller.signal); + const offline = wentOffline(phase); + if (slept.kind === "INTERRUPTED") { + if (offline) { + const drained = await drainWithinCeiling( + sleeping, + controller.signal, + ); + phase.dispose(); + const afterDrain = currentFailure(); + if (drained.kind === "INTERRUPTED" || afterDrain) { + return failure(afterDrain ?? "ABORTED"); + } + if (drained.kind === "TIMED_OUT") { + return failure("PROVIDER_UNAVAILABLE"); + } + continue; + } + phase.dispose(); + return failure(currentFailure() ?? "ABORTED"); + } + phase.dispose(); + if (offline) continue; + if (slept.kind === "THREW") return retry.terminalResult; + now = safeNow(clock); + if ( + now === null || + reconnectBudgetRemaining( + policy, + budgetStartedAt, + now, + ) <= 0 + ) { + return retry.terminalResult; + } + retriesUsed += 1; + } else if (!online || requiredOnlineVersion !== null) { + retry = offlineRetry(initialNow, "CONNECT"); + continue; + } + + const beforeAttempt = safeNow(clock); + if ( + beforeAttempt === null || + reconnectBudgetRemaining( + policy, + budgetStartedAt, + beforeAttempt, + ) <= 0 + ) { + return ( + retry?.terminalResult ?? + failure("PROVIDER_UNAVAILABLE") + ); + } + + const phase = beginPhase(); + let validSignalObserved = false; + const attemptRecovery = pendingRecovery?.recovery ?? null; + const attemptGate = + attemptRecovery === null ? null : createBarrierGate(); + if (attemptGate) activeRecoveryGate = attemptGate; + const noPendingRecovery = recoveryFailure( + "PROTOCOL_MISMATCH", + ); + const settleAttemptGate = ( + result: RealtimeResult, + ) => { + attemptGate?.settle(result); + if (activeRecoveryGate === attemptGate) { + activeRecoveryGate = null; + } + }; + const isAttemptCurrent = () => + state === "RUNNING" && + generation === runGeneration && + phaseController === phase.controller && + !phase.controller.signal.aborted && + online && + safeCurrent(scopeCurrent); + const context = + Object.freeze({ + signal: phase.controller.signal, + pendingRecovery: attemptRecovery, + isCurrent: isAttemptCurrent, + markValidHeartbeatOrEvent() { + if (isAttemptCurrent()) validSignalObserved = true; + }, + waitForRecoveryBarrierConfirmation() { + return ( + attemptGate?.promise ?? + Promise.resolve(noPendingRecovery) + ); + }, + }); + const attempting = track( + () => attempt(context), + ); + const attempted = await race( + attempting, + phase.controller.signal, + ); + const attemptOffline = wentOffline(phase); + + if (attempted.kind === "INTERRUPTED") { + const retainedAttempt = retainTracked( + attempting, + closeLateSession, + ); + if (attemptOffline) { + const offlineFailure = recoveryFailure("OFFLINE"); + settleAttemptGate(offlineFailure); + const drained = await drainWithinCeiling( + retainedAttempt, + controller.signal, + ); + phase.dispose(); + const afterDrain = currentFailure(); + if (drained.kind === "INTERRUPTED" || afterDrain) { + return failure(afterDrain ?? "ABORTED"); + } + if (drained.kind === "TIMED_OUT") { + return failure("PROVIDER_UNAVAILABLE"); + } + const drainedAt = safeNow(clock); + if (drainedAt === null) { + return failure("PROVIDER_UNAVAILABLE"); + } + retry = offlineRetry( + drainedAt, + "CONNECT", + pendingRecovery?.terminalFailure.result, + ); + continue; + } + const interrupted = currentFailure() ?? "ABORTED"; + settleAttemptGate(recoveryFailure(interrupted)); + phase.dispose(); + return failure(interrupted); + } + + const afterAttempt = currentFailure(); + if (afterAttempt) { + closeLateSession(attempted); + settleAttemptGate(recoveryFailure(afterAttempt)); + phase.controller.abort(); + phase.dispose(); + return failure(afterAttempt); + } + if (attemptOffline) { + closeLateSession(attempted); + settleAttemptGate(recoveryFailure("OFFLINE")); + phase.controller.abort(); + phase.dispose(); + retry = offlineRetry( + safeNow(clock) ?? beforeAttempt, + "CONNECT", + pendingRecovery?.terminalFailure.result, + ); + continue; + } + if (attempted.kind === "THREW") { + const nextRetry = providerRetry( + safeNow(clock) ?? beforeAttempt, + "CONNECT", + pendingRecovery?.terminalFailure.result, + ); + settleAttemptGate(nextRetry.result); + phase.controller.abort(); + phase.dispose(); + retry = nextRetry; + continue; + } + + const opened = parseAttemptOutcome( + attempted.value, + ); + if (!opened) { + const mismatch = failure("PROTOCOL_MISMATCH"); + settleAttemptGate(mismatch); + phase.controller.abort(); + phase.dispose(); + return mismatch; + } + + const afterAttemptValidation = currentFailure(); + if (afterAttemptValidation) { + if (opened.ok) safelyClose(opened.value.session); + settleAttemptGate( + recoveryFailure(afterAttemptValidation), + ); + phase.controller.abort(); + phase.dispose(); + return failure(afterAttemptValidation); + } + + if (!opened.ok) { + settleAttemptGate(opened.failure.result); + phase.controller.abort(); + phase.dispose(); + if ( + !reconnectable( + opened.failure, + opened.serverNotBeforeMs, + ) + ) { + return opened.failure.result; + } + const failedAt = safeNow(clock); + if (failedAt === null) return opened.failure.result; + retry = captureRetry( + opened.failure.result, + pendingRecovery?.terminalFailure.result ?? + opened.failure.result, + failedAt, + opened.serverNotBeforeMs, + ); + if (opened.failure.kind === "OFFLINE") requireOnline(); + continue; + } + + const active = opened.value.session; + if (pendingRecovery) { + if ( + !opened.value.hasRecoveryBarrier || + opened.value.establishedRecoveryBarrier !== + pendingRecovery.recovery + ) { + const mismatch = recoveryFailure( + "PROTOCOL_MISMATCH", + ); + settleAttemptGate(mismatch); + safelyClose(active); + phase.controller.abort(); + phase.dispose(); + return mismatch; + } + + const beforeConfirmation = currentFailure(); + if (beforeConfirmation) { + settleAttemptGate( + recoveryFailure(beforeConfirmation), + ); + safelyClose(active); + phase.controller.abort(); + phase.dispose(); + return failure(beforeConfirmation); + } + + let rawConfirmation: unknown; + try { + rawConfirmation = + confirmTransportBarrier(pendingRecovery.recovery); + } catch { + rawConfirmation = null; + } + const confirmation = parseVoidResult(rawConfirmation); + if (!confirmation) { + const mismatch = recoveryFailure( + "PROTOCOL_MISMATCH", + ); + settleAttemptGate(mismatch); + safelyClose(active); + phase.controller.abort(); + phase.dispose(); + return mismatch; + } + + const afterConfirmation = currentFailure(); + if (afterConfirmation) { + settleAttemptGate( + recoveryFailure(afterConfirmation), + ); + safelyClose(active); + phase.controller.abort(); + phase.dispose(); + return failure(afterConfirmation); + } + if (!confirmation.ok) { + settleAttemptGate(confirmation.failure.result); + safelyClose(active); + phase.controller.abort(); + phase.dispose(); + return confirmation.failure.result; + } + + settleAttemptGate( + confirmation.result as RealtimeResult, + ); + pendingRecovery = null; + } else { + settleAttemptGate(noPendingRecovery); + if (opened.value.hasRecoveryBarrier) { + safelyClose(active); + phase.controller.abort(); + phase.dispose(); + return noPendingRecovery; + } + } + + const openedAt = safeNow(clock); + if ( + openedAt === null || + reconnectBudgetRemaining( + policy, + budgetStartedAt, + openedAt, + ) <= 0 + ) { + safelyClose(active); + phase.controller.abort(); + phase.dispose(); + return ( + retry?.terminalResult ?? + failure("PROVIDER_UNAVAILABLE") + ); + } + retry = null; + + session = active; + const waiting = track(() => active.waitClosed()); + const closed = await race( + waiting, + phase.controller.signal, + ); + const closeOffline = wentOffline(phase); + + const afterWaitClosed = currentFailure(); + if (afterWaitClosed) { + safelyClose(active); + session = null; + phase.controller.abort(); + phase.dispose(); + return failure(afterWaitClosed); + } + if (closed.kind === "INTERRUPTED") { + safelyClose(active); + if (closeOffline) { + const drained = await drainWithinCeiling( + waiting, + controller.signal, + ); + session = null; + phase.dispose(); + const afterDrain = currentFailure(); + if (drained.kind === "INTERRUPTED" || afterDrain) { + return failure(afterDrain ?? "ABORTED"); + } + if (drained.kind === "TIMED_OUT") { + return failure("PROVIDER_UNAVAILABLE"); + } + const drainedAt = safeNow(clock); + if (drainedAt === null) { + return failure("PROVIDER_UNAVAILABLE"); + } + retry = offlineRetry( + drainedAt, + "RECEIVE", + ); + continue; + } + session = null; + phase.dispose(); + return failure(currentFailure() ?? "ABORTED"); + } + + if (closeOffline) { + safelyClose(active); + session = null; + phase.controller.abort(); + phase.dispose(); + retry = offlineRetry( + safeNow(clock) ?? openedAt, + "RECEIVE", + ); + continue; + } + if (closed.kind === "THREW") { + safelyClose(active); + session = null; + phase.controller.abort(); + phase.dispose(); + retry = providerRetry( + safeNow(clock) ?? openedAt, + "RECEIVE", + ); + continue; + } + + let rawClassification: unknown; + try { + rawClassification = active.classifyClosed(closed.value); + } catch { + rawClassification = null; + } + const afterClassification = currentFailure(); + safelyClose(active); + session = null; + phase.controller.abort(); + phase.dispose(); + if (afterClassification) { + return failure(afterClassification); + } + + const classified = parseCloseClassification( + rawClassification, + ); + if (!classified) return failure("PROTOCOL_MISMATCH"); + const afterClassificationValidation = currentFailure(); + if (afterClassificationValidation) { + return failure(afterClassificationValidation); + } + + if (classified.kind === "OUTCOME") { + if (classified.outcome.ok) { + return classified.outcome.result as RealtimeResult; + } + if ( + !reconnectable( + classified.outcome.failure, + classified.outcome.serverNotBeforeMs, + ) + ) { + return classified.outcome.failure.result; + } + + const closedAt = safeNow(clock); + if (closedAt === null) { + return classified.outcome.failure.result; + } + if ( + isReconnectAttemptResetEligible({ + policy, + openedAtMs: openedAt, + nowMs: closedAt, + observedValidHeartbeatOrEvent: + validSignalObserved, + }) + ) { + retriesUsed = 0; + budgetStartedAt = closedAt; + } + retry = captureRetry( + classified.outcome.failure.result, + classified.outcome.failure.result, + closedAt, + classified.outcome.serverNotBeforeMs, + ); + if ( + classified.outcome.failure.kind === "OFFLINE" + ) { + requireOnline(); + } + continue; + } + + const closedAt = safeNow(clock); + if (closedAt === null) { + return classified.directive.terminalFailure.result; + } + if ( + isReconnectAttemptResetEligible({ + policy, + openedAtMs: openedAt, + nowMs: closedAt, + observedValidHeartbeatOrEvent: validSignalObserved, + }) + ) { + retriesUsed = 0; + budgetStartedAt = closedAt; + } + pendingRecovery = classified.directive; + retry = captureRetry( + classified.directive.terminalFailure.result, + classified.directive.terminalFailure.result, + closedAt, + classified.directive.serverNotBeforeMs, + ); + } + } finally { + safelyRemoveAbortListener(inputSignal, onAbort); + safelyUnsubscribe(unsubscribe); + controller.abort(); + phaseController?.abort(); + safelyClose(session); + session = null; + activeRecoveryGate?.settle( + recoveryFailure(currentFailure() ?? "CLOSED"), + ); + activeRecoveryGate = null; + safelyWake(wakeOnline); + if (runController === controller) runController = null; + if (generation === runGeneration) { + state = activeTask === null ? "IDLE" : "DRAINING"; + } + } + } + + function close(): void { + if (state === "CLOSED") return; + state = "CLOSED"; + generation += 1; + activeRecoveryGate?.settle(recoveryFailure("CLOSED")); + activeRecoveryGate = null; + runController?.abort(); + phaseController?.abort(); + safelyClose(session); + } + + return Object.freeze({ + run, + getState: () => state, + close, + }); +} + +async function race( + task: Promise>, + signal: AbortSignal, +): Promise> { + if (signal.aborted) return Object.freeze({ kind: "INTERRUPTED" }); + let cleanup: () => void = () => undefined; + const interrupted = new Promise< + Readonly<{ kind: "INTERRUPTED" }> + >((resolve) => { + const abort = () => + resolve(Object.freeze({ kind: "INTERRUPTED" })); + signal.addEventListener("abort", abort, { once: true }); + cleanup = () => signal.removeEventListener("abort", abort); + if (signal.aborted) abort(); + }); + try { + return await Promise.race([task, interrupted]); + } finally { + cleanup(); + } +} + +async function drainWithinCeiling( + task: Promise>, + signal: AbortSignal, +): Promise> { + if (signal.aborted) return Object.freeze({ kind: "INTERRUPTED" }); + let timer: ReturnType | undefined; + let cleanup: () => void = () => undefined; + const boundary = new Promise< + | Readonly<{ kind: "INTERRUPTED" }> + | Readonly<{ kind: "TIMED_OUT" }> + >((resolve) => { + const abort = () => + resolve(Object.freeze({ kind: "INTERRUPTED" })); + cleanup = () => safelyRemoveAbortListener(signal, abort); + try { + signal.addEventListener("abort", abort, { once: true }); + timer = setTimeout( + () => resolve(Object.freeze({ kind: "TIMED_OUT" })), + REALTIME_RECONNECT_CEILINGS.drainTimeoutMs, + ); + } catch { + resolve(Object.freeze({ kind: "TIMED_OUT" })); + } + if (signal.aborted) abort(); + }); + try { + return await Promise.race([task, boundary]); + } finally { + cleanup(); + if (timer !== undefined) { + try { + clearTimeout(timer); + } catch { + // The generation remains fenced if host timer cleanup fails. + } + } + } +} + +function createBarrierGate(): BarrierGate { + let settled = false; + let resolveGate: + | ((result: RealtimeResult) => void) + | undefined; + const promise = new Promise>((resolve) => { + resolveGate = resolve; + }); + return Object.freeze({ + promise, + settle(result) { + if (settled) return; + settled = true; + resolveGate?.(result); + resolveGate = undefined; + }, + }); +} + +function parseRunInput(value: unknown): ParsedRunInput | null { + const snapshot = captureDataSnapshot(value); + if ( + !snapshot || + !hasOneExactKeySet(snapshot, [ + [], + ["signal"], + ["initialRecovery"], + ["initialRecovery", "signal"], + ]) + ) { + return null; + } + const signalValue = snapshot.values.signal; + if ( + signalValue !== undefined && + (signalValue === null || + (typeof signalValue !== "object" && + typeof signalValue !== "function")) + ) { + return null; + } + const initialValue = snapshot.values.initialRecovery; + const initialRecovery = + initialValue === undefined + ? null + : parseRecoveryDirectiveSnapshot( + captureDataSnapshot(initialValue), + ); + if (initialValue !== undefined && !initialRecovery) return null; + return Object.freeze({ + signal: signalValue as AbortSignal | undefined, + initialRecovery, + }); +} + +function parseAttemptOutcome( + value: unknown, +): ParsedOutcome> | null { + return parseOutcomeSnapshot( + captureDataSnapshot(value), + (candidate) => + parseAttemptSuccess( + captureDataSnapshot(candidate), + ), + ); +} + +function parseAttemptSuccess( + snapshot: DataSnapshot | null, +): ParsedValue> | null { + if ( + !snapshot || + !hasOneExactKeySet(snapshot, [ + ["session"], + ["establishedRecoveryBarrier", "session"], + ]) + ) { + return null; + } + const session = parseSession( + captureDataSnapshot(snapshot.values.session), + ); + if (!session) return null; + const hasRecoveryBarrier = snapshot.keys.includes( + "establishedRecoveryBarrier", + ); + return Object.freeze({ + value: Object.freeze({ + session, + hasRecoveryBarrier, + establishedRecoveryBarrier: + snapshot.values.establishedRecoveryBarrier, + }), + }); +} + +function parseSession( + snapshot: DataSnapshot | null, +): RealtimeReconnectSession | null { + if ( + !snapshot || + !hasExactKeys(snapshot, [ + "classifyClosed", + "close", + "waitClosed", + ]) + ) { + return null; + } + const waitClosed = snapshot.values.waitClosed; + const classifyClosed = snapshot.values.classifyClosed; + const close = snapshot.values.close; + if ( + typeof waitClosed !== "function" || + typeof classifyClosed !== "function" || + typeof close !== "function" + ) { + return null; + } + return Object.freeze({ + waitClosed: () => + Reflect.apply(waitClosed, snapshot.source, []) as + | ClosedReceipt + | Promise, + classifyClosed: (receipt: ClosedReceipt) => + Reflect.apply(classifyClosed, snapshot.source, [ + receipt, + ]) as RealtimeReconnectCloseClassification, + close: () => { + Reflect.apply(close, snapshot.source, []); + }, + }); +} + +function parseCloseClassification( + value: unknown, +): ParsedCloseClassification | null { + const snapshot = captureDataSnapshot(value); + if (!snapshot) return null; + if (snapshot.values.kind === "RECOVERY_RECONNECT") { + const directive = parseRecoveryDirectiveSnapshot(snapshot); + return directive + ? Object.freeze({ + kind: "RECOVERY_RECONNECT", + directive, + }) + : null; + } + const outcome = parseOutcomeSnapshot( + snapshot, + (candidate) => + candidate === undefined + ? Object.freeze({ value: undefined }) + : null, + ); + return outcome + ? Object.freeze({ kind: "OUTCOME", outcome }) + : null; +} + +function parseRecoveryDirectiveSnapshot( + snapshot: DataSnapshot | null, +): ParsedRecoveryDirective | null { + if ( + !snapshot || + !hasOneExactKeySet(snapshot, [ + ["kind", "recovery", "terminalResult"], + [ + "kind", + "recovery", + "serverNotBeforeMs", + "terminalResult", + ], + ]) || + snapshot.values.kind !== "RECOVERY_RECONNECT" + ) { + return null; + } + const recovery = parseCommittedRecovery( + captureDataSnapshot(snapshot.values.recovery), + ); + const terminalFailure = parseFailureResult( + snapshot.values.terminalResult, + ); + const serverNotBeforeMs = parseNotBefore( + snapshot.values.serverNotBeforeMs, + ); + if ( + !recovery || + !terminalFailure || + serverNotBeforeMs === undefined + ) { + return null; + } + return Object.freeze({ + recovery, + terminalFailure, + serverNotBeforeMs, + }); +} + +function parseCommittedRecovery( + snapshot: DataSnapshot | null, +): RealtimeCommittedRecovery | null { + if ( + !snapshot || + !hasExactKeys(snapshot, [ + "checkpoint", + "kind", + "streamId", + ]) || + snapshot.values.kind !== "RECOVERY_COMMITTED" || + !safeFrozen(snapshot.source) + ) { + return null; + } + const checkpoint = captureDataSnapshot( + snapshot.values.checkpoint, + ); + if ( + !checkpoint || + !hasExactKeys(checkpoint, [ + "lastAppliedSequence", + "recoveryMode", + "resumeCursor", + "streamEpoch", + ]) || + !safeFrozen(checkpoint.source) + ) { + return null; + } + const canonicalCheckpoint = Object.freeze({ + lastAppliedSequence: + checkpoint.values.lastAppliedSequence, + recoveryMode: checkpoint.values.recoveryMode, + resumeCursor: checkpoint.values.resumeCursor, + streamEpoch: checkpoint.values.streamEpoch, + }); + const canonicalRecovery = Object.freeze({ + checkpoint: canonicalCheckpoint, + kind: snapshot.values.kind, + streamId: snapshot.values.streamId, + }); + if ( + !isRealtimeTransportEventOutcome(canonicalRecovery) || + canonicalRecovery.kind !== "RECOVERY_COMMITTED" + ) { + return null; + } + return snapshot.source as RealtimeCommittedRecovery; +} + +function parseVoidResult( + value: unknown, +): ParsedResult | null { + return parseResult(value, (candidate) => + candidate === undefined + ? Object.freeze({ value: undefined }) + : null, + ); +} + +function parseOutcomeSnapshot( + snapshot: DataSnapshot | null, + parseValue: (candidate: unknown) => ParsedValue | null, +): ParsedOutcome | null { + if ( + !snapshot || + !hasOneExactKeySet(snapshot, [ + ["result"], + ["result", "serverNotBeforeMs"], + ]) + ) { + return null; + } + const result = parseResult(snapshot.values.result, parseValue); + if (!result) return null; + if (result.ok) { + return hasExactKeys(snapshot, ["result"]) + ? Object.freeze({ + ok: true, + result: result.result, + value: result.value, + }) + : null; + } + const serverNotBeforeMs = parseNotBefore( + snapshot.values.serverNotBeforeMs, + ); + if (serverNotBeforeMs === undefined) return null; + return Object.freeze({ + ok: false, + failure: result.failure, + serverNotBeforeMs, + }); +} + +function parseResult( + value: unknown, + parseValue: (candidate: unknown) => ParsedValue | null, +): ParsedResult | null { + const snapshot = captureDataSnapshot(value); + if (!snapshot || !safeFrozen(snapshot.source)) return null; + + if (snapshot.values.ok === true) { + if (!hasExactKeys(snapshot, ["ok", "value"])) return null; + const parsedValue = parseValue(snapshot.values.value); + const canonical = Object.freeze({ + ok: true, + value: snapshot.values.value, + }); + if ( + !parsedValue || + !isRealtimeResult( + canonical, + (candidate: unknown): candidate is unknown => true, + ) + ) { + return null; + } + return Object.freeze({ + ok: true, + result: snapshot.source as SuccessResult, + value: parsedValue.value, + }); + } + + const failure = parseFailureResultSnapshot(snapshot); + return failure + ? Object.freeze({ ok: false, failure }) + : null; +} + +function parseFailureResult( + value: unknown, +): FailureSnapshot | null { + return parseFailureResultSnapshot(captureDataSnapshot(value)); +} + +function parseFailureResultSnapshot( + snapshot: DataSnapshot | null, +): FailureSnapshot | null { + if ( + !snapshot || + !hasExactKeys(snapshot, ["error", "ok"]) || + snapshot.values.ok !== false || + !safeFrozen(snapshot.source) + ) { + return null; + } + const error = captureDataSnapshot(snapshot.values.error); + if ( + !error || + !hasExactKeys(error, [ + "kind", + "operation", + "retryable", + ]) || + !safeFrozen(error.source) + ) { + return null; + } + const canonicalError = Object.freeze({ + kind: error.values.kind, + operation: error.values.operation, + retryable: error.values.retryable, + }); + const canonical = Object.freeze({ + error: canonicalError, + ok: false, + }); + if ( + !isRealtimeResult( + canonical, + (candidate: unknown): candidate is never => false, + ) + ) { + return null; + } + const validated = + canonical as Extract, { ok: false }>; + return Object.freeze({ + result: snapshot.source as FailureResult, + kind: validated.error.kind, + retryable: validated.error.retryable, + }); +} + +function captureDataSnapshot(value: unknown): DataSnapshot | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + try { + const prototype = Object.getPrototypeOf(value); + if ( + prototype !== Object.prototype && + prototype !== null + ) { + return null; + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const ownKeys = Reflect.ownKeys(descriptors); + if (ownKeys.some((key) => typeof key !== "string")) { + return null; + } + const keys = (ownKeys as string[]).sort(); + const values = Object.create(null) as Record; + for (const key of keys) { + const descriptor = descriptors[key]; + if ( + !descriptor || + !("value" in descriptor) || + descriptor.enumerable !== true + ) { + return null; + } + Object.defineProperty(values, key, { + configurable: false, + enumerable: true, + value: descriptor.value, + writable: false, + }); + } + return Object.freeze({ + source: value, + keys: Object.freeze(keys), + values: Object.freeze(values), + }); + } catch { + return null; + } +} + +function hasOneExactKeySet( + snapshot: DataSnapshot, + expected: readonly (readonly string[])[], +): boolean { + return expected.some((keys) => hasExactKeys(snapshot, keys)); +} + +function hasExactKeys( + snapshot: DataSnapshot, + expected: readonly string[], +): boolean { + const sorted = [...expected].sort(); + return ( + snapshot.keys.length === sorted.length && + snapshot.keys.every((key, index) => key === sorted[index]) + ); +} + +function parseNotBefore( + value: unknown, +): number | null | undefined { + if (value === undefined || value === null) return null; + return typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 + ? value + : undefined; +} + +function captureRetry( + result: FailureResult, + terminalResult: FailureResult, + at: number, + serverNotBeforeMs: number | null = null, +): Retry { + return Object.freeze({ + result, + terminalResult, + at, + notBeforeMs: serverNotBeforeMs, + }); +} + +function canRetry( + retry: Retry, + attempts: number, + startedAt: number, + now: number, + policy: ReconnectPolicy, +): boolean { + if (attempts >= policy.maxAttempts) return false; + const remaining = reconnectBudgetRemaining( + policy, + startedAt, + now, + ); + const serverDelay = remainingServerDelay(retry, now); + return ( + remaining > 0 && + serverDelay !== null && + serverDelay <= policy.maxDelayMs && + serverDelay < remaining + ); +} + +function nextDelay( + retry: Retry, + attempts: number, + startedAt: number, + now: number, + policy: ReconnectPolicy, + random: () => number, +): number | null { + const serverNotBeforeMs = remainingServerDelay(retry, now); + if (serverNotBeforeMs === null) return null; + return calculateReconnectDelay({ + policy, + attemptIndex: attempts, + remainingElapsedMs: reconnectBudgetRemaining( + policy, + startedAt, + now, + ), + random, + serverNotBeforeMs, + }); +} + +function remainingServerDelay( + retry: Retry, + now: number, +): number | null { + if (!Number.isFinite(now) || now < retry.at) return null; + return retry.notBeforeMs === null + ? 0 + : Math.max( + 0, + Math.ceil(retry.notBeforeMs - (now - retry.at)), + ); +} + +function offlineRetry( + at: number, + operation: "CONNECT" | "RECEIVE", + terminalResult?: FailureResult, +): Retry { + const result = realtimeFailure("OFFLINE", operation, true); + return captureRetry( + result, + terminalResult ?? result, + at, + ); +} + +function providerRetry( + at: number, + operation: "CONNECT" | "RECEIVE", + terminalResult?: FailureResult, +): Retry { + const result = realtimeFailure( + "PROVIDER_UNAVAILABLE", + operation, + true, + ); + return captureRetry( + result, + terminalResult ?? result, + at, + ); +} + +function reconnectable( + failureSnapshot: FailureSnapshot, + serverNotBeforeMs: number | null, +): boolean { + if ( + serverNotBeforeMs === null && + (failureSnapshot.kind === "RATE_LIMITED" || + failureSnapshot.kind === "PROVIDER_UNAVAILABLE") + ) { + return false; + } + return ( + failureSnapshot.retryable && + RETRYABLE_KINDS.includes( + failureSnapshot.kind as (typeof RETRYABLE_KINDS)[number], + ) + ); +} + +function closeLateSession( + outcome: Settled, +): void { + if (outcome.kind !== "VALUE") return; + const parsed = parseAttemptOutcome(outcome.value); + if (parsed?.ok) safelyClose(parsed.value.session); +} + +function safeNow(clock: ClockPort): number | null { + try { + const value = clock.now(); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +function safeOnline( + environment: RealtimeReconnectEnvironment, +): boolean | null { + try { + const value = environment.online(); + return typeof value === "boolean" ? value : null; + } catch { + return null; + } +} + +function safeCurrent(check: () => boolean): boolean { + try { + return check() === true; + } catch { + return false; + } +} + +function safeSignalAborted( + signal: AbortSignal | undefined, +): boolean | null { + if (!signal) return false; + try { + const aborted = signal.aborted; + return typeof aborted === "boolean" ? aborted : null; + } catch { + return null; + } +} + +function safeFrozen(value: object): boolean { + try { + return Object.isFrozen(value); + } catch { + return false; + } +} + +function safelyAddAbortListener( + signal: AbortSignal | undefined, + listener: () => void, +): boolean { + if (!signal) return true; + try { + signal.addEventListener("abort", listener, { once: true }); + return true; + } catch { + return false; + } +} + +function safelyRemoveAbortListener( + signal: AbortSignal | undefined, + listener: () => void, +): void { + try { + signal?.removeEventListener("abort", listener); + } catch { + // Cleanup is best effort for a hostile signal implementation. + } +} + +function safelyClose( + active: RealtimeReconnectSession | null, +): void { + try { + active?.close(); + } catch { + // The generation is fenced even if host cleanup throws. + } +} + +function safelyUnsubscribe( + unsubscribe: (() => void) | undefined, +): void { + try { + unsubscribe?.(); + } catch { + // Cleanup cannot reopen a terminal run. + } +} + +function safelyWake(wake: (() => void) | null): void { + try { + wake?.(); + } catch { + // Wake-up remains best effort during terminal cleanup. + } +} + +function recoveryFailure( + kind: RealtimeFailureKind, +): FailureResult { + return realtimeFailure(kind, "RECOVER"); +} + +function failure(kind: RealtimeFailureKind): FailureResult { + return realtimeFailure(kind, "CONNECT", false); +} diff --git a/src/adapters/realtime/reconnect-policy.ts b/src/adapters/realtime/reconnect-policy.ts new file mode 100644 index 0000000..7bc6231 --- /dev/null +++ b/src/adapters/realtime/reconnect-policy.ts @@ -0,0 +1,224 @@ +export const REALTIME_RECONNECT_CEILINGS = Object.freeze({ + drainTimeoutMs: 2_000, + maxAttempts: 10, + maxDrainTimeoutMs: 30_000, + maxElapsedMs: 5 * 60 * 1_000, + maxDelayMs: 60_000, + maxStableOpenMs: 60_000, +}); + +export type ReconnectPolicy = Readonly<{ + baseDelayMs: number; + maxDelayMs: number; + maxAttempts: number; + maxElapsedMs: number; + stableOpenMs: number; +}>; + +const RECONNECT_POLICY_KEYS = Object.freeze([ + "baseDelayMs", + "maxDelayMs", + "maxAttempts", + "maxElapsedMs", + "stableOpenMs", +] as const); + +export type ReconnectDelayInput = Readonly<{ + policy: ReconnectPolicy; + /** + * Zero-based number of the reconnect that is about to be scheduled. + */ + attemptIndex: number; + remainingElapsedMs: number; + random: () => number; + /** + * Relative delay required by Retry-After, SSE retry or another validated + * protocol hint. It is a lower bound, never a value to clamp downward. + */ + serverNotBeforeMs?: number | null; +}>; + +export function defineReconnectPolicy( + input: ReconnectPolicy, +): ReconnectPolicy { + const snapshot = snapshotPolicy(input); + if ( + !snapshot || + !positiveInteger(snapshot.baseDelayMs) || + !positiveInteger(snapshot.maxDelayMs) || + snapshot.baseDelayMs > snapshot.maxDelayMs || + snapshot.maxDelayMs > + REALTIME_RECONNECT_CEILINGS.maxDelayMs || + !positiveInteger(snapshot.maxAttempts) || + snapshot.maxAttempts > + REALTIME_RECONNECT_CEILINGS.maxAttempts || + !positiveInteger(snapshot.maxElapsedMs) || + snapshot.maxElapsedMs > + REALTIME_RECONNECT_CEILINGS.maxElapsedMs || + !positiveInteger(snapshot.stableOpenMs) || + snapshot.stableOpenMs > + REALTIME_RECONNECT_CEILINGS.maxStableOpenMs + ) { + throw new TypeError("Invalid realtime reconnect policy."); + } + return Object.freeze(snapshot); +} + +/** + * Full-jitter exponential backoff with a server-provided not-before floor. + * `null` means the attempt budget cannot safely admit another delay. + */ +export function calculateReconnectDelay( + input: ReconnectDelayInput, +): number | null { + const { policy } = input; + if ( + !Number.isSafeInteger(input.attemptIndex) || + input.attemptIndex < 0 || + input.attemptIndex >= policy.maxAttempts || + !Number.isFinite(input.remainingElapsedMs) || + input.remainingElapsedMs <= 0 + ) { + return null; + } + + let sample: number; + try { + sample = input.random(); + } catch { + return null; + } + if (!Number.isFinite(sample) || sample < 0 || sample >= 1) { + return null; + } + + const exponentialCeiling = Math.min( + policy.maxDelayMs, + policy.baseDelayMs * 2 ** input.attemptIndex, + ); + const localDelay = Math.floor(exponentialCeiling * sample); + const serverNotBeforeMs = input.serverNotBeforeMs ?? 0; + if ( + !Number.isSafeInteger(serverNotBeforeMs) || + serverNotBeforeMs < 0 || + serverNotBeforeMs > policy.maxDelayMs + ) { + return null; + } + const effectiveDelay = Math.max(localDelay, serverNotBeforeMs); + return effectiveDelay >= input.remainingElapsedMs + ? null + : effectiveDelay; +} + +export function reconnectBudgetRemaining( + policy: ReconnectPolicy, + startedAtMs: number, + nowMs: number, +): number { + if ( + !Number.isFinite(startedAtMs) || + !Number.isFinite(nowMs) || + nowMs < startedAtMs + ) { + return 0; + } + return Math.max(0, policy.maxElapsedMs - (nowMs - startedAtMs)); +} + +export function isReconnectAttemptResetEligible(input: Readonly<{ + policy: ReconnectPolicy; + openedAtMs: number; + nowMs: number; + observedValidHeartbeatOrEvent: boolean; +}>): boolean { + if (input.observedValidHeartbeatOrEvent) return true; + return ( + Number.isFinite(input.openedAtMs) && + Number.isFinite(input.nowMs) && + input.nowMs - input.openedAtMs >= input.policy.stableOpenMs + ); +} + +/** + * Parses the HTTP Retry-After delay without applying a runtime ceiling. + * Callers must reject a value that exceeds their remaining/max-delay budget. + */ +export function parseRetryAfterDelay( + value: string | null | undefined, + nowEpochMs: number, +): number | null { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 128 || + !Number.isFinite(nowEpochMs) + ) { + return null; + } + const normalized = value.trim(); + if (/^\d+$/u.test(normalized)) { + const seconds = Number(normalized); + return Number.isSafeInteger(seconds) && + seconds <= Math.floor(Number.MAX_SAFE_INTEGER / 1_000) + ? seconds * 1_000 + : null; + } + if ( + !/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/u.test( + normalized, + ) + ) { + return null; + } + const timestamp = Date.parse(normalized); + return Number.isFinite(timestamp) + ? Math.max(0, timestamp - nowEpochMs) + : null; +} + +function positiveInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function snapshotPolicy(input: unknown): ReconnectPolicy | null { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) + ) { + return null; + } + try { + if (Object.getPrototypeOf(input) !== Object.prototype) { + return null; + } + const ownKeys = Reflect.ownKeys(input); + if ( + ownKeys.length !== RECONNECT_POLICY_KEYS.length || + RECONNECT_POLICY_KEYS.some( + (key) => !ownKeys.includes(key), + ) + ) { + return null; + } + const descriptors = Object.getOwnPropertyDescriptors(input); + if ( + RECONNECT_POLICY_KEYS.some((key) => { + const descriptor = descriptors[key]; + return !descriptor || !Object.hasOwn(descriptor, "value"); + }) + ) { + return null; + } + return { + baseDelayMs: descriptors.baseDelayMs!.value as number, + maxDelayMs: descriptors.maxDelayMs!.value as number, + maxAttempts: descriptors.maxAttempts!.value as number, + maxElapsedMs: descriptors.maxElapsedMs!.value as number, + stableOpenMs: descriptors.stableOpenMs!.value as number, + }; + } catch { + return null; + } +} diff --git a/src/adapters/realtime/result.ts b/src/adapters/realtime/result.ts new file mode 100644 index 0000000..8b5efa7 --- /dev/null +++ b/src/adapters/realtime/result.ts @@ -0,0 +1,278 @@ +import type { + RealtimeTransportEventOutcome, +} from "../../application/ports/realtime/event-authority.ts"; +import type { + RealtimeFailure, + RealtimeFailureKind, + RealtimeOperation, + RealtimeResult, +} from "../../application/ports/realtime/shared.ts"; +import { + REALTIME_FAILURE_KINDS, + REALTIME_OPERATIONS, +} from "../../application/ports/realtime/shared.ts"; +import { + isCanonicalRealtimeSequence, + isRealtimeOpaqueIdentifier, + isRealtimeResumeCursor, +} from "../../contracts/realtime-events.ts"; + +export type { + RealtimeFailure, + RealtimeFailureKind, + RealtimeOperation, + RealtimeResult, +} from "../../application/ports/realtime/shared.ts"; + +const DEFAULT_RETRYABLE = new Set([ + "OFFLINE", + "CONNECT_TIMEOUT", + "IDLE_TIMEOUT", + "RATE_LIMITED", + "PROVIDER_UNAVAILABLE", +]); +const FAILURE_KINDS = new Set(REALTIME_FAILURE_KINDS); +const OPERATIONS = new Set(REALTIME_OPERATIONS); + +export type RealtimeDataSnapshot = Readonly<{ + keys: readonly string[]; + values: Readonly>; + frozen: boolean; +}>; + +export function realtimeSuccess( + value: Value, +): Extract, { ok: true }> { + return Object.freeze({ ok: true, value }); +} + +export function realtimeFailure( + kind: RealtimeFailureKind, + operation: RealtimeOperation, + retryable = DEFAULT_RETRYABLE.has(kind), +): Extract, { ok: false }> { + return Object.freeze({ + ok: false, + error: Object.freeze({ + kind, + operation, + retryable, + } satisfies RealtimeFailure), + }); +} + +export function isRealtimeFailure( + value: unknown, +): value is RealtimeFailure { + return parseRealtimeFailure(value, true) !== null; +} + +/** + * Captures an external result through own data descriptors exactly once and + * returns a new canonical value. Callers that need to use the validated fields + * must use this returned snapshot rather than reading the source again. + */ +export function snapshotRealtimeResult( + value: unknown, + isValue: (candidate: unknown) => candidate is Value, +): RealtimeResult | null { + return parseRealtimeResult(value, isValue, false); +} + +export function isRealtimeResult( + value: unknown, + isValue: (candidate: unknown) => candidate is Value, +): value is RealtimeResult { + return parseRealtimeResult(value, isValue, true) !== null; +} + +export function isRealtimeTransportEventOutcome( + value: unknown, +): value is RealtimeTransportEventOutcome { + const snapshot = captureRealtimeDataSnapshot(value); + if (!snapshot || !snapshot.frozen) { + return false; + } + if (snapshot.values.kind === "CONTINUE") { + return hasExactSnapshotKeys(snapshot, ["kind"]); + } + if ( + snapshot.values.kind !== "RECOVERY_COMMITTED" || + !hasExactSnapshotKeys(snapshot, [ + "checkpoint", + "kind", + "streamId", + ]) || + typeof snapshot.values.streamId !== "string" + ) { + return false; + } + const checkpoint = captureRealtimeDataSnapshot( + snapshot.values.checkpoint, + ); + return ( + checkpoint !== null && + checkpoint.frozen && + hasExactSnapshotKeys(checkpoint, [ + "lastAppliedSequence", + "recoveryMode", + "resumeCursor", + "streamEpoch", + ]) && + isRealtimeOpaqueIdentifier(snapshot.values.streamId) && + isRealtimeOpaqueIdentifier(checkpoint.values.streamEpoch) && + isCanonicalRealtimeSequence( + checkpoint.values.lastAppliedSequence, + ) && + (checkpoint.values.recoveryMode === "CURSOR" + ? isRealtimeResumeCursor(checkpoint.values.resumeCursor) + : (checkpoint.values.recoveryMode === "SNAPSHOT_ONLY" || + checkpoint.values.recoveryMode === "SESSION_REBUILD") && + checkpoint.values.resumeCursor === null) + ); +} + +/** + * Reads a plain record without invoking property accessors. Symbol keys, + * inherited shapes, non-enumerable fields and accessors are rejected. The + * returned null-prototype value map is immutable and detached from later + * property reads on the source object. + */ +export function captureRealtimeDataSnapshot( + value: unknown, +): RealtimeDataSnapshot | null { + try { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) + ) { + return null; + } + const prototype = Object.getPrototypeOf(value); + if ( + prototype !== Object.prototype && + prototype !== null + ) { + return null; + } + const extensible = Object.isExtensible(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + const ownKeys = Reflect.ownKeys(descriptors); + if (ownKeys.some((key) => typeof key !== "string")) { + return null; + } + const keys = (ownKeys as string[]).sort(); + const values = Object.create(null) as Record; + let frozen = !extensible; + for (const key of keys) { + const descriptor = descriptors[key]; + if ( + !descriptor || + !Object.hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + return null; + } + Object.defineProperty(values, key, { + configurable: false, + enumerable: true, + value: descriptor.value, + writable: false, + }); + frozen = + frozen && + descriptor.configurable === false && + descriptor.writable === false; + } + return Object.freeze({ + keys: Object.freeze(keys), + values: Object.freeze(values), + frozen, + }); + } catch { + return null; + } +} + +function parseRealtimeResult( + value: unknown, + isValue: (candidate: unknown) => candidate is Value, + requireFrozenSource: boolean, +): RealtimeResult | null { + const snapshot = captureRealtimeDataSnapshot(value); + if ( + !snapshot || + (requireFrozenSource && !snapshot.frozen) + ) { + return null; + } + if ( + snapshot.values.ok === true && + hasExactSnapshotKeys(snapshot, ["ok", "value"]) + ) { + let accepted: boolean; + try { + accepted = isValue(snapshot.values.value); + } catch { + return null; + } + return accepted + ? realtimeSuccess(snapshot.values.value as Value) + : null; + } + if ( + snapshot.values.ok !== false || + !hasExactSnapshotKeys(snapshot, ["error", "ok"]) + ) { + return null; + } + const failure = parseRealtimeFailure( + snapshot.values.error, + requireFrozenSource, + ); + return failure + ? realtimeFailure( + failure.kind, + failure.operation, + failure.retryable, + ) + : null; +} + +function parseRealtimeFailure( + value: unknown, + requireFrozenSource: boolean, +): RealtimeFailure | null { + const snapshot = captureRealtimeDataSnapshot(value); + if ( + !snapshot || + (requireFrozenSource && !snapshot.frozen) || + !hasExactSnapshotKeys(snapshot, [ + "kind", + "operation", + "retryable", + ]) || + !FAILURE_KINDS.has(snapshot.values.kind) || + !OPERATIONS.has(snapshot.values.operation) || + typeof snapshot.values.retryable !== "boolean" + ) { + return null; + } + return Object.freeze({ + kind: snapshot.values.kind as RealtimeFailureKind, + operation: snapshot.values.operation as RealtimeOperation, + retryable: snapshot.values.retryable, + }); +} + +function hasExactSnapshotKeys( + snapshot: RealtimeDataSnapshot, + expectedKeys: readonly string[], +): boolean { + const expected = [...expectedKeys].sort(); + return ( + snapshot.keys.length === expected.length && + snapshot.keys.every((key, index) => key === expected[index]) + ); +} diff --git a/src/adapters/realtime/sse/fetch-sse-connection.ts b/src/adapters/realtime/sse/fetch-sse-connection.ts new file mode 100644 index 0000000..3115b9f --- /dev/null +++ b/src/adapters/realtime/sse/fetch-sse-connection.ts @@ -0,0 +1,738 @@ +import type { ClockPort } from "../../../application/ports/clock-port.ts"; +import type { + RealtimeFailureKind, + RealtimeOperation, + RealtimeResult, +} from "../../../application/ports/realtime/shared.ts"; +import type { + RealtimeTransportEventOutcome, +} from "../../../application/ports/realtime/event-authority.ts"; +import { + REALTIME_TRANSPORT_CONTINUE, +} from "../../../application/ports/realtime/event-authority.ts"; +import { isRealtimeResumeCursor } from "../../../contracts/realtime-events.ts"; +import { systemClock } from "../../platform/system-clock.ts"; +import { parseRetryAfterDelay } from "../reconnect-policy.ts"; +import { + isRealtimeResult, + isRealtimeTransportEventOutcome, + realtimeFailure, + realtimeSuccess, +} from "../result.ts"; +import { + createIncrementalSseParser, + type ParsedSseEvent, + type SseParserItem, + type SseParserLimits, +} from "./sse-parser.ts"; + +export type SseRecoveryMode = + | "CURSOR" + | "SESSION_REBUILD" + | "SNAPSHOT_ONLY"; + +export type FetchSseClosedOutcome = + | Readonly<{ + kind: "EOF"; + incompleteEventDiscarded: boolean; + retryHintMs: number | null; + }> + | Readonly<{ kind: "NO_RECONNECT" }> + | Extract< + RealtimeTransportEventOutcome, + { kind: "RECOVERY_COMMITTED" } + >; + +export type SseInboundEventOutcome = + RealtimeTransportEventOutcome; + +export const SSE_CONTINUE: SseInboundEventOutcome = + REALTIME_TRANSPORT_CONTINUE; + +export type FetchSseReadInput = Readonly<{ + resumeCursor: string | null; + signal?: AbortSignal; + /** + * Runs after the response and stream contract are validated but before any + * event bytes are consumed. A reconnect bridge can hold this gate until the + * exact recovery checkpoint's replay barrier is confirmed. + */ + onOpen?( + signal: AbortSignal, + ): + | RealtimeResult + | Promise>; + onEvent( + event: ParsedSseEvent, + signal: AbortSignal, + ): + | RealtimeResult + | Promise>; + onComment?: () => void; + onRetryHint?: (retryMs: number) => void; +}>; + +export type FetchSseConnection = Readonly<{ + read( + input: FetchSseReadInput, + ): Promise>; + close(): void; +}>; + +export type FetchSseConnectionDependencies = Readonly<{ + endpoint: string; + applicationOrigin: string; + recoveryMode: SseRecoveryMode; + fetcher?: typeof fetch; + clock?: ClockPort; + parserLimits?: Partial; + connectTimeoutMs?: number; + idleTimeoutMs?: number; + maxCursorBytes?: number; + maxRetryAfterMs?: number; +}>; + +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; +const DEFAULT_IDLE_TIMEOUT_MS = 45_000; +const DEFAULT_MAX_CURSOR_BYTES = 1_024; +const DEFAULT_MAX_RETRY_AFTER_MS = 60_000; +const MAX_CONNECT_TIMEOUT_MS = 30_000; +const MAX_IDLE_TIMEOUT_MS = 120_000; +const MAX_CURSOR_BYTES = 1_024; +const READER_CANCEL_TIMEOUT_MS = 2_000; + +export function createFetchSseConnection( + dependencies: FetchSseConnectionDependencies, +): FetchSseConnection { + const endpoint = fixedEndpoint( + dependencies.endpoint, + dependencies.applicationOrigin, + ); + const fetcher = dependencies.fetcher ?? fetch; + const clock = dependencies.clock ?? systemClock; + const connectTimeoutMs = + dependencies.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + const idleTimeoutMs = + dependencies.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + const maxCursorBytes = + dependencies.maxCursorBytes ?? DEFAULT_MAX_CURSOR_BYTES; + const maxRetryAfterMs = + dependencies.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS; + validateDependencies( + dependencies.recoveryMode, + connectTimeoutMs, + idleTimeoutMs, + maxCursorBytes, + maxRetryAfterMs, + ); + // Validate immutable parser policy at factory construction, before network + // side effects. A fresh parser is still created for every physical attempt. + createIncrementalSseParser(dependencies.parserLimits); + + let closed = false; + let active = false; + let activeController: AbortController | null = null; + let activeReader: ReadableStreamDefaultReader | null = null; + + async function read( + input: FetchSseReadInput, + ): Promise> { + if (closed) return failed("CLOSED", "CONNECT", false); + if (active) { + return failed("PROTOCOL_MISMATCH", "CONNECT", false); + } + if ( + !validResumeCursor( + input.resumeCursor, + dependencies.recoveryMode, + maxCursorBytes, + ) + ) { + return failed("PROTOCOL_MISMATCH", "CONNECT", false); + } + if (input.signal?.aborted) { + return failed("ABORTED", "CONNECT", false); + } + + active = true; + const controller = new AbortController(); + activeController = controller; + const onCallerAbort = () => controller.abort(); + input.signal?.addEventListener("abort", onCallerAbort, { + once: true, + }); + if (input.signal?.aborted) onCallerAbort(); + + try { + const request = timed( + Promise.resolve().then(() => + fetcher(endpoint.href, { + method: "GET", + credentials: "same-origin", + redirect: "error", + cache: "no-store", + referrerPolicy: "no-referrer", + headers: { + Accept: "text/event-stream", + ...(input.resumeCursor === null + ? {} + : { "Last-Event-ID": input.resumeCursor }), + }, + signal: controller.signal, + }), + ), + connectTimeoutMs, + clock, + controller.signal, + ); + const responseResult = await request; + if (responseResult.kind === "CLOCK_FAILED") { + controller.abort(); + return failed("PROVIDER_UNAVAILABLE", "CONNECT", true); + } + if (responseResult.kind === "ABORTED") { + return failed("ABORTED", "CONNECT", false); + } + if (responseResult.kind === "TIMEOUT") { + controller.abort(); + return failed("CONNECT_TIMEOUT", "CONNECT", true); + } + if (responseResult.kind === "REJECTED") { + return failed( + controller.signal.aborted ? "ABORTED" : "OFFLINE", + "CONNECT", + !controller.signal.aborted, + ); + } + const response = responseResult.value; + if (response.redirected) { + return failed("PROTOCOL_MISMATCH", "CONNECT", false); + } + if (response.status === 204) { + return succeeded(Object.freeze({ kind: "NO_RECONNECT" })); + } + if (response.status !== 200) { + const responseObservedAt = readClockNow(clock); + if (responseObservedAt === null) { + controller.abort(); + return failed( + "PROVIDER_UNAVAILABLE", + "CONNECT", + true, + ); + } + return responseFailure( + response, + responseObservedAt, + maxRetryAfterMs, + input.onRetryHint, + ); + } + if (!isEventStreamContentType(response.headers.get("content-type"))) { + return failed("PROTOCOL_MISMATCH", "CONNECT", false); + } + if (!response.body) { + return failed("MALFORMED_EVENT", "RECEIVE", false); + } + + const parser = createIncrementalSseParser( + dependencies.parserLimits, + ); + const reader = response.body.getReader(); + activeReader = reader; + let retryHintMs: number | null = null; + if (input.onOpen) { + let opening: Promise>; + try { + opening = Promise.resolve(input.onOpen(controller.signal)); + } catch { + await cancelReader(reader, clock, controller); + return failed( + "PROVIDER_UNAVAILABLE", + "CONNECT", + false, + ); + } + const opened = await timed( + opening, + connectTimeoutMs, + clock, + controller.signal, + ); + if (opened.kind !== "VALUE") { + await cancelReader(reader, clock, controller); + if (opened.kind === "ABORTED") { + return failed("ABORTED", "CONNECT", false); + } + if (opened.kind === "TIMEOUT") { + return failed("CONNECT_TIMEOUT", "CONNECT", true); + } + return failed( + "PROVIDER_UNAVAILABLE", + "CONNECT", + false, + ); + } + if (!isRealtimeResult(opened.value, isUndefined)) { + await cancelReader(reader, clock, controller); + return failed( + "PROTOCOL_MISMATCH", + "CONNECT", + false, + ); + } + if (!opened.value.ok) { + await cancelReader(reader, clock, controller); + return opened.value; + } + } + + async function handleParserItems( + items: readonly SseParserItem[], + ): Promise | null> { + for (const item of items) { + if (item.kind === "COMMENT") { + safelyNotify(input.onComment); + continue; + } + if (item.kind === "RETRY") { + retryHintMs = item.retryMs; + safelyNotify(input.onRetryHint, item.retryMs); + continue; + } + if ( + dependencies.recoveryMode === "CURSOR" && + (!item.hasExplicitId || + !item.id || + !isRealtimeResumeCursor(item.id) || + new TextEncoder().encode(item.id).byteLength > + maxCursorBytes) + ) { + await cancelReader(reader, clock, controller); + return failed("PROTOCOL_MISMATCH", "DECODE", false); + } + if ( + dependencies.recoveryMode !== "CURSOR" && + (item.hasExplicitId || item.id !== null) + ) { + await cancelReader(reader, clock, controller); + return failed("PROTOCOL_MISMATCH", "DECODE", false); + } + let handler: Promise< + RealtimeResult + >; + try { + handler = Promise.resolve( + input.onEvent(item, controller.signal), + ); + } catch { + await cancelReader(reader, clock, controller); + return failed("APPLY_FAILED", "APPLY", false); + } + const handled = await timed( + handler, + idleTimeoutMs, + clock, + controller.signal, + ); + if (handled.kind === "ABORTED") { + await cancelReader(reader, clock, controller); + return failed("ABORTED", "APPLY", false); + } + if (handled.kind === "CLOCK_FAILED") { + await cancelReader(reader, clock, controller); + return failed( + "PROVIDER_UNAVAILABLE", + "APPLY", + false, + ); + } + if ( + handled.kind === "REJECTED" || + handled.kind === "TIMEOUT" + ) { + await cancelReader(reader, clock, controller); + return failed("APPLY_FAILED", "APPLY", false); + } + if ( + handled.kind !== "VALUE" || + !isRealtimeResult( + handled.value, + isRealtimeTransportEventOutcome, + ) + ) { + await cancelReader(reader, clock, controller); + return failed("APPLY_FAILED", "APPLY", false); + } + if (!handled.value.ok) { + await cancelReader(reader, clock, controller); + return handled.value; + } + if ( + handled.value.value.kind === "RECOVERY_COMMITTED" + ) { + await cancelReader(reader, clock, controller); + return succeeded(handled.value.value); + } + if (controller.signal.aborted) { + await cancelReader(reader, clock, controller); + return failed("ABORTED", "APPLY", false); + } + } + return null; + } + + while (true) { + const readResult = await timed( + reader.read(), + idleTimeoutMs, + clock, + controller.signal, + ); + if (readResult.kind === "ABORTED") { + await cancelReader(reader, clock, controller); + return failed("ABORTED", "RECEIVE", false); + } + if (readResult.kind === "CLOCK_FAILED") { + controller.abort(); + await cancelReader(reader, clock, controller); + return failed( + "PROVIDER_UNAVAILABLE", + "RECEIVE", + true, + ); + } + if (readResult.kind === "TIMEOUT") { + controller.abort(); + await cancelReader(reader, clock, controller); + return failed("IDLE_TIMEOUT", "RECEIVE", true); + } + if (readResult.kind === "REJECTED") { + return failed( + controller.signal.aborted ? "ABORTED" : "OFFLINE", + "RECEIVE", + !controller.signal.aborted, + ); + } + if (readResult.value.done) { + const finished = parser.finish(); + if (!finished.ok) return finished; + const dispatchFailure = await handleParserItems( + finished.value.items, + ); + if (dispatchFailure) return dispatchFailure; + return succeeded( + Object.freeze({ + kind: "EOF", + incompleteEventDiscarded: + finished.value.incompleteEventDiscarded, + retryHintMs, + }), + ); + } + if (!(readResult.value.value instanceof Uint8Array)) { + await cancelReader(reader, clock, controller); + return failed("MALFORMED_EVENT", "DECODE", false); + } + const parsed = parser.push(readResult.value.value); + if (!parsed.ok) { + await cancelReader(reader, clock, controller); + return parsed; + } + const dispatchFailure = await handleParserItems(parsed.value); + if (dispatchFailure) return dispatchFailure; + } + } catch { + return failed( + controller.signal.aborted ? "ABORTED" : "MALFORMED_EVENT", + activeReader ? "RECEIVE" : "CONNECT", + false, + ); + } finally { + input.signal?.removeEventListener("abort", onCallerAbort); + controller.abort(); + if (activeReader) { + try { + activeReader.releaseLock(); + } catch { + // The terminal outcome is already determined. + } + } + activeReader = null; + activeController = null; + active = false; + } + } + + function close(): void { + if (closed) return; + closed = true; + activeController?.abort(); + if (activeReader) { + void cancelReader( + activeReader, + clock, + activeController ?? undefined, + ); + } + } + + return Object.freeze({ read, close }); +} + +function fixedEndpoint(endpoint: string, applicationOrigin: string): URL { + let parsedEndpoint: URL; + let parsedOrigin: URL; + try { + parsedEndpoint = new URL(endpoint); + parsedOrigin = new URL(applicationOrigin); + } catch { + throw new TypeError("SSE endpoint must be an absolute URL."); + } + if ( + parsedEndpoint.protocol !== "https:" || + parsedEndpoint.origin !== parsedOrigin.origin || + parsedEndpoint.username || + parsedEndpoint.password || + parsedEndpoint.search || + parsedEndpoint.hash + ) { + throw new TypeError("SSE endpoint must be fixed same-origin HTTPS."); + } + return parsedEndpoint; +} + +function validateDependencies( + recoveryMode: SseRecoveryMode, + connectTimeoutMs: number, + idleTimeoutMs: number, + maxCursorBytes: number, + maxRetryAfterMs: number, +): void { + if ( + !["CURSOR", "SESSION_REBUILD", "SNAPSHOT_ONLY"].includes( + recoveryMode, + ) || + !integerWithin(connectTimeoutMs, 1, MAX_CONNECT_TIMEOUT_MS) || + !integerWithin(idleTimeoutMs, 1, MAX_IDLE_TIMEOUT_MS) || + !integerWithin(maxCursorBytes, 1, MAX_CURSOR_BYTES) || + !integerWithin(maxRetryAfterMs, 1, DEFAULT_MAX_RETRY_AFTER_MS) + ) { + throw new TypeError("Invalid fetch SSE connection policy."); + } +} + +function validResumeCursor( + cursor: string | null, + recoveryMode: SseRecoveryMode, + maxCursorBytes: number, +): boolean { + if (recoveryMode !== "CURSOR") return cursor === null; + if (cursor === null) return true; + return ( + typeof cursor === "string" && + isRealtimeResumeCursor(cursor) && + new TextEncoder().encode(cursor).byteLength <= maxCursorBytes + ); +} + +function isEventStreamContentType(value: string | null): boolean { + if (typeof value !== "string" || value.length > 128) return false; + const parts = value.split(";").map((part) => part.trim().toLowerCase()); + if (parts[0] !== "text/event-stream") return false; + if (parts.length === 1) return true; + return ( + parts.length === 2 && + /^(?:charset=utf-8|charset="utf-8")$/u.test(parts[1] ?? "") + ); +} + +function responseFailure( + response: Response, + nowEpochMs: number, + maxRetryAfterMs: number, + onRetryHint: ((retryMs: number) => void) | undefined, +): RealtimeResult { + const status = response.status; + if (status === 401) { + return failed("AUTH_REQUIRED", "CONNECT", false); + } + if (status === 403) { + return failed("FORBIDDEN", "CONNECT", false); + } + if (status === 409 || status === 410) { + return failed("CURSOR_EXPIRED", "CONNECT", false); + } + const retryAfterMs = + status === 429 || status === 503 + ? parseRetryAfterDelay( + response.headers.get("retry-after"), + nowEpochMs, + ) + : null; + const retryHintAccepted = + retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs; + if (retryHintAccepted) { + safelyNotify(onRetryHint, retryAfterMs); + } + if (status === 429) { + return failed("RATE_LIMITED", "CONNECT", retryHintAccepted); + } + if (status === 503) { + return failed( + "PROVIDER_UNAVAILABLE", + "CONNECT", + retryHintAccepted, + ); + } + if (status === 502 || status === 504) { + return failed("PROVIDER_UNAVAILABLE", "CONNECT", true); + } + return failed("PROTOCOL_MISMATCH", "CONNECT", false); +} + +type TimedResult = + | Readonly<{ kind: "VALUE"; value: Value }> + | Readonly<{ kind: "REJECTED" }> + | Readonly<{ kind: "ABORTED" }> + | Readonly<{ kind: "CLOCK_FAILED" }> + | Readonly<{ kind: "TIMEOUT" }>; + +async function timed( + operation: Promise, + timeoutMs: number, + clock: ClockPort, + signal: AbortSignal, +): Promise> { + if (signal.aborted) { + return Object.freeze({ kind: "ABORTED" }); + } + const timer = new AbortController(); + let abortListener: (() => void) | undefined; + const operationResult = operation.then< + TimedResult, + TimedResult + >( + (value) => Object.freeze({ kind: "VALUE", value }), + () => Object.freeze({ kind: "REJECTED" }), + ); + let timeoutResult: Promise>; + try { + timeoutResult = clock.sleep(timeoutMs, timer.signal).then< + TimedResult, + TimedResult + >( + () => Object.freeze({ kind: "TIMEOUT" }), + () => + Object.freeze({ + kind: timer.signal.aborted + ? ("ABORTED" as const) + : ("CLOCK_FAILED" as const), + }), + ); + } catch { + return Object.freeze({ kind: "CLOCK_FAILED" }); + } + const abortedResult = new Promise>((resolve) => { + abortListener = () => + resolve(Object.freeze({ kind: "ABORTED" })); + signal.addEventListener("abort", abortListener, { once: true }); + if (signal.aborted) abortListener(); + }); + const result = await Promise.race([ + operationResult, + timeoutResult, + abortedResult, + ]); + timer.abort(); + if (abortListener) { + signal.removeEventListener("abort", abortListener); + } + return result; +} + +function readClockNow(clock: ClockPort): number | null { + try { + const value = clock.now(); + return Number.isFinite(value) && value >= 0 ? value : null; + } catch { + return null; + } +} + +async function cancelReader( + reader: ReadableStreamDefaultReader, + clock: ClockPort, + generation?: AbortController, +): Promise { + generation?.abort(); + let cancellation: Promise; + try { + cancellation = Promise.resolve(reader.cancel()).then( + () => undefined, + () => undefined, + ); + } catch { + return; + } + const timeout = new AbortController(); + let timeoutPromise: Promise; + try { + timeoutPromise = clock + .sleep(READER_CANCEL_TIMEOUT_MS, timeout.signal) + .then( + () => undefined, + () => undefined, + ); + } catch { + timeoutPromise = Promise.resolve(); + } + await Promise.race([cancellation, timeoutPromise]); + timeout.abort(); +} + +function safelyNotify( + callback: ((value?: never) => void) | undefined, +): void; +function safelyNotify( + callback: ((value: Value) => void) | undefined, + value: Value, +): void; +function safelyNotify( + callback: ((value: Value) => void) | (() => void) | undefined, + value?: Value, +): void { + try { + if (callback) callback(value as Value); + } catch { + // Observation and retry-hint consumers are best effort. + } +} + +function succeeded(value: Value): RealtimeResult { + return realtimeSuccess(value); +} + +function failed( + kind: RealtimeFailureKind, + operation: RealtimeOperation, + retryable?: boolean, +): RealtimeResult { + return realtimeFailure(kind, operation, retryable); +} + +function integerWithin( + value: number, + minimum: number, + maximum: number, +): boolean { + return ( + Number.isSafeInteger(value) && + value >= minimum && + value <= maximum + ); +} + +function isUndefined(value: unknown): value is undefined { + return value === undefined; +} diff --git a/src/adapters/realtime/sse/index.ts b/src/adapters/realtime/sse/index.ts new file mode 100644 index 0000000..49cc802 --- /dev/null +++ b/src/adapters/realtime/sse/index.ts @@ -0,0 +1,19 @@ +export { + createFetchSseConnection, + SSE_CONTINUE, + type FetchSseClosedOutcome, + type FetchSseConnection, + type FetchSseConnectionDependencies, + type FetchSseReadInput, + type SseInboundEventOutcome, + type SseRecoveryMode, +} from "./fetch-sse-connection.ts"; +export { + createIncrementalSseParser, + SSE_PARSER_CEILINGS, + type IncrementalSseParser, + type ParsedSseEvent, + type SseParserFinish, + type SseParserItem, + type SseParserLimits, +} from "./sse-parser.ts"; diff --git a/src/adapters/realtime/sse/sse-parser.ts b/src/adapters/realtime/sse/sse-parser.ts new file mode 100644 index 0000000..deef233 --- /dev/null +++ b/src/adapters/realtime/sse/sse-parser.ts @@ -0,0 +1,346 @@ +import type { RealtimeResult } from "../../../application/ports/realtime/shared.ts"; +import { + realtimeFailure, + realtimeSuccess, +} from "../result.ts"; + +export const SSE_PARSER_CEILINGS = Object.freeze({ + maxLineBytes: 64 * 1_024, + maxEventBytes: 64 * 1_024, + maxIncompleteBufferBytes: 128 * 1_024, + maxChunkBytes: 256 * 1_024, + maxItemsPerChunk: 256, + maxRetryMs: 60_000, +}); + +export type SseParserLimits = Readonly<{ + maxLineBytes: number; + maxEventBytes: number; + maxIncompleteBufferBytes: number; + maxChunkBytes: number; + maxItemsPerChunk: number; + maxRetryMs: number; +}>; + +export type ParsedSseEvent = Readonly<{ + kind: "EVENT"; + eventType: string; + data: string; + /** + * Standard SSE last-event-ID state. Consumers that require cursor-after- + * effect must additionally require `hasExplicitId` and commit independently. + */ + id: string | null; + hasExplicitId: boolean; +}>; + +export type SseParserItem = + | ParsedSseEvent + | Readonly<{ kind: "COMMENT" }> + | Readonly<{ kind: "RETRY"; retryMs: number }>; + +export type SseParserFinish = Readonly<{ + items: readonly SseParserItem[]; + incompleteEventDiscarded: boolean; +}>; + +export type IncrementalSseParser = Readonly<{ + push(chunk: Uint8Array): RealtimeResult; + finish(): RealtimeResult; +}>; + +export function createIncrementalSseParser( + limits: Partial = {}, +): IncrementalSseParser { + const resolved = resolveLimits(limits); + const decoder = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: false, + }); + const encoder = new TextEncoder(); + + let state: "OPEN" | "FAILED" | "FINISHED" = "OPEN"; + let atStart = true; + let pendingCarriageReturn = false; + let line = ""; + let lineBytes = 0; + let blockBytes = 0; + let dataLines: string[] = []; + let eventType = ""; + let lastEventId: string | null = null; + let hasExplicitId = false; + + function push( + chunk: Uint8Array, + ): RealtimeResult { + if (state !== "OPEN") { + return realtimeFailure("CLOSED", "DECODE"); + } + if (!(chunk instanceof Uint8Array)) { + return fail("MALFORMED_EVENT"); + } + if (chunk.byteLength > resolved.maxChunkBytes) { + return fail("EVENT_TOO_LARGE"); + } + let text: string; + try { + text = decoder.decode(chunk, { stream: true }); + } catch { + return fail("MALFORMED_EVENT"); + } + return consumeText(text); + } + + function finish(): RealtimeResult { + if (state !== "OPEN") { + return realtimeFailure("CLOSED", "DECODE"); + } + let tail: string; + try { + tail = decoder.decode(); + } catch { + return fail("MALFORMED_EVENT"); + } + const consumed = consumeText(tail); + if (!consumed.ok) return consumed; + const items = [...consumed.value]; + if (pendingCarriageReturn) { + pendingCarriageReturn = false; + const processed = processLine(1); + if (!processed.ok) return processed; + if (!appendItems(items, processed.value)) { + return fail("QUEUE_OVERFLOW"); + } + } + const incompleteEventDiscarded = + lineBytes > 0 || + blockBytes > 0 || + dataLines.length > 0 || + eventType.length > 0 || + hasExplicitId; + clearBlock(); + line = ""; + lineBytes = 0; + state = "FINISHED"; + return success( + Object.freeze({ + items: Object.freeze(items), + incompleteEventDiscarded, + }), + ); + } + + function consumeText( + text: string, + ): RealtimeResult { + const items: SseParserItem[] = []; + for (const character of text) { + if (atStart) { + atStart = false; + if (character === "\uFEFF") continue; + } + + if (pendingCarriageReturn) { + pendingCarriageReturn = false; + const processed = processLine(character === "\n" ? 2 : 1); + if (!processed.ok) return processed; + if (!appendItems(items, processed.value)) { + return fail("QUEUE_OVERFLOW"); + } + if (character === "\n") continue; + } + + if (character === "\r") { + pendingCarriageReturn = true; + continue; + } + if (character === "\n") { + const processed = processLine(1); + if (!processed.ok) return processed; + if (!appendItems(items, processed.value)) { + return fail("QUEUE_OVERFLOW"); + } + continue; + } + + line += character; + lineBytes += encoder.encode(character).byteLength; + if (lineBytes > resolved.maxLineBytes) { + return fail("EVENT_TOO_LARGE"); + } + if ( + lineBytes + blockBytes > + resolved.maxIncompleteBufferBytes + ) { + return fail("EVENT_TOO_LARGE"); + } + } + return success(Object.freeze(items)); + } + + function processLine( + terminatorBytes: number, + ): RealtimeResult { + const currentLine = line; + const currentLineBytes = lineBytes; + line = ""; + lineBytes = 0; + + if (currentLine.length === 0) { + const items: SseParserItem[] = []; + if (dataLines.length > 0) { + items.push( + Object.freeze({ + kind: "EVENT", + eventType: eventType.length > 0 ? eventType : "message", + data: dataLines.join("\n"), + id: lastEventId, + hasExplicitId, + }), + ); + } + clearBlock(); + return success(Object.freeze(items)); + } + + if (currentLine.startsWith(":")) { + return success( + Object.freeze([ + Object.freeze({ kind: "COMMENT" as const }), + ]), + ); + } + + blockBytes += currentLineBytes + terminatorBytes; + if (blockBytes > resolved.maxEventBytes) { + return fail("EVENT_TOO_LARGE"); + } + if (blockBytes > resolved.maxIncompleteBufferBytes) { + return fail("EVENT_TOO_LARGE"); + } + + const separator = currentLine.indexOf(":"); + const field = + separator === -1 + ? currentLine + : currentLine.slice(0, separator); + let value = + separator === -1 ? "" : currentLine.slice(separator + 1); + if (value.startsWith(" ")) value = value.slice(1); + + if (field === "data") { + dataLines.push(value); + return success(Object.freeze([])); + } + if (field === "event") { + eventType = value; + return success(Object.freeze([])); + } + if (field === "id") { + if (!value.includes("\0")) { + lastEventId = value; + hasExplicitId = true; + } + return success(Object.freeze([])); + } + if (field === "retry" && /^\d+$/u.test(value)) { + const retryMs = Number(value); + if ( + Number.isSafeInteger(retryMs) && + retryMs <= resolved.maxRetryMs + ) { + return success( + Object.freeze([ + Object.freeze({ kind: "RETRY" as const, retryMs }), + ]), + ); + } + } + return success(Object.freeze([])); + } + + function clearBlock(): void { + blockBytes = 0; + dataLines = []; + eventType = ""; + hasExplicitId = false; + } + + function fail( + kind: + | "EVENT_TOO_LARGE" + | "MALFORMED_EVENT" + | "QUEUE_OVERFLOW", + ): RealtimeResult { + state = "FAILED"; + line = ""; + lineBytes = 0; + clearBlock(); + return realtimeFailure(kind, "DECODE"); + } + + function appendItems( + target: SseParserItem[], + additions: readonly SseParserItem[], + ): boolean { + if ( + target.length + additions.length > + resolved.maxItemsPerChunk + ) { + return false; + } + target.push(...additions); + return true; + } + + return Object.freeze({ push, finish }); +} + +function resolveLimits( + input: Partial, +): SseParserLimits { + const limits = { + maxLineBytes: + input.maxLineBytes ?? SSE_PARSER_CEILINGS.maxLineBytes, + maxEventBytes: + input.maxEventBytes ?? SSE_PARSER_CEILINGS.maxEventBytes, + maxIncompleteBufferBytes: + input.maxIncompleteBufferBytes ?? + SSE_PARSER_CEILINGS.maxIncompleteBufferBytes, + maxChunkBytes: + input.maxChunkBytes ?? SSE_PARSER_CEILINGS.maxChunkBytes, + maxItemsPerChunk: + input.maxItemsPerChunk ?? + SSE_PARSER_CEILINGS.maxItemsPerChunk, + maxRetryMs: input.maxRetryMs ?? SSE_PARSER_CEILINGS.maxRetryMs, + }; + if ( + !positiveInteger(limits.maxLineBytes) || + limits.maxLineBytes > SSE_PARSER_CEILINGS.maxLineBytes || + !positiveInteger(limits.maxEventBytes) || + limits.maxEventBytes > SSE_PARSER_CEILINGS.maxEventBytes || + !positiveInteger(limits.maxIncompleteBufferBytes) || + limits.maxIncompleteBufferBytes > + SSE_PARSER_CEILINGS.maxIncompleteBufferBytes || + limits.maxIncompleteBufferBytes < limits.maxEventBytes || + !positiveInteger(limits.maxChunkBytes) || + limits.maxChunkBytes > SSE_PARSER_CEILINGS.maxChunkBytes || + limits.maxChunkBytes < limits.maxEventBytes || + !positiveInteger(limits.maxItemsPerChunk) || + limits.maxItemsPerChunk > + SSE_PARSER_CEILINGS.maxItemsPerChunk || + !positiveInteger(limits.maxRetryMs) || + limits.maxRetryMs > SSE_PARSER_CEILINGS.maxRetryMs + ) { + throw new TypeError("Invalid SSE parser limits."); + } + return Object.freeze(limits); +} + +function success(value: Value): RealtimeResult { + return realtimeSuccess(value); +} + +function positiveInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} diff --git a/src/adapters/realtime/stream-coordinator.ts b/src/adapters/realtime/stream-coordinator.ts new file mode 100644 index 0000000..d890c77 --- /dev/null +++ b/src/adapters/realtime/stream-coordinator.ts @@ -0,0 +1,1154 @@ +import type { + RealtimeAcceptDisposition, + RealtimeEventAuthority, + RealtimeEventObservationSink, + RealtimeObservation, + RealtimeRecoveryCheckpoint, + RealtimeRecoveryCommit, + RealtimeRecoveryReason, + RealtimeScopeSnapshot, + RealtimeStreamInspection, +} from "../../application/ports/realtime/event-authority.ts"; +import type { RealtimeResult } from "../../application/ports/realtime/shared.ts"; +import { + compareRealtimeSequences, + isCanonicalRealtimeSequence, + isRealtimeOpaqueIdentifier, + isRealtimeResumeCursor, + isRealtimeScopeBinding, + nextRealtimeSequence, + type RealtimeResumeState, +} from "../../contracts/realtime-events.ts"; +import { + mapWithBoundaryRegistry, + type InstalledBoundaryMapper, +} from "../../contracts/boundary-mapper.ts"; +import type { + EventTypeId, + RealtimePolicyRegistry, + RealtimeStreamRegistration, + StreamRegistrationId, +} from "../../contracts/realtime-streams.ts"; +import { + isValidatedRealtimeEventDto, + type ValidatedRealtimeEventDto, +} from "./event-codec.ts"; +import { + captureRealtimeDataSnapshot, + realtimeFailure, + realtimeSuccess, + snapshotRealtimeResult, + type RealtimeDataSnapshot, +} from "./result.ts"; + +export type RealtimeStreamCoordinatorDependencies = Readonly<{ + registry: RealtimePolicyRegistry; + mappers: Readonly>; + authority: RealtimeEventAuthority; + scope: RealtimeScopeSnapshot; + now?: () => number; + observe?: RealtimeEventObservationSink; +}>; + +export type RealtimeStreamCoordinator = Readonly<{ + accept( + event: ValidatedRealtimeEventDto, + signal?: AbortSignal, + ): Promise>; + recover( + streamId: StreamRegistrationId, + reason: RealtimeRecoveryReason, + signal?: AbortSignal, + ): Promise>; + confirmTransportBarrier( + streamId: StreamRegistrationId, + checkpoint: RealtimeRecoveryCheckpoint, + ): RealtimeResult; + getResumeState(streamId: StreamRegistrationId): RealtimeResumeState | null; + inspect(streamId: StreamRegistrationId): RealtimeStreamInspection; + close(): void; +}>; + +type DedupeEntry = Readonly<{ + eventId: string; + sequence: string; + semanticFingerprint: string; + fingerprintBytes: number; + appliedAt: number; +}>; + +type StreamState = { + registration: RealtimeStreamRegistration; + freshness: "UNKNOWN" | "CURRENT" | "STALE" | "RESYNCING"; + resumeState: RealtimeResumeState | null; + queuedEvents: number; + queuedBytes: number; + dedupeBytes: number; + eventIds: Map; + sequences: Map; + processingGeneration: number; + lastObservedAt: number; + tail: Promise; + recoveryPromise: Promise> | null; + activeRecoveryReason: RealtimeRecoveryReason | null; + activeEffectAbort: AbortController | null; + activeRecoveryAbort: AbortController | null; + awaitingTransportBarrier: boolean; + barrierCheckpoint: RealtimeRecoveryCheckpoint | null; + closed: boolean; +}; + +const SNAPSHOT_CHECKPOINT_KEYS = Object.freeze([ + "lastAppliedSequence", + "recoveryMode", + "resumeCursor", + "snapshotRevision", + "streamEpoch", +] as const); +const SNAPSHOT_COMMIT_KEYS = Object.freeze([ + "checkpoint", + "kind", +] as const); +const SESSION_COMMIT_KEYS = Object.freeze([ + "kind", + "lastAppliedSequence", + "streamEpoch", +] as const); + +export function createRealtimeStreamCoordinator( + dependencies: RealtimeStreamCoordinatorDependencies, +): RealtimeStreamCoordinator { + assertDependencies(dependencies); + const now = dependencies.now ?? Date.now; + const states = new Map(); + let closed = false; + + for (const registration of dependencies.registry.listStreams()) { + states.set(registration.id, { + registration, + freshness: "UNKNOWN", + resumeState: null, + queuedEvents: 0, + queuedBytes: 0, + dedupeBytes: 0, + eventIds: new Map(), + sequences: new Map(), + processingGeneration: 0, + lastObservedAt: 0, + tail: Promise.resolve(), + recoveryPromise: null, + activeRecoveryReason: null, + activeEffectAbort: null, + activeRecoveryAbort: null, + awaitingTransportBarrier: false, + barrierCheckpoint: null, + closed: false, + }); + } + + function accept( + event: ValidatedRealtimeEventDto, + signal?: AbortSignal, + ): Promise> { + if (signal?.aborted) { + return Promise.resolve(realtimeFailure("ABORTED", "RECEIVE")); + } + if (!isValidatedRealtimeEventDto(event)) { + return Promise.resolve( + realtimeFailure("MALFORMED_EVENT", "RECEIVE"), + ); + } + const state = states.get(event.envelope.streamId); + if (!state) { + return Promise.resolve( + realtimeFailure("MALFORMED_EVENT", "RECEIVE"), + ); + } + if (closed || state.closed) { + return Promise.resolve(dropped("CLOSED")); + } + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return Promise.resolve(dropped("SCOPE_FENCED")); + } + if (event.envelope.scopeBinding !== dependencies.scope.scopeBinding) { + return recoverForAccept( + state, + "SCOPE_PROTOCOL_VIOLATION", + false, + signal, + ); + } + if ( + state.awaitingTransportBarrier && + state.barrierCheckpoint + ) { + return Promise.resolve( + realtimeSuccess( + Object.freeze({ + outcome: "RECOVERY_BARRIER_REQUIRED" as const, + resumeState: state.barrierCheckpoint, + }), + ), + ); + } + if (state.recoveryPromise) { + return awaitActiveRecoveryForAccept(state, signal); + } + + const limits = state.registration.limits; + if ( + state.queuedEvents + 1 > limits.maxQueueEvents || + state.queuedBytes + event.wireBytes > limits.maxQueueBytes + ) { + observe({ + operation: "RECEIVE", + outcome: "FAILED", + streamId: state.registration.id, + eventType: event.envelope.eventType, + reason: "QUEUE_OVERFLOW", + queueSizeBucket: "OVERFLOW", + }); + return recoverForAccept( + state, + "QUEUE_OVERFLOW", + false, + signal, + ); + } + + state.queuedEvents += 1; + state.queuedBytes += event.wireBytes; + const expectedGeneration = state.processingGeneration; + observe({ + operation: "RECEIVE", + outcome: "ACCEPTED", + streamId: state.registration.id, + eventType: event.envelope.eventType, + queueSizeBucket: queueSizeBucket(state.queuedEvents), + }); + + const execution = state.tail.then(() => + processEvent(state, event, expectedGeneration, signal), + ); + const completion = execution.then( + (result) => { + releaseQueueAdmission(state, event.wireBytes); + return result; + }, + () => { + releaseQueueAdmission(state, event.wireBytes); + return realtimeFailure("APPLY_FAILED", "APPLY"); + }, + ); + state.tail = completion.then( + () => undefined, + () => undefined, + ); + return completion; + } + + async function processEvent( + state: StreamState, + event: ValidatedRealtimeEventDto, + expectedGeneration: number, + signal?: AbortSignal, + ): Promise> { + if (signal?.aborted) { + return realtimeFailure("ABORTED", "RECEIVE"); + } + if (closed || state.closed) return dropped("CLOSED"); + if (expectedGeneration !== state.processingGeneration) { + return dropped("SCOPE_FENCED"); + } + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return dropped("SCOPE_FENCED"); + } + if (event.envelope.scopeBinding !== dependencies.scope.scopeBinding) { + return recoverForAccept( + state, + "SCOPE_PROTOCOL_VIOLATION", + true, + signal, + ); + } + if (state.freshness === "UNKNOWN" || !state.resumeState) { + return recoverForAccept(state, "INITIALIZE", true, signal); + } + if (event.envelope.streamEpoch !== state.resumeState.streamEpoch) { + return recoverForAccept( + state, + "STREAM_EPOCH_CHANGED", + true, + signal, + ); + } + + pruneDedupe(state); + const duplicate = state.eventIds.get(event.envelope.eventId); + if (duplicate) { + if ( + duplicate.sequence !== event.envelope.sequence || + duplicate.semanticFingerprint !== event.semanticFingerprint + ) { + return recoverForAccept( + state, + "EVENT_CONFLICT", + true, + signal, + ); + } + observeDrop(state, event.envelope.eventType, "DUPLICATE_EVENT"); + return dropped("DUPLICATE_EVENT"); + } + const sequenceEntry = state.sequences.get(event.envelope.sequence); + if ( + sequenceEntry && + (sequenceEntry.eventId !== event.envelope.eventId || + sequenceEntry.semanticFingerprint !== event.semanticFingerprint) + ) { + return recoverForAccept( + state, + "EVENT_CONFLICT", + true, + signal, + ); + } + + const ordering = compareRealtimeSequences( + event.envelope.sequence, + state.resumeState.lastAppliedSequence, + ); + if (ordering <= 0) { + observeDrop(state, event.envelope.eventType, "STALE_EVENT"); + return dropped("STALE_EVENT"); + } + const next = nextRealtimeSequence( + state.resumeState.lastAppliedSequence, + ); + if (!next || event.envelope.sequence !== next) { + return recoverForAccept( + state, + "SEQUENCE_GAP", + true, + signal, + ); + } + if ( + state.eventIds.size + 1 > + state.registration.limits.maxDedupeEntries || + state.dedupeBytes + event.fingerprintBytes > + state.registration.limits.maxDedupeBytes + ) { + return recoverForAccept( + state, + "DEDUPE_OVERFLOW", + true, + signal, + ); + } + + const eventType = dependencies.registry.findStreamEventType( + state.registration.id, + event.envelope.eventType, + ); + if (!eventType) { + return realtimeFailure("MALFORMED_EVENT", "RECEIVE"); + } + const mapped = mapWithBoundaryRegistry( + eventType.mapperId, + event.envelope.payload, + dependencies.mappers, + ); + if (!mapped.ok) { + return recoverForAccept( + state, + "MAPPING_CONTRACT_VIOLATION", + true, + signal, + ); + } + + const effectAbort = new AbortController(); + const abortEffect = () => effectAbort.abort(); + signal?.addEventListener("abort", abortEffect, { once: true }); + state.activeEffectAbort = effectAbort; + let effectLeaseActive = true; + const effectIsCurrent = () => + effectLeaseActive && + !closed && + !state.closed && + expectedGeneration === state.processingGeneration && + state.activeEffectAbort === effectAbort && + !effectAbort.signal.aborted && + scopeIsCurrent(dependencies.scope); + let effect: unknown; + try { + effect = await dependencies.authority.effects.apply( + eventType.effectProfileId, + mapped.value, + Object.freeze({ + streamId: state.registration.id, + eventType: eventType.id, + occurredAt: event.envelope.occurredAt, + scopeGeneration: dependencies.scope.generation, + isCurrent: effectIsCurrent, + }), + effectAbort.signal, + ); + } catch { + effect = null; + } finally { + effectLeaseActive = false; + signal?.removeEventListener("abort", abortEffect); + if (state.activeEffectAbort === effectAbort) { + state.activeEffectAbort = null; + } + } + + if (closed || state.closed) { + return dropped("CLOSED"); + } + if (expectedGeneration !== state.processingGeneration) { + return dropped("SCOPE_FENCED"); + } + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return dropped("SCOPE_FENCED"); + } + if (signal?.aborted) { + state.freshness = "UNKNOWN"; + return realtimeFailure("ABORTED", "APPLY"); + } + const effectResult = snapshotRealtimeResult(effect, isUndefined); + if (!effectResult || !effectResult.ok) { + return recoverForAccept( + state, + "APPLY_FAILED", + true, + signal, + ); + } + + const resumeState = resumeStateFromEvent(event); + state.resumeState = resumeState; + state.awaitingTransportBarrier = false; + state.barrierCheckpoint = null; + state.freshness = + state.registration.delivery === "INVALIDATION_HINT" + ? "STALE" + : "CURRENT"; + const entry = Object.freeze({ + eventId: event.envelope.eventId, + sequence: event.envelope.sequence, + semanticFingerprint: event.semanticFingerprint, + fingerprintBytes: event.fingerprintBytes, + appliedAt: safeNow(state), + }); + state.eventIds.set(entry.eventId, entry); + state.sequences.set(entry.sequence, entry); + state.dedupeBytes += entry.fingerprintBytes; + observe({ + operation: "APPLY", + outcome: "APPLIED", + streamId: state.registration.id, + eventType: event.envelope.eventType, + }); + return realtimeSuccess( + Object.freeze({ + outcome: "APPLIED" as const, + resumeState, + }), + ); + } + + function recover( + streamId: StreamRegistrationId, + reason: RealtimeRecoveryReason, + signal?: AbortSignal, + ): Promise> { + const state = states.get(streamId); + if (!state) { + return Promise.resolve( + realtimeFailure("MALFORMED_EVENT", "RECOVER"), + ); + } + return runRecovery(state, reason, false, signal); + } + + function runRecovery( + state: StreamState, + reason: RealtimeRecoveryReason, + calledFromCurrentJob: boolean, + signal?: AbortSignal, + ): Promise> { + if (closed || state.closed) { + return Promise.resolve(realtimeFailure("CLOSED", "RECOVER")); + } + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return Promise.resolve( + realtimeFailure("SCOPE_FENCED", "RECOVER"), + ); + } + if (state.recoveryPromise) return state.recoveryPromise; + + const quiescence = state.tail; + state.processingGeneration += 1; + state.freshness = "RESYNCING"; + state.activeEffectAbort?.abort(); + observe({ + operation: "RECOVER", + outcome: "ACCEPTED", + streamId: state.registration.id, + reason, + }); + + const recoveryAbort = new AbortController(); + const abortRecovery = () => recoveryAbort.abort(); + signal?.addEventListener("abort", abortRecovery, { once: true }); + if (signal?.aborted) recoveryAbort.abort(); + state.activeRecoveryAbort = recoveryAbort; + state.activeRecoveryReason = reason; + const recoveryGeneration = state.processingGeneration; + let recoveryLeaseActive = true; + const recoveryIsCurrent = () => + recoveryLeaseActive && + !closed && + !state.closed && + recoveryGeneration === state.processingGeneration && + state.activeRecoveryAbort === recoveryAbort && + !recoveryAbort.signal.aborted && + scopeIsCurrent(dependencies.scope); + const task = (async (): Promise< + RealtimeResult + > => { + if (!calledFromCurrentJob) { + await quiescence; + } + if (closed || state.closed) { + state.freshness = "UNKNOWN"; + return realtimeFailure("CLOSED", "RECOVER"); + } + if (recoveryAbort.signal.aborted) { + state.freshness = "UNKNOWN"; + return realtimeFailure("ABORTED", "RECOVER"); + } + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return realtimeFailure("SCOPE_FENCED", "RECOVER"); + } + + let recovered: unknown; + let recoveryThrew = false; + try { + recovered = await dependencies.authority.recovery.recover( + Object.freeze({ + streamId: state.registration.id, + reason, + scopeGeneration: dependencies.scope.generation, + signal: recoveryAbort.signal, + isCurrent: recoveryIsCurrent, + }), + ); + } catch { + recovered = null; + recoveryThrew = true; + } finally { + recoveryLeaseActive = false; + } + if (closed || state.closed) { + state.freshness = "UNKNOWN"; + return realtimeFailure("CLOSED", "RECOVER"); + } + if (recoveryAbort.signal.aborted) { + state.freshness = "UNKNOWN"; + return realtimeFailure("ABORTED", "RECOVER"); + } + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return realtimeFailure("SCOPE_FENCED", "RECOVER"); + } + const recoveredResult = recoveryThrew + ? null + : snapshotRealtimeResult( + recovered, + isRecoveryCommitCandidate, + ); + if (!recoveredResult) { + state.freshness = "UNKNOWN"; + const failure = recoveryThrew + ? realtimeFailure("PROVIDER_UNAVAILABLE", "RECOVER") + : realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"); + observe({ + operation: "RECOVER", + outcome: "FAILED", + streamId: state.registration.id, + reason: failure.error.kind, + }); + return failure; + } + if (!recoveredResult.ok) { + state.freshness = "UNKNOWN"; + const failure = + recoveredResult.error.operation === "RECOVER" + ? realtimeFailure( + recoveredResult.error.kind, + "RECOVER", + recoveredResult.error.retryable, + ) + : realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"); + observe({ + operation: "RECOVER", + outcome: "FAILED", + streamId: state.registration.id, + reason: failure.error.kind, + }); + return failure; + } + + let validatedResumeState: RealtimeResumeState | null; + try { + validatedResumeState = validateRecoveryCommit( + state, + recoveredResult.value, + ); + } catch { + validatedResumeState = null; + } + if (!validatedResumeState) { + state.freshness = "UNKNOWN"; + observe({ + operation: "RECOVER", + outcome: "FAILED", + streamId: state.registration.id, + reason: "PROTOCOL_MISMATCH", + }); + return realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"); + } + const resumeState = + validatedResumeState as RealtimeRecoveryCheckpoint; + + state.resumeState = resumeState; + state.awaitingTransportBarrier = + recoveryRequiresTransportBarrier(state.registration); + state.barrierCheckpoint = state.awaitingTransportBarrier + ? resumeState + : null; + state.freshness = + state.awaitingTransportBarrier || + recoveryCannotProveFreshness(state.registration) + ? "STALE" + : "CURRENT"; + state.eventIds.clear(); + state.sequences.clear(); + state.dedupeBytes = 0; + observe({ + operation: "RECOVER", + outcome: "RECOVERED", + streamId: state.registration.id, + reason, + }); + return realtimeSuccess(resumeState); + })(); + + state.recoveryPromise = task; + void task.then( + () => { + signal?.removeEventListener("abort", abortRecovery); + if (state.activeRecoveryAbort === recoveryAbort) { + state.activeRecoveryAbort = null; + } + if (state.recoveryPromise === task) { + state.recoveryPromise = null; + state.activeRecoveryReason = null; + } + }, + () => { + signal?.removeEventListener("abort", abortRecovery); + if (state.activeRecoveryAbort === recoveryAbort) { + state.activeRecoveryAbort = null; + } + if (state.recoveryPromise === task) { + state.recoveryPromise = null; + state.activeRecoveryReason = null; + } + }, + ); + return task; + } + + async function recoverForAccept( + state: StreamState, + reason: RealtimeRecoveryReason, + calledFromCurrentJob: boolean, + signal?: AbortSignal, + ): Promise> { + const result = await runRecovery( + state, + reason, + calledFromCurrentJob, + signal, + ); + if (!result.ok) return result; + return realtimeSuccess( + Object.freeze({ + outcome: "RECOVERED" as const, + reason, + resumeState: result.value, + }), + ); + } + + async function awaitActiveRecoveryForAccept( + state: StreamState, + signal?: AbortSignal, + ): Promise> { + const active = state.recoveryPromise; + const reason = state.activeRecoveryReason ?? "INITIALIZE"; + if (!active) return dropped("RECOVERY_IN_PROGRESS"); + if (signal?.aborted) { + return realtimeFailure("ABORTED", "RECEIVE"); + } + let removeAbortListener: () => void = () => undefined; + const aborted = new Promise>( + (resolve) => { + if (!signal) return; + const onAbort = () => resolve({ kind: "ABORTED" }); + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => + signal.removeEventListener("abort", onAbort); + if (signal.aborted) onAbort(); + }, + ); + const settled = await Promise.race([ + active.then((result) => ({ + kind: "RESULT" as const, + result, + })), + aborted, + ]); + removeAbortListener(); + if (settled.kind === "ABORTED") { + return realtimeFailure("ABORTED", "RECEIVE"); + } + const result = settled.result; + if (!result.ok) return result; + return realtimeSuccess( + Object.freeze({ + outcome: "RECOVERED" as const, + reason, + resumeState: result.value, + }), + ); + } + + function getResumeState( + streamId: StreamRegistrationId, + ): RealtimeResumeState | null { + const state = states.get(streamId); + if (!state || closed || state.closed) return null; + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return null; + } + return state.resumeState + ? cloneResumeState(state.resumeState) + : null; + } + + function confirmTransportBarrier( + streamId: StreamRegistrationId, + checkpoint: RealtimeRecoveryCheckpoint, + ): RealtimeResult { + const state = states.get(streamId); + if (!state) { + return realtimeFailure("MALFORMED_EVENT", "RECOVER"); + } + if (closed || state.closed) { + return realtimeFailure("CLOSED", "RECOVER"); + } + if (!scopeIsCurrent(dependencies.scope)) { + fenceState(state); + return realtimeFailure("SCOPE_FENCED", "RECOVER"); + } + if ( + !state.awaitingTransportBarrier || + !state.barrierCheckpoint || + state.queuedEvents !== 0 || + state.recoveryPromise !== null || + state.barrierCheckpoint !== checkpoint + ) { + return realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"); + } + state.awaitingTransportBarrier = false; + state.barrierCheckpoint = null; + state.freshness = "CURRENT"; + return realtimeSuccess(undefined); + } + + function inspect( + streamId: StreamRegistrationId, + ): RealtimeStreamInspection { + const state = states.get(streamId); + if (!state) { + throw new TypeError("Realtime stream is not registered."); + } + return Object.freeze({ + freshness: state.freshness, + queuedEvents: state.queuedEvents, + queuedBytes: state.queuedBytes, + dedupeEntries: state.eventIds.size, + closed: closed || state.closed, + hasResumeState: state.resumeState !== null, + awaitingTransportBarrier: state.awaitingTransportBarrier, + }); + } + + function close(): void { + if (closed) return; + closed = true; + for (const state of states.values()) { + state.closed = true; + state.processingGeneration += 1; + state.activeEffectAbort?.abort(); + state.activeEffectAbort = null; + state.activeRecoveryAbort?.abort(); + state.activeRecoveryAbort = null; + state.activeRecoveryReason = null; + state.resumeState = null; + state.freshness = "UNKNOWN"; + state.awaitingTransportBarrier = false; + state.barrierCheckpoint = null; + state.queuedEvents = 0; + state.queuedBytes = 0; + state.eventIds.clear(); + state.sequences.clear(); + state.dedupeBytes = 0; + } + observe({ + operation: "CLOSE", + outcome: "ACCEPTED", + }); + } + + function safeNow(state: StreamState): number { + let candidate = state.lastObservedAt; + try { + const selected = now(); + if (Number.isFinite(selected) && selected >= 0) { + candidate = Math.max(state.lastObservedAt, selected); + } + } catch { + // A broken clock cannot make dedupe entries expire early. + } + state.lastObservedAt = candidate; + return candidate; + } + + function pruneDedupe(state: StreamState): void { + const selectedNow = safeNow(state); + for (const [eventId, entry] of state.eventIds) { + if ( + selectedNow - entry.appliedAt < + state.registration.limits.dedupeTtlMs + ) { + continue; + } + state.eventIds.delete(eventId); + if (state.sequences.get(entry.sequence) === entry) { + state.sequences.delete(entry.sequence); + } + state.dedupeBytes = Math.max( + 0, + state.dedupeBytes - entry.fingerprintBytes, + ); + } + } + + function fenceState(state: StreamState): void { + state.processingGeneration += 1; + state.activeEffectAbort?.abort(); + state.activeRecoveryAbort?.abort(); + state.resumeState = null; + state.queuedEvents = 0; + state.queuedBytes = 0; + state.eventIds.clear(); + state.sequences.clear(); + state.dedupeBytes = 0; + state.awaitingTransportBarrier = false; + state.barrierCheckpoint = null; + state.activeRecoveryReason = null; + state.freshness = "UNKNOWN"; + } + + function observe(observation: RealtimeObservation): void { + try { + dependencies.observe?.(Object.freeze({ ...observation })); + } catch { + // Correctness and cleanup never depend on diagnostics. + } + } + + function observeDrop( + state: StreamState, + eventType: EventTypeId, + reason: "DUPLICATE_EVENT" | "STALE_EVENT", + ): void { + observe({ + operation: "RECEIVE", + outcome: "DROPPED", + streamId: state.registration.id, + eventType, + reason, + }); + } + + return Object.freeze({ + accept, + recover, + confirmTransportBarrier, + getResumeState, + inspect, + close, + }); +} + +function assertDependencies( + dependencies: RealtimeStreamCoordinatorDependencies, +): void { + if ( + !dependencies || + typeof dependencies !== "object" || + !dependencies.authority || + !dependencies.authority.effects || + typeof dependencies.authority.effects.apply !== "function" || + !dependencies.authority.recovery || + typeof dependencies.authority.recovery.recover !== "function" || + !Number.isSafeInteger(dependencies.scope.generation) || + dependencies.scope.generation < 1 || + !isRealtimeScopeBinding(dependencies.scope.scopeBinding) || + typeof dependencies.scope.isCurrent !== "function" + ) { + throw new TypeError("Realtime coordinator dependencies are invalid."); + } + for (const eventType of dependencies.registry.listEventTypes()) { + const mapper = dependencies.mappers[eventType.mapperId]; + if ( + mapper?.mapperId !== eventType.mapperId || + mapper.inputSchemaId !== eventType.payloadSchemaId + ) { + throw new TypeError("Realtime mapper binding is invalid."); + } + } +} + +function scopeIsCurrent(scope: RealtimeScopeSnapshot): boolean { + try { + return scope.isCurrent() === true; + } catch { + return false; + } +} + +function isUndefined(value: unknown): value is undefined { + return value === undefined; +} + +function isRecoveryCommitCandidate( + value: unknown, +): value is RealtimeRecoveryCommit { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) + ); +} + +function dropped( + reason: + | "CLOSED" + | "DUPLICATE_EVENT" + | "RECOVERY_IN_PROGRESS" + | "SCOPE_FENCED" + | "STALE_EVENT", +): RealtimeResult { + return realtimeSuccess( + Object.freeze({ + outcome: "DROPPED" as const, + reason, + }), + ); +} + +function releaseQueueAdmission( + state: StreamState, + wireBytes: number, +): void { + state.queuedEvents = Math.max(0, state.queuedEvents - 1); + state.queuedBytes = Math.max(0, state.queuedBytes - wireBytes); +} + +function queueSizeBucket( + queuedEvents: number, +): "0" | "1-8" | "9-64" | "65-256" | "OVERFLOW" { + if (queuedEvents <= 0) return "0"; + if (queuedEvents <= 8) return "1-8"; + if (queuedEvents <= 64) return "9-64"; + if (queuedEvents <= 256) return "65-256"; + return "OVERFLOW"; +} + +function recoveryRequiresTransportBarrier( + registration: RealtimeStreamRegistration, +): boolean { + return ( + registration.primaryTransport !== "NONE" && + !( + registration.recovery.mode === "SNAPSHOT_ONLY" && + registration.recovery.barrier === "NONE" + ) + ); +} + +function recoveryCannotProveFreshness( + registration: RealtimeStreamRegistration, +): boolean { + return ( + registration.recovery.mode === "SNAPSHOT_ONLY" && + registration.recovery.barrier === "NONE" + ); +} + +function resumeStateFromEvent( + event: ValidatedRealtimeEventDto, +): RealtimeResumeState { + const base = { + streamEpoch: event.envelope.streamEpoch, + lastAppliedSequence: event.envelope.sequence, + }; + return event.envelope.recoveryMode === "CURSOR" + ? Object.freeze({ + ...base, + recoveryMode: "CURSOR" as const, + resumeCursor: event.envelope.resumeCursor, + }) + : Object.freeze({ + ...base, + recoveryMode: event.envelope.recoveryMode, + resumeCursor: null, + }); +} + +function cloneResumeState( + state: RealtimeResumeState, +): RealtimeResumeState { + return Object.freeze({ + recoveryMode: state.recoveryMode, + streamEpoch: state.streamEpoch, + lastAppliedSequence: state.lastAppliedSequence, + resumeCursor: state.resumeCursor, + }) as RealtimeResumeState; +} + +function validateRecoveryCommit( + state: StreamState, + value: RealtimeRecoveryCommit, +): RealtimeResumeState | null { + const mode = state.registration.recovery.mode; + const commit = captureRealtimeDataSnapshot(value); + if (!commit) return null; + let candidate: RealtimeResumeState; + + if (mode === "SESSION_REBUILD") { + if ( + !hasExactSnapshotKeys(commit, SESSION_COMMIT_KEYS) || + commit.values.kind !== "SESSION_REBUILD" || + !isRealtimeOpaqueIdentifier(commit.values.streamEpoch) || + !isCanonicalRealtimeSequence( + commit.values.lastAppliedSequence, + ) + ) { + return null; + } + candidate = Object.freeze({ + recoveryMode: "SESSION_REBUILD", + streamEpoch: commit.values.streamEpoch, + lastAppliedSequence: commit.values.lastAppliedSequence, + resumeCursor: null, + }); + } else { + const checkpoint = captureSnapshotCheckpoint( + commit.values.checkpoint, + mode, + ); + if ( + !hasExactSnapshotKeys(commit, SNAPSHOT_COMMIT_KEYS) || + commit.values.kind !== "SNAPSHOT_RESET" || + !checkpoint + ) { + return null; + } + candidate = + checkpoint.values.recoveryMode === "CURSOR" + ? Object.freeze({ + recoveryMode: "CURSOR" as const, + streamEpoch: checkpoint.values.streamEpoch as string, + lastAppliedSequence: + checkpoint.values.lastAppliedSequence as string, + resumeCursor: checkpoint.values.resumeCursor as string, + }) + : Object.freeze({ + recoveryMode: "SNAPSHOT_ONLY" as const, + streamEpoch: checkpoint.values.streamEpoch as string, + lastAppliedSequence: + checkpoint.values.lastAppliedSequence as string, + resumeCursor: null, + }); + } + + const current = state.resumeState; + if ( + current && + current.streamEpoch === candidate.streamEpoch && + compareRealtimeSequences( + candidate.lastAppliedSequence, + current.lastAppliedSequence, + ) < 0 + ) { + return null; + } + return candidate; +} + +function captureSnapshotCheckpoint( + value: unknown, + expectedMode: "CURSOR" | "SNAPSHOT_ONLY", +): RealtimeDataSnapshot | null { + const checkpoint = captureRealtimeDataSnapshot(value); + return checkpoint && + hasExactSnapshotKeys(checkpoint, SNAPSHOT_CHECKPOINT_KEYS) && + checkpoint.values.recoveryMode === expectedMode && + isRealtimeOpaqueIdentifier(checkpoint.values.streamEpoch) && + isCanonicalRealtimeSequence( + checkpoint.values.lastAppliedSequence, + ) && + isRealtimeOpaqueIdentifier(checkpoint.values.snapshotRevision) && + (expectedMode === "CURSOR" + ? isRealtimeResumeCursor(checkpoint.values.resumeCursor) + : checkpoint.values.resumeCursor === null) + ? checkpoint + : null; +} + +function hasExactSnapshotKeys( + snapshot: RealtimeDataSnapshot, + expected: readonly string[], +): boolean { + const selected = [...expected].sort(); + return ( + snapshot.keys.length === selected.length && + snapshot.keys.every((key, index) => key === selected[index]) + ); +} diff --git a/src/adapters/realtime/websocket/index.ts b/src/adapters/realtime/websocket/index.ts new file mode 100644 index 0000000..7ff6d37 --- /dev/null +++ b/src/adapters/realtime/websocket/index.ts @@ -0,0 +1,43 @@ +export { + createWebSocketConnection, + WEBSOCKET_IMPLEMENTATION_CEILINGS, + type WebSocketClientCeilings, + type WebSocketClosedReceipt, + type WebSocketConnection, + type WebSocketConnectionDependencies, + type WebSocketConnectionObservation, + type WebSocketConnectionSnapshot, + type WebSocketConnectionStatus, + type WebSocketFacade, + type WebSocketInboundEventOutcome, + type WebSocketLocalSendReceipt, + type WebSocketOpenReceipt, + type WebSocketRecoveryRequest, + type WebSocketResumeCheckpoint, + type WebSocketSubscribedReceipt, + type WebSocketSubscriptionRequest, +} from "./websocket-connection.ts"; +export { + decodeWebSocketServerFrame, + encodeWebSocketClientFrame, + nextUnsignedSequence, + REALTIME_WEBSOCKET_PROTOCOL, + type WebSocketAdvertisedLimits, + type WebSocketClientCloseFrame, + type WebSocketClientFrame, + type WebSocketCloseCategory, + type WebSocketEventFrame, + type WebSocketHeartbeatAckFrame, + type WebSocketHeartbeatFrame, + type WebSocketProtocolFailure, + type WebSocketProtocolResult, + type WebSocketResetReason, + type WebSocketResetRequiredFrame, + type WebSocketServerCloseFrame, + type WebSocketServerFrame, + type WebSocketSubscribedFrame, + type WebSocketSubscribeFrame, + type WebSocketUnsubscribedFrame, + type WebSocketUnsubscribeFrame, + type WebSocketWelcomeFrame, +} from "./websocket-protocol.ts"; diff --git a/src/adapters/realtime/websocket/websocket-connection.ts b/src/adapters/realtime/websocket/websocket-connection.ts new file mode 100644 index 0000000..92358ce --- /dev/null +++ b/src/adapters/realtime/websocket/websocket-connection.ts @@ -0,0 +1,2003 @@ +import type { ClockPort } from "../../../application/ports/clock-port.ts"; +import type { + RealtimeTransportEventOutcome, +} from "../../../application/ports/realtime/event-authority.ts"; +import type { + RealtimeFailure, + RealtimeFailureKind, + RealtimeOperation, + RealtimeResult, +} from "../../../application/ports/realtime/shared.ts"; +import { + isRealtimeResult, + isRealtimeTransportEventOutcome, + realtimeFailure, + realtimeSuccess, +} from "../result.ts"; +import { + REALTIME_WEBSOCKET_PROTOCOL, + decodeWebSocketServerFrame, + encodeWebSocketClientFrame, + nextUnsignedSequence, + type WebSocketAdvertisedLimits, + type WebSocketClientFrame, + type WebSocketCloseCategory, + type WebSocketEventFrame, + type WebSocketServerFrame, + type WebSocketSubscribedFrame, +} from "./websocket-protocol.ts"; + +export type WebSocketConnectionStatus = + | "IDLE" + | "CONNECTING" + | "OPEN" + | "CLOSING" + | "CLOSED"; + +export type WebSocketResumeCheckpoint = Readonly<{ + streamEpoch: string; + lastAppliedSequence: string; + cursor: string | null; +}>; + +export type WebSocketSubscriptionRequest = Readonly<{ + subscriptionId: string; + streamId: string; + scopeBinding: string; + stateBearing: boolean; + /** + * State-bearing subscriptions require a snapshot/replay checkpoint before + * SUBSCRIBE. This prevents an initial live frame from silently becoming the + * state authority. + */ + checkpoint?: WebSocketResumeCheckpoint; +}>; + +export type WebSocketRecoveryRequest = Readonly<{ + subscriptionId: string | null; + reason: + | "APPLY_FAILED" + | "CURSOR_EXPIRED" + | "EVENT_RATE_EXCEEDED" + | "PROTOCOL_MISMATCH" + | "QUEUE_OVERFLOW" + | "SCOPE_CHANGED" + | "SEQUENCE_GAP" + | "SERVER_RESET"; +}>; + +export type WebSocketConnectionObservation = Readonly<{ + kind: + | "CONNECTED" + | "SUBSCRIBED" + | "FRAME_REJECTED" + | "RECOVERY_REQUIRED" + | "CLOSED"; + failureKind?: RealtimeFailureKind; + closeCategory?: WebSocketCloseCategory; +}>; + +export type WebSocketConnectionSnapshot = Readonly<{ + status: WebSocketConnectionStatus; + subscriptionCount: number; + inboundQueueCount: number; + inboundQueueBytes: number; + outboundQueueCount: number; + outboundQueueBytes: number; + awaitingHeartbeatAck: boolean; +}>; + +export type WebSocketOpenReceipt = Readonly<{ + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + effectiveLimits: WebSocketAdvertisedLimits; +}>; + +export type WebSocketSubscribedReceipt = Readonly<{ + subscriptionId: string; + streamId: string; + streamEpoch: string; + acceptedCursor: string | null; + nextExpectedSequence: string; +}>; + +export type WebSocketLocalSendReceipt = Readonly<{ + acceptedLocally: true; +}>; + +export type WebSocketInboundEventOutcome = + RealtimeTransportEventOutcome; + +export type WebSocketClosedReceipt = Readonly<{ + category: WebSocketCloseCategory; + error: RealtimeFailure; + recovery: Extract< + RealtimeTransportEventOutcome, + { kind: "RECOVERY_COMMITTED" } + > | null; +}>; + +export type WebSocketFacade = Readonly<{ + protocol: string; + readyState: number; + bufferedAmount: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: unknown) => void, + ): void; + removeEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: unknown) => void, + ): void; +}>; + +export type WebSocketConnection = Readonly<{ + connect( + signal?: AbortSignal, + ): Promise>; + subscribe( + request: WebSocketSubscriptionRequest, + ): RealtimeResult; + unsubscribe( + subscriptionId: string, + ): RealtimeResult; + waitClosed(): Promise; + close(category?: WebSocketCloseCategory): void; + inspect(): WebSocketConnectionSnapshot; +}>; + +export type WebSocketConnectionDependencies = Readonly<{ + origin: string; + endpointPath: string; + clock: ClockPort; + createNonce(): string; + createSocket( + endpoint: string, + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL, + ): WebSocketFacade; + onEvent( + frame: WebSocketEventFrame, + signal: AbortSignal, + ): Promise>; + /** + * Runs after the SUBSCRIBED cursor/epoch/sequence contract is validated and + * before the subscription becomes an event writer. A reconnect bridge can + * hold this gate until the exact recovery checkpoint is confirmed. + */ + onSubscribed?( + receipt: WebSocketSubscribedReceipt, + signal: AbortSignal, + ): + | RealtimeResult + | Promise>; + onRecoveryRequired(request: WebSocketRecoveryRequest): void; + heartbeatEligible?(): boolean; + observe?(observation: WebSocketConnectionObservation): void; + verifyCursorRotation?(input: Readonly<{ + requestedCursor: string; + acceptedCursor: string; + streamEpoch: string; + nextExpectedSequence: string; + }>): boolean; + ceilings?: Partial; +}>; + +export type WebSocketClientCeilings = Readonly< + WebSocketAdvertisedLimits & { + connectTimeoutMs: number; + minHeartbeatMs: number; + maxHeartbeatMs: number; + minHeartbeatAckTimeoutMs: number; + maxHeartbeatAckTimeoutMs: number; + maxApplyMs: number; + } +>; + +export const WEBSOCKET_IMPLEMENTATION_CEILINGS: WebSocketClientCeilings = + Object.freeze({ + maxFrameBytes: 64 * 1_024, + maxSubscriptions: 32, + maxInboundQueueCount: 256, + maxInboundQueueBytes: 4 * 1_024 * 1_024, + maxOutboundQueueCount: 128, + maxOutboundQueueBytes: 256 * 1_024, + maxBufferedAmountBytes: 256 * 1_024, + maxEventsPerSecond: 256, + connectTimeoutMs: 30_000, + minHeartbeatMs: 5_000, + maxHeartbeatMs: 60_000, + minHeartbeatAckTimeoutMs: 1_000, + maxHeartbeatAckTimeoutMs: 30_000, + maxApplyMs: 30_000, + }); + +type SubscriptionState = { + readonly request: WebSocketSubscriptionRequest; + state: "PENDING" | "ACTIVE" | "UNSUBSCRIBING"; + unsubscribeController: AbortController | null; + eventWindowStartedAt: number; + eventCount: number; +}; + +type QueuedFrame = Readonly<{ + frame: WebSocketServerFrame; + byteLength: number; +}>; + +type QueuedOutboundFrame = Readonly<{ + data: string; + byteLength: number; + operation: RealtimeOperation; +}>; + +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const OPAQUE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/u; +const NATIVE_CONNECTING = 0; +const NATIVE_OPEN = 1; +const NATIVE_CLOSING = 2; +const PRIVATE_CLOSE_CODE = 4_000; + +/** + * One connection generation. Reconnect ownership deliberately remains + * outside this object so a route/component cannot create competing retry + * loops. + */ +export function createWebSocketConnection( + dependencies: WebSocketConnectionDependencies, +): WebSocketConnection { + if ( + dependencies.onSubscribed !== undefined && + typeof dependencies.onSubscribed !== "function" + ) { + throw new TypeError("WebSocket subscribed barrier is invalid."); + } + const endpointCandidate = fixedSameOriginWebSocketEndpoint( + dependencies.origin, + dependencies.endpointPath, + ); + if (!endpointCandidate) { + throw new TypeError("WebSocket endpoint policy is invalid."); + } + const endpoint: string = endpointCandidate; + const ceilings = resolveCeilings(dependencies.ceilings); + + let status: WebSocketConnectionStatus = "IDLE"; + let socket: WebSocketFacade | null = null; + let transportOpened = false; + let welcomeReceived = false; + let effectiveLimits: WebSocketAdvertisedLimits | null = null; + const generation = new AbortController(); + const connectDeadlineController = new AbortController(); + let externalAbortCleanup: (() => void) | null = null; + let settleConnect: + | (( + result: RealtimeResult, + ) => void) + | null = null; + let connectPromise: + | Promise> + | null = null; + let settleClosed: + | ((receipt: WebSocketClosedReceipt) => void) + | null = null; + const closedPromise = new Promise( + (resolve) => { + settleClosed = resolve; + }, + ); + let closeObserved = false; + let processing = false; + let queuedBytes = 0; + let outboundQueuedBytes = 0; + let outboundDrainScheduled = false; + let outboundDraining = false; + let outstandingHeartbeat: string | null = null; + let retiredHeartbeat: string | null = null; + const queue: QueuedFrame[] = []; + const outboundQueue: QueuedOutboundFrame[] = []; + const subscriptions = new Map(); + + const openListener = () => { + if (status !== "CONNECTING" || !socket) return; + if (socket.protocol !== REALTIME_WEBSOCKET_PROTOCOL) { + terminate( + failure( + "PROTOCOL_MISMATCH", + "CONNECT", + false, + "NONE", + ), + "PROTOCOL_MISMATCH", + { + subscriptionId: null, + reason: "PROTOCOL_MISMATCH", + }, + ); + return; + } + transportOpened = true; + }; + + const messageListener = (event: unknown) => { + if (status === "CLOSED" || status === "CLOSING") return; + let decoded: ReturnType; + try { + const data = eventData(event); + decoded = decodeWebSocketServerFrame( + data, + effectiveLimits?.maxFrameBytes ?? ceilings.maxFrameBytes, + ); + } catch { + decoded = Object.freeze({ + ok: false, + error: Object.freeze({ + code: "MALFORMED_FRAME" as const, + }), + }); + } + if (!decoded.ok) { + const code = + decoded.error.code === "FRAME_TOO_LARGE" + ? "EVENT_TOO_LARGE" + : decoded.error.code === "PROTOCOL_MISMATCH" + ? "PROTOCOL_MISMATCH" + : "MALFORMED_EVENT"; + observe({ + kind: "FRAME_REJECTED", + failureKind: code, + }); + terminate( + failure( + code, + "RECEIVE", + false, + code === "EVENT_TOO_LARGE" ? "SNAPSHOT" : "NONE", + ), + "PROTOCOL_MISMATCH", + { + subscriptionId: null, + reason: "PROTOCOL_MISMATCH", + }, + ); + return; + } + if (decoded.value.type === "HEARTBEAT_ACK") { + if ( + !welcomeReceived || + (decoded.value.nonce !== outstandingHeartbeat && + decoded.value.nonce !== retiredHeartbeat) + ) { + terminateProtocol(); + return; + } + if (decoded.value.nonce === outstandingHeartbeat) { + outstandingHeartbeat = null; + } + if (decoded.value.nonce === retiredHeartbeat) { + retiredHeartbeat = null; + } + return; + } + const queueLimits = effectiveLimits ?? ceilings; + if ( + queue.length + 1 > queueLimits.maxInboundQueueCount || + queuedBytes + decoded.byteLength > + queueLimits.maxInboundQueueBytes + ) { + terminate( + failure( + "QUEUE_OVERFLOW", + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + { + subscriptionId: null, + reason: "QUEUE_OVERFLOW", + }, + ); + return; + } + queue.push( + Object.freeze({ + frame: decoded.value, + byteLength: decoded.byteLength, + }), + ); + queuedBytes += decoded.byteLength; + void drainQueue(); + }; + + const closeListener = (event: unknown) => { + if (status === "CLOSED") return; + const category = mapNativeCloseCategory(eventCode(event)); + terminate( + closeFailure(category), + category, + category === "CURSOR_RESET" + ? { + subscriptionId: null, + reason: "CURSOR_EXPIRED", + } + : undefined, + false, + ); + }; + + const errorListener = () => { + if (status === "CLOSED") return; + terminate( + failure( + "PROVIDER_UNAVAILABLE", + status === "CONNECTING" + ? "CONNECT" + : "RECEIVE", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + }; + + async function connect( + signal?: AbortSignal, + ): Promise> { + if (status === "CLOSED" || status === "CLOSING") { + return connectionFailure( + "CLOSED", + "CONNECT", + false, + "NONE", + ); + } + if (connectPromise) return connectPromise; + if (status !== "IDLE") { + return connectionFailure( + "CLOSED", + "CONNECT", + false, + "NONE", + ); + } + if (signal?.aborted) { + const aborted = connectionFailure( + "ABORTED", + "CONNECT", + false, + "NONE", + ); + terminate( + aborted.error, + "NORMAL", + undefined, + false, + ); + return aborted; + } + status = "CONNECTING"; + connectPromise = new Promise((resolve) => { + settleConnect = resolve; + }); + + try { + socket = dependencies.createSocket( + endpoint, + REALTIME_WEBSOCKET_PROTOCOL, + ); + socket.addEventListener("open", openListener); + socket.addEventListener("message", messageListener); + socket.addEventListener("close", closeListener); + socket.addEventListener("error", errorListener); + } catch { + terminate( + failure( + "PROVIDER_UNAVAILABLE", + "CONNECT", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + return connectPromise; + } + + if (signal) { + const abort = () => { + terminate( + failure( + "ABORTED", + "CONNECT", + false, + "NONE", + ), + "NORMAL", + undefined, + ); + }; + signal.addEventListener("abort", abort, { once: true }); + externalAbortCleanup = () => + signal.removeEventListener("abort", abort); + if (signal.aborted) abort(); + } + + void connectDeadline(connectDeadlineController.signal); + return connectPromise; + } + + function subscribe( + request: WebSocketSubscriptionRequest, + ): RealtimeResult { + if ( + status !== "OPEN" || + !effectiveLimits || + !isSubscriptionRequest(request) || + (request.stateBearing && !request.checkpoint) || + subscriptions.has(request.subscriptionId) || + subscriptions.size >= effectiveLimits.maxSubscriptions + ) { + return connectionFailure( + status === "OPEN" ? "PROTOCOL_MISMATCH" : "CLOSED", + "SUBSCRIBE", + false, + "NONE", + ); + } + const eventWindowStartedAt = readClockNow(dependencies.clock); + if (eventWindowStartedAt === null) { + const unavailable = connectionFailure( + "PROVIDER_UNAVAILABLE", + "SUBSCRIBE", + true, + "RECONNECT", + ); + terminate( + unavailable.error, + "NETWORK_LOST", + undefined, + ); + return unavailable; + } + const frame: WebSocketClientFrame = { + type: "SUBSCRIBE", + protocol: REALTIME_WEBSOCKET_PROTOCOL, + subscriptionId: request.subscriptionId, + streamId: request.streamId, + cursor: request.checkpoint?.cursor ?? null, + scopeBinding: request.scopeBinding, + }; + const sent = sendCritical(frame, "SUBSCRIBE"); + if (!sent.ok) return sent; + subscriptions.set(request.subscriptionId, { + request: snapshotSubscriptionRequest(request), + state: "PENDING", + unsubscribeController: null, + eventWindowStartedAt, + eventCount: 0, + }); + return sent; + } + + function unsubscribe( + subscriptionId: string, + ): RealtimeResult { + const subscription = subscriptions.get(subscriptionId); + if ( + status !== "OPEN" || + !IDENTIFIER.test(subscriptionId) || + !subscription || + subscription.state === "UNSUBSCRIBING" + ) { + return connectionFailure( + status === "OPEN" ? "PROTOCOL_MISMATCH" : "CLOSED", + "SUBSCRIBE", + false, + "NONE", + ); + } + const sent = sendCritical( + { + type: "UNSUBSCRIBE", + protocol: REALTIME_WEBSOCKET_PROTOCOL, + subscriptionId, + }, + "SUBSCRIBE", + ); + if (sent.ok) { + subscription.state = "UNSUBSCRIBING"; + startUnsubscribeDeadline(subscriptionId, subscription); + } + return sent; + } + + function waitClosed(): Promise { + return closedPromise; + } + + function close( + category: WebSocketCloseCategory = "NORMAL", + ): void { + if (status === "CLOSED" || status === "CLOSING") return; + if ( + status === "OPEN" && + socket?.readyState === NATIVE_OPEN && + effectiveLimits + ) { + const sent = sendCritical( + { + type: "CLOSE", + protocol: REALTIME_WEBSOCKET_PROTOCOL, + category, + }, + "CLOSE", + ); + if (sent.ok) drainOutboundQueue(); + } + terminate( + failure("CLOSED", "CLOSE", false, "NONE"), + category, + undefined, + ); + } + + function inspect(): WebSocketConnectionSnapshot { + return Object.freeze({ + status, + subscriptionCount: countLiveSubscriptions(subscriptions), + inboundQueueCount: queue.length, + inboundQueueBytes: queuedBytes, + outboundQueueCount: outboundQueue.length, + outboundQueueBytes: outboundQueuedBytes, + awaitingHeartbeatAck: outstandingHeartbeat !== null, + }); + } + + async function connectDeadline(signal: AbortSignal): Promise { + try { + await dependencies.clock.sleep(ceilings.connectTimeoutMs, signal); + if (status === "CONNECTING") { + terminate( + failure( + "CONNECT_TIMEOUT", + "CONNECT", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + } + } catch { + if ( + !signal.aborted && + status === "CONNECTING" + ) { + terminate( + failure( + "PROVIDER_UNAVAILABLE", + "CONNECT", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + } + } + } + + async function drainQueue(): Promise { + if (processing) return; + processing = true; + try { + while (queue.length > 0 && status !== "CLOSED") { + const queued = queue.shift(); + if (!queued) break; + queuedBytes -= queued.byteLength; + await processFrame(queued.frame); + } + } finally { + processing = false; + } + } + + async function processFrame( + frame: WebSocketServerFrame, + ): Promise { + if (!welcomeReceived) { + if (frame.type !== "WELCOME" || !transportOpened) { + terminateProtocol(); + return; + } + processWelcome(frame); + return; + } + if (frame.type === "WELCOME") { + terminateProtocol(); + return; + } + + switch (frame.type) { + case "SUBSCRIBED": + await processSubscribed(frame); + return; + case "UNSUBSCRIBED": { + const subscription = subscriptions.get( + frame.subscriptionId, + ); + if ( + !subscription || + subscription.state !== "UNSUBSCRIBING" + ) { + terminateProtocol(); + return; + } + subscription.unsubscribeController?.abort(); + subscription.unsubscribeController = null; + subscriptions.delete(frame.subscriptionId); + return; + } + case "EVENT": + await processEvent(frame); + return; + case "RESET_REQUIRED": { + const subscription = subscriptions.get( + frame.subscriptionId, + ); + if (!subscription) { + terminateProtocol(); + return; + } + if (subscription.state === "UNSUBSCRIBING") return; + requestRecovery({ + subscriptionId: frame.subscriptionId, + reason: frame.reason, + }); + terminate( + failure( + resetFailureCode(frame.reason), + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + ); + return; + } + case "HEARTBEAT_ACK": + // HEARTBEAT_ACK is consumed by the bounded control path in the + // message listener so application effects cannot head-of-line block + // the acknowledgement deadline. + terminateProtocol(); + return; + case "CLOSE": + terminate( + closeFailure(frame.category), + frame.category, + frame.category === "CURSOR_RESET" + ? { + subscriptionId: null, + reason: "CURSOR_EXPIRED", + } + : undefined, + ); + return; + } + } + + function processWelcome( + frame: Extract, + ): void { + if ( + frame.heartbeatMs < ceilings.minHeartbeatMs || + frame.heartbeatMs > ceilings.maxHeartbeatMs || + frame.heartbeatAckTimeoutMs < + ceilings.minHeartbeatAckTimeoutMs || + frame.heartbeatAckTimeoutMs > + ceilings.maxHeartbeatAckTimeoutMs + ) { + terminateProtocol(); + return; + } + effectiveLimits = minLimits(ceilings, frame.limits); + welcomeReceived = true; + status = "OPEN"; + connectDeadlineController.abort(); + settleOpen( + realtimeSuccess( + Object.freeze({ + protocol: REALTIME_WEBSOCKET_PROTOCOL, + effectiveLimits, + }), + ), + ); + observe({ kind: "CONNECTED" }); + void heartbeatLoop( + generation.signal, + frame.heartbeatMs, + frame.heartbeatAckTimeoutMs, + ); + } + + async function processSubscribed( + frame: WebSocketSubscribedFrame, + ): Promise { + const subscription = subscriptions.get(frame.subscriptionId); + if (!subscription) { + terminateProtocol(); + return; + } + if (subscription.state === "UNSUBSCRIBING") return; + if (subscription.state !== "PENDING") { + terminateProtocol(); + return; + } + const checkpoint = subscription.request.checkpoint; + if (checkpoint) { + const expectedSequence = nextUnsignedSequence( + checkpoint.lastAppliedSequence, + ); + const cursorMatches = + checkpoint.cursor === null + ? frame.acceptedCursor === null + : frame.acceptedCursor === checkpoint.cursor || + (frame.acceptedCursor !== null && + verifyCursorRotation( + dependencies.verifyCursorRotation, + checkpoint.cursor, + frame.acceptedCursor, + frame.streamEpoch, + frame.nextExpectedSequence, + )); + if ( + expectedSequence === null || + frame.streamEpoch !== checkpoint.streamEpoch || + frame.nextExpectedSequence !== expectedSequence || + !cursorMatches + ) { + requestRecovery({ + subscriptionId: frame.subscriptionId, + reason: "SEQUENCE_GAP", + }); + terminate( + failure( + "SEQUENCE_GAP", + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + ); + return; + } + } else if (subscription.request.stateBearing) { + terminateProtocol(); + return; + } else if (frame.acceptedCursor !== null) { + terminateProtocol(); + return; + } + if (dependencies.onSubscribed) { + const barrierController = new AbortController(); + const abortBarrier = () => barrierController.abort(); + generation.signal.addEventListener("abort", abortBarrier, { + once: true, + }); + if (generation.signal.aborted) abortBarrier(); + try { + const outcome = await Promise.race([ + Promise.resolve() + .then(() => + dependencies.onSubscribed!( + Object.freeze({ + subscriptionId: frame.subscriptionId, + streamId: subscription.request.streamId, + streamEpoch: frame.streamEpoch, + acceptedCursor: frame.acceptedCursor, + nextExpectedSequence: frame.nextExpectedSequence, + }), + barrierController.signal, + ), + ) + .then((value) => + Object.freeze({ + kind: "HANDLED" as const, + value, + }), + ), + dependencies.clock + .sleep( + ceilings.maxApplyMs, + barrierController.signal, + ) + .then(() => + Object.freeze({ kind: "TIMED_OUT" as const }), + ), + ]); + if ( + outcome.kind === "TIMED_OUT" && + status !== "CLOSED" + ) { + requestRecovery({ + subscriptionId: frame.subscriptionId, + reason: "APPLY_FAILED", + }); + terminate( + failure( + "APPLY_FAILED", + "RECOVER", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + ); + return; + } + if ( + outcome.kind === "HANDLED" && + status !== "CLOSED" + ) { + if (!isRealtimeResult(outcome.value, isUndefined)) { + terminateInboundFailure( + realtimeFailure( + "PROTOCOL_MISMATCH", + "RECOVER", + false, + ).error, + frame.subscriptionId, + ); + return; + } + if (!outcome.value.ok) { + terminateInboundFailure( + outcome.value.error, + frame.subscriptionId, + ); + return; + } + } + } catch { + if ( + status !== "CLOSED" && + !generation.signal.aborted + ) { + terminateInboundFailure( + realtimeFailure( + "PROVIDER_UNAVAILABLE", + "RECOVER", + false, + ).error, + frame.subscriptionId, + ); + } + return; + } finally { + generation.signal.removeEventListener( + "abort", + abortBarrier, + ); + barrierController.abort(); + } + } + if ( + status !== "OPEN" || + generation.signal.aborted || + subscription.state !== "PENDING" + ) { + return; + } + subscription.state = "ACTIVE"; + observe({ kind: "SUBSCRIBED" }); + } + + async function processEvent( + frame: WebSocketEventFrame, + ): Promise { + const subscription = subscriptions.get(frame.subscriptionId); + if (!subscription) { + terminateProtocol(); + return; + } + if (subscription.state === "UNSUBSCRIBING") return; + if (subscription.state !== "ACTIVE") { + terminateProtocol(); + return; + } + if ( + frame.envelope.streamId !== + subscription.request.streamId + ) { + terminateProtocol(); + return; + } + const now = readClockNow(dependencies.clock); + if (now === null) { + terminate( + failure( + "PROVIDER_UNAVAILABLE", + "RECEIVE", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + return; + } + if (now - subscription.eventWindowStartedAt >= 1_000) { + subscription.eventWindowStartedAt = now; + subscription.eventCount = 0; + } + subscription.eventCount += 1; + if ( + subscription.eventCount > + (effectiveLimits?.maxEventsPerSecond ?? + ceilings.maxEventsPerSecond) + ) { + requestRecovery({ + subscriptionId: frame.subscriptionId, + reason: "EVENT_RATE_EXCEEDED", + }); + terminate( + failure( + "QUEUE_OVERFLOW", + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + ); + return; + } + + const applyController = new AbortController(); + const abortApply = () => applyController.abort(); + generation.signal.addEventListener("abort", abortApply, { + once: true, + }); + try { + const outcome = await Promise.race([ + dependencies + .onEvent(frame, applyController.signal) + .then((value) => + Object.freeze({ + kind: "HANDLED" as const, + value, + }), + ), + dependencies.clock + .sleep(ceilings.maxApplyMs, applyController.signal) + .then(() => + Object.freeze({ kind: "TIMED_OUT" as const }), + ), + ]); + if (outcome.kind === "TIMED_OUT" && status !== "CLOSED") { + requestRecovery({ + subscriptionId: frame.subscriptionId, + reason: "APPLY_FAILED", + }); + terminate( + failure( + "APPLY_FAILED", + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + ); + } else if (outcome.kind === "HANDLED" && status !== "CLOSED") { + if ( + !isRealtimeResult( + outcome.value, + isRealtimeTransportEventOutcome, + ) + ) { + requestRecovery({ + subscriptionId: frame.subscriptionId, + reason: "APPLY_FAILED", + }); + terminate( + failure( + "APPLY_FAILED", + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + ); + } else if (!outcome.value.ok) { + terminateInboundFailure( + outcome.value.error, + frame.subscriptionId, + ); + } else if ( + outcome.value.value.kind === "RECOVERY_COMMITTED" + ) { + terminate( + failure( + "CURSOR_EXPIRED", + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + true, + outcome.value.value, + ); + } + } + } catch { + if (status !== "CLOSED" && !generation.signal.aborted) { + requestRecovery({ + subscriptionId: frame.subscriptionId, + reason: "APPLY_FAILED", + }); + terminate( + failure( + "APPLY_FAILED", + "RECEIVE", + false, + "SNAPSHOT", + ), + "CURSOR_RESET", + undefined, + ); + } + } finally { + generation.signal.removeEventListener("abort", abortApply); + applyController.abort(); + } + } + + function terminateInboundFailure( + terminalFailure: RealtimeFailure, + subscriptionId: string, + ): void { + terminate( + terminalFailure, + closeCategoryForInboundFailure(terminalFailure), + recoveryRequestForInboundFailure( + terminalFailure.kind, + subscriptionId, + ), + ); + } + + async function heartbeatLoop( + signal: AbortSignal, + heartbeatMs: number, + ackTimeoutMs: number, + ): Promise { + try { + while (!signal.aborted && status === "OPEN") { + await dependencies.clock.sleep(heartbeatMs, signal); + if (signal.aborted || status !== "OPEN") return; + const eligible = heartbeatIsEligible(); + if (eligible === null) { + terminateClockFailure(); + return; + } + if (!eligible) continue; + if (outstandingHeartbeat !== null) { + terminateIdleTimeout(); + return; + } + const nonce = dependencies.createNonce(); + if (!IDENTIFIER.test(nonce)) { + terminateProtocol(); + return; + } + const sent = sendCritical( + { + type: "HEARTBEAT", + protocol: REALTIME_WEBSOCKET_PROTOCOL, + nonce, + }, + "SEND", + ); + if (!sent.ok) return; + retiredHeartbeat = null; + outstandingHeartbeat = nonce; + await dependencies.clock.sleep(ackTimeoutMs, signal); + const stillEligible = heartbeatIsEligible(); + if (stillEligible === null) { + if (!signal.aborted && status === "OPEN") { + terminateClockFailure(); + } + return; + } + if ( + !signal.aborted && + status === "OPEN" && + outstandingHeartbeat === nonce && + !stillEligible + ) { + outstandingHeartbeat = null; + retiredHeartbeat = nonce; + continue; + } + if ( + !signal.aborted && + status === "OPEN" && + outstandingHeartbeat === nonce + ) { + terminateIdleTimeout(); + return; + } + } + } catch { + if (!signal.aborted && status === "OPEN") { + terminateClockFailure(); + } + } + } + + function heartbeatIsEligible(): boolean | null { + try { + return dependencies.heartbeatEligible?.() !== false; + } catch { + return null; + } + } + + function terminateClockFailure(): void { + terminate( + failure( + "PROVIDER_UNAVAILABLE", + "RECEIVE", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + } + + function terminateIdleTimeout(): void { + terminate( + failure( + "IDLE_TIMEOUT", + "RECEIVE", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + } + + function terminateProtocol(): void { + requestRecovery({ + subscriptionId: null, + reason: "PROTOCOL_MISMATCH", + }); + terminate( + failure( + "PROTOCOL_MISMATCH", + "RECEIVE", + false, + "NONE", + ), + "PROTOCOL_MISMATCH", + undefined, + ); + } + + function sendCritical( + frame: WebSocketClientFrame, + operation: RealtimeOperation, + ): RealtimeResult { + if ( + status !== "OPEN" || + !socket || + socket.readyState !== NATIVE_OPEN || + !effectiveLimits + ) { + return connectionFailure("CLOSED", operation, false, "NONE"); + } + const encoded = encodeWebSocketClientFrame( + frame, + effectiveLimits.maxFrameBytes, + ); + if (!encoded.ok) { + return connectionFailure( + encoded.error.code === "FRAME_TOO_LARGE" + ? "EVENT_TOO_LARGE" + : "PROTOCOL_MISMATCH", + operation, + false, + "NONE", + ); + } + if ( + outboundQueue.length + 1 > + effectiveLimits.maxOutboundQueueCount || + outboundQueuedBytes + encoded.byteLength > + effectiveLimits.maxOutboundQueueBytes + ) { + return terminateOutboundOverflow(operation); + } + const bufferedAmount = readSocketBufferedAmount(socket); + if (bufferedAmount === null) { + const result = connectionFailure( + "PROVIDER_UNAVAILABLE", + operation, + true, + "RECONNECT", + ); + terminate(result.error, "NETWORK_LOST", undefined); + return result; + } + if ( + bufferedAmount + + outboundQueuedBytes + + encoded.byteLength > + effectiveLimits.maxBufferedAmountBytes + ) { + return terminateOutboundOverflow(operation); + } + outboundQueue.push( + Object.freeze({ + data: encoded.value, + byteLength: encoded.byteLength, + operation, + }), + ); + outboundQueuedBytes += encoded.byteLength; + scheduleOutboundDrain(); + return realtimeSuccess( + Object.freeze({ acceptedLocally: true as const }), + ); + } + + function scheduleOutboundDrain(): void { + if (outboundDrainScheduled || outboundDraining) return; + outboundDrainScheduled = true; + void Promise.resolve().then(() => { + outboundDrainScheduled = false; + drainOutboundQueue(); + }); + } + + function drainOutboundQueue(): void { + if (outboundDraining) return; + outboundDraining = true; + try { + while (outboundQueue.length > 0 && status === "OPEN") { + if (!socket || !effectiveLimits) return; + const queued = outboundQueue[0]; + if (!queued) return; + const bufferedAmount = readSocketBufferedAmount(socket); + if (bufferedAmount === null) { + terminate( + failure( + "PROVIDER_UNAVAILABLE", + queued.operation, + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + return; + } + if ( + bufferedAmount + queued.byteLength > + effectiveLimits.maxBufferedAmountBytes + ) { + terminateOutboundOverflow(queued.operation); + return; + } + outboundQueue.shift(); + outboundQueuedBytes -= queued.byteLength; + try { + socket.send(queued.data); + } catch { + terminate( + failure( + "PROVIDER_UNAVAILABLE", + queued.operation, + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + return; + } + } + } finally { + outboundDraining = false; + } + } + + function terminateOutboundOverflow( + operation: RealtimeOperation, + ): Extract, { ok: false }> { + const result = connectionFailure( + "QUEUE_OVERFLOW", + operation, + false, + "NONE", + ); + terminate(result.error, "OVERLOADED", { + subscriptionId: null, + reason: "QUEUE_OVERFLOW", + }); + return result; + } + + function terminate( + terminalFailure: RealtimeFailure, + category: WebSocketCloseCategory, + recoveryRequest?: WebSocketRecoveryRequest, + closeNative = true, + committedRecovery: Extract< + RealtimeTransportEventOutcome, + { kind: "RECOVERY_COMMITTED" } + > | null = null, + ): void { + if (status === "CLOSED") return; + status = "CLOSING"; + if (recoveryRequest) requestRecovery(recoveryRequest); + connectDeadlineController.abort(); + generation.abort(); + externalAbortCleanup?.(); + externalAbortCleanup = null; + queue.length = 0; + queuedBytes = 0; + outboundQueue.length = 0; + outboundQueuedBytes = 0; + outboundDrainScheduled = false; + for (const subscription of subscriptions.values()) { + subscription.unsubscribeController?.abort(); + subscription.unsubscribeController = null; + } + subscriptions.clear(); + outstandingHeartbeat = null; + retiredHeartbeat = null; + detachSocket(); + if ( + closeNative && + socket && + (socket.readyState === NATIVE_CONNECTING || + socket.readyState === NATIVE_OPEN || + socket.readyState === NATIVE_CLOSING) + ) { + try { + socket.close( + category === "NORMAL" ? 1_000 : PRIVATE_CLOSE_CODE, + ); + } catch { + // Teardown remains idempotent even when the native provider throws. + } + } + status = "CLOSED"; + settleOpen(Object.freeze({ ok: false, error: terminalFailure })); + const settleTerminal = settleClosed; + settleClosed = null; + settleTerminal?.( + Object.freeze({ + category, + error: terminalFailure, + recovery: committedRecovery, + }), + ); + if (!closeObserved) { + closeObserved = true; + observe({ + kind: "CLOSED", + failureKind: terminalFailure.kind, + closeCategory: category, + }); + } + } + + function detachSocket(): void { + if (!socket) return; + try { + socket.removeEventListener("open", openListener); + socket.removeEventListener("message", messageListener); + socket.removeEventListener("close", closeListener); + socket.removeEventListener("error", errorListener); + } catch { + // Listener cleanup is best effort after the generation is fenced. + } + } + + function startUnsubscribeDeadline( + subscriptionId: string, + subscription: SubscriptionState, + ): void { + const controller = new AbortController(); + const abort = () => controller.abort(); + generation.signal.addEventListener("abort", abort, { + once: true, + }); + if (generation.signal.aborted) abort(); + subscription.unsubscribeController = controller; + void (async () => { + try { + await dependencies.clock.sleep( + ceilings.maxApplyMs, + controller.signal, + ); + if ( + status === "OPEN" && + subscriptions.get(subscriptionId) === subscription && + subscription.state === "UNSUBSCRIBING" && + subscription.unsubscribeController === controller + ) { + terminate( + failure( + "IDLE_TIMEOUT", + "SUBSCRIBE", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + } + } catch { + if ( + !controller.signal.aborted && + status === "OPEN" && + subscriptions.get(subscriptionId) === subscription && + subscription.state === "UNSUBSCRIBING" && + subscription.unsubscribeController === controller + ) { + terminate( + failure( + "PROVIDER_UNAVAILABLE", + "SUBSCRIBE", + true, + "RECONNECT", + ), + "NETWORK_LOST", + undefined, + ); + } + } finally { + generation.signal.removeEventListener("abort", abort); + if (subscription.unsubscribeController === controller) { + subscription.unsubscribeController = null; + } + } + })(); + } + + function settleOpen( + result: RealtimeResult, + ): void { + const settle = settleConnect; + settleConnect = null; + settle?.(result); + } + + function requestRecovery(request: WebSocketRecoveryRequest): void { + try { + dependencies.onRecoveryRequired(Object.freeze({ ...request })); + } catch { + // Recovery observation cannot change connection safety. + } + observe({ kind: "RECOVERY_REQUIRED" }); + } + + function observe(observation: WebSocketConnectionObservation): void { + try { + dependencies.observe?.(Object.freeze({ ...observation })); + } catch { + // Best-effort telemetry never controls the protocol. + } + } + + return Object.freeze({ + connect, + subscribe, + unsubscribe, + waitClosed, + close, + inspect, + }); +} + +function countLiveSubscriptions( + subscriptions: ReadonlyMap, +): number { + let count = 0; + for (const subscription of subscriptions.values()) { + if (subscription.state !== "UNSUBSCRIBING") count += 1; + } + return count; +} + +function readClockNow(clock: ClockPort): number | null { + try { + const value = clock.now(); + return Number.isFinite(value) && value >= 0 ? value : null; + } catch { + return null; + } +} + +function readSocketBufferedAmount( + socket: WebSocketFacade, +): number | null { + try { + const value = socket.bufferedAmount; + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } catch { + return null; + } +} + +function closeCategoryForInboundFailure( + error: RealtimeFailure, +): WebSocketCloseCategory { + switch (error.kind) { + case "ABORTED": + case "CLOSED": + return "NORMAL"; + case "AUTH_REQUIRED": + return "AUTH_REQUIRED"; + case "FORBIDDEN": + return "FORBIDDEN"; + case "PROTOCOL_MISMATCH": + case "MALFORMED_EVENT": + case "MAPPING_CONTRACT_VIOLATION": + case "EVENT_CONFLICT": + return "PROTOCOL_MISMATCH"; + case "CURSOR_EXPIRED": + case "SEQUENCE_GAP": + case "QUEUE_OVERFLOW": + case "EVENT_TOO_LARGE": + case "APPLY_FAILED": + case "SCOPE_FENCED": + case "SCOPE_PROTOCOL_VIOLATION": + return "CURSOR_RESET"; + default: + return "NETWORK_LOST"; + } +} + +function recoveryRequestForInboundFailure( + kind: RealtimeFailureKind, + subscriptionId: string, +): WebSocketRecoveryRequest | undefined { + switch (kind) { + case "CURSOR_EXPIRED": + return { subscriptionId, reason: "CURSOR_EXPIRED" }; + case "SEQUENCE_GAP": + return { subscriptionId, reason: "SEQUENCE_GAP" }; + case "SCOPE_FENCED": + case "SCOPE_PROTOCOL_VIOLATION": + return { subscriptionId, reason: "SCOPE_CHANGED" }; + case "QUEUE_OVERFLOW": + return { subscriptionId, reason: "QUEUE_OVERFLOW" }; + case "APPLY_FAILED": + return { subscriptionId, reason: "APPLY_FAILED" }; + case "MALFORMED_EVENT": + case "MAPPING_CONTRACT_VIOLATION": + case "EVENT_CONFLICT": + case "PROTOCOL_MISMATCH": + return { subscriptionId, reason: "PROTOCOL_MISMATCH" }; + default: + return undefined; + } +} + +function fixedSameOriginWebSocketEndpoint( + originInput: string, + endpointPath: string, +): string | null { + if ( + typeof originInput !== "string" || + !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/u.test(endpointPath) + ) { + return null; + } + try { + const origin = new URL(originInput); + if ( + origin.protocol !== "https:" || + origin.username !== "" || + origin.password !== "" || + origin.search !== "" || + origin.hash !== "" || + origin.origin !== originInput.replace(/\/$/u, "") || + endpointPath.includes("//") + ) { + return null; + } + const endpoint = new URL(endpointPath, origin); + if (endpoint.origin !== origin.origin) return null; + endpoint.protocol = "wss:"; + return endpoint.toString(); + } catch { + return null; + } +} + +function isUndefined(value: unknown): value is undefined { + return value === undefined; +} + +function resolveCeilings( + reductions: Partial | undefined, +): WebSocketClientCeilings { + const resolved = { + ...WEBSOCKET_IMPLEMENTATION_CEILINGS, + ...reductions, + }; + const lowerBoundKeys = new Set([ + "minHeartbeatMs", + "minHeartbeatAckTimeoutMs", + ]); + for (const [key, hardLimit] of Object.entries( + WEBSOCKET_IMPLEMENTATION_CEILINGS, + ) as [keyof WebSocketClientCeilings, number][]) { + const value = resolved[key]; + if ( + !Number.isSafeInteger(value) || + value < 1 || + (lowerBoundKeys.has(key) + ? value < hardLimit + : value > hardLimit) + ) { + throw new TypeError("WebSocket ceiling is invalid."); + } + } + if ( + resolved.minHeartbeatMs > resolved.maxHeartbeatMs || + resolved.minHeartbeatAckTimeoutMs > + resolved.maxHeartbeatAckTimeoutMs + ) { + throw new TypeError("WebSocket heartbeat range is invalid."); + } + return Object.freeze(resolved); +} + +function minLimits( + client: WebSocketClientCeilings, + server: WebSocketAdvertisedLimits, +): WebSocketAdvertisedLimits { + return Object.freeze({ + maxFrameBytes: Math.min( + client.maxFrameBytes, + server.maxFrameBytes, + ), + maxSubscriptions: Math.min( + client.maxSubscriptions, + server.maxSubscriptions, + ), + maxInboundQueueCount: Math.min( + client.maxInboundQueueCount, + server.maxInboundQueueCount, + ), + maxInboundQueueBytes: Math.min( + client.maxInboundQueueBytes, + server.maxInboundQueueBytes, + ), + maxOutboundQueueCount: Math.min( + client.maxOutboundQueueCount, + server.maxOutboundQueueCount, + ), + maxOutboundQueueBytes: Math.min( + client.maxOutboundQueueBytes, + server.maxOutboundQueueBytes, + ), + maxBufferedAmountBytes: Math.min( + client.maxBufferedAmountBytes, + server.maxBufferedAmountBytes, + ), + maxEventsPerSecond: Math.min( + client.maxEventsPerSecond, + server.maxEventsPerSecond, + ), + }); +} + +function isSubscriptionRequest( + request: WebSocketSubscriptionRequest, +): boolean { + if ( + !IDENTIFIER.test(request.subscriptionId) || + !IDENTIFIER.test(request.streamId) || + !OPAQUE_VALUE.test(request.scopeBinding) || + typeof request.stateBearing !== "boolean" + ) { + return false; + } + const checkpoint = request.checkpoint; + return ( + checkpoint === undefined || + (IDENTIFIER.test(checkpoint.streamEpoch) && + nextUnsignedSequence(checkpoint.lastAppliedSequence) !== null && + (checkpoint.cursor === null || + OPAQUE_VALUE.test(checkpoint.cursor))) + ); +} + +function snapshotSubscriptionRequest( + request: WebSocketSubscriptionRequest, +): WebSocketSubscriptionRequest { + return Object.freeze({ + subscriptionId: request.subscriptionId, + streamId: request.streamId, + scopeBinding: request.scopeBinding, + stateBearing: request.stateBearing, + ...(request.checkpoint + ? { + checkpoint: Object.freeze({ + ...request.checkpoint, + }), + } + : {}), + }); +} + +function eventData(event: unknown): unknown { + if (!event || typeof event !== "object") return undefined; + try { + return Reflect.get(event, "data"); + } catch { + return undefined; + } +} + +function verifyCursorRotation( + verify: + | WebSocketConnectionDependencies["verifyCursorRotation"] + | undefined, + requestedCursor: string, + acceptedCursor: string, + streamEpoch: string, + nextExpectedSequence: string, +): boolean { + try { + return ( + verify?.( + Object.freeze({ + requestedCursor, + acceptedCursor, + streamEpoch, + nextExpectedSequence, + }), + ) === true + ); + } catch { + return false; + } +} + +function eventCode(event: unknown): number { + if (!event || typeof event !== "object") return 0; + try { + const code = Reflect.get(event, "code"); + return Number.isSafeInteger(code) ? Number(code) : 0; + } catch { + return 0; + } +} + +function mapNativeCloseCategory( + code: number, +): WebSocketCloseCategory { + switch (code) { + case 1_000: + return "NORMAL"; + case 1_001: + case 1_012: + return "RESTART"; + case 1_013: + return "OVERLOADED"; + case 1_008: + case 4_403: + return "FORBIDDEN"; + case 4_001: + return "AUTH_REQUIRED"; + case 4_002: + return "CURSOR_RESET"; + default: + return "NETWORK_LOST"; + } +} + +function closeFailure( + category: WebSocketCloseCategory, +): RealtimeFailure { + switch (category) { + case "NORMAL": + return failure("CLOSED", "CLOSE", false, "NONE"); + case "RESTART": + case "NETWORK_LOST": + return failure( + "PROVIDER_UNAVAILABLE", + "RECEIVE", + true, + "RECONNECT", + ); + case "OVERLOADED": + return failure( + "PROVIDER_UNAVAILABLE", + "RECEIVE", + false, + "NONE", + ); + case "AUTH_REQUIRED": + return failure( + "AUTH_REQUIRED", + "RECEIVE", + false, + "SESSION_REVALIDATE", + ); + case "FORBIDDEN": + return failure( + "FORBIDDEN", + "RECEIVE", + false, + "NONE", + ); + case "PROTOCOL_MISMATCH": + return failure( + "PROTOCOL_MISMATCH", + "RECEIVE", + false, + "NONE", + ); + case "CURSOR_RESET": + return failure( + "CURSOR_EXPIRED", + "RECEIVE", + false, + "SNAPSHOT", + ); + } +} + +function resetFailureCode( + reason: + | "CURSOR_EXPIRED" + | "SEQUENCE_GAP" + | "SERVER_RESET" + | "SCOPE_CHANGED", +): RealtimeFailureKind { + switch (reason) { + case "CURSOR_EXPIRED": + return "CURSOR_EXPIRED"; + case "SEQUENCE_GAP": + return "SEQUENCE_GAP"; + case "SERVER_RESET": + case "SCOPE_CHANGED": + return "PROTOCOL_MISMATCH"; + } +} + +function failure( + kind: RealtimeFailureKind, + operation: RealtimeOperation, + retryable: boolean, + _recovery: "NONE" | "RECONNECT" | "SNAPSHOT" | "SESSION_REVALIDATE", +): RealtimeFailure { + return realtimeFailure(kind, operation, retryable).error; +} + +function connectionFailure( + kind: RealtimeFailureKind, + operation: RealtimeOperation, + retryable: boolean, + _recovery: "NONE" | "RECONNECT" | "SNAPSHOT" | "SESSION_REVALIDATE", +): Extract, { ok: false }> { + return realtimeFailure(kind, operation, retryable); +} diff --git a/src/adapters/realtime/websocket/websocket-protocol.ts b/src/adapters/realtime/websocket/websocket-protocol.ts new file mode 100644 index 0000000..0e53907 --- /dev/null +++ b/src/adapters/realtime/websocket/websocket-protocol.ts @@ -0,0 +1,518 @@ +import { + hasDuplicateJsonMembers, +} from "../json-member-scanner.ts"; + +export const REALTIME_WEBSOCKET_PROTOCOL = "realtime.v1" as const; + +export type WebSocketCloseCategory = + | "NORMAL" + | "RESTART" + | "OVERLOADED" + | "AUTH_REQUIRED" + | "FORBIDDEN" + | "PROTOCOL_MISMATCH" + | "CURSOR_RESET" + | "NETWORK_LOST"; + +export type WebSocketResetReason = + | "CURSOR_EXPIRED" + | "SEQUENCE_GAP" + | "SERVER_RESET" + | "SCOPE_CHANGED"; + +export type WebSocketAdvertisedLimits = Readonly<{ + maxFrameBytes: number; + maxSubscriptions: number; + maxInboundQueueCount: number; + maxInboundQueueBytes: number; + maxOutboundQueueCount: number; + maxOutboundQueueBytes: number; + maxBufferedAmountBytes: number; + maxEventsPerSecond: number; +}>; + +export type WebSocketWelcomeFrame = Readonly<{ + type: "WELCOME"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + connectionId: string; + heartbeatMs: number; + heartbeatAckTimeoutMs: number; + limits: WebSocketAdvertisedLimits; +}>; + +export type WebSocketSubscribedFrame = Readonly<{ + type: "SUBSCRIBED"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + subscriptionId: string; + streamEpoch: string; + acceptedCursor: string | null; + nextExpectedSequence: string; +}>; + +export type WebSocketUnsubscribedFrame = Readonly<{ + type: "UNSUBSCRIBED"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + subscriptionId: string; +}>; + +export type WebSocketEventFrame = Readonly<{ + type: "EVENT"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + subscriptionId: string; + envelope: Readonly>; +}>; + +export type WebSocketResetRequiredFrame = Readonly<{ + type: "RESET_REQUIRED"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + subscriptionId: string; + reason: WebSocketResetReason; +}>; + +export type WebSocketHeartbeatAckFrame = Readonly<{ + type: "HEARTBEAT_ACK"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + nonce: string; +}>; + +export type WebSocketServerCloseFrame = Readonly<{ + type: "CLOSE"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + category: WebSocketCloseCategory; +}>; + +export type WebSocketServerFrame = + | WebSocketWelcomeFrame + | WebSocketSubscribedFrame + | WebSocketUnsubscribedFrame + | WebSocketEventFrame + | WebSocketResetRequiredFrame + | WebSocketHeartbeatAckFrame + | WebSocketServerCloseFrame; + +export type WebSocketSubscribeFrame = Readonly<{ + type: "SUBSCRIBE"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + subscriptionId: string; + streamId: string; + cursor: string | null; + scopeBinding: string; +}>; + +export type WebSocketUnsubscribeFrame = Readonly<{ + type: "UNSUBSCRIBE"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + subscriptionId: string; +}>; + +export type WebSocketHeartbeatFrame = Readonly<{ + type: "HEARTBEAT"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + nonce: string; +}>; + +export type WebSocketClientCloseFrame = Readonly<{ + type: "CLOSE"; + protocol: typeof REALTIME_WEBSOCKET_PROTOCOL; + category: WebSocketCloseCategory; +}>; + +export type WebSocketClientFrame = + | WebSocketSubscribeFrame + | WebSocketUnsubscribeFrame + | WebSocketHeartbeatFrame + | WebSocketClientCloseFrame; + +export type WebSocketProtocolFailure = Readonly<{ + code: + | "BINARY_FRAME" + | "FRAME_TOO_LARGE" + | "MALFORMED_FRAME" + | "PROTOCOL_MISMATCH" + | "UNKNOWN_FRAME"; +}>; + +export type WebSocketProtocolResult = + | Readonly<{ ok: true; value: Value; byteLength: number }> + | Readonly<{ ok: false; error: WebSocketProtocolFailure }>; + +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const OPAQUE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/u; +const UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]{0,19})$/u; +const UINT64_MAX = 18_446_744_073_709_551_615n; +const MAX_FRAME_STRUCTURE_DEPTH = 32; +const MAX_FRAME_STRUCTURE_NODES = 4_096; +const CLOSE_CATEGORIES: readonly WebSocketCloseCategory[] = [ + "NORMAL", + "RESTART", + "OVERLOADED", + "AUTH_REQUIRED", + "FORBIDDEN", + "PROTOCOL_MISMATCH", + "CURSOR_RESET", + "NETWORK_LOST", +]; +const RESET_REASONS: readonly WebSocketResetReason[] = [ + "CURSOR_EXPIRED", + "SEQUENCE_GAP", + "SERVER_RESET", + "SCOPE_CHANGED", +]; +const LIMIT_KEYS = [ + "maxBufferedAmountBytes", + "maxEventsPerSecond", + "maxFrameBytes", + "maxInboundQueueBytes", + "maxInboundQueueCount", + "maxOutboundQueueBytes", + "maxOutboundQueueCount", + "maxSubscriptions", +] as const; + +const SERVER_KEYS = Object.freeze({ + WELCOME: [ + "connectionId", + "heartbeatAckTimeoutMs", + "heartbeatMs", + "limits", + "protocol", + "type", + ], + SUBSCRIBED: [ + "acceptedCursor", + "nextExpectedSequence", + "protocol", + "streamEpoch", + "subscriptionId", + "type", + ], + UNSUBSCRIBED: ["protocol", "subscriptionId", "type"], + EVENT: ["envelope", "protocol", "subscriptionId", "type"], + RESET_REQUIRED: [ + "protocol", + "reason", + "subscriptionId", + "type", + ], + HEARTBEAT_ACK: ["nonce", "protocol", "type"], + CLOSE: ["category", "protocol", "type"], +} satisfies Record); + +const CLIENT_KEYS = Object.freeze({ + SUBSCRIBE: [ + "cursor", + "protocol", + "scopeBinding", + "streamId", + "subscriptionId", + "type", + ], + UNSUBSCRIBE: ["protocol", "subscriptionId", "type"], + HEARTBEAT: ["nonce", "protocol", "type"], + CLOSE: ["category", "protocol", "type"], +} satisfies Record); + +export function decodeWebSocketServerFrame( + input: unknown, + maxFrameBytes: number, +): WebSocketProtocolResult { + if (typeof input !== "string") { + return protocolFailure("BINARY_FRAME"); + } + if (!isPositiveInteger(maxFrameBytes)) { + return protocolFailure("FRAME_TOO_LARGE"); + } + const byteLength = utf8ByteLength(input); + if (byteLength > maxFrameBytes) { + return protocolFailure("FRAME_TOO_LARGE"); + } + if ( + hasDuplicateJsonMembers(input, { + maxDepth: MAX_FRAME_STRUCTURE_DEPTH, + maxMembers: MAX_FRAME_STRUCTURE_NODES, + }) + ) { + return protocolFailure("MALFORMED_FRAME"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return protocolFailure("MALFORMED_FRAME"); + } + if (!isRecord(parsed) || typeof parsed.type !== "string") { + return protocolFailure("MALFORMED_FRAME"); + } + if (parsed.protocol !== REALTIME_WEBSOCKET_PROTOCOL) { + return protocolFailure("PROTOCOL_MISMATCH"); + } + + const frame = decodeKnownServerFrame(parsed); + if (!frame) { + return protocolFailure( + Object.hasOwn(SERVER_KEYS, parsed.type) + ? "MALFORMED_FRAME" + : "UNKNOWN_FRAME", + ); + } + try { + if (!freezeBoundedJsonTree(frame)) { + return protocolFailure("MALFORMED_FRAME"); + } + return Object.freeze({ + ok: true, + value: frame, + byteLength, + }); + } catch { + return protocolFailure("MALFORMED_FRAME"); + } +} + +export function encodeWebSocketClientFrame( + frame: WebSocketClientFrame, + maxFrameBytes: number, +): WebSocketProtocolResult { + if ( + !isPositiveInteger(maxFrameBytes) || + !isRecord(frame) || + frame.protocol !== REALTIME_WEBSOCKET_PROTOCOL || + typeof frame.type !== "string" + ) { + return protocolFailure("MALFORMED_FRAME"); + } + const keys = CLIENT_KEYS[frame.type as keyof typeof CLIENT_KEYS]; + if (!keys || !hasExactKeys(frame, keys) || !isValidClientFrame(frame)) { + return protocolFailure( + keys ? "MALFORMED_FRAME" : "UNKNOWN_FRAME", + ); + } + let value: string; + try { + value = JSON.stringify(frame); + } catch { + return protocolFailure("MALFORMED_FRAME"); + } + const byteLength = utf8ByteLength(value); + if (byteLength > maxFrameBytes) { + return protocolFailure("FRAME_TOO_LARGE"); + } + return Object.freeze({ ok: true, value, byteLength }); +} + +export function nextUnsignedSequence( + sequence: string, +): string | null { + if (!isUnsignedSequence(sequence)) return null; + const value = BigInt(sequence); + return value === UINT64_MAX ? null : String(value + 1n); +} + +function decodeKnownServerFrame( + frame: Record, +): WebSocketServerFrame | null { + switch (frame.type) { + case "WELCOME": + if ( + !hasExactKeys(frame, SERVER_KEYS.WELCOME) || + !isIdentifier(frame.connectionId) || + !isPositiveInteger(frame.heartbeatMs) || + !isPositiveInteger(frame.heartbeatAckTimeoutMs) || + !isAdvertisedLimits(frame.limits) + ) { + return null; + } + return frame as WebSocketWelcomeFrame; + case "SUBSCRIBED": + if ( + !hasExactKeys(frame, SERVER_KEYS.SUBSCRIBED) || + !isIdentifier(frame.subscriptionId) || + !isIdentifier(frame.streamEpoch) || + !isOptionalOpaque(frame.acceptedCursor) || + !isUnsignedSequence(frame.nextExpectedSequence) + ) { + return null; + } + return frame as WebSocketSubscribedFrame; + case "UNSUBSCRIBED": + if ( + !hasExactKeys(frame, SERVER_KEYS.UNSUBSCRIBED) || + !isIdentifier(frame.subscriptionId) + ) { + return null; + } + return frame as WebSocketUnsubscribedFrame; + case "EVENT": + if ( + !hasExactKeys(frame, SERVER_KEYS.EVENT) || + !isIdentifier(frame.subscriptionId) || + !isRecord(frame.envelope) + ) { + return null; + } + return frame as WebSocketEventFrame; + case "RESET_REQUIRED": + if ( + !hasExactKeys(frame, SERVER_KEYS.RESET_REQUIRED) || + !isIdentifier(frame.subscriptionId) || + !RESET_REASONS.includes(frame.reason as WebSocketResetReason) + ) { + return null; + } + return frame as WebSocketResetRequiredFrame; + case "HEARTBEAT_ACK": + if ( + !hasExactKeys(frame, SERVER_KEYS.HEARTBEAT_ACK) || + !isIdentifier(frame.nonce) + ) { + return null; + } + return frame as WebSocketHeartbeatAckFrame; + case "CLOSE": + if ( + !hasExactKeys(frame, SERVER_KEYS.CLOSE) || + !CLOSE_CATEGORIES.includes( + frame.category as WebSocketCloseCategory, + ) + ) { + return null; + } + return frame as WebSocketServerCloseFrame; + default: + return null; + } +} + +function isValidClientFrame( + frame: Record, +): boolean { + switch (frame.type) { + case "SUBSCRIBE": + return ( + isIdentifier(frame.subscriptionId) && + isIdentifier(frame.streamId) && + isOptionalOpaque(frame.cursor) && + isOpaque(frame.scopeBinding) + ); + case "UNSUBSCRIBE": + return isIdentifier(frame.subscriptionId); + case "HEARTBEAT": + return isIdentifier(frame.nonce); + case "CLOSE": + return CLOSE_CATEGORIES.includes( + frame.category as WebSocketCloseCategory, + ); + default: + return false; + } +} + +function isAdvertisedLimits( + input: unknown, +): input is WebSocketAdvertisedLimits { + if (!isRecord(input) || !hasExactKeys(input, LIMIT_KEYS)) { + return false; + } + return LIMIT_KEYS.every((key) => isPositiveInteger(input[key])); +} + +function isRecord( + input: unknown, +): input is Record { + return ( + typeof input === "object" && + input !== null && + !Array.isArray(input) && + Object.getPrototypeOf(input) === Object.prototype + ); +} + +function hasExactKeys( + input: Record, + expected: readonly string[], +): boolean { + const keys = Object.keys(input).sort(); + return ( + keys.length === expected.length && + keys.every((key, index) => key === expected[index]) + ); +} + +function isIdentifier(input: unknown): input is string { + return typeof input === "string" && IDENTIFIER.test(input); +} + +function isOpaque(input: unknown): input is string { + return typeof input === "string" && OPAQUE_VALUE.test(input); +} + +function isOptionalOpaque(input: unknown): input is string | null { + return input === null || isOpaque(input); +} + +function isPositiveInteger(input: unknown): input is number { + return Number.isSafeInteger(input) && Number(input) > 0; +} + +function isUnsignedSequence(input: unknown): input is string { + if (typeof input !== "string" || !UNSIGNED_DECIMAL.test(input)) { + return false; + } + try { + return BigInt(input) <= UINT64_MAX; + } catch { + return false; + } +} + +function utf8ByteLength(input: string): number { + return new TextEncoder().encode(input).byteLength; +} + +function protocolFailure( + code: WebSocketProtocolFailure["code"], +): WebSocketProtocolResult { + return Object.freeze({ + ok: false, + error: Object.freeze({ code }), + }); +} + +function freezeBoundedJsonTree(root: object): boolean { + const pending: Array< + Readonly<{ + value: object; + depth: number; + freeze: boolean; + }> + > = [{ value: root, depth: 0, freeze: false }]; + let discoveredNodes = 1; + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) return false; + if (current.freeze) { + Object.freeze(current.value); + continue; + } + if (current.depth > MAX_FRAME_STRUCTURE_DEPTH) { + return false; + } + pending.push({ ...current, freeze: true }); + for (const child of Object.values(current.value)) { + if (child !== null && typeof child === "object") { + discoveredNodes += 1; + if (discoveredNodes > MAX_FRAME_STRUCTURE_NODES) { + return false; + } + pending.push({ + value: child, + depth: current.depth + 1, + freeze: false, + }); + } + } + } + return true; +} diff --git a/src/adapters/service-worker/service-worker-entry.ts b/src/adapters/service-worker/service-worker-entry.ts new file mode 100644 index 0000000..2db48b8 --- /dev/null +++ b/src/adapters/service-worker/service-worker-entry.ts @@ -0,0 +1,170 @@ +/// +import { OFFLINE_SYNC_TAG } from "../../contracts/offline-command.ts"; +import type { + ServiceWorkerHandlerId, + ServiceWorkerProtocolIdentity, + StaticAssetManifestV1, +} from "../../contracts/service-worker.ts"; +import { + createServiceWorkerRuntime, + type WorkerScopeLike, +} from "./service-worker-lifecycle.ts"; +import { parseServiceWorkerMessage } from "./service-worker-protocol.ts"; + +/** + * §17.1. The one physical worker entry for this scope. + * + * PWA lifecycle, verified static asset fetch, Web Push and the optional sync + * wake-up are all handler factories inside this single entry. A second + * registration for any of them is prohibited. + * + * This module is compiled only by `vite.service-worker.config.ts` when the + * static selection is `ACTIVE`; it is never part of the page bundle. + */ + +declare const self: ServiceWorkerGlobalScope; + +// Build-time virtual modules (§18.3). They resolve through the Service Worker +// Vite config only, so the page bundle can never import a worker asset list. +declare const __CA_SERVICE_WORKER_BUILD_INFO__: ServiceWorkerProtocolIdentity; +declare const __CA_SERVICE_WORKER_ASSETS__: StaticAssetManifestV1 | null; +declare const __CA_SERVICE_WORKER_HANDLERS__: readonly ServiceWorkerHandlerId[]; +declare const __CA_RUNTIME_CONFIG_URL__: string; +declare const __CA_RELEASE_MANIFEST_URL__: string; + +const identity = __CA_SERVICE_WORKER_BUILD_INFO__; +const handlers = __CA_SERVICE_WORKER_HANDLERS__; + +const scope: WorkerScopeLike = { + caches: { + open: (name) => caches.open(name), + keys: () => caches.keys(), + delete: (name) => caches.delete(name), + match: (request) => caches.match(request), + }, + clients: { + matchAll: (options) => + self.clients.matchAll( + options as { type?: "window"; includeUncontrolled?: boolean }, + ) as Promise< + readonly { + id: string; + url: string; + postMessage(m: unknown): void; + }[] + >, + }, + registrationScope: self.registration.scope, + skipWaiting: () => self.skipWaiting(), + fetcher: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init), + async digest(bytes) { + const buffer = await crypto.subtle.digest( + "SHA-256", + bytes.slice().buffer as ArrayBuffer, + ); + let hex = ""; + for (const byte of new Uint8Array(buffer)) { + hex += byte.toString(16).padStart(2, "0"); + } + return `sha256:${hex}`; + }, +}; + +const runtime = createServiceWorkerRuntime(scope, { + identity, + handlers, + manifest: __CA_SERVICE_WORKER_ASSETS__, + runtimeConfigUrl: __CA_RUNTIME_CONFIG_URL__, + releaseManifestUrl: __CA_RELEASE_MANIFEST_URL__, +}); + +self.addEventListener("install", (event) => { + // §17.10. Install never calls skipWaiting(); activation is a page handshake. + event.waitUntil(runtime.onInstall()); +}); + +self.addEventListener("activate", (event) => { + // §17.12. No clients.claim() in the baseline. + event.waitUntil(runtime.onActivate()); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + event.respondWith( + runtime + .onFetch({ + method: request.method, + url: request.url, + mode: request.mode, + }) + .then((cached) => cached ?? fetch(request)), + ); +}); + +self.addEventListener("message", (event) => { + const parsed = parseServiceWorkerMessage(event.data); + if (!parsed.ok) return; + if (parsed.message.kind === "ACTIVATE_REQUEST") { + event.waitUntil(runtime.onActivateRequest(event.data)); + return; + } + if ( + parsed.message.kind === "CLIENT_DRAINED" || + parsed.message.kind === "ACTIVATE_REJECTED" + ) { + const source = event.source; + if (source && "id" in source && typeof source.id === "string") { + runtime.onClientMessage(event.data, source.id); + } + return; + } + if (parsed.message.kind === "CACHE_RESET_REQUEST") { + const source = event.source; + if ( + source && + "id" in source && + typeof source.id === "string" && + "postMessage" in source && + typeof source.postMessage === "function" + ) { + event.waitUntil(runtime.onCacheResetRequest(event.data, source)); + } + } +}); + +// §17.1 WEB_PUSH composition point. +// +// The Web Push runtime is a handler factory inside this one entry, never a +// second registration. It is not wired here because the template cannot supply +// the two product-owned inputs it needs: a PushAssociationFenceStore over the +// product push control repository, and a WebPushNotificationRegistry of exact +// notification types with the same-origin routes their clicks may open +// (§21.11). Selecting WEB_PUSH means adding, inside a +// `handlers.includes("WEB_PUSH")` guard: import +// createWebPushServiceWorkerRuntime and createServiceWorkerScopeHost from the +// sibling web-push adapter, then call the runtime with the scope host built +// from `self` plus the product fence store and notification registry. +// +// Keeping the import out of the baseline entry is also what lets the realtime +// and Web Push runtime be removed as a pure file deletion (§24.12). + +if (handlers.includes("OFFLINE_SYNC_WAKEUP")) { + // §19.18. Wake-up only: the handler records that a sync fired and notifies + // controlled clients. It never sends an authenticated command (§19.20). + self.addEventListener("sync", (rawEvent: Event) => { + const event = rawEvent as ExtendableEvent & { tag?: string }; + if (event.tag !== OFFLINE_SYNC_TAG) return; + event.waitUntil( + self.clients.matchAll({ type: "window" }).then((clients) => { + for (const client of clients) { + client.postMessage({ + protocolVersion: 1, + kind: "SYNC_WAKE_OBSERVED", + messageId: crypto.randomUUID(), + sourceBuildId: identity.buildId, + }); + } + }), + ); + }); +} diff --git a/src/adapters/service-worker/service-worker-lifecycle.ts b/src/adapters/service-worker/service-worker-lifecycle.ts new file mode 100644 index 0000000..e93239c --- /dev/null +++ b/src/adapters/service-worker/service-worker-lifecycle.ts @@ -0,0 +1,442 @@ +import { + SERVICE_WORKER_BOUNDS, + isOwnedStaticCacheName, + staticCacheName, + type ServiceWorkerHandlerId, + type ServiceWorkerProtocolIdentity, + type StaticAssetManifestV1, +} from "../../contracts/service-worker.ts"; +import { + createServiceWorkerMessage, + parseServiceWorkerMessage, +} from "./service-worker-protocol.ts"; +import { + classifyFetch, + installStaticAssets, + selectCachesToDelete, +} from "./service-worker-static-assets.ts"; + +/** + * §17.9–§17.15. Worker-side lifecycle, expressed against structural types so it + * can be unit-tested outside a real Service Worker global and compiled under + * `tsconfig.service-worker.json` without pulling in DOM globals. + */ + +export type WorkerClientLike = Readonly<{ + id: string; + url: string; + postMessage(message: unknown): void; +}>; + +export type WorkerScopeLike = Readonly<{ + caches: Readonly<{ + open(cacheName: string): Promise; + keys(): Promise; + delete(cacheName: string): Promise; + match(request: string): Promise; + }>; + clients: Readonly<{ + matchAll( + options?: Readonly<{ + type?: "window"; + includeUncontrolled?: boolean; + }>, + ): Promise< + readonly WorkerClientLike[] + >; + }>; + registrationScope: string; + skipWaiting(): Promise; + fetcher: typeof fetch; + digest(bytes: Uint8Array): Promise; +}>; + +export type WorkerRuntimeConfig = Readonly<{ + identity: ServiceWorkerProtocolIdentity; + handlers: readonly ServiceWorkerHandlerId[]; + manifest: StaticAssetManifestV1 | null; + runtimeConfigUrl: string; + releaseManifestUrl: string; +}>; + +const ACTIVATION_MARKER_URL = + "https://clean-architecture.invalid/__service-worker-activation-v1__"; +const ACTIVATION_MARKER_MAX_BYTES = 256; + +type ActivationMarker = Readonly<{ + cacheName: string; + activationSequence: number; +}>; + +export function createServiceWorkerRuntime( + scope: WorkerScopeLike, + config: WorkerRuntimeConfig, +) { + const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS"); + const manifestUrls = new Set( + staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [], + ); + const consumedNonces = new Set(); + type PendingActivation = Readonly<{ + requesterBuildId: string; + expectedClientIds: ReadonlySet; + acknowledgedClientIds: Set; + resolve(drained: boolean): void; + timer: ReturnType; + }>; + const pendingActivations = new Map(); + + /** + * §17.9. Without the static asset handler the install step opens zero caches; + * it only registers lifecycle, push and sync handlers. + */ + async function onInstall(): Promise { + if (!staticEnabled || !config.manifest) return; + const outcome = await installStaticAssets(config.manifest, { + caches: scope.caches, + fetcher: scope.fetcher, + digest: scope.digest, + }); + if (outcome.kind === "REJECTED") { + throw new Error(`STATIC_INSTALL_REJECTED:${outcome.code}`); + } + } + + /** + * §17.15. Delete only owned caches outside the current and one previous + * revision. `clients.claim()` is never called (§17.12). + */ + async function onActivate(): Promise { + if (!staticEnabled || !config.manifest) return 0; + const current = staticCacheName(config.manifest.setDigest); + const names = await scope.caches.keys(); + const owned = names.filter(isOwnedStaticCacheName); + const currentCache = await scope.caches.open(current); + const oldCaches = owned.filter((name) => name !== current); + const markers: ActivationMarker[] = []; + for (const name of oldCaches) { + const marker = await readActivationMarker(await scope.caches.open(name), name); + if (marker) markers.push(marker); + } + const currentMarker = await readActivationMarker(currentCache, current); + const highestOld = markers.reduce( + (highest, marker) => + !highest || marker.activationSequence >= highest.activationSequence + ? marker + : highest, + null, + ); + const previous = highestOld?.cacheName ?? oldCaches.at(-1) ?? null; + if ( + !currentMarker || + currentMarker.activationSequence < (highestOld?.activationSequence ?? 0) + ) { + const nextSequence = (highestOld?.activationSequence ?? 0) + 1; + if (!Number.isSafeInteger(nextSequence)) { + throw new Error("SERVICE_WORKER_ACTIVATION_SEQUENCE_EXHAUSTED"); + } + await currentCache.put( + ACTIVATION_MARKER_URL, + new Response( + JSON.stringify({ + schemaVersion: 1, + cacheName: current, + activationSequence: nextSequence, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + } + const stale = selectCachesToDelete(names, current, previous); + let deleted = 0; + for (const name of stale) { + if (await scope.caches.delete(name)) deleted += 1; + } + void SERVICE_WORKER_BOUNDS.retainedPreviousCaches; + return deleted; + } + + /** + * §18.5–§18.7. A verified cache hit is returned; anything else goes to the + * network and is never written back into the active cache at runtime. + */ + async function onFetch( + request: Readonly<{ method: string; url: string; mode?: string }>, + ): Promise { + const classification = classifyFetch({ + method: request.method, + requestUrl: request.url, + isNavigation: request.mode === "navigate", + runtimeConfigUrl: config.runtimeConfigUrl, + releaseManifestUrl: config.releaseManifestUrl, + manifestUrls, + }); + if (classification !== "VERIFIED_CACHE_FIRST") return null; + + const cached = await scope.caches.match(request.url); + if (!cached) return null; + if (cached.status !== 200 || cached.type === "opaque") { + // §18.6. An invalid hit is deleted and treated as a release mismatch. + const current = config.manifest + ? staticCacheName(config.manifest.setDigest) + : null; + if (current) { + const cache = await scope.caches.open(current); + await cache.delete(request.url).catch(() => false); + } + return null; + } + return cached; + } + + /** + * §17.11. The waiting worker validates the request, drains every controlled + * client, and only then calls `skipWaiting()`. + */ + async function onActivateRequest( + data: unknown, + ): Promise<"ACCEPTED" | "REJECTED" | "IGNORED"> { + const parsed = parseServiceWorkerMessage(data); + if (!parsed.ok || parsed.message.kind !== "ACTIVATE_REQUEST") return "IGNORED"; + const nonce = parsed.message.nonce; + if (!nonce || consumedNonces.has(nonce)) return "REJECTED"; + if ( + parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== config.identity.buildId + ) { + return "REJECTED"; + } + consumedNonces.add(nonce); + if (consumedNonces.size > 64) { + const oldest = consumedNonces.values().next().value; + if (oldest !== undefined) consumedNonces.delete(oldest); + } + + const candidates = await scope.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + const clients = candidates.filter((client) => + isClientWithinRegistrationScope(client.url, scope.registrationScope), + ); + const drained = await drainClients( + clients, + nonce, + parsed.message.sourceBuildId, + ); + if (!drained) { + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATE_REJECTED", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + } + return "REJECTED"; + } + + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATE_ACCEPTED", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + } + await scope.skipWaiting(); + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATED_RELOAD_REQUIRED", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + } + return "ACCEPTED"; + } + + async function drainClients( + clients: readonly WorkerClientLike[], + nonce: string, + requesterBuildId: string, + ): Promise { + if (clients.length === 0) return false; + const drained = new Promise((resolve) => { + const timer = setTimeout(() => { + pendingActivations.delete(nonce); + resolve(false); + }, SERVICE_WORKER_BOUNDS.clientDrainMs); + pendingActivations.set( + nonce, + Object.freeze({ + requesterBuildId, + expectedClientIds: new Set(clients.map((client) => client.id)), + acknowledgedClientIds: new Set(), + resolve, + timer, + }), + ); + }); + for (const client of clients) { + client.postMessage( + createServiceWorkerMessage({ + kind: "CLIENT_DRAIN_REQUEST", + sourceBuildId: config.identity.buildId, + targetBuildId: requesterBuildId, + nonce, + }), + ); + } + return drained; + } + + function onClientMessage(data: unknown, sourceClientId: string): void { + const parsed = parseServiceWorkerMessage(data); + if (!parsed.ok || !parsed.message.nonce) return; + if ( + parsed.message.targetBuildId !== config.identity.buildId || + (parsed.message.kind !== "CLIENT_DRAINED" && + parsed.message.kind !== "ACTIVATE_REJECTED") + ) { + return; + } + const pending = pendingActivations.get(parsed.message.nonce); + if ( + !pending || + parsed.message.sourceBuildId !== pending.requesterBuildId || + !pending.expectedClientIds.has(sourceClientId) + ) { + return; + } + if (parsed.message.kind === "ACTIVATE_REJECTED") { + settlePendingActivation(parsed.message.nonce, pending, false); + return; + } + pending.acknowledgedClientIds.add(sourceClientId); + if ( + pending.acknowledgedClientIds.size === pending.expectedClientIds.size + ) { + settlePendingActivation(parsed.message.nonce, pending, true); + } + } + + async function onCacheResetRequest( + data: unknown, + source: WorkerClientLike, + ): Promise { + const parsed = parseServiceWorkerMessage(data); + if ( + !parsed.ok || + parsed.message.kind !== "CACHE_RESET_REQUEST" || + !parsed.message.nonce || + (parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== config.identity.buildId) + ) { + return; + } + let cachesDeleted = 0; + const names = await scope.caches.keys(); + for (const name of names) { + if (!name.startsWith("ca-static-v1-")) continue; + try { + if (await scope.caches.delete(name)) cachesDeleted += 1; + } catch { + return; + } + } + source.postMessage( + createServiceWorkerMessage({ + kind: "CACHE_RESET_RESULT", + sourceBuildId: config.identity.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce: parsed.message.nonce, + cachesDeleted, + }), + ); + } + + return Object.freeze({ + onInstall, + onActivate, + onFetch, + onActivateRequest, + onClientMessage, + onCacheResetRequest, + manifestUrls: manifestUrls as ReadonlySet, + }); + + function settlePendingActivation( + nonce: string, + pending: PendingActivation, + drained: boolean, + ): void { + clearTimeout(pending.timer); + pendingActivations.delete(nonce); + pending.resolve(drained); + } +} + +function isClientWithinRegistrationScope( + clientUrl: string, + registrationScope: string, +): boolean { + try { + const client = new URL(clientUrl); + const scope = new URL(registrationScope); + return client.origin === scope.origin && client.href.startsWith(scope.href); + } catch { + return false; + } +} + +async function readActivationMarker( + cache: Cache, + expectedCacheName: string, +): Promise { + try { + const response = await cache.match(ACTIVATION_MARKER_URL); + if (!response || response.status !== 200) return null; + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + (!/^\d+$/u.test(declaredLength) || + Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES) + ) { + return null; + } + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) { + return null; + } + const value: unknown = JSON.parse(text); + if ( + value === null || + typeof value !== "object" || + (value as { schemaVersion?: unknown }).schemaVersion !== 1 || + (value as { cacheName?: unknown }).cacheName !== expectedCacheName || + !Number.isSafeInteger( + (value as { activationSequence?: unknown }).activationSequence, + ) || + ((value as { activationSequence: number }).activationSequence ?? 0) < 1 + ) { + return null; + } + return Object.freeze({ + cacheName: expectedCacheName, + activationSequence: (value as { activationSequence: number }) + .activationSequence, + }); + } catch { + return null; + } +} diff --git a/src/adapters/service-worker/service-worker-page-controller.ts b/src/adapters/service-worker/service-worker-page-controller.ts new file mode 100644 index 0000000..346ce93 --- /dev/null +++ b/src/adapters/service-worker/service-worker-page-controller.ts @@ -0,0 +1,399 @@ +import { + SERVICE_WORKER_BOUNDS, + type InstalledServiceWorkerSelection, + type ServiceWorkerActivationOutcome, + type ServiceWorkerResetOutcome, + type ServiceWorkerRuntimeHost, + type ServiceWorkerStartOutcome, +} from "../../contracts/service-worker.ts"; +import { + createNonceRegistry, + createServiceWorkerMessage, + parseServiceWorkerMessage, +} from "./service-worker-protocol.ts"; +import { + expectedServiceWorkerUrls, + isOwnedRegistration, + purgeOwnedResources, + removeOwnedRegistration, +} from "./service-worker-removal.ts"; + +/** + * §17.5–§17.16. The page-side controller. + * + * Registration happens after Runtime Config, release and contract set have all + * validated and the first React effect has committed. The controller never + * calls `skipWaiting()` blindly and never calls `clients.claim()`. + */ + +export type ActivationBlocker = () => boolean; + +export type PageControllerDependencies = Readonly<{ + selection: InstalledServiceWorkerSelection | null; + /** True when static selection is ACTIVE but Runtime Config disabled it. */ + disabledCleanup: boolean; + routerBasePath: string; + origin: string; + buildId: string; + container?: ServiceWorkerContainer; + caches?: CacheStorage; + /** §17.10. Any blocker returning true rejects automatic activation. */ + blockers?: readonly ActivationBlocker[]; + now?: () => number; + observe?: (observation: Readonly<{ event: string; outcome: string }>) => void; +}>; + +export function createServiceWorkerPageController( + dependencies: PageControllerDependencies, +): ServiceWorkerRuntimeHost { + const nonces = createNonceRegistry(); + const now = dependencies.now ?? (() => Date.now()); + const urls = expectedServiceWorkerUrls( + dependencies.routerBasePath, + dependencies.origin, + ); + + let registrationPromise: Promise | null = null; + let registration: ServiceWorkerRegistration | null = null; + let messageListener: ((event: MessageEvent) => void) | null = null; + let updateTimer: ReturnType | null = null; + let stopped = false; + const pendingStops = new Set<() => void>(); + + const observe = (event: string, outcome: string) => + dependencies.observe?.({ event, outcome }); + + function isBlocked(): boolean { + for (const blocker of dependencies.blockers ?? []) { + try { + if (blocker()) return true; + } catch { + // A defective blocker is treated as blocking: never activate on doubt. + return true; + } + } + return false; + } + + async function start(): Promise { + if (stopped) return failed("STOPPED"); + const container = dependencies.container; + + // §3.6 / §17.6. Static ACTIVE plus runtime DISABLED performs exactly one + // owned-registration lookup and at most one unregister. No new register, no + // cache deletion, no message or update timer. + if (dependencies.disabledCleanup) { + if (!container) return Object.freeze({ kind: "DISABLED" as const }); + const outcome = await removeOwnedRegistration({ + container, + routerBasePath: dependencies.routerBasePath, + origin: dependencies.origin, + }); + observe("disable_cleanup", outcome.kind); + if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED"); + return Object.freeze({ kind: "DISABLED" as const }); + } + + const selection = dependencies.selection; + // §3.6 / §17.3 `null`: zero registration lookups and zero Cache Storage + // access. The controller must not even probe. + if (!selection) return Object.freeze({ kind: "DISABLED" as const }); + + if (!container) return Object.freeze({ kind: "INCOMPATIBLE" as const }); + + if (selection.mode === "REMOVE_REGISTRATION") { + const outcome = await removeOwnedRegistration({ + container, + routerBasePath: dependencies.routerBasePath, + origin: dependencies.origin, + }); + observe("remove_registration", outcome.kind); + return Object.freeze({ kind: "DISABLED" as const }); + } + if (selection.mode === "PURGE_OWNED_RESOURCES") { + const outcome = await purgeOwnedResources({ + container, + ...(dependencies.caches ? { caches: dependencies.caches } : {}), + routerBasePath: dependencies.routerBasePath, + origin: dependencies.origin, + }); + observe("purge_owned_resources", outcome.kind); + return Object.freeze({ kind: "DISABLED" as const }); + } + + // §17.5. StrictMode's repeated effect returns the same in-flight promise + // instead of issuing a second registration. + registrationPromise ??= container.register(urls.scriptHref, { + scope: urls.scopePath, + type: "module", + updateViaCache: "none", + }); + + let installedRegistration: ServiceWorkerRegistration; + try { + installedRegistration = await registrationPromise; + } catch { + registrationPromise = null; + observe("register", "FAILED"); + return failed("REGISTRATION_FAILED"); + } + if (stopped) return failed("STOPPED"); + registration = installedRegistration; + + if ( + !isOwnedRegistration({ + registration, + expectedScopeHref: urls.scopeHref, + expectedScriptHref: urls.scriptHref, + }) + ) { + observe("register", "OWNERSHIP_MISMATCH"); + return Object.freeze({ kind: "INCOMPATIBLE" as const }); + } + + attachMessageListener(container); + scheduleUpdateChecks(); + + if (registration.waiting) { + observe("register", "UPDATE_WAITING"); + return Object.freeze({ kind: "UPDATE_WAITING" as const }); + } + // §17.13. Without `clients.claim()` the first install leaves this page + // uncontrolled. That is reported, never silently reloaded. + if (registration.active && !container.controller) { + observe("register", "RELOAD_TO_ENABLE"); + return Object.freeze({ kind: "RELOAD_TO_ENABLE" as const }); + } + observe("register", "ACTIVE"); + return Object.freeze({ + kind: "ACTIVE" as const, + buildId: dependencies.buildId, + }); + } + + function attachMessageListener(container: ServiceWorkerContainer): void { + if (messageListener) return; + messageListener = (event: MessageEvent) => { + if (event.origin && event.origin !== dependencies.origin) return; + const parsed = parseServiceWorkerMessage(event.data); + if (!parsed.ok) { + observe("message", parsed.code); + return; + } + if ( + parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== dependencies.buildId + ) { + observe("message", "TARGET_BUILD_MISMATCH"); + return; + } + if (parsed.message.kind === "CLIENT_DRAIN_REQUEST") { + const nonce = parsed.message.nonce; + const source = event.source; + if (!nonce || !canPostMessage(source)) { + observe("client_drain", "MALFORMED"); + return; + } + const rejected = isBlocked(); + source.postMessage( + createServiceWorkerMessage({ + kind: rejected ? "ACTIVATE_REJECTED" : "CLIENT_DRAINED", + sourceBuildId: dependencies.buildId, + targetBuildId: parsed.message.sourceBuildId, + nonce, + }), + ); + observe("client_drain", rejected ? "BLOCKED" : "DRAINED"); + return; + } + observe("message", parsed.message.kind); + }; + container.addEventListener("message", messageListener); + } + + function scheduleUpdateChecks(): void { + // §17.14. At most one check per 6 hours, and none while the page is hidden. + if (updateTimer) return; + updateTimer = setInterval(() => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + void registration?.update().catch(() => { + // A failed update check never fails a product flow. + }); + }, SERVICE_WORKER_BOUNDS.updateCheckIntervalMs); + } + + /** + * §17.11. Activation is a handshake: every controlled client must close new + * admission and acknowledge within 30s. One missing client rejects it. + */ + async function requestActivation(): Promise { + const waiting = registration?.waiting; + if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const }); + if (isBlocked()) { + observe("activation", "BLOCKED_DIRTY_CLIENT"); + return Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const }); + } + + const nonce = nonces.issue(); + const deadline = now() + SERVICE_WORKER_BOUNDS.clientDrainMs; + const accepted = await new Promise( + (resolve) => { + const container = dependencies.container; + if (!container) { + resolve(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const })); + return; + } + let settled = false; + const finish = (outcome: ServiceWorkerActivationOutcome) => { + if (settled) return; + settled = true; + nonces.consume(nonce); + clearTimeout(timer); + container.removeEventListener("message", onMessage); + pendingStops.delete(onStop); + resolve(outcome); + }; + const onMessage = (event: MessageEvent) => { + const parsed = parseServiceWorkerMessage(event.data); + if (!parsed.ok) return; + if ( + parsed.message.targetBuildId !== undefined && + parsed.message.targetBuildId !== dependencies.buildId + ) { + return; + } + if ( + parsed.message.kind === "ACTIVATE_REJECTED" && + parsed.message.nonce === nonce + ) { + finish(Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const })); + return; + } + if ( + parsed.message.kind === "ACTIVATED_RELOAD_REQUIRED" && + parsed.message.nonce === nonce + ) { + finish( + Object.freeze({ kind: "ACTIVATED_RELOAD_REQUIRED" as const }), + ); + } + }; + const onStop = () => + finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" })); + const timer = setTimeout( + () => finish(Object.freeze({ kind: "CLIENT_DRAIN_TIMEOUT" as const })), + Math.max(0, deadline - now()), + ); + pendingStops.add(onStop); + container.addEventListener("message", onMessage); + try { + waiting.postMessage( + createServiceWorkerMessage({ + kind: "ACTIVATE_REQUEST", + sourceBuildId: dependencies.buildId, + nonce, + }), + ); + } catch { + finish(Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" })); + } + }, + ); + observe("activation", accepted.kind); + return accepted; + } + + /** §18.10. Static caches only; the registration itself is left in place. */ + async function resetOwnedCaches(): Promise { + const container = dependencies.container; + if (!container?.controller) { + return Object.freeze({ kind: "NOT_CONTROLLED" as const }); + } + const nonce = nonces.issue(); + return new Promise((resolve) => { + let settled = false; + const finish = (outcome: ServiceWorkerResetOutcome) => { + if (settled) return; + settled = true; + nonces.consume(nonce); + clearTimeout(timer); + container.removeEventListener("message", onMessage); + pendingStops.delete(onStop); + resolve(outcome); + }; + const onMessage = (event: MessageEvent) => { + if (event.origin && event.origin !== dependencies.origin) return; + const parsed = parseServiceWorkerMessage(event.data); + if ( + !parsed.ok || + parsed.message.kind !== "CACHE_RESET_RESULT" || + parsed.message.targetBuildId !== dependencies.buildId || + parsed.message.nonce !== nonce + ) { + return; + } + finish( + Object.freeze({ + kind: "RESET" as const, + cachesDeleted: parsed.message.cachesDeleted ?? 0, + }), + ); + }; + const onStop = () => + finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" })); + const timer = setTimeout( + () => + finish( + Object.freeze({ kind: "FAILED" as const, code: "RESET_TIMEOUT" }), + ), + SERVICE_WORKER_BOUNDS.clientDrainMs, + ); + pendingStops.add(onStop); + container.addEventListener("message", onMessage); + try { + container.controller?.postMessage( + createServiceWorkerMessage({ + kind: "CACHE_RESET_REQUEST", + sourceBuildId: dependencies.buildId, + nonce, + }), + ); + observe("cache_reset", "REQUESTED"); + } catch { + finish( + Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }), + ); + } + }); + } + + /** §17.16. Ordinary shutdown removes listeners and timers; it never unregisters. */ + async function stop(): Promise { + stopped = true; + for (const stopPending of [...pendingStops]) stopPending(); + pendingStops.clear(); + if (updateTimer) { + clearInterval(updateTimer); + updateTimer = null; + } + if (messageListener && dependencies.container) { + dependencies.container.removeEventListener("message", messageListener); + messageListener = null; + } + nonces.clear(); + } + + return Object.freeze({ start, requestActivation, resetOwnedCaches, stop }); +} + +function canPostMessage( + source: MessageEventSource | null, +): source is MessageEventSource & { postMessage(message: unknown): void } { + return !!source && typeof source.postMessage === "function"; +} + +function failed(code: string): ServiceWorkerStartOutcome { + return Object.freeze({ kind: "FAILED" as const, code }); +} diff --git a/src/adapters/service-worker/service-worker-protocol.ts b/src/adapters/service-worker/service-worker-protocol.ts new file mode 100644 index 0000000..74d86d0 --- /dev/null +++ b/src/adapters/service-worker/service-worker-protocol.ts @@ -0,0 +1,178 @@ +import { + SERVICE_WORKER_PROTOCOL_VERSION, + type ServiceWorkerMessage, + type ServiceWorkerMessageKind, +} from "../../contracts/service-worker.ts"; + +/** + * §17.8. Message protocol shared by the page controller and the worker entry. + * + * Only structural types are used here: this module is compiled into both the + * DOM realm and the WebWorker realm, so it must not reference a global from + * either one. + */ + +const MESSAGE_KINDS: ReadonlySet = new Set([ + "PAGE_HELLO", + "WORKER_HELLO_ACK", + "UPDATE_READY", + "ACTIVATE_REQUEST", + "ACTIVATE_ACCEPTED", + "ACTIVATE_REJECTED", + "CLIENT_DRAIN_REQUEST", + "CLIENT_DRAINED", + "ACTIVATED_RELOAD_REQUIRED", + "CACHE_RESET_REQUEST", + "CACHE_RESET_RESULT", + "SYNC_WAKE_OBSERVED", +]); + +const ID = /^[A-Za-z0-9._:-]{1,128}$/; + +export type ParsedMessage = + | Readonly<{ ok: true; message: ServiceWorkerMessage }> + | Readonly<{ + ok: false; + code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND"; + }>; + +/** + * Exact key set, exact protocol version, bounded identifiers. Anything else is + * rejected rather than partially interpreted: a postMessage payload is an + * untrusted runtime input (§21.1). + */ +export function parseServiceWorkerMessage(value: unknown): ParsedMessage { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return reject("MALFORMED"); + } + const candidate = value as Record; + const allowed = new Set([ + "protocolVersion", + "kind", + "messageId", + "sourceBuildId", + "targetBuildId", + "nonce", + "cachesDeleted", + ]); + for (const key of Object.keys(candidate)) { + if (!allowed.has(key)) return reject("MALFORMED"); + } + if (candidate.protocolVersion !== SERVICE_WORKER_PROTOCOL_VERSION) { + return reject("PROTOCOL_MISMATCH"); + } + if (typeof candidate.kind !== "string" || !MESSAGE_KINDS.has(candidate.kind)) { + return reject("UNKNOWN_KIND"); + } + if ( + typeof candidate.messageId !== "string" || + !ID.test(candidate.messageId) || + typeof candidate.sourceBuildId !== "string" || + !ID.test(candidate.sourceBuildId) + ) { + return reject("MALFORMED"); + } + if ( + candidate.cachesDeleted !== undefined && + (!Number.isSafeInteger(candidate.cachesDeleted) || + (candidate.cachesDeleted as number) < 0 || + (candidate.cachesDeleted as number) > 1_024) + ) { + return reject("MALFORMED"); + } + if ( + (candidate.kind === "CACHE_RESET_RESULT") !== + (candidate.cachesDeleted !== undefined) + ) { + return reject("MALFORMED"); + } + if ( + candidate.targetBuildId !== undefined && + (typeof candidate.targetBuildId !== "string" || + !ID.test(candidate.targetBuildId)) + ) { + return reject("MALFORMED"); + } + if ( + candidate.nonce !== undefined && + (typeof candidate.nonce !== "string" || !ID.test(candidate.nonce)) + ) { + return reject("MALFORMED"); + } + + return Object.freeze({ + ok: true as const, + message: Object.freeze({ + protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION, + kind: candidate.kind as ServiceWorkerMessageKind, + messageId: candidate.messageId, + sourceBuildId: candidate.sourceBuildId, + ...(candidate.targetBuildId === undefined + ? {} + : { targetBuildId: candidate.targetBuildId }), + ...(candidate.nonce === undefined ? {} : { nonce: candidate.nonce }), + ...(candidate.cachesDeleted === undefined + ? {} + : { cachesDeleted: candidate.cachesDeleted as number }), + }), + }); +} + +export function createServiceWorkerMessage( + input: Readonly<{ + kind: ServiceWorkerMessageKind; + sourceBuildId: string; + targetBuildId?: string; + nonce?: string; + cachesDeleted?: number; + messageId?: string; + }>, +): ServiceWorkerMessage { + return Object.freeze({ + protocolVersion: SERVICE_WORKER_PROTOCOL_VERSION, + kind: input.kind, + messageId: input.messageId ?? randomId(), + sourceBuildId: input.sourceBuildId, + ...(input.targetBuildId === undefined + ? {} + : { targetBuildId: input.targetBuildId }), + ...(input.nonce === undefined ? {} : { nonce: input.nonce }), + ...(input.cachesDeleted === undefined + ? {} + : { cachesDeleted: input.cachesDeleted }), + }); +} + +/** One-time nonce store. A nonce is consumed on first match and never reused. */ +export function createNonceRegistry(maximumEntries = 32) { + const nonces = new Set(); + return Object.freeze({ + issue(): string { + if (nonces.size >= maximumEntries) { + const oldest = nonces.values().next().value; + if (oldest !== undefined) nonces.delete(oldest); + } + const nonce = randomId(); + nonces.add(nonce); + return nonce; + }, + consume(nonce: string | undefined): boolean { + if (!nonce || !nonces.has(nonce)) return false; + nonces.delete(nonce); + return true; + }, + clear(): void { + nonces.clear(); + }, + }); +} + +function randomId(): string { + return crypto.randomUUID(); +} + +function reject( + code: "PROTOCOL_MISMATCH" | "MALFORMED" | "UNKNOWN_KIND", +): ParsedMessage { + return Object.freeze({ ok: false as const, code }); +} diff --git a/src/adapters/service-worker/service-worker-removal.ts b/src/adapters/service-worker/service-worker-removal.ts new file mode 100644 index 0000000..e7ca8ee --- /dev/null +++ b/src/adapters/service-worker/service-worker-removal.ts @@ -0,0 +1,169 @@ +import { + isOwnedStaticCacheName, + SERVICE_WORKER_SCRIPT_PATH, + type ServiceWorkerRemovalOutcome, +} from "../../contracts/service-worker.ts"; + +/** + * §17.4 / §17.17. Exact ownership check and staged removal. + * + * A registration is only ours when the scope matches exactly and every present + * worker's script URL is same-origin, with at least one matching the expected + * script and none pointing anywhere else. A scope-prefix guess is never enough: + * a foreign registration must never be unregistered. + */ + +export type ServiceWorkerContainerLike = Readonly<{ + getRegistration( + clientUrl?: string, + ): Promise; +}>; + +export type CacheStorageLike = Readonly<{ + keys(): Promise; + delete(cacheName: string): Promise; +}>; + +export type OwnershipInput = Readonly<{ + registration: ServiceWorkerRegistration; + expectedScopeHref: string; + expectedScriptHref: string; +}>; + +export function isOwnedRegistration(input: OwnershipInput): boolean { + const { registration, expectedScopeHref, expectedScriptHref } = input; + if (registration.scope !== expectedScopeHref) return false; + + const expectedOrigin = new URL(expectedScriptHref).origin; + const present = [ + registration.installing, + registration.waiting, + registration.active, + ].filter((worker): worker is ServiceWorker => worker !== null); + if (present.length === 0) return false; + + let matched = false; + for (const worker of present) { + let scriptOrigin: string; + try { + scriptOrigin = new URL(worker.scriptURL).origin; + } catch { + return false; + } + if (scriptOrigin !== expectedOrigin) return false; + if (worker.scriptURL === expectedScriptHref) { + matched = true; + } else { + // A present worker running a different script means this registration is + // not exclusively ours. + return false; + } + } + return matched; +} + +export function expectedServiceWorkerUrls( + routerBasePath: string, + origin: string, +): Readonly<{ scopeHref: string; scriptHref: string; scopePath: string }> { + const scope = new URL(routerBasePath, origin); + const script = new URL(SERVICE_WORKER_SCRIPT_PATH, scope); + return Object.freeze({ + scopeHref: scope.href, + scriptHref: script.href, + scopePath: scope.pathname, + }); +} + +export type RemovalDependencies = Readonly<{ + container: ServiceWorkerContainerLike; + caches?: CacheStorageLike; + routerBasePath: string; + origin: string; +}>; + +/** + * `REMOVE_REGISTRATION`: unregister only, caches retained so a rollback within + * the retention window still finds its verified assets. + */ +export async function removeOwnedRegistration( + dependencies: RemovalDependencies, +): Promise { + const urls = expectedServiceWorkerUrls( + dependencies.routerBasePath, + dependencies.origin, + ); + let registration: ServiceWorkerRegistration | undefined; + try { + registration = await dependencies.container.getRegistration(urls.scopePath); + } catch { + return Object.freeze({ kind: "FAILED" as const, operation: "LOOKUP" as const }); + } + if (!registration) return Object.freeze({ kind: "ABSENT" as const }); + if ( + !isOwnedRegistration({ + registration, + expectedScopeHref: urls.scopeHref, + expectedScriptHref: urls.scriptHref, + }) + ) { + return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const }); + } + try { + await registration.unregister(); + } catch { + return Object.freeze({ + kind: "FAILED" as const, + operation: "UNREGISTER" as const, + }); + } + return Object.freeze({ kind: "UNREGISTERED" as const }); +} + +/** + * `PURGE_OWNED_RESOURCES`: repeat the unregister check, then delete only caches + * whose name parses as ours. Outbox, OPFS and user file data are untouched, and + * unregistering is never confused with cache deletion. + */ +export async function purgeOwnedResources( + dependencies: RemovalDependencies, +): Promise { + const removal = await removeOwnedRegistration(dependencies); + if (removal.kind === "OWNERSHIP_MISMATCH" || removal.kind === "FAILED") { + return removal; + } + + const cacheStorage = dependencies.caches; + if (!cacheStorage) { + return Object.freeze({ + kind: "PURGED" as const, + cachesDeleted: 0, + metadataDeleted: 0, + }); + } + + let names: readonly string[]; + try { + names = await cacheStorage.keys(); + } catch { + return Object.freeze({ kind: "FAILED" as const, operation: "PURGE" as const }); + } + + let cachesDeleted = 0; + for (const name of names) { + if (!isOwnedStaticCacheName(name)) continue; + try { + if (await cacheStorage.delete(name)) cachesDeleted += 1; + } catch { + return Object.freeze({ + kind: "FAILED" as const, + operation: "PURGE" as const, + }); + } + } + return Object.freeze({ + kind: "PURGED" as const, + cachesDeleted, + metadataDeleted: 0, + }); +} diff --git a/src/adapters/service-worker/service-worker-static-assets.ts b/src/adapters/service-worker/service-worker-static-assets.ts new file mode 100644 index 0000000..a028924 --- /dev/null +++ b/src/adapters/service-worker/service-worker-static-assets.ts @@ -0,0 +1,359 @@ +import { + isOwnedStaticCacheName, + SERVICE_WORKER_BOUNDS, + staticCacheName, + type StaticAssetManifestV1, +} from "../../contracts/service-worker.ts"; + +/** + * §17.9 / §18. Static asset install and fetch classification. + * + * Only immutable hashed build assets are cached, all-or-nothing, verified at + * install time. Navigation, runtime config, the release manifest and every API + * response are network-only, and no runtime response is ever written into the + * active cache. + */ + +export type FetchClassification = + | "NETWORK_PASSTHROUGH" + | "NETWORK_ONLY" + | "VERIFIED_CACHE_FIRST"; + +export type ClassificationInput = Readonly<{ + method: string; + requestUrl: string; + isNavigation: boolean; + runtimeConfigUrl: string; + releaseManifestUrl: string; + manifestUrls: ReadonlySet; +}>; + +/** + * §18.5. Order matters: the exact static hit is evaluated before the generic + * network passthrough, because an API base may legitimately be `/`. + */ +export function classifyFetch(input: ClassificationInput): FetchClassification { + if (input.method !== "GET") return "NETWORK_PASSTHROUGH"; + if (input.isNavigation) return "NETWORK_ONLY"; + if ( + sameResource(input.requestUrl, input.runtimeConfigUrl) || + sameResource(input.requestUrl, input.releaseManifestUrl) + ) { + return "NETWORK_ONLY"; + } + if (input.manifestUrls.has(input.requestUrl)) return "VERIFIED_CACHE_FIRST"; + return "NETWORK_PASSTHROUGH"; +} + +function sameResource(left: string, right: string): boolean { + try { + const a = new URL(left); + const b = new URL(right, left); + return a.origin === b.origin && a.pathname === b.pathname; + } catch { + return false; + } +} + +export type InstallOutcome = + | Readonly<{ kind: "INSTALLED"; cacheName: string; assets: number }> + | Readonly<{ + kind: "REJECTED"; + code: + | "MANIFEST_INVALID" + | "ASSET_COUNT_EXCEEDED" + | "ASSET_TOO_LARGE" + | "ASSET_SET_TOO_LARGE" + | "INSTALL_DEADLINE_EXCEEDED" + | "FETCH_FAILED" + | "STATUS_INVALID" + | "CONTENT_TYPE_INVALID" + | "BYTES_MISMATCH" + | "INTEGRITY_MISMATCH" + | "QUOTA_EXCEEDED"; + }>; + +export type InstallDependencies = Readonly<{ + caches: Readonly<{ + open(cacheName: string): Promise; + delete(cacheName: string): Promise; + }>; + fetcher: typeof fetch; + digest(bytes: Uint8Array): Promise; +}>; + +export function validateStaticAssetManifest( + manifest: StaticAssetManifestV1, +): InstallOutcome | null { + const bounds = SERVICE_WORKER_BOUNDS; + if ( + manifest.schemaVersion !== 1 || + !/^sha256:[0-9a-f]{64}$/.test(manifest.setDigest) + ) { + return rejected("MANIFEST_INVALID"); + } + if (manifest.assets.length > bounds.assets) { + return rejected("ASSET_COUNT_EXCEEDED"); + } + let total = 0; + for (const asset of manifest.assets) { + if ( + !asset.url || + !/^sha256:[0-9a-f]{64}$/.test(asset.sha256) || + !Number.isSafeInteger(asset.bytes) || + asset.bytes < 0 + ) { + return rejected("MANIFEST_INVALID"); + } + if (asset.bytes > bounds.singleAssetBytes) return rejected("ASSET_TOO_LARGE"); + total += asset.bytes; + } + if (total > bounds.assetSetBytes) return rejected("ASSET_SET_TOO_LARGE"); + return null; +} + +/** + * §17.9. A partial candidate is never used: any failure deletes the candidate + * cache and rejects install, leaving the previous verified revision in place. + */ +export async function installStaticAssets( + manifest: StaticAssetManifestV1, + dependencies: InstallDependencies, +): Promise { + const invalid = validateStaticAssetManifest(manifest); + if (invalid) return invalid; + + const cacheName = staticCacheName(manifest.setDigest); + const abortController = new AbortController(); + let deadlineExceeded = false; + let deadlineTimer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => { + deadlineExceeded = true; + abortController.abort(); + resolve(rejected("INSTALL_DEADLINE_EXCEEDED")); + }, SERVICE_WORKER_BOUNDS.installDeadlineMs); + }); + + const installation = installCandidate( + manifest, + cacheName, + dependencies, + abortController, + ); + const raced = await Promise.race([installation, deadline]); + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + const outcome = deadlineExceeded + ? rejected("INSTALL_DEADLINE_EXCEEDED") + : raced; + + if (outcome.kind === "REJECTED") { + await dependencies.caches.delete(cacheName).catch(() => false); + } + return outcome; +} + +async function installCandidate( + manifest: StaticAssetManifestV1, + cacheName: string, + dependencies: InstallDependencies, + abortController: AbortController, +): Promise { + const signal = abortController.signal; + let cache: Cache; + try { + cache = await dependencies.caches.open(cacheName); + } catch { + return rejected("QUOTA_EXCEEDED"); + } + + const queue = [...manifest.assets]; + let failure: InstallOutcome | null = null; + + const worker = async (): Promise => { + for (;;) { + if (failure) return; + const asset = queue.shift(); + if (!asset) return; + const outcome = await storeAsset(asset, cache, dependencies, signal); + if (outcome) { + failure ??= outcome; + abortController.abort(); + return; + } + } + }; + + await Promise.all( + Array.from({ length: SERVICE_WORKER_BOUNDS.fetchConcurrency }, worker), + ); + + if (failure) return failure; + return Object.freeze({ + kind: "INSTALLED" as const, + cacheName, + assets: manifest.assets.length, + }); +} + +async function storeAsset( + asset: StaticAssetManifestV1["assets"][number], + cache: Cache, + dependencies: InstallDependencies, + signal: AbortSignal, +): Promise { + if (signal.aborted) return rejected("FETCH_FAILED"); + let response: Response; + try { + const fetched = await abortable( + dependencies.fetcher(asset.url, { + cache: "no-store", + credentials: "omit", + redirect: "error", + signal, + }), + signal, + ); + if (fetched === ABORTED) return rejected("FETCH_FAILED"); + response = fetched; + } catch { + return rejected("FETCH_FAILED"); + } + if (response.status !== 200 || response.type === "opaque") { + return rejected("STATUS_INVALID"); + } + const contentType = response.headers.get("content-type") ?? ""; + if ( + contentType.split(";", 1)[0]?.trim().toLowerCase() !== + asset.contentType.toLowerCase() + ) { + return rejected("CONTENT_TYPE_INVALID"); + } + + const body = await readBoundedBody(response, asset.bytes, signal); + if (!body.ok) return rejected(body.code); + const bytes = body.bytes; + + const digest = await abortable(dependencies.digest(bytes), signal); + if (digest === ABORTED) return rejected("FETCH_FAILED"); + if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH"); + + try { + if (signal.aborted) return rejected("FETCH_FAILED"); + await cache.put( + asset.url, + new Response(bytes.slice(), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + ); + } catch { + return rejected("QUOTA_EXCEEDED"); + } + return null; +} + +const ABORTED = Symbol("service-worker-install-aborted"); + +async function abortable( + operation: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return ABORTED; + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(ABORTED); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function readBoundedBody( + response: Response, + expectedBytes: number, + signal: AbortSignal, +): Promise< + | Readonly<{ ok: true; bytes: Uint8Array }> + | Readonly<{ ok: false; code: "BYTES_MISMATCH" | "FETCH_FAILED" }> +> { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + /^\d+$/u.test(declaredLength) && + Number(declaredLength) !== expectedBytes + ) { + await response.body?.cancel().catch(() => {}); + return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); + } + if (!response.body) { + return expectedBytes === 0 + ? Object.freeze({ ok: true as const, bytes: new Uint8Array() }) + : Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const result = await abortable(reader.read(), signal); + if (result === ABORTED) { + await reader.cancel().catch(() => {}); + return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const }); + } + if (result.done) break; + total += result.value.byteLength; + if (total > expectedBytes) { + await reader.cancel().catch(() => {}); + return Object.freeze({ + ok: false as const, + code: "BYTES_MISMATCH" as const, + }); + } + chunks.push(result.value); + } + } catch { + return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const }); + } finally { + reader.releaseLock(); + } + if (total !== expectedBytes) { + return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return Object.freeze({ ok: true as const, bytes }); +} + +/** + * §17.15. Keep the current revision plus exactly one previous verified cache. + * A cache found outside the owned prefix is left alone; a cache holding config, + * manifest or API data is a security violation and is deleted. + */ +export function selectCachesToDelete( + names: readonly string[], + currentCacheName: string, + previousCacheName: string | null, +): readonly string[] { + return Object.freeze( + names.filter( + (name) => + isOwnedStaticCacheName(name) && + name !== currentCacheName && + name !== previousCacheName, + ), + ); +} + +function rejected(code: Extract["code"]) { + return Object.freeze({ kind: "REJECTED" as const, code }); +} diff --git a/src/adapters/storage/browser-storage-adapter.ts b/src/adapters/storage/browser-storage-adapter.ts new file mode 100644 index 0000000..8c240fb --- /dev/null +++ b/src/adapters/storage/browser-storage-adapter.ts @@ -0,0 +1,391 @@ +import { createFailure } from "../../contracts/errors.ts"; +import { + getStorageDefinition, + isStorageValueAllowed, + type StorageDefinition, +} from "../../contracts/storage-keys.ts"; +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; +import type { + StorageMutationResult, + StoragePort, +} from "../../application/ports/storage-port.ts"; +import { + assertValidBrowserStorageByteLimit, + decodeBrowserStorageEnvelope, + DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES, + encodeBrowserStorageEnvelope, + type BrowserStorageCodecFailure, +} from "./browser-storage-codec.ts"; + +export type BrowserStorageDependencies = Readonly<{ + localStorage?: Storage; + sessionStorage?: Storage; + now?: () => number; + diagnostics?: DiagnosticsPort; + maxSerializedBytes?: number; + resolveDefinition?: (logicalName: string) => StorageDefinition; +}>; + +type StorageFailureCause = + | "QUOTA_EXCEEDED" + | "SIZE_LIMIT_EXCEEDED" + | "UNAVAILABLE" + | "VALUE_REJECTED"; + +export function createBrowserStorageAdapter( + dependencies: BrowserStorageDependencies = {}, +): StoragePort { + const memoryOverlay = new Map(); + const suppressedPersistentValues = new Set(); + const now = dependencies.now ?? Date.now; + const maxSerializedBytes = + dependencies.maxSerializedBytes ?? + DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES; + const resolveDefinition = + dependencies.resolveDefinition ?? getStorageDefinition; + assertValidBrowserStorageByteLimit(maxSerializedBytes); + + function backendFor(name: string): Storage | undefined { + if (name === "localStorage") return dependencies.localStorage; + if (name === "sessionStorage") return dependencies.sessionStorage; + return undefined; + } + + function definitionFor( + logicalName: string, + phase: string, + ): + | Readonly<{ ok: true; value: StorageDefinition }> + | Extract { + try { + return { ok: true, value: resolveDefinition(logicalName) }; + } catch { + return unavailable(phase, logicalName, dependencies.diagnostics); + } + } + + function currentTime( + phase: string, + logicalName: string, + ): + | Readonly<{ ok: true; value: number }> + | Extract { + try { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError("Invalid storage clock."); + } + return { ok: true, value }; + } catch { + return unavailable(phase, logicalName, dependencies.diagnostics); + } + } + + function discardRecord( + definition: StorageDefinition, + backend: Storage | undefined, + ): void { + memoryOverlay.delete(definition.physicalKey); + suppressedPersistentValues.add(definition.physicalKey); + if (!backend) { + suppressedPersistentValues.delete(definition.physicalKey); + return; + } + try { + backend.removeItem(definition.physicalKey); + suppressedPersistentValues.delete(definition.physicalKey); + } catch { + // Keep the in-memory tombstone so the rejected value is not parsed again. + } + } + + function readEnvelope( + raw: string, + definition: StorageDefinition, + backend: Storage | undefined, + logicalName: string, + ) { + const decoded = decodeBrowserStorageEnvelope(raw, maxSerializedBytes); + if (!decoded.ok) { + recordStorageFailure( + dependencies.diagnostics, + "discard", + logicalName, + codecFailureCause(decoded.reason), + ); + discardRecord(definition, backend); + return { ok: true as const, value: undefined }; + } + const envelope = decoded.value; + const expectsExpiry = typeof definition.ttl === "number"; + if ( + envelope.schemaVersion !== definition.schemaVersion || + expectsExpiry !== (envelope.expiresAt !== null) || + !isStorageValueAllowed(definition, envelope.value) + ) { + recordStorageFailure( + dependencies.diagnostics, + "discard", + logicalName, + "VALUE_REJECTED", + ); + discardRecord(definition, backend); + return { ok: true as const, value: undefined }; + } + if (envelope.expiresAt !== null) { + const timestamp = currentTime("read", logicalName); + if (!timestamp.ok) return timestamp; + if (envelope.expiresAt <= timestamp.value) { + discardRecord(definition, backend); + return { ok: true as const, value: undefined }; + } + } + return { ok: true as const, value: envelope.value }; + } + + return Object.freeze({ + read(logicalName) { + const selected = definitionFor(logicalName, "read"); + if (!selected.ok) return selected; + const definition = selected.value; + const backend = backendFor(definition.backend); + const overlay = memoryOverlay.get(definition.physicalKey); + if (overlay !== undefined) { + return readEnvelope( + overlay, + definition, + backend, + logicalName, + ); + } + if (suppressedPersistentValues.has(definition.physicalKey)) { + return { ok: true, value: undefined }; + } + + try { + const raw = backend?.getItem(definition.physicalKey); + if (raw === null || raw === undefined) { + return { ok: true, value: undefined }; + } + return readEnvelope(raw, definition, backend, logicalName); + } catch { + return unavailable("read", logicalName, dependencies.diagnostics); + } + }, + + write(logicalName, value) { + const selected = definitionFor(logicalName, "write"); + if (!selected.ok) return selected; + const definition = selected.value; + if (!isStorageValueAllowed(definition, value)) { + recordStorageFailure( + dependencies.diagnostics, + "write", + logicalName, + "VALUE_REJECTED", + ); + return { + ok: false, + error: storageFailure( + "VALUE_REJECTED", + "write", + logicalName, + ), + }; + } + + let expiresAt: number | null = null; + if (typeof definition.ttl === "number") { + const timestamp = currentTime("write", logicalName); + if (!timestamp.ok) return timestamp; + const expiration = timestamp.value + definition.ttl; + if (!Number.isSafeInteger(expiration)) { + return unavailable( + "write", + logicalName, + dependencies.diagnostics, + ); + } + expiresAt = expiration; + } + + const encoded = encodeBrowserStorageEnvelope( + { + schemaVersion: definition.schemaVersion, + expiresAt, + value, + }, + maxSerializedBytes, + ); + if (!encoded.ok) { + const cause = codecFailureCause(encoded.reason); + recordStorageFailure( + dependencies.diagnostics, + "write", + logicalName, + cause, + ); + return { + ok: false, + error: storageFailure(cause, "write", logicalName), + }; + } + + if (definition.backend === "memory") { + memoryOverlay.set(definition.physicalKey, encoded.value); + suppressedPersistentValues.delete(definition.physicalKey); + return { ok: true }; + } + + const backend = backendFor(definition.backend); + try { + if (!backend) { + throw new DOMException("Storage unavailable", "SecurityError"); + } + backend.setItem(definition.physicalKey, encoded.value); + memoryOverlay.delete(definition.physicalKey); + suppressedPersistentValues.delete(definition.physicalKey); + return { ok: true }; + } catch (error) { + const cause: StorageFailureCause = isQuotaError(error) + ? "QUOTA_EXCEEDED" + : "UNAVAILABLE"; + if (definition.quotaFallback === "memory") { + memoryOverlay.set(definition.physicalKey, encoded.value); + suppressedPersistentValues.delete(definition.physicalKey); + recordStorageFailure( + dependencies.diagnostics, + "write", + logicalName, + cause, + ); + return { + ok: false, + error: storageFailure(cause, "write", logicalName), + fallback: "memory", + }; + } + recordStorageFailure( + dependencies.diagnostics, + "write", + logicalName, + cause, + ); + return { + ok: false, + error: storageFailure(cause, "write", logicalName), + fallback: definition.quotaFallback, + }; + } + }, + + remove(logicalName) { + const selected = definitionFor(logicalName, "remove"); + if (!selected.ok) return selected; + const definition = selected.value; + const backend = backendFor(definition.backend); + + memoryOverlay.delete(definition.physicalKey); + suppressedPersistentValues.add(definition.physicalKey); + try { + backend?.removeItem(definition.physicalKey); + suppressedPersistentValues.delete(definition.physicalKey); + return { ok: true }; + } catch { + recordStorageFailure( + dependencies.diagnostics, + "remove", + logicalName, + "UNAVAILABLE", + ); + return { + ok: false, + error: storageFailure("UNAVAILABLE", "remove", logicalName), + }; + } + }, + }); +} + +function codecFailureCause( + reason: BrowserStorageCodecFailure, +): StorageFailureCause { + return reason === "OVERSIZE" + ? "SIZE_LIMIT_EXCEEDED" + : "VALUE_REJECTED"; +} + +function isQuotaError(error: unknown): boolean { + try { + if (!error || typeof error !== "object") return false; + const name = (error as Readonly<{ name?: unknown }>).name; + return ( + typeof name === "string" && + ["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(name) + ); + } catch { + return false; + } +} + +function storageFailure( + cause: StorageFailureCause, + phase: string, + logicalName: string, +) { + const quota = cause === "QUOTA_EXCEEDED"; + return createFailure( + quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE", + "STORAGE", + 0, + { + code: `${safeLogicalName(logicalName)}_${phase.toUpperCase()}_${cause}`, + }, + ); +} + +function unavailable( + phase: string, + logicalName: string, + diagnostics: DiagnosticsPort | undefined, +): Extract { + recordStorageFailure( + diagnostics, + phase, + logicalName, + "UNAVAILABLE", + ); + return { + ok: false, + error: storageFailure("UNAVAILABLE", phase, logicalName), + }; +} + +function recordStorageFailure( + diagnostics: DiagnosticsPort | undefined, + phase: string, + logicalName: string, + cause: StorageFailureCause, +): void { + try { + diagnostics?.record({ + level: "warn", + eventId: "storage.operation.failed", + context: { + operation: `${phase}:${safeLogicalName(logicalName)}`, + error_kind: + cause === "QUOTA_EXCEEDED" + ? "STORAGE_QUOTA_EXCEEDED" + : "STORAGE_UNAVAILABLE", + }, + }); + } catch { + // Storage behavior remains independent from diagnostics. + } +} + +function safeLogicalName(logicalName: string): string { + return /^[A-Z][A-Z0-9_]{0,63}$/u.test(logicalName) + ? logicalName + : "UNKNOWN_KEY"; +} diff --git a/src/adapters/storage/browser-storage-codec.ts b/src/adapters/storage/browser-storage-codec.ts new file mode 100644 index 0000000..3c6c93d --- /dev/null +++ b/src/adapters/storage/browser-storage-codec.ts @@ -0,0 +1,231 @@ +export const DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES = 16_384; + +const MAX_VALUE_DEPTH = 32; +const MAX_VALUE_NODES = 2_048; +const FORBIDDEN_RECORD_KEYS = new Set([ + "__proto__", + "constructor", + "prototype", +]); + +export type BrowserStorageEnvelope = Readonly<{ + schemaVersion: number; + expiresAt: number | null; + value: unknown; +}>; + +export type BrowserStorageCodecFailure = + | "INVALID_VALUE" + | "MALFORMED_RECORD" + | "OVERSIZE"; + +export type BrowserStorageCodecResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; reason: BrowserStorageCodecFailure }>; + +/** + * Closed JSON codec for small Web Storage values. It rejects values that JSON + * would silently coerce or omit, accessors, exotic prototypes and unsafe + * record keys before they can cross the persistence boundary. + */ +export function encodeBrowserStorageEnvelope( + envelope: BrowserStorageEnvelope, + maxSerializedBytes: number, +): BrowserStorageCodecResult { + try { + if (!validEnvelopeMetadata(envelope)) { + return { ok: false, reason: "INVALID_VALUE" }; + } + const valueValidation = validateStorageValue( + envelope.value, + maxSerializedBytes, + ); + if (!valueValidation.ok) return valueValidation; + const raw = JSON.stringify(envelope); + if ( + typeof raw !== "string" || + serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes + ) { + return { ok: false, reason: "OVERSIZE" }; + } + return { ok: true, value: raw }; + } catch { + return { ok: false, reason: "INVALID_VALUE" }; + } +} + +export function decodeBrowserStorageEnvelope( + raw: string, + maxSerializedBytes: number, +): BrowserStorageCodecResult { + try { + if (serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes) { + return { ok: false, reason: "OVERSIZE" }; + } + const parsed: unknown = JSON.parse(raw); + if (!isExactEnvelope(parsed)) { + return { ok: false, reason: "MALFORMED_RECORD" }; + } + const valueValidation = validateStorageValue( + parsed.value, + maxSerializedBytes, + ); + if (!valueValidation.ok) { + return { + ok: false, + reason: + valueValidation.reason === "OVERSIZE" + ? "OVERSIZE" + : "MALFORMED_RECORD", + }; + } + return { ok: true, value: parsed }; + } catch { + return { ok: false, reason: "MALFORMED_RECORD" }; + } +} + +export function assertValidBrowserStorageByteLimit(value: number): void { + if (!Number.isSafeInteger(value) || value < 64) { + throw new TypeError( + "Browser storage serialized byte limit must be a safe integer of at least 64.", + ); + } +} + +function validEnvelopeMetadata(envelope: BrowserStorageEnvelope): boolean { + return ( + Boolean(envelope) && + typeof envelope === "object" && + Number.isSafeInteger(envelope.schemaVersion) && + envelope.schemaVersion > 0 && + (envelope.expiresAt === null || + (Number.isSafeInteger(envelope.expiresAt) && envelope.expiresAt >= 0)) + ); +} + +function isExactEnvelope(value: unknown): value is BrowserStorageEnvelope { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const keys = Object.keys(value).sort(); + if ( + keys.length !== 3 || + keys[0] !== "expiresAt" || + keys[1] !== "schemaVersion" || + keys[2] !== "value" + ) { + return false; + } + return validEnvelopeMetadata(value as BrowserStorageEnvelope); +} + +function serializedByteLength(raw: string, limit: number): number { + if (raw.length > limit) return limit + 1; + return new TextEncoder().encode(raw).byteLength; +} + +function validateStorageValue( + root: unknown, + maxSerializedBytes: number, +): BrowserStorageCodecResult { + let visited = 0; + const ancestors = new Set(); + + function visit( + value: unknown, + depth: number, + ): BrowserStorageCodecResult { + visited += 1; + if (visited > MAX_VALUE_NODES || depth > MAX_VALUE_DEPTH) { + return { ok: false, reason: "OVERSIZE" }; + } + + if ( + value === null || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) { + return { ok: true, value: undefined }; + } + if (typeof value === "string") { + if (value.length > maxSerializedBytes) { + return { ok: false, reason: "OVERSIZE" }; + } + return { ok: true, value: undefined }; + } + if (!value || typeof value !== "object") { + return { ok: false, reason: "INVALID_VALUE" }; + } + if (ancestors.has(value)) { + return { ok: false, reason: "INVALID_VALUE" }; + } + + const prototype = Object.getPrototypeOf(value); + if ( + !Array.isArray(value) && + prototype !== Object.prototype && + prototype !== null + ) { + return { ok: false, reason: "INVALID_VALUE" }; + } + if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) { + return { ok: false, reason: "INVALID_VALUE" }; + } + + const descriptors = Object.getOwnPropertyDescriptors(value); + const childValues: unknown[] = []; + if (Array.isArray(value)) { + if ( + !Number.isSafeInteger(value.length) || + value.length > MAX_VALUE_NODES + ) { + return { ok: false, reason: "OVERSIZE" }; + } + const descriptorKeys = Object.keys(descriptors).filter( + (key) => key !== "length", + ); + if (descriptorKeys.length !== value.length) { + return { ok: false, reason: "INVALID_VALUE" }; + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + !descriptor.enumerable || + !("value" in descriptor) + ) { + return { ok: false, reason: "INVALID_VALUE" }; + } + childValues.push(descriptor.value); + } + } else { + for (const [key, descriptor] of Object.entries(descriptors)) { + if ( + key.length > maxSerializedBytes || + FORBIDDEN_RECORD_KEYS.has(key) || + !descriptor.enumerable || + !("value" in descriptor) + ) { + return { ok: false, reason: "INVALID_VALUE" }; + } + childValues.push(descriptor.value); + } + } + + ancestors.add(value); + try { + for (const child of childValues) { + const result = visit(child, depth + 1); + if (!result.ok) return result; + } + } finally { + ancestors.delete(value); + } + return { ok: true, value: undefined }; + } + + try { + return visit(root, 0); + } catch { + return { ok: false, reason: "INVALID_VALUE" }; + } +} diff --git a/src/adapters/storage/indexeddb/index.ts b/src/adapters/storage/indexeddb/index.ts new file mode 100644 index 0000000..5cc496b --- /dev/null +++ b/src/adapters/storage/indexeddb/index.ts @@ -0,0 +1,25 @@ +export { createIndexedDbMaintenance } from "./indexeddb-maintenance.ts"; +export { createIndexedDbRuntime } from "./indexeddb-runtime.ts"; +export { + assertValidIndexedDbDatasetGovernance, + indexedDbPhysicalDatabaseName, +} from "./indexeddb-governance.ts"; + +export type { + IndexedDbCodec, + IndexedDbCodecResult, + IndexedDbCountBucket, + IndexedDbDataMigrationPolicy, + IndexedDbDataMigrationSource, + IndexedDbDurabilityPolicy, + IndexedDbIndexDefinition, + IndexedDbKeyRangePlan, + IndexedDbMaintenanceDependencies, + IndexedDbObservation, + IndexedDbQueryPlan, + IndexedDbQueryPolicy, + IndexedDbRuntimeDependencies, + IndexedDbScheduler, + IndexedDbSchemaMigration, + IndexedDbSchemaOperation, +} from "./indexeddb-types.ts"; diff --git a/src/adapters/storage/indexeddb/indexeddb-failure.ts b/src/adapters/storage/indexeddb/indexeddb-failure.ts new file mode 100644 index 0000000..70c7835 --- /dev/null +++ b/src/adapters/storage/indexeddb/indexeddb-failure.ts @@ -0,0 +1,72 @@ +import type { + BrowserDataOperation, + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { browserDataFailure } from "../../browser-file-storage/result.ts"; + +function exceptionName(error: unknown): string { + if ( + error && + typeof error === "object" && + "name" in error && + typeof error.name === "string" + ) { + return error.name; + } + return ""; +} + +/** + * Maps the closed DOMException vocabulary without exposing an exception + * object, message, key or stored value across the adapter boundary. + */ +export function mapIndexedDbException( + error: unknown, + operation: BrowserDataOperation, +): BrowserDataResult { + switch (exceptionName(error)) { + case "AbortError": + return browserDataFailure("ABORTED", operation); + case "ConstraintError": + return browserDataFailure("CONFLICT", operation); + case "DataCloneError": + case "DataError": + return browserDataFailure("CORRUPT_DATA", operation, { + recovery: "READ_ONLY", + }); + case "InvalidAccessError": + case "InvalidStateError": + case "NotFoundError": + case "ReadOnlyError": + case "TransactionInactiveError": + case "VersionError": + return browserDataFailure("MIGRATION_FAILED", operation, { + recovery: "READ_ONLY", + }); + case "NotAllowedError": + case "SecurityError": + return browserDataFailure("PERMISSION_DENIED", operation, { + recovery: "ONLINE_ONLY", + }); + case "NotReadableError": + return browserDataFailure("NOT_READABLE", operation, { + retryable: true, + recovery: "REOPEN", + }); + case "QuotaExceededError": + case "NS_ERROR_DOM_QUOTA_REACHED": + return browserDataFailure("QUOTA_EXCEEDED", operation, { + recovery: "READ_ONLY", + }); + case "UnknownError": + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "REOPEN", + }); + default: + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "RETRY", + }); + } +} diff --git a/src/adapters/storage/indexeddb/indexeddb-governance.ts b/src/adapters/storage/indexeddb/indexeddb-governance.ts new file mode 100644 index 0000000..d1659e0 --- /dev/null +++ b/src/adapters/storage/indexeddb/indexeddb-governance.ts @@ -0,0 +1,339 @@ +import type { IndexedDbDatasetScope } from "../../../application/ports/browser-file-storage/indexeddb-port.ts"; +import { + assertValidStoragePolicy, + type BrowserStoragePolicy, +} from "../../../application/ports/browser-file-storage/shared.ts"; + +export const INDEXEDDB_DATASET_BINDING_KEY = "dataset-binding"; +export const INDEXEDDB_DATASET_BUDGET_KEY = "dataset-budget"; + +const OPAQUE_SCOPE_TOKEN = /^[A-Za-z0-9_-]{16,48}$/u; + +type StoredDatasetBinding = Readonly<{ + bindingKey: typeof INDEXEDDB_DATASET_BINDING_KEY; + bindingVersion: 1; + scope: IndexedDbDatasetScope; + storagePolicy: BrowserStoragePolicy; +}>; + +export type IndexedDbBindingVerification = + | Readonly<{ ok: true }> + | Readonly<{ + ok: false; + reason: "ABORTED" | "CORRUPT" | "MISMATCH" | "MISSING" | "NATIVE_ERROR"; + error?: unknown; + }>; + +function validOpaqueToken(value: unknown): value is string { + return typeof value === "string" && OPAQUE_SCOPE_TOKEN.test(value); +} + +export function assertValidIndexedDbDatasetGovernance( + scope: IndexedDbDatasetScope, + storagePolicy: BrowserStoragePolicy, +): void { + assertValidStoragePolicy(storagePolicy); + if ( + !scope || + typeof scope !== "object" || + !validOpaqueToken(scope.authorityToken) || + !validOpaqueToken(scope.namespaceToken) || + !validOpaqueToken(scope.partitionToken) || + new Set([ + scope.authorityToken, + scope.namespaceToken, + scope.partitionToken, + ]).size !== 3 || + scope.accountScope !== storagePolicy.accountScope || + scope.authorityToken === storagePolicy.owner || + scope.namespaceToken === storagePolicy.namespace || + scope.partitionToken === storagePolicy.namespace || + (storagePolicy.classification === "PERSONAL" && + scope.accountScope !== "OPAQUE_PARTITION") || + (storagePolicy.classification === "CONFIDENTIAL" && + scope.accountScope !== "OPAQUE_PARTITION") + ) { + throw new TypeError("IndexedDB dataset governance is invalid."); + } +} + +/** + * Physical identity is derived exclusively from opaque registry tokens. The + * readable policy namespace and all business/account identifiers are excluded. + */ +export function indexedDbPhysicalDatabaseName( + scope: IndexedDbDatasetScope, +): string { + if ( + !scope || + typeof scope !== "object" || + !validOpaqueToken(scope.authorityToken) || + !validOpaqueToken(scope.namespaceToken) || + !validOpaqueToken(scope.partitionToken) + ) { + throw new TypeError("IndexedDB dataset scope is invalid."); + } + return `ca-idb-v1:${scope.authorityToken}.${scope.namespaceToken}.${scope.partitionToken}`; +} + +export function createIndexedDbDatasetBinding( + scope: IndexedDbDatasetScope, + storagePolicy: BrowserStoragePolicy, +): StoredDatasetBinding { + assertValidIndexedDbDatasetGovernance(scope, storagePolicy); + return Object.freeze({ + bindingKey: INDEXEDDB_DATASET_BINDING_KEY, + bindingVersion: 1, + scope: Object.freeze({ ...scope }), + storagePolicy: Object.freeze({ + ...storagePolicy, + retention: Object.freeze({ ...storagePolicy.retention }), + }), + }); +} + +function isStoredDatasetBinding( + value: unknown, +): value is StoredDatasetBinding { + if (!value || typeof value !== "object") return false; + const binding = value as Partial; + if ( + binding.bindingKey !== INDEXEDDB_DATASET_BINDING_KEY || + binding.bindingVersion !== 1 || + !binding.scope || + !binding.storagePolicy + ) { + return false; + } + try { + assertValidIndexedDbDatasetGovernance( + binding.scope, + binding.storagePolicy, + ); + return true; + } catch { + return false; + } +} + +function canonicalPolicy(policy: BrowserStoragePolicy): string { + return JSON.stringify([ + policy.owner, + policy.namespace, + policy.classification, + policy.authority, + policy.accountScope, + policy.retention.kind, + policy.retention.kind === "TTL" + ? policy.retention.maxAgeMs + : null, + policy.softBudgetBytes, + policy.hardBudgetBytes, + policy.evictionPriority, + policy.logoutAction, + policy.accountDeletionAction, + policy.pressureAction, + policy.unavailableFallback, + ]); +} + +export function sameIndexedDbDatasetBinding( + value: unknown, + expected: StoredDatasetBinding, +): boolean { + if (!isStoredDatasetBinding(value)) return false; + return ( + value.scope.authorityToken === expected.scope.authorityToken && + value.scope.namespaceToken === expected.scope.namespaceToken && + value.scope.partitionToken === expected.scope.partitionToken && + value.scope.accountScope === expected.scope.accountScope && + canonicalPolicy(value.storagePolicy) === + canonicalPolicy(expected.storagePolicy) + ); +} + +/** + * Queues binding validation inside the versionchange transaction. Any mismatch + * aborts that transaction, so schema changes cannot commit under the wrong + * namespace or policy. + */ +export function queueIndexedDbUpgradeBinding( + transaction: IDBTransaction, + governanceStore: string, + expected: StoredDatasetBinding, + oldVersion: number, + onRejected: () => void, +): void { + const store = transaction.objectStore(governanceStore); + if (oldVersion === 0) { + let addRequest: IDBRequest; + try { + addRequest = store.add(expected); + } catch { + onRejected(); + transaction.abort(); + return; + } + addRequest.onerror = () => onRejected(); + let budgetRequest: IDBRequest; + try { + budgetRequest = store.add( + Object.freeze({ + bindingKey: INDEXEDDB_DATASET_BUDGET_KEY, + budgetVersion: 1, + usedBytes: 0, + receiptCount: 0, + }), + ); + } catch { + onRejected(); + transaction.abort(); + return; + } + budgetRequest.onerror = () => onRejected(); + return; + } + const request = store.get(INDEXEDDB_DATASET_BINDING_KEY); + request.onerror = () => { + onRejected(); + try { + transaction.abort(); + } catch { + // The native request/transaction error owns the terminal state. + } + }; + request.onsuccess = () => { + if (!sameIndexedDbDatasetBinding(request.result, expected)) { + onRejected(); + try { + transaction.abort(); + } catch { + // The mismatch remains fail-closed even if abort already won. + } + } + }; +} + +/** + * Post-open verification protects non-upgrade opens and maintenance callers. + */ +export function verifyIndexedDbDatasetBinding( + database: IDBDatabase, + governanceStore: string, + expected: StoredDatasetBinding, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + return Promise.resolve({ ok: false, reason: "ABORTED" }); + } + let transaction: IDBTransaction; + try { + transaction = database.transaction(governanceStore, "readonly"); + } catch (error) { + return Promise.resolve({ + ok: false, + reason: "NATIVE_ERROR", + error, + }); + } + + return new Promise((resolve) => { + let settled = false; + let observed: unknown; + let observedBudget: unknown; + let requestError: unknown; + let callerAborted = false; + const finish = (result: IndexedDbBindingVerification) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + function onAbort(): void { + callerAborted = true; + try { + transaction.abort(); + } catch { + // Completion determines the race. + } + } + signal?.addEventListener("abort", onAbort, { once: true }); + + transaction.onerror = () => { + requestError ??= transaction.error; + }; + transaction.onabort = () => + finish( + callerAborted + ? { ok: false, reason: "ABORTED" } + : { + ok: false, + reason: "NATIVE_ERROR", + error: requestError ?? transaction.error, + }, + ); + transaction.oncomplete = () => { + if (observed === undefined) { + finish({ ok: false, reason: "MISSING" }); + } else if (!isStoredDatasetBinding(observed)) { + finish({ ok: false, reason: "CORRUPT" }); + } else if (!sameIndexedDbDatasetBinding(observed, expected)) { + finish({ ok: false, reason: "MISMATCH" }); + } else if ( + !observedBudget || + typeof observedBudget !== "object" || + (observedBudget as { bindingKey?: unknown }).bindingKey !== + INDEXEDDB_DATASET_BUDGET_KEY || + (observedBudget as { budgetVersion?: unknown }).budgetVersion !== + 1 || + !Number.isSafeInteger( + (observedBudget as { usedBytes?: unknown }).usedBytes, + ) || + typeof (observedBudget as { usedBytes?: unknown }).usedBytes !== + "number" || + (observedBudget as { usedBytes: number }).usedBytes < 0 || + (observedBudget as { usedBytes: number }).usedBytes > + expected.storagePolicy.hardBudgetBytes + || + !Number.isSafeInteger( + (observedBudget as { receiptCount?: unknown }).receiptCount, + ) || + typeof (observedBudget as { receiptCount?: unknown }) + .receiptCount !== "number" || + (observedBudget as { receiptCount: number }).receiptCount < 0 + ) { + finish({ ok: false, reason: "CORRUPT" }); + } else { + finish({ ok: true }); + } + }; + + try { + const request = transaction + .objectStore(governanceStore) + .get(INDEXEDDB_DATASET_BINDING_KEY); + request.onerror = () => { + requestError ??= request.error; + }; + request.onsuccess = () => { + observed = request.result; + }; + const budgetRequest = transaction + .objectStore(governanceStore) + .get(INDEXEDDB_DATASET_BUDGET_KEY); + budgetRequest.onerror = () => { + requestError ??= budgetRequest.error; + }; + budgetRequest.onsuccess = () => { + observedBudget = budgetRequest.result; + }; + } catch (error) { + requestError = error; + try { + transaction.abort(); + } catch { + finish({ ok: false, reason: "NATIVE_ERROR", error }); + } + } + }); +} diff --git a/src/adapters/storage/indexeddb/indexeddb-maintenance.ts b/src/adapters/storage/indexeddb/indexeddb-maintenance.ts new file mode 100644 index 0000000..1e8745f --- /dev/null +++ b/src/adapters/storage/indexeddb/indexeddb-maintenance.ts @@ -0,0 +1,1540 @@ +import type { + IndexedDbMaintenanceBatchInput, + IndexedDbMaintenanceBatchReceipt, + IndexedDbMaintenancePort, + IndexedDbReceiptPruneBatchReceipt, +} from "../../../application/ports/browser-file-storage/indexeddb-port.ts"; +import type { + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { mapIndexedDbException } from "./indexeddb-failure.ts"; +import { + createIndexedDbDatasetBinding, + INDEXEDDB_DATASET_BUDGET_KEY, + indexedDbPhysicalDatabaseName, + verifyIndexedDbDatasetBinding, +} from "./indexeddb-governance.ts"; +import type { + IndexedDbCountBucket, + IndexedDbMaintenanceDependencies, + IndexedDbObservation, +} from "./indexeddb-types.ts"; + +type StoredRecord = Readonly<{ + key: string; + codecVersion: number; + revision: number; + payload: unknown; +}>; + +type StoredCheckpoint = Readonly<{ + checkpointKey: string; + migrationId: string; + targetCodecVersion: number; + lastKey: string | null; + state: "MORE" | "COMPLETE"; +}>; + +type StoredIdempotencyReceipt = Readonly<{ + idempotencyKey: string; + operation: "PUT" | "DELETE"; + recordKey: string; + expectedRevision: number | null; + fingerprint: string; + synchronization: "NONE" | "PENDING" | "CONFIRMED"; + revision: number; + expiresAtEpochMs: number; +}>; + +type StoredRetentionRecord = Readonly<{ + recordKey: string; + writtenAtEpochMs: number; + synchronization: "NONE" | "PENDING" | "CONFIRMED"; + measuredBytes: number; + eligibleAtEpochMs?: number; +}>; + +type StoredDatasetBudget = Readonly<{ + bindingKey: typeof INDEXEDDB_DATASET_BUDGET_KEY; + budgetVersion: 1; + usedBytes: number; + receiptCount: number; +}>; + +type CheckpointState = Readonly<{ + persisted: StoredCheckpoint | null; + effective: Readonly<{ + lastKey: string | null; + state: "MORE" | "COMPLETE"; + }>; +}>; + +type ScannedRecord = Readonly<{ + key: string; + codecVersion: number; + revision: number; + payload: unknown; +}>; + +type ScanBatch = Readonly<{ + rows: readonly ScannedRecord[]; + reachedEnd: boolean; + budgetExhausted: boolean; +}>; + +type PreparedRecord = Readonly<{ + source: ScannedRecord; + needsMigration: boolean; + migratedPayload: WireValue | undefined; + measuredBytes: number | undefined; +}>; + +type TransactionContext = Readonly<{ + succeed(value: Value): void; + fail(result: BrowserDataResult): void; + requestFailed(error: unknown): void; +}>; + +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const OPAQUE_SHA256_FINGERPRINT = /^[a-f0-9]{64}$/u; +const MAX_BATCH_ROWS = 500; +const MAX_BATCH_DURATION_MS = 30_000; +const RECORD_ENVELOPE_RESERVATION_BYTES = 512; +const MAX_MEASURED_RECORD_BYTES = 2_147_483_647; + +function isPositiveInteger(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 1 + ); +} + +function isStoredRecord( + value: unknown, + expectedKey?: string, +): value is StoredRecord { + if (!value || typeof value !== "object") return false; + const record = value as Partial; + return ( + typeof record.key === "string" && + record.key.length > 0 && + record.key.length <= 200 && + (expectedKey === undefined || record.key === expectedKey) && + isPositiveInteger(record.codecVersion) && + isPositiveInteger(record.revision) && + Object.hasOwn(record, "payload") + ); +} + +function isStoredCheckpoint( + value: unknown, + checkpointKey: string, +): value is StoredCheckpoint { + if (!value || typeof value !== "object") return false; + const checkpoint = value as Partial; + return ( + checkpoint.checkpointKey === checkpointKey && + typeof checkpoint.migrationId === "string" && + SAFE_IDENTIFIER.test(checkpoint.migrationId) && + isPositiveInteger(checkpoint.targetCodecVersion) && + (checkpoint.lastKey === null || + (typeof checkpoint.lastKey === "string" && + checkpoint.lastKey.length > 0 && + checkpoint.lastKey.length <= 200)) && + (checkpoint.state === "MORE" || + checkpoint.state === "COMPLETE") + ); +} + +function isStoredReceipt( + value: unknown, +): value is StoredIdempotencyReceipt { + if (!value || typeof value !== "object") return false; + const receipt = value as Partial; + return ( + typeof receipt.idempotencyKey === "string" && + receipt.idempotencyKey.length > 0 && + receipt.idempotencyKey.length <= 200 && + (receipt.operation === "PUT" || + receipt.operation === "DELETE") && + typeof receipt.recordKey === "string" && + receipt.recordKey.length > 0 && + receipt.recordKey.length <= 200 && + (receipt.expectedRevision === null || + isPositiveInteger(receipt.expectedRevision)) && + typeof receipt.fingerprint === "string" && + OPAQUE_SHA256_FINGERPRINT.test(receipt.fingerprint) && + (receipt.synchronization === "NONE" || + receipt.synchronization === "PENDING" || + receipt.synchronization === "CONFIRMED") && + isPositiveInteger(receipt.revision) && + typeof receipt.expiresAtEpochMs === "number" && + Number.isSafeInteger(receipt.expiresAtEpochMs) && + receipt.expiresAtEpochMs >= 0 + ); +} + +function isStoredRetentionRecord( + value: unknown, + expectedKey: string, +): value is StoredRetentionRecord { + if (!value || typeof value !== "object") return false; + const record = value as Partial; + return ( + record.recordKey === expectedKey && + typeof record.writtenAtEpochMs === "number" && + Number.isSafeInteger(record.writtenAtEpochMs) && + record.writtenAtEpochMs >= 0 && + (record.synchronization === "NONE" || + record.synchronization === "PENDING" || + record.synchronization === "CONFIRMED") && + typeof record.measuredBytes === "number" && + Number.isSafeInteger(record.measuredBytes) && + record.measuredBytes >= 1 && + record.measuredBytes <= MAX_MEASURED_RECORD_BYTES && + (record.eligibleAtEpochMs === undefined || + (typeof record.eligibleAtEpochMs === "number" && + Number.isSafeInteger(record.eligibleAtEpochMs) && + record.eligibleAtEpochMs >= record.writtenAtEpochMs)) + ); +} + +function isStoredDatasetBudget( + value: unknown, +): value is StoredDatasetBudget { + if (!value || typeof value !== "object") return false; + const budget = value as Partial; + return ( + budget.bindingKey === INDEXEDDB_DATASET_BUDGET_KEY && + budget.budgetVersion === 1 && + typeof budget.usedBytes === "number" && + Number.isSafeInteger(budget.usedBytes) && + budget.usedBytes >= 0 && + typeof budget.receiptCount === "number" && + Number.isSafeInteger(budget.receiptCount) && + budget.receiptCount >= 0 + ); +} + +function sameCheckpoint( + left: StoredCheckpoint | null, + right: StoredCheckpoint | null, +): boolean { + if (left === null || right === null) return left === right; + return ( + left.checkpointKey === right.checkpointKey && + left.migrationId === right.migrationId && + left.targetCodecVersion === right.targetCodecVersion && + left.lastKey === right.lastKey && + left.state === right.state + ); +} + +function countBucket(count: number): IndexedDbCountBucket { + if (count <= 0) return "0"; + if (count === 1) return "1"; + if (count <= 10) return "2-10"; + if (count <= 100) return "11-100"; + return "101+"; +} + +function invalidInput(): BrowserDataResult { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_MIGRATE"); +} + +function migrationFailed(): BrowserDataResult { + return browserDataFailure("MIGRATION_FAILED", "INDEXEDDB_MIGRATE", { + recovery: "READ_ONLY", + }); +} + +function unavailable(): BrowserDataResult { + return browserDataFailure("UNAVAILABLE", "INDEXEDDB_MIGRATE", { + retryable: true, + recovery: "REOPEN", + }); +} + +function defaultNow(): number { + return typeof globalThis.performance === "undefined" + ? Date.now() + : globalThis.performance.now(); +} + +/** + * Creates an opt-in one-shot maintenance adapter. Every batch opens and closes + * its own exact-version connection so the repository runtime does not expose a + * native connection or transaction across its public boundary. + */ +export function createIndexedDbMaintenance( + inputDependencies: IndexedDbMaintenanceDependencies, +): IndexedDbMaintenancePort { + const expectedBinding = createIndexedDbDatasetBinding( + inputDependencies.scope, + inputDependencies.storagePolicy, + ); + const storagePolicySnapshot = expectedBinding.storagePolicy; + const migrationPolicySource = + inputDependencies.migrationPolicy; + const dependencies: IndexedDbMaintenanceDependencies = + Object.freeze({ + ...inputDependencies, + scope: expectedBinding.scope, + storagePolicy: storagePolicySnapshot, + migrationPolicy: Object.freeze({ + migrationId: migrationPolicySource.migrationId, + targetCodecVersion: + migrationPolicySource.targetCodecVersion, + measureStoredBytes: + migrationPolicySource.measureStoredBytes.bind( + migrationPolicySource, + ), + isOldWriterDrainConfirmed: + migrationPolicySource.isOldWriterDrainConfirmed.bind( + migrationPolicySource, + ), + migrate: migrationPolicySource.migrate.bind( + migrationPolicySource, + ), + }), + ...(inputDependencies.keyRange + ? { + keyRange: Object.freeze({ + lowerBound: + inputDependencies.keyRange.lowerBound.bind( + inputDependencies.keyRange, + ), + upperBound: + inputDependencies.keyRange.upperBound.bind( + inputDependencies.keyRange, + ), + }), + } + : {}), + ...(inputDependencies.durability + ? { + durability: Object.freeze({ + ...inputDependencies.durability, + }), + } + : {}), + }); + const databaseName = indexedDbPhysicalDatabaseName( + expectedBinding.scope, + ); + if ( + (dependencies.databaseNameAssertion !== undefined && + dependencies.databaseNameAssertion !== databaseName) || + !SAFE_IDENTIFIER.test(dependencies.recordStore) || + !SAFE_IDENTIFIER.test(dependencies.governanceStore) || + !SAFE_IDENTIFIER.test(dependencies.retentionStore) || + !SAFE_IDENTIFIER.test(dependencies.checkpointStore) || + !SAFE_IDENTIFIER.test(dependencies.checkpointKey) || + !SAFE_IDENTIFIER.test(dependencies.idempotencyStore) || + !SAFE_IDENTIFIER.test( + dependencies.idempotencyExpiryIndex, + ) || + !SAFE_IDENTIFIER.test(dependencies.migrationPolicy.migrationId) || + new Set([ + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + dependencies.checkpointStore, + dependencies.idempotencyStore, + ]).size !== 5 || + !isPositiveInteger(dependencies.schemaVersion) || + !isPositiveInteger( + dependencies.migrationPolicy.targetCodecVersion, + ) + ) { + throw new TypeError("IndexedDB maintenance configuration is invalid."); + } + + const factory = + dependencies.factory ?? + (typeof globalThis.indexedDB === "undefined" + ? undefined + : globalThis.indexedDB); + const keyRange = + dependencies.keyRange ?? + (typeof globalThis.IDBKeyRange === "undefined" + ? undefined + : globalThis.IDBKeyRange); + const now = dependencies.now ?? defaultNow; + const nowEpochMilliseconds = + dependencies.nowEpochMilliseconds ?? Date.now; + + function observe(event: IndexedDbObservation): void { + try { + dependencies.observe?.(Object.freeze(event)); + } catch { + // Maintenance correctness is independent from observation. + } + } + + function observeResult( + result: BrowserDataResult, + count = 0, + ): BrowserDataResult { + observe({ + operation: "INDEXEDDB_MIGRATE", + outcome: result.ok + ? "SUCCESS" + : result.error.code === "ABORTED" + ? "ABORTED" + : result.error.code === "BLOCKED" + ? "BLOCKED" + : "FAILED", + schemaVersion: dependencies.schemaVersion, + countBucket: countBucket(count), + ...(result.ok ? {} : { failureCode: result.error.code }), + }); + return result; + } + + function clock(): BrowserDataResult { + try { + const value = now(); + return Number.isFinite(value) + ? browserDataSuccess(value) + : invalidInput(); + } catch { + return unavailable(); + } + } + + function epochClock(): BrowserDataResult { + try { + const value = nowEpochMilliseconds(); + return Number.isSafeInteger(value) && value >= 0 + ? browserDataSuccess(value) + : invalidInput(); + } catch { + return unavailable(); + } + } + + function validBatchInput( + input: IndexedDbMaintenanceBatchInput, + ): boolean { + return ( + isPositiveInteger(input.maxRows) && + input.maxRows <= MAX_BATCH_ROWS && + isPositiveInteger(input.maxDurationMs) && + input.maxDurationMs <= MAX_BATCH_DURATION_MS + ); + } + + function openExactVersion( + signal: AbortSignal | undefined, + ): Promise> { + const cancelled = abortedResult(signal, "INDEXEDDB_MIGRATE"); + if (cancelled) return Promise.resolve(cancelled); + if (!factory) { + return Promise.resolve( + browserDataFailure("UNSUPPORTED", "INDEXEDDB_MIGRATE", { + recovery: "ONLINE_ONLY", + }), + ); + } + + return new Promise>((resolve) => { + let request: IDBOpenDBRequest; + try { + request = factory.open( + databaseName, + dependencies.schemaVersion, + ); + } catch (error) { + resolve(mapIndexedDbException(error, "INDEXEDDB_MIGRATE")); + return; + } + + let settled = false; + let unexpectedUpgrade = false; + const finish = (result: BrowserDataResult) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + function onAbort(): void { + try { + request.transaction?.abort(); + } catch { + // A pending non-upgrade open request cannot be cancelled. + } + finish(browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE")); + } + signal?.addEventListener("abort", onAbort, { once: true }); + + request.onupgradeneeded = () => { + unexpectedUpgrade = true; + try { + request.transaction?.abort(); + } catch { + // The error handler below owns the closed failure result. + } + }; + request.onblocked = () => { + finish( + browserDataFailure("BLOCKED", "INDEXEDDB_MIGRATE", { + retryable: true, + recovery: "RELOAD_OTHER_CONTEXTS", + }), + ); + }; + request.onerror = () => { + finish( + unexpectedUpgrade + ? migrationFailed() + : mapIndexedDbException( + request.error, + "INDEXEDDB_MIGRATE", + ), + ); + }; + request.onsuccess = () => { + const database = request.result; + if (settled) { + database.close(); + return; + } + if ( + !database.objectStoreNames.contains( + dependencies.recordStore, + ) || + !database.objectStoreNames.contains( + dependencies.governanceStore, + ) || + !database.objectStoreNames.contains( + dependencies.retentionStore, + ) || + !database.objectStoreNames.contains( + dependencies.checkpointStore, + ) || + !database.objectStoreNames.contains( + dependencies.idempotencyStore, + ) + ) { + database.close(); + finish(migrationFailed()); + return; + } + try { + const transaction = database.transaction( + dependencies.idempotencyStore, + "readonly", + ); + transaction + .objectStore(dependencies.idempotencyStore) + .index(dependencies.idempotencyExpiryIndex); + } catch { + database.close(); + finish(migrationFailed()); + return; + } + database.onversionchange = () => database.close(); + void (async () => { + const binding = await verifyIndexedDbDatasetBinding( + database, + dependencies.governanceStore, + expectedBinding, + signal, + ); + if (!binding.ok) { + database.close(); + if (!settled) { + finish( + binding.reason === "ABORTED" + ? browserDataFailure( + "ABORTED", + "INDEXEDDB_MIGRATE", + ) + : binding.reason === "NATIVE_ERROR" + ? mapIndexedDbException( + binding.error, + "INDEXEDDB_MIGRATE", + ) + : browserDataFailure( + "POLICY_REJECTED", + "INDEXEDDB_MIGRATE", + { + recovery: + storagePolicySnapshot.unavailableFallback, + }, + ), + ); + } + return; + } + if (settled) { + database.close(); + return; + } + finish(browserDataSuccess(database)); + })(); + }; + }); + } + + function createTransaction( + database: IDBDatabase, + stores: readonly string[], + mode: "readonly" | "readwrite", + ): IDBTransaction { + const durability = + mode === "readonly" + ? dependencies.durability?.read ?? "default" + : dependencies.durability?.write ?? "strict"; + try { + return database.transaction([...stores], mode, { durability }); + } catch (error) { + if (error instanceof TypeError) { + return database.transaction([...stores], mode); + } + throw error; + } + } + + function runTransaction( + database: IDBDatabase, + stores: readonly string[], + mode: "readonly" | "readwrite", + signal: AbortSignal | undefined, + queue: ( + transaction: IDBTransaction, + context: TransactionContext, + ) => void, + ): Promise> { + const cancelled = abortedResult(signal, "INDEXEDDB_MIGRATE"); + if (cancelled) return Promise.resolve(cancelled); + + let transaction: IDBTransaction; + try { + transaction = createTransaction(database, stores, mode); + } catch (error) { + return Promise.resolve( + mapIndexedDbException(error, "INDEXEDDB_MIGRATE"), + ); + } + + return new Promise>((resolve) => { + let candidate: BrowserDataResult | undefined; + let requestError: unknown; + let callerAborted = false; + let settled = false; + + const finish = (result: BrowserDataResult) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + const abortTransaction = () => { + try { + transaction.abort(); + } catch { + // Completion or a prior abort already owns the result. + } + }; + function onAbort(): void { + callerAborted = true; + abortTransaction(); + } + + transaction.oncomplete = () => { + finish(candidate ?? unavailable()); + }; + transaction.onerror = () => { + requestError ??= transaction.error; + }; + transaction.onabort = () => { + if (callerAborted) { + finish( + browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"), + ); + return; + } + if (candidate && !candidate.ok) { + finish(candidate); + return; + } + finish( + mapIndexedDbException( + requestError ?? transaction.error, + "INDEXEDDB_MIGRATE", + ), + ); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + const context: TransactionContext = Object.freeze({ + succeed(value) { + if (!candidate) candidate = browserDataSuccess(value); + }, + fail(result) { + if (!candidate) candidate = result; + abortTransaction(); + }, + requestFailed(error) { + requestError ??= error; + if (!candidate) { + candidate = mapIndexedDbException( + error, + "INDEXEDDB_MIGRATE", + ); + } + }, + }); + + try { + queue(transaction, context); + } catch (error) { + context.fail( + mapIndexedDbException(error, "INDEXEDDB_MIGRATE"), + ); + } + }); + } + + async function readCheckpoint( + database: IDBDatabase, + signal: AbortSignal | undefined, + ): Promise> { + return await runTransaction( + database, + [dependencies.checkpointStore], + "readonly", + signal, + (transaction, context) => { + const request = transaction + .objectStore(dependencies.checkpointStore) + .get(dependencies.checkpointKey); + request.onerror = () => context.requestFailed(request.error); + request.onsuccess = () => { + if (request.result === undefined) { + context.succeed({ + persisted: null, + effective: { lastKey: null, state: "MORE" }, + }); + return; + } + if ( + !isStoredCheckpoint( + request.result, + dependencies.checkpointKey, + ) + ) { + context.fail(migrationFailed()); + return; + } + const persisted = request.result; + const currentPolicy = + persisted.migrationId === + dependencies.migrationPolicy.migrationId && + persisted.targetCodecVersion === + dependencies.migrationPolicy.targetCodecVersion; + context.succeed({ + persisted, + effective: currentPolicy + ? { + lastKey: persisted.lastKey, + state: persisted.state, + } + : { lastKey: null, state: "MORE" }, + }); + }; + }, + ); + } + + async function scanBatch( + database: IDBDatabase, + checkpoint: CheckpointState, + input: IndexedDbMaintenanceBatchInput, + deadline: number, + ): Promise> { + return await runTransaction( + database, + [dependencies.recordStore], + "readonly", + input.signal, + (transaction, context) => { + let query: IDBKeyRange | undefined; + try { + query = + checkpoint.effective.lastKey === null + ? undefined + : keyRange?.lowerBound( + checkpoint.effective.lastKey, + true, + ); + } catch { + context.fail(invalidInput()); + return; + } + if (checkpoint.effective.lastKey !== null && !query) { + context.fail( + browserDataFailure( + "UNSUPPORTED", + "INDEXEDDB_MIGRATE", + { recovery: "ONLINE_ONLY" }, + ), + ); + return; + } + + const rows: ScannedRecord[] = []; + const request = transaction + .objectStore(dependencies.recordStore) + .openCursor(query); + request.onerror = () => context.requestFailed(request.error); + request.onsuccess = () => { + if (input.signal?.aborted) { + try { + transaction.abort(); + } catch { + // The transaction event decides the abort/complete race. + } + return; + } + const cursor = request.result; + if (!cursor) { + context.succeed({ + rows: Object.freeze(rows), + reachedEnd: true, + budgetExhausted: false, + }); + return; + } + const currentTime = clock(); + if (!currentTime.ok) { + context.fail(currentTime); + return; + } + if ( + rows.length >= input.maxRows || + currentTime.value >= deadline + ) { + context.succeed({ + rows: Object.freeze(rows), + reachedEnd: false, + budgetExhausted: + currentTime.value >= deadline, + }); + return; + } + if (!isStoredRecord(cursor.value)) { + context.fail(migrationFailed()); + return; + } + if ( + cursor.value.codecVersion > + dependencies.migrationPolicy.targetCodecVersion + ) { + context.fail(migrationFailed()); + return; + } + rows.push({ + key: cursor.value.key, + codecVersion: cursor.value.codecVersion, + revision: cursor.value.revision, + payload: cursor.value.payload, + }); + try { + cursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException(error, "INDEXEDDB_MIGRATE"), + ); + } + }; + }, + ); + } + + async function prepareRecords( + scan: ScanBatch, + input: IndexedDbMaintenanceBatchInput, + deadline: number, + ): Promise< + BrowserDataResult< + Readonly<{ + records: readonly PreparedRecord[]; + budgetExhausted: boolean; + }> + > + > { + const prepared: PreparedRecord[] = []; + let budgetExhausted = scan.budgetExhausted; + for (const source of scan.rows) { + const cancelled = abortedResult( + input.signal, + "INDEXEDDB_MIGRATE", + ); + if (cancelled) return cancelled; + const currentTime = clock(); + if (!currentTime.ok) return currentTime; + if (currentTime.value >= deadline) { + budgetExhausted = true; + break; + } + if ( + source.codecVersion === + dependencies.migrationPolicy.targetCodecVersion + ) { + prepared.push({ + source, + needsMigration: false, + migratedPayload: undefined, + measuredBytes: undefined, + }); + continue; + } + + let migrated; + try { + migrated = await dependencies.migrationPolicy.migrate({ + key: source.key, + fromCodecVersion: source.codecVersion, + payload: source.payload, + signal: input.signal, + }); + } catch { + return migrationFailed(); + } + if (input.signal?.aborted) { + return browserDataFailure( + "ABORTED", + "INDEXEDDB_MIGRATE", + ); + } + if (!migrated.ok) return migrationFailed(); + let migratedPayload: WireValue; + try { + // Keep the prepared batch independent from objects retained by an + // asynchronous migration policy and fail before opening a write + // transaction when the result is not structured-cloneable. + migratedPayload = structuredClone(migrated.value); + } catch { + return migrationFailed(); + } + let measuredPayloadBytes: number; + try { + measuredPayloadBytes = + dependencies.migrationPolicy.measureStoredBytes( + migratedPayload, + ); + } catch { + return migrationFailed(); + } + const measuredBytes = + measuredPayloadBytes + + RECORD_ENVELOPE_RESERVATION_BYTES + + source.key.length * 2; + if ( + !Number.isSafeInteger(measuredPayloadBytes) || + measuredPayloadBytes < 0 || + !Number.isSafeInteger(measuredBytes) || + measuredBytes < 1 || + measuredBytes > MAX_MEASURED_RECORD_BYTES + ) { + return migrationFailed(); + } + const afterMigration = clock(); + if (!afterMigration.ok) return afterMigration; + if (afterMigration.value >= deadline) { + budgetExhausted = true; + } + prepared.push({ + source, + needsMigration: true, + migratedPayload, + measuredBytes, + }); + } + return browserDataSuccess({ + records: Object.freeze(prepared), + budgetExhausted, + }); + } + + async function commitPrepared( + database: IDBDatabase, + checkpoint: CheckpointState, + prepared: readonly PreparedRecord[], + scan: ScanBatch, + budgetExhausted: boolean, + signal: AbortSignal | undefined, + ): Promise< + BrowserDataResult + > { + return await runTransaction( + database, + [ + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + dependencies.checkpointStore, + ], + "readwrite", + signal, + (transaction, context) => { + const records = transaction.objectStore( + dependencies.recordStore, + ); + const checkpoints = transaction.objectStore( + dependencies.checkpointStore, + ); + const governance = transaction.objectStore( + dependencies.governanceStore, + ); + const retention = transaction.objectStore( + dependencies.retentionStore, + ); + let migratedRows = 0; + let concurrentlyChangedRows = 0; + let safeRows = 0; + let lastSafeKey = checkpoint.effective.lastKey; + + const finishWithCheckpoint = () => { + const complete = + safeRows === prepared.length && + prepared.length === scan.rows.length && + scan.reachedEnd; + const stored: StoredCheckpoint = Object.freeze({ + checkpointKey: dependencies.checkpointKey, + migrationId: + dependencies.migrationPolicy.migrationId, + targetCodecVersion: + dependencies.migrationPolicy.targetCodecVersion, + lastKey: lastSafeKey, + state: complete ? "COMPLETE" : "MORE", + }); + let request: IDBRequest; + try { + request = checkpoints.put(stored); + } catch (error) { + context.fail( + mapIndexedDbException(error, "INDEXEDDB_MIGRATE"), + ); + return; + } + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => + context.succeed( + Object.freeze({ + state: stored.state, + scannedRows: prepared.length, + checkpointedRows: safeRows, + migratedRows, + concurrentlyChangedRows, + budgetExhausted, + }), + ); + }; + + const processRecord = (index: number) => { + const preparedRecord = prepared[index]; + if (!preparedRecord) { + finishWithCheckpoint(); + return; + } + let request: IDBRequest; + try { + request = records.get(preparedRecord.source.key); + } catch (error) { + context.fail( + mapIndexedDbException(error, "INDEXEDDB_MIGRATE"), + ); + return; + } + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => { + const live = request.result; + if (live === undefined) { + concurrentlyChangedRows += 1; + safeRows += 1; + lastSafeKey = preparedRecord.source.key; + processRecord(index + 1); + return; + } + if ( + !isStoredRecord( + live, + preparedRecord.source.key, + ) + ) { + context.fail(migrationFailed()); + return; + } + if ( + live.codecVersion === + dependencies.migrationPolicy.targetCodecVersion + ) { + if ( + live.codecVersion !== + preparedRecord.source.codecVersion || + live.revision !== preparedRecord.source.revision + ) { + concurrentlyChangedRows += 1; + } + safeRows += 1; + lastSafeKey = preparedRecord.source.key; + processRecord(index + 1); + return; + } + if ( + live.codecVersion !== + preparedRecord.source.codecVersion || + live.revision !== preparedRecord.source.revision + ) { + concurrentlyChangedRows += 1; + finishWithCheckpoint(); + return; + } + if ( + !preparedRecord.needsMigration || + preparedRecord.measuredBytes === undefined + ) { + context.fail(migrationFailed()); + return; + } + const measuredBytes = preparedRecord.measuredBytes; + const sidecarRequest = retention.get(live.key); + sidecarRequest.onerror = () => + context.requestFailed(sidecarRequest.error); + sidecarRequest.onsuccess = () => { + if ( + !isStoredRetentionRecord( + sidecarRequest.result, + live.key, + ) + ) { + context.fail(migrationFailed()); + return; + } + const previousSidecar = sidecarRequest.result; + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if ( + !isStoredDatasetBudget(budgetRequest.result) || + budgetRequest.result.usedBytes < + previousSidecar.measuredBytes + ) { + context.fail(migrationFailed()); + return; + } + const usedBytes = + budgetRequest.result.usedBytes - + previousSidecar.measuredBytes + + measuredBytes; + if ( + !Number.isSafeInteger(usedBytes) || + usedBytes < 0 || + usedBytes > + storagePolicySnapshot.hardBudgetBytes + ) { + context.fail(migrationFailed()); + return; + } + const writeRequest = records.put({ + key: live.key, + codecVersion: + dependencies.migrationPolicy + .targetCodecVersion, + revision: live.revision, + payload: preparedRecord.migratedPayload, + } satisfies StoredRecord); + writeRequest.onerror = () => + context.requestFailed(writeRequest.error); + writeRequest.onsuccess = () => { + const retentionRequest = retention.put({ + ...previousSidecar, + measuredBytes, + } satisfies StoredRetentionRecord); + retentionRequest.onerror = () => + context.requestFailed( + retentionRequest.error, + ); + retentionRequest.onsuccess = () => { + const budgetWrite = governance.put({ + ...budgetRequest.result, + usedBytes, + } satisfies StoredDatasetBudget); + budgetWrite.onerror = () => + context.requestFailed(budgetWrite.error); + budgetWrite.onsuccess = () => { + migratedRows += 1; + safeRows += 1; + lastSafeKey = preparedRecord.source.key; + processRecord(index + 1); + }; + }; + }; + }; + }; + }; + }; + + const checkpointRequest = checkpoints.get( + dependencies.checkpointKey, + ); + checkpointRequest.onerror = () => + context.requestFailed(checkpointRequest.error); + checkpointRequest.onsuccess = () => { + let liveCheckpoint: StoredCheckpoint | null = null; + if (checkpointRequest.result !== undefined) { + if ( + !isStoredCheckpoint( + checkpointRequest.result, + dependencies.checkpointKey, + ) + ) { + context.fail(migrationFailed()); + return; + } + liveCheckpoint = checkpointRequest.result; + } + if ( + !sameCheckpoint( + liveCheckpoint, + checkpoint.persisted, + ) + ) { + context.succeed( + Object.freeze({ + state: "MORE" as const, + scannedRows: prepared.length, + checkpointedRows: 0, + migratedRows: 0, + concurrentlyChangedRows: 1, + budgetExhausted, + }), + ); + return; + } + processRecord(0); + }; + }, + ); + } + + async function migrateCodecBatch( + sourceInput: IndexedDbMaintenanceBatchInput, + ): Promise< + BrowserDataResult + > { + if (!validBatchInput(sourceInput)) { + return observeResult(invalidInput()); + } + const input: IndexedDbMaintenanceBatchInput = Object.freeze({ + maxRows: sourceInput.maxRows, + maxDurationMs: sourceInput.maxDurationMs, + ...(sourceInput.signal ? { signal: sourceInput.signal } : {}), + }); + const started = clock(); + if (!started.ok) return observeResult(started); + const deadline = started.value + input.maxDurationMs; + if (!Number.isFinite(deadline)) { + return observeResult(invalidInput()); + } + let oldWriterDrainConfirmed: boolean; + try { + oldWriterDrainConfirmed = + await dependencies.migrationPolicy.isOldWriterDrainConfirmed( + input.signal, + ); + } catch { + return observeResult(unavailable()); + } + if (input.signal?.aborted) { + return observeResult( + browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"), + ); + } + if (oldWriterDrainConfirmed !== true) { + return observeResult( + browserDataFailure("BLOCKED", "INDEXEDDB_MIGRATE", { + retryable: true, + recovery: "RELOAD_OTHER_CONTEXTS", + }), + ); + } + const opened = await openExactVersion(input.signal); + if (!opened.ok) return observeResult(opened); + const database = opened.value; + + try { + const checkpoint = await readCheckpoint( + database, + input.signal, + ); + if (!checkpoint.ok) return observeResult(checkpoint); + if (checkpoint.value.effective.state === "COMPLETE") { + return observeResult( + browserDataSuccess( + Object.freeze({ + state: "COMPLETE", + scannedRows: 0, + checkpointedRows: 0, + migratedRows: 0, + concurrentlyChangedRows: 0, + budgetExhausted: false, + }), + ), + ); + } + + const scan = await scanBatch( + database, + checkpoint.value, + input, + deadline, + ); + if (!scan.ok) return observeResult(scan); + const prepared = await prepareRecords( + scan.value, + input, + deadline, + ); + if (!prepared.ok) return observeResult(prepared); + if ( + prepared.value.records.length === 0 && + !scan.value.reachedEnd + ) { + return observeResult( + browserDataSuccess( + Object.freeze({ + state: "MORE", + scannedRows: 0, + checkpointedRows: 0, + migratedRows: 0, + concurrentlyChangedRows: 0, + budgetExhausted: + prepared.value.budgetExhausted, + }), + ), + ); + } + + const committed = await commitPrepared( + database, + checkpoint.value, + prepared.value.records, + scan.value, + prepared.value.budgetExhausted, + input.signal, + ); + return observeResult( + committed, + committed.ok ? committed.value.migratedRows : 0, + ); + } finally { + database.close(); + } + } + + async function pruneExpiredReceipts( + sourceInput: IndexedDbMaintenanceBatchInput, + ): Promise< + BrowserDataResult + > { + if (!validBatchInput(sourceInput)) { + return observeResult(invalidInput()); + } + const input: IndexedDbMaintenanceBatchInput = Object.freeze({ + maxRows: sourceInput.maxRows, + maxDurationMs: sourceInput.maxDurationMs, + ...(sourceInput.signal ? { signal: sourceInput.signal } : {}), + }); + const started = clock(); + if (!started.ok) return observeResult(started); + const cutoff = epochClock(); + if (!cutoff.ok) return observeResult(cutoff); + const deadline = started.value + input.maxDurationMs; + if (!Number.isFinite(deadline)) { + return observeResult(invalidInput()); + } + const opened = await openExactVersion(input.signal); + if (!opened.ok) return observeResult(opened); + const database = opened.value; + + try { + const pruned = await runTransaction< + IndexedDbReceiptPruneBatchReceipt + >( + database, + [ + dependencies.governanceStore, + dependencies.idempotencyStore, + ], + "readwrite", + input.signal, + (transaction, context) => { + const store = transaction.objectStore( + dependencies.idempotencyStore, + ); + const governance = transaction.objectStore( + dependencies.governanceStore, + ); + let range: IDBKeyRange; + try { + if (!keyRange) { + context.fail( + browserDataFailure( + "UNSUPPORTED", + "INDEXEDDB_MIGRATE", + { recovery: "ONLINE_ONLY" }, + ), + ); + return; + } + range = keyRange.upperBound(cutoff.value); + } catch { + context.fail(invalidInput()); + return; + } + const request = store + .index(dependencies.idempotencyExpiryIndex) + .openCursor(range); + let scannedRows = 0; + let deletedRows = 0; + + const succeed = ( + state: "MORE" | "COMPLETE", + budgetExhausted: boolean, + ) => { + context.succeed( + Object.freeze({ + state, + scannedRows, + deletedRows, + budgetExhausted, + }), + ); + }; + + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => { + if (input.signal?.aborted) { + try { + transaction.abort(); + } catch { + // The transaction event owns the completion race. + } + return; + } + const cursor = request.result; + if (!cursor) { + succeed("COMPLETE", false); + return; + } + const currentTime = clock(); + if (!currentTime.ok) { + context.fail(currentTime); + return; + } + if ( + deletedRows >= input.maxRows || + currentTime.value >= deadline + ) { + succeed( + "MORE", + currentTime.value >= deadline, + ); + return; + } + if ( + !isStoredReceipt(cursor.value) || + cursor.value.idempotencyKey !== + String(cursor.primaryKey) || + cursor.value.expiresAtEpochMs > cutoff.value + ) { + context.fail(migrationFailed()); + return; + } + scannedRows += 1; + let deleteRequest: IDBRequest; + try { + deleteRequest = store.delete( + cursor.primaryKey, + ); + } catch (error) { + context.fail( + mapIndexedDbException( + error, + "INDEXEDDB_MIGRATE", + ), + ); + return; + } + deleteRequest.onerror = () => + context.requestFailed(deleteRequest.error); + deleteRequest.onsuccess = () => { + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if ( + !isStoredDatasetBudget(budgetRequest.result) || + budgetRequest.result.receiptCount < 1 + ) { + context.fail(migrationFailed()); + return; + } + const budgetWrite = governance.put({ + ...budgetRequest.result, + receiptCount: + budgetRequest.result.receiptCount - 1, + } satisfies StoredDatasetBudget); + budgetWrite.onerror = () => + context.requestFailed(budgetWrite.error); + budgetWrite.onsuccess = () => { + deletedRows += 1; + try { + cursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException( + error, + "INDEXEDDB_MIGRATE", + ), + ); + } + }; + }; + }; + }; + }, + ); + return observeResult( + pruned, + pruned.ok ? pruned.value.deletedRows : 0, + ); + } finally { + database.close(); + } + } + + return Object.freeze({ + migrateCodecBatch, + pruneExpiredReceipts, + }); +} diff --git a/src/adapters/storage/indexeddb/indexeddb-migrations.ts b/src/adapters/storage/indexeddb/indexeddb-migrations.ts new file mode 100644 index 0000000..e9ab266 --- /dev/null +++ b/src/adapters/storage/indexeddb/indexeddb-migrations.ts @@ -0,0 +1,208 @@ +import type { + IndexedDbIndexDefinition, + IndexedDbSchemaMigration, + IndexedDbSchemaOperation, +} from "./indexeddb-types.ts"; + +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/u; + +function invalidMigration(): never { + throw new DOMException("Invalid IndexedDB schema migration.", "InvalidStateError"); +} + +function validIdentifier(value: string): boolean { + return SAFE_IDENTIFIER.test(value); +} + +function validateIndex(index: IndexedDbIndexDefinition): void { + if ( + !validIdentifier(index.name) || + (typeof index.keyPath !== "string" && + (!Array.isArray(index.keyPath) || + index.keyPath.length === 0 || + !index.keyPath.every( + (entry) => typeof entry === "string" && entry.length > 0, + ))) || + (typeof index.keyPath === "string" && index.keyPath.length === 0) + ) { + invalidMigration(); + } +} + +function validateOperation(operation: IndexedDbSchemaOperation): void { + if (operation.kind === "CREATE_STORE") { + if ( + !validIdentifier(operation.name) || + operation.keyPath.length === 0 || + operation.indexes?.some((index) => { + try { + validateIndex(index); + return false; + } catch { + return true; + } + }) + ) { + invalidMigration(); + } + return; + } + + if ( + operation.kind !== "CREATE_INDEX" || + !validIdentifier(operation.store) + ) { + invalidMigration(); + } + validateIndex(operation.index); +} + +export function validateIndexedDbMigrations( + schemaVersion: number, + migrations: readonly IndexedDbSchemaMigration[], +): void { + if ( + !Number.isSafeInteger(schemaVersion) || + schemaVersion < 1 || + migrations.length !== schemaVersion + ) { + invalidMigration(); + } + + const ids = new Set(); + for (let index = 0; index < migrations.length; index += 1) { + const migration = migrations[index]; + if ( + !migration || + !validIdentifier(migration.id) || + ids.has(migration.id) || + migration.fromVersion !== index || + migration.toVersion !== index + 1 + ) { + invalidMigration(); + } + ids.add(migration.id); + migration.operations.forEach(validateOperation); + } +} + +function createIndex( + store: IDBObjectStore, + index: IndexedDbIndexDefinition, +): void { + if (store.indexNames.contains(index.name)) invalidMigration(); + store.createIndex( + index.name, + Array.isArray(index.keyPath) ? [...index.keyPath] : index.keyPath, + { + unique: index.unique ?? false, + multiEntry: index.multiEntry ?? false, + }, + ); +} + +function applyOperation( + db: IDBDatabase, + transaction: IDBTransaction, + operation: IndexedDbSchemaOperation, +): void { + switch (operation.kind) { + case "CREATE_STORE": { + if (db.objectStoreNames.contains(operation.name)) invalidMigration(); + const store = db.createObjectStore(operation.name, { + keyPath: operation.keyPath, + autoIncrement: operation.autoIncrement ?? false, + }); + for (const index of operation.indexes ?? []) createIndex(store, index); + return; + } + case "CREATE_INDEX": { + if (!db.objectStoreNames.contains(operation.store)) invalidMigration(); + createIndex(transaction.objectStore(operation.store), operation.index); + return; + } + } +} + +export function applyIndexedDbMigrations( + db: IDBDatabase, + transaction: IDBTransaction, + oldVersion: number, + newVersion: number, + migrations: readonly IndexedDbSchemaMigration[], +): number { + if ( + !Number.isSafeInteger(oldVersion) || + !Number.isSafeInteger(newVersion) || + oldVersion < 0 || + newVersion <= oldVersion || + newVersion > migrations.length + ) { + invalidMigration(); + } + + let applied = 0; + for (let version = oldVersion + 1; version <= newVersion; version += 1) { + const migration = migrations[version - 1]; + if ( + !migration || + migration.fromVersion !== version - 1 || + migration.toVersion !== version + ) { + invalidMigration(); + } + for (const operation of migration.operations) { + applyOperation(db, transaction, operation); + } + applied += 1; + } + return applied; +} + +export function assertIndexedDbRuntimeStores( + db: IDBDatabase, + recordStore: string, + governanceStore: string, + retentionStore: string, + retentionEligibilityIndex: string, + lifecycleMetadataStores: readonly string[], + idempotencyStore: string, + idempotencyExpiryIndex: string, +): void { + if ( + new Set([ + recordStore, + governanceStore, + retentionStore, + idempotencyStore, + ...lifecycleMetadataStores, + ]).size !== 4 + lifecycleMetadataStores.length || + !validIdentifier(recordStore) || + !validIdentifier(governanceStore) || + !validIdentifier(retentionStore) || + !validIdentifier(retentionEligibilityIndex) || + lifecycleMetadataStores.some( + (store) => + !validIdentifier(store) || + !db.objectStoreNames.contains(store), + ) || + !validIdentifier(idempotencyStore) || + !validIdentifier(idempotencyExpiryIndex) || + !db.objectStoreNames.contains(recordStore) || + !db.objectStoreNames.contains(governanceStore) || + !db.objectStoreNames.contains(retentionStore) || + !db.objectStoreNames.contains(idempotencyStore) + ) { + invalidMigration(); + } + const transaction = db.transaction( + [retentionStore, idempotencyStore], + "readonly", + ); + transaction + .objectStore(retentionStore) + .index(retentionEligibilityIndex); + transaction + .objectStore(idempotencyStore) + .index(idempotencyExpiryIndex); +} diff --git a/src/adapters/storage/indexeddb/indexeddb-runtime.ts b/src/adapters/storage/indexeddb/indexeddb-runtime.ts new file mode 100644 index 0000000..3d29f99 --- /dev/null +++ b/src/adapters/storage/indexeddb/indexeddb-runtime.ts @@ -0,0 +1,2902 @@ +import type { + IndexedDbCompareAndSwapInput, + IndexedDbConnectionStatus, + IndexedDbCursor, + IndexedDbCursorKey, + IndexedDbDeleteInput, + IndexedDbLifecycleBatchInput, + IndexedDbLifecycleBatchReceipt, + IndexedDbPage, + IndexedDbRepositoryPort, + IndexedDbSynchronizationState, + IndexedDbWriteReceipt, +} from "../../../application/ports/browser-file-storage/indexeddb-port.ts"; +import type { + BrowserDataOperation, + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { mapIndexedDbException } from "./indexeddb-failure.ts"; +import { + createIndexedDbDatasetBinding, + INDEXEDDB_DATASET_BUDGET_KEY, + indexedDbPhysicalDatabaseName, + queueIndexedDbUpgradeBinding, + verifyIndexedDbDatasetBinding, +} from "./indexeddb-governance.ts"; +import { + applyIndexedDbMigrations, + assertIndexedDbRuntimeStores, + validateIndexedDbMigrations, +} from "./indexeddb-migrations.ts"; +import type { + IndexedDbCountBucket, + IndexedDbKeyRangePlan, + IndexedDbObservation, + IndexedDbQueryPlan, + IndexedDbRuntimeDependencies, + IndexedDbScheduler, +} from "./indexeddb-types.ts"; + +type StoredRecord = Readonly<{ + key: string; + codecVersion: number; + revision: number; + payload: unknown; +}>; + +type StoredIdempotencyReceipt = Readonly<{ + idempotencyKey: string; + operation: "PUT" | "DELETE"; + recordKey: string; + expectedRevision: number | null; + fingerprint: string; + synchronization: IndexedDbSynchronizationState | "NONE"; + revision: number; + expiresAtEpochMs: number; +}>; + +type StoredRetentionRecord = Readonly<{ + recordKey: string; + writtenAtEpochMs: number; + synchronization: IndexedDbSynchronizationState | "NONE"; + measuredBytes: number; + eligibleAtEpochMs?: number; +}>; + +type StoredDatasetBudget = Readonly<{ + bindingKey: typeof INDEXEDDB_DATASET_BUDGET_KEY; + budgetVersion: 1; + usedBytes: number; + receiptCount: number; +}>; + +type ReceiptLookup = + | Readonly<{ kind: "MISSING" | "EXPIRED" }> + | Readonly<{ + kind: "RESULT"; + result: BrowserDataResult; + }>; + +type TransactionContext = Readonly<{ + succeed(value: Value): void; + fail(result: BrowserDataResult): void; + requestFailed(error: unknown): void; +}>; + +const SAFE_DATABASE_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/u; +const OPAQUE_SHA256_FINGERPRINT = /^[a-f0-9]{64}$/u; +const DELETE_OPERATION_FINGERPRINT = + "65daeb378bdec35d90224389f6e10917bcc0b94c90c489014ba156dd6cbf9350"; +const MAX_QUERY_LIMIT = 1_000; +const MAX_CURSOR_KEY_DEPTH = 8; +const MAX_CURSOR_KEY_ELEMENTS = 64; +const MAX_CURSOR_KEY_BYTES = 4_096; +const MAX_RECEIPT_RETENTION_MS = 31 * 24 * 60 * 60 * 1_000; +const MAX_LIFECYCLE_ROWS = 500; +const MAX_LIFECYCLE_DURATION_MS = 30_000; +const OPAQUE_AUTHORITY_PROOF = /^[A-Za-z0-9_-]{16,128}$/u; +const RECORD_ENVELOPE_RESERVATION_BYTES = 512; +const MAX_MEASURED_RECORD_BYTES = 2_147_483_647; +const MAX_QUERY_SCANNED_ROWS = 5_000; +const MAX_IDEMPOTENCY_RECEIPTS = 1_000_000; + +function defaultScheduler(): IndexedDbScheduler { + return Object.freeze({ + setTimeout: (callback, milliseconds) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle) => + globalThis.clearTimeout( + handle as ReturnType, + ), + }); +} + +function countBucket(count: number): IndexedDbCountBucket { + if (count <= 0) return "0"; + if (count === 1) return "1"; + if (count <= 10) return "2-10"; + if (count <= 100) return "11-100"; + return "101+"; +} + +function isValidBoundaryIdentifier(value: string): boolean { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 200 + ); +} + +function isRevision(value: unknown): value is number { + return Number.isSafeInteger(value) && typeof value === "number" && value >= 1; +} + +function isBoundedCursorKey(value: unknown): value is IDBValidKey { + const pending: Array> = [ + { value, depth: 0 }, + ]; + let elements = 0; + let bytes = 0; + try { + while (pending.length > 0) { + const current = pending.pop(); + if (!current || current.depth > MAX_CURSOR_KEY_DEPTH) { + return false; + } + if (Array.isArray(current.value)) { + elements += current.value.length; + if (elements > MAX_CURSOR_KEY_ELEMENTS) return false; + for ( + let index = current.value.length - 1; + index >= 0; + index -= 1 + ) { + pending.push({ + value: current.value[index], + depth: current.depth + 1, + }); + } + continue; + } + if (typeof current.value === "string") { + bytes += current.value.length * 2; + } else if (typeof current.value === "number") { + if (!Number.isFinite(current.value)) return false; + bytes += 8; + } else if (current.value instanceof Date) { + if (!Number.isFinite(current.value.getTime())) return false; + bytes += 8; + } else if (current.value instanceof ArrayBuffer) { + bytes += current.value.byteLength; + } else { + return false; + } + if (bytes > MAX_CURSOR_KEY_BYTES) return false; + } + } catch { + return false; + } + return true; +} + +function isBoundedRangePlan( + value: unknown, +): value is IndexedDbKeyRangePlan { + if (!value || typeof value !== "object" || !("kind" in value)) { + return false; + } + const plan = value as Partial; + switch (plan.kind) { + case "ONLY": + return isBoundedCursorKey(plan.value); + case "LOWER": + return ( + isBoundedCursorKey(plan.lower) && + (plan.open === undefined || + typeof plan.open === "boolean") + ); + case "UPPER": + return ( + isBoundedCursorKey(plan.upper) && + (plan.open === undefined || + typeof plan.open === "boolean") + ); + case "BOUND": + return ( + isBoundedCursorKey(plan.lower) && + isBoundedCursorKey(plan.upper) && + (plan.lowerOpen === undefined || + typeof plan.lowerOpen === "boolean") && + (plan.upperOpen === undefined || + typeof plan.upperOpen === "boolean") + ); + default: + return false; + } +} + +function isStoredRecord(value: unknown, expectedKey?: string): value is StoredRecord { + if (!value || typeof value !== "object") return false; + const record = value as Partial; + return ( + typeof record.key === "string" && + isValidBoundaryIdentifier(record.key) && + (expectedKey === undefined || record.key === expectedKey) && + isRevision(record.codecVersion) && + isRevision(record.revision) && + Object.hasOwn(record, "payload") + ); +} + +function isStoredReceipt(value: unknown): value is StoredIdempotencyReceipt { + if (!value || typeof value !== "object") return false; + const receipt = value as Partial; + return ( + typeof receipt.idempotencyKey === "string" && + isValidBoundaryIdentifier(receipt.idempotencyKey) && + (receipt.operation === "PUT" || receipt.operation === "DELETE") && + typeof receipt.recordKey === "string" && + isValidBoundaryIdentifier(receipt.recordKey) && + (receipt.expectedRevision === null || + isRevision(receipt.expectedRevision)) && + typeof receipt.fingerprint === "string" && + OPAQUE_SHA256_FINGERPRINT.test(receipt.fingerprint) && + (receipt.synchronization === "NONE" || + receipt.synchronization === "PENDING" || + receipt.synchronization === "CONFIRMED") && + isRevision(receipt.revision) && + typeof receipt.expiresAtEpochMs === "number" && + Number.isSafeInteger(receipt.expiresAtEpochMs) && + receipt.expiresAtEpochMs >= 0 + ); +} + +function isStoredRetentionRecord( + value: unknown, + expectedKey?: string, +): value is StoredRetentionRecord { + if (!value || typeof value !== "object") return false; + const record = value as Partial; + return ( + typeof record.recordKey === "string" && + isValidBoundaryIdentifier(record.recordKey) && + (expectedKey === undefined || record.recordKey === expectedKey) && + typeof record.writtenAtEpochMs === "number" && + Number.isSafeInteger(record.writtenAtEpochMs) && + record.writtenAtEpochMs >= 0 && + (record.synchronization === "NONE" || + record.synchronization === "PENDING" || + record.synchronization === "CONFIRMED") && + typeof record.measuredBytes === "number" && + Number.isSafeInteger(record.measuredBytes) && + record.measuredBytes >= 1 && + record.measuredBytes <= MAX_MEASURED_RECORD_BYTES && + (record.eligibleAtEpochMs === undefined || + (typeof record.eligibleAtEpochMs === "number" && + Number.isSafeInteger(record.eligibleAtEpochMs) && + record.eligibleAtEpochMs >= record.writtenAtEpochMs)) + ); +} + +function isStoredDatasetBudget( + value: unknown, +): value is StoredDatasetBudget { + if (!value || typeof value !== "object") return false; + const budget = value as Partial; + return ( + budget.bindingKey === INDEXEDDB_DATASET_BUDGET_KEY && + budget.budgetVersion === 1 && + typeof budget.usedBytes === "number" && + Number.isSafeInteger(budget.usedBytes) && + budget.usedBytes >= 0 && + typeof budget.receiptCount === "number" && + Number.isSafeInteger(budget.receiptCount) && + budget.receiptCount >= 0 + ); +} + +function normalizeCursorKey(value: IDBValidKey): IndexedDbCursorKey { + if (Array.isArray(value)) { + return Object.freeze(value.map((entry) => normalizeCursorKey(entry))); + } + if (value instanceof Date) return new Date(value.getTime()); + if (value instanceof ArrayBuffer) return value.slice(0); + if (ArrayBuffer.isView(value)) { + return value.buffer.slice( + value.byteOffset, + value.byteOffset + value.byteLength, + ) as ArrayBuffer; + } + return value; +} + +function nativeCursorKey(value: IndexedDbCursorKey): IDBValidKey { + if (Array.isArray(value)) { + return Array.from(value, (entry) => nativeCursorKey(entry)); + } + if (value instanceof Date) return new Date(value.getTime()); + if (value instanceof ArrayBuffer) return value.slice(0); + return value as string | number; +} + +function cursorAfter( + factory: IDBFactory, + current: IndexedDbCursor, + previous: IndexedDbCursor, + direction: "next" | "prev", +): boolean { + const indexComparison = factory.cmp( + nativeCursorKey(current.indexKey), + nativeCursorKey(previous.indexKey), + ); + const comparison = + indexComparison !== 0 + ? indexComparison + : factory.cmp( + nativeCursorKey(current.primaryKey), + nativeCursorKey(previous.primaryKey), + ); + return direction === "next" ? comparison > 0 : comparison < 0; +} + +function rangeFromPlan( + rangeFactory: Pick< + typeof IDBKeyRange, + "only" | "lowerBound" | "upperBound" | "bound" + >, + plan: IndexedDbKeyRangePlan, +): IDBKeyRange { + switch (plan.kind) { + case "ONLY": + return rangeFactory.only(nativeCursorKey(plan.value)); + case "LOWER": + return rangeFactory.lowerBound( + nativeCursorKey(plan.lower), + plan.open ?? false, + ); + case "UPPER": + return rangeFactory.upperBound( + nativeCursorKey(plan.upper), + plan.open ?? false, + ); + case "BOUND": + return rangeFactory.bound( + nativeCursorKey(plan.lower), + nativeCursorKey(plan.upper), + plan.lowerOpen ?? false, + plan.upperOpen ?? false, + ); + } +} + +function readwriteConflict( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("CONFLICT", operation); +} + +function corruptData( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("CORRUPT_DATA", operation, { + recovery: "READ_ONLY", + }); +} + +function invalidInput( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("INVALID_INPUT", operation); +} + +function unavailable( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "REOPEN", + }); +} + +export function createIndexedDbRuntime( + inputDependencies: IndexedDbRuntimeDependencies, +): IndexedDbRepositoryPort { + const expectedBinding = createIndexedDbDatasetBinding( + inputDependencies.scope, + inputDependencies.storagePolicy, + ); + const scopeSnapshot = expectedBinding.scope; + const storagePolicySnapshot = expectedBinding.storagePolicy; + validateIndexedDbMigrations( + inputDependencies.schemaVersion, + inputDependencies.migrations, + ); + const codecSource = inputDependencies.codec; + const queryPolicySource = inputDependencies.queryPolicy; + const authorizeLifecycleSource = + inputDependencies.authorizeLifecycle; + const dependencies: IndexedDbRuntimeDependencies< + Value, + WireValue, + Query + > = Object.freeze({ + ...inputDependencies, + scope: scopeSnapshot, + storagePolicy: storagePolicySnapshot, + migrations: Object.freeze( + inputDependencies.migrations.map((migration) => + Object.freeze({ + ...migration, + operations: Object.freeze( + migration.operations.map((operation) => + operation.kind === "CREATE_STORE" + ? Object.freeze({ + ...operation, + indexes: operation.indexes + ? Object.freeze( + operation.indexes.map((index) => + Object.freeze({ + ...index, + keyPath: Array.isArray(index.keyPath) + ? Object.freeze([...index.keyPath]) + : index.keyPath, + }), + ), + ) + : undefined, + }) + : Object.freeze({ + ...operation, + index: Object.freeze({ + ...operation.index, + keyPath: Array.isArray( + operation.index.keyPath, + ) + ? Object.freeze([ + ...operation.index.keyPath, + ]) + : operation.index.keyPath, + }), + }), + ), + ), + }), + ), + ), + lifecycleMetadataStores: Object.freeze([ + ...inputDependencies.lifecycleMetadataStores, + ]), + codec: Object.freeze({ + currentVersion: codecSource.currentVersion, + encode: codecSource.encode.bind(codecSource), + decode: codecSource.decode.bind(codecSource), + fingerprint: codecSource.fingerprint.bind(codecSource), + measureStoredBytes: + codecSource.measureStoredBytes.bind(codecSource), + }), + queryPolicy: Object.freeze({ + plan: queryPolicySource.plan.bind(queryPolicySource), + }), + authorizeLifecycle: (request) => + authorizeLifecycleSource(request), + ...(inputDependencies.keyRange + ? { + keyRange: Object.freeze({ + only: inputDependencies.keyRange.only.bind( + inputDependencies.keyRange, + ), + lowerBound: + inputDependencies.keyRange.lowerBound.bind( + inputDependencies.keyRange, + ), + upperBound: + inputDependencies.keyRange.upperBound.bind( + inputDependencies.keyRange, + ), + bound: inputDependencies.keyRange.bound.bind( + inputDependencies.keyRange, + ), + }), + } + : {}), + ...(inputDependencies.durability + ? { + durability: Object.freeze({ + ...inputDependencies.durability, + }), + } + : {}), + ...(inputDependencies.scheduler + ? { + scheduler: Object.freeze({ + setTimeout: + inputDependencies.scheduler.setTimeout.bind( + inputDependencies.scheduler, + ), + clearTimeout: + inputDependencies.scheduler.clearTimeout.bind( + inputDependencies.scheduler, + ), + }), + } + : {}), + }); + const databaseName = indexedDbPhysicalDatabaseName(scopeSnapshot); + if ( + (dependencies.databaseNameAssertion !== undefined && + dependencies.databaseNameAssertion !== databaseName) || + !SAFE_DATABASE_NAME.test(dependencies.recordStore) || + !SAFE_DATABASE_NAME.test(dependencies.governanceStore) || + !SAFE_DATABASE_NAME.test(dependencies.retentionStore) || + !SAFE_DATABASE_NAME.test(dependencies.retentionEligibilityIndex) || + dependencies.lifecycleMetadataStores.some( + (store) => !SAFE_DATABASE_NAME.test(store), + ) || + !SAFE_DATABASE_NAME.test(dependencies.idempotencyStore) || + !SAFE_DATABASE_NAME.test(dependencies.idempotencyExpiryIndex) || + new Set([ + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + dependencies.idempotencyStore, + ...dependencies.lifecycleMetadataStores, + ]).size !== 4 + dependencies.lifecycleMetadataStores.length || + typeof dependencies.authorizeLifecycle !== "function" || + !Number.isSafeInteger(dependencies.receiptRetentionMs) || + dependencies.receiptRetentionMs < 1 || + dependencies.receiptRetentionMs > MAX_RECEIPT_RETENTION_MS || + !Number.isSafeInteger(dependencies.maxIdempotencyReceipts) || + dependencies.maxIdempotencyReceipts < 1 || + dependencies.maxIdempotencyReceipts > + MAX_IDEMPOTENCY_RECEIPTS || + !Number.isSafeInteger(dependencies.codec.currentVersion) || + dependencies.codec.currentVersion < 1 || + !Number.isSafeInteger(dependencies.blockedTimeoutMs ?? 10_000) || + (dependencies.blockedTimeoutMs ?? 10_000) < 0 + ) { + throw new TypeError("IndexedDB runtime configuration is invalid."); + } + + const factory = + dependencies.factory ?? + (typeof globalThis.indexedDB === "undefined" + ? undefined + : globalThis.indexedDB); + const keyRange = + dependencies.keyRange ?? + (typeof globalThis.IDBKeyRange === "undefined" + ? undefined + : globalThis.IDBKeyRange); + const scheduler = dependencies.scheduler ?? defaultScheduler(); + const blockedTimeoutMs = dependencies.blockedTimeoutMs ?? 10_000; + const nowEpochMilliseconds = + dependencies.nowEpochMilliseconds ?? Date.now; + const nowMonotonicMilliseconds = + dependencies.nowMonotonicMilliseconds ?? + (() => + typeof globalThis.performance === "undefined" + ? Date.now() + : globalThis.performance.now()); + const subscribers = new Set< + (status: IndexedDbConnectionStatus) => void + >(); + let status: IndexedDbConnectionStatus = Object.freeze({ + kind: "CLOSED", + reason: "NOT_OPENED", + }); + let connection: IDBDatabase | null = null; + let openingRequest: IDBOpenDBRequest | null = null; + let openingPromise: Promise> | null = null; + let activeOpeningGeneration: object | null = null; + let cancelPendingOpen: (() => void) | null = null; + let disposed = false; + + function observe(event: IndexedDbObservation): void { + try { + dependencies.observe?.(Object.freeze(event)); + } catch { + // Observation cannot alter storage behavior. + } + } + + function observeResult( + operation: BrowserDataOperation, + result: BrowserDataResult, + count = 0, + ): BrowserDataResult { + observe({ + operation, + outcome: result.ok + ? "SUCCESS" + : result.error.code === "ABORTED" + ? "ABORTED" + : "FAILED", + schemaVersion: dependencies.schemaVersion, + countBucket: countBucket(count), + ...(result.ok ? {} : { failureCode: result.error.code }), + }); + return result; + } + + function updateStatus(next: IndexedDbConnectionStatus): void { + status = Object.freeze(next); + for (const subscriber of subscribers) { + try { + subscriber(status); + } catch { + // A status listener cannot alter connection lifecycle. + } + } + } + + function handleVersionChange(db: IDBDatabase): void { + if (connection !== db || disposed) return; + connection = null; + db.close(); + const next = Object.freeze({ + kind: "CLOSED" as const, + reason: "VERSION_CHANGE" as const, + }); + updateStatus(next); + try { + dependencies.onVersionChange?.(next); + } catch { + // Connection closure is independent from the notification callback. + } + } + + function handleForcedClose(db: IDBDatabase): void { + if (connection !== db || disposed) return; + connection = null; + updateStatus({ kind: "CLOSED", reason: "FORCED" }); + } + + function waitForOpeningAttempt( + attempt: Promise>, + signal: AbortSignal | undefined, + ): Promise> { + const operation = "INDEXEDDB_OPEN" as const; + const cancelled = abortedResult(signal, operation); + if (cancelled) return Promise.resolve(cancelled); + + return new Promise>((resolve) => { + let callerSettled = false; + const finishCaller = (result: BrowserDataResult) => { + if (callerSettled) return; + callerSettled = true; + signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + function onAbort(): void { + finishCaller(browserDataFailure("ABORTED", operation)); + } + + signal?.addEventListener("abort", onAbort, { once: true }); + void attempt.then(finishCaller, () => finishCaller(unavailable(operation))); + }); + } + + function startOpeningAttempt( + availableFactory: IDBFactory, + generation: object, + ): Promise> { + const operation = "INDEXEDDB_OPEN" as const; + + return new Promise>((resolve) => { + const settleNativeRequest = () => { + if (activeOpeningGeneration !== generation) return; + activeOpeningGeneration = null; + openingRequest = null; + openingPromise = null; + cancelPendingOpen = null; + }; + let request: IDBOpenDBRequest; + try { + request = availableFactory.open( + databaseName, + dependencies.schemaVersion, + ); + } catch (error) { + const result = mapIndexedDbException(error, operation); + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + resolve(result); + queueMicrotask(settleNativeRequest); + return; + } + + openingRequest = request; + let requestSettled = false; + let migrationFailed = false; + let policyBindingRejected = false; + let appliedMigrations = 0; + let blockedTimer: unknown; + + const clearBlockedTimer = () => { + if (blockedTimer !== undefined) { + scheduler.clearTimeout(blockedTimer); + blockedTimer = undefined; + } + }; + const finishOpeningAttempt = (result: BrowserDataResult) => { + if (requestSettled) return; + requestSettled = true; + clearBlockedTimer(); + resolve(result); + }; + const settleLateRequest = () => { + settleNativeRequest(); + }; + const abortUpgrade = () => { + try { + request.transaction?.abort(); + } catch { + // An open request cannot otherwise be cancelled. + } + }; + cancelPendingOpen = () => { + abortUpgrade(); + finishOpeningAttempt(unavailable(operation)); + }; + + request.onupgradeneeded = (event) => { + const transaction = request.transaction; + if (!transaction || event.newVersion === null) { + migrationFailed = true; + abortUpgrade(); + return; + } + try { + appliedMigrations = applyIndexedDbMigrations( + request.result, + transaction, + event.oldVersion, + event.newVersion, + dependencies.migrations, + ); + queueIndexedDbUpgradeBinding( + transaction, + dependencies.governanceStore, + expectedBinding, + event.oldVersion, + () => { + policyBindingRejected = true; + }, + ); + } catch { + migrationFailed = true; + abortUpgrade(); + } + }; + + request.onblocked = (event) => { + updateStatus({ + kind: "BLOCKED", + currentVersion: event.oldVersion, + targetVersion: event.newVersion ?? dependencies.schemaVersion, + }); + observe({ + operation, + outcome: "BLOCKED", + schemaVersion: dependencies.schemaVersion, + countBucket: "0", + failureCode: "BLOCKED", + }); + if (blockedTimer === undefined) { + blockedTimer = scheduler.setTimeout(() => { + finishOpeningAttempt( + browserDataFailure("BLOCKED", operation, { + retryable: true, + recovery: "RELOAD_OTHER_CONTEXTS", + }), + ); + }, blockedTimeoutMs); + } + }; + + request.onerror = () => { + settleLateRequest(); + try { + request.result.close(); + } catch { + // Failed native open requests do not expose a result. + } + if (!disposed) { + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + } + if (migrationFailed) { + const result = browserDataFailure( + "MIGRATION_FAILED", + "INDEXEDDB_MIGRATE", + { recovery: "READ_ONLY" }, + ); + observeResult("INDEXEDDB_MIGRATE", result); + finishOpeningAttempt(result); + return; + } + if (policyBindingRejected) { + finishOpeningAttempt( + browserDataFailure("POLICY_REJECTED", operation, { + recovery: storagePolicySnapshot.unavailableFallback, + }), + ); + return; + } + finishOpeningAttempt(mapIndexedDbException(request.error, operation)); + }; + + request.onsuccess = () => { + const opened = request.result; + void (async () => { + if (requestSettled || disposed) { + settleLateRequest(); + opened.close(); + if (!disposed) { + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + } + return; + } + try { + assertIndexedDbRuntimeStores( + opened, + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + dependencies.retentionEligibilityIndex, + dependencies.lifecycleMetadataStores, + dependencies.idempotencyStore, + dependencies.idempotencyExpiryIndex, + ); + } catch (error) { + settleLateRequest(); + opened.close(); + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + const result = mapIndexedDbException( + error, + "INDEXEDDB_MIGRATE", + ); + observeResult("INDEXEDDB_MIGRATE", result); + finishOpeningAttempt(result); + return; + } + const binding = await verifyIndexedDbDatasetBinding( + opened, + dependencies.governanceStore, + expectedBinding, + undefined, + ); + settleLateRequest(); + if (!binding.ok) { + opened.close(); + if (!disposed) { + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + } + if (!requestSettled) { + finishOpeningAttempt( + binding.reason === "ABORTED" + ? browserDataFailure("ABORTED", operation) + : binding.reason === "NATIVE_ERROR" + ? mapIndexedDbException(binding.error, operation) + : browserDataFailure( + "POLICY_REJECTED", + operation, + { + recovery: + storagePolicySnapshot.unavailableFallback, + }, + ), + ); + } + return; + } + if (requestSettled || disposed) { + opened.close(); + if (!disposed) { + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + } + return; + } + connection = opened; + opened.onversionchange = () => handleVersionChange(opened); + opened.onclose = () => handleForcedClose(opened); + updateStatus({ + kind: "READY", + schemaVersion: opened.version, + }); + if (appliedMigrations > 0) { + observeResult( + "INDEXEDDB_MIGRATE", + browserDataSuccess(undefined), + appliedMigrations, + ); + } + finishOpeningAttempt(browserDataSuccess(undefined)); + })(); + }; + }); + } + + async function open( + signal?: AbortSignal, + ): Promise> { + const operation = "INDEXEDDB_OPEN" as const; + if (disposed) return observeResult(operation, unavailable(operation)); + if (connection) { + return observeResult(operation, browserDataSuccess(undefined)); + } + const cancelled = abortedResult(signal, operation); + if (cancelled) return observeResult(operation, cancelled); + if (!factory) { + return observeResult( + operation, + browserDataFailure("UNSUPPORTED", operation, { + recovery: "ONLINE_ONLY", + }), + ); + } + + if (!openingPromise) { + updateStatus({ + kind: "OPENING", + targetVersion: dependencies.schemaVersion, + }); + const generation = {}; + activeOpeningGeneration = generation; + const attempt = startOpeningAttempt(factory, generation); + openingPromise = attempt; + } + + const result = await waitForOpeningAttempt(openingPromise, signal); + return observeResult(operation, result); + } + + function createTransaction( + db: IDBDatabase, + stores: readonly string[], + mode: IDBTransactionMode, + ): IDBTransaction { + const durability = + mode === "readonly" + ? dependencies.durability?.read ?? "default" + : dependencies.durability?.write ?? "strict"; + try { + return db.transaction([...stores], mode, { durability }); + } catch (error) { + if (error instanceof TypeError) { + return db.transaction([...stores], mode); + } + throw error; + } + } + + function runTransaction( + stores: readonly string[], + mode: "readonly" | "readwrite", + operation: BrowserDataOperation, + signal: AbortSignal | undefined, + queue: ( + transaction: IDBTransaction, + context: TransactionContext, + ) => void, + ): Promise> { + const cancelled = abortedResult(signal, operation); + if (cancelled) return Promise.resolve(cancelled); + const db = connection; + if (!db || disposed) return Promise.resolve(unavailable(operation)); + + let transaction: IDBTransaction; + try { + transaction = createTransaction(db, stores, mode); + } catch (error) { + return Promise.resolve(mapIndexedDbException(error, operation)); + } + + return new Promise>((resolve) => { + let candidate: BrowserDataResult | undefined; + let requestError: unknown; + let callerAborted = false; + let settled = false; + + const finish = (result: BrowserDataResult) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + function onAbort(): void { + callerAborted = true; + try { + transaction.abort(); + } catch { + // If complete won the race, its result remains authoritative. + } + } + + transaction.oncomplete = () => { + finish(candidate ?? unavailable(operation)); + }; + transaction.onerror = () => { + requestError ??= transaction.error; + }; + transaction.onabort = () => { + if (callerAborted) { + finish(browserDataFailure("ABORTED", operation)); + return; + } + if (candidate && !candidate.ok) { + finish(candidate); + return; + } + finish( + mapIndexedDbException( + requestError ?? transaction.error, + operation, + ), + ); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + const context: TransactionContext = Object.freeze({ + succeed(value) { + if (!candidate) candidate = browserDataSuccess(value); + }, + fail(result) { + if (!candidate) candidate = result; + try { + transaction.abort(); + } catch { + // Completion or a prior abort already owns the result. + } + }, + requestFailed(error) { + requestError ??= error; + if (!candidate) { + candidate = mapIndexedDbException(error, operation); + } + }, + }); + + try { + queue(transaction, context); + } catch (error) { + context.fail(mapIndexedDbException(error, operation)); + try { + transaction.abort(); + } catch { + finish(candidate ?? unavailable(operation)); + } + } + }); + } + + function decodeRecord( + raw: unknown, + expectedKey: string, + ): BrowserDataResult> { + if (!isStoredRecord(raw, expectedKey)) { + return corruptData("INDEXEDDB_READ"); + } + try { + const decoded = dependencies.codec.decode( + raw.codecVersion, + raw.payload, + ); + return decoded.ok + ? browserDataSuccess( + Object.freeze({ + value: decoded.value, + revision: raw.revision, + }), + ) + : corruptData("INDEXEDDB_READ"); + } catch { + return corruptData("INDEXEDDB_READ"); + } + } + + async function read( + key: string, + signal?: AbortSignal, + ): Promise< + BrowserDataResult | null> + > { + const operation = "INDEXEDDB_READ" as const; + if (!isValidBoundaryIdentifier(key)) { + return observeResult(operation, invalidInput(operation)); + } + let readEpochMs = 0; + if (storagePolicySnapshot.retention.kind === "TTL") { + try { + readEpochMs = nowEpochMilliseconds(); + } catch { + return observeResult(operation, unavailable(operation)); + } + if (!Number.isSafeInteger(readEpochMs) || readEpochMs < 0) { + return observeResult(operation, invalidInput(operation)); + } + } + const result = await runTransaction< + Readonly<{ value: Value; revision: number }> | null + >( + [dependencies.recordStore, dependencies.retentionStore], + "readonly", + operation, + signal, + (transaction, context) => { + const request = transaction + .objectStore(dependencies.recordStore) + .get(key); + request.onerror = () => context.requestFailed(request.error); + request.onsuccess = () => { + if (request.result === undefined) { + context.succeed(null); + return; + } + const decoded = decodeRecord(request.result, key); + if (!decoded.ok) { + context.fail(decoded); + return; + } + const retentionRequest = transaction + .objectStore(dependencies.retentionStore) + .get(key); + retentionRequest.onerror = () => + context.requestFailed(retentionRequest.error); + retentionRequest.onsuccess = () => { + if ( + !isStoredRetentionRecord( + retentionRequest.result, + key, + ) + ) { + context.fail(corruptData(operation)); + return; + } + if ( + storagePolicySnapshot.retention.kind === "TTL" && + retentionRequest.result.eligibleAtEpochMs === undefined + ) { + context.fail(corruptData(operation)); + return; + } + if ( + storagePolicySnapshot.retention.kind === "TTL" && + retentionRequest.result.eligibleAtEpochMs !== undefined && + retentionRequest.result.eligibleAtEpochMs <= readEpochMs + ) { + context.fail( + browserDataFailure( + "EXPIRED_RESOURCE", + operation, + ), + ); + return; + } + context.succeed(decoded.value); + }; + }; + }, + ); + return observeResult(operation, result, result.ok && result.value ? 1 : 0); + } + + function validateQueryPlan( + plan: IndexedDbQueryPlan, + cursor: IndexedDbCursor | null, + ): BrowserDataResult< + Readonly<{ + plan: IndexedDbQueryPlan; + range: IDBKeyRange | undefined; + cursor: IndexedDbCursor | null; + }> + > { + if ( + !plan || + typeof plan !== "object" || + !Number.isSafeInteger(plan.limit) || + plan.limit < 1 || + plan.limit > MAX_QUERY_LIMIT || + (plan.direction !== undefined && + plan.direction !== "next" && + plan.direction !== "prev") || + (plan.index !== undefined && + (typeof plan.index !== "string" || + !SAFE_DATABASE_NAME.test(plan.index) || + plan.index.length === 0)) + || (plan.range !== undefined && + !isBoundedRangePlan(plan.range)) + || (cursor !== null && + (!cursor || + typeof cursor !== "object" || + !isBoundedCursorKey(cursor.indexKey) || + !isBoundedCursorKey(cursor.primaryKey))) + ) { + return invalidInput("INDEXEDDB_READ"); + } + try { + if (cursor && factory) { + factory.cmp( + nativeCursorKey(cursor.indexKey), + nativeCursorKey(cursor.indexKey), + ); + factory.cmp( + nativeCursorKey(cursor.primaryKey), + nativeCursorKey(cursor.primaryKey), + ); + if ( + plan.index === undefined && + factory.cmp( + nativeCursorKey(cursor.indexKey), + nativeCursorKey(cursor.primaryKey), + ) !== 0 + ) { + return invalidInput("INDEXEDDB_READ"); + } + } + const snapshotKey = ( + value: IndexedDbCursorKey, + ): IndexedDbCursorKey => + normalizeCursorKey(nativeCursorKey(value)); + const snapshotRange: IndexedDbKeyRangePlan | undefined = + plan.range === undefined + ? undefined + : plan.range.kind === "ONLY" + ? Object.freeze({ + kind: "ONLY", + value: snapshotKey(plan.range.value), + }) + : plan.range.kind === "LOWER" + ? Object.freeze({ + kind: "LOWER", + lower: snapshotKey(plan.range.lower), + ...(plan.range.open === undefined + ? {} + : { open: plan.range.open }), + }) + : plan.range.kind === "UPPER" + ? Object.freeze({ + kind: "UPPER", + upper: snapshotKey(plan.range.upper), + ...(plan.range.open === undefined + ? {} + : { open: plan.range.open }), + }) + : Object.freeze({ + kind: "BOUND", + lower: snapshotKey(plan.range.lower), + upper: snapshotKey(plan.range.upper), + ...(plan.range.lowerOpen === undefined + ? {} + : { lowerOpen: plan.range.lowerOpen }), + ...(plan.range.upperOpen === undefined + ? {} + : { upperOpen: plan.range.upperOpen }), + }); + const snapshotPlan: IndexedDbQueryPlan = Object.freeze({ + ...(plan.index === undefined ? {} : { index: plan.index }), + ...(snapshotRange === undefined + ? {} + : { range: snapshotRange }), + ...(plan.direction === undefined + ? {} + : { direction: plan.direction }), + limit: plan.limit, + }); + const snapshotCursor = + cursor === null + ? null + : Object.freeze({ + indexKey: snapshotKey(cursor.indexKey), + primaryKey: snapshotKey(cursor.primaryKey), + }); + const range = snapshotRange + ? keyRange + ? rangeFromPlan(keyRange, snapshotRange) + : undefined + : undefined; + if (snapshotRange && !range) { + return browserDataFailure("UNSUPPORTED", "INDEXEDDB_READ", { + recovery: "ONLINE_ONLY", + }); + } + return browserDataSuccess( + Object.freeze({ + plan: snapshotPlan, + range, + cursor: snapshotCursor, + }), + ); + } catch { + return invalidInput("INDEXEDDB_READ"); + } + } + + async function query( + queryInput: Query, + cursor: IndexedDbCursor | null = null, + signal?: AbortSignal, + ): Promise>> { + const operation = "INDEXEDDB_READ" as const; + let rawPlan: IndexedDbQueryPlan; + try { + rawPlan = dependencies.queryPolicy.plan(queryInput, cursor); + } catch { + return observeResult(operation, invalidInput(operation)); + } + let validated: ReturnType; + try { + validated = validateQueryPlan(rawPlan, cursor); + } catch { + return observeResult(operation, invalidInput(operation)); + } + if (!validated.ok) return observeResult(operation, validated); + const direction = validated.value.plan.direction ?? "next"; + let queryEpochMs = 0; + if (storagePolicySnapshot.retention.kind === "TTL") { + try { + queryEpochMs = nowEpochMilliseconds(); + } catch { + return observeResult(operation, unavailable(operation)); + } + if (!Number.isSafeInteger(queryEpochMs) || queryEpochMs < 0) { + return observeResult(operation, invalidInput(operation)); + } + } + const scanLimit = Math.min( + MAX_QUERY_SCANNED_ROWS, + Math.max(validated.value.plan.limit * 20, 100), + ); + + const result = await runTransaction>( + [dependencies.recordStore, dependencies.retentionStore], + "readonly", + operation, + signal, + (transaction, context) => { + const store = transaction.objectStore(dependencies.recordStore); + const source = validated.value.plan.index + ? store.index(validated.value.plan.index) + : store; + const request = source.openCursor( + validated.value.range, + direction, + ); + const items: Value[] = []; + let lastCursor: IndexedDbCursor | null = null; + let lastScannedCursor: IndexedDbCursor | null = null; + let scannedRows = 0; + + request.onerror = () => context.requestFailed(request.error); + request.onsuccess = () => { + if (signal?.aborted) { + try { + transaction.abort(); + } catch { + // Transaction completion decides the race. + } + return; + } + const nativeCursor = request.result; + if (!nativeCursor) { + context.succeed( + Object.freeze({ + items: Object.freeze(items), + nextCursor: null, + }), + ); + return; + } + if ( + !isBoundedCursorKey(nativeCursor.key) || + !isBoundedCursorKey(nativeCursor.primaryKey) + ) { + context.fail(corruptData(operation)); + return; + } + const currentCursor: IndexedDbCursor = Object.freeze({ + indexKey: normalizeCursorKey(nativeCursor.key), + primaryKey: normalizeCursorKey(nativeCursor.primaryKey), + }); + if ( + validated.value.cursor && + factory && + !cursorAfter( + factory, + currentCursor, + validated.value.cursor, + direction, + ) + ) { + try { + const sameIndexKey = + factory.cmp( + nativeCursorKey(currentCursor.indexKey), + nativeCursorKey( + validated.value.cursor.indexKey, + ), + ) === 0; + const samePrimaryKey = + factory.cmp( + nativeCursorKey(currentCursor.primaryKey), + nativeCursorKey( + validated.value.cursor.primaryKey, + ), + ) === 0; + if (sameIndexKey && samePrimaryKey) { + nativeCursor.continue(); + } else if (validated.value.plan.index) { + nativeCursor.continuePrimaryKey( + nativeCursorKey( + validated.value.cursor.indexKey, + ), + nativeCursorKey( + validated.value.cursor.primaryKey, + ), + ); + } else { + nativeCursor.continue( + nativeCursorKey( + validated.value.cursor.primaryKey, + ), + ); + } + } catch (error) { + context.fail( + mapIndexedDbException(error, operation), + ); + } + return; + } + if (scannedRows >= scanLimit) { + context.succeed( + Object.freeze({ + items: Object.freeze(items), + nextCursor: lastScannedCursor, + }), + ); + return; + } + if (items.length >= validated.value.plan.limit) { + context.succeed( + Object.freeze({ + items: Object.freeze(items), + nextCursor: lastCursor, + }), + ); + return; + } + if (!isStoredRecord(nativeCursor.value)) { + context.fail(corruptData(operation)); + return; + } + const decoded = decodeRecord( + nativeCursor.value, + nativeCursor.value.key, + ); + if (!decoded.ok) { + context.fail(decoded); + return; + } + scannedRows += 1; + lastScannedCursor = currentCursor; + const recordKey = nativeCursor.value.key; + const retentionRequest = transaction + .objectStore(dependencies.retentionStore) + .get(recordKey); + retentionRequest.onerror = () => + context.requestFailed(retentionRequest.error); + retentionRequest.onsuccess = () => { + if ( + !isStoredRetentionRecord( + retentionRequest.result, + recordKey, + ) + ) { + context.fail(corruptData(operation)); + return; + } + const expired = + storagePolicySnapshot.retention.kind === "TTL" && + retentionRequest.result.eligibleAtEpochMs !== + undefined && + retentionRequest.result.eligibleAtEpochMs <= + queryEpochMs; + if ( + storagePolicySnapshot.retention.kind === "TTL" && + retentionRequest.result.eligibleAtEpochMs === undefined + ) { + context.fail(corruptData(operation)); + return; + } + if (!expired) { + items.push(decoded.value.value); + lastCursor = currentCursor; + } + try { + nativeCursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException(error, operation), + ); + } + }; + }; + }, + ); + return observeResult(operation, result, result.ok ? result.value.items.length : 0); + } + + function receiptWindow(): BrowserDataResult< + Readonly<{ + nowEpochMs: number; + expiresAtEpochMs: number; + }> + > { + let nowEpochMs: number; + try { + nowEpochMs = nowEpochMilliseconds(); + } catch { + return unavailable("INDEXEDDB_WRITE"); + } + const expiresAtEpochMs = + nowEpochMs + dependencies.receiptRetentionMs; + if ( + !Number.isSafeInteger(nowEpochMs) || + nowEpochMs < 0 || + !Number.isSafeInteger(expiresAtEpochMs) + ) { + return invalidInput("INDEXEDDB_WRITE"); + } + return browserDataSuccess( + Object.freeze({ nowEpochMs, expiresAtEpochMs }), + ); + } + + async function prepareWrite( + input: IndexedDbCompareAndSwapInput, + ): Promise< + BrowserDataResult< + Readonly<{ + wireValue: WireValue; + fingerprint: string; + synchronization: IndexedDbSynchronizationState | "NONE"; + measuredBytes: number; + writtenAtEpochMs: number; + eligibleAtEpochMs: number | undefined; + receiptNowEpochMs: number; + receiptExpiresAtEpochMs: number; + }> + > + > { + const operation = "INDEXEDDB_WRITE" as const; + if ( + !isValidBoundaryIdentifier(input.key) || + !isValidBoundaryIdentifier(input.idempotencyKey) || + (input.expectedRevision !== null && + !isRevision(input.expectedRevision)) || + (storagePolicySnapshot.retention.kind === "UNTIL_SYNCED" + ? input.synchronization !== "PENDING" && + input.synchronization !== "CONFIRMED" + : input.synchronization !== undefined) + ) { + return invalidInput(operation); + } + let encoded; + try { + encoded = dependencies.codec.encode(input.value); + } catch { + return invalidInput(operation); + } + if (!encoded.ok) return invalidInput(operation); + let wireValue: WireValue; + try { + // IndexedDB applies structured cloning only when put() is queued. Clone + // here so caller-owned or codec-owned objects cannot change while the + // asynchronous fingerprint is being computed. + wireValue = structuredClone(encoded.value); + } catch (error) { + return mapIndexedDbException(error, operation); + } + let measuredPayloadBytes: number; + try { + measuredPayloadBytes = + dependencies.codec.measureStoredBytes(wireValue); + } catch { + return invalidInput(operation); + } + const measuredBytes = + measuredPayloadBytes + + RECORD_ENVELOPE_RESERVATION_BYTES + + input.key.length * 2; + if ( + !Number.isSafeInteger(measuredPayloadBytes) || + measuredPayloadBytes < 0 || + !Number.isSafeInteger(measuredBytes) || + measuredBytes < 1 || + measuredBytes > MAX_MEASURED_RECORD_BYTES + ) { + return invalidInput(operation); + } + const cancelled = abortedResult(input.signal, operation); + if (cancelled) return cancelled; + + let fingerprint: string; + try { + fingerprint = await dependencies.codec.fingerprint(wireValue); + } catch { + return invalidInput(operation); + } + if ( + input.signal?.aborted || + typeof fingerprint !== "string" || + !OPAQUE_SHA256_FINGERPRINT.test(fingerprint) + ) { + return input.signal?.aborted + ? browserDataFailure("ABORTED", operation) + : invalidInput(operation); + } + const window = receiptWindow(); + if (!window.ok) return window; + const synchronization = + input.synchronization ?? ("NONE" as const); + const eligibleAtEpochMs = + storagePolicySnapshot.retention.kind === "TTL" + ? window.value.nowEpochMs + + storagePolicySnapshot.retention.maxAgeMs + : storagePolicySnapshot.retention.kind === + "UNTIL_SYNCED" && + synchronization === "CONFIRMED" + ? window.value.nowEpochMs + : undefined; + if ( + eligibleAtEpochMs !== undefined && + !Number.isSafeInteger(eligibleAtEpochMs) + ) { + return invalidInput(operation); + } + return browserDataSuccess( + Object.freeze({ + wireValue, + fingerprint, + synchronization, + measuredBytes, + writtenAtEpochMs: window.value.nowEpochMs, + eligibleAtEpochMs, + receiptNowEpochMs: window.value.nowEpochMs, + receiptExpiresAtEpochMs: window.value.expiresAtEpochMs, + }), + ); + } + + function replayReceipt( + raw: unknown, + expected: Readonly<{ + idempotencyKey: string; + operation: "PUT" | "DELETE"; + recordKey: string; + expectedRevision: number | null; + fingerprint: string; + synchronization: IndexedDbSynchronizationState | "NONE"; + }>, + nowEpochMs: number, + ): ReceiptLookup { + if (raw === undefined) return { kind: "MISSING" }; + if (!isStoredReceipt(raw)) { + return { + kind: "RESULT", + result: corruptData("INDEXEDDB_WRITE"), + }; + } + if (raw.expiresAtEpochMs <= nowEpochMs) { + return { kind: "EXPIRED" }; + } + if ( + raw.idempotencyKey !== expected.idempotencyKey || + raw.operation !== expected.operation || + raw.recordKey !== expected.recordKey || + raw.expectedRevision !== expected.expectedRevision || + raw.fingerprint !== expected.fingerprint || + raw.synchronization !== expected.synchronization + ) { + return { + kind: "RESULT", + result: readwriteConflict("INDEXEDDB_WRITE"), + }; + } + return { + kind: "RESULT", + result: browserDataSuccess( + Object.freeze({ + key: raw.recordKey, + revision: raw.revision, + replayed: true, + }), + ), + }; + } + + async function compareAndSwap( + sourceInput: IndexedDbCompareAndSwapInput, + ): Promise> { + const operation = "INDEXEDDB_WRITE" as const; + const input: IndexedDbCompareAndSwapInput = Object.freeze({ + key: sourceInput.key, + value: sourceInput.value, + expectedRevision: sourceInput.expectedRevision, + idempotencyKey: sourceInput.idempotencyKey, + ...(sourceInput.synchronization === undefined + ? {} + : { synchronization: sourceInput.synchronization }), + ...(sourceInput.signal ? { signal: sourceInput.signal } : {}), + }); + const prepared = await prepareWrite(input); + if (!prepared.ok) return observeResult(operation, prepared); + + const result = await runTransaction( + [ + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + dependencies.idempotencyStore, + ], + "readwrite", + operation, + input.signal, + (transaction, context) => { + const records = transaction.objectStore(dependencies.recordStore); + const receipts = transaction.objectStore( + dependencies.idempotencyStore, + ); + const retention = transaction.objectStore( + dependencies.retentionStore, + ); + const governance = transaction.objectStore( + dependencies.governanceStore, + ); + const expectedReceipt = Object.freeze({ + idempotencyKey: input.idempotencyKey, + operation: "PUT" as const, + recordKey: input.key, + expectedRevision: input.expectedRevision, + fingerprint: prepared.value.fingerprint, + synchronization: prepared.value.synchronization, + }); + const continueWithRecord = ( + receiptCountDelta: 0 | 1, + ) => { + let recordRequest: IDBRequest; + try { + recordRequest = records.get(input.key); + } catch (error) { + context.fail( + mapIndexedDbException(error, operation), + ); + return; + } + recordRequest.onerror = () => + context.requestFailed(recordRequest.error); + recordRequest.onsuccess = () => { + const raw = recordRequest.result; + if (raw !== undefined && !isStoredRecord(raw, input.key)) { + context.fail(corruptData(operation)); + return; + } + if ( + (input.expectedRevision === null && raw !== undefined) || + (input.expectedRevision !== null && + (raw === undefined || + raw.revision !== input.expectedRevision)) + ) { + context.fail(readwriteConflict(operation)); + return; + } + const revision = raw === undefined ? 1 : raw.revision + 1; + if (!Number.isSafeInteger(revision)) { + context.fail(corruptData(operation)); + return; + } + const sidecarRequest = retention.get(input.key); + sidecarRequest.onerror = () => + context.requestFailed(sidecarRequest.error); + sidecarRequest.onsuccess = () => { + const previousSidecar = sidecarRequest.result; + if ( + (raw === undefined && + previousSidecar !== undefined) || + (raw !== undefined && + !isStoredRetentionRecord( + previousSidecar, + input.key, + )) + ) { + context.fail(corruptData(operation)); + return; + } + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if (!isStoredDatasetBudget(budgetRequest.result)) { + context.fail(corruptData(operation)); + return; + } + const previousBytes = + previousSidecar === undefined + ? 0 + : previousSidecar.measuredBytes; + const usedBytes = + budgetRequest.result.usedBytes - + previousBytes + + prepared.value.measuredBytes; + const receiptCount = + budgetRequest.result.receiptCount + + receiptCountDelta; + if ( + !Number.isSafeInteger(usedBytes) || + usedBytes < 0 || + usedBytes > + storagePolicySnapshot.hardBudgetBytes || + receiptCount > + dependencies.maxIdempotencyReceipts + ) { + context.fail( + usedBytes > + storagePolicySnapshot.hardBudgetBytes || + receiptCount > + dependencies.maxIdempotencyReceipts + ? browserDataFailure( + "LIMIT_EXCEEDED", + operation, + ) + : corruptData(operation), + ); + return; + } + const stored: StoredRecord = Object.freeze({ + key: input.key, + codecVersion: dependencies.codec.currentVersion, + revision, + payload: prepared.value.wireValue, + }); + let writeRequest: IDBRequest; + try { + writeRequest = records.put(stored); + } catch (error) { + context.fail( + mapIndexedDbException(error, operation), + ); + return; + } + writeRequest.onerror = () => + context.requestFailed(writeRequest.error); + writeRequest.onsuccess = () => { + const retentionRecord: StoredRetentionRecord = + Object.freeze({ + recordKey: input.key, + writtenAtEpochMs: + prepared.value.writtenAtEpochMs, + synchronization: + prepared.value.synchronization, + measuredBytes: + prepared.value.measuredBytes, + ...(prepared.value.eligibleAtEpochMs === + undefined + ? {} + : { + eligibleAtEpochMs: + prepared.value.eligibleAtEpochMs, + }), + }); + const retentionRequest = + retention.put(retentionRecord); + retentionRequest.onerror = () => + context.requestFailed( + retentionRequest.error, + ); + retentionRequest.onsuccess = () => { + const storedBudget: StoredDatasetBudget = + Object.freeze({ + bindingKey: + INDEXEDDB_DATASET_BUDGET_KEY, + budgetVersion: 1, + usedBytes, + receiptCount, + }); + const storedBudgetRequest = + governance.put(storedBudget); + storedBudgetRequest.onerror = () => + context.requestFailed( + storedBudgetRequest.error, + ); + storedBudgetRequest.onsuccess = () => { + const storedReceipt: StoredIdempotencyReceipt = + Object.freeze({ + ...expectedReceipt, + revision, + expiresAtEpochMs: + prepared.value + .receiptExpiresAtEpochMs, + }); + const idempotencyRequest = + receipts.add(storedReceipt); + idempotencyRequest.onerror = () => + context.requestFailed( + idempotencyRequest.error, + ); + idempotencyRequest.onsuccess = () => + context.succeed( + Object.freeze({ + key: input.key, + revision, + replayed: false, + }), + ); + }; + }; + }; + }; + }; + }; + }; + const receiptRequest = receipts.get(input.idempotencyKey); + receiptRequest.onerror = () => + context.requestFailed(receiptRequest.error); + receiptRequest.onsuccess = () => { + const replay = replayReceipt( + receiptRequest.result, + expectedReceipt, + prepared.value.receiptNowEpochMs, + ); + if (replay.kind === "RESULT") { + if (replay.result.ok) { + context.succeed(replay.result.value); + } else { + context.fail(replay.result); + } + return; + } + if (replay.kind === "EXPIRED") { + let deleteRequest: IDBRequest; + try { + deleteRequest = receipts.delete( + input.idempotencyKey, + ); + } catch (error) { + context.fail( + mapIndexedDbException(error, operation), + ); + return; + } + deleteRequest.onerror = () => + context.requestFailed(deleteRequest.error); + deleteRequest.onsuccess = () => + continueWithRecord(0); + return; + } + continueWithRecord(1); + }; + }, + ); + return observeResult(operation, result, result.ok ? 1 : 0); + } + + async function remove( + sourceInput: IndexedDbDeleteInput, + ): Promise> { + const operation = "INDEXEDDB_WRITE" as const; + if ( + !isValidBoundaryIdentifier(sourceInput.key) || + !isValidBoundaryIdentifier(sourceInput.idempotencyKey) || + !isRevision(sourceInput.expectedRevision) + ) { + return observeResult(operation, invalidInput(operation)); + } + const input: IndexedDbDeleteInput = Object.freeze({ + key: sourceInput.key, + idempotencyKey: sourceInput.idempotencyKey, + expectedRevision: sourceInput.expectedRevision, + ...(sourceInput.signal ? { signal: sourceInput.signal } : {}), + }); + const cancelled = abortedResult(input.signal, operation); + if (cancelled) return observeResult(operation, cancelled); + const fingerprint = DELETE_OPERATION_FINGERPRINT; + const window = receiptWindow(); + if (!window.ok) return observeResult(operation, window); + + const result = await runTransaction( + [ + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + dependencies.idempotencyStore, + ], + "readwrite", + operation, + input.signal, + (transaction, context) => { + const records = transaction.objectStore(dependencies.recordStore); + const receipts = transaction.objectStore( + dependencies.idempotencyStore, + ); + const retention = transaction.objectStore( + dependencies.retentionStore, + ); + const governance = transaction.objectStore( + dependencies.governanceStore, + ); + const expectedReceipt = Object.freeze({ + idempotencyKey: input.idempotencyKey, + operation: "DELETE" as const, + recordKey: input.key, + expectedRevision: input.expectedRevision, + fingerprint, + synchronization: "NONE" as const, + }); + const continueWithRecord = ( + receiptCountDelta: 0 | 1, + ) => { + let recordRequest: IDBRequest; + try { + recordRequest = records.get(input.key); + } catch (error) { + context.fail( + mapIndexedDbException(error, operation), + ); + return; + } + recordRequest.onerror = () => + context.requestFailed(recordRequest.error); + recordRequest.onsuccess = () => { + if ( + !isStoredRecord(recordRequest.result, input.key) || + recordRequest.result.revision !== input.expectedRevision + ) { + context.fail(readwriteConflict(operation)); + return; + } + const revision = input.expectedRevision + 1; + const sidecarRequest = retention.get(input.key); + sidecarRequest.onerror = () => + context.requestFailed(sidecarRequest.error); + sidecarRequest.onsuccess = () => { + if ( + !isStoredRetentionRecord( + sidecarRequest.result, + input.key, + ) + ) { + context.fail(corruptData(operation)); + return; + } + const previousBytes = + sidecarRequest.result.measuredBytes; + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if ( + !isStoredDatasetBudget(budgetRequest.result) || + budgetRequest.result.usedBytes < previousBytes + ) { + context.fail(corruptData(operation)); + return; + } + const usedBytes = + budgetRequest.result.usedBytes - previousBytes; + const receiptCount = + budgetRequest.result.receiptCount + + receiptCountDelta; + if ( + receiptCount > + dependencies.maxIdempotencyReceipts + ) { + context.fail( + browserDataFailure( + "LIMIT_EXCEEDED", + operation, + ), + ); + return; + } + const deleteRequest = records.delete(input.key); + deleteRequest.onerror = () => + context.requestFailed(deleteRequest.error); + deleteRequest.onsuccess = () => { + const retentionRequest = + retention.delete(input.key); + retentionRequest.onerror = () => + context.requestFailed( + retentionRequest.error, + ); + retentionRequest.onsuccess = () => { + const storedBudgetRequest = + governance.put({ + bindingKey: + INDEXEDDB_DATASET_BUDGET_KEY, + budgetVersion: 1, + usedBytes, + receiptCount, + } satisfies StoredDatasetBudget); + storedBudgetRequest.onerror = () => + context.requestFailed( + storedBudgetRequest.error, + ); + storedBudgetRequest.onsuccess = () => { + const storedReceipt: StoredIdempotencyReceipt = + Object.freeze({ + ...expectedReceipt, + revision, + expiresAtEpochMs: + window.value.expiresAtEpochMs, + }); + const idempotencyRequest = + receipts.add(storedReceipt); + idempotencyRequest.onerror = () => + context.requestFailed( + idempotencyRequest.error, + ); + idempotencyRequest.onsuccess = () => + context.succeed( + Object.freeze({ + key: input.key, + revision, + replayed: false, + }), + ); + }; + }; + }; + }; + }; + }; + }; + const receiptRequest = receipts.get(input.idempotencyKey); + receiptRequest.onerror = () => + context.requestFailed(receiptRequest.error); + receiptRequest.onsuccess = () => { + const replay = replayReceipt( + receiptRequest.result, + expectedReceipt, + window.value.nowEpochMs, + ); + if (replay.kind === "RESULT") { + if (replay.result.ok) { + context.succeed(replay.result.value); + } else { + context.fail(replay.result); + } + return; + } + if (replay.kind === "EXPIRED") { + let deleteRequest: IDBRequest; + try { + deleteRequest = receipts.delete( + input.idempotencyKey, + ); + } catch (error) { + context.fail( + mapIndexedDbException(error, operation), + ); + return; + } + deleteRequest.onerror = () => + context.requestFailed(deleteRequest.error); + deleteRequest.onsuccess = () => + continueWithRecord(0); + return; + } + continueWithRecord(1); + }; + }, + ); + return observeResult(operation, result, result.ok ? 1 : 0); + } + + function lifecyclePolicyRejected(): BrowserDataResult { + return browserDataFailure("POLICY_REJECTED", "INDEXEDDB_WRITE", { + recovery: storagePolicySnapshot.unavailableFallback, + }); + } + + function validLifecycleInput( + input: IndexedDbLifecycleBatchInput, + ): boolean { + return ( + !!input && + typeof input === "object" && + [ + "SESSION_END", + "LOGOUT", + "ACCOUNT_DELETION", + "RETENTION_SWEEP", + ].includes(input.action) && + Number.isSafeInteger(input.maxRows) && + input.maxRows >= 1 && + input.maxRows <= MAX_LIFECYCLE_ROWS && + Number.isSafeInteger(input.maxDurationMs) && + input.maxDurationMs >= 1 && + input.maxDurationMs <= MAX_LIFECYCLE_DURATION_MS + ); + } + + function lifecycleActionAllowed( + action: IndexedDbLifecycleBatchInput["action"], + ): boolean { + switch (action) { + case "SESSION_END": + return storagePolicySnapshot.retention.kind === "SESSION"; + case "LOGOUT": + return ( + storagePolicySnapshot.accountScope === + "OPAQUE_PARTITION" && + (storagePolicySnapshot.logoutAction === + "PURGE_PARTITION" || + storagePolicySnapshot.logoutAction === + "EXPORT_THEN_PURGE") + ); + case "ACCOUNT_DELETION": + return ( + storagePolicySnapshot.accountScope === + "OPAQUE_PARTITION" && + storagePolicySnapshot.accountDeletionAction === + "PURGE_PARTITION" + ); + case "RETENTION_SWEEP": + return ( + storagePolicySnapshot.retention.kind === "TTL" || + storagePolicySnapshot.retention.kind === "UNTIL_SYNCED" + ); + } + } + + function monotonicClock(): BrowserDataResult { + try { + const value = nowMonotonicMilliseconds(); + return Number.isFinite(value) + ? browserDataSuccess(value) + : invalidInput("INDEXEDDB_WRITE"); + } catch { + return unavailable("INDEXEDDB_WRITE"); + } + } + + async function authorizeLifecycle( + input: IndexedDbLifecycleBatchInput, + ): Promise> { + if (!lifecycleActionAllowed(input.action)) { + return lifecyclePolicyRejected(); + } + let decision; + try { + decision = await dependencies.authorizeLifecycle( + Object.freeze({ + action: input.action, + scope: scopeSnapshot, + storagePolicy: storagePolicySnapshot, + ...(input.signal ? { signal: input.signal } : {}), + }), + ); + } catch { + return lifecyclePolicyRejected(); + } + if ( + input.signal?.aborted || + !decision || + decision.authorized !== true || + typeof decision.proofToken !== "string" || + !OPAQUE_AUTHORITY_PROOF.test(decision.proofToken) + ) { + return input.signal?.aborted + ? browserDataFailure("ABORTED", "INDEXEDDB_WRITE") + : lifecyclePolicyRejected(); + } + return browserDataSuccess(undefined); + } + + function purgeEligibleRecords( + input: IndexedDbLifecycleBatchInput, + deadline: number, + cutoffEpochMs: number, + ): Promise> { + return runTransaction( + [ + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + ], + "readwrite", + "INDEXEDDB_WRITE", + input.signal, + (transaction, context) => { + if (!keyRange) { + context.fail( + browserDataFailure( + "UNSUPPORTED", + "INDEXEDDB_WRITE", + { recovery: "ONLINE_ONLY" }, + ), + ); + return; + } + const records = transaction.objectStore( + dependencies.recordStore, + ); + const retention = transaction.objectStore( + dependencies.retentionStore, + ); + const governance = transaction.objectStore( + dependencies.governanceStore, + ); + let range: IDBKeyRange; + try { + range = keyRange.upperBound(cutoffEpochMs); + } catch { + context.fail(invalidInput("INDEXEDDB_WRITE")); + return; + } + const request = retention + .index(dependencies.retentionEligibilityIndex) + .openCursor(range); + let scannedRows = 0; + let deletedRows = 0; + const succeed = ( + state: "MORE" | "COMPLETE", + budgetExhausted: boolean, + ) => + context.succeed( + Object.freeze({ + state, + scannedRows, + deletedRows, + budgetExhausted, + }), + ); + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => { + if (input.signal?.aborted) { + try { + transaction.abort(); + } catch { + // The transaction completion event decides the race. + } + return; + } + const cursor = request.result; + if (!cursor) { + succeed("COMPLETE", false); + return; + } + const clock = monotonicClock(); + if (!clock.ok) { + context.fail(clock); + return; + } + if ( + deletedRows >= input.maxRows || + clock.value >= deadline + ) { + succeed("MORE", clock.value >= deadline); + return; + } + if ( + !isStoredRetentionRecord( + cursor.value, + String(cursor.primaryKey), + ) || + cursor.value.eligibleAtEpochMs === undefined || + cursor.value.eligibleAtEpochMs > cutoffEpochMs || + (storagePolicySnapshot.retention.kind === + "UNTIL_SYNCED" && + cursor.value.synchronization !== "CONFIRMED") + ) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + scannedRows += 1; + const measuredBytes = cursor.value.measuredBytes; + let recordDelete: IDBRequest; + try { + recordDelete = records.delete(cursor.value.recordKey); + } catch (error) { + context.fail( + mapIndexedDbException(error, "INDEXEDDB_WRITE"), + ); + return; + } + recordDelete.onerror = () => + context.requestFailed(recordDelete.error); + recordDelete.onsuccess = () => { + let sidecarDelete: IDBRequest; + try { + sidecarDelete = retention.delete( + cursor.value.recordKey, + ); + } catch (error) { + context.fail( + mapIndexedDbException(error, "INDEXEDDB_WRITE"), + ); + return; + } + sidecarDelete.onerror = () => + context.requestFailed(sidecarDelete.error); + sidecarDelete.onsuccess = () => { + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if ( + !isStoredDatasetBudget(budgetRequest.result) || + budgetRequest.result.usedBytes < measuredBytes + ) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + const storedBudgetRequest = governance.put({ + bindingKey: INDEXEDDB_DATASET_BUDGET_KEY, + budgetVersion: 1, + usedBytes: + budgetRequest.result.usedBytes - measuredBytes, + receiptCount: budgetRequest.result.receiptCount, + } satisfies StoredDatasetBudget); + storedBudgetRequest.onerror = () => + context.requestFailed( + storedBudgetRequest.error, + ); + storedBudgetRequest.onsuccess = () => { + deletedRows += 1; + try { + cursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException( + error, + "INDEXEDDB_WRITE", + ), + ); + } + }; + }; + }; + }; + }; + }, + ); + } + + function purgePartitionRecords( + input: IndexedDbLifecycleBatchInput, + deadline: number, + ): Promise> { + return runTransaction( + [ + dependencies.recordStore, + dependencies.governanceStore, + dependencies.retentionStore, + dependencies.idempotencyStore, + ...dependencies.lifecycleMetadataStores, + ], + "readwrite", + "INDEXEDDB_WRITE", + input.signal, + (transaction, context) => { + const records = transaction.objectStore( + dependencies.recordStore, + ); + const retention = transaction.objectStore( + dependencies.retentionStore, + ); + const receipts = transaction.objectStore( + dependencies.idempotencyStore, + ); + const governance = transaction.objectStore( + dependencies.governanceStore, + ); + let scannedRows = 0; + let deletedRows = 0; + const succeed = ( + state: "MORE" | "COMPLETE", + budgetExhausted: boolean, + ) => + context.succeed( + Object.freeze({ + state, + scannedRows, + deletedRows, + budgetExhausted, + }), + ); + + const processMetadataStore = (storeIndex: number) => { + const storeName = + dependencies.lifecycleMetadataStores[storeIndex]; + if (storeName === undefined) { + processReceipts(); + return; + } + const store = transaction.objectStore(storeName); + const request = store.openCursor(); + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + processMetadataStore(storeIndex + 1); + return; + } + const clock = monotonicClock(); + if (!clock.ok) { + context.fail(clock); + return; + } + if ( + deletedRows >= input.maxRows || + clock.value >= deadline + ) { + succeed("MORE", clock.value >= deadline); + return; + } + scannedRows += 1; + const deletion = store.delete(cursor.primaryKey); + deletion.onerror = () => + context.requestFailed(deletion.error); + deletion.onsuccess = () => { + deletedRows += 1; + try { + cursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException( + error, + "INDEXEDDB_WRITE", + ), + ); + } + }; + }; + }; + + const processReceipts = () => { + const request = receipts.openCursor(); + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + succeed("COMPLETE", false); + return; + } + const clock = monotonicClock(); + if (!clock.ok) { + context.fail(clock); + return; + } + if ( + deletedRows >= input.maxRows || + clock.value >= deadline + ) { + succeed("MORE", clock.value >= deadline); + return; + } + if (!isStoredReceipt(cursor.value)) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + scannedRows += 1; + const deletion = receipts.delete(cursor.primaryKey); + deletion.onerror = () => + context.requestFailed(deletion.error); + deletion.onsuccess = () => { + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if ( + !isStoredDatasetBudget(budgetRequest.result) || + budgetRequest.result.receiptCount < 1 + ) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + const storedBudgetRequest = governance.put({ + ...budgetRequest.result, + receiptCount: + budgetRequest.result.receiptCount - 1, + } satisfies StoredDatasetBudget); + storedBudgetRequest.onerror = () => + context.requestFailed( + storedBudgetRequest.error, + ); + storedBudgetRequest.onsuccess = () => { + deletedRows += 1; + try { + cursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException( + error, + "INDEXEDDB_WRITE", + ), + ); + } + }; + }; + }; + }; + }; + + const processOrphanedRetention = () => { + const request = retention.openCursor(); + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + processMetadataStore(0); + return; + } + const clock = monotonicClock(); + if (!clock.ok) { + context.fail(clock); + return; + } + if ( + deletedRows >= input.maxRows || + clock.value >= deadline + ) { + succeed("MORE", clock.value >= deadline); + return; + } + if ( + !isStoredRetentionRecord( + cursor.value, + String(cursor.primaryKey), + ) + ) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + scannedRows += 1; + const measuredBytes = cursor.value.measuredBytes; + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if ( + !isStoredDatasetBudget(budgetRequest.result) || + budgetRequest.result.usedBytes < measuredBytes + ) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + const deletion = retention.delete(cursor.primaryKey); + deletion.onerror = () => + context.requestFailed(deletion.error); + deletion.onsuccess = () => { + const budgetWrite = governance.put({ + ...budgetRequest.result, + usedBytes: + budgetRequest.result.usedBytes - measuredBytes, + } satisfies StoredDatasetBudget); + budgetWrite.onerror = () => + context.requestFailed(budgetWrite.error); + budgetWrite.onsuccess = () => { + deletedRows += 1; + try { + cursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException( + error, + "INDEXEDDB_WRITE", + ), + ); + } + }; + }; + }; + }; + }; + + const request = records.openCursor(); + request.onerror = () => + context.requestFailed(request.error); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + processOrphanedRetention(); + return; + } + const clock = monotonicClock(); + if (!clock.ok) { + context.fail(clock); + return; + } + if ( + deletedRows >= input.maxRows || + clock.value >= deadline + ) { + succeed("MORE", clock.value >= deadline); + return; + } + if (!isStoredRecord(cursor.value)) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + scannedRows += 1; + const key = cursor.value.key; + const sidecarRequest = retention.get(key); + sidecarRequest.onerror = () => + context.requestFailed(sidecarRequest.error); + sidecarRequest.onsuccess = () => { + if ( + !isStoredRetentionRecord( + sidecarRequest.result, + key, + ) + ) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + const measuredBytes = + sidecarRequest.result.measuredBytes; + const budgetRequest = governance.get( + INDEXEDDB_DATASET_BUDGET_KEY, + ); + budgetRequest.onerror = () => + context.requestFailed(budgetRequest.error); + budgetRequest.onsuccess = () => { + if ( + !isStoredDatasetBudget(budgetRequest.result) || + budgetRequest.result.usedBytes < measuredBytes + ) { + context.fail(corruptData("INDEXEDDB_WRITE")); + return; + } + const recordDelete = records.delete(key); + recordDelete.onerror = () => + context.requestFailed(recordDelete.error); + recordDelete.onsuccess = () => { + const sidecarDelete = retention.delete(key); + sidecarDelete.onerror = () => + context.requestFailed(sidecarDelete.error); + sidecarDelete.onsuccess = () => { + const storedBudgetRequest = governance.put({ + bindingKey: + INDEXEDDB_DATASET_BUDGET_KEY, + budgetVersion: 1, + usedBytes: + budgetRequest.result.usedBytes - + measuredBytes, + receiptCount: + budgetRequest.result.receiptCount, + } satisfies StoredDatasetBudget); + storedBudgetRequest.onerror = () => + context.requestFailed( + storedBudgetRequest.error, + ); + storedBudgetRequest.onsuccess = () => { + deletedRows += 1; + try { + cursor.continue(); + } catch (error) { + context.fail( + mapIndexedDbException( + error, + "INDEXEDDB_WRITE", + ), + ); + } + }; + }; + }; + }; + }; + }; + }, + ); + } + + async function enforceLifecycleBatch( + sourceInput: IndexedDbLifecycleBatchInput, + ): Promise> { + const operation = "INDEXEDDB_WRITE" as const; + if (!validLifecycleInput(sourceInput)) { + return observeResult(operation, invalidInput(operation)); + } + const input: IndexedDbLifecycleBatchInput = Object.freeze({ + action: sourceInput.action, + maxRows: sourceInput.maxRows, + maxDurationMs: sourceInput.maxDurationMs, + ...(sourceInput.signal ? { signal: sourceInput.signal } : {}), + }); + const cancelled = abortedResult(input.signal, operation); + if (cancelled) return observeResult(operation, cancelled); + const started = monotonicClock(); + if (!started.ok) return observeResult(operation, started); + const deadline = started.value + input.maxDurationMs; + if (!Number.isFinite(deadline)) { + return observeResult(operation, invalidInput(operation)); + } + const authority = await authorizeLifecycle(input); + if (!authority.ok) return observeResult(operation, authority); + if (input.signal?.aborted) { + return observeResult( + operation, + browserDataFailure("ABORTED", operation), + ); + } + + let result: BrowserDataResult; + if (input.action === "RETENTION_SWEEP") { + let cutoffEpochMs: number; + try { + cutoffEpochMs = nowEpochMilliseconds(); + } catch { + return observeResult(operation, unavailable(operation)); + } + if ( + !Number.isSafeInteger(cutoffEpochMs) || + cutoffEpochMs < 0 + ) { + return observeResult(operation, invalidInput(operation)); + } + result = await purgeEligibleRecords( + input, + deadline, + cutoffEpochMs, + ); + } else { + result = await purgePartitionRecords(input, deadline); + } + return observeResult( + operation, + result, + result.ok ? result.value.deletedRows : 0, + ); + } + + function close(): void { + if (disposed) return; + disposed = true; + cancelPendingOpen?.(); + cancelPendingOpen = null; + openingRequest = null; + const current = connection; + connection = null; + current?.close(); + updateStatus({ kind: "DISPOSED" }); + subscribers.clear(); + } + + return Object.freeze({ + open, + read, + query, + compareAndSwap, + remove, + enforceLifecycleBatch, + getStatus: () => status, + subscribeStatus( + listener: (status: IndexedDbConnectionStatus) => void, + ) { + subscribers.add(listener); + return () => subscribers.delete(listener); + }, + close, + }); +} diff --git a/src/adapters/storage/indexeddb/indexeddb-types.ts b/src/adapters/storage/indexeddb/indexeddb-types.ts new file mode 100644 index 0000000..4966cd9 --- /dev/null +++ b/src/adapters/storage/indexeddb/indexeddb-types.ts @@ -0,0 +1,234 @@ +import type { + IndexedDbConnectionStatus, + IndexedDbCursor, + IndexedDbCursorKey, + IndexedDbDatasetScope, + IndexedDbLifecycleAuthorityDecision, + IndexedDbLifecycleAuthorityRequest, +} from "../../../application/ports/browser-file-storage/indexeddb-port.ts"; +import type { + BrowserDataFailureCode, + BrowserDataOperation, + BrowserStoragePolicy, +} from "../../../application/ports/browser-file-storage/shared.ts"; + +export type IndexedDbCodecResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false }>; + +/** + * The codec is the only boundary allowed to turn an IndexedDB structured + * clone into a trusted value. It must accept every retained historical record + * version and emit only current-version wire values. + */ +export interface IndexedDbCodec { + readonly currentVersion: number; + encode(value: Value): IndexedDbCodecResult; + /** + * Deterministic conservative byte estimate for the encoded wire value. + * Returning an invalid value or throwing rejects the write fail-closed. + */ + measureStoredBytes(value: WireValue): number; + decode( + codecVersion: number, + value: unknown, + ): IndexedDbCodecResult; + /** + * Returns lowercase SHA-256 hex over a canonical, domain-approved wire + * representation. Raw labels, identifiers or reversible encodings are + * rejected by the runtime and must never be persisted as fingerprints. The + * canonicalization and digest contract must remain stable for at least the + * receipt retention plus supported rollback window. + */ + fingerprint(value: WireValue): string | Promise; +} + +export type IndexedDbIndexDefinition = Readonly<{ + name: string; + keyPath: string | readonly string[]; + unique?: boolean; + multiEntry?: boolean; +}>; + +export type IndexedDbSchemaOperation = + | Readonly<{ + kind: "CREATE_STORE"; + name: string; + keyPath: string; + autoIncrement?: boolean; + indexes?: readonly IndexedDbIndexDefinition[]; + }> + | Readonly<{ + kind: "CREATE_INDEX"; + store: string; + index: IndexedDbIndexDefinition; + }>; + +export type IndexedDbSchemaMigration = Readonly<{ + id: string; + fromVersion: number; + toVersion: number; + operations: readonly IndexedDbSchemaOperation[]; +}>; + +export type IndexedDbKeyRangePlan = + | Readonly<{ kind: "ONLY"; value: IndexedDbCursorKey }> + | Readonly<{ + kind: "LOWER"; + lower: IndexedDbCursorKey; + open?: boolean; + }> + | Readonly<{ + kind: "UPPER"; + upper: IndexedDbCursorKey; + open?: boolean; + }> + | Readonly<{ + kind: "BOUND"; + lower: IndexedDbCursorKey; + upper: IndexedDbCursorKey; + lowerOpen?: boolean; + upperOpen?: boolean; + }>; + +export type IndexedDbQueryPlan = Readonly<{ + index?: string; + range?: IndexedDbKeyRangePlan; + direction?: "next" | "prev"; + limit: number; +}>; + +export interface IndexedDbQueryPolicy { + plan(query: Query, cursor: IndexedDbCursor | null): IndexedDbQueryPlan; +} + +export type IndexedDbDataMigrationSource = Readonly<{ + key: string; + fromCodecVersion: number; + payload: unknown; + signal: AbortSignal | undefined; +}>; + +/** + * Owns all domain-aware historical payload conversion. It runs outside an + * IndexedDB transaction, so asynchronous validation/crypto cannot accidentally + * make a transaction inactive. + */ +export interface IndexedDbDataMigrationPolicy { + readonly migrationId: string; + readonly targetCodecVersion: number; + measureStoredBytes(value: WireValue): number; + /** + * Must be backed by product rollout/session authority that keeps N-1 + * old-codec writers drained for the entire migration and contract window. + * BroadcastChannel or a best-effort tab hint is not a correctness fence. + */ + isOldWriterDrainConfirmed( + signal: AbortSignal | undefined, + ): boolean | Promise; + migrate( + source: IndexedDbDataMigrationSource, + ): + | IndexedDbCodecResult + | Promise>; +} + +export type IndexedDbCountBucket = + | "0" + | "1" + | "2-10" + | "11-100" + | "101+"; + +/** + * Safe observation event. It intentionally contains no database/store/index + * name, key, account identifier, value or native exception. + */ +export type IndexedDbObservation = Readonly<{ + operation: BrowserDataOperation; + outcome: "SUCCESS" | "FAILED" | "ABORTED" | "BLOCKED"; + schemaVersion: number; + countBucket: IndexedDbCountBucket; + failureCode?: BrowserDataFailureCode; +}>; + +export type IndexedDbScheduler = Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +export type IndexedDbDurabilityPolicy = Readonly<{ + read?: "default" | "strict" | "relaxed"; + write?: "default" | "strict" | "relaxed"; +}>; + +export type IndexedDbRuntimeDependencies = Readonly<{ + scope: IndexedDbDatasetScope; + storagePolicy: BrowserStoragePolicy; + /** + * Optional deployment assertion only. It cannot override the derived name + * and construction fails unless it is byte-for-byte equal. + */ + databaseNameAssertion?: string; + schemaVersion: number; + recordStore: string; + governanceStore: string; + retentionStore: string; + retentionEligibilityIndex: string; + /** + * Adapter-owned stores (for example migration checkpoints) whose metadata + * must be removed by partition/session lifecycle purge. Never include the + * immutable governance store. + */ + lifecycleMetadataStores: readonly string[]; + idempotencyStore: string; + idempotencyExpiryIndex: string; + receiptRetentionMs: number; + maxIdempotencyReceipts: number; + migrations: readonly IndexedDbSchemaMigration[]; + codec: IndexedDbCodec; + queryPolicy: IndexedDbQueryPolicy; + factory?: IDBFactory; + keyRange?: Pick< + typeof IDBKeyRange, + "only" | "lowerBound" | "upperBound" | "bound" + >; + durability?: IndexedDbDurabilityPolicy; + blockedTimeoutMs?: number; + nowEpochMilliseconds?: () => number; + scheduler?: IndexedDbScheduler; + nowMonotonicMilliseconds?: () => number; + authorizeLifecycle( + request: IndexedDbLifecycleAuthorityRequest, + ): + | IndexedDbLifecycleAuthorityDecision + | Promise; + observe?: (event: IndexedDbObservation) => void; + onVersionChange?: ( + status: Extract, + ) => void; +}>; + +export type IndexedDbMaintenanceDependencies = Readonly<{ + scope: IndexedDbDatasetScope; + storagePolicy: BrowserStoragePolicy; + databaseNameAssertion?: string; + schemaVersion: number; + recordStore: string; + governanceStore: string; + retentionStore: string; + checkpointStore: string; + checkpointKey: string; + idempotencyStore: string; + idempotencyExpiryIndex: string; + migrationPolicy: IndexedDbDataMigrationPolicy; + factory?: IDBFactory; + keyRange?: Pick< + typeof IDBKeyRange, + "lowerBound" | "upperBound" + >; + durability?: IndexedDbDurabilityPolicy; + now?: () => number; + nowEpochMilliseconds?: () => number; + observe?: (event: IndexedDbObservation) => void; +}>; diff --git a/src/adapters/storage/opfs/browser-opfs-runtime.ts b/src/adapters/storage/opfs/browser-opfs-runtime.ts new file mode 100644 index 0000000..f000f10 --- /dev/null +++ b/src/adapters/storage/opfs/browser-opfs-runtime.ts @@ -0,0 +1,217 @@ +import type { + DurableObjectDescriptor, + DurableObjectMaintenancePort, + DurableObjectStorePort, + OpenedDurableObject, + OpfsCapabilities, + OpfsPolicyMaintenanceReport, + OpfsReconciliationReport, + OpfsStorageScope, +} from "../../../application/ports/browser-file-storage/opfs-ports.ts"; +import { + type BrowserDataFailureCode, + type BrowserDataResult, + type BrowserStoragePolicy, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { + createIndexedDbOpfsJournal, + type IndexedDbOpfsJournal, +} from "./indexeddb-opfs-journal.ts"; +import { + createOpfsByteStoreAdapter, + type OpfsMaintenanceAuthorityConsumer, + type OpfsMaintenanceAuthorityProvider, +} from "./opfs-byte-store-adapter.ts"; +import { + resolveOpfsRuntimePolicy, + snapshotOpfsStoragePolicy, + snapshotOpfsStorageScope, + type OpfsRuntimePolicy, + type OpfsSafeObserver, +} from "./opfs-policy.ts"; +import { + createOwnedOpfsWorkerClient, + type OwnedOpfsWorkerClient, +} from "./opfs-worker-client.ts"; + +export type BrowserOpfsRuntime = Readonly<{ + objects: DurableObjectStorePort; + maintenance: DurableObjectMaintenancePort; + close(): void; +}>; + +export type BrowserOpfsRuntimeDependencies = Readonly<{ + workerUrl: string | URL; + workerName?: string; + /** + * Optional only for assertion/testing. When provided it must equal the + * deterministic name derived from scope.authorityToken. + */ + databaseName?: string; + scope: OpfsStorageScope; + storagePolicy: BrowserStoragePolicy; + policy: OpfsRuntimePolicy; + indexedDbFactory?: IDBFactory; + createTransactionId?: () => string; + createWorkerRequestId?: () => string; + createFencingToken?: () => string; + now?: () => number; + blockedTimeoutMs?: number; + observer?: OpfsSafeObserver; + requestMaintenanceAuthority?: OpfsMaintenanceAuthorityProvider; + consumeMaintenanceAuthority?: OpfsMaintenanceAuthorityConsumer; +}>; + +/** + * Optional owned composition. Importing this module has no side effects and + * does not add OPFS to the default bootstrap or bundle. The caller must point + * workerUrl at an entry that starts startBrowserOpfsDedicatedWorker with the + * same resolved policy. + */ +export function createBrowserOpfsRuntime( + inputDependencies: BrowserOpfsRuntimeDependencies, +): BrowserOpfsRuntime { + const policy = resolveOpfsRuntimePolicy(inputDependencies.policy); + const scope = snapshotOpfsStorageScope(inputDependencies.scope); + const storagePolicy = snapshotOpfsStoragePolicy( + inputDependencies.storagePolicy, + ); + if ( + storagePolicy.namespace !== scope.namespace + ) { + throw new TypeError("Browser OPFS scope binding is invalid."); + } + const dependencies: BrowserOpfsRuntimeDependencies = Object.freeze({ + ...inputDependencies, + scope, + storagePolicy, + policy, + }); + const support = inspectBrowserOpfsSupport(policy); + if (!support.ok) { + return failedBrowserOpfsRuntime("UNSUPPORTED"); + } + const journal: IndexedDbOpfsJournal = createIndexedDbOpfsJournal({ + authorityToken: dependencies.scope.authorityToken, + databaseName: dependencies.databaseName, + factory: dependencies.indexedDbFactory, + createFencingToken: dependencies.createFencingToken, + blockedTimeoutMs: dependencies.blockedTimeoutMs, + }); + + let workerClient: OwnedOpfsWorkerClient; + try { + workerClient = createOwnedOpfsWorkerClient({ + workerUrl: dependencies.workerUrl, + workerName: dependencies.workerName, + policy, + createRequestId: dependencies.createWorkerRequestId, + }); + } catch { + journal.close(); + return failedBrowserOpfsRuntime("UNAVAILABLE"); + } + + const byteStore = createOpfsByteStoreAdapter({ + journal, + worker: workerClient.gateway, + scope: dependencies.scope, + storagePolicy: dependencies.storagePolicy, + policy, + createTransactionId: dependencies.createTransactionId, + now: dependencies.now, + observer: dependencies.observer, + requestMaintenanceAuthority: + dependencies.requestMaintenanceAuthority, + consumeMaintenanceAuthority: + dependencies.consumeMaintenanceAuthority, + }); + let closed = false; + + return Object.freeze({ + ...byteStore, + close() { + if (closed) return; + closed = true; + workerClient.terminate(); + journal.close(); + }, + }); +} + +/** + * Side-effect-free platform probe. It is also the single preflight used by + * createBrowserOpfsRuntime, so unsupported engines return the same closed + * Result contract instead of throwing during Worker construction. + */ +export function inspectBrowserOpfsSupport( + policy: OpfsRuntimePolicy = resolveOpfsRuntimePolicy(), +): BrowserDataResult { + const dedicatedWorkerAvailable = typeof Worker !== "undefined"; + const opfsAvailable = + typeof navigator !== "undefined" && + typeof navigator.storage?.getDirectory === "function"; + const webLocksAvailable = + typeof navigator !== "undefined" && + typeof navigator.locks?.request === "function"; + const synchronousAccessHandleAvailable = + typeof FileSystemFileHandle !== "undefined" && + "createSyncAccessHandle" in FileSystemFileHandle.prototype; + const capabilities: OpfsCapabilities = Object.freeze({ + available: + dedicatedWorkerAvailable && + opfsAvailable && + webLocksAvailable && + (synchronousAccessHandleAvailable || + policy.allowAsyncWritableChunkFallback), + dedicatedWorkerRequired: true, + crossContextMutationLockAvailable: webLocksAvailable, + synchronousAccessHandleAvailable, + }); + return capabilities.available + ? browserDataSuccess(capabilities) + : browserDataFailure("UNSUPPORTED", "OBJECT_READ", { + recovery: "ONLINE_ONLY", + }); +} + +function failedBrowserOpfsRuntime( + code: Extract< + BrowserDataFailureCode, + "UNAVAILABLE" | "UNSUPPORTED" + >, +): BrowserOpfsRuntime { + const failure = ( + operation: + | "OBJECT_READ" + | "OBJECT_WRITE" + | "OBJECT_DELETE" + | "OBJECT_RECONCILE", + ): BrowserDataResult => + browserDataFailure(code, operation, { + retryable: code === "UNAVAILABLE", + recovery: code === "UNAVAILABLE" ? "RETRY" : "ONLINE_ONLY", + }); + return Object.freeze({ + objects: Object.freeze({ + capabilities: async () => + failure("OBJECT_READ"), + put: async () => + failure("OBJECT_WRITE"), + open: async () => + failure("OBJECT_READ"), + remove: async () => failure("OBJECT_DELETE"), + }), + maintenance: Object.freeze({ + reconcile: async () => + failure("OBJECT_RECONCILE"), + enforcePolicies: async () => + failure("OBJECT_RECONCILE"), + }), + close() {}, + }); +} diff --git a/src/adapters/storage/opfs/index.ts b/src/adapters/storage/opfs/index.ts new file mode 100644 index 0000000..877db50 --- /dev/null +++ b/src/adapters/storage/opfs/index.ts @@ -0,0 +1,53 @@ +export { + createBrowserOpfsRuntime, + inspectBrowserOpfsSupport, + type BrowserOpfsRuntime, + type BrowserOpfsRuntimeDependencies, +} from "./browser-opfs-runtime.ts"; +export { + createOpfsByteStoreAdapter, + type OpfsByteStore, + type OpfsByteStoreDependencies, + type OpfsMaintenanceAuthorityConsumer, + type OpfsMaintenanceAuthorityDecision, + type OpfsMaintenanceAuthorityProvider, + type OpfsMaintenanceAuthorityRequest, +} from "./opfs-byte-store-adapter.ts"; +export { + createIndexedDbOpfsJournal, + opfsJournalDatabaseName, + type IndexedDbOpfsJournal, + type IndexedDbOpfsJournalDependencies, +} from "./indexeddb-opfs-journal.ts"; +export { + DEFAULT_OPFS_RUNTIME_POLICY, + resolveOpfsRuntimePolicy, + type OpfsRuntimePolicy, + type OpfsSafeObservation, + type OpfsSafeObserver, +} from "./opfs-policy.ts"; +export { + createOpfsWorkerGateway, + createOwnedOpfsWorkerClient, + type OpfsWorkerClientDependencies, + type OpfsWorkerLike, + type OwnedOpfsWorkerClient, +} from "./opfs-worker-client.ts"; +export { + createBrowserOpfsWorkerRuntime, + createOpfsWorkerRuntime, + createWebLockLeaseManager, + installOpfsWorkerMessageHandler, + startBrowserOpfsDedicatedWorker, + type BrowserOpfsWorkerDependencies, + type OpfsMutationLease, + type OpfsMutationLeaseManager, + type OpfsWorkerMessageHost, + type OpfsWorkerRuntime, +} from "./opfs-worker-runtime.ts"; +export type { + OpfsWorkerGateway, + OpfsWorkerRequest, + OpfsWorkerRequestBody, + OpfsWorkerResponse, +} from "./opfs-worker-protocol.ts"; diff --git a/src/adapters/storage/opfs/indexeddb-opfs-journal.ts b/src/adapters/storage/opfs/indexeddb-opfs-journal.ts new file mode 100644 index 0000000..6b2b8df --- /dev/null +++ b/src/adapters/storage/opfs/indexeddb-opfs-journal.ts @@ -0,0 +1,1803 @@ +import type { + BeginOpfsJournalTransaction, + OpfsCommittedObjectPage, + OpfsJournalPage, + OpfsJournalPort, + OpfsJournalTransaction, + OpfsPreparedObject, + OpfsStorageScope, +} from "../../../application/ports/browser-file-storage/opfs-ports.ts"; +import { + assertValidStoragePolicy, + type BrowserDataFailure, + type BrowserDataOperation, + type BrowserDataResult, + type BrowserStoragePolicy, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { mapIndexedDbException } from "../indexeddb/indexeddb-failure.ts"; +import { isValidOpfsStorageScope } from "./opfs-policy.ts"; + +export type IndexedDbOpfsJournalDependencies = Readonly<{ + authorityToken: string; + databaseName?: string; + factory?: IDBFactory; + keyRange?: Pick; + createFencingToken?: () => string; + blockedTimeoutMs?: number; + scheduler?: Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; + }>; + observe?: ( + event: Readonly<{ + operation: BrowserDataOperation; + outcome: "SUCCEEDED" | "FAILED"; + failureCode?: BrowserDataFailure["code"]; + }>, + ) => void; +}>; + +export interface IndexedDbOpfsJournal extends OpfsJournalPort { + close(): void; +} + +type TransactionContext = Readonly<{ + succeed(value: Value): void; + fail(result: BrowserDataResult): void; +}>; + +type StoredJournalRow = OpfsJournalTransaction & + Readonly<{ logicalKey: string }>; + +type StoredObjectRow = Readonly<{ + logicalKey: string; + scopeObjectKey: string; + scopeKey: string; + objectId: string; + preparedObject: OpfsPreparedObject; +}>; + +type StoredBudgetRow = Readonly<{ + budgetKey: string; + namespace: string; + authorityToken: string; + namespaceToken: string; + partitionToken: string; + hardBudgetBytes: number; + committedBytes: number; + reservedBytes: number; +}>; + +type StoredScopeBinding = Readonly<{ + scopeKey: string; + namespace: string; + authorityToken: string; + namespaceToken: string; + partitionToken: string; + policyFingerprint: string; +}>; + +type StoredLogicalScopeBinding = Readonly<{ + logicalScopeKey: string; + physicalScopeKey: string; + authorityToken: string; + namespace: string; + namespaceToken: string; + partitionToken: string; +}>; + +type StoredChunkReference = Readonly<{ + referenceKey: string; + scopeKey: string; + digestHex: string; + referenceCount: number; +}>; + +const DATABASE_VERSION = 1; +const JOURNAL_STORE = "opfs-journal"; +const OBJECT_STORE = "opfs-objects"; +const BUDGET_STORE = "opfs-budgets"; +const SCOPE_BINDING_STORE = "opfs-scope-bindings"; +const LOGICAL_SCOPE_BINDING_STORE = "opfs-logical-scope-bindings"; +const CHUNK_REFERENCE_STORE = "opfs-chunk-references"; +const LOGICAL_KEY_INDEX = "by-logical-key"; +const STARTED_AT_INDEX = "by-started-at"; +const SCOPE_OBJECT_INDEX = "by-scope-object"; +const SAFE_DATABASE_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; +const SAFE_BOUNDARY_ID = /^[A-Za-z0-9_-]{8,128}$/u; +const SHA256_HEX = /^[a-f0-9]{64}$/u; + +export function opfsJournalDatabaseName( + authorityToken: string, +): string { + if (!SAFE_BOUNDARY_ID.test(authorityToken)) { + throw new TypeError("OPFS authority token is invalid."); + } + return `ca-frontend-opfs-metadata-v1:${authorityToken}`; +} + +export function createIndexedDbOpfsJournal( + dependencies: IndexedDbOpfsJournalDependencies, +): IndexedDbOpfsJournal { + const expectedDatabaseName = opfsJournalDatabaseName( + dependencies.authorityToken, + ); + const databaseName = dependencies.databaseName ?? expectedDatabaseName; + const factory = + dependencies.factory ?? + (typeof globalThis.indexedDB === "undefined" + ? undefined + : globalThis.indexedDB); + const keyRange = + dependencies.keyRange ?? + (typeof globalThis.IDBKeyRange === "undefined" + ? undefined + : globalThis.IDBKeyRange); + const createFencingToken = + dependencies.createFencingToken ?? + (() => globalThis.crypto.randomUUID()); + const blockedTimeoutMs = dependencies.blockedTimeoutMs ?? 10_000; + const scheduler = + dependencies.scheduler ?? + Object.freeze({ + setTimeout: (callback: () => void, milliseconds: number) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle: unknown) => + globalThis.clearTimeout( + handle as ReturnType, + ), + }); + + if ( + !SAFE_BOUNDARY_ID.test(dependencies.authorityToken) || + !SAFE_DATABASE_NAME.test(databaseName) || + databaseName !== expectedDatabaseName || + !Number.isSafeInteger(blockedTimeoutMs) || + blockedTimeoutMs < 0 + ) { + throw new TypeError("IndexedDB OPFS journal configuration is invalid."); + } + + let database: IDBDatabase | null = null; + let opening: Promise> | null = null; + let closed = false; + + const journal: IndexedDbOpfsJournal = { + async getCommittedObject(scope, objectId) { + if ( + !isBoundScope(scope) || + !validScopedObject(scope, objectId) + ) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_READ"); + } + return await withDatabase("INDEXEDDB_READ", (db) => + runTransaction( + db, + [OBJECT_STORE], + "readonly", + "INDEXEDDB_READ", + (transaction, context) => { + const request = transaction + .objectStore(OBJECT_STORE) + .get(logicalObjectKey(scope, objectId)); + request.onsuccess = () => { + if (request.result === undefined) { + context.succeed(null); + return; + } + if ( + !isStoredObjectRow(request.result) || + !sameScope(request.result.preparedObject.descriptor.scope, scope) + ) { + context.fail(corrupt("INDEXEDDB_READ")); + return; + } + context.succeed(request.result.preparedObject); + }; + }, + ), + ); + }, + + async begin(input) { + if ( + !isBoundScope(input.scope) || + !isBeginTransaction(input) + ) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE"); + } + const fencingToken = createFencingToken(); + if (!SAFE_BOUNDARY_ID.test(fencingToken)) { + return browserDataFailure("UNAVAILABLE", "INDEXEDDB_WRITE", { + recovery: "REOPEN", + }); + } + return await withDatabase("INDEXEDDB_WRITE", (db) => + runTransaction( + db, + [ + JOURNAL_STORE, + OBJECT_STORE, + BUDGET_STORE, + SCOPE_BINDING_STORE, + LOGICAL_SCOPE_BINDING_STORE, + ], + "readwrite", + "INDEXEDDB_WRITE", + (nativeTransaction, context) => { + const bindingStore = + nativeTransaction.objectStore(SCOPE_BINDING_STORE); + const scopeKey = storageScopeKey(input.scope); + const bindingRequest = bindingStore.get(scopeKey); + bindingRequest.onsuccess = () => { + const storedBinding = bindingRequest.result; + if ( + storedBinding !== undefined && + !isScopeBinding(storedBinding) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + const expectedBinding = scopeBinding( + input.scope, + input.targetStoragePolicy, + ); + const logicalBindingStore = nativeTransaction.objectStore( + LOGICAL_SCOPE_BINDING_STORE, + ); + const expectedLogicalBinding = logicalScopeBinding( + input.scope, + ); + const logicalRequest = logicalBindingStore.get( + expectedLogicalBinding.logicalScopeKey, + ); + logicalRequest.onsuccess = () => { + const storedLogicalBinding = logicalRequest.result; + if ( + storedLogicalBinding !== undefined && + !isLogicalScopeBinding(storedLogicalBinding) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + if ( + (storedBinding && + stableJson(storedBinding) !== + stableJson(expectedBinding)) || + (storedLogicalBinding && + stableJson(storedLogicalBinding) !== + stableJson(expectedLogicalBinding)) + ) { + context.fail(policyRejected("INDEXEDDB_WRITE")); + return; + } + if ( + (storedBinding === undefined) !== + (storedLogicalBinding === undefined) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + if (!storedBinding) { + bindingStore.add(expectedBinding); + logicalBindingStore.add(expectedLogicalBinding); + } + }; + }; + const logicalKey = logicalObjectKey( + input.scope, + input.objectId, + ); + const objectRequest = nativeTransaction + .objectStore(OBJECT_STORE) + .get(logicalKey); + objectRequest.onsuccess = () => { + const storedCurrent = objectRequest.result; + if ( + storedCurrent !== undefined && + !isStoredObjectRow(storedCurrent) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + const current: OpfsPreparedObject | undefined = + storedCurrent?.preparedObject; + if ( + !generationMatches( + current, + input.expectedGeneration, + ) || + input.targetGeneration !== + (current?.descriptor.generation ?? 0) + 1 + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + const currentBytes = + current?.descriptor.byteLength ?? 0; + const reservedBytes = + input.mutation === "PUT" + ? Math.max(0, input.targetByteLength - currentBytes) + : 0; + const budgetKey = storageBudgetKey(input.scope); + const budgetRequest = nativeTransaction + .objectStore(BUDGET_STORE) + .get(budgetKey); + budgetRequest.onsuccess = () => { + const budgetResult = budgetRequest.result; + if ( + budgetResult !== undefined && + !isBudgetRow(budgetResult) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + if ( + budgetResult && + (budgetResult.hardBudgetBytes !== + input.targetStoragePolicy.hardBudgetBytes || + budgetResult.namespace !== input.scope.namespace || + budgetResult.authorityToken !== + input.scope.authorityToken || + budgetResult.namespaceToken !== + input.scope.namespaceToken || + budgetResult.partitionToken !== + input.scope.partitionToken) + ) { + context.fail(policyRejected("INDEXEDDB_WRITE")); + return; + } + if (!budgetResult && current) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + const budget: StoredBudgetRow = + budgetResult ?? { + budgetKey, + namespace: input.scope.namespace, + authorityToken: input.scope.authorityToken, + namespaceToken: input.scope.namespaceToken, + partitionToken: input.scope.partitionToken, + hardBudgetBytes: + input.targetStoragePolicy.hardBudgetBytes, + committedBytes: 0, + reservedBytes: 0, + }; + if ( + budget.committedBytes + + budget.reservedBytes + + reservedBytes > + budget.hardBudgetBytes + ) { + context.fail(limitExceeded("INDEXEDDB_WRITE")); + return; + } + const nextBudget: StoredBudgetRow = Object.freeze({ + ...budget, + reservedBytes: + budget.reservedBytes + reservedBytes, + }); + nativeTransaction + .objectStore(BUDGET_STORE) + .put(nextBudget); + const row: StoredJournalRow = Object.freeze({ + ...input, + logicalKey, + fencingToken, + phase: "PREPARING", + budgetReservation: Object.freeze({ + namespace: input.scope.namespace, + reservedBytes, + hardBudgetBytes: + input.targetStoragePolicy.hardBudgetBytes, + }), + }); + nativeTransaction + .objectStore(JOURNAL_STORE) + .add(row); + context.succeed(row); + }; + }; + }, + ), + ); + }, + + async markFilesReady( + transactionId, + fencingToken, + preparedObject, + ) { + if ( + !SAFE_BOUNDARY_ID.test(transactionId) || + !SAFE_BOUNDARY_ID.test(fencingToken) || + !isPreparedObject(preparedObject) || + !isBoundScope(preparedObject.descriptor.scope) + ) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE"); + } + return await updateJournalRow( + transactionId, + fencingToken, + (row, context, store) => { + if ( + row.mutation !== "PUT" || + row.objectId !== preparedObject.descriptor.objectId || + !sameScope(row.scope, preparedObject.descriptor.scope) || + row.targetGeneration !== + preparedObject.descriptor.generation || + row.targetByteLength !== + preparedObject.descriptor.byteLength || + stableJson(row.targetStoragePolicy) !== + stableJson(preparedObject.descriptor.storagePolicy) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + if (row.phase === "FILES_READY") { + if ( + !row.preparedObject || + stableJson(row.preparedObject) !== + stableJson(preparedObject) + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + context.succeed(row); + return; + } + if (row.phase !== "PREPARING") { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + const updated: StoredJournalRow = Object.freeze({ + ...row, + phase: "FILES_READY", + preparedObject, + }); + store.put(updated); + context.succeed(updated); + }, + ); + }, + + async commitPut(transactionId, fencingToken) { + return await commitMutation( + transactionId, + fencingToken, + "PUT", + ); + }, + + async commitDelete(transactionId, fencingToken) { + return await commitMutation( + transactionId, + fencingToken, + "DELETE", + ); + }, + + async complete(transactionId, fencingToken) { + if (!validTransactionIdentity(transactionId, fencingToken)) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE"); + } + return await withDatabase("INDEXEDDB_WRITE", (db) => + runTransaction( + db, + [JOURNAL_STORE], + "readwrite", + "INDEXEDDB_WRITE", + (transaction, context) => { + const store = transaction.objectStore(JOURNAL_STORE); + const request = store.get(transactionId); + request.onsuccess = () => { + if (request.result === undefined) { + context.succeed(undefined); + return; + } + if ( + !isStoredJournalRow(request.result) || + !isBoundScope(request.result.scope) || + request.result.fencingToken !== fencingToken || + request.result.phase !== "COMMITTED" + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + store.delete(transactionId); + context.succeed(undefined); + }; + }, + ), + ); + }, + + async rollback(transactionId, fencingToken) { + if (!validTransactionIdentity(transactionId, fencingToken)) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE"); + } + return await withDatabase("INDEXEDDB_WRITE", (db) => + runTransaction( + db, + [JOURNAL_STORE, BUDGET_STORE], + "readwrite", + "INDEXEDDB_WRITE", + (transaction, context) => { + const journalStore = + transaction.objectStore(JOURNAL_STORE); + const request = journalStore.get(transactionId); + request.onsuccess = () => { + if (request.result === undefined) { + context.succeed(undefined); + return; + } + const row = request.result; + if ( + !isStoredJournalRow(row) || + !isBoundScope(row.scope) || + row.fencingToken !== fencingToken || + row.phase === "COMMITTED" + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + const budgetStore = + transaction.objectStore(BUDGET_STORE); + const budgetRequest = budgetStore.get( + storageBudgetKey(row.scope), + ); + budgetRequest.onsuccess = () => { + if (!isBudgetRow(budgetRequest.result)) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + const budget = budgetRequest.result; + if ( + budget.reservedBytes < + row.budgetReservation.reservedBytes + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + putOrDeleteBudget( + budgetStore, + Object.freeze({ + ...budget, + reservedBytes: + budget.reservedBytes - + row.budgetReservation.reservedBytes, + }), + ); + journalStore.delete(transactionId); + context.succeed(undefined); + }; + }; + }, + ), + ); + }, + + async listIncomplete(limit) { + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 1_000 + ) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_READ"); + } + return await withDatabase("INDEXEDDB_READ", (db) => + runTransaction( + db, + [JOURNAL_STORE], + "readonly", + "INDEXEDDB_READ", + (transaction, context) => { + const rows: OpfsJournalTransaction[] = []; + const request = transaction + .objectStore(JOURNAL_STORE) + .index(STARTED_AT_INDEX) + .openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + context.succeed( + Object.freeze({ + transactions: Object.freeze(rows), + moreAvailable: false, + }), + ); + return; + } + if ( + !isStoredJournalRow(cursor.value) || + !isBoundScope(cursor.value.scope) + ) { + context.fail(corrupt("INDEXEDDB_READ")); + return; + } + if (rows.length === limit) { + context.succeed( + Object.freeze({ + transactions: Object.freeze(rows), + moreAvailable: true, + }), + ); + return; + } + rows.push(cursor.value); + cursor.continue(); + }; + }, + ), + ); + }, + + async listCommittedObjects(request) { + if ( + !keyRange || + !isBoundScope(request.scope) || + !isValidOpfsStorageScope(request.scope) || + !Number.isSafeInteger(request.limit) || + request.limit < 1 || + request.limit > 1_000 || + (request.afterObjectId !== undefined && + !SAFE_BOUNDARY_ID.test(request.afterObjectId)) + ) { + return browserDataFailure( + keyRange ? "INVALID_INPUT" : "UNSUPPORTED", + "INDEXEDDB_READ", + ); + } + return await withDatabase("INDEXEDDB_READ", (db) => + runTransaction( + db, + [OBJECT_STORE], + "readonly", + "INDEXEDDB_READ", + (transaction, context) => { + const prefix = `${storageScopeKey(request.scope)}|`; + const lower = request.afterObjectId + ? `${prefix}${request.afterObjectId}` + : prefix; + const range = keyRange.bound( + lower, + `${prefix}\uffff`, + request.afterObjectId !== undefined, + false, + ); + const objects: OpfsPreparedObject[] = []; + const cursorRequest = transaction + .objectStore(OBJECT_STORE) + .index(SCOPE_OBJECT_INDEX) + .openCursor(range); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) { + context.succeed( + Object.freeze({ + objects: Object.freeze(objects), + nextObjectId: null, + moreAvailable: false, + }), + ); + return; + } + if ( + !isStoredObjectRow(cursor.value) || + !sameScope( + cursor.value.preparedObject.descriptor.scope, + request.scope, + ) + ) { + context.fail(corrupt("INDEXEDDB_READ")); + return; + } + if (objects.length === request.limit) { + context.succeed( + Object.freeze({ + objects: Object.freeze(objects), + nextObjectId: + objects.at(-1)?.descriptor.objectId ?? null, + moreAvailable: true, + }), + ); + return; + } + objects.push(cursor.value.preparedObject); + cursor.continue(); + }; + }, + ), + ); + }, + + async isChunkReferenced(scope, digestHex) { + if ( + !isBoundScope(scope) || + !isValidOpfsStorageScope(scope) || + !SHA256_HEX.test(digestHex) + ) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_READ"); + } + const referenceKey = chunkReferenceKey(scope, digestHex); + return await withDatabase("INDEXEDDB_READ", (db) => + runTransaction( + db, + [CHUNK_REFERENCE_STORE], + "readonly", + "INDEXEDDB_READ", + (transaction, context) => { + const request = transaction + .objectStore(CHUNK_REFERENCE_STORE) + .get(referenceKey); + request.onsuccess = () => { + if (request.result === undefined) { + context.succeed(false); + return; + } + if ( + !isChunkReference(request.result) || + request.result.referenceKey !== referenceKey || + request.result.scopeKey !== storageScopeKey(scope) + ) { + context.fail(corrupt("INDEXEDDB_READ")); + return; + } + context.succeed(request.result.referenceCount > 0); + }; + }, + ), + ); + }, + + close() { + closed = true; + database?.close(); + database = null; + opening = null; + }, + }; + + return Object.freeze(journal); + + async function commitMutation( + transactionId: string, + fencingToken: string, + mutation: "PUT" | "DELETE", + ): Promise> { + if (!validTransactionIdentity(transactionId, fencingToken)) { + return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE"); + } + return await withDatabase("INDEXEDDB_WRITE", (db) => + runTransaction( + db, + [ + JOURNAL_STORE, + OBJECT_STORE, + BUDGET_STORE, + CHUNK_REFERENCE_STORE, + ], + "readwrite", + "INDEXEDDB_WRITE", + (nativeTransaction, context) => { + const journalStore = + nativeTransaction.objectStore(JOURNAL_STORE); + const journalRequest = journalStore.get(transactionId); + journalRequest.onsuccess = () => { + const row = journalRequest.result; + if ( + !isStoredJournalRow(row) || + !isBoundScope(row.scope) || + row.fencingToken !== fencingToken || + row.mutation !== mutation + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + if (row.phase === "COMMITTED") { + context.succeed(row); + return; + } + if ( + mutation === "PUT" && + (row.phase !== "FILES_READY" || !row.preparedObject) + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + if (mutation === "DELETE" && row.phase !== "PREPARING") { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + + const objectStore = + nativeTransaction.objectStore(OBJECT_STORE); + const objectRequest = objectStore.get(row.logicalKey); + objectRequest.onsuccess = () => { + const storedCurrent = objectRequest.result; + if ( + storedCurrent !== undefined && + !isStoredObjectRow(storedCurrent) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + const current: OpfsPreparedObject | undefined = + storedCurrent?.preparedObject; + if ( + !generationMatches(current, row.expectedGeneration) + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + const budgetStore = + nativeTransaction.objectStore(BUDGET_STORE); + const budgetRequest = budgetStore.get( + storageBudgetKey(row.scope), + ); + budgetRequest.onsuccess = () => { + if (!isBudgetRow(budgetRequest.result)) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + const budget = budgetRequest.result; + const currentBytes = + current?.descriptor.byteLength ?? 0; + const nextBytes = + mutation === "PUT" ? row.targetByteLength : 0; + const nextCommitted = + budget.committedBytes - currentBytes + nextBytes; + const nextReserved = + budget.reservedBytes - + row.budgetReservation.reservedBytes; + if ( + nextCommitted < 0 || + nextReserved < 0 || + nextCommitted + nextReserved > + budget.hardBudgetBytes + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + + const referenceDeltas = chunkReferenceDeltas( + current, + mutation === "PUT" ? row.preparedObject : undefined, + ); + applyChunkReferenceDeltas( + nativeTransaction.objectStore( + CHUNK_REFERENCE_STORE, + ), + row.scope, + referenceDeltas, + context, + () => { + putOrDeleteBudget( + budgetStore, + Object.freeze({ + ...budget, + committedBytes: nextCommitted, + reservedBytes: nextReserved, + }), + ); + if (mutation === "PUT") { + objectStore.put( + storedObjectRow(row.preparedObject!), + ); + } else { + objectStore.delete(row.logicalKey); + } + const committed: StoredJournalRow = Object.freeze({ + ...row, + phase: "COMMITTED", + }); + journalStore.put(committed); + context.succeed(committed); + }, + ); + }; + }; + }; + }, + ), + ); + } + + async function updateJournalRow( + transactionId: string, + fencingToken: string, + update: ( + row: StoredJournalRow, + context: TransactionContext, + store: IDBObjectStore, + ) => void, + ): Promise> { + return await withDatabase("INDEXEDDB_WRITE", (db) => + runTransaction( + db, + [JOURNAL_STORE], + "readwrite", + "INDEXEDDB_WRITE", + (transaction, context) => { + const store = transaction.objectStore(JOURNAL_STORE); + const request = store.get(transactionId); + request.onsuccess = () => { + if ( + !isStoredJournalRow(request.result) || + !isBoundScope(request.result.scope) || + request.result.fencingToken !== fencingToken + ) { + context.fail(conflict("INDEXEDDB_WRITE")); + return; + } + update(request.result, context, store); + }; + }, + ), + ); + } + + async function withDatabase( + operation: BrowserDataOperation, + task: (db: IDBDatabase) => Promise>, + ): Promise> { + const opened = await openDatabase(); + if (!opened.ok) return opened; + try { + const result = await task(opened.value); + observe(result, operation); + return result; + } catch (error) { + const result = mapIndexedDbException(error, operation); + observe(result, operation); + return result; + } + } + + async function openDatabase(): Promise> { + if (closed || !factory) { + return browserDataFailure("UNSUPPORTED", "INDEXEDDB_OPEN", { + recovery: "ONLINE_ONLY", + }); + } + if (database) return browserDataSuccess(database); + if (opening) return await opening; + + const currentOpening = new Promise>( + (resolve) => { + let settled = false; + let blockedTimer: unknown; + const settle = ( + result: BrowserDataResult, + ): void => { + if (settled) return; + settled = true; + if (blockedTimer !== undefined) { + scheduler.clearTimeout(blockedTimer); + } + resolve(result); + }; + let request: IDBOpenDBRequest; + try { + request = factory.open(databaseName, DATABASE_VERSION); + } catch (error) { + settle(mapIndexedDbException(error, "INDEXEDDB_OPEN")); + return; + } + request.onupgradeneeded = (event) => { + const db = request.result; + if (event.oldVersion !== 0) { + request.transaction?.abort(); + return; + } + const journalStore = db.createObjectStore(JOURNAL_STORE, { + keyPath: "transactionId", + }); + journalStore.createIndex( + LOGICAL_KEY_INDEX, + "logicalKey", + { unique: true }, + ); + journalStore.createIndex( + STARTED_AT_INDEX, + "startedAtEpochMs", + { unique: false }, + ); + const objectStore = db.createObjectStore(OBJECT_STORE, { + keyPath: "logicalKey", + }); + objectStore.createIndex( + SCOPE_OBJECT_INDEX, + "scopeObjectKey", + { unique: true }, + ); + db.createObjectStore(BUDGET_STORE, { + keyPath: "budgetKey", + }); + db.createObjectStore(SCOPE_BINDING_STORE, { + keyPath: "scopeKey", + }); + db.createObjectStore(LOGICAL_SCOPE_BINDING_STORE, { + keyPath: "logicalScopeKey", + }); + db.createObjectStore(CHUNK_REFERENCE_STORE, { + keyPath: "referenceKey", + }); + }; + request.onblocked = () => { + if (blockedTimer !== undefined) return; + blockedTimer = scheduler.setTimeout(() => { + settle( + browserDataFailure("BLOCKED", "INDEXEDDB_OPEN", { + retryable: true, + recovery: "RELOAD_OTHER_CONTEXTS", + }), + ); + }, blockedTimeoutMs); + }; + request.onerror = () => + settle( + mapIndexedDbException( + request.error, + "INDEXEDDB_OPEN", + ), + ); + request.onsuccess = () => { + if (settled || closed) { + request.result.close(); + return; + } + database = request.result; + database.onversionchange = () => { + database?.close(); + database = null; + opening = null; + }; + database.onclose = () => { + database = null; + opening = null; + }; + settle(browserDataSuccess(request.result)); + }; + }, + ).finally(() => { + if (opening === currentOpening) opening = null; + }); + opening = currentOpening; + return await currentOpening; + } + + function observe( + result: BrowserDataResult, + operation: BrowserDataOperation, + ): void { + try { + dependencies.observe?.( + result.ok + ? { operation, outcome: "SUCCEEDED" } + : { + operation, + outcome: "FAILED", + failureCode: result.error.code, + }, + ); + } catch { + // Journal behavior never depends on telemetry. + } + } + + function isBoundScope(scope: OpfsStorageScope): boolean { + return scope.authorityToken === dependencies.authorityToken; + } +} + +function runTransaction( + database: IDBDatabase, + storeNames: readonly string[], + mode: IDBTransactionMode, + operation: BrowserDataOperation, + run: ( + transaction: IDBTransaction, + context: TransactionContext, + ) => void, +): Promise> { + return new Promise((resolve) => { + let value: Value | undefined; + let hasValue = false; + let explicitFailure: BrowserDataResult | null = null; + let settled = false; + let transaction: IDBTransaction; + try { + transaction = + mode === "readwrite" + ? strictReadwriteTransaction(database, storeNames) + : database.transaction([...storeNames], mode); + } catch (error) { + resolve(mapIndexedDbException(error, operation)); + return; + } + const settle = (result: BrowserDataResult): void => { + if (settled) return; + settled = true; + resolve(result); + }; + const context: TransactionContext = { + succeed(nextValue) { + value = nextValue; + hasValue = true; + }, + fail(result) { + if (explicitFailure) return; + explicitFailure = result; + try { + transaction.abort(); + } catch { + settle(result); + } + }, + }; + transaction.oncomplete = () => { + if (!hasValue) { + settle( + browserDataFailure("UNAVAILABLE", operation, { + retryable: true, + recovery: "REOPEN", + }), + ); + return; + } + settle(browserDataSuccess(value as Value)); + }; + transaction.onabort = () => + settle( + explicitFailure ?? + mapIndexedDbException(transaction.error, operation), + ); + transaction.onerror = () => { + // onabort owns the single failure result. + }; + try { + run(transaction, context); + } catch (error) { + explicitFailure = mapIndexedDbException(error, operation); + try { + transaction.abort(); + } catch { + settle(explicitFailure); + } + } + }); +} + +function strictReadwriteTransaction( + database: IDBDatabase, + storeNames: readonly string[], +): IDBTransaction { + try { + return database.transaction([...storeNames], "readwrite", { + durability: "strict", + }); + } catch (error) { + if (error instanceof TypeError) { + return database.transaction([...storeNames], "readwrite"); + } + throw error; + } +} + +function applyChunkReferenceDeltas( + store: IDBObjectStore, + scope: OpfsStorageScope, + deltas: ReadonlyMap, + context: TransactionContext, + completed: () => void, +): void { + const entries = [...deltas].filter(([, delta]) => delta !== 0); + if (entries.length === 0) { + completed(); + return; + } + let remaining = entries.length; + for (const [digestHex, delta] of entries) { + const referenceKey = chunkReferenceKey(scope, digestHex); + const request = store.get(referenceKey); + request.onsuccess = () => { + if ( + request.result !== undefined && + (!isChunkReference(request.result) || + request.result.referenceKey !== referenceKey || + request.result.scopeKey !== storageScopeKey(scope)) + ) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + const current = + (request.result as StoredChunkReference | undefined) + ?.referenceCount ?? 0; + const next = current + delta; + if (!Number.isSafeInteger(next) || next < 0) { + context.fail(corrupt("INDEXEDDB_WRITE")); + return; + } + if (next === 0) { + store.delete(referenceKey); + } else { + store.put( + Object.freeze({ + referenceKey, + scopeKey: storageScopeKey(scope), + digestHex, + referenceCount: next, + }), + ); + } + remaining -= 1; + if (remaining === 0) completed(); + }; + } +} + +function chunkReferenceDeltas( + previous: OpfsPreparedObject | undefined, + next: OpfsPreparedObject | undefined, +): ReadonlyMap { + const deltas = new Map(); + for (const chunk of previous?.chunks ?? []) { + deltas.set(chunk.digestHex, (deltas.get(chunk.digestHex) ?? 0) - 1); + } + for (const chunk of next?.chunks ?? []) { + deltas.set(chunk.digestHex, (deltas.get(chunk.digestHex) ?? 0) + 1); + } + return deltas; +} + +function putOrDeleteBudget( + store: IDBObjectStore, + budget: StoredBudgetRow, +): void { + if (budget.committedBytes === 0 && budget.reservedBytes === 0) { + store.delete(budget.budgetKey); + } else { + store.put(budget); + } +} + +function storedObjectRow( + preparedObject: OpfsPreparedObject, +): StoredObjectRow { + const { scope, objectId } = preparedObject.descriptor; + return Object.freeze({ + logicalKey: logicalObjectKey(scope, objectId), + scopeObjectKey: `${storageScopeKey(scope)}|${objectId}`, + scopeKey: storageScopeKey(scope), + objectId, + preparedObject, + }); +} + +function generationMatches( + current: OpfsPreparedObject | undefined, + expectedGeneration: number | null, +): boolean { + return expectedGeneration === null + ? current === undefined + : current?.descriptor.generation === expectedGeneration; +} + +function logicalObjectKey( + scope: OpfsStorageScope, + objectId: string, +): string { + return `${storageScopeKey(scope)}|${objectId}`; +} + +function storageScopeKey(scope: OpfsStorageScope): string { + return [ + scope.authorityToken, + scope.namespaceToken, + scope.partitionToken, + ].join("|"); +} + +function storageBudgetKey(scope: OpfsStorageScope): string { + return storageScopeKey(scope); +} + +function scopeBinding( + scope: OpfsStorageScope, + storagePolicy: BrowserStoragePolicy, +): StoredScopeBinding { + return Object.freeze({ + scopeKey: storageScopeKey(scope), + namespace: scope.namespace, + authorityToken: scope.authorityToken, + namespaceToken: scope.namespaceToken, + partitionToken: scope.partitionToken, + policyFingerprint: stableJson(storagePolicy), + }); +} + +function logicalScopeBinding( + scope: OpfsStorageScope, +): StoredLogicalScopeBinding { + return Object.freeze({ + logicalScopeKey: [ + scope.authorityToken, + scope.namespace, + scope.partitionToken, + ].join("|"), + physicalScopeKey: storageScopeKey(scope), + authorityToken: scope.authorityToken, + namespace: scope.namespace, + namespaceToken: scope.namespaceToken, + partitionToken: scope.partitionToken, + }); +} + +function chunkReferenceKey( + scope: OpfsStorageScope, + digestHex: string, +): string { + return `${storageScopeKey(scope)}|${digestHex}`; +} + +function sameScope( + left: OpfsStorageScope, + right: OpfsStorageScope, +): boolean { + return ( + left.namespace === right.namespace && + left.authorityToken === right.authorityToken && + left.namespaceToken === right.namespaceToken && + left.partitionToken === right.partitionToken + ); +} + +function validScopedObject( + scope: OpfsStorageScope, + objectId: string, +): boolean { + return ( + isValidOpfsStorageScope(scope) && + SAFE_BOUNDARY_ID.test(objectId) + ); +} + +function validTransactionIdentity( + transactionId: string, + fencingToken: string, +): boolean { + return ( + SAFE_BOUNDARY_ID.test(transactionId) && + SAFE_BOUNDARY_ID.test(fencingToken) + ); +} + +function isBeginTransaction( + value: BeginOpfsJournalTransaction, +): boolean { + try { + assertValidStoragePolicy(value.targetStoragePolicy); + } catch { + return false; + } + return Boolean( + validScopedObject(value.scope, value.objectId) && + SAFE_BOUNDARY_ID.test(value.transactionId) && + value.targetStoragePolicy.namespace === value.scope.namespace && + (value.mutation === "PUT" || value.mutation === "DELETE") && + (value.expectedGeneration === null || + (Number.isSafeInteger(value.expectedGeneration) && + value.expectedGeneration > 0)) && + Number.isSafeInteger(value.targetGeneration) && + value.targetGeneration > 0 && + Number.isSafeInteger(value.targetByteLength) && + value.targetByteLength >= 0 && + (value.mutation === "PUT" || value.targetByteLength === 0) && + Number.isSafeInteger(value.startedAtEpochMs) && + value.startedAtEpochMs >= 0, + ); +} + +function isStoredJournalRow( + value: unknown, +): value is StoredJournalRow { + if ( + !value || + typeof value !== "object" || + !("logicalKey" in value) || + typeof value.logicalKey !== "string" || + !("transactionId" in value) || + typeof value.transactionId !== "string" || + !SAFE_BOUNDARY_ID.test(value.transactionId) || + !("fencingToken" in value) || + typeof value.fencingToken !== "string" || + !SAFE_BOUNDARY_ID.test(value.fencingToken) || + !("scope" in value) || + !value.scope || + typeof value.scope !== "object" || + !isValidOpfsStorageScope(value.scope as OpfsStorageScope) || + !("mutation" in value) || + (value.mutation !== "PUT" && value.mutation !== "DELETE") || + !("phase" in value) || + !["PREPARING", "FILES_READY", "COMMITTED"].includes( + String(value.phase), + ) || + !("objectId" in value) || + typeof value.objectId !== "string" || + !SAFE_BOUNDARY_ID.test(value.objectId) || + value.logicalKey !== + logicalObjectKey(value.scope as OpfsStorageScope, value.objectId) || + !("expectedGeneration" in value) || + (value.expectedGeneration !== null && + (typeof value.expectedGeneration !== "number" || + !Number.isSafeInteger(value.expectedGeneration) || + value.expectedGeneration < 1)) || + !("targetGeneration" in value) || + typeof value.targetGeneration !== "number" || + !Number.isSafeInteger(value.targetGeneration) || + value.targetGeneration < 1 || + !("targetByteLength" in value) || + typeof value.targetByteLength !== "number" || + !Number.isSafeInteger(value.targetByteLength) || + value.targetByteLength < 0 || + !("targetStoragePolicy" in value) || + !value.targetStoragePolicy || + typeof value.targetStoragePolicy !== "object" || + !("budgetReservation" in value) || + !isBudgetReservation(value.budgetReservation) || + !("startedAtEpochMs" in value) || + typeof value.startedAtEpochMs !== "number" || + !Number.isSafeInteger(value.startedAtEpochMs) || + value.startedAtEpochMs < 0 + ) { + return false; + } + try { + assertValidStoragePolicy( + value.targetStoragePolicy as OpfsJournalTransaction["targetStoragePolicy"], + ); + } catch { + return false; + } + const scope = value.scope as OpfsStorageScope; + const storagePolicy = + value.targetStoragePolicy as OpfsJournalTransaction["targetStoragePolicy"]; + const budgetReservation = + value.budgetReservation as OpfsJournalTransaction["budgetReservation"]; + if ( + storagePolicy.namespace !== scope.namespace || + budgetReservation.namespace !== scope.namespace || + budgetReservation.hardBudgetBytes !== + storagePolicy.hardBudgetBytes + ) { + return false; + } + if ("preparedObject" in value && value.preparedObject !== undefined) { + return ( + value.mutation === "PUT" && + value.phase !== "PREPARING" && + isPreparedObject(value.preparedObject) && + value.preparedObject.descriptor.objectId === value.objectId && + sameScope( + value.preparedObject.descriptor.scope, + value.scope as OpfsStorageScope, + ) + ); + } + return ( + value.phase === "PREPARING" || + (value.phase === "COMMITTED" && value.mutation === "DELETE") + ); +} + +function isBudgetReservation(value: unknown): boolean { + return Boolean( + value && + typeof value === "object" && + "namespace" in value && + typeof value.namespace === "string" && + "reservedBytes" in value && + typeof value.reservedBytes === "number" && + Number.isSafeInteger(value.reservedBytes) && + value.reservedBytes >= 0 && + "hardBudgetBytes" in value && + typeof value.hardBudgetBytes === "number" && + Number.isSafeInteger(value.hardBudgetBytes) && + value.hardBudgetBytes >= 0, + ); +} + +function isStoredObjectRow(value: unknown): value is StoredObjectRow { + if ( + !value || + typeof value !== "object" || + !("logicalKey" in value) || + typeof value.logicalKey !== "string" || + !("scopeObjectKey" in value) || + typeof value.scopeObjectKey !== "string" || + !("scopeKey" in value) || + typeof value.scopeKey !== "string" || + !("objectId" in value) || + typeof value.objectId !== "string" || + !("preparedObject" in value) || + !isPreparedObject(value.preparedObject) + ) { + return false; + } + const scope = value.preparedObject.descriptor.scope; + return ( + value.objectId === value.preparedObject.descriptor.objectId && + value.logicalKey === logicalObjectKey(scope, value.objectId) && + value.scopeKey === storageScopeKey(scope) && + value.scopeObjectKey === `${value.scopeKey}|${value.objectId}` + ); +} + +function isBudgetRow(value: unknown): value is StoredBudgetRow { + return Boolean( + value && + typeof value === "object" && + "budgetKey" in value && + typeof value.budgetKey === "string" && + "namespace" in value && + typeof value.namespace === "string" && + "authorityToken" in value && + typeof value.authorityToken === "string" && + SAFE_BOUNDARY_ID.test(value.authorityToken) && + "namespaceToken" in value && + typeof value.namespaceToken === "string" && + SAFE_BOUNDARY_ID.test(value.namespaceToken) && + "partitionToken" in value && + typeof value.partitionToken === "string" && + SAFE_BOUNDARY_ID.test(value.partitionToken) && + value.budgetKey === + `${value.authorityToken}|${value.namespaceToken}|${value.partitionToken}` && + "hardBudgetBytes" in value && + typeof value.hardBudgetBytes === "number" && + Number.isSafeInteger(value.hardBudgetBytes) && + value.hardBudgetBytes >= 0 && + "committedBytes" in value && + typeof value.committedBytes === "number" && + Number.isSafeInteger(value.committedBytes) && + value.committedBytes >= 0 && + "reservedBytes" in value && + typeof value.reservedBytes === "number" && + Number.isSafeInteger(value.reservedBytes) && + value.reservedBytes >= 0 && + value.committedBytes + value.reservedBytes <= + value.hardBudgetBytes, + ); +} + +function isScopeBinding(value: unknown): value is StoredScopeBinding { + return Boolean( + value && + typeof value === "object" && + "scopeKey" in value && + typeof value.scopeKey === "string" && + "namespace" in value && + typeof value.namespace === "string" && + "authorityToken" in value && + typeof value.authorityToken === "string" && + SAFE_BOUNDARY_ID.test(value.authorityToken) && + "namespaceToken" in value && + typeof value.namespaceToken === "string" && + SAFE_BOUNDARY_ID.test(value.namespaceToken) && + "partitionToken" in value && + typeof value.partitionToken === "string" && + SAFE_BOUNDARY_ID.test(value.partitionToken) && + value.scopeKey === + `${value.authorityToken}|${value.namespaceToken}|${value.partitionToken}` && + "policyFingerprint" in value && + typeof value.policyFingerprint === "string" && + value.policyFingerprint.length > 0 && + value.policyFingerprint.length <= 4_096, + ); +} + +function isLogicalScopeBinding( + value: unknown, +): value is StoredLogicalScopeBinding { + return Boolean( + value && + typeof value === "object" && + "logicalScopeKey" in value && + typeof value.logicalScopeKey === "string" && + "physicalScopeKey" in value && + typeof value.physicalScopeKey === "string" && + "authorityToken" in value && + typeof value.authorityToken === "string" && + SAFE_BOUNDARY_ID.test(value.authorityToken) && + "namespace" in value && + typeof value.namespace === "string" && + "namespaceToken" in value && + typeof value.namespaceToken === "string" && + SAFE_BOUNDARY_ID.test(value.namespaceToken) && + "partitionToken" in value && + typeof value.partitionToken === "string" && + SAFE_BOUNDARY_ID.test(value.partitionToken) && + value.logicalScopeKey === + `${value.authorityToken}|${value.namespace}|${value.partitionToken}` && + value.physicalScopeKey === + `${value.authorityToken}|${value.namespaceToken}|${value.partitionToken}`, + ); +} + +function isChunkReference( + value: unknown, +): value is StoredChunkReference { + return Boolean( + value && + typeof value === "object" && + "referenceKey" in value && + typeof value.referenceKey === "string" && + "scopeKey" in value && + typeof value.scopeKey === "string" && + "digestHex" in value && + typeof value.digestHex === "string" && + SHA256_HEX.test(value.digestHex) && + value.referenceKey === + `${value.scopeKey}|${value.digestHex}` && + "referenceCount" in value && + typeof value.referenceCount === "number" && + Number.isSafeInteger(value.referenceCount) && + value.referenceCount > 0, + ); +} + +function isPreparedObject(value: unknown): value is OpfsPreparedObject { + if ( + !value || + typeof value !== "object" || + !("physicalSchemaVersion" in value) || + value.physicalSchemaVersion !== 1 || + !("descriptor" in value) || + !value.descriptor || + typeof value.descriptor !== "object" || + !("chunks" in value) || + !Array.isArray(value.chunks) + ) { + return false; + } + const descriptor = value.descriptor as Record; + const integrity = + descriptor.integrity && typeof descriptor.integrity === "object" + ? (descriptor.integrity as Record) + : null; + if ( + typeof descriptor.objectId !== "string" || + !SAFE_BOUNDARY_ID.test(descriptor.objectId) || + !descriptor.scope || + typeof descriptor.scope !== "object" || + !isValidOpfsStorageScope(descriptor.scope as OpfsStorageScope) || + typeof descriptor.generation !== "number" || + !Number.isSafeInteger(descriptor.generation) || + descriptor.generation < 1 || + typeof descriptor.byteLength !== "number" || + !Number.isSafeInteger(descriptor.byteLength) || + descriptor.byteLength < 0 || + typeof descriptor.mediaType !== "string" || + descriptor.mediaType.length < 1 || + typeof descriptor.createdAtEpochMs !== "number" || + !Number.isSafeInteger(descriptor.createdAtEpochMs) || + descriptor.createdAtEpochMs < 0 || + !integrity || + integrity.algorithm !== "SHA-256-TREE-V1" || + typeof integrity.rootDigestHex !== "string" || + !SHA256_HEX.test(integrity.rootDigestHex) || + typeof integrity.chunkSizeBytes !== "number" || + !Number.isSafeInteger(integrity.chunkSizeBytes) || + integrity.chunkSizeBytes < 1 || + !descriptor.storagePolicy || + typeof descriptor.storagePolicy !== "object" + ) { + return false; + } + try { + assertValidStoragePolicy( + descriptor.storagePolicy as OpfsPreparedObject["descriptor"]["storagePolicy"], + ); + } catch { + return false; + } + if ( + (descriptor.scope as OpfsStorageScope).namespace !== + ( + descriptor.storagePolicy as OpfsPreparedObject["descriptor"]["storagePolicy"] + ).namespace + ) { + return false; + } + + let totalBytes = 0; + for (let index = 0; index < value.chunks.length; index += 1) { + const chunk = value.chunks[index] as unknown; + if ( + !chunk || + typeof chunk !== "object" || + !("sequence" in chunk) || + chunk.sequence !== index || + !("byteLength" in chunk) || + typeof chunk.byteLength !== "number" || + !Number.isSafeInteger(chunk.byteLength) || + chunk.byteLength < 1 || + chunk.byteLength > integrity.chunkSizeBytes || + !("digestHex" in chunk) || + typeof chunk.digestHex !== "string" || + !SHA256_HEX.test(chunk.digestHex) + ) { + return false; + } + if ( + index < value.chunks.length - 1 && + chunk.byteLength !== integrity.chunkSizeBytes + ) { + return false; + } + totalBytes += chunk.byteLength; + } + return ( + totalBytes === descriptor.byteLength && + value.chunks.length === + Math.ceil(descriptor.byteLength / integrity.chunkSizeBytes) + ); +} + +function stableJson(value: unknown): string { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" + ) { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; + } + throw new TypeError("Journal value is not JSON-safe."); +} + +function conflict( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("CONFLICT", operation, { + recovery: "REOPEN", + }); +} + +function corrupt( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("CORRUPT_DATA", operation, { + recovery: "READ_ONLY", + }); +} + +function policyRejected( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("POLICY_REJECTED", operation, { + recovery: "READ_ONLY", + }); +} + +function limitExceeded( + operation: BrowserDataOperation, +): BrowserDataResult { + return browserDataFailure("LIMIT_EXCEEDED", operation, { + recovery: "EXPORT_REQUIRED", + }); +} diff --git a/src/adapters/storage/opfs/opfs-byte-store-adapter.ts b/src/adapters/storage/opfs/opfs-byte-store-adapter.ts new file mode 100644 index 0000000..1deb6a8 --- /dev/null +++ b/src/adapters/storage/opfs/opfs-byte-store-adapter.ts @@ -0,0 +1,1533 @@ +import type { + DurableObjectDescriptor, + DurableObjectMaintenancePort, + DurableObjectStorePort, + OpfsJournalPort, + OpfsJournalTransaction, + OpfsPolicyMaintenanceReport, + OpfsPreparedObject, + OpfsSensitiveMaintenanceReason, + OpfsReconciliationReport, + OpfsStorageScope, + PutDurableObjectRequest, +} from "../../../application/ports/browser-file-storage/opfs-ports.ts"; +import { + type BrowserDataFailure, + type BrowserDataOperation, + type BrowserDataResult, + type BrowserStoragePolicy, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + abortedResult, + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import { + byteBucket, + observeOpfsSafely, + resolveOpfsRuntimePolicy, + transactionBucket, + snapshotOpfsStoragePolicy, + snapshotOpfsStorageScope, + validateObjectWriteInput, + type OpfsRuntimePolicy, + type OpfsSafeObserver, +} from "./opfs-policy.ts"; +import type { OpfsWorkerGateway } from "./opfs-worker-protocol.ts"; + +type BrowserFailureResult = Readonly<{ + ok: false; + error: BrowserDataFailure; +}>; + +export type OpfsByteStore = Readonly<{ + objects: DurableObjectStorePort; + maintenance: DurableObjectMaintenancePort; +}>; + +export type OpfsMaintenanceAuthorityRequest = Readonly<{ + reason: OpfsSensitiveMaintenanceReason; + scope: OpfsStorageScope; + storagePolicy: BrowserStoragePolicy; + signal?: AbortSignal; +}>; + +export type OpfsMaintenanceAuthorityDecision = + | Readonly<{ authorized: false }> + | Readonly<{ + authorized: true; + /** + * Opaque, short-lived proof issued for this exact action and scope. + * It is passed directly to the composition-owned consumer and is never + * persisted or returned through the application port. + */ + proofToken: string; + expiresAtEpochMs: number; + }>; + +export type OpfsMaintenanceAuthorityProvider = ( + request: OpfsMaintenanceAuthorityRequest, +) => + | OpfsMaintenanceAuthorityDecision + | Promise; + +export type OpfsMaintenanceAuthorityConsumer = ( + request: Readonly<{ + reason: OpfsSensitiveMaintenanceReason; + proofToken: string; + expiresAtEpochMs: number; + scope: OpfsStorageScope; + storagePolicy: BrowserStoragePolicy; + signal?: AbortSignal; + }>, +) => boolean | Promise; + +export type OpfsByteStoreDependencies = Readonly<{ + journal: OpfsJournalPort; + worker: OpfsWorkerGateway; + scope: OpfsStorageScope; + storagePolicy: BrowserStoragePolicy; + policy?: Partial; + createTransactionId?: () => string; + now?: () => number; + observer?: OpfsSafeObserver; + /** + * Obtains a new proof for every sensitive maintenance invocation. The + * provider must bind it to the exact reason, scope and policy. + */ + requestMaintenanceAuthority?: OpfsMaintenanceAuthorityProvider; + /** + * Atomically validates and consumes the issued proof. It must reject replay, + * scope/action/policy mismatch and expiry. The runtime also performs shape + * and short-expiry checks before invoking it. + */ + consumeMaintenanceAuthority?: OpfsMaintenanceAuthorityConsumer; +}>; + +export function createOpfsByteStoreAdapter( + inputDependencies: OpfsByteStoreDependencies, +): OpfsByteStore { + const policy = resolveOpfsRuntimePolicy(inputDependencies.policy); + const scope = snapshotOpfsStorageScope(inputDependencies.scope); + const storagePolicy = snapshotOpfsStoragePolicy( + inputDependencies.storagePolicy, + ); + if ( + storagePolicy.namespace !== scope.namespace + ) { + throw new TypeError( + "OPFS storage policy must match the bound scope.", + ); + } + const dependencies: OpfsByteStoreDependencies = Object.freeze({ + ...inputDependencies, + journal: snapshotOpfsJournal(inputDependencies.journal), + worker: snapshotOpfsWorker(inputDependencies.worker), + scope, + storagePolicy, + policy, + }); + const createTransactionId = + dependencies.createTransactionId ?? + (() => globalThis.crypto.randomUUID()); + const now = dependencies.now ?? Date.now; + + const objects: DurableObjectStorePort = Object.freeze({ + async capabilities() { + return await dependencies.worker.capabilities(); + }, + + async put(sourceRequest: PutDurableObjectRequest) { + const requestSnapshot = snapshotPutRequest(sourceRequest); + if (!requestSnapshot.ok) return requestSnapshot; + const request = requestSnapshot.value; + const earlyFailure = validatePut( + request, + policy, + dependencies.scope, + dependencies.storagePolicy, + ); + if (earlyFailure) return earlyFailure; + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_WRITE", + outcome: "STARTED", + byteBucket: byteBucket(request.source.byteLength!), + }); + notifyProgress(request, "VALIDATING", 0); + + const currentResult = await dependencies.journal.getCommittedObject( + dependencies.scope, + request.objectId, + ); + if (!currentResult.ok) { + return observeFailure( + rebaseFailure(currentResult.error, "OBJECT_WRITE"), + dependencies.observer, + request.source.byteLength!, + ); + } + const current = currentResult.value; + if ( + (current === null && request.expectedGeneration !== null) || + (current !== null && + request.expectedGeneration !== current.descriptor.generation) + ) { + return observeFailure( + browserDataFailure("CONFLICT", "OBJECT_WRITE", { + recovery: "REOPEN", + }), + dependencies.observer, + request.source.byteLength!, + ); + } + const targetGeneration = (current?.descriptor.generation ?? 0) + 1; + const transactionId = createTransactionId(); + notifyProgress(request, "PREPARING", 0); + const begun = await dependencies.journal.begin({ + transactionId, + mutation: "PUT", + scope: dependencies.scope, + objectId: request.objectId, + expectedGeneration: request.expectedGeneration, + targetGeneration, + targetByteLength: request.source.byteLength!, + targetStoragePolicy: dependencies.storagePolicy, + startedAtEpochMs: now(), + }); + if (!begun.ok) { + return observeFailure( + rebaseFailure(begun.error, "OBJECT_WRITE"), + dependencies.observer, + request.source.byteLength!, + ); + } + + const descriptor: Omit = + Object.freeze({ + objectId: request.objectId, + scope: dependencies.scope, + generation: targetGeneration, + byteLength: request.source.byteLength!, + mediaType: request.mediaType, + createdAtEpochMs: now(), + storagePolicy: dependencies.storagePolicy, + }); + const prepared = await dependencies.worker.preparePut({ + transactionId, + descriptor, + source: request.source, + signal: request.signal, + onProgress: request.onProgress, + }); + if (!prepared.ok) { + await rollbackBestEffort(begun.value, request.signal); + return observeFailure( + prepared, + dependencies.observer, + request.source.byteLength!, + ); + } + + const filesReady = await dependencies.journal.markFilesReady( + transactionId, + begun.value.fencingToken, + prepared.value, + ); + if (!filesReady.ok) { + await rollbackBestEffort(begun.value, request.signal); + return observeFailure( + rebaseFailure(filesReady.error, "OBJECT_WRITE"), + dependencies.observer, + request.source.byteLength!, + ); + } + + const committed = await dependencies.journal.commitPut( + transactionId, + begun.value.fencingToken, + ); + if (!committed.ok) { + // The commit response can be lost after IndexedDB commits. Reconciliation + // decides from the durable journal; rolling back here would be unsafe. + return observeFailure( + rebaseFailure(committed.error, "OBJECT_WRITE"), + dependencies.observer, + request.source.byteLength!, + ); + } + + notifyProgress( + request, + "FINALIZING", + prepared.value.descriptor.byteLength, + ); + const finalized = await dependencies.worker.finalizePut( + transactionId, + prepared.value, + request.signal, + ); + if (finalized.ok) { + await dependencies.journal.complete( + transactionId, + begun.value.fencingToken, + ); + } + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_WRITE", + outcome: "SUCCEEDED", + byteBucket: byteBucket(prepared.value.descriptor.byteLength), + }); + return browserDataSuccess(prepared.value.descriptor); + }, + + async open( + sourceRequest: Parameters[0], + ) { + const requestSnapshot = snapshotOpenRequest(sourceRequest); + if (!requestSnapshot.ok) return requestSnapshot; + const request = requestSnapshot.value; + const aborted = abortedResult(request.signal, "OBJECT_READ"); + if (aborted) return aborted; + if ( + !policy.isObjectIdAllowed(request.objectId) || + (request.generation !== undefined && + (!Number.isSafeInteger(request.generation) || + request.generation < 1)) + ) { + return browserDataFailure("INVALID_INPUT", "OBJECT_READ"); + } + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_READ", + outcome: "STARTED", + }); + const committed = await dependencies.journal.getCommittedObject( + dependencies.scope, + request.objectId, + ); + if (!committed.ok) { + return observeFailure( + rebaseFailure(committed.error, "OBJECT_READ"), + dependencies.observer, + ); + } + if ( + !committed.value || + (request.generation !== undefined && + request.generation !== committed.value.descriptor.generation) + ) { + return observeFailure( + browserDataFailure("NOT_FOUND", "OBJECT_READ", { + recovery: "REHYDRATE", + }), + dependencies.observer, + ); + } + if (isExpired(committed.value.descriptor, now())) { + await objects.remove({ + objectId: request.objectId, + expectedGeneration: committed.value.descriptor.generation, + signal: request.signal, + }); + return observeFailure( + browserDataFailure("EXPIRED_RESOURCE", "OBJECT_READ", { + recovery: "REHYDRATE", + }), + dependencies.observer, + committed.value.descriptor.byteLength, + ); + } + const opened = await dependencies.worker.openObject( + committed.value, + request.signal, + ); + if (!opened.ok) { + return observeFailure( + opened, + dependencies.observer, + committed.value.descriptor.byteLength, + ); + } + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_READ", + outcome: "SUCCEEDED", + byteBucket: byteBucket(committed.value.descriptor.byteLength), + }); + return browserDataSuccess({ + descriptor: committed.value.descriptor, + source: opened.value, + }); + }, + + async remove( + sourceRequest: Parameters[0], + ) { + const requestSnapshot = snapshotRemoveRequest(sourceRequest); + if (!requestSnapshot.ok) return requestSnapshot; + const request = requestSnapshot.value; + const aborted = abortedResult(request.signal, "OBJECT_DELETE"); + if (aborted) return aborted; + if ( + !policy.isObjectIdAllowed(request.objectId) || + !Number.isSafeInteger(request.expectedGeneration) || + request.expectedGeneration < 1 + ) { + return browserDataFailure("INVALID_INPUT", "OBJECT_DELETE"); + } + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_DELETE", + outcome: "STARTED", + }); + const current = await dependencies.journal.getCommittedObject( + dependencies.scope, + request.objectId, + ); + if (!current.ok) { + return observeFailure( + rebaseFailure(current.error, "OBJECT_DELETE"), + dependencies.observer, + ); + } + if (!current.value) { + return observeFailure( + browserDataFailure("NOT_FOUND", "OBJECT_DELETE"), + dependencies.observer, + ); + } + if ( + current.value.descriptor.generation !== request.expectedGeneration + ) { + return observeFailure( + browserDataFailure("CONFLICT", "OBJECT_DELETE", { + recovery: "REOPEN", + }), + dependencies.observer, + ); + } + + const transactionId = createTransactionId(); + const begun = await dependencies.journal.begin({ + transactionId, + mutation: "DELETE", + scope: dependencies.scope, + objectId: request.objectId, + expectedGeneration: request.expectedGeneration, + targetGeneration: request.expectedGeneration + 1, + targetByteLength: 0, + targetStoragePolicy: current.value.descriptor.storagePolicy, + startedAtEpochMs: now(), + }); + if (!begun.ok) { + return observeFailure( + rebaseFailure(begun.error, "OBJECT_DELETE"), + dependencies.observer, + ); + } + const committed = await dependencies.journal.commitDelete( + transactionId, + begun.value.fencingToken, + ); + if (!committed.ok) { + return observeFailure( + rebaseFailure(committed.error, "OBJECT_DELETE"), + dependencies.observer, + ); + } + const removed = await dependencies.worker.removeObject( + dependencies.scope, + request.objectId, + request.expectedGeneration, + request.signal, + ); + if (removed.ok) { + await dependencies.journal.complete( + transactionId, + begun.value.fencingToken, + ); + } + // Logical deletion is already committed. Physical cleanup is retryable + // maintenance and must not make the caller repeat a non-idempotent delete. + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_DELETE", + outcome: "SUCCEEDED", + }); + return browserDataSuccess(undefined); + }, + }); + + const maintenance: DurableObjectMaintenancePort = Object.freeze({ + async reconcile( + sourceRequest: NonNullable< + Parameters[0] + > = {}, + ) { + const requestSnapshot = snapshotReconciliationRequest(sourceRequest); + if (!requestSnapshot.ok) return requestSnapshot; + const request = requestSnapshot.value; + const aborted = abortedResult(request.signal, "OBJECT_RECONCILE"); + if (aborted) return aborted; + if ( + !isValidOptionalPositiveInteger(request.budgetMs) || + !isValidOptionalPositiveInteger(request.maxTransactions) + ) { + return browserDataFailure("INVALID_INPUT", "OBJECT_RECONCILE"); + } + if ( + exceedsConfiguredCeiling( + request.budgetMs, + policy.reconciliationBudgetMs, + ) || + exceedsConfiguredCeiling( + request.maxTransactions, + policy.reconciliationBatchSize, + ) + ) { + return browserDataFailure("LIMIT_EXCEEDED", "OBJECT_RECONCILE"); + } + const budgetMs = + request.budgetMs ?? policy.reconciliationBudgetMs; + const maxTransactions = + request.maxTransactions ?? policy.reconciliationBatchSize; + const deadline = now() + budgetMs; + const page = await dependencies.journal.listIncomplete(maxTransactions); + if (!page.ok) { + return rebaseFailure(page.error, "OBJECT_RECONCILE"); + } + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_RECONCILE", + outcome: "STARTED", + transactionBucket: transactionBucket(page.value.transactions.length), + }); + + const report: { + inspectedTransactions: number; + committedTransactions: number; + rolledBackTransactions: number; + cleanedTransactions: number; + inspectedOrphanChunks: number; + deletedOrphanChunks: number; + orphanGcStatus: + | "COMPLETED" + | "DEADLINE_REACHED" + | "STAGING_STATE_UNREADABLE"; + moreTransactionsAvailable: boolean; + deadlineReached: boolean; + } = { + inspectedTransactions: 0, + committedTransactions: 0, + rolledBackTransactions: 0, + cleanedTransactions: 0, + inspectedOrphanChunks: 0, + deletedOrphanChunks: 0, + orphanGcStatus: "COMPLETED", + moreTransactionsAvailable: page.value.moreAvailable, + deadlineReached: false, + }; + for (const transaction of page.value.transactions) { + if (request.signal?.aborted) { + return browserDataFailure("ABORTED", "OBJECT_RECONCILE"); + } + if (now() >= deadline) { + report.deadlineReached = true; + report.moreTransactionsAvailable = true; + break; + } + report.inspectedTransactions += 1; + const reconciled = await reconcileTransaction(transaction, request.signal); + if (!reconciled.ok) { + return observeFailure(reconciled, dependencies.observer); + } + report.committedTransactions += reconciled.value.committed; + report.rolledBackTransactions += reconciled.value.rolledBack; + report.cleanedTransactions += reconciled.value.cleaned; + } + if (now() >= deadline) { + report.deadlineReached = true; + report.orphanGcStatus = "DEADLINE_REACHED"; + } else { + const gc = await reconcileOrphanChunks( + deadline, + request.signal, + ); + if (!gc.ok) return observeFailure(gc, dependencies.observer); + report.inspectedOrphanChunks = gc.value.inspected; + report.deletedOrphanChunks = gc.value.deleted; + report.orphanGcStatus = gc.value.status; + if (gc.value.status === "DEADLINE_REACHED") { + report.deadlineReached = true; + } + } + observeOpfsSafely(dependencies.observer, { + operation: "OBJECT_RECONCILE", + outcome: "SUCCEEDED", + transactionBucket: transactionBucket(report.inspectedTransactions), + }); + return browserDataSuccess( + Object.freeze({ ...report }), + ); + }, + + async enforcePolicies( + sourceRequest: Parameters< + DurableObjectMaintenancePort["enforcePolicies"] + >[0], + ) { + const requestSnapshot = snapshotPolicyMaintenanceRequest( + sourceRequest, + ); + if (!requestSnapshot.ok) return requestSnapshot; + const request = requestSnapshot.value; + const aborted = abortedResult(request.signal, "OBJECT_RECONCILE"); + if (aborted) return aborted; + if ( + !isValidOptionalPositiveInteger(request.budgetMs) || + !isValidOptionalPositiveInteger(request.maxObjects) || + (request.reason === "PRESSURE" && + (!Number.isSafeInteger(request.targetBytesToRelease) || + request.targetBytesToRelease < 1)) + ) { + return browserDataFailure("INVALID_INPUT", "OBJECT_RECONCILE"); + } + if ( + exceedsConfiguredCeiling( + request.budgetMs, + policy.reconciliationBudgetMs, + ) || + exceedsConfiguredCeiling( + request.maxObjects, + policy.reconciliationBatchSize, + ) + ) { + return browserDataFailure("LIMIT_EXCEEDED", "OBJECT_RECONCILE"); + } + if ( + !maintenanceReasonAllowed( + request.reason, + dependencies.storagePolicy, + ) + ) { + return browserDataFailure( + "POLICY_REJECTED", + "OBJECT_RECONCILE", + { recovery: dependencies.storagePolicy.unavailableFallback }, + ); + } + const budgetMs = + request.budgetMs ?? policy.reconciliationBudgetMs; + const maxObjects = + request.maxObjects ?? policy.reconciliationBatchSize; + const deadline = now() + budgetMs; + const authorized = await authorizePolicyMaintenance(request); + if (!authorized.ok) return authorized; + if (request.signal?.aborted) { + return browserDataFailure("ABORTED", "OBJECT_RECONCILE"); + } + if (now() >= deadline) { + // Authority acquisition is part of the maintenance budget. No journal + // read is allowed after the deadline, so remaining work is reported + // conservatively instead of implying that the partition was scanned. + return browserDataSuccess( + Object.freeze({ + inspectedObjects: 0, + removedObjects: 0, + releasedBytes: 0, + moreObjectsAvailable: true, + deadlineReached: true, + }), + ); + } + let afterObjectId: string | undefined; + let inspectedObjects = 0; + let removedObjects = 0; + let releasedBytes = 0; + let moreObjectsAvailable = false; + let deadlineReached = false; + + while ( + inspectedObjects < maxObjects && + now() < deadline + ) { + const page = await dependencies.journal.listCommittedObjects({ + scope: dependencies.scope, + afterObjectId, + limit: Math.min( + policy.reconciliationBatchSize, + maxObjects - inspectedObjects, + ), + }); + if (!page.ok) { + return rebaseFailure(page.error, "OBJECT_RECONCILE"); + } + const candidates = [...page.value.objects].sort( + policyMaintenanceOrder, + ); + for (const candidate of candidates) { + if ( + request.signal?.aborted || + now() >= deadline || + inspectedObjects >= maxObjects + ) { + deadlineReached = now() >= deadline; + moreObjectsAvailable = true; + break; + } + inspectedObjects += 1; + if (!shouldRemoveForPolicy(candidate, request, now())) continue; + const removed = await objects.remove({ + objectId: candidate.descriptor.objectId, + expectedGeneration: candidate.descriptor.generation, + signal: request.signal, + }); + if (!removed.ok) { + if (removed.error.code === "CONFLICT" || removed.error.code === "NOT_FOUND") { + continue; + } + return rebaseFailure(removed.error, "OBJECT_RECONCILE"); + } + removedObjects += 1; + releasedBytes += candidate.descriptor.byteLength; + if ( + request.reason === "PRESSURE" && + releasedBytes >= request.targetBytesToRelease + ) { + moreObjectsAvailable = page.value.moreAvailable; + break; + } + } + if ( + deadlineReached || + (request.reason === "PRESSURE" && + releasedBytes >= request.targetBytesToRelease) || + !page.value.moreAvailable || + !page.value.nextObjectId + ) { + moreObjectsAvailable ||= page.value.moreAvailable; + break; + } + afterObjectId = page.value.nextObjectId; + moreObjectsAvailable = page.value.moreAvailable; + } + const report: OpfsPolicyMaintenanceReport = Object.freeze({ + inspectedObjects, + removedObjects, + releasedBytes, + moreObjectsAvailable, + deadlineReached, + }); + return browserDataSuccess(report); + }, + }); + + async function authorizePolicyMaintenance( + request: Parameters< + DurableObjectMaintenancePort["enforcePolicies"] + >[0], + ): Promise> { + if (!requiresMaintenanceAuthority(request)) { + return browserDataSuccess(undefined); + } + const requestAuthority = dependencies.requestMaintenanceAuthority; + const consumeAuthority = dependencies.consumeMaintenanceAuthority; + if (!requestAuthority || !consumeAuthority) { + return browserDataFailure( + "POLICY_REJECTED", + "OBJECT_RECONCILE", + { recovery: "READ_ONLY" }, + ); + } + + let decision: OpfsMaintenanceAuthorityDecision; + try { + decision = await requestAuthority( + Object.freeze({ + reason: request.reason, + scope: dependencies.scope, + storagePolicy: dependencies.storagePolicy, + ...(request.signal ? { signal: request.signal } : {}), + }), + ); + } catch { + return browserDataFailure( + "POLICY_REJECTED", + "OBJECT_RECONCILE", + { recovery: "READ_ONLY" }, + ); + } + if (request.signal?.aborted) { + return browserDataFailure("ABORTED", "OBJECT_RECONCILE"); + } + if ( + !decision || + decision.authorized !== true || + typeof decision.proofToken !== "string" || + !OPAQUE_AUTHORITY_PROOF.test(decision.proofToken) || + !Number.isSafeInteger(decision.expiresAtEpochMs) + ) { + return browserDataFailure( + "POLICY_REJECTED", + "OBJECT_RECONCILE", + { recovery: "READ_ONLY" }, + ); + } + + let authorizationEpochMs: number; + try { + authorizationEpochMs = now(); + } catch { + return browserDataFailure( + "UNAVAILABLE", + "OBJECT_RECONCILE", + { retryable: true, recovery: "RETRY" }, + ); + } + if ( + !Number.isSafeInteger(authorizationEpochMs) || + authorizationEpochMs < 0 || + decision.expiresAtEpochMs <= authorizationEpochMs || + decision.expiresAtEpochMs - authorizationEpochMs > + MAX_AUTHORITY_PROOF_LIFETIME_MS + ) { + return browserDataFailure( + "POLICY_REJECTED", + "OBJECT_RECONCILE", + { recovery: "READ_ONLY" }, + ); + } + + const proofToken = decision.proofToken; + const expiresAtEpochMs = decision.expiresAtEpochMs; + let consumed: boolean; + try { + consumed = await consumeAuthority( + Object.freeze({ + reason: request.reason, + proofToken, + expiresAtEpochMs, + scope: dependencies.scope, + storagePolicy: dependencies.storagePolicy, + ...(request.signal ? { signal: request.signal } : {}), + }), + ); + } catch { + return browserDataFailure( + "POLICY_REJECTED", + "OBJECT_RECONCILE", + { recovery: "READ_ONLY" }, + ); + } + // The proof is intentionally not retained after this invocation. The + // composition consumer owns atomic replay prevention across runtimes. + if (request.signal?.aborted) { + return browserDataFailure("ABORTED", "OBJECT_RECONCILE"); + } + return consumed + ? browserDataSuccess(undefined) + : browserDataFailure( + "POLICY_REJECTED", + "OBJECT_RECONCILE", + { recovery: "READ_ONLY" }, + ); + } + + return Object.freeze({ objects, maintenance }); + + async function rollbackBestEffort( + transaction: OpfsJournalTransaction, + signal: AbortSignal | undefined, + ): Promise { + await dependencies.worker.cleanupTransaction( + transaction.scope, + transaction.transactionId, + signal, + ); + await dependencies.journal.rollback( + transaction.transactionId, + transaction.fencingToken, + ); + } + + async function reconcileTransaction( + transaction: OpfsJournalTransaction, + signal: AbortSignal | undefined, + ): Promise< + BrowserDataResult< + Readonly<{ committed: number; rolledBack: number; cleaned: number }> + > + > { + if (transaction.phase === "PREPARING") { + const cleaned = await dependencies.worker.cleanupTransaction( + transaction.scope, + transaction.transactionId, + signal, + ); + if (!cleaned.ok) return cleaned; + const rolledBack = await dependencies.journal.rollback( + transaction.transactionId, + transaction.fencingToken, + ); + if (!rolledBack.ok) { + return rebaseFailure(rolledBack.error, "OBJECT_RECONCILE"); + } + return browserDataSuccess({ committed: 0, rolledBack: 1, cleaned: 1 }); + } + + if (transaction.phase === "FILES_READY") { + if ( + transaction.mutation !== "PUT" || + !transaction.preparedObject + ) { + return browserDataFailure("CORRUPT_DATA", "OBJECT_RECONCILE", { + recovery: "READ_ONLY", + }); + } + const verified = await dependencies.worker.verifyObject( + transaction.preparedObject, + signal, + ); + if (!verified.ok) return verified; + if (!verified.value) { + const cleaned = await dependencies.worker.cleanupTransaction( + transaction.scope, + transaction.transactionId, + signal, + ); + if (!cleaned.ok) return cleaned; + const rolledBack = await dependencies.journal.rollback( + transaction.transactionId, + transaction.fencingToken, + ); + if (!rolledBack.ok) { + return rebaseFailure(rolledBack.error, "OBJECT_RECONCILE"); + } + return browserDataSuccess({ + committed: 0, + rolledBack: 1, + cleaned: 1, + }); + } + const committed = await dependencies.journal.commitPut( + transaction.transactionId, + transaction.fencingToken, + ); + if (!committed.ok) { + return rebaseFailure(committed.error, "OBJECT_RECONCILE"); + } + const finalized = await dependencies.worker.finalizePut( + transaction.transactionId, + transaction.preparedObject, + signal, + ); + if (!finalized.ok) return finalized; + const completed = await dependencies.journal.complete( + transaction.transactionId, + transaction.fencingToken, + ); + if (!completed.ok) { + return rebaseFailure(completed.error, "OBJECT_RECONCILE"); + } + return browserDataSuccess({ committed: 1, rolledBack: 0, cleaned: 1 }); + } + + if (transaction.phase === "COMMITTED") { + if (transaction.mutation === "PUT") { + if (!transaction.preparedObject) { + return browserDataFailure("CORRUPT_DATA", "OBJECT_RECONCILE", { + recovery: "READ_ONLY", + }); + } + const finalized = await dependencies.worker.finalizePut( + transaction.transactionId, + transaction.preparedObject, + signal, + ); + if (!finalized.ok) return finalized; + } else { + const removed = await dependencies.worker.removeObject( + transaction.scope, + transaction.objectId, + Math.max(1, transaction.targetGeneration - 1), + signal, + ); + if (!removed.ok) return removed; + } + const completed = await dependencies.journal.complete( + transaction.transactionId, + transaction.fencingToken, + ); + if (!completed.ok) { + return rebaseFailure(completed.error, "OBJECT_RECONCILE"); + } + return browserDataSuccess({ committed: 0, rolledBack: 0, cleaned: 1 }); + } + + return browserDataFailure("CORRUPT_DATA", "OBJECT_RECONCILE", { + recovery: "READ_ONLY", + }); + } + + async function reconcileOrphanChunks( + deadline: number, + signal: AbortSignal | undefined, + ): Promise< + BrowserDataResult< + Readonly<{ + inspected: number; + deleted: number; + status: + | "COMPLETED" + | "DEADLINE_REACHED" + | "STAGING_STATE_UNREADABLE"; + }> + > + > { + const olderThanEpochMs = Math.max( + 0, + now() - policy.orphanGracePeriodMs, + ); + const candidates = await dependencies.worker.listOrphanCandidates( + dependencies.scope, + olderThanEpochMs, + policy.orphanGcBatchSize, + signal, + ); + if (!candidates.ok) return candidates; + if (!candidates.value.safeToSweep) { + return browserDataSuccess({ + inspected: 0, + deleted: 0, + status: "STAGING_STATE_UNREADABLE", + }); + } + let inspected = 0; + let deleted = 0; + for (const digestHex of candidates.value.digests) { + if (signal?.aborted) { + return browserDataFailure("ABORTED", "OBJECT_RECONCILE"); + } + if (now() >= deadline) { + return browserDataSuccess({ + inspected, + deleted, + status: "DEADLINE_REACHED", + }); + } + inspected += 1; + const referenced = await dependencies.journal.isChunkReferenced( + dependencies.scope, + digestHex, + ); + if (!referenced.ok) { + return rebaseFailure(referenced.error, "OBJECT_RECONCILE"); + } + if (referenced.value) continue; + const removal = await dependencies.worker.deleteOrphanChunk( + dependencies.scope, + digestHex, + olderThanEpochMs, + signal, + ); + if (!removal.ok) return removal; + if (removal.value.skippedUnsafe) { + return browserDataSuccess({ + inspected, + deleted, + status: "STAGING_STATE_UNREADABLE", + }); + } + if (removal.value.deleted) deleted += 1; + } + return browserDataSuccess({ + inspected, + deleted, + status: + candidates.value.moreAvailable + ? "DEADLINE_REACHED" + : "COMPLETED", + }); + } +} + +function snapshotPutRequest( + input: PutDurableObjectRequest, +): BrowserDataResult { + try { + if (!input || typeof input !== "object") { + return browserDataFailure("INVALID_INPUT", "OBJECT_WRITE"); + } + const sourceInput = input.source; + if (!sourceInput || typeof sourceInput !== "object") { + return browserDataFailure("INVALID_INPUT", "OBJECT_WRITE"); + } + const objectId = input.objectId; + const expectedGeneration = input.expectedGeneration; + const mediaType = input.mediaType; + const signal = input.signal; + const onProgress = input.onProgress; + const byteLength = sourceInput.byteLength; + const stream = sourceInput.stream; + if ( + typeof stream !== "function" || + (onProgress !== undefined && + typeof onProgress !== "function") + ) { + return browserDataFailure("INVALID_INPUT", "OBJECT_WRITE"); + } + const boundStream = stream.bind(sourceInput); + const source = Object.freeze({ + byteLength, + stream(signal: AbortSignal) { + return boundStream(signal); + }, + }); + return browserDataSuccess( + Object.freeze({ + objectId, + expectedGeneration, + mediaType, + source, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined + ? {} + : { onProgress }), + }), + ); + } catch { + return browserDataFailure("INVALID_INPUT", "OBJECT_WRITE"); + } +} + +function snapshotOpenRequest( + input: Parameters[0], +): BrowserDataResult< + Parameters[0] +> { + try { + if (!input || typeof input !== "object") { + return browserDataFailure("INVALID_INPUT", "OBJECT_READ"); + } + const objectId = input.objectId; + const generation = input.generation; + const signal = input.signal; + return browserDataSuccess( + Object.freeze({ + objectId, + ...(generation === undefined + ? {} + : { generation }), + ...(signal === undefined ? {} : { signal }), + }), + ); + } catch { + return browserDataFailure("INVALID_INPUT", "OBJECT_READ"); + } +} + +function snapshotRemoveRequest( + input: Parameters[0], +): BrowserDataResult< + Parameters[0] +> { + try { + if (!input || typeof input !== "object") { + return browserDataFailure("INVALID_INPUT", "OBJECT_DELETE"); + } + const objectId = input.objectId; + const expectedGeneration = input.expectedGeneration; + const signal = input.signal; + return browserDataSuccess( + Object.freeze({ + objectId, + expectedGeneration, + ...(signal === undefined ? {} : { signal }), + }), + ); + } catch { + return browserDataFailure("INVALID_INPUT", "OBJECT_DELETE"); + } +} + +function snapshotReconciliationRequest( + input: NonNullable< + Parameters[0] + >, +): BrowserDataResult< + NonNullable< + Parameters[0] + > +> { + try { + if (!input || typeof input !== "object") { + return browserDataFailure("INVALID_INPUT", "OBJECT_RECONCILE"); + } + const budgetMs = input.budgetMs; + const maxTransactions = input.maxTransactions; + const signal = input.signal; + return browserDataSuccess( + Object.freeze({ + ...(budgetMs === undefined + ? {} + : { budgetMs }), + ...(maxTransactions === undefined + ? {} + : { maxTransactions }), + ...(signal === undefined ? {} : { signal }), + }), + ); + } catch { + return browserDataFailure("INVALID_INPUT", "OBJECT_RECONCILE"); + } +} + +function snapshotPolicyMaintenanceRequest( + input: Parameters< + DurableObjectMaintenancePort["enforcePolicies"] + >[0], +): BrowserDataResult< + Parameters[0] +> { + try { + if (!input || typeof input !== "object") { + return browserDataFailure("INVALID_INPUT", "OBJECT_RECONCILE"); + } + const reason = input.reason; + const budgetMs = input.budgetMs; + const maxObjects = input.maxObjects; + const signal = input.signal; + const common = { + ...(budgetMs === undefined + ? {} + : { budgetMs }), + ...(maxObjects === undefined + ? {} + : { maxObjects }), + ...(signal === undefined ? {} : { signal }), + }; + switch (reason) { + case "TTL": + case "LOGOUT": + case "SESSION_END": + case "UNTIL_SYNCED": + case "ACCOUNT_DELETION": + return browserDataSuccess( + Object.freeze({ reason, ...common }), + ); + case "PRESSURE": { + const targetBytesToRelease = input.targetBytesToRelease; + return browserDataSuccess( + Object.freeze({ + reason: "PRESSURE", + targetBytesToRelease, + ...common, + }), + ); + } + default: + return browserDataFailure( + "INVALID_INPUT", + "OBJECT_RECONCILE", + ); + } + } catch { + return browserDataFailure("INVALID_INPUT", "OBJECT_RECONCILE"); + } +} + +function snapshotOpfsJournal( + source: OpfsJournalPort, +): OpfsJournalPort { + if (!source || typeof source !== "object") { + throw new TypeError("OPFS journal dependency is invalid."); + } + const { + getCommittedObject, + begin, + markFilesReady, + commitPut, + commitDelete, + complete, + rollback, + listIncomplete, + listCommittedObjects, + isChunkReferenced, + } = source; + if ( + [ + getCommittedObject, + begin, + markFilesReady, + commitPut, + commitDelete, + complete, + rollback, + listIncomplete, + listCommittedObjects, + isChunkReferenced, + ].some((method) => typeof method !== "function") + ) { + throw new TypeError("OPFS journal dependency is invalid."); + } + return Object.freeze({ + getCommittedObject: getCommittedObject.bind(source), + begin: begin.bind(source), + markFilesReady: markFilesReady.bind(source), + commitPut: commitPut.bind(source), + commitDelete: commitDelete.bind(source), + complete: complete.bind(source), + rollback: rollback.bind(source), + listIncomplete: listIncomplete.bind(source), + listCommittedObjects: listCommittedObjects.bind(source), + isChunkReferenced: isChunkReferenced.bind(source), + }); +} + +function snapshotOpfsWorker( + source: OpfsWorkerGateway, +): OpfsWorkerGateway { + if (!source || typeof source !== "object") { + throw new TypeError("OPFS worker dependency is invalid."); + } + const { + capabilities, + preparePut, + verifyObject, + openObject, + removeObject, + cleanupTransaction, + finalizePut, + listOrphanCandidates, + deleteOrphanChunk, + close, + } = source; + if ( + [ + capabilities, + preparePut, + verifyObject, + openObject, + removeObject, + cleanupTransaction, + finalizePut, + listOrphanCandidates, + deleteOrphanChunk, + close, + ].some((method) => typeof method !== "function") + ) { + throw new TypeError("OPFS worker dependency is invalid."); + } + return Object.freeze({ + capabilities: capabilities.bind(source), + preparePut: preparePut.bind(source), + verifyObject: verifyObject.bind(source), + openObject: openObject.bind(source), + removeObject: removeObject.bind(source), + cleanupTransaction: cleanupTransaction.bind(source), + finalizePut: finalizePut.bind(source), + listOrphanCandidates: listOrphanCandidates.bind(source), + deleteOrphanChunk: deleteOrphanChunk.bind(source), + close: close.bind(source), + }); +} + +function validatePut( + request: PutDurableObjectRequest, + policy: OpfsRuntimePolicy, + scope: OpfsStorageScope, + storagePolicy: BrowserStoragePolicy, +): BrowserDataResult | null { + const aborted = abortedResult(request.signal, "OBJECT_WRITE"); + if (aborted) return aborted; + if ( + !validateObjectWriteInput( + { + objectId: request.objectId, + scope, + expectedGeneration: request.expectedGeneration, + mediaType: request.mediaType, + byteLength: request.source.byteLength, + storagePolicy, + }, + policy, + ) + ) { + return browserDataFailure( + request.source.byteLength !== null && + request.source.byteLength > policy.maxObjectBytes + ? "LIMIT_EXCEEDED" + : "INVALID_INPUT", + "OBJECT_WRITE", + ); + } + return null; +} + +function rebaseFailure( + failure: BrowserDataFailure, + operation: BrowserDataOperation, +): BrowserFailureResult { + return { + ok: false, + error: Object.freeze({ ...failure, operation }), + }; +} + +function observeFailure( + failure: BrowserDataResult, + observer: OpfsSafeObserver | undefined, + byteLength?: number, +): BrowserFailureResult { + if (failure.ok) { + throw new TypeError("Expected an OPFS failure result."); + } + observeOpfsSafely(observer, { + operation: failure.error.operation, + outcome: "FAILED", + failureCode: failure.error.code, + byteBucket: + byteLength === undefined ? undefined : byteBucket(byteLength), + }); + return failure; +} + +function notifyProgress( + request: PutDurableObjectRequest, + phase: "VALIDATING" | "PREPARING" | "FINALIZING", + transferredBytes: number, +): void { + try { + request.onProgress?.({ + phase, + transferredBytes, + totalBytes: request.source.byteLength, + }); + } catch { + // A UI callback cannot affect persistence. + } +} + +function exceedsConfiguredCeiling( + requested: number | undefined, + configured: number, +): boolean { + return requested !== undefined && requested > configured; +} + +function isValidOptionalPositiveInteger( + value: number | undefined, +): boolean { + return ( + value === undefined || + (Number.isSafeInteger(value) && value > 0) + ); +} + +function isExpired( + descriptor: DurableObjectDescriptor, + nowEpochMs: number, +): boolean { + const retention = descriptor.storagePolicy.retention; + return ( + retention.kind === "TTL" && + descriptor.createdAtEpochMs + retention.maxAgeMs <= nowEpochMs + ); +} + +function policyMaintenanceOrder( + left: OpfsPreparedObject, + right: OpfsPreparedObject, +): number { + const priorities = { + RECONSTRUCTABLE: 0, + SYNCED_COPY: 1, + USER_AUTHORED: 2, + } as const; + const priority = + priorities[left.descriptor.storagePolicy.evictionPriority] - + priorities[right.descriptor.storagePolicy.evictionPriority]; + return priority !== 0 + ? priority + : left.descriptor.createdAtEpochMs - + right.descriptor.createdAtEpochMs; +} + +function shouldRemoveForPolicy( + object: OpfsPreparedObject, + request: Parameters< + DurableObjectMaintenancePort["enforcePolicies"] + >[0], + nowEpochMs: number, +): boolean { + if (request.reason === "TTL") { + return isExpired(object.descriptor, nowEpochMs); + } + if (request.reason === "LOGOUT") { + return ( + object.descriptor.storagePolicy.logoutAction === + "PURGE_PARTITION" || + object.descriptor.storagePolicy.logoutAction === + "EXPORT_THEN_PURGE" + ); + } + if (request.reason === "SESSION_END") { + return object.descriptor.storagePolicy.retention.kind === "SESSION"; + } + if (request.reason === "UNTIL_SYNCED") { + return object.descriptor.storagePolicy.retention.kind === "UNTIL_SYNCED"; + } + if (request.reason === "ACCOUNT_DELETION") { + return ( + object.descriptor.storagePolicy.accountDeletionAction === + "PURGE_PARTITION" + ); + } + return ( + object.descriptor.storagePolicy.retention.kind !== "EXPLICIT_DELETE" && + object.descriptor.storagePolicy.pressureAction === + "EVICT_RECONSTRUCTABLE" + ); +} + +function maintenanceReasonAllowed( + reason: Parameters< + DurableObjectMaintenancePort["enforcePolicies"] + >[0]["reason"], + policy: BrowserStoragePolicy, +): boolean { + switch (reason) { + case "TTL": + return policy.retention.kind === "TTL"; + case "SESSION_END": + return policy.retention.kind === "SESSION"; + case "UNTIL_SYNCED": + return policy.retention.kind === "UNTIL_SYNCED"; + case "LOGOUT": + return ( + policy.accountScope === "OPAQUE_PARTITION" && + (policy.logoutAction === "PURGE_PARTITION" || + policy.logoutAction === "EXPORT_THEN_PURGE") + ); + case "ACCOUNT_DELETION": + return ( + policy.accountScope === "OPAQUE_PARTITION" && + policy.accountDeletionAction === "PURGE_PARTITION" + ); + case "PRESSURE": + return policy.pressureAction === "EVICT_RECONSTRUCTABLE"; + } +} + +function requiresMaintenanceAuthority( + request: Parameters< + DurableObjectMaintenancePort["enforcePolicies"] + >[0], +): request is Extract< + Parameters[0], + { reason: "LOGOUT" | "UNTIL_SYNCED" | "ACCOUNT_DELETION" } +> { + return ( + request.reason === "LOGOUT" || + request.reason === "UNTIL_SYNCED" || + request.reason === "ACCOUNT_DELETION" + ); +} + +const OPAQUE_AUTHORITY_PROOF = /^[A-Za-z0-9_-]{16,512}$/u; +const MAX_AUTHORITY_PROOF_LIFETIME_MS = 5 * 60 * 1_000; diff --git a/src/adapters/storage/opfs/opfs-policy.ts b/src/adapters/storage/opfs/opfs-policy.ts new file mode 100644 index 0000000..e02e6d5 --- /dev/null +++ b/src/adapters/storage/opfs/opfs-policy.ts @@ -0,0 +1,236 @@ +import { + assertValidStoragePolicy, + isValidByteLength, + type BrowserDataFailureCode, + type BrowserDataOperation, + type BrowserStoragePolicy, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import type { OpfsStorageScope } from "../../../application/ports/browser-file-storage/opfs-ports.ts"; + +export type OpfsRuntimePolicy = Readonly<{ + rootDirectoryName: string; + mutationLockName: string; + chunkSizeBytes: number; + maxObjectBytes: number; + maxChunkCount: number; + rpcTimeoutMs: number; + reconciliationBudgetMs: number; + reconciliationBatchSize: number; + orphanGracePeriodMs: number; + orphanGcBatchSize: number; + maxCancellationTombstones: number; + allowAsyncWritableChunkFallback: boolean; + isObjectIdAllowed: (objectId: string) => boolean; + isMediaTypeAllowed: (mediaType: string) => boolean; +}>; + +export type OpfsSafeObservation = Readonly<{ + operation: BrowserDataOperation; + outcome: "STARTED" | "SUCCEEDED" | "FAILED"; + failureCode?: BrowserDataFailureCode; + byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB"; + transactionBucket?: "0" | "1_10" | "11_100" | "GT_100"; +}>; + +export type OpfsSafeObserver = (observation: OpfsSafeObservation) => void; + +const SAFE_SEGMENT = /^[a-z0-9][a-z0-9._-]{0,63}$/u; +const OPAQUE_OBJECT_ID = /^[A-Za-z0-9_-]{8,128}$/u; +const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;.*)?$/iu; + +export const DEFAULT_OPFS_RUNTIME_POLICY: OpfsRuntimePolicy = Object.freeze({ + rootDirectoryName: "ca-frontend-opfs-v1", + mutationLockName: "ca-frontend-opfs-v1:mutation", + chunkSizeBytes: 4 * 1024 * 1024, + maxObjectBytes: 2 * 1024 * 1024 * 1024, + maxChunkCount: 512, + rpcTimeoutMs: 60_000, + reconciliationBudgetMs: 5_000, + reconciliationBatchSize: 100, + orphanGracePeriodMs: 24 * 60 * 60 * 1_000, + orphanGcBatchSize: 100, + maxCancellationTombstones: 1_024, + allowAsyncWritableChunkFallback: true, + isObjectIdAllowed: (objectId) => OPAQUE_OBJECT_ID.test(objectId), + isMediaTypeAllowed: (mediaType) => MEDIA_TYPE.test(mediaType), +}); + +export function resolveOpfsRuntimePolicy( + policy: Partial = {}, +): OpfsRuntimePolicy { + const resolved: OpfsRuntimePolicy = Object.freeze({ + ...DEFAULT_OPFS_RUNTIME_POLICY, + ...policy, + }); + assertOpfsRuntimePolicy(resolved); + return resolved; +} + +export function assertOpfsRuntimePolicy(policy: OpfsRuntimePolicy): void { + if ( + !SAFE_SEGMENT.test(policy.rootDirectoryName) || + policy.mutationLockName.length === 0 || + !Number.isSafeInteger(policy.chunkSizeBytes) || + policy.chunkSizeBytes < 64 * 1024 || + policy.chunkSizeBytes > 64 * 1024 * 1024 || + !isValidByteLength(policy.maxObjectBytes) || + policy.maxObjectBytes < policy.chunkSizeBytes || + !Number.isSafeInteger(policy.maxChunkCount) || + policy.maxChunkCount < 1 || + policy.maxObjectBytes > policy.chunkSizeBytes * policy.maxChunkCount || + !Number.isSafeInteger(policy.rpcTimeoutMs) || + policy.rpcTimeoutMs < 1_000 || + !Number.isSafeInteger(policy.reconciliationBudgetMs) || + policy.reconciliationBudgetMs < 1 || + policy.reconciliationBudgetMs > 60_000 || + !Number.isSafeInteger(policy.reconciliationBatchSize) || + policy.reconciliationBatchSize < 1 || + policy.reconciliationBatchSize > 1_000 || + !Number.isSafeInteger(policy.orphanGracePeriodMs) || + policy.orphanGracePeriodMs < 60_000 || + !Number.isSafeInteger(policy.orphanGcBatchSize) || + policy.orphanGcBatchSize < 1 || + policy.orphanGcBatchSize > 1_000 || + !Number.isSafeInteger(policy.maxCancellationTombstones) || + policy.maxCancellationTombstones < 16 || + policy.maxCancellationTombstones > 10_000 || + typeof policy.isObjectIdAllowed !== "function" || + typeof policy.isMediaTypeAllowed !== "function" + ) { + throw new TypeError("OPFS runtime policy is invalid."); + } +} + +export function validateObjectWriteInput( + input: Readonly<{ + scope: OpfsStorageScope; + objectId: string; + expectedGeneration: number | null; + mediaType: string; + byteLength: number | null; + storagePolicy: Parameters[0]; + }>, + policy: OpfsRuntimePolicy, +): boolean { + try { + assertValidStoragePolicy(input.storagePolicy); + } catch { + return false; + } + + return ( + isValidOpfsStorageScope(input.scope) && + input.scope.namespace === input.storagePolicy.namespace && + policy.isObjectIdAllowed(input.objectId) && + policy.isMediaTypeAllowed(input.mediaType) && + (input.expectedGeneration === null || + (Number.isSafeInteger(input.expectedGeneration) && + input.expectedGeneration > 0)) && + input.byteLength !== null && + isValidByteLength(input.byteLength) && + input.byteLength <= policy.maxObjectBytes && + Math.ceil(input.byteLength / policy.chunkSizeBytes) <= + policy.maxChunkCount + ); +} + +export function isValidOpfsStorageScope( + scope: OpfsStorageScope, +): boolean { + return ( + scope.namespace.length > 0 && + scope.namespace.length <= 64 && + OPAQUE_OBJECT_ID.test(scope.authorityToken) && + OPAQUE_OBJECT_ID.test(scope.namespaceToken) && + OPAQUE_OBJECT_ID.test(scope.partitionToken) + ); +} + +/** + * Captures the registry binding at composition time. Callers may own mutable + * config objects, so no OPFS operation is allowed to retain those references. + */ +export function snapshotOpfsStorageScope( + input: OpfsStorageScope, +): OpfsStorageScope { + try { + const snapshot: OpfsStorageScope = Object.freeze({ + namespace: input.namespace, + authorityToken: input.authorityToken, + namespaceToken: input.namespaceToken, + partitionToken: input.partitionToken, + }); + if (!isValidOpfsStorageScope(snapshot)) throw new TypeError(); + return snapshot; + } catch { + throw new TypeError("OPFS storage scope is invalid."); + } +} + +/** + * Deep enough for the closed BrowserStoragePolicy contract: retention is the + * only nested value. Fields are copied explicitly so later caller mutation or + * extension properties cannot alter the bound policy fingerprint. + */ +export function snapshotOpfsStoragePolicy( + input: BrowserStoragePolicy, +): BrowserStoragePolicy { + try { + const retention: BrowserStoragePolicy["retention"] = + input.retention.kind === "TTL" + ? Object.freeze({ + kind: "TTL", + maxAgeMs: input.retention.maxAgeMs, + }) + : Object.freeze({ kind: input.retention.kind }); + const snapshot: BrowserStoragePolicy = Object.freeze({ + owner: input.owner, + namespace: input.namespace, + classification: input.classification, + authority: input.authority, + accountScope: input.accountScope, + retention, + softBudgetBytes: input.softBudgetBytes, + hardBudgetBytes: input.hardBudgetBytes, + evictionPriority: input.evictionPriority, + logoutAction: input.logoutAction, + accountDeletionAction: input.accountDeletionAction, + pressureAction: input.pressureAction, + unavailableFallback: input.unavailableFallback, + }); + assertValidStoragePolicy(snapshot); + return snapshot; + } catch { + throw new TypeError("OPFS storage policy is invalid."); + } +} + +export function byteBucket( + byteLength: number, +): NonNullable { + if (byteLength === 0) return "0"; + if (byteLength <= 1024 * 1024) return "1B_1MiB"; + if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB"; + if (byteLength <= 256 * 1024 * 1024) return "16MiB_256MiB"; + return "GT_256MiB"; +} + +export function transactionBucket( + count: number, +): NonNullable { + if (count === 0) return "0"; + if (count <= 10) return "1_10"; + if (count <= 100) return "11_100"; + return "GT_100"; +} + +export function observeOpfsSafely( + observer: OpfsSafeObserver | undefined, + observation: OpfsSafeObservation, +): void { + try { + observer?.(Object.freeze({ ...observation })); + } catch { + // Persistence behavior never depends on observability. + } +} diff --git a/src/adapters/storage/opfs/opfs-worker-client.ts b/src/adapters/storage/opfs/opfs-worker-client.ts new file mode 100644 index 0000000..8e1e2bf --- /dev/null +++ b/src/adapters/storage/opfs/opfs-worker-client.ts @@ -0,0 +1,675 @@ +import type { + OpfsCapabilities, + OpfsPreparedObject, +} from "../../../application/ports/browser-file-storage/opfs-ports.ts"; +import type { + BrowserDataFailureCode, + BrowserDataOperation, + BrowserDataResult, + ByteSource, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../browser-file-storage/result.ts"; +import type { OpfsRuntimePolicy } from "./opfs-policy.ts"; +import type { + OpfsWorkerGateway, + OpfsOrphanCandidateBatch, + OpfsOrphanDeleteResult, + OpfsWorkerRequest, + OpfsWorkerRequestBody, + OpfsWorkerResponse, + PreparePhysicalObjectRequest, +} from "./opfs-worker-protocol.ts"; + +export interface OpfsWorkerLike { + postMessage(message: OpfsWorkerRequest, transfer?: readonly Transferable[]): void; + addEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void; + removeEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void; + addFailureEventListener?(listener: (event: Event) => void): void; + removeFailureEventListener?(listener: (event: Event) => void): void; +} + +export type OpfsWorkerClientDependencies = Readonly<{ + worker: OpfsWorkerLike; + policy: OpfsRuntimePolicy; + createRequestId?: () => string; +}>; + +export type OwnedOpfsWorkerClient = Readonly<{ + gateway: OpfsWorkerGateway; + terminate(): void; +}>; + +type PendingRequest = Readonly<{ + resolve: (response: OpfsWorkerResponse) => void; + reject: (error: OpfsRpcError) => void; + timeout: ReturnType; + removeAbortListener: () => void; +}>; + +class OpfsRpcError extends Error { + readonly code: BrowserDataFailureCode; + + constructor(code: BrowserDataFailureCode) { + super("OPFS worker request failed."); + this.name = "OpfsRpcError"; + this.code = code; + } +} + +export function createOpfsWorkerGateway( + dependencies: OpfsWorkerClientDependencies, +): OpfsWorkerGateway { + const createRequestId = + dependencies.createRequestId ?? + (() => globalThis.crypto.randomUUID()); + const pending = new Map(); + let disposed = false; + + const rejectAllPending = (): void => { + for (const request of pending.values()) { + clearTimeout(request.timeout); + request.removeAbortListener(); + request.reject(new OpfsRpcError("UNAVAILABLE")); + } + pending.clear(); + }; + + const onMessage = (event: MessageEvent): void => { + if (disposed) return; + if (!isWorkerResponse(event.data)) return; + const request = pending.get(event.data.requestId); + if (!request) return; + pending.delete(event.data.requestId); + clearTimeout(request.timeout); + request.removeAbortListener(); + request.resolve(event.data); + }; + const onWorkerFailure = (): void => { + if (disposed) return; + disposed = true; + dependencies.worker.removeEventListener("message", onMessage); + dependencies.worker.removeFailureEventListener?.(onWorkerFailure); + rejectAllPending(); + }; + dependencies.worker.addEventListener("message", onMessage); + dependencies.worker.addFailureEventListener?.(onWorkerFailure); + + async function rpc( + request: OpfsWorkerRequestBody, + signal?: AbortSignal, + transfer: readonly Transferable[] = [], + ): Promise { + if (disposed) throw new OpfsRpcError("UNAVAILABLE"); + if (signal?.aborted) throw new OpfsRpcError("ABORTED"); + const requestId = createRequestId(); + if ( + typeof requestId !== "string" || + requestId.length === 0 || + requestId.length > 128 || + pending.has(requestId) + ) { + throw new OpfsRpcError("UNAVAILABLE"); + } + const message = { ...request, requestId } as OpfsWorkerRequest; + + return await new Promise((resolve, reject) => { + const abort = (): void => { + const item = pending.get(requestId); + if (!item) return; + pending.delete(requestId); + clearTimeout(item.timeout); + item.removeAbortListener(); + reject(new OpfsRpcError("ABORTED")); + }; + signal?.addEventListener("abort", abort, { once: true }); + const timeout = setTimeout(() => { + const item = pending.get(requestId); + if (!item) return; + pending.delete(requestId); + item.removeAbortListener(); + reject(new OpfsRpcError("UNAVAILABLE")); + }, dependencies.policy.rpcTimeoutMs); + pending.set(requestId, { + resolve, + reject, + timeout, + removeAbortListener: () => + signal?.removeEventListener("abort", abort), + }); + + try { + dependencies.worker.postMessage(message, transfer); + } catch { + const item = pending.get(requestId); + if (item) { + pending.delete(requestId); + clearTimeout(item.timeout); + item.removeAbortListener(); + } + reject(new OpfsRpcError("UNAVAILABLE")); + } + }); + } + + async function invoke( + operation: BrowserDataOperation, + request: OpfsWorkerRequestBody, + signal?: AbortSignal, + transfer: readonly Transferable[] = [], + parse?: (value: unknown) => Value | null, + ): Promise> { + try { + const response = await rpc(request, signal, transfer); + if (!response.ok) { + return failureResult(response.failure.code, operation); + } + const parsed = parse?.(response.value); + if (parse && parsed === null) { + return browserDataFailure("CORRUPT_DATA", operation, { + recovery: "REHYDRATE", + }); + } + return browserDataSuccess(parsed as Value); + } catch (error) { + return failureResult( + error instanceof OpfsRpcError ? error.code : "UNAVAILABLE", + operation, + ); + } + } + + async function abortAndCleanup( + scope: OpfsPreparedObject["descriptor"]["scope"], + transactionId: string, + ): Promise { + try { + await rpc({ kind: "ABORT_PUT", scope, transactionId }); + } catch { + // Journal reconciliation repeats cleanup after a crash or timeout. + } + } + + return Object.freeze({ + async capabilities() { + return await invoke( + "OBJECT_READ", + { kind: "CAPABILITIES" }, + undefined, + [], + parseCapabilities, + ); + }, + + async preparePut(request: PreparePhysicalObjectRequest) { + const begin = await invoke( + "OBJECT_WRITE", + { + kind: "BEGIN_PUT", + transactionId: request.transactionId, + scope: request.descriptor.scope, + objectId: request.descriptor.objectId, + generation: request.descriptor.generation, + declaredByteLength: request.descriptor.byteLength, + mediaType: request.descriptor.mediaType, + createdAtEpochMs: request.descriptor.createdAtEpochMs, + storagePolicy: request.descriptor.storagePolicy, + chunkSizeBytes: dependencies.policy.chunkSizeBytes, + }, + request.signal, + ); + if (!begin.ok) { + await abortAndCleanup( + request.descriptor.scope, + request.transactionId, + ); + return begin; + } + + let sequence = 0; + let transferredBytes = 0; + notifyProgress(request, "TRANSFERRING", 0); + try { + for await (const chunk of rechunk( + request.source.stream(requiredSignal(request.signal)), + dependencies.policy.chunkSizeBytes, + dependencies.policy.maxObjectBytes, + request.signal, + )) { + const chunkByteLength = chunk.byteLength; + const append = await invoke( + "OBJECT_WRITE", + { + kind: "APPEND_CHUNK", + scope: request.descriptor.scope, + transactionId: request.transactionId, + sequence, + bytes: chunk, + }, + request.signal, + [chunk], + ); + if (!append.ok) { + await abortAndCleanup( + request.descriptor.scope, + request.transactionId, + ); + return append; + } + sequence += 1; + transferredBytes += chunkByteLength; + notifyProgress(request, "TRANSFERRING", transferredBytes); + } + if (transferredBytes !== request.descriptor.byteLength) { + await abortAndCleanup( + request.descriptor.scope, + request.transactionId, + ); + return browserDataFailure("INTEGRITY_FAILED", "OBJECT_WRITE", { + recovery: "RESELECT", + }); + } + notifyProgress(request, "VERIFYING", transferredBytes); + const finished = await invoke( + "OBJECT_WRITE", + { + kind: "FINISH_PUT", + scope: request.descriptor.scope, + transactionId: request.transactionId, + }, + request.signal, + [], + parsePreparedObject, + ); + if (!finished.ok) { + await abortAndCleanup( + request.descriptor.scope, + request.transactionId, + ); + } + return finished; + } catch (error) { + await abortAndCleanup( + request.descriptor.scope, + request.transactionId, + ); + return failureResult( + error instanceof OpfsRpcError ? error.code : "NOT_READABLE", + "OBJECT_WRITE", + ); + } + }, + + async verifyObject( + preparedObject: OpfsPreparedObject, + signal?: AbortSignal, + ) { + return await invoke( + "OBJECT_READ", + { kind: "VERIFY_OBJECT", preparedObject }, + signal, + [], + (value) => (typeof value === "boolean" ? value : null), + ); + }, + + async openObject( + preparedObject: OpfsPreparedObject, + signal?: AbortSignal, + ) { + const verified = await invoke( + "OBJECT_READ", + { kind: "VERIFY_OBJECT", preparedObject }, + signal, + [], + (value) => (typeof value === "boolean" ? value : null), + ); + if (!verified.ok) return verified; + if (!verified.value) { + return browserDataFailure("INTEGRITY_FAILED", "OBJECT_READ", { + recovery: "REHYDRATE", + }); + } + + const source: ByteSource = Object.freeze({ + byteLength: preparedObject.descriptor.byteLength, + async *stream(streamSignal: AbortSignal) { + for (const chunk of preparedObject.chunks) { + if (streamSignal.aborted) { + yield browserDataFailure("ABORTED", "OBJECT_READ"); + return; + } + const result = await invoke( + "OBJECT_READ", + { + kind: "READ_CHUNK", + preparedObject, + sequence: chunk.sequence, + }, + streamSignal, + [], + (value) => (value instanceof ArrayBuffer ? value : null), + ); + if (!result.ok) { + yield result; + return; + } + yield browserDataSuccess(new Uint8Array(result.value)); + } + }, + }); + return browserDataSuccess(source); + }, + + async removeObject( + scope: OpfsPreparedObject["descriptor"]["scope"], + objectId: string, + generation: number, + signal?: AbortSignal, + ) { + return await invoke( + "OBJECT_DELETE", + { kind: "REMOVE_OBJECT", scope, objectId, generation }, + signal, + ); + }, + + async cleanupTransaction( + scope: OpfsPreparedObject["descriptor"]["scope"], + transactionId: string, + signal?: AbortSignal, + ) { + return await invoke( + "OBJECT_RECONCILE", + { kind: "CLEANUP_TRANSACTION", scope, transactionId }, + signal, + ); + }, + + async finalizePut( + transactionId: string, + preparedObject: OpfsPreparedObject, + signal?: AbortSignal, + ) { + return await invoke( + "OBJECT_RECONCILE", + { kind: "FINALIZE_PUT", transactionId, preparedObject }, + signal, + ); + }, + + async listOrphanCandidates( + scope: OpfsPreparedObject["descriptor"]["scope"], + olderThanEpochMs: number, + maxEntries: number, + signal?: AbortSignal, + ) { + return await invoke( + "OBJECT_RECONCILE", + { + kind: "LIST_ORPHAN_CANDIDATES", + scope, + olderThanEpochMs, + maxEntries, + }, + signal, + [], + parseOrphanCandidateBatch, + ); + }, + + async deleteOrphanChunk( + scope: OpfsPreparedObject["descriptor"]["scope"], + digestHex: string, + olderThanEpochMs: number, + signal?: AbortSignal, + ) { + return await invoke( + "OBJECT_RECONCILE", + { + kind: "DELETE_ORPHAN_CHUNK", + scope, + digestHex, + olderThanEpochMs, + }, + signal, + [], + parseOrphanDeleteResult, + ); + }, + + close() { + if (disposed) return; + disposed = true; + dependencies.worker.removeEventListener("message", onMessage); + dependencies.worker.removeFailureEventListener?.(onWorkerFailure); + rejectAllPending(); + }, + }); +} + +export function createOwnedOpfsWorkerClient( + dependencies: Readonly<{ + workerUrl: string | URL; + policy: OpfsRuntimePolicy; + workerName?: string; + createRequestId?: () => string; + }>, +): OwnedOpfsWorkerClient { + const worker = new Worker(dependencies.workerUrl, { + type: "module", + name: dependencies.workerName ?? "ca-opfs-byte-store", + }); + const workerPort: OpfsWorkerLike = { + postMessage: (message, transfer) => + worker.postMessage(message, transfer ? [...transfer] : []), + addEventListener: (_type, listener) => + worker.addEventListener("message", listener), + removeEventListener: (_type, listener) => + worker.removeEventListener("message", listener), + addFailureEventListener: (listener) => { + worker.addEventListener("error", listener); + worker.addEventListener("messageerror", listener); + }, + removeFailureEventListener: (listener) => { + worker.removeEventListener("error", listener); + worker.removeEventListener("messageerror", listener); + }, + }; + const gateway = createOpfsWorkerGateway({ + worker: workerPort, + policy: dependencies.policy, + createRequestId: dependencies.createRequestId, + }); + return Object.freeze({ + gateway, + terminate: () => { + gateway.close(); + worker.terminate(); + }, + }); +} + +async function* rechunk( + source: AsyncIterable>, + chunkSize: number, + maxBytes: number, + signal: AbortSignal | undefined, +): AsyncGenerator { + let target = new Uint8Array(chunkSize); + let targetOffset = 0; + let totalBytes = 0; + + for await (const sourceResult of source) { + if (signal?.aborted) throw new OpfsRpcError("ABORTED"); + if (!sourceResult.ok) { + throw new OpfsRpcError(sourceResult.error.code); + } + const sourceChunk = sourceResult.value; + if (!(sourceChunk instanceof Uint8Array)) { + throw new OpfsRpcError("CORRUPT_DATA"); + } + let sourceOffset = 0; + totalBytes += sourceChunk.byteLength; + if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) { + throw new OpfsRpcError("LIMIT_EXCEEDED"); + } + while (sourceOffset < sourceChunk.byteLength) { + const copyLength = Math.min( + chunkSize - targetOffset, + sourceChunk.byteLength - sourceOffset, + ); + target.set( + sourceChunk.subarray(sourceOffset, sourceOffset + copyLength), + targetOffset, + ); + sourceOffset += copyLength; + targetOffset += copyLength; + if (targetOffset === chunkSize) { + yield target.buffer as ArrayBuffer; + target = new Uint8Array(chunkSize); + targetOffset = 0; + } + } + } + + if (targetOffset > 0) { + yield target.slice(0, targetOffset).buffer as ArrayBuffer; + } +} + +function requiredSignal(signal: AbortSignal | undefined): AbortSignal { + return signal ?? new AbortController().signal; +} + +function notifyProgress( + request: PreparePhysicalObjectRequest, + phase: "TRANSFERRING" | "VERIFYING", + transferredBytes: number, +): void { + try { + request.onProgress?.({ + phase, + transferredBytes, + totalBytes: request.descriptor.byteLength, + }); + } catch { + // A UI callback cannot affect the write protocol. + } +} + +function failureResult( + code: BrowserDataFailureCode, + operation: BrowserDataOperation, +): BrowserDataResult { + if (code === "ABORTED") return browserDataFailure(code, operation); + if (code === "QUOTA_EXCEEDED") { + return browserDataFailure(code, operation, { + retryable: true, + recovery: "READ_ONLY", + }); + } + if (code === "INTEGRITY_FAILED" || code === "CORRUPT_DATA") { + return browserDataFailure(code, operation, { recovery: "REHYDRATE" }); + } + if (code === "UNSUPPORTED" || code === "UNAVAILABLE") { + return browserDataFailure(code, operation, { + retryable: code === "UNAVAILABLE", + recovery: "ONLINE_ONLY", + }); + } + return browserDataFailure(code, operation, { + retryable: code === "BLOCKED" || code === "NOT_READABLE", + recovery: code === "NOT_FOUND" ? "REHYDRATE" : "RETRY", + }); +} + +function parseCapabilities(value: unknown): OpfsCapabilities | null { + if ( + !value || + typeof value !== "object" || + !("available" in value) || + typeof value.available !== "boolean" || + !("dedicatedWorkerRequired" in value) || + value.dedicatedWorkerRequired !== true || + !("crossContextMutationLockAvailable" in value) || + typeof value.crossContextMutationLockAvailable !== "boolean" || + !("synchronousAccessHandleAvailable" in value) || + typeof value.synchronousAccessHandleAvailable !== "boolean" + ) { + return null; + } + return value as OpfsCapabilities; +} + +function parsePreparedObject(value: unknown): OpfsPreparedObject | null { + if ( + !value || + typeof value !== "object" || + !("physicalSchemaVersion" in value) || + value.physicalSchemaVersion !== 1 || + !("descriptor" in value) || + !("chunks" in value) || + !Array.isArray(value.chunks) + ) { + return null; + } + return value as OpfsPreparedObject; +} + +function parseOrphanCandidateBatch( + value: unknown, +): OpfsOrphanCandidateBatch | null { + if ( + !value || + typeof value !== "object" || + !("safeToSweep" in value) || + typeof value.safeToSweep !== "boolean" || + !("digests" in value) || + !Array.isArray(value.digests) || + !value.digests.every( + (digest) => + typeof digest === "string" && /^[a-f0-9]{64}$/u.test(digest), + ) || + !("moreAvailable" in value) || + typeof value.moreAvailable !== "boolean" + ) { + return null; + } + return value as OpfsOrphanCandidateBatch; +} + +function parseOrphanDeleteResult( + value: unknown, +): OpfsOrphanDeleteResult | null { + if ( + !value || + typeof value !== "object" || + !("deleted" in value) || + typeof value.deleted !== "boolean" || + !("skippedUnsafe" in value) || + typeof value.skippedUnsafe !== "boolean" + ) { + return null; + } + return value as OpfsOrphanDeleteResult; +} + +function isWorkerResponse(value: unknown): value is OpfsWorkerResponse { + return Boolean( + value && + typeof value === "object" && + "requestId" in value && + typeof value.requestId === "string" && + "ok" in value && + typeof value.ok === "boolean", + ); +} diff --git a/src/adapters/storage/opfs/opfs-worker-protocol.ts b/src/adapters/storage/opfs/opfs-worker-protocol.ts new file mode 100644 index 0000000..90768a6 --- /dev/null +++ b/src/adapters/storage/opfs/opfs-worker-protocol.ts @@ -0,0 +1,194 @@ +import type { + DurableObjectDescriptor, + OpfsCapabilities, + OpfsPreparedObject, + OpfsStorageScope, +} from "../../../application/ports/browser-file-storage/opfs-ports.ts"; +import type { + BrowserDataFailureCode, + BrowserDataResult, + BrowserStoragePolicy, + ByteSource, + TransferProgress, +} from "../../../application/ports/browser-file-storage/shared.ts"; + +export type OpfsWorkerRequest = + | Readonly<{ + requestId: string; + kind: "CAPABILITIES"; + }> + | Readonly<{ + requestId: string; + kind: "BEGIN_PUT"; + transactionId: string; + scope: OpfsStorageScope; + objectId: string; + generation: number; + declaredByteLength: number; + mediaType: string; + createdAtEpochMs: number; + storagePolicy: BrowserStoragePolicy; + chunkSizeBytes: number; + }> + | Readonly<{ + requestId: string; + kind: "APPEND_CHUNK"; + scope: OpfsStorageScope; + transactionId: string; + sequence: number; + bytes: ArrayBuffer; + }> + | Readonly<{ + requestId: string; + kind: "FINISH_PUT"; + scope: OpfsStorageScope; + transactionId: string; + }> + | Readonly<{ + requestId: string; + kind: "ABORT_PUT"; + scope: OpfsStorageScope; + transactionId: string; + }> + | Readonly<{ + requestId: string; + kind: "VERIFY_OBJECT"; + preparedObject: OpfsPreparedObject; + }> + | Readonly<{ + requestId: string; + kind: "READ_CHUNK"; + preparedObject: OpfsPreparedObject; + sequence: number; + }> + | Readonly<{ + requestId: string; + kind: "REMOVE_OBJECT"; + scope: OpfsStorageScope; + objectId: string; + generation: number; + }> + | Readonly<{ + requestId: string; + kind: "CLEANUP_TRANSACTION"; + scope: OpfsStorageScope; + transactionId: string; + }> + | Readonly<{ + requestId: string; + kind: "FINALIZE_PUT"; + transactionId: string; + preparedObject: OpfsPreparedObject; + }> + | Readonly<{ + requestId: string; + kind: "LIST_ORPHAN_CANDIDATES"; + scope: OpfsStorageScope; + olderThanEpochMs: number; + maxEntries: number; + }> + | Readonly<{ + requestId: string; + kind: "DELETE_ORPHAN_CHUNK"; + scope: OpfsStorageScope; + digestHex: string; + olderThanEpochMs: number; + }>; + +export type OpfsWorkerRequestBody = + OpfsWorkerRequest extends infer Request + ? Request extends OpfsWorkerRequest + ? Omit + : never + : never; + +export type OpfsWorkerFailure = Readonly<{ + code: BrowserDataFailureCode; + retryable: boolean; +}>; + +export type OpfsOrphanCandidateBatch = Readonly<{ + safeToSweep: boolean; + digests: readonly string[]; + moreAvailable: boolean; +}>; + +export type OpfsOrphanDeleteResult = Readonly<{ + deleted: boolean; + skippedUnsafe: boolean; +}>; + +export type OpfsWorkerResponse = + | Readonly<{ + requestId: string; + ok: true; + value?: + | OpfsCapabilities + | OpfsPreparedObject + | ArrayBuffer + | boolean + | OpfsOrphanCandidateBatch + | OpfsOrphanDeleteResult; + }> + | Readonly<{ + requestId: string; + ok: false; + failure: OpfsWorkerFailure; + }>; + +export type PreparePhysicalObjectRequest = Readonly<{ + transactionId: string; + descriptor: Omit; + source: ByteSource; + signal?: AbortSignal; + onProgress?: (progress: TransferProgress) => void; +}>; + +/** + * The coordinator depends on this technology-neutral worker gateway. The + * browser implementation below the boundary owns Worker, MessageEvent and + * transferable ArrayBuffer instances. + */ +export interface OpfsWorkerGateway { + capabilities(): Promise>; + preparePut( + request: PreparePhysicalObjectRequest, + ): Promise>; + verifyObject( + preparedObject: OpfsPreparedObject, + signal?: AbortSignal, + ): Promise>; + openObject( + preparedObject: OpfsPreparedObject, + signal?: AbortSignal, + ): Promise>; + removeObject( + scope: OpfsStorageScope, + objectId: string, + generation: number, + signal?: AbortSignal, + ): Promise>; + cleanupTransaction( + scope: OpfsStorageScope, + transactionId: string, + signal?: AbortSignal, + ): Promise>; + finalizePut( + transactionId: string, + preparedObject: OpfsPreparedObject, + signal?: AbortSignal, + ): Promise>; + listOrphanCandidates( + scope: OpfsStorageScope, + olderThanEpochMs: number, + maxEntries: number, + signal?: AbortSignal, + ): Promise>; + deleteOrphanChunk( + scope: OpfsStorageScope, + digestHex: string, + olderThanEpochMs: number, + signal?: AbortSignal, + ): Promise>; + close(): void; +} diff --git a/src/adapters/storage/opfs/opfs-worker-runtime.ts b/src/adapters/storage/opfs/opfs-worker-runtime.ts new file mode 100644 index 0000000..96c5856 --- /dev/null +++ b/src/adapters/storage/opfs/opfs-worker-runtime.ts @@ -0,0 +1,1703 @@ +import type { + OpfsCapabilities, + OpfsChunkReference, + OpfsPreparedObject, + OpfsStorageScope, +} from "../../../application/ports/browser-file-storage/opfs-ports.ts"; +import { + assertValidStoragePolicy, + type BrowserDataFailureCode, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { + resolveOpfsRuntimePolicy, + isValidOpfsStorageScope, + type OpfsRuntimePolicy, +} from "./opfs-policy.ts"; +import type { + OpfsOrphanCandidateBatch, + OpfsOrphanDeleteResult, + OpfsWorkerFailure, + OpfsWorkerRequest, + OpfsWorkerResponse, +} from "./opfs-worker-protocol.ts"; + +export interface OpfsMutationLease { + release(): void; +} + +export interface OpfsMutationLeaseManager { + acquire(signal?: AbortSignal): Promise; +} + +export interface OpfsWorkerMessageHost { + addEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void; + postMessage( + message: OpfsWorkerResponse, + transfer?: readonly Transferable[], + ): void; +} + +export type BrowserOpfsWorkerDependencies = Readonly<{ + storageManager: StorageManager; + lockManager?: LockManager; + crypto: Crypto; + dedicatedWorker: boolean; + supportsSynchronousAccessHandles: boolean; + policy?: Partial; +}>; + +type ActivePut = { + readonly transactionId: string; + readonly scope: OpfsPreparedObject["descriptor"]["scope"]; + readonly objectId: string; + readonly generation: number; + readonly declaredByteLength: number; + readonly mediaType: string; + readonly createdAtEpochMs: number; + readonly storagePolicy: OpfsPreparedObject["descriptor"]["storagePolicy"]; + readonly chunkSizeBytes: number; + readonly lease: OpfsMutationLease; + readonly abortController: AbortController; + readonly chunks: OpfsChunkReference[]; + operationTail: Promise; + state: "ACTIVE" | "FINISHING" | "ABORTED"; + totalBytes: number; + sawShortChunk: boolean; +}; + +type SyncAccessHandleLike = { + write(data: ArrayBufferView, options?: { at?: number }): number; + truncate(newSize: number): void; + flush(): void; + close(): void; +}; + +type SyncCapableFileHandle = FileSystemFileHandle & { + createSyncAccessHandle?: () => Promise; +}; + +type LockManagerLike = { + request( + name: string, + options: Readonly<{ mode: "exclusive"; signal?: AbortSignal }>, + callback: (lock: unknown) => Promise, + ): Promise; +}; + +class OpfsRuntimeFailure extends Error { + readonly code: BrowserDataFailureCode; + readonly retryable: boolean; + + constructor(code: BrowserDataFailureCode, retryable = false) { + super("OPFS operation failed."); + this.name = "OpfsRuntimeFailure"; + this.code = code; + this.retryable = retryable; + } +} + +export interface OpfsWorkerRuntime { + handleRequest(request: unknown): Promise; +} + +export async function createBrowserOpfsWorkerRuntime( + dependencies: BrowserOpfsWorkerDependencies, +): Promise { + const policy = resolveOpfsRuntimePolicy(dependencies.policy); + const originRoot = await dependencies.storageManager.getDirectory(); + const root = await originRoot.getDirectoryHandle(policy.rootDirectoryName, { + create: true, + }); + const leaseManager = dependencies.lockManager + ? createWebLockLeaseManager( + dependencies.lockManager as unknown as LockManagerLike, + policy.mutationLockName, + ) + : null; + + return createOpfsWorkerRuntime({ + root, + crypto: dependencies.crypto, + policy, + leaseManager, + dedicatedWorker: dependencies.dedicatedWorker, + supportsSynchronousAccessHandles: + dependencies.supportsSynchronousAccessHandles, + }); +} + +export async function startBrowserOpfsDedicatedWorker( + host: OpfsWorkerMessageHost, + dependencies: Readonly<{ + storageManager: StorageManager; + lockManager?: LockManager; + crypto: Crypto; + policy?: Partial; + }>, +): Promise { + const supportsSynchronousAccessHandles = + typeof FileSystemFileHandle !== "undefined" && + "createSyncAccessHandle" in FileSystemFileHandle.prototype; + const runtimePromise = createBrowserOpfsWorkerRuntime({ + ...dependencies, + dedicatedWorker: true, + supportsSynchronousAccessHandles, + }); + // Install before awaiting OPFS initialization. Worker messages posted while + // getDirectory() is pending must not be dropped during bootstrap. + host.addEventListener("message", (event) => { + if (!hasRequestId(event.data)) return; + const requestData = event.data; + void runtimePromise + .then((runtime) => runtime.handleRequest(requestData)) + .then((response) => postWorkerResponse(host, response)) + .catch((error: unknown) => { + host.postMessage( + failure(requestData.requestId, mapRuntimeFailure(error)), + ); + }); + }); + await runtimePromise; +} + +export function createOpfsWorkerRuntime( + dependencies: Readonly<{ + root: FileSystemDirectoryHandle; + crypto: Crypto; + policy: OpfsRuntimePolicy; + leaseManager: OpfsMutationLeaseManager | null; + dedicatedWorker: boolean; + supportsSynchronousAccessHandles: boolean; + }>, +): OpfsWorkerRuntime { + const activePuts = new Map(); + const pendingBegins = new Map(); + const cancellationTombstones = new Set(); + const requiredCapabilitiesAvailable = + dependencies.dedicatedWorker && + dependencies.leaseManager !== null && + (dependencies.supportsSynchronousAccessHandles || + dependencies.policy.allowAsyncWritableChunkFallback); + const capabilities: OpfsCapabilities = Object.freeze({ + available: requiredCapabilitiesAvailable, + dedicatedWorkerRequired: true, + crossContextMutationLockAvailable: dependencies.leaseManager !== null, + synchronousAccessHandleAvailable: + dependencies.dedicatedWorker && + dependencies.supportsSynchronousAccessHandles, + }); + + return Object.freeze({ + async handleRequest(request: unknown) { + if (!hasRequestId(request)) return null; + try { + if (!isWorkerRequest(request)) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + switch (request.kind) { + case "CAPABILITIES": + return success(request.requestId, capabilities); + case "BEGIN_PUT": + await beginPut(request); + return success(request.requestId); + case "APPEND_CHUNK": + await appendChunk(request); + return success(request.requestId); + case "FINISH_PUT": + return success(request.requestId, await finishPut(request)); + case "ABORT_PUT": + await abortPut(request.scope, request.transactionId); + return success(request.requestId); + case "VERIFY_OBJECT": + return success( + request.requestId, + await verifyObject(request.preparedObject), + ); + case "READ_CHUNK": + return success( + request.requestId, + await readVerifiedChunk( + request.preparedObject, + request.sequence, + ), + ); + case "REMOVE_OBJECT": + await removeObject( + request.scope, + request.objectId, + request.generation, + ); + return success(request.requestId); + case "CLEANUP_TRANSACTION": + await cleanupTransaction( + request.scope, + request.transactionId, + ); + return success(request.requestId); + case "FINALIZE_PUT": + await finalizePut( + request.transactionId, + request.preparedObject, + ); + return success(request.requestId); + case "LIST_ORPHAN_CANDIDATES": + return success( + request.requestId, + await listOrphanCandidates( + request.scope, + request.olderThanEpochMs, + request.maxEntries, + ), + ); + case "DELETE_ORPHAN_CHUNK": + return success( + request.requestId, + await deleteOrphanChunk( + request.scope, + request.digestHex, + request.olderThanEpochMs, + ), + ); + } + } catch (error) { + return failure(request.requestId, mapRuntimeFailure(error)); + } + }, + }); + + async function beginPut( + request: Extract, + ): Promise { + assertWorkerAvailable(); + if ( + !SAFE_TRANSACTION_ID.test(request.transactionId) || + !isValidOpfsStorageScope(request.scope) || + !dependencies.policy.isObjectIdAllowed(request.objectId) || + !Number.isSafeInteger(request.generation) || + request.generation < 1 || + !Number.isSafeInteger(request.declaredByteLength) || + request.declaredByteLength < 0 || + request.declaredByteLength > dependencies.policy.maxObjectBytes || + !dependencies.policy.isMediaTypeAllowed(request.mediaType) || + !Number.isSafeInteger(request.createdAtEpochMs) || + request.createdAtEpochMs < 0 || + request.chunkSizeBytes !== dependencies.policy.chunkSizeBytes + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + try { + assertValidStoragePolicy(request.storagePolicy); + } catch { + throw new OpfsRuntimeFailure("POLICY_REJECTED"); + } + if (request.storagePolicy.namespace !== request.scope.namespace) { + throw new OpfsRuntimeFailure("POLICY_REJECTED"); + } + const transactionKey = scopedTransactionKey( + request.scope, + request.transactionId, + ); + if (activePuts.has(transactionKey)) { + throw new OpfsRuntimeFailure("CONFLICT"); + } + + if (cancellationTombstones.has(transactionKey)) { + throw new OpfsRuntimeFailure("ABORTED"); + } + const beginAbort = new AbortController(); + pendingBegins.set(transactionKey, beginAbort); + let lease: OpfsMutationLease; + try { + lease = await dependencies.leaseManager!.acquire(beginAbort.signal); + } finally { + pendingBegins.delete(transactionKey); + } + if ( + beginAbort.signal.aborted || + cancellationTombstones.has(transactionKey) + ) { + lease.release(); + throw new OpfsRuntimeFailure("ABORTED"); + } + const put: ActivePut = { + transactionId: request.transactionId, + scope: request.scope, + objectId: request.objectId, + generation: request.generation, + declaredByteLength: request.declaredByteLength, + mediaType: request.mediaType, + createdAtEpochMs: request.createdAtEpochMs, + storagePolicy: request.storagePolicy, + chunkSizeBytes: request.chunkSizeBytes, + lease, + abortController: beginAbort, + chunks: [], + operationTail: Promise.resolve(), + state: "ACTIVE", + totalBytes: 0, + sawShortChunk: false, + }; + activePuts.set(transactionKey, put); + try { + await writeReceipt(put); + if ( + beginAbort.signal.aborted || + cancellationTombstones.has(transactionKey) + ) { + if (activePuts.get(transactionKey) === put) { + activePuts.delete(transactionKey); + lease.release(); + } + await cleanupTransaction(request.scope, request.transactionId); + throw new OpfsRuntimeFailure("ABORTED"); + } + } catch (error) { + if (activePuts.get(transactionKey) === put) { + activePuts.delete(transactionKey); + lease.release(); + } + throw error; + } + } + + async function appendChunk( + request: Extract, + ): Promise { + if (!isValidOpfsStorageScope(request.scope)) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const transactionKey = scopedTransactionKey( + request.scope, + request.transactionId, + ); + const put = activePuts.get(transactionKey); + if (!put) throw new OpfsRuntimeFailure("INVALID_INPUT"); + try { + await runActivePutOperation(transactionKey, put, async () => { + if ( + request.sequence !== put.chunks.length || + !(request.bytes instanceof ArrayBuffer) || + request.bytes.byteLength === 0 || + request.bytes.byteLength > put.chunkSizeBytes || + put.sawShortChunk || + put.chunks.length >= dependencies.policy.maxChunkCount + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const nextTotal = put.totalBytes + request.bytes.byteLength; + if ( + !Number.isSafeInteger(nextTotal) || + nextTotal > put.declaredByteLength || + nextTotal > dependencies.policy.maxObjectBytes + ) { + throw new OpfsRuntimeFailure("LIMIT_EXCEEDED"); + } + + const bytes = new Uint8Array(request.bytes); + const digestHex = await sha256Hex(dependencies.crypto, bytes); + assertActivePut(transactionKey, put); + await writeImmutableChunk(put.scope, digestHex, bytes); + assertActivePut(transactionKey, put); + put.chunks.push( + Object.freeze({ + sequence: request.sequence, + byteLength: bytes.byteLength, + digestHex, + }), + ); + put.totalBytes = nextTotal; + put.sawShortChunk = bytes.byteLength < put.chunkSizeBytes; + await writeReceipt(put); + assertActivePut(transactionKey, put); + }); + } catch (error) { + if (put.state !== "ABORTED") { + await failActivePut(transactionKey, put); + } + throw error; + } + } + + async function finishPut( + request: Extract, + ): Promise { + if (!isValidOpfsStorageScope(request.scope)) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const transactionKey = scopedTransactionKey( + request.scope, + request.transactionId, + ); + const put = activePuts.get(transactionKey); + if (!put) throw new OpfsRuntimeFailure("INVALID_INPUT"); + try { + const preparedObject = await runActivePutOperation( + transactionKey, + put, + async () => { + if (put.state !== "ACTIVE") { + throw new OpfsRuntimeFailure("CONFLICT"); + } + put.state = "FINISHING"; + if ( + put.totalBytes !== put.declaredByteLength || + put.chunks.length !== + Math.ceil(put.declaredByteLength / put.chunkSizeBytes) + ) { + throw new OpfsRuntimeFailure("INTEGRITY_FAILED"); + } + const rootDigestHex = await treeDigestHex( + dependencies.crypto, + put.chunkSizeBytes, + put.declaredByteLength, + put.chunks, + ); + assertActivePut(transactionKey, put, true); + const prepared: OpfsPreparedObject = Object.freeze({ + physicalSchemaVersion: 1, + descriptor: Object.freeze({ + objectId: put.objectId, + scope: put.scope, + generation: put.generation, + byteLength: put.declaredByteLength, + mediaType: put.mediaType, + createdAtEpochMs: put.createdAtEpochMs, + integrity: Object.freeze({ + algorithm: "SHA-256-TREE-V1", + rootDigestHex, + chunkSizeBytes: put.chunkSizeBytes, + }), + storagePolicy: put.storagePolicy, + }), + chunks: Object.freeze([...put.chunks]), + }); + await writeJsonAtomic(manifestPath(prepared), prepared); + assertActivePut(transactionKey, put, true); + await writeJsonAtomic(receiptPath(put.scope, put.transactionId), { + schemaVersion: 1, + phase: "FILES_READY", + preparedObject: prepared, + }); + assertActivePut(transactionKey, put, true); + return prepared; + }, + ); + if (activePuts.get(transactionKey) === put) { + activePuts.delete(transactionKey); + put.lease.release(); + } + return preparedObject; + } catch (error) { + if (put.state !== "ABORTED") { + await failActivePut(transactionKey, put); + } + throw error; + } + } + + async function abortPut( + scope: OpfsPreparedObject["descriptor"]["scope"], + transactionId: string, + ): Promise { + if ( + !isValidOpfsStorageScope(scope) || + !SAFE_TRANSACTION_ID.test(transactionId) + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const transactionKey = scopedTransactionKey(scope, transactionId); + rememberCancellation(transactionKey); + pendingBegins.get(transactionKey)?.abort(); + const active = activePuts.get(transactionKey); + if (active) { + active.state = "ABORTED"; + active.abortController.abort(); + await active.operationTail; + if (activePuts.get(transactionKey) === active) { + activePuts.delete(transactionKey); + active.lease.release(); + } + await removePhysicalGeneration( + active.scope, + active.objectId, + active.generation, + ); + } + await cleanupTransaction(scope, transactionId); + } + + async function runActivePutOperation( + transactionKey: string, + put: ActivePut, + operation: () => Promise, + ): Promise { + const previous = put.operationTail; + let release: (() => void) | undefined; + put.operationTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + assertActivePut(transactionKey, put, true); + return await operation(); + } finally { + release?.(); + } + } + + function assertActivePut( + transactionKey: string, + put: ActivePut, + allowFinishing = false, + ): void { + if ( + activePuts.get(transactionKey) !== put || + put.abortController.signal.aborted || + put.state === "ABORTED" || + (!allowFinishing && put.state !== "ACTIVE") + ) { + throw new OpfsRuntimeFailure("ABORTED"); + } + } + + async function failActivePut( + transactionKey: string, + put: ActivePut, + ): Promise { + put.state = "ABORTED"; + put.abortController.abort(); + if (activePuts.get(transactionKey) === put) { + activePuts.delete(transactionKey); + put.lease.release(); + } + await removePhysicalGeneration( + put.scope, + put.objectId, + put.generation, + ); + await cleanupTransaction(put.scope, put.transactionId); + } + + async function removePhysicalGeneration( + scope: OpfsStorageScope, + objectId: string, + generation: number, + ): Promise { + try { + const objectDirectory = await getDirectory( + dependencies.root, + [ + ...scopeRootPath(scope), + "objects", + objectId.slice(0, 2), + objectId, + ], + false, + ); + await removeEntryIfPresent( + objectDirectory, + String(generation), + true, + ); + } catch (error) { + if (!isNotFound(error)) throw error; + } + } + + async function verifyObject( + expected: OpfsPreparedObject, + ): Promise { + if (!isPreparedObjectSafe(expected, dependencies.policy)) return false; + let stored: unknown; + try { + stored = await readJson(manifestPath(expected)); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + if ( + !isPreparedObjectSafe(stored, dependencies.policy) || + stableJson(stored) !== stableJson(expected) + ) { + return false; + } + const rootDigestHex = await treeDigestHex( + dependencies.crypto, + expected.descriptor.integrity.chunkSizeBytes, + expected.descriptor.byteLength, + expected.chunks, + ); + if (rootDigestHex !== expected.descriptor.integrity.rootDigestHex) { + return false; + } + for (const chunk of expected.chunks) { + let bytes: Uint8Array; + try { + bytes = await readFile( + chunkPath(expected.descriptor.scope, chunk.digestHex), + ); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + if ( + bytes.byteLength !== chunk.byteLength || + (await sha256Hex(dependencies.crypto, bytes)) !== chunk.digestHex + ) { + return false; + } + } + return true; + } + + async function readVerifiedChunk( + expected: OpfsPreparedObject, + sequence: number, + ): Promise { + if ( + !isPreparedObjectSafe(expected, dependencies.policy) || + !Number.isSafeInteger(sequence) || + sequence < 0 + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const reference = expected.chunks[sequence]; + if (!reference || reference.sequence !== sequence) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const bytes = await readFile( + chunkPath(expected.descriptor.scope, reference.digestHex), + ); + if ( + bytes.byteLength !== reference.byteLength || + (await sha256Hex(dependencies.crypto, bytes)) !== reference.digestHex + ) { + throw new OpfsRuntimeFailure("INTEGRITY_FAILED"); + } + const copy = Uint8Array.from(bytes); + return copy.buffer as ArrayBuffer; + } + + async function removeObject( + scope: OpfsPreparedObject["descriptor"]["scope"], + objectId: string, + generation: number, + ): Promise { + assertWorkerAvailable(); + if ( + !isValidOpfsStorageScope(scope) || + !dependencies.policy.isObjectIdAllowed(objectId) || + !Number.isSafeInteger(generation) || + generation < 1 + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const lease = await dependencies.leaseManager!.acquire(); + try { + const prefixDirectory = await getDirectory( + dependencies.root, + [ + ...scopeRootPath(scope), + "objects", + objectId.slice(0, 2), + ], + false, + ); + await removeEntryIfPresent(prefixDirectory, objectId, true); + } catch (error) { + if (!isNotFound(error)) throw error; + } finally { + lease.release(); + } + } + + async function cleanupTransaction( + scope: OpfsPreparedObject["descriptor"]["scope"], + transactionId: string, + removePreparedGeneration = true, + ): Promise { + if ( + !isValidOpfsStorageScope(scope) || + !SAFE_TRANSACTION_ID.test(transactionId) + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + let staging: FileSystemDirectoryHandle; + try { + staging = await getDirectory( + dependencies.root, + [...scopeRootPath(scope), "staging"], + false, + ); + } catch (error) { + if (isNotFound(error)) return; + throw error; + } + if (removePreparedGeneration) { + let receipt: unknown; + try { + receipt = await readJson(receiptPath(scope, transactionId)); + } catch (error) { + if (isNotFound(error)) { + await removeEntryIfPresent(staging, transactionId, true); + return; + } + // Keep unreadable staging in place so orphan GC fails closed. + throw error; + } + const target = extractReceiptPhysicalTarget(receipt, scope); + if (!target) { + throw new OpfsRuntimeFailure("CORRUPT_DATA"); + } + await removePhysicalGeneration( + scope, + target.objectId, + target.generation, + ); + } + await removeEntryIfPresent(staging, transactionId, true); + } + + async function finalizePut( + transactionId: string, + preparedObject: OpfsPreparedObject, + ): Promise { + assertWorkerAvailable(); + if ( + !SAFE_TRANSACTION_ID.test(transactionId) || + !isPreparedObjectSafe(preparedObject, dependencies.policy) + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const lease = await dependencies.leaseManager!.acquire(); + try { + const descriptor = preparedObject.descriptor; + const objectDirectory = await getDirectory( + dependencies.root, + [ + ...scopeRootPath(descriptor.scope), + "objects", + descriptor.objectId.slice(0, 2), + descriptor.objectId, + ], + false, + ); + for await (const [name, handle] of objectDirectory.entries()) { + if ( + handle.kind === "directory" && + /^\d+$/u.test(name) && + name !== String(descriptor.generation) + ) { + await objectDirectory.removeEntry(name, { recursive: true }); + } + } + await cleanupTransaction(descriptor.scope, transactionId, false); + } catch (error) { + if (!isNotFound(error)) throw error; + await cleanupTransaction( + preparedObject.descriptor.scope, + transactionId, + false, + ); + } finally { + lease.release(); + } + } + + async function listOrphanCandidates( + scope: OpfsPreparedObject["descriptor"]["scope"], + olderThanEpochMs: number, + maxEntries: number, + ): Promise { + assertWorkerAvailable(); + if ( + !isValidOpfsStorageScope(scope) || + !Number.isSafeInteger(olderThanEpochMs) || + olderThanEpochMs < 0 || + !Number.isSafeInteger(maxEntries) || + maxEntries < 1 || + maxEntries > dependencies.policy.orphanGcBatchSize + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const lease = await dependencies.leaseManager!.acquire(); + try { + const staged = await stagedChunkDigests(scope); + if (!staged.safeToSweep) { + return Object.freeze({ + safeToSweep: false, + digests: Object.freeze([]), + moreAvailable: false, + }); + } + let shaRoot: FileSystemDirectoryHandle; + try { + shaRoot = await getDirectory( + dependencies.root, + [...scopeRootPath(scope), "chunks", "sha256"], + false, + ); + } catch (error) { + if (isNotFound(error)) { + return Object.freeze({ + safeToSweep: true, + digests: Object.freeze([]), + moreAvailable: false, + }); + } + throw error; + } + const candidates: string[] = []; + outer: for await (const [, prefixHandle] of shaRoot.entries()) { + if (prefixHandle.kind !== "directory") continue; + const prefixDirectory = + prefixHandle as FileSystemDirectoryHandle; + for await (const [name, handle] of prefixDirectory.entries()) { + if (handle.kind !== "file") continue; + const digestHex = name.endsWith(".bin") + ? name.slice(0, -4) + : ""; + if ( + !SHA256_HEX.test(digestHex) || + staged.digests.has(digestHex) + ) { + continue; + } + const file = await ( + handle as FileSystemFileHandle + ).getFile(); + if (file.lastModified > olderThanEpochMs) continue; + candidates.push(digestHex); + if (candidates.length > maxEntries) break outer; + } + } + return Object.freeze({ + safeToSweep: true, + digests: Object.freeze(candidates.slice(0, maxEntries)), + moreAvailable: candidates.length > maxEntries, + }); + } finally { + lease.release(); + } + } + + async function deleteOrphanChunk( + scope: OpfsPreparedObject["descriptor"]["scope"], + digestHex: string, + olderThanEpochMs: number, + ): Promise { + assertWorkerAvailable(); + if ( + !isValidOpfsStorageScope(scope) || + !SHA256_HEX.test(digestHex) || + !Number.isSafeInteger(olderThanEpochMs) || + olderThanEpochMs < 0 + ) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const lease = await dependencies.leaseManager!.acquire(); + try { + const staged = await stagedChunkDigests(scope); + if (!staged.safeToSweep) { + return Object.freeze({ deleted: false, skippedUnsafe: true }); + } + if (staged.digests.has(digestHex)) { + return Object.freeze({ deleted: false, skippedUnsafe: false }); + } + const path = chunkPath(scope, digestHex); + try { + const { directory, fileName } = await resolveFileParent( + dependencies.root, + path, + false, + ); + const handle = await directory.getFileHandle(fileName); + const file = await handle.getFile(); + if (file.lastModified > olderThanEpochMs) { + return Object.freeze({ deleted: false, skippedUnsafe: false }); + } + await directory.removeEntry(fileName); + return Object.freeze({ deleted: true, skippedUnsafe: false }); + } catch (error) { + if (isNotFound(error)) { + return Object.freeze({ deleted: false, skippedUnsafe: false }); + } + throw error; + } + } finally { + lease.release(); + } + } + + async function stagedChunkDigests( + scope: OpfsPreparedObject["descriptor"]["scope"], + ): Promise; + }>> { + const digests = new Set(); + let staging: FileSystemDirectoryHandle; + try { + staging = await getDirectory( + dependencies.root, + [...scopeRootPath(scope), "staging"], + false, + ); + } catch (error) { + if (isNotFound(error)) { + return { safeToSweep: true, digests }; + } + throw error; + } + for await (const [transactionId, handle] of staging.entries()) { + if ( + handle.kind !== "directory" || + !SAFE_TRANSACTION_ID.test(transactionId) + ) { + return { safeToSweep: false, digests }; + } + let receipt: unknown; + try { + receipt = await readJson(receiptPath(scope, transactionId)); + } catch { + return { safeToSweep: false, digests }; + } + if (!receiptBelongsToScope(receipt, scope)) { + return { safeToSweep: false, digests }; + } + const receiptDigests = extractReceiptDigests(receipt); + if (!receiptDigests) { + return { safeToSweep: false, digests }; + } + for (const digest of receiptDigests) digests.add(digest); + } + return { safeToSweep: true, digests }; + } + + function extractReceiptPhysicalTarget( + receipt: unknown, + scope: OpfsStorageScope, + ): Readonly<{ objectId: string; generation: number }> | null { + if (!receiptBelongsToScope(receipt, scope)) return null; + const record = receipt as Record; + if (record.phase === "PREPARING") { + return typeof record.objectId === "string" && + dependencies.policy.isObjectIdAllowed(record.objectId) && + typeof record.generation === "number" && + Number.isSafeInteger(record.generation) && + record.generation > 0 + ? { objectId: record.objectId, generation: record.generation } + : null; + } + if ( + record.phase === "FILES_READY" && + isPreparedObjectSafe(record.preparedObject, dependencies.policy) && + sameScope(record.preparedObject.descriptor.scope, scope) + ) { + return { + objectId: record.preparedObject.descriptor.objectId, + generation: record.preparedObject.descriptor.generation, + }; + } + return null; + } + + function rememberCancellation(transactionId: string): void { + cancellationTombstones.add(transactionId); + while ( + cancellationTombstones.size > + dependencies.policy.maxCancellationTombstones + ) { + const oldest = cancellationTombstones.values().next().value; + if (typeof oldest !== "string") break; + cancellationTombstones.delete(oldest); + } + } + + async function writeReceipt(put: ActivePut): Promise { + await writeJsonAtomic(receiptPath(put.scope, put.transactionId), { + schemaVersion: 1, + phase: "PREPARING", + scope: put.scope, + objectId: put.objectId, + generation: put.generation, + declaredByteLength: put.declaredByteLength, + chunks: put.chunks, + }); + } + + async function writeImmutableChunk( + scope: OpfsPreparedObject["descriptor"]["scope"], + digestHex: string, + bytes: Uint8Array, + ): Promise { + const path = chunkPath(scope, digestHex); + try { + const existing = await readFile(path); + if ( + existing.byteLength !== bytes.byteLength || + (await sha256Hex(dependencies.crypto, existing)) !== digestHex + ) { + const { directory, fileName } = await resolveFileParent( + dependencies.root, + path, + false, + ); + await directory.removeEntry(fileName); + } else { + return; + } + } catch (error) { + if (!isNotFound(error)) throw error; + } + + const { directory, fileName } = await resolveFileParent( + dependencies.root, + path, + true, + ); + const fileHandle = (await directory.getFileHandle(fileName, { + create: true, + })) as SyncCapableFileHandle; + if ( + dependencies.dedicatedWorker && + typeof fileHandle.createSyncAccessHandle === "function" + ) { + try { + await writeWithSyncAccessHandle(fileHandle, bytes); + return; + } catch (error) { + if ( + !dependencies.policy.allowAsyncWritableChunkFallback || + !isSyncUnsupported(error) + ) { + throw error; + } + } + } + if (!dependencies.policy.allowAsyncWritableChunkFallback) { + throw new OpfsRuntimeFailure("UNSUPPORTED"); + } + await writeWithAtomicWritable(fileHandle, bytes); + } + + async function writeJsonAtomic( + path: readonly string[], + value: unknown, + ): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const { directory, fileName } = await resolveFileParent( + dependencies.root, + path, + true, + ); + const handle = await directory.getFileHandle(fileName, { create: true }); + await writeWithAtomicWritable(handle, bytes); + } + + async function readJson(path: readonly string[]): Promise { + const bytes = await readFile(path); + if (bytes.byteLength > MAX_MANIFEST_BYTES) { + throw new OpfsRuntimeFailure("CORRUPT_DATA"); + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + throw new OpfsRuntimeFailure("CORRUPT_DATA"); + } + } + + async function readFile(path: readonly string[]): Promise { + const { directory, fileName } = await resolveFileParent( + dependencies.root, + path, + false, + ); + const handle = await directory.getFileHandle(fileName); + const file = await handle.getFile(); + return new Uint8Array(await file.arrayBuffer()); + } + + function assertWorkerAvailable(): void { + if (!requiredCapabilitiesAvailable) { + throw new OpfsRuntimeFailure("UNSUPPORTED"); + } + } +} + +export function installOpfsWorkerMessageHandler( + host: OpfsWorkerMessageHost, + runtime: OpfsWorkerRuntime, +): void { + host.addEventListener("message", (event) => { + void runtime.handleRequest(event.data).then((response) => { + postWorkerResponse(host, response); + }); + }); +} + +function postWorkerResponse( + host: OpfsWorkerMessageHost, + response: OpfsWorkerResponse | null, +): void { + if (!response) return; + if ( + response.ok && + response.value instanceof ArrayBuffer + ) { + host.postMessage(response, [response.value]); + return; + } + host.postMessage(response); +} + +export function createWebLockLeaseManager( + lockManager: LockManagerLike, + lockName: string, +): OpfsMutationLeaseManager { + if (lockName.length === 0) { + throw new TypeError("OPFS mutation lock name is required."); + } + return Object.freeze({ + async acquire(signal?: AbortSignal) { + if (signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + let releaseHold: (() => void) | undefined; + let released = false; + const hold = new Promise((resolve) => { + releaseHold = resolve; + }); + let acquiredResolve: ((lease: OpfsMutationLease) => void) | undefined; + let acquiredReject: ((error: unknown) => void) | undefined; + const acquired = new Promise((resolve, reject) => { + acquiredResolve = resolve; + acquiredReject = reject; + }); + + void lockManager + .request(lockName, { mode: "exclusive", signal }, async (lock) => { + if (!lock) throw new OpfsRuntimeFailure("BLOCKED", true); + const lease: OpfsMutationLease = Object.freeze({ + release() { + if (released) return; + released = true; + releaseHold?.(); + }, + }); + acquiredResolve?.(lease); + await hold; + }) + .catch((error: unknown) => acquiredReject?.(error)); + return await acquired; + }, + }); +} + +/** + * Exported for a focused lifecycle test. Partial writes are retried and every + * acquired handle is closed, including failure paths. + */ +export async function writeWithSyncAccessHandle( + fileHandle: SyncCapableFileHandle, + bytes: Uint8Array, +): Promise { + if (typeof fileHandle.createSyncAccessHandle !== "function") { + throw new OpfsRuntimeFailure("UNSUPPORTED"); + } + let accessHandle: SyncAccessHandleLike | undefined; + try { + accessHandle = await fileHandle.createSyncAccessHandle(); + accessHandle.truncate(0); + let offset = 0; + while (offset < bytes.byteLength) { + const written = accessHandle.write(bytes.subarray(offset), { at: offset }); + if ( + !Number.isSafeInteger(written) || + written <= 0 || + written > bytes.byteLength - offset + ) { + throw new OpfsRuntimeFailure("NOT_READABLE", true); + } + offset += written; + } + accessHandle.truncate(bytes.byteLength); + accessHandle.flush(); + } finally { + accessHandle?.close(); + } +} + +async function writeWithAtomicWritable( + fileHandle: FileSystemFileHandle, + bytes: Uint8Array, +): Promise { + const writable = await fileHandle.createWritable({ keepExistingData: false }); + let closed = false; + try { + const copy = Uint8Array.from(bytes); + await writable.write(copy); + await writable.close(); + closed = true; + } finally { + if (!closed) { + try { + await writable.abort(); + } catch { + // Preserve the original write error. + } + } + } +} + +async function resolveFileParent( + root: FileSystemDirectoryHandle, + path: readonly string[], + create: boolean, +): Promise> { + if (path.length < 1 || path.some((segment) => !SAFE_PATH_SEGMENT.test(segment))) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + const fileName = path.at(-1)!; + const directory = await getDirectory(root, path.slice(0, -1), create); + return { directory, fileName }; +} + +async function getDirectory( + root: FileSystemDirectoryHandle, + path: readonly string[], + create: boolean, +): Promise { + let current = root; + for (const segment of path) { + if (!SAFE_PATH_SEGMENT.test(segment)) { + throw new OpfsRuntimeFailure("INVALID_INPUT"); + } + current = await current.getDirectoryHandle(segment, { create }); + } + return current; +} + +async function removeEntryIfPresent( + directory: FileSystemDirectoryHandle, + name: string, + recursive: boolean, +): Promise { + try { + await directory.removeEntry(name, { recursive }); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + +function manifestPath(preparedObject: OpfsPreparedObject): readonly string[] { + const descriptor = preparedObject.descriptor; + return [ + ...scopeRootPath(descriptor.scope), + "objects", + descriptor.objectId.slice(0, 2), + descriptor.objectId, + String(descriptor.generation), + "manifest.json", + ]; +} + +function chunkPath( + scope: OpfsStorageScope, + digestHex: string, +): readonly string[] { + return [ + ...scopeRootPath(scope), + "chunks", + "sha256", + digestHex.slice(0, 2), + `${digestHex}.bin`, + ]; +} + +function receiptPath( + scope: OpfsStorageScope, + transactionId: string, +): readonly string[] { + return [ + ...scopeRootPath(scope), + "staging", + transactionId, + "receipt.json", + ]; +} + +function scopeRootPath(scope: OpfsStorageScope): readonly string[] { + return [ + "authorities", + scope.authorityToken, + scope.namespaceToken, + scope.partitionToken, + ]; +} + +function scopedTransactionKey( + scope: OpfsStorageScope, + transactionId: string, +): string { + return [ + scope.authorityToken, + scope.namespaceToken, + scope.partitionToken, + transactionId, + ].join("|"); +} + +function sameScope( + left: OpfsStorageScope, + right: OpfsStorageScope, +): boolean { + return ( + left.namespace === right.namespace && + left.authorityToken === right.authorityToken && + left.namespaceToken === right.namespaceToken && + left.partitionToken === right.partitionToken + ); +} + +function receiptBelongsToScope( + receipt: unknown, + scope: OpfsStorageScope, +): boolean { + if (!receipt || typeof receipt !== "object") return false; + const record = receipt as Record; + if ( + record.schemaVersion !== 1 || + (record.phase !== "PREPARING" && record.phase !== "FILES_READY") + ) { + return false; + } + if (record.phase === "PREPARING") { + return Boolean( + record.scope && + typeof record.scope === "object" && + isValidOpfsStorageScope(record.scope as OpfsStorageScope) && + sameScope(record.scope as OpfsStorageScope, scope), + ); + } + if ( + !record.preparedObject || + typeof record.preparedObject !== "object" || + !("descriptor" in record.preparedObject) || + !record.preparedObject.descriptor || + typeof record.preparedObject.descriptor !== "object" || + !("scope" in record.preparedObject.descriptor) + ) { + return false; + } + const preparedScope = record.preparedObject.descriptor.scope; + return Boolean( + preparedScope && + typeof preparedScope === "object" && + isValidOpfsStorageScope(preparedScope as OpfsStorageScope) && + sameScope(preparedScope as OpfsStorageScope, scope), + ); +} + +async function sha256Hex( + crypto: Crypto, + bytes: Uint8Array, +): Promise { + const copy = Uint8Array.from(bytes); + const digest = await crypto.subtle.digest("SHA-256", copy); + return bytesToHex(new Uint8Array(digest)); +} + +async function treeDigestHex( + crypto: Crypto, + chunkSizeBytes: number, + byteLength: number, + chunks: readonly OpfsChunkReference[], +): Promise { + const canonical = [ + "sha-256-tree-v1", + `chunk-size:${chunkSizeBytes}`, + `byte-length:${byteLength}`, + ...chunks.map( + (chunk) => + `${chunk.sequence}:${chunk.byteLength}:${chunk.digestHex}`, + ), + "", + ].join("\n"); + return await sha256Hex(crypto, new TextEncoder().encode(canonical)); +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = ""; + for (const byte of bytes) hex += byte.toString(16).padStart(2, "0"); + return hex; +} + +function stableJson(value: unknown): string { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new OpfsRuntimeFailure("CORRUPT_DATA"); + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; + } + throw new OpfsRuntimeFailure("CORRUPT_DATA"); +} + +function isPreparedObjectSafe( + value: unknown, + policy: OpfsRuntimePolicy, +): value is OpfsPreparedObject { + if ( + !value || + typeof value !== "object" || + !("physicalSchemaVersion" in value) || + value.physicalSchemaVersion !== 1 || + !("descriptor" in value) || + !value.descriptor || + typeof value.descriptor !== "object" || + !("chunks" in value) || + !Array.isArray(value.chunks) + ) { + return false; + } + const descriptor = value.descriptor as Record; + const integrity = + descriptor.integrity && typeof descriptor.integrity === "object" + ? (descriptor.integrity as Record) + : null; + if ( + typeof descriptor.objectId !== "string" || + !policy.isObjectIdAllowed(descriptor.objectId) || + !("scope" in descriptor) || + !descriptor.scope || + typeof descriptor.scope !== "object" || + !isValidOpfsStorageScope( + descriptor.scope as OpfsPreparedObject["descriptor"]["scope"], + ) || + (descriptor.scope as OpfsPreparedObject["descriptor"]["scope"]) + .namespace !== + (descriptor.storagePolicy as + | OpfsPreparedObject["descriptor"]["storagePolicy"] + | undefined)?.namespace || + typeof descriptor.generation !== "number" || + !Number.isSafeInteger(descriptor.generation) || + descriptor.generation < 1 || + typeof descriptor.byteLength !== "number" || + !Number.isSafeInteger(descriptor.byteLength) || + descriptor.byteLength < 0 || + descriptor.byteLength > policy.maxObjectBytes || + typeof descriptor.mediaType !== "string" || + !policy.isMediaTypeAllowed(descriptor.mediaType) || + typeof descriptor.createdAtEpochMs !== "number" || + !Number.isSafeInteger(descriptor.createdAtEpochMs) || + descriptor.createdAtEpochMs < 0 || + !integrity || + integrity.algorithm !== "SHA-256-TREE-V1" || + typeof integrity.rootDigestHex !== "string" || + !SHA256_HEX.test(integrity.rootDigestHex) || + integrity.chunkSizeBytes !== policy.chunkSizeBytes || + !("storagePolicy" in descriptor) || + !descriptor.storagePolicy || + typeof descriptor.storagePolicy !== "object" || + value.chunks.length > policy.maxChunkCount + ) { + return false; + } + try { + assertValidStoragePolicy( + descriptor.storagePolicy as OpfsPreparedObject["descriptor"]["storagePolicy"], + ); + } catch { + return false; + } + let total = 0; + for (let index = 0; index < value.chunks.length; index += 1) { + const chunk = value.chunks[index] as unknown; + if ( + !chunk || + typeof chunk !== "object" || + !("sequence" in chunk) || + chunk.sequence !== index || + !("byteLength" in chunk) || + typeof chunk.byteLength !== "number" || + !Number.isSafeInteger(chunk.byteLength) || + chunk.byteLength < 1 || + chunk.byteLength > policy.chunkSizeBytes || + !("digestHex" in chunk) || + typeof chunk.digestHex !== "string" || + !SHA256_HEX.test(chunk.digestHex) + ) { + return false; + } + if (index < value.chunks.length - 1 && chunk.byteLength !== policy.chunkSizeBytes) { + return false; + } + total += chunk.byteLength; + } + return ( + total === descriptor.byteLength && + value.chunks.length === + Math.ceil(descriptor.byteLength / policy.chunkSizeBytes) + ); +} + +function success( + requestId: string, + value?: OpfsWorkerResponse extends infer _Response + ? + | OpfsCapabilities + | OpfsPreparedObject + | ArrayBuffer + | boolean + | OpfsOrphanCandidateBatch + | OpfsOrphanDeleteResult + : never, +): OpfsWorkerResponse { + return value === undefined + ? { requestId, ok: true } + : { requestId, ok: true, value }; +} + +function failure( + requestId: string, + workerFailure: OpfsWorkerFailure, +): OpfsWorkerResponse { + return { requestId, ok: false, failure: workerFailure }; +} + +function mapRuntimeFailure(error: unknown): OpfsWorkerFailure { + if (error instanceof OpfsRuntimeFailure) { + return { code: error.code, retryable: error.retryable }; + } + if (error instanceof DOMException) { + if (error.name === "QuotaExceededError") { + return { code: "QUOTA_EXCEEDED", retryable: true }; + } + if (error.name === "NotFoundError") { + return { code: "NOT_FOUND", retryable: false }; + } + if ( + error.name === "NoModificationAllowedError" || + error.name === "InvalidStateError" + ) { + return { code: "BLOCKED", retryable: true }; + } + if (error.name === "NotAllowedError" || error.name === "SecurityError") { + return { code: "PERMISSION_DENIED", retryable: false }; + } + if (error.name === "AbortError") { + return { code: "ABORTED", retryable: false }; + } + if (error.name === "NotSupportedError") { + return { code: "UNSUPPORTED", retryable: false }; + } + } + return { code: "UNAVAILABLE", retryable: true }; +} + +function isSyncUnsupported(error: unknown): boolean { + return ( + error instanceof TypeError || + (error instanceof DOMException && error.name === "NotSupportedError") + ); +} + +function isNotFound(error: unknown): boolean { + return error instanceof DOMException && error.name === "NotFoundError"; +} + +function hasRequestId( + value: unknown, +): value is Readonly<{ requestId: string }> { + return Boolean( + value && + typeof value === "object" && + "requestId" in value && + typeof value.requestId === "string" && + value.requestId.length > 0 && + value.requestId.length <= 128, + ); +} + +function isWorkerRequest(value: unknown): value is OpfsWorkerRequest { + return Boolean( + hasRequestId(value) && + "kind" in value && + typeof value.kind === "string" && + WORKER_REQUEST_KINDS.has(value.kind), + ); +} + +const MAX_MANIFEST_BYTES = 1024 * 1024; +const SAFE_TRANSACTION_ID = /^[A-Za-z0-9_-]{8,128}$/u; +const SAFE_PATH_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; +const SHA256_HEX = /^[a-f0-9]{64}$/u; +const WORKER_REQUEST_KINDS = new Set([ + "CAPABILITIES", + "BEGIN_PUT", + "APPEND_CHUNK", + "FINISH_PUT", + "ABORT_PUT", + "VERIFY_OBJECT", + "READ_CHUNK", + "REMOVE_OBJECT", + "CLEANUP_TRANSACTION", + "FINALIZE_PUT", + "LIST_ORPHAN_CANDIDATES", + "DELETE_ORPHAN_CHUNK", +]); + +function extractReceiptDigests( + receipt: unknown, +): readonly string[] | null { + if (!receipt || typeof receipt !== "object") return null; + const record = receipt as Record; + let chunks: unknown; + if (Array.isArray(record.chunks)) { + chunks = record.chunks; + } else if ( + record.preparedObject && + typeof record.preparedObject === "object" && + "chunks" in record.preparedObject + ) { + chunks = record.preparedObject.chunks; + } else { + return null; + } + if (!Array.isArray(chunks)) return null; + const digests: string[] = []; + for (const chunk of chunks) { + if ( + !chunk || + typeof chunk !== "object" || + !("digestHex" in chunk) || + typeof chunk.digestHex !== "string" || + !SHA256_HEX.test(chunk.digestHex) + ) { + return null; + } + digests.push(chunk.digestHex); + } + return digests; +} diff --git a/src/adapters/telemetry/best-effort-telemetry.ts b/src/adapters/telemetry/best-effort-telemetry.ts new file mode 100644 index 0000000..933aaa3 --- /dev/null +++ b/src/adapters/telemetry/best-effort-telemetry.ts @@ -0,0 +1,182 @@ +import { projectTelemetryEvent } from "../../contracts/telemetry.ts"; +import { queueSizeBucket } from "../../contracts/diagnostic-buckets.ts"; +import type { + TelemetryEvent, + TelemetryEventName, +} from "../../contracts/telemetry.ts"; +import type { TelemetryPort } from "../../application/ports/telemetry-port.ts"; + +export type TelemetryAdapter = TelemetryPort & + Readonly<{ + flush(): Promise; + pendingCount(): number; + droppedCount(): number; + dropReasons(): Readonly>; + deliveryEvidence(): TelemetryEvent | null; + dispose(): void; + }>; + +export type TelemetryAdapterOptions = Readonly<{ + enabled: boolean; + endpoint?: string; + fetcher?: typeof fetch; + maxQueue?: number; + schedule?: (callback: () => void) => void; + now?: () => number; + onDrop?: (event: TelemetryEvent) => void; + lifecycle?: Pick; +}>; + +export const noOpTelemetry: TelemetryAdapter = Object.freeze({ + emit: () => {}, + flush: async () => {}, + pendingCount: () => 0, + droppedCount: () => 0, + dropReasons: () => Object.freeze({}), + deliveryEvidence: () => null, + dispose: () => {}, +}); + +export function createTelemetryAdapter( + options: TelemetryAdapterOptions, +): TelemetryAdapter { + if (!options.enabled || !options.endpoint) { + return noOpTelemetry; + } + + const endpoint = options.endpoint; + const fetcher = options.fetcher ?? fetch; + const maxQueue = Math.max(1, options.maxQueue ?? 100); + const schedule = options.schedule ?? queueMicrotask; + const queue: TelemetryEvent[] = []; + let scheduled = false; + let flushing = false; + let dropped = 0; + const dropReasons = new Map(); + let lastDeliveryEvidence: TelemetryEvent | null = null; + const lifecycle = + options.lifecycle ?? + (typeof globalThis.addEventListener === "function" && + typeof globalThis.removeEventListener === "function" + ? globalThis + : undefined); + + function recordDrop(reason: string, count = 1): void { + const safeReason = + { + "queue-full": "queue-full", + "sink-failure": "sink-failure", + "serialization-failure": "serialization-failure", + "unknown-attributes": "invalid-context", + "invalid-attribute-value": "invalid-context", + "missing-required-attributes": "invalid-context", + "unregistered-event": "invalid-event", + }[reason] ?? "invalid-event"; + dropped += count; + dropReasons.set(safeReason, (dropReasons.get(safeReason) ?? 0) + count); + const internal = projectTelemetryEvent( + "telemetry.delivery.dropped", + { + reason: safeReason, + queue_size_bucket: queueSizeBucket(queue.length), + }, + options.now, + ); + if (internal.success) { + lastDeliveryEvidence = internal.event; + try { + options.onDrop?.(internal.event); + } catch { + // Drop observers are deliberately nonrecursive. + } + } + } + + function scheduleFlush(): void { + if (scheduled) return; + scheduled = true; + schedule(() => { + scheduled = false; + void flush(); + }); + } + + function emit( + eventName: TelemetryEventName, + attributes: Record, + ): void { + const projected = projectTelemetryEvent( + eventName, + attributes, + options.now, + ); + if (!projected.success) { + recordDrop(projected.reason); + return; + } + + if (queue.length >= maxQueue) { + queue.shift(); + recordDrop("queue-full"); + } + queue.push(projected.event); + + scheduleFlush(); + } + + async function flush(): Promise { + if (flushing || queue.length === 0) return; + flushing = true; + const batch = queue.splice(0, queue.length); + try { + const response = await fetcher(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events: batch }), + keepalive: true, + }); + if (!response.ok) recordDrop("sink-failure", batch.length); + } catch { + recordDrop("sink-failure", batch.length); + } finally { + flushing = false; + if (queue.length > 0) { + scheduleFlush(); + } + } + } + + const flushBeforePageExit = () => { + void flush(); + }; + lifecycle?.addEventListener("pagehide", flushBeforePageExit); + + function dispose(): void { + lifecycle?.removeEventListener("pagehide", flushBeforePageExit); + } + + return Object.freeze({ + emit, + flush, + pendingCount: () => queue.length, + droppedCount: () => dropped, + dropReasons: () => Object.freeze(Object.fromEntries(dropReasons)), + deliveryEvidence: () => + lastDeliveryEvidence ? structuredClone(lastDeliveryEvidence) : null, + dispose, + }); +} + +/** + * Propagates only a structurally valid W3C traceparent. Invalid/raw headers are + * discarded rather than logged or surfaced. + * + */ +export function safeTraceparent( + traceparent: string | null | undefined, +): string | null { + return typeof traceparent === "string" && + /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/i.test(traceparent) + ? traceparent.toLowerCase() + : null; +} diff --git a/src/adapters/web-push/inbound/notification-click-adapter.ts b/src/adapters/web-push/inbound/notification-click-adapter.ts new file mode 100644 index 0000000..4b1d004 --- /dev/null +++ b/src/adapters/web-push/inbound/notification-click-adapter.ts @@ -0,0 +1,230 @@ +import { + WEB_PUSH_LIMITS, + WEB_PUSH_PROTOCOLS, + webPushFailure, + webPushSuccess, + type WebPushObserver, + type WebPushResult, +} from "../../../contracts/web-push.ts"; +import type { PushAssociationFenceStore } from "../push-association-fence-store.ts"; +import { decodeNotificationClickData } from "../push-codec.ts"; +import type { WebPushNotificationRegistry } from "../notification-registry.ts"; +import { + createLinkedAbortController, + nativeFailure, + observeWebPush, + withAbortableDeadline, + type TimeoutScheduler, +} from "../runtime-support.ts"; + +export type NotificationFacade = Readonly<{ + data: unknown; + close(): void; +}>; + +export type NotificationClickEventFacade = Readonly<{ + notification: NotificationFacade; + waitUntil(task: Promise): void; +}>; + +export type WindowClientFacade = Readonly<{ + url: string; + focus(): Promise; + postMessage(message: unknown): void; +}>; + +export type WorkerClientsFacade = Readonly<{ + matchControlledWindowClients(): Promise< + readonly WindowClientFacade[] + >; + openWindow(url: string): Promise; +}>; + +export type NotificationClickAdapter = Readonly<{ + handle(event: NotificationClickEventFacade): Promise>; +}>; + +export function createNotificationClickAdapter(dependencies: Readonly<{ + fenceStore: PushAssociationFenceStore; + clients: WorkerClientsFacade; + origin: string; + now?: () => number; + handlerDeadlineMs?: number; + scheduler?: TimeoutScheduler; + signal?: AbortSignal; + observer?: WebPushObserver; + registry: WebPushNotificationRegistry; +}>): NotificationClickAdapter { + const now = dependencies.now ?? Date.now; + const handlerDeadlineMs = + dependencies.handlerDeadlineMs ?? WEB_PUSH_LIMITS.handlerDeadlineMs; + const parsedOrigin = safeOrigin(dependencies.origin); + if ( + !parsedOrigin || + !Number.isSafeInteger(handlerDeadlineMs) || + handlerDeadlineMs < 1 || + handlerDeadlineMs > WEB_PUSH_LIMITS.handlerDeadlineMs + ) { + throw new TypeError("Web Push click adapter configuration is invalid."); + } + const origin: string = parsedOrigin; + + return Object.freeze({ + handle(event) { + try { + event.notification.close(); + } catch { + // Closing is best effort and never expands click authority. + } + const taskControl = createLinkedAbortController( + dependencies.signal, + ); + const processing = withAbortableDeadline( + (signal) => process(event.notification.data, signal), + { + deadlineMs: handlerDeadlineMs, + operation: "NOTIFICATION_CLICK", + signal: taskControl.signal, + scheduler: dependencies.scheduler, + }, + ).finally(taskControl.dispose); + try { + event.waitUntil( + processing.then((result) => { + observeWebPush(dependencies.observer, { + event: "web_push_click_dispatched", + outcome: result.ok ? "SUCCEEDED" : "FAILED", + ...(result.ok ? {} : { reason: result.error.code }), + }); + }), + ); + } catch { + taskControl.abort(); + return Promise.resolve(nativeFailure("NOTIFICATION_CLICK", false)); + } + return processing; + }, + }); + + async function process( + data: unknown, + signal: AbortSignal, + ): Promise> { + const decoded = decodeNotificationClickData(data, now()); + if (!decoded.ok) return decoded; + const initialFence = await validateActiveFence( + decoded.value.associationEpoch, + decoded.value.releaseEpoch, + signal, + ); + if (!initialFence.ok) return initialFence; + const path = dependencies.registry.routePath(decoded.value.routeIntent); + if (!path) { + return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK"); + } + const target = safeTarget(origin, path); + if (!target) { + return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK"); + } + + let clients: readonly WindowClientFacade[]; + try { + clients = + await dependencies.clients.matchControlledWindowClients(); + } catch { + return nativeFailure("NOTIFICATION_CLICK", true); + } + const boundedClients = clients.slice( + 0, + WEB_PUSH_LIMITS.clientHandoffCount, + ); + const existing = boundedClients.find( + (client) => safeOrigin(client.url) === origin, + ); + const finalFence = await validateActiveFence( + decoded.value.associationEpoch, + decoded.value.releaseEpoch, + signal, + ); + if (!finalFence.ok) return finalFence; + const handoff = Object.freeze({ + protocol: WEB_PUSH_PROTOCOLS.clickHandoff, + routeIntent: decoded.value.routeIntent, + notificationId: decoded.value.notificationId, + associationEpoch: decoded.value.associationEpoch, + releaseEpoch: decoded.value.releaseEpoch, + expiresAt: decoded.value.expiresAt, + path, + }); + try { + if (signal.aborted) { + return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); + } + if (existing) { + existing.postMessage(handoff); + await existing.focus(); + } else { + const opened = await dependencies.clients.openWindow(target); + if (!opened) return nativeFailure("NOTIFICATION_CLICK", true); + } + if (signal.aborted) { + return webPushFailure("ABORTED", "NOTIFICATION_CLICK"); + } + } catch { + return nativeFailure("NOTIFICATION_CLICK", true); + } + return webPushSuccess(undefined); + } + + async function validateActiveFence( + associationEpoch: string, + releaseEpoch: string, + signal: AbortSignal, + ): Promise> { + const control = await dependencies.fenceStore.read({ signal }); + if (!control.ok) return control; + if ( + !control.value || + control.value.control.association.state !== "ACTIVE" || + control.value.control.association.associationEpoch !== + associationEpoch + ) { + return webPushFailure( + "ASSOCIATION_MISMATCH", + "NOTIFICATION_CLICK", + ); + } + return control.value.control.releaseEpoch === releaseEpoch + ? webPushSuccess(undefined) + : webPushFailure( + "RELEASE_MISMATCH", + "NOTIFICATION_CLICK", + ); + } +} + +function safeOrigin(value: string): string | null { + try { + const parsed = new URL(value); + const local = + parsed.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + return parsed.protocol === "https:" || local ? parsed.origin : null; + } catch { + return null; + } +} + +function safeTarget(origin: string, path: string): string | null { + try { + const target = new URL(path, origin); + return target.origin === origin && + target.username === "" && + target.password === "" && + target.hash === "" + ? target.href + : null; + } catch { + return null; + } +} diff --git a/src/adapters/web-push/inbound/push-event-adapter.ts b/src/adapters/web-push/inbound/push-event-adapter.ts new file mode 100644 index 0000000..40ca39f --- /dev/null +++ b/src/adapters/web-push/inbound/push-event-adapter.ts @@ -0,0 +1,196 @@ +import { + WEB_PUSH_LIMITS, + webPushFailure, + webPushSuccess, + type WebPushObserver, + type WebPushResult, +} from "../../../contracts/web-push.ts"; +import type { PushAssociationFenceStore } from "../push-association-fence-store.ts"; +import { + clickDataFromHint, + decodeWebPushHint, +} from "../push-codec.ts"; +import { + createAssociationNotificationTag, + type SafeNotification, + type WebPushNotificationRegistry, +} from "../notification-registry.ts"; +import { + createLinkedAbortController, + failureCode, + nativeFailure, + observeWebPush, + withAbortableDeadline, + type TimeoutScheduler, +} from "../runtime-support.ts"; + +export type PushMessageDataFacade = Readonly<{ + arrayBuffer(): ArrayBuffer; +}>; + +export type PushEventFacade = Readonly<{ + data: PushMessageDataFacade | null; + waitUntil(task: Promise): void; +}>; + +export type WorkerNotificationFacade = Readonly<{ + showNotification( + title: string, + options: SafeNotification["options"], + ): Promise; +}>; + +export type PushEventAdapter = Readonly<{ + handle(event: PushEventFacade): Promise>; +}>; + +export function createPushEventAdapter(dependencies: Readonly<{ + fenceStore: PushAssociationFenceStore; + notifications: WorkerNotificationFacade; + now?: () => number; + handlerDeadlineMs?: number; + scheduler?: TimeoutScheduler; + signal?: AbortSignal; + observer?: WebPushObserver; + tagDigest?: Parameters[2]; + registry: WebPushNotificationRegistry; +}>): PushEventAdapter { + const now = dependencies.now ?? Date.now; + const handlerDeadlineMs = + dependencies.handlerDeadlineMs ?? WEB_PUSH_LIMITS.handlerDeadlineMs; + if ( + !Number.isSafeInteger(handlerDeadlineMs) || + handlerDeadlineMs < 1 || + handlerDeadlineMs > WEB_PUSH_LIMITS.handlerDeadlineMs + ) { + throw new TypeError("Web Push handler deadline is invalid."); + } + + return Object.freeze({ + handle(event) { + const taskControl = createLinkedAbortController( + dependencies.signal, + ); + const processing = withAbortableDeadline( + (signal) => process(event, signal), + { + deadlineMs: handlerDeadlineMs, + operation: "PUSH_HANDLE", + signal: taskControl.signal, + scheduler: dependencies.scheduler, + }, + ).finally(taskControl.dispose); + try { + event.waitUntil( + processing.then((result) => { + observeWebPush(dependencies.observer, { + event: "web_push_hint_processed", + outcome: result.ok ? "SUCCEEDED" : "FAILED", + ...(result.ok ? {} : { reason: result.error.code }), + }); + }), + ); + } catch { + taskControl.abort(); + return Promise.resolve(nativeFailure("PUSH_HANDLE", false)); + } + return processing; + }, + }); + + async function process( + event: PushEventFacade, + signal: AbortSignal, + ): Promise> { + if (!event.data) { + return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE"); + } + let bytes: ArrayBuffer; + try { + bytes = event.data.arrayBuffer(); + } catch { + return nativeFailure("PUSH_DECODE", false); + } + const decoded = decodeWebPushHint(bytes, now()); + if (!decoded.ok) return decoded; + + const initialFence = await validateActiveFence( + decoded.value.associationEpoch, + decoded.value.releaseEpoch, + signal, + ); + if (!initialFence.ok) return initialFence; + + const definition = dependencies.registry.resolve( + decoded.value.notificationType, + decoded.value.routeIntent, + ); + if (!definition) { + return webPushFailure("CONTRACT_REJECTED", "PUSH_HANDLE"); + } + let tag: string; + try { + tag = await createAssociationNotificationTag( + decoded.value.associationEpoch, + decoded.value.notificationType, + dependencies.tagDigest, + ); + } catch { + return nativeFailure("PUSH_HANDLE", false); + } + if (signal.aborted) { + return webPushFailure("ABORTED", "PUSH_HANDLE"); + } + const finalFence = await validateActiveFence( + decoded.value.associationEpoch, + decoded.value.releaseEpoch, + signal, + ); + if (!finalFence.ok) return finalFence; + const clickData = clickDataFromHint(decoded.value); + try { + await dependencies.notifications.showNotification(definition.title, { + body: definition.body, + data: clickData, + requireInteraction: false, + tag, + }); + if (signal.aborted) { + return webPushFailure("ABORTED", "NOTIFICATION_SHOW"); + } + } catch { + const failed = nativeFailure("NOTIFICATION_SHOW", true); + observeWebPush(dependencies.observer, { + event: "web_push_notification_finished", + outcome: "FAILED", + reason: failureCode(failed), + }); + return failed; + } + observeWebPush(dependencies.observer, { + event: "web_push_notification_finished", + outcome: "SUCCEEDED", + }); + return webPushSuccess(undefined); + } + + async function validateActiveFence( + associationEpoch: string, + releaseEpoch: string, + signal: AbortSignal, + ): Promise> { + const control = await dependencies.fenceStore.read({ signal }); + if (!control.ok) return control; + if ( + !control.value || + control.value.control.association.state !== "ACTIVE" || + control.value.control.association.associationEpoch !== + associationEpoch + ) { + return webPushFailure("ASSOCIATION_MISMATCH", "PUSH_HANDLE"); + } + return control.value.control.releaseEpoch === releaseEpoch + ? webPushSuccess(undefined) + : webPushFailure("RELEASE_MISMATCH", "PUSH_HANDLE"); + } +} diff --git a/src/adapters/web-push/index.ts b/src/adapters/web-push/index.ts new file mode 100644 index 0000000..e746f2f --- /dev/null +++ b/src/adapters/web-push/index.ts @@ -0,0 +1,66 @@ +export { + createAssociationNotificationTag, + createWebPushNotificationRegistry, + type SafeNotification, + type WebPushNotificationRegistry, +} from "./notification-registry.ts"; +export { + createPushAssociationFenceStore, + type PushAssociationFenceStore, + type PushAssociationFenceStoreDependencies, + type PushControlReceipt, + type PushControlRepository, +} from "./push-association-fence-store.ts"; +export { + clickDataFromHint, + decodeNotificationClickData, + decodeNotificationClickDataForCleanup, + decodeWebPushHint, +} from "./push-codec.ts"; +export { + createWebPushRegistrationGateway, + WEB_PUSH_REGISTRATION_OPERATIONS, + type NativePushSubscriptionMaterial, + type WebPushReconciliation, + type WebPushRegistrationCommit, + type WebPushRegistrationExecutor, + type WebPushRegistrationGateway, +} from "./push-registration-gateway.ts"; +export { + createWebPushSubscriptionAdapter, + type NotificationPermissionFacade, + type OwnedNotificationFacade, + type WindowPushManagerFacade, + type WindowPushSubscriptionFacade, + type WindowServiceWorkerRegistrationFacade, +} from "./push-subscription-adapter.ts"; +export { + createWebPushServiceWorkerRuntime, + type ServiceWorkerEventHost, + type WebPushServiceWorkerRuntime, +} from "./service-worker-runtime.ts"; +export { + createLinkedAbortController, + failureCode, + nativeFailure, + observeWebPush, + systemTimeoutScheduler, + withAbortableDeadline, + type LinkedAbortController, + type TimeoutScheduler, +} from "./runtime-support.ts"; +export { + createNotificationClickAdapter, + type NotificationClickAdapter, + type NotificationClickEventFacade, + type NotificationFacade, + type WindowClientFacade, + type WorkerClientsFacade, +} from "./inbound/notification-click-adapter.ts"; +export { + createPushEventAdapter, + type PushEventAdapter, + type PushEventFacade, + type PushMessageDataFacade, + type WorkerNotificationFacade, +} from "./inbound/push-event-adapter.ts"; diff --git a/src/adapters/web-push/notification-registry.ts b/src/adapters/web-push/notification-registry.ts new file mode 100644 index 0000000..17c0bbd --- /dev/null +++ b/src/adapters/web-push/notification-registry.ts @@ -0,0 +1,122 @@ +import type { + NotificationClickDataV1, + NotificationRouteIntentId, + NotificationTypeId, +} from "../../contracts/web-push.ts"; + +export type SafeNotification = Readonly<{ + title: string; + options: Readonly<{ + body: string; + data: NotificationClickDataV1; + requireInteraction: false; + tag: string; + }>; +}>; + +export type WebPushNotificationDefinition = Readonly<{ + notificationType: NotificationTypeId; + routeIntent: NotificationRouteIntentId; + title: string; + body: string; + path: string; +}>; + +export interface WebPushNotificationRegistry { + resolve( + notificationType: NotificationTypeId, + routeIntent: NotificationRouteIntentId, + ): WebPushNotificationDefinition | null; + routePath(routeIntent: NotificationRouteIntentId): string | null; +} + +const REGISTRY_ID = /^[A-Z][A-Z0-9_]{0,63}$/u; + +export function createWebPushNotificationRegistry( + definitions: readonly WebPushNotificationDefinition[], +): WebPushNotificationRegistry { + if ( + definitions.length === 0 || + definitions.length > 32 || + definitions.some( + (definition) => + !REGISTRY_ID.test(definition.notificationType) || + !REGISTRY_ID.test(definition.routeIntent) || + definition.title.length < 1 || + definition.title.length > 80 || + definition.body.length < 1 || + definition.body.length > 160 || + !safePath(definition.path), + ) + ) { + throw new TypeError("Web Push notification registry is invalid."); + } + const byType = new Map(); + const byRoute = new Map(); + for (const definition of definitions) { + if ( + byType.has(definition.notificationType) || + (byRoute.has(definition.routeIntent) && + byRoute.get(definition.routeIntent) !== definition.path) + ) { + throw new TypeError("Web Push notification registry is ambiguous."); + } + const snapshot = Object.freeze({ ...definition }); + byType.set(definition.notificationType, snapshot); + byRoute.set(definition.routeIntent, definition.path); + } + const registry: WebPushNotificationRegistry = { + resolve(notificationType, routeIntent) { + const definition = byType.get(notificationType); + return definition && definition.routeIntent === routeIntent + ? definition + : null; + }, + routePath(routeIntent) { + return byRoute.get(routeIntent) ?? null; + }, + }; + return Object.freeze(registry); +} + +export type AssociationNotificationTagDigest = ( + algorithm: "SHA-256", + data: Uint8Array, +) => Promise; + +export async function createAssociationNotificationTag( + associationEpoch: string, + notificationType: NotificationTypeId, + digest: AssociationNotificationTagDigest = (algorithm, data) => + globalThis.crypto.subtle.digest(algorithm, data), +): Promise { + const encoded = new TextEncoder().encode( + `PUSH_ASSOCIATION_TAG_V1\0${associationEpoch}`, + ); + const hashed = new Uint8Array(await digest("SHA-256", encoded)); + if (hashed.byteLength !== 32) { + throw new TypeError("Web Push notification tag digest is invalid."); + } + const prefix = [...hashed.subarray(0, 12)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); + return `ca-push-v1-${prefix}-${notificationType.toLowerCase()}`; +} + +function safePath(path: string): boolean { + if ( + !path.startsWith("/") || + path.startsWith("//") || + path.includes("\\") || + path.includes("#") || + path.length > 512 + ) { + return false; + } + try { + const parsed = new URL(path, "https://registry.invalid"); + return parsed.origin === "https://registry.invalid"; + } catch { + return false; + } +} diff --git a/src/adapters/web-push/push-association-fence-store.ts b/src/adapters/web-push/push-association-fence-store.ts new file mode 100644 index 0000000..c19af32 --- /dev/null +++ b/src/adapters/web-push/push-association-fence-store.ts @@ -0,0 +1,733 @@ +import { + WEB_PUSH_LIMITS, + WEB_PUSH_PROTOCOLS, + samePushAuthority, + webPushFailure, + webPushSuccess, + type PushAuthoritySnapshot, + type PushControlAssociationV1, + type PushControlV1, + type WebPushOperation, + type WebPushResult, +} from "../../contracts/web-push.ts"; +import { + withAbortableDeadline, + type TimeoutScheduler, +} from "./runtime-support.ts"; + +const CONTROL_KEY = "push-control-v1"; +const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const ISO_INSTANT = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; + +export type PushControlReceipt = Readonly<{ + control: PushControlV1; + revision: number; +}>; + +export type PushControlWriteReceipt = Readonly<{ + key: string; + revision: number; + replayed: boolean; +}>; + +/** + * The failure surface this adapter consumes. `code` is intentionally a plain + * string: the storage taxonomy is owned by whichever runtime backs the store, + * and an unrecognised code maps to the safe default in + * {@link mapRepositoryFailure} rather than failing to compile. + */ +export type PushControlStoreFailure = Readonly<{ + code: string; + retryable: boolean; +}>; + +export type PushControlStoreResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: PushControlStoreFailure }>; + +/** + * The narrow durable store this capability requires: revisioned + * compare-and-swap over a single key. + * + * It is declared here, structurally, rather than imported from the browser + * file/storage port so the two capabilities stay independently removable. The + * generic IndexedDB repository satisfies it as-is; the composition root is + * where the two are joined, and it owns connection, migration, transaction, + * timeout, codec and version-change policy. + */ +export type PushControlRepository = Readonly<{ + open(signal?: AbortSignal): Promise>; + read( + key: string, + signal?: AbortSignal, + ): Promise< + PushControlStoreResult< + Readonly<{ value: PushControlV1; revision: number }> | null + > + >; + compareAndSwap( + input: Readonly<{ + key: string; + value: PushControlV1; + expectedRevision: number | null; + idempotencyKey: string; + signal?: AbortSignal; + }>, + ): Promise>; + remove( + input: Readonly<{ + key: string; + expectedRevision: number | null; + idempotencyKey: string; + signal?: AbortSignal; + }>, + ): Promise>; + close(): void; +}>; + +export interface PushAssociationFenceStore { + read(input?: Readonly<{ + signal?: AbortSignal; + }>): Promise>; + + prepare(input: Readonly<{ + authority: PushAuthoritySnapshot; + updatedAt: string; + signal?: AbortSignal; + }>): Promise>; + + activate(input: Readonly<{ + expectedRevision: number; + authority: PushAuthoritySnapshot; + associationEpoch: string; + updatedAt: string; + signal?: AbortSignal; + }>): Promise>; + + markRevoked(input: Readonly<{ + expectedRevision: number; + authority: PushAuthoritySnapshot; + updatedAt: string; + signal?: AbortSignal; + }>): Promise>; + + rotateAndRevoke(input: Readonly<{ + expectedRevision: number; + previousAuthority: PushAuthoritySnapshot; + nextAuthority: PushAuthoritySnapshot; + updatedAt: string; + signal?: AbortSignal; + }>): Promise>; + + purge(input: Readonly<{ + expectedRevision: number; + authority: PushAuthoritySnapshot; + associationEpoch: string; + signal?: AbortSignal; + }>): Promise>; + + close(): void; +} + +export type PushAssociationFenceStoreDependencies = Readonly<{ + repository: PushControlRepository; + idempotencyKeyFactory?: () => string; + operationDeadlineMs?: number; + scheduler?: TimeoutScheduler; +}>; + +export function createPushAssociationFenceStore( + dependencies: PushAssociationFenceStoreDependencies, +): PushAssociationFenceStore { + if ( + !dependencies || + typeof dependencies !== "object" || + !validRepository(dependencies.repository) + ) { + throw new TypeError("Web Push fence store configuration is invalid."); + } + const repository = dependencies.repository; + const operationDeadlineMs = + dependencies.operationDeadlineMs ?? + WEB_PUSH_LIMITS.fenceOperationDeadlineMs; + if ( + !Number.isSafeInteger(operationDeadlineMs) || + operationDeadlineMs < 1 || + operationDeadlineMs > WEB_PUSH_LIMITS.fenceOperationDeadlineMs + ) { + throw new TypeError("Web Push fence deadline is invalid."); + } + const idempotencyKeyFactory = + dependencies.idempotencyKeyFactory ?? + (() => `push-control-${globalThis.crypto.randomUUID()}`); + let closed = false; + + const store: PushAssociationFenceStore = { + async read(input = {}) { + return await bounded( + "CONTROL_READ", + input.signal, + (signal) => readReceipt("CONTROL_READ", signal), + ); + }, + + async prepare(input) { + if ( + !validAuthority(input.authority) || + !validInstant(input.updatedAt) + ) { + return webPushFailure("INVALID_INPUT", "CONTROL_PREPARE"); + } + return await bounded( + "CONTROL_PREPARE", + input.signal, + async (signal) => { + const current = await readReceipt( + "CONTROL_PREPARE", + signal, + ); + if (!current.ok) return current; + if (current.value) { + return samePushAuthority( + current.value.control, + input.authority, + ) + ? webPushSuccess(current.value) + : webPushFailure( + "STALE_AUTHORITY", + "CONTROL_PREPARE", + ); + } + return await compareAndSwap( + "CONTROL_PREPARE", + null, + controlSnapshot({ + protocol: WEB_PUSH_PROTOCOLS.control, + ...input.authority, + updatedAt: input.updatedAt, + association: Object.freeze({ + state: "UNASSOCIATED", + }), + }), + signal, + ); + }, + ); + }, + + async activate(input) { + if ( + !validRevision(input.expectedRevision) || + !validAuthority(input.authority) || + !validOpaqueId(input.associationEpoch) || + !validInstant(input.updatedAt) + ) { + return webPushFailure("INVALID_INPUT", "CONTROL_ACTIVATE"); + } + return await bounded( + "CONTROL_ACTIVATE", + input.signal, + async (signal) => { + const current = await currentForMutation( + "CONTROL_ACTIVATE", + input.expectedRevision, + input.authority, + signal, + ); + if (!current.ok) return current; + if ( + current.value.control.association.state === "ACTIVE" && + current.value.control.association.associationEpoch === + input.associationEpoch + ) { + return current; + } + if ( + current.value.control.association.state === "REVOKED" && + current.value.control.association.associationEpoch === + input.associationEpoch + ) { + return webPushFailure( + "TOMBSTONE_CONFLICT", + "CONTROL_ACTIVATE", + ); + } + return await compareAndSwap( + "CONTROL_ACTIVATE", + input.expectedRevision, + controlSnapshot({ + ...current.value.control, + updatedAt: input.updatedAt, + association: Object.freeze({ + state: "ACTIVE", + associationEpoch: input.associationEpoch, + }), + }), + signal, + ); + }, + ); + }, + + async markRevoked(input) { + if ( + !validRevision(input.expectedRevision) || + !validAuthority(input.authority) || + !validInstant(input.updatedAt) + ) { + return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE"); + } + return await bounded( + "CONTROL_REVOKE", + input.signal, + async (signal) => { + const current = await currentForMutation( + "CONTROL_REVOKE", + input.expectedRevision, + input.authority, + signal, + ); + if (!current.ok) return current; + if ( + current.value.control.association.state === + "UNASSOCIATED" + ) { + return current; + } + return await compareAndSwap( + "CONTROL_REVOKE", + input.expectedRevision, + controlSnapshot({ + ...current.value.control, + updatedAt: input.updatedAt, + association: Object.freeze({ + state: "REVOKED", + associationEpoch: + current.value.control.association + .associationEpoch, + }), + }), + signal, + ); + }, + ); + }, + + async rotateAndRevoke(input) { + if ( + !validRevision(input.expectedRevision) || + !validAuthority(input.previousAuthority) || + !validAuthority(input.nextAuthority) || + input.previousAuthority.fenceGeneration === + input.nextAuthority.fenceGeneration || + !validInstant(input.updatedAt) + ) { + return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE"); + } + return await bounded( + "CONTROL_REVOKE", + input.signal, + async (signal) => { + const current = await currentForMutation( + "CONTROL_REVOKE", + input.expectedRevision, + input.previousAuthority, + signal, + ); + if (!current.ok) return current; + const association: PushControlAssociationV1 = + current.value.control.association.state === + "UNASSOCIATED" + ? Object.freeze({ state: "UNASSOCIATED" }) + : Object.freeze({ + state: "REVOKED", + associationEpoch: + current.value.control.association + .associationEpoch, + }); + return await compareAndSwap( + "CONTROL_REVOKE", + input.expectedRevision, + controlSnapshot({ + protocol: WEB_PUSH_PROTOCOLS.control, + ...input.nextAuthority, + updatedAt: input.updatedAt, + association, + }), + signal, + ); + }, + ); + }, + + async purge(input) { + if ( + !validRevision(input.expectedRevision) || + !validAuthority(input.authority) || + !validOpaqueId(input.associationEpoch) + ) { + return webPushFailure("INVALID_INPUT", "CONTROL_PURGE"); + } + return await bounded( + "CONTROL_PURGE", + input.signal, + async (signal) => { + const current = await currentForMutation( + "CONTROL_PURGE", + input.expectedRevision, + input.authority, + signal, + ); + if (!current.ok) return current; + if ( + current.value.control.association.state !== "REVOKED" || + current.value.control.association.associationEpoch !== + input.associationEpoch + ) { + return webPushFailure( + "ASSOCIATION_MISMATCH", + "CONTROL_PURGE", + ); + } + return await removeControl(input.expectedRevision, signal); + }, + ); + }, + + close() { + if (closed) return; + closed = true; + try { + repository.close(); + } catch { + // The local authority is terminal even if host cleanup throws. + } + }, + }; + + return Object.freeze(store); + + async function bounded( + operation: WebPushOperation, + signal: AbortSignal | undefined, + task: ( + boundedSignal: AbortSignal, + ) => Promise>, + ): Promise> { + return await withAbortableDeadline(task, { + deadlineMs: operationDeadlineMs, + operation, + signal, + scheduler: dependencies.scheduler, + }); + } + + async function currentForMutation( + operation: + | "CONTROL_ACTIVATE" + | "CONTROL_REVOKE" + | "CONTROL_PURGE", + expectedRevision: number, + authority: PushAuthoritySnapshot, + signal: AbortSignal | undefined, + ): Promise> { + const current = await readReceipt(operation, signal); + if (!current.ok) return current; + if (!current.value || current.value.revision !== expectedRevision) { + return webPushFailure("STALE_REVISION", operation); + } + if (!samePushAuthority(current.value.control, authority)) { + return webPushFailure("STALE_AUTHORITY", operation); + } + return webPushSuccess(current.value); + } + + async function readReceipt( + operation: WebPushOperation, + signal: AbortSignal | undefined, + ): Promise> { + if (closed) return webPushFailure("NATIVE_FAILURE", operation); + if (signal?.aborted) return webPushFailure("ABORTED", operation); + const opened = await callRepository( + () => repository.open(signal), + operation, + ); + if (!opened.ok) return opened; + const read = await callRepository( + () => repository.read(CONTROL_KEY, signal), + operation, + ); + if (!read.ok) return read; + if (!read.value) return webPushSuccess(null); + const control = decodeControl(read.value.value); + if (!control || !validRevision(read.value.revision)) { + return webPushFailure("CONTROL_CORRUPT", operation); + } + return webPushSuccess( + Object.freeze({ + control, + revision: read.value.revision, + }), + ); + } + + async function compareAndSwap( + operation: + | "CONTROL_PREPARE" + | "CONTROL_ACTIVATE" + | "CONTROL_REVOKE", + expectedRevision: number | null, + control: PushControlV1, + signal: AbortSignal | undefined, + ): Promise> { + let idempotencyKey: string; + try { + idempotencyKey = idempotencyKeyFactory(); + } catch { + return webPushFailure("NATIVE_FAILURE", operation); + } + if (!validIdempotencyKey(idempotencyKey)) { + return webPushFailure("INVALID_INPUT", operation); + } + const written = await callRepository( + () => + repository.compareAndSwap({ + key: CONTROL_KEY, + value: control, + expectedRevision, + idempotencyKey, + ...(signal ? { signal } : {}), + }), + operation, + ); + if (!written.ok) return written; + if (!validWriteReceipt(written.value)) { + return webPushFailure("CONTROL_CORRUPT", operation); + } + return webPushSuccess( + Object.freeze({ + control, + revision: written.value.revision, + }), + ); + } + + async function removeControl( + expectedRevision: number, + signal: AbortSignal | undefined, + ): Promise> { + let idempotencyKey: string; + try { + idempotencyKey = idempotencyKeyFactory(); + } catch { + return webPushFailure("NATIVE_FAILURE", "CONTROL_PURGE"); + } + if (!validIdempotencyKey(idempotencyKey)) { + return webPushFailure("INVALID_INPUT", "CONTROL_PURGE"); + } + const removed = await callRepository( + () => + repository.remove({ + key: CONTROL_KEY, + expectedRevision, + idempotencyKey, + ...(signal ? { signal } : {}), + }), + "CONTROL_PURGE", + ); + if (!removed.ok) return removed; + if ( + !validWriteReceipt(removed.value) || + removed.value.revision !== expectedRevision + 1 + ) { + return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE"); + } + return webPushSuccess(undefined); + } +} + +async function callRepository( + call: () => Promise>, + operation: WebPushOperation, +): Promise> { + try { + const result = await call(); + return result.ok + ? webPushSuccess(result.value) + : mapRepositoryFailure(result.error, operation); + } catch { + return webPushFailure("NATIVE_FAILURE", operation, true); + } +} + +function mapRepositoryFailure( + failure: PushControlStoreFailure, + operation: WebPushOperation, +): WebPushResult { + switch (failure.code) { + case "ABORTED": + return webPushFailure("ABORTED", operation); + case "BLOCKED": + return webPushFailure("BLOCKED", operation, failure.retryable); + case "CONFLICT": + case "STALE_RESULT": + return webPushFailure( + "STALE_REVISION", + operation, + failure.retryable, + ); + case "CORRUPT_DATA": + case "EXPIRED_RESOURCE": + case "INTEGRITY_FAILED": + case "MIGRATION_FAILED": + return webPushFailure( + "CONTROL_CORRUPT", + operation, + failure.retryable, + ); + case "INVALID_INPUT": + return webPushFailure("INVALID_INPUT", operation); + case "LIMIT_EXCEEDED": + return webPushFailure( + "LIMIT_EXCEEDED", + operation, + failure.retryable, + ); + case "UNSUPPORTED": + return webPushFailure("UNSUPPORTED", operation); + default: + return webPushFailure( + "NATIVE_FAILURE", + operation, + failure.retryable, + ); + } +} + +function controlSnapshot(value: PushControlV1): PushControlV1 { + const association: PushControlAssociationV1 = + value.association.state === "UNASSOCIATED" + ? Object.freeze({ state: "UNASSOCIATED" }) + : Object.freeze({ + state: value.association.state, + associationEpoch: value.association.associationEpoch, + }); + return Object.freeze({ + protocol: WEB_PUSH_PROTOCOLS.control, + fenceGeneration: value.fenceGeneration, + sessionBindingEpoch: value.sessionBindingEpoch, + releaseEpoch: value.releaseEpoch, + updatedAt: value.updatedAt, + association, + }); +} + +function decodeControl(value: unknown): PushControlV1 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const keys = Object.keys(record).sort(); + if ( + keys.length !== 6 || + keys.join("|") !== + "association|fenceGeneration|protocol|releaseEpoch|sessionBindingEpoch|updatedAt" || + record.protocol !== WEB_PUSH_PROTOCOLS.control || + !validOpaqueId(record.fenceGeneration) || + !validOpaqueId(record.sessionBindingEpoch) || + !validOpaqueId(record.releaseEpoch) || + !validInstant(record.updatedAt) || + !validAssociation(record.association) + ) { + return null; + } + return controlSnapshot({ + protocol: WEB_PUSH_PROTOCOLS.control, + fenceGeneration: record.fenceGeneration, + sessionBindingEpoch: record.sessionBindingEpoch, + releaseEpoch: record.releaseEpoch, + updatedAt: record.updatedAt, + association: record.association, + }); +} + +function validAssociation( + value: unknown, +): value is PushControlAssociationV1 { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + const keys = Object.keys(record).sort(); + if (record.state === "UNASSOCIATED") { + return keys.length === 1 && keys[0] === "state"; + } + return ( + (record.state === "ACTIVE" || record.state === "REVOKED") && + keys.length === 2 && + keys[0] === "associationEpoch" && + keys[1] === "state" && + validOpaqueId(record.associationEpoch) + ); +} + +function validAuthority( + value: PushAuthoritySnapshot, +): value is PushAuthoritySnapshot { + return ( + Boolean(value) && + validOpaqueId(value.fenceGeneration) && + validOpaqueId(value.sessionBindingEpoch) && + validOpaqueId(value.releaseEpoch) + ); +} + +function validRepository( + value: unknown, +): value is PushControlRepository { + if (!value || typeof value !== "object") return false; + const repository = value as Partial; + return ( + typeof repository.open === "function" && + typeof repository.read === "function" && + typeof repository.compareAndSwap === "function" && + typeof repository.remove === "function" && + typeof repository.close === "function" + ); +} + +function validWriteReceipt( + value: PushControlWriteReceipt, +): value is PushControlWriteReceipt { + return ( + Boolean(value) && + value.key === CONTROL_KEY && + validRevision(value.revision) && + typeof value.replayed === "boolean" + ); +} + +function validIdempotencyKey(value: unknown): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= 200 + ); +} + +function validOpaqueId(value: unknown): value is string { + return typeof value === "string" && OPAQUE_ID.test(value); +} + +function validRevision(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 1 + ); +} + +function validInstant(value: unknown): value is string { + return ( + typeof value === "string" && + ISO_INSTANT.test(value) && + Number.isFinite(Date.parse(value)) + ); +} diff --git a/src/adapters/web-push/push-codec.ts b/src/adapters/web-push/push-codec.ts new file mode 100644 index 0000000..8301cd6 --- /dev/null +++ b/src/adapters/web-push/push-codec.ts @@ -0,0 +1,308 @@ +import { + WEB_PUSH_LIMITS, + WEB_PUSH_PROTOCOLS, + webPushFailure, + webPushSuccess, + type NotificationClickDataV1, + type WebPushHintV1, + type WebPushResult, +} from "../../contracts/web-push.ts"; + +const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const REGISTRY_ID = /^[A-Z][A-Z0-9_]{0,63}$/u; +const ISO_INSTANT = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; +const HINT_KEYS = Object.freeze([ + "associationEpoch", + "expiresAt", + "issuedAt", + "notificationId", + "notificationType", + "protocol", + "releaseEpoch", + "routeIntent", +] as const); +const CLICK_KEYS = Object.freeze([ + "associationEpoch", + "expiresAt", + "notificationId", + "protocol", + "releaseEpoch", + "routeIntent", +] as const); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasExactKeys( + value: Record, + expected: readonly string[], +): boolean { + const actual = Object.keys(value).sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function validOpaqueId(value: unknown): value is string { + return typeof value === "string" && OPAQUE_ID.test(value); +} + +function instant(value: unknown): number | null { + if (typeof value !== "string" || !ISO_INSTANT.test(value)) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function byteView(input: ArrayBuffer | Uint8Array): Uint8Array { + return input instanceof Uint8Array ? input : new Uint8Array(input); +} + +export function decodeWebPushHint( + input: ArrayBuffer | Uint8Array, + nowEpochMs: number, +): WebPushResult { + const bytes = byteView(input); + if (bytes.byteLength === 0 || bytes.byteLength > WEB_PUSH_LIMITS.decodedHintBytes) { + return webPushFailure("LIMIT_EXCEEDED", "PUSH_DECODE"); + } + if ( + bytes.byteLength >= 3 && + bytes[0] === 0xef && + bytes[1] === 0xbb && + bytes[2] === 0xbf + ) { + return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE"); + } + + let decoded: string; + try { + decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE"); + } + + let value: unknown; + try { + value = JSON.parse(decoded); + } catch { + return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE"); + } + if (hasDuplicateTopLevelJsonKeys(decoded)) { + return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE"); + } + if (isRecord(value) && Object.hasOwn(value, "web_push")) { + return webPushFailure("DECLARATIVE_PUSH_FORBIDDEN", "PUSH_DECODE"); + } + if (!isRecord(value) || !hasExactKeys(value, HINT_KEYS)) { + return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE"); + } + const issuedAt = instant(value.issuedAt); + const expiresAt = instant(value.expiresAt); + if ( + value.protocol !== WEB_PUSH_PROTOCOLS.hint || + typeof value.notificationType !== "string" || + !REGISTRY_ID.test(value.notificationType) || + typeof value.routeIntent !== "string" || + !REGISTRY_ID.test(value.routeIntent) || + !validOpaqueId(value.notificationId) || + !validOpaqueId(value.associationEpoch) || + !validOpaqueId(value.releaseEpoch) || + issuedAt === null || + expiresAt === null || + !Number.isFinite(nowEpochMs) || + issuedAt > expiresAt || + issuedAt > nowEpochMs + WEB_PUSH_LIMITS.hintFutureSkewMs || + expiresAt - issuedAt > WEB_PUSH_LIMITS.hintMaxLifetimeMs + ) { + return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE"); + } + if (expiresAt < nowEpochMs) { + return webPushFailure("EXPIRED", "PUSH_DECODE"); + } + return webPushSuccess( + Object.freeze({ + protocol: WEB_PUSH_PROTOCOLS.hint, + notificationType: value.notificationType, + notificationId: value.notificationId, + associationEpoch: value.associationEpoch, + releaseEpoch: value.releaseEpoch, + issuedAt: value.issuedAt as string, + expiresAt: value.expiresAt as string, + routeIntent: value.routeIntent, + }), + ); +} + +export function decodeNotificationClickData( + value: unknown, + nowEpochMs: number, +): WebPushResult { + const decoded = decodeNotificationClickDataForCleanup(value); + if (!decoded.ok) return decoded; + const expiresAt = instant(decoded.value.expiresAt); + if ( + expiresAt === null || + !Number.isFinite(nowEpochMs) || + expiresAt < nowEpochMs + ) { + return webPushFailure("EXPIRED", "NOTIFICATION_CLICK"); + } + return decoded; +} + +/** + * Strictly recognizes mechanism-owned notification data without using expiry + * as an ownership test. Logout cleanup must still close an expired envelope. + */ +export function decodeNotificationClickDataForCleanup( + value: unknown, +): WebPushResult { + if (!isRecord(value) || !hasExactKeys(value, CLICK_KEYS)) { + return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK"); + } + const expiresAt = instant(value.expiresAt); + if ( + value.protocol !== WEB_PUSH_PROTOCOLS.click || + typeof value.routeIntent !== "string" || + !REGISTRY_ID.test(value.routeIntent) || + !validOpaqueId(value.notificationId) || + !validOpaqueId(value.associationEpoch) || + !validOpaqueId(value.releaseEpoch) || + expiresAt === null + ) { + return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK"); + } + let encodedBytes: number; + try { + encodedBytes = new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK"); + } + if (encodedBytes > WEB_PUSH_LIMITS.decodedHintBytes) { + return webPushFailure("LIMIT_EXCEEDED", "NOTIFICATION_CLICK"); + } + return webPushSuccess( + Object.freeze({ + protocol: WEB_PUSH_PROTOCOLS.click, + notificationId: value.notificationId, + routeIntent: value.routeIntent, + associationEpoch: value.associationEpoch, + releaseEpoch: value.releaseEpoch, + expiresAt: value.expiresAt as string, + }), + ); +} + +export function clickDataFromHint( + hint: WebPushHintV1, +): NotificationClickDataV1 { + return Object.freeze({ + protocol: WEB_PUSH_PROTOCOLS.click, + notificationId: hint.notificationId, + routeIntent: hint.routeIntent, + associationEpoch: hint.associationEpoch, + releaseEpoch: hint.releaseEpoch, + expiresAt: hint.expiresAt, + }); +} + +function hasDuplicateTopLevelJsonKeys(source: string): boolean { + let cursor = skipWhitespace(source, 0); + if (source[cursor] !== "{") return false; + cursor = skipWhitespace(source, cursor + 1); + const keys = new Set(); + while (cursor < source.length && source[cursor] !== "}") { + if (source[cursor] !== "\"") return false; + const keyEnd = jsonStringEnd(source, cursor); + if (keyEnd === null) return false; + let key: unknown; + try { + key = JSON.parse(source.slice(cursor, keyEnd)); + } catch { + return false; + } + if (typeof key !== "string") return false; + if (keys.has(key)) return true; + keys.add(key); + cursor = skipWhitespace(source, keyEnd); + if (source[cursor] !== ":") return false; + const valueEnd = jsonValueEnd(source, cursor + 1); + if (valueEnd === null) return false; + cursor = skipWhitespace(source, valueEnd); + if (source[cursor] === ",") { + cursor = skipWhitespace(source, cursor + 1); + continue; + } + if (source[cursor] !== "}") return false; + } + return false; +} + +function jsonStringEnd( + source: string, + start: number, +): number | null { + let escaped = false; + for (let cursor = start + 1; cursor < source.length; cursor += 1) { + const character = source[cursor]; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === "\"") { + return cursor + 1; + } + } + return null; +} + +function jsonValueEnd( + source: string, + start: number, +): number | null { + let cursor = skipWhitespace(source, start); + let depth = 0; + let inString = false; + let escaped = false; + for (; cursor < source.length; cursor += 1) { + const character = source[cursor]; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === "\"") { + inString = false; + } + continue; + } + if (character === "\"") { + inString = true; + } else if (character === "{" || character === "[") { + depth += 1; + } else if (character === "}" || character === "]") { + if (depth === 0) return cursor; + depth -= 1; + } else if (character === "," && depth === 0) { + return cursor; + } + } + return inString || depth !== 0 ? null : cursor; +} + +function skipWhitespace(source: string, start: number): number { + let cursor = start; + while ( + cursor < source.length && + (source[cursor] === " " || + source[cursor] === "\n" || + source[cursor] === "\r" || + source[cursor] === "\t") + ) { + cursor += 1; + } + return cursor; +} diff --git a/src/adapters/web-push/push-registration-gateway.ts b/src/adapters/web-push/push-registration-gateway.ts new file mode 100644 index 0000000..f9a1241 --- /dev/null +++ b/src/adapters/web-push/push-registration-gateway.ts @@ -0,0 +1,325 @@ +import { + WEB_PUSH_PROTOCOLS, + webPushFailure, + webPushSuccess, + type PushAuthoritySnapshot, + type WebPushResult, +} from "../../contracts/web-push.ts"; + +const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; + +export const WEB_PUSH_REGISTRATION_OPERATIONS = Object.freeze({ + register: "REGISTER_WEB_PUSH_SUBSCRIPTION", + reconcile: "RECONCILE_WEB_PUSH_SUBSCRIPTION", + revoke: "REVOKE_WEB_PUSH_ASSOCIATION", +} as const); + +export type NativePushSubscriptionMaterial = Readonly<{ + endpoint: string; + p256dh: string; + auth: string; + expirationTime: number | null; +}>; + +export type WebPushRegistrationExecutor = Readonly<{ + execute(input: Readonly<{ + operationId: + (typeof WEB_PUSH_REGISTRATION_OPERATIONS)[keyof typeof WEB_PUSH_REGISTRATION_OPERATIONS]; + body: unknown; + idempotencyKey?: string; + signal?: AbortSignal; + }>): Promise>; +}>; + +export type WebPushRegistrationCommit = Readonly<{ + associationEpoch: string; + sessionBindingEpoch: string; +}>; + +export type WebPushReconciliation = + | Readonly<{ state: "ABSENT" }> + | Readonly<{ + state: "ACTIVE"; + associationEpoch: string; + sessionBindingEpoch: string; + }>; + +export interface WebPushRegistrationGateway { + register(input: Readonly<{ + material: NativePushSubscriptionMaterial; + authority: PushAuthoritySnapshot; + idempotencyKey: string; + signal?: AbortSignal; + }>): Promise>; + + reconcile(input: Readonly<{ + material: NativePushSubscriptionMaterial; + authority: PushAuthoritySnapshot; + signal?: AbortSignal; + }>): Promise>; + + revoke(input: Readonly<{ + associationEpoch: string; + signal?: AbortSignal; + }>): Promise>>; +} + +export function createWebPushRegistrationGateway( + executor: WebPushRegistrationExecutor, +): WebPushRegistrationGateway { + const gateway: WebPushRegistrationGateway = { + async register(input) { + if ( + !validMaterial(input.material) || + !validAuthority(input.authority) || + !validOpaqueId(input.idempotencyKey) + ) { + return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_CREATE"); + } + const response = await execute( + { + operationId: WEB_PUSH_REGISTRATION_OPERATIONS.register, + body: Object.freeze({ + protocol: "WEB_PUSH_REGISTER_COMMAND_V1", + subscription: snapshotMaterial(input.material), + fenceGeneration: input.authority.fenceGeneration, + sessionBindingEpoch: input.authority.sessionBindingEpoch, + releaseEpoch: input.authority.releaseEpoch, + }), + idempotencyKey: input.idempotencyKey, + signal: input.signal, + }, + "SUBSCRIPTION_CREATE", + ); + if (!response.ok) return response; + const decoded = decodeRegistration(response.value); + return decoded + ? webPushSuccess(decoded) + : webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_CREATE"); + }, + + async reconcile(input) { + if ( + !validMaterial(input.material) || + !validAuthority(input.authority) + ) { + return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_RECONCILE"); + } + const response = await execute( + { + operationId: WEB_PUSH_REGISTRATION_OPERATIONS.reconcile, + body: Object.freeze({ + protocol: "WEB_PUSH_RECONCILE_COMMAND_V1", + subscription: snapshotMaterial(input.material), + fenceGeneration: input.authority.fenceGeneration, + sessionBindingEpoch: input.authority.sessionBindingEpoch, + releaseEpoch: input.authority.releaseEpoch, + }), + signal: input.signal, + }, + "SUBSCRIPTION_RECONCILE", + ); + if (!response.ok) return response; + const decoded = decodeReconciliation(response.value); + return decoded + ? webPushSuccess(decoded) + : webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_RECONCILE"); + }, + + async revoke(input) { + if (!validOpaqueId(input.associationEpoch)) { + return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_REVOKE"); + } + const response = await execute( + { + operationId: WEB_PUSH_REGISTRATION_OPERATIONS.revoke, + body: Object.freeze({ + protocol: "WEB_PUSH_REVOKE_COMMAND_V1", + associationEpoch: input.associationEpoch, + }), + signal: input.signal, + }, + "SUBSCRIPTION_REVOKE", + ); + if (!response.ok) return response; + const decoded = decodeRevoke(response.value); + return decoded + ? webPushSuccess(decoded) + : webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_REVOKE"); + }, + }; + return Object.freeze(gateway); + + async function execute( + input: Parameters[0], + operation: + | "SUBSCRIPTION_CREATE" + | "SUBSCRIPTION_RECONCILE" + | "SUBSCRIPTION_REVOKE", + ): Promise> { + try { + return await executor.execute(input); + } catch { + return webPushFailure("NATIVE_FAILURE", operation, true); + } + } +} + +function decodeRegistration( + value: unknown, +): WebPushRegistrationCommit | null { + if (!exactRecord(value, [ + "associationEpoch", + "protocol", + "sessionBindingEpoch", + ])) { + return null; + } + return value.protocol === WEB_PUSH_PROTOCOLS.registration && + validOpaqueId(value.associationEpoch) && + validOpaqueId(value.sessionBindingEpoch) + ? Object.freeze({ + associationEpoch: value.associationEpoch, + sessionBindingEpoch: value.sessionBindingEpoch, + }) + : null; +} + +function decodeReconciliation( + value: unknown, +): WebPushReconciliation | null { + if ( + exactRecord(value, ["protocol", "state"]) && + value.protocol === WEB_PUSH_PROTOCOLS.reconciliation && + value.state === "ABSENT" + ) { + return Object.freeze({ state: "ABSENT" }); + } + if ( + !exactRecord(value, [ + "associationEpoch", + "protocol", + "sessionBindingEpoch", + "state", + ]) || + value.protocol !== WEB_PUSH_PROTOCOLS.reconciliation || + value.state !== "ACTIVE" || + !validOpaqueId(value.associationEpoch) || + !validOpaqueId(value.sessionBindingEpoch) + ) { + return null; + } + return Object.freeze({ + state: "ACTIVE", + associationEpoch: value.associationEpoch, + sessionBindingEpoch: value.sessionBindingEpoch, + }); +} + +function decodeRevoke( + value: unknown, +): Readonly<{ state: "REVOKED" | "ALREADY_GONE" }> | null { + return exactRecord(value, ["protocol", "state"]) && + value.protocol === WEB_PUSH_PROTOCOLS.revoke && + (value.state === "REVOKED" || value.state === "ALREADY_GONE") + ? Object.freeze({ state: value.state }) + : null; +} + +function exactRecord( + value: unknown, + keys: readonly string[], +): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const actual = Object.keys(value).sort(); + return ( + actual.length === keys.length && + actual.every((key, index) => key === keys[index]) + ); +} + +function validOpaqueId(value: unknown): value is string { + return typeof value === "string" && OPAQUE_ID.test(value); +} + +function validAuthority(value: PushAuthoritySnapshot): boolean { + return ( + validOpaqueId(value.fenceGeneration) && + validOpaqueId(value.sessionBindingEpoch) && + validOpaqueId(value.releaseEpoch) + ); +} + +function validMaterial(value: NativePushSubscriptionMaterial): boolean { + const p256dh = base64UrlDecode(value?.p256dh); + const auth = base64UrlDecode(value?.auth); + if ( + !value || + typeof value !== "object" || + typeof value.endpoint !== "string" || + value.endpoint.length > 4_096 || + !/^[A-Za-z0-9_-]{87}$/u.test(value.p256dh) || + !/^[A-Za-z0-9_-]{22}$/u.test(value.auth) || + p256dh?.byteLength !== 65 || + p256dh[0] !== 4 || + auth?.byteLength !== 16 || + (value.expirationTime !== null && + (!Number.isFinite(value.expirationTime) || + value.expirationTime <= 0)) + ) { + return false; + } + try { + const endpoint = new URL(value.endpoint); + return ( + endpoint.protocol === "https:" && + !endpoint.username && + !endpoint.password && + !endpoint.hash + ); + } catch { + return false; + } +} + +function snapshotMaterial( + value: NativePushSubscriptionMaterial, +): NativePushSubscriptionMaterial { + return Object.freeze({ + endpoint: value.endpoint, + p256dh: value.p256dh, + auth: value.auth, + expirationTime: value.expirationTime, + }); +} + +function base64UrlDecode(value: unknown): Uint8Array | null { + if (typeof value !== "string") return null; + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const output = new Uint8Array( + new ArrayBuffer(Math.floor((value.length * 6) / 8)), + ); + let accumulator = 0; + let bitCount = 0; + let outputIndex = 0; + for (const character of value) { + const digit = alphabet.indexOf(character); + if (digit < 0) return null; + accumulator = (accumulator << 6) | digit; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + output[outputIndex] = (accumulator >> bitCount) & 0xff; + outputIndex += 1; + } + } + return bitCount < 6 && + outputIndex === output.length && + (bitCount === 0 || + (accumulator & ((1 << bitCount) - 1)) === 0) + ? output + : null; +} diff --git a/src/adapters/web-push/push-subscription-adapter.ts b/src/adapters/web-push/push-subscription-adapter.ts new file mode 100644 index 0000000..33c6320 --- /dev/null +++ b/src/adapters/web-push/push-subscription-adapter.ts @@ -0,0 +1,1119 @@ +import type { WebPushControlPort } from "../../application/ports/out/web-push-control.ts"; +import { + WEB_PUSH_LIMITS, + samePushAuthority, + webPushFailure, + webPushSuccess, + type PushAuthoritySnapshot, + type PushControlV1, + type WebPushObserver, + type WebPushReadiness, + type WebPushResult, + type WebPushUnavailableReason, +} from "../../contracts/web-push.ts"; +import type { + PushAssociationFenceStore, + PushControlReceipt, +} from "./push-association-fence-store.ts"; +import { + decodeNotificationClickDataForCleanup, +} from "./push-codec.ts"; +import type { + NativePushSubscriptionMaterial, + WebPushRegistrationCommit, + WebPushRegistrationGateway, +} from "./push-registration-gateway.ts"; +import { + createLinkedAbortController, + observeWebPush, + withAbortableDeadline, + type TimeoutScheduler, +} from "./runtime-support.ts"; + +type PermissionState = "default" | "denied" | "granted"; + +type CapturedNativeSubscription = + | Readonly<{ + captured: true; + subscription: WindowPushSubscriptionFacade | null; + }> + | Readonly<{ captured: false }>; + +export type NotificationPermissionFacade = Readonly<{ + permission(): PermissionState; + requestPermission(): Promise; +}>; + +export type WindowPushSubscriptionFacade = Readonly<{ + endpoint: string; + expirationTime: number | null; + options: Readonly<{ applicationServerKey: ArrayBuffer | null }>; + getKey(name: "p256dh" | "auth"): ArrayBuffer | null; + unsubscribe(): Promise; +}>; + +export type WindowPushManagerFacade = Readonly<{ + getSubscription(): Promise; + subscribe(input: Readonly<{ + userVisibleOnly: true; + applicationServerKey: Uint8Array; + }>): Promise; +}>; + +export type OwnedNotificationFacade = Readonly<{ + data: unknown; + close(): void; +}>; + +export type WindowServiceWorkerRegistrationFacade = Readonly<{ + active: boolean; + pushManager: WindowPushManagerFacade; + getNotifications(): Promise; +}>; + +export function createWebPushSubscriptionAdapter( + dependencies: Readonly<{ + secureContext: boolean; + userActivationIsActive(): boolean; + permission: NotificationPermissionFacade; + registration: WindowServiceWorkerRegistrationFacade; + fenceStore: PushAssociationFenceStore; + gateway: WebPushRegistrationGateway; + vapidPublicKey: string; + now?: () => number; + idempotencyKeyFactory?: () => string; + nativeOperationDeadlineMs?: number; + backendOperationDeadlineMs?: number; + scheduler?: TimeoutScheduler; + observer?: WebPushObserver; + }>, +): WebPushControlPort { + const decodedVapidPublicKey = decodeVapidPublicKey( + dependencies.vapidPublicKey, + ); + if (!decodedVapidPublicKey) { + throw new TypeError("Web Push VAPID public key is invalid."); + } + const vapidPublicKey: Uint8Array = + decodedVapidPublicKey; + const now = dependencies.now ?? Date.now; + const nativeOperationDeadlineMs = + dependencies.nativeOperationDeadlineMs ?? + WEB_PUSH_LIMITS.nativeOperationDeadlineMs; + const backendOperationDeadlineMs = + dependencies.backendOperationDeadlineMs ?? + WEB_PUSH_LIMITS.backendOperationDeadlineMs; + if ( + !validDeadline( + nativeOperationDeadlineMs, + WEB_PUSH_LIMITS.nativeOperationDeadlineMs, + ) || + !validDeadline( + backendOperationDeadlineMs, + WEB_PUSH_LIMITS.backendOperationDeadlineMs, + ) + ) { + throw new TypeError("Web Push operation deadline is invalid."); + } + const idempotencyKeyFactory = + dependencies.idempotencyKeyFactory ?? (() => globalThis.crypto.randomUUID()); + let closed = false; + let busy = false; + let lifecycleGeneration = 0; + const inFlight = new Set< + ReturnType + >(); + + const adapter: WebPushControlPort = { + inspect(input) { + return exclusive( + "SUBSCRIPTION_INSPECT", + input.signal, + async (generation, operationSignal) => { + const supported = supportReadiness(); + if (supported) return webPushSuccess(supported); + const permission = permissionReadiness(); + if (permission) return webPushSuccess(permission); + const native = await getSubscription(operationSignal, generation); + if (!native.ok) return native; + if (!native.value) { + return webPushSuccess( + unavailable("NATIVE_SUBSCRIPTION_MISSING"), + ); + } + if (!subscriptionUsesKey(native.value, vapidPublicKey)) { + return webPushSuccess( + unavailable("SUBSCRIPTION_KEY_MISMATCH"), + ); + } + const control = await dependencies.fenceStore.read({ + signal: operationSignal, + }); + if (!control.ok) return control; + return webPushSuccess( + isActiveFor(control.value?.control ?? null, input.authority) + ? readiness("PUSH_READY") + : unavailable("SESSION_AUTHORITY_CHANGED"), + ); + }, + ); + }, + + enable(input) { + return exclusive( + "SUBSCRIPTION_CREATE", + input.signal, + async (generation, operationSignal) => { + const supported = supportReadiness(); + if (supported) return webPushSuccess(supported); + let permission = dependencies.permission.permission(); + if (permission === "denied") { + await disableCurrentAssociation( + input.authority, + operationSignal, + generation, + ); + return webPushSuccess(readiness("PUSH_DENIED")); + } + if (permission === "default") { + if (!dependencies.userActivationIsActive()) { + await disableCurrentAssociation( + input.authority, + operationSignal, + generation, + ); + return webPushFailure( + "PERMISSION_DENIED", + "PERMISSION_REQUEST", + ); + } + const requested = await boundedNative( + "PERMISSION_REQUEST", + operationSignal, + async (signal) => { + const value = + await dependencies.permission.requestPermission(); + return signal.aborted + ? webPushFailure("ABORTED", "PERMISSION_REQUEST") + : webPushSuccess(value); + }, + ); + if (!requested.ok) return requested; + permission = requested.value; + observeWebPush(dependencies.observer, { + event: "web_push_permission_finished", + outcome: permission === "granted" ? "SUCCEEDED" : "DEGRADED", + ...(permission === "default" + ? { reason: "PERMISSION_DISMISSED" } + : {}), + }); + } + if (permission === "denied") { + await disableCurrentAssociation( + input.authority, + operationSignal, + generation, + ); + return webPushSuccess(readiness("PUSH_DENIED")); + } + if (permission !== "granted") { + await disableCurrentAssociation( + input.authority, + operationSignal, + generation, + ); + return webPushSuccess( + unavailable("PERMISSION_DISMISSED"), + ); + } + + const prepared = await dependencies.fenceStore.prepare({ + authority: input.authority, + updatedAt: instantNow(), + signal: operationSignal, + }); + if (!prepared.ok) return prepared; + + const existing = await getSubscription( + operationSignal, + generation, + ); + if (!existing.ok) return existing; + let subscription: WindowPushSubscriptionFacade; + let created = false; + if (existing.value) { + if (!subscriptionUsesKey(existing.value, vapidPublicKey)) { + await disableReceipt( + prepared.value, + input.authority, + operationSignal, + generation, + existing.value, + ); + return webPushSuccess( + unavailable("SUBSCRIPTION_KEY_MISMATCH"), + ); + } + subscription = existing.value; + } else { + const subscribed = await createSubscription(operationSignal); + if (!subscribed.ok) return subscribed; + subscription = subscribed.value; + created = true; + } + if (stale(operationSignal, generation)) { + if (created) void safeUnsubscribe(subscription); + return webPushFailure("ABORTED", "SUBSCRIPTION_CREATE"); + } + return await registerAndActivate({ + subscription, + prepared: prepared.value, + authority: input.authority, + signal: operationSignal, + generation, + created, + }); + }, + ); + }, + + reconcile(input) { + return exclusive( + "SUBSCRIPTION_RECONCILE", + input.signal, + async (generation, operationSignal) => { + const supported = supportReadiness(); + if (supported) return webPushSuccess(supported); + const permission = permissionReadiness(); + if (permission) { + await disableCurrentAssociation( + input.authority, + operationSignal, + generation, + ); + return webPushSuccess(permission); + } + const prepared = await dependencies.fenceStore.prepare({ + authority: input.authority, + updatedAt: instantNow(), + signal: operationSignal, + }); + if (!prepared.ok) return prepared; + + const native = await getSubscription( + operationSignal, + generation, + ); + if (!native.ok) return native; + if (!native.value) { + const revoked = await revokeMissingNative( + prepared.value, + input.authority, + operationSignal, + generation, + ); + return revoked.ok + ? webPushSuccess( + unavailable("NATIVE_SUBSCRIPTION_MISSING"), + ) + : revoked; + } + if (!subscriptionUsesKey(native.value, vapidPublicKey)) { + await disableReceipt( + prepared.value, + input.authority, + operationSignal, + generation, + native.value, + ); + return webPushSuccess( + unavailable("SUBSCRIPTION_KEY_MISMATCH"), + ); + } + const material = captureNativeMaterial(native.value); + if (!material.ok) return material; + const reconciled = await boundedBackendReconcile({ + material: material.value, + authority: input.authority, + signal: operationSignal, + }); + if (!reconciled.ok) return reconciled; + if (stale(operationSignal, generation)) { + return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE"); + } + if (reconciled.value.state === "ABSENT") { + return await registerAndActivate({ + subscription: native.value, + prepared: prepared.value, + authority: input.authority, + signal: operationSignal, + generation, + created: false, + }); + } + return await activateCommit({ + subscription: native.value, + prepared: prepared.value, + authority: input.authority, + commit: reconciled.value, + signal: operationSignal, + generation, + }); + }, + ); + }, + + revoke(input) { + return exclusive( + "SUBSCRIPTION_REVOKE", + input.signal, + async (generation, operationSignal) => { + const current = await dependencies.fenceStore.read({ + signal: operationSignal, + }); + if (!current.ok) { + return webPushSuccess(unavailable("LOCAL_FENCE_UNSAFE")); + } + if ( + !current.value || + !samePushAuthority( + current.value.control, + input.previousAuthority, + ) + ) { + return webPushSuccess(unavailable("LOCAL_FENCE_UNSAFE")); + } + const capturedNative = await captureNativeForCleanup( + operationSignal, + generation, + ); + const previousAssociation = + current.value.control.association.state === "UNASSOCIATED" + ? null + : current.value.control.association.associationEpoch; + const fenced = await dependencies.fenceStore.rotateAndRevoke({ + expectedRevision: current.value.revision, + previousAuthority: input.previousAuthority, + nextAuthority: input.nextAuthority, + updatedAt: instantNow(), + signal: operationSignal, + }); + if (!fenced.ok) { + if (previousAssociation) { + await boundedBackendRevoke(previousAssociation); + } + return webPushSuccess(unavailable("LOCAL_FENCE_UNSAFE")); + } + + let revokeAmbiguous = false; + if (previousAssociation) { + const backend = await boundedBackendRevoke( + previousAssociation, + ); + revokeAmbiguous = !backend.ok; + } + const nativeClean = await cleanupNative( + previousAssociation, + generation, + capturedNative, + fenced.value, + ); + observeWebPush(dependencies.observer, { + event: "web_push_association_revoked", + outcome: + revokeAmbiguous || !nativeClean ? "DEGRADED" : "SUCCEEDED", + ...(revokeAmbiguous + ? { reason: "BACKEND_REVOKE_AMBIGUOUS" } + : !nativeClean + ? { reason: "NATIVE_UNSUBSCRIBE_AMBIGUOUS" } + : {}), + }); + if (revokeAmbiguous) { + return webPushSuccess( + unavailable("BACKEND_REVOKE_AMBIGUOUS"), + ); + } + if (!nativeClean) { + return webPushSuccess( + unavailable("NATIVE_UNSUBSCRIBE_AMBIGUOUS"), + ); + } + return webPushSuccess(unavailable("REVOKED")); + }, + ); + }, + + dispose() { + if (closed) return; + closed = true; + lifecycleGeneration += 1; + for (const operation of inFlight) operation.abort(); + inFlight.clear(); + try { + dependencies.fenceStore.close(); + } catch { + // The lifecycle is terminal even if host cleanup throws. + } + }, + }; + + return Object.freeze(adapter); + + async function exclusive( + failureOperation: + | "SUBSCRIPTION_INSPECT" + | "SUBSCRIPTION_CREATE" + | "SUBSCRIPTION_RECONCILE" + | "SUBSCRIPTION_REVOKE", + signal: AbortSignal | undefined, + task: ( + generation: number, + operationSignal: AbortSignal, + ) => Promise>, + ): Promise> { + if (closed) return webPushSuccess(unavailable("CLOSED")); + if (busy) return webPushSuccess(unavailable("BUSY")); + if (signal?.aborted) { + return webPushFailure("ABORTED", "SUBSCRIPTION_INSPECT"); + } + busy = true; + const generation = lifecycleGeneration; + const operation = createLinkedAbortController(signal); + inFlight.add(operation); + try { + try { + return await task(generation, operation.signal); + } catch { + return webPushFailure( + "NATIVE_FAILURE", + failureOperation, + true, + ); + } + } finally { + operation.dispose(); + inFlight.delete(operation); + busy = false; + } + } + + function supportReadiness(): WebPushReadiness | null { + if (!dependencies.secureContext) { + return readiness("PUSH_UNSUPPORTED"); + } + if (!dependencies.registration.active) { + return unavailable("REGISTRATION_NOT_ACTIVE"); + } + return null; + } + + function permissionReadiness(): WebPushReadiness | null { + const permission = dependencies.permission.permission(); + if (permission === "denied") return readiness("PUSH_DENIED"); + if (permission === "default") { + return readiness("PUSH_PERMISSION_REQUIRED"); + } + return null; + } + + async function getSubscription( + signal: AbortSignal | undefined, + generation: number, + ): Promise> { + return await boundedNative( + "SUBSCRIPTION_INSPECT", + signal, + async (boundedSignal) => { + const subscription = + await dependencies.registration.pushManager.getSubscription(); + return boundedSignal.aborted || stale(signal, generation) + ? webPushFailure("ABORTED", "SUBSCRIPTION_INSPECT") + : webPushSuccess(subscription); + }, + ); + } + + async function createSubscription( + signal: AbortSignal, + ): Promise> { + return await boundedNative( + "SUBSCRIPTION_CREATE", + signal, + async (boundedSignal) => { + const subscription = + await dependencies.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidPublicKey, + }); + if (boundedSignal.aborted) { + void safeUnsubscribe(subscription); + return webPushFailure("ABORTED", "SUBSCRIPTION_CREATE"); + } + return webPushSuccess(subscription); + }, + ); + } + + async function boundedNative( + operation: + | "PERMISSION_REQUEST" + | "SUBSCRIPTION_INSPECT" + | "SUBSCRIPTION_CREATE", + signal: AbortSignal | undefined, + task: ( + boundedSignal: AbortSignal, + ) => Promise>, + ): Promise> { + return await withAbortableDeadline(task, { + deadlineMs: nativeOperationDeadlineMs, + operation, + signal, + scheduler: dependencies.scheduler, + }); + } + + async function boundedBackendRegister( + input: Parameters[0], + ): ReturnType { + return await withAbortableDeadline( + async (signal) => { + const registered = await dependencies.gateway.register({ + ...input, + signal, + }); + if (signal.aborted && registered.ok) { + void boundedBackendRevoke( + registered.value.associationEpoch, + ); + return webPushFailure("ABORTED", "SUBSCRIPTION_CREATE"); + } + return registered; + }, + { + deadlineMs: backendOperationDeadlineMs, + operation: "SUBSCRIPTION_CREATE", + signal: input.signal, + scheduler: dependencies.scheduler, + }, + ); + } + + async function boundedBackendReconcile( + input: Parameters[0], + ): ReturnType { + return await withAbortableDeadline( + (signal) => + dependencies.gateway.reconcile({ + ...input, + signal, + }), + { + deadlineMs: backendOperationDeadlineMs, + operation: "SUBSCRIPTION_RECONCILE", + signal: input.signal, + scheduler: dependencies.scheduler, + }, + ); + } + + async function boundedBackendRevoke( + associationEpoch: string, + signal?: AbortSignal, + ): ReturnType { + return await withAbortableDeadline( + (boundedSignal) => + dependencies.gateway.revoke({ + associationEpoch, + signal: boundedSignal, + }), + { + deadlineMs: backendOperationDeadlineMs, + operation: "SUBSCRIPTION_REVOKE", + signal, + scheduler: dependencies.scheduler, + }, + ); + } + + async function registerAndActivate(input: Readonly<{ + subscription: WindowPushSubscriptionFacade; + prepared: PushControlReceipt; + authority: PushAuthoritySnapshot; + signal: AbortSignal | undefined; + generation: number; + created: boolean; + }>): Promise> { + const material = captureNativeMaterial(input.subscription); + if (!material.ok) { + if (input.created) void safeUnsubscribe(input.subscription); + return material; + } + let idempotencyKey: string; + try { + idempotencyKey = idempotencyKeyFactory(); + } catch { + if (input.created) void safeUnsubscribe(input.subscription); + return webPushFailure("NATIVE_FAILURE", "SUBSCRIPTION_CREATE"); + } + const registered = await boundedBackendRegister({ + material: material.value, + authority: input.authority, + idempotencyKey, + signal: input.signal, + }); + if (!registered.ok) { + if (input.created) void safeUnsubscribe(input.subscription); + return registered; + } + observeWebPush(dependencies.observer, { + event: "web_push_registration_finished", + outcome: "SUCCEEDED", + }); + return await activateCommit({ + subscription: input.subscription, + prepared: input.prepared, + authority: input.authority, + commit: registered.value, + signal: input.signal, + generation: input.generation, + }); + } + + async function activateCommit(input: Readonly<{ + subscription: WindowPushSubscriptionFacade; + prepared: PushControlReceipt; + authority: PushAuthoritySnapshot; + commit: WebPushRegistrationCommit; + signal: AbortSignal | undefined; + generation: number; + }>): Promise> { + if ( + input.commit.sessionBindingEpoch !== + input.authority.sessionBindingEpoch || + stale(input.signal, input.generation) + ) { + await compensate(input.subscription, input.commit.associationEpoch); + return webPushSuccess( + unavailable("SESSION_AUTHORITY_CHANGED"), + ); + } + const activated = await dependencies.fenceStore.activate({ + expectedRevision: input.prepared.revision, + authority: input.authority, + associationEpoch: input.commit.associationEpoch, + updatedAt: instantNow(), + signal: input.signal, + }); + if (!activated.ok) { + await compensate(input.subscription, input.commit.associationEpoch); + return webPushSuccess(unavailable("LOCAL_FENCE_UNSAFE")); + } + return webPushSuccess(readiness("PUSH_READY")); + } + + async function compensate( + subscription: WindowPushSubscriptionFacade, + associationEpoch: string, + ): Promise { + await withAbortableDeadline( + async (signal) => { + await Promise.allSettled([ + boundedBackendRevoke(associationEpoch, signal), + safeUnsubscribe(subscription), + ]); + return webPushSuccess(undefined); + }, + { + deadlineMs: WEB_PUSH_LIMITS.notificationCleanupDeadlineMs, + operation: "SUBSCRIPTION_REVOKE", + scheduler: dependencies.scheduler, + }, + ); + } + + async function revokeMissingNative( + current: PushControlReceipt, + authority: PushAuthoritySnapshot, + signal: AbortSignal | undefined, + generation: number, + ): Promise> { + if (current.control.association.state === "UNASSOCIATED") { + return webPushSuccess(undefined); + } + let fenced = current; + if (current.control.association.state === "ACTIVE") { + const marked = await dependencies.fenceStore.markRevoked({ + expectedRevision: current.revision, + authority, + updatedAt: instantNow(), + signal, + }); + if (!marked.ok) { + await boundedBackendRevoke( + current.control.association.associationEpoch, + ); + return marked; + } + fenced = marked.value; + } + const backend = await boundedBackendRevoke( + current.control.association.associationEpoch, + ); + await cleanupNative( + current.control.association.associationEpoch, + generation, + Object.freeze({ + captured: true, + subscription: null, + }), + fenced, + ); + return backend.ok ? webPushSuccess(undefined) : backend; + } + + async function disableCurrentAssociation( + authority: PushAuthoritySnapshot, + signal: AbortSignal | undefined, + generation: number, + ): Promise { + const current = await dependencies.fenceStore.read({ signal }); + if ( + !current.ok || + !current.value || + !samePushAuthority(current.value.control, authority) + ) { + return false; + } + return await disableReceipt( + current.value, + authority, + signal, + generation, + ); + } + + async function disableReceipt( + current: PushControlReceipt, + authority: PushAuthoritySnapshot, + signal: AbortSignal | undefined, + generation: number, + knownSubscription?: WindowPushSubscriptionFacade, + ): Promise { + const association = + current.control.association.state === "UNASSOCIATED" + ? null + : current.control.association.associationEpoch; + const capturedNative = + knownSubscription === undefined + ? await captureNativeForCleanup(signal, generation) + : Object.freeze({ + captured: true as const, + subscription: knownSubscription, + }); + let fenced = current; + if (current.control.association.state === "ACTIVE") { + const marked = await dependencies.fenceStore.markRevoked({ + expectedRevision: current.revision, + authority, + updatedAt: instantNow(), + signal, + }); + if (!marked.ok) { + if (association) await boundedBackendRevoke(association); + return false; + } + fenced = marked.value; + } + const backendSafe = association + ? (await boundedBackendRevoke(association)).ok + : true; + const nativeSafe = await cleanupNative( + association, + generation, + capturedNative, + fenced, + ); + return backendSafe && nativeSafe; + } + + async function captureNativeForCleanup( + signal: AbortSignal | undefined, + generation: number, + ): Promise { + const native = await getSubscription(signal, generation); + return native.ok + ? Object.freeze({ + captured: true as const, + subscription: native.value, + }) + : Object.freeze({ captured: false as const }); + } + + async function cleanupNative( + associationEpoch: string | null, + generation: number, + capturedNative: CapturedNativeSubscription, + expectedFence: PushControlReceipt, + ): Promise { + const cleanup = await withAbortableDeadline( + async (signal) => { + if (stale(signal, generation)) { + return webPushSuccess(false); + } + const currentFence = await dependencies.fenceStore.read({ + signal, + }); + if ( + stale(signal, generation) || + !currentFence.ok || + !sameCleanupFence(currentFence.value, expectedFence) + ) { + return webPushSuccess(false); + } + const unsubscribe = !capturedNative.captured + ? Promise.resolve(false) + : capturedNative.subscription + ? safeUnsubscribe(capturedNative.subscription) + : Promise.resolve(true); + const [nativeResult, notificationResult] = + await Promise.allSettled([ + unsubscribe, + associationEpoch === null + ? Promise.resolve(webPushSuccess(undefined)) + : closeOwnedNotifications( + associationEpoch, + signal, + generation, + ), + ]); + const nativeClean = + nativeResult.status === "fulfilled" && + nativeResult.value; + const notificationsClean = + notificationResult.status === "fulfilled" && + notificationResult.value.ok; + return webPushSuccess( + nativeClean && notificationsClean, + ); + }, + { + deadlineMs: WEB_PUSH_LIMITS.notificationCleanupDeadlineMs, + operation: "NOTIFICATION_CLEANUP", + scheduler: dependencies.scheduler, + }, + ); + return cleanup.ok && cleanup.value; + } + + async function closeOwnedNotifications( + associationEpoch: string, + signal: AbortSignal | undefined, + generation: number, + ): Promise> { + let notifications: readonly OwnedNotificationFacade[]; + try { + notifications = await dependencies.registration.getNotifications(); + } catch { + return webPushFailure( + "NATIVE_FAILURE", + "NOTIFICATION_CLEANUP", + true, + ); + } + if (stale(signal, generation)) { + return webPushFailure("ABORTED", "NOTIFICATION_CLEANUP"); + } + for (const notification of notifications.slice( + 0, + WEB_PUSH_LIMITS.notificationCleanupCount, + )) { + const decoded = decodeNotificationClickDataForCleanup( + notification.data, + ); + if ( + decoded.ok && + decoded.value.associationEpoch === associationEpoch + ) { + try { + notification.close(); + } catch { + // Closing an OS notification is best effort. + } + } + } + return webPushSuccess(undefined); + } + + function stale( + signal: AbortSignal | undefined, + generation: number, + ): boolean { + return ( + Boolean(signal?.aborted) || + closed || + generation !== lifecycleGeneration + ); + } + + function instantNow(): string { + const value = now(); + return Number.isFinite(value) + ? new Date(value).toISOString() + : new Date(0).toISOString(); + } +} + +function readiness( + state: WebPushReadiness["state"], +): WebPushReadiness { + return Object.freeze({ state }); +} + +function unavailable( + reason: WebPushUnavailableReason, +): WebPushReadiness { + return Object.freeze({ state: "PUSH_UNAVAILABLE", reason }); +} + +function sameCleanupFence( + current: PushControlReceipt | null, + expected: PushControlReceipt, +): boolean { + if ( + !current || + current.revision !== expected.revision || + !samePushAuthority(current.control, expected.control) || + current.control.association.state !== + expected.control.association.state + ) { + return false; + } + return current.control.association.state === "UNASSOCIATED" + ? true + : expected.control.association.state !== "UNASSOCIATED" && + current.control.association.associationEpoch === + expected.control.association.associationEpoch; +} + +function validDeadline(value: number, ceiling: number): boolean { + return ( + Number.isSafeInteger(value) && + value >= 1 && + value <= ceiling + ); +} + +function isActiveFor( + value: PushControlV1 | null, + authority: PushAuthoritySnapshot, +): boolean { + return ( + Boolean(value) && + value?.association.state === "ACTIVE" && + samePushAuthority(value, authority) + ); +} + +function captureNativeMaterial( + subscription: WindowPushSubscriptionFacade, +): WebPushResult { + let p256dh: ArrayBuffer | null; + let auth: ArrayBuffer | null; + try { + p256dh = subscription.getKey("p256dh"); + auth = subscription.getKey("auth"); + } catch { + return webPushFailure("NATIVE_FAILURE", "SUBSCRIPTION_INSPECT"); + } + if (!p256dh || !auth || p256dh.byteLength !== 65 || auth.byteLength !== 16) { + return webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_INSPECT"); + } + return webPushSuccess( + Object.freeze({ + endpoint: subscription.endpoint, + p256dh: base64UrlEncode(new Uint8Array(p256dh)), + auth: base64UrlEncode(new Uint8Array(auth)), + expirationTime: subscription.expirationTime, + }), + ); +} + +function subscriptionUsesKey( + subscription: WindowPushSubscriptionFacade, + expected: Uint8Array, +): boolean { + const current = subscription.options.applicationServerKey; + if (!current || current.byteLength !== expected.byteLength) return false; + const bytes = new Uint8Array(current); + let difference = 0; + for (let index = 0; index < bytes.length; index += 1) { + difference |= bytes[index]! ^ expected[index]!; + } + return difference === 0; +} + +async function safeUnsubscribe( + subscription: WindowPushSubscriptionFacade, +): Promise { + try { + return await subscription.unsubscribe(); + } catch { + return false; + } +} + +function decodeVapidPublicKey( + value: string, +): Uint8Array | null { + if (!/^[A-Za-z0-9_-]{87}$/u.test(value)) return null; + const decoded = base64UrlDecode(value); + return decoded?.byteLength === 65 && decoded[0] === 4 ? decoded : null; +} + +function base64UrlDecode(value: string): Uint8Array | null { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const outputLength = Math.floor((value.length * 6) / 8); + const output = new Uint8Array(new ArrayBuffer(outputLength)); + let accumulator = 0; + let bitCount = 0; + let outputIndex = 0; + for (const character of value) { + const digit = alphabet.indexOf(character); + if (digit < 0) return null; + accumulator = (accumulator << 6) | digit; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + output[outputIndex] = (accumulator >>> bitCount) & 0xff; + outputIndex += 1; + } + } + if ( + outputIndex !== outputLength || + (bitCount > 0 && (accumulator & ((1 << bitCount) - 1)) !== 0) + ) { + return null; + } + return output; +} + +function base64UrlEncode(bytes: Uint8Array): string { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let output = ""; + let accumulator = 0; + let bitCount = 0; + for (const byte of bytes) { + accumulator = (accumulator << 8) | byte; + bitCount += 8; + while (bitCount >= 6) { + bitCount -= 6; + output += alphabet[(accumulator >>> bitCount) & 63]; + } + } + if (bitCount > 0) { + output += alphabet[(accumulator << (6 - bitCount)) & 63]; + } + return output; +} diff --git a/src/adapters/web-push/runtime-support.ts b/src/adapters/web-push/runtime-support.ts new file mode 100644 index 0000000..ae0e804 --- /dev/null +++ b/src/adapters/web-push/runtime-support.ts @@ -0,0 +1,137 @@ +import { + webPushFailure, + type WebPushFailureCode, + type WebPushObserver, + type WebPushOperation, + type WebPushResult, +} from "../../contracts/web-push.ts"; + +export type TimeoutScheduler = Readonly<{ + setTimeout(callback: () => void, milliseconds: number): unknown; + clearTimeout(handle: unknown): void; +}>; + +export const systemTimeoutScheduler: TimeoutScheduler = Object.freeze({ + setTimeout(callback, milliseconds) { + return globalThis.setTimeout(callback, milliseconds); + }, + clearTimeout(handle) { + globalThis.clearTimeout( + handle as ReturnType, + ); + }, +}); + +export type LinkedAbortController = Readonly<{ + signal: AbortSignal; + abort(): void; + dispose(): void; +}>; + +export function createLinkedAbortController( + source?: AbortSignal, +): LinkedAbortController { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (source?.aborted) { + controller.abort(); + } else { + source?.addEventListener("abort", abort, { once: true }); + if (source?.aborted) abort(); + } + return Object.freeze({ + signal: controller.signal, + abort, + dispose() { + source?.removeEventListener("abort", abort); + }, + }); +} + +export async function withAbortableDeadline( + task: (signal: AbortSignal) => Promise>, + input: Readonly<{ + deadlineMs: number; + operation: WebPushOperation; + signal?: AbortSignal; + scheduler?: TimeoutScheduler; + }>, +): Promise> { + if (input.signal?.aborted) { + return webPushFailure("ABORTED", input.operation); + } + const scheduler = input.scheduler ?? systemTimeoutScheduler; + const controller = new AbortController(); + let timeoutHandle: unknown; + let settleTerminal: + | ((result: WebPushResult) => void) + | undefined; + const terminal = new Promise>((resolve) => { + settleTerminal = resolve; + }); + const abortFromCaller = () => { + controller.abort(); + settleTerminal?.(webPushFailure("ABORTED", input.operation)); + }; + input.signal?.addEventListener("abort", abortFromCaller, { + once: true, + }); + if (input.signal?.aborted) abortFromCaller(); + try { + timeoutHandle = scheduler.setTimeout(() => { + controller.abort(); + settleTerminal?.( + webPushFailure("DEADLINE_EXCEEDED", input.operation), + ); + }, input.deadlineMs); + } catch { + controller.abort(); + input.signal?.removeEventListener("abort", abortFromCaller); + return webPushFailure( + "NATIVE_FAILURE", + input.operation, + true, + ); + } + const execution = Promise.resolve() + .then(() => + controller.signal.aborted + ? webPushFailure("ABORTED", input.operation) + : task(controller.signal), + ) + .catch(() => webPushFailure("NATIVE_FAILURE", input.operation, true)); + try { + return await Promise.race([execution, terminal]); + } finally { + try { + scheduler.clearTimeout(timeoutHandle); + } catch { + // A host cleanup failure cannot replace the settled closed result. + } + input.signal?.removeEventListener("abort", abortFromCaller); + } +} + +export function observeWebPush( + observer: WebPushObserver | undefined, + input: Parameters[0], +): void { + try { + observer?.record(Object.freeze({ ...input })); + } catch { + // Capability correctness is independent from best-effort observation. + } +} + +export function nativeFailure( + operation: WebPushOperation, + retryable = true, +): WebPushResult { + return webPushFailure("NATIVE_FAILURE", operation, retryable); +} + +export function failureCode( + result: WebPushResult, +): WebPushFailureCode | undefined { + return result.ok ? undefined : result.error.code; +} diff --git a/src/adapters/web-push/service-worker-runtime.ts b/src/adapters/web-push/service-worker-runtime.ts new file mode 100644 index 0000000..d7cac3d --- /dev/null +++ b/src/adapters/web-push/service-worker-runtime.ts @@ -0,0 +1,281 @@ +import { + WEB_PUSH_LIMITS, + WEB_PUSH_PROTOCOLS, + webPushFailure, + webPushSuccess, + type WebPushObserver, +} from "../../contracts/web-push.ts"; +import type { PushAssociationFenceStore } from "./push-association-fence-store.ts"; +import { + createPushEventAdapter, + type PushEventFacade, +} from "./inbound/push-event-adapter.ts"; +import { + createNotificationClickAdapter, + type NotificationClickEventFacade, + type WindowClientFacade, +} from "./inbound/notification-click-adapter.ts"; +import type { + AssociationNotificationTagDigest, + WebPushNotificationRegistry, +} from "./notification-registry.ts"; +import { + createLinkedAbortController, + nativeFailure, + observeWebPush, + withAbortableDeadline, + type TimeoutScheduler, +} from "./runtime-support.ts"; + +type FunctionalEventFacade = Readonly<{ + waitUntil(task: Promise): void; +}>; + +type ServiceWorkerRegistrationFacade = Readonly<{ + showNotification( + title: string, + options: Readonly<{ + body: string; + data: unknown; + requireInteraction: false; + tag: string; + }>, + ): Promise; +}>; + +type ServiceWorkerClientsFacade = Readonly<{ + matchAll(input: Readonly<{ + type: "window"; + includeUncontrolled: boolean; + }>): Promise; + openWindow(url: string): Promise; +}>; + +/** + * Structural worker host used deliberately instead of exposing DOM worker + * globals to application/test compilation. A selected worker entry adapts its + * native scope to this facade; this factory has no registration side effect. + */ +export type ServiceWorkerEventHost = Readonly<{ + origin: string; + registration: ServiceWorkerRegistrationFacade; + clients: ServiceWorkerClientsFacade; + addEventListener(type: string, listener: (event: unknown) => void): void; + removeEventListener(type: string, listener: (event: unknown) => void): void; +}>; + +export type WebPushServiceWorkerRuntime = Readonly<{ + dispose(): void; +}>; + +export function createWebPushServiceWorkerRuntime( + dependencies: Readonly<{ + host: ServiceWorkerEventHost; + fenceStore: PushAssociationFenceStore; + registry: WebPushNotificationRegistry; + now?: () => number; + scheduler?: TimeoutScheduler; + observer?: WebPushObserver; + tagDigest?: AssociationNotificationTagDigest; + }>, +): WebPushServiceWorkerRuntime { + const lifecycle = new AbortController(); + const push = createPushEventAdapter({ + fenceStore: dependencies.fenceStore, + registry: dependencies.registry, + notifications: { + showNotification: (title, options) => + dependencies.host.registration.showNotification(title, options), + }, + now: dependencies.now, + scheduler: dependencies.scheduler, + observer: dependencies.observer, + tagDigest: dependencies.tagDigest, + signal: lifecycle.signal, + }); + const click = createNotificationClickAdapter({ + fenceStore: dependencies.fenceStore, + registry: dependencies.registry, + clients: { + async matchControlledWindowClients() { + const candidates = await dependencies.host.clients.matchAll({ + type: "window", + includeUncontrolled: false, + }); + return candidates + .map(windowClientFacade) + .filter( + (candidate): candidate is WindowClientFacade => + candidate !== null, + ); + }, + async openWindow(url) { + return windowClientFacade( + await dependencies.host.clients.openWindow(url), + ); + }, + }, + origin: dependencies.host.origin, + now: dependencies.now, + scheduler: dependencies.scheduler, + observer: dependencies.observer, + signal: lifecycle.signal, + }); + + const onPush = (event: unknown) => { + const facade = pushEventFacade(event); + if (facade) void push.handle(facade); + }; + const onNotificationClick = (event: unknown) => { + const facade = notificationClickEventFacade(event); + if (facade) void click.handle(facade); + }; + const onSubscriptionChange = (event: unknown) => { + const facade = functionalEventFacade(event); + if (!facade) return; + const taskControl = createLinkedAbortController(lifecycle.signal); + const processing = withAbortableDeadline( + async (signal) => { + let clients: readonly unknown[]; + try { + clients = await dependencies.host.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + } catch { + return nativeFailure("SUBSCRIPTION_RECONCILE", true); + } + if (signal.aborted) { + return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE"); + } + try { + for (const candidate of clients.slice( + 0, + WEB_PUSH_LIMITS.clientHandoffCount, + )) { + if (signal.aborted) { + return webPushFailure( + "ABORTED", + "SUBSCRIPTION_RECONCILE", + ); + } + const client = windowClientFacade(candidate); + client?.postMessage( + Object.freeze({ + protocol: WEB_PUSH_PROTOCOLS.reconcileRequired, + }), + ); + } + } catch { + return nativeFailure("SUBSCRIPTION_RECONCILE", true); + } + return webPushSuccess(undefined); + }, + { + deadlineMs: WEB_PUSH_LIMITS.handlerDeadlineMs, + operation: "SUBSCRIPTION_RECONCILE", + signal: taskControl.signal, + scheduler: dependencies.scheduler, + }, + ).finally(taskControl.dispose); + const lifetime = processing.then((result) => { + observeWebPush(dependencies.observer, { + event: "web_push_subscription_rotated", + outcome: result.ok ? "SUCCEEDED" : "DEGRADED", + ...(result.ok ? {} : { reason: result.error.code }), + }); + }); + try { + facade.waitUntil(lifetime); + } catch { + taskControl.abort(); + void lifetime; + } + }; + + dependencies.host.addEventListener("push", onPush); + dependencies.host.addEventListener( + "notificationclick", + onNotificationClick, + ); + dependencies.host.addEventListener( + "pushsubscriptionchange", + onSubscriptionChange, + ); + + let disposed = false; + return Object.freeze({ + dispose() { + if (disposed) return; + disposed = true; + lifecycle.abort(); + dependencies.host.removeEventListener("push", onPush); + dependencies.host.removeEventListener( + "notificationclick", + onNotificationClick, + ); + dependencies.host.removeEventListener( + "pushsubscriptionchange", + onSubscriptionChange, + ); + dependencies.fenceStore.close(); + }, + }); +} + +function functionalEventFacade( + value: unknown, +): FunctionalEventFacade | null { + try { + return isRecord(value) && typeof value.waitUntil === "function" + ? (value as FunctionalEventFacade) + : null; + } catch { + return null; + } +} + +function pushEventFacade(value: unknown): PushEventFacade | null { + if ( + !isRecord(value) || + typeof value.waitUntil !== "function" || + !( + value.data === null || + (isRecord(value.data) && typeof value.data.arrayBuffer === "function") + ) + ) { + return null; + } + return value as PushEventFacade; +} + +function notificationClickEventFacade( + value: unknown, +): NotificationClickEventFacade | null { + if ( + !isRecord(value) || + typeof value.waitUntil !== "function" || + !isRecord(value.notification) || + typeof value.notification.close !== "function" || + !Object.hasOwn(value.notification, "data") + ) { + return null; + } + return value as NotificationClickEventFacade; +} + +function windowClientFacade(value: unknown): WindowClientFacade | null { + if ( + !isRecord(value) || + typeof value.url !== "string" || + typeof value.focus !== "function" || + typeof value.postMessage !== "function" + ) { + return null; + } + return value as WindowClientFacade; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/adapters/web-push/service-worker-scope-host.ts b/src/adapters/web-push/service-worker-scope-host.ts new file mode 100644 index 0000000..dda28aa --- /dev/null +++ b/src/adapters/web-push/service-worker-scope-host.ts @@ -0,0 +1,57 @@ +import type { ServiceWorkerEventHost } from "./service-worker-runtime.ts"; + +type NotificationOptions = Parameters< + ServiceWorkerEventHost["registration"]["showNotification"] +>[1]; +type MatchAllOptions = Parameters< + ServiceWorkerEventHost["clients"]["matchAll"] +>[0]; + +/** + * Adapts a native Service Worker global scope onto the structural + * {@link ServiceWorkerEventHost} facade. + * + * The native `showNotification` call lives here because `src/adapters/web-push` + * is the owner of the notification API. The single physical worker entry + * (§17.1) composes the runtime but never touches the native API itself. + */ + +export type NativeWorkerScope = Readonly<{ + location: Readonly<{ origin: string }>; + registration: Readonly<{ + showNotification(title: string, options: unknown): Promise; + }>; + clients: Readonly<{ + matchAll(options: unknown): Promise; + openWindow(url: string): Promise; + }>; + // Deliberately loose: a native scope declares a richly overloaded listener + // signature, and this facade only needs to forward the registration. + addEventListener(type: string, listener: never, options?: never): void; + removeEventListener(type: string, listener: never, options?: never): void; +}>; + +export function createServiceWorkerScopeHost( + scope: NativeWorkerScope, +): ServiceWorkerEventHost { + return Object.freeze({ + origin: scope.location.origin, + registration: Object.freeze({ + showNotification: (title: string, options: NotificationOptions) => + scope.registration.showNotification(title, { + body: options.body, + data: options.data, + requireInteraction: options.requireInteraction, + tag: options.tag, + }), + }), + clients: Object.freeze({ + matchAll: (options: MatchAllOptions) => scope.clients.matchAll(options), + openWindow: (url: string) => scope.clients.openWindow(url), + }), + addEventListener: (type, listener) => + scope.addEventListener(type, listener as never), + removeEventListener: (type, listener) => + scope.removeEventListener(type, listener as never), + }); +} diff --git a/src/application/create-application.ts b/src/application/create-application.ts new file mode 100644 index 0000000..81f3ad3 --- /dev/null +++ b/src/application/create-application.ts @@ -0,0 +1,205 @@ +import { normalizeColorSchemePreference } from "./policies/color-scheme.ts"; +import type { + ApplicationApi, + ApplicationFeatureId, + ApplicationFeatureInputs, + ColorSchemePreference, + RenderFailureReport, + RouteChangedReport, +} from "./ports/in/application-api.ts"; +import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.ts"; +import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.ts"; + +export type { ApplicationApi, ApplicationOutputPorts }; + +/** + * Builds the driving API consumed by inbound adapters. Concrete output ports + * remain inside these closures and are never returned to React. + */ +export function createApplication( + outputPorts: ApplicationOutputPorts, + featureInputs: Readonly> = {}, +): ApplicationApi { + const session = Object.freeze({ + getSnapshot: () => outputPorts.session.getState(), + subscribe: (listener: () => void) => + outputPorts.session.subscribe(listener), + beginSignIn: (returnTo?: string) => + outputPorts.session.beginSignIn(returnTo), + signOut: () => outputPorts.session.signOut(), + recover: () => outputPorts.session.recover(), + }); + + const preferences = Object.freeze({ + getColorScheme(): ColorSchemePreference { + const result = outputPorts.preferences.read("COLOR_SCHEME"); + return normalizeColorSchemePreference( + result.ok ? result.value : undefined, + ); + }, + setColorScheme(preference: ColorSchemePreference) { + const normalized = normalizeColorSchemePreference(preference); + return outputPorts.preferences.write("COLOR_SCHEME", normalized); + }, + }); + + const diagnostics = Object.freeze({ + reportRenderFailure(report: RenderFailureReport) { + try { + outputPorts.diagnostics.record({ + level: "error", + eventId: "ui.render.failed", + context: { + route_id: report.routeId, + build_id: report.buildId, + component_boundary: report.boundaryName, + }, + }); + } catch { + // Diagnostics are best-effort and cannot become an application failure. + } + try { + outputPorts.telemetry.emit("ui.render.failed", { + route_id: report.routeId, + build_id: report.buildId, + component_boundary: report.boundaryName, + }); + } catch { + // Diagnostics are best-effort and cannot become an application failure. + } + }, + reportRouteChanged(report: RouteChangedReport) { + try { + outputPorts.diagnostics.record({ + level: "info", + eventId: "route.changed", + context: { + route_id: report.routeId, + build_id: report.buildId, + }, + }); + } catch { + // Diagnostics are best-effort and cannot become navigation failure. + } + }, + }); + + const runtime = Object.freeze({ + async getReleaseSummary() { + const release = await outputPorts.releaseInfo.getCurrent(); + return Object.freeze({ + buildId: release.buildId, + releaseId: release.releaseId, + configSchemaVersion: release.configSchemaVersion, + ...(release.apiContractVersion === undefined + ? {} + : { apiContractVersion: release.apiContractVersion }), + ...(release.contractSetDigest === undefined + ? {} + : { contractSetDigest: release.contractSetDigest }), + }); + }, + getCapabilitySnapshot() { + return outputPorts.runtimeCapabilities.getSnapshot(); + }, + }); + + const recovery = Object.freeze({ + async recoverChunk(input: { + chunkId: string; + failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH"; + }) { + try { + const current = await outputPorts.releaseInfo.getCurrent(); + const active = await outputPorts.releaseInfo.refresh(); + if ( + current.buildId !== active.buildId || + current.releaseId !== active.releaseId + ) { + const mismatchKind = + current.buildId !== active.buildId + ? "BUILD_MISMATCH" + : "RELEASE_MISMATCH"; + try { + outputPorts.diagnostics.record({ + level: "warn", + eventId: "release.mismatch.detected", + context: { + build_id: current.buildId, + active_release_id: active.releaseId, + mismatch_kind: mismatchKind, + }, + }); + } catch { + // Recovery remains independent from diagnostics. + } + try { + outputPorts.telemetry.emit("release.mismatch.detected", { + build_id: current.buildId, + active_release_id: active.releaseId, + mismatch_kind: mismatchKind, + }); + } catch { + // Recovery remains independent from telemetry. + } + } + if (!active.routeChunks[input.chunkId]) { + return { + action: "support" as const, + reason: "active-chunk-unknown", + }; + } + const decision = decideChunkRecovery({ + failureKind: input.failureKind, + manifestLoaded: true, + currentBuildId: current.buildId, + currentReleaseId: current.releaseId, + activeBuildId: active.buildId, + activeReleaseId: active.releaseId, + storage: outputPorts.preferences, + }); + if (decision.action === "reload-once") { + try { + outputPorts.navigation.reload(); + } catch { + return { + action: "support" as const, + reason: "reload-failed", + }; + } + } + return decision; + } catch { + return { + action: "support" as const, + reason: "manifest-unavailable", + }; + } + }, + }); + const installedFeatureInputs = Object.freeze({ ...featureInputs }); + const features = Object.freeze({ + has(featureId: string): featureId is ApplicationFeatureId { + return Object.hasOwn(installedFeatureInputs, featureId); + }, + get( + featureId: FeatureId, + ): ApplicationFeatureInputs[FeatureId] { + if (!Object.hasOwn(installedFeatureInputs, featureId)) { + throw new Error(`Application feature is not installed: ${featureId}`); + } + return installedFeatureInputs[ + featureId + ] as ApplicationFeatureInputs[FeatureId]; + }, + }); + + return Object.freeze({ + session, + preferences, + diagnostics, + runtime, + recovery, + features, + }); +} diff --git a/src/application/policies/bounded-polling.ts b/src/application/policies/bounded-polling.ts new file mode 100644 index 0000000..bea0609 --- /dev/null +++ b/src/application/policies/bounded-polling.ts @@ -0,0 +1,159 @@ +export const BOUNDED_POLLING_CEILINGS = Object.freeze({ + minimumIntervalMs: 5_000, + maxIntervalMs: 60_000, + maxAttempts: 120, + maxElapsedMs: 30 * 60 * 1_000, + maxResponseBytes: 8 * 1_024 * 1_024, + maxTerminalStates: 32, +}); + +export type PollFallbackReason = + | "CONVERGENCE" + | "RELAXED_FRESHNESS" + | "STREAM_DEGRADED" + | "STREAM_UNAVAILABLE"; + +export type PollLeasePolicy = Readonly<{ + operationId: string; + owner: string; + minimumIntervalMs: number; + successIntervalMs: number; + maxIntervalMs: number; + maxAttempts: number; + maxElapsedMs: number; + maxResponseBytes: number; + visibility: "VISIBLE_ONLY"; + fallbackReason: PollFallbackReason; + terminalStates: readonly string[]; +}>; + +/** + * The HTTP executor used by a poll lease must represent exactly one physical + * request. Transport retry, credential replay and cumulative retry sleep stay + * disabled so a poll attempt cannot hide additional requests. + */ +export type BoundedPollOperationContract = Readonly<{ + operationId: string; + contractVersion: 2; + protocol: "REST"; + semantics: "QUERY"; + method: "GET" | "HEAD"; + replayPolicy: "SAFE" | "IDEMPOTENT"; + retry: "never"; + maxResponseBytes: number; + transportMaxAttempts: 1; + authRecoveryCount: 0; + maxCumulativeSleepMs: 0; + serverStream: false; +}>; + +const OPERATION_ID = /^[A-Z][A-Z0-9_]{2,79}$/u; +const OWNER_ID = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u; +const TERMINAL_STATE = /^[A-Z][A-Z0-9_]{0,63}$/u; + +export function definePollLeasePolicy( + input: PollLeasePolicy, +): PollLeasePolicy { + if ( + !input || + typeof input !== "object" || + !OPERATION_ID.test(input.operationId) || + !OWNER_ID.test(input.owner) || + input.visibility !== "VISIBLE_ONLY" || + ![ + "CONVERGENCE", + "RELAXED_FRESHNESS", + "STREAM_DEGRADED", + "STREAM_UNAVAILABLE", + ].includes(input.fallbackReason) || + !isIntegerWithin( + input.minimumIntervalMs, + BOUNDED_POLLING_CEILINGS.minimumIntervalMs, + BOUNDED_POLLING_CEILINGS.maxIntervalMs, + ) || + !isIntegerWithin( + input.successIntervalMs, + input.minimumIntervalMs, + BOUNDED_POLLING_CEILINGS.maxIntervalMs, + ) || + !isIntegerWithin( + input.maxIntervalMs, + input.successIntervalMs, + BOUNDED_POLLING_CEILINGS.maxIntervalMs, + ) || + !isIntegerWithin( + input.maxAttempts, + 1, + BOUNDED_POLLING_CEILINGS.maxAttempts, + ) || + !isIntegerWithin( + input.maxElapsedMs, + input.minimumIntervalMs, + BOUNDED_POLLING_CEILINGS.maxElapsedMs, + ) || + !isIntegerWithin( + input.maxResponseBytes, + 1, + BOUNDED_POLLING_CEILINGS.maxResponseBytes, + ) || + !Array.isArray(input.terminalStates) || + input.terminalStates.length > + BOUNDED_POLLING_CEILINGS.maxTerminalStates || + input.terminalStates.some( + (state) => + typeof state !== "string" || !TERMINAL_STATE.test(state), + ) || + new Set(input.terminalStates).size !== input.terminalStates.length || + (input.fallbackReason === "CONVERGENCE" && + input.terminalStates.length === 0) + ) { + throw new TypeError("Invalid bounded polling lease policy."); + } + + return Object.freeze({ + ...input, + terminalStates: Object.freeze([...input.terminalStates]), + }); +} + +export function assertBoundedPollOperation( + policy: PollLeasePolicy, + operation: BoundedPollOperationContract, +): void { + if ( + !operation || + typeof operation !== "object" || + operation.operationId !== policy.operationId || + operation.contractVersion !== 2 || + operation.protocol !== "REST" || + operation.semantics !== "QUERY" || + !["GET", "HEAD"].includes(operation.method) || + !["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy) || + operation.retry !== "never" || + operation.serverStream !== false || + operation.transportMaxAttempts !== 1 || + operation.authRecoveryCount !== 0 || + operation.maxCumulativeSleepMs !== 0 || + !isIntegerWithin( + operation.maxResponseBytes, + 1, + BOUNDED_POLLING_CEILINGS.maxResponseBytes, + ) + ) { + throw new TypeError( + "Bounded polling operation must be one terminal replay-safe REST request.", + ); + } +} + +function isIntegerWithin( + value: number, + minimum: number, + maximum: number, +): boolean { + return ( + Number.isSafeInteger(value) && + value >= minimum && + value <= maximum + ); +} diff --git a/src/application/policies/color-scheme.ts b/src/application/policies/color-scheme.ts new file mode 100644 index 0000000..f1f45bb --- /dev/null +++ b/src/application/policies/color-scheme.ts @@ -0,0 +1,26 @@ +export const COLOR_SCHEME_PREFERENCES = Object.freeze([ + "system", + "light", + "dark", +] as const); + +export type ColorSchemePreference = + (typeof COLOR_SCHEME_PREFERENCES)[number]; +export type ResolvedColorScheme = Exclude; + +export function normalizeColorSchemePreference( + value: unknown, +): ColorSchemePreference { + return typeof value === "string" && + COLOR_SCHEME_PREFERENCES.some((preference) => preference === value) + ? (value as ColorSchemePreference) + : "system"; +} + +export function resolveColorScheme( + preference: ColorSchemePreference, + systemPrefersDark: boolean, +): ResolvedColorScheme { + if (preference === "system") return systemPrefersDark ? "dark" : "light"; + return preference; +} diff --git a/src/application/policies/compatibility.ts b/src/application/policies/compatibility.ts new file mode 100644 index 0000000..486a954 --- /dev/null +++ b/src/application/policies/compatibility.ts @@ -0,0 +1,107 @@ +export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([ + "buildId", + "configSchemaVersion", + "apiContractVersion", + "assetManifestHash", + "releaseId", +] as const); + +export type CompatibilityTupleField = + (typeof COMPATIBILITY_TUPLE_FIELDS)[number]; + +export type CompatibilityTuple = Readonly< + Record +>; + +export type NumericVersion = Readonly<{ + major: number; + minor: number; + patch: number; +}>; + +export function parseNumericVersion(version: string): NumericVersion | null { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + }; +} + +export function isVersionCompatible( + supported: string, + actual: string, +): boolean { + const expected = parseNumericVersion(supported); + const candidate = parseNumericVersion(actual); + if (!expected || !candidate) return false; + return ( + expected.major === candidate.major && + candidate.minor >= expected.minor + ); +} + +export function verifyCompatibilityTuple(input: Readonly<{ + frontend: CompatibilityTuple; + runtime: CompatibilityTuple; +}>) { + const mismatches: CompatibilityTupleField[] = []; + if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId"); + if ( + !isVersionCompatible( + input.frontend.configSchemaVersion, + input.runtime.configSchemaVersion, + ) + ) { + mismatches.push("configSchemaVersion"); + } + if ( + !isVersionCompatible( + input.frontend.apiContractVersion, + input.runtime.apiContractVersion, + ) + ) { + mismatches.push("apiContractVersion"); + } + if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) { + mismatches.push("assetManifestHash"); + } + + const releaseWarning: "releaseId" | null = + input.frontend.releaseId === input.runtime.releaseId + ? null + : "releaseId"; + return Object.freeze({ + compatible: mismatches.length === 0, + mismatches: Object.freeze(mismatches), + warnings: Object.freeze(releaseWarning ? [releaseWarning] : []), + }); +} + +export type ObjectSchemaShape = Readonly<{ + required?: readonly string[]; + properties?: Readonly>; +}>; + +export type SchemaChangeClassification = "breaking" | "additive" | "none"; + +export function classifyObjectSchemaChange( + before: ObjectSchemaShape, + after: ObjectSchemaShape, +): SchemaChangeClassification { + const beforeRequired = new Set(before.required ?? []); + const afterRequired = new Set(after.required ?? []); + const removedProperties = Object.keys(before.properties ?? {}).filter( + (key) => !(key in (after.properties ?? {})), + ); + const addedRequired = [...afterRequired].filter( + (key) => !beforeRequired.has(key), + ); + if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking"; + + const addedProperties = Object.keys(after.properties ?? {}).filter( + (key) => !(key in (before.properties ?? {})), + ); + return addedProperties.length > 0 ? "additive" : "none"; +} diff --git a/src/application/policies/performance-budgets.ts b/src/application/policies/performance-budgets.ts new file mode 100644 index 0000000..d6caac3 --- /dev/null +++ b/src/application/policies/performance-budgets.ts @@ -0,0 +1,118 @@ +export type BundleMeasurements = Readonly<{ + initialJsGzipBytes: number; + lazyChunks: readonly Readonly<{ path: string; gzipBytes: number }>[]; +}>; + +export type BundleThresholds = Readonly<{ + initialJsGzipBytes: number; + lazyChunkGzipBytes: number; +}>; + +export function evaluateBundleBudget( + measurements: BundleMeasurements, + thresholds: BundleThresholds, +) { + const initialPassed = + measurements.initialJsGzipBytes <= thresholds.initialJsGzipBytes; + const lazyResults = measurements.lazyChunks.map((chunk) => ({ + ...chunk, + threshold: thresholds.lazyChunkGzipBytes, + passed: chunk.gzipBytes <= thresholds.lazyChunkGzipBytes, + })); + return Object.freeze({ + initialPassed, + lazyResults: Object.freeze(lazyResults), + passed: initialPassed && lazyResults.every((chunk) => chunk.passed), + }); +} + +export type LabMetrics = Readonly<{ + lcpMs: number; + cls: number; + namedInteractionMs: number; +}>; + +export function evaluateLabBudget( + report: Readonly<{ + context?: Readonly>; + metrics: LabMetrics; + }>, + thresholds: LabMetrics, +) { + const requiredContext = [ + "runner", + "browser", + "viewport", + "network", + "cpu", + "cache", + "build", + ]; + const missingContext = requiredContext.filter( + (field) => report.context?.[field] === undefined, + ); + const results = { + lcp: report.metrics.lcpMs <= thresholds.lcpMs, + cls: report.metrics.cls <= thresholds.cls, + namedInteraction: + report.metrics.namedInteractionMs <= thresholds.namedInteractionMs, + }; + return Object.freeze({ + missingContext: Object.freeze(missingContext), + results: Object.freeze(results), + passed: missingContext.length === 0 && Object.values(results).every(Boolean), + }); +} + +export function percentile75(values: readonly number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.ceil(sorted.length * 0.75) - 1]; +} + +export type FieldMetrics = Readonly<{ + p75LcpMs: number | null; + p75Cls: number | null; + p75InpMs: number | null; +}>; + +export type FieldThresholds = Readonly<{ + p75LcpMs: number; + p75Cls: number; + p75InpMs: number; + minimumEligibleSamples: number | null; +}>; + +export type FieldBudgetResult = Readonly<{ + status: "PASS" | "FAIL_THRESHOLD" | "FAIL_UNVERIFIED"; + passed: boolean; +}>; + +export function evaluateFieldBudget( + report: Readonly<{ metrics: FieldMetrics; eligibleSamples: number }>, + thresholds: FieldThresholds, +): FieldBudgetResult { + if ( + thresholds.minimumEligibleSamples === null || + report.eligibleSamples < thresholds.minimumEligibleSamples || + Object.values(report.metrics).some((value) => value === null) + ) { + return Object.freeze({ + status: "FAIL_UNVERIFIED", + passed: false, + }); + } + const metrics = report.metrics as Readonly<{ + p75LcpMs: number; + p75Cls: number; + p75InpMs: number; + }>; + const passed = + metrics.p75LcpMs <= thresholds.p75LcpMs && + metrics.p75Cls <= thresholds.p75Cls && + metrics.p75InpMs <= thresholds.p75InpMs; + return Object.freeze({ + status: passed ? "PASS" : "FAIL_THRESHOLD", + passed, + }); +} diff --git a/src/application/policies/promotion-readiness.ts b/src/application/policies/promotion-readiness.ts new file mode 100644 index 0000000..9dcaf52 --- /dev/null +++ b/src/application/policies/promotion-readiness.ts @@ -0,0 +1,60 @@ +export const PROMOTION_FORMULA = Object.freeze({ + MERGE_READY: Object.freeze([ + "FE-GATE-001", + "FE-GATE-002", + "FE-GATE-003", + "FE-GATE-004", + "FE-GATE-005", + "FE-GATE-006", + "FE-GATE-007", + "FE-GATE-008", + "FE-GATE-009", + "FE-GATE-010", + "FE-GATE-011", + "FE-GATE-013", + "FE-GATE-020", + ]), + RELEASE_READY: Object.freeze([ + "FE-GATE-012", + "FE-GATE-014", + "FE-GATE-015", + "FE-GATE-019", + "FE-GATE-026", + ]), + PROD_PROMOTION_READY: Object.freeze([ + "FE-GATE-016", + "FE-GATE-021", + "FE-GATE-022", + "FE-GATE-023", + "FE-GATE-024", + "FE-GATE-025", + ]), + FIELD_SLO_READY: Object.freeze(["FE-GATE-018"]), + DOCUMENTATION_READY: Object.freeze(["FE-GATE-017"]), +}); + +export type GateResult = "PASS" | "FAIL" | "UNVERIFIED"; + +export function evaluatePromotionReadiness( + gateResults: Readonly>, +) { + const allPass = (gateIds: readonly string[]) => + gateIds.every((gateId) => gateResults[gateId] === "PASS"); + + const mergeReady = allPass(PROMOTION_FORMULA.MERGE_READY); + const releaseReady = + mergeReady && allPass(PROMOTION_FORMULA.RELEASE_READY); + const productionReady = + releaseReady && allPass(PROMOTION_FORMULA.PROD_PROMOTION_READY); + const fieldReady = + productionReady && allPass(PROMOTION_FORMULA.FIELD_SLO_READY); + const documentationReady = allPass(PROMOTION_FORMULA.DOCUMENTATION_READY); + + return Object.freeze({ + MERGE_READY: mergeReady, + RELEASE_READY: releaseReady, + PROD_PROMOTION_READY: productionReady, + FIELD_SLO_READY: fieldReady, + DOCUMENTATION_READY: documentationReady, + }); +} diff --git a/src/application/ports/auth-session-port.ts b/src/application/ports/auth-session-port.ts new file mode 100644 index 0000000..be28a19 --- /dev/null +++ b/src/application/ports/auth-session-port.ts @@ -0,0 +1,30 @@ +export type SessionState = + | "authenticated" + | "unauthenticated" + | "recovery-pending" + | "integration-failed"; + +export type SessionGateway = Readonly<{ + getState(): SessionState; + subscribe(listener: () => void): () => void; + beginSignIn(returnTo?: string): Promise; + signOut(): Promise; + recover(): Promise<"restored" | "no-session">; +}>; + +export type CredentialRequestBinding = Readonly<{ + origin: string; + method: string; + operationId: string; +}>; + +export type CredentialPatch = Readonly<{ + headers: Readonly>; +}>; + +export type CredentialAttacher = Readonly<{ + credentialPatch(binding: CredentialRequestBinding): Promise; + onUnauthenticated(): void; +}>; + +export type AuthSessionPort = SessionGateway & CredentialAttacher; diff --git a/src/application/ports/browser-file-storage/cache-storage-ports.ts b/src/application/ports/browser-file-storage/cache-storage-ports.ts new file mode 100644 index 0000000..8072a33 --- /dev/null +++ b/src/application/ports/browser-file-storage/cache-storage-ports.ts @@ -0,0 +1,90 @@ +import type { + BrowserDataResult, + ByteSource, +} from "./shared.ts"; + +export type PublicCacheHeader = readonly [name: string, value: string]; + +export type PublicCacheAsset = Readonly<{ + absoluteUrl: string; + expectedByteLength: number; + expectedContentType: string; + integrity: Readonly<{ + algorithm: "SHA-256"; + digestHex: string; + }>; + requestHeaders?: readonly PublicCacheHeader[]; +}>; + +export type PublicCacheReleaseManifest = Readonly<{ + releaseRegistryId: string; + manifestDigestHex: string; + assets: readonly PublicCacheAsset[]; +}>; + +export type PublicCacheReleaseSummary = Readonly<{ + releaseRegistryId: string; + entryCount: number; + totalBytes: number; + stagedAtEpochMs: number; +}>; + +export type CachedPublicResponse = Readonly<{ + status: 200; + headers: readonly PublicCacheHeader[]; + body: ByteSource; + integrity: Readonly<{ + algorithm: "SHA-256"; + digestHex: string; + }>; +}>; + +export type PublicCacheInspection = Readonly<{ + activeReleaseRegistryId: string | null; + ownedCacheCount: number; + unreadableOwnedCacheCount: number; + releaseCandidates: readonly Readonly<{ + releaseRegistryId: string; + verified: boolean; + entryCount: number | null; + }>[]; +}>; + +export type PublicCacheCleanupReport = Readonly<{ + inspectedOwnedCaches: number; + deletedOwnedCaches: number; + retainedOwnedCaches: number; +}>; + +export interface PublicResponseCachePort { + matchActiveExact( + request: Readonly<{ + absoluteUrl: string; + requestHeaders?: readonly PublicCacheHeader[]; + signal?: AbortSignal; + }>, + ): Promise>; +} + +export interface PublicResponseCacheAdminPort { + stageRelease( + manifest: PublicCacheReleaseManifest, + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + activateRelease( + releaseRegistryId: string, + manifestDigestHex: string, + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + cleanupOwned( + request?: Readonly<{ + signal?: AbortSignal; + }>, + ): Promise>; + inspect(): Promise>; +} + +export type PublicResponseCache = Readonly<{ + responses: PublicResponseCachePort; + admin: PublicResponseCacheAdminPort; +}>; diff --git a/src/application/ports/browser-file-storage/file.ts b/src/application/ports/browser-file-storage/file.ts new file mode 100644 index 0000000..890daec --- /dev/null +++ b/src/application/ports/browser-file-storage/file.ts @@ -0,0 +1,250 @@ +import type { + BrowserDataResult, + TransferProgress, +} from "./shared.ts"; +import type { AuthorizedDownloadCapability } from "../browser-transfer/authorized-download.ts"; + +/** + * Application-owned browser-file contracts. + * + * Native File, Blob, FileList, FileSystemHandle, Response and ReadableStream + * intentionally do not cross this boundary. A transient object URL may cross + * only through the presentation-local PreviewLease below; it must never enter + * domain state, persistence, diagnostics or a general application cache. + */ + +declare const localFileRefBrand: unique symbol; +declare const fileVerificationReceiptBrand: unique symbol; +declare const filePolicyKeyBrand: unique symbol; +declare const filePolicyIntentionBrand: unique symbol; +declare const browserManagedCapabilityReceiptBrand: unique symbol; + +export type LocalFileRef = string & { + readonly [localFileRefBrand]: "LocalFileRef"; +}; + +export type FileVerificationReceipt = string & { + readonly [fileVerificationReceiptBrand]: "FileVerificationReceipt"; +}; + +/** + * Registry-issued, non-semantic identifiers. A feature receives a frozen + * reference from its composition root; presentation must not construct policy + * definitions or select another feature's registered policy. + */ +export type FilePolicyKey = string & { + readonly [filePolicyKeyBrand]: "FilePolicyKey"; +}; + +export type FilePolicyIntention = string & { + readonly [filePolicyIntentionBrand]: "FilePolicyIntention"; +}; + +export type FilePolicyReference = Readonly<{ + policyKey: FilePolicyKey; + intention: FilePolicyIntention; +}>; + +export type FileSelectionSource = + | "NATIVE_INPUT" + | "SYSTEM_PICKER" + | "DROP"; + +export type FileSelectionLimitReduction = Readonly<{ + maxCount?: number; + maxFileBytes?: number; + maxTotalBytes?: number; +}>; + +export type FileCandidate = Readonly<{ + ref: LocalFileRef; + /** + * Untrusted, potentially personal display metadata. It must never be used as + * a resource identifier or diagnostics attribute. + */ + displayName: string; + sizeBytes: number; + reportedMediaType: string | null; + lastModifiedEpochMs: number | null; + source: FileSelectionSource; +}>; + +export type FileSelectionOutcome = + | Readonly<{ kind: "SELECTED"; files: readonly FileCandidate[] }> + | Readonly<{ kind: "DISMISSED" }>; + +export type FilePickerSupport = Readonly<{ + nativeInput: true; + systemOpenPicker: boolean; + systemSavePicker: boolean; +}>; + +export interface FilePickerPort { + readonly support: FilePickerSupport; + + /** + * Must be invoked as the first browser action in a trusted user activation. + * A dismissed picker is a successful DISMISSED outcome, not an error. + */ + select(input: { + policy: FilePolicyReference; + limits?: FileSelectionLimitReduction; + signal?: AbortSignal; + }): Promise>; + + release(ref: LocalFileRef): void; +} + +export type FileSignatureResult = + | "MATCHED" + | "MISMATCHED" + | "UNKNOWN"; + +export type FileInspection = Readonly<{ + byteLength: number; + reportedMediaType: string | null; + detectedMediaType: string | null; + normalizedExtension: string | null; + signature: FileSignatureResult; + /** + * Issued only for a matched signature and bound inside the transient vault + * to this file snapshot and inspection policy. + */ + verificationReceipt: FileVerificationReceipt | null; +}>; + +/** + * File-capability byte stream with a closed failure channel. Implementations + * must convert native exceptions to BrowserDataResult and never throw a raw + * DOMException across the application boundary. + */ +export interface FileByteSource { + readonly byteLength: number | null; + stream( + signal: AbortSignal, + ): AsyncIterable>; +} + +export interface FileContentPort { + inspect(input: { + ref: LocalFileRef; + policy: FilePolicyReference; + maxInspectionBytes?: number; + signal: AbortSignal; + }): Promise>; + + readRange(input: { + ref: LocalFileRef; + offset: number; + length: number; + signal: AbortSignal; + }): Promise>; + + openSource(input: { + ref: LocalFileRef; + signal: AbortSignal; + }): Promise>; + + release(ref: LocalFileRef): void; +} + +export type PreviewLease = Readonly<{ + url: string; + mediaType: string; + release(): void; +}>; + +export interface TransientPreviewPort { + create(input: { + ref: LocalFileRef; + verificationReceipt: FileVerificationReceipt; + policy: FilePolicyReference; + maxPreviewBytes?: number; + signal: AbortSignal; + }): Promise>; + + dispose(): void; +} + +export type BrowserManagedDownloadCapabilityReceipt = string & { + readonly [browserManagedCapabilityReceiptBrand]: + "BrowserManagedDownloadCapabilityReceipt"; +}; + +export type DownloadSource = + | Readonly<{ + kind: "BROWSER_MANAGED_RESOURCE"; + resourceId: string; + capabilityReceipt: BrowserManagedDownloadCapabilityReceipt; + }> + | Readonly<{ + kind: "AUTHORIZED_STREAM_RESOURCE"; + resourceId: string; + /** + * Exact provider-issued handle. Raw href/query/header values are never + * caller inputs and an equal-looking fabricated handle must be rejected. + */ + capability: AuthorizedDownloadCapability; + }> + | Readonly<{ + kind: "GENERATED"; + bytes: FileByteSource; + expectedSha256?: string; + }>; + +export type DownloadStrategy = + | "BROWSER_MANAGED" + | "PROMPT_AND_STREAM" + | "BOUNDED_OBJECT_URL"; + +export type DownloadOutcome = + | Readonly<{ + kind: "BROWSER_HANDOFF"; + transferId: string; + }> + | Readonly<{ + kind: "SAVED"; + transferId: string; + bytesWritten: number; + integrity: "VERIFIED" | "NOT_PROVIDED"; + }> + | Readonly<{ kind: "DISMISSED" }>; + +export interface DownloadDeliveryPort { + deliver(input: { + policy: FilePolicyReference; + source: DownloadSource; + suggestedFileName: string; + /** + * Optional reductions of the composition-owned policy ceiling. These + * values can never raise the registered or absolute runtime limits. + */ + maxTransferBytes?: number; + maxBufferedBytes?: number; + signal: AbortSignal; + onProgress(progress: TransferProgress): void; + }): Promise>; +} + +/** + * Synchronously resolved, server-enforced handoff capability. The endpoint + * behind href must bind and enforce every field, including expiry and the + * optional digest; the browser adapter cannot observe navigation bytes. + */ +export type BrowserManagedDownloadCapability = Readonly<{ + capabilityReceipt: BrowserManagedDownloadCapabilityReceipt; + href: string; + resourceId: string; + mediaType: string; + safeExtension: string; + maxBytes: number; + expectedSha256?: string; + expiresAtEpochMs: number; +}>; + +export interface BrowserManagedDownloadCapabilityResolver { + resolve(input: Readonly<{ + resourceId: string; + capabilityReceipt: BrowserManagedDownloadCapabilityReceipt; + }>): BrowserDataResult; +} diff --git a/src/application/ports/browser-file-storage/index.ts b/src/application/ports/browser-file-storage/index.ts new file mode 100644 index 0000000..fcac2cf --- /dev/null +++ b/src/application/ports/browser-file-storage/index.ts @@ -0,0 +1,114 @@ +export type { + BrowserDataFailure, + BrowserDataFailureCode, + BrowserAccountDeletionAction, + BrowserDataAuthority, + BrowserAccountScope, + BrowserDataObservation, + BrowserDataObserver, + BrowserLogoutAction, + BrowserDataOperation, + BrowserPressureAction, + BrowserDataRecovery, + BrowserDataResult, + BrowserStoragePolicy, + ByteSource, + PersistableDataClass, + TransferProgress, +} from "./shared.ts"; +export { + assertValidStoragePolicy, + isValidByteLength, +} from "./shared.ts"; + +export type { + BrowserManagedDownloadCapability, + BrowserManagedDownloadCapabilityReceipt, + BrowserManagedDownloadCapabilityResolver, + DownloadDeliveryPort, + DownloadOutcome, + DownloadSource, + DownloadStrategy, + FileByteSource, + FileCandidate, + FileContentPort, + FileInspection, + FilePolicyIntention, + FilePolicyKey, + FilePolicyReference, + FilePickerPort, + FilePickerSupport, + FileSelectionOutcome, + FileSelectionLimitReduction, + FileSelectionSource, + FileSignatureResult, + FileVerificationReceipt, + LocalFileRef, + PreviewLease, + TransientPreviewPort, +} from "./file.ts"; + +export type { + IndexedDbCompareAndSwapInput, + IndexedDbConnectionStatus, + IndexedDbCursor, + IndexedDbCursorKey, + IndexedDbDatasetScope, + IndexedDbDeleteInput, + IndexedDbLifecycleAction, + IndexedDbLifecycleAuthorityDecision, + IndexedDbLifecycleAuthorityRequest, + IndexedDbLifecycleBatchInput, + IndexedDbLifecycleBatchReceipt, + IndexedDbMaintenanceBatchInput, + IndexedDbMaintenanceBatchReceipt, + IndexedDbMaintenancePort, + IndexedDbPage, + IndexedDbReceiptPruneBatchReceipt, + IndexedDbRepositoryPort, + IndexedDbSynchronizationState, + IndexedDbWriteReceipt, +} from "./indexeddb-port.ts"; + +export type { + BeginOpfsJournalTransaction, + DurableObjectDescriptor, + DurableObjectMaintenancePort, + DurableObjectStorePort, + OpenDurableObjectRequest, + OpenedDurableObject, + OpfsCapabilities, + OpfsChunkReference, + OpfsCommittedObjectPage, + OpfsIntegrity, + OpfsJournalMutation, + OpfsJournalPage, + OpfsJournalPhase, + OpfsJournalPort, + OpfsJournalTransaction, + OpfsPolicyMaintenanceReport, + OpfsPreparedObject, + OpfsReconciliationReport, + OpfsSensitiveMaintenanceReason, + OpfsStorageScope, + PutDurableObjectRequest, + RemoveDurableObjectRequest, +} from "./opfs-ports.ts"; + +export type { + CachedPublicResponse, + PublicCacheAsset, + PublicCacheCleanupReport, + PublicCacheHeader, + PublicCacheInspection, + PublicCacheReleaseManifest, + PublicCacheReleaseSummary, + PublicResponseCache, + PublicResponseCacheAdminPort, + PublicResponseCachePort, +} from "./cache-storage-ports.ts"; + +export type { + StorageDurabilityPort, + StorageEstimate, +} from "./storage-durability-port.ts"; diff --git a/src/application/ports/browser-file-storage/indexeddb-port.ts b/src/application/ports/browser-file-storage/indexeddb-port.ts new file mode 100644 index 0000000..ca73f04 --- /dev/null +++ b/src/application/ports/browser-file-storage/indexeddb-port.ts @@ -0,0 +1,186 @@ +import type { + BrowserAccountScope, + BrowserDataResult, + BrowserStoragePolicy, +} from "./shared.ts"; + +/** + * Registry-issued, non-semantic dataset identity. Tokens must be random and + * must never contain a tenant, account, user, email, domain object ID, or the + * human-readable policy namespace. + */ +export type IndexedDbDatasetScope = Readonly<{ + authorityToken: string; + namespaceToken: string; + partitionToken: string; + accountScope: BrowserAccountScope; +}>; + +export type IndexedDbSynchronizationState = + | "PENDING" + | "CONFIRMED"; + +export type IndexedDbConnectionStatus = + | Readonly<{ kind: "CLOSED"; reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED" }> + | Readonly<{ kind: "OPENING"; targetVersion: number }> + | Readonly<{ + kind: "BLOCKED"; + currentVersion: number; + targetVersion: number; + }> + | Readonly<{ kind: "READY"; schemaVersion: number }> + | Readonly<{ kind: "DISPOSED" }>; + +export type IndexedDbCursorKey = + | string + | number + | Date + | ArrayBuffer + | readonly IndexedDbCursorKey[]; + +/** + * Opaque continuation state owned by an adapter query policy. Feature ports + * should wrap this value if a cursor crosses a presentation or URL boundary. + */ +export type IndexedDbCursor = Readonly<{ + indexKey: IndexedDbCursorKey; + primaryKey: IndexedDbCursorKey; +}>; + +export type IndexedDbPage = Readonly<{ + items: readonly Value[]; + nextCursor: IndexedDbCursor | null; +}>; + +export type IndexedDbWriteReceipt = Readonly<{ + key: string; + revision: number; + replayed: boolean; +}>; + +export type IndexedDbCompareAndSwapInput = Readonly<{ + key: string; + value: Value; + expectedRevision: number | null; + idempotencyKey: string; + /** + * Required only for UNTIL_SYNCED datasets. The adapter never infers server + * acknowledgement from a successful local write. + */ + synchronization?: IndexedDbSynchronizationState; + signal?: AbortSignal; +}>; + +export type IndexedDbDeleteInput = Readonly<{ + key: string; + expectedRevision: number; + idempotencyKey: string; + signal?: AbortSignal; +}>; + +export type IndexedDbMaintenanceBatchInput = Readonly<{ + /** + * Hard row-count ceiling for one invocation. The adapter also observes the + * cooperative duration budget between asynchronous storage operations. + */ + maxRows: number; + maxDurationMs: number; + signal?: AbortSignal; +}>; + +export type IndexedDbMaintenanceBatchReceipt = Readonly<{ + state: "MORE" | "COMPLETE"; + scannedRows: number; + checkpointedRows: number; + migratedRows: number; + concurrentlyChangedRows: number; + budgetExhausted: boolean; +}>; + +export type IndexedDbReceiptPruneBatchReceipt = Readonly<{ + state: "MORE" | "COMPLETE"; + scannedRows: number; + deletedRows: number; + budgetExhausted: boolean; +}>; + +export type IndexedDbLifecycleAction = + | "SESSION_END" + | "LOGOUT" + | "ACCOUNT_DELETION" + | "RETENTION_SWEEP"; + +export type IndexedDbLifecycleBatchInput = Readonly<{ + action: IndexedDbLifecycleAction; + maxRows: number; + maxDurationMs: number; + signal?: AbortSignal; +}>; + +export type IndexedDbLifecycleBatchReceipt = Readonly<{ + state: "MORE" | "COMPLETE"; + scannedRows: number; + deletedRows: number; + budgetExhausted: boolean; +}>; + +export type IndexedDbLifecycleAuthorityRequest = Readonly<{ + action: IndexedDbLifecycleAction; + scope: IndexedDbDatasetScope; + storagePolicy: BrowserStoragePolicy; + signal?: AbortSignal; +}>; + +export type IndexedDbLifecycleAuthorityDecision = + | Readonly<{ authorized: false }> + | Readonly<{ + authorized: true; + /** Opaque, short-lived proof. It is validated and discarded, never stored. */ + proofToken: string; + }>; + +/** + * Domain-neutral asynchronous repository boundary. Native IndexedDB objects, + * object-store names, indexes and transaction callbacks remain adapter-local. + */ +export interface IndexedDbRepositoryPort { + open(signal?: AbortSignal): Promise>; + read( + key: string, + signal?: AbortSignal, + ): Promise | null>>; + query( + query: Query, + cursor?: IndexedDbCursor | null, + signal?: AbortSignal, + ): Promise>>; + compareAndSwap( + input: IndexedDbCompareAndSwapInput, + ): Promise>; + remove( + input: IndexedDbDeleteInput, + ): Promise>; + /** + * Bounded destructive lifecycle work. Every deleting invocation is gated by + * the composition-root authority callback; callers cannot provide proof. + */ + enforceLifecycleBatch( + input: IndexedDbLifecycleBatchInput, + ): Promise>; + getStatus(): IndexedDbConnectionStatus; + subscribeStatus(listener: (status: IndexedDbConnectionStatus) => void): () => void; + close(): void; +} + +/** + * Bounded, restart-safe maintenance boundary. Checkpoint keys and raw stored + * records stay private to the adapter; callers receive aggregate progress only. + */ +export interface IndexedDbMaintenancePort { + migrateCodecBatch( + input: IndexedDbMaintenanceBatchInput, + ): Promise>; + pruneExpiredReceipts( + input: IndexedDbMaintenanceBatchInput, + ): Promise>; +} diff --git a/src/application/ports/browser-file-storage/opfs-ports.ts b/src/application/ports/browser-file-storage/opfs-ports.ts new file mode 100644 index 0000000..011adc3 --- /dev/null +++ b/src/application/ports/browser-file-storage/opfs-ports.ts @@ -0,0 +1,276 @@ +import type { + BrowserDataResult, + BrowserStoragePolicy, + ByteSource, + TransferProgress, +} from "./shared.ts"; + +export type OpfsIntegrity = Readonly<{ + algorithm: "SHA-256-TREE-V1"; + rootDigestHex: string; + chunkSizeBytes: number; +}>; + +/** + * namespaceToken and partitionToken must be random/opaque registry values. + * Raw account IDs, email addresses and business identifiers are forbidden. + * Only these tokens, never namespace or owner, may be used in physical paths. + */ +export type OpfsStorageScope = Readonly<{ + namespace: string; + authorityToken: string; + namespaceToken: string; + partitionToken: string; +}>; + +export type DurableObjectDescriptor = Readonly<{ + objectId: string; + scope: OpfsStorageScope; + generation: number; + byteLength: number; + mediaType: string; + createdAtEpochMs: number; + integrity: OpfsIntegrity; + storagePolicy: BrowserStoragePolicy; +}>; + +export type PutDurableObjectRequest = Readonly<{ + objectId: string; + expectedGeneration: number | null; + mediaType: string; + source: ByteSource; + signal?: AbortSignal; + onProgress?: (progress: TransferProgress) => void; +}>; + +export type OpenDurableObjectRequest = Readonly<{ + objectId: string; + generation?: number; + signal?: AbortSignal; +}>; + +export type RemoveDurableObjectRequest = Readonly<{ + objectId: string; + expectedGeneration: number; + signal?: AbortSignal; +}>; + +export type OpenedDurableObject = Readonly<{ + descriptor: DurableObjectDescriptor; + source: ByteSource; +}>; + +export type OpfsCapabilities = Readonly<{ + available: boolean; + dedicatedWorkerRequired: true; + crossContextMutationLockAvailable: boolean; + synchronousAccessHandleAvailable: boolean; +}>; + +export interface DurableObjectStorePort { + capabilities(): Promise>; + put( + request: PutDurableObjectRequest, + ): Promise>; + open( + request: OpenDurableObjectRequest, + ): Promise>; + remove( + request: RemoveDurableObjectRequest, + ): Promise>; +} + +export type OpfsReconciliationReport = Readonly<{ + inspectedTransactions: number; + committedTransactions: number; + rolledBackTransactions: number; + cleanedTransactions: number; + inspectedOrphanChunks: number; + deletedOrphanChunks: number; + orphanGcStatus: + | "COMPLETED" + | "DEADLINE_REACHED" + | "STAGING_STATE_UNREADABLE"; + moreTransactionsAvailable: boolean; + deadlineReached: boolean; +}>; + +export type OpfsPolicyMaintenanceReport = Readonly<{ + inspectedObjects: number; + removedObjects: number; + releasedBytes: number; + moreObjectsAvailable: boolean; + deadlineReached: boolean; +}>; + +export type OpfsSensitiveMaintenanceReason = + | "LOGOUT" + | "UNTIL_SYNCED" + | "ACCOUNT_DELETION"; + +export interface DurableObjectMaintenancePort { + reconcile( + request?: Readonly<{ + budgetMs?: number; + maxTransactions?: number; + signal?: AbortSignal; + }>, + ): Promise>; + enforcePolicies( + request: + | Readonly<{ + reason: "TTL"; + budgetMs?: number; + maxObjects?: number; + signal?: AbortSignal; + }> + | Readonly<{ + reason: "LOGOUT"; + budgetMs?: number; + maxObjects?: number; + signal?: AbortSignal; + }> + | Readonly<{ + reason: "SESSION_END"; + budgetMs?: number; + maxObjects?: number; + signal?: AbortSignal; + }> + | Readonly<{ + reason: "UNTIL_SYNCED"; + budgetMs?: number; + maxObjects?: number; + signal?: AbortSignal; + }> + | Readonly<{ + reason: "ACCOUNT_DELETION"; + budgetMs?: number; + maxObjects?: number; + signal?: AbortSignal; + }> + | Readonly<{ + reason: "PRESSURE"; + targetBytesToRelease: number; + budgetMs?: number; + maxObjects?: number; + signal?: AbortSignal; + }>, + ): Promise>; +} + +/** + * Physical details are kept in the journal contract, not in the object-store + * API. The IndexedDB implementation must update the journal row and logical + * object row in the same readwrite transaction and re-check the fencing token. + */ +export type OpfsChunkReference = Readonly<{ + sequence: number; + byteLength: number; + digestHex: string; +}>; + +export type OpfsPreparedObject = Readonly<{ + descriptor: DurableObjectDescriptor; + chunks: readonly OpfsChunkReference[]; + physicalSchemaVersion: 1; +}>; + +export type OpfsJournalMutation = "PUT" | "DELETE"; +export type OpfsJournalPhase = + | "PREPARING" + | "FILES_READY" + | "COMMITTED"; + +export type OpfsJournalTransaction = Readonly<{ + transactionId: string; + fencingToken: string; + mutation: OpfsJournalMutation; + scope: OpfsStorageScope; + phase: OpfsJournalPhase; + objectId: string; + expectedGeneration: number | null; + targetGeneration: number; + targetByteLength: number; + targetStoragePolicy: BrowserStoragePolicy; + budgetReservation: Readonly<{ + namespace: string; + reservedBytes: number; + hardBudgetBytes: number; + }>; + startedAtEpochMs: number; + preparedObject?: OpfsPreparedObject; +}>; + +export type BeginOpfsJournalTransaction = Readonly<{ + transactionId: string; + mutation: OpfsJournalMutation; + scope: OpfsStorageScope; + objectId: string; + expectedGeneration: number | null; + targetGeneration: number; + targetByteLength: number; + targetStoragePolicy: BrowserStoragePolicy; + startedAtEpochMs: number; +}>; + +export type OpfsJournalPage = Readonly<{ + transactions: readonly OpfsJournalTransaction[]; + moreAvailable: boolean; +}>; + +export type OpfsCommittedObjectPage = Readonly<{ + objects: readonly OpfsPreparedObject[]; + nextObjectId: string | null; + moreAvailable: boolean; +}>; + +export interface OpfsJournalPort { + getCommittedObject( + scope: OpfsStorageScope, + objectId: string, + ): Promise>; + begin( + transaction: BeginOpfsJournalTransaction, + ): Promise>; + markFilesReady( + transactionId: string, + fencingToken: string, + preparedObject: OpfsPreparedObject, + ): Promise>; + /** + * Atomically publishes preparedObject and advances the journal to COMMITTED. + */ + commitPut( + transactionId: string, + fencingToken: string, + ): Promise>; + /** + * Atomically removes the logical object and advances the journal to COMMITTED. + */ + commitDelete( + transactionId: string, + fencingToken: string, + ): Promise>; + complete( + transactionId: string, + fencingToken: string, + ): Promise>; + rollback( + transactionId: string, + fencingToken: string, + ): Promise>; + listIncomplete( + limit: number, + ): Promise>; + listCommittedObjects( + request: Readonly<{ + scope: OpfsStorageScope; + afterObjectId?: string; + limit: number; + }>, + ): Promise>; + isChunkReferenced( + scope: OpfsStorageScope, + digestHex: string, + ): Promise>; +} diff --git a/src/application/ports/browser-file-storage/shared.ts b/src/application/ports/browser-file-storage/shared.ts new file mode 100644 index 0000000..0e13c91 --- /dev/null +++ b/src/application/ports/browser-file-storage/shared.ts @@ -0,0 +1,238 @@ +import type { Result } from "../../result.ts"; + +export type BrowserDataFailureCode = + | "ABORTED" + | "BLOCKED" + | "CONFLICT" + | "CORRUPT_DATA" + | "EXPIRED_RESOURCE" + | "INTEGRITY_FAILED" + | "INVALID_INPUT" + | "LIMIT_EXCEEDED" + | "MIGRATION_FAILED" + | "NOT_FOUND" + | "NOT_READABLE" + | "PERMISSION_DENIED" + | "POLICY_REJECTED" + | "QUOTA_EXCEEDED" + | "STALE_RESULT" + | "STORAGE_EVICTED" + | "UNAVAILABLE" + | "UNSUPPORTED"; + +export type BrowserDataOperation = + | "CACHE_ACTIVATE" + | "CACHE_DELETE" + | "CACHE_LOOKUP" + | "CACHE_STAGE" + | "DOWNLOAD" + | "FILE_INSPECT" + | "FILE_READ" + | "FILE_SELECT" + | "IMAGE_RESOLVE" + | "INDEXEDDB_MIGRATE" + | "INDEXEDDB_OPEN" + | "INDEXEDDB_READ" + | "INDEXEDDB_WRITE" + | "OBJECT_DELETE" + | "OBJECT_READ" + | "OBJECT_RECONCILE" + | "OBJECT_WRITE" + | "PRESIGNED_TRANSFER" + | "PREVIEW" + | "STORAGE_ESTIMATE" + | "STORAGE_PERSIST" + | "UPLOAD_ABORT" + | "UPLOAD_COMPLETE" + | "UPLOAD_PART" + | "UPLOAD_RECONCILE" + | "UPLOAD_SESSION"; + +export type BrowserDataRecovery = + | "NONE" + | "RETRY" + | "REOPEN" + | "RESELECT" + | "RELOAD_OTHER_CONTEXTS" + | "READ_ONLY" + | "ONLINE_ONLY" + | "REHYDRATE" + | "EXPORT_REQUIRED" + | "REISSUE_CAPABILITY" + | "RESUME" + | "RESTART" + | "RECONCILE"; + +/** + * Closed, telemetry-safe failure. Native exception messages, paths, record + * keys, URLs, file names and data values must not cross the adapter boundary. + */ +export type BrowserDataFailure = Readonly<{ + code: BrowserDataFailureCode; + operation: BrowserDataOperation; + retryable: boolean; + recovery: BrowserDataRecovery; +}>; + +export type BrowserDataResult = Result; + +export type BrowserDataObservation = Readonly<{ + operation: BrowserDataOperation; + outcome: "SUCCEEDED" | "FAILED" | "DEGRADED"; + failureCode?: BrowserDataFailureCode; + durationBucket?: "LT100MS" | "100_TO_499MS" | "500_TO_1999MS" | "GTE2000MS"; + byteBucket?: "ZERO" | "LT1MIB" | "1_TO_9MIB" | "10_TO_99MIB" | "GTE100MIB"; + countBucket?: "ZERO" | "ONE" | "TWO_TO_TEN" | "ELEVEN_TO_HUNDRED" | "GT_HUNDRED"; +}>; + +export interface BrowserDataObserver { + record(observation: BrowserDataObservation): void; +} + +export type TransferProgress = Readonly<{ + phase: + | "VALIDATING" + | "PREPARING" + | "TRANSFERRING" + | "VERIFYING" + | "FINALIZING"; + transferredBytes: number; + totalBytes: number | null; +}>; + +/** + * Technology-neutral byte stream. Implementations must validate every emitted + * chunk, honor AbortSignal between chunks and close native/runtime failures + * into BrowserDataResult. A consumer must stop after the first failed chunk. + */ +export interface ByteSource { + readonly byteLength: number | null; + stream( + signal: AbortSignal, + ): AsyncIterable>; +} + +export type PersistableDataClass = + | "PUBLIC" + | "INTERNAL" + | "PERSONAL" + | "CONFIDENTIAL"; + +export type BrowserDataAuthority = + | "SERVER" + | "LOCAL_FIRST" + | "RECONSTRUCTABLE"; + +export type BrowserAccountScope = + | "ORIGIN_SHARED" + | "OPAQUE_PARTITION"; + +export type BrowserLogoutAction = + | "KEEP_ORIGIN_SHARED" + | "PURGE_PARTITION" + | "EXPORT_THEN_PURGE"; + +export type BrowserAccountDeletionAction = + | "KEEP_ORIGIN_SHARED" + | "PURGE_PARTITION"; + +export type BrowserPressureAction = + | "EVICT_RECONSTRUCTABLE" + | "RETAIN"; + +export type BrowserStoragePolicy = Readonly<{ + /** Governance owner/team identifier, never an account or user ID. */ + owner: string; + namespace: string; + classification: PersistableDataClass; + authority: BrowserDataAuthority; + accountScope: BrowserAccountScope; + retention: + | Readonly<{ kind: "SESSION" }> + | Readonly<{ kind: "TTL"; maxAgeMs: number }> + | Readonly<{ kind: "UNTIL_SYNCED" }> + | Readonly<{ kind: "EXPLICIT_DELETE" }>; + softBudgetBytes: number; + hardBudgetBytes: number; + evictionPriority: "RECONSTRUCTABLE" | "SYNCED_COPY" | "USER_AUTHORED"; + logoutAction: BrowserLogoutAction; + accountDeletionAction: BrowserAccountDeletionAction; + pressureAction: BrowserPressureAction; + unavailableFallback: "ONLINE_ONLY" | "READ_ONLY" | "EXPORT_REQUIRED"; +}>; + +export function isValidByteLength(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +export function assertValidStoragePolicy( + policy: BrowserStoragePolicy, +): void { + const safeRegistryIdentifier = + /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; + if ( + !policy || + typeof policy !== "object" || + typeof policy.owner !== "string" || + !safeRegistryIdentifier.test(policy.owner) || + typeof policy.namespace !== "string" || + !safeRegistryIdentifier.test(policy.namespace) || + !["PUBLIC", "INTERNAL", "PERSONAL", "CONFIDENTIAL"].includes( + policy.classification, + ) || + !["SERVER", "LOCAL_FIRST", "RECONSTRUCTABLE"].includes( + policy.authority, + ) || + !["ORIGIN_SHARED", "OPAQUE_PARTITION"].includes( + policy.accountScope, + ) || + !policy.retention || + typeof policy.retention !== "object" || + !["SESSION", "TTL", "UNTIL_SYNCED", "EXPLICIT_DELETE"].includes( + policy.retention.kind, + ) || + !isValidByteLength(policy.softBudgetBytes) || + !isValidByteLength(policy.hardBudgetBytes) || + policy.hardBudgetBytes === 0 || + policy.softBudgetBytes > policy.hardBudgetBytes || + !["RECONSTRUCTABLE", "SYNCED_COPY", "USER_AUTHORED"].includes( + policy.evictionPriority, + ) || + ![ + "KEEP_ORIGIN_SHARED", + "PURGE_PARTITION", + "EXPORT_THEN_PURGE", + ].includes(policy.logoutAction) || + !["KEEP_ORIGIN_SHARED", "PURGE_PARTITION"].includes( + policy.accountDeletionAction, + ) || + !["EVICT_RECONSTRUCTABLE", "RETAIN"].includes( + policy.pressureAction, + ) || + !["ONLINE_ONLY", "READ_ONLY", "EXPORT_REQUIRED"].includes( + policy.unavailableFallback, + ) || + (policy.retention.kind === "TTL" && + (!Number.isSafeInteger(policy.retention.maxAgeMs) || + policy.retention.maxAgeMs < 1)) || + (policy.accountScope === "ORIGIN_SHARED" && + (policy.logoutAction !== "KEEP_ORIGIN_SHARED" || + policy.accountDeletionAction !== "KEEP_ORIGIN_SHARED")) || + (policy.accountScope === "OPAQUE_PARTITION" && + (policy.logoutAction === "KEEP_ORIGIN_SHARED" || + policy.accountDeletionAction !== "PURGE_PARTITION")) || + (["PERSONAL", "CONFIDENTIAL"].includes(policy.classification) && + policy.accountScope !== "OPAQUE_PARTITION") || + (policy.accountScope === "OPAQUE_PARTITION" && + (policy.retention.kind === "UNTIL_SYNCED" || + (policy.authority === "LOCAL_FIRST" && + policy.unavailableFallback === "EXPORT_REQUIRED")) && + policy.logoutAction !== "EXPORT_THEN_PURGE") || + (policy.pressureAction === "EVICT_RECONSTRUCTABLE" && + (policy.authority !== "RECONSTRUCTABLE" || + policy.evictionPriority !== "RECONSTRUCTABLE" || + policy.retention.kind === "EXPLICIT_DELETE")) + ) { + throw new TypeError("Browser storage policy is invalid."); + } +} diff --git a/src/application/ports/browser-file-storage/storage-durability-port.ts b/src/application/ports/browser-file-storage/storage-durability-port.ts new file mode 100644 index 0000000..4eca6ed --- /dev/null +++ b/src/application/ports/browser-file-storage/storage-durability-port.ts @@ -0,0 +1,22 @@ +import type { BrowserDataResult } from "./shared.ts"; + +export type StorageEstimate = Readonly<{ + usageBytes: number | null; + quotaBytes: number | null; + /** + * null means the engine did not expose a persistence-state query. It must + * not be collapsed into a false "not persisted" claim. + */ + persisted: boolean | null; + pressure: "UNKNOWN" | "NORMAL" | "PRESSURE" | "CRITICAL"; +}>; + +export interface StorageDurabilityPort { + inspect(signal?: AbortSignal): Promise>; + + requestPersistence(input: { + reason: "PROTECT_UNSYNCED_USER_DATA"; + userInitiated: true; + signal?: AbortSignal; + }): Promise>; +} diff --git a/src/application/ports/browser-rpc/browser-rpc.ts b/src/application/ports/browser-rpc/browser-rpc.ts new file mode 100644 index 0000000..7e09428 --- /dev/null +++ b/src/application/ports/browser-rpc/browser-rpc.ts @@ -0,0 +1,36 @@ +import type { Result } from "../../result.ts"; +import type { AppFailure } from "../../../contracts/errors.ts"; + +export type BrowserRpcCallContext = Readonly<{ + signal?: AbortSignal; + idempotencyKey?: string; +}>; + +/** + * A feature gateway binds a semantic operation during composition and exposes + * only this typed port to its use case. Generated services, messages, endpoint + * IDs and transport metadata remain adapter-private. + */ +export type BrowserRpcUnaryPort = Readonly<{ + execute( + input: Input, + context?: BrowserRpcCallContext, + ): Promise>; +}>; + +/** + * Server streams remain operation-bound outbound results. They are not a + * runtime-wide event bus and do not expose protocol frames or generated + * messages to application callers. + */ +export type BrowserRpcServerStreamPort = Readonly<{ + open( + input: Input, + context?: BrowserRpcCallContext, + ): AsyncIterable>; +}>; + +export type BrowserRpcGenerationFence = Readonly<{ + capture(): Token; + isCurrent(token: Token): boolean; +}>; diff --git a/src/application/ports/browser-rpc/index.ts b/src/application/ports/browser-rpc/index.ts new file mode 100644 index 0000000..e0d16e2 --- /dev/null +++ b/src/application/ports/browser-rpc/index.ts @@ -0,0 +1,6 @@ +export type { + BrowserRpcCallContext, + BrowserRpcGenerationFence, + BrowserRpcServerStreamPort, + BrowserRpcUnaryPort, +} from "./browser-rpc.ts"; diff --git a/src/application/ports/browser-transfer/authorized-download.ts b/src/application/ports/browser-transfer/authorized-download.ts new file mode 100644 index 0000000..64853f5 --- /dev/null +++ b/src/application/ports/browser-transfer/authorized-download.ts @@ -0,0 +1,32 @@ +declare const authorizedDownloadCapabilityBrand: unique symbol; +declare const authorizedDownloadCapabilityReceiptBrand: unique symbol; + +/** + * Telemetry-safe server-issued identifier. It is not a URL, credential, + * object-store key or authorization token. + */ +export type AuthorizedDownloadCapabilityReceipt = string & { + readonly [authorizedDownloadCapabilityReceiptBrand]: + "AuthorizedDownloadCapabilityReceipt"; +}; + +/** + * Opaque GET-only download handle shared by the file-delivery and transfer + * ports. The adapter owns the corresponding URL/query/header binding in an + * identity vault, so structurally equal caller-created objects are rejected. + */ +export type AuthorizedDownloadCapability = Readonly<{ + capabilityReceipt: AuthorizedDownloadCapabilityReceipt; + method: "GET"; + binding: Readonly<{ + kind: "DOWNLOAD"; + resourceId: string; + }>; + mediaType: string; + byteLength: number; + maxBytes: number; + expectedSha256: string; + expiresAtEpochMs: number; + readonly [authorizedDownloadCapabilityBrand]: + "AuthorizedDownloadCapability"; +}>; diff --git a/src/application/ports/browser-transfer/image-cdn.ts b/src/application/ports/browser-transfer/image-cdn.ts new file mode 100644 index 0000000..b039b9b --- /dev/null +++ b/src/application/ports/browser-transfer/image-cdn.ts @@ -0,0 +1,183 @@ +import type { BrowserDataResult } from "../browser-file-storage/shared.ts"; + +declare const imageAssetReferenceBrand: unique symbol; +declare const imagePresetReferenceBrand: unique symbol; + +export type ImageRasterMediaType = + | "image/avif" + | "image/jpeg" + | "image/png" + | "image/webp"; + +export type ImageOutputFormat = "avif" | "jpeg" | "png" | "webp"; +export type ImageFit = "contain" | "cover" | "fill" | "inside" | "outside"; + +/** + * Identity capability backed by an adapter-owned WeakMap. A structurally equal + * object or a reference issued by another runtime must be rejected. + */ +export type ImageAssetReference = Readonly<{ + readonly [imageAssetReferenceBrand]: "ImageAssetReference"; +}>; + +/** + * Composition-issued named preset reference. Presentation cannot submit + * width, height, DPR, quality, format, URL or query overrides. + */ +export type ImagePresetReference = Readonly<{ + presetKey: string; + intention: string; + readonly [imagePresetReferenceBrand]: "ImagePresetReference"; +}>; + +export type PublicImmutableImageAsset = Readonly<{ + kind: "ALLOWLISTED_PUBLIC"; + originKey: string; + assetId: string; + revision: string; + mediaType: ImageRasterMediaType; + contentKind: "RASTER_STATIC"; + intrinsicWidth: number; + intrinsicHeight: number; +}>; + +/** + * Server-issued descriptor for private signed delivery. It contains no URL or + * request headers. The signature covers every immutable field and the exact + * set of registry-owned preset binding IDs. + */ +export type BackendIssuedImageAsset = Readonly<{ + kind: "BACKEND_ISSUED_PRIVATE"; + issuer: string; + originKey: string; + assetId: string; + revision: string; + mediaType: ImageRasterMediaType; + contentKind: "RASTER_STATIC"; + intrinsicWidth: number; + intrinsicHeight: number; + capabilityId: string; + issuedAtEpochMs: number; + expiresAtEpochMs: number; + allowedPresetBindingIds: readonly string[]; + signature: Readonly<{ + algorithm: "ECDSA_P256_SHA256"; + keyId: string; + capabilityBindingDigestHex: string; + valueBase64Url: string; + }>; +}>; + +export type ImageCapabilityVerificationRequest = Readonly<{ + algorithm: "ECDSA_P256_SHA256"; + keyId: string; + canonicalPayload: Uint8Array; + signatureBase64Url: string; +}>; + +export interface ImageCapabilityVerifier { + /** Exact membership check against the verifier's immutable key registry. */ + acceptsKey(keyId: string): boolean; + verify( + request: ImageCapabilityVerificationRequest, + ): Promise; +} + +export interface ImageAssetAcceptancePort { + acceptPublicImmutable( + descriptor: PublicImmutableImageAsset, + ): BrowserDataResult; + acceptBackendIssued( + descriptor: BackendIssuedImageAsset, + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; +} + +export type ImageDeliveryClass = + | "PUBLIC_IMMUTABLE" + | "PRIVATE_SIGNED"; + +export type ImageProbeRequest = Readonly<{ + absoluteUrl: string; + expectedMediaType: ImageRasterMediaType; + expectedWidth: number; + expectedHeight: number; + maxEncodedBytes: number; + maxDecodedPixels: number; + maxDecodedBytes: number; + delivery: ImageDeliveryClass; + minimumPublicMaxAgeSeconds: number; + referrerPolicy: "no-referrer" | "strict-origin-when-cross-origin"; + signal: AbortSignal; +}>; + +export type ImageProbeReceipt = Readonly<{ + absoluteUrl: string; + mediaType: ImageRasterMediaType; + encodedBytes: number; + decodedWidth: number; + decodedHeight: number; +}>; + +/** + * Optional browser-native seam. Implementations must bound the encoded body + * before buffering and close the decoded ImageBitmap after inspecting it. + */ +export interface ImageResourceProbePort { + probe( + request: ImageProbeRequest, + ): Promise>; +} + +export type ImagePresentationSource = Readonly<{ + type: ImageRasterMediaType; + srcSet: string; +}>; + +export type ImagePresentationDescriptor = Readonly<{ + src: string; + srcSet: string; + sources: readonly ImagePresentationSource[]; + sizes: string; + width: number; + height: number; + fallbackMediaType: ImageRasterMediaType; + loading: "eager" | "lazy"; + decoding: "async" | "sync"; + fetchPriority: "high" | "low" | "auto"; + referrerPolicy: "no-referrer" | "strict-origin-when-cross-origin"; + crossOrigin: "anonymous"; + delivery: Readonly<{ + class: ImageDeliveryClass; + assetVersion: string; + browserCache: "PUBLIC_IMMUTABLE" | "NO_STORE"; + sharedCache: "PUBLIC_IMMUTABLE" | "FORBIDDEN"; + purge: + | "REVISION_ROLLOVER" + | "CAPABILITY_REVOCATION_OR_EXPIRY"; + expiresAtEpochMs: number | null; + }>; + decodeBudget: Readonly<{ + maximumCandidatePixels: number; + maximumDecodedBytes: number; + maximumEncodedBytes: number; + }>; +}>; + +export interface ImageCdnPresentationPort { + resolve(request: Readonly<{ + asset: ImageAssetReference; + preset: ImagePresetReference; + signal?: AbortSignal; + }>): Promise>; +} + +export type ImageCdnRuntime = Readonly<{ + assets: ImageAssetAcceptancePort; + presentation: ImageCdnPresentationPort; + /** + * Terminal and idempotent. Aborts in-flight verification/probing, revokes + * every issued reference and makes later accept/resolve calls unavailable. + */ + close(): void; +}>; diff --git a/src/application/ports/browser-transfer/index.ts b/src/application/ports/browser-transfer/index.ts new file mode 100644 index 0000000..56839f3 --- /dev/null +++ b/src/application/ports/browser-transfer/index.ts @@ -0,0 +1,68 @@ +export type { + AuthorizedDownloadCapability, + AuthorizedDownloadCapabilityReceipt, +} from "./authorized-download.ts"; + +export type { + BackendIssuedImageAsset, + ImageAssetAcceptancePort, + ImageAssetReference, + ImageCapabilityVerificationRequest, + ImageCapabilityVerifier, + ImageCdnPresentationPort, + ImageCdnRuntime, + ImageDeliveryClass, + ImageFit, + ImageOutputFormat, + ImagePresentationDescriptor, + ImagePresentationSource, + ImagePresetReference, + ImageProbeReceipt, + ImageProbeRequest, + ImageRasterMediaType, + ImageResourceProbePort, + PublicImmutableImageAsset, +} from "./image-cdn.ts"; + +export type { + PresignedDownloadByteSource, + PresignedDownloadCapability, + PresignedDownloadSourcePort, + PresignedTransferBinding, + PresignedTransferCapability, + PresignedTransferCapabilityProvider, + PresignedTransferCapabilityReceipt, + PresignedTransferMethod, + PresignedTransferReplayGuard, + PresignedUploadPartCapability, + PresignedUploadPartCapabilityProvider, + PresignedUploadPartOutcome, + PresignedUploadPartPort, +} from "./presigned-transfer.ts"; + +export type { + ActiveUploadStatus, + QuarantinedUpload, + ResumableUploadCheckpoint, + ResumableUploadCheckpointAdmin, + ResumableUploadCheckpointStore, + ResumableUploadControlPlane, + ResumableUploadPort, + ResumableUploadRequest, + ResumableUploadSource, + UploadAbortOutcome, + UploadFileFingerprint, + UploadPartCapability, + UploadPartDescriptor, + UploadPartExecutor, + UploadPartReceipt, + UploadProviderFailure, + UploadProviderResult, + UploadRangeReader, + UploadSession, + UploadSessionStatus, +} from "./resumable-upload.ts"; +export { + RESUMABLE_UPLOAD_PROTOCOL, + type ResumableUploadProtocol, +} from "./resumable-upload.ts"; diff --git a/src/application/ports/browser-transfer/presigned-transfer.ts b/src/application/ports/browser-transfer/presigned-transfer.ts new file mode 100644 index 0000000..5e90cd9 --- /dev/null +++ b/src/application/ports/browser-transfer/presigned-transfer.ts @@ -0,0 +1,151 @@ +import type { FileByteSource } from "../browser-file-storage/file.ts"; +import type { BrowserDataResult } from "../browser-file-storage/shared.ts"; +import type { + AuthorizedDownloadCapability, + AuthorizedDownloadCapabilityReceipt, +} from "./authorized-download.ts"; +import type { ResumableUploadProtocol } from "./resumable-upload.ts"; + +declare const presignedTransferCapabilityBrand: unique symbol; + +/** + * Server-issued, telemetry-safe identifier. It is not a URL, credential, + * object-store key or authorization token. + */ +export type PresignedTransferCapabilityReceipt = + AuthorizedDownloadCapabilityReceipt; + +export type PresignedTransferMethod = "GET" | "PUT"; + +export type PresignedTransferBinding = + | Readonly<{ + kind: "DOWNLOAD"; + resourceId: string; + }> + | Readonly<{ + kind: "UPLOAD_PART"; + protocol: ResumableUploadProtocol; + sessionId: string; + requestBindingSha256: string; + /** + * SHA-256 over the canonical session, request and whole-file fingerprint + * binding. The raw session fields remain owned by the upload control + * plane; they must never be smuggled into resourceId. + */ + uploadBindingSha256: string; + partNumber: number; + offset: number; + idempotencyKey: string; + }>; + +/** + * Opaque capability handle crossing the application boundary. The adapter owns + * the corresponding URL, query values and request headers in an in-memory + * identity vault. Implementations must reject structurally equal or fabricated + * handles, even if every visible field matches. + */ +export type PresignedDownloadCapability = AuthorizedDownloadCapability; + +export type PresignedUploadPartCapability = Readonly<{ + capabilityReceipt: PresignedTransferCapabilityReceipt; + method: "PUT"; + binding: Extract< + PresignedTransferBinding, + Readonly<{ kind: "UPLOAD_PART" }> + >; + mediaType: string; + byteLength: number; + maxBytes: number; + expectedSha256: string; + expiresAtEpochMs: number; + readonly [presignedTransferCapabilityBrand]: + "PresignedTransferCapability"; +}>; + +export type PresignedTransferCapability = + | PresignedDownloadCapability + | PresignedUploadPartCapability; + +export interface PresignedTransferCapabilityProvider { + /** + * Calls a composition-owned backend/BFF capability endpoint. Callers choose + * only an opaque resource ID; they cannot supply a transfer URL or headers. + */ + issueDownload(input: Readonly<{ + resourceId: string; + signal: AbortSignal; + }>): Promise>; +} + +export interface PresignedUploadPartCapabilityProvider { + issueUploadPart(input: Readonly<{ + sessionId: string; + requestBindingSha256: string; + uploadBindingSha256: string; + partNumber: number; + offset: number; + byteLength: number; + checksumSha256: string; + mediaType: string; + idempotencyKey: string; + signal: AbortSignal; + }>): Promise>; +} + +/** + * Atomic local replay seam. The server/object-store capability must also + * enforce single use or equivalent idempotency because a browser guard is not + * an authorization boundary. + */ +export interface PresignedTransferReplayGuard { + claim( + capability: PresignedTransferCapability, + ): BrowserDataResult; +} + +/** + * Successful exhaustion proves length and SHA-256 before the terminal success + * of the closed-Result stream. Consumers must not commit a destination until + * the iterable finishes without a failure result. + */ +export type PresignedDownloadByteSource = FileByteSource & + Readonly<{ + byteLength: number; + capability: PresignedDownloadCapability; + integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION"; + }>; + +export interface PresignedDownloadSourcePort { + open(input: Readonly<{ + resourceId: string; + capability: PresignedDownloadCapability; + signal: AbortSignal; + }>): Promise>; +} + +export type PresignedUploadPartOutcome = Readonly<{ + bytesWritten: number; + checksumSha256: string; + /** + * Non-authorizing object-store acknowledgement (for example a normalized + * ETag). It is safe to persist only as part of the exact completed-part + * binding and must never be reused as a transfer capability. + */ + receiptToken: string; +}>; + +export interface PresignedUploadPartPort { + put(input: Readonly<{ + capability: PresignedUploadPartCapability; + sessionId: string; + requestBindingSha256: string; + uploadBindingSha256: string; + partNumber: number; + offset: number; + byteLength: number; + checksumSha256: string; + idempotencyKey: string; + bytes: Uint8Array; + signal: AbortSignal; + }>): Promise>; +} diff --git a/src/application/ports/browser-transfer/resumable-upload.ts b/src/application/ports/browser-transfer/resumable-upload.ts new file mode 100644 index 0000000..6afa0b8 --- /dev/null +++ b/src/application/ports/browser-transfer/resumable-upload.ts @@ -0,0 +1,279 @@ +import type { Result } from "../../result.ts"; +import type { FileByteSource } from "../browser-file-storage/file.ts"; +import type { + BrowserDataFailure, + BrowserDataResult, + TransferProgress, +} from "../browser-file-storage/shared.ts"; + +export const RESUMABLE_UPLOAD_PROTOCOL = + "PRESIGNED_MULTIPART_V1" as const; +export type ResumableUploadProtocol = + typeof RESUMABLE_UPLOAD_PROTOCOL; + +/** + * Multipart uploads use a bounded part manifest instead of a whole-file + * ArrayBuffer. The digest is SHA-256 over the canonical ordered part metadata + * and SHA-256 part digests. + */ +export type UploadFileFingerprint = Readonly<{ + algorithm: "SHA-256-PARTS-V1"; + digestHex: string; + byteLength: number; + partSizeBytes: number; + partCount: number; +}>; + +export type UploadPartDescriptor = Readonly<{ + partNumber: number; + offset: number; + byteLength: number; + checksumSha256: string; +}>; + +/** + * A non-authorizing server acknowledgement. It must be opaque, contain no PII, + * URL or credential, and be accepted only with the exact descriptor binding. + */ +export type UploadPartReceipt = UploadPartDescriptor & + Readonly<{ + receiptToken: string; + }>; + +export interface UploadRangeReader { + readonly byteLength: number; + readRange(input: Readonly<{ + offset: number; + length: number; + signal: AbortSignal; + }>): Promise>; +} + +/** + * FILE_BYTE_SOURCE supports existing FileByteSource implementations. It must + * be replayable for the fingerprint pass and transfer pass. RANGE_READER is + * preferred for concurrent uploads and OPFS/file-vault range adapters. + */ +export type ResumableUploadSource = + | Readonly<{ + kind: "FILE_BYTE_SOURCE"; + bytes: FileByteSource; + }> + | Readonly<{ + kind: "RANGE_READER"; + reader: UploadRangeReader; + }>; + +export type ResumableUploadRequest = Readonly<{ + /** Opaque, caller-stable operation key. It must not contain a file name. */ + uploadKey: string; + /** Registry-approved backend purpose identifier, not user-provided text. */ + purpose: string; + mediaType: string; + source: ResumableUploadSource; + signal: AbortSignal; + onProgress?: (progress: TransferProgress) => void; +}>; + +export type QuarantinedUpload = Readonly<{ + state: "QUARANTINED"; + resourceId: string; + byteLength: number; + replayed: boolean; +}>; + +export type UploadAbortOutcome = Readonly<{ + state: "ABORTED" | "ORPHANED" | "ALREADY_COMPLETED" | "NOT_FOUND"; +}>; + +export interface ResumableUploadPort { + upload( + request: ResumableUploadRequest, + ): Promise>; + abort(request: Readonly<{ + uploadKey: string; + signal: AbortSignal; + }>): Promise>; +} + +/** + * Retry-After is transport metadata used only by the runtime. It is bounded + * before sleeping and is removed from the application-facing failure. + */ +export type UploadProviderFailure = BrowserDataFailure & + Readonly<{ + retryAfterMs?: number; + }>; + +export type UploadProviderResult = Result< + Value, + UploadProviderFailure +>; + +export type UploadSession = Readonly<{ + protocol: ResumableUploadProtocol; + sessionId: string; + requestBindingSha256: string; + fingerprint: UploadFileFingerprint; + partSizeBytes: number; + partCount: number; + maxConcurrency: number; + expiresAtEpochMs: number; +}>; + +export type ActiveUploadStatus = Readonly<{ + state: "ACTIVE"; + session: UploadSession; + acceptedParts: readonly UploadPartReceipt[]; +}>; + +export type UploadSessionStatus = + | ActiveUploadStatus + | Readonly<{ + state: "QUARANTINED"; + session: UploadSession; + resourceId: string; + }> + | Readonly<{ + state: "ABORTED" | "EXPIRED" | "NOT_FOUND"; + protocol: ResumableUploadProtocol; + sessionId: string; + requestBindingSha256: string; + }>; + +/** + * The capability value is intentionally generic. The presigned-transfer + * adapter owns its URL/method/header contract; this port neither duplicates + * that type nor permits it to enter a durable checkpoint. + */ +export type UploadPartCapability = Readonly<{ + capability: Capability; + uploadBindingSha256: string; + expiresAtEpochMs: number; +}>; + +export interface ResumableUploadControlPlane { + createSession(input: Readonly<{ + protocol: ResumableUploadProtocol; + uploadKey: string; + purpose: string; + mediaType: string; + requestBindingSha256: string; + fingerprint: UploadFileFingerprint; + requestedPartSizeBytes: number; + requestedMaxConcurrency: number; + idempotencyKey: string; + signal: AbortSignal; + }>): Promise>; + + getStatus(input: Readonly<{ + protocol: ResumableUploadProtocol; + sessionId: string; + requestBindingSha256: string; + fingerprint: UploadFileFingerprint; + signal: AbortSignal; + }>): Promise>; + + issuePartCapability(input: Readonly<{ + protocol: ResumableUploadProtocol; + sessionId: string; + requestBindingSha256: string; + uploadBindingSha256: string; + fingerprint: UploadFileFingerprint; + mediaType: string; + part: UploadPartDescriptor; + idempotencyKey: string; + signal: AbortSignal; + }>): Promise>>; + + complete(input: Readonly<{ + protocol: ResumableUploadProtocol; + sessionId: string; + requestBindingSha256: string; + fingerprint: UploadFileFingerprint; + orderedParts: readonly UploadPartReceipt[]; + idempotencyKey: string; + signal: AbortSignal; + }>): Promise>>; + + abort(input: Readonly<{ + protocol: ResumableUploadProtocol; + sessionId: string; + requestBindingSha256: string; + idempotencyKey: string; + signal: AbortSignal; + }>): Promise>>; +} + +export interface UploadPartExecutor { + uploadPart(input: Readonly<{ + protocol: ResumableUploadProtocol; + capability: Capability; + sessionId: string; + requestBindingSha256: string; + uploadBindingSha256: string; + fingerprint: UploadFileFingerprint; + mediaType: string; + part: UploadPartDescriptor; + bytes: Uint8Array; + idempotencyKey: string; + signal: AbortSignal; + }>): Promise>; +} + +/** + * Durable, non-secret recovery state. Implementations must reject any unknown + * property so a signed URL, authorization header or user metadata cannot be + * smuggled into persistence. + */ +export type ResumableUploadCheckpoint = Readonly<{ + schemaVersion: 1; + protocol: ResumableUploadProtocol; + revision: number; + state: "ACTIVE" | "ABORT_PENDING"; + uploadKey: string; + requestBindingSha256: string; + fingerprint: UploadFileFingerprint; + sessionId: string; + sessionExpiresAtEpochMs: number; + sessionMaxConcurrency: number; + acceptedParts: readonly UploadPartReceipt[]; + updatedAtEpochMs: number; +}>; + +export interface ResumableUploadCheckpointStore { + read( + uploadKey: string, + signal?: AbortSignal, + ): Promise>; + compareAndSwap(input: Readonly<{ + expectedRevision: number | null; + checkpoint: ResumableUploadCheckpoint; + signal?: AbortSignal; + }>): Promise>; + remove(input: Readonly<{ + uploadKey: string; + expectedRevision: number; + signal?: AbortSignal; + }>): Promise>; + close(): void; +} + +export interface ResumableUploadCheckpointAdmin { + /** + * Account/logout lifecycle operation for this already-bound opaque partition. + * The adapter closes its connection before deletion and bounds blocked waits. + */ + deletePartition( + signal?: AbortSignal, + ): Promise>>; +} diff --git a/src/application/ports/clock-port.ts b/src/application/ports/clock-port.ts new file mode 100644 index 0000000..4971133 --- /dev/null +++ b/src/application/ports/clock-port.ts @@ -0,0 +1,4 @@ +export type ClockPort = Readonly<{ + now(): number; + sleep(milliseconds: number, signal?: AbortSignal): Promise; +}>; diff --git a/src/application/ports/diagnostics-port.ts b/src/application/ports/diagnostics-port.ts new file mode 100644 index 0000000..a2b100d --- /dev/null +++ b/src/application/ports/diagnostics-port.ts @@ -0,0 +1,5 @@ +import type { DiagnosticRecordInput } from "../../contracts/diagnostics.ts"; + +export type DiagnosticsPort = Readonly<{ + record(input: DiagnosticRecordInput): void; +}>; diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts new file mode 100644 index 0000000..602093c --- /dev/null +++ b/src/application/ports/in/application-api.ts @@ -0,0 +1,83 @@ +import type { SessionState } from "../auth-session-port.ts"; +import type { RuntimeCapabilitySnapshot } from "../runtime-capabilities-port.ts"; +import type { StoragePort } from "../storage-port.ts"; + +export type { SessionState } from "../auth-session-port.ts"; +export type { RuntimeCapabilitySnapshot }; + +/** + * Features add their driving API through module augmentation. The application + * owns the registry contract without importing any concrete feature. + */ +export interface ApplicationFeatureInputs {} + +export type ApplicationFeatureId = Extract< + keyof ApplicationFeatureInputs, + string +>; + +export type ColorSchemePreference = "system" | "light" | "dark"; + +export type RenderFailureReport = Readonly<{ + routeId: string; + buildId: string; + boundaryName: "route" | "feature"; +}>; + +export type RouteChangedReport = Readonly<{ + routeId: string; + buildId: string; +}>; + +export type ReleaseSummary = Readonly<{ + buildId: string; + releaseId: string; + configSchemaVersion: string; + /** Legacy V1 scalar; absent once the release manifest is V2. */ + apiContractVersion?: string; + /** §5.2 contract set identity for a V2 release manifest. */ + contractSetDigest?: string; +}>; + +export type ApplicationApi = Readonly<{ + session: Readonly<{ + getSnapshot(): SessionState; + subscribe(listener: () => void): () => void; + beginSignIn(returnTo?: string): Promise; + signOut(): Promise; + recover(): Promise<"restored" | "no-session">; + }>; + preferences: Readonly<{ + getColorScheme(): ColorSchemePreference; + setColorScheme( + preference: ColorSchemePreference, + ): ReturnType; + }>; + diagnostics: Readonly<{ + reportRenderFailure(report: RenderFailureReport): void; + reportRouteChanged(report: RouteChangedReport): void; + }>; + runtime: Readonly<{ + getReleaseSummary(): Promise; + /** + * §3.5. The static selection reduced by the runtime overrides. Presentation + * reads capability state here instead of importing the composition root. + */ + getCapabilitySnapshot(): RuntimeCapabilitySnapshot; + }>; + recovery: Readonly<{ + recoverChunk(input: Readonly<{ + chunkId: string; + failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH"; + }>): Promise< + | Readonly<{ action: "reload-once"; releasePair: string }> + | Readonly<{ action: "support"; reason: string }> + >; + }>; + features: Readonly<{ + has(featureId: string): featureId is ApplicationFeatureId; + get( + featureId: FeatureId, + ): ApplicationFeatureInputs[FeatureId]; + }>; +}>; diff --git a/src/application/ports/in/index.ts b/src/application/ports/in/index.ts new file mode 100644 index 0000000..e309bfa --- /dev/null +++ b/src/application/ports/in/index.ts @@ -0,0 +1,9 @@ +export type { + ApplicationApi, + ApplicationFeatureId, + ApplicationFeatureInputs, + ColorSchemePreference, + ReleaseSummary, + RenderFailureReport, + SessionState, +} from "./application-api.ts"; diff --git a/src/application/ports/mutation-intent-factory.ts b/src/application/ports/mutation-intent-factory.ts new file mode 100644 index 0000000..d1bd07b --- /dev/null +++ b/src/application/ports/mutation-intent-factory.ts @@ -0,0 +1,11 @@ +import type { MutationIntent } from "../../contracts/mutation-intent.ts"; + +export type MutationIntentFactoryInput = Readonly<{ + operationId: string; + canonicalInputIdentity: string; + requiresIdempotencyKey: boolean; +}>; + +export type MutationIntentFactory = Readonly<{ + create(input: MutationIntentFactoryInput): MutationIntent; +}>; diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts new file mode 100644 index 0000000..352a971 --- /dev/null +++ b/src/application/ports/out/application-output-ports.ts @@ -0,0 +1,23 @@ +import type { AuthSessionPort } from "../auth-session-port.ts"; +import type { ReleaseInfoPort } from "../release-info-port.ts"; +import type { RuntimeCapabilitiesPort } from "../runtime-capabilities-port.ts"; +import type { StoragePort } from "../storage-port.ts"; +import type { TelemetryPort } from "../telemetry-port.ts"; +import type { DiagnosticsPort } from "../diagnostics-port.ts"; + +/** + * Capabilities required by application use cases. Implementations live in + * outbound adapters and are selected only by bootstrap. + */ +export type ApplicationOutputPorts = Readonly<{ + session: Pick< + AuthSessionPort, + "getState" | "subscribe" | "beginSignIn" | "signOut" | "recover" + >; + preferences: StoragePort; + diagnostics: DiagnosticsPort; + telemetry: TelemetryPort; + releaseInfo: ReleaseInfoPort; + runtimeCapabilities: RuntimeCapabilitiesPort; + navigation: Readonly<{ reload(): void }>; +}>; diff --git a/src/application/ports/out/index.ts b/src/application/ports/out/index.ts new file mode 100644 index 0000000..6f107f7 --- /dev/null +++ b/src/application/ports/out/index.ts @@ -0,0 +1,19 @@ +export type { ApplicationOutputPorts } from "./application-output-ports.ts"; +export type { + AuthSessionPort, + CredentialAttacher, + SessionGateway, +} from "../auth-session-port.ts"; +export type { ClockPort } from "../clock-port.ts"; +export type { QueryCachePort } from "../query-cache-port.ts"; +export type { ReleaseInfoPort } from "../release-info-port.ts"; +export type { StoragePort } from "../storage-port.ts"; +export type { TelemetryPort } from "../telemetry-port.ts"; +export type { DiagnosticsPort } from "../diagnostics-port.ts"; +export type { WebPushControlPort } from "./web-push-control.ts"; +export type { + BrowserRpcCallContext, + BrowserRpcGenerationFence, + BrowserRpcServerStreamPort, + BrowserRpcUnaryPort, +} from "../browser-rpc/index.ts"; diff --git a/src/application/ports/out/web-push-control.ts b/src/application/ports/out/web-push-control.ts new file mode 100644 index 0000000..bd27b8f --- /dev/null +++ b/src/application/ports/out/web-push-control.ts @@ -0,0 +1,31 @@ +import type { + PushAuthoritySnapshot, + WebPushReadiness, + WebPushResult, +} from "../../../contracts/web-push.ts"; + +export interface WebPushControlPort { + inspect(input: Readonly<{ + authority: PushAuthoritySnapshot; + signal?: AbortSignal; + }>): Promise>; + + enable(input: Readonly<{ + authority: PushAuthoritySnapshot; + signal: AbortSignal; + }>): Promise>; + + reconcile(input: Readonly<{ + authority: PushAuthoritySnapshot; + signal?: AbortSignal; + }>): Promise>; + + revoke(input: Readonly<{ + previousAuthority: PushAuthoritySnapshot; + nextAuthority: PushAuthoritySnapshot; + signal?: AbortSignal; + }>): Promise>; + + dispose(): void; +} + diff --git a/src/application/ports/query-cache-port.ts b/src/application/ports/query-cache-port.ts new file mode 100644 index 0000000..0862bb2 --- /dev/null +++ b/src/application/ports/query-cache-port.ts @@ -0,0 +1,15 @@ +import type { ApiFailure } from "../../contracts/errors.ts"; + +export type QueryCacheReadResult = + | Readonly<{ ok: true; value: unknown }> + | Readonly<{ ok: false; error: ApiFailure }>; + +export type QueryCacheWriteResult = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; error: ApiFailure }>; + +export type QueryCachePort = Readonly<{ + read(key: readonly unknown[]): QueryCacheReadResult; + write(key: readonly unknown[], value: unknown): QueryCacheWriteResult; + invalidate(namespace: readonly unknown[]): Promise; +}>; diff --git a/src/application/ports/realtime/event-authority.ts b/src/application/ports/realtime/event-authority.ts new file mode 100644 index 0000000..f427f94 --- /dev/null +++ b/src/application/ports/realtime/event-authority.ts @@ -0,0 +1,202 @@ +import type { + EventTypeId, + ExternalEventEffectProfileId, + StreamRegistrationId, +} from "../../../contracts/realtime-streams.ts"; +import type { + RealtimeResumeState, + SnapshotCheckpoint, +} from "../../../contracts/realtime-events.ts"; +import type { + RealtimeFailureKind, + RealtimeResult, +} from "./shared.ts"; + +declare const realtimeRecoveryCheckpointBrand: unique symbol; + +/** + * An in-memory, one-generation recovery lease. Callers must retain the exact + * object returned by the coordinator; reconstructing an equal checkpoint does + * not authorize a transport-barrier commit. + */ +export type RealtimeRecoveryCheckpoint = RealtimeResumeState & + Readonly<{ [realtimeRecoveryCheckpointBrand]: true }>; + +export type RealtimeScopeSnapshot = Readonly<{ + generation: number; + /** + * Session/BFF-issued opaque binding. This is not a cache fingerprint or an + * authorization credential and must never be projected into diagnostics. + */ + scopeBinding: string; + isCurrent(): boolean; +}>; + +export type ExternalRealtimeEventContext = Readonly<{ + streamId: StreamRegistrationId; + eventType: EventTypeId; + occurredAt: string; + scopeGeneration: number; + /** + * Per-callback commit authority. The effect owner must check this immediately + * before its final local commit. It becomes permanently false when the + * callback settles, even if the captured scope itself is still current. + */ + isCurrent(): boolean; +}>; + +export type RealtimeEventEffectAuthority = Readonly<{ + /** + * Resolves a registry-owned profile to a feature-owned application input and + * commits its local effect. Success means the complete local effect has + * committed; only then may the coordinator advance its checkpoint. + */ + apply( + effectProfileId: ExternalEventEffectProfileId, + event: unknown, + context: ExternalRealtimeEventContext, + signal: AbortSignal, + ): Promise>; +}>; + +export type RealtimeRecoveryReason = + | "INITIALIZE" + | "STREAM_EPOCH_CHANGED" + | "SEQUENCE_GAP" + | "CURSOR_EXPIRED" + | "QUEUE_OVERFLOW" + | "DEDUPE_OVERFLOW" + | "EVENT_CONFLICT" + | "MAPPING_CONTRACT_VIOLATION" + | "APPLY_FAILED" + | "SCOPE_PROTOCOL_VIOLATION"; + +export type RealtimeRecoveryRequest = Readonly<{ + streamId: StreamRegistrationId; + reason: RealtimeRecoveryReason; + scopeGeneration: number; + signal: AbortSignal; + /** + * Per-recovery commit authority. Snapshot/rebuild projection owners must + * check this immediately before commit. It becomes permanently false when + * `recover` settles. + */ + isCurrent(): boolean; +}>; + +export type RealtimeRecoveryCommit = + | Readonly<{ + kind: "SNAPSHOT_RESET"; + checkpoint: SnapshotCheckpoint; + }> + | Readonly<{ + kind: "SESSION_REBUILD"; + streamEpoch: string; + lastAppliedSequence: string; + }>; + +export type RealtimeRecoveryAuthority = Readonly<{ + /** + * Success is an authoritative commit: all required projections have already + * been applied for the returned checkpoint in the captured scope. + */ + recover( + request: RealtimeRecoveryRequest, + ): Promise>; +}>; + +export type RealtimeEventAuthority = Readonly<{ + effects: RealtimeEventEffectAuthority; + recovery: RealtimeRecoveryAuthority; +}>; + +export type RealtimeAcceptDropReason = + | "CLOSED" + | "DUPLICATE_EVENT" + | "RECOVERY_IN_PROGRESS" + | "SCOPE_FENCED" + | "STALE_EVENT"; + +export type RealtimeAcceptDisposition = + | Readonly<{ + outcome: "APPLIED"; + resumeState: RealtimeResumeState; + }> + | Readonly<{ + outcome: "DROPPED"; + reason: RealtimeAcceptDropReason; + }> + | Readonly<{ + outcome: "RECOVERED"; + reason: RealtimeRecoveryReason; + resumeState: RealtimeRecoveryCheckpoint; + }> + | Readonly<{ + outcome: "RECOVERY_BARRIER_REQUIRED"; + resumeState: RealtimeRecoveryCheckpoint; + }>; + +export type RealtimeTransportEventOutcome = + | Readonly<{ kind: "CONTINUE" }> + | Readonly<{ + kind: "RECOVERY_COMMITTED"; + streamId: StreamRegistrationId; + checkpoint: RealtimeRecoveryCheckpoint; + }>; + +export const REALTIME_TRANSPORT_CONTINUE: RealtimeTransportEventOutcome = + Object.freeze({ kind: "CONTINUE" }); + +export function realtimeTransportRecoveryCommitted( + streamId: StreamRegistrationId, + checkpoint: RealtimeRecoveryCheckpoint, +): Extract< + RealtimeTransportEventOutcome, + { kind: "RECOVERY_COMMITTED" } +> { + return Object.freeze({ + kind: "RECOVERY_COMMITTED", + streamId, + checkpoint, + }); +} + +export type RealtimeStreamFreshness = + | "UNKNOWN" + | "CURRENT" + | "STALE" + | "RESYNCING"; + +export type RealtimeStreamInspection = Readonly<{ + freshness: RealtimeStreamFreshness; + queuedEvents: number; + queuedBytes: number; + dedupeEntries: number; + closed: boolean; + hasResumeState: boolean; + awaitingTransportBarrier: boolean; +}>; + +export type RealtimeObservationOutcome = + | "ACCEPTED" + | "APPLIED" + | "DROPPED" + | "FAILED" + | "RECOVERED"; + +/** + * Safe low-cardinality observation. It intentionally has no event identifier, + * sequence, payload, cursor, epoch, scope binding or native error. + */ +export type RealtimeObservation = Readonly<{ + operation: "RECEIVE" | "APPLY" | "RECOVER" | "CLOSE"; + outcome: RealtimeObservationOutcome; + streamId?: StreamRegistrationId; + eventType?: EventTypeId; + reason?: RealtimeFailureKind | RealtimeRecoveryReason; + queueSizeBucket?: "0" | "1-8" | "9-64" | "65-256" | "OVERFLOW"; +}>; + +export type RealtimeEventObservationSink = ( + observation: RealtimeObservation, +) => void; diff --git a/src/application/ports/realtime/index.ts b/src/application/ports/realtime/index.ts new file mode 100644 index 0000000..c2277f7 --- /dev/null +++ b/src/application/ports/realtime/index.ts @@ -0,0 +1,31 @@ +export type { + ExternalRealtimeEventContext, + RealtimeAcceptDisposition, + RealtimeAcceptDropReason, + RealtimeEventAuthority, + RealtimeEventEffectAuthority, + RealtimeEventObservationSink, + RealtimeObservation, + RealtimeObservationOutcome, + RealtimeRecoveryCheckpoint, + RealtimeRecoveryAuthority, + RealtimeRecoveryCommit, + RealtimeRecoveryReason, + RealtimeRecoveryRequest, + RealtimeScopeSnapshot, + RealtimeStreamFreshness, + RealtimeStreamInspection, + RealtimeTransportEventOutcome, +} from "./event-authority.ts"; +export { + REALTIME_TRANSPORT_CONTINUE, + realtimeTransportRecoveryCommitted, +} from "./event-authority.ts"; +export { + REALTIME_FAILURE_KINDS, + REALTIME_OPERATIONS, + type RealtimeFailure, + type RealtimeFailureKind, + type RealtimeOperation, + type RealtimeResult, +} from "./shared.ts"; diff --git a/src/application/ports/realtime/shared.ts b/src/application/ports/realtime/shared.ts new file mode 100644 index 0000000..de70b77 --- /dev/null +++ b/src/application/ports/realtime/shared.ts @@ -0,0 +1,67 @@ +export const REALTIME_OPERATIONS = Object.freeze([ + "REGISTRY", + "DECODE", + "CONNECT", + "SUBSCRIBE", + "RECEIVE", + "SEND", + "APPLY", + "RECOVER", + "POLL", + "PUSH_REGISTER", + "PUSH_REVOKE", + "CLOSE", +] as const); + +export type RealtimeOperation = (typeof REALTIME_OPERATIONS)[number]; + +export const REALTIME_FAILURE_KINDS = Object.freeze([ + "ABORTED", + "UNSUPPORTED", + "OFFLINE", + "CONNECT_TIMEOUT", + "IDLE_TIMEOUT", + "AUTH_REQUIRED", + "FORBIDDEN", + "RATE_LIMITED", + "PROVIDER_UNAVAILABLE", + "PROTOCOL_MISMATCH", + "MALFORMED_EVENT", + "MAPPING_CONTRACT_VIOLATION", + "EVENT_CONFLICT", + "EVENT_TOO_LARGE", + "DUPLICATE_EVENT", + "STALE_EVENT", + "SEQUENCE_GAP", + "CURSOR_EXPIRED", + "QUEUE_OVERFLOW", + "APPLY_FAILED", + "POLL_BUDGET_EXHAUSTED", + "PUSH_PERMISSION_DENIED", + "PUSH_SUBSCRIPTION_STALE", + "NOTIFICATION_REJECTED", + "SCOPE_FENCED", + "SCOPE_PROTOCOL_VIOLATION", + "CLOSED", +] as const); + +export type RealtimeFailureKind = + (typeof REALTIME_FAILURE_KINDS)[number]; + +/** + * Failure projected across realtime adapter boundaries. + * + * Native exceptions, frames, payloads, cursors, endpoints and scope bindings + * are deliberately absent. Adapters may observe those values locally while + * classifying a failure, but cannot expose them through this result. + */ +export type RealtimeFailure = Readonly<{ + kind: RealtimeFailureKind; + operation: RealtimeOperation; + retryable: boolean; +}>; + +export type RealtimeResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: RealtimeFailure }>; + diff --git a/src/application/ports/release-info-port.ts b/src/application/ports/release-info-port.ts new file mode 100644 index 0000000..b569861 --- /dev/null +++ b/src/application/ports/release-info-port.ts @@ -0,0 +1,36 @@ +export type ReleaseInfo = Readonly<{ + schemaVersion?: number; + appVersion?: string; + buildId: string; + commitSha?: string; + configSchemaVersion: string; + /** + * §5.1. Legacy scalar, present only while a V1 release manifest is still + * accepted. A V2 manifest expresses contract identity through + * {@link ReleaseInfo.contractSetDigest}. + */ + apiContractVersion?: string; + contractSetDigest?: string; + assetManifestHash: string; + releaseId: string; + builtAt?: string; + routeChunks: Readonly>; +}>; + +export type ActiveReleaseInfo = Readonly< + Pick< + ReleaseInfo, + | "buildId" + | "configSchemaVersion" + | "apiContractVersion" + | "contractSetDigest" + | "assetManifestHash" + | "releaseId" + | "routeChunks" + > +>; + +export type ReleaseInfoPort = Readonly<{ + getCurrent(): Promise; + refresh(): Promise; +}>; diff --git a/src/application/ports/runtime-capabilities-port.ts b/src/application/ports/runtime-capabilities-port.ts new file mode 100644 index 0000000..4b282a0 --- /dev/null +++ b/src/application/ports/runtime-capabilities-port.ts @@ -0,0 +1,12 @@ +import type { RuntimeCapabilitySnapshot } from "../../contracts/runtime-capabilities.ts"; + +export type { RuntimeCapabilitySnapshot }; + +/** + * §3.5. The application reads capability state; it never resolves it. Only the + * composition root knows the runtime overrides, so the snapshot arrives here + * already reduced to counts and cannot be used to reach a runtime object. + */ +export type RuntimeCapabilitiesPort = Readonly<{ + getSnapshot(): RuntimeCapabilitySnapshot; +}>; diff --git a/src/application/ports/storage-port.ts b/src/application/ports/storage-port.ts new file mode 100644 index 0000000..e6b0e44 --- /dev/null +++ b/src/application/ports/storage-port.ts @@ -0,0 +1,15 @@ +import type { ApiFailure } from "../../contracts/errors.ts"; + +export type StorageReadResult = + | Readonly<{ ok: true; value: unknown }> + | Readonly<{ ok: false; error: ApiFailure }>; + +export type StorageMutationResult = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; error: ApiFailure; fallback?: string }>; + +export type StoragePort = Readonly<{ + read(logicalName: string): StorageReadResult; + write(logicalName: string, value: unknown): StorageMutationResult; + remove(logicalName: string): StorageMutationResult; +}>; diff --git a/src/application/ports/telemetry-port.ts b/src/application/ports/telemetry-port.ts new file mode 100644 index 0000000..38222a5 --- /dev/null +++ b/src/application/ports/telemetry-port.ts @@ -0,0 +1,10 @@ +import type { TelemetryEventName } from "../../contracts/telemetry.ts"; + +export type { TelemetryEventName }; + +export type TelemetryPort = Readonly<{ + emit( + eventName: TelemetryEventName, + attributes: Record, + ): void; +}>; diff --git a/src/application/result.ts b/src/application/result.ts new file mode 100644 index 0000000..9440c1c --- /dev/null +++ b/src/application/result.ts @@ -0,0 +1,10 @@ +import type { AppFailure } from "../contracts/errors.ts"; + +/** + * The single success/failure carrier used across application input boundaries. + * Adapters map technology-specific errors to an application failure before + * constructing this value. + */ +export type Result = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: Failure }>; diff --git a/src/application/use-cases/decide-chunk-recovery.ts b/src/application/use-cases/decide-chunk-recovery.ts new file mode 100644 index 0000000..dd51469 --- /dev/null +++ b/src/application/use-cases/decide-chunk-recovery.ts @@ -0,0 +1,51 @@ +import type { StoragePort } from "../ports/storage-port.ts"; + +const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]); + +export type ChunkRecoveryDecision = + | Readonly<{ action: "reload-once"; releasePair: string }> + | Readonly<{ action: "support"; reason: string }>; + +export type ChunkRecoveryInput = Readonly<{ + failureKind: string; + manifestLoaded: boolean; + currentBuildId: string; + currentReleaseId: string; + activeBuildId: string; + activeReleaseId: string; + storage: StoragePort; +}>; + +export function decideChunkRecovery( + input: ChunkRecoveryInput, +): ChunkRecoveryDecision { + if (!RECOVERABLE_KINDS.has(input.failureKind)) { + return { action: "support", reason: "not-recoverable" }; + } + if (!input.manifestLoaded) { + return { action: "support", reason: "manifest-unavailable" }; + } + if ( + input.activeBuildId === input.currentBuildId && + input.activeReleaseId === input.currentReleaseId + ) { + return { action: "support", reason: "same-release" }; + } + + const releasePair = + `${input.currentBuildId}/${input.currentReleaseId}` + + `->${input.activeBuildId}/${input.activeReleaseId}`; + const guard = input.storage.read("CHUNK_RELOAD_GUARD"); + if (!guard.ok) { + return { action: "support", reason: "guard-read-failed" }; + } + if (guard.value === releasePair) { + return { action: "support", reason: "reload-already-attempted" }; + } + + const recorded = input.storage.write("CHUNK_RELOAD_GUARD", releasePair); + if (!recorded.ok) { + return { action: "support", reason: "guard-write-failed" }; + } + return { action: "reload-once", releasePair }; +} diff --git a/src/application/view-models/async-state.ts b/src/application/view-models/async-state.ts new file mode 100644 index 0000000..3a30cde --- /dev/null +++ b/src/application/view-models/async-state.ts @@ -0,0 +1,187 @@ +import type { AppFailure } from "../../contracts/errors.ts"; + +export const ASYNC_BASE_STATES = Object.freeze([ + "initial-loading", + "success", + "empty", + "terminal-error", +] as const); + +export const ASYNC_OVERLAYS = Object.freeze([ + "refreshing", + "stale-degraded", + "mutation-pending", + "mutation-effect-unknown", + "mutation-conflict", +] as const); + +export type AsyncOverlay = + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: false; + mutationEffectUnknown: false; + mutationConflict: false; + }> + | Readonly<{ + refreshing: true; + staleDegraded: false; + mutationPending: false; + mutationEffectUnknown: false; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: true; + mutationPending: false; + mutationEffectUnknown: false; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: true; + mutationEffectUnknown: false; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: false; + mutationEffectUnknown: true; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: false; + mutationEffectUnknown: false; + mutationConflict: true; + }>; + +export type AsyncSignals = Readonly<{ + data?: unknown; + isInitialLoading?: boolean; + failure?: AppFailure; + isFetching?: boolean; + isStale?: boolean; + isDegraded?: boolean; + isMutationPending?: boolean; + hasMutationEffectUnknown?: boolean; + hasMutationConflict?: boolean; +}>; + +export type AsyncState = Readonly<{ + base: (typeof ASYNC_BASE_STATES)[number]; + data?: unknown; + failure?: AppFailure; + overlay: AsyncOverlay; + indicator: (typeof ASYNC_OVERLAYS)[number] | null; +}>; + +export function deriveAsyncState(signals: AsyncSignals): AsyncState { + const hasData = signals.data !== undefined && signals.data !== null; + const empty = + hasData && + ((Array.isArray(signals.data) && signals.data.length === 0) || + signals.data === ""); + + const base = + signals.isInitialLoading && !hasData + ? "initial-loading" + : signals.failure && !hasData + ? "terminal-error" + : empty + ? "empty" + : hasData + ? "success" + : "initial-loading"; + + const indicator = + signals.hasMutationEffectUnknown && hasData + ? "mutation-effect-unknown" + : signals.hasMutationConflict && hasData + ? "mutation-conflict" + : signals.isMutationPending && hasData + ? "mutation-pending" + : signals.isStale && signals.isDegraded && hasData + ? "stale-degraded" + : signals.isFetching && hasData + ? "refreshing" + : null; + const overlay = overlayFor(indicator); + + return Object.freeze({ + base, + data: base === "success" || base === "empty" ? signals.data : undefined, + failure: base === "terminal-error" ? signals.failure : undefined, + overlay, + indicator, + }); +} + +export function selectOverlayIndicator( + overlay: AsyncOverlay, +): AsyncState["indicator"] { + if (overlay.mutationEffectUnknown) return "mutation-effect-unknown"; + if (overlay.mutationConflict) return "mutation-conflict"; + if (overlay.mutationPending) return "mutation-pending"; + if (overlay.staleDegraded) return "stale-degraded"; + if (overlay.refreshing) return "refreshing"; + return null; +} + +function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { + if (indicator === "refreshing") { + return Object.freeze({ + refreshing: true, + staleDegraded: false, + mutationPending: false, + mutationEffectUnknown: false, + mutationConflict: false, + }); + } + if (indicator === "stale-degraded") { + return Object.freeze({ + refreshing: false, + staleDegraded: true, + mutationPending: false, + mutationEffectUnknown: false, + mutationConflict: false, + }); + } + if (indicator === "mutation-pending") { + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: true, + mutationEffectUnknown: false, + mutationConflict: false, + }); + } + if (indicator === "mutation-effect-unknown") { + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: false, + mutationEffectUnknown: true, + mutationConflict: false, + }); + } + if (indicator === "mutation-conflict") { + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: false, + mutationEffectUnknown: false, + mutationConflict: true, + }); + } + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: false, + mutationEffectUnknown: false, + mutationConflict: false, + }); +} diff --git a/src/bootstrap/composition-root.ts b/src/bootstrap/composition-root.ts new file mode 100644 index 0000000..6318436 --- /dev/null +++ b/src/bootstrap/composition-root.ts @@ -0,0 +1,51 @@ +import { createApplication } from "../application/create-application.ts"; +import type { + ApplicationApi, + ApplicationFeatureInputs, +} from "../application/ports/in/application-api.ts"; +import type { ApplicationOutputPorts } from "../application/ports/out/application-output-ports.ts"; + +export type CompositionRoot = Readonly<{ + config: Config; + release: Release; + infrastructure: Infrastructure; + application: ApplicationApi; +}>; + +type AdapterBundle = Readonly<{ + outputPorts: ApplicationOutputPorts; + infrastructure: Infrastructure; + featureInputs?: Readonly>; +}>; + +type CompositionFactories = Readonly<{ + loadConfig(): Promise; + loadRelease(config: Config): Promise; + createAdapters(context: Readonly<{ + config: Config; + release: Release; + }>): Promise>; +}>; + +/** + * This is the only module allowed to join concrete adapters to application + * ports. Boot phases are explicit so failures can stop before product mount. + */ +export async function createCompositionRoot( + factories: CompositionFactories, +): Promise> { + const config = await factories.loadConfig(); + const release = await factories.loadRelease(config); + const adapters = await factories.createAdapters({ config, release }); + const application = createApplication( + adapters.outputPorts, + adapters.featureInputs, + ); + + return Object.freeze({ + config, + release, + infrastructure: adapters.infrastructure, + application, + }); +} diff --git a/src/bootstrap/create-runtime-composition.ts b/src/bootstrap/create-runtime-composition.ts new file mode 100644 index 0000000..8d1b712 --- /dev/null +++ b/src/bootstrap/create-runtime-composition.ts @@ -0,0 +1,72 @@ +import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts"; +import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; +import { createCompositionRoot } from "./composition-root.ts"; +import { loadReleaseManifest } from "./load-release-manifest.ts"; +import { loadRuntimeConfig } from "./load-runtime-config.ts"; +import { + createOptionalRuntimeHost, + type OptionalRuntimeHost, +} from "./optional-runtime-host.ts"; +import { createRuntimeAdapters } from "./runtime-adapters.ts"; + +export type RuntimeCompositionDependencies = Readonly<{ + fetcher?: typeof fetch; + host?: Record; +}>; + +/** + * §6.7 steps 7 and 14. The static capability selection is compiled against the + * runtime overrides, and the optional host objects are created without any + * start side effect. Nothing observable happens until the first committed React + * effect calls `optional.startAfterMount()`. + */ +export async function createRuntimeComposition( + dependencies: RuntimeCompositionDependencies = {}, +) { + const root = await createCompositionRoot({ + loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }), + loadRelease: (runtime) => + loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }), + createAdapters: ({ config: runtime, release }) => + createRuntimeAdapters({ + runtime, + release, + fetcher: dependencies.fetcher, + host: dependencies.host, + }), + }); + + const capabilities = resolveRuntimeCapabilities( + INSTALLED_RUNTIME_CAPABILITIES, + root.config.config.CAPABILITY_OVERRIDES, + ); + const optional: OptionalRuntimeHost = createOptionalRuntimeHost({ + capabilities, + routerBasePath: root.config.build.routerBasePath, + buildId: root.release.buildId, + ...(dependencies.host + ? { + host: dependencies.host as Parameters< + typeof createOptionalRuntimeHost + >[0]["host"], + } + : {}), + }); + + return Object.freeze({ + ...root, + capabilities, + optional, + async dispose(): Promise { + // §20.3. Optional runtime first, then the base infrastructure: the query + // cache is cleared only after realtime and worker admission has closed, + // so a late effect cannot repopulate a cleared cache. + await optional.stop("APPLICATION_SHUTDOWN"); + root.infrastructure.dispose(); + }, + }); +} + +export type RuntimeComposition = Awaited< + ReturnType +>; diff --git a/src/bootstrap/initialize-color-scheme.ts b/src/bootstrap/initialize-color-scheme.ts new file mode 100644 index 0000000..7478170 --- /dev/null +++ b/src/bootstrap/initialize-color-scheme.ts @@ -0,0 +1,36 @@ +import { + normalizeColorSchemePreference, + resolveColorScheme, +} from "../application/policies/color-scheme.ts"; +import type { ApplicationApi } from "../application/ports/in/application-api.ts"; + +type ColorSchemeBrowser = Readonly<{ + documentElement?: HTMLElement; + matchMedia?: (query: string) => Pick; +}>; + +/** Applies the persisted public preference before React paints. */ +export function initializeColorScheme( + preferences: Pick, + browser: ColorSchemeBrowser = {}, +) { + const documentElement = browser.documentElement ?? document.documentElement; + const matchMedia = + browser.matchMedia ?? + (typeof window.matchMedia === "function" + ? window.matchMedia.bind(window) + : () => ({ matches: false })); + const preference = normalizeColorSchemePreference( + preferences.getColorScheme(), + ); + const resolved = resolveColorScheme( + preference, + matchMedia("(prefers-color-scheme: dark)").matches, + ); + + documentElement.dataset.theme = resolved; + documentElement.dataset.themePreference = preference; + documentElement.style.colorScheme = resolved; + + return Object.freeze({ preference, resolved }); +} diff --git a/src/bootstrap/load-release-manifest.ts b/src/bootstrap/load-release-manifest.ts new file mode 100644 index 0000000..79f1661 --- /dev/null +++ b/src/bootstrap/load-release-manifest.ts @@ -0,0 +1,294 @@ +import { + verifyContractSet, + type ContractSet, + type ContractSetFailureCode, +} from "../contracts/contract-set.ts"; +import type { ContractSetPackage } from "../contracts/contract-set-canonical.ts"; +import { + releaseManifestV1ArtifactSchema, + releaseManifestV2ArtifactSchema, + type ReleaseManifestV1Artifact, + type ReleaseManifestV2Artifact, +} from "../contracts/release-artifacts.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../features/installed-contract-contributions.ts"; +import { + BOOT_JSON_POLICIES, + readBoundedBootJson, + type BootLoadFailure, +} from "./read-bounded-boot-json.ts"; +import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts"; + +/** §5.8. Retained for one compatibility window; carries the removed scalar. */ +export const releaseManifestV1Schema = releaseManifestV1ArtifactSchema; + +/** §5.2. The frontend build's compiled external contract package set. */ +export const releaseManifestV2Schema = releaseManifestV2ArtifactSchema; + +export type ReleaseManifestV1 = ReleaseManifestV1Artifact; +export type ReleaseManifestV2 = ReleaseManifestV2Artifact; + +/** + * The composition-facing manifest. A V1 document is normalized onto it with a + * null contract set so downstream runtime never branches on schema version. + */ +export type ReleaseManifest = Readonly<{ + schemaVersion: 1 | 2; + appVersion: string; + buildId: string; + commitSha: string; + configSchemaVersion: string; + assetManifestHash: string; + releaseId: string; + builtAt: string; + routeChunks: Readonly>; + contractSet: ContractSet | null; + legacyApiContractVersion?: string; +}>; + +export type ReleaseManifestErrorCode = + | "MANIFEST_BUILD_MISMATCH" + | "MANIFEST_PROTOCOL_PAIR_MISMATCH" + | "MANIFEST_CONFIG_SCHEMA_MISMATCH" + | "MANIFEST_API_CONTRACT_MISMATCH" + | "MANIFEST_RELEASE_MISMATCH" + | "MANIFEST_ASSET_MISMATCH" + | "MANIFEST_FETCH_FAILED" + | "MANIFEST_TIMEOUT" + | "MANIFEST_HTTP_FAILED" + | "MANIFEST_CONTENT_TYPE_INVALID" + | "MANIFEST_BODY_TOO_LARGE" + | "MANIFEST_UTF8_INVALID" + | "MANIFEST_JSON_INVALID" + | "MANIFEST_SCHEMA_INVALID" + | ContractSetFailureCode; + +export type ReleaseManifestFailureKind = + | "BUILD_MISMATCH" + | "PROTOCOL_PAIR_MISMATCH" + | "CONFIG_MISMATCH" + | "API_CONTRACT_MISMATCH" + | "RELEASE_MISMATCH" + | "ASSET_MISMATCH" + | "CONTRACT_SET_MISMATCH" + | "RELEASE_MANIFEST_FAILURE"; + +export type ReleaseManifestSafe = Readonly<{ + kind: ReleaseManifestFailureKind; + code: ReleaseManifestErrorCode; + buildId: string; + releaseId?: string; + supportReference: string; +}>; + +type ReleaseManifestSafeInput = Readonly<{ + buildId: string; + releaseId?: string; +}>; + +function failureKindFor( + code: ReleaseManifestErrorCode, +): ReleaseManifestFailureKind { + switch (code) { + case "MANIFEST_BUILD_MISMATCH": + return "BUILD_MISMATCH"; + case "MANIFEST_PROTOCOL_PAIR_MISMATCH": + return "PROTOCOL_PAIR_MISMATCH"; + case "MANIFEST_CONFIG_SCHEMA_MISMATCH": + return "CONFIG_MISMATCH"; + case "MANIFEST_API_CONTRACT_MISMATCH": + return "API_CONTRACT_MISMATCH"; + case "MANIFEST_RELEASE_MISMATCH": + return "RELEASE_MISMATCH"; + case "MANIFEST_ASSET_MISMATCH": + return "ASSET_MISMATCH"; + default: + return code.startsWith("CONTRACT_") + ? "CONTRACT_SET_MISMATCH" + : "RELEASE_MANIFEST_FAILURE"; + } +} + +export class ReleaseManifestError extends Error { + readonly kind: ReleaseManifestFailureKind; + readonly code: ReleaseManifestErrorCode; + readonly safe: ReleaseManifestSafe; + + constructor(code: ReleaseManifestErrorCode, safe: ReleaseManifestSafeInput) { + super("Release manifest could not be loaded"); + this.name = "ReleaseManifestError"; + this.kind = failureKindFor(code); + this.code = code; + this.safe = Object.freeze({ + kind: this.kind, + code, + buildId: safe.buildId, + releaseId: safe.releaseId, + supportReference: `${safe.buildId}:${code}`, + }); + } +} + +const READ_FAILURE_CODE: Readonly< + Record +> = Object.freeze({ + FETCH_FAILED: "MANIFEST_FETCH_FAILED", + TIMEOUT: "MANIFEST_TIMEOUT", + HTTP_STATUS_INVALID: "MANIFEST_HTTP_FAILED", + CONTENT_TYPE_INVALID: "MANIFEST_CONTENT_TYPE_INVALID", + BODY_TOO_LARGE: "MANIFEST_BODY_TOO_LARGE", + UTF8_INVALID: "MANIFEST_UTF8_INVALID", + JSON_INVALID: "MANIFEST_JSON_INVALID", + SHAPE_INVALID: "MANIFEST_SCHEMA_INVALID", + SECRET_NAME_REJECTED: "MANIFEST_SCHEMA_INVALID", + SCHEMA_INVALID: "MANIFEST_SCHEMA_INVALID", + BUILD_MISMATCH: "MANIFEST_BUILD_MISMATCH", + RELEASE_MISMATCH: "MANIFEST_RELEASE_MISMATCH", + ASSET_MISMATCH: "MANIFEST_ASSET_MISMATCH", + CONTRACT_SET_MISMATCH: "CONTRACT_SET_DIGEST_MISMATCH", +}); + +export type FetchReleaseManifestOptions = Readonly<{ + fetcher?: typeof fetch; + buildId: string; + releaseId?: string; + signal?: AbortSignal; +}>; + +/** + * Fetches and validates the active manifest without imposing the current build + * tuple. Chunk recovery uses this no-store view to detect a new release. + */ +export async function fetchReleaseManifest( + url: string, + options: FetchReleaseManifestOptions, +): Promise { + const outcome = await readBoundedBootJson( + url, + BOOT_JSON_POLICIES.RELEASE_MANIFEST, + { + ...(options.fetcher ? { fetcher: options.fetcher } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }, + ); + if (!outcome.ok) { + throw new ReleaseManifestError(READ_FAILURE_CODE[outcome.failure], options); + } + + if (outcome.value.schemaVersion === 2) { + const parsed = releaseManifestV2Schema.safeParse(outcome.value); + if (!parsed.success) { + throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options); + } + return Object.freeze(structuredClone(parsed.data)); + } + + const parsed = releaseManifestV1Schema.safeParse(outcome.value); + if (!parsed.success) { + throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options); + } + const { apiContractVersion, ...rest } = structuredClone(parsed.data); + return Object.freeze({ + ...rest, + contractSet: null, + legacyApiContractVersion: apiContractVersion, + }); +} + +export type LoadReleaseManifestOptions = Readonly<{ + fetcher?: typeof fetch; + expectedAssetManifestHash?: string; + expectedContractSetPackages?: readonly ContractSetPackage[]; + signal?: AbortSignal; +}>; + +export async function loadReleaseManifest( + runtime: RuntimeConfigLoadResult, + options: LoadReleaseManifestOptions = {}, +): Promise { + const identity: ReleaseManifestSafeInput = { + buildId: runtime.build.buildId, + ...(runtime.config.RELEASE_ID + ? { releaseId: runtime.config.RELEASE_ID } + : {}), + }; + + const manifest = await fetchReleaseManifest( + runtime.config.RELEASE_MANIFEST_URL, + { + ...(options.fetcher ? { fetcher: options.fetcher } : {}), + ...(options.signal ? { signal: options.signal } : {}), + buildId: runtime.build.buildId, + ...(runtime.config.RELEASE_ID + ? { releaseId: runtime.config.RELEASE_ID } + : {}), + }, + ); + + const expectedManifestVersion = runtime.configSchema === "V1" ? 1 : 2; + if (manifest.schemaVersion !== expectedManifestVersion) { + throw new ReleaseManifestError( + "MANIFEST_PROTOCOL_PAIR_MISMATCH", + identity, + ); + } + + // §6.7 steps 5-6, in order: build, config, release, assets, then contractSet. + let mismatchCode: ReleaseManifestErrorCode | null = null; + if (manifest.buildId !== runtime.build.buildId) { + mismatchCode = "MANIFEST_BUILD_MISMATCH"; + } + if ( + !mismatchCode && + runtime.config.BUILD_ID && + manifest.buildId !== runtime.config.BUILD_ID + ) { + mismatchCode = "MANIFEST_BUILD_MISMATCH"; + } + if ( + !mismatchCode && + manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION + ) { + mismatchCode = "MANIFEST_CONFIG_SCHEMA_MISMATCH"; + } + if ( + !mismatchCode && + runtime.configSchema === "V1" && + (manifest.legacyApiContractVersion === undefined || + runtime.config.LEGACY_API_CONTRACT_VERSION === undefined || + manifest.legacyApiContractVersion !== + runtime.config.LEGACY_API_CONTRACT_VERSION) + ) { + mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH"; + } + if ( + !mismatchCode && + runtime.config.RELEASE_ID && + manifest.releaseId !== runtime.config.RELEASE_ID + ) { + mismatchCode = "MANIFEST_RELEASE_MISMATCH"; + } + if ( + !mismatchCode && + options.expectedAssetManifestHash && + manifest.assetManifestHash !== options.expectedAssetManifestHash + ) { + mismatchCode = "MANIFEST_ASSET_MISMATCH"; + } + if (mismatchCode) { + throw new ReleaseManifestError(mismatchCode, identity); + } + + if (manifest.schemaVersion === 2 && manifest.contractSet) { + const verification = await verifyContractSet({ + expected: + options.expectedContractSetPackages ?? + (EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[]), + manifest: manifest.contractSet, + }); + if (!verification.ok) { + throw new ReleaseManifestError(verification.code, identity); + } + } + + return manifest; +} diff --git a/src/bootstrap/load-runtime-config.ts b/src/bootstrap/load-runtime-config.ts new file mode 100644 index 0000000..af0f815 --- /dev/null +++ b/src/bootstrap/load-runtime-config.ts @@ -0,0 +1,150 @@ +import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.ts"; +import { + BOOT_JSON_POLICIES, + readBoundedBootJson, + type BootLoadFailure, +} from "./read-bounded-boot-json.ts"; +import { + validateRuntimeConfig, + type RuntimeConfig, +} from "./runtime-config-schema.ts"; + +/** + * §6.6. A safe boot error carries the failure kind, the build identity and a + * support reference. It never carries a URL, a response body, a validation + * value, an endpoint hostname or a stack trace. + */ +export type BootConfigSafe = Readonly<{ + kind: "BOOT_CONFIG_FAILURE"; + code: string; + buildId: string; + configSchemaVersion?: string; + releaseId?: string; + supportReference: string; +}>; + +type BootConfigSafeInput = Readonly<{ + buildId: string; + configSchemaVersion?: string; + releaseId?: string; +}>; + +export class BootConfigError extends Error { + readonly kind = "BOOT_CONFIG_FAILURE" as const; + readonly code: string; + readonly safe: BootConfigSafe; + + constructor(code: string, safe: BootConfigSafeInput) { + super("Runtime configuration could not be loaded"); + this.name = "BootConfigError"; + this.code = code; + this.safe = Object.freeze({ + kind: this.kind, + code, + buildId: safe.buildId, + configSchemaVersion: safe.configSchemaVersion, + releaseId: safe.releaseId, + supportReference: `${safe.buildId}:${code}`, + }); + } +} + +export type RuntimeConfigLoadOptions = Readonly<{ + fetcher?: typeof fetch; + buildConfig?: ReturnType; + now?: () => number; + signal?: AbortSignal; +}>; + +export type RuntimeConfigLoadResult = Readonly<{ + config: RuntimeConfig; + configSchema: "V1" | "V2"; + build: ReturnType; + validationDurationMs: number; +}>; + +const READ_FAILURE_CODE: Readonly> = + Object.freeze({ + FETCH_FAILED: "CONFIG_FETCH_FAILED", + TIMEOUT: "CONFIG_TIMEOUT", + HTTP_STATUS_INVALID: "CONFIG_HTTP_FAILED", + CONTENT_TYPE_INVALID: "CONFIG_CONTENT_TYPE_INVALID", + BODY_TOO_LARGE: "CONFIG_BODY_TOO_LARGE", + UTF8_INVALID: "CONFIG_UTF8_INVALID", + JSON_INVALID: "CONFIG_JSON_INVALID", + SHAPE_INVALID: "CONFIG_SHAPE_INVALID", + SECRET_NAME_REJECTED: "CONFIG_SECRET_NAME_REJECTED", + SCHEMA_INVALID: "CONFIG_SCHEMA_INVALID", + BUILD_MISMATCH: "CONFIG_BUILD_MISMATCH", + RELEASE_MISMATCH: "CONFIG_RELEASE_MISMATCH", + ASSET_MISMATCH: "CONFIG_ASSET_MISMATCH", + CONTRACT_SET_MISMATCH: "CONFIG_CONTRACT_SET_MISMATCH", + }); + +export async function loadRuntimeConfig( + options: RuntimeConfigLoadOptions = {}, +): Promise { + const buildConfig = options.buildConfig ?? getBuildConfig(); + const now = options.now ?? performance.now.bind(performance); + + const outcome = await readBoundedBootJson( + buildConfig.runtimeConfigUrl, + BOOT_JSON_POLICIES.RUNTIME_CONFIG, + { + ...(options.fetcher ? { fetcher: options.fetcher } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }, + ); + + if (!outcome.ok) { + throw new BootConfigError(READ_FAILURE_CODE[outcome.failure], { + buildId: buildConfig.buildId, + }); + } + + const startedAt = now(); + const rawConfig = outcome.value; + + try { + assertSafeConfigNames(rawConfig); + } catch { + throw new BootConfigError("CONFIG_SECRET_NAME_REJECTED", { + buildId: buildConfig.buildId, + }); + } + + const validated = validateRuntimeConfig(rawConfig); + if (!validated.success) { + throw new BootConfigError("CONFIG_SCHEMA_INVALID", { + buildId: buildConfig.buildId, + configSchemaVersion: + typeof rawConfig.CONFIG_SCHEMA_VERSION === "string" + ? rawConfig.CONFIG_SCHEMA_VERSION + : undefined, + releaseId: + typeof rawConfig.RELEASE_ID === "string" + ? rawConfig.RELEASE_ID + : undefined, + }); + } + + if ( + validated.data.BUILD_ID && + validated.data.BUILD_ID !== buildConfig.buildId + ) { + throw new BootConfigError("CONFIG_BUILD_MISMATCH", { + buildId: buildConfig.buildId, + configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION, + releaseId: validated.data.RELEASE_ID, + }); + } + + // §6.10: the validated snapshot is frozen. Nothing re-reads or mutates it; + // a kill-switch change only takes effect on a new boot. + return Object.freeze({ + config: validated.data, + configSchema: validated.schema, + build: buildConfig, + validationDurationMs: now() - startedAt, + }); +} diff --git a/src/bootstrap/main.tsx b/src/bootstrap/main.tsx new file mode 100644 index 0000000..bc5509e --- /dev/null +++ b/src/bootstrap/main.tsx @@ -0,0 +1,41 @@ +import { createRoot } from "react-dom/client"; + +import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.ts"; +import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.tsx"; +import "../presentation/styles/theme.css"; +import { createRuntimeComposition } from "./create-runtime-composition.ts"; +import { initializeColorScheme } from "./initialize-color-scheme.ts"; +import { ReleaseManifestError } from "./load-release-manifest.ts"; +import { BootConfigError } from "./load-runtime-config.ts"; +import { RuntimeApplication } from "./runtime-application.tsx"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Missing #root mount element"); +} + +const root = createRoot(rootElement); + +async function boot(): Promise { + try { + const composition = await createRuntimeComposition(); + document.documentElement.dataset.buildId = composition.release.buildId; + document.documentElement.dataset.releaseId = composition.release.releaseId; + initializeColorScheme(composition.application.preferences); + root.render(); + import.meta.hot?.dispose(() => { + void composition.dispose(); + }); + } catch (error: unknown) { + const safe = + error instanceof BootConfigError || error instanceof ReleaseManifestError + ? error.safe + : { supportReference: "boot:unknown" }; + + recordBootFailure(error, safe); + root.render(); + } +} + +void boot(); diff --git a/src/bootstrap/optional-runtime-host.ts b/src/bootstrap/optional-runtime-host.ts new file mode 100644 index 0000000..d6c465d --- /dev/null +++ b/src/bootstrap/optional-runtime-host.ts @@ -0,0 +1,152 @@ +import { + createBrowserLifecycleRuntime, + type BrowserLifecycleRuntime, +} from "../adapters/platform/browser-lifecycle.ts"; +import type { + ResolvedRuntimeCapabilities, + RuntimeHealth, + RuntimeStopReason, +} from "../contracts/runtime-capabilities.ts"; +import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts"; +import { createServiceWorkerRuntimeHost } from "./register-service-worker.ts"; + +/** + * §3.4 / §20.3. Optional runtime host. + * + * Creating this object has no side effect: no listener, timer, network call, + * IndexedDB open or worker is created until `startAfterMount()` runs from the + * first committed React effect. `stop()` unwinds in reverse order. + */ + +export type OptionalRuntimeHost = Readonly<{ + readonly realtime: null; + readonly webWorkers: null; + readonly serviceWorker: ServiceWorkerRuntimeHost | null; + readonly offlineCommands: null; + browserLifecycle(): BrowserLifecycleRuntime | null; + health(): Readonly>; + startAfterMount(): Promise; + stop(reason?: RuntimeStopReason): Promise; +}>; + +export type OptionalRuntimeHostInput = Readonly<{ + capabilities: ResolvedRuntimeCapabilities; + routerBasePath: string; + buildId: string; + /** Explicit host seam for deterministic lifecycle tests and platform shells. */ + serviceWorkerHost?: ServiceWorkerRuntimeHost | null; + browserLifecycleHost?: Parameters[0]; + host?: Parameters[0]["host"]; + blockers?: readonly (() => boolean)[]; + observe?: (observation: Readonly<{ event: string; outcome: string }>) => void; +}>; + +export function createOptionalRuntimeHost( + input: OptionalRuntimeHostInput, +): OptionalRuntimeHost { + const { capabilities } = input; + + const serviceWorker = Object.hasOwn(input, "serviceWorkerHost") + ? (input.serviceWorkerHost ?? null) + : createServiceWorkerRuntimeHost({ + capabilities, + routerBasePath: input.routerBasePath, + buildId: input.buildId, + ...(input.host ? { host: input.host } : {}), + ...(input.blockers ? { blockers: input.blockers } : {}), + ...(input.observe ? { observe: input.observe } : {}), + }); + + let lifecycle: BrowserLifecycleRuntime | null = null; + let started = false; + let startPromise: Promise | null = null; + let stopPromise: Promise | null = null; + let stopRequested = false; + const health: Record = { + realtime: capabilities.realtime.length === 0 ? "DISABLED" : "UNAVAILABLE", + webWorkers: capabilities.webWorkers.length === 0 ? "DISABLED" : "UNAVAILABLE", + serviceWorker: serviceWorker ? "UNAVAILABLE" : "DISABLED", + offlineCommands: capabilities.offlineCommands ? "UNAVAILABLE" : "DISABLED", + }; + + /** + * §3.4 start order: + * 1. offline foreground browser lifecycle observer + * 2. realtime runtime + * 3. no Web Worker prewarm + * 4. Service Worker active/cleanup controller + */ + async function startAfterMount(): Promise { + // A stopped host is terminal. Its children (notably the Service Worker page + // controller) own one-shot listeners and timers and cannot be resurrected. + if (stopPromise) return stopPromise; + if (startPromise) return startPromise; + startPromise = (async () => { + // 1. The lifecycle observer is the single window listener owner. It is + // only created when something downstream can actually consume it. + if (serviceWorker || capabilities.realtime.length > 0) { + lifecycle = createBrowserLifecycleRuntime(input.browserLifecycleHost); + } + + // 2. Realtime stays NOT_SELECTED until a product contribution exists + // (§13.6), so there is nothing to start and nothing to observe. + + // 3. Web Workers are created lazily on first task; there is no prewarm. + + // 4. Service Worker registration or the exact-owned cleanup action. + if (serviceWorker) { + const outcome = await serviceWorker.start(); + // `stop()` may have fenced this generation while start was awaiting a + // browser operation. A late result must never reactivate health. + if (!stopRequested) { + health.serviceWorker = + outcome.kind === "ACTIVE" + ? "AVAILABLE" + : outcome.kind === "DISABLED" + ? "DISABLED" + : outcome.kind === "INCOMPATIBLE" + ? "INCOMPATIBLE" + : "DEGRADED"; + } + } + if (!stopRequested) started = true; + })(); + return startPromise; + } + + async function stop( + reason: RuntimeStopReason = "APPLICATION_SHUTDOWN", + ): Promise { + void reason; + stopRequested = true; + if (stopPromise) return stopPromise; + stopPromise = (async () => { + // Serialize teardown behind any in-flight browser registration. Cleanup + // then observes the final acquired resources and unwinds them exactly once. + await startPromise?.catch(() => {}); + // Reverse of the start order. + if (serviceWorker && (started || startPromise)) { + await serviceWorker.stop().catch(() => {}); + } + if (serviceWorker) { + health.serviceWorker = "DISABLED"; + } + lifecycle?.dispose(); + lifecycle = null; + started = false; + startPromise = null; + })(); + return stopPromise; + } + + return Object.freeze({ + realtime: null, + webWorkers: null, + serviceWorker, + offlineCommands: null, + browserLifecycle: () => lifecycle, + health: () => Object.freeze({ ...health }), + startAfterMount, + stop, + }); +} diff --git a/src/bootstrap/read-bounded-boot-json.ts b/src/bootstrap/read-bounded-boot-json.ts new file mode 100644 index 0000000..161964d --- /dev/null +++ b/src/bootstrap/read-bounded-boot-json.ts @@ -0,0 +1,189 @@ +/** + * §6.4–§6.5. The only boot-time JSON reader. + * + * `response.json()` and unbounded `response.text()` are prohibited: a hostile or + * misconfigured origin must not be able to amplify boot memory, and an HTML + * error page must not reach `JSON.parse` as if it were configuration. + */ + +export type BootJsonOperation = "RUNTIME_CONFIG" | "RELEASE_MANIFEST"; + +export interface BootJsonPolicy { + readonly operation: BootJsonOperation; + readonly maximumBytes: number; + readonly totalDeadlineMs: 5_000; +} + +export const BOOT_JSON_POLICIES = Object.freeze({ + RUNTIME_CONFIG: Object.freeze({ + operation: "RUNTIME_CONFIG" as const, + maximumBytes: 65_536, + totalDeadlineMs: 5_000 as const, + }), + RELEASE_MANIFEST: Object.freeze({ + operation: "RELEASE_MANIFEST" as const, + maximumBytes: 1_048_576, + totalDeadlineMs: 5_000 as const, + }), +} satisfies Readonly>); + +export type BootLoadFailure = + | "FETCH_FAILED" + | "TIMEOUT" + | "HTTP_STATUS_INVALID" + | "CONTENT_TYPE_INVALID" + | "BODY_TOO_LARGE" + | "UTF8_INVALID" + | "JSON_INVALID" + | "SHAPE_INVALID" + | "SECRET_NAME_REJECTED" + | "SCHEMA_INVALID" + | "BUILD_MISMATCH" + | "RELEASE_MISMATCH" + | "ASSET_MISMATCH" + | "CONTRACT_SET_MISMATCH"; + +export type BootJsonOutcome = + | Readonly<{ ok: true; value: Readonly> }> + | Readonly<{ ok: false; failure: BootLoadFailure }>; + +export type ReadBoundedBootJsonOptions = Readonly<{ + fetcher?: typeof fetch; + signal?: AbortSignal; +}>; + +function isJsonMediaType(headerValue: string | null): boolean { + if (!headerValue) return false; + const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return essence === "application/json" || essence.endsWith("+json"); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export async function readBoundedBootJson( + url: string, + policy: BootJsonPolicy, + options: ReadBoundedBootJsonOptions = {}, +): Promise { + if (options.signal?.aborted) return fail("FETCH_FAILED"); + + const fetcher = options.fetcher ?? fetch; + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, policy.totalDeadlineMs); + const forwardAbort = () => controller.abort(); + options.signal?.addEventListener("abort", forwardAbort, { once: true }); + + try { + let response: Response; + try { + response = await fetcher(url, { + method: "GET", + cache: "no-store", + credentials: "same-origin", + redirect: "error", + referrerPolicy: "no-referrer", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + } catch { + return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED"); + } + + if (response.status !== 200 || response.redirected) { + await discard(response); + return fail("HTTP_STATUS_INVALID"); + } + if (!isJsonMediaType(response.headers.get("content-type"))) { + await discard(response); + return fail("CONTENT_TYPE_INVALID"); + } + + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > policy.maximumBytes) { + await discard(response); + return fail("BODY_TOO_LARGE"); + } + + const bytes = await readBoundedBytes(response, policy.maximumBytes, controller); + if (bytes === "TOO_LARGE") return fail("BODY_TOO_LARGE"); + if (bytes === "STREAM_FAILED") return fail(timedOut ? "TIMEOUT" : "FETCH_FAILED"); + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return fail("UTF8_INVALID"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return fail("JSON_INVALID"); + } + if (!isRecord(parsed)) return fail("SHAPE_INVALID"); + + return Object.freeze({ ok: true as const, value: parsed }); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", forwardAbort); + } +} + +async function readBoundedBytes( + response: Response, + maximumBytes: number, + controller: AbortController, +): Promise { + const body = response.body; + if (!body) { + // A body-less 200 cannot satisfy any boot document. + return new Uint8Array(0); + } + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maximumBytes) { + await reader.cancel().catch(() => {}); + controller.abort(); + return "TOO_LARGE"; + } + chunks.push(value); + } + } catch { + await reader.cancel().catch(() => {}); + return "STREAM_FAILED"; + } + + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +async function discard(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Cancelling an already-settled body is not a boot failure. + } +} + +function fail(failure: BootLoadFailure): BootJsonOutcome { + return Object.freeze({ ok: false as const, failure }); +} diff --git a/src/bootstrap/register-service-worker.ts b/src/bootstrap/register-service-worker.ts new file mode 100644 index 0000000..ff87d96 --- /dev/null +++ b/src/bootstrap/register-service-worker.ts @@ -0,0 +1,57 @@ +import type { + ResolvedRuntimeCapabilities, +} from "../contracts/runtime-capabilities.ts"; +import type { ServiceWorkerRuntimeHost } from "../contracts/service-worker.ts"; +import { createServiceWorkerPageController } from "../adapters/service-worker/service-worker-page-controller.ts"; + +/** + * §17.5. Composition-side factory. The host object is created without any + * side effect; `start()` is only called from the post-mount runtime starter, + * after Runtime Config, the release manifest and the contract set have all + * validated and React has committed its first render. + */ + +export type ServiceWorkerHostInput = Readonly<{ + capabilities: ResolvedRuntimeCapabilities; + routerBasePath: string; + buildId: string; + host?: Readonly<{ + location?: Pick; + navigator?: Pick; + caches?: CacheStorage; + document?: Pick; + }>; + blockers?: readonly (() => boolean)[]; + observe?: (observation: Readonly<{ event: string; outcome: string }>) => void; +}>; + +export function createServiceWorkerRuntimeHost( + input: ServiceWorkerHostInput, +): ServiceWorkerRuntimeHost | null { + const { capabilities } = input; + + // §3.6. `null` selection with no disable-cleanup obligation means zero + // registration lookups and zero Cache Storage access: no host at all. + if (!capabilities.serviceWorker && !capabilities.serviceWorkerDisabledCleanup) { + return null; + } + + const host = + input.host ?? + (globalThis as unknown as NonNullable); + const container = host?.navigator?.serviceWorker; + const origin = host?.location?.origin; + if (!origin) return null; + + return createServiceWorkerPageController({ + selection: capabilities.serviceWorker, + disabledCleanup: capabilities.serviceWorkerDisabledCleanup, + routerBasePath: input.routerBasePath, + origin, + buildId: input.buildId, + ...(container ? { container } : {}), + ...(host?.caches ? { caches: host.caches } : {}), + ...(input.blockers ? { blockers: input.blockers } : {}), + ...(input.observe ? { observe: input.observe } : {}), + }); +} diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts new file mode 100644 index 0000000..f0b5bbb --- /dev/null +++ b/src/bootstrap/runtime-adapters.ts @@ -0,0 +1,420 @@ +import { + createDemoSessionAdapter, + createExternalAuthSessionAdapter, + createUnavailableSessionAdapter, + type ExternalSessionOwner, +} from "../adapters/auth/external-session-adapter.ts"; +import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts"; +import { createHttpClient } from "../adapters/http/client.ts"; +import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts"; +import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts"; +import { + createTanStackCacheCoordinator, +} from "../adapters/query-cache/tanstack-cache-coordinator.ts"; +import { createQueryClient } from "../adapters/query-cache/tanstack-query-cache.ts"; +import { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts"; +import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts"; +import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts"; +import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts"; +import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts"; +import type { AuthSessionPort } from "../application/ports/auth-session-port.ts"; +import type { ReleaseInfo } from "../application/ports/release-info-port.ts"; +import { createRestProviderProfile } from "../contracts/rest-profiles.ts"; +import type { ClockPort } from "../application/ports/clock-port.ts"; +import type { MutationIntent } from "../contracts/mutation-intent.ts"; +import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts"; +import { + INVALIDATION_REGISTRY, + INVALIDATION_TOPIC_VERSIONS, +} from "../features/installed-feature-contracts.ts"; +import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; +import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts"; +import { + fetchReleaseManifest, + type ReleaseManifest, +} from "./load-release-manifest.ts"; +import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts"; +import { createServerStateGenerationStore } from "./server-state-generation-store.ts"; +import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../features/installed-contract-contributions.ts"; +import { + indexInvalidationRegistry, + indexInvalidationTopicVersions, +} from "../contracts/query-invalidation.ts"; + +type HttpClientDependencies = Parameters[0]; +export type RuntimeHttpContract = Pick< + HttpClientDependencies, + "getOperation" | "validatePayload" | "validateRequest" | "mapPayload" +>; + +type RuntimeHttpContext = Readonly<{ + runtime: RuntimeConfigLoadResult; + authSession: AuthSessionPort; + fetcher?: typeof fetch; + clock?: ClockPort; + scheduler?: HttpClientDependencies["scheduler"]; + diagnostics?: HttpClientDependencies["diagnostics"]; + telemetry?: HttpClientDependencies["telemetry"]; +}>; + +export type RuntimeAdaptersContext = Readonly<{ + runtime: RuntimeConfigLoadResult; + release: Readonly; + host?: Record; + fetcher?: typeof fetch; +}>; + +/** + * §5.2. The composition root is where a manifest becomes release info. A V2 + * manifest states contract identity as a verified contract set and a V1 + * manifest as the legacy scalar; the application layer sees one shape and never + * branches on the schema version to find the identity. + */ +function toReleaseInfo(manifest: Readonly): ReleaseInfo { + const { contractSet, legacyApiContractVersion, ...rest } = + structuredClone(manifest); + return Object.freeze({ + ...rest, + ...(legacyApiContractVersion === undefined + ? {} + : { apiContractVersion: legacyApiContractVersion }), + ...(contractSet === null + ? {} + : { contractSetDigest: contractSet.setDigest }), + }); +} + +const EXTERNAL_OWNER_METHODS = Object.freeze([ + "readState", + "subscribe", + "beginSignIn", + "signOut", + "attachCredential", + "recoverSession", + "notifyUnauthenticated", +] as const); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function externalOwnerFrom( + host: Record, +): ExternalSessionOwner | null { + const candidate = host.__CA_FRONTEND_AUTH_OWNER__; + if (!isRecord(candidate)) return null; + return EXTERNAL_OWNER_METHODS.every( + (name) => typeof candidate[name] === "function", + ) + ? (candidate as ExternalSessionOwner) + : null; +} + +function hostValue( + host: Record, + property: string, +): unknown { + try { + return Reflect.get(host, property); + } catch { + return undefined; + } +} + +function storageOrUndefined(value: unknown): Storage | undefined { + if (!value || typeof value !== "object") return undefined; + const candidate = value as Record; + try { + return ["getItem", "setItem", "removeItem"].every( + (method) => typeof Reflect.get(candidate, method) === "function", + ) + ? (value as Storage) + : undefined; + } catch { + return undefined; + } +} + +/** + * Runtime-aware transport factory. Feature gateway composition calls this + * factory when a registered API capability is installed. + */ +export function createRuntimeHttpClient( + context: RuntimeHttpContext, + contract: RuntimeHttpContract = {}, +) { + return createHttpClient({ + baseUrl: context.runtime.config.API_BASE_URL, + timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS, + maxRetryAttempts: context.runtime.config.MAX_RETRY_ATTEMPTS, + authSession: context.authSession, + providerProfile: createRestProviderProfile( + "PRIMARY_API", + context.runtime.config.API_BASE_URL, + ["omit"], + ), + fetcher: context.fetcher, + clock: context.clock, + scheduler: context.scheduler, + diagnostics: context.diagnostics, + telemetry: context.telemetry, + ...contract, + }); +} + +export async function createRuntimeAdapters( + context: RuntimeAdaptersContext, +) { + const host = + context.host ?? (globalThis as unknown as Record); + const config = context.runtime.config; + const mutationIntentFactory = createBrowserMutationIntentFactory(); + const externalOwner = externalOwnerFrom(host); + const authSession = + config.AUTH_MODE === "demo" + ? createDemoSessionAdapter() + : externalOwner + ? createExternalAuthSessionAdapter(externalOwner) + : createUnavailableSessionAdapter(); + const diagnostics = createDiagnosticsAdapter(); + const telemetry = createTelemetryAdapter({ + enabled: config.TELEMETRY_ENABLED, + endpoint: config.TELEMETRY_ENDPOINT, + fetcher: context.fetcher, + onDrop(event) { + const attributes = + event.attributes && typeof event.attributes === "object" + ? event.attributes + : {}; + diagnostics.record({ + level: "warn", + eventId: "telemetry.delivery.dropped", + context: attributes, + }); + }, + }); + const invalidationIndex = indexInvalidationRegistry(INVALIDATION_REGISTRY); + const invalidationTopicVersions = indexInvalidationTopicVersions( + INVALIDATION_REGISTRY, + INVALIDATION_TOPIC_VERSIONS, + ); + const conditionalValidators = createConditionalValidatorStore(); + const serverStateGeneration = createServerStateGenerationStore(() => { + const queryClient = createQueryClient({ diagnostics }); + const crossContextInvalidation = + createBrowserCrossContextInvalidationFromHost({ + ...(context.host === undefined ? {} : { host: context.host }), + cacheEpoch: `release.${context.release.releaseId}`, + topics: [...invalidationTopicVersions].map(([topic, topicVersion]) => + Object.freeze({ + topic, + topicVersion, + }), + ), + observe(observation) { + if ( + observation.outcome !== "FAILED" && + observation.outcome !== "DEGRADED" + ) { + return; + } + diagnostics.record({ + level: "warn", + eventId: "cache.operation.failed", + context: { + operation: observation.operation, + outcome: observation.outcome, + reason: observation.reason, + }, + }); + }, + }); + const queryInvalidation = createTanStackCacheCoordinator({ + queryClient, + invalidationIndex, + topicVersions: invalidationTopicVersions, + crossContext: crossContextInvalidation, + diagnostics, + }); + return Object.freeze({ + queryClient, + queryInvalidation, + crossContextStatus: () => + crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY", + }); + }); + const serverStateScope = createServerStateScopeRuntime({ + session: authSession, + queryInvalidation: { + resetLocal: () => serverStateGeneration.resetCurrent(), + }, + participants: [ + { + order: 4, + label: "conditional-validators", + close: () => conditionalValidators.clear(), + }, + ], + activateNextGeneration: () => serverStateGeneration.activateNext(), + }); + const storage = createBrowserStorageAdapter({ + localStorage: storageOrUndefined(hostValue(host, "localStorage")), + sessionStorage: storageOrUndefined( + hostValue(host, "sessionStorage"), + ), + diagnostics, + }); + const releaseInfo = Object.freeze({ + async getCurrent() { + return toReleaseInfo(context.release); + }, + async refresh() { + return toReleaseInfo( + await fetchReleaseManifest(config.RELEASE_MANIFEST_URL, { + fetcher: context.fetcher, + buildId: context.release.buildId, + releaseId: context.release.releaseId, + }), + ); + }, + }); + const runtimeCapabilities = Object.freeze({ + getSnapshot() { + return describeRuntimeCapabilities( + INSTALLED_RUNTIME_CAPABILITIES, + config.CAPABILITY_OVERRIDES, + ); + }, + }); + const navigation = Object.freeze({ + reload() { + const location = host.location; + if (!isRecord(location) || typeof location.reload !== "function") { + throw new Error("Browser reload is unavailable"); + } + location.reload(); + }, + }); + const contractHttp = createContractHttpExecutor({ + baseUrl: config.API_BASE_URL, + maxRetryAttempts: config.MAX_RETRY_ATTEMPTS, + fetcher: context.fetcher, + async attachCredentials(operation) { + if (serverStateScope.getPhase() !== "READY") { + return Object.freeze({ kind: "SCOPE_FENCED" as const }); + } + const state = authSession.getState(); + if (state === "integration-failed") { + return Object.freeze({ kind: "UNAVAILABLE" as const }); + } + if (state !== "authenticated") { + return Object.freeze({ kind: "UNAUTHENTICATED" as const }); + } + try { + const patch = await authSession.credentialPatch({ + origin: new URL(config.API_BASE_URL).origin, + method: operation.method, + operationId: operation.operationId, + }); + if (serverStateScope.getPhase() !== "READY") { + return Object.freeze({ kind: "SCOPE_FENCED" as const }); + } + return Object.freeze({ + kind: "READY" as const, + headers: patch.headers, + credentials: "omit" as const, + }); + } catch { + return Object.freeze({ kind: "UNAVAILABLE" as const }); + } + }, + observe(observation) { + try { + diagnostics.record({ + level: + observation.outcome === "SUCCESS" ? "info" : "warn", + eventId: "http.request.completed", + context: { + operation_id: observation.diagnosticsOperation, + outcome: observation.outcome, + attempts: observation.attempts, + certainty: observation.certainty, + }, + }); + } catch { + // Diagnostics cannot change a contract execution outcome. + } + }, + }); + const contractOperations = Object.freeze({ + async execute( + operationId: string, + input: unknown, + executionContext: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }> = {}, + ) { + const operation = + COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId); + if (!operation) { + return Object.freeze({ + kind: "CONTRACT_VIOLATION" as const, + effect: "NOT_STARTED" as const, + violation: Object.freeze({ + kind: "FINAL_REQUEST_INVARIANT_FAILED" as const, + operation: "REQUEST" as const, + }), + }); + } + const outcome = await contractHttp.execute(operation, input, { + scope: serverStateScope.getSnapshot(), + ...(executionContext.signal === undefined + ? {} + : { signal: executionContext.signal }), + ...(executionContext.intent === undefined + ? {} + : { intent: executionContext.intent }), + }); + if (outcome.kind === "UNAUTHENTICATED") { + authSession.onUnauthenticated(); + } + return outcome; + }, + }); + const featureInputs = createInstalledFeatureInputs({ + contractOperations, + }); + + return Object.freeze({ + outputPorts: Object.freeze({ + session: authSession, + preferences: storage, + diagnostics, + telemetry, + releaseInfo, + runtimeCapabilities, + navigation, + }), + infrastructure: Object.freeze({ + get queryClient() { + return serverStateGeneration.getSnapshot().queryClient; + }, + get queryInvalidation() { + return serverStateGeneration.getSnapshot().queryInvalidation; + }, + serverStateGeneration, + serverStateScope, + mutationIntentFactory, + conditionalValidators, + crossContextInvalidationStatus: () => + serverStateGeneration.getSnapshot().crossContextStatus(), + dispose() { + conditionalValidators.clear(); + serverStateScope.dispose(); + serverStateGeneration.dispose(); + }, + }), + featureInputs, + }); +} diff --git a/src/bootstrap/runtime-application.tsx b/src/bootstrap/runtime-application.tsx new file mode 100644 index 0000000..475eeed --- /dev/null +++ b/src/bootstrap/runtime-application.tsx @@ -0,0 +1,55 @@ +import { StrictMode, useEffect } from "react"; + +import { ApplicationProvider } from "../presentation/providers/application-provider.tsx"; +import { ServerStateGenerationProvider } from "../presentation/adapters/query/server-state-generation-provider.tsx"; +import { AppRouter } from "../presentation/routes/app-router.tsx"; +import type { RuntimeComposition } from "./create-runtime-composition.ts"; + +/** + * Production provider tree. Tests import this component so the validated + * composition is proven against the same provider order used by main. + */ +/** + * §6.7 step 16 / §17.5. Optional runtime starts from the first committed + * effect, never during render and never during composition. StrictMode double + * invocation is safe: `startAfterMount` returns the same in-flight promise. + */ +function PostMountRuntimeStarter({ + composition, +}: Readonly<{ composition: RuntimeComposition }>) { + useEffect(() => { + void composition.optional.startAfterMount(); + }, [composition]); + return null; +} + +export function RuntimeApplication({ + composition, +}: Readonly<{ composition: RuntimeComposition }>) { + return ( + + + } + > + + + + + + + ); +} diff --git a/src/bootstrap/runtime-config-schema.ts b/src/bootstrap/runtime-config-schema.ts new file mode 100644 index 0000000..a7b1d68 --- /dev/null +++ b/src/bootstrap/runtime-config-schema.ts @@ -0,0 +1,138 @@ +import { + runtimeConfigV1ArtifactSchema, + runtimeConfigV2ArtifactSchema, + type CapabilityOverrideArtifact, + type RuntimeConfigV1Artifact, + type RuntimeConfigV2Artifact, +} from "../contracts/release-artifacts.ts"; + +export { isValidReleaseManifestUrl } from "../contracts/release-artifacts.ts"; + +export type CapabilityOverrides = CapabilityOverrideArtifact; + +/** + * §6.1. Runtime Config V2 is deployment and browser operational setting only. + * `API_CONTRACT_VERSION` is gone: a scalar cannot describe a multi-package + * contract set, and Release Manifest V2 `contractSet` owns that meaning. + */ +export const runtimeConfigV2Schema = runtimeConfigV2ArtifactSchema; + +/** + * §5.8 / §24.6 RC-2. The V1 reader is retained for one compatibility window so + * a release never swaps source shape, manifest shape and runtime behaviour at + * the same time. Only a V1 document may carry the scalar contract version. + */ +export const runtimeConfigV1Schema = runtimeConfigV1ArtifactSchema; + +export type RuntimeConfigV2 = RuntimeConfigV2Artifact; +export type RuntimeConfigV1 = RuntimeConfigV1Artifact; + +/** + * The composition-facing shape. V1 documents are normalized onto it so the rest + * of the runtime never branches on config schema version. + */ +export type RuntimeConfig = Readonly<{ + APP_ENV: RuntimeConfigV2["APP_ENV"]; + API_BASE_URL: string; + REQUEST_TIMEOUT_MS: number; + MAX_RETRY_ATTEMPTS: number; + TELEMETRY_ENABLED: boolean; + TELEMETRY_ENDPOINT?: string; + AUTH_MODE: RuntimeConfigV2["AUTH_MODE"]; + CONFIG_SCHEMA_VERSION: string; + RELEASE_MANIFEST_URL: string; + RELEASE_ID?: string; + BUILD_ID?: string; + CAPABILITY_OVERRIDES: CapabilityOverrides; + /** Present only while a V1 document is still accepted. */ + LEGACY_API_CONTRACT_VERSION?: string; +}>; + +export type RuntimeConfigValidation = + | Readonly<{ success: true; data: RuntimeConfig; schema: "V1" | "V2" }> + | Readonly<{ + success: false; + issues: readonly Readonly<{ path: string; code: string }>[]; + }>; + +const DEFAULT_OVERRIDES: CapabilityOverrides = Object.freeze({ + REALTIME: "DEFAULT" as const, + WEB_WORKER: "DEFAULT" as const, + SERVICE_WORKER: "DEFAULT" as const, + OFFLINE_COMMANDS: "DEFAULT" as const, +}); + +function canonicalUrl(value: string): string { + return new URL(value).href; +} + +export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation { + const declared = + value && typeof value === "object" + ? (value as Record).CONFIG_SCHEMA_VERSION + : undefined; + + // §5.8: no precedence between V1 and V2. The declared version selects exactly + // one parser, and a V2 document carrying the removed scalar is rejected. + if (declared !== "1" && declared !== "2.0") { + return Object.freeze({ + success: false as const, + issues: Object.freeze([ + Object.freeze({ + path: "CONFIG_SCHEMA_VERSION", + code: "unsupported_value", + }), + ]), + }); + } + + const isV2 = declared === "2.0"; + const result = isV2 + ? runtimeConfigV2Schema.safeParse(value) + : runtimeConfigV1Schema.safeParse(value); + + if (!result.success) { + return Object.freeze({ + success: false as const, + issues: Object.freeze( + result.error.issues.map((issue) => + Object.freeze({ path: issue.path.join("."), code: issue.code }), + ), + ), + }); + } + + const parsed = result.data; + const normalized: RuntimeConfig = Object.freeze({ + APP_ENV: parsed.APP_ENV, + API_BASE_URL: canonicalUrl(parsed.API_BASE_URL), + REQUEST_TIMEOUT_MS: parsed.REQUEST_TIMEOUT_MS, + MAX_RETRY_ATTEMPTS: parsed.MAX_RETRY_ATTEMPTS, + TELEMETRY_ENABLED: parsed.TELEMETRY_ENABLED, + ...(parsed.TELEMETRY_ENDPOINT + ? { TELEMETRY_ENDPOINT: canonicalUrl(parsed.TELEMETRY_ENDPOINT) } + : {}), + AUTH_MODE: parsed.AUTH_MODE, + CONFIG_SCHEMA_VERSION: parsed.CONFIG_SCHEMA_VERSION, + RELEASE_MANIFEST_URL: parsed.RELEASE_MANIFEST_URL, + ...(parsed.RELEASE_ID ? { RELEASE_ID: parsed.RELEASE_ID } : {}), + ...(parsed.BUILD_ID ? { BUILD_ID: parsed.BUILD_ID } : {}), + CAPABILITY_OVERRIDES: Object.freeze({ + ...(isV2 + ? (parsed as RuntimeConfigV2).CAPABILITY_OVERRIDES + : DEFAULT_OVERRIDES), + }), + ...(isV2 + ? {} + : { + LEGACY_API_CONTRACT_VERSION: (parsed as RuntimeConfigV1) + .API_CONTRACT_VERSION, + }), + }); + + return Object.freeze({ + success: true as const, + data: normalized, + schema: isV2 ? ("V2" as const) : ("V1" as const), + }); +} diff --git a/src/bootstrap/server-state-generation-store.ts b/src/bootstrap/server-state-generation-store.ts new file mode 100644 index 0000000..450290e --- /dev/null +++ b/src/bootstrap/server-state-generation-store.ts @@ -0,0 +1,74 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { CrossContextInvalidationStatus } from "../adapters/cross-context-invalidation/index.ts"; +import type { QueryInvalidationCoordinator } from "../contracts/query-invalidation.ts"; + +export type ServerStateGenerationResources = Readonly<{ + queryClient: QueryClient; + queryInvalidation: QueryInvalidationCoordinator; + crossContextStatus(): CrossContextInvalidationStatus; +}>; + +export type ServerStateGenerationSnapshot = + ServerStateGenerationResources & Readonly<{ generation: number }>; + +export type ServerStateGenerationStore = Readonly<{ + getSnapshot(): ServerStateGenerationSnapshot; + subscribe(listener: () => void): () => void; + resetCurrent(): Promise; + activateNext(): void; + dispose(): void; +}>; + +export function createServerStateGenerationStore( + createResources: (generation: number) => ServerStateGenerationResources, +): ServerStateGenerationStore { + const listeners = new Set<() => void>(); + let disposed = false; + let current = snapshot(1, createResources(1)); + + function publish(): void { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + // Provider defects cannot change generation ownership. + } + } + } + + return Object.freeze({ + getSnapshot: () => current, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async resetCurrent() { + if (disposed) throw new TypeError("Server-state generations are disposed."); + await current.queryInvalidation.resetLocal(); + }, + activateNext() { + if (disposed) throw new TypeError("Server-state generations are disposed."); + const previous = current; + previous.queryInvalidation.dispose(); + previous.queryClient.clear(); + const generation = previous.generation + 1; + current = snapshot(generation, createResources(generation)); + publish(); + }, + dispose() { + if (disposed) return; + disposed = true; + listeners.clear(); + current.queryInvalidation.dispose(); + current.queryClient.clear(); + }, + }); +} + +function snapshot( + generation: number, + resources: ServerStateGenerationResources, +): ServerStateGenerationSnapshot { + return Object.freeze({ generation, ...resources }); +} diff --git a/src/contracts/api-operations.ts b/src/contracts/api-operations.ts new file mode 100644 index 0000000..bcd220b --- /dev/null +++ b/src/contracts/api-operations.ts @@ -0,0 +1,208 @@ +export type ApiOperation = Readonly<{ + method: string; + path: string; + operationId: string; + auth: "none" | "external-session"; + timeoutMs: number | null; + idempotency: "safe" | "keyed" | "none"; + retry: "runtime" | "never"; + requestSource: "search" | "body" | "none"; + requestSchema: string; + responseSchema: string; + owner: string; + contractVersion?: 2; + protocol?: "REST"; + semantics?: "QUERY" | "COMMAND"; + replayPolicy?: "SAFE" | "IDEMPOTENT" | "KEYED_COMMAND" | "NON_REPLAYABLE"; + idempotencyKeyPolicy?: "NONE" | "REQUIRED"; + mapperId?: string; + successStatuses?: readonly number[]; + responseMediaTypes?: readonly string[]; + maxResponseBytes?: number; + providerId?: string; + authProfileId?: string; + csrfProfileId?: string; + pathSchema?: string; + pathParameterNames?: readonly string[]; + maxEncodedSearchBytes?: number; +}>; + +export type RestOperationV2 = ApiOperation & + Readonly<{ + contractVersion: 2; + protocol: "REST"; + semantics: "QUERY" | "COMMAND"; + replayPolicy: + | "SAFE" + | "IDEMPOTENT" + | "KEYED_COMMAND" + | "NON_REPLAYABLE"; + idempotencyKeyPolicy: "NONE" | "REQUIRED"; + mapperId: string; + successStatuses: readonly number[]; + responseMediaTypes: readonly string[]; + maxResponseBytes: number; + providerId: string; + authProfileId: string; + csrfProfileId: string; + pathSchema: string; + pathParameterNames: readonly string[]; + maxEncodedSearchBytes: number; + }>; + +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +const OPERATION_ID = /^[A-Z][A-Z0-9_]{2,79}$/; +const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/; + +export function defineRestOperation( + operation: RestOperationV2, +): RestOperationV2 { + validateRestOperation(operation); + return Object.freeze({ + ...operation, + successStatuses: Object.freeze([...operation.successStatuses]), + responseMediaTypes: Object.freeze([...operation.responseMediaTypes]), + pathParameterNames: Object.freeze([...operation.pathParameterNames]), + }); +} + +export function composeApiOperations( + contributions: readonly Readonly>[], +): Readonly> { + const result: Record = Object.create(null); + for (const contribution of contributions) { + for (const [registryId, operation] of Object.entries(contribution)) { + if (registryId !== operation.operationId) { + throw new TypeError("API operation registry key does not match operationId."); + } + if (Object.hasOwn(result, registryId)) { + throw new TypeError(`Duplicate API operation: ${registryId}`); + } + if (operation.contractVersion === 2) { + validateRestOperation(operation as RestOperationV2); + } + result[registryId] = operation; + } + } + return Object.freeze(result); +} + +export function validateApiRuntimeBindings( + operations: Readonly>, + schemaMetadata: Readonly>>, + schemaCodecs: Readonly>>, + mappers: Readonly< + Record< + string, + Readonly<{ mapperId: string; inputSchemaId: string; maxOutputItems: number }> + > + >, +): true { + for (const operation of Object.values(operations)) { + if (operation.contractVersion !== 2) continue; + for (const schemaId of [ + operation.pathSchema, + operation.requestSchema, + operation.responseSchema, + ]) { + if ( + !schemaId || + schemaMetadata[schemaId]?.schemaId !== schemaId || + schemaCodecs[schemaId]?.schemaId !== schemaId + ) { + throw new TypeError( + `Unresolved API schema binding: ${operation.operationId}`, + ); + } + } + const mapper = mappers[operation.mapperId ?? ""]; + if ( + !mapper || + mapper.mapperId !== operation.mapperId || + mapper.inputSchemaId !== operation.responseSchema || + mapper.maxOutputItems < 1 + ) { + throw new TypeError( + `Unresolved API mapper binding: ${operation.operationId}`, + ); + } + } + return true; +} + +function validateRestOperation(operation: RestOperationV2): void { + if ( + operation.protocol !== "REST" || + !OPERATION_ID.test(operation.operationId) || + !operation.owner || + !operation.mapperId || + !operation.providerId || + !operation.authProfileId || + !operation.csrfProfileId || + !operation.pathSchema || + !operation.path.startsWith("/") || + operation.path.startsWith("//") || + operation.path.includes("?") || + operation.path.includes("#") || + !Number.isSafeInteger(operation.maxResponseBytes) || + operation.maxResponseBytes < 1 || + operation.maxResponseBytes > MAX_RESPONSE_BYTES || + !Number.isSafeInteger(operation.maxEncodedSearchBytes) || + operation.maxEncodedSearchBytes < 0 || + operation.maxEncodedSearchBytes > 32_768 || + operation.successStatuses.length === 0 || + operation.successStatuses.some( + (status) => !Number.isInteger(status) || status < 200 || status > 299, + ) || + new Set(operation.successStatuses).size !== operation.successStatuses.length || + operation.responseMediaTypes.length === 0 || + operation.responseMediaTypes.some( + (value) => !MEDIA_TYPE.test(value) || value !== value.toLowerCase(), + ) + ) { + throw new TypeError(`Invalid REST operation contract: ${operation.operationId}`); + } + const placeholders = [ + ...operation.path.matchAll( + /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g, + ), + ] + .map((match) => match[1] ?? match[2] ?? "") + .sort(); + const codecKeys = [...operation.pathParameterNames].sort(); + if ( + new Set(codecKeys).size !== codecKeys.length || + placeholders.length !== codecKeys.length || + placeholders.some((name, index) => name !== codecKeys[index]) + ) { + throw new TypeError( + `REST path codec does not match its template: ${operation.operationId}`, + ); + } + const isQuery = operation.semantics === "QUERY"; + if ( + (isQuery && !["GET", "HEAD"].includes(operation.method)) || + (isQuery && !["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy)) || + (operation.replayPolicy === "KEYED_COMMAND" && + (operation.idempotencyKeyPolicy !== "REQUIRED" || + operation.idempotency !== "keyed")) || + (operation.replayPolicy === "NON_REPLAYABLE" && + (operation.retry !== "never" || operation.idempotency !== "none")) + ) { + throw new TypeError(`Incoherent REST replay contract: ${operation.operationId}`); + } +} + +export const API_OPERATIONS: Readonly> = + Object.freeze({}); + +export function getApiOperation( + operationId: string, + operations: Readonly> = API_OPERATIONS, +): ApiOperation { + const selected = operations[operationId]; + if (!selected) { + throw new Error(`Unregistered API operation: ${operationId}`); + } + return selected; +} diff --git a/src/contracts/boundary-mapper.ts b/src/contracts/boundary-mapper.ts new file mode 100644 index 0000000..a6b9757 --- /dev/null +++ b/src/contracts/boundary-mapper.ts @@ -0,0 +1,69 @@ +export type MappingFailureCode = + | "MAPPING_INVARIANT_REJECTED" + | "UNSUPPORTED_WIRE_VALUE" + | "OUTPUT_LIMIT_EXCEEDED"; + +export type MappingResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; code: MappingFailureCode }>; + +export type BoundaryMapper = Readonly<{ + mapperId: string; + mapperVersion: number; + inputSchemaId: string; + outputContractId: string; + owner: string; + maxOutputItems: number; + map(input: Input): MappingResult; +}>; + +export type InstalledBoundaryMapper = BoundaryMapper; + +export function composeBoundaryMapperRegistry( + contributions: readonly Readonly>[], +): Readonly> { + const result: Record = Object.create(null); + for (const contribution of contributions) { + for (const [registryId, mapper] of Object.entries(contribution)) { + if ( + registryId !== mapper.mapperId || + !mapper.owner || + !mapper.inputSchemaId || + !mapper.outputContractId || + !Number.isSafeInteger(mapper.mapperVersion) || + mapper.mapperVersion < 1 || + !Number.isSafeInteger(mapper.maxOutputItems) || + mapper.maxOutputItems < 1 || + Object.hasOwn(result, registryId) + ) { + throw new TypeError( + `Invalid or duplicate boundary mapper: ${registryId}`, + ); + } + result[registryId] = mapper; + } + } + return Object.freeze(result); +} + +export function mapWithBoundaryRegistry( + mapperId: string, + input: unknown, + registry: Readonly>, +): MappingResult { + const mapper = registry[mapperId]; + if (!mapper) return mappingFailure("MAPPING_INVARIANT_REJECTED"); + try { + return mapper.map(input); + } catch { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); + } +} + +export function mappingSuccess(value: Value): MappingResult { + return Object.freeze({ ok: true, value }); +} + +export function mappingFailure(code: MappingFailureCode): MappingResult { + return Object.freeze({ ok: false, code }); +} diff --git a/src/contracts/browser-rpc.ts b/src/contracts/browser-rpc.ts new file mode 100644 index 0000000..e49508f --- /dev/null +++ b/src/contracts/browser-rpc.ts @@ -0,0 +1,772 @@ +import type { InstalledBoundaryMapper } from "./boundary-mapper.ts"; +import type { RuntimeSchemaCodec } from "./schema-registry.ts"; + +export const BROWSER_RPC_CONTRACT_VERSION = 3 as const; + +export const BROWSER_RPC_HARD_LIMITS = Object.freeze({ + maxRequestMessageBytes: 8 * 1024 * 1024, + maxResponseMessageBytes: 8 * 1024 * 1024, + maxTotalResponseBytes: 64 * 1024 * 1024, + maxBufferedBytes: 16 * 1024 * 1024, + maxResponseMessages: 10_000, + maxDeadlineMs: 30 * 60_000, + maxAttempts: 4, + maxBackoffMs: 30_000, + maxRetryAfterMs: 60_000, + maxProfiles: 128, + maxOperations: 512, +}); + +export type BrowserRpcProtocol = "CONNECT_HTTP" | "GRPC_WEB"; +export type BrowserRpcRuntimeKind = + | "CONNECT_WEB_FETCH" + | "OFFICIAL_GRPC_WEB_XHR" + | "CUSTOM_FETCH_FRAMED"; +export type BrowserRpcClientApiKind = + | "PROMISE_UNARY" + | "ASYNC_ITERABLE" + | "CALLBACK_STREAM"; +export type BrowserRpcKind = "UNARY" | "SERVER_STREAM"; +export type BrowserRpcMessageEncoding = "PROTO" | "JSON"; +export type BrowserRpcFraming = + | "CONNECT_BARE" + | "CONNECT_ENVELOPE" + | "GRPC_WEB_BINARY_ENVELOPE" + | "GRPC_WEB_BASE64_TEXT"; +export type BrowserRpcRequestMethod = "POST" | "GET"; +export type BrowserRpcSemantics = "QUERY" | "COMMAND" | "SERVER_STREAM"; +export type BrowserRpcReplayPolicy = + | "SAFE" + | "IDEMPOTENT" + | "KEYED_COMMAND" + | "NON_REPLAYABLE"; +export type BrowserRpcIdempotencyLevel = + | "NONE" + | "IDEMPOTENT" + | "NO_SIDE_EFFECTS"; +export type BrowserRpcDataClassification = + | "PUBLIC" + | "INTERNAL" + | "CONFIDENTIAL"; +export type BrowserRpcRetryOwner = + | "FRONTEND_ADAPTER" + | "EDGE_PROXY" + | "NONE"; +export type BrowserRpcRawByteCeilingOwner = + | "EDGE_PROXY" + | "BOUNDED_TRANSPORT" + | "EDGE_AND_TRANSPORT"; +export type BrowserRpcDeadlineDialect = + | "CONNECT_TIMEOUT_MS" + | "GRPC_TIMEOUT" + | "OFFICIAL_DEADLINE_METADATA"; +export type BrowserRpcCancelDialect = + | "ABORT_SIGNAL" + | "CLIENT_READABLE_STREAM_CANCEL"; +export type BrowserRpcTransportFailureCode = + | "NETWORK_UNREACHABLE" + | "CANCELED" + | "DEADLINE_EXCEEDED" + | "UNAUTHENTICATED" + | "PERMISSION_DENIED" + | "NOT_FOUND" + | "ALREADY_EXISTS" + | "ABORTED" + | "FAILED_PRECONDITION" + | "INVALID_ARGUMENT" + | "RESOURCE_EXHAUSTED" + | "UNAVAILABLE" + | "UNIMPLEMENTED" + | "INTERNAL" + | "DATA_LOSS" + | "PROTOCOL_MISMATCH" + | "MESSAGE_LIMIT"; + +export type BrowserRpcOperationV3 = Readonly<{ + contractVersion: typeof BROWSER_RPC_CONTRACT_VERSION; + operationId: string; + owner: string; + protocol: BrowserRpcProtocol; + semantics: BrowserRpcSemantics; + replayPolicy: BrowserRpcReplayPolicy; + idempotencyKeyPolicy: "NONE" | "REQUIRED"; + idempotencyLevel: BrowserRpcIdempotencyLevel; + dataClassification: BrowserRpcDataClassification; + runtimeProfileId: string; + providerId: string; + fullyQualifiedService: string; + method: string; + rpcKind: BrowserRpcKind; + requestMessageId: string; + responseMessageId: string; + descriptorArtifactId: string; + descriptorDigest: string; + requestSchemaId: string; + responseSchemaId: string; + requestEncoderId: string; + mapperId: string; + authProfileId: string; + csrfProfileId: string; + errorProfileId: string; + deadlineProfileId: string; + retryProfileId: string; + serverStateProfileId: string | null; + maxRequestMessageBytes: number; + maxResponseMessageBytes: number; + maxResponseMessages: number; + maxTotalResponseBytes: number; + maxBufferedBytes: number; + idleDeadlineMs: number | null; + totalDeadlineMs: number; +}>; + +export type BrowserRpcProviderProfile = Readonly<{ + runtimeProfileId: string; + providerId: string; + fixedBaseUrl: string; + runtimeId: string; + runtimeVersion: string; + runtimeDigest: string; + protocol: BrowserRpcProtocol; + runtimeKind: BrowserRpcRuntimeKind; + clientApiKind: BrowserRpcClientApiKind; + rpcKind: BrowserRpcKind; + messageEncoding: BrowserRpcMessageEncoding; + framing: BrowserRpcFraming; + requestMethod: BrowserRpcRequestMethod; + descriptorArtifactId: string; + descriptorDigest: string; + allowedProcedures: readonly string[]; + authProfileId: string; + csrfProfileId: string; + corsProfileId: string; + errorProfileId: string; + deadlineProfileId: string; + retryProfileId: string; + retryOwner: BrowserRpcRetryOwner; + maxAttempts: number; + backoffMs: readonly number[]; + retryableFailures: readonly BrowserRpcTransportFailureCode[]; + maxRetryAfterMs: number; + deadlineDialect: BrowserRpcDeadlineDialect; + cancelDialect: BrowserRpcCancelDialect; + rawByteCeilingOwner: BrowserRpcRawByteCeilingOwner; + streamMessageCompression: "IDENTITY_ONLY"; +}>; + +export type BrowserRpcRequestEncoder = Readonly<{ + encoderId: string; + operationId: string; + encode(value: unknown): + | Readonly<{ ok: true; value: unknown; encodedBytes: number }> + | Readonly<{ ok: false; code: string }>; +}>; + +export type BrowserRpcRuntimeBindingIdentity = Readonly<{ + runtimeProfileId: string; + providerId: string; + protocol: BrowserRpcProtocol; + rpcKind: BrowserRpcKind; +}>; + +export type BrowserRpcContractBindings = Readonly<{ + operations: Readonly>; + profiles: Readonly>; + schemaCodecs: Readonly>; + mappers: Readonly>; + requestEncoders: Readonly>; + runtimeBindings?: Readonly>; +}>; + +const REGISTRY_ID = /^[A-Z][A-Z0-9_]{2,79}$/; +const ARTIFACT_ID = /^[A-Za-z][A-Za-z0-9_.:-]{2,159}$/; +const OWNER = /^[a-z][a-z0-9-]{2,159}$/; +const SERVICE = + /^(?:[a-z][a-z0-9_]*\.)+[A-Z][A-Za-z0-9_]{1,79}$/; +const METHOD = /^[A-Z][A-Za-z0-9_]{1,79}$/; +const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,79}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const RETRYABLE_FAILURES = new Set([ + "NETWORK_UNREACHABLE", + "RESOURCE_EXHAUSTED", + "UNAVAILABLE", +]); +const PROTOCOLS = new Set(["CONNECT_HTTP", "GRPC_WEB"]); +const RUNTIME_KINDS = new Set([ + "CONNECT_WEB_FETCH", + "OFFICIAL_GRPC_WEB_XHR", + "CUSTOM_FETCH_FRAMED", +]); +const CLIENT_API_KINDS = new Set([ + "PROMISE_UNARY", + "ASYNC_ITERABLE", + "CALLBACK_STREAM", +]); +const RPC_KINDS = new Set(["UNARY", "SERVER_STREAM"]); +const MESSAGE_ENCODINGS = new Set(["PROTO", "JSON"]); +const FRAMINGS = new Set([ + "CONNECT_BARE", + "CONNECT_ENVELOPE", + "GRPC_WEB_BINARY_ENVELOPE", + "GRPC_WEB_BASE64_TEXT", +]); +const REQUEST_METHODS = new Set(["POST", "GET"]); +const SEMANTICS = new Set([ + "QUERY", + "COMMAND", + "SERVER_STREAM", +]); +const REPLAY_POLICIES = new Set([ + "SAFE", + "IDEMPOTENT", + "KEYED_COMMAND", + "NON_REPLAYABLE", +]); +const IDEMPOTENCY_KEY_POLICIES = new Set(["NONE", "REQUIRED"]); +const IDEMPOTENCY_LEVELS = new Set([ + "NONE", + "IDEMPOTENT", + "NO_SIDE_EFFECTS", +]); +const DATA_CLASSIFICATIONS = new Set([ + "PUBLIC", + "INTERNAL", + "CONFIDENTIAL", +]); +const RETRY_OWNERS = new Set([ + "FRONTEND_ADAPTER", + "EDGE_PROXY", + "NONE", +]); +const DEADLINE_DIALECTS = new Set([ + "CONNECT_TIMEOUT_MS", + "GRPC_TIMEOUT", + "OFFICIAL_DEADLINE_METADATA", +]); +const CANCEL_DIALECTS = new Set([ + "ABORT_SIGNAL", + "CLIENT_READABLE_STREAM_CANCEL", +]); +const RAW_BYTE_CEILING_OWNERS = new Set([ + "EDGE_PROXY", + "BOUNDED_TRANSPORT", + "EDGE_AND_TRANSPORT", +]); + +export function defineBrowserRpcOperation( + operation: BrowserRpcOperationV3, +): BrowserRpcOperationV3 { + validateOperation(operation); + return Object.freeze({ ...operation }); +} + +export function defineBrowserRpcProviderProfile( + profile: BrowserRpcProviderProfile, +): BrowserRpcProviderProfile { + validateProviderProfile(profile); + return Object.freeze({ + ...profile, + allowedProcedures: Object.freeze([...profile.allowedProcedures]), + backoffMs: Object.freeze([...profile.backoffMs]), + retryableFailures: Object.freeze([...profile.retryableFailures]), + }); +} + +export function defineBrowserRpcRequestEncoder( + encoder: BrowserRpcRequestEncoder, +): BrowserRpcRequestEncoder { + if ( + !ARTIFACT_ID.test(encoder.encoderId) || + !REGISTRY_ID.test(encoder.operationId) || + typeof encoder.encode !== "function" + ) { + throw new TypeError("Browser RPC request encoder is invalid."); + } + return Object.freeze({ ...encoder }); +} + +export function composeBrowserRpcOperationRegistry( + contributions: readonly Readonly< + Record + >[], +): Readonly> { + return composeRegistry( + contributions, + (operation) => operation.operationId, + defineBrowserRpcOperation, + "operation", + BROWSER_RPC_HARD_LIMITS.maxOperations, + ); +} + +export function composeBrowserRpcProviderProfileRegistry( + contributions: readonly Readonly< + Record + >[], +): Readonly> { + return composeRegistry( + contributions, + (profile) => profile.runtimeProfileId, + defineBrowserRpcProviderProfile, + "provider profile", + BROWSER_RPC_HARD_LIMITS.maxProfiles, + ); +} + +export function composeBrowserRpcRequestEncoderRegistry( + contributions: readonly Readonly< + Record + >[], +): Readonly> { + return composeRegistry( + contributions, + (encoder) => encoder.encoderId, + defineBrowserRpcRequestEncoder, + "request encoder", + BROWSER_RPC_HARD_LIMITS.maxOperations, + ); +} + +export function validateBrowserRpcContractBindings( + bindings: BrowserRpcContractBindings, +): true { + for (const [profileId, profile] of Object.entries(bindings.profiles)) { + if (profileId !== profile.runtimeProfileId) { + throw new TypeError( + `Browser RPC provider profile registry is invalid: ${profileId}`, + ); + } + validateProviderProfile(profile); + } + for (const [schemaId, schema] of Object.entries(bindings.schemaCodecs)) { + if (schemaId !== schema.schemaId || typeof schema.parse !== "function") { + throw new TypeError( + `Browser RPC schema registry is invalid: ${schemaId}`, + ); + } + } + for (const [mapperId, mapper] of Object.entries(bindings.mappers)) { + if ( + mapperId !== mapper.mapperId || + typeof mapper.map !== "function" || + !Number.isSafeInteger(mapper.mapperVersion) || + mapper.mapperVersion < 1 + ) { + throw new TypeError( + `Browser RPC mapper registry is invalid: ${mapperId}`, + ); + } + } + for (const [encoderId, encoder] of Object.entries( + bindings.requestEncoders, + )) { + if (encoderId !== encoder.encoderId) { + throw new TypeError( + `Browser RPC request encoder registry is invalid: ${encoderId}`, + ); + } + defineBrowserRpcRequestEncoder(encoder); + } + for (const [profileId, runtime] of Object.entries( + bindings.runtimeBindings ?? {}, + )) { + if ( + profileId !== runtime.runtimeProfileId || + !REGISTRY_ID.test(runtime.runtimeProfileId) || + !REGISTRY_ID.test(runtime.providerId) || + !PROTOCOLS.has(runtime.protocol) || + !RPC_KINDS.has(runtime.rpcKind) + ) { + throw new TypeError( + `Browser RPC runtime registry is invalid: ${profileId}`, + ); + } + } + + for (const [operationId, operation] of Object.entries( + bindings.operations, + )) { + if (operationId !== operation.operationId) { + throw new TypeError( + `Browser RPC operation registry is invalid: ${operationId}`, + ); + } + validateOperation(operation); + const profile = bindings.profiles[operation.runtimeProfileId]; + const requestSchema = bindings.schemaCodecs[operation.requestSchemaId]; + const responseSchema = bindings.schemaCodecs[operation.responseSchemaId]; + const mapper = bindings.mappers[operation.mapperId]; + const encoder = bindings.requestEncoders[operation.requestEncoderId]; + const runtime = bindings.runtimeBindings?.[operation.runtimeProfileId]; + const procedure = `${operation.fullyQualifiedService}/${operation.method}`; + + if ( + !profile || + profile.providerId !== operation.providerId || + profile.protocol !== operation.protocol || + profile.rpcKind !== operation.rpcKind || + profile.descriptorArtifactId !== operation.descriptorArtifactId || + profile.descriptorDigest !== operation.descriptorDigest || + profile.authProfileId !== operation.authProfileId || + profile.csrfProfileId !== operation.csrfProfileId || + profile.errorProfileId !== operation.errorProfileId || + profile.deadlineProfileId !== operation.deadlineProfileId || + profile.retryProfileId !== operation.retryProfileId || + !profile.allowedProcedures.includes(procedure) + ) { + throw new TypeError( + `Browser RPC provider binding is invalid: ${operation.operationId}`, + ); + } + if ( + requestSchema?.schemaId !== operation.requestSchemaId || + responseSchema?.schemaId !== operation.responseSchemaId || + mapper?.mapperId !== operation.mapperId || + mapper.inputSchemaId !== operation.responseSchemaId || + mapper.maxOutputItems < 1 || + encoder?.encoderId !== operation.requestEncoderId || + encoder.operationId !== operation.operationId + ) { + throw new TypeError( + `Browser RPC schema/mapper binding is invalid: ${operation.operationId}`, + ); + } + if ( + runtime && + (runtime.runtimeProfileId !== operation.runtimeProfileId || + runtime.providerId !== operation.providerId || + runtime.protocol !== operation.protocol || + runtime.rpcKind !== operation.rpcKind) + ) { + throw new TypeError( + `Browser RPC runtime binding is invalid: ${operation.operationId}`, + ); + } + if ( + profile.requestMethod === "GET" && + (operation.protocol !== "CONNECT_HTTP" || + operation.rpcKind !== "UNARY" || + operation.semantics !== "QUERY" || + operation.replayPolicy !== "SAFE" || + operation.idempotencyLevel !== "NO_SIDE_EFFECTS" || + operation.dataClassification !== "PUBLIC" || + operation.authProfileId !== "ANONYMOUS" || + operation.csrfProfileId !== "NONE") + ) { + throw new TypeError( + `Browser RPC GET binding is invalid: ${operation.operationId}`, + ); + } + if ( + profile.retryOwner === "FRONTEND_ADAPTER" && + !isFrontendReplayAllowed(operation) + ) { + throw new TypeError( + `Browser RPC retry binding is invalid: ${operation.operationId}`, + ); + } + } + return true; +} + +function validateOperation(operation: BrowserRpcOperationV3): void { + if ( + operation.contractVersion !== BROWSER_RPC_CONTRACT_VERSION || + !REGISTRY_ID.test(operation.operationId) || + !OWNER.test(operation.owner) || + !PROTOCOLS.has(operation.protocol) || + !SEMANTICS.has(operation.semantics) || + !REPLAY_POLICIES.has(operation.replayPolicy) || + !IDEMPOTENCY_KEY_POLICIES.has(operation.idempotencyKeyPolicy) || + !IDEMPOTENCY_LEVELS.has(operation.idempotencyLevel) || + !DATA_CLASSIFICATIONS.has(operation.dataClassification) || + !REGISTRY_ID.test(operation.runtimeProfileId) || + !REGISTRY_ID.test(operation.providerId) || + !SERVICE.test(operation.fullyQualifiedService) || + !METHOD.test(operation.method) || + !RPC_KINDS.has(operation.rpcKind) || + !ARTIFACT_ID.test(operation.requestMessageId) || + !ARTIFACT_ID.test(operation.responseMessageId) || + !ARTIFACT_ID.test(operation.descriptorArtifactId) || + !SHA256.test(operation.descriptorDigest) || + !ARTIFACT_ID.test(operation.requestSchemaId) || + !ARTIFACT_ID.test(operation.responseSchemaId) || + !ARTIFACT_ID.test(operation.requestEncoderId) || + !ARTIFACT_ID.test(operation.mapperId) || + !REGISTRY_ID.test(operation.authProfileId) || + !REGISTRY_ID.test(operation.csrfProfileId) || + !REGISTRY_ID.test(operation.errorProfileId) || + !REGISTRY_ID.test(operation.deadlineProfileId) || + !REGISTRY_ID.test(operation.retryProfileId) || + (operation.serverStateProfileId !== null && + !ARTIFACT_ID.test(operation.serverStateProfileId)) || + !positiveIntegerWithin( + operation.maxRequestMessageBytes, + BROWSER_RPC_HARD_LIMITS.maxRequestMessageBytes, + ) || + !positiveIntegerWithin( + operation.maxResponseMessageBytes, + BROWSER_RPC_HARD_LIMITS.maxResponseMessageBytes, + ) || + !positiveIntegerWithin( + operation.maxResponseMessages, + BROWSER_RPC_HARD_LIMITS.maxResponseMessages, + ) || + !positiveIntegerWithin( + operation.maxTotalResponseBytes, + BROWSER_RPC_HARD_LIMITS.maxTotalResponseBytes, + ) || + !positiveIntegerWithin( + operation.maxBufferedBytes, + BROWSER_RPC_HARD_LIMITS.maxBufferedBytes, + ) || + !positiveIntegerWithin( + operation.totalDeadlineMs, + BROWSER_RPC_HARD_LIMITS.maxDeadlineMs, + ) || + operation.maxTotalResponseBytes < operation.maxResponseMessageBytes || + operation.maxBufferedBytes < operation.maxResponseMessageBytes + ) { + throw new TypeError( + `Invalid Browser RPC operation: ${operation.operationId}`, + ); + } + + const unary = operation.rpcKind === "UNARY"; + if ( + (unary && + (operation.semantics === "SERVER_STREAM" || + operation.maxResponseMessages !== 1 || + operation.idleDeadlineMs !== null)) || + (!unary && + (operation.semantics !== "SERVER_STREAM" || + !positiveIntegerWithin( + operation.idleDeadlineMs, + operation.totalDeadlineMs, + ))) || + (operation.semantics === "QUERY" && + !["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy)) || + (operation.semantics === "COMMAND" && + ["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy)) || + (operation.replayPolicy === "KEYED_COMMAND" && + operation.idempotencyKeyPolicy !== "REQUIRED") || + (operation.replayPolicy !== "KEYED_COMMAND" && + operation.idempotencyKeyPolicy !== "NONE") || + (operation.idempotencyLevel === "NO_SIDE_EFFECTS" && + operation.semantics !== "QUERY") + ) { + throw new TypeError( + `Incoherent Browser RPC operation: ${operation.operationId}`, + ); + } +} + +function validateProviderProfile(profile: BrowserRpcProviderProfile): void { + let endpoint: URL; + try { + endpoint = new URL(profile.fixedBaseUrl); + } catch { + throw new TypeError("Browser RPC provider profile is invalid."); + } + const localHttp = + endpoint.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname); + if ( + !REGISTRY_ID.test(profile.runtimeProfileId) || + !REGISTRY_ID.test(profile.providerId) || + !ARTIFACT_ID.test(profile.runtimeId) || + !VERSION.test(profile.runtimeVersion) || + !SHA256.test(profile.runtimeDigest) || + !PROTOCOLS.has(profile.protocol) || + !RUNTIME_KINDS.has(profile.runtimeKind) || + !CLIENT_API_KINDS.has(profile.clientApiKind) || + !RPC_KINDS.has(profile.rpcKind) || + !MESSAGE_ENCODINGS.has(profile.messageEncoding) || + !FRAMINGS.has(profile.framing) || + !REQUEST_METHODS.has(profile.requestMethod) || + !ARTIFACT_ID.test(profile.descriptorArtifactId) || + !SHA256.test(profile.descriptorDigest) || + !REGISTRY_ID.test(profile.authProfileId) || + !REGISTRY_ID.test(profile.csrfProfileId) || + !REGISTRY_ID.test(profile.corsProfileId) || + !REGISTRY_ID.test(profile.errorProfileId) || + !REGISTRY_ID.test(profile.deadlineProfileId) || + !REGISTRY_ID.test(profile.retryProfileId) || + !RETRY_OWNERS.has(profile.retryOwner) || + !DEADLINE_DIALECTS.has(profile.deadlineDialect) || + !CANCEL_DIALECTS.has(profile.cancelDialect) || + !RAW_BYTE_CEILING_OWNERS.has(profile.rawByteCeilingOwner) || + profile.streamMessageCompression !== "IDENTITY_ONLY" || + (endpoint.protocol !== "https:" && !localHttp) || + endpoint.username || + endpoint.password || + endpoint.search || + endpoint.hash || + profile.allowedProcedures.length === 0 || + profile.allowedProcedures.length > BROWSER_RPC_HARD_LIMITS.maxOperations || + new Set(profile.allowedProcedures).size !== + profile.allowedProcedures.length || + profile.allowedProcedures.some((procedure) => { + const separator = procedure.lastIndexOf("/"); + return ( + separator < 1 || + !SERVICE.test(procedure.slice(0, separator)) || + !METHOD.test(procedure.slice(separator + 1)) + ); + }) || + !positiveIntegerWithin( + profile.maxAttempts, + BROWSER_RPC_HARD_LIMITS.maxAttempts, + ) || + !nonNegativeIntegerWithin( + profile.maxRetryAfterMs, + BROWSER_RPC_HARD_LIMITS.maxRetryAfterMs, + ) || + new Set(profile.retryableFailures).size !== + profile.retryableFailures.length || + profile.retryableFailures.some( + (failure) => !RETRYABLE_FAILURES.has(failure), + ) || + profile.backoffMs.some( + (delay) => + !nonNegativeIntegerWithin( + delay, + BROWSER_RPC_HARD_LIMITS.maxBackoffMs, + ), + ) || + (profile.retryOwner === "FRONTEND_ADAPTER" + ? profile.maxAttempts < 2 || + profile.backoffMs.length !== profile.maxAttempts - 1 || + profile.retryableFailures.length === 0 + : profile.maxAttempts !== 1 || + profile.backoffMs.length !== 0 || + profile.retryableFailures.length !== 0) || + (profile.rpcKind === "SERVER_STREAM" && + profile.retryOwner !== "NONE") || + !runtimeTupleIsValid(profile) + ) { + throw new TypeError("Browser RPC provider profile is invalid."); + } +} + +function runtimeTupleIsValid(profile: BrowserRpcProviderProfile): boolean { + if (profile.protocol === "CONNECT_HTTP") { + if ( + profile.runtimeKind !== "CONNECT_WEB_FETCH" || + profile.deadlineDialect !== "CONNECT_TIMEOUT_MS" || + profile.cancelDialect !== "ABORT_SIGNAL" + ) { + return false; + } + if (profile.rpcKind === "UNARY") { + return ( + profile.clientApiKind === "PROMISE_UNARY" && + profile.framing === "CONNECT_BARE" + ); + } + return ( + profile.requestMethod === "POST" && + profile.clientApiKind === "ASYNC_ITERABLE" && + profile.framing === "CONNECT_ENVELOPE" + ); + } + + if (profile.requestMethod !== "POST") return false; + if (profile.runtimeKind === "OFFICIAL_GRPC_WEB_XHR") { + if ( + profile.messageEncoding !== "PROTO" || + profile.deadlineDialect !== "OFFICIAL_DEADLINE_METADATA" + ) { + return false; + } + if (profile.rpcKind === "UNARY") { + return ( + ["PROMISE_UNARY", "CALLBACK_STREAM"].includes( + profile.clientApiKind, + ) && + ["GRPC_WEB_BINARY_ENVELOPE", "GRPC_WEB_BASE64_TEXT"].includes( + profile.framing, + ) && + (profile.clientApiKind === "CALLBACK_STREAM" + ? profile.cancelDialect === "CLIENT_READABLE_STREAM_CANCEL" + : profile.cancelDialect === "ABORT_SIGNAL") + ); + } + return ( + profile.clientApiKind === "CALLBACK_STREAM" && + profile.framing === "GRPC_WEB_BASE64_TEXT" && + profile.cancelDialect === "CLIENT_READABLE_STREAM_CANCEL" + ); + } + + if (profile.runtimeKind === "CONNECT_WEB_FETCH") { + return ( + profile.deadlineDialect === "GRPC_TIMEOUT" && + profile.cancelDialect === "ABORT_SIGNAL" && + profile.framing === "GRPC_WEB_BINARY_ENVELOPE" && + (profile.rpcKind === "UNARY" + ? profile.clientApiKind === "PROMISE_UNARY" + : profile.clientApiKind === "ASYNC_ITERABLE") + ); + } + + return ( + profile.runtimeKind === "CUSTOM_FETCH_FRAMED" && + profile.deadlineDialect === "GRPC_TIMEOUT" && + profile.cancelDialect === "ABORT_SIGNAL" && + (profile.rpcKind === "UNARY" + ? profile.clientApiKind === "PROMISE_UNARY" + : profile.clientApiKind === "ASYNC_ITERABLE") + ); +} + +function isFrontendReplayAllowed( + operation: BrowserRpcOperationV3, +): boolean { + return ( + ["SAFE", "IDEMPOTENT"].includes(operation.replayPolicy) || + (operation.replayPolicy === "KEYED_COMMAND" && + operation.idempotencyKeyPolicy === "REQUIRED") + ); +} + +function composeRegistry( + contributions: readonly Readonly>[], + identity: (value: Value) => string, + define: (value: Value) => Value, + label: string, + maximumRows: number, +): Readonly> { + const result: Record = Object.create(null); + let rows = 0; + for (const contribution of contributions) { + for (const [registryId, value] of Object.entries(contribution)) { + rows += 1; + if ( + rows > maximumRows || + registryId !== identity(value) || + Object.hasOwn(result, registryId) + ) { + throw new TypeError( + `Invalid or duplicate Browser RPC ${label}: ${registryId}`, + ); + } + result[registryId] = define(value); + } + } + return Object.freeze(result); +} + +function positiveIntegerWithin( + value: number | null, + maximum: number, +): value is number { + return Number.isSafeInteger(value) && value !== null && value > 0 && value <= maximum; +} + +function nonNegativeIntegerWithin( + value: number, + maximum: number, +): boolean { + return Number.isSafeInteger(value) && value >= 0 && value <= maximum; +} diff --git a/src/contracts/cache-invalidation.ts b/src/contracts/cache-invalidation.ts new file mode 100644 index 0000000..4725e3c --- /dev/null +++ b/src/contracts/cache-invalidation.ts @@ -0,0 +1,241 @@ +/** + * Cross-context cache invalidation is a best-effort hint protocol. The wire + * event intentionally carries neither cached data nor a concrete query key. + * Receivers resolve the allowlisted topic through their local policy. + */ +export const CACHE_INVALIDATION_PROTOCOL_VERSION = 1 as const; + +export const CACHE_INVALIDATION_WIRE_LIMITS = Object.freeze({ + maxWireBytes: 2_048, + maxOpaqueIdentifierLength: 128, + maxTopicLength: 64, + maxEventTtlMs: 5 * 60 * 1_000, + maxFutureClockSkewMs: 30_000, +}); + +export type CacheInvalidationTopicDefinition = Readonly<{ + topic: string; + topicVersion: number; +}>; + +export type CacheInvalidationWireEvent = Readonly<{ + protocolVersion: typeof CACHE_INVALIDATION_PROTOCOL_VERSION; + eventId: string; + sourceId: string; + sourceEpoch: string; + sequence: number; + cacheEpoch: string; + topic: string; + topicVersion: number; + emittedAt: number; + expiresAt: number; +}>; + +export type CacheInvalidationParseFailureReason = + | "CACHE_EPOCH_MISMATCH" + | "EXPIRED" + | "INVALID_ENVELOPE" + | "MALFORMED_JSON" + | "OVERSIZED" + | "PROTOCOL_MISMATCH" + | "TOPIC_REJECTED"; + +export type CacheInvalidationParseResult = + | Readonly<{ ok: true; value: CacheInvalidationWireEvent }> + | Readonly<{ + ok: false; + reason: CacheInvalidationParseFailureReason; + }>; + +export type CacheInvalidationParsePolicy = Readonly<{ + cacheEpoch: string; + topicVersions: Readonly>; + nowEpochMilliseconds: number; +}>; + +const WIRE_KEYS = Object.freeze([ + "cacheEpoch", + "emittedAt", + "eventId", + "expiresAt", + "protocolVersion", + "sequence", + "sourceEpoch", + "sourceId", + "topic", + "topicVersion", +] as const); + +const OPAQUE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u; +const TOPIC = /^[a-z][a-z0-9.-]*$/u; + +export function isCacheInvalidationOpaqueIdentifier( + value: unknown, +): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= + CACHE_INVALIDATION_WIRE_LIMITS.maxOpaqueIdentifierLength && + OPAQUE_IDENTIFIER.test(value) + ); +} + +export function isCacheInvalidationTopic( + value: unknown, +): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= CACHE_INVALIDATION_WIRE_LIMITS.maxTopicLength && + TOPIC.test(value) + ); +} + +export function cacheInvalidationWireByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +export function decodeCacheInvalidationWireEvent( + raw: string, + policy: CacheInvalidationParsePolicy, +): CacheInvalidationParseResult { + if ( + typeof raw !== "string" || + raw.length > CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes || + cacheInvalidationWireByteLength(raw) > + CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes + ) { + return failure("OVERSIZED"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return failure("MALFORMED_JSON"); + } + return parseCacheInvalidationWireEvent(parsed, policy); +} + +export function parseCacheInvalidationWireEvent( + input: unknown, + policy: CacheInvalidationParsePolicy, +): CacheInvalidationParseResult { + try { + return parseCacheInvalidationWireEventUnsafe(input, policy); + } catch { + return failure("INVALID_ENVELOPE"); + } +} + +function parseCacheInvalidationWireEventUnsafe( + input: unknown, + policy: CacheInvalidationParsePolicy, +): CacheInvalidationParseResult { + if (!isExactWireRecord(input)) { + return failure("INVALID_ENVELOPE"); + } + + let serialized: string; + try { + serialized = JSON.stringify(input); + } catch { + return failure("INVALID_ENVELOPE"); + } + if ( + cacheInvalidationWireByteLength(serialized) > + CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes + ) { + return failure("OVERSIZED"); + } + + if (input.protocolVersion !== CACHE_INVALIDATION_PROTOCOL_VERSION) { + return failure("PROTOCOL_MISMATCH"); + } + if ( + !isCacheInvalidationOpaqueIdentifier(input.eventId) || + !isCacheInvalidationOpaqueIdentifier(input.sourceId) || + !isCacheInvalidationOpaqueIdentifier(input.sourceEpoch) || + !isCacheInvalidationOpaqueIdentifier(input.cacheEpoch) || + !isCacheInvalidationTopic(input.topic) || + !isPositiveSafeInteger(input.sequence) || + !isPositiveSafeInteger(input.topicVersion) || + !isEpochMilliseconds(input.emittedAt) || + !isEpochMilliseconds(input.expiresAt) || + input.expiresAt <= input.emittedAt || + input.expiresAt - input.emittedAt > + CACHE_INVALIDATION_WIRE_LIMITS.maxEventTtlMs || + !isEpochMilliseconds(policy.nowEpochMilliseconds) + ) { + return failure("INVALID_ENVELOPE"); + } + if (input.cacheEpoch !== policy.cacheEpoch) { + return failure("CACHE_EPOCH_MISMATCH"); + } + if ( + !Object.hasOwn(policy.topicVersions, input.topic) || + policy.topicVersions[input.topic] !== input.topicVersion + ) { + return failure("TOPIC_REJECTED"); + } + if ( + input.expiresAt <= policy.nowEpochMilliseconds || + input.emittedAt > + policy.nowEpochMilliseconds + + CACHE_INVALIDATION_WIRE_LIMITS.maxFutureClockSkewMs + ) { + return failure("EXPIRED"); + } + + return { + ok: true, + value: Object.freeze({ + protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION, + eventId: input.eventId, + sourceId: input.sourceId, + sourceEpoch: input.sourceEpoch, + sequence: input.sequence, + cacheEpoch: input.cacheEpoch, + topic: input.topic, + topicVersion: input.topicVersion, + emittedAt: input.emittedAt, + expiresAt: input.expiresAt, + }), + }; +} + +function isExactWireRecord( + value: unknown, +): value is Readonly> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const keys = Object.keys(value).sort(); + return ( + keys.length === WIRE_KEYS.length && + keys.every((key, index) => key === WIRE_KEYS[index]) + ); +} + +function isPositiveSafeInteger(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 1 + ); +} + +function isEpochMilliseconds(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 + ); +} + +function failure( + reason: CacheInvalidationParseFailureReason, +): Extract { + return Object.freeze({ ok: false, reason }); +} diff --git a/src/contracts/contract-set-canonical.ts b/src/contracts/contract-set-canonical.ts new file mode 100644 index 0000000..2eb23dd --- /dev/null +++ b/src/contracts/contract-set-canonical.ts @@ -0,0 +1,111 @@ +/** + * §5.3. The single canonical byte producer for a contract set. + * + * Node build scripts and the browser runtime share this function. Only the hash + * adapter differs (Node `crypto` vs Web Crypto), so a digest can never diverge + * because of JSON property order or a locale-sensitive sort. + */ + +export type ContractSetPackage = Readonly<{ + packageId: string; + version: string; + digest: `sha256:${string}`; + runtimeProtocolVersion: 1; + sourceRevision: string; +}>; + +export const CONTRACT_SET_ALGORITHM = "CA_CONTRACT_SET_V1" as const; + +const HEADER = "CA_FRONTEND_CONTRACT_SET_V1\u0000"; + +const encoder = new TextEncoder(); + +function compareUtf8(left: string, right: string): number { + const a = encoder.encode(left); + const b = encoder.encode(right); + const shared = Math.min(a.length, b.length); + for (let index = 0; index < shared; index += 1) { + const difference = (a[index] as number) - (b[index] as number); + if (difference !== 0) return difference; + } + return a.length - b.length; +} + +export function canonicalizeContractSet( + packages: readonly ContractSetPackage[], +): Uint8Array { + const seen = new Set(); + for (const entry of packages) { + if (seen.has(entry.packageId)) { + throw new TypeError("Duplicate contract set package identity."); + } + seen.add(entry.packageId); + } + + const sorted = [...packages].sort((left, right) => + compareUtf8(left.packageId, right.packageId), + ); + + const chunks: Uint8Array[] = [encoder.encode(HEADER)]; + for (const entry of sorted) { + appendString(chunks, entry.packageId); + appendString(chunks, entry.version); + appendString(chunks, entry.digest); + chunks.push(u32be(entry.runtimeProtocolVersion)); + appendString(chunks, entry.sourceRevision); + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + return output; +} + +function appendString(chunks: Uint8Array[], value: string): void { + const bytes = encoder.encode(value); + chunks.push(u32be(bytes.length)); + chunks.push(bytes); +} + +function u32be(value: number): Uint8Array { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new TypeError("Contract set length prefix is out of range."); + } + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, false); + return bytes; +} + +export function toLowerHex(digest: ArrayBuffer | Uint8Array): string { + const bytes = + digest instanceof Uint8Array ? digest : new Uint8Array(digest); + let output = ""; + for (const byte of bytes) output += byte.toString(16).padStart(2, "0"); + return output; +} + +/** + * Browser-side digest. Node callers pass their own `crypto.createHash` adapter + * through {@link computeContractSetDigestWith}. + */ +export async function computeContractSetDigest( + packages: readonly ContractSetPackage[], +): Promise<`sha256:${string}`> { + const bytes = canonicalizeContractSet(packages); + const buffer = await crypto.subtle.digest( + "SHA-256", + bytes.slice().buffer as ArrayBuffer, + ); + return `sha256:${toLowerHex(buffer)}`; +} + +export function computeContractSetDigestWith( + packages: readonly ContractSetPackage[], + sha256: (bytes: Uint8Array) => Uint8Array, +): `sha256:${string}` { + return `sha256:${toLowerHex(sha256(canonicalizeContractSet(packages)))}`; +} diff --git a/src/contracts/contract-set.ts b/src/contracts/contract-set.ts new file mode 100644 index 0000000..caf3956 --- /dev/null +++ b/src/contracts/contract-set.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +import { + CONTRACT_SET_ALGORITHM, + computeContractSetDigest, + type ContractSetPackage, +} from "./contract-set-canonical.ts"; + +/** + * §5. Contract set and release coherence. + * + * The frontend verifies that the packages compiled into this build match the + * packages the release manifest declares. It never negotiates ranges, resolves + * `latest`, or infers a provider runtime version. + */ + +export type ContractSetFailureCode = + | "CONTRACT_SET_SCHEMA_INVALID" + | "CONTRACT_SET_ENTRY_INVALID" + | "CONTRACT_SET_DUPLICATE_PACKAGE" + | "CONTRACT_SET_DIGEST_INVALID" + | "CONTRACT_SET_DIGEST_MISMATCH" + | "CONTRACT_SET_PACKAGE_MISSING" + | "CONTRACT_SET_PACKAGE_UNEXPECTED" + | "CONTRACT_SET_VERSION_MISMATCH" + | "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED"; + +const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/); + +export const contractSetPackageSchema = z + .object({ + packageId: z + .string() + .regex(/^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/), + version: z + .string() + .regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/, + ), + digest: digestSchema, + runtimeProtocolVersion: z.literal(1), + sourceRevision: z.string().regex(/^[0-9a-f]{7,64}$/), + }) + .strict(); + +export const contractSetSchema = z + .object({ + setAlgorithm: z.literal(CONTRACT_SET_ALGORITHM), + setDigest: digestSchema, + packages: z.array(contractSetPackageSchema).max(256), + }) + .strict(); + +export type ContractSet = z.output; + +export type ContractSetVerification = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; code: ContractSetFailureCode }>; + +/** + * §5.5. Every comparison below must hold. There is no precedence rule between + * the embedded expectation and the manifest: a disagreement fails the boot. + */ +export async function verifyContractSet( + input: Readonly<{ + expected: readonly ContractSetPackage[]; + manifest: ContractSet; + expectedSetDigest?: `sha256:${string}`; + }>, +): Promise { + const manifestIds = new Set(); + for (const entry of input.manifest.packages) { + if (manifestIds.has(entry.packageId)) { + return failure("CONTRACT_SET_DUPLICATE_PACKAGE"); + } + manifestIds.add(entry.packageId); + if (entry.runtimeProtocolVersion !== 1) { + return failure("CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED"); + } + } + + const expectedById = new Map( + input.expected.map((entry) => [entry.packageId, entry] as const), + ); + for (const entry of input.manifest.packages) { + if (!expectedById.has(entry.packageId)) { + return failure("CONTRACT_SET_PACKAGE_UNEXPECTED"); + } + } + for (const entry of input.expected) { + const found = input.manifest.packages.find( + (candidate) => candidate.packageId === entry.packageId, + ); + if (!found) return failure("CONTRACT_SET_PACKAGE_MISSING"); + if ( + found.version !== entry.version || + found.sourceRevision !== entry.sourceRevision + ) { + return failure("CONTRACT_SET_VERSION_MISMATCH"); + } + if (found.digest !== entry.digest) { + return failure("CONTRACT_SET_DIGEST_MISMATCH"); + } + } + + let recomputed: `sha256:${string}`; + try { + recomputed = await computeContractSetDigest( + input.manifest.packages as readonly ContractSetPackage[], + ); + } catch { + return failure("CONTRACT_SET_DIGEST_INVALID"); + } + if (recomputed !== input.manifest.setDigest) { + return failure("CONTRACT_SET_DIGEST_MISMATCH"); + } + + const expectedDigest = + input.expectedSetDigest ?? (await computeContractSetDigest(input.expected)); + if (expectedDigest !== input.manifest.setDigest) { + return failure("CONTRACT_SET_DIGEST_MISMATCH"); + } + + return Object.freeze({ ok: true as const }); +} + +function failure(code: ContractSetFailureCode): ContractSetVerification { + return Object.freeze({ ok: false as const, code }); +} diff --git a/src/contracts/cursor-pagination.ts b/src/contracts/cursor-pagination.ts new file mode 100644 index 0000000..3297314 --- /dev/null +++ b/src/contracts/cursor-pagination.ts @@ -0,0 +1,23 @@ +import type { Result } from "../application/result.ts"; + +export type CursorPage = Readonly<{ + items: readonly Value[]; + nextCursor: string | null; + hasMore: boolean; + snapshotToken: string | null; +}>; + +export type CursorPaginationProfile = Readonly<{ + profileId: string; + maxPages: number; + maxTotalItems: number; + maxEstimatedBytes: number; + maxCursorBytes: number; + allowSparsePage: boolean; +}>; + +export type CursorPaginationRuntime = Readonly<{ + loadAll(context: Readonly<{ signal?: AbortSignal }>): Promise< + Result + >; +}>; diff --git a/src/contracts/diagnostic-buckets.ts b/src/contracts/diagnostic-buckets.ts new file mode 100644 index 0000000..c1b4ab0 --- /dev/null +++ b/src/contracts/diagnostic-buckets.ts @@ -0,0 +1,8 @@ +export type QueueSizeBucket = "0" | "1-10" | "11-50" | "51+"; + +export function queueSizeBucket(size: number): QueueSizeBucket { + if (size <= 0) return "0"; + if (size <= 10) return "1-10"; + if (size <= 50) return "11-50"; + return "51+"; +} diff --git a/src/contracts/diagnostics.ts b/src/contracts/diagnostics.ts new file mode 100644 index 0000000..bbbeb1b --- /dev/null +++ b/src/contracts/diagnostics.ts @@ -0,0 +1,177 @@ +export const DIAGNOSTIC_LEVELS = Object.freeze([ + "debug", + "info", + "warn", + "error", +] as const); +export { queueSizeBucket } from "./diagnostic-buckets.ts"; +export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number]; + +export const DIAGNOSTIC_EVENT_REGISTRY = Object.freeze({ + "app.boot.failed": Object.freeze({ level: "error" }), + "http.request.completed": Object.freeze({ level: "info" }), + "cache.operation.failed": Object.freeze({ level: "warn" }), + "storage.operation.failed": Object.freeze({ level: "warn" }), + "route.changed": Object.freeze({ level: "info" }), + "ui.render.failed": Object.freeze({ level: "error" }), + "release.mismatch.detected": Object.freeze({ level: "warn" }), + "telemetry.delivery.dropped": Object.freeze({ level: "warn" }), +}); +export type DiagnosticEventId = keyof typeof DIAGNOSTIC_EVENT_REGISTRY; + +export const DIAGNOSTIC_CONTEXT_ALLOWLIST = Object.freeze([ + "app_version", + "build_id", + "release_id", + "active_release_id", + "config_schema_version", + "api_contract_version", + "route_id", + "operation_id", + "correlation_id", + "error_kind", + "outcome", + "http_status_group", + "attempt_count_bucket", + "duration_bucket", + "component_boundary", + "mismatch_kind", + "operation", + "reason", + "queue_size_bucket", +] as const); +export type DiagnosticContextKey = + (typeof DIAGNOSTIC_CONTEXT_ALLOWLIST)[number]; +export type DiagnosticContext = Readonly< + Partial> +>; + +export type DiagnosticRecordInput = Readonly<{ + level: DiagnosticLevel; + eventId: DiagnosticEventId; + context?: Readonly>; +}>; + +export type DiagnosticRecord = Readonly<{ + level: DiagnosticLevel; + eventId: DiagnosticEventId; + timestamp: string; + context: DiagnosticContext; +}>; + +const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/; + +function projectDiagnosticRecordUnsafe( + input: DiagnosticRecordInput, + now: () => number = Date.now, +): + | Readonly<{ success: true; record: DiagnosticRecord }> + | Readonly<{ success: false; reason: string }> { + if (!Object.hasOwn(DIAGNOSTIC_EVENT_REGISTRY, input.eventId)) { + return { success: false, reason: "unregistered-event" }; + } + if (!DIAGNOSTIC_LEVELS.includes(input.level)) { + return { success: false, reason: "invalid-level" }; + } + const contextEntries = Object.entries(input.context ?? {}); + if (contextEntries.length > DIAGNOSTIC_CONTEXT_ALLOWLIST.length) { + return { success: false, reason: "invalid-context" }; + } + const projected: Partial< + Record + > = {}; + for (const [key, value] of contextEntries) { + if ( + !DIAGNOSTIC_CONTEXT_ALLOWLIST.includes(key as DiagnosticContextKey) + ) { + return { success: false, reason: "unknown-context" }; + } + if (typeof value === "string") { + if (!SAFE_VALUE.test(value)) { + return { success: false, reason: "invalid-context" }; + } + projected[key as DiagnosticContextKey] = value; + } else if (typeof value === "number" && Number.isFinite(value)) { + projected[key as DiagnosticContextKey] = value; + } else if (typeof value === "boolean") { + projected[key as DiagnosticContextKey] = value; + } else { + return { success: false, reason: "invalid-context" }; + } + } + let timestamp: string; + try { + timestamp = new Date(now()).toISOString(); + } catch { + timestamp = new Date(0).toISOString(); + } + return { + success: true, + record: Object.freeze({ + level: input.level, + eventId: input.eventId, + timestamp, + context: Object.freeze(projected), + }), + }; +} + +export function projectDiagnosticRecord( + input: DiagnosticRecordInput, + now: () => number = Date.now, +): ReturnType { + try { + return projectDiagnosticRecordUnsafe(input, now); + } catch { + return { success: false, reason: "serialization-failure" }; + } +} + +export function safeErrorKind(error: unknown): string { + try { + if (error && typeof error === "object") { + const record = error as Readonly>; + if ( + typeof record.kind === "string" && + /^[A-Z][A-Z0-9_]{0,63}$/.test(record.kind) + ) { + return record.kind; + } + if ( + typeof record.name === "string" && + /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(record.name) + ) { + const normalized = record.name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toUpperCase(); + return /^[A-Z][A-Z0-9_]{0,63}$/.test(normalized) + ? normalized + : "UNKNOWN_FAILURE"; + } + } + } catch { + return "UNKNOWN_FAILURE"; + } + return "UNKNOWN_FAILURE"; +} + +export function statusGroup(status: number | undefined): string { + return typeof status === "number" && Number.isFinite(status) + ? `${Math.max(0, Math.min(9, Math.floor(status / 100)))}xx` + : "none"; +} + +export function attemptBucket(attemptCount: number): string { + if (attemptCount <= 1) return "1"; + if (attemptCount === 2) return "2"; + if (attemptCount <= 4) return "3-4"; + return "5+"; +} + +export function durationBucket(durationMs: number): string { + if (!Number.isFinite(durationMs) || durationMs < 0) return "unknown"; + if (durationMs < 100) return "lt100ms"; + if (durationMs < 500) return "100-499ms"; + if (durationMs < 2_000) return "500-1999ms"; + return "gte2000ms"; +} diff --git a/src/contracts/env.ts b/src/contracts/env.ts new file mode 100644 index 0000000..95679f4 --- /dev/null +++ b/src/contracts/env.ts @@ -0,0 +1,109 @@ +/** + * §6.3. Case-insensitive key fragments that can never appear in a client + * configuration document. + */ +const FORBIDDEN_CONFIG_NAME_FRAGMENTS = Object.freeze([ + "PASSWORD", + "SECRET", + "TOKEN", + "PRIVATE_KEY", + "CLIENT_SECRET", + "ACCESS_KEY", + "REFRESH_TOKEN", + "COOKIE", + "AUTHORIZATION", +]); + +/** + * Exact top-level keys whose fragment match is a semantic enum name, not a + * credential. The allowlist is exact-key only; it is never applied to arbitrary + * nested keys. + */ +const SEMANTIC_KEY_ALLOWLIST = Object.freeze( + new Set(["AUTH_MODE", "TELEMETRY_ENABLED"]), +); + +export type EnvironmentPhase = "build" | "runtime"; +export type EnvironmentDefinition = Readonly<{ + phase: EnvironmentPhase; + classification: string; + required: boolean; + defaultValue: unknown; +}>; + +export const ENV_REGISTRY = Object.freeze({ + VITE_BUILD_ID: build("public-metadata", true, null), + VITE_COMMIT_SHA: build("public-metadata", false, "local"), + VITE_ROUTER_BASE_PATH: build("compile-time", true, "/"), + VITE_RUNTIME_CONFIG_URL: build("compile-time", true, "/config.json"), + APP_ENV: runtime("public", true, null), + API_BASE_URL: runtime("public-sensitive", true, null), + REQUEST_TIMEOUT_MS: runtime("public", false, 10_000), + MAX_RETRY_ATTEMPTS: runtime("public", false, 2), + TELEMETRY_ENABLED: runtime("public", true, false), + TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null), + AUTH_MODE: runtime("public", true, "external"), + CONFIG_SCHEMA_VERSION: runtime("public", true, null), + RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"), + // §3.5: overrides may only disable an installed capability, never enable one. + CAPABILITY_OVERRIDES: runtime("public", false, null), +}); + +function build( + classification: string, + required: boolean, + defaultValue: unknown, +): EnvironmentDefinition { + return Object.freeze({ phase: "build", classification, required, defaultValue }); +} + +function runtime( + classification: string, + required: boolean, + defaultValue: unknown, +): EnvironmentDefinition { + return Object.freeze({ phase: "runtime", classification, required, defaultValue }); +} + +export function assertSafeConfigNames( + config: Readonly>, + depth = 0, +): void { + if (depth > 4) { + throw new Error("Client configuration nesting exceeds its bound"); + } + for (const [name, value] of Object.entries(config)) { + const allowlisted = depth === 0 && SEMANTIC_KEY_ALLOWLIST.has(name); + if (!allowlisted && isForbiddenConfigName(name)) { + throw new Error(`Forbidden client configuration key: ${name}`); + } + if (value && typeof value === "object" && !Array.isArray(value)) { + assertSafeConfigNames(value as Record, depth + 1); + } + } +} + +function isForbiddenConfigName(name: string): boolean { + const upper = name.toUpperCase(); + return FORBIDDEN_CONFIG_NAME_FRAGMENTS.some((fragment) => + upper.includes(fragment), + ); +} + +export type BuildEnvironment = Readonly<{ + VITE_BUILD_ID?: string; + VITE_COMMIT_SHA?: string; + VITE_ROUTER_BASE_PATH?: string; + VITE_RUNTIME_CONFIG_URL?: string; +}>; + +export function getBuildConfig( + environment: BuildEnvironment = import.meta.env as BuildEnvironment, +) { + const buildId = environment.VITE_BUILD_ID || "local-build"; + const commitSha = environment.VITE_COMMIT_SHA || "local"; + const routerBasePath = environment.VITE_ROUTER_BASE_PATH || "/"; + const runtimeConfigUrl = environment.VITE_RUNTIME_CONFIG_URL || "/config.json"; + + return Object.freeze({ buildId, commitSha, routerBasePath, runtimeConfigUrl }); +} diff --git a/src/contracts/errors.ts b/src/contracts/errors.ts new file mode 100644 index 0000000..6ef354a --- /dev/null +++ b/src/contracts/errors.ts @@ -0,0 +1,424 @@ +const DROP_SENSITIVE = Object.freeze([ + "cause", + "body", + "headers", + "authorization", + "url", + "query", + "stack", + "storageValue", +] as const); + +export type ErrorAction = + | "retry" + | "reauth" + | "navigate" + | "reload-once" + | "contact-support" + | "none"; + +export type ErrorDefinition = Readonly<{ + kind: Kind; + defaultRetryable: boolean; + severity: string; + userMessageKey: string; + action: ErrorAction; + telemetryEvent: string; + redaction: readonly string[]; +}>; + +const row = ( + kind: Kind, + defaultRetryable: boolean, + severity: string, + action: ErrorAction, + telemetryEvent = "api.request.failed", +): ErrorDefinition => + Object.freeze({ + kind, + defaultRetryable, + severity, + userMessageKey: `error.${kind.toLowerCase()}`, + action, + telemetryEvent, + redaction: DROP_SENSITIVE, + }); + +export const ERROR_REGISTRY = Object.freeze({ + NETWORK_UNREACHABLE: row("NETWORK_UNREACHABLE", true, "warning", "retry"), + REQUEST_TIMEOUT: row("REQUEST_TIMEOUT", true, "warning", "retry"), + REQUEST_ABORTED: row("REQUEST_ABORTED", false, "info", "none"), + CONTENT_TYPE_MISMATCH: row( + "CONTENT_TYPE_MISMATCH", + false, + "error", + "contact-support", + ), + MALFORMED_JSON: row("MALFORMED_JSON", false, "error", "contact-support"), + RESPONSE_BODY_LIMIT: row( + "RESPONSE_BODY_LIMIT", + false, + "error", + "contact-support", + ), + ENVELOPE_MISMATCH: row("ENVELOPE_MISMATCH", false, "error", "contact-support"), + SCHEMA_MISMATCH: row("SCHEMA_MISMATCH", false, "error", "contact-support"), + MAPPING_CONTRACT_VIOLATION: row( + "MAPPING_CONTRACT_VIOLATION", + false, + "error", + "contact-support", + ), + RESULT_LIMIT_EXCEEDED: row( + "RESULT_LIMIT_EXCEEDED", + false, + "error", + "contact-support", + ), + SCOPE_GENERATION_CHANGED: row( + "SCOPE_GENERATION_CHANGED", + false, + "info", + "none", + ), + IDENTITY_INTERN_LIMIT_EXCEEDED: row( + "IDENTITY_INTERN_LIMIT_EXCEEDED", + false, + "warning", + "retry", + ), + DUPLICATE_IN_FLIGHT: row( + "DUPLICATE_IN_FLIGHT", + false, + "info", + "none", + ), + PAGINATION_CONTRACT_VIOLATION: row( + "PAGINATION_CONTRACT_VIOLATION", + false, + "error", + "contact-support", + ), + AUTH_REQUIRED: row("AUTH_REQUIRED", false, "info", "reauth"), + AUTH_INTEGRATION_FAILURE: row( + "AUTH_INTEGRATION_FAILURE", + false, + "error", + "contact-support", + ), + FORBIDDEN: row("FORBIDDEN", false, "warning", "navigate"), + NOT_FOUND: row("NOT_FOUND", false, "info", "navigate"), + CONFLICT: row("CONFLICT", false, "warning", "retry"), + VALIDATION_REJECTED: row("VALIDATION_REJECTED", false, "info", "none"), + UNKNOWN_CLIENT_FAILURE: row( + "UNKNOWN_CLIENT_FAILURE", + false, + "warning", + "contact-support", + ), + RATE_LIMITED: row("RATE_LIMITED", true, "warning", "retry"), + SERVER_FAILURE: row("SERVER_FAILURE", true, "error", "retry"), + CHUNK_LOAD_FAILURE: row( + "CHUNK_LOAD_FAILURE", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), + BOOT_CONFIG_FAILURE: row( + "BOOT_CONFIG_FAILURE", + false, + "error", + "contact-support", + "app.boot.failed", + ), + RELEASE_MANIFEST_FAILURE: row( + "RELEASE_MANIFEST_FAILURE", + false, + "error", + "contact-support", + "app.boot.failed", + ), + DEPLOY_MISMATCH: row( + "DEPLOY_MISMATCH", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), + BUILD_MISMATCH: row( + "BUILD_MISMATCH", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), + CONFIG_MISMATCH: row( + "CONFIG_MISMATCH", + false, + "error", + "contact-support", + "app.boot.failed", + ), + API_CONTRACT_MISMATCH: row( + "API_CONTRACT_MISMATCH", + false, + "error", + "contact-support", + "app.boot.failed", + ), + RELEASE_MISMATCH: row( + "RELEASE_MISMATCH", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), + ASSET_MISMATCH: row( + "ASSET_MISMATCH", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), + STORAGE_UNAVAILABLE: row( + "STORAGE_UNAVAILABLE", + false, + "warning", + "none", + "storage.operation.failed", + ), + STORAGE_QUOTA_EXCEEDED: row( + "STORAGE_QUOTA_EXCEEDED", + false, + "warning", + "none", + "storage.operation.failed", + ), + RENDER_FAILURE: row( + "RENDER_FAILURE", + false, + "error", + "reload-once", + "ui.render.failed", + ), + TELEMETRY_FAILURE: row( + "TELEMETRY_FAILURE", + false, + "info", + "none", + "telemetry.delivery.dropped", + ), + QUERY_CACHE_FAILURE: row( + "QUERY_CACHE_FAILURE", + false, + "error", + "retry", + "query.cache.failed", + ), + UNKNOWN_FAILURE: row("UNKNOWN_FAILURE", false, "error", "contact-support"), +}); + +/** + * Every failure crossing an application input boundary must use one of the + * registry-owned kinds. Adapters may accept untrusted backend codes, but must + * map those codes to this closed vocabulary before returning. + */ +export type FailureKind = keyof typeof ERROR_REGISTRY; + +export type ValidationIssue = Readonly<{ path: string; code: string }>; + +export type FailureEffectCertainty = + | "NOT_APPLICABLE" + | "NOT_STARTED" + | "NOT_APPLIED" + | "APPLIED_CONFIRMED" + | "MAYBE_APPLIED"; + +export type AppFailure = Readonly<{ + kind: FailureKind; + code: string; + httpStatus?: number; + retryable: boolean; + operationId: string; + attemptCount: number; + requestId?: string; + traceId?: string; + retryAfterMs?: number; + effect?: FailureEffectCertainty; + validationIssues?: readonly ValidationIssue[]; + userMessageKey: string; + action: ErrorAction; + causeClass?: string; +}>; + +/** + * Backward-compatible transport-facing name. New application and presentation + * code should prefer AppFailure. + * + */ +export type ApiFailure = AppFailure; + +export type FailureDetails = Readonly<{ + code?: string; + httpStatus?: number; + requestId?: string; + traceId?: string; + retryAfterMs?: number; + effect?: FailureEffectCertainty; + validationIssues?: readonly ValidationIssue[]; + causeClass?: string; +}>; + +export function createFailure( + kind: FailureKind, + operationId: string, + attempt: number, + details: FailureDetails = {}, +): AppFailure { + const definition: ErrorDefinition = ERROR_REGISTRY[kind]; + return Object.freeze({ + kind: definition.kind, + code: typeof details.code === "string" ? details.code : definition.kind, + retryable: + details.effect === "MAYBE_APPLIED" || + details.effect === "APPLIED_CONFIRMED" + ? false + : definition.defaultRetryable, + operationId, + attemptCount: Math.max(1, attempt + 1), + ...(Number.isInteger(details.httpStatus) + ? { httpStatus: details.httpStatus } + : {}), + ...(typeof details.requestId === "string" ? { requestId: details.requestId } : {}), + ...(typeof details.traceId === "string" ? { traceId: details.traceId } : {}), + ...(typeof details.retryAfterMs === "number" + ? { retryAfterMs: details.retryAfterMs } + : {}), + ...(details.effect === undefined ? {} : { effect: details.effect }), + ...(Array.isArray(details.validationIssues) + ? { + validationIssues: Object.freeze( + details.validationIssues + .filter( + (issue) => + issue && + typeof issue === "object" && + typeof issue.path === "string" && + typeof issue.code === "string", + ) + .slice(0, 50) + .map((issue) => + Object.freeze({ path: issue.path, code: issue.code }), + ), + ), + } + : {}), + ...(typeof details.causeClass === "string" + ? { causeClass: details.causeClass } + : {}), + userMessageKey: definition.userMessageKey, + action: + details.effect === "MAYBE_APPLIED" + ? "contact-support" + : details.effect === "APPLIED_CONFIRMED" + ? "none" + : definition.action, + }); +} + +/** + * Adds controller-owned mutation effect knowledge without weakening the + * fail-safe handling required for an unknown server-side outcome. + */ +export function withFailureEffect( + failure: AppFailure, + effect: Exclude, +): AppFailure { + return Object.freeze({ + ...failure, + effect, + ...(effect === "MAYBE_APPLIED" + ? { retryable: false as const, action: "contact-support" as const } + : effect === "APPLIED_CONFIRMED" + ? { retryable: false as const, action: "none" as const } + : {}), + }); +} + +/** + * Projects an untrusted 422 details payload into the only validation metadata + * allowed to cross the HTTP boundary. Backend copy and additional values are + * deliberately discarded. + * + */ +export function safeValidationIssues( + value: unknown, +): readonly ValidationIssue[] { + if (!value || typeof value !== "object") return Object.freeze([]); + const candidate = value as Readonly<{ + issues?: unknown; + fieldErrors?: unknown; + }>; + const issues = Array.isArray(candidate.issues) + ? candidate.issues + : Array.isArray(candidate.fieldErrors) + ? candidate.fieldErrors + : []; + return Object.freeze( + issues + .filter( + (issue): issue is ValidationIssue => + issue && + typeof issue === "object" && + "path" in issue && + "code" in issue && + typeof issue.path === "string" && + typeof issue.code === "string" && + issue.path.length <= 120 && + issue.code.length <= 80, + ) + .slice(0, 50) + .map((issue) => + Object.freeze({ + path: issue.path, + code: issue.code, + }), + ), + ); +} + +export function kindForStatus(status: number): FailureKind { + if (status === 401) return "AUTH_REQUIRED"; + if (status === 403) return "FORBIDDEN"; + if (status === 404) return "NOT_FOUND"; + if (status === 409) return "CONFLICT"; + if (status === 422) return "VALIDATION_REJECTED"; + if (status === 429) return "RATE_LIMITED"; + if (status >= 500) return "SERVER_FAILURE"; + if (status >= 400) return "UNKNOWN_CLIENT_FAILURE"; + return "ENVELOPE_MISMATCH"; +} + +/** + * Total catch-all that intentionally discards the thrown value. + * + */ +export function normalizeUnknownFailure( + value: unknown, + context: Readonly<{ operationId?: string; attempt?: number }> = {}, +): AppFailure { + const causeClass = + value instanceof Error + ? value.name + : value === null + ? "null" + : typeof value; + + return createFailure( + "UNKNOWN_FAILURE", + context.operationId ?? "UNKNOWN_OPERATION", + context.attempt ?? 0, + { code: "UNKNOWN_FAILURE", causeClass }, + ); +} diff --git a/src/contracts/external-contract-runtime.ts b/src/contracts/external-contract-runtime.ts new file mode 100644 index 0000000..53fa937 --- /dev/null +++ b/src/contracts/external-contract-runtime.ts @@ -0,0 +1,582 @@ +/** + * External contract package consumer boundary (§4). + * + * This repository does not own OpenAPI/AsyncAPI source, operation semantics, + * Problem Details meaning or event payload schemas. It owns only the normalized + * descriptor interface, the runtime validator protocol, and the bounds it + * applies before a contribution may be composed. + */ + +/** §7.3 hard ceilings. A contribution may lower these, never raise them. */ +export const HTTP_EXECUTION_CEILINGS = Object.freeze({ + defaultRequestBytes: 262_144, + hardRequestBytes: 1_048_576, + defaultResponseBytes: 1_048_576, + hardResponseBytes: 8_388_608, + problemResponseBytes: 65_536, + pathTemplateBytes: 512, + encodedQueryBytes: 8_192, + examinedHeaderValueBytes: 8_192, + defaultTotalDeadlineMs: 10_000, + hardTotalDeadlineMs: 60_000, + hardRetryCount: 2, + finalUrlBytes: 16_384, +}); + +export type RuntimeValidationIssue = Readonly<{ + path: readonly (string | number)[]; + code: string; +}>; + +export type RuntimeValidationResult = + | Readonly<{ success: true; data: T }> + | Readonly<{ success: false; issues: readonly RuntimeValidationIssue[] }>; + +export interface RuntimeValidator { + readonly schemaId: string; + safeParse(value: unknown): RuntimeValidationResult; +} + +/** + * A validator invocation never escapes as a native throw. `THROWN` is the + * `CONTRACT_RUNTIME_FAILURE` signal; `false` success is the ordinary + * `CONTRACT_VALUE_INVALID` signal. + */ +export type ValidatorInvocation = + | Readonly<{ outcome: "VALID"; value: T }> + | Readonly<{ outcome: "INVALID"; issues: readonly RuntimeValidationIssue[] }> + | Readonly<{ outcome: "THROWN" }>; + +export function invokeValidator( + validator: RuntimeValidator, + value: unknown, +): ValidatorInvocation { + let result: RuntimeValidationResult; + try { + result = validator.safeParse(value); + } catch { + return Object.freeze({ outcome: "THROWN" as const }); + } + if (!result || typeof result !== "object" || !("success" in result)) { + return Object.freeze({ outcome: "THROWN" as const }); + } + if (result.success) { + return Object.freeze({ outcome: "VALID" as const, value: result.data }); + } + return Object.freeze({ + outcome: "INVALID" as const, + issues: Object.freeze([...(result.issues ?? [])]), + }); +} + +export type MappingResult = + | Readonly<{ ok: true; value: T }> + | Readonly<{ + ok: false; + error: Readonly<{ kind: "MAPPING_CONTRACT_VIOLATION"; code: string }>; + }>; + +export function mappingViolation(code: string): MappingResult { + return Object.freeze({ + ok: false as const, + error: Object.freeze({ + kind: "MAPPING_CONTRACT_VIOLATION" as const, + code, + }), + }); +} + +export type CommandRecoveryDescriptor = Readonly<{ + mode: "IDEMPOTENCY_REPLAY" | "INSPECT_OPERATION"; + operationIdentityField: string; + inspectOperationId?: string; +}>; + +export type CommandEffectClassification = + | "NOT_APPLIED" + | "APPLIED_CONFIRMED" + | "MAYBE_APPLIED"; + +export interface CommandEffectDescriptor { + readonly successEffect: "APPLIED_CONFIRMED"; + classifyProblem( + input: Readonly<{ status: number; problem: Problem }>, + ): CommandEffectClassification; +} + +export type HttpMethod = + | "GET" + | "HEAD" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; + +export type RetrySemantics = "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER"; + +export interface HttpExecutionPolicy { + readonly policyId: string; + readonly requestByteLimit: number; + readonly responseByteLimit: number; + readonly totalDeadlineMs: number; + readonly retryBudget: 0 | 1 | 2; + readonly authProfileId: string; + readonly diagnosticsOperation: string; +} + +export interface InstalledHttpContract { + readonly contract: Readonly<{ + operationId: string; + method: HttpMethod; + pathTemplate: string; + inputValidator: RuntimeValidator; + outputValidator: RuntimeValidator; + problemValidator: RuntimeValidator; + acceptedStatuses: readonly number[]; + emptyBodyStatuses: readonly number[]; + retrySemantics: RetrySemantics; + requestBody: "NONE" | "JSON"; + responseBody: "REQUIRED_JSON" | "OPTIONAL_JSON" | "NONE"; + commandRecovery: CommandRecoveryDescriptor | null; + commandEffect: CommandEffectDescriptor | null; + /** + * Descriptor-owned projection from canonical application input to the wire + * request. The frontend never re-derives method, path or query semantics. + */ + projectRequest(input: Input): HttpRequestProjection; + }>; + readonly frontend: HttpExecutionPolicy; +} + +export type HttpRequestProjection = Readonly<{ + /** Ordered path placeholder values keyed by descriptor placeholder name. */ + pathValues: Readonly>; + /** Descriptor-generated query entry order; array encoding is descriptor-owned. */ + queryEntries: readonly (readonly [string, string])[]; + /** Canonical JSON body value, or `null` when `requestBody` is `NONE`. */ + body: unknown; +}>; + +export interface InstalledEventContract { + readonly eventType: string; + readonly envelopeValidator: RuntimeValidator; + readonly payloadValidator: RuntimeValidator; +} + +export interface InstalledContractPackageIdentity { + readonly packageId: string; + readonly version: string; + readonly digest: `sha256:${string}`; + readonly runtimeProtocolVersion: 1; + readonly sourceRevision: string; +} + +export type ContractContributionSource = + | Readonly<{ + kind: "EXTERNAL_PACKAGE"; + package: InstalledContractPackageIdentity; + }> + | Readonly<{ + kind: "TEMPLATE_FIXTURE"; + fixtureId: "REFERENCE_FEATURE_V1"; + revision: 1; + }>; + +export interface InstalledContractContribution { + readonly contributionId: string; + readonly featureId: string; + readonly source: ContractContributionSource; + readonly http: readonly InstalledHttpContract[]; + readonly events: readonly InstalledEventContract[]; +} + +export type ContractCompositionFailureCode = + | "CONTRACT_CONTRIBUTION_INVALID" + | "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED"; + +export class ContractContributionError extends Error { + readonly code: ContractCompositionFailureCode; + readonly reason: string; + + constructor(reason: string, code: ContractCompositionFailureCode = "CONTRACT_CONTRIBUTION_INVALID") { + super("Installed contract contribution is not composable"); + this.name = "ContractContributionError"; + this.code = code; + this.reason = reason; + } +} + +// §4.9 exact validation vocabulary. +const FEATURE_ID = /^[a-z][a-z0-9-]{0,63}$/; +const CONTRIBUTION_ID = /^[a-z][a-z0-9._-]{0,127}$/; +const PACKAGE_ID = + /^@[a-z0-9][a-z0-9._-]{0,62}\/[a-z0-9][a-z0-9._-]{0,62}$/; +const SEM_VER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +const DIGEST = /^sha256:[0-9a-f]{64}$/; +const SOURCE_REVISION = /^[0-9a-f]{7,64}$/; +const OPERATION_ID = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/; +/** Whitespace and C0/C1 control characters are rejected in an event type. */ +function hasControlOrSpace(value: string): boolean { + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code <= 0x20 || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +} + +const encoder = new TextEncoder(); + +function utf8Bytes(value: string): number { + return encoder.encode(value).byteLength; +} + +function fail(reason: string): never { + throw new ContractContributionError(reason); +} + +function assertPackageIdentity( + identity: InstalledContractPackageIdentity, + featureId: string, +): void { + if (!identity || typeof identity !== "object") { + fail(`${featureId}: package identity`); + } + if (identity.runtimeProtocolVersion !== 1) { + throw new ContractContributionError( + `${featureId}: runtime protocol version must be exactly 1`, + "CONTRACT_RUNTIME_PROTOCOL_UNSUPPORTED", + ); + } + if (!PACKAGE_ID.test(identity.packageId)) fail(`${featureId}: packageId`); + if ( + typeof identity.version !== "string" || + identity.version !== identity.version.trim() || + identity.version.startsWith("v") || + !SEM_VER.test(identity.version) + ) { + fail(`${featureId}: package version must be exact SemVer`); + } + if (!DIGEST.test(identity.digest)) fail(`${featureId}: package digest`); + if (!SOURCE_REVISION.test(identity.sourceRevision)) { + fail(`${featureId}: package sourceRevision`); + } +} + +function assertValidator( + validator: RuntimeValidator, + label: string, +): void { + if ( + !validator || + typeof validator.safeParse !== "function" || + typeof validator.schemaId !== "string" || + validator.schemaId.length === 0 + ) { + fail(`${label}: runtime validator with non-empty schemaId is required`); + } +} + +function assertStatuses( + statuses: readonly number[], + label: string, +): void { + if (!Array.isArray(statuses)) fail(`${label}: status array required`); + if (statuses.length < 1 || statuses.length > 32) { + fail(`${label}: 1..32 statuses required`); + } + for (let index = 0; index < statuses.length; index += 1) { + const status = statuses[index] as number; + if (!Number.isInteger(status) || status < 100 || status > 599) { + fail(`${label}: status out of range`); + } + const previous = statuses[index - 1]; + if (index > 0 && previous !== undefined && status <= previous) { + fail(`${label}: statuses must be sorted and unique`); + } + } +} + +function assertExecutionPolicy( + policy: HttpExecutionPolicy, + label: string, +): void { + const ceilings = HTTP_EXECUTION_CEILINGS; + if ( + !policy || + typeof policy.policyId !== "string" || + policy.policyId.length === 0 || + typeof policy.authProfileId !== "string" || + policy.authProfileId.length === 0 || + typeof policy.diagnosticsOperation !== "string" || + policy.diagnosticsOperation.length === 0 + ) { + fail(`${label}: frontend execution policy identity`); + } + if ( + !Number.isSafeInteger(policy.requestByteLimit) || + policy.requestByteLimit < 0 || + policy.requestByteLimit > ceilings.hardRequestBytes + ) { + fail(`${label}: requestByteLimit exceeds the hard ceiling`); + } + if ( + !Number.isSafeInteger(policy.responseByteLimit) || + policy.responseByteLimit < 1 || + policy.responseByteLimit > ceilings.hardResponseBytes + ) { + fail(`${label}: responseByteLimit exceeds the hard ceiling`); + } + if ( + !Number.isSafeInteger(policy.totalDeadlineMs) || + policy.totalDeadlineMs < 1 || + policy.totalDeadlineMs > ceilings.hardTotalDeadlineMs + ) { + fail(`${label}: totalDeadlineMs exceeds the hard ceiling`); + } + if ( + policy.retryBudget !== 0 && + policy.retryBudget !== 1 && + policy.retryBudget !== 2 + ) { + fail(`${label}: retryBudget must be 0, 1 or 2`); + } +} + +function assertHttpContract( + installed: InstalledHttpContract, + featureId: string, +): void { + const contract = installed?.contract; + if (!contract || typeof contract !== "object") { + fail(`${featureId}: http contract descriptor missing`); + } + const label = `${featureId}/${String(contract.operationId)}`; + + if ( + typeof contract.operationId !== "string" || + !OPERATION_ID.test(contract.operationId) + ) { + fail(`${label}: operationId`); + } + if ( + contract.method !== "GET" && + contract.method !== "HEAD" && + contract.method !== "POST" && + contract.method !== "PUT" && + contract.method !== "PATCH" && + contract.method !== "DELETE" + ) { + fail(`${label}: method`); + } + if ( + typeof contract.pathTemplate !== "string" || + !contract.pathTemplate.startsWith("/") || + utf8Bytes(contract.pathTemplate) > HTTP_EXECUTION_CEILINGS.pathTemplateBytes || + contract.pathTemplate.includes("?") || + contract.pathTemplate.includes("#") + ) { + fail(`${label}: pathTemplate`); + } + assertValidator(contract.inputValidator, `${label}.input`); + assertValidator(contract.outputValidator, `${label}.output`); + assertValidator(contract.problemValidator, `${label}.problem`); + if (typeof contract.projectRequest !== "function") { + fail(`${label}: descriptor request projection is required`); + } + if ( + contract.retrySemantics !== "SAFE" && + contract.retrySemantics !== "IDEMPOTENT" && + contract.retrySemantics !== "KEYED" && + contract.retrySemantics !== "NEVER" + ) { + fail(`${label}: retrySemantics`); + } + if (contract.requestBody !== "NONE" && contract.requestBody !== "JSON") { + fail(`${label}: requestBody`); + } + if ( + contract.responseBody !== "REQUIRED_JSON" && + contract.responseBody !== "OPTIONAL_JSON" && + contract.responseBody !== "NONE" + ) { + fail(`${label}: responseBody`); + } + assertStatuses(contract.acceptedStatuses, `${label}.acceptedStatuses`); + if (contract.emptyBodyStatuses.length > 0) { + assertStatuses(contract.emptyBodyStatuses, `${label}.emptyBodyStatuses`); + const accepted = new Set(contract.acceptedStatuses); + for (const status of contract.emptyBodyStatuses) { + if (!accepted.has(status)) { + fail(`${label}: empty-body status must be an accepted status`); + } + } + } + const isRead = contract.method === "GET" || contract.method === "HEAD"; + if (isRead) { + if (contract.commandEffect !== null || contract.commandRecovery !== null) { + fail(`${label}: read operations carry no command descriptors`); + } + if (contract.requestBody !== "NONE") { + fail(`${label}: read operations carry no request body`); + } + } else { + if (!contract.commandEffect) { + fail(`${label}: command operations require a command effect descriptor`); + } + if (contract.commandEffect.successEffect !== "APPLIED_CONFIRMED") { + fail(`${label}: command success effect`); + } + if (typeof contract.commandEffect.classifyProblem !== "function") { + fail(`${label}: command effect classifier is required`); + } + if (contract.retrySemantics === "KEYED" && !contract.commandRecovery) { + fail(`${label}: KEYED commands require a recovery descriptor`); + } + } + if (contract.commandRecovery) { + const recovery = contract.commandRecovery; + if ( + (recovery.mode !== "IDEMPOTENCY_REPLAY" && + recovery.mode !== "INSPECT_OPERATION") || + !recovery.operationIdentityField || + (recovery.mode === "INSPECT_OPERATION" && !recovery.inspectOperationId) + ) { + fail(`${label}: command recovery descriptor`); + } + } + assertExecutionPolicy(installed.frontend, label); + if ( + installed.frontend.retryBudget > 0 && + contract.retrySemantics === "NEVER" + ) { + fail(`${label}: retry budget contradicts NEVER retry semantics`); + } +} + +function assertEventContract( + event: InstalledEventContract, + featureId: string, +): void { + const label = `${featureId}/${String(event?.eventType)}`; + if ( + typeof event?.eventType !== "string" || + event.eventType.length === 0 || + utf8Bytes(event.eventType) > 160 || + hasControlOrSpace(event.eventType) + ) { + fail(`${label}: eventType`); + } + assertValidator(event.envelopeValidator, `${label}.envelope`); + assertValidator(event.payloadValidator, `${label}.payload`); +} + +export type ComposedContractContributions = Readonly<{ + contributions: readonly InstalledContractContribution[]; + httpByOperationId: ReadonlyMap< + string, + InstalledHttpContract + >; + eventByType: ReadonlyMap>; + externalPackages: readonly InstalledContractPackageIdentity[]; +}>; + +/** + * §4.8–§4.9. The only place installed contributions become a runtime registry. + * Every bound is checked before composition; a violation stops the boot rather + * than degrading into an assumed meaning. + */ +export function composeContractContributions( + contributions: readonly InstalledContractContribution[], +): ComposedContractContributions { + if (!Array.isArray(contributions)) fail("contributions: array required"); + const httpByOperationId = new Map< + string, + InstalledHttpContract + >(); + const eventByType = new Map< + string, + InstalledEventContract + >(); + const packagesById = new Map(); + const contributionIds = new Set(); + + for (const contribution of contributions) { + if (!contribution || typeof contribution !== "object") { + fail("contribution: object required"); + } + const contributionId = contribution.contributionId; + if ( + typeof contributionId !== "string" || + !CONTRIBUTION_ID.test(contributionId) + ) { + fail(`contributionId: ${String(contributionId)}`); + } + if (contributionIds.has(contributionId)) { + fail(`duplicate contributionId: ${contributionId}`); + } + contributionIds.add(contributionId); + const featureId = contribution?.featureId; + if (typeof featureId !== "string" || !FEATURE_ID.test(featureId)) { + fail(`featureId: ${String(featureId)}`); + } + const source = contribution.source; + if (!source || typeof source !== "object" || !("kind" in source)) { + fail(`${featureId}: source`); + } + if (!Array.isArray(contribution.http) || !Array.isArray(contribution.events)) { + fail(`${featureId}: contribution arrays`); + } + if (source.kind === "EXTERNAL_PACKAGE") { + assertPackageIdentity(source.package, featureId); + const existing = packagesById.get(source.package.packageId); + if ( + existing && + (existing.version !== source.package.version || + existing.digest !== source.package.digest || + existing.sourceRevision !== source.package.sourceRevision) + ) { + fail( + `${featureId}: package ${source.package.packageId} has conflicting identities`, + ); + } + packagesById.set(source.package.packageId, source.package); + } else if (source.kind === "TEMPLATE_FIXTURE") { + if (source.fixtureId !== "REFERENCE_FEATURE_V1" || source.revision !== 1) { + fail(`${featureId}: template fixture identity`); + } + if (contribution.events.length !== 0) { + fail(`${featureId}: template fixture must not contribute events`); + } + } else { + fail(`${featureId}: unknown contribution source kind`); + } + + for (const installed of contribution.http) { + assertHttpContract(installed, featureId); + const operationId = installed.contract.operationId; + const previous = httpByOperationId.get(operationId); + if (previous) fail(`duplicate operation: ${operationId}`); + httpByOperationId.set(operationId, installed); + } + + for (const event of contribution.events) { + assertEventContract(event, featureId); + if (eventByType.has(event.eventType)) { + fail(`duplicate event type: ${event.eventType}`); + } + eventByType.set(event.eventType, event); + } + } + + const externalPackages = [...packagesById.values()].map((identity) => + Object.freeze({ ...identity }), + ); + + return Object.freeze({ + contributions: Object.freeze([...contributions]), + httpByOperationId, + eventByType, + externalPackages: Object.freeze(externalPackages), + }); +} diff --git a/src/contracts/mutation-intent.ts b/src/contracts/mutation-intent.ts new file mode 100644 index 0000000..526033d --- /dev/null +++ b/src/contracts/mutation-intent.ts @@ -0,0 +1,59 @@ +export const MUTATION_INTENT_BOUNDS = Object.freeze({ + intentIdMaxBytes: 256, + operationIdMaxBytes: 256, + canonicalInputIdentityMaxBytes: 16_384, + idempotencyKeyMaxBytes: 256, +} as const); + +export type MutationIntent = Readonly<{ + intentId: string; + operationId: string; + canonicalInputIdentity: string; + idempotencyKey?: string; + createdAtMonotonicMs: number; +}>; + +const UTF8 = new TextEncoder(); + +function validBoundedString(value: unknown, maxBytes: number): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + UTF8.encode(value).byteLength <= maxBytes + ); +} + +export function defineMutationIntent(intent: MutationIntent): MutationIntent { + if ( + !validBoundedString( + intent.intentId, + MUTATION_INTENT_BOUNDS.intentIdMaxBytes, + ) || + !validBoundedString( + intent.operationId, + MUTATION_INTENT_BOUNDS.operationIdMaxBytes, + ) || + !validBoundedString( + intent.canonicalInputIdentity, + MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes, + ) || + (intent.idempotencyKey !== undefined && + !validBoundedString( + intent.idempotencyKey, + MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes, + )) || + !Number.isFinite(intent.createdAtMonotonicMs) || + intent.createdAtMonotonicMs < 0 + ) { + throw new TypeError("Mutation intent is invalid."); + } + return Object.freeze({ + intentId: intent.intentId, + operationId: intent.operationId, + canonicalInputIdentity: intent.canonicalInputIdentity, + ...(intent.idempotencyKey === undefined + ? {} + : { idempotencyKey: intent.idempotencyKey }), + createdAtMonotonicMs: intent.createdAtMonotonicMs, + }); +} diff --git a/src/contracts/offline-command.ts b/src/contracts/offline-command.ts new file mode 100644 index 0000000..1c57aa9 --- /dev/null +++ b/src/contracts/offline-command.ts @@ -0,0 +1,154 @@ +/** + * §19. Offline Command and Background Sync contract. + * + * The product capability is `NOT_SELECTED` (§19.1). An operation only becomes + * queueable when the external package contribution provides `KEYED` retry + * semantics plus a non-null recovery descriptor (§19.3); the frontend never + * defines server idempotency or an inspect protocol of its own. + */ + +export const OFFLINE_COMMAND_BOUNDS = Object.freeze({ + operations: 64, + defaultRequestBytes: 262_144, + hardRequestBytes: 1_048_576, + records: 1_000, + datasetBytes: 50 * 1024 * 1024, + senderLeaseMs: 30_000, + leaseRenewMs: 10_000, + batchCount: 10, + batchWindowMs: 30_000, + parallelSend: 1, + retryBaseMs: 1_000, + retryMaxMs: 300_000, + attemptCap: 10, + ordinaryRetentionMs: 7 * 24 * 60 * 60 * 1_000, + conflictRetentionMs: 30 * 24 * 60 * 60 * 1_000, + ackedSummaryRetentionMs: 24 * 60 * 60 * 1_000, + syncReregisterMinimumMs: 60_000, +}); + +/** §19.18. Background Sync is a wake-up hint only; it never sends a command. */ +export const OFFLINE_SYNC_TAG = "ca-outbox-v1" as const; + +export interface InstalledOfflineOperation { + readonly operationId: string; + readonly contractPackageId: string; + readonly maximumRequestBytes: number; + readonly retentionClass: "STANDARD_7D"; +} + +export interface InstalledOfflineCommandContribution { + readonly datasetId: "OFFLINE_COMMANDS_V1"; + readonly operations: readonly InstalledOfflineOperation[]; +} + +export type OfflineCommandState = + | "PENDING" + | "LEASED" + | "FOREGROUND_REQUIRED" + | "SENDING" + | "RETRY_WAIT" + | "ACKED" + | "CONFLICT" + | "EFFECT_UNKNOWN" + | "EXPIRED"; + +export interface OfflineCommandRecordV1 { + readonly recordVersion: 1; + readonly commandId: string; + readonly operationId: string; + readonly contractPackageId: string; + readonly contractPackageVersion: string; + readonly contractPackageDigest: `sha256:${string}`; + readonly scopePartition: string; + readonly requestDigest: `sha256:${string}`; + readonly requestPayload: Uint8Array; + readonly idempotencyKey: string; + readonly state: OfflineCommandState; + readonly attempt: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly nextAttemptAt?: string; + readonly leaseOwner?: string; + readonly leaseExpiresAt?: string; + readonly terminalCode?: string; +} + +/** + * §19.10. Anything not listed is corruption. In particular an expired + * `SENDING` record is never reset to `PENDING`: it becomes `EFFECT_UNKNOWN`. + */ +const ALLOWED_TRANSITIONS = Object.freeze({ + PENDING: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]), + LEASED: Object.freeze(["SENDING", "PENDING"]), + SENDING: Object.freeze([ + "ACKED", + "RETRY_WAIT", + "CONFLICT", + "EFFECT_UNKNOWN", + ]), + RETRY_WAIT: Object.freeze(["LEASED", "FOREGROUND_REQUIRED", "EXPIRED"]), + FOREGROUND_REQUIRED: Object.freeze(["LEASED", "EXPIRED", "PENDING"]), + ACKED: Object.freeze([]), + CONFLICT: Object.freeze([]), + EFFECT_UNKNOWN: Object.freeze(["ACKED", "PENDING"]), + EXPIRED: Object.freeze([]), +} satisfies Readonly>); + +export function isAllowedOfflineTransition( + from: OfflineCommandState, + to: OfflineCommandState, +): boolean { + const allowed: readonly OfflineCommandState[] = ALLOWED_TRANSITIONS[from]; + return allowed.includes(to); +} + +/** §19.21. Nothing sensitive reaches the UI: no payload, key, digest or partition. */ +export interface OfflineCommandSummary { + readonly commandId: string; + readonly operationLabelKey: string; + readonly state: OfflineCommandState; + readonly createdAt: string; + readonly nextAction: + | "WAIT" + | "OPEN_APP" + | "CHECK_STATUS" + | "RESOLVE_CONFLICT" + | "CONTACT_SUPPORT" + | "DISMISS"; +} + +export function validateOfflineCommandContribution( + contribution: InstalledOfflineCommandContribution, +): InstalledOfflineCommandContribution { + if (contribution.datasetId !== "OFFLINE_COMMANDS_V1") { + throw new TypeError("Offline command dataset identity is invalid."); + } + if ( + contribution.operations.length === 0 || + contribution.operations.length > OFFLINE_COMMAND_BOUNDS.operations + ) { + throw new TypeError("Offline command operation count is out of range."); + } + const seen = new Set(); + for (const operation of contribution.operations) { + if (!operation.operationId || seen.has(operation.operationId)) { + throw new TypeError("Duplicate offline command operation."); + } + seen.add(operation.operationId); + if ( + !Number.isSafeInteger(operation.maximumRequestBytes) || + operation.maximumRequestBytes < 1 || + operation.maximumRequestBytes > OFFLINE_COMMAND_BOUNDS.hardRequestBytes || + operation.retentionClass !== "STANDARD_7D" + ) { + throw new TypeError( + `Offline command operation bounds invalid: ${operation.operationId}`, + ); + } + } + return Object.freeze({ + datasetId: contribution.datasetId, + operations: Object.freeze([...contribution.operations]), + }); +} diff --git a/src/contracts/query-invalidation.ts b/src/contracts/query-invalidation.ts new file mode 100644 index 0000000..aa58c33 --- /dev/null +++ b/src/contracts/query-invalidation.ts @@ -0,0 +1,258 @@ +import { isCacheInvalidationTopic } from "./cache-invalidation.ts"; +import { + defineQueryNamespaceIdentity, + queryNamespaceIdentityKey, + type QueryNamespaceIdentity, +} from "./query-keys.ts"; + +declare const queryInvalidationTopicBrand: unique symbol; + +/** + * Opaque registry-issued invalidation identity. The brand prevents feature + * code from accidentally passing a concrete query-key string to the mutation + * bridge. + */ +export type QueryInvalidationTopic = string & + Readonly<{ [queryInvalidationTopicBrand]: true }>; + +export function defineQueryInvalidationTopic( + value: string, +): QueryInvalidationTopic { + if (!isCacheInvalidationTopic(value)) { + throw new TypeError("Query invalidation topic is invalid."); + } + return value as QueryInvalidationTopic; +} + +/** + * §12.2. Many-to-many topic/namespace registry. + * + * Topics stay opaque on the wire; the registry is what turns one received topic + * into the local namespaces that must revalidate. Bounds are checked at startup + * so a fan-out explosion cannot be introduced at runtime. + */ +export const INVALIDATION_REGISTRY_BOUNDS = Object.freeze({ + maxTopics: 256, + maxNamespaces: 256, + maxEdges: 1_024, + maxTopicFanOut: 64, + maxNamespaceFanIn: 64, + maxIdBytes: 80, +}); + +export type InvalidationRegistryEdge = Readonly<{ + topicId: string; + namespace: QueryNamespaceIdentity; +}>; + +export interface InvalidationRegistry { + readonly topics: readonly string[]; + readonly namespaces: readonly QueryNamespaceIdentity[]; + readonly edges: readonly InvalidationRegistryEdge[]; +} + +export type InvalidationRegistryIndex = Readonly<{ + namespacesForTopic: ReadonlyMap< + string, + readonly QueryNamespaceIdentity[] + >; + topicsForNamespace: ReadonlyMap; +}>; + +export type InvalidationTopicVersionDefinition = Readonly<{ + topicId: string; + topicVersion: number; +}>; + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 0x1f || codePoint === 0x7f) return true; + } + return false; +} + +/** + * Rejects duplicates, orphan topics and orphan namespaces at startup. An edge + * that points at an unregistered endpoint is a composition defect, not a + * runtime condition to be tolerated. + */ +export function indexInvalidationRegistry( + registry: InvalidationRegistry, +): InvalidationRegistryIndex { + const bounds = INVALIDATION_REGISTRY_BOUNDS; + const encoder = new TextEncoder(); + const assertId = (value: string, label: string) => { + if ( + typeof value !== "string" || + value.length === 0 || + hasControlCharacter(value) || + encoder.encode(value).byteLength > bounds.maxIdBytes + ) { + throw new TypeError(`Invalidation registry ${label} is invalid.`); + } + }; + + if ( + registry.topics.length > bounds.maxTopics || + registry.namespaces.length > bounds.maxNamespaces || + registry.edges.length > bounds.maxEdges + ) { + throw new TypeError("Invalidation registry exceeds its bounds."); + } + + const topics = new Set(); + for (const topic of registry.topics) { + assertId(topic, "topic"); + if (topics.has(topic)) { + throw new TypeError(`Duplicate invalidation topic: ${topic}`); + } + topics.add(topic); + } + const namespaces = new Map(); + for (const namespace of registry.namespaces) { + let namespaceKey: string; + let namespaceSnapshot: QueryNamespaceIdentity; + try { + namespaceSnapshot = defineQueryNamespaceIdentity( + namespace.namespaceId, + namespace.namespaceVersion, + ); + namespaceKey = queryNamespaceIdentityKey(namespaceSnapshot); + } catch (error) { + throw new TypeError("Invalidation registry namespace is invalid.", { + cause: error, + }); + } + if (namespaces.has(namespaceKey)) { + throw new TypeError(`Duplicate invalidation namespace: ${namespaceKey}`); + } + namespaces.set(namespaceKey, namespaceSnapshot); + } + + const namespacesForTopic = new Map(); + const topicsForNamespace = new Map(); + const seenEdges = new Map>(); + for (const edge of registry.edges) { + let namespaceKey: string; + try { + namespaceKey = queryNamespaceIdentityKey(edge.namespace); + } catch (error) { + throw new TypeError("Invalidation registry namespace is invalid.", { + cause: error, + }); + } + const registeredNamespace = namespaces.get(namespaceKey); + if (!topics.has(edge.topicId) || !registeredNamespace) { + throw new TypeError("Invalidation edge references an unknown endpoint."); + } + const seenNamespaces = seenEdges.get(edge.topicId) ?? new Set(); + if (seenNamespaces.has(namespaceKey)) { + throw new TypeError("Duplicate invalidation edge."); + } + seenNamespaces.add(namespaceKey); + seenEdges.set(edge.topicId, seenNamespaces); + + const fanOut = namespacesForTopic.get(edge.topicId) ?? []; + fanOut.push(registeredNamespace); + if (fanOut.length > bounds.maxTopicFanOut) { + throw new TypeError(`Invalidation topic fan-out exceeded: ${edge.topicId}`); + } + namespacesForTopic.set(edge.topicId, fanOut); + + const fanIn = topicsForNamespace.get(namespaceKey) ?? []; + fanIn.push(edge.topicId); + if (fanIn.length > bounds.maxNamespaceFanIn) { + throw new TypeError( + `Invalidation namespace fan-in exceeded: ${namespaceKey}`, + ); + } + topicsForNamespace.set(namespaceKey, fanIn); + } + + for (const topic of topics) { + if (!namespacesForTopic.has(topic)) { + throw new TypeError(`Orphan invalidation topic: ${topic}`); + } + } + for (const namespaceKey of namespaces.keys()) { + if (!topicsForNamespace.has(namespaceKey)) { + throw new TypeError(`Orphan invalidation namespace: ${namespaceKey}`); + } + } + + return Object.freeze({ + namespacesForTopic: new Map( + [...namespacesForTopic].map(([key, value]) => [ + key, + Object.freeze([...value]) as readonly QueryNamespaceIdentity[], + ]), + ), + topicsForNamespace: new Map( + [...topicsForNamespace].map(([key, value]) => [ + key, + Object.freeze([...value]) as readonly string[], + ]), + ), + }); +} + +/** Projects the graph's wire-only topic versions and rejects composition drift. */ +export function indexInvalidationTopicVersions( + registry: Pick, + definitions: readonly InvalidationTopicVersionDefinition[], +): ReadonlyMap { + if ( + registry.topics.length > INVALIDATION_REGISTRY_BOUNDS.maxTopics || + definitions.length > INVALIDATION_REGISTRY_BOUNDS.maxTopics + ) { + throw new TypeError("Invalidation topic version registry exceeds its bounds."); + } + const registeredTopics = new Set(registry.topics); + if ( + registeredTopics.size !== registry.topics.length || + definitions.length !== registeredTopics.size + ) { + throw new TypeError("Invalidation topic version registry is inconsistent."); + } + + const versions = new Map(); + for (const definition of definitions) { + if ( + !isCacheInvalidationTopic(definition.topicId) || + !registeredTopics.has(definition.topicId) || + !Number.isSafeInteger(definition.topicVersion) || + definition.topicVersion < 1 || + versions.has(definition.topicId) + ) { + throw new TypeError("Invalidation topic version registry is invalid."); + } + versions.set(definition.topicId, definition.topicVersion); + } + return versions; +} + +export type QueryMutationLease = Readonly<{ + /** + * Releases one local mutation fence. Remote hints coalesced while the fence + * was held are applied once after the final lease for each topic is released. + */ + release(): Promise; +}>; + +/** + * Presentation-side facade for server-state invalidation. + * + * The caller knows only registry-issued topics. Query keys, BroadcastChannel + * envelopes and browser transports stay inside the query infrastructure. + */ +export interface QueryInvalidationCoordinator { + invalidate(topics: readonly QueryInvalidationTopic[]): Promise; + beginMutation(topics: readonly QueryInvalidationTopic[]): QueryMutationLease; + /** + * Local verified lifecycle only. A remote invalidation hint is never allowed + * to clear the complete cache. + */ + resetLocal(): Promise; + dispose(): void; +} diff --git a/src/contracts/query-keys.ts b/src/contracts/query-keys.ts new file mode 100644 index 0000000..2a0f23f --- /dev/null +++ b/src/contracts/query-keys.ts @@ -0,0 +1,315 @@ +export const QUERY_KEY_SCHEMA_VERSION = 2 as const; +export const QUERY_NAMESPACE_ID_MAX_BYTES = 80 as const; + +export type QueryNamespaceIdentity = Readonly<{ + namespaceId: string; + namespaceVersion: number; +}>; + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) + ) { + return true; + } + } + return false; +} + +function assertQueryNamespaceIdentity( + namespace: QueryNamespaceIdentity, +): void { + if ( + !namespace || + typeof namespace !== "object" || + typeof namespace.namespaceId !== "string" || + namespace.namespaceId.length === 0 || + hasControlCharacter(namespace.namespaceId) || + new TextEncoder().encode(namespace.namespaceId).byteLength > + QUERY_NAMESPACE_ID_MAX_BYTES || + !Number.isSafeInteger(namespace.namespaceVersion) || + namespace.namespaceVersion < 1 + ) { + throw new TypeError("Query namespace identity is invalid."); + } +} + +export function defineQueryNamespaceIdentity( + namespaceId: string, + namespaceVersion: number, +): QueryNamespaceIdentity { + const namespace = { namespaceId, namespaceVersion }; + assertQueryNamespaceIdentity(namespace); + return Object.freeze(namespace); +} + +export function queryNamespaceIdentityKey( + namespace: QueryNamespaceIdentity, +): string { + assertQueryNamespaceIdentity(namespace); + return JSON.stringify([namespace.namespaceId, namespace.namespaceVersion]); +} + +export function createQueryInvalidationPrefix( + namespace: QueryNamespaceIdentity, +) { + assertQueryNamespaceIdentity(namespace); + return Object.freeze([ + "query", + QUERY_KEY_SCHEMA_VERSION, + namespace.namespaceId, + namespace.namespaceVersion, + ] as const); +} + +export function createBoundQueryKey( + namespace: QueryNamespaceIdentity, + scopeFingerprint: string, + definitionVersion: number, + identityToken: string, +) { + if (!Number.isSafeInteger(definitionVersion) || definitionVersion < 1) { + throw new TypeError("Query definition version is invalid."); + } + return Object.freeze([ + ...createQueryInvalidationPrefix(namespace), + scopeFingerprint, + definitionVersion, + identityToken, + ] as const); +} + +export type CanonicalValue = + | null + | boolean + | number + | string + | readonly CanonicalValue[] + | Readonly<{ [key: string]: CanonicalValue }>; + +const DEFAULT_LIMITS = Object.freeze({ + maxDepth: 12, + maxNodes: 512, + maxStringBytes: 2_048, + maxEncodedBytes: 16_384, +}); + +export function canonicalize(value: unknown): CanonicalValue { + const seen = new WeakSet(); + let nodes = 0; + const encoder = new TextEncoder(); + + function visit(candidate: unknown, depth: number): CanonicalValue { + nodes += 1; + if (nodes > DEFAULT_LIMITS.maxNodes || depth > DEFAULT_LIMITS.maxDepth) { + throw new TypeError("Query identity exceeds its structural budget."); + } + if ( + candidate === null || + typeof candidate === "boolean" || + (typeof candidate === "number" && + Number.isFinite(candidate) && + !Object.is(candidate, -0)) + ) { + return candidate; + } + if (typeof candidate === "string") { + if (encoder.encode(candidate).byteLength > DEFAULT_LIMITS.maxStringBytes) { + throw new TypeError("Query identity string exceeds its byte budget."); + } + return candidate; + } + if (!candidate || typeof candidate !== "object") { + throw new TypeError("Query identity contains a non-canonical value."); + } + if (seen.has(candidate)) { + throw new TypeError("Query identity contains a cycle or shared reference."); + } + seen.add(candidate); + if (Array.isArray(candidate)) { + for (let index = 0; index < candidate.length; index += 1) { + if (!Object.hasOwn(candidate, index)) { + throw new TypeError("Query identity contains a sparse array."); + } + } + return Object.freeze(candidate.map((item) => visit(item, depth + 1))); + } + const prototype = Object.getPrototypeOf(candidate); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Query identity requires plain objects."); + } + const descriptors = Object.getOwnPropertyDescriptors(candidate); + const output: Record = Object.create(null); + for (const key of Object.keys(descriptors).sort()) { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new TypeError("Query identity contains a forbidden key."); + } + const descriptor = descriptors[key]; + if (!descriptor || !("value" in descriptor)) { + throw new TypeError("Query identity contains an accessor."); + } + output[key] = visit(descriptor.value, depth + 1); + } + return Object.freeze(output); + } + + const result = visit(value, 0); + if (encoder.encode(JSON.stringify(result)).byteLength > DEFAULT_LIMITS.maxEncodedBytes) { + throw new TypeError("Query identity exceeds its encoded byte budget."); + } + return result; +} + +export type RuntimeIdentityBinding = Readonly<{ + token: string; + acquire(): void; + release(): void; +}>; + +export type RuntimeIdentityRegistry = Readonly<{ + intern(value: unknown): RuntimeIdentityBinding; + close(): void; + inspect(): Readonly<{ + entries: number; + canonicalBytes: number; + activeLeases: number; + closed: boolean; + }>; +}>; + +type IdentityRow = { + canonical: string; + canonicalBytes: number; + token: string; + refCount: number; + touched: number; +}; + +export function createRuntimeIdentityRegistry( + options: Readonly<{ + maxEntries?: number; + maxCanonicalBytes?: number; + tokenFactory?: () => string; + }> = {}, +): RuntimeIdentityRegistry { + const maxEntries = options.maxEntries ?? 4_096; + const maxCanonicalBytes = options.maxCanonicalBytes ?? 4 * 1024 * 1024; + const tokenFactory = + options.tokenFactory ?? + (() => { + if ( + typeof crypto === "undefined" || + typeof crypto.randomUUID !== "function" + ) { + throw new TypeError("Secure runtime identity generation is unavailable."); + } + return crypto.randomUUID(); + }); + const byCanonical = new Map(); + const byToken = new Map(); + let totalCanonicalBytes = 0; + let sequence = 0; + let closed = false; + + function evictAvailable(requiredBytes: number): void { + const candidates = [...byCanonical.values()] + .filter((row) => row.refCount === 0) + .sort((left, right) => left.touched - right.touched); + for (const row of candidates) { + if ( + byCanonical.size < maxEntries && + totalCanonicalBytes + requiredBytes <= maxCanonicalBytes + ) { + return; + } + byCanonical.delete(row.canonical); + byToken.delete(row.token); + totalCanonicalBytes -= row.canonicalBytes; + } + } + + return Object.freeze({ + intern(value): RuntimeIdentityBinding { + if (closed) throw new TypeError("Runtime identity registry is closed."); + const canonical = JSON.stringify(canonicalize(value)); + const canonicalBytes = new TextEncoder().encode(canonical).byteLength; + let row = byCanonical.get(canonical); + if (!row) { + evictAvailable(canonicalBytes); + if ( + byCanonical.size >= maxEntries || + totalCanonicalBytes + canonicalBytes > maxCanonicalBytes + ) { + throw new TypeError("Runtime identity capacity exceeded."); + } + let token = ""; + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = tokenFactory(); + if ( + /^[A-Za-z0-9._:-]{16,128}$/.test(candidate) && + !byToken.has(candidate) + ) { + token = candidate; + break; + } + } + if (!token) { + throw new TypeError("Runtime identity token collision."); + } + row = { + canonical, + canonicalBytes, + token, + refCount: 0, + touched: sequence++, + }; + byCanonical.set(canonical, row); + byToken.set(token, row); + totalCanonicalBytes += canonicalBytes; + } + row.touched = sequence++; + let leaseCount = 0; + return Object.freeze({ + token: row.token, + acquire() { + if (closed) return; + leaseCount += 1; + row.refCount += 1; + row.touched = sequence++; + }, + release() { + if (leaseCount === 0) return; + leaseCount -= 1; + row.refCount = Math.max(0, row.refCount - 1); + row.touched = sequence++; + }, + }); + }, + close() { + closed = true; + byCanonical.clear(); + byToken.clear(); + totalCanonicalBytes = 0; + }, + inspect() { + return Object.freeze({ + entries: byCanonical.size, + canonicalBytes: totalCanonicalBytes, + activeLeases: [...byCanonical.values()].reduce( + (total, row) => total + row.refCount, + 0, + ), + closed, + }); + }, + }); +} + +const defaultIdentityRegistry = createRuntimeIdentityRegistry(); + +export function runtimeIdentityToken(value: unknown): string { + return defaultIdentityRegistry.intern(value).token; +} diff --git a/src/contracts/realtime-events.ts b/src/contracts/realtime-events.ts new file mode 100644 index 0000000..8473ecc --- /dev/null +++ b/src/contracts/realtime-events.ts @@ -0,0 +1,185 @@ +import { + REALTIME_EVENT_PROTOCOL, + type EventTypeId, + type StreamRegistrationId, +} from "./realtime-streams.ts"; + +export const REALTIME_EVENT_FIELD_LIMITS = Object.freeze({ + maxOpaqueIdentifierLength: 128, + maxScopeBindingLength: 256, + maxResumeCursorLength: 1_024, + maxSequenceDigits: 20, + maxTimestampFractionDigits: 9, +}); + +export const REALTIME_MAX_SEQUENCE = "18446744073709551615"; + +export type RealtimeEventBase = Readonly<{ + protocol: typeof REALTIME_EVENT_PROTOCOL; + streamId: StreamRegistrationId; + streamEpoch: string; + eventType: EventTypeId; + eventId: string; + sequence: string; + occurredAt: string; + scopeBinding: string; + payload: Payload; +}>; + +export type RealtimeEventEnvelope = Readonly< + RealtimeEventBase & + ( + | Readonly<{ + recoveryMode: "CURSOR"; + resumeCursor: string; + }> + | Readonly<{ + recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD"; + resumeCursor: null; + }> + ) +>; + +export type SnapshotCheckpoint = Readonly< + { + streamEpoch: string; + lastAppliedSequence: string; + snapshotRevision: string; + } & ( + | Readonly<{ + recoveryMode: "CURSOR"; + resumeCursor: string; + }> + | Readonly<{ + recoveryMode: "SNAPSHOT_ONLY"; + resumeCursor: null; + }> + ) +>; + +export type RealtimeResumeState = Readonly< + { + streamEpoch: string; + lastAppliedSequence: string; + } & ( + | Readonly<{ + recoveryMode: "CURSOR"; + resumeCursor: string; + }> + | Readonly<{ + recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD"; + resumeCursor: null; + }> + ) +>; + +const OPAQUE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:~+/=-]*$/u; +const HEADER_SAFE_CURSOR = /^[\x21-\x7e]+$/u; +const CANONICAL_SEQUENCE = /^(?:0|[1-9][0-9]{0,19})$/u; +const RFC_3339 = + /^([0-9]{4})-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(?:\.([0-9]{1,9}))?(Z|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))$/u; + +export function isRealtimeOpaqueIdentifier( + value: unknown, +): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= + REALTIME_EVENT_FIELD_LIMITS.maxOpaqueIdentifierLength && + OPAQUE_IDENTIFIER.test(value) + ); +} + +export function isRealtimeScopeBinding( + value: unknown, +): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= REALTIME_EVENT_FIELD_LIMITS.maxScopeBindingLength && + OPAQUE_IDENTIFIER.test(value) + ); +} + +export function isRealtimeResumeCursor( + value: unknown, +): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= + REALTIME_EVENT_FIELD_LIMITS.maxResumeCursorLength && + HEADER_SAFE_CURSOR.test(value) && + !value.includes("\0") && + !value.includes("\r") && + !value.includes("\n") + ); +} + +export function isCanonicalRealtimeSequence( + value: unknown, +): value is string { + if ( + typeof value !== "string" || + value.length > REALTIME_EVENT_FIELD_LIMITS.maxSequenceDigits || + !CANONICAL_SEQUENCE.test(value) + ) { + return false; + } + try { + return BigInt(value) <= BigInt(REALTIME_MAX_SEQUENCE); + } catch { + return false; + } +} + +export function compareRealtimeSequences( + left: string, + right: string, +): -1 | 0 | 1 { + if ( + !isCanonicalRealtimeSequence(left) || + !isCanonicalRealtimeSequence(right) + ) { + throw new TypeError("Realtime sequence is invalid."); + } + const leftValue = BigInt(left); + const rightValue = BigInt(right); + return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0; +} + +export function nextRealtimeSequence(value: string): string | null { + if (!isCanonicalRealtimeSequence(value)) { + throw new TypeError("Realtime sequence is invalid."); + } + const next = BigInt(value) + 1n; + return next > BigInt(REALTIME_MAX_SEQUENCE) ? null : next.toString(10); +} + +/** + * A bounded RFC 3339 profile: uppercase T/Z, a required offset, real calendar + * dates, seconds 00-59 and at most nanosecond fractional precision. + */ +export function isStrictRealtimeTimestamp( + value: unknown, +): value is string { + if (typeof value !== "string") return false; + const match = RFC_3339.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (year < 1 || day > daysInMonth(year, month)) return false; + return Number.isFinite(Date.parse(value)); +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) + ? 29 + : 28; + } + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + diff --git a/src/contracts/realtime-streams.ts b/src/contracts/realtime-streams.ts new file mode 100644 index 0000000..4e64512 --- /dev/null +++ b/src/contracts/realtime-streams.ts @@ -0,0 +1,548 @@ +import type { ApiOperation } from "./api-operations.ts"; +import type { InstalledBoundaryMapper } from "./boundary-mapper.ts"; +import type { RuntimeSchemaCodec } from "./schema-registry.ts"; + +declare const streamRegistrationIdBrand: unique symbol; +declare const eventTypeIdBrand: unique symbol; +declare const realtimeEndpointIdBrand: unique symbol; +declare const externalEventEffectProfileIdBrand: unique symbol; +declare const killSwitchIdBrand: unique symbol; + +export type StreamRegistrationId = string & + Readonly<{ [streamRegistrationIdBrand]: true }>; +export type EventTypeId = string & + Readonly<{ [eventTypeIdBrand]: true }>; +export type RealtimeEndpointId = string & + Readonly<{ [realtimeEndpointIdBrand]: true }>; +export type ExternalEventEffectProfileId = string & + Readonly<{ [externalEventEffectProfileIdBrand]: true }>; +export type KillSwitchId = string & + Readonly<{ [killSwitchIdBrand]: true }>; + +export const REALTIME_EVENT_PROTOCOL = "REALTIME_EVENT_V1" as const; + +export const REALTIME_HARD_LIMITS = Object.freeze({ + maxEventBytes: 64 * 1024, + maxPayloadDepth: 16, + maxPayloadNodes: 4_096, + maxQueueEvents: 256, + maxQueueBytes: 4 * 1024 * 1024, + maxDedupeEntries: 2_048, + maxDedupeBytes: 4 * 1024 * 1024, + dedupeTtlMs: 10 * 60 * 1_000, +}); + +export type RealtimeLimits = Readonly<{ + maxEventBytes: number; + maxPayloadDepth: number; + maxPayloadNodes: number; + maxQueueEvents: number; + maxQueueBytes: number; + maxDedupeEntries: number; + /** + * Additional implementation memory ceiling for semantic conflict + * fingerprints. Reaching it has the same recovery meaning as exhausting the + * count/time dedupe window. + */ + maxDedupeBytes: number; + dedupeTtlMs: number; +}>; + +export type RealtimeRecoveryProfile = + | Readonly<{ + mode: "CURSOR"; + snapshotOperationId: string; + checkpointCodecId: string; + barrier: "REPLAY"; + }> + | Readonly<{ + mode: "SNAPSHOT_ONLY"; + snapshotOperationId: string; + checkpointCodecId: string; + barrier: "CONNECT_BUFFER" | "SERVER_HOLD" | "NONE"; + }> + | Readonly<{ + mode: "SESSION_REBUILD"; + rebuildInputId: string; + }>; + +export type RealtimeStreamRegistration = Readonly<{ + id: StreamRegistrationId; + protocol: typeof REALTIME_EVENT_PROTOCOL; + owner: string; + scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND"; + primaryTransport: "SSE" | "WEBSOCKET" | "NONE"; + endpointId: RealtimeEndpointId; + eventTypeIds: readonly EventTypeId[]; + delivery: "INVALIDATION_HINT" | "AUTHORITATIVE_DELTA" | "EPHEMERAL"; + recovery: RealtimeRecoveryProfile; + fallback: "BOUNDED_POLLING" | "EXPLICITLY_STALE"; + hiddenPolicy: "CLOSE" | "BOUNDED_GRACE"; + limits: RealtimeLimits; + killSwitchId: KillSwitchId; +}>; + +export type RealtimeEventTypeRegistration = Readonly<{ + id: EventTypeId; + owner: string; + payloadSchemaId: string; + mapperId: string; + effectProfileId: ExternalEventEffectProfileId; + stateBearing: boolean; +}>; + +export type RealtimePolicyRegistryBindings = Readonly<{ + schemaCodecs: Readonly>; + mappers: Readonly>; + apiOperations: Readonly>; + endpointIds: readonly RealtimeEndpointId[]; + effectProfileIds: readonly ExternalEventEffectProfileId[]; + killSwitchIds: readonly KillSwitchId[]; + rebuildInputIds?: readonly string[]; +}>; + +export type RealtimePolicyRegistry = Readonly<{ + findStream(id: string): RealtimeStreamRegistration | undefined; + findEventType(id: string): RealtimeEventTypeRegistration | undefined; + findStreamEventType( + streamId: string, + eventTypeId: string, + ): RealtimeEventTypeRegistration | undefined; + listStreams(): readonly RealtimeStreamRegistration[]; + listEventTypes(): readonly RealtimeEventTypeRegistration[]; +}>; + +export type RealtimePolicyRegistryInput = Readonly<{ + streams: readonly RealtimeStreamRegistration[]; + eventTypes: readonly RealtimeEventTypeRegistration[]; + bindings: RealtimePolicyRegistryBindings; +}>; + +const REGISTRY_ID = /^[A-Z][A-Z0-9_]{2,79}$/u; +const OWNED_ID = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u; +const OWNER = /^[a-z0-9][a-z0-9._:-]{0,127}$/u; + +const STREAM_KEYS = Object.freeze([ + "delivery", + "endpointId", + "eventTypeIds", + "fallback", + "hiddenPolicy", + "id", + "killSwitchId", + "limits", + "owner", + "primaryTransport", + "protocol", + "recovery", + "scope", +] as const); +const EVENT_TYPE_KEYS = Object.freeze([ + "effectProfileId", + "id", + "mapperId", + "owner", + "payloadSchemaId", + "stateBearing", +] as const); +const LIMIT_KEYS = Object.freeze([ + "dedupeTtlMs", + "maxDedupeBytes", + "maxDedupeEntries", + "maxEventBytes", + "maxPayloadDepth", + "maxPayloadNodes", + "maxQueueBytes", + "maxQueueEvents", +] as const); + +export function defineStreamRegistrationId( + value: string, +): StreamRegistrationId { + return defineRegistryId(value, "stream") as StreamRegistrationId; +} + +export function defineEventTypeId(value: string): EventTypeId { + return defineRegistryId(value, "event type") as EventTypeId; +} + +export function defineRealtimeEndpointId( + value: string, +): RealtimeEndpointId { + return defineRegistryId(value, "endpoint") as RealtimeEndpointId; +} + +export function defineExternalEventEffectProfileId( + value: string, +): ExternalEventEffectProfileId { + return defineRegistryId( + value, + "effect profile", + ) as ExternalEventEffectProfileId; +} + +export function defineRealtimeKillSwitchId(value: string): KillSwitchId { + return defineRegistryId(value, "kill switch") as KillSwitchId; +} + +/** + * Builds a composition-time registry and retains no caller-owned registration + * object or array. + */ +export function createRealtimePolicyRegistry( + input: RealtimePolicyRegistryInput, +): RealtimePolicyRegistry { + if ( + !input || + typeof input !== "object" || + !Array.isArray(input.streams) || + input.streams.length < 1 || + input.streams.length > 128 || + !Array.isArray(input.eventTypes) || + input.eventTypes.length < 1 || + input.eventTypes.length > 512 + ) { + throw new TypeError("Realtime policy registry is invalid."); + } + + const endpointIds = identifierSet( + input.bindings.endpointIds, + "endpoint", + ); + const effectProfileIds = identifierSet( + input.bindings.effectProfileIds, + "effect profile", + ); + const killSwitchIds = identifierSet( + input.bindings.killSwitchIds, + "kill switch", + ); + const rebuildInputIds = ownedIdentifierSet( + input.bindings.rebuildInputIds ?? [], + "rebuild input", + ); + + const eventTypes = new Map(); + for (const candidate of input.eventTypes) { + const registration = snapshotEventType( + candidate, + input.bindings, + effectProfileIds, + ); + if (eventTypes.has(registration.id)) { + throw new TypeError("Realtime event type is duplicated."); + } + eventTypes.set(registration.id, registration); + } + + const streams = new Map(); + const referencedEventTypes = new Set(); + for (const candidate of input.streams) { + const registration = snapshotStream( + candidate, + input.bindings, + eventTypes, + endpointIds, + killSwitchIds, + rebuildInputIds, + ); + if (streams.has(registration.id)) { + throw new TypeError("Realtime stream is duplicated."); + } + streams.set(registration.id, registration); + for (const eventTypeId of registration.eventTypeIds) { + referencedEventTypes.add(eventTypeId); + } + } + + if ( + [...eventTypes.keys()].some( + (eventTypeId) => !referencedEventTypes.has(eventTypeId), + ) + ) { + throw new TypeError("Realtime event type is not owned by a stream."); + } + + const streamList = Object.freeze([...streams.values()]); + const eventTypeList = Object.freeze([...eventTypes.values()]); + + return Object.freeze({ + findStream(id: string) { + return streams.get(id as StreamRegistrationId); + }, + findEventType(id: string) { + return eventTypes.get(id as EventTypeId); + }, + findStreamEventType(streamId: string, eventTypeId: string) { + const stream = streams.get(streamId as StreamRegistrationId); + if (!stream || !stream.eventTypeIds.includes(eventTypeId as EventTypeId)) { + return undefined; + } + return eventTypes.get(eventTypeId as EventTypeId); + }, + listStreams: () => streamList, + listEventTypes: () => eventTypeList, + }); +} + +function snapshotEventType( + input: RealtimeEventTypeRegistration, + bindings: RealtimePolicyRegistryBindings, + effectProfileIds: ReadonlySet, +): RealtimeEventTypeRegistration { + if ( + !hasExactKeys(input, EVENT_TYPE_KEYS) || + !REGISTRY_ID.test(input.id) || + !OWNER.test(input.owner) || + !OWNED_ID.test(input.payloadSchemaId) || + !OWNED_ID.test(input.mapperId) || + !REGISTRY_ID.test(input.effectProfileId) || + typeof input.stateBearing !== "boolean" || + bindings.schemaCodecs[input.payloadSchemaId]?.schemaId !== + input.payloadSchemaId || + bindings.mappers[input.mapperId]?.mapperId !== input.mapperId || + bindings.mappers[input.mapperId]?.inputSchemaId !== + input.payloadSchemaId || + !effectProfileIds.has(input.effectProfileId) + ) { + throw new TypeError("Realtime event type registration is invalid."); + } + return Object.freeze({ ...input }); +} + +function snapshotStream( + input: RealtimeStreamRegistration, + bindings: RealtimePolicyRegistryBindings, + eventTypes: ReadonlyMap, + endpointIds: ReadonlySet, + killSwitchIds: ReadonlySet, + rebuildInputIds: ReadonlySet, +): RealtimeStreamRegistration { + if ( + !hasExactKeys(input, STREAM_KEYS) || + !REGISTRY_ID.test(input.id) || + input.protocol !== REALTIME_EVENT_PROTOCOL || + !OWNER.test(input.owner) || + !["ORIGIN_SHARED", "ACCOUNT_BOUND", "SESSION_BOUND"].includes( + input.scope, + ) || + !["SSE", "WEBSOCKET", "NONE"].includes(input.primaryTransport) || + !REGISTRY_ID.test(input.endpointId) || + !endpointIds.has(input.endpointId) || + !Array.isArray(input.eventTypeIds) || + input.eventTypeIds.length < 1 || + input.eventTypeIds.length > 128 || + new Set(input.eventTypeIds).size !== input.eventTypeIds.length || + input.eventTypeIds.some( + (eventTypeId) => + !REGISTRY_ID.test(eventTypeId) || !eventTypes.has(eventTypeId), + ) || + !["INVALIDATION_HINT", "AUTHORITATIVE_DELTA", "EPHEMERAL"].includes( + input.delivery, + ) || + !["BOUNDED_POLLING", "EXPLICITLY_STALE"].includes(input.fallback) || + !["CLOSE", "BOUNDED_GRACE"].includes(input.hiddenPolicy) || + !REGISTRY_ID.test(input.killSwitchId) || + !killSwitchIds.has(input.killSwitchId) + ) { + throw new TypeError("Realtime stream registration is invalid."); + } + + const limits = snapshotLimits(input.limits); + const recovery = snapshotRecovery( + input.recovery, + bindings, + rebuildInputIds, + ); + const selectedEventTypes = input.eventTypeIds.map((eventTypeId) => { + const selected = eventTypes.get(eventTypeId); + if (!selected) { + throw new TypeError("Realtime stream event type is unresolved."); + } + return selected; + }); + const hasStateBearingEvent = selectedEventTypes.some( + (eventType) => eventType.stateBearing, + ); + const hasNonStateBearingEvent = selectedEventTypes.some( + (eventType) => !eventType.stateBearing, + ); + + if ( + (hasStateBearingEvent && recovery.mode === "SESSION_REBUILD") || + (hasStateBearingEvent && + recovery.mode === "SNAPSHOT_ONLY" && + recovery.barrier === "NONE") || + (input.delivery === "EPHEMERAL" && hasStateBearingEvent) || + (input.delivery === "AUTHORITATIVE_DELTA" && + hasNonStateBearingEvent) || + (recovery.mode === "SESSION_REBUILD" && + input.delivery !== "EPHEMERAL") || + (input.delivery === "EPHEMERAL" && + input.fallback === "BOUNDED_POLLING") + ) { + throw new TypeError("Realtime stream recovery contract is contradictory."); + } + + return Object.freeze({ + ...input, + eventTypeIds: Object.freeze([...input.eventTypeIds]), + recovery, + limits, + }); +} + +function snapshotRecovery( + input: RealtimeRecoveryProfile, + bindings: RealtimePolicyRegistryBindings, + rebuildInputIds: ReadonlySet, +): RealtimeRecoveryProfile { + if (!input || typeof input !== "object") { + throw new TypeError("Realtime recovery profile is invalid."); + } + if (input.mode === "CURSOR") { + if ( + !hasExactKeys(input, [ + "barrier", + "checkpointCodecId", + "mode", + "snapshotOperationId", + ]) || + input.barrier !== "REPLAY" || + !validSnapshotBindings(input, bindings) + ) { + throw new TypeError("Realtime cursor recovery profile is invalid."); + } + return Object.freeze({ ...input }); + } + if (input.mode === "SNAPSHOT_ONLY") { + if ( + !hasExactKeys(input, [ + "barrier", + "checkpointCodecId", + "mode", + "snapshotOperationId", + ]) || + !["CONNECT_BUFFER", "SERVER_HOLD", "NONE"].includes(input.barrier) || + !validSnapshotBindings(input, bindings) + ) { + throw new TypeError("Realtime snapshot recovery profile is invalid."); + } + return Object.freeze({ ...input }); + } + if ( + input.mode !== "SESSION_REBUILD" || + !hasExactKeys(input, ["mode", "rebuildInputId"]) || + !OWNED_ID.test(input.rebuildInputId) || + !rebuildInputIds.has(input.rebuildInputId) + ) { + throw new TypeError("Realtime session rebuild profile is invalid."); + } + return Object.freeze({ ...input }); +} + +function validSnapshotBindings( + input: Readonly<{ + snapshotOperationId: string; + checkpointCodecId: string; + }>, + bindings: RealtimePolicyRegistryBindings, +): boolean { + const operation = bindings.apiOperations[input.snapshotOperationId]; + return ( + OWNED_ID.test(input.snapshotOperationId) && + OWNED_ID.test(input.checkpointCodecId) && + bindings.schemaCodecs[input.checkpointCodecId]?.schemaId === + input.checkpointCodecId && + operation?.contractVersion === 2 && + operation.protocol === "REST" && + operation.semantics === "QUERY" && + (operation.replayPolicy === "SAFE" || + operation.replayPolicy === "IDEMPOTENT") + ); +} + +function snapshotLimits(input: RealtimeLimits): RealtimeLimits { + if (!hasExactKeys(input, LIMIT_KEYS)) { + throw new TypeError("Realtime limits are invalid."); + } + for (const key of LIMIT_KEYS) { + const value = input[key]; + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > REALTIME_HARD_LIMITS[key] + ) { + throw new TypeError("Realtime limits exceed implementation ceilings."); + } + } + if ( + input.maxQueueBytes < input.maxEventBytes || + input.maxDedupeBytes < input.maxEventBytes + ) { + throw new TypeError("Realtime memory limits cannot hold one event."); + } + return Object.freeze({ ...input }); +} + +function identifierSet( + values: readonly string[], + label: string, +): ReadonlySet { + if (!Array.isArray(values)) { + throw new TypeError(`Realtime ${label} bindings are invalid.`); + } + const result = new Set(); + for (const value of values) { + if (!REGISTRY_ID.test(value) || result.has(value)) { + throw new TypeError(`Realtime ${label} bindings are invalid.`); + } + result.add(value); + } + return result; +} + +function ownedIdentifierSet( + values: readonly string[], + label: string, +): ReadonlySet { + if (!Array.isArray(values)) { + throw new TypeError(`Realtime ${label} bindings are invalid.`); + } + const result = new Set(); + for (const value of values) { + if (!OWNED_ID.test(value) || result.has(value)) { + throw new TypeError(`Realtime ${label} bindings are invalid.`); + } + result.add(value); + } + return result; +} + +function defineRegistryId(value: string, label: string): string { + if (!REGISTRY_ID.test(value)) { + throw new TypeError(`Realtime ${label} ID is invalid.`); + } + return value; +} + +function hasExactKeys( + value: unknown, + expected: readonly string[], +): value is Readonly> { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + return false; + } + const keys = Object.keys(value).sort(); + const selected = [...expected].sort(); + return ( + keys.length === selected.length && + keys.every((key, index) => key === selected[index]) + ); +} diff --git a/src/contracts/release-artifacts.ts b/src/contracts/release-artifacts.ts new file mode 100644 index 0000000..05f2b47 --- /dev/null +++ b/src/contracts/release-artifacts.ts @@ -0,0 +1,268 @@ +import { z } from "zod"; + +import { contractSetSchema } from "./contract-set.ts"; + +const versionSchema = z.string().regex(/^\d+(?:\.\d+){0,2}$/); + +function assertEndpointUrl( + value: string, + local: boolean, + options: Readonly<{ trailingSlashPath?: boolean }> = {}, +): void { + const parsed = new URL(value); + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") || + parsed.username || + parsed.password || + parsed.hash || + parsed.search || + (!local && parsed.protocol !== "https:") + ) { + throw new TypeError("invalid"); + } + if (options.trailingSlashPath && !parsed.pathname.endsWith("/")) { + throw new TypeError("invalid"); + } +} + +export function isValidReleaseManifestUrl(value: string): boolean { + if (!value.startsWith("/") || value.startsWith("//")) return false; + if (new TextEncoder().encode(value).byteLength > 256) return false; + if (value.includes("?") || value.includes("#") || value.includes("\\")) { + return false; + } + if (/%2f|%5c/i.test(value)) return false; + return !value + .split("/") + .some((segment) => segment === "." || segment === ".."); +} + +export const capabilityOverrideArtifactSchema = z + .object({ + REALTIME: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + WEB_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + SERVICE_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + OFFLINE_COMMANDS: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"), + }) + .strict() + .default({ + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }); + +type RuntimeConfigArtifactDraft = Readonly<{ + APP_ENV: "local" | "development" | "staging" | "production"; + API_BASE_URL: string; + TELEMETRY_ENABLED: boolean; + TELEMETRY_ENDPOINT?: string; + AUTH_MODE: "external" | "demo"; + RELEASE_MANIFEST_URL: string; +}>; + +function runtimeConfigArtifactInvariants( + config: RuntimeConfigArtifactDraft, + context: z.RefinementCtx, +): void { + const local = config.APP_ENV === "local" || config.APP_ENV === "development"; + if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) { + context.addIssue({ + code: "custom", + path: ["TELEMETRY_ENDPOINT"], + message: "required when telemetry is enabled", + }); + } + if (!local && config.AUTH_MODE === "demo") { + context.addIssue({ + code: "custom", + path: ["AUTH_MODE"], + message: "demo authentication is limited to local environments", + }); + } + try { + assertEndpointUrl(config.API_BASE_URL, local, { trailingSlashPath: true }); + } catch { + context.addIssue({ + code: "custom", + path: ["API_BASE_URL"], + message: + "absolute credential-free URL ending in / is required; HTTPS outside local", + }); + } + if (config.TELEMETRY_ENDPOINT) { + try { + assertEndpointUrl(config.TELEMETRY_ENDPOINT, local); + } catch { + context.addIssue({ + code: "custom", + path: ["TELEMETRY_ENDPOINT"], + message: + "absolute credential-free URL is required; HTTPS outside local", + }); + } + } + if (!isValidReleaseManifestUrl(config.RELEASE_MANIFEST_URL)) { + context.addIssue({ + code: "custom", + path: ["RELEASE_MANIFEST_URL"], + message: "same-origin absolute path without query, hash or traversal", + }); + } +} + +const runtimeConfigArtifactFields = { + APP_ENV: z.enum(["local", "development", "staging", "production"]), + API_BASE_URL: z.url(), + REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000), + MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), + TELEMETRY_ENABLED: z.boolean(), + TELEMETRY_ENDPOINT: z.url().optional(), + AUTH_MODE: z.enum(["external", "demo"]), + RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), + RELEASE_ID: z.string().min(1).optional(), + BUILD_ID: z.string().min(1).optional(), +} as const; + +export const runtimeConfigV1ArtifactSchema = z + .object({ + ...runtimeConfigArtifactFields, + CONFIG_SCHEMA_VERSION: z.literal("1"), + API_CONTRACT_VERSION: versionSchema, + }) + .strict() + .superRefine(runtimeConfigArtifactInvariants); + +export const runtimeConfigV2ArtifactSchema = z + .object({ + ...runtimeConfigArtifactFields, + CONFIG_SCHEMA_VERSION: z.literal("2.0"), + CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema, + }) + .strict() + .superRefine(runtimeConfigArtifactInvariants); + +export const runtimeConfigArtifactSchema = z.discriminatedUnion( + "CONFIG_SCHEMA_VERSION", + [ + runtimeConfigV1ArtifactSchema, + runtimeConfigV2ArtifactSchema, + ], +); + +const releaseManifestArtifactFields = { + appVersion: z.string().min(1), + buildId: z.string().min(1), + commitSha: z.string().min(1), + assetManifestHash: z.string().min(1), + releaseId: z.string().min(1), + builtAt: z.string().min(1), + routeChunks: z.record(z.string().min(1), z.string().min(1)), +} as const; + +export const releaseManifestV1ArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + ...releaseManifestArtifactFields, + configSchemaVersion: versionSchema, + apiContractVersion: versionSchema, + }) + .strict(); + +export const releaseManifestV2ArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + ...releaseManifestArtifactFields, + configSchemaVersion: z.literal("2.0"), + contractSet: contractSetSchema, + }) + .strict(); + +export const releaseManifestArtifactSchema = z.discriminatedUnion( + "schemaVersion", + [releaseManifestV1ArtifactSchema, releaseManifestV2ArtifactSchema], +); + +export const buildManifestArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + buildId: z.string().min(1), + commitSha: z.string().min(1), + releaseId: z.string().min(1), + moduleInventoryHash: z.string().min(1), + generatedAt: z.string().min(1), + buildContext: z + .object({ + nodeVersion: z.string().min(1), + packageManagerVersion: z.string().min(1), + runnerImage: z.string().min(1), + sourceDateEpoch: z.string().min(1).nullable(), + }) + .strict(), + outputs: z + .object({ + directory: z.string().min(1), + viteManifest: z.string().min(1), + moduleInventory: z.string().min(1), + routeChunks: z.record(z.string().min(1), z.string().min(1)), + runtimeConfigSchema: z.string().min(1), + }) + .strict(), + }) + .strict(); + +export type RuntimeConfigV1Artifact = z.output< + typeof runtimeConfigV1ArtifactSchema +>; +export type RuntimeConfigV2Artifact = z.output< + typeof runtimeConfigV2ArtifactSchema +>; +export type CapabilityOverrideArtifact = z.output< + typeof capabilityOverrideArtifactSchema +>; +export type RuntimeConfigArtifact = z.output; +export type ReleaseManifestV1Artifact = z.output< + typeof releaseManifestV1ArtifactSchema +>; +export type ReleaseManifestV2Artifact = z.output< + typeof releaseManifestV2ArtifactSchema +>; +export type ReleaseArtifact = z.output; +export type BuildManifestArtifact = z.output; + +export function parseReleaseArtifact(value: unknown): ReleaseArtifact { + return releaseManifestArtifactSchema.parse(value); +} + +export function parseRuntimeConfigArtifact(value: unknown): RuntimeConfigArtifact { + return runtimeConfigArtifactSchema.parse(value); +} + +export function parseBuildManifestArtifact(value: unknown): BuildManifestArtifact { + return buildManifestArtifactSchema.parse(value); +} + +export function projectReleaseTokens(release: ReleaseArtifact) { + const common = { + schemaVersion: release.schemaVersion, + appVersion: release.appVersion, + buildId: release.buildId, + commitSha: release.commitSha, + configSchemaVersion: release.configSchemaVersion, + assetManifestHash: release.assetManifestHash, + releaseId: release.releaseId, + builtAt: release.builtAt, + } as const; + + return release.schemaVersion === 1 + ? Object.freeze({ + ...common, + schemaVersion: 1 as const, + apiContractVersion: release.apiContractVersion, + }) + : Object.freeze({ + ...common, + schemaVersion: 2 as const, + contractSetDigest: release.contractSet.setDigest, + }); +} diff --git a/src/contracts/release-tokens.ts b/src/contracts/release-tokens.ts new file mode 100644 index 0000000..437db64 --- /dev/null +++ b/src/contracts/release-tokens.ts @@ -0,0 +1,66 @@ +import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts"; + +export const RELEASE_TOKEN_REGISTRY = Object.freeze({ + appVersion: token("appVersion", "manifest", "human release label"), + buildId: token("buildId", "CI build", "asset and HTML coherence"), + commitSha: token("commitSha", "VCS", "source traceability"), + configSchemaVersion: token( + "configSchemaVersion", + "runtime config schema", + "boot compatibility", + ), + apiContractVersion: token( + "apiContractVersion", + "frontend/backend agreement", + "legacy V1 scalar; superseded by contractSetDigest", + ), + contractSetDigest: token( + "contractSetDigest", + "compiled external contract package set", + "release coherence for multi-package contracts", + ), + assetManifestHash: token( + "assetManifestHash", + "build output", + "chunk integrity and mismatch detection", + ), + releaseId: token("releaseId", "deploy system", "rollback target"), + builtAt: token("builtAt", "CI", "diagnostics only; never cache identity"), +}); + +function token(name: string, source: string, compatibilityRole: string) { + return Object.freeze({ token: name, source, compatibilityRole }); +} + +/** + * Compare a release manifest and runtime configuration structurally. Version + * fields are delegated to the numeric compatibility policy, never compared + * lexically. + * + */ +export function compareReleaseToRuntime( + release: Readonly<{ + buildId: string; + configSchemaVersion: string; + apiContractVersion: string; + assetManifestHash: string; + releaseId: string; + }>, + runtimeConfig: Readonly<{ + BUILD_ID: string; + CONFIG_SCHEMA_VERSION: string; + API_CONTRACT_VERSION: string; + RELEASE_ID: string; + }>, +) { + return verifyCompatibilityTuple({ + frontend: release, + runtime: { + buildId: runtimeConfig.BUILD_ID, + configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, + apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, + assetManifestHash: release.assetManifestHash, + releaseId: runtimeConfig.RELEASE_ID, + }, + }); +} diff --git a/src/contracts/rest-profiles.ts b/src/contracts/rest-profiles.ts new file mode 100644 index 0000000..a176199 --- /dev/null +++ b/src/contracts/rest-profiles.ts @@ -0,0 +1,151 @@ +export type FetchCredentialsMode = "omit" | "same-origin" | "include"; + +export type RestProviderProfile = Readonly<{ + providerId: string; + baseUrl: string; + allowedCredentialsModes: readonly FetchCredentialsMode[]; + redirect: "error"; + referrerPolicy: "no-referrer"; +}>; + +export type RestAuthProfile = Readonly<{ + authProfileId: string; + transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE"; + credentials: FetchCredentialsMode; + allowedCredentialHeaders: readonly ("authorization" | "x-csrf-token")[]; +}>; + +export type RestCsrfProfile = Readonly<{ + csrfProfileId: string; + mode: "NONE" | "HEADER"; + headerName: "x-csrf-token" | null; +}>; + +export const REST_AUTH_PROFILES = Object.freeze({ + REFERENCE_EXTERNAL_BEARER: Object.freeze({ + authProfileId: "REFERENCE_EXTERNAL_BEARER", + transport: "BEARER_HEADER", + credentials: "omit", + allowedCredentialHeaders: Object.freeze(["authorization"] as const), + }), + ANONYMOUS: Object.freeze({ + authProfileId: "ANONYMOUS", + transport: "ANONYMOUS", + credentials: "omit", + allowedCredentialHeaders: Object.freeze([]), + }), +} satisfies Readonly>); + +export const REST_CSRF_PROFILES = Object.freeze({ + NO_CSRF_BEARER: Object.freeze({ + csrfProfileId: "NO_CSRF_BEARER", + mode: "NONE", + headerName: null, + }), +} satisfies Readonly>); + +export function createRestProviderProfile( + providerId: string, + baseUrl: string, + allowedCredentialsModes: readonly FetchCredentialsMode[] = ["omit"], +): RestProviderProfile { + const parsed = new URL(baseUrl); + const localHttp = + parsed.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + if ( + !providerId || + (parsed.protocol !== "https:" && !localHttp) || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + allowedCredentialsModes.length === 0 || + new Set(allowedCredentialsModes).size !== allowedCredentialsModes.length + ) { + throw new TypeError("Invalid REST provider profile."); + } + return Object.freeze({ + providerId, + baseUrl: parsed.href, + allowedCredentialsModes: Object.freeze([...allowedCredentialsModes]), + redirect: "error", + referrerPolicy: "no-referrer", + }); +} + +export function resolveRestSecurityProfiles( + operation: Readonly<{ + method: string; + auth: "none" | "external-session"; + authProfileId?: string; + csrfProfileId?: string; + }>, + provider: RestProviderProfile, + authProfiles: Readonly> = REST_AUTH_PROFILES, + csrfProfiles: Readonly> = REST_CSRF_PROFILES, +): Readonly<{ auth: RestAuthProfile; csrf: RestCsrfProfile }> { + const auth = authProfiles[operation.authProfileId ?? ""]; + const csrf = csrfProfiles[operation.csrfProfileId ?? ""]; + const unsafe = !["GET", "HEAD", "OPTIONS"].includes(operation.method); + if ( + !auth || + !csrf || + !provider.allowedCredentialsModes.includes(auth.credentials) || + (operation.auth === "none" && auth.transport !== "ANONYMOUS") || + (operation.auth === "external-session" && + auth.transport === "ANONYMOUS") || + (auth.transport === "BEARER_HEADER" && csrf.mode !== "NONE") || + (unsafe && + auth.transport === "SAME_ORIGIN_COOKIE" && + csrf.mode !== "HEADER") + ) { + throw new TypeError("REST security profiles are incoherent."); + } + return Object.freeze({ auth, csrf }); +} + +export function validateRestProfileBindings( + operations: Readonly< + Record< + string, + Readonly<{ + contractVersion?: number; + operationId: string; + method: string; + auth: "none" | "external-session"; + providerId?: string; + authProfileId?: string; + csrfProfileId?: string; + }> + > + >, + providerCredentialModes: Readonly< + Record + >, + authProfiles: Readonly> = REST_AUTH_PROFILES, + csrfProfiles: Readonly> = REST_CSRF_PROFILES, +): true { + for (const operation of Object.values(operations)) { + if (operation.contractVersion !== 2) continue; + const allowed = providerCredentialModes[operation.providerId ?? ""]; + if (!allowed) { + throw new TypeError( + `Unregistered REST provider binding: ${operation.operationId}`, + ); + } + resolveRestSecurityProfiles( + operation, + Object.freeze({ + providerId: operation.providerId ?? "", + baseUrl: "https://contract.invalid/", + allowedCredentialsModes: allowed, + redirect: "error", + referrerPolicy: "no-referrer", + }), + authProfiles, + csrfProfiles, + ); + } + return true; +} diff --git a/src/contracts/route-runtime-contract.ts b/src/contracts/route-runtime-contract.ts new file mode 100644 index 0000000..ddbbdfa --- /dev/null +++ b/src/contracts/route-runtime-contract.ts @@ -0,0 +1,50 @@ +export type RouteCodecId = "none" | "NotFoundSplat"; +export type RouteRuntimeDefinition = Readonly<{ + routeId: string; + moduleId: string; + paramsCodec: RouteCodecId; + searchCodec: RouteCodecId; +}>; + +const runtime = ( + value: Definition, +): Readonly => Object.freeze(value); + +export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({ + APP_HOME: runtime({ + routeId: "APP_HOME", + moduleId: "home-page", + paramsCodec: "none", + searchCodec: "none", + }), + EXAMPLES_PLATFORM: runtime({ + routeId: "EXAMPLES_PLATFORM", + moduleId: "platform-overview-page", + paramsCodec: "none", + searchCodec: "none", + }), + EXAMPLES_UI: runtime({ + routeId: "EXAMPLES_UI", + moduleId: "ui-gallery-page", + paramsCodec: "none", + searchCodec: "none", + }), + EXAMPLES_STATES: runtime({ + routeId: "EXAMPLES_STATES", + moduleId: "state-gallery-page", + paramsCodec: "none", + searchCodec: "none", + }), + EXAMPLES_AUTH: runtime({ + routeId: "EXAMPLES_AUTH", + moduleId: "auth-example-page", + paramsCodec: "none", + searchCodec: "none", + }), + NOT_FOUND: runtime({ + routeId: "NOT_FOUND", + moduleId: "not-found-page", + paramsCodec: "NotFoundSplat", + searchCodec: "none", + }), +}); diff --git a/src/contracts/routes.ts b/src/contracts/routes.ts new file mode 100644 index 0000000..8318f31 --- /dev/null +++ b/src/contracts/routes.ts @@ -0,0 +1,98 @@ +export type RouteDefinition = Readonly<{ + routeId: string; + path: string; + paramsSchema: string | null; + searchSchema: string | null; + access: "public" | "session-required"; + loadingSurface: string; + errorSurface: string; + chunkId: string; + title: string; + navigationLabel: string | null; + navigationOrder: number | null; +}>; + +const route = ( + definition: Definition, +): Readonly => Object.freeze(definition); + +export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ + APP_HOME: route({ + routeId: "APP_HOME", + path: "/", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "app-shell", + errorSurface: "route-boundary", + chunkId: "route-home", + title: "시작", + navigationLabel: "시작", + navigationOrder: 10, + }), + EXAMPLES_PLATFORM: route({ + routeId: "EXAMPLES_PLATFORM", + path: "/examples/platform", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-platform", + title: "플랫폼 구성", + navigationLabel: "플랫폼 구성", + navigationOrder: 15, + }), + EXAMPLES_UI: route({ + routeId: "EXAMPLES_UI", + path: "/examples/ui", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-ui", + title: "UI 구성요소", + navigationLabel: "UI 구성요소", + navigationOrder: 20, + }), + EXAMPLES_STATES: route({ + routeId: "EXAMPLES_STATES", + path: "/examples/states", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-states", + title: "화면 상태", + navigationLabel: "화면 상태", + navigationOrder: 30, + }), + EXAMPLES_AUTH: route({ + routeId: "EXAMPLES_AUTH", + path: "/examples/auth", + paramsSchema: null, + searchSchema: null, + access: "public", + loadingSurface: "example-page", + errorSurface: "route-boundary", + chunkId: "route-examples-auth", + title: "인증 연동", + navigationLabel: "인증 연동", + navigationOrder: 40, + }), + NOT_FOUND: route({ + routeId: "NOT_FOUND", + path: "*", + paramsSchema: "NotFoundSplat", + searchSchema: null, + access: "public", + loadingSurface: "none", + errorSurface: "not-found", + chunkId: "route-not-found", + title: "페이지를 찾을 수 없음", + navigationLabel: null, + navigationOrder: null, + }), +}); diff --git a/src/contracts/runtime-capabilities.ts b/src/contracts/runtime-capabilities.ts new file mode 100644 index 0000000..e6d448f --- /dev/null +++ b/src/contracts/runtime-capabilities.ts @@ -0,0 +1,266 @@ +import type { InstalledOfflineCommandContribution } from "./offline-command.ts"; +import type { InstalledServiceWorkerSelection } from "./service-worker.ts"; +import type { InstalledWebWorkerContribution } from "./web-worker.ts"; + +/** + * §3.4–§3.6. Optional runtime capability selection and hosting. + * + * Source contribution decides what is installed. Runtime Config may only carry + * `DEFAULT | DISABLED`, so a configuration document can never switch on a + * capability whose source is absent. + */ + +export type RuntimeCapabilityOverride = "DEFAULT" | "DISABLED"; + +export type RuntimeStopReason = + | "APPLICATION_SHUTDOWN" + | "SCOPE_FENCED" + | "FEATURE_DISABLED" + | "HIDDEN_POLICY" + | "INCIDENT_CONTAINMENT"; + +export interface RuntimeLifecycle { + start(): void | Promise; + stop(reason: RuntimeStopReason): void | Promise; + dispose(): void | Promise; +} + +/** §20.2. `FAILED -> STARTING` is never automatic. */ +export type RuntimeLifecycleState = + | "NEW" + | "STARTING" + | "RUNNING" + | "STOPPING" + | "STOPPED" + | "FAILED" + | "DISPOSING" + | "DISPOSED"; + +/** §22.12. Health is per capability; there is no global `healthy` boolean. */ +export type RuntimeHealth = + | "AVAILABLE" + | "DEGRADED" + | "UNAVAILABLE" + | "INCOMPATIBLE" + | "DISABLED"; + +// §13.2. Realtime product contribution. +export type RealtimeEffectKind = + | "INVALIDATE_TOPICS" + | "APPLY_AUTHORITATIVE_DELTA" + | "EPHEMERAL_NOTIFICATION"; + +export interface InstalledRealtimeEventEffect { + readonly eventType: string; + readonly mapperId: string; + readonly effect: RealtimeEffectKind; + readonly invalidationTopics: readonly string[]; +} + +export type InstalledRealtimeTransport = + | Readonly<{ kind: "SSE"; endpointId: string }> + | Readonly<{ kind: "WEBSOCKET"; endpointId: string }> + | Readonly<{ kind: "POLLING"; operationId: string; intervalMs: number }>; + +export interface InstalledRealtimeContribution { + readonly contributionId: string; + readonly featureId: string; + readonly contractSourcePackageId: string; + readonly streamId: string; + readonly recoveryMode: "CURSOR" | "SNAPSHOT_ONLY" | "SESSION_REBUILD"; + readonly eventEffects: readonly InstalledRealtimeEventEffect[]; + readonly transport: InstalledRealtimeTransport; +} + +export const REALTIME_CONTRIBUTION_BOUNDS = Object.freeze({ + contributions: 64, + streams: 128, + eventTypes: 512, + effectsPerEvent: 8, + invalidationTopicsPerEvent: 32, + minimumPollingIntervalMs: 5_000, + maximumPollingIntervalMs: 300_000, +}); + +export interface InstalledRuntimeCapabilities { + readonly realtime: readonly InstalledRealtimeContribution[]; + readonly webWorkers: readonly InstalledWebWorkerContribution[]; + readonly serviceWorker: InstalledServiceWorkerSelection | null; + readonly offlineCommands: InstalledOfflineCommandContribution | null; +} + +export type CapabilityOverrideMap = Readonly<{ + REALTIME: RuntimeCapabilityOverride; + WEB_WORKER: RuntimeCapabilityOverride; + SERVICE_WORKER: RuntimeCapabilityOverride; + OFFLINE_COMMANDS: RuntimeCapabilityOverride; +}>; + +/** + * The effective selection after applying runtime overrides. `serviceWorkerMode` + * keeps the §3.6 persistent-registration exception explicit: a statically + * `ACTIVE` worker that runtime config disables still performs exactly one + * owned-registration lookup and at most one unregister, and deletes no cache. + */ +export type ResolvedRuntimeCapabilities = Readonly<{ + realtime: readonly InstalledRealtimeContribution[]; + webWorkers: readonly InstalledWebWorkerContribution[]; + serviceWorker: InstalledServiceWorkerSelection | null; + serviceWorkerDisabledCleanup: boolean; + offlineCommands: InstalledOfflineCommandContribution | null; +}>; + +export function resolveRuntimeCapabilities( + installed: InstalledRuntimeCapabilities, + overrides: CapabilityOverrideMap, +): ResolvedRuntimeCapabilities { + const realtimeDisabled = overrides.REALTIME === "DISABLED"; + const workersDisabled = overrides.WEB_WORKER === "DISABLED"; + const serviceWorkerDisabled = overrides.SERVICE_WORKER === "DISABLED"; + const offlineDisabled = overrides.OFFLINE_COMMANDS === "DISABLED"; + + return Object.freeze({ + realtime: realtimeDisabled ? Object.freeze([]) : installed.realtime, + webWorkers: workersDisabled ? Object.freeze([]) : installed.webWorkers, + serviceWorker: serviceWorkerDisabled ? null : installed.serviceWorker, + serviceWorkerDisabledCleanup: + serviceWorkerDisabled && installed.serviceWorker?.mode === "ACTIVE", + offlineCommands: offlineDisabled ? null : installed.offlineCommands, + }); +} + +export function validateRealtimeContributions( + contributions: readonly InstalledRealtimeContribution[], +): readonly InstalledRealtimeContribution[] { + const bounds = REALTIME_CONTRIBUTION_BOUNDS; + if (contributions.length > bounds.contributions) { + throw new TypeError("Realtime contributions exceed their bound."); + } + const contributionIds = new Set(); + const streamIds = new Set(); + let eventTypeCount = 0; + + for (const contribution of contributions) { + if ( + !contribution.contributionId || + contributionIds.has(contribution.contributionId) + ) { + throw new TypeError("Duplicate realtime contribution identity."); + } + contributionIds.add(contribution.contributionId); + streamIds.add(contribution.streamId); + if (streamIds.size > bounds.streams) { + throw new TypeError("Realtime streams exceed their bound."); + } + + const transport = contribution.transport; + if (transport.kind === "POLLING") { + if ( + !Number.isSafeInteger(transport.intervalMs) || + transport.intervalMs < bounds.minimumPollingIntervalMs || + transport.intervalMs > bounds.maximumPollingIntervalMs + ) { + throw new TypeError( + `Realtime polling interval is out of range: ${contribution.contributionId}`, + ); + } + } + + const seenEvents = new Map(); + for (const effect of contribution.eventEffects) { + eventTypeCount += 1; + if (eventTypeCount > bounds.eventTypes) { + throw new TypeError("Realtime event types exceed their bound."); + } + const count = (seenEvents.get(effect.eventType) ?? 0) + 1; + if (count > bounds.effectsPerEvent) { + throw new TypeError( + `Realtime effects per event exceeded: ${effect.eventType}`, + ); + } + seenEvents.set(effect.eventType, count); + if ( + effect.invalidationTopics.length > bounds.invalidationTopicsPerEvent + ) { + throw new TypeError( + `Realtime invalidation topics exceeded: ${effect.eventType}`, + ); + } + if ( + effect.effect === "INVALIDATE_TOPICS" && + effect.invalidationTopics.length === 0 + ) { + throw new TypeError( + `INVALIDATE_TOPICS effect declares no topic: ${effect.eventType}`, + ); + } + } + } + return Object.freeze([...contributions]); +} + +export type RuntimeCapabilityId = + | "REALTIME" + | "WEB_WORKER" + | "SERVICE_WORKER" + | "OFFLINE_COMMANDS"; + +/** + * §3.5. A bounded, serialisable view of one capability. `selected` is the + * static SSOT count and `active` is what survived the runtime override, so the + * difference between the two is exactly the operator's effect. An override can + * only subtract, which is why a never-selected capability stays at zero. + */ +export type RuntimeCapabilityStatus = Readonly<{ + capabilityId: RuntimeCapabilityId; + selected: number; + active: number; + override: RuntimeCapabilityOverride; +}>; + +export type RuntimeCapabilitySnapshot = readonly RuntimeCapabilityStatus[]; + +const CAPABILITY_ORDER = Object.freeze([ + "REALTIME", + "WEB_WORKER", + "SERVICE_WORKER", + "OFFLINE_COMMANDS", +] as const); + +export function describeRuntimeCapabilities( + installed: InstalledRuntimeCapabilities, + overrides: CapabilityOverrideMap, +): RuntimeCapabilitySnapshot { + const resolved = resolveRuntimeCapabilities(installed, overrides); + const counts: Readonly< + Record> + > = Object.freeze({ + REALTIME: Object.freeze({ + selected: installed.realtime.length, + active: resolved.realtime.length, + }), + WEB_WORKER: Object.freeze({ + selected: installed.webWorkers.length, + active: resolved.webWorkers.length, + }), + SERVICE_WORKER: Object.freeze({ + selected: installed.serviceWorker === null ? 0 : 1, + active: resolved.serviceWorker === null ? 0 : 1, + }), + OFFLINE_COMMANDS: Object.freeze({ + selected: installed.offlineCommands === null ? 0 : 1, + active: resolved.offlineCommands === null ? 0 : 1, + }), + }); + + return Object.freeze( + CAPABILITY_ORDER.map((capabilityId) => + Object.freeze({ + capabilityId, + selected: counts[capabilityId].selected, + active: counts[capabilityId].active, + override: overrides[capabilityId], + }), + ), + ); +} diff --git a/src/contracts/schema-registry.ts b/src/contracts/schema-registry.ts new file mode 100644 index 0000000..ceeff37 --- /dev/null +++ b/src/contracts/schema-registry.ts @@ -0,0 +1,111 @@ +export type SchemaDefinition = Readonly<{ + schemaId: string; + boundary: + | "route-params" + | "route-search" + | "route-search-api-request" + | "api-request" + | "api-response"; + owner: string; + runtime: "zod"; + schemaVersion?: number; + direction?: "REQUEST" | "RESPONSE"; + unknownFieldPolicy?: "REJECT_UNKNOWN" | "STRIP_UNKNOWN"; +}>; + +export type RuntimeSchemaResult = + | Readonly<{ success: true; data: unknown }> + | Readonly<{ + success: false; + issues: readonly Readonly<{ path: string; code: string }>[]; + }>; + +export type RuntimeSchemaCodec = Readonly<{ + schemaId: string; + parse(value: unknown): RuntimeSchemaResult; +}>; + +export function composeRuntimeSchemaCodecs( + contributions: readonly Readonly>[], +): Readonly> { + const result: Record = Object.create(null); + for (const contribution of contributions) { + for (const [registryId, codec] of Object.entries(contribution)) { + if ( + registryId !== codec.schemaId || + Object.hasOwn(result, registryId) + ) { + throw new TypeError( + `Invalid or duplicate runtime schema codec: ${registryId}`, + ); + } + result[registryId] = codec; + } + } + return Object.freeze(result); +} + +export function validateWithRuntimeSchemaRegistry( + schemaId: string, + value: unknown, + registry: Readonly>, +): RuntimeSchemaResult { + const codec = registry[schemaId]; + if (!codec) { + return Object.freeze({ + success: false, + issues: Object.freeze([ + Object.freeze({ path: "", code: "SCHEMA_NOT_REGISTERED" }), + ]), + }); + } + try { + return codec.parse(value); + } catch { + return Object.freeze({ + success: false, + issues: Object.freeze([ + Object.freeze({ path: "", code: "SCHEMA_EXECUTION_FAILED" }), + ]), + }); + } +} + +export function composeSchemaRegistry( + contributions: readonly Readonly>[], +): Readonly> { + const result: Record = Object.create(null); + for (const contribution of contributions) { + for (const [registryId, definition] of Object.entries(contribution)) { + if ( + registryId !== definition.schemaId || + !definition.owner || + (definition.schemaVersion !== undefined && + (!Number.isSafeInteger(definition.schemaVersion) || + definition.schemaVersion < 1)) || + Object.hasOwn(result, registryId) + ) { + throw new TypeError(`Invalid or duplicate schema definition: ${registryId}`); + } + result[registryId] = definition; + } + } + return Object.freeze(result); +} + +export const PLATFORM_SCHEMA_REGISTRY: Readonly< + Record +> = Object.freeze({ + none: Object.freeze({ + schemaId: "none", + boundary: "route-params", + owner: "feature-frontend-routing-release-recovery-runtime", + runtime: "zod", + }), + NotFoundSplat: Object.freeze({ + schemaId: "NotFoundSplat", + boundary: "route-params", + owner: "feature-frontend-routing-release-recovery-runtime", + runtime: "zod", + }), + }); diff --git a/src/contracts/server-state-scope.ts b/src/contracts/server-state-scope.ts new file mode 100644 index 0000000..632aa2b --- /dev/null +++ b/src/contracts/server-state-scope.ts @@ -0,0 +1,36 @@ +import type { RuntimeIdentityRegistry } from "./query-keys.ts"; + +export type CacheScopeSnapshot = Readonly<{ + generation: number; + fingerprint: string; + identities: RuntimeIdentityRegistry; + /** Aborted synchronously when this generation is fenced or disposed. */ + signal: AbortSignal; + isCurrent(): boolean; +}>; + +/** + * §10.5. Client scope authority lifecycle. + * + * `FENCED` is published synchronously so no subscriber can render a value that + * belonged to the previous identity. `READY` arrives only after the exact reset + * sequence in §10.6 has completed. + */ +export type ClientScopeLifecycleEvent = + | Readonly<{ kind: "FENCED"; previousGeneration: number }> + | Readonly<{ kind: "READY"; snapshot: CacheScopeSnapshot }> + | Readonly<{ kind: "FAILED"; generation: number }> + | Readonly<{ kind: "DISPOSED" }>; + +/** UI reads `FENCED` as the `scope-transition` state, never as stale data. */ +export type ClientScopePhase = "READY" | "FENCED" | "FAILED" | "DISPOSED"; + +export type ServerStateScopeRuntime = Readonly<{ + getSnapshot(): CacheScopeSnapshot; + getPhase(): ClientScopePhase; + subscribe(listener: () => void): () => void; + subscribeLifecycle( + listener: (event: ClientScopeLifecycleEvent) => void, + ): () => void; + dispose(): void; +}>; diff --git a/src/contracts/server-state.ts b/src/contracts/server-state.ts new file mode 100644 index 0000000..79de43f --- /dev/null +++ b/src/contracts/server-state.ts @@ -0,0 +1,271 @@ +import type { Result } from "../application/result.ts"; +import type { QueryInvalidationTopic } from "./query-invalidation.ts"; +import { + createBoundQueryKey, + defineQueryNamespaceIdentity, + type RuntimeIdentityBinding, +} from "./query-keys.ts"; +import type { CacheScopeSnapshot } from "./server-state-scope.ts"; +import type { MutationIntent } from "./mutation-intent.ts"; + +/** + * §10.2. The four fixed profiles. A feature selects one by ID; it never + * declares its own numbers. If none of the four can express a requirement, the + * design document and this registry are amended together. + */ +export type ServerStateProfileId = + | "DETAIL_STANDARD" + | "LIST_STANDARD" + | "LOOKUP_STABLE" + | "VOLATILE_STATUS"; + +export type ServerStateProfile = Readonly<{ + profileId: string; + staleTimeMs: number; + gcTimeMs: number; + refetchOnMount: boolean | "always"; + refetchOnFocus: boolean; + refetchOnReconnect: boolean; + retryOwner: "TRANSPORT" | "QUERY" | "NONE"; + maxResultItems: number; + maxEstimatedResultBytes: number; +}>; + +export const SERVER_STATE_PROFILES: Readonly< + Record +> = Object.freeze({ + DETAIL_STANDARD: Object.freeze({ + profileId: "DETAIL_STANDARD", + staleTimeMs: 30_000, + gcTimeMs: 300_000, + refetchOnMount: true, + refetchOnFocus: true, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 1, + maxEstimatedResultBytes: 262_144, + }), + LIST_STANDARD: Object.freeze({ + profileId: "LIST_STANDARD", + staleTimeMs: 15_000, + gcTimeMs: 300_000, + refetchOnMount: true, + refetchOnFocus: true, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 200, + maxEstimatedResultBytes: 1_048_576, + }), + LOOKUP_STABLE: Object.freeze({ + profileId: "LOOKUP_STABLE", + staleTimeMs: 300_000, + gcTimeMs: 1_800_000, + refetchOnMount: false, + refetchOnFocus: false, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 500, + maxEstimatedResultBytes: 2_097_152, + }), + VOLATILE_STATUS: Object.freeze({ + profileId: "VOLATILE_STATUS", + staleTimeMs: 0, + gcTimeMs: 60_000, + refetchOnMount: "always", + refetchOnFocus: true, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 1, + maxEstimatedResultBytes: 65_536, + }), +}); + +export function getServerStateProfile( + profileId: ServerStateProfileId, +): ServerStateProfile { + const profile = SERVER_STATE_PROFILES[profileId]; + if (!profile) { + throw new TypeError(`Unregistered server-state profile: ${profileId}`); + } + return profile; +} + +/** §10.3. Feature-owned, mandatory result measurement. */ +export type QueryResultMeasure = Readonly<{ + itemCount: number; + estimatedBytes: number; +}>; + +export type ResultAdmission = + | Readonly<{ ok: true; measure: QueryResultMeasure }> + | Readonly<{ + ok: false; + code: "RESULT_MEASUREMENT_FAILED" | "RESULT_BUDGET_EXCEEDED"; + }>; + +/** + * §10.4. There is no generic fallback: `JSON.stringify` sizing, wire DTO + * re-serialization and recursive walkers are all prohibited, so a definition + * without a working `measureResult` fails closed instead of guessing. + */ +export function admitQueryResult( + measureResult: (value: Value) => QueryResultMeasure, + value: Value, + profile: ServerStateProfile, +): ResultAdmission { + let measure: QueryResultMeasure; + try { + measure = measureResult(value); + } catch { + return Object.freeze({ + ok: false as const, + code: "RESULT_MEASUREMENT_FAILED" as const, + }); + } + if ( + !measure || + !Number.isSafeInteger(measure.itemCount) || + measure.itemCount < 0 || + !Number.isSafeInteger(measure.estimatedBytes) || + measure.estimatedBytes < 0 + ) { + return Object.freeze({ + ok: false as const, + code: "RESULT_MEASUREMENT_FAILED" as const, + }); + } + if ( + measure.itemCount > profile.maxResultItems || + measure.estimatedBytes > profile.maxEstimatedResultBytes + ) { + return Object.freeze({ + ok: false as const, + code: "RESULT_BUDGET_EXCEEDED" as const, + }); + } + return Object.freeze({ ok: true as const, measure: Object.freeze(measure) }); +} + +export type BoundQuery = Readonly<{ + definitionId: string; + queryKey: readonly unknown[]; + profile: ServerStateProfile; + identity: RuntimeIdentityBinding; + scope: CacheScopeSnapshot; + measureResult(value: Value): QueryResultMeasure; + execute(context: Readonly<{ signal: AbortSignal }>): Promise>; +}>; + +export type QueryDefinition = Readonly<{ + definitionId: string; + definitionVersion: number; + owner: string; + namespace: string; + namespaceVersion: number; + operationId: string; + profileId: ServerStateProfileId; + measureResult(value: Value): QueryResultMeasure; + execute( + input: Input, + context: Readonly<{ signal: AbortSignal }>, + ): Promise>; +}>; + +export function bindQuery( + definition: QueryDefinition, + input: Input, + scope: CacheScopeSnapshot, +): BoundQuery { + if (typeof definition.measureResult !== "function") { + throw new TypeError( + `Query definition requires measureResult: ${definition.definitionId}`, + ); + } + const namespace = defineQueryNamespaceIdentity( + definition.namespace, + definition.namespaceVersion, + ); + const identity = scope.identities.intern(input); + return Object.freeze({ + definitionId: definition.definitionId, + // §10.7. Opaque runtime identity only. No raw account/resource ID, URL, + // filter object, document or cursor ever enters a query key. + queryKey: createBoundQueryKey( + namespace, + scope.fingerprint, + definition.definitionVersion, + identity.token, + ), + profile: getServerStateProfile(definition.profileId), + identity, + scope, + measureResult: (value: Value) => definition.measureResult(value), + execute: (context) => definition.execute(input, context), + }); +} + +/** + * §11.2. `REJECT_WHILE_ACTIVE` is the command default. `JOIN_IDENTICAL` is + * only valid when scope, definition, canonical input and user intent all match; + * `ALLOW_PARALLEL` is an explicit per-feature decision. + */ +export type MutationDuplicatePolicy = + | "JOIN_IDENTICAL" + | "REJECT_WHILE_ACTIVE" + | "ALLOW_PARALLEL"; + +/** §11.5. Ordered optimistic layer bounds. Overflow means pessimistic execution. */ +export const OPTIMISTIC_LAYER_BOUNDS = Object.freeze({ + maxLayersPerQueryKey: 8, + maxSingleLayerBytes: 262_144, + maxTotalLayerBytesPerQueryKey: 2_097_152, + maxLayerAgeGraceMs: 60_000, +}); + +/** §11.3. Duplicate coordinator bounds. The map is never used as a result cache. */ +export const MUTATION_COORDINATOR_BOUNDS = Object.freeze({ + activeDefinitionsPerRuntime: 256, + activeIntentsTotal: 1_024, + canonicalIdentityBytes: 16_384, + waitersPerJoinedIntent: 32, + settledRetentionMs: 0, +}); + +export type BoundMutation = Readonly<{ + definitionId: string; + definitionVersion: number; + operationId: string; + requiresIdempotencyKey: boolean; + owner: string; + duplicatePolicy: MutationDuplicatePolicy; + scope: CacheScopeSnapshot; + execute( + input: Input, + context: Readonly<{ signal: AbortSignal; intent: MutationIntent }>, + ): Promise>; + invalidate: readonly QueryInvalidationTopic[]; + optimistic?: Readonly<{ + queryKey: readonly unknown[]; + update(previous: unknown, input: Input): unknown; + }>; +}>; + +export function defineServerStateProfile( + profile: ServerStateProfile, +): ServerStateProfile { + if ( + !profile.profileId || + !Number.isSafeInteger(profile.staleTimeMs) || + profile.staleTimeMs < 0 || + !Number.isSafeInteger(profile.gcTimeMs) || + profile.gcTimeMs < 1 || + profile.retryOwner === "QUERY" || + !Number.isSafeInteger(profile.maxResultItems) || + profile.maxResultItems < 1 || + !Number.isSafeInteger(profile.maxEstimatedResultBytes) || + profile.maxEstimatedResultBytes < 1 + ) { + throw new TypeError("Invalid server-state profile."); + } + return Object.freeze({ ...profile }); +} diff --git a/src/contracts/service-worker.ts b/src/contracts/service-worker.ts new file mode 100644 index 0000000..c92b7c0 --- /dev/null +++ b/src/contracts/service-worker.ts @@ -0,0 +1,152 @@ +/** + * §17–§18. Service Worker contract. + * + * One physical registration per scope covers PWA lifecycle, verified static + * asset fetch, Web Push and the optional sync wake-up. A separate registration + * for any of those is prohibited. + */ + +export const SERVICE_WORKER_PROTOCOL_VERSION = 1 as const; +export const SERVICE_WORKER_CACHE_SCHEMA_VERSION = 1 as const; +export const SERVICE_WORKER_SCRIPT_PATH = "service-worker.js" as const; + +export const SERVICE_WORKER_BOUNDS = Object.freeze({ + assets: 256, + singleAssetBytes: 2 * 1024 * 1024, + assetSetBytes: 5 * 1024 * 1024, + fetchConcurrency: 4, + installDeadlineMs: 60_000, + clientDrainMs: 30_000, + updateCheckIntervalMs: 6 * 60 * 60 * 1_000, + retainedPreviousCaches: 1, + reloadGuardBytes: 512, + reloadGuardTtlMs: 10 * 60 * 1_000, +}); + +export type ServiceWorkerHandlerId = + | "WEB_PUSH" + | "PWA_STATIC_ASSETS" + | "OFFLINE_SYNC_WAKEUP"; + +/** + * §17.3. Removal is staged. `ACTIVE` never transitions directly to `null`: + * a registration that already exists in a browser must first be unregistered, + * then have its owned resources purged, before the source may disappear. + */ +export type InstalledServiceWorkerSelection = + | Readonly<{ + mode: "ACTIVE"; + scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH; + handlers: readonly ServiceWorkerHandlerId[]; + }> + | Readonly<{ + mode: "REMOVE_REGISTRATION"; + scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH; + }> + | Readonly<{ + mode: "PURGE_OWNED_RESOURCES"; + scriptPath: typeof SERVICE_WORKER_SCRIPT_PATH; + }>; + +/** §17.7. Page and worker read the same compile-time identity tuple. */ +export type ServiceWorkerProtocolIdentity = Readonly<{ + serviceWorkerProtocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION; + cacheSchemaVersion: typeof SERVICE_WORKER_CACHE_SCHEMA_VERSION; + buildId: string; + releaseId: string; + contractSetDigest: string; + staticAssetSetDigest: string; +}>; + +export type ServiceWorkerMessageKind = + | "PAGE_HELLO" + | "WORKER_HELLO_ACK" + | "UPDATE_READY" + | "ACTIVATE_REQUEST" + | "ACTIVATE_ACCEPTED" + | "ACTIVATE_REJECTED" + | "CLIENT_DRAIN_REQUEST" + | "CLIENT_DRAINED" + | "ACTIVATED_RELOAD_REQUIRED" + | "CACHE_RESET_REQUEST" + | "CACHE_RESET_RESULT" + | "SYNC_WAKE_OBSERVED"; + +export type ServiceWorkerMessage = Readonly<{ + protocolVersion: typeof SERVICE_WORKER_PROTOCOL_VERSION; + kind: ServiceWorkerMessageKind; + messageId: string; + sourceBuildId: string; + targetBuildId?: string; + nonce?: string; + /** Present only on a successful CACHE_RESET_RESULT. */ + cachesDeleted?: number; +}>; + +/** §18.3. Compile-time asset manifest; the worker never fetches one. */ +export interface StaticAssetManifestV1 { + readonly schemaVersion: 1; + readonly buildId: string; + readonly releaseId: string; + readonly setDigest: `sha256:${string}`; + readonly assets: readonly Readonly<{ + url: string; + sha256: `sha256:${string}`; + bytes: number; + contentType: string; + }>[]; +} + +/** §18.2. `ca-static-v1-`. */ +export const STATIC_CACHE_PREFIX = "ca-static-v1-" as const; + +export function staticCacheName(setDigest: string): string { + const hex = setDigest.replace(/^sha256:/, "").slice(0, 16); + if (!/^[0-9a-f]{16}$/.test(hex)) { + throw new TypeError("Static asset set digest is invalid."); + } + return `${STATIC_CACHE_PREFIX}${hex}`; +} + +export function isOwnedStaticCacheName(name: string): boolean { + return ( + name.startsWith(STATIC_CACHE_PREFIX) && + /^[0-9a-f]{16}$/.test(name.slice(STATIC_CACHE_PREFIX.length)) + ); +} + +export type ServiceWorkerStartOutcome = + | Readonly<{ kind: "ACTIVE"; buildId: string }> + | Readonly<{ kind: "RELOAD_TO_ENABLE" }> + | Readonly<{ kind: "UPDATE_WAITING" }> + | Readonly<{ kind: "DISABLED" }> + | Readonly<{ kind: "INCOMPATIBLE" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerActivationOutcome = + | Readonly<{ kind: "ACTIVATED_RELOAD_REQUIRED" }> + | Readonly<{ kind: "BLOCKED_DIRTY_CLIENT" }> + | Readonly<{ kind: "CLIENT_DRAIN_TIMEOUT" }> + | Readonly<{ kind: "NO_WAITING_WORKER" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerResetOutcome = + | Readonly<{ kind: "RESET"; cachesDeleted: number }> + | Readonly<{ kind: "NOT_CONTROLLED" }> + | Readonly<{ kind: "PROTOCOL_MISMATCH" }> + | Readonly<{ kind: "FAILED"; code: string }>; + +export type ServiceWorkerRemovalOutcome = + | Readonly<{ kind: "ABSENT" }> + | Readonly<{ kind: "UNREGISTERED" }> + | Readonly<{ kind: "PURGED"; cachesDeleted: number; metadataDeleted: number }> + | Readonly<{ kind: "OWNERSHIP_MISMATCH" }> + | Readonly<{ kind: "FAILED"; operation: "LOOKUP" | "UNREGISTER" | "PURGE" }>; + +export interface ServiceWorkerRuntimeHost { + start(): Promise; + requestActivation(): Promise; + resetOwnedCaches(): Promise; + stop(): Promise; +} diff --git a/src/contracts/storage-keys.ts b/src/contracts/storage-keys.ts new file mode 100644 index 0000000..c92217b --- /dev/null +++ b/src/contracts/storage-keys.ts @@ -0,0 +1,157 @@ +const APP_NAMESPACE = "ca-frontend"; + +export type StorageBackend = + | "memory" + | "sessionStorage" + | "localStorage" + | "disabled" + | "forbidden"; + +export type StorageValueCodec = + | "color-scheme-v1" + | "opaque-string-v1" + | "none"; + +export type StorageKeyInput = Readonly<{ + logicalName: string; + scope: string; + name: string; + backend: StorageBackend; + classification: + | "public-preference" + | "opaque-cache" + | "sensitive-forbidden"; + schemaVersion: number; + valueCodec: StorageValueCodec; + ttl: number | "session" | null; + migration: "discard"; + quotaFallback: "memory" | "no-persist" | "feature-disable"; +}>; + +export type StorageDefinition = Readonly< + StorageKeyInput & { physicalKey: string } +>; + +export const STORAGE_REGISTRY = Object.freeze({ + COLOR_SCHEME: defineStorageKey({ + logicalName: "COLOR_SCHEME", + scope: "preference", + name: "color-scheme", + backend: "localStorage", + classification: "public-preference", + schemaVersion: 1, + valueCodec: "color-scheme-v1", + ttl: null, + migration: "discard", + quotaFallback: "memory", + }), + CHUNK_RELOAD_GUARD: defineStorageKey({ + logicalName: "CHUNK_RELOAD_GUARD", + scope: "release", + name: "chunk-reload-guard", + backend: "sessionStorage", + classification: "opaque-cache", + schemaVersion: 1, + valueCodec: "opaque-string-v1", + ttl: "session", + migration: "discard", + quotaFallback: "no-persist", + }), + QUERY_PERSISTENCE: defineStorageKey({ + logicalName: "QUERY_PERSISTENCE", + scope: "cache", + name: "query-persistence", + backend: "disabled", + classification: "sensitive-forbidden", + schemaVersion: 1, + valueCodec: "none", + ttl: null, + migration: "discard", + quotaFallback: "feature-disable", + }), + AUTH_TOKEN: defineStorageKey({ + logicalName: "AUTH_TOKEN", + scope: "auth", + name: "auth-token", + backend: "forbidden", + classification: "sensitive-forbidden", + schemaVersion: 1, + valueCodec: "none", + ttl: null, + migration: "discard", + quotaFallback: "feature-disable", + }), +}); + +export function defineStorageKey( + definition: Definition, +): Readonly { + if ( + !["color-scheme-v1", "opaque-string-v1", "none"].includes( + definition.valueCodec, + ) + ) { + throw new Error("Unknown client storage value codec"); + } + if (definition.migration !== "discard") { + throw new Error("Unsupported client storage migration policy"); + } + if (definition.classification === "sensitive-forbidden") { + if (!["disabled", "forbidden"].includes(definition.backend)) { + throw new Error("Sensitive client storage registration is forbidden"); + } + if (definition.valueCodec !== "none") { + throw new Error("Sensitive client storage codec is forbidden"); + } + } else if (definition.valueCodec === "none") { + throw new Error("Persisted storage keys require a value codec"); + } + if (!Number.isInteger(definition.schemaVersion) || definition.schemaVersion < 1) { + throw new Error("Storage schemaVersion must be a positive integer"); + } + + return Object.freeze({ + ...definition, + physicalKey: buildPhysicalKey( + definition.scope, + definition.schemaVersion, + definition.name, + ), + }); +} + +export function buildPhysicalKey( + scope: string, + schemaVersion: number, + name: string, +): string { + return `${APP_NAMESPACE}:${scope}:v${schemaVersion}:${name}`; +} + +export function getStorageDefinition(logicalName: string): StorageDefinition { + const registry: Readonly> = STORAGE_REGISTRY; + const definition = registry[logicalName]; + if (!definition) throw new Error(`Unregistered storage key: ${logicalName}`); + if (definition.classification === "sensitive-forbidden") { + throw new Error(`Forbidden storage key: ${logicalName}`); + } + return definition; +} + +export function isStorageValueAllowed( + definition: StorageDefinition, + value: unknown, +): boolean { + switch (definition.valueCodec) { + case "color-scheme-v1": + return value === "light" || value === "dark" || value === "system"; + case "opaque-string-v1": + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= 2_048 + ); + default: + return false; + } +} diff --git a/src/contracts/telemetry.ts b/src/contracts/telemetry.ts new file mode 100644 index 0000000..66ba272 --- /dev/null +++ b/src/contracts/telemetry.ts @@ -0,0 +1,237 @@ +export const TELEMETRY_ATTRIBUTE_ALLOWLIST = Object.freeze([ + "app_version", + "build_id", + "release_id", + "config_schema_version", + "api_contract_version", + "route_id", + "operation_id", + "error_kind", + "http_status_group", + "attempt_count_bucket", + "duration_bucket", + "component_boundary", + "active_release_id", + "mismatch_kind", + "reason", + "queue_size_bucket", +] as const); + +export const TELEMETRY_FORBIDDEN_ATTRIBUTES = Object.freeze([ + "access_token", + "refresh_token", + "authorization_header", + "cookie", + "email", + "user_name", + "raw_user_id", + "raw_url", + "query_string", + "request_body", + "response_body", + "storage_value", + "stack_in_user_message", +] as const); + +type TelemetryDefinitionFor = Readonly<{ + eventName: Name; + trigger: string; + requiredAttributes: readonly string[]; + optionalAttributes: readonly string[]; + forbiddenAttributes: readonly string[]; + sampling: string; + delivery: "best-effort"; +}>; + +const event = ( + eventName: Name, + trigger: string, + requiredAttributes: readonly string[], + optionalAttributes: readonly string[] = [], + sampling = "all", +): TelemetryDefinitionFor => + Object.freeze({ + eventName, + trigger, + requiredAttributes: Object.freeze(requiredAttributes), + optionalAttributes: Object.freeze(optionalAttributes), + forbiddenAttributes: TELEMETRY_FORBIDDEN_ATTRIBUTES, + sampling, + delivery: "best-effort", + }); + +export const TELEMETRY_REGISTRY = Object.freeze({ + "app.boot.failed": event("app.boot.failed", "boot validation failure", [ + "error_kind", + "build_id", + "config_schema_version", + ]), + "api.request.failed": event("api.request.failed", "terminal API failure", [ + "error_kind", + "http_status_group", + "attempt_count_bucket", + "route_id", + ], ["operation_id", "duration_bucket"]), + "ui.render.failed": event("ui.render.failed", "React boundary catch", [ + "route_id", + "build_id", + "component_boundary", + ]), + "release.mismatch.detected": event( + "release.mismatch.detected", + "release tuple mismatch", + ["build_id", "active_release_id", "mismatch_kind"], + ), + "telemetry.delivery.dropped": event( + "telemetry.delivery.dropped", + "queue or sink failure", + ["reason", "queue_size_bucket"], + [], + "internal-counter", + ), +}); + +export type TelemetryEventName = keyof typeof TELEMETRY_REGISTRY; +export type TelemetryDefinition = TelemetryDefinitionFor; +export type TelemetryEvent = Readonly<{ + eventName: TelemetryEventName; + timestamp: string; + attributes: Readonly>; +}>; + +export type TelemetryProjectionResult = + | Readonly<{ success: true; event: TelemetryEvent }> + | Readonly<{ success: false; reason: string }>; + +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,63}$/; +const ATTRIBUTE_VALUE_POLICIES: Readonly< + Record boolean> +> = Object.freeze({ + route_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + operation_id: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + error_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + http_status_group: (value) => /^(?:[1-5]xx|none)$/.test(value), + attempt_count_bucket: (value) => /^(?:1|2|3|3-4|5\+)$/.test(value), + duration_bucket: (value) => + /^(?:lt100ms|100-499ms|500-1999ms|gte2000ms|unknown)$/.test( + value, + ), + component_boundary: (value) => + /^(?:route|feature|boot)$/.test(value), + mismatch_kind: (value) => /^[A-Z][A-Z0-9_]{0,63}$/.test(value), + reason: (value) => + /^(?:queue-full|sink-failure|invalid-event|invalid-context|serialization-failure)$/.test( + value, + ), + queue_size_bucket: (value) => + /^(?:0|1-10|11-50|51\+)$/.test(value), + }); + +function validAttributeValue(key: string, value: unknown): value is string { + if (typeof value !== "string") return false; + const policy = ATTRIBUTE_VALUE_POLICIES[key]; + return policy ? policy(value) : SAFE_IDENTIFIER.test(value); +} + +function isTelemetryEventName(value: string): value is TelemetryEventName { + return Object.hasOwn(TELEMETRY_REGISTRY, value); +} + +function includesAttribute(list: readonly string[], key: string): boolean { + return list.includes(key); +} + +function projectTelemetryEventUnsafe( + eventName: string, + attributes: Readonly>, + now: () => number = Date.now, +): TelemetryProjectionResult { + if (!isTelemetryEventName(eventName)) { + return { + success: false, + reason: "unregistered-event", + }; + } + const definition = TELEMETRY_REGISTRY[eventName]; + + const attributeKeys = Object.keys(attributes); + if ( + attributeKeys.length > + TELEMETRY_ATTRIBUTE_ALLOWLIST.length + + TELEMETRY_FORBIDDEN_ATTRIBUTES.length + ) { + return { + success: false, + reason: "invalid-attribute-value", + }; + } + const unknown = attributeKeys.filter( + (key) => + !includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) && + !includesAttribute(TELEMETRY_FORBIDDEN_ATTRIBUTES, key), + ); + if (unknown.length > 0) { + return { + success: false, + reason: "unknown-attributes", + }; + } + const projected = Object.fromEntries( + Object.entries(attributes).filter( + ([key, value]) => + includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) && + !includesAttribute(TELEMETRY_FORBIDDEN_ATTRIBUTES, key) && + validAttributeValue(key, value), + ), + ); + const invalid = Object.entries(attributes).filter( + ([key, value]) => + includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) && + !validAttributeValue(key, value), + ); + if (invalid.length > 0) { + return { + success: false, + reason: "invalid-attribute-value", + }; + } + const missing = definition.requiredAttributes.filter( + (key) => projected[key] === undefined, + ); + if (missing.length > 0) { + return { + success: false, + reason: "missing-required-attributes", + }; + } + + let timestamp: string; + try { + timestamp = new Date(now()).toISOString(); + } catch { + timestamp = new Date(0).toISOString(); + } + return { + success: true, + event: Object.freeze({ + eventName, + timestamp, + attributes: Object.freeze(projected), + }), + }; +} + +export function projectTelemetryEvent( + eventName: string, + attributes: Readonly>, + now: () => number = Date.now, +): TelemetryProjectionResult { + try { + return projectTelemetryEventUnsafe(eventName, attributes, now); + } catch { + return { + success: false, + reason: "serialization-failure", + }; + } +} diff --git a/src/contracts/web-push.ts b/src/contracts/web-push.ts new file mode 100644 index 0000000..3badc39 --- /dev/null +++ b/src/contracts/web-push.ts @@ -0,0 +1,200 @@ +export const WEB_PUSH_LIMITS = Object.freeze({ + decodedHintBytes: 3 * 1024, + hintFutureSkewMs: 5 * 60 * 1_000, + hintMaxLifetimeMs: 24 * 60 * 60 * 1_000, + handlerDeadlineMs: 10_000, + fenceOperationDeadlineMs: 2_000, + nativeOperationDeadlineMs: 30_000, + backendOperationDeadlineMs: 15_000, + notificationCleanupCount: 64, + notificationCleanupDeadlineMs: 2_000, + clientHandoffCount: 32, +} as const); + +export const WEB_PUSH_PROTOCOLS = Object.freeze({ + control: "PUSH_CONTROL_V1", + hint: "WEB_PUSH_HINT_V1", + click: "NOTIFICATION_CLICK_DATA_V1", + registration: "WEB_PUSH_REGISTRATION_V1", + reconciliation: "WEB_PUSH_RECONCILIATION_V1", + revoke: "WEB_PUSH_REVOKE_V1", + clickHandoff: "WEB_PUSH_CLICK_HANDOFF_V1", + reconcileRequired: "WEB_PUSH_RECONCILE_REQUIRED_V1", +} as const); + +/** + * Product IDs stay opaque to the mechanism. The selected composition must + * provide a closed registry that resolves these syntactically validated IDs. + */ +export type NotificationTypeId = string; +export type NotificationRouteIntentId = string; + +export type PushAuthoritySnapshot = Readonly<{ + fenceGeneration: string; + sessionBindingEpoch: string; + releaseEpoch: string; +}>; + +export type PushControlAssociationV1 = + | Readonly<{ state: "UNASSOCIATED" }> + | Readonly<{ + state: "ACTIVE" | "REVOKED"; + associationEpoch: string; + }>; + +/** + * One origin-scoped durable authority record shared by the window and worker. + * It intentionally contains no account identifier or native subscription + * material. `updatedAt` is diagnostic metadata, never an ordering authority. + */ +export type PushControlV1 = Readonly<{ + protocol: typeof WEB_PUSH_PROTOCOLS.control; + fenceGeneration: string; + sessionBindingEpoch: string; + releaseEpoch: string; + updatedAt: string; + association: PushControlAssociationV1; +}>; + +export type WebPushHintV1 = Readonly<{ + protocol: typeof WEB_PUSH_PROTOCOLS.hint; + notificationType: NotificationTypeId; + notificationId: string; + associationEpoch: string; + releaseEpoch: string; + issuedAt: string; + expiresAt: string; + routeIntent: NotificationRouteIntentId; +}>; + +export type NotificationClickDataV1 = Readonly<{ + protocol: typeof WEB_PUSH_PROTOCOLS.click; + notificationId: string; + routeIntent: NotificationRouteIntentId; + associationEpoch: string; + releaseEpoch: string; + expiresAt: string; +}>; + +export type WebPushReadinessState = + | "PUSH_READY" + | "PUSH_PERMISSION_REQUIRED" + | "PUSH_DENIED" + | "PUSH_UNSUPPORTED" + | "PUSH_UNAVAILABLE"; + +export type WebPushUnavailableReason = + | "ABORTED" + | "BACKEND_ASSOCIATION_MISSING" + | "BACKEND_REVOKE_AMBIGUOUS" + | "BUSY" + | "CLOSED" + | "LOCAL_FENCE_UNSAFE" + | "NATIVE_UNSUBSCRIBE_AMBIGUOUS" + | "NATIVE_SUBSCRIPTION_MISSING" + | "PERMISSION_DISMISSED" + | "REGISTRATION_NOT_ACTIVE" + | "REVOKED" + | "SESSION_AUTHORITY_CHANGED" + | "SUBSCRIPTION_KEY_MISMATCH" + | "WORKER_UNAVAILABLE"; + +export type WebPushReadiness = Readonly<{ + state: WebPushReadinessState; + reason?: WebPushUnavailableReason; +}>; + +export type WebPushOperation = + | "CONTROL_OPEN" + | "CONTROL_READ" + | "CONTROL_PREPARE" + | "CONTROL_ACTIVATE" + | "CONTROL_REVOKE" + | "CONTROL_PURGE" + | "PERMISSION_REQUEST" + | "SUBSCRIPTION_INSPECT" + | "SUBSCRIPTION_CREATE" + | "SUBSCRIPTION_RECONCILE" + | "SUBSCRIPTION_REVOKE" + | "PUSH_DECODE" + | "PUSH_HANDLE" + | "NOTIFICATION_SHOW" + | "NOTIFICATION_CLICK" + | "NOTIFICATION_CLEANUP"; + +export type WebPushFailureCode = + | "ABORTED" + | "ASSOCIATION_MISMATCH" + | "BLOCKED" + | "CONTRACT_REJECTED" + | "CONTROL_CORRUPT" + | "DEADLINE_EXCEEDED" + | "DECLARATIVE_PUSH_FORBIDDEN" + | "EXPIRED" + | "INVALID_INPUT" + | "LIMIT_EXCEEDED" + | "NATIVE_FAILURE" + | "PERMISSION_DENIED" + | "PROVIDER_UNAVAILABLE" + | "RELEASE_MISMATCH" + | "STALE_AUTHORITY" + | "STALE_REVISION" + | "TOMBSTONE_CONFLICT" + | "UNSUPPORTED"; + +export type WebPushFailure = Readonly<{ + code: WebPushFailureCode; + operation: WebPushOperation; + retryable: boolean; +}>; + +export type WebPushResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: WebPushFailure }>; + +export type WebPushObservationEvent = + | "web_push_permission_finished" + | "web_push_registration_finished" + | "web_push_subscription_rotated" + | "web_push_hint_processed" + | "web_push_notification_finished" + | "web_push_click_dispatched" + | "web_push_association_revoked"; + +export type WebPushObservation = Readonly<{ + event: WebPushObservationEvent; + outcome: "SUCCEEDED" | "FAILED" | "DEGRADED"; + reason?: WebPushFailureCode | WebPushUnavailableReason; +}>; + +export interface WebPushObserver { + record(observation: WebPushObservation): void; +} + +export function webPushSuccess( + value: Value, +): WebPushResult { + return Object.freeze({ ok: true, value }); +} + +export function webPushFailure( + code: WebPushFailureCode, + operation: WebPushOperation, + retryable = false, +): WebPushResult { + return Object.freeze({ + ok: false, + error: Object.freeze({ code, operation, retryable }), + }); +} + +export function samePushAuthority( + left: PushAuthoritySnapshot, + right: PushAuthoritySnapshot, +): boolean { + return ( + left.fenceGeneration === right.fenceGeneration && + left.sessionBindingEpoch === right.sessionBindingEpoch && + left.releaseEpoch === right.releaseEpoch + ); +} diff --git a/src/contracts/web-worker.ts b/src/contracts/web-worker.ts new file mode 100644 index 0000000..894e9c9 --- /dev/null +++ b/src/contracts/web-worker.ts @@ -0,0 +1,139 @@ +/** + * §16. Generic CPU Web Worker contract. + * + * The capability is `NOT_SELECTED` by default (§16.2). These types exist so a + * selection can be expressed and validated, but no production worker entry is + * created until a measured, CPU-bound task with an owner is contributed. This + * runtime is separate from the OPFS dedicated worker and the Service Worker. + */ + +export const WEB_WORKER_PROTOCOL = "CA_WEB_WORKER_V1" as const; + +export const WEB_WORKER_BOUNDS = Object.freeze({ + taskGroups: 16, + tasksPerGroup: 32, + activeGroups: 4, + queuedPerGroup: 32, + queuedBytesPerGroup: 16 * 1024 * 1024, + defaultInputBytes: 1024 * 1024, + hardInputBytes: 8 * 1024 * 1024, + defaultOutputBytes: 1024 * 1024, + hardOutputBytes: 8 * 1024 * 1024, + defaultDeadlineMs: 5_000, + hardDeadlineMs: 30_000, + cancelGraceMs: 250, + idleTerminateMs: 60_000, + restartsPerWindow: 3, + restartWindowMs: 300_000, + transferables: 16, + mainThreadChunkBudgetMs: 8, + mainThreadFallbackInputBytes: 1024 * 1024, +}); + +export interface InstalledWorkerTask { + readonly taskId: string; + readonly taskVersion: 1; + readonly maximumInputBytes: number; + readonly maximumOutputBytes: number; + readonly deadlineMs: number; +} + +export interface InstalledWebWorkerContribution { + readonly taskGroupId: string; + readonly tasks: readonly InstalledWorkerTask[]; + readonly fallback: "MAIN_THREAD_CHUNKED" | "UNSUPPORTED"; +} + +export type WorkerRequestMessage = Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "EXECUTE"; + taskId: string; + taskVersion: 1; + requestId: string; + workerGeneration: number; + deadlineEpochMs: number; + payload: unknown; +}>; + +export type WorkerCancelMessage = Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "CANCEL"; + requestId: string; + workerGeneration: number; +}>; + +export type WorkerFailureCode = + | "TASK_UNKNOWN" + | "VERSION_UNSUPPORTED" + | "INPUT_INVALID" + | "INPUT_TOO_LARGE" + | "OUTPUT_INVALID" + | "OUTPUT_TOO_LARGE" + | "QUEUE_FULL" + | "DEADLINE_EXCEEDED" + | "CANCELLED" + | "CRASHED" + | "TRANSFER_FAILED" + | "STALE_RESULT" + | "RUNTIME_PROTOCOL_FAILURE"; + +export type WorkerResponseMessage = + | Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "SUCCESS"; + requestId: string; + workerGeneration: number; + payload: unknown; + }> + | Readonly<{ + protocol: typeof WEB_WORKER_PROTOCOL; + kind: "FAILURE"; + requestId: string; + workerGeneration: number; + code: WorkerFailureCode; + }>; + +export function validateWebWorkerContributions( + contributions: readonly InstalledWebWorkerContribution[], +): readonly InstalledWebWorkerContribution[] { + const bounds = WEB_WORKER_BOUNDS; + if (contributions.length > bounds.taskGroups) { + throw new TypeError("Web Worker task groups exceed their bound."); + } + const groupIds = new Set(); + const taskIds = new Set(); + for (const contribution of contributions) { + if (!contribution.taskGroupId || groupIds.has(contribution.taskGroupId)) { + throw new TypeError("Web Worker task group identity is invalid."); + } + groupIds.add(contribution.taskGroupId); + if ( + contribution.tasks.length === 0 || + contribution.tasks.length > bounds.tasksPerGroup + ) { + throw new TypeError("Web Worker task count is out of range."); + } + for (const task of contribution.tasks) { + const qualified = `${contribution.taskGroupId}/${task.taskId}`; + if (!task.taskId || taskIds.has(qualified)) { + throw new TypeError("Duplicate Web Worker task."); + } + taskIds.add(qualified); + if ( + task.taskVersion !== 1 || + !Number.isSafeInteger(task.maximumInputBytes) || + task.maximumInputBytes < 1 || + task.maximumInputBytes > bounds.hardInputBytes || + !Number.isSafeInteger(task.maximumOutputBytes) || + task.maximumOutputBytes < 1 || + task.maximumOutputBytes > bounds.hardOutputBytes || + !Number.isSafeInteger(task.deadlineMs) || + task.deadlineMs < 1 || + task.deadlineMs > bounds.hardDeadlineMs + ) { + throw new TypeError(`Web Worker task bounds invalid: ${qualified}`); + } + } + } + return Object.freeze([...contributions]); +} diff --git a/src/features/installed-contract-contributions.ts b/src/features/installed-contract-contributions.ts new file mode 100644 index 0000000..3483efc --- /dev/null +++ b/src/features/installed-contract-contributions.ts @@ -0,0 +1,27 @@ +import { + composeContractContributions, + type InstalledContractContribution, + type InstalledContractPackageIdentity, +} from "../contracts/external-contract-runtime.ts"; +import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts"; + +/** + * §4.8. Static contract selection SSOT. + * + * A product feature adds exactly one entry per service package here and imports + * the generated package only from + * `src/features//contracts/-contract-contribution.ts`. + */ +export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = + Object.freeze([REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]); + +export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( + INSTALLED_CONTRACT_CONTRIBUTIONS, +); + +/** + * §5.4. `TEMPLATE_FIXTURE` provenance is filtered out before the contract set + * is canonicalized, so a fixture can never influence the release digest. + */ +export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageIdentity[] = + COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages; diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts new file mode 100644 index 0000000..ba6c8c9 --- /dev/null +++ b/src/features/installed-feature-adapters.ts @@ -0,0 +1,16 @@ +import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts"; +import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts"; +import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; + +type InstalledFeatureInputs = Readonly< + Pick +>; + +export function createInstalledFeatureInputs( + context: Parameters[0], +): InstalledFeatureInputs { + const referenceFeature = createReferenceFeatureInstalledInput(context); + return Object.freeze({ + [referenceFeature.featureId]: referenceFeature.input, + }); +} diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts new file mode 100644 index 0000000..e5ce974 --- /dev/null +++ b/src/features/installed-feature-contracts.ts @@ -0,0 +1,99 @@ +import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; +import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.ts"; +import { + composeSchemaRegistry, + PLATFORM_SCHEMA_REGISTRY, +} from "../contracts/schema-registry.ts"; +import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.ts"; +import { + composeApiOperations, + validateApiRuntimeBindings, +} from "../contracts/api-operations.ts"; +import { validateRestProfileBindings } from "../contracts/rest-profiles.ts"; +import { composeRuntimeSchemaCodecs } from "../contracts/schema-registry.ts"; +import { composeBoundaryMapperRegistry } from "../contracts/boundary-mapper.ts"; + +export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([ + REFERENCE_FEATURE_CONTRACT, +]); + +export const ROUTE_REGISTRY = Object.freeze({ + ...PLATFORM_ROUTE_REGISTRY, + ...REFERENCE_FEATURE_CONTRACT.routes, +}); +export const ROUTE_RUNTIME_CONTRACT = Object.freeze({ + ...PLATFORM_ROUTE_RUNTIME_CONTRACT, + ...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts, +}); +export const API_OPERATIONS = composeApiOperations( + INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations), +); +export const REST_PROFILE_BINDINGS_VALID = validateRestProfileBindings( + API_OPERATIONS, + Object.freeze({ PRIMARY_API: Object.freeze(["omit"] as const) }), +); +export const INVALIDATION_REGISTRY = Object.freeze({ + topics: Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap( + (contract) => contract.invalidation.topics, + ), + ), + namespaces: Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap( + (contract) => contract.invalidation.namespaces, + ), + ), + edges: Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap( + (contract) => contract.invalidation.edges, + ), + ), +}); +export const INVALIDATION_TOPIC_VERSIONS = Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap((contract) => contract.topicVersions), +); +export const SCHEMA_REGISTRY = composeSchemaRegistry([ + PLATFORM_SCHEMA_REGISTRY, + ...INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.schemas), +]); +export const RUNTIME_SCHEMA_CODECS = composeRuntimeSchemaCodecs( + INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.runtimeSchemas), +); +export const MAPPER_REGISTRY = composeBoundaryMapperRegistry( + INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.mappers), +); +export const API_RUNTIME_BINDINGS_VALID = validateApiRuntimeBindings( + API_OPERATIONS, + SCHEMA_REGISTRY, + RUNTIME_SCHEMA_CODECS, + MAPPER_REGISTRY, +); + +export const NAVIGATION_ROUTES = Object.freeze( + Object.values(ROUTE_REGISTRY) + .filter(isNavigableRoute) + .sort((left, right) => left.navigationOrder - right.navigationOrder), +); + +type InstalledRouteDefinition = + (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY]; +type NavigableRoute = InstalledRouteDefinition & + Readonly<{ navigationOrder: number }>; + +function isNavigableRoute( + definition: InstalledRouteDefinition, +): definition is NavigableRoute { + return definition.navigationOrder !== null; +} + +export function getRoute(routeId: string): InstalledRouteDefinition { + const registry: Readonly> = + ROUTE_REGISTRY; + const selected = registry[routeId]; + if (!selected) throw new Error(`Unregistered route: ${routeId}`); + return selected; +} + +export function routePath(routeId: string): string { + return getRoute(routeId).path; +} diff --git a/src/features/installed-feature-messages.ts b/src/features/installed-feature-messages.ts new file mode 100644 index 0000000..99ddc3f --- /dev/null +++ b/src/features/installed-feature-messages.ts @@ -0,0 +1,10 @@ +import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts"; + +export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": Object.freeze({ + ...REFERENCE_MESSAGE_CATALOGS["ko-KR"], + }), + "en-US": Object.freeze({ + ...REFERENCE_MESSAGE_CATALOGS["en-US"], + }), +} as const); diff --git a/src/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx new file mode 100644 index 0000000..cc14993 --- /dev/null +++ b/src/features/installed-feature-runtimes.tsx @@ -0,0 +1,16 @@ +import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts"; +import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx"; +import { + REFERENCE_FEATURE_ROUTE_CODECS, + REFERENCE_FEATURE_ROUTE_RUNTIME, +} from "./reference-feature/presentation/reference-feature-runtime.tsx"; + +export const ROUTE_CODECS = Object.freeze({ + ...PLATFORM_ROUTE_CODECS, + ...REFERENCE_FEATURE_ROUTE_CODECS, +}); + +export const ROUTE_RUNTIME = Object.freeze({ + ...PLATFORM_ROUTE_RUNTIME, + ...REFERENCE_FEATURE_ROUTE_RUNTIME, +}); diff --git a/src/features/installed-runtime-capabilities.ts b/src/features/installed-runtime-capabilities.ts new file mode 100644 index 0000000..822fa31 --- /dev/null +++ b/src/features/installed-runtime-capabilities.ts @@ -0,0 +1,49 @@ +import type { InstalledOfflineCommandContribution } from "../contracts/offline-command.ts"; +import type { + InstalledRealtimeContribution, + InstalledRuntimeCapabilities, +} from "../contracts/runtime-capabilities.ts"; +import { + validateRealtimeContributions, +} from "../contracts/runtime-capabilities.ts"; +import type { InstalledServiceWorkerSelection } from "../contracts/service-worker.ts"; +import { + validateWebWorkerContributions, + type InstalledWebWorkerContribution, +} from "../contracts/web-worker.ts"; + +/** + * §3.5. Static optional capability selection SSOT. + * + * Adding an entry here is what installs a capability. Runtime Config can only + * disable what this file already selected, and no dynamic import path is ever + * built from a configuration string. + * + * Current template state, matching the §25 stop conditions: + * + * - realtime: `NOT_SELECTED`. The RT-01..RT-04 common runtime exists and is + * independently verified, but no product contribution supplies an exact + * endpoint, event descriptor and recovery mode (§13.6). + * - webWorkers: `NOT_SELECTED`. No profiled CPU-bound task with an owner + * exists, so no production worker entry is created (§16.2). + * - serviceWorker: `null`. Switching to `ACTIVE` also enables the two-pass + * build in `scripts/build-frontend.ts`. + * - offlineCommands: `NOT_SELECTED`. No external package declares a `KEYED` + * operation with a recovery descriptor (§19.3). + */ + +const REALTIME: readonly InstalledRealtimeContribution[] = Object.freeze([]); + +const WEB_WORKERS: readonly InstalledWebWorkerContribution[] = Object.freeze([]); + +const SERVICE_WORKER: InstalledServiceWorkerSelection | null = null; + +const OFFLINE_COMMANDS: InstalledOfflineCommandContribution | null = null; + +export const INSTALLED_RUNTIME_CAPABILITIES: InstalledRuntimeCapabilities = + Object.freeze({ + realtime: validateRealtimeContributions(REALTIME), + webWorkers: validateWebWorkerContributions(WEB_WORKERS), + serviceWorker: SERVICE_WORKER, + offlineCommands: OFFLINE_COMMANDS, + }); diff --git a/src/features/reference-feature/README.md b/src/features/reference-feature/README.md new file mode 100644 index 0000000..1062d0c --- /dev/null +++ b/src/features/reference-feature/README.md @@ -0,0 +1,68 @@ +# Reference feature ownership and removal + +이 모듈은 제품 도메인이 아니라 새 기능의 수직 경계를 검증하는 제거 가능한 +reference implementation이다. + +## 소유 경계 + +- `domain`: 외부 DTO와 React를 모르는 불변 model +- `application`: UI가 호출하는 list/get/create input과 gateway 계약 +- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter +- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper +- `presentation`: route input을 query/form controller로 연결하는 inbound + adapter, 독립 form schema/command mapper와 list/detail/form/status page + +generic application은 비어 있는 `ApplicationFeatureInputs`와 typed +`features.has/get` registry만 소유한다. 이 feature의 application API가 module +augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므로 +등록되지 않은 ID와 잘못된 input shape는 typecheck에서 거절된다. feature hook은 +별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로 +설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다. + +예측 가능한 실패는 `src/application/result.ts`의 공통 +`Result`로 반환한다. `AppFailure.kind`는 +`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인 +`ApiFailure`는 같은 type의 alias다. + +HTTP adapter의 operation map은 operation ID마다 허용된 route ID, request shape와 +성공 value type을 함께 묶는다. raw HTTP executor의 성공값은 Zod request/response +검증과 feature mapper를 통과한 뒤 operation별 runtime result guard에서 typed +executor로 승격된다. 따라서 gateway 메서드는 개별 응답 cast 없이 정확한 결과를 +반환하고, operation/route/request 조합 오류는 typecheck에서 차단된다. + +route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route type은 +`route-contract.ts`, React context/provider/hook은 `route-input.tsx`가 각각 +소유한다. reference page는 `app-router.tsx`를 역참조하지 않고 좁은 +`useRouteInput` 경계만 사용하므로 lazy route와 router 사이의 순환 의존을 만들지 +않는다. page는 HTTP client, storage, auth owner, output port나 TanStack API를 +직접 import하지 않는다. + +## 설치 지점 + +- 직렬화 계약: `src/features/installed-feature-contracts.ts` +- component/codec: `src/features/installed-feature-runtimes.tsx` +- bootstrap input 조립: `src/features/installed-feature-adapters.ts` + +새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application, +router나 HTTP client에 하드코딩하지 않는다. + +## 검증과 제거 + +```sh +corepack pnpm test:reference-feature +corepack pnpm test:sample-removal +``` + +첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든 +query/mutation/form 상태와 production composition을 검증한다. 두 번째 명령은 임시 +복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로 +재생성한다. feature 소유 coverage include, risk-policy 행과 test-evidence +contribution도 제거한 뒤 typecheck, architecture, registry, unit/integration, +coverage, source evidence, home smoke, production build와 source/built fixture ID +잔여 0개를 검사한다. generic feature registry의 성공 경로는 reference와 무관한 +unit test가 소유하므로 feature 삭제 후에도 공통 boundary coverage가 유지된다. + +CI의 negative type fixture는 잘못된 feature ID/input shape, registry에 없는 +failure kind, operation과 route ID의 불일치가 실제로 컴파일 실패하는지도 +검증한다. Architecture fixture는 허용 edge뿐 아니라 unresolved import, 금지 계층 +edge와 TypeScript 순환 의존을 각각 거절해야 통과한다. diff --git a/src/features/reference-feature/adapters/create-reference-feature-input.ts b/src/features/reference-feature/adapters/create-reference-feature-input.ts new file mode 100644 index 0000000..3540253 --- /dev/null +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -0,0 +1,182 @@ +import type { Result } from "../../../application/result.ts"; +import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts"; +import type { FailureEffectCertainty } from "../../../contracts/errors.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; +import { + createFailure, + kindForStatus, +} from "../../../contracts/errors.ts"; +import type { HttpExecutionOutcome } from "../../../adapters/http/http-execution-v3.ts"; +import { createReferenceFeatureInput } from "../application/reference-feature-api.ts"; +import { + REFERENCE_FEATURE_ID, +} from "../contracts/reference-feature-contract.ts"; +import { mapReferenceOperation } from "../contracts/reference-mapper.ts"; +import { + createReferenceHttpGateway, + type RawReferenceHttpExecutor, + type ReferenceHttpRequest, + type ReferenceOperationId, +} from "./reference-http-gateway.ts"; + +export type InstalledContractOperationExecutor = Readonly<{ + execute( + operationId: string, + input: unknown, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ): Promise>; +}>; + +/** + * The installed feature consumes the composed external-contract operation + * registry through one descriptor-driven executor. Legacy ApiOperation/schema + * registries are intentionally absent from this production composition seam. + */ +export function createReferenceFeatureInstalledInput(context: Readonly<{ + contractOperations: InstalledContractOperationExecutor; +}>) { + const rawHttp: RawReferenceHttpExecutor = Object.freeze({ + async execute(request) { + const operationId = request.operationId; + const input = inputFor(request); + const signal = "signal" in request ? request.signal : undefined; + const intent = "intent" in request ? request.intent : undefined; + const outcome = await context.contractOperations.execute( + operationId, + input, + { + ...(signal === undefined ? {} : { signal }), + ...(intent === undefined ? {} : { intent }), + }, + ); + return projectExecutionOutcome(operationId, outcome); + }, + }); + + return Object.freeze({ + featureId: REFERENCE_FEATURE_ID, + input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)), + }); +} + +function inputFor( + request: ReferenceHttpRequest, +): unknown { + switch (request.operationId) { + case "LIST_REFERENCE_RESOURCES": + return request.searchParams; + case "CREATE_REFERENCE_RESOURCE": + return request.body; + case "GET_REFERENCE_RESOURCE": + return request.pathParams; + } +} + +function projectExecutionOutcome( + operationId: ReferenceOperationId, + outcome: HttpExecutionOutcome, +): Result { + switch (outcome.kind) { + case "SUCCESS": { + const mapped = mapReferenceOperation(operationId, outcome.value); + return mapped.ok + ? Object.freeze({ ok: true as const, value: mapped.value }) + : failure( + "MAPPING_CONTRACT_VIOLATION", + operationId, + mapped.code, + { effect: outcome.effect }, + ); + } + case "PROBLEM": + return failure( + kindForStatus(outcome.metadata.status), + operationId, + "CONTRACT_PROBLEM", + { httpStatus: outcome.metadata.status, effect: outcome.effect }, + ); + case "UNAUTHENTICATED": + return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", { + effect: outcome.effect, + }); + case "FORBIDDEN": + return failure("FORBIDDEN", operationId, "FORBIDDEN", { + effect: outcome.effect, + }); + case "RATE_LIMITED": + return failure("RATE_LIMITED", operationId, "RATE_LIMITED", { + ...(outcome.retryAfterMs === undefined + ? {} + : { retryAfterMs: outcome.retryAfterMs }), + effect: outcome.effect, + }); + case "CANCELLED": + return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", { + effect: outcome.effect, + }); + case "TRANSPORT_FAILURE": + return failure( + outcome.failure.kind === "TIMEOUT" + ? "REQUEST_TIMEOUT" + : outcome.failure.kind === "ABORTED_BY_SCOPE" + ? "SCOPE_GENERATION_CHANGED" + : "NETWORK_UNREACHABLE", + operationId, + outcome.failure.kind, + { effect: outcome.effect }, + ); + case "CONTRACT_VIOLATION": + return failure( + failureKindForViolation(outcome.violation.kind), + operationId, + outcome.violation.kind, + { effect: outcome.effect }, + ); + } +} + +function failureKindForViolation( + violation: Extract< + HttpExecutionOutcome, + { kind: "CONTRACT_VIOLATION" } + >["violation"]["kind"], +): FailureKind { + switch (violation) { + case "CONTENT_TYPE_MISMATCH": + return "CONTENT_TYPE_MISMATCH"; + case "RESPONSE_TOO_LARGE": + return "RESPONSE_BODY_LIMIT"; + case "UTF8_INVALID": + case "JSON_INVALID": + return "MALFORMED_JSON"; + case "MAPPING_CONTRACT_VIOLATION": + return "MAPPING_CONTRACT_VIOLATION"; + case "SCOPE_FENCED": + return "SCOPE_GENERATION_CHANGED"; + case "SUCCESS_SCHEMA_INVALID": + case "PROBLEM_SCHEMA_INVALID": + case "VALIDATOR_RUNTIME_FAILURE": + return "SCHEMA_MISMATCH"; + default: + return "ENVELOPE_MISMATCH"; + } +} + +function failure( + kind: FailureKind, + operationId: string, + code: string, + details: Readonly<{ + httpStatus?: number; + retryAfterMs?: number; + effect?: FailureEffectCertainty; + }> = {}, +): Result { + return Object.freeze({ + ok: false as const, + error: createFailure(kind, operationId, 0, { code, ...details }), + }); +} diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts new file mode 100644 index 0000000..34e2874 --- /dev/null +++ b/src/features/reference-feature/adapters/reference-http-gateway.ts @@ -0,0 +1,160 @@ +import type { Result } from "../../../application/result.ts"; +import { + createFailure, + type ApiFailure, +} from "../../../contracts/errors.ts"; +import type { + ReferenceCreateCommand, + ReferenceGateway, + ReferenceListFilters, +} from "../application/reference-feature-api.ts"; +import type { ReferenceResource } from "../domain/reference-resource.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; + +type ReferenceOperationMap = Readonly<{ + LIST_REFERENCE_RESOURCES: Readonly<{ + request: Readonly<{ + operationId: "LIST_REFERENCE_RESOURCES"; + routeId: "REFERENCE_RESOURCE_LIST"; + searchParams: ReferenceListFilters; + signal?: AbortSignal; + }>; + value: readonly ReferenceResource[]; + }>; + CREATE_REFERENCE_RESOURCE: Readonly<{ + request: Readonly<{ + operationId: "CREATE_REFERENCE_RESOURCE"; + routeId: "REFERENCE_RESOURCE_LIST"; + body: ReferenceCreateCommand; + signal?: AbortSignal; + intent?: MutationIntent; + }>; + value: ReferenceResource; + }>; + GET_REFERENCE_RESOURCE: Readonly<{ + request: Readonly<{ + operationId: "GET_REFERENCE_RESOURCE"; + routeId: "REFERENCE_RESOURCE_DETAIL"; + pathParams: Readonly<{ resourceId: string }>; + signal?: AbortSignal; + }>; + value: ReferenceResource; + }>; +}>; + +export type ReferenceOperationId = keyof ReferenceOperationMap; + +export type ReferenceHttpRequest< + OperationId extends ReferenceOperationId, +> = ReferenceOperationMap[OperationId]["request"]; + +export type ReferenceHttpResult< + OperationId extends ReferenceOperationId, +> = Result; + +export type RawReferenceHttpExecutor = Readonly<{ + execute( + request: ReferenceHttpRequest, + ): Promise>; +}>; + +export function createReferenceHttpGateway( + http: RawReferenceHttpExecutor, +): ReferenceGateway { + return Object.freeze({ + async list( + filters: ReferenceListFilters, + context?: Readonly<{ signal?: AbortSignal }>, + ) { + const result = await http.execute({ + operationId: "LIST_REFERENCE_RESOURCES", + routeId: "REFERENCE_RESOURCE_LIST", + searchParams: filters, + signal: context?.signal, + }); + return projectListResult(result); + }, + async create( + command: ReferenceCreateCommand, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ) { + const result = await http.execute({ + operationId: "CREATE_REFERENCE_RESOURCE", + routeId: "REFERENCE_RESOURCE_LIST", + body: command, + signal: context?.signal, + intent: context?.intent, + }); + return projectResourceResult("CREATE_REFERENCE_RESOURCE", result); + }, + async get( + resourceId: string, + context?: Readonly<{ signal?: AbortSignal }>, + ) { + const result = await http.execute({ + operationId: "GET_REFERENCE_RESOURCE", + routeId: "REFERENCE_RESOURCE_DETAIL", + pathParams: { resourceId }, + signal: context?.signal, + }); + return projectResourceResult("GET_REFERENCE_RESOURCE", result); + }, + }); +} + +function projectListResult( + result: Result, +): ReferenceHttpResult<"LIST_REFERENCE_RESOURCES"> { + if (!result.ok) return result; + if (isReferenceResourceList(result.value)) { + return { ok: true, value: result.value }; + } + return typedResultFailure("LIST_REFERENCE_RESOURCES"); +} + +function projectResourceResult( + operationId: + | "CREATE_REFERENCE_RESOURCE" + | "GET_REFERENCE_RESOURCE", + result: Result, +): Result { + if (!result.ok) return result; + if (isReferenceResource(result.value)) { + return { ok: true, value: result.value }; + } + return typedResultFailure(operationId); +} + +function typedResultFailure(operationId: ReferenceOperationId) { + return { + ok: false as const, + error: createFailure( + "MAPPING_CONTRACT_VIOLATION", + operationId, + 0, + { code: "BOUND_RESULT_TYPE_MISMATCH" }, + ), + }; +} + +function isReferenceResourceList( + value: unknown, +): value is readonly ReferenceResource[] { + return Array.isArray(value) && value.every(isReferenceResource); +} + +function isReferenceResource(value: unknown): value is ReferenceResource { + if (!value || typeof value !== "object") return false; + const candidate = value as Readonly>; + return ( + typeof candidate.id === "string" && + candidate.id.length > 0 && + typeof candidate.displayName === "string" && + candidate.displayName.length > 0 && + (candidate.createdAt === null || + typeof candidate.createdAt === "string") + ); +} diff --git a/src/features/reference-feature/application/reference-feature-api.ts b/src/features/reference-feature/application/reference-feature-api.ts new file mode 100644 index 0000000..4ee895e --- /dev/null +++ b/src/features/reference-feature/application/reference-feature-api.ts @@ -0,0 +1,91 @@ +import type { Result } from "../../../application/result.ts"; +import type {} from "../../../application/ports/in/application-api.ts"; +import { + toReferenceView, + type ReferenceResourceView, +} from "../contracts/reference-mapper.ts"; +import type { ReferenceResource } from "../domain/reference-resource.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; + +export type ReferenceListFilters = Readonly<{ + cursor?: string; + limit: number; + tags?: readonly string[]; +}>; + +export type ReferenceResult = Result; + +export type ReferenceCreateCommand = Readonly<{ + name: string; + note?: string; +}>; + +export type ReferenceFeatureInput = Readonly<{ + listResources( + filters: ReferenceListFilters, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + createResource( + command: ReferenceCreateCommand, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ): Promise>; + getResource( + resourceId: string, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; +}>; + +declare module "../../../application/ports/in/application-api.ts" { + interface ApplicationFeatureInputs { + "reference-feature": ReferenceFeatureInput; + } +} + +export type ReferenceGateway = Readonly<{ + list( + filters: ReferenceListFilters, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + create( + command: ReferenceCreateCommand, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ): Promise>; + get( + resourceId: string, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; +}>; + +export function createReferenceFeatureInput( + gateway: ReferenceGateway, +): ReferenceFeatureInput { + return Object.freeze({ + async listResources(filters, context) { + const result = await gateway.list(filters, context); + return result.ok + ? { + ok: true as const, + value: result.value.map((resource) => toReferenceView(resource)), + } + : result; + }, + async createResource(command, context) { + const result = await gateway.create(command, context); + return result.ok + ? { ok: true as const, value: toReferenceView(result.value) } + : result; + }, + async getResource(resourceId, context) { + const result = await gateway.get(resourceId, context); + return result.ok + ? { ok: true as const, value: toReferenceView(result.value) } + : result; + }, + }); +} diff --git a/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts b/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts new file mode 100644 index 0000000..a16c67b --- /dev/null +++ b/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts @@ -0,0 +1,255 @@ +import { z } from "zod"; + +import type { + CommandEffectDescriptor, + HttpRequestProjection, + InstalledContractContribution, + InstalledHttpContract, + RuntimeValidator, +} from "../../../contracts/external-contract-runtime.ts"; +import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts"; +import { + referenceResourceListQuerySchema, + referenceResourceParamsSchema, +} from "./reference-schemas.ts"; + +/** + * §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference + * HTTP vertical executable as deterministic template data and is excluded from + * `contractSet`. A product feature MUST instead pin an external package and + * import it from its own `contracts/-contract-contribution.ts`. + */ + +function zodValidator( + schemaId: string, + schema: z.ZodType, +): RuntimeValidator { + return Object.freeze({ + schemaId, + safeParse(value: unknown) { + const result = schema.safeParse(value); + if (result.success) { + return Object.freeze({ + success: true as const, + data: structuredClone(result.data), + }); + } + return Object.freeze({ + success: false as const, + issues: Object.freeze( + result.error.issues.map((issue) => + Object.freeze({ + path: Object.freeze( + issue.path.map((segment): string | number => + typeof segment === "number" ? segment : String(segment), + ), + ), + code: String(issue.code), + }), + ), + ), + }); + }, + }); +} + +const referenceResourceDto = z + .object({ + id: z.string().min(1).max(120), + name: z.string().min(1).max(240), + createdAt: z.string().min(1).optional(), + }) + .strip(); + +const problemSchema = z + .object({ + type: z.string().min(1).max(512), + title: z.string().min(1).max(240), + status: z.int().min(100).max(599), + code: z.string().min(1).max(120).optional(), + }) + .strip(); + +export type ReferenceProblem = z.output; + +const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema); + +const createCommandSchema = z + .object({ + name: z.string().trim().min(1).max(120), + note: z.string().trim().max(500).optional(), + }) + .strict(); + +/** + * Fixture-side classifier standing in for the package-provided pure bounded + * classifier. A status the fixture does not describe stays `MAYBE_APPLIED`. + */ +const CREATE_EFFECT: CommandEffectDescriptor = Object.freeze({ + successEffect: "APPLIED_CONFIRMED" as const, + classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) { + if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED"; + return "MAYBE_APPLIED"; + }, +}); + +const LIST_REFERENCE_RESOURCES: InstalledHttpContract< + z.output, + readonly z.output[], + ReferenceProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "LIST_REFERENCE_RESOURCES", + method: "GET" as const, + pathTemplate: "/api/reference-resources", + inputValidator: zodValidator( + "ReferenceResourceListQuery", + referenceResourceListQuerySchema, + ), + outputValidator: zodValidator( + "ReferenceResourceListPayload", + z.array(referenceResourceDto).max(100), + ), + problemValidator: PROBLEM_VALIDATOR, + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: z.output) { + const entries: (readonly [string, string])[] = []; + if (input.cursor !== undefined) entries.push(["cursor", input.cursor]); + entries.push(["limit", String(input.limit)]); + for (const tag of input.tags ?? []) entries.push(["tags", tag]); + return Object.freeze({ + pathValues: Object.freeze({}), + queryEntries: Object.freeze(entries), + body: null, + }); + }, + }), + frontend: Object.freeze({ + policyId: "REFERENCE_LIST_V1", + requestByteLimit: 0, + responseByteLimit: 262_144, + totalDeadlineMs: 10_000, + retryBudget: 2 as const, + authProfileId: "REFERENCE_EXTERNAL_BEARER", + diagnosticsOperation: "reference.list", + }), +}); + +const GET_REFERENCE_RESOURCE: InstalledHttpContract< + z.output, + z.output, + ReferenceProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "GET_REFERENCE_RESOURCE", + method: "GET" as const, + pathTemplate: "/api/reference-resources/{resourceId}", + inputValidator: zodValidator( + "ReferenceResourceParams", + referenceResourceParamsSchema, + ), + outputValidator: zodValidator( + "ReferenceResourcePayload", + referenceResourceDto, + ), + problemValidator: PROBLEM_VALIDATOR, + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: z.output) { + return Object.freeze({ + pathValues: Object.freeze({ resourceId: input.resourceId }), + queryEntries: Object.freeze([]), + body: null, + }); + }, + }), + frontend: Object.freeze({ + policyId: "REFERENCE_DETAIL_V1", + requestByteLimit: 0, + responseByteLimit: 32_768, + totalDeadlineMs: 10_000, + retryBudget: 2 as const, + authProfileId: "REFERENCE_EXTERNAL_BEARER", + diagnosticsOperation: "reference.detail", + }), +}); + +const CREATE_REFERENCE_RESOURCE: InstalledHttpContract< + z.output, + z.output, + ReferenceProblem +> = Object.freeze({ + contract: Object.freeze({ + operationId: "CREATE_REFERENCE_RESOURCE", + method: "POST" as const, + pathTemplate: "/api/reference-resources", + inputValidator: zodValidator( + "CreateReferenceResourceCommand", + createCommandSchema, + ), + outputValidator: zodValidator( + "ReferenceResourcePayload", + referenceResourceDto, + ), + problemValidator: PROBLEM_VALIDATOR, + acceptedStatuses: Object.freeze([200, 201]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "KEYED" as const, + requestBody: "JSON" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: Object.freeze({ + mode: "IDEMPOTENCY_REPLAY" as const, + operationIdentityField: "idempotencyKey", + }), + commandEffect: CREATE_EFFECT, + projectRequest(input: z.output) { + return Object.freeze({ + pathValues: Object.freeze({}), + queryEntries: Object.freeze([]), + body: Object.freeze({ ...input }), + }); + }, + }), + frontend: Object.freeze({ + policyId: "REFERENCE_CREATE_V1", + requestByteLimit: 32_768, + responseByteLimit: 32_768, + totalDeadlineMs: 10_000, + /** + * §8.3. A KEYED command has no automatic retry after a dispatched attempt + * lost its response; the fixture therefore declares a zero budget rather + * than relying on a runtime special case. + */ + retryBudget: 0 as const, + authProfileId: "REFERENCE_EXTERNAL_BEARER", + diagnosticsOperation: "reference.create", + }), +}); + +export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution = + Object.freeze({ + contributionId: "reference-feature-http-v1", + featureId: REFERENCE_FEATURE_ID, + source: Object.freeze({ + kind: "TEMPLATE_FIXTURE" as const, + fixtureId: "REFERENCE_FEATURE_V1" as const, + revision: 1 as const, + }), + http: Object.freeze([ + LIST_REFERENCE_RESOURCES, + GET_REFERENCE_RESOURCE, + CREATE_REFERENCE_RESOURCE, + ]) as readonly InstalledHttpContract[], + events: Object.freeze([]), + }); diff --git a/src/features/reference-feature/contracts/reference-feature-contract.ts b/src/features/reference-feature/contracts/reference-feature-contract.ts new file mode 100644 index 0000000..3bac006 --- /dev/null +++ b/src/features/reference-feature/contracts/reference-feature-contract.ts @@ -0,0 +1,257 @@ +import { defineQueryNamespaceIdentity } from "../../../contracts/query-keys.ts"; +import { defineQueryInvalidationTopic } from "../../../contracts/query-invalidation.ts"; +import { defineRestOperation } from "../../../contracts/api-operations.ts"; +import { REFERENCE_RUNTIME_SCHEMA_CODECS } from "./reference-schemas.ts"; +import { REFERENCE_BOUNDARY_MAPPERS } from "./reference-mapper.ts"; + +export const REFERENCE_FEATURE_ID = "reference-feature"; +export const REFERENCE_RESOURCE_QUERY_NAMESPACE = defineQueryNamespaceIdentity( + "reference-resource", + 1, +); +export const REFERENCE_RESOURCE_INVALIDATION_TOPIC = + defineQueryInvalidationTopic("qinv.01k10f7m3w9p6r2c8v5n4x"); + +export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ + featureId: REFERENCE_FEATURE_ID, + runtimeSchemas: REFERENCE_RUNTIME_SCHEMA_CODECS, + mappers: REFERENCE_BOUNDARY_MAPPERS, + schemas: Object.freeze({ + ReferenceResourceParams: Object.freeze({ + schemaId: "ReferenceResourceParams", + boundary: "route-params", + owner: "feature-frontend-reference-feature-vertical-slice", + runtime: "zod", + schemaVersion: 1, + direction: "REQUEST", + unknownFieldPolicy: "REJECT_UNKNOWN", + }), + ReferenceResourceListQuery: Object.freeze({ + schemaId: "ReferenceResourceListQuery", + boundary: "route-search-api-request", + owner: "feature-frontend-reference-feature-vertical-slice", + runtime: "zod", + schemaVersion: 1, + direction: "REQUEST", + unknownFieldPolicy: "REJECT_UNKNOWN", + }), + CreateReferenceResourceCommand: Object.freeze({ + schemaId: "CreateReferenceResourceCommand", + boundary: "api-request", + owner: "feature-frontend-reference-feature-vertical-slice", + runtime: "zod", + schemaVersion: 1, + direction: "REQUEST", + unknownFieldPolicy: "REJECT_UNKNOWN", + }), + NoRequest: Object.freeze({ + schemaId: "NoRequest", + boundary: "api-request", + owner: "feature-frontend-reference-feature-vertical-slice", + runtime: "zod", + schemaVersion: 1, + direction: "REQUEST", + unknownFieldPolicy: "REJECT_UNKNOWN", + }), + ReferenceResourceListPayload: Object.freeze({ + schemaId: "ReferenceResourceListPayload", + boundary: "api-response", + owner: "feature-frontend-reference-feature-vertical-slice", + runtime: "zod", + schemaVersion: 1, + direction: "RESPONSE", + unknownFieldPolicy: "STRIP_UNKNOWN", + }), + ReferenceResourcePayload: Object.freeze({ + schemaId: "ReferenceResourcePayload", + boundary: "api-response", + owner: "feature-frontend-reference-feature-vertical-slice", + runtime: "zod", + schemaVersion: 1, + direction: "RESPONSE", + unknownFieldPolicy: "STRIP_UNKNOWN", + }), + }), + routes: Object.freeze({ + REFERENCE_RESOURCE_LIST: Object.freeze({ + routeId: "REFERENCE_RESOURCE_LIST", + path: "/examples/reference-resources", + paramsSchema: null, + searchSchema: "ReferenceResourceListQuery", + access: "session-required", + loadingSurface: "reference-resource-list", + errorSurface: "feature-boundary", + chunkId: "route-reference-resources", + title: "Reference feature", + navigationLabel: "Reference feature", + navigationOrder: 50, + }), + REFERENCE_RESOURCE_DETAIL: Object.freeze({ + routeId: "REFERENCE_RESOURCE_DETAIL", + path: "/examples/reference-resources/:resourceId", + paramsSchema: "ReferenceResourceParams", + searchSchema: null, + access: "session-required", + loadingSurface: "reference-resource-detail", + errorSurface: "feature-boundary", + chunkId: "route-reference-resource-detail", + title: "Reference detail", + navigationLabel: null, + navigationOrder: null, + }), + REFERENCE_RESOURCE_FORM: Object.freeze({ + routeId: "REFERENCE_RESOURCE_FORM", + path: "/examples/reference-resources/new", + paramsSchema: null, + searchSchema: null, + access: "session-required", + loadingSurface: "reference-resource-form", + errorSurface: "feature-boundary", + chunkId: "route-reference-resource-form", + title: "Reference form", + navigationLabel: null, + navigationOrder: null, + }), + REFERENCE_RESOURCE_STATUS: Object.freeze({ + routeId: "REFERENCE_RESOURCE_STATUS", + path: "/examples/reference-resources/status", + paramsSchema: null, + searchSchema: null, + access: "session-required", + loadingSurface: "reference-resource-status", + errorSurface: "feature-boundary", + chunkId: "route-reference-resource-status", + title: "Reference status", + navigationLabel: null, + navigationOrder: null, + }), + }), + routeRuntimeContracts: Object.freeze({ + REFERENCE_RESOURCE_LIST: Object.freeze({ + routeId: "REFERENCE_RESOURCE_LIST", + moduleId: "reference-resource-page", + paramsCodec: "none", + searchCodec: "ReferenceResourceListQuery", + }), + REFERENCE_RESOURCE_DETAIL: Object.freeze({ + routeId: "REFERENCE_RESOURCE_DETAIL", + moduleId: "reference-resource-detail-page", + paramsCodec: "ReferenceResourceParams", + searchCodec: "none", + }), + REFERENCE_RESOURCE_FORM: Object.freeze({ + routeId: "REFERENCE_RESOURCE_FORM", + moduleId: "reference-resource-form-page", + paramsCodec: "none", + searchCodec: "none", + }), + REFERENCE_RESOURCE_STATUS: Object.freeze({ + routeId: "REFERENCE_RESOURCE_STATUS", + moduleId: "reference-resource-status-page", + paramsCodec: "none", + searchCodec: "none", + }), + }), + apiOperations: Object.freeze({ + LIST_REFERENCE_RESOURCES: defineRestOperation({ + method: "GET", + path: "/api/reference-resources", + operationId: "LIST_REFERENCE_RESOURCES", + auth: "external-session", + timeoutMs: null, + idempotency: "safe", + retry: "runtime", + requestSource: "search", + requestSchema: "ReferenceResourceListQuery", + responseSchema: "ReferenceResourceListPayload", + owner: "feature-frontend-reference-feature-vertical-slice", + contractVersion: 2, + protocol: "REST", + semantics: "QUERY", + replayPolicy: "SAFE", + idempotencyKeyPolicy: "NONE", + mapperId: "ReferenceResourceListMapper", + successStatuses: [200], + responseMediaTypes: ["application/json"], + maxResponseBytes: 262_144, + providerId: "PRIMARY_API", + authProfileId: "REFERENCE_EXTERNAL_BEARER", + csrfProfileId: "NO_CSRF_BEARER", + pathSchema: "NoRequest", + pathParameterNames: [], + maxEncodedSearchBytes: 4_096, + }), + CREATE_REFERENCE_RESOURCE: defineRestOperation({ + method: "POST", + path: "/api/reference-resources", + operationId: "CREATE_REFERENCE_RESOURCE", + auth: "external-session", + timeoutMs: null, + idempotency: "keyed", + retry: "runtime", + requestSource: "body", + requestSchema: "CreateReferenceResourceCommand", + responseSchema: "ReferenceResourcePayload", + owner: "feature-frontend-reference-feature-vertical-slice", + contractVersion: 2, + protocol: "REST", + semantics: "COMMAND", + replayPolicy: "KEYED_COMMAND", + idempotencyKeyPolicy: "REQUIRED", + mapperId: "ReferenceResourceMapper", + successStatuses: [200, 201], + responseMediaTypes: ["application/json"], + maxResponseBytes: 32_768, + providerId: "PRIMARY_API", + authProfileId: "REFERENCE_EXTERNAL_BEARER", + csrfProfileId: "NO_CSRF_BEARER", + pathSchema: "NoRequest", + pathParameterNames: [], + maxEncodedSearchBytes: 0, + }), + GET_REFERENCE_RESOURCE: defineRestOperation({ + method: "GET", + path: "/api/reference-resources/{resourceId}", + operationId: "GET_REFERENCE_RESOURCE", + auth: "external-session", + timeoutMs: null, + idempotency: "safe", + retry: "runtime", + requestSource: "none", + requestSchema: "NoRequest", + responseSchema: "ReferenceResourcePayload", + owner: "feature-frontend-reference-feature-vertical-slice", + contractVersion: 2, + protocol: "REST", + semantics: "QUERY", + replayPolicy: "SAFE", + idempotencyKeyPolicy: "NONE", + mapperId: "ReferenceResourceMapper", + successStatuses: [200], + responseMediaTypes: ["application/json"], + maxResponseBytes: 32_768, + providerId: "PRIMARY_API", + authProfileId: "REFERENCE_EXTERNAL_BEARER", + csrfProfileId: "NO_CSRF_BEARER", + pathSchema: "ReferenceResourceParams", + pathParameterNames: ["resourceId"], + maxEncodedSearchBytes: 0, + }), + }), + invalidation: Object.freeze({ + topics: Object.freeze([REFERENCE_RESOURCE_INVALIDATION_TOPIC]), + namespaces: Object.freeze([REFERENCE_RESOURCE_QUERY_NAMESPACE]), + edges: Object.freeze([ + Object.freeze({ + topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC, + namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE, + }), + ]), + }), + topicVersions: Object.freeze([ + Object.freeze({ + topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC, + topicVersion: 1, + }), + ]), +} as const); diff --git a/src/features/reference-feature/contracts/reference-mapper.ts b/src/features/reference-feature/contracts/reference-mapper.ts new file mode 100644 index 0000000..11b226e --- /dev/null +++ b/src/features/reference-feature/contracts/reference-mapper.ts @@ -0,0 +1,97 @@ +import { + createReferenceResource, + type ReferenceResource, +} from "../domain/reference-resource.ts"; +import { + mappingFailure, + mappingSuccess, + type MappingResult, + type InstalledBoundaryMapper, +} from "../../../contracts/boundary-mapper.ts"; + +export type ReferenceResourceView = Readonly<{ + resourceId: string; + title: string; + createdAt: string | null; + optimistic?: boolean; +}>; + +function mapReferenceDto(value: unknown): MappingResult { + if (!value || typeof value !== "object") { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); + } + const dto = value as Record; + if (typeof dto.id !== "string" || typeof dto.name !== "string") { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); + } + try { + return mappingSuccess( + createReferenceResource({ + id: dto.id, + displayName: dto.name, + createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null, + }), + ); + } catch { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); + } +} + +export function mapReferenceOperation( + operationId: string, + payload: unknown, +): MappingResult { + if (operationId === "LIST_REFERENCE_RESOURCES") { + if (!Array.isArray(payload)) { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); + } + if (payload.length > 100) return mappingFailure("OUTPUT_LIMIT_EXCEEDED"); + const output: ReferenceResource[] = []; + for (const item of payload) { + const mapped = mapReferenceDto(item); + if (!mapped.ok) return mapped; + output.push(mapped.value); + } + return mappingSuccess(Object.freeze(output)); + } + if ( + operationId === "CREATE_REFERENCE_RESOURCE" || + operationId === "GET_REFERENCE_RESOURCE" + ) { + return mapReferenceDto(payload); + } + return mappingFailure("MAPPING_INVARIANT_REJECTED"); +} + +export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({ + ReferenceResourceListMapper: Object.freeze({ + mapperId: "ReferenceResourceListMapper", + mapperVersion: 1, + inputSchemaId: "ReferenceResourceListPayload", + outputContractId: "ReferenceResourceList", + owner: "feature-frontend-reference-feature-vertical-slice", + maxOutputItems: 100, + map: (input: unknown) => + mapReferenceOperation("LIST_REFERENCE_RESOURCES", input), + }), + ReferenceResourceMapper: Object.freeze({ + mapperId: "ReferenceResourceMapper", + mapperVersion: 1, + inputSchemaId: "ReferenceResourcePayload", + outputContractId: "ReferenceResource", + owner: "feature-frontend-reference-feature-vertical-slice", + maxOutputItems: 1, + map: (input: unknown) => + mapReferenceOperation("GET_REFERENCE_RESOURCE", input), + }), +} satisfies Readonly>); + +export function toReferenceView( + resource: ReferenceResource, +): ReferenceResourceView { + return Object.freeze({ + resourceId: resource.id, + title: resource.displayName, + createdAt: resource.createdAt, + }); +} diff --git a/src/features/reference-feature/contracts/reference-message-catalog.ts b/src/features/reference-feature/contracts/reference-message-catalog.ts new file mode 100644 index 0000000..a7880c9 --- /dev/null +++ b/src/features/reference-feature/contracts/reference-message-catalog.ts @@ -0,0 +1,22 @@ +export const REFERENCE_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": Object.freeze({ + "route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature", + "route.REFERENCE_RESOURCE_LIST.title": "Reference resources", + "route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail", + "route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail", + "route.REFERENCE_RESOURCE_FORM.navigation": "Reference form", + "route.REFERENCE_RESOURCE_FORM.title": "Create reference resource", + "route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status", + "route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status", + }), + "en-US": Object.freeze({ + "route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature", + "route.REFERENCE_RESOURCE_LIST.title": "Reference resources", + "route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail", + "route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail", + "route.REFERENCE_RESOURCE_FORM.navigation": "Reference form", + "route.REFERENCE_RESOURCE_FORM.title": "Create reference resource", + "route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status", + "route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status", + }), +} as const); diff --git a/src/features/reference-feature/contracts/reference-schemas.ts b/src/features/reference-feature/contracts/reference-schemas.ts new file mode 100644 index 0000000..2300a49 --- /dev/null +++ b/src/features/reference-feature/contracts/reference-schemas.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import type { RuntimeSchemaCodec } from "../../../contracts/schema-registry.ts"; + +export const referenceResourceListQuerySchema = z + .object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + tags: z + .preprocess( + (value) => + value === undefined + ? undefined + : Array.isArray(value) + ? value + : [value], + z.array(z.string().trim().min(1)), + ) + .optional(), + }) + .strict(); + +export const referenceResourceParamsSchema = z + .object({ + resourceId: z.string().trim().min(1).max(120), + }) + .strict(); + +const referenceResourceDtoSchema = z + .object({ + id: z.string().min(1).max(120), + name: z.string().min(1).max(240), + createdAt: z.string().datetime().optional(), + }) + .strip(); + +const payloadSchemas = { + ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100), + ReferenceResourcePayload: referenceResourceDtoSchema, +} satisfies Record; + +const requestSchemas = { + ReferenceResourceParams: referenceResourceParamsSchema, + ReferenceResourceListQuery: referenceResourceListQuerySchema, + NoRequest: z.object({}).strict(), + CreateReferenceResourceCommand: z + .object({ + name: z.string().trim().min(1).max(120), + note: z.string().trim().max(500).optional(), + }) + .strict(), +} satisfies Record; + +function project(result: z.ZodSafeParseResult) { + return result.success + ? ({ success: true as const, data: structuredClone(result.data) }) + : ({ + success: false as const, + issues: result.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })), + }); +} + +function codec( + schemaId: string, + schema: z.ZodType, +): RuntimeSchemaCodec { + return Object.freeze({ + schemaId, + parse: (value: unknown) => project(schema.safeParse(value)), + }); +} + +export const REFERENCE_RUNTIME_SCHEMA_CODECS = Object.freeze({ + ReferenceResourceParams: codec( + "ReferenceResourceParams", + referenceResourceParamsSchema, + ), + ReferenceResourceListQuery: codec( + "ReferenceResourceListQuery", + referenceResourceListQuerySchema, + ), + NoRequest: codec("NoRequest", requestSchemas.NoRequest), + CreateReferenceResourceCommand: codec( + "CreateReferenceResourceCommand", + requestSchemas.CreateReferenceResourceCommand, + ), + ReferenceResourceListPayload: codec( + "ReferenceResourceListPayload", + payloadSchemas.ReferenceResourceListPayload, + ), + ReferenceResourcePayload: codec( + "ReferenceResourcePayload", + payloadSchemas.ReferenceResourcePayload, + ), +}); + +export function validateReferencePayload(schemaId: string, value: unknown) { + const schema = payloadSchemas[schemaId as keyof typeof payloadSchemas]; + return schema + ? project(schema.safeParse(value)) + : { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] }; +} + +export function validateReferenceRequest(schemaId: string, value: unknown) { + const schema = requestSchemas[schemaId as keyof typeof requestSchemas]; + return schema + ? project(schema.safeParse(value)) + : { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] }; +} diff --git a/src/features/reference-feature/domain/reference-resource.ts b/src/features/reference-feature/domain/reference-resource.ts new file mode 100644 index 0000000..4aba209 --- /dev/null +++ b/src/features/reference-feature/domain/reference-resource.ts @@ -0,0 +1,14 @@ +export type ReferenceResource = Readonly<{ + id: string; + displayName: string; + createdAt: string | null; +}>; + +export function createReferenceResource( + values: ReferenceResource, +): ReferenceResource { + if (!values.id || !values.displayName) { + throw new TypeError("Reference resource invariants require id and displayName"); + } + return Object.freeze({ ...values }); +} diff --git a/src/features/reference-feature/presentation/reference-feature-runtime.tsx b/src/features/reference-feature/presentation/reference-feature-runtime.tsx new file mode 100644 index 0000000..5e2055c --- /dev/null +++ b/src/features/reference-feature/presentation/reference-feature-runtime.tsx @@ -0,0 +1,30 @@ +import { lazy } from "react"; + +import { + referenceResourceListQuerySchema, + referenceResourceParamsSchema, +} from "../contracts/reference-schemas.ts"; + +export const REFERENCE_FEATURE_ROUTE_CODECS = { + ReferenceResourceListQuery: referenceResourceListQuerySchema, + ReferenceResourceParams: referenceResourceParamsSchema, +} as const; + +export const REFERENCE_FEATURE_ROUTE_RUNTIME = { + REFERENCE_RESOURCE_LIST: Object.freeze({ + moduleId: "reference-resource-page", + Component: lazy(() => import("./reference-resource-page.tsx")), + }), + REFERENCE_RESOURCE_DETAIL: Object.freeze({ + moduleId: "reference-resource-detail-page", + Component: lazy(() => import("./reference-resource-detail-page.tsx")), + }), + REFERENCE_RESOURCE_FORM: Object.freeze({ + moduleId: "reference-resource-form-page", + Component: lazy(() => import("./reference-resource-form-page.tsx")), + }), + REFERENCE_RESOURCE_STATUS: Object.freeze({ + moduleId: "reference-resource-status-page", + Component: lazy(() => import("./reference-resource-status-page.tsx")), + }), +} as const; diff --git a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx new file mode 100644 index 0000000..008bd25 --- /dev/null +++ b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx @@ -0,0 +1,61 @@ +import { Link } from "react-router-dom"; + +import { + AsyncSurface, + DetailPage, +} from "../../../presentation/design-system/index.ts"; +import { useRouteInput } from "../../../presentation/routes/route-input.tsx"; +import { useLocale } from "../../../presentation/i18n/index.ts"; +import { useReferenceFailureAction } from "./use-reference-failure-action.ts"; +import { useReferenceDetail } from "./use-reference-feature.ts"; + +export default function ReferenceResourceDetailPage() { + const { date, message } = useLocale(); + const route = useRouteInput(); + const resourceId = String(route.params.resourceId); + const { query } = useReferenceDetail(resourceId); + const resource = query.data; + const failureAction = useReferenceFailureAction(query.state.failure); + + return ( + Reference resources + } + heading={{ + eyebrow: "DetailPage", + title: resource?.title ?? "Reference detail", + description: "route param과 detail query의 reset 경계를 확인합니다.", + }} + metadata={ + resource ? ( +
+
Resource ID
+
{resource.resourceId}
+
Created
+
+ {resource.createdAt + ? date(new Date(resource.createdAt)) + : message("common.noDisplayValue")} +
+
+ ) : ( +

요약 정보를 준비하고 있습니다.

+ ) + } + feedback={ + + {resource ? ( +

이 영역에는 제품별 상세 section을 조립할 수 있습니다.

+ ) : null} +
+ } + aside={

상세 페이지의 관련 정보 slot입니다.

} + /> + ); +} diff --git a/src/features/reference-feature/presentation/reference-resource-form-page.tsx b/src/features/reference-feature/presentation/reference-resource-form-page.tsx new file mode 100644 index 0000000..599bdd6 --- /dev/null +++ b/src/features/reference-feature/presentation/reference-resource-form-page.tsx @@ -0,0 +1,158 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { + Button, + AsyncSurface, + DirtyNavigationDialog, + ErrorSummary, + Form, + FormActions, + FormPage, + FormField, + useAppForm, + useDirtyNavigationGuard, +} from "../../../presentation/design-system/index.ts"; +import { + REFERENCE_FORM_DEFAULTS, + referenceResourceFormSchema, + toCreateReferenceCommand, + type ReferenceResourceFormValues, +} from "./reference-resource-form.ts"; +import { useReferenceCreate } from "./use-reference-feature.ts"; + +const FIELD_LABELS = Object.freeze({ + name: "새 항목 이름", + note: "설명", +}) satisfies Record; + +export default function ReferenceResourceFormPage() { + const navigate = useNavigate(); + const mutation = useReferenceCreate(); + const submit = useCallback( + (command: ReturnType) => + mutation.submit(command), + [mutation], + ); + const form = useAppForm({ + schema: referenceResourceFormSchema, + defaultValues: REFERENCE_FORM_DEFAULTS, + allowedServerFields: ["name", "note"], + mapToCommand: toCreateReferenceCommand, + submit, + }); + const mutationEffectUnknown = + mutation.state.indicator === "mutation-effect-unknown"; + const mutationBlocked = + mutationEffectUnknown || mutation.state.indicator === "mutation-pending"; + const guard = useDirtyNavigationGuard(form.dirty && !form.pending); + + return ( +
{ + if (mutationBlocked) { + event.preventDefault(); + return; + } + void form.submitForm(event); + }} + > + navigate("/examples/reference-resources")} + > + 목록으로 돌아가기 + + } + heading={{ + eyebrow: "FormPage", + title: "Reference resource 만들기", + description: + "presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.", + }} + errorSummary={ + + } + fields={ + <> + + + + } + formActions={ + + + + + + } + feedback={ + mutationEffectUnknown ? ( + { + void mutation.reconcileUnknownEffect(resolution).then(() => { + if (resolution === "APPLIED") { + form.settleApplied(); + } else { + form.settleNotApplied(); + } + }); + }} + /> + ) : form.result === "success" ? ( +

저장했습니다.

+ ) : form.result === "conflict" ? ( +

충돌을 해결한 뒤 다시 제출할 수 있습니다.

+ ) : null + } + aside={ +

+ form value는 URL, storage, telemetry에 저장되지 않고 submit 시에만 + application command로 변환됩니다. +

+ } + guard={} + /> + + ); +} diff --git a/src/features/reference-feature/presentation/reference-resource-form.ts b/src/features/reference-feature/presentation/reference-resource-form.ts new file mode 100644 index 0000000..059c596 --- /dev/null +++ b/src/features/reference-feature/presentation/reference-resource-form.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const referenceResourceFormSchema = z + .object({ + name: z + .string() + .trim() + .min(2, "이름은 두 글자 이상이어야 합니다.") + .max(120), + note: z.string().trim().max(500).default(""), + }) + .strict(); + +export type ReferenceResourceFormValues = z.infer< + typeof referenceResourceFormSchema +>; + +export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues = + Object.freeze({ + name: "", + note: "", + }); + +export function toCreateReferenceCommand(values: ReferenceResourceFormValues) { + return Object.freeze({ + name: values.name, + ...(values.note ? { note: values.note } : {}), + }); +} diff --git a/src/features/reference-feature/presentation/reference-resource-page.tsx b/src/features/reference-feature/presentation/reference-resource-page.tsx new file mode 100644 index 0000000..d3ac363 --- /dev/null +++ b/src/features/reference-feature/presentation/reference-resource-page.tsx @@ -0,0 +1,61 @@ +import { Link, useNavigate } from "react-router-dom"; + +import { + AsyncSurface, + Button, + CollectionPage, +} from "../../../presentation/design-system/index.ts"; +import { useReferenceFailureAction } from "./use-reference-failure-action.ts"; +import { useReferenceFeature } from "./use-reference-feature.ts"; + +export default function ReferenceResourcePage() { + const navigate = useNavigate(); + const { filters, query } = useReferenceFeature(); + const failureAction = useReferenceFailureAction(query.state.failure); + + return ( + navigate("/examples/reference-resources/new"), + }, + ]} + activeFilters={ +

+ limit {filters.limit} + {filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""} +

+ } + toolbar={} + resultCount={ + query.data ? `총 ${query.data.length}개 항목` : "결과 확인 중" + } + > + +
    + {(query.data ?? []).map((resource) => ( +
  • + + {resource.title} + +
  • + ))} +
+
+
+ ); +} diff --git a/src/features/reference-feature/presentation/reference-resource-status-page.tsx b/src/features/reference-feature/presentation/reference-resource-status-page.tsx new file mode 100644 index 0000000..b98764a --- /dev/null +++ b/src/features/reference-feature/presentation/reference-resource-status-page.tsx @@ -0,0 +1,25 @@ +import { useNavigate } from "react-router-dom"; + +import { StatusPage } from "../../../presentation/design-system/index.ts"; + +export default function ReferenceResourceStatusPage() { + const navigate = useNavigate(); + + return ( + navigate("/examples/reference-resources"), + }} + supportReference="REFERENCE-STATUS-DEMO" + /> + ); +} diff --git a/src/features/reference-feature/presentation/use-reference-failure-action.ts b/src/features/reference-feature/presentation/use-reference-failure-action.ts new file mode 100644 index 0000000..3ef131e --- /dev/null +++ b/src/features/reference-feature/presentation/use-reference-failure-action.ts @@ -0,0 +1,50 @@ +import { useCallback } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import type { AppFailure } from "../../../contracts/errors.ts"; +import { useSession } from "../../../presentation/providers/session-provider.tsx"; + +const REFERENCE_SUPPORT_ROUTE = "/examples/reference-resources/status"; + +/** + * Reference queries own concrete destinations for generic application failure + * actions. Retry remains query-owned; guarded release reloads remain in the + * chunk recovery boundary. + */ +export function useReferenceFailureAction( + failure: AppFailure | undefined, +): (() => void) | undefined { + const location = useLocation(); + const navigate = useNavigate(); + const { beginSignIn } = useSession(); + const action = failure?.action; + const handleAction = useCallback(() => { + if (action === "reauth") { + const returnTo = `${location.pathname}${location.search}${location.hash}`; + void beginSignIn(returnTo).catch(() => { + void navigate("/"); + }); + return; + } + if (action === "navigate") { + void navigate("/"); + return; + } + if (action === "contact-support") { + void navigate(REFERENCE_SUPPORT_ROUTE); + } + }, [ + action, + beginSignIn, + location.hash, + location.pathname, + location.search, + navigate, + ]); + + return action === "reauth" || + action === "navigate" || + action === "contact-support" + ? handleAction + : undefined; +} diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts new file mode 100644 index 0000000..64ba9e3 --- /dev/null +++ b/src/features/reference-feature/presentation/use-reference-feature.ts @@ -0,0 +1,138 @@ +import { useApplication } from "../../../presentation/providers/application-provider.tsx"; +import { + useApplicationMutation, + useApplicationQuery, +} from "../../../presentation/adapters/query/application-query.ts"; +import { useRouteInput } from "../../../presentation/routes/route-input.tsx"; +import { + REFERENCE_FEATURE_ID, + REFERENCE_RESOURCE_INVALIDATION_TOPIC, + REFERENCE_RESOURCE_QUERY_NAMESPACE, +} from "../contracts/reference-feature-contract.ts"; +import type { + ReferenceCreateCommand, + ReferenceFeatureInput, + ReferenceListFilters, +} from "../application/reference-feature-api.ts"; +import type { ReferenceResourceView } from "../contracts/reference-mapper.ts"; +import { + bindQuery, + type BoundMutation, + type QueryResultMeasure, +} from "../../../contracts/server-state.ts"; +import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx"; + +const UTF8 = new TextEncoder(); + +/** + * §10.4. Feature-owned measurement over the mapped application value. There is + * no generic fallback: bounded string bytes plus fixed primitive width plus a + * small per-item overhead, never `JSON.stringify` or a recursive walker. + */ +function measureResourceView(view: ReferenceResourceView): QueryResultMeasure { + return { + itemCount: 1, + estimatedBytes: + UTF8.encode(view.resourceId).byteLength + + UTF8.encode(view.title).byteLength + + UTF8.encode(view.createdAt ?? "").byteLength + + 32, + }; +} + +function measureResourceList( + views: readonly ReferenceResourceView[], +): QueryResultMeasure { + let estimatedBytes = 16; + for (const view of views) { + estimatedBytes += measureResourceView(view).estimatedBytes; + } + return { itemCount: views.length, estimatedBytes }; +} + +export function useReferenceFeatureInput(): ReferenceFeatureInput { + return useApplication().features.get(REFERENCE_FEATURE_ID); +} + +export function useReferenceDetail(resourceId: string) { + const input = useReferenceFeatureInput(); + const scope = useServerStateScope(); + const query = useApplicationQuery( + bindQuery( + { + definitionId: "reference-resource-detail-v1", + definitionVersion: 1, + owner: REFERENCE_FEATURE_ID, + namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId, + namespaceVersion: + REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion, + operationId: "GET_REFERENCE_RESOURCE", + profileId: "DETAIL_STANDARD", + measureResult: measureResourceView, + execute: (selectedResourceId: string, { signal }) => + input.getResource(selectedResourceId, { signal }), + }, + resourceId, + scope, + ), + ); + return Object.freeze({ query }); +} + +export function useReferenceCreate() { + const input = useReferenceFeatureInput(); + const scope = useServerStateScope(); + const mutation: BoundMutation< + ReferenceCreateCommand, + ReferenceResourceView + > = { + definitionId: "reference-resource-create-v1", + definitionVersion: 1, + operationId: "CREATE_REFERENCE_RESOURCE", + requiresIdempotencyKey: true, + owner: REFERENCE_FEATURE_ID, + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute: input.createResource, + invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC], + }; + return useApplicationMutation(mutation); +} + +export function useReferenceFeature() { + const input = useReferenceFeatureInput(); + const scope = useServerStateScope(); + const routeInput = useRouteInput(); + const filters = routeInput.search as ReferenceListFilters; + const query = useApplicationQuery( + bindQuery( + { + definitionId: "reference-resource-list-v1", + definitionVersion: 1, + owner: REFERENCE_FEATURE_ID, + namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId, + namespaceVersion: + REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion, + operationId: "LIST_REFERENCE_RESOURCES", + profileId: "LIST_STANDARD", + measureResult: measureResourceList, + execute: (selectedFilters: ReferenceListFilters, { signal }) => + input.listResources(selectedFilters, { signal }), + }, + filters, + scope, + ), + ); + const mutation = useApplicationMutation({ + definitionId: "reference-resource-create-v1", + definitionVersion: 1, + operationId: "CREATE_REFERENCE_RESOURCE", + requiresIdempotencyKey: true, + owner: REFERENCE_FEATURE_ID, + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute: input.createResource, + invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC], + }); + return Object.freeze({ filters, query, mutation }); +} diff --git a/src/presentation/adapters/query/application-query.ts b/src/presentation/adapters/query/application-query.ts new file mode 100644 index 0000000..98a4432 --- /dev/null +++ b/src/presentation/adapters/query/application-query.ts @@ -0,0 +1,844 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; +import { + hashKey, + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { + deriveAsyncState, + type AsyncState, +} from "../../../application/view-models/async-state.ts"; +import type { Result } from "../../../application/result.ts"; +import { + createFailure, + normalizeUnknownFailure, + withFailureEffect, + type AppFailure, +} from "../../../contracts/errors.ts"; +import type { + QueryInvalidationCoordinator, + QueryInvalidationTopic, +} from "../../../contracts/query-invalidation.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; +import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts"; +import { + admitQueryResult, + type BoundMutation, + type BoundQuery, + MUTATION_COORDINATOR_BOUNDS, + type MutationDuplicatePolicy, +} from "../../../contracts/server-state.ts"; +import { runtimeIdentityToken } from "../../../contracts/query-keys.ts"; +import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx"; +import { useMutationIntentFactory } from "./mutation-intent-provider.tsx"; +import { + createOptimisticLayerRuntime, + type OptimisticLayerLease, +} from "./optimistic-layer-runtime.ts"; + +export type ApplicationResult = Result; + +class ApplicationQueryError extends Error { + readonly failure: AppFailure; + + constructor(failure: AppFailure) { + super(failure.kind); + this.name = "ApplicationQueryError"; + this.failure = failure; + } +} + +export function useApplicationQuery( + options: + | BoundQuery + | Readonly<{ + queryKey: readonly unknown[]; + execute(context: Readonly<{ signal: AbortSignal }>): Promise< + ApplicationResult + >; + enabled?: boolean; + }>, +): Readonly<{ + data: Value | undefined; + state: AsyncState; + retry(): Promise; +}> { + const { queryKey, execute } = options; + const enabled = "enabled" in options ? options.enabled ?? true : true; + const profile = "profile" in options ? options.profile : undefined; + const scope = "scope" in options ? options.scope : undefined; + const identity = "identity" in options ? options.identity : undefined; + const measureResult = + "measureResult" in options ? options.measureResult : undefined; + const queryDefinitionId = + "definitionId" in options ? options.definitionId : "APPLICATION_QUERY"; + const [staleFailure, setStaleFailure] = useState(false); + useEffect(() => { + identity?.acquire(); + return () => identity?.release(); + }, [identity]); + const query = useQuery({ + queryKey, + enabled, + retry: false, + staleTime: profile?.staleTimeMs, + gcTime: profile?.gcTimeMs, + refetchOnMount: profile?.refetchOnMount, + refetchOnWindowFocus: profile?.refetchOnFocus, + refetchOnReconnect: profile?.refetchOnReconnect, + queryFn: async ({ signal }) => { + identity?.acquire(); + try { + if (scope && !scope.isCurrent()) { + throw new ApplicationQueryError( + createFailure( + "SCOPE_GENERATION_CHANGED", + queryDefinitionId, + 0, + { code: "QUERY_SCOPE_STALE" }, + ), + ); + } + const result = await execute({ signal }); + if (scope && !scope.isCurrent()) { + throw new ApplicationQueryError( + createFailure( + "SCOPE_GENERATION_CHANGED", + queryDefinitionId, + 0, + { code: "QUERY_SCOPE_CHANGED" }, + ), + ); + } + if (result.ok) { + if (profile && measureResult) { + const admission = admitQueryResult( + measureResult, + result.value, + profile, + ); + if (!admission.ok) { + throw new ApplicationQueryError( + createFailure( + "RESULT_LIMIT_EXCEEDED", + queryDefinitionId, + 0, + { code: admission.code }, + ), + ); + } + } + return result.value; + } + if (signal.aborted) { + throw new DOMException("Query cancelled", "AbortError"); + } + throw new ApplicationQueryError(result.error); + } catch (error) { + if (signal.aborted) { + throw new DOMException("Query cancelled", "AbortError"); + } + if (error instanceof ApplicationQueryError) throw error; + throw new ApplicationQueryError( + normalizeUnknownFailure(error, { + operationId: "APPLICATION_QUERY", + }), + ); + } finally { + identity?.release(); + } + }, + }); + const hasData = query.data !== undefined && query.data !== null; + + useEffect(() => { + if (query.isError && hasData) { + setStaleFailure(true); + } else if (query.isSuccess && !query.isFetching) { + setStaleFailure(false); + } + }, [hasData, query.isError, query.isFetching, query.isSuccess]); + + const retry = useCallback(async () => { + setStaleFailure(false); + await query.refetch(); + }, [query]); + + return Object.freeze({ + data: query.data, + state: deriveAsyncState({ + data: query.data, + isInitialLoading: query.isPending, + failure: + !hasData && query.error instanceof ApplicationQueryError + ? query.error.failure + : undefined, + isFetching: query.isFetching && !query.isPending, + isStale: staleFailure, + isDegraded: staleFailure, + }), + retry, + }); +} + +type LegacyMutationBase = Readonly<{ + execute(input: Input): Promise>; + duplicatePolicy?: MutationDuplicatePolicy; + invalidate?: readonly QueryInvalidationTopic[]; + currentData?: unknown; +}>; + +type LegacyMutationOptions = LegacyMutationBase & + ( + | Readonly<{ + /** Stable logical identity required to retain an unknown effect across remounts. */ + definitionId: string; + optimistic?: never; + }> + | Readonly<{ + definitionId?: string; + optimistic: Readonly<{ + queryKey: readonly unknown[]; + update(previous: unknown, input: Input): unknown; + }>; + }> + ); + +type ApplicationMutationController = Readonly<{ + state: AsyncState; + submit(input: Input): Promise>; + resolveConflict(): Promise; + reconcileUnknownEffect( + resolution: "APPLIED" | "NOT_APPLIED", + ): Promise; +}>; + +type MutationExecution = + | Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }> + | Readonly<{ kind: "LEGACY"; input: Input }>; + +type MutationAdmission = { + sequence: number; + state: "ACTIVE" | "UNKNOWN" | "RECONCILING" | "SETTLED"; + intent: MutationIntent | null; + scope: CacheScopeSnapshot | null; + optimisticLayer: OptimisticLayerLease | null; + optimisticQueryKey: readonly unknown[] | null; + readonly invalidate: readonly QueryInvalidationTopic[]; + invalidationCoordinator: QueryInvalidationCoordinator | null; +}; + +type UnknownEffectChannel = { + readonly key: string; + readonly registry: UnknownEffectRegistry; + readonly owner: QueryClient; + readonly scope: CacheScopeSnapshot | null; + nextSequence: number; + admissions: MutationAdmission[]; + version: number; + reconciliationInFlight: boolean; + readonly listeners: Set<() => void>; + disposeScopeListener: (() => void) | null; +}; + +type UnknownEffectRegistry = { + readonly channels: Map; + activeAdmissions: number; +}; + +const LEGACY_OPTIMISTIC_SCOPE = Object.freeze({ + isCurrent: () => true, +}); + +export function useApplicationMutation( + options: BoundMutation, +): ApplicationMutationController; +export function useApplicationMutation( + options: LegacyMutationOptions, +): ApplicationMutationController; +export function useApplicationMutation( + options: BoundMutation | LegacyMutationOptions, +): ApplicationMutationController { + const queryClient = useQueryClient(); + const invalidationCoordinator = useQueryInvalidationCoordinator(); + const mutationIntentFactory = useMutationIntentFactory(); + const invalidate = useMemo( + () => options.invalidate ?? [], + [options.invalidate], + ); + const optimistic = "optimistic" in options ? options.optimistic : undefined; + const currentData = "currentData" in options ? options.currentData : undefined; + const definitionId = + "definitionId" in options + ? options.definitionId ?? "LEGACY_MUTATION" + : "LEGACY_MUTATION"; + const definitionVersion = + "definitionVersion" in options ? options.definitionVersion : 0; + const duplicatePolicy = + "duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE"; + const [conflict, setConflict] = useState(null); + const scope = "scope" in options ? options.scope : undefined; + const mutationOperationId = + "operationId" in options ? options.operationId : definitionId; + const requiresIdempotencyKey = + "requiresIdempotencyKey" in options + ? options.requiresIdempotencyKey + : false; + const unknownEffectChannelKey = scope + ? `bound:${scope.generation}:${scope.fingerprint}:${definitionId}:${definitionVersion}` + : `legacy:${definitionId}:${ + optimistic + ? hashKey(optimistic.queryKey) + : "non-optimistic" + }`; + const [acquiredUnknownEffectChannel, setAcquiredUnknownEffectChannel] = + useState(null); + const unknownEffectChannel = + acquiredUnknownEffectChannel?.owner === queryClient && + acquiredUnknownEffectChannel.key === unknownEffectChannelKey && + acquiredUnknownEffectChannel.scope === (scope ?? null) + ? acquiredUnknownEffectChannel + : null; + useEffect(() => { + const acquired = acquireUnknownEffectChannel( + queryClient, + unknownEffectChannelKey, + scope, + ); + setAcquiredUnknownEffectChannel(acquired); + return () => { + if (acquired) { + releaseUnknownEffectChannelIfUnused(queryClient, acquired); + } + }; + }, [queryClient, scope, unknownEffectChannelKey]); + const subscribeToUnknownEffects = useCallback( + (listener: () => void) => { + if (!unknownEffectChannel) return () => {}; + unknownEffectChannel.listeners.add(listener); + return () => { + unknownEffectChannel.listeners.delete(listener); + releaseUnknownEffectChannelIfUnused( + queryClient, + unknownEffectChannel, + ); + }; + }, + [queryClient, unknownEffectChannel], + ); + useSyncExternalStore( + subscribeToUnknownEffects, + () => unknownEffectChannel?.version ?? 0, + () => unknownEffectChannel?.version ?? 0, + ); + const mutation = useMutation< + Value, + ApplicationQueryError, + MutationExecution + >({ + retry: false, + mutationFn: async (execution) => { + const input = execution.input; + if (scope && !scope.isCurrent()) { + throw new ApplicationQueryError( + createFailure( + "SCOPE_GENERATION_CHANGED", + definitionId, + 0, + { code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" }, + ), + ); + } + const result = + "scope" in options + ? await options.execute(input, { + signal: options.scope.signal, + intent: + execution.kind === "BOUND" + ? execution.intent + : (() => { + throw new TypeError( + "Bound mutation execution requires an intent.", + ); + })(), + }) + : await options.execute(input); + if (scope && !scope.isCurrent()) { + const effect = + !result.ok && result.error.effect !== undefined + ? result.error.effect + : "MAYBE_APPLIED"; + throw new ApplicationQueryError( + createFailure( + "SCOPE_GENERATION_CHANGED", + definitionId, + 0, + { code: "MUTATION_SCOPE_CHANGED", effect }, + ), + ); + } + if (result.ok) return result.value; + throw new ApplicationQueryError(result.error); + }, + }); + + const submit = useCallback( + (input: Input): Promise> => { + let identity: string; + let identityLease: ReturnType< + NonNullable["identities"]["intern"] + > | null = null; + try { + if (scope) { + if (!scope.isCurrent()) { + return Promise.resolve({ + ok: false, + error: createFailure( + "SCOPE_GENERATION_CHANGED", + definitionId, + 0, + { code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" }, + ), + }); + } + identityLease = scope.identities.intern(input); + identityLease.acquire(); + identity = `${scope.fingerprint}:${definitionId}:${identityLease.token}`; + } else { + identity = `${definitionId}:${runtimeIdentityToken(input)}`; + } + } catch (error) { + return Promise.resolve({ + ok: false, + error: withFailureEffect( + normalizeUnknownFailure(error, { + operationId: definitionId, + }), + "NOT_STARTED", + ), + }); + } + const active = mutationExecutions(queryClient).get(identity) as + | Promise> + | undefined; + if (active && duplicatePolicy === "JOIN_IDENTICAL") { + identityLease?.release(); + return active; + } + if (active && duplicatePolicy === "REJECT_WHILE_ACTIVE") { + identityLease?.release(); + return Promise.resolve({ + ok: false, + error: createFailure( + "DUPLICATE_IN_FLIGHT", + definitionId, + 0, + { code: "DUPLICATE_IN_FLIGHT", effect: "NOT_STARTED" }, + ), + }); + } + if ( + !unknownEffectChannel || + unknownEffectChannel.registry.activeAdmissions >= + MUTATION_COORDINATOR_BOUNDS.activeIntentsTotal + ) { + identityLease?.release(); + return Promise.resolve({ + ok: false, + error: createFailure( + "IDENTITY_INTERN_LIMIT_EXCEEDED", + definitionId, + 0, + { + code: "UNKNOWN_EFFECT_CHANNEL_LIMIT_EXCEEDED", + effect: "NOT_STARTED", + }, + ), + }); + } + const admission: MutationAdmission = { + sequence: unknownEffectChannel.nextSequence++, + state: "ACTIVE", + intent: null, + scope: scope ?? null, + optimisticLayer: null, + optimisticQueryKey: optimistic?.queryKey ?? null, + invalidate, + invalidationCoordinator: invalidationCoordinator ?? null, + }; + unknownEffectChannel.admissions.push(admission); + unknownEffectChannel.registry.activeAdmissions += 1; + notifyUnknownEffectChannel(unknownEffectChannel); + setConflict(null); + mutation.reset(); + + const pending = (async (): Promise> => { + let execution: MutationExecution; + if (scope) { + const intent = mutationIntentFactory.create({ + operationId: mutationOperationId, + canonicalInputIdentity: identity, + requiresIdempotencyKey, + }); + admission.intent = intent; + execution = Object.freeze({ kind: "BOUND", input, intent }); + } else { + execution = Object.freeze({ kind: "LEGACY", input }); + } + const mutationLease = + invalidate.length === 0 + ? null + : invalidationCoordinator?.beginMutation(invalidate); + if (invalidate.length > 0 && !mutationLease) { + throw new Error("Query invalidation coordinator is not installed."); + } + try { + let optimisticLayer: OptimisticLayerLease | null = null; + if (optimistic) { + await queryClient.cancelQueries({ + queryKey: optimistic.queryKey, + exact: true, + }); + optimisticLayer = optimisticLayers(queryClient).begin( + optimistic.queryKey, + input, + optimistic.update, + scope ?? LEGACY_OPTIMISTIC_SCOPE, + ); + admission.optimisticLayer = optimisticLayer; + } + + let value: Value; + try { + value = await mutation.mutateAsync(execution); + } catch (error: unknown) { + const reportedFailure = + error instanceof ApplicationQueryError + ? error.failure + : normalizeUnknownFailure(error, { + operationId: "APPLICATION_MUTATION", + }); + const failure = mutationFailureWithEffect(reportedFailure); + switch (failure.effect) { + case "NOT_STARTED": + case "NOT_APPLIED": + optimisticLayer?.rollback(); + if (failure.kind === "CONFLICT") setConflict(failure); + settleMutationAdmission(unknownEffectChannel, admission); + break; + case "APPLIED_CONFIRMED": + optimisticLayer?.commit(); + if (!scope || scope.isCurrent()) { + try { + await invalidationCoordinator?.invalidate(invalidate); + } catch { + // A confirmed command remains committed if refresh fails. + } + } + settleMutationAdmission(unknownEffectChannel, admission); + break; + case "MAYBE_APPLIED": + optimisticLayer?.markUncertain(); + if (!scope || scope.isCurrent()) { + admission.state = "UNKNOWN"; + notifyUnknownEffectChannel(unknownEffectChannel); + } else { + settleMutationAdmission(unknownEffectChannel, admission); + } + break; + } + return { ok: false, error: failure }; + } + + optimisticLayer?.commit(); + try { + await invalidationCoordinator?.invalidate(invalidate); + } catch { + // Cache refresh remains best effort after the server has committed. + } + settleMutationAdmission(unknownEffectChannel, admission); + return { ok: true, value }; + } finally { + try { + await mutationLease?.release(); + } catch { + // A cache coordination defect cannot change the committed command. + } + } + })() + .catch((error: unknown) => { + const failure = withFailureEffect( + normalizeUnknownFailure(error, { + operationId: "APPLICATION_MUTATION", + }), + "NOT_STARTED", + ); + settleMutationAdmission(unknownEffectChannel, admission); + if (failure.kind === "CONFLICT") setConflict(failure); + return { ok: false as const, error: failure }; + }) + .finally(() => { + identityLease?.release(); + if (mutationExecutions(queryClient).get(identity) === pending) { + mutationExecutions(queryClient).delete(identity); + } + }); + if (duplicatePolicy !== "ALLOW_PARALLEL") { + mutationExecutions(queryClient).set(identity, pending); + } + return pending; + }, + [ + invalidate, + invalidationCoordinator, + mutation, + optimistic, + queryClient, + definitionId, + duplicatePolicy, + mutationIntentFactory, + mutationOperationId, + requiresIdempotencyKey, + scope, + unknownEffectChannel, + ], + ); + + const resolveConflict = useCallback(async () => { + setConflict(null); + mutation.reset(); + if (invalidate.length > 0 && !invalidationCoordinator) { + throw new Error("Query invalidation coordinator is not installed."); + } + await invalidationCoordinator?.invalidate(invalidate); + }, [invalidate, invalidationCoordinator, mutation]); + + const reconcileUnknownEffect = useCallback( + async (resolution: "APPLIED" | "NOT_APPLIED") => { + if ( + !unknownEffectChannel || + unknownEffectChannel.reconciliationInFlight + ) { + return; + } + const pending = unknownEffectChannel.admissions[0]; + if (!pending || pending.state !== "UNKNOWN") return; + if (pending.scope && !pending.intent) return; + unknownEffectChannel.reconciliationInFlight = true; + pending.state = "RECONCILING"; + notifyUnknownEffectChannel(unknownEffectChannel); + try { + pending.optimisticLayer?.reconcile(resolution); + if ( + resolution === "APPLIED" && + (!pending.scope || pending.scope.isCurrent()) + ) { + try { + await pending.invalidationCoordinator?.invalidate( + pending.invalidate, + ); + } catch { + // Explicit reconciliation remains settled if refresh fails. + } + } + } finally { + if (pending.scope && !pending.scope.isCurrent()) { + removeAdmissionOptimisticQuery(queryClient, pending); + } + settleMutationAdmission(unknownEffectChannel, pending); + if ( + !unknownEffectChannel.admissions.some( + (admission) => admission.state === "ACTIVE", + ) + ) { + mutation.reset(); + } + // Keep the head intent locked through the current event turn so two + // clicks cannot consume two FIFO records when reconciliation itself + // has no asynchronous invalidation work. + await Promise.resolve(); + unknownEffectChannel.reconciliationInFlight = false; + notifyUnknownEffectChannel(unknownEffectChannel); + } + }, + [mutation, queryClient, unknownEffectChannel], + ); + + const unknownEffectHead = unknownEffectChannel?.admissions[0]; + + return Object.freeze({ + state: deriveAsyncState({ + data: currentData ?? true, + isMutationPending: + mutation.isPending || + unknownEffectHead?.state === "ACTIVE" || + unknownEffectHead?.state === "RECONCILING", + hasMutationEffectUnknown: unknownEffectHead?.state === "UNKNOWN", + hasMutationConflict: conflict !== null, + }), + submit, + resolveConflict, + reconcileUnknownEffect, + }); +} + +function mutationFailureWithEffect(failure: AppFailure): AppFailure { + switch (failure.effect) { + case "NOT_STARTED": + case "NOT_APPLIED": + case "APPLIED_CONFIRMED": + case "MAYBE_APPLIED": + return withFailureEffect(failure, failure.effect); + case "NOT_APPLICABLE": + case undefined: + return withFailureEffect(failure, "MAYBE_APPLIED"); + } +} + +const UNKNOWN_EFFECT_CHANNELS = new WeakMap< + object, + UnknownEffectRegistry +>(); + +function acquireUnknownEffectChannel( + queryClient: QueryClient, + key: string, + scope: CacheScopeSnapshot | undefined, +): UnknownEffectChannel | null { + let registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient); + if (!registry) { + registry = { channels: new Map(), activeAdmissions: 0 }; + UNKNOWN_EFFECT_CHANNELS.set(queryClient, registry); + } + const { channels } = registry; + const existing = channels.get(key); + if (existing) return existing.scope === (scope ?? null) ? existing : null; + if ( + channels.size >= MUTATION_COORDINATOR_BOUNDS.activeDefinitionsPerRuntime + ) { + return null; + } + const created: UnknownEffectChannel = { + key, + registry, + owner: queryClient, + scope: scope ?? null, + nextSequence: 1, + admissions: [], + version: 0, + reconciliationInFlight: false, + listeners: new Set(), + disposeScopeListener: null, + }; + channels.set(key, created); + if (scope) { + const discardScope = () => { + for (const admission of created.admissions) { + removeAdmissionOptimisticQuery(queryClient, admission); + markMutationAdmissionSettled(created, admission); + } + created.admissions = []; + notifyUnknownEffectChannel(created); + releaseUnknownEffectChannelIfUnused(queryClient, created); + }; + scope.signal.addEventListener("abort", discardScope, { once: true }); + created.disposeScopeListener = () => + scope.signal.removeEventListener("abort", discardScope); + if (scope.signal.aborted || !scope.isCurrent()) discardScope(); + } + return created; +} + +function releaseUnknownEffectChannelIfUnused( + queryClient: QueryClient, + channel: UnknownEffectChannel, +): void { + if (channel.admissions.length > 0 || channel.listeners.size > 0) return; + const registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient); + const channels = registry?.channels; + if (channels?.get(channel.key) !== channel) return; + channel.disposeScopeListener?.(); + channels.delete(channel.key); + if (channels.size === 0 && registry?.activeAdmissions === 0) { + UNKNOWN_EFFECT_CHANNELS.delete(queryClient); + } +} + +function notifyUnknownEffectChannel(channel: UnknownEffectChannel): void { + channel.version += 1; + for (const listener of [...channel.listeners]) listener(); +} + +function settleMutationAdmission( + channel: UnknownEffectChannel, + admission: MutationAdmission, +): void { + markMutationAdmissionSettled(channel, admission); + while (channel.admissions[0]?.state === "SETTLED") { + channel.admissions.shift(); + } + notifyUnknownEffectChannel(channel); + releaseUnknownEffectChannelIfUnused(channel.owner, channel); +} + +function markMutationAdmissionSettled( + channel: UnknownEffectChannel, + admission: MutationAdmission, +): void { + if (admission.state === "SETTLED") return; + admission.state = "SETTLED"; + channel.registry.activeAdmissions = Math.max( + 0, + channel.registry.activeAdmissions - 1, + ); +} + +function removeAdmissionOptimisticQuery( + queryClient: QueryClient, + admission: MutationAdmission, +): void { + if (!admission.optimisticQueryKey) return; + queryClient.removeQueries({ + queryKey: admission.optimisticQueryKey, + exact: true, + }); +} + +const RUNTIME_MUTATION_EXECUTIONS = new WeakMap< + object, + Map>> +>(); + +function mutationExecutions( + owner: object, +): Map>> { + const existing = RUNTIME_MUTATION_EXECUTIONS.get(owner); + if (existing) return existing; + const created = new Map>>(); + RUNTIME_MUTATION_EXECUTIONS.set(owner, created); + return created; +} + +const OPTIMISTIC_LAYER_RUNTIMES = new WeakMap< + object, + ReturnType +>(); + +function optimisticLayers( + queryClient: Parameters[0], +): ReturnType { + const existing = OPTIMISTIC_LAYER_RUNTIMES.get(queryClient); + if (existing) return existing; + const created = createOptimisticLayerRuntime(queryClient); + OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created); + return created; +} diff --git a/src/presentation/adapters/query/index.ts b/src/presentation/adapters/query/index.ts new file mode 100644 index 0000000..64a1b2f --- /dev/null +++ b/src/presentation/adapters/query/index.ts @@ -0,0 +1,9 @@ +export { + useApplicationMutation, + useApplicationQuery, + type ApplicationResult, +} from "./application-query.ts"; +export { + QueryInvalidationProvider, + useQueryInvalidationCoordinator, +} from "./query-invalidation-provider.tsx"; diff --git a/src/presentation/adapters/query/mutation-intent-provider.tsx b/src/presentation/adapters/query/mutation-intent-provider.tsx new file mode 100644 index 0000000..7347e1a --- /dev/null +++ b/src/presentation/adapters/query/mutation-intent-provider.tsx @@ -0,0 +1,35 @@ +import { + createContext, + type ReactNode, + useContext, +} from "react"; + +import type { MutationIntentFactory } from "../../../application/ports/mutation-intent-factory.ts"; + +const unavailableMutationIntentFactory: MutationIntentFactory = Object.freeze({ + create() { + throw new TypeError("Mutation intent factory is not installed."); + }, +}); + +const MutationIntentFactoryContext = createContext( + unavailableMutationIntentFactory, +); + +export function MutationIntentProvider({ + factory, + children, +}: Readonly<{ + factory: MutationIntentFactory; + children: ReactNode; +}>) { + return ( + + {children} + + ); +} + +export function useMutationIntentFactory(): MutationIntentFactory { + return useContext(MutationIntentFactoryContext); +} diff --git a/src/presentation/adapters/query/optimistic-layer-runtime.ts b/src/presentation/adapters/query/optimistic-layer-runtime.ts new file mode 100644 index 0000000..45a6ca4 --- /dev/null +++ b/src/presentation/adapters/query/optimistic-layer-runtime.ts @@ -0,0 +1,246 @@ +import { hashKey, type QueryClient } from "@tanstack/react-query"; + +import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts"; +import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts"; + +export type OptimisticLayerLease = Readonly<{ + commit(): void; + rollback(): void; + markUncertain(): void; + reconcile(resolution: "APPLIED" | "NOT_APPLIED"): void; +}>; + +type Layer = { + id: number; + status: "pending" | "uncertain" | "committed"; + apply(value: unknown): unknown; +}; + +type OptimisticLayerScope = Pick & + Partial>; + +type EntryState = { + queryKey: readonly unknown[]; + scope: OptimisticLayerScope; + base: unknown; + layers: Layer[]; + disposeScopeListener: (() => void) | null; +}; + +export function createOptimisticLayerRuntime(queryClient: QueryClient) { + const entries = new Map(); + let nextId = 1; + let writing = false; + + function removeEntry(key: string, entry: EntryState): void { + if (entries.get(key) === entry) entries.delete(key); + entry.disposeScopeListener?.(); + entry.disposeScopeListener = null; + } + + function writeProjection(entry: EntryState, value: unknown): void { + writing = true; + try { + queryClient.setQueryData(entry.queryKey, value); + } finally { + writing = false; + } + } + + queryClient.getQueryCache().subscribe((event) => { + if ( + writing || + event.type !== "updated" || + !entries.has(event.query.queryHash) + ) { + return; + } + const entry = entries.get(event.query.queryHash); + if (!entry) return; + entry.base = event.query.state.data; + project(event.query.queryHash, entry); + }); + + function project(key: string, entry: EntryState): void { + if (!entry.scope.isCurrent()) { + removeEntry(key, entry); + queryClient.removeQueries({ queryKey: entry.queryKey, exact: true }); + return; + } + if (entry.base === undefined && entry.layers.length === 0) { + removeEntry(key, entry); + queryClient.removeQueries({ queryKey: entry.queryKey, exact: true }); + return; + } + let value = entry.base; + try { + for (const layer of entry.layers) value = layer.apply(value); + } catch { + removeEntry(key, entry); + return; + } + writeProjection(entry, value); + } + + function collapse(key: string, entry: EntryState): void { + while (entry.layers[0]?.status === "committed") { + const committed = entry.layers.shift(); + if (!committed) break; + entry.base = committed.apply(entry.base); + } + project(key, entry); + if (entry.layers.length === 0) removeEntry(key, entry); + } + + return Object.freeze({ + begin( + queryKey: readonly unknown[], + input: Input, + update: (previous: unknown, input: Input) => unknown, + scope: OptimisticLayerScope, + ): OptimisticLayerLease | null { + if (!scope.isCurrent()) return null; + const current = queryClient.getQueryData(queryKey); + const key = hashKey(queryKey); + let entry = entries.get(key); + if (!entry) { + entry = { + queryKey, + scope, + base: current, + layers: [], + disposeScopeListener: null, + }; + entries.set(key, entry); + if (scope.signal) { + const selectedEntry = entry; + const discardScope = () => { + removeEntry(key, selectedEntry); + queryClient.removeQueries({ queryKey, exact: true }); + }; + scope.signal.addEventListener("abort", discardScope, { once: true }); + entry.disposeScopeListener = () => + scope.signal?.removeEventListener("abort", discardScope); + if (scope.signal.aborted || !scope.isCurrent()) { + discardScope(); + return null; + } + } + } else if (entry.scope !== scope) { + return null; + } + // §11.5. Overflow falls back to pessimistic execution; an existing + // layer is never silently evicted to make room for a new one. + if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) { + if (entry.layers.length === 0) removeEntry(key, entry); + return null; + } + const layer: Layer = { + id: nextId, + status: "pending", + apply: (value) => update(value, input), + }; + let projected: unknown; + try { + projected = entry.base; + for (const existingLayer of entry.layers) { + projected = existingLayer.apply(projected); + } + projected = layer.apply(projected); + } catch (error) { + if (entry.layers.length > 0) return null; + removeEntry(key, entry); + throw error; + } + if ( + estimateLayerBytes(projected) > + OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes + ) { + if (entry.layers.length === 0) removeEntry(key, entry); + return null; + } + nextId += 1; + entry.layers.push(layer); + writeProjection(entry, projected); + let state: "pending" | "uncertain" | "settled" = "pending"; + const selectedLayer = () => { + if (entries.get(key) !== entry) return undefined; + return entry.layers.find((candidate) => candidate.id === layer.id); + }; + return Object.freeze({ + commit() { + if (state !== "pending") return; + state = "settled"; + const selected = selectedLayer(); + if (!selected) return; + selected.status = "committed"; + collapse(key, entry); + }, + rollback() { + if (state !== "pending") return; + state = "settled"; + if (!selectedLayer()) return; + entry.layers = entry.layers.filter( + (candidate) => candidate.id !== layer.id, + ); + collapse(key, entry); + }, + markUncertain() { + if (state !== "pending") return; + state = "uncertain"; + const selected = selectedLayer(); + if (!selected) return; + selected.status = "uncertain"; + collapse(key, entry); + }, + reconcile(resolution) { + if (state !== "uncertain") return; + state = "settled"; + const selected = selectedLayer(); + if (!selected) return; + if (resolution === "APPLIED") { + selected.status = "committed"; + } else { + entry.layers = entry.layers.filter( + (candidate) => candidate.id !== layer.id, + ); + } + collapse(key, entry); + }, + }); + }, + }); +} + +const encoder = new TextEncoder(); + +/** + * A local, bounded estimate of one optimistic projection. This is not the + * §10.4 query result measurement: it only decides whether a rollback snapshot + * stays inside the layer budget, and it stops as soon as the budget is passed. + */ +function estimateLayerBytes(value: unknown): number { + let total = 0; + const stack: unknown[] = [value]; + let visited = 0; + while (stack.length > 0) { + if (visited++ > 4_096) return Number.POSITIVE_INFINITY; + if (total > OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes) return total; + const current = stack.pop(); + if (typeof current === "string") { + total += encoder.encode(current).byteLength; + } else if (typeof current === "number" || typeof current === "boolean") { + total += 8; + } else if (Array.isArray(current)) { + total += 8; + for (const item of current) stack.push(item); + } else if (current && typeof current === "object") { + total += 8; + for (const [key, item] of Object.entries(current)) { + total += encoder.encode(key).byteLength; + stack.push(item); + } + } + } + return total; +} diff --git a/src/presentation/adapters/query/query-invalidation-provider.tsx b/src/presentation/adapters/query/query-invalidation-provider.tsx new file mode 100644 index 0000000..15a339f --- /dev/null +++ b/src/presentation/adapters/query/query-invalidation-provider.tsx @@ -0,0 +1,30 @@ +import { + createContext, + type ReactNode, + useContext, +} from "react"; + +import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts"; + +const QueryInvalidationContext = + createContext(null); + +export function QueryInvalidationProvider({ + coordinator, + children, +}: Readonly<{ + coordinator: QueryInvalidationCoordinator; + children: ReactNode; +}>) { + return ( + + {children} + + ); +} + +export function useQueryInvalidationCoordinator(): + | QueryInvalidationCoordinator + | null { + return useContext(QueryInvalidationContext); +} diff --git a/src/presentation/adapters/query/server-state-generation-provider.tsx b/src/presentation/adapters/query/server-state-generation-provider.tsx new file mode 100644 index 0000000..163922a --- /dev/null +++ b/src/presentation/adapters/query/server-state-generation-provider.tsx @@ -0,0 +1,58 @@ +import { + QueryClientProvider, + type QueryClient, +} from "@tanstack/react-query"; +import { type ReactNode, useSyncExternalStore } from "react"; + +import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts"; +import type { ServerStateScopeRuntime } from "../../../contracts/server-state-scope.ts"; +import type { MutationIntentFactory } from "../../../application/ports/mutation-intent-factory.ts"; +import { MutationIntentProvider } from "./mutation-intent-provider.tsx"; +import { QueryInvalidationProvider } from "./query-invalidation-provider.tsx"; +import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx"; + +export type ServerStateGenerationSource = Readonly<{ + getSnapshot(): Readonly<{ + generation: number; + queryClient: QueryClient; + queryInvalidation: QueryInvalidationCoordinator; + }>; + subscribe(listener: () => void): () => void; +}>; + +export function ServerStateGenerationProvider({ + store, + scope, + mutationIntentFactory, + children, + transitionFallback, +}: Readonly<{ + store: ServerStateGenerationSource; + scope: ServerStateScopeRuntime; + mutationIntentFactory: MutationIntentFactory; + children: ReactNode; + transitionFallback?: ReactNode; +}>) { + const generation = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); + return ( + + + + + {children} + + + + + ); +} diff --git a/src/presentation/adapters/query/server-state-scope-provider.tsx b/src/presentation/adapters/query/server-state-scope-provider.tsx new file mode 100644 index 0000000..a99e2f2 --- /dev/null +++ b/src/presentation/adapters/query/server-state-scope-provider.tsx @@ -0,0 +1,52 @@ +import { + createContext, + type ReactNode, + useContext, + useSyncExternalStore, +} from "react"; + +import type { + CacheScopeSnapshot, + ServerStateScopeRuntime, +} from "../../../contracts/server-state-scope.ts"; + +const ServerStateScopeContext = + createContext(null); + +export function ServerStateScopeProvider({ + runtime, + children, + transitionFallback = null, +}: Readonly<{ + runtime: ServerStateScopeRuntime; + children: ReactNode; + transitionFallback?: ReactNode; +}>) { + const phase = useSyncExternalStore( + runtime.subscribe, + runtime.getPhase, + runtime.getPhase, + ); + const content = + phase === "READY" + ? children + : phase === "DISPOSED" + ? null + : transitionFallback; + + return ( + + {content} + + ); +} + +export function useServerStateScope(): CacheScopeSnapshot { + const runtime = useContext(ServerStateScopeContext); + if (!runtime) throw new Error("ServerStateScopeProvider is required"); + return useSyncExternalStore( + runtime.subscribe, + runtime.getSnapshot, + runtime.getSnapshot, + ); +} diff --git a/src/presentation/boundaries/boot-error-shell.tsx b/src/presentation/boundaries/boot-error-shell.tsx new file mode 100644 index 0000000..d3fe5ec --- /dev/null +++ b/src/presentation/boundaries/boot-error-shell.tsx @@ -0,0 +1,54 @@ +import { formatMessage } from "../i18n/index.ts"; + +export type BootErrorShellProps = Readonly<{ + kind?: string; + code?: string; + buildId?: string; + configSchemaVersion?: string; + releaseId?: string; + supportReference: string; +}>; + +export function BootErrorShell({ + kind = "BOOT_CONFIG_FAILURE", + code = "BOOT_FAILED", + buildId, + configSchemaVersion, + releaseId, + supportReference, +}: BootErrorShellProps) { + return ( +
+

{formatMessage("ko-KR", "boot.failure.title")}

+
+
{formatMessage("ko-KR", "boot.field.error")}
+
{kind}
+
{formatMessage("ko-KR", "boot.field.code")}
+
{code}
+ {buildId && ( + <> +
{formatMessage("ko-KR", "boot.field.build")}
+
{buildId}
+ + )} + {configSchemaVersion && ( + <> +
{formatMessage("ko-KR", "boot.field.configSchema")}
+
{configSchemaVersion}
+ + )} + {releaseId && ( + <> +
{formatMessage("ko-KR", "boot.field.release")}
+
{releaseId}
+ + )} +
+

+ {formatMessage("ko-KR", "boot.supportReference", { + reference: supportReference, + })} +

+
+ ); +} diff --git a/src/presentation/boundaries/chunk-recovery-boundary.tsx b/src/presentation/boundaries/chunk-recovery-boundary.tsx new file mode 100644 index 0000000..1ae0bea --- /dev/null +++ b/src/presentation/boundaries/chunk-recovery-boundary.tsx @@ -0,0 +1,115 @@ +import { + Component, + type ErrorInfo, + type ReactNode, +} from "react"; +import { useLocale } from "../i18n/index.ts"; + +type RecoveryResult = + | Readonly<{ action: "reload-once"; releasePair: string }> + | Readonly<{ action: "support"; reason: string }>; + +type Props = Readonly<{ + children: ReactNode; + chunkId: string; + recover(input: Readonly<{ + chunkId: string; + failureKind: "CHUNK_LOAD_FAILURE"; + }>): Promise; +}>; + +type State = Readonly<{ + error: unknown | null; + recovery: "idle" | "checking" | "reload-requested" | "support"; + reason?: string; +}>; + +export function isChunkLoadFailure(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const value = `${error.name} ${error.message}`.toLowerCase(); + return ( + value.includes("chunkloaderror") || + value.includes("loading chunk") || + value.includes("dynamically imported module") || + value.includes("failed to fetch module script") + ); +} + +export class ChunkRecoveryBoundary extends Component { + state: State = { error: null, recovery: "idle" }; + + static getDerivedStateFromError(error: unknown): State { + return { error, recovery: "checking" }; + } + + componentDidCatch(error: unknown, _info: ErrorInfo) { + if (!isChunkLoadFailure(error)) return; + void this.props + .recover({ + chunkId: this.props.chunkId, + failureKind: "CHUNK_LOAD_FAILURE", + }) + .then((result) => { + this.setState({ + error, + recovery: + result.action === "reload-once" + ? "reload-requested" + : "support", + ...(result.action === "support" ? { reason: result.reason } : {}), + }); + }) + .catch(() => { + this.setState({ + error, + recovery: "support", + reason: "recovery-controller-failed", + }); + }); + } + + render() { + const { error, recovery, reason } = this.state; + if (error && !isChunkLoadFailure(error)) throw error; + if (error && recovery === "checking") { + return ; + } + if (error && recovery === "reload-requested") { + return ; + } + if (error && recovery === "support") { + return ; + } + return this.props.children; + } +} + +function ChunkRecoverySurface({ + recovery, + reason, +}: Readonly<{ + recovery: Exclude; + reason?: string; +}>) { + const { message } = useLocale(); + if (recovery === "checking") { + return ( +
+ {message("chunk.checking")} +
+ ); + } + if (recovery === "reload-requested") { + return ( +
+ {message("chunk.reloadOnce")} +
+ ); + } + return ( +
+

{message("chunk.failure.title")}

+

{message("chunk.failure.description")}

+
+ ); +} diff --git a/src/presentation/boundaries/render-error-boundary.tsx b/src/presentation/boundaries/render-error-boundary.tsx new file mode 100644 index 0000000..782638a --- /dev/null +++ b/src/presentation/boundaries/render-error-boundary.tsx @@ -0,0 +1,77 @@ +import { Component, type ReactNode } from "react"; + +import type { RenderFailureReport } from "../../application/ports/in/application-api.ts"; +import { formatMessage } from "../i18n/index.ts"; + +export type RenderBoundaryProps = Readonly<{ + children: ReactNode; + boundaryName: RenderFailureReport["boundaryName"]; + routeId: string; + buildId: string; + resetKey?: string; + onRenderFailure?: (report: RenderFailureReport) => void; + fallback?: ReactNode; +}>; + +type RenderBoundaryState = Readonly<{ hasError: boolean }>; + +export class RenderErrorBoundary extends Component< + RenderBoundaryProps, + RenderBoundaryState +> { + state: RenderBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): RenderBoundaryState { + return { hasError: true }; + } + + componentDidCatch(): void { + try { + this.props.onRenderFailure?.({ + routeId: this.props.routeId, + buildId: this.props.buildId, + boundaryName: this.props.boundaryName, + }); + } catch { + // Diagnostics must never recurse into another render failure. + } + } + + componentDidUpdate(previous: Readonly): void { + if (this.state.hasError && previous.resetKey !== this.props.resetKey) { + this.setState({ hasError: false }); + } + } + + reset = (): void => { + this.setState({ hasError: false }); + }; + + render(): ReactNode { + if (this.state.hasError) { + return ( + this.props.fallback ?? ( +
+

{formatMessage("ko-KR", "error.render_failure")}

+ +
+ ) + ); + } + return this.props.children; + } +} + +export function RouteBoundary( + props: Omit, +) { + return ; +} + +export function FeatureBoundary( + props: Omit, +) { + return ; +} diff --git a/src/presentation/components/async-surface.tsx b/src/presentation/components/async-surface.tsx new file mode 100644 index 0000000..f172014 --- /dev/null +++ b/src/presentation/components/async-surface.tsx @@ -0,0 +1,165 @@ +import { useId, type ReactNode } from "react"; + +import type { AsyncState } from "../../application/view-models/async-state.ts"; +import type { AppFailure } from "../../contracts/errors.ts"; +import { useLocale } from "../i18n/index.ts"; +import { errorMessage } from "./error-copy.ts"; +import { Button } from "./ui/button.ts"; + +export function LoadingSurface({ label }: Readonly<{ label?: string }>) { + const { message } = useLocale(); + const accessibleLabel = label ?? message("async.loading"); + return ( +
+
+ ); +} + +export type EmptySurfaceProps = Readonly<{ + title?: string; + description?: string; + action?: ReactNode; +}>; + +export function EmptySurface({ + title, + description, + action, +}: EmptySurfaceProps) { + const { message } = useLocale(); + return ( +
+

{title ?? message("async.empty")}

+ {description ?

{description}

: null} + {action} +
+ ); +} + +export type TerminalErrorSurfaceProps = Readonly<{ + userMessageKey: string; + action: AppFailure["action"]; + onAction?: () => void; +}>; + +export function TerminalErrorSurface({ + userMessageKey, + action, + onAction, +}: TerminalErrorSurfaceProps) { + const { locale, message } = useLocale(); + const messageId = useId(); + const actionLabels: Readonly< + Record, string> + > = Object.freeze({ + retry: message("action.retry"), + reauth: message("action.reauth"), + navigate: message("action.navigateSafe"), + "reload-once": message("action.reloadOnce"), + "contact-support": message("action.contactSupport"), + }); + return ( +
+

{errorMessage(userMessageKey, locale)}

+ {action !== "none" && onAction && ( + + )} +
+ ); +} + +export type AsyncSurfaceProps = Readonly<{ + state: AsyncState; + children?: ReactNode; + onAction?: () => void; + onRetry?: () => void; + onResolveConflict?: () => void; + onReconcileUnknownEffect?: ( + resolution: "APPLIED" | "NOT_APPLIED", + ) => void; +}>; + +export function AsyncSurface({ + state, + children, + onAction, + onRetry, + onResolveConflict, + onReconcileUnknownEffect, +}: AsyncSurfaceProps) { + const { message } = useLocale(); + if (state.base === "initial-loading") return ; + if (state.base === "empty") return ; + if (state.base === "terminal-error" && state.failure) { + return ( + + ); + } + + return ( +
+ {state.indicator ? ( +
+ + {message( + state.indicator === "stale-degraded" + ? "async.staleDegraded" + : state.indicator === "mutation-effect-unknown" + ? "async.mutationEffectUnknown" + : state.indicator === "mutation-conflict" + ? "async.mutationConflict" + : state.indicator === "mutation-pending" + ? "async.mutationPending" + : "async.refreshing", + )} + + {state.indicator === "stale-degraded" && onRetry ? ( + + ) : null} + {state.indicator === "mutation-conflict" && onResolveConflict ? ( + + ) : null} + {state.indicator === "mutation-effect-unknown" && + onReconcileUnknownEffect ? ( + <> + + + + ) : null} +
+ ) : null} + {children} +
+ ); +} diff --git a/src/presentation/components/error-copy.ts b/src/presentation/components/error-copy.ts new file mode 100644 index 0000000..bb90787 --- /dev/null +++ b/src/presentation/components/error-copy.ts @@ -0,0 +1,8 @@ +import { resolveMessage } from "../i18n/index.ts"; + +export function errorMessage( + messageKey: string, + locale: string = "ko-KR", +): string { + return resolveMessage(locale, messageKey); +} diff --git a/src/presentation/components/page-header.tsx b/src/presentation/components/page-header.tsx new file mode 100644 index 0000000..1eb3add --- /dev/null +++ b/src/presentation/components/page-header.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef } from "react"; + +export type PageHeaderProps = Readonly<{ + title: string; + description?: string; + eyebrow?: string; +}>; + +export function PageHeader({ + title, + description, + eyebrow, +}: PageHeaderProps) { + const headingRef = useRef(null); + + useEffect(() => { + const activeElement = document.activeElement; + const main = document.getElementById("main-content"); + const routeOwnsFocus = + activeElement === null || + activeElement === document.body || + activeElement === document.documentElement || + activeElement === main; + if (routeOwnsFocus) { + headingRef.current?.focus(); + } + }, [title]); + + return ( +
+ {eyebrow ?

{eyebrow}

: null} +

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+ ); +} diff --git a/src/presentation/components/state-surfaces.tsx b/src/presentation/components/state-surfaces.tsx new file mode 100644 index 0000000..d48c546 --- /dev/null +++ b/src/presentation/components/state-surfaces.tsx @@ -0,0 +1,75 @@ +import { useLocale } from "../i18n/index.ts"; +import { Button } from "./ui/button.ts"; + +type StateSurfaceProps = Readonly<{ + eyebrow: string; + title: string; + description: string; + actionLabel?: string; + onAction?: () => void; + tone?: "neutral" | "danger" | "warning"; +}>; + +function StateSurface({ + eyebrow, + title, + description, + actionLabel, + onAction, + tone = "neutral", +}: StateSurfaceProps) { + return ( +
+

{eyebrow}

+

{title}

+

{description}

+ {actionLabel ? : null} +
+ ); +} + +export function AuthRequiredSurface({ + onSignIn, +}: Readonly<{ onSignIn?: () => void }>) { + const { message } = useLocale(); + return ( + + ); +} + +export function ForbiddenSurface({ + onNavigate, +}: Readonly<{ onNavigate?: () => void }>) { + const { message } = useLocale(); + return ( + + ); +} + +export function NotFoundSurface({ + onNavigate, +}: Readonly<{ onNavigate?: () => void }>) { + const { message } = useLocale(); + return ( + + ); +} diff --git a/src/presentation/components/ui/alert.ts b/src/presentation/components/ui/alert.ts new file mode 100644 index 0000000..e9f83f7 --- /dev/null +++ b/src/presentation/components/ui/alert.ts @@ -0,0 +1 @@ +export { Alert, type AlertProps } from "../../design-system/primitives/core.tsx"; diff --git a/src/presentation/components/ui/badge.ts b/src/presentation/components/ui/badge.ts new file mode 100644 index 0000000..9dc4498 --- /dev/null +++ b/src/presentation/components/ui/badge.ts @@ -0,0 +1 @@ +export { Badge, type BadgeProps } from "../../design-system/primitives/core.tsx"; diff --git a/src/presentation/components/ui/button.ts b/src/presentation/components/ui/button.ts new file mode 100644 index 0000000..87a1d27 --- /dev/null +++ b/src/presentation/components/ui/button.ts @@ -0,0 +1,6 @@ +export { + Button, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +} from "../../design-system/primitives/core.tsx"; diff --git a/src/presentation/components/ui/card.ts b/src/presentation/components/ui/card.ts new file mode 100644 index 0000000..fd4d718 --- /dev/null +++ b/src/presentation/components/ui/card.ts @@ -0,0 +1 @@ +export { Card, type CardProps } from "../../design-system/primitives/core.tsx"; diff --git a/src/presentation/components/ui/dialog.ts b/src/presentation/components/ui/dialog.ts new file mode 100644 index 0000000..dacfa8b --- /dev/null +++ b/src/presentation/components/ui/dialog.ts @@ -0,0 +1 @@ +export { Dialog, type DialogProps } from "../../design-system/primitives/core.tsx"; diff --git a/src/presentation/components/ui/text-field.ts b/src/presentation/components/ui/text-field.ts new file mode 100644 index 0000000..a6e32fe --- /dev/null +++ b/src/presentation/components/ui/text-field.ts @@ -0,0 +1,4 @@ +export { + TextField, + type TextFieldProps, +} from "../../design-system/primitives/core.tsx"; diff --git a/src/presentation/design-system/design-system.stories.tsx b/src/presentation/design-system/design-system.stories.tsx new file mode 100644 index 0000000..2852b97 --- /dev/null +++ b/src/presentation/design-system/design-system.stories.tsx @@ -0,0 +1,128 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { useState } from "react"; + +import { + Alert, + Badge, + Button, + Card, + Dialog, + Menu, + ProgressBar, + Skeleton, + Tabs, + TextArea, + TextField, +} from "./index.ts"; + +const meta = { + title: "Platform/Design System", + component: Button, + tags: ["autodocs"], + parameters: { + layout: "padded", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primitives: Story = { + render: () => ( +
+ +
+ + + + +
+
+ Neutral + Success + Warning + Danger +
+
+ + +