diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs deleted file mode 100644 index d320e6d..0000000 --- a/.dependency-cruiser.cjs +++ /dev/null @@ -1,108 +0,0 @@ -/** @type {import("dependency-cruiser").IConfiguration} */ -module.exports = { - 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: "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/.dependency-cruiser.json b/.dependency-cruiser.json new file mode 100644 index 0000000..b78e1a2 --- /dev/null +++ b/.dependency-cruiser.json @@ -0,0 +1,107 @@ +{ + "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": "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 index 5fcfafa..e4613df 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -19,8 +19,15 @@ on: - field - documentation +permissions: + contents: read + env: - NODE_VERSION: "24" + 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: @@ -48,7 +55,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - name: Frozen install run: | corepack enable @@ -86,7 +93,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - name: Frozen install run: | corepack enable @@ -123,7 +130,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - name: Frozen install run: | corepack enable @@ -150,7 +157,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - name: Frozen install run: | corepack enable @@ -173,7 +180,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: .nvmrc - name: Frozen install run: | corepack enable diff --git a/.gitignore b/.gitignore index d9f9671..eda4cdd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ artifacts/**/*.xml artifacts/**/*.txt artifacts/**/*.sarif artifacts/tests/e2e/ +artifacts/tests/browser-capabilities/ artifacts/storybook/ artifacts/tests/storybook/ artifacts/tests/visual/ diff --git a/.storybook/main.ts b/.storybook/main.ts index 3566608..a243e16 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,7 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { - stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], + stories: ["../src/**/*.stories.@(ts|tsx)"], addons: ["@storybook/addon-a11y"], framework: { name: "@storybook/react-vite", diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 9c11377..454ed29 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,13 +2,13 @@ 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.js"; -import { createQueryClient } from "../src/adapters/query-cache/tanstack-query-cache.js"; -import { createApplication } from "../src/application/create-application.js"; -import { LocaleProvider } from "../src/presentation/i18n/index.js"; -import { ApplicationProvider } from "../src/presentation/providers/application-provider.js"; -import { SessionProvider } from "../src/presentation/providers/session-provider.jsx"; -import { ThemeProvider } from "../src/presentation/providers/theme-provider.jsx"; +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(); diff --git a/README.md b/README.md index 12477ad..2a3ee0d 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,15 @@ operations are executable contracts rather than conventions. ## Start locally -Requirements: Node 24.11 or newer and Corepack. The repository pins pnpm in -`package.json`. +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 @@ -29,7 +36,7 @@ persistent `system` / `light` / `dark` theme selector. | `/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 | -| `/sample/resources` | protected, domain-neutral integration route | +| `/examples/reference-resources` | removable, integration-defined 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 @@ -50,10 +57,11 @@ contracts own cross-cutting registries ``` See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and -`docs/architecture/starter-experience.md`. The removable sample slice is under -`src/sample/contract-fixture`; product code is not allowed to import it. The -visible starter routes do not depend on that fixture and continue to build -after it is removed. +`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 @@ -63,8 +71,17 @@ 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) @@ -105,6 +122,14 @@ fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative [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: diff --git a/config/ci/gates.json b/config/ci/gates.json index e3ca319..6930458 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -83,6 +83,9 @@ { "script": "check:types:fixture:ts-result", "expect": "fail" }, { "script": "check:types:fixture:application-output", "expect": "fail" }, { "script": "check:types:fixture:application-input", "expect": "fail" }, + { "script": "check:types:fixture:feature-input", "expect": "fail" }, + { "script": "check:types:fixture:failure-kind", "expect": "fail" }, + { "script": "check:types:fixture:reference-operation", "expect": "fail" }, { "script": "check:types:fixture:async-overlay", "expect": "fail" }, { "script": "check:types:fixture:route-runtime", "expect": "fail" }, { "script": "check:types:fixture:page-action", "expect": "fail" }, @@ -145,6 +148,11 @@ "name": "e2e", "steps": [ { "script": "test:e2e", "expect": "pass" }, + { "script": "test:browser-capabilities", "expect": "pass" }, + { + "script": "verify:browser-capability-evidence", + "expect": "pass" + }, { "script": "test:storybook", "expect": "pass" }, { "script": "test:visual", "expect": "pass" }, { "script": "check:test-evidence", "expect": "pass" }, @@ -154,6 +162,8 @@ "evidence": [ "artifacts/tests/e2e/report/index.html", "artifacts/tests/e2e/results.xml", + "artifacts/tests/browser-capabilities/report/index.html", + "artifacts/tests/browser-capabilities/results.xml", "artifacts/tests/storybook/report/index.html", "artifacts/tests/storybook/results.xml", "artifacts/tests/visual/report/index.html", @@ -192,6 +202,9 @@ { "script": "check:i18n:fixture", "expect": "fail" }, { "script": "check:diagnostics", "expect": "pass" }, { "script": "check:diagnostics:fixture", "expect": "fail" }, + { "script": "check:browser-file-storage-boundaries", "expect": "pass" }, + { "script": "check:realtime-boundaries", "expect": "pass" }, + { "script": "check:realtime-boundaries:fixture", "expect": "pass" }, { "script": "check:optional-recipes:source", "expect": "pass" }, { "script": "check:optional-recipe-fixtures", "expect": "pass" }, { "script": "check:registries", "expect": "pass" }, @@ -212,6 +225,7 @@ "artifacts/quality/i18n-fixture.json", "artifacts/quality/diagnostics.json", "artifacts/quality/diagnostics-fixture.json", + "artifacts/quality/realtime-boundaries.json", "artifacts/quality/optional-recipes.json", "artifacts/quality/optional-recipe-fixtures.json", "artifacts/quality/registries.json", @@ -315,7 +329,7 @@ ], "logPath": "artifacts/quality/gates/FE-GATE-016.txt", "evidence": [ - "artifacts/runbooks/FE-RB-005/local-release/record.json" + "artifacts/runbooks/FE-RB-005/record.json" ], "retentionClass": "prod-drill" }, @@ -354,12 +368,22 @@ "name": "removability", "steps": [ { "script": "test:sample-removal", "expect": "pass" }, - { "script": "test:optional-recipe-removal", "expect": "pass" } + { "script": "test:optional-recipe-removal", "expect": "pass" }, + { + "script": "test:browser-file-storage-removal", + "expect": "pass" + }, + { + "script": "test:realtime-removal", + "expect": "pass" + } ], "logPath": "artifacts/quality/gates/FE-GATE-020.txt", "evidence": [ "artifacts/tests/sample-removal.xml", - "artifacts/tests/optional-recipe-removal.xml" + "artifacts/tests/optional-recipe-removal.xml", + "artifacts/tests/browser-file-storage-runtime-removal.xml", + "artifacts/tests/realtime-runtime-removal.xml" ], "retentionClass": "merge-cycle" }, @@ -375,7 +399,7 @@ ], "logPath": "artifacts/quality/gates/FE-GATE-021.txt", "evidence": [ - "artifacts/runbooks/FE-RB-001/local-release/record.json" + "artifacts/runbooks/FE-RB-001/record.json" ], "retentionClass": "prod-drill" }, @@ -391,7 +415,7 @@ ], "logPath": "artifacts/quality/gates/FE-GATE-022.txt", "evidence": [ - "artifacts/runbooks/FE-RB-002/local-release/record.json" + "artifacts/runbooks/FE-RB-002/record.json" ], "retentionClass": "prod-drill" }, @@ -407,7 +431,7 @@ ], "logPath": "artifacts/quality/gates/FE-GATE-023.txt", "evidence": [ - "artifacts/runbooks/FE-RB-003/local-release/record.json" + "artifacts/runbooks/FE-RB-003/record.json" ], "retentionClass": "prod-drill" }, @@ -423,7 +447,7 @@ ], "logPath": "artifacts/quality/gates/FE-GATE-024.txt", "evidence": [ - "artifacts/runbooks/FE-RB-004/local-release/record.json" + "artifacts/runbooks/FE-RB-004/record.json" ], "retentionClass": "prod-drill" }, @@ -439,7 +463,7 @@ ], "logPath": "artifacts/quality/gates/FE-GATE-025.txt", "evidence": [ - "artifacts/runbooks/FE-RB-005/local-release/record.json" + "artifacts/runbooks/FE-RB-005/record.json" ], "retentionClass": "prod-drill" }, diff --git a/config/contracts/registry-baseline.approval.json b/config/contracts/registry-baseline.approval.json index aea11d9..a245e1d 100644 --- a/config/contracts/registry-baseline.approval.json +++ b/config/contracts/registry-baseline.approval.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "snapshotDigest": "e8448e46bc65326e9b2eb23cdce0870242faedb7a354942389c4a95b0e392d90", + "snapshotDigest": "67f1ad2bf35d04470a7fba1a5ae8f98608b5d70b70887e383692b1e2d6127752", "owner": "frontend-platform", - "reason": "RP-10 initial approved executable registry baseline", - "approvedAt": "2026-07-26T07:53:45.969Z" + "reason": "Typed Web Storage codecs and query invalidation topic protocol", + "approvedAt": "2026-07-27T16:41:15.888Z" } diff --git a/config/contracts/registry-baseline.json b/config/contracts/registry-baseline.json index c185c8f..8316edd 100644 --- a/config/contracts/registry-baseline.json +++ b/config/contracts/registry-baseline.json @@ -4,7 +4,7 @@ { "registryId": "FE-REG-ROUTE", "owner": "feature-frontend-routing-release-recovery-runtime", - "source": "src/features/installed-feature-contracts.js", + "source": "src/features/installed-feature-contracts.ts", "rowCount": 9, "contract": { "requiredFields": [ @@ -218,7 +218,7 @@ { "registryId": "FE-REG-ROUTE-RUNTIME", "owner": "feature-frontend-routing-release-recovery-runtime", - "source": "src/features/installed-feature-contracts.js", + "source": "src/features/installed-feature-contracts.ts", "rowCount": 9, "contract": { "requiredFields": [ @@ -323,7 +323,7 @@ { "registryId": "FE-REG-API", "owner": "feature-frontend-api-client-response-envelope-contract", - "source": "src/features/installed-feature-contracts.js", + "source": "src/features/installed-feature-contracts.ts", "rowCount": 3, "contract": { "requiredFields": [ @@ -451,7 +451,7 @@ { "registryId": "FE-REG-SCHEMA", "owner": "feature-frontend-contract-schema-registry", - "source": "src/features/installed-feature-contracts.js", + "source": "src/features/installed-feature-contracts.ts", "rowCount": 8, "contract": { "requiredFields": [ @@ -543,7 +543,7 @@ { "registryId": "FE-REG-ENV", "owner": "feature-frontend-env-runtime-config-contract", - "source": "src/contracts/env.js", + "source": "src/contracts/env.ts", "rowCount": 14, "contract": { "requiredFields": [ @@ -669,7 +669,7 @@ { "registryId": "FE-REG-STORAGE", "owner": "feature-frontend-storage-registry-contract", - "source": "src/contracts/storage-keys.js", + "source": "src/contracts/storage-keys.ts", "rowCount": 4, "contract": { "requiredFields": [ @@ -678,6 +678,7 @@ "backend", "classification", "schemaVersion", + "valueCodec", "ttl", "migration", "quotaFallback" @@ -688,8 +689,9 @@ "backend": "string", "classification": "string", "schemaVersion": "integer", + "valueCodec": "string", "ttl": "integer|string|null", - "migration": "string|function", + "migration": "string", "quotaFallback": "string" }, "uniqueFields": [ @@ -701,7 +703,6 @@ "memory", "sessionStorage", "localStorage", - "indexedDB", "disabled", "forbidden" ], @@ -710,6 +711,14 @@ "opaque-cache", "sensitive-forbidden" ], + "valueCodec": [ + "color-scheme-v1", + "opaque-string-v1", + "none" + ], + "migration": [ + "discard" + ], "quotaFallback": [ "memory", "no-persist", @@ -724,6 +733,7 @@ "backend", "classification", "schemaVersion", + "valueCodec", "migration" ] }, @@ -738,7 +748,8 @@ "quotaFallback": "feature-disable", "schemaVersion": 1, "scope": "auth", - "ttl": null + "ttl": null, + "valueCodec": "none" }, "CHUNK_RELOAD_GUARD": { "backend": "sessionStorage", @@ -750,7 +761,8 @@ "quotaFallback": "no-persist", "schemaVersion": 1, "scope": "release", - "ttl": "session" + "ttl": "session", + "valueCodec": "opaque-string-v1" }, "COLOR_SCHEME": { "backend": "localStorage", @@ -762,7 +774,8 @@ "quotaFallback": "memory", "schemaVersion": 1, "scope": "preference", - "ttl": null + "ttl": null, + "valueCodec": "color-scheme-v1" }, "QUERY_PERSISTENCE": { "backend": "disabled", @@ -774,14 +787,15 @@ "quotaFallback": "feature-disable", "schemaVersion": 1, "scope": "cache", - "ttl": null + "ttl": null, + "valueCodec": "none" } } }, { "registryId": "FE-REG-ERROR", "owner": "feature-frontend-error-classification-boundary-contract", - "source": "src/contracts/errors.js", + "source": "src/contracts/errors.ts", "rowCount": 31, "contract": { "requiredFields": [ @@ -1393,7 +1407,7 @@ { "registryId": "FE-REG-QUERY", "owner": "feature-frontend-server-state-caching-contract", - "source": "src/features/installed-feature-contracts.js", + "source": "src/features/installed-feature-contracts.ts", "rowCount": 1, "contract": { "requiredFields": [ @@ -1401,6 +1415,8 @@ "serialization", "identity", "invalidation", + "invalidationTopic", + "crossContext", "version", "persistence" ], @@ -1409,13 +1425,19 @@ "serialization": "string", "identity": "string", "invalidation": "string", + "invalidationTopic": "string", + "crossContext": "string", "version": "integer", "persistence": "string" }, "uniqueFields": [ - "namespace" + "namespace", + "invalidationTopic" ], "allowedValues": { + "crossContext": [ + "invalidate-only" + ], "persistence": [ "disabled" ] @@ -1426,14 +1448,18 @@ "namespace", "serialization", "identity", + "invalidationTopic", + "crossContext", "version", "persistence" ] }, "rows": { "REFERENCE_RESOURCE": { + "crossContext": "invalidate-only", "identity": "no-pii-token-or-raw-url", "invalidation": "reference resource namespace after successful mutation", + "invalidationTopic": "qinv.01k10f7m3w9p6r2c8v5n4x", "namespace": [ "reference-resource", 1 @@ -1447,7 +1473,7 @@ { "registryId": "FE-REG-TELEMETRY", "owner": "feature-frontend-diagnostics-telemetry-runtime", - "source": "src/contracts/telemetry.js", + "source": "src/contracts/telemetry.ts", "rowCount": 5, "contract": { "requiredFields": [ @@ -1629,7 +1655,7 @@ { "registryId": "FE-REG-RELEASE", "owner": "feature-frontend-release-cache-rollback-contract", - "source": "src/contracts/release-tokens.js", + "source": "src/contracts/release-tokens.ts", "rowCount": 8, "contract": { "requiredFields": [ diff --git a/config/contracts/registry-change-evidence.json b/config/contracts/registry-change-evidence.json index 62d2c3c..ee4bba1 100644 --- a/config/contracts/registry-change-evidence.json +++ b/config/contracts/registry-change-evidence.json @@ -1,4 +1,77 @@ { "schemaVersion": 1, - "changes": [] + "changes": [ + { + "changeId": "FE-REG-QUERY:$contract:allowedValues:contract-field-changed", + "versionBump": "Cross-context invalidation wire protocol starts at version 1.", + "migration": "Every installed query row declares invalidate-only; older tabs remain local-only.", + "compatibilityWindow": "Mixed releases are isolated by release cacheEpoch and never exchange query keys or values.", + "rollback": "Remove the coordinator composition and the two query registry fields.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-QUERY:$contract:breakingFields:contract-field-changed", + "versionBump": "Cross-context invalidation wire protocol starts at version 1.", + "migration": "Every installed query row declares its opaque topic and transport policy.", + "compatibilityWindow": "A release cacheEpoch rejects messages from a different deployed contract.", + "rollback": "Remove the coordinator composition and restore the prior query registry contract.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-QUERY:$contract:fieldTypes:contract-field-changed", + "versionBump": "Cross-context invalidation wire protocol starts at version 1.", + "migration": "The installed reference query row and all consumers were updated atomically.", + "compatibilityWindow": "Old clients do not consume the new fields; new clients validate them before boot.", + "rollback": "Restore the previous query registry field types and local-only invalidation.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-QUERY:$contract:requiredFields:contract-field-changed", + "versionBump": "Cross-context invalidation wire protocol starts at version 1.", + "migration": "Missing topics now fail registry validation instead of silently degrading at runtime.", + "compatibilityWindow": "Only a fully built release consumes its own installed registry snapshot.", + "rollback": "Remove the newly required fields and cross-context composition together.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-QUERY:$contract:uniqueFields:contract-field-changed", + "versionBump": "Cross-context invalidation wire protocol starts at version 1.", + "migration": "Existing query namespaces received unique registry-issued opaque topics.", + "compatibilityWindow": "Topics are scoped by release cacheEpoch, so mixed releases cannot collide.", + "rollback": "Drop invalidationTopic uniqueness after removing the transport consumer.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-STORAGE:$contract:allowedValues:contract-field-changed", + "versionBump": "Web Storage value codec contracts start at version 1.", + "migration": "Existing keys retain their physical schema version; values outside the selected codec are discarded.", + "compatibilityWindow": "Valid existing color-scheme and opaque-string records remain readable.", + "rollback": "Restore the previous registry contract; no physical key deletion is required.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-STORAGE:$contract:breakingFields:contract-field-changed", + "versionBump": "Web Storage value codec contracts start at version 1.", + "migration": "Each existing row received an explicit codec; executable migration was never consumed and is now forbidden.", + "compatibilityWindow": "Schema-versioned physical keys and the envelope shape remain unchanged.", + "rollback": "Remove valueCodec enforcement and restore the prior metadata declaration.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-STORAGE:$contract:fieldTypes:contract-field-changed", + "versionBump": "Web Storage value codec contracts start at version 1.", + "migration": "The dead function migration union was narrowed to the only implemented discard policy.", + "compatibilityWindow": "All installed definitions already used discard before this contract change.", + "rollback": "Restore the former type metadata without rewriting persisted records.", + "owner": "frontend-platform" + }, + { + "changeId": "FE-REG-STORAGE:$contract:requiredFields:contract-field-changed", + "versionBump": "Web Storage value codec contracts start at version 1.", + "migration": "All installed storage rows now declare their closed value codec.", + "compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.", + "rollback": "Remove the required codec field and runtime codec dispatch together.", + "owner": "frontend-platform" + } + ] } diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index 52d7c14..f33db32 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -8,7 +8,7 @@ "registries": [ { "registryId": "FE-REG-ROUTE", - "path": "src/features/installed-feature-contracts.js", + "path": "src/features/installed-feature-contracts.ts", "exportName": "ROUTE_REGISTRY", "owner": "feature-frontend-routing-release-recovery-runtime", "keyField": "routeId", @@ -92,7 +92,7 @@ }, { "registryId": "FE-REG-ROUTE-RUNTIME", - "path": "src/features/installed-feature-contracts.js", + "path": "src/features/installed-feature-contracts.ts", "exportName": "ROUTE_RUNTIME_CONTRACT", "owner": "feature-frontend-routing-release-recovery-runtime", "keyField": "routeId", @@ -141,7 +141,7 @@ }, { "registryId": "FE-REG-API", - "path": "src/features/installed-feature-contracts.js", + "path": "src/features/installed-feature-contracts.ts", "exportName": "API_OPERATIONS", "owner": "feature-frontend-api-client-response-envelope-contract", "keyField": "operationId", @@ -209,7 +209,7 @@ }, { "registryId": "FE-REG-SCHEMA", - "path": "src/features/installed-feature-contracts.js", + "path": "src/features/installed-feature-contracts.ts", "exportName": "SCHEMA_REGISTRY", "owner": "feature-frontend-contract-schema-registry", "keyField": "schemaId", @@ -241,7 +241,7 @@ }, { "registryId": "FE-REG-ENV", - "path": "src/contracts/env.js", + "path": "src/contracts/env.ts", "exportName": "ENV_REGISTRY", "owner": "feature-frontend-env-runtime-config-contract", "requiredFields": ["phase", "classification", "required", "defaultValue"], @@ -262,11 +262,11 @@ }, "consumers": [ { - "path": "src/bootstrap/runtime-config-schema.js", + "path": "src/bootstrap/runtime-config-schema.ts", "token": "APP_ENV" }, { - "path": "src/contracts/env.js", + "path": "src/contracts/env.ts", "token": "getBuildConfig" } ], @@ -274,7 +274,7 @@ }, { "registryId": "FE-REG-STORAGE", - "path": "src/contracts/storage-keys.js", + "path": "src/contracts/storage-keys.ts", "exportName": "STORAGE_REGISTRY", "owner": "feature-frontend-storage-registry-contract", "keyField": "logicalName", @@ -284,6 +284,7 @@ "backend", "classification", "schemaVersion", + "valueCodec", "ttl", "migration", "quotaFallback" @@ -294,8 +295,9 @@ "backend": "string", "classification": "string", "schemaVersion": "integer", + "valueCodec": "string", "ttl": "integer|string|null", - "migration": "string|function", + "migration": "string", "quotaFallback": "string" }, "uniqueFields": ["logicalName", "physicalKey"], @@ -304,7 +306,6 @@ "memory", "sessionStorage", "localStorage", - "indexedDB", "disabled", "forbidden" ], @@ -313,6 +314,12 @@ "opaque-cache", "sensitive-forbidden" ], + "valueCodec": [ + "color-scheme-v1", + "opaque-string-v1", + "none" + ], + "migration": ["discard"], "quotaFallback": ["memory", "no-persist", "feature-disable"] }, "consumerIdentityField": "logicalName", @@ -324,12 +331,13 @@ "backend", "classification", "schemaVersion", + "valueCodec", "migration" ] }, { "registryId": "FE-REG-ERROR", - "path": "src/contracts/errors.js", + "path": "src/contracts/errors.ts", "exportName": "ERROR_REGISTRY", "owner": "feature-frontend-error-classification-boundary-contract", "keyField": "kind", @@ -365,7 +373,7 @@ }, "consumers": [ { - "path": "src/adapters/http/client.js", + "path": "src/adapters/http/client.ts", "token": "failure(" } ], @@ -373,7 +381,7 @@ }, { "registryId": "FE-REG-QUERY", - "path": "src/features/installed-feature-contracts.js", + "path": "src/features/installed-feature-contracts.ts", "exportName": "QUERY_REGISTRY", "owner": "feature-frontend-server-state-caching-contract", "requiredFields": [ @@ -381,6 +389,8 @@ "serialization", "identity", "invalidation", + "invalidationTopic", + "crossContext", "version", "persistence" ], @@ -389,16 +399,19 @@ "serialization": "string", "identity": "string", "invalidation": "string", + "invalidationTopic": "string", + "crossContext": "string", "version": "integer", "persistence": "string" }, - "uniqueFields": ["namespace"], + "uniqueFields": ["namespace", "invalidationTopic"], "allowedValues": { + "crossContext": ["invalidate-only"], "persistence": ["disabled"] }, "consumers": [ { - "path": "src/features/reference-feature/contracts/reference-feature-contract.js", + "path": "src/features/reference-feature/contracts/reference-feature-contract.ts", "token": "referenceQueryKeys" } ], @@ -406,13 +419,15 @@ "namespace", "serialization", "identity", + "invalidationTopic", + "crossContext", "version", "persistence" ] }, { "registryId": "FE-REG-TELEMETRY", - "path": "src/contracts/telemetry.js", + "path": "src/contracts/telemetry.ts", "exportName": "TELEMETRY_REGISTRY", "owner": "feature-frontend-diagnostics-telemetry-runtime", "keyField": "eventName", @@ -440,7 +455,7 @@ }, "consumers": [ { - "path": "scripts/check-diagnostics.mjs", + "path": "scripts/check-diagnostics.ts", "token": "TELEMETRY_REGISTRY" } ], @@ -453,7 +468,7 @@ }, { "registryId": "FE-REG-RELEASE", - "path": "src/contracts/release-tokens.js", + "path": "src/contracts/release-tokens.ts", "exportName": "RELEASE_TOKEN_REGISTRY", "owner": "feature-frontend-release-cache-rollback-contract", "keyField": "token", @@ -466,7 +481,7 @@ "uniqueFields": ["token"], "consumers": [ { - "path": "src/bootstrap/load-release-manifest.js", + "path": "src/bootstrap/load-release-manifest.ts", "token": "assetManifestHash" } ], diff --git a/config/recipes/frontend-capability-recipes.json b/config/recipes/frontend-capability-recipes.json index 277676a..a7f79e9 100644 --- a/config/recipes/frontend-capability-recipes.json +++ b/config/recipes/frontend-capability-recipes.json @@ -28,69 +28,176 @@ { "id": "realtime", "status": "RECIPE_AVAILABLE", + "referenceRuntime": { + "status": "AVAILABLE_NOT_COMPOSED", + "coveredCapabilities": [ + "transport-independent event authority and recovery", + "bounded reconnect ownership", + "fetch-stream SSE", + "bounded polling", + "single-writer live and polling handoff", + "WebSocket closed protocol", + "Web Push window and Service Worker control" + ], + "sourceRoots": [ + "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" + ], + "conformanceScripts": [ + "test:unit", + "check:realtime-boundaries", + "check:realtime-boundaries:fixture", + "check:optional-recipes", + "test:realtime-removal" + ], + "productionComposition": false + }, "trigger": "The backend exposes ordered push events with a documented resume and authorization protocol.", "forbiddenWhen": ["Polling satisfies the measured freshness requirement.", "Event ordering and reconnect ownership are undefined."], - "boundary": "outbound connection plus inbound validated event adapter", - "port": "RealtimePort", - "fake": "FakeRealtimeAdapter", - "failureKinds": ["disconnect", "duplicate", "out-of-order", "auth-expiry"], - "lifecycleMethods": ["unsubscribe"], + "boundary": "transport-independent event authority plus separately owned SSE, WebSocket, bounded polling and Web Push adapters", + "port": "RealtimeEventAuthority / WebPushControlPort / transport-specific connection and polling factories", + "fake": "Deterministic event authority, transport facade, clock, repository and Service Worker test doubles", + "failureKinds": ["abort", "disconnect-or-timeout", "protocol-or-mapping-mismatch", "duplicate-or-stale", "sequence-gap-or-cursor-expiry", "queue-overflow", "scope-fenced", "poll-budget-exhausted", "push-permission-or-subscription-failure"], + "lifecycleMethods": ["close-or-dispose", "unsubscribe", "cancel-via-AbortSignal", "bounded-poll-lease", "revoke-push-association"], "owner": "project-owner-required", - "securityPrivacy": ["Validate every event envelope.", "Never place credentials in URLs or telemetry.", "Refresh authorization through the session boundary."], - "bundleBudgetGzipBytes": 12000, + "securityPrivacy": ["Validate every event envelope and closed transport frame before application effects.", "Bind stream state to the current opaque scope generation and advance checkpoints only after committed effects or authoritative recovery.", "Use fixed same-origin endpoints and an exact WebSocket subprotocol; never place credentials, cursors, subscription material or scope bindings in URLs or telemetry.", "Treat Web Push as a notification hint, fence registration and revocation with one durable compare-and-swap control record, and allow only registry-owned notification and route intents.", "Keep every queue, parser, reconnect, poll, notification and storage operation bounded and abortable."], + "bundleBudgetGzipBytes": 40000, "fallback": "Bounded polling or explicitly stale UI.", - "removal": ["Remove composition registration.", "Remove adapter and vendor dependency.", "Run recipe-removal and production-bundle gates."], + "removal": ["Disable admission and close active readers, sockets, poll leases and worker handlers.", "Revoke and purge only the owned Web Push association and notification state.", "Remove composition, registries, adapters and any selected vendor dependency.", "Run realtime boundary, runtime-removal and production-bundle gates."], "serverStatePolicy": "query-cache-owned" }, { "id": "offline-indexeddb", "status": "RECIPE_AVAILABLE", - "trigger": "A product requirement needs durable offline data or a durable command queue beyond small public preferences.", - "forbiddenWhen": ["The data contains credentials.", "The browser would connect directly to a database or object store.", "A normal HTTP cache is sufficient."], - "boundary": "application-owned versioned repository output port", - "port": "VersionedOfflineRepository", - "fake": "MemoryOfflineRepository", - "failureKinds": ["quota", "corruption", "migration-rollback"], - "lifecycleMethods": ["close"], + "referenceRuntime": { + "status": "AVAILABLE_NOT_COMPOSED", + "coveredCapabilities": [ + "IndexedDB", + "OPFS", + "StorageManager estimate/persistence" + ], + "sourceRoots": [ + "src/application/ports/browser-file-storage/indexeddb-port.ts", + "src/application/ports/browser-file-storage/opfs-ports.ts", + "src/application/ports/browser-file-storage/storage-durability-port.ts", + "src/adapters/browser-file-storage", + "src/adapters/storage/indexeddb", + "src/adapters/storage/opfs" + ], + "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": "A product requirement needs indexed offline records, an unsynced command queue, or a large local binary sidecar beyond small public preferences.", + "forbiddenWhen": ["The data contains credentials.", "The browser would connect directly to a server database or object store.", "A normal HTTP cache is sufficient.", "Partition, retention, quota and recovery ownership are undefined."], + "boundary": "feature-specific async repository with registry-issued opaque dataset scope and immutable full-policy binding, plus an OPFS large-object sidecar whose logical commit authority and bidirectional scope binding are owned by an IndexedDB journal", + "port": "IndexedDbRepositoryPort / IndexedDbMaintenancePort / DurableObjectStorePort / DurableObjectMaintenancePort / StorageDurabilityPort", + "fake": "MemoryStructuredOfflineStore / MemoryDurableObjectStore / MemoryStorageDurabilityAdapter", + "failureKinds": ["open-blocked", "versionchange", "quota", "corruption", "migration-rollback", "revision-conflict", "storage-eviction", "partial-object-write", "dataset-binding-mismatch", "dataset-budget-exceeded", "lifecycle-authorization-denied", "expired-resource"], + "lifecycleMethods": ["close", "cancel-via-AbortSignal", "enforce-bounded-lifecycle-batch", "prune-expired-receipts", "reconcile", "enforcePolicies-with-composition-authority"], "owner": "project-owner-required", - "securityPrivacy": ["Classify persisted fields.", "Encrypting in the same client is not a credential protection boundary.", "Version and test every migration."], - "bundleBudgetGzipBytes": 8000, - "fallback": "Online-only query path with an explicit offline state.", - "removal": ["Stop writes.", "Migrate or purge owned stores.", "Remove repository composition and dependency."], + "securityPrivacy": ["Classify every persisted field and binary namespace.", "Encrypting in the same client is not a credential protection boundary.", "Keep database schema and record codec versions separate.", "Derive the physical IndexedDB name only from registry-issued authority, namespace and partition tokens; readable namespace, business and account IDs are forbidden.", "Persist and revalidate an immutable scope plus full BrowserStoragePolicy binding during upgrade, post-open and maintenance; missing or mismatched existing bindings fail closed.", "Use the actual IndexedDB wire split: StoredRecord contains only key, codecVersion, revision and payload; writtenAtEpochMs, synchronization, measuredBytes and eligibleAtEpochMs belong to the retention sidecar, while idempotency receipts and governance binding/budget use separate stores.", "Include every store registered in lifecycleMetadataStores in bounded full-partition purge while retaining immutable governance identity.", "Measure conservative logical bytes through the codec and atomically enforce dataset usedBytes plus receiptCount with record, lifecycle and migration writes.", "Enforce TTL before sweep visibility, delete UNTIL_SYNCED only after explicit confirmation, and require a composition-authorized short-lived proof for every deleting IndexedDB lifecycle batch.", "Bound idempotency receipt retention to 31 days and configured count to the implementation ceiling of 1000000; bound migration to old-writer-drained batches no larger than 500 rows or 30000ms.", "Bind OPFS readable and physical scopes in both directions and use only /ca-frontend-opfs-v1/authorities//// 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", - "trigger": "Installability or a measured offline-shell requirement is approved with cache ownership.", - "forbiddenWhen": ["Hosting cache and worker cache ownership conflict.", "Update and rollback UX is undefined."], - "boundary": "bootstrap update controller and cache policy adapter", - "port": "ServiceWorkerUpdatePort", - "fake": "FakeServiceWorkerUpdateAdapter", - "failureKinds": ["stale-worker", "update-loop", "offline-fallback"], - "lifecycleMethods": ["unregister", "rollback"], + "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": ["Never cache authenticated API responses by default.", "Bind cache names to release identity.", "Fail closed on malformed update metadata."], + "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 owned caches.", "Remove worker registration and manifest."], + "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", - "trigger": "The product accepts or delivers files with progress and cancellation requirements.", - "forbiddenWhen": ["Allowed size and MIME policy is missing.", "Long-lived credentials would be embedded in URLs."], - "boundary": "application file transfer output port behind an authorized backend protocol", - "port": "FileTransferPort", - "fake": "FakeFileTransferAdapter", - "failureKinds": ["size-rejection", "type-rejection", "abort", "expired-url"], - "lifecycleMethods": ["cancel-via-AbortSignal"], + "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 MIME as untrusted metadata.", "Use short-lived opaque resource identifiers.", "Redact file names when classified as personal data."], - "bundleBudgetGzipBytes": 6000, - "fallback": "Standard request with bounded size and no background continuation.", - "removal": ["Cancel active transfers.", "Remove route actions and composition.", "Remove transfer dependency."], + "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" }, { diff --git a/config/security/secret-scan-policy.json b/config/security/secret-scan-policy.json index eb21d40..1c39b85 100644 --- a/config/security/secret-scan-policy.json +++ b/config/security/secret-scan-policy.json @@ -11,9 +11,9 @@ ".storybook", "package.json", "pnpm-lock.yaml", - "vite.config.js", - "vitest.config.js", - "playwright.config.js" + "vite.config.ts", + "vitest.config.ts", + "playwright.config.ts" ], "generatedRoots": ["dist", "artifacts/release"], "excludedPaths": [ diff --git a/config/testing/risk-coverage.json b/config/testing/risk-coverage.json index 27445cf..2346fb5 100644 --- a/config/testing/risk-coverage.json +++ b/config/testing/risk-coverage.json @@ -8,7 +8,7 @@ }, "criticalModules": [ { - "path": "src/adapters/http/retry-policy.js", + "path": "src/adapters/http/retry-policy.ts", "minimum": { "lines": 80, "statements": 78, @@ -17,7 +17,7 @@ } }, { - "path": "src/adapters/storage/browser-storage-adapter.js", + "path": "src/adapters/storage/browser-storage-adapter.ts", "minimum": { "lines": 60, "statements": 60, @@ -26,7 +26,7 @@ } }, { - "path": "src/adapters/telemetry/best-effort-telemetry.js", + "path": "src/adapters/telemetry/best-effort-telemetry.ts", "minimum": { "lines": 85, "statements": 85, @@ -35,7 +35,16 @@ } }, { - "path": "src/application/policies/compatibility.js", + "path": "src/application/create-application.ts", + "minimum": { + "lines": 90, + "statements": 90, + "functions": 80, + "branches": 68 + } + }, + { + "path": "src/application/policies/compatibility.ts", "minimum": { "lines": 95, "statements": 95, @@ -44,7 +53,7 @@ } }, { - "path": "src/application/policies/performance-budgets.js", + "path": "src/application/policies/performance-budgets.ts", "minimum": { "lines": 80, "statements": 80, @@ -53,7 +62,7 @@ } }, { - "path": "src/application/policies/promotion-readiness.js", + "path": "src/application/policies/promotion-readiness.ts", "minimum": { "lines": 95, "statements": 95, @@ -62,7 +71,7 @@ } }, { - "path": "src/application/use-cases/decide-chunk-recovery.js", + "path": "src/application/use-cases/decide-chunk-recovery.ts", "minimum": { "lines": 90, "statements": 90, @@ -80,7 +89,25 @@ } }, { - "path": "scripts/lib/registry-compatibility.mjs", + "path": "src/features/reference-feature/adapters/reference-http-gateway.ts", + "minimum": { + "lines": 90, + "statements": 90, + "functions": 90, + "branches": 90 + } + }, + { + "path": "src/presentation/adapters/query/application-query.ts", + "minimum": { + "lines": 90, + "statements": 90, + "functions": 90, + "branches": 80 + } + }, + { + "path": "scripts/lib/registry-compatibility.ts", "minimum": { "lines": 80, "statements": 80, diff --git a/config/testing/test-evidence.json b/config/testing/test-evidence.json new file mode 100644 index 0000000..4da088b --- /dev/null +++ b/config/testing/test-evidence.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "scenarioCatalogs": [ + { + "owner": "reference-feature", + "path": "tests/mocks/scenarios/catalog.ts", + "arrayExport": "HTTP_SCENARIO_IDS", + "minimumEntries": 19 + } + ], + "sourceContracts": [ + { + "owner": "reference-feature", + "path": "tests/mocks/handlers/reference-resources.ts", + "requiredTokens": [ + "assertOperationScenario", + "../scenarios/catalog.ts" + ] + } + ] +} 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..169fc18 --- /dev/null +++ b/docs/architecture/api-contract-schema-mapper-and-server-state.md @@ -0,0 +1,965 @@ +# API contract, Schema, Mapper와 Server State platform + +- 상태: 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..84974c6 --- /dev/null +++ b/docs/architecture/backend-api-and-server-state-contract.md @@ -0,0 +1,710 @@ +# Backend API와 Server State handoff contract + +- 상태: 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..286221f --- /dev/null +++ b/docs/architecture/client-cache-and-storage.md @@ -0,0 +1,1672 @@ +# Client cache and browser storage platform + +- 상태: 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 index 83c1e23..408a0c7 100644 --- a/docs/architecture/decisions/VD-01-typescript-lint-tooling.md +++ b/docs/architecture/decisions/VD-01-typescript-lint-tooling.md @@ -36,10 +36,20 @@ JS/JSX/TS/TSX가 같은 품질 게이트를 통과하게 해야 한다. 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를 모두 검사한다. -- JS invalid-call, TS invalid port, TS discriminated-union fixture는 실패해야 한다. +- TS invalid-call, invalid port, discriminated-union fixture는 실패해야 한다. - ESLint와 dependency-cruiser는 TS/TSX architecture fixture를 검사한다. - registry scanner는 TS registry의 required field, uniqueness와 reference를 검증한다. diff --git a/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md b/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md index 8938441..0204c94 100644 --- a/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md +++ b/docs/architecture/decisions/VD-06-intl-typed-message-catalog.md @@ -57,8 +57,8 @@ route/form/failure 의미 값 ``` - canonical catalog: `src/presentation/i18n/catalog.ts` -- feature contribution: `src/features/*/contracts/*-message-catalog.js`를 - `src/features/installed-feature-messages.js`에서 조립 +- 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` diff --git a/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md index c45c585..bb7a2ec 100644 --- a/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md +++ b/docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md @@ -62,12 +62,12 @@ route/application/HTTP/cache/storage/bootstrap ``` - diagnostics contract: `src/contracts/diagnostics.ts` -- telemetry contract: `src/contracts/telemetry.js` +- 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.js` -- composition: `src/bootstrap/runtime-adapters.js` +- best-effort telemetry: `src/adapters/telemetry/best-effort-telemetry.ts` +- composition: `src/bootstrap/runtime-adapters.ts` ## 검증 diff --git a/docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md b/docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md index 49a30d1..4f8332b 100644 --- a/docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md +++ b/docs/architecture/decisions/VD-08-storybook-and-visual-evidence.md @@ -50,9 +50,9 @@ application bundle에 workshop runtime을 포함하는 것 모두 적절하지 - interaction/a11y: `tests/storybook/workshop.spec.ts` - visual: `tests/visual/platform.visual.spec.ts` - baseline: `tests/visual/__snapshots__/` -- production E2E: `playwright.config.js` -- local dev E2E: `playwright.dev.config.js` -- evidence policy: `scripts/check-test-evidence.mjs` +- 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를 함께 검사한다. diff --git a/docs/architecture/decisions/VD-09-supply-chain-evidence.md b/docs/architecture/decisions/VD-09-supply-chain-evidence.md index f609ee2..48d9008 100644 --- a/docs/architecture/decisions/VD-09-supply-chain-evidence.md +++ b/docs/architecture/decisions/VD-09-supply-chain-evidence.md @@ -66,10 +66,10 @@ source/config/lock + production dist ``` - policy: `config/security/` -- generator: `scripts/generate-supply-chain.mjs` -- coherence: `scripts/verify-supply-chain-artifacts.mjs` -- secret scan: `scripts/security-scan.mjs` -- reproducibility: `scripts/verify-reproducible-build.mjs` +- 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` diff --git a/docs/architecture/decisions/VD-10-optional-capability-recipes.md b/docs/architecture/decisions/VD-10-optional-capability-recipes.md index 77bf75c..23faee3 100644 --- a/docs/architecture/decisions/VD-10-optional-capability-recipes.md +++ b/docs/architecture/decisions/VD-10-optional-capability-recipes.md @@ -48,8 +48,15 @@ fallback, fake와 제거 기준을 다시 설계해야 한다. 따라서 product 접속하는 recipe도 금지한다. 8. lifecycle이 있는 capability는 unsubscribe, close, unregister, dispose, cancel 또는 `AbortSignal`을 계약과 contract test에 포함해야 한다. -9. 선택하지 않은 recipe sentinel이나 vendor dependency가 production bundle에 - 들어가면 gate를 실패시킨다. +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가 통과해야 한다. @@ -77,13 +84,20 @@ 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.mjs` + `scripts/check-optional-recipes.ts` - removal: - `scripts/test-optional-recipe-removal.mjs` + `scripts/test-optional-recipe-removal.ts` - evidence: `artifacts/quality/optional-recipes.json`, `artifacts/quality/optional-recipe-fixtures.json`, 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 index 222b8fd..06a6fd1 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -57,17 +57,19 @@ P0/P1 acceptance와 P2 recipe 기본값은 `LOCAL_TEMPLATE_READY`다. 다만 실 | 영역 | 현재 판정 | 근거 | 필요한 다음 상태 | | --- | --- | --- | --- | | 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 | -| 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | -| application facade | 준비됨 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 | -| HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 | -| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off, logical execution당 bounded diagnostics | terminal event 중복 방지 계약 유지 | -| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | -| 검증 | 준비됨 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 | +| 계층 의존 방향 | 준비됨 | 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 저장소는 추가하지 않음 | -| 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 | +| 서버 상태 | 기본 경로 준비됨 + 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, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 | +| 라우팅 | 준비됨 | 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은 외부 프로젝트에서 확장 | @@ -83,7 +85,15 @@ P0/P1 acceptance와 P2 recipe 기본값은 `LOCAL_TEMPLATE_READY`다. 다만 실 | 샘플 제거 | 준비됨 | 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·offline·file 등 | 준비됨/프로젝트 선택 | 12개 opt-in TypeScript port/fake/unavailable, failure/security/bundle/removal gate | 실제 요구·owner 승인 시 해당 recipe만 설치 | +| 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. 우선순위별 발견 사항 @@ -91,7 +101,7 @@ P0/P1 acceptance와 P2 recipe 기본값은 `LOCAL_TEMPLATE_READY`다. 다만 실 #### RP-02에서 application 런타임 우회 해결 -`src/bootstrap/composition-root.js`가 만든 typed application input API는 +`src/bootstrap/composition-root.ts`가 만든 typed application input API는 production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와 release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime query만 사용한다. @@ -125,18 +135,19 @@ rollback, conflict resolution과 invalidation을 검증한다. 다른 presentati #### RP-01에서 TypeScript 검사 도구 안전망 구현 -현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은 -중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다. +초기 source는 JS/JSX와 `strict + allowJs + checkJs`를 사용했다. RP-01 후속 +migration으로 현재 product source, tests, Node scripts와 지원 config는 모두 +TS/TSX이며 `allowJs`는 꺼져 있다. -- ESLint의 계층·보안 glob은 JS/JSX 중심이다. -- registry scanner는 `.ts`와 `.tsx`를 찾지 않는다. -- registry governance 경로가 `.js` 확장자로 고정되어 있다. -- tests는 현재 `tsconfig.json` 검사 범위에서 빠진다. +- 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` + 재유입을 거절한다. -따라서 파일 확장자를 먼저 바꾸면 새 TS 코드가 일부 자동 검사에서 빠질 수 있다. -TypeScript 전환은 -[TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html) -처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다. +전환은 tooling glob과 CI를 먼저 고친 뒤 계약 계층부터 화면·테스트·운영 script +순서로 완료했다. Node 24가 운영 `.ts` script를 직접 실행하고 NodeNext strict +typecheck가 같은 경로를 검증한다. #### RP-03에서 HTTP 선언과 실행의 차이 해결 @@ -153,7 +164,7 @@ timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 #### RP-04에서 route registry를 실행 계약으로 전환 -platform route 계약과 `src/features/installed-feature-contracts.js`의 직렬화 +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만 @@ -182,9 +193,11 @@ DTO/schema, mapper, route/API/query contract, query/mutation controller와 page `test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck, -architecture, registry, unit/integration, home smoke, build와 fixture ID 잔여 -0개를 검사한다. 설치 모드에서는 MSW를 사용한 bootstrap → router → application -→ HTTP → schema → mapper → query cache → page 수직 테스트가 실행된다. +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 수직 테스트가 실행된다. #### 비동기·복구 상태의 불변식이 닫혀 있지 않다 @@ -273,11 +286,14 @@ vendor facade, 선택 조건, 실패 정책, 테스트 fixture를 문서로 제 | capability | 대표 기술 | 기본 제공할 경계 | 실제 설치 조건 | | --- | --- | --- | --- | -| realtime | WebSocket, SSE | subscribe/unsubscribe, reconnect, resume, heartbeat | 서버가 push event를 제공할 때 | -| offline storage | IndexedDB | versioned repository, migration, quota failure | offline read/write가 제품 요구일 때 | -| background/cache | Service Worker, PWA | cache ownership, update, rollback recipe | installable/offline 앱일 때 | -| file transfer | presigned HTTP, multipart | progress, cancel, size/type validation | 업로드·대용량 다운로드가 있을 때 | -| generated API | OpenAPI, GraphQL, gRPC-Web | generated client를 gateway 뒤에 감싸는 규칙 | 서버 계약 형식이 확정됐을 때 | +| 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 | 탭 간 동기화가 필요할 때 | @@ -286,18 +302,46 @@ vendor facade, 선택 조건, 실패 정책, 테스트 fixture를 문서로 제 | large data UI | virtualization, data grid | owned component facade | 데이터 규모가 측정 기준을 넘을 때 | | analytics/error sink | vendor SDK, OpenTelemetry | redaction, consent, sampling adapter | 운영 provider와 정책이 정해졌을 때 | -12개 항목의 현재 상태는 모두 `RECIPE_AVAILABLE / NOT_INSTALLED`다. +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가 있다. 도입 절차는 -`docs/architecture/optional-adapter-recipes.md`를 따른다. +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/gRPC-Web, realtime, file transfer, cache, storage, -worker, browser capability 같은 프로토콜·런타임 capability다. +vendor가 아니라 HTTP/GraphQL/Connect/gRPC-Web/REST Gateway, realtime, file +transfer, cache, storage, worker, browser capability 같은 프로토콜·런타임 +capability다. ### 성능 최적화는 모두 adapter 문제인가 @@ -382,13 +426,15 @@ bootstrap → React TSX → tests 순서로 이동한다. ### retry, API client, logger, token manager, error, validation은 어디에 있는가 -- retry: `src/adapters/http/retry-policy.js`, 부분 준비 -- API client: `src/adapters/http/client.js`, 부분 준비 +- 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.js`와 HTTP normalization, 부분 준비 -- validation: runtime/API Zod는 존재, route/form/domain 분리는 미완성 +- 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을 일반 해법으로 만들면 보안 위험이 커지기 때문이다. @@ -468,6 +514,8 @@ 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) diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md index 2d04e9b..542aa19 100644 --- a/docs/architecture/frontend-platform-implementation-roadmap.md +++ b/docs/architecture/frontend-platform-implementation-roadmap.md @@ -157,19 +157,19 @@ CI gate를 통과하게 한다. 애플리케이션 전체를 일괄 변환하지 - root, application/source, tests용 TypeScript project/reference를 분리한다. - `src`, `tests`, `scripts`의 허용 확장자와 build output 제외 범위를 명시한다. -- ESLint, dependency-cruiser, registry scanner와 architecture script의 JS-only - glob 및 `.js` 고정 경로를 JS/TS 양쪽으로 확장한다. +- ESLint, dependency-cruiser, registry scanner와 architecture script의 검색 + 범위를 TS/TSX source와 fixture로 통일한다. - Vite, Vitest와 Playwright config가 TS source/test 오류를 우회하지 않게 한다. -- 기존 JS에는 `checkJs`, 새 TS에는 strict 정책을 적용한다. +- 기존 fixture까지 TS/TSX로 전환하고 strict 정책을 적용한다. - port, registry와 forbidden import용 `.ts`/`.tsx` fixture를 추가한다. **자동 테스트와 negative test** -- JS와 TS source/test를 함께 typecheck하는 smoke +- 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 실패 -- 기존 JS invalid-call fixture도 계속 실패 +- 변환된 TS invalid-call fixture도 계속 실패 - `pnpm lint`, `pnpm check:types`, `pnpm check:architecture`, `pnpm check:registries`, `pnpm test:all`, `pnpm build` @@ -177,7 +177,7 @@ CI gate를 통과하게 한다. 애플리케이션 전체를 일괄 변환하지 - 허용된 모든 source/test 확장자가 typecheck, lint, architecture, registry gate 중 필요한 검색 범위에 포함된다. -- JS 안전망이 약해지지 않은 상태에서 TS fixture가 CI에 의해 차단된다. +- 모든 TS/TSX negative fixture가 CI에 의해 차단된다. - 광범위한 unchecked cast나 임시 `any`로 통과시키지 않는다. - production source 대량 변환 없이 독립적으로 revert할 수 있다. @@ -296,6 +296,29 @@ adapter로 연결한다. 표준 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 또는 @@ -826,11 +849,11 @@ owner와 만료 시한이 있는 quarantine만 허용한다. 사용한다. - 기본 Playwright는 `build` + `preview`의 실제 `dist`를 Chromium, Firefox, WebKit에서 검사하고 별도 compact project를 제공한다. 개발 피드백용 Vite - profile은 `playwright.dev.config.js`로 분리했다. + 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와 9개 high-risk module을 대상으로 40개 scoped threshold를 +- 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를 제공한다. @@ -959,10 +982,10 @@ gate 없이는 production promotion을 통과했다고 보지 않는다. | recipe | 기본 경계 | 반드시 다룰 실패 | | --- | --- | --- | -| realtime | subscribe/unsubscribe, reconnect, resume, heartbeat | disconnect, duplicate/out-of-order, auth expiry | -| offline/IndexedDB | versioned repository와 migration | quota, corruption, migration rollback | -| Service Worker/PWA | cache ownership와 update controller | stale worker, update loop, offline fallback | -| file transfer | upload/download, progress, cancel | size/type rejection, abort, expired URL | +| 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 | @@ -1027,6 +1050,137 @@ revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 co 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 @@ -1042,6 +1196,14 @@ revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 co | 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와 재검토 시점을 기록한다. @@ -1054,7 +1216,7 @@ revert하고 RP-11을 유지한다. 여러 vendor를 되돌릴 수 없는 한 co | checkpoint | 포함 범위 | 다음 단계 진입 조건 | 대표 rollback 사유 | | --- | --- | --- | --- | -| RP-01 | tooling | TS/JS fixture와 gate 통과 | TS 파일이 검사에서 누락 | +| 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 | diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index 313c93c..2fdc678 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -116,7 +116,7 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다 | `src/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter | | `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root | | `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 | -| `src/features/reference-feature` | 완전한 제거 가능 수직 예제 | installed contribution과 8단계 제거 gate 유지 | +| `src/features/reference-feature` | 완전한 제거 가능 수직 예제 | installed contribution과 제거 gate 유지 | 현재 구조가 잘 제공하는 기반은 다음과 같다. @@ -132,10 +132,11 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다 RP-02 구현으로 다음 경계는 실행 경로에 연결됐다. -- `src/bootstrap/composition-root.js`가 만든 application을 production +- `src/bootstrap/composition-root.ts`가 만든 application을 production `ApplicationProvider`가 실제 React tree에 주입한다. -- `createApplication`은 session, preference, diagnostics, runtime query의 - input API만 반환하며 storage, telemetry, release output port를 숨긴다. +- `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와 @@ -153,6 +154,16 @@ RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다. 정리를 테스트한다. - 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의 @@ -163,6 +174,12 @@ contract/presentation은 `src/features/reference-feature`가 소유하고, gener 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로 감싼다. @@ -198,7 +215,7 @@ adapter만 교체한다. | 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 | 필수 | -| 외부 push event 처리 | inbound | event input port | WebSocket/SSE listener | 선택 | +| 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 | @@ -396,15 +413,19 @@ Inbound adapter가 해당 type을 application command와 query로 변환한다. ### 8.3 예측 가능한 실패를 typed result로 반환한다 검증 실패, 인증 필요, conflict, network failure처럼 사용자 흐름에 -포함되는 실패는 `Result`로 반환한다. programmer error와 +포함되는 실패는 `Result`로 반환한다. programmer error와 불변식 위반을 모두 일반 API 실패로 숨기지는 않는다. ```ts -export type Result = +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` 한 개에 모든 메서드를 계속 추가하지 않는다. @@ -885,9 +906,11 @@ capability의 기본 정책, port 또는 안전한 no-op 구현과 composition | Adapter | 도입 조건 | 기본 상태 | | --- | --- | --- | -| WebSocket/SSE | 실시간 server event 필요 | opt-in recipe 제공, 미설치 | -| IndexedDB | 큰 offline data 또는 durable queue 필요 | opt-in recipe 제공, 미설치 | -| Service Worker/PWA | offline shell과 installability 필요 | opt-in recipe 제공, 미설치 | +| 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 뒤에 미설치 | @@ -896,7 +919,8 @@ capability의 기본 정책, port 또는 안전한 no-op 구현과 composition | OpenTelemetry | 조직 trace 연계 필요 | opt-in recipe 제공, 미설치 | | Web Worker | CPU 작업이 main thread를 막음 | opt-in recipe 제공, 미설치 | | Notification | 사용자 권한 기반 browser notification 필요 | opt-in recipe 제공, 미설치 | -| Clipboard/File/Media | 해당 browser capability 필요 | 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 제공, 미설치 | @@ -906,11 +930,24 @@ capability의 기본 정책, port 또는 안전한 no-op 구현과 composition 선택 adapter는 “나중에 쓸 수 있으므로” 기본 bundle에 넣지 않는다. 도입 조건, 보안 영향, bundle 비용과 제거 방법이 확인된 경우에만 추가한다. -현재 구현된 공통 catalog, TypeScript contract/fake와 blocking gate는 +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 @@ -1213,8 +1250,8 @@ design-system primitive를 조합하고 controller hook을 통해 application을 | Reference feature | full vertical integration과 complete removal | TypeScript 전환 후에는 source뿐 아니라 test와 architecture/security -fixture도 typecheck 또는 lint 대상이어야 한다. `.ts/.tsx`가 JS 전용 glob을 -우회하지 않도록 한다. +fixture도 typecheck 또는 lint 대상이어야 한다. 모든 실행 fixture를 +`.ts/.tsx`로 유지해 확장자별 검사 우회를 허용하지 않는다. Coverage는 단순 report 생성이 아니라 branch/function/line threshold를 blocking gate로 둔다. 수치만 올리기 위한 구현 세부 테스트보다 port @@ -1230,13 +1267,13 @@ contract와 실패 분기를 우선한다. - [ ] `main`은 raw ports 대신 application API를 제공한다. - [ ] `contracts`는 unrestricted 우회 계층이 아니다. - [ ] dependency rule과 문서의 source of truth가 하나다. -- [ ] TypeScript와 TSX도 동일한 architecture/security lint를 받는다. +- [x] TypeScript와 TSX도 동일한 architecture/security lint를 받는다. ### 26.2 Runtime composition -- [ ] runtime timeout/retry 설정이 실제 HTTP transport에 반영된다. -- [ ] QueryClient와 feature query bridge가 실제 route에서 동작한다. -- [ ] session UI API와 credential attachment가 분리되어 있다. +- [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`가 @@ -1245,8 +1282,9 @@ contract와 실패 분기를 우선한다. ### 26.3 HTTP와 validation - [ ] path, search, body를 각각 검증하고 직렬화한다. -- [ ] schema가 반환한 normalized data를 실제 request에 사용한다. -- [ ] timeout과 AbortSignal listener가 모든 반환 경로에서 정리된다. +- [x] schema가 반환한 normalized data를 실제 request에 사용한다. +- [x] 생성된 timeout과 AbortSignal listener가 현재 HTTP attempt의 terminal + 경로에서 정리된다. - [ ] retry는 runtime cap, idempotency와 `Retry-After`를 따른다. - [ ] raw payload와 credential이 failure나 log에 포함되지 않는다. @@ -1256,7 +1294,7 @@ contract와 실패 분기를 우선한다. - [x] params/search schema가 실제 navigation에서 실행된다. - [x] loading/error/chunk/access metadata가 실행 behavior와 연결된다. - [ ] local, URL, server, session, persisted state가 분류 규칙을 따른다. -- [ ] server state를 별도 global store에 중복 보관하지 않는다. +- [x] reference server state를 별도 global store에 중복 보관하지 않는다. ### 26.5 Reference feature @@ -1268,7 +1306,7 @@ contract와 실패 분기를 우선한다. ### 26.6 품질 -- [ ] source와 test가 strict TypeScript 검사를 받는다. +- [x] source와 test가 strict TypeScript 검사를 받는다. - [ ] React Hooks와 JSX accessibility lint가 blocking이다. - [ ] coverage threshold가 blocking이다. - [ ] MSW integration, component, 3-engine E2E와 axe가 통과한다. @@ -1298,10 +1336,12 @@ contract와 실패 분기를 우선한다. ### P2: 프로젝트별 선택 capability -WebSocket/SSE, offline/IndexedDB, Service Worker, feature flag, product +SSE/WebSocket, Web Push, bounded polling, offline/IndexedDB, Service Worker, feature flag, product analytics, vendor error reporting, worker, virtualization, OpenAPI generation, -global store와 cloud visual-review service는 실제 프로젝트 요구와 측정 결과에 -따라 추가한다. +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 index 5d278a0..45d1c35 100644 --- a/docs/architecture/layers.md +++ b/docs/architecture/layers.md @@ -10,6 +10,7 @@ adapters implement application-owned ports and are assembled only in | `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: @@ -18,25 +19,41 @@ The following edges are forbidden: - 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. -This table is the current coarse-grained rule. The -[ports, adapters, and feature-boundary target](./frontend-ports-adapters-and-boundaries.md) -defines the missing application input boundary, explains that `presentation` -acts as the inbound adapter, and separates current outbound adapters from -project-selected capabilities. The -[platform capability review](./frontend-platform-capability-review.md) records -where the current composition still bypasses this intended rule. +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. -Architecture reports use this shape: +`check:architecture` keeps dependency-cruiser's report and adds the +authoritative TypeScript-aware graph below it: ```json { - "schemaVersion": 1, - "generatedAt": "ISO-8601", - "rules": [{ "name": "rule-id", "severity": "error", "violations": 0 }], - "summary": { "errors": 0, "warnings": 0 } + "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 index 8046985..d05f57e 100644 --- a/docs/architecture/optional-adapter-recipes.md +++ b/docs/architecture/optional-adapter-recipes.md @@ -2,8 +2,13 @@ 이 문서는 도메인과 무관한 선택형 frontend capability를 실제 프로젝트에 도입하는 실행 가이드다. 기본 스켈레톤에는 vendor runtime을 설치하지 않는다. -`RECIPE_AVAILABLE`은 계약·fake·failure policy가 준비됐다는 뜻이며 실제 provider, -runtime behavior 또는 production readiness를 뜻하지 않는다. +`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. 현재 상태와 파일 지도 @@ -14,13 +19,57 @@ runtime behavior 또는 production readiness를 뜻하지 않는다. | TypeScript port | `recipes/frontend-capabilities/contracts.ts` | 아니오 | | fake/unavailable | `recipes/frontend-capabilities/fake-adapters.ts` | 아니오 | | contract test | `tests/recipes/optional-capability-contracts.test.ts` | 아니오 | -| 정적/번들 gate | `scripts/check-optional-recipes.mjs` | build 도구 | -| negative fixture | `scripts/check-optional-recipe-fixtures.mjs` | 아니오 | -| 완전 제거 gate | `scripts/test-optional-recipe-removal.mjs` | 아니오 | +| 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 모두 선택되지 -않았다. TypeScript example은 product source가 import할 library가 아니라 선택 -시 복사하고 좁힐 출발점이다. +않았다. `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. 어느 경계에 두는가 @@ -40,10 +89,10 @@ type은 facade 밖으로 노출하지 않는다. | recipe | 설치하는 경우 | 설치하면 안 되는 경우 | 핵심 fallback | | --- | --- | --- | --- | -| realtime | ordered push/resume protocol이 확정됨 | polling이 충분하거나 ordering owner 없음 | bounded polling/stale UI | -| offline/IndexedDB | durable offline data/queue가 제품 요구 | credential 저장, DB 직접 연결, HTTP cache로 충분 | online-only + offline state | -| Service Worker/PWA | install/offline shell과 cache owner 승인 | update/rollback UX 없음 | hosting cache 기반 network app | -| file transfer | progress/cancel/size/type 정책 필요 | long-lived credential URL | bounded normal request | +| 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 | @@ -57,6 +106,13 @@ type은 facade 밖으로 노출하지 않는다. 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와 측정값으로 기록한다. @@ -86,19 +142,67 @@ SSOT다. 문서와 catalog가 다르면 gate가 검사하는 catalog를 우선 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은 없다. -### Offline/Service Worker +### IndexedDB/OPFS -- store/cache 이름과 schema는 release와 독립적인 migration version을 가진다. -- quota, corrupt row, partial migration, downgrade/rollback을 fixture로 만든다. -- authenticated response와 credential은 기본 cache 대상이 아니다. -- stale worker loop를 막고 unregister 후 owned cache 삭제가 가능한지 검증한다. +- 기존 동기식 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에서 검증한다. -### File/generated API +### Cache Storage/Service Worker -- upload는 client MIME을 신뢰하지 않고 size/type/server rejection을 모두 다룬다. +- 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를 유지한다. @@ -128,17 +232,22 @@ SSOT다. 문서와 catalog가 다르면 gate가 검사하는 catalog를 우선 ```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를 거절한다. removal gate는 recipe와 -recipe test를 삭제한 임시 사본에서 base typecheck, architecture, test와 build를 -실행한다. +복제, 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. 제거 체크리스트 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index b824bdb..9c52370 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -18,17 +18,18 @@ flowchart LR Contracts --> Presentation ``` -The intended dependency rule points inward: presentation calls application use +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 intended named source -for routes, API operations, environment values, storage keys, errors, queries, -telemetry, and release tokens. The platform review below records where the -current runtime still bypasses that target or duplicates registry metadata. +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 target design -makes this role explicit, introduces application input ports, and prevents the -React tree from receiving raw outbound dependencies: +`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 @@ -41,21 +42,38 @@ flowchart LR Bootstrap2 -. selects and injects .-> Outbound ``` -The current executable route tree is mounted only after runtime configuration -and release-manifest coherence pass. It receives the composed query client, -credential-opaque session port, storage port, telemetry port, and immutable -build ID. Generic starter pages do not depend on the removable reference -feature. +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. -This describes the current starter composition, not the completed target. The -capability review found that raw outbound capabilities still reach the React -tree, the composed application facade is not yet its entry point, and several -route, HTTP, recovery, telemetry, and reference-feature removal contracts are only -partially connected. Use the following documents for the evidence and migration -plan: +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..6dfec9a --- /dev/null +++ b/docs/architecture/realtime-events-web-push-and-bounded-polling.md @@ -0,0 +1,2347 @@ +# Realtime events, Web Push, and bounded polling + +이 문서는 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/routing-pages-and-patterns.md b/docs/architecture/routing-pages-and-patterns.md index 4aebd6c..074b17f 100644 --- a/docs/architecture/routing-pages-and-patterns.md +++ b/docs/architecture/routing-pages-and-patterns.md @@ -29,7 +29,7 @@ RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 - reference feature의 list/detail/create/status route가 네 template variation을 production composition에서 실행한다. -platform route 계약, `src/features/installed-feature-contracts.js`, +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는 @@ -37,6 +37,13 @@ access, title, navigation, chunk ID는 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만 전달한다. 이 분리는 @@ -391,10 +398,8 @@ page view ```tsx export function ResourceListRoute() { - const input = useRouteInput(resourceListRoute); - if (!input.ok) return ; - - return ; + const input = useRouteInput(); + return ; } function ResourceListController({ input }: ResourceListControllerProps) { @@ -403,7 +408,8 @@ function ResourceListController({ input }: ResourceListControllerProps) { } ``` -route parse boundary와 controller component를 분리하므로 controller hook은 +router의 parse/invalid-route boundary가 성공한 `ParsedRouteInput`만 provider에 +넣고, page controller는 그 context를 바로 읽는다. 따라서 controller hook은 조건부로 호출되지 않는다. controller가 소유하는 것: 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 index 313022d..2bae499 100644 --- a/docs/architecture/starter-experience.md +++ b/docs/architecture/starter-experience.md @@ -62,7 +62,7 @@ and the 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.js`. + 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 @@ -72,8 +72,8 @@ rather than adding another independent route or data-loading convention. 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/home smoke, and builds without the complete - reference feature. + 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/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md index 9bc186c..abe0229 100644 --- a/docs/architecture/typescript-state-and-data-flow.md +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -4,21 +4,27 @@ 이 문서는 다음 질문에 대한 저장소 표준을 정의한다. -- JavaScript를 어떤 순서로 TypeScript로 전환하는가. +- TypeScript-only 경계를 어떻게 유지하고 JavaScript 재유입을 막는가. - local, URL, server, form, global, persisted 상태를 어디에 둬야 하는가. - React 화면이 application use case와 TanStack Query를 어떻게 사용해야 하는가. - HTTP, retry, auth, error, validation, logging의 책임을 어떻게 나누는가. - 새 query, mutation, form을 추가할 때 어떤 파일과 테스트가 필요한가. -이 문서는 목표 설계다. 현재 구현 상태는 -[프론트엔드 플랫폼 역량 재검토](./frontend-platform-capability-review.md)를 따른다. +이 문서는 현재 구현된 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 상태에서 유용한 안전망이다. 그러나 -JSDoc cast가 늘어나면 다음 계약을 정확히 닫기 어렵다. +초기 기준선의 `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 @@ -31,16 +37,17 @@ JSDoc cast가 늘어나면 다음 계약을 정확히 닫기 어렵다. TypeScript 전환 목적은 확장자 변경이 아니라 이 계약을 컴파일 단계에서 검증하는 것이다. -### 2.2 전환 전에 고칠 도구 +### 2.2 현재 도구 경계 -다음 변경이 첫 브랜치에서 완료되기 전에는 source rename을 시작하지 않는다. +전환 이후에도 다음 조건을 blocking gate로 유지한다. -1. ESLint가 `js`, `jsx`, `mjs`, `ts`, `tsx`, `mts`를 모두 검사한다. +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.mjs`가 TS/TSX를 검색한다. -5. `config/contracts/registry-governance.json`의 경로 갱신 절차를 만든다. +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를 @@ -56,36 +63,34 @@ tsconfig.test.json tsconfig.json # project references only ``` -`tsconfig.base.json`의 초기 핵심 옵션: +`tsconfig.base.json`의 핵심 옵션: ```json { "compilerOptions": { "strict": true, "noEmit": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "useUnknownInCatchVariables": true, - "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, - "verbatimModuleSyntax": true, + "allowJs": false, + "checkJs": false, + "allowImportingTsExtensions": true, "isolatedModules": true } } ``` -실제 TypeScript 7/Vite 호환 옵션은 설치된 공식 문서와 빌드 결과를 기준으로 -확정한다. 옵션을 한꺼번에 켜서 수백 개 예외를 만들지 말고, 각 단계에서 새 -예외를 금지한다. +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는 각각 독립된 project로 -typecheck하며 JS에는 `checkJs`, TS에는 `strict`를 적용한다. TypeScript 7을 -아직 지원하지 않는 parser plugin을 강제 설치하지 않고 Babel parser는 lint -syntax/import/security 검사, `tsc`는 type semantics를 소유한다. +기록돼 있다. app, Node scripts/config, tests는 각각 독립된 strict project로 +typecheck한다. TypeScript 7을 아직 지원하지 않는 parser plugin을 강제 +설치하지 않고 Babel parser는 lint syntax/import/security 검사, `tsc`는 type +semantics를 소유한다. -### 2.3 전환 순서 +### 2.3 완료된 전환 순서 | 단계 | 대상 | 이유 | 종료 조건 | | --- | --- | --- | --- | @@ -97,37 +102,29 @@ syntax/import/security 검사, `tsc`는 type semantics를 소유한다. | 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 | 우회 없는 전체 저장소 | source `allowJs` 제거 가능 | +| 8 | tests/scripts/config | 우회 없는 전체 저장소 | `allowJs: false`, TS-only architecture gate | -각 단계는 빌드 가능한 작은 커밋으로 유지한다. JavaScript와 TypeScript가 공존하는 -동안에는 [공식 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)의 -점진적 방식을 사용한다. +이 순서는 기존 계약을 보존하며 수행한 migration 기록이다. 현재 실행 코드와 +`tests/fixtures/**`에는 JavaScript와 TypeScript를 공존시키지 않는다. 실패를 +의도한 fixture도 실제 `.ts/.tsx` 입력이며 product import 대상은 아니다. ### 2.4 기본 type 계약 -다음 형태를 core application에 둔다. +공통 success/failure carrier는 `src/application/result.ts`에 한 번만 둔다. ```ts -export type Ok = Readonly<{ ok: true; value: T }>; -export type Err = Readonly<{ ok: false; error: E }>; -export type Result = Ok | Err; +import type { AppFailure } from "../contracts/errors.ts"; -export type AppFailure = - | Readonly<{ kind: "unauthenticated"; code: "AUTH_REQUIRED"; traceId?: string }> - | Readonly<{ kind: "forbidden"; code: "FORBIDDEN"; traceId?: string }> - | Readonly<{ kind: "not-found"; code: "NOT_FOUND"; traceId?: string }> - | Readonly<{ kind: "conflict"; code: "CONFLICT"; traceId?: string }> - | Readonly<{ - kind: "validation"; - code: "VALIDATION_FAILED"; - fields: Readonly>; - traceId?: string; - }> - | Readonly<{ kind: "rate-limited"; code: "RATE_LIMITED"; retryAt?: Date }> - | Readonly<{ kind: "unavailable"; code: "UNAVAILABLE"; retryable: boolean }> - | Readonly<{ kind: "unexpected"; code: "UNEXPECTED"; traceId?: string }>; +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한다. @@ -139,6 +136,28 @@ export type AppFailure = - `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 상태 분류표 @@ -204,6 +223,13 @@ type PersistedRecord = Readonly<{ - 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 @@ -286,13 +312,11 @@ export function useResourcesQuery(input: ListResourcesInput) { ```ts export function useCreateResourceMutation() { const application = useApplication(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (command: CreateResourceCommand) => - application.resources.create(command).then(unwrapResult), - onSuccess: () => - queryClient.invalidateQueries({ queryKey: resourceKeys.all }), + return useApplicationMutation({ + execute: (command: CreateResourceCommand) => + application.resources.create(command), + invalidate: [RESOURCE_INVALIDATION_TOPIC], }); } ``` @@ -316,6 +340,32 @@ export function useCreateResourceMutation() { 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 목표 파이프라인 @@ -625,3 +675,7 @@ backend message와 알 수 없는 path는 field copy로 사용하지 않는다. - 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 index de3548f..1e3dbf5 100644 --- a/docs/contracts/compatibility.md +++ b/docs/contracts/compatibility.md @@ -9,3 +9,14 @@ assetManifestHash, releaseId)`. Versions are parsed numerically. 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 index e3c82dd..d6925b2 100644 --- a/docs/operations/ci-quality-gates.md +++ b/docs/operations/ci-quality-gates.md @@ -15,6 +15,37 @@ MERGE_READY DOCUMENTATION_READY (off-chain) ``` +`check:architecture`는 dependency-cruiser 결과와 별도로 Babel parser/Node +resolver 기반 `staticImportGraph`를 `artifacts/quality/dependency-report.json`에 +기록한다. TS/TSX의 static, dynamic, type, CommonJS 및 JSDoc import를 검사하고, +로컬 source import에는 실제 TypeScript 확장자를 요구한다. 실행 source나 로컬 +specifier에 `.js/.jsx/.mjs/.cjs`가 있거나 unresolved dependency, parse failure, +error-severity layer violation, cycle 또는 지원하지 않는 architecture rule +shape가 하나라도 있으면 gate는 실패한다. resolver, unresolved import, layer +edge와 cycle regression fixture도 같은 명령에서 실행된다. + +Gitea workflow는 run ID/attempt, checkout SHA와 ref에서 build/release +식별자를 만들고 최소 `contents: read` 권한만 요청한다. Gate runner는 `HEAD`의 +full commit ID와 commit timestamp를 한 번 읽어 `SOURCE_DATE_EPOCH`를 유도한 +뒤 workflow의 SHA와 실제 checkout이 일치하는지 확인한다. `CI=true`에서 이 +식별자 중 하나라도 없거나 불일치하면 build step 전에 fail-closed하며, build와 +release manifest도 같은 build ID, commit SHA, release ID와 timestamp를 가져야 +한다. + +Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이 +workflow를 required check로 전환하기 전에 staging instance에서 +`permissions`, `gitea.run_attempt`, `actions/upload-artifact@v4`를 포함한 한 +번의 전체 provider smoke를 통과시켜야 한다. 모든 setup step은 +`node-version-file: .nvmrc`를 사용하므로 CI Node.js 버전은 `.nvmrc`의 exact +pin과 같다. CI contract는 `.nvmrc`가 full semantic version인지, 모든 job이 이 +파일을 사용하는지 함께 검사한다. `ubuntu-latest` runner label은 +관리자가 임의 환경에 매핑할 수 있으므로 provenance로 사용하지 않는다. 대신 +그 label을 immutable container image에 매핑하고 동일한 image digest를 +repository variable `RUNNER_IMAGE_DIGEST`에 설정한다. Workflow는 이 값을 +runtime environment의 `CI_RUNNER_IMAGE`로 전달하며, 값이 비어 있으면 모든 +gate가 실행 전에 실패한다. Gitea repository variable 이름에는 `CI` prefix를 +사용할 수 없으므로 두 이름을 의도적으로 구분한다. + Pull requests and `develop` pushes evaluate merge readiness. Version tags evaluate merge then release readiness. Production and field evaluation require an explicit workflow dispatch. The field tier cannot pass until the 28-day @@ -30,13 +61,74 @@ registry: merge evidence through the PR decision, coherent release evidence through the next release promotion, drill evidence through the next production promotion, and field evidence through aggregation. +Runbook jobs write to the stable path +`artifacts/runbooks//record.json`; the dynamic release identity is +stored inside the record. This keeps gate evidence lookup independent from +slashes or other provider-specific characters in `RELEASE_ID`, while each +workflow artifact remains scoped to its own run. + Browser-backed merge gates install and execute the pinned Chromium, Firefox, and WebKit engines. This makes route behavior, reflow, native dialog semantics, theme persistence, and automated accessibility a cross-engine contract rather than a Chromium-only smoke check. +`FE-GATE-005`의 unit suite에는 client cache/storage의 deterministic contract가 +포함된다. `storage-registry.test.ts`는 key별 codec, schema/TTL, 16,384-byte +상한, quota/security/corrupt cleanup과 memory fallback을, +`cross-tab-invalidation.test.ts`는 2,048-byte exact wire, topic/release epoch, +self/duplicate/out-of-order/gap, BroadcastChannel → localStorage → local-only +degrade와 cleanup을 검증한다. `tanstack-cache-coordinator.test.ts`는 topic을 +local namespace로만 resolve하고 query key/data를 wire에 보내지 않으며 mutation +중 remote hint를 coalesce하는지 검증한다. 결과는 기존 +`artifacts/tests/unit.xml`과 coverage evidence에 포함된다. + +`FE-GATE-006`과 runtime adapter unit은 +`QueryInvalidationProvider`/coordinator가 실제 production provider tree와 +bootstrap에 존재하고 browser capability getter가 실패해도 boot가 +`DEGRADED_LOCAL_ONLY`로 계속되는지 확인한다. + +`FE-GATE-008`의 `test:browser-capabilities` suite는 File/Blob, native file +input, IndexedDB, OPFS, Cache Storage와 StorageManager reference runtime을 Vite +dev origin의 실제 browser API에 연결한다. API가 없는 engine에서는 skip하지 않고 +adapter의 명시적인 `UNSUPPORTED` fallback을 검증한다. 생성한 database, OPFS +namespace와 owned cache는 각 test가 자신이 만든 opaque namespace만 정리한다. + +`FE-GATE-010`은 application/domain/presentation에서 raw browser storage와 +picker global 접근을 막고, 선택되지 않은 reference runtime을 bootstrap이나 +installed feature가 import하는 것도 거절한다. catalog의 +`referenceRuntime.sourceRoots`와 conformance script가 실제로 존재해야 하며, +`productionComposition`은 project capability 결정 전까지 `false`다. 같은 +gate의 realtime source/fixture 검사는 native SSE/WebSocket/Web Push API가 +소유 adapter 밖으로 새는 것, presentation timer owner, 미선택 runtime의 +bootstrap/installed-feature 조합을 차단한다. optional recipe source gate는 +uncomposed runtime 전체를 tree-shaking 없이 합성해 catalog의 gzip 예산도 +blocking으로 검증한다. +`FE-GATE-020`은 reference runtime source·전용 test·catalog metadata를 제거한 +임시 repository에서도 base typecheck, architecture, test, build가 통과하는지 +검증해 skeleton의 선택성을 유지한다. browser file/storage와 realtime runtime은 +각각 독립 removal fixture와 JUnit evidence를 가지며, realtime fixture는 공통 +event authority, SSE, WebSocket, bounded Polling, Web Push source와 공개 export, +전용 boundary script를 제거한 뒤 base gate를 다시 실행한다. + +같은 `FE-GATE-010`의 `FE-REG-QUERY` governance는 installed query마다 +namespace, serialization/identity, invalidation topic, version, +`crossContext: "invalidate-only"`와 `persistence: "disabled"`를 요구한다. +따라서 cross-tab invalidation은 조립됐지만 query persistence와 기존 IndexedDB +reference runtime은 계속 미조립이다. + +현재 `FE-GATE-008` browser-capability suite에는 실제 두 page의 +BroadcastChannel/localStorage fallback scenario가 아직 없다. unit fake 통과를 +native multi-tab promotion evidence로 간주하지 않는다. 이 gap은 +[Client cache and browser storage platform](../architecture/client-cache-and-storage.md)의 +완료 기준에 미완료로 남아 있다. 현재 장애 분류와 안전한 local-only 복구 절차는 +[Client cache and Web Storage recovery](./client-cache-and-storage-recovery.md)를 +따른다. + Repository variables required by higher tiers: +- `RUNNER_IMAGE_DIGEST` for the immutable job-container image digest used by the + `ubuntu-latest` runner label; the workflow exposes it to gates as + `CI_RUNNER_IMAGE` (required by every tier) - `HOSTING_BASE_URL` for live header verification - `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document - `MIN_ELIGIBLE_SAMPLES` after the baseline decision diff --git a/docs/operations/client-cache-and-storage-recovery.md b/docs/operations/client-cache-and-storage-recovery.md new file mode 100644 index 0000000..7771936 --- /dev/null +++ b/docs/operations/client-cache-and-storage-recovery.md @@ -0,0 +1,203 @@ +# Client cache and Web Storage recovery + +- 적용 대상: production-composed TanStack memory cache, registered Web Storage, + cross-tab invalidate-only runtime +- 비대상: query persistence와 IndexedDB query cache +- 기준일: 2026-07-28 + +현재 query persistence는 정책과 runtime에서 `disabled`다. 기존 IndexedDB +reference runtime은 query cache에 조립되지 않았으므로 query-cache incident에서 +database 삭제, migration 또는 hydration 조치를 수행하지 않는다. +session/account Query lifecycle, strict query policy와 Web Storage v2 lifecycle도 +현재 `DESIGNED_NOT_IMPLEMENTED`다. 아래 목표 절차를 현재 runtime의 보장으로 +해석하지 않는다. + +## 1. 변경할 수 없는 복구 원칙 + +- 서버가 server state와 authorization의 source of truth다. +- cross-tab event는 invalidate hint다. delivery나 server commit 증명이 아니다. +- remote event로 `queryClient.clear()`, account logout 또는 credential 폐기를 + 수행하지 않는다. +- `BroadcastChannel` 실패 후 localStorage fallback도 실패하면 + `DEGRADED_LOCAL_ONLY`가 정상 fallback이다. 현재 tab의 local invalidation과 + server request는 계속 동작해야 한다. +- origin 전체 `localStorage.clear()`나 `deleteDatabase()`를 복구 명령으로 + 사용하지 않는다. +- query key, storage value, event/topic/epoch/source ID, user/account/tenant ID를 + incident log에 복사하지 않는다. +- incident 대응 중 `QUERY_PERSISTENCE`를 켜거나 server response를 Web Storage에 + 저장하지 않는다. + +## 2. 최초 확인 + +1. 영향 release ID, browser family/version과 최초 발생 시각을 확인한다. +2. `cache.operation.failed`와 `storage.operation.failed`의 allowlisted + operation/outcome/reason만으로 범위를 좁힌다. +3. runtime status가 `ACTIVE_BROADCAST`, `ACTIVE_STORAGE_FALLBACK`, + `DEGRADED_LOCAL_ONLY`, `CLOSED` 중 무엇인지 확인한다. +4. 현재 tab의 mutation 성공 후 local namespace가 stale 처리되는지 확인한다. +5. 다른 tab의 오래된 화면이 단순 stale 표시인지, 실제 server authorization + 우회인지 분리한다. authorization 우회는 cache incident가 아니라 P1 security + incident다. +6. affected release의 query registry topic/version과 release cache epoch가 + 일치하는지 확인한다. + +## 3. Cross-tab invalidation degradation + +### 신호 + +- `BROADCAST_OPEN_FAILED` +- `BROADCAST_PUBLISH_FAILED` +- `STORAGE_LISTENER_FAILED` +- `STORAGE_PUBLISH_FAILED` +- runtime status `DEGRADED_LOCAL_ONLY` + +### 확인 + +1. browser policy, embedded/sandbox context 또는 privacy mode가 + BroadcastChannel/Web Storage를 제한하는지 확인한다. +2. BroadcastChannel publish 실패 뒤 fixed localStorage pulse key로 정확히 한 번 + fallback하는지 확인한다. +3. publishing tab은 `storage` event를 받지 않으므로 local invalidation을 + coordinator가 직접 수행했는지 확인한다. +4. 다른 tab은 focus/reconnect 또는 명시적 refresh에서 server를 다시 읽는지 + 확인한다. + +### 안전한 조치 + +- local-only 상태에서는 사용자에게 현재 tab refresh action을 유지한다. +- fallback regression이 특정 release에서 시작됐으면 이전 compatible release로 + rollback한다. +- event payload에 query key/data를 추가하거나 TTL/size/source tracking limit을 + 임시 확대하지 않는다. +- 현재 runtime에는 별도 dynamic kill switch가 없다. 존재하지 않는 flag로 + 복구됐다고 선언하지 않는다. + +## 4. Duplicate, sequence gap과 refetch 증가 + +### 신호 + +- `DUPLICATE`, `STALE`, sequence-gap observation 증가 +- 여러 namespace의 active refetch 동시 증가 +- backend read traffic 증가 + +### 확인 + +1. 같은 source/epoch의 sequence gap인지 새 page epoch의 정상 sequence reset인지 + 구분한다. +2. duplicate가 BroadcastChannel과 storage fallback 양쪽에서 들어온 것인지 + 확인한다. +3. mutation lease가 유지되는 동안 remote hint가 topic별로 coalesce되는지 + 확인한다. +4. gap에서 inactive query까지 즉시 refetch하거나 remote hint를 다시 publish하는 + echo가 없는지 확인한다. + +### 안전한 조치 + +- gap은 등록 namespace를 stale 처리하고 active query만 refetch한다. +- backend가 압박을 받으면 API degradation runbook의 server read 보호 정책을 + 적용한다. client query와 HTTP 양쪽 retry를 동시에 늘리지 않는다. +- persistent event queue나 localStorage counter를 급히 추가하지 않는다. + +## 5. Web Storage quota, corruption과 denial + +### 신호 + +- `STORAGE_QUOTA_EXCEEDED` +- `SIZE_LIMIT_EXCEEDED` +- `VALUE_REJECTED` +- `STORAGE_UNAVAILABLE` + +### 확인 + +1. 실패 key가 registry backend/value codec/schema/TTL과 일치하는지 확인한다. +2. value가 기본 16,384-byte hard cap 안인지 확인한다. +3. corrupt/expired record가 exact physical key에서만 제거되고 다른 application + key는 유지되는지 확인한다. +4. `COLOR_SCHEME` quota fallback이 동일 serialized envelope와 TTL 규칙을 쓰는지 + 확인한다. +5. native exception message나 value가 diagnostics에 포함되지 않았는지 확인한다. + +### 안전한 조치 + +- preference persistence 실패는 memory fallback 또는 safe default로 degrade한다. +- exact corrupt/expired registered key만 제거한다. +- credential key를 임시 storage key로 재등록하지 않는다. +- origin 전체 clear, arbitrary key enumeration과 query-cache persistence 전환을 + 금지한다. + +## 6. Logout, account 전환과 stale data + +현재 구현은 release cache epoch만 제공하고 session/account epoch와 opaque account +partition은 아직 없다. 따라서 다음을 과장해 보장하지 않는다. + +- cross-tab hint가 모든 tab의 logout을 완료했다. +- old in-flight result가 새 account runtime에 기록되지 않는다. +- persisted query data가 account별로 분리된다. + +실제 account-switching 제품을 배포하기 전에 session owner 기반 local admission +fence, old QueryClient cancel/clear, late-result generation fence와 account epoch를 +구현해야 한다. 그 전에는 external auth owner와 서버 authorization을 +authoritative하게 유지하고, 의심되는 stale personal data incident는 security +owner에게 escalation한다. + +VD-13 lifecycle이 구현된 제품의 안전한 전환 순서는 다음과 같다. + +```text +local session authority revoke + -> old generation FENCED + -> new query/mutation admission 거절 + -> in-flight cancel + -> provider/listener detach + -> old QueryClient clear + dispose + -> old Web Storage/optional persistence partition purge policy + -> 새 opaque scope와 새 QueryClient remount +``` + +각 async callback은 terminal cache write 직전에 captured generation을 다시 +검증한다. broadcast logout 수신 여부와 무관하게 local 전환이 완료되어야 한다. +optional query persistence를 선택한 제품은 durable namespace epoch/CAS와 old +record resurrection fault까지 통과한 경우에만 restore를 다시 연다. + +## 7. Cleanup과 종료 + +정상 dispose는 coordinator subscription, BroadcastChannel, storage listener, +dedupe/high-watermark state와 pending remote set을 idempotent하게 정리한다. + +확인: + +- HMR/unmount 반복 뒤 listener/channel 수가 증가하지 않음 +- dispose 뒤 event가 query invalidation을 실행하지 않음 +- fixed pulse key가 publish 후 best-effort 제거됨 +- cleanup failure가 cached value나 identifier를 log하지 않음 + +## 8. 복구 완료 조건 + +- current tab의 committed mutation 후 local namespace invalidation 성공 +- available transport에서는 two-tab remote invalidation 성공 +- unavailable transport에서는 explicit `DEGRADED_LOCAL_ONLY`와 manual/focus + revalidation 성공 +- duplicate/self/stale event가 추가 refetch를 만들지 않음 +- sequence gap은 bounded active-query reconciliation으로 종료 +- Web Storage corrupt/quota scenario가 exact-key cleanup 또는 documented fallback + 으로 종료 +- raw key/value/query/user identifier가 diagnostic evidence에 없음 +- affected browser의 manual two-tab smoke 기록 보존 + +two-page native cross-context transport suite는 존재하지만 현재 보존 evidence는 +Chromium/Firefox에 한정되고, production QueryClient/coordinator의 account +lifecycle과 late-result fence까지 연결한 end-to-end case는 아직 없다. 따라서 +manual smoke나 transport-only suite를 production promotion evidence의 영구 +대체물로 사용하지 않는다. Chromium/Firefox/WebKit에서 scope 전환을 포함한 +동일 case set이 통과할 때 이 runbook의 promotion close criteria를 충족한다. + +## 9. 관련 문서 + +- [API contract와 Server State recovery](./api-contract-and-server-state-recovery.md) +- [VD-25 Server State Cache lifecycle](../architecture/decisions/VD-25-server-state-cache-lifecycle.md) +- [Client cache and browser storage platform](../architecture/client-cache-and-storage.md) +- [Browser data capability completion ledger](../architecture/browser-data-capability-completion-ledger.md) +- [VD-13 Client cache scope and persistence](../architecture/decisions/VD-13-client-cache-scope-and-persistence.md) +- [TypeScript, 상태 소유권, 데이터 흐름](../architecture/typescript-state-and-data-flow.md) +- [Backend API degradation](../runbooks/FE-RB-003.md) +- [Release, cache, and rollback contract](./release-cache-rollback.md) diff --git a/docs/styling/design-system-platform.md b/docs/styling/design-system-platform.md index c5538e6..d7f48f3 100644 --- a/docs/styling/design-system-platform.md +++ b/docs/styling/design-system-platform.md @@ -19,7 +19,7 @@ - focus indicator - 반응형 앱 셸 - reduced-motion 처리 -- `src/presentation/providers/theme-provider.jsx` +- `src/presentation/providers/theme-provider.tsx` - `system`, `light`, `dark` 선호도 - 저장소 포트를 통한 선호도 영속화 - 운영체제 색상 변경 구독 @@ -30,9 +30,9 @@ - `Alert` - `Badge` - `Dialog` -- `src/presentation/components/async-surface.jsx` +- `src/presentation/components/async-surface.tsx` - 초기 로딩, 빈 화면, terminal error, background 상태 -- `src/presentation/components/state-surfaces.jsx` +- `src/presentation/components/state-surfaces.tsx` - 인증 필요, 권한 없음, 찾을 수 없음 - `src/presentation/forms` - local form facade, field/error summary, dirty navigation dialog diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index 8c8dbe9..12c4039 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -77,8 +77,8 @@ field/documentation 단계를 구성한다. #### 테스트 TypeScript typecheck 기반 `check:types`는 app, Node scripts/config, tests project를 순서대로 검사한다. -테스트 project는 JS/JSX/TS/TSX의 callback, mock, fixture와 config type을 -검사하되 실패를 의도한 `tests/fixtures`는 별도 negative command가 소유한다. +테스트 project는 TS/TSX callback, mock과 config type을 검사하되 실패를 +의도한 `tests/fixtures`는 별도 TypeScript negative command가 소유한다. Vitest의 변환 성공을 TypeScript typecheck의 대체물로 취급하지 않는다. #### 실제 bootstrap integration @@ -90,7 +90,7 @@ production Playwright profile은 source fixture가 아니라 `build` + `preview` #### TanStack Query의 React integration test 기반 -`tests/component/application-query.test.jsx`는 production query inbound +`tests/component/application-query.test.tsx`는 production query inbound adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal failure, background stale-failure latch와 retry 복구, duplicate submit, optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제 @@ -127,10 +127,11 @@ Chromium, Firefox, WebKit과 compact project로 실행한다. 빠른 Vite 개발 #### 위험 기반 coverage -V8 text/JSON/LCOV를 생성하고 전체 기준과 retry/storage/telemetry/compatibility/ -performance/promotion/chunk/diagnostics/registry compatibility 9개 high-risk -module에 40개 scoped threshold를 적용한다. critical module 누락 또는 threshold -미달 fixture는 merge gate를 실패시킨다. +V8 text/JSON/LCOV를 생성하고 전체 기준과 retry/storage/telemetry/application +composition/compatibility/performance/promotion/chunk/diagnostics/reference HTTP +operation/query-mutation/registry compatibility 12개 high-risk module에 52개 +scoped threshold를 적용한다. critical module 누락 또는 threshold 미달 fixture는 +merge gate를 실패시킨다. 남은 범위는 실제 device/browser farm, cloud visual approval, 외부 인증·telemetry provider와 production field data다. 이 증거가 없을 때 저장소 내부 test를 @@ -447,7 +448,7 @@ contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 bu RP-06 form/page matrix는 `tests/component/form-foundation.test.tsx`, `tests/component/page-templates.test.tsx`, `tests/features/reference-feature/reference-page.test.tsx`와 -`tests/e2e/reference-form.spec.js`에 있다. client validation에서 command 0회와 +`tests/e2e/reference-form.spec.ts`에 있다. client validation에서 command 0회와 첫 오류 focus, Zod transform/default, pending 중 중복 제출, 승인된 422 field/unknown field mapping, conflict 입력 보존, reset/dirty, navigation confirmation/focus restore, URL/storage 비노출과 320px reflow를 검증한다. @@ -555,14 +556,14 @@ client route guard는 UX이며 authorization이 아님을 test 이름과 문서 RP-07의 실행 경로는 `check:design-system`, `check:design-system:fixture`, `check:types:fixture:icon-button`, `tests/component/design-system-platform.test.tsx`와 -`tests/e2e/design-system-interactions.spec.js`다. Story interaction/visual +`tests/e2e/design-system-interactions.spec.ts`다. Story interaction/visual baseline은 VD-08/RP-10에서 추가하며 현재 runtime gallery를 isolated workshop 완료 증거로 사용하지 않는다. RP-08의 국제화 실행 경로는 `check:i18n`, `check:i18n:fixture`, `check:types:fixture:i18n-key`, `check:types:fixture:i18n-params`, `tests/unit/i18n-contract.test.ts`, `tests/component/locale-platform.test.tsx`와 -`tests/e2e/i18n.spec.js`다. +`tests/e2e/i18n.spec.ts`다. - unit: locale catalog/placeholder parity, safe fallback/alias, pseudo expansion, direction과 UTC date/number/relative/list/plural/select @@ -639,6 +640,10 @@ open dialog, invalid form, expanded menu 같은 state story로 보완한다. [MSW](https://mswjs.io/docs/)는 request client를 mock하지 않고 HTTP 경계에서 요청을 가로챈다. Node integration과 browser workshop이 동일한 operation contract 및 scenario vocabulary를 사용하도록 중앙 catalog를 만든다. +설치된 feature의 catalog와 handler 증적은 +`config/testing/test-evidence.json`의 owner별 contribution으로 등록한다. 따라서 +feature 제거 시 해당 contribution도 함께 제거할 수 있고 공통 evidence checker는 +특정 sample 경로를 하드코딩하지 않는다. ### 11.1 목표 구조 diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index a5fd490..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,324 +0,0 @@ -import babelParser from "@babel/eslint-parser"; -import eslint from "@eslint/js"; -import reactHooks from "eslint-plugin-react-hooks"; -import globals from "globals"; - -const sourceExtensions = "{js,jsx,mjs,ts,tsx,mts}"; - -const layerPatterns = { - domain: [ - "**/application/**", - "**/presentation/**", - "**/adapters/**", - "**/bootstrap/**", - "react", - "react-dom", - "@tanstack/**", - ], - application: [ - "**/presentation/**", - "**/adapters/**", - "**/bootstrap/**", - "react", - "react-dom", - "@tanstack/**", - ], - presentation: [ - "**/adapters/**", - "**/bootstrap/**", - "**/application/ports/out/**", - "@tanstack/**", - ], - adapters: ["**/presentation/**", "**/bootstrap/**"], -}; - -function restrictedImports(patterns) { - return ["error", { patterns }]; -} - -const commonLanguageOptions = { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.browser, - ...globals.node, - }, -}; - -const commonSecurityRules = { - "no-eval": "error", - "no-new-func": "error", - "no-script-url": "error", - "no-restricted-syntax": [ - "error", - { - selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", - message: "Raw HTML injection is prohibited by FE-OC-019.", - }, - { - selector: - "CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']", - message: "Runtime script construction is prohibited by FE-OC-019.", - }, - ], -}; - -const hookRules = { - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "error", -}; - -export default [ - { - ignores: [ - "dist/**", - "node_modules/**", - "artifacts/**", - "tests/fixtures/typecheck/**", - "tests/fixtures/architecture/forbidden/**", - "tests/fixtures/diagnostics/forbidden/**", - "tests/fixtures/i18n/forbidden/**", - "tests/fixtures/security/forbidden/**", - "tests/fixtures/optional-recipes/**", - ], - }, - eslint.configs.recommended, - { - files: [`**/*.${sourceExtensions}`], - languageOptions: { - ...commonLanguageOptions, - parserOptions: { - ecmaFeatures: { jsx: true }, - }, - }, - plugins: { - "react-hooks": reactHooks, - }, - rules: { - ...commonSecurityRules, - ...hookRules, - }, - }, - { - files: ["**/*.{ts,mts}"], - languageOptions: { - ...commonLanguageOptions, - parser: babelParser, - parserOptions: { - requireConfigFile: false, - babelOptions: { - plugins: [ - ["@babel/plugin-syntax-typescript", { isTSX: false }], - ], - }, - }, - }, - rules: { - "no-undef": "off", - "no-unused-vars": "off", - }, - }, - { - files: ["**/*.d.ts"], - languageOptions: { - ...commonLanguageOptions, - parser: babelParser, - parserOptions: { - requireConfigFile: false, - babelOptions: { - plugins: [ - ["@babel/plugin-syntax-typescript", { dts: true }], - ], - }, - }, - }, - rules: { - "no-undef": "off", - "no-unused-vars": "off", - }, - }, - { - files: ["**/*.tsx"], - languageOptions: { - ...commonLanguageOptions, - parser: babelParser, - parserOptions: { - requireConfigFile: false, - babelOptions: { - plugins: [ - [ - "@babel/plugin-syntax-typescript", - { allExtensions: true, isTSX: true }, - ], - "@babel/plugin-syntax-jsx", - ], - }, - }, - }, - rules: { - "no-undef": "off", - "no-unused-vars": "off", - }, - }, - { - files: [`src/domain/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports(layerPatterns.domain), - "no-restricted-globals": [ - "error", - "window", - "document", - "localStorage", - "fetch", - ], - }, - }, - { - files: [`src/application/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports(layerPatterns.application), - "no-restricted-globals": [ - "error", - "window", - "document", - "localStorage", - "fetch", - ], - }, - }, - { - files: [`src/presentation/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports(layerPatterns.presentation), - "no-restricted-globals": [ - "error", - "fetch", - "localStorage", - "sessionStorage", - ], - }, - }, - { - files: [ - `src/presentation/adapters/query/**/*.${sourceExtensions}`, - ], - rules: { - "no-restricted-imports": restrictedImports([ - "**/adapters/http/**", - "**/adapters/storage/**", - "**/adapters/auth/**", - "**/bootstrap/**", - "**/application/ports/out/**", - ]), - }, - }, - { - files: [`src/presentation/templates/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports([ - "**/application/**", - "**/adapters/**", - "**/bootstrap/**", - "@tanstack/**", - ]), - }, - }, - { - files: [`src/adapters/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports(layerPatterns.adapters), - }, - }, - { - files: [`src/features/*/domain/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports(layerPatterns.domain), - "no-restricted-globals": [ - "error", - "window", - "document", - "localStorage", - "fetch", - ], - }, - }, - { - files: [`src/features/*/application/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports(layerPatterns.application), - "no-restricted-globals": [ - "error", - "window", - "document", - "localStorage", - "fetch", - ], - }, - }, - { - files: [`src/features/*/presentation/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports([ - "**/features/*/adapters/**", - "**/adapters/http/**", - "**/adapters/storage/**", - "**/adapters/auth/**", - "**/bootstrap/**", - "**/application/ports/out/**", - "@tanstack/**", - ]), - "no-restricted-globals": [ - "error", - "fetch", - "localStorage", - "sessionStorage", - ], - }, - }, - { - files: [`src/features/*/adapters/**/*.${sourceExtensions}`], - rules: { - "no-restricted-imports": restrictedImports([ - "**/presentation/**", - "**/bootstrap/**", - "@tanstack/**", - ]), - }, - }, - { - files: [`tests/**/*.${sourceExtensions}`], - languageOptions: { - globals: { - ...globals.browser, - ...globals.node, - }, - }, - }, - { - files: [`tests/support/browser/**/*.${sourceExtensions}`], - rules: { - "react-hooks/rules-of-hooks": "off", - }, - }, - { - files: [ - `tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`, - ], - rules: { - "no-restricted-imports": restrictedImports([ - "**/adapters/**", - "**/application/ports/out/**", - "@tanstack/**", - "react", - "react-dom", - "**/application/**", - ]), - "no-restricted-globals": [ - "error", - "fetch", - "localStorage", - "sessionStorage", - ], - }, - }, -]; diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..c171e17 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,862 @@ +import babelParser from "@babel/eslint-parser"; +import eslint from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import type { Rule } from "eslint"; + +const sourceExtensions = "{ts,tsx,mts,cts}"; + +const layerPatterns = { + domain: [ + "**/application/**", + "**/presentation/**", + "**/adapters/**", + "**/bootstrap/**", + "react", + "react-dom", + "@tanstack/**", + ], + application: [ + "**/presentation/**", + "**/adapters/**", + "**/bootstrap/**", + "react", + "react-dom", + "@tanstack/**", + ], + presentation: [ + "**/adapters/**", + "**/bootstrap/**", + "**/application/ports/out/**", + "@tanstack/**", + ], + adapters: ["**/presentation/**", "**/bootstrap/**"], +}; + +function restrictedImports(patterns: readonly string[]) { + return ["error", { patterns }]; +} + +const restrictedBrowserDataProperties = [ + "error", + ...(["globalThis", "window", "self"] as const).flatMap((object) => + [ + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "navigator", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + "URL", + ].map((property) => ({ + object, + property, + message: + "Browser file/storage APIs are adapter-owned; use the policy port.", + })), + ), + { + object: "navigator", + property: "storage", + message: "StorageManager and OPFS access are adapter-owned.", + }, + { + object: "URL", + property: "createObjectURL", + message: "Object URL allocation is owned by the preview/download adapter.", + }, + { + object: "URL", + property: "revokeObjectURL", + message: "Object URL revocation is owned by the preview/download adapter.", + }, +] as const; + +const browserCapabilityRoots = new Set([ + "globalThis", + "window", + "self", + "navigator", + "URL", +]); +const browserCapabilityProperties = new Set([ + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "navigator", + "storage", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + "URL", + "createObjectURL", + "revokeObjectURL", +]); + +/** + * no-restricted-properties only recognizes the literal object identifier. + * This rule follows local aliases of browser roots so `const host = + * globalThis; host.indexedDB` cannot bypass the owned-adapter boundary. + */ +const noBrowserCapabilityAliasRule: Rule.RuleModule = { + meta: { + type: "problem", + schema: [], + messages: { + owned: + "Browser file/storage capabilities are owned by the approved common adapters.", + }, + }, + create(context) { + const aliases = new Set(); + + const unwrap = (input: any): any => { + let node = input; + while ( + node && + [ + "ChainExpression", + "TSAsExpression", + "TSNonNullExpression", + "TSTypeAssertion", + ].includes(node.type) + ) { + node = node.expression; + } + return node; + }; + + const propertyName = (input: any): string | null => { + const node = unwrap(input); + if (!node) return null; + if (!node.computed && node.property?.type === "Identifier") { + return node.property.name; + } + if ( + node.computed && + node.property?.type === "Literal" && + typeof node.property.value === "string" + ) { + return node.property.value; + } + if ( + node.computed && + node.property?.type === "StringLiteral" + ) { + return node.property.value; + } + return null; + }; + + const isRootAlias = (input: any): boolean => { + const node = unwrap(input); + if (!node) return false; + if (node.type === "Identifier") { + return browserCapabilityRoots.has(node.name) || + aliases.has(node.name); + } + if (node.type === "MemberExpression") { + return isRootAlias(node.object); + } + if (node.type === "LogicalExpression") { + return isRootAlias(node.left) || isRootAlias(node.right); + } + if (node.type === "ConditionalExpression") { + return ( + isRootAlias(node.consequent) || + isRootAlias(node.alternate) + ); + } + if (node.type === "SequenceExpression") { + return node.expressions.some(isRootAlias); + } + return false; + }; + + const isBareRootAlias = (input: any): boolean => { + const node = unwrap(input); + if (!node) return false; + if (node.type === "Identifier") { + return browserCapabilityRoots.has(node.name) || + aliases.has(node.name); + } + if (node.type === "LogicalExpression") { + return ( + isBareRootAlias(node.left) || + isBareRootAlias(node.right) + ); + } + if (node.type === "ConditionalExpression") { + return ( + isBareRootAlias(node.consequent) || + isBareRootAlias(node.alternate) + ); + } + if (node.type === "SequenceExpression") { + return node.expressions.some(isBareRootAlias); + } + return false; + }; + + const isDirectBrowserRoot = (input: any): boolean => { + const node = unwrap(input); + return ( + node?.type === "Identifier" && + browserCapabilityRoots.has(node.name) + ); + }; + + const safeMethodThisBinding = ( + call: any, + argumentIndex: number, + ): boolean => { + if (argumentIndex !== 0) return false; + const callee = unwrap(call.callee); + if ( + callee?.type !== "MemberExpression" || + propertyName(callee) !== "bind" + ) { + return false; + } + const target = unwrap(callee.object); + const targetName = + target?.type === "MemberExpression" + ? propertyName(target) + : null; + return ( + targetName !== null && + !browserCapabilityProperties.has(targetName) && + isRootAlias(target.object) + ); + }; + + const rememberPattern = (patternInput: any, source: any): void => { + const pattern = unwrap(patternInput); + if (!pattern || !isRootAlias(source)) return; + if (pattern.type === "Identifier") { + aliases.add(pattern.name); + return; + } + if (pattern.type !== "ObjectPattern") return; + for (const property of pattern.properties ?? []) { + if (property.type !== "Property") continue; + const name = + property.computed + ? property.key?.value + : property.key?.name ?? property.key?.value; + if ( + typeof name === "string" && + browserCapabilityProperties.has(name) + ) { + context.report({ node: property, messageId: "owned" }); + } + } + }; + + return { + VariableDeclarator(node: any) { + rememberPattern(node.id, node.init); + }, + AssignmentExpression(node: any) { + rememberPattern(node.left, node.right); + const target = unwrap(node.left); + if ( + target?.type !== "Identifier" && + target?.type !== "ObjectPattern" && + isBareRootAlias(node.right) + ) { + context.report({ node: node.right, messageId: "owned" }); + } + }, + AssignmentPattern(node: any) { + rememberPattern(node.left, node.right); + }, + PropertyDefinition(node: any) { + if (isBareRootAlias(node.value)) { + context.report({ node: node.value, messageId: "owned" }); + } + }, + MemberExpression(node: any) { + const name = propertyName(node); + if ( + isRootAlias(node.object) && + ((name !== null && + browserCapabilityProperties.has(name)) || + (name === null && node.computed)) + ) { + context.report({ node, messageId: "owned" }); + } + }, + CallExpression(node: any) { + for (const [index, argument] of ( + node.arguments ?? [] + ).entries()) { + const candidate = + argument.type === "SpreadElement" + ? argument.argument + : argument; + if ( + isDirectBrowserRoot(candidate) && + !safeMethodThisBinding(node, index) + ) { + context.report({ node: argument, messageId: "owned" }); + } + } + const callee = unwrap(node.callee); + if ( + callee?.type !== "MemberExpression" || + propertyName(callee) !== "get" || + unwrap(callee.object)?.type !== "Identifier" || + unwrap(callee.object).name !== "Reflect" || + !isRootAlias(node.arguments?.[0]) + ) { + return; + } + const key = unwrap(node.arguments?.[1]); + if ( + (key?.type === "Literal" || + key?.type === "StringLiteral") && + typeof key.value === "string" && + browserCapabilityProperties.has(key.value) + ) { + context.report({ node, messageId: "owned" }); + } + }, + NewExpression(node: any) { + for (const argument of node.arguments ?? []) { + const candidate = + argument.type === "SpreadElement" + ? argument.argument + : argument; + if (isBareRootAlias(candidate)) { + context.report({ node: argument, messageId: "owned" }); + } + } + }, + Property(node: any) { + if ( + node.parent?.type === "ObjectExpression" && + isBareRootAlias(node.value) + ) { + context.report({ node: node.value, messageId: "owned" }); + } + }, + ArrayExpression(node: any) { + for (const element of node.elements ?? []) { + if (isBareRootAlias(element)) { + context.report({ node: element, messageId: "owned" }); + } + } + }, + ReturnStatement(node: any) { + if (isBareRootAlias(node.argument)) { + context.report({ node: node.argument, messageId: "owned" }); + } + }, + ArrowFunctionExpression(node: any) { + if ( + node.expression === true && + isBareRootAlias(node.body) + ) { + context.report({ node: node.body, messageId: "owned" }); + } + }, + }; + }, +}; + +const browserDataBoundaryPlugin = { + rules: { + "no-capability-alias": noBrowserCapabilityAliasRule, + }, +}; + +const commonLanguageOptions = { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + }, +}; + +const commonSecurityRules = { + "no-eval": "error", + "no-new-func": "error", + "no-script-url": "error", + "no-restricted-syntax": [ + "error", + { + selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", + message: "Raw HTML injection is prohibited by FE-OC-019.", + }, + { + selector: + "CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']", + message: "Runtime script construction is prohibited by FE-OC-019.", + }, + ], +}; + +const hookRules = { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", +}; + +export default [ + { + ignores: [ + "dist/**", + "node_modules/**", + "artifacts/**", + "tests/fixtures/typecheck/**", + "tests/fixtures/architecture/forbidden/**", + "tests/fixtures/diagnostics/forbidden/**", + "tests/fixtures/i18n/forbidden/**", + "tests/fixtures/security/forbidden/**", + "tests/fixtures/optional-recipes/**", + "tests/fixtures/browser-file-storage-boundaries/**", + ], + }, + eslint.configs.recommended, + { + files: [`**/*.${sourceExtensions}`], + languageOptions: { + ...commonLanguageOptions, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { + "react-hooks": reactHooks, + }, + rules: { + ...commonSecurityRules, + ...hookRules, + }, + }, + { + files: ["**/*.{ts,mts,cts}"], + languageOptions: { + ...commonLanguageOptions, + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + plugins: [ + ["@babel/plugin-syntax-typescript", { isTSX: false }], + ], + }, + }, + }, + rules: { + "no-undef": "off", + "no-unused-vars": "off", + }, + }, + { + files: ["**/*.d.ts"], + languageOptions: { + ...commonLanguageOptions, + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + plugins: [ + ["@babel/plugin-syntax-typescript", { dts: true }], + ], + }, + }, + }, + rules: { + "no-undef": "off", + "no-unused-vars": "off", + }, + }, + { + files: ["**/*.tsx"], + languageOptions: { + ...commonLanguageOptions, + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + plugins: [ + [ + "@babel/plugin-syntax-typescript", + { allExtensions: true, isTSX: true }, + ], + "@babel/plugin-syntax-jsx", + ], + }, + }, + }, + rules: { + "no-undef": "off", + "no-unused-vars": "off", + }, + }, + { + files: [ + `src/domain/**/*.${sourceExtensions}`, + `src/application/**/*.${sourceExtensions}`, + `src/presentation/**/*.${sourceExtensions}`, + `src/bootstrap/**/*.${sourceExtensions}`, + `src/features/**/*.${sourceExtensions}`, + `src/adapters/**/*.${sourceExtensions}`, + ], + ignores: [ + `src/adapters/browser-file-storage/**/*.${sourceExtensions}`, + `src/adapters/browser-files/**/*.${sourceExtensions}`, + `src/adapters/browser-transfer/**/*.${sourceExtensions}`, + `src/adapters/cache-storage/**/*.${sourceExtensions}`, + `src/adapters/cross-context-invalidation/**/*.${sourceExtensions}`, + `src/adapters/storage/**/*.${sourceExtensions}`, + `src/adapters/storage/indexeddb/**/*.${sourceExtensions}`, + `src/adapters/storage/opfs/**/*.${sourceExtensions}`, + ], + plugins: { + "browser-data-boundary": browserDataBoundaryPlugin, + }, + rules: { + "browser-data-boundary/no-capability-alias": "error", + }, + }, + { + files: [`src/domain/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.domain), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "window", + "document", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "fetch", + "indexedDB", + "caches", + "navigator", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [`src/application/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.application), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "window", + "document", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "fetch", + "indexedDB", + "caches", + "navigator", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [`src/presentation/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.presentation), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "fetch", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [ + `src/presentation/adapters/query/**/*.${sourceExtensions}`, + ], + rules: { + "no-restricted-imports": restrictedImports([ + "**/adapters/http/**", + "**/adapters/storage/**", + "**/adapters/auth/**", + "**/bootstrap/**", + "**/application/ports/out/**", + ]), + }, + }, + { + files: [`src/presentation/templates/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports([ + "**/application/**", + "**/adapters/**", + "**/bootstrap/**", + "@tanstack/**", + ]), + }, + }, + { + files: [ + `src/bootstrap/**/*.${sourceExtensions}`, + `src/features/installed-feature-*.${sourceExtensions}`, + ], + rules: { + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [`src/adapters/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.adapters), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "navigator", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [ + `src/adapters/browser-file-storage/**/*.${sourceExtensions}`, + `src/adapters/browser-files/**/*.${sourceExtensions}`, + `src/adapters/browser-transfer/**/*.${sourceExtensions}`, + `src/adapters/cache-storage/**/*.${sourceExtensions}`, + `src/adapters/cross-context-invalidation/**/*.${sourceExtensions}`, + `src/adapters/storage/**/*.${sourceExtensions}`, + `src/adapters/storage/indexeddb/**/*.${sourceExtensions}`, + `src/adapters/storage/opfs/**/*.${sourceExtensions}`, + ], + rules: { + "no-restricted-properties": "off", + "no-restricted-globals": "off", + }, + }, + { + files: [`src/features/*/domain/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.domain), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "window", + "document", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "fetch", + "indexedDB", + "caches", + "navigator", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [`src/features/*/application/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports(layerPatterns.application), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "window", + "document", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "fetch", + "indexedDB", + "caches", + "navigator", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [`src/features/*/presentation/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports([ + "**/features/*/adapters/**", + "**/adapters/http/**", + "**/adapters/storage/**", + "**/adapters/auth/**", + "**/bootstrap/**", + "**/application/ports/out/**", + "@tanstack/**", + ]), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "fetch", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [`src/features/*/adapters/**/*.${sourceExtensions}`], + rules: { + "no-restricted-imports": restrictedImports([ + "**/presentation/**", + "**/bootstrap/**", + "@tanstack/**", + ]), + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "navigator", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, + { + files: [`tests/**/*.${sourceExtensions}`], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + }, + { + files: [`tests/support/browser/**/*.${sourceExtensions}`], + rules: { + "react-hooks/rules-of-hooks": "off", + }, + }, + { + files: [ + `tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`, + ], + rules: { + "no-restricted-imports": restrictedImports([ + "**/adapters/**", + "**/application/ports/out/**", + "@tanstack/**", + "react", + "react-dom", + "**/application/**", + ]), + "no-restricted-globals": [ + "error", + "fetch", + "localStorage", + "sessionStorage", + ], + }, + }, + { + files: [ + `tests/fixtures/browser-file-storage-boundaries/forbidden/**/*.${sourceExtensions}`, + ], + plugins: { + "browser-data-boundary": browserDataBoundaryPlugin, + }, + rules: { + "browser-data-boundary/no-capability-alias": "error", + "no-restricted-properties": restrictedBrowserDataProperties, + "no-restricted-globals": [ + "error", + "BroadcastChannel", + "localStorage", + "sessionStorage", + "indexedDB", + "caches", + "File", + "Blob", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "showOpenFilePicker", + "showSaveFilePicker", + ], + }, + }, +]; diff --git a/index.html b/index.html index bc72e40..047e363 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,6 @@
- + diff --git a/package.json b/package.json index 3ef240e..1fb352f 100644 --- a/package.json +++ b/package.json @@ -10,31 +10,34 @@ }, "scripts": { "dev": "vite", - "build": "vite build && node scripts/generate-build-manifest.mjs", + "build": "vite build && node scripts/generate-build-manifest.ts", "build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security", "preview": "vite preview", - "lint": "eslint src scripts tests recipes .storybook vite.config.js vitest.config.js playwright*.config.js --max-warnings=0", - "check:architecture": "node scripts/check-architecture.mjs", - "check:design-system": "node scripts/check-design-system.mjs", - "check:design-system:fixture": "node scripts/check-design-system.mjs --fixture", - "check:i18n": "node scripts/check-i18n.mjs", - "check:i18n:fixture": "node scripts/check-i18n.mjs --fixture", - "check:diagnostics": "node scripts/check-diagnostics.mjs", - "check:diagnostics:fixture": "node scripts/check-diagnostics.mjs --fixture", - "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test", + "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", "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": "tsc --project tsconfig.recipes.json", - "check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js", + "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 --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", - "check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", - "check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", - "check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts", - "check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --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 --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx", + "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", @@ -44,54 +47,62 @@ "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", "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.js", + "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.js", - "test:visual": "playwright test --config playwright.visual.config.js", - "test:visual:update": "playwright test --config playwright.visual.config.js --update-snapshots", - "check:test-evidence": "node scripts/check-test-evidence.mjs", - "check:test-evidence:fixture": "node scripts/check-test-evidence.mjs --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.mjs", - "review:a11y-manual": "node scripts/verify-a11y-manual.mjs", - "test:sample-removal": "node scripts/test-sample-removal.mjs", - "test:optional-recipe-removal": "node scripts/test-optional-recipe-removal.mjs", + "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: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", - "test:coverage": "vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.mjs", - "check:coverage:fixture": "node scripts/check-risk-coverage.mjs --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json", + "test:coverage": "vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --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", - "check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.mjs", - "generate:supply-chain": "node scripts/generate-supply-chain.mjs", - "verify:supply-chain": "node scripts/verify-supply-chain-artifacts.mjs", - "update:dependency-baseline": "node scripts/update-dependency-baseline.mjs", - "check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.mjs", - "check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.mjs", - "verify:supply-chain:promotion": "node scripts/verify-supply-chain-promotion.mjs", - "verify:reproducible-build": "node scripts/verify-reproducible-build.mjs", - "scan:security": "node scripts/security-scan.mjs", - "scan:security:fixture": "node scripts/security-scan.mjs --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif", - "check:browser-security": "node scripts/check-browser-security.mjs", - "check:optional-recipes": "node scripts/check-optional-recipes.mjs --require-dist", - "check:optional-recipes:source": "node scripts/check-optional-recipes.mjs", - "check:optional-recipe-fixtures": "node scripts/check-optional-recipe-fixtures.mjs", - "check:registries": "node scripts/check-registries.mjs", - "check:registries:structure": "node scripts/check-registries.mjs --no-baseline", - "check:registries:compatibility-fixtures": "node scripts/check-registry-compatibility-fixtures.mjs", - "check:registries:baseline-fixture": "node scripts/check-registries.mjs --approval tests/fixtures/registry/compatibility/tampered-approval.json --artifact artifacts/quality/registry-baseline-fixture.json", - "check:registries:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/forbidden/governance.json --artifact artifacts/quality/registry-fixture.json", - "check:routes:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/routes/governance.json --artifact artifacts/quality/route-registry-fixture.json", - "verify:compatibility": "node scripts/check-compatibility.mjs", - "verify:release": "node scripts/verify-release.mjs", - "verify:hosting-headers": "node scripts/verify-hosting-headers.mjs", - "check:bundle": "node scripts/generate-supply-chain.mjs && node scripts/check-bundle.mjs", - "test:performance": "node scripts/test-performance.mjs", - "collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.mjs", - "drill:runbook": "node scripts/drill-runbook.mjs", + "check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts", + "generate:supply-chain": "node scripts/generate-supply-chain.ts", + "verify:supply-chain": "node scripts/verify-supply-chain-artifacts.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:supply-chain:promotion": "node scripts/verify-supply-chain-promotion.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: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.mjs", - "check:ci": "node scripts/check-ci-contract.mjs", - "verify:documentation": "node scripts/verify-documentation-readiness.mjs" + "ci:gate": "node scripts/run-ci-gate.ts", + "check:ci": "node scripts/check-ci-contract.ts", + "verify:documentation": "node scripts/verify-documentation-readiness.ts" }, "dependencies": { "@tanstack/react-query": "5.101.4", 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.js b/playwright.config.ts similarity index 85% rename from playwright.config.js rename to playwright.config.ts index 0be5b51..83fe081 100644 --- a/playwright.config.js +++ b/playwright.config.ts @@ -22,22 +22,22 @@ export default defineConfig({ projects: [ { name: "chromium", - testIgnore: "**/compact-smoke.spec.js", + testIgnore: "**/compact-smoke.spec.ts", use: { ...devices["Desktop Chrome"] }, }, { name: "firefox", - testIgnore: "**/compact-smoke.spec.js", + testIgnore: "**/compact-smoke.spec.ts", use: { ...devices["Desktop Firefox"] }, }, { name: "webkit", - testIgnore: "**/compact-smoke.spec.js", + testIgnore: "**/compact-smoke.spec.ts", use: { ...devices["Desktop Safari"] }, }, { name: "chromium-compact", - testMatch: "**/compact-smoke.spec.js", + testMatch: "**/compact-smoke.spec.ts", use: { ...devices["Desktop Chrome"], viewport: { width: 390, height: 844 }, diff --git a/playwright.dev.config.js b/playwright.dev.config.ts similarity index 85% rename from playwright.dev.config.js rename to playwright.dev.config.ts index ddb8fb7..95cc321 100644 --- a/playwright.dev.config.js +++ b/playwright.dev.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from "@playwright/test"; -import releaseConfig from "./playwright.config.js"; +import releaseConfig from "./playwright.config.ts"; export default defineConfig({ ...releaseConfig, diff --git a/playwright.storybook.config.js b/playwright.storybook.config.ts similarity index 95% rename from playwright.storybook.config.js rename to playwright.storybook.config.ts index 62d2ace..4c2fac0 100644 --- a/playwright.storybook.config.js +++ b/playwright.storybook.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ }, webServer: { command: - "corepack pnpm build:storybook && node scripts/serve-static.mjs artifacts/storybook/static 6006", + "corepack pnpm build:storybook && node scripts/serve-static.ts artifacts/storybook/static 6006", url: "http://127.0.0.1:6006", reuseExistingServer: false, }, diff --git a/playwright.visual.config.js b/playwright.visual.config.ts similarity index 100% rename from playwright.visual.config.js rename to playwright.visual.config.ts diff --git a/public/release-manifest.json b/public/release-manifest.json index 553bde5..056bc95 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -9,14 +9,14 @@ "releaseId": "local-release", "builtAt": "1970-01-01T00:00:00.000Z", "routeChunks": { - "route-home": "src/presentation/pages/home-page.jsx", - "route-examples-ui": "src/presentation/examples/ui-gallery-page.jsx", - "route-examples-states": "src/presentation/examples/state-gallery-page.jsx", - "route-examples-auth": "src/presentation/examples/auth-example-page.jsx", + "route-home": "src/presentation/pages/home-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.jsx" + "route-not-found": "src/presentation/pages/not-found-page.tsx" } } 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 index 38d0b8e..e6603ad 100644 --- a/recipes/frontend-capabilities/contracts.ts +++ b/recipes/frontend-capabilities/contracts.ts @@ -11,19 +11,25 @@ export const OPTIONAL_RECIPE_RUNTIME_SENTINEL = 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<{ @@ -85,13 +91,29 @@ export interface ServiceWorkerUpdatePort { } 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 }>; + file: Readonly<{ + name: string; + size: number; + type: string; + content: TransferByteSource; + }>; signal: AbortSignal; onProgress(progress: TransferProgress): void; }): Promise>>; @@ -99,7 +121,15 @@ export interface FileTransferPort { resourceId: string; signal: AbortSignal; onProgress(progress: TransferProgress): void; - }): Promise>; + }): Promise< + CapabilityResult< + Readonly<{ + fileName: string; + mediaType: string; + content: TransferByteSource; + }> + > + >; } export interface GeneratedApiFacade { diff --git a/recipes/frontend-capabilities/fake-adapters.ts b/recipes/frontend-capabilities/fake-adapters.ts index 0e22ca0..27a0efc 100644 --- a/recipes/frontend-capabilities/fake-adapters.ts +++ b/recipes/frontend-capabilities/fake-adapters.ts @@ -20,7 +20,7 @@ import type { ServiceWorkerUpdatePort, VersionedOfflineRepository, WorkerTaskPort, -} from "./contracts.js"; +} from "./contracts.ts"; export function success(value: T): CapabilityResult { return Object.freeze({ ok: true, value }); @@ -221,11 +221,35 @@ export class FakeFileTransferAdapter implements FileTransferPort { ) { return failure("LIMIT_EXCEEDED", false, "File size or type is not allowed."); } - input.onProgress({ - transferredBytes: input.file.size, - totalBytes: input.file.size, - }); - return success({ resourceId: `fake:${input.file.name}` }); + 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]) { @@ -235,11 +259,23 @@ export class FakeFileTransferAdapter implements FileTransferPort { return failure("EXPIRED_RESOURCE", true, "The download link expired."); } const bytes = new TextEncoder().encode(input.resourceId); - input.onProgress({ - transferredBytes: bytes.byteLength, - totalBytes: bytes.byteLength, + 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(), + }, }); - return success(bytes); } } diff --git a/recipes/frontend-capabilities/index.ts b/recipes/frontend-capabilities/index.ts index 2de0ecd..0aca00f 100644 --- a/recipes/frontend-capabilities/index.ts +++ b/recipes/frontend-capabilities/index.ts @@ -1,2 +1,4 @@ -export * from "./contracts.js"; -export * from "./fake-adapters.js"; +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/config/frontend-capability-recipes.schema.json b/schemas/config/frontend-capability-recipes.schema.json index 28721a7..9471e67 100644 --- a/schemas/config/frontend-capability-recipes.schema.json +++ b/schemas/config/frontend-capability-recipes.schema.json @@ -63,6 +63,7 @@ "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 }, @@ -78,6 +79,24 @@ "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, diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs deleted file mode 100644 index fba5973..0000000 --- a/scripts/check-architecture.mjs +++ /dev/null @@ -1,110 +0,0 @@ -import { mkdir, readdir, writeFile } from "node:fs/promises"; -import { spawnSync } from "node:child_process"; - -await mkdir("artifacts/quality", { recursive: true }); - -const pnpmCli = /** @type {string} */ (process.env.npm_execpath); - -if (!pnpmCli) { - throw new Error("check:architecture must run through the pnpm script"); -} - -/** @param {string[]} arguments_ */ -function runPnpm(arguments_) { - return spawnSync(process.execPath, [pnpmCli, ...arguments_], { - encoding: "utf8", - }); -} - -const production = runPnpm( - [ - "exec", - "depcruise", - "src", - "--config", - ".dependency-cruiser.cjs", - "--output-type", - "json", - ], -); - -await writeFile( - "artifacts/quality/dependency-report.json", - production.stdout || JSON.stringify({ summary: { errors: 1 } }), -); - -if (production.status !== 0) { - process.stderr.write( - production.error?.message ?? production.stderr ?? production.stdout ?? "failed", - ); - process.exit(production.status ?? 1); -} - -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", - ], -); - -/** @param {string} directory @returns {Promise} */ -async function fixtureFiles(directory) { - 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) - : /\.(?:js|jsx|mjs|ts|tsx|mts)$/.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`, -); diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts new file mode 100644 index 0000000..e08e911 --- /dev/null +++ b/scripts/check-architecture.ts @@ -0,0 +1,1055 @@ +import { parseAsync } from "@babel/core"; +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, readdir, writeFile } 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"; + +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 writeFile( + qualityArtifact, + `${JSON.stringify(dependencyReport, null, 2)}\n`, +); + +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.mjs b/scripts/check-browser-security.ts similarity index 78% rename from scripts/check-browser-security.mjs rename to scripts/check-browser-security.ts index 034448b..ba8acb5 100644 --- a/scripts/check-browser-security.mjs +++ b/scripts/check-browser-security.ts @@ -1,10 +1,17 @@ import { readdir } from "node:fs/promises"; import { spawnSync } from "node:child_process"; -const pnpmCli = /** @type {string} */ (process.env.npm_execpath); +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; +} -/** @param {string[]} arguments_ */ -function runPnpm(arguments_) { +const pnpmCli = requiredPnpmCli(); + +function runPnpm(arguments_: string[]) { return spawnSync(process.execPath, [pnpmCli, ...arguments_], { encoding: "utf8", }); diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.ts similarity index 76% rename from scripts/check-bundle.mjs rename to scripts/check-bundle.ts index 9d8878c..a309a2c 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.ts @@ -1,23 +1,28 @@ import { readFile, writeFile } from "node:fs/promises"; -import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js"; -import { classifyViteJavascript } from "./lib/classify-vite-bundle.mjs"; +import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.ts"; +import { classifyViteJavascript } from "./lib/classify-vite-bundle.ts"; -const report = - /** @type {{ - * outputs: Array<{ path: string, gzipBytes: number }>, - * [key: string]: unknown - * }} */ ( - JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8")) - ); -const viteManifest = - /** @type {Record} */ ( - JSON.parse(await readFile("dist/.vite/manifest.json", "utf8")) - ); -const budgets = - /** @type {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} */ ( - JSON.parse(await readFile("config/performance/budgets.json", "utf8")).bundle - ); +type BundleOutput = { path: string; gzipBytes: number }; +type BundleReport = { outputs: BundleOutput[]; [key: string]: unknown }; +type ViteManifest = Record< + string, + { file: string; isEntry?: boolean; imports?: string[] } +>; +type BundleBudgets = { + initialJsGzipBytes: number; + lazyChunkGzipBytes: number; +}; + +const report = JSON.parse( + await readFile("artifacts/performance/bundle.json", "utf8"), +) as BundleReport; +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]), diff --git a/scripts/check-ci-contract.mjs b/scripts/check-ci-contract.mjs deleted file mode 100644 index 98fdd68..0000000 --- a/scripts/check-ci-contract.mjs +++ /dev/null @@ -1,111 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; - -import { - evaluatePromotionReadiness, - PROMOTION_FORMULA, -} from "../src/application/policies/promotion-readiness.js"; - -const document = JSON.parse(await readFile("config/ci/gates.json", "utf8")); -const workflow = await readFile(document.providerAdapter, "utf8"); -const failures = []; -const stageFormula = { - merge: PROMOTION_FORMULA.MERGE_READY, - release: PROMOTION_FORMULA.RELEASE_READY, - production: PROMOTION_FORMULA.PROD_PROMOTION_READY, - field: PROMOTION_FORMULA.FIELD_SLO_READY, - documentation: PROMOTION_FORMULA.DOCUMENTATION_READY, -}; - -for (const [stage, expectedGates] of Object.entries(stageFormula)) { - const actual = document.stages[stage]?.gates; - if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) { - failures.push(`${stage} gate formula drift`); - } -} - -const configuredGateIds = Object.keys(document.gates).sort(); -const expectedGateIds = Array.from( - { length: 26 }, - (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, -); -if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) { - failures.push("gate registry must contain FE-GATE-001..026 exactly once"); -} - -for (const [gateId, gate] of Object.entries(document.gates)) { - if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) { - failures.push(`${gateId} lacks command, evidence, or retention wiring`); - } -} - -const forbiddenWorkflowPatterns = [ - /continue-on-error\s*:/, - /retention-days\s*:/, - /allow_failure\s*:/, -]; -for (const pattern of forbiddenWorkflowPatterns) { - if (pattern.test(workflow)) { - failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`); - } -} -for (const requiredToken of [ - "merge_gate:", - "release_gate:", - "production_gate:", - "field_gate:", - "documentation_gate:", - "needs: merge_gate", - "needs: release_gate", - "needs: production_gate", - "actions/upload-artifact@v4", - "if: always()", -]) { - if (!workflow.includes(requiredToken)) { - failures.push(`workflow missing ${requiredToken}`); - } -} - -const passingResults = Object.fromEntries( - expectedGateIds.map((gateId) => [gateId, /** @type {const} */ ("PASS")]), -); -const allPass = evaluatePromotionReadiness(passingResults); -const negativeFixtures = []; -for (const [readiness, gateIds] of Object.entries(PROMOTION_FORMULA)) { - const failedGate = gateIds[0]; - const result = evaluatePromotionReadiness({ - ...passingResults, - [failedGate]: "FAIL", - }); - const passed = - /** @type {Readonly>} */ (result)[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 report = { - schemaVersion: 1, - generatedAt: new Date().toISOString(), - providerAdapter: document.providerAdapter, - gateCount: configuredGateIds.length, - noDowngrade: failures.every( - (failure) => !failure.includes("downgrade"), - ), - durationStatus: document.retention.durationStatus, - negativeFixtures, - failures, - passed: failures.length === 0, -}; -await mkdir("artifacts/quality", { recursive: true }); -await writeFile( - "artifacts/quality/ci-contract.json", - `${JSON.stringify(report, null, 2)}\n`, -); -if (failures.length > 0) { - process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); - process.exit(1); -} -process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n"); diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts new file mode 100644 index 0000000..7f28493 --- /dev/null +++ b/scripts/check-ci-contract.ts @@ -0,0 +1,266 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { + evaluatePromotionReadiness, + PROMOTION_FORMULA, + type GateResult, +} from "../src/application/policies/promotion-readiness.ts"; + +type GateDefinition = Readonly<{ + steps?: readonly unknown[]; + evidence?: readonly string[]; + retentionClass?: string; +}>; +type CiContractDocument = Readonly<{ + providerAdapter: string; + stages: Readonly>>; + gates: Readonly>; + retention: Readonly<{ durationStatus: unknown }>; +}>; + +const document = parseCiContractDocument( + JSON.parse(await readFile("config/ci/gates.json", "utf8")), +); +const workflow = await readFile(document.providerAdapter, "utf8"); +const nodeVersion = (await readFile(".nvmrc", "utf8")).trim(); +const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8"); +const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8"); +const buildManifestGenerator = await readFile( + "scripts/generate-build-manifest.ts", + "utf8", +); +const failures: string[] = []; +if (!/^\d+\.\d+\.\d+$/.test(nodeVersion)) { + failures.push(".nvmrc must contain one exact Node.js semantic version"); +} +const setupNodeCount = + workflow.match(/uses:\s*actions\/setup-node@v4/g)?.length ?? 0; +const nodeVersionFileCount = + workflow.match(/node-version-file:\s*\.nvmrc/g)?.length ?? 0; +if (setupNodeCount === 0 || nodeVersionFileCount !== setupNodeCount) { + failures.push("every setup-node step must use node-version-file: .nvmrc"); +} +if (/node-version\s*:/.test(workflow) || /NODE_VERSION\s*:/.test(workflow)) { + failures.push("workflow must not override the exact .nvmrc Node.js pin"); +} +const stageFormula: Readonly> = { + merge: PROMOTION_FORMULA.MERGE_READY, + release: PROMOTION_FORMULA.RELEASE_READY, + production: PROMOTION_FORMULA.PROD_PROMOTION_READY, + field: PROMOTION_FORMULA.FIELD_SLO_READY, + documentation: PROMOTION_FORMULA.DOCUMENTATION_READY, +}; + +for (const [stage, expectedGates] of Object.entries(stageFormula)) { + const actual = document.stages[stage]?.gates; + if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) { + failures.push(`${stage} gate formula drift`); + } +} + +const configuredGateIds = Object.keys(document.gates).sort(); +const expectedGateIds = Array.from( + { length: 26 }, + (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, +); +if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) { + failures.push("gate registry must contain FE-GATE-001..026 exactly once"); +} + +for (const [gateId, gate] of Object.entries(document.gates)) { + if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) { + failures.push(`${gateId} lacks command, evidence, or retention wiring`); + } +} + +const runbookGateEvidence = Object.freeze({ + "FE-GATE-016": "artifacts/runbooks/FE-RB-005/record.json", + "FE-GATE-021": "artifacts/runbooks/FE-RB-001/record.json", + "FE-GATE-022": "artifacts/runbooks/FE-RB-002/record.json", + "FE-GATE-023": "artifacts/runbooks/FE-RB-003/record.json", + "FE-GATE-024": "artifacts/runbooks/FE-RB-004/record.json", + "FE-GATE-025": "artifacts/runbooks/FE-RB-005/record.json", +}); +for (const [gateId, evidencePath] of Object.entries(runbookGateEvidence)) { + const evidence = document.gates[gateId]?.evidence; + if ( + !Array.isArray(evidence) || + evidence.length !== 1 || + evidence[0] !== evidencePath + ) { + failures.push(`${gateId} runbook evidence path drift`); + } +} +if ( + !drillRunner.includes( + "const artifactDirectory = `artifacts/runbooks/${runbookId}`", + ) || + drillRunner.includes( + "artifacts/runbooks/${runbookId}/${release.releaseId}", + ) +) { + failures.push( + "runbook evidence path must be stable while releaseId stays in the record", + ); +} + +const forbiddenWorkflowPatterns = [ + /continue-on-error\s*:/, + /retention-days\s*:/, + /timeout-minutes\s*:/, + /allow_failure\s*:/, +]; +for (const pattern of forbiddenWorkflowPatterns) { + if (pattern.test(workflow)) { + failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`); + } +} +for (const requiredToken of [ + "merge_gate:", + "release_gate:", + "production_gate:", + "field_gate:", + "documentation_gate:", + "needs: merge_gate", + "needs: release_gate", + "needs: production_gate", + "actions/upload-artifact@v4", + "if: always()", + "permissions:", + "contents: read", + '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 }}"', +]) { + if (!workflow.includes(requiredToken)) { + failures.push(`workflow missing ${requiredToken}`); + } +} +for (const requiredToken of [ + "ciCheckoutIdentityFailures", + "ciBuildEnvironmentFailures", + "SOURCE_DATE_EPOCH", + '"--format=%H%n%ct"', + "env: gateEnvironment", +]) { + if (!gateRunner.includes(requiredToken)) { + failures.push(`CI gate runner missing ${requiredToken}`); + } +} +for (const requiredToken of [ + "assertCiBuildEnvironment(process.env)", + "releaseId", + "sourceDateEpoch", +]) { + if (!buildManifestGenerator.includes(requiredToken)) { + failures.push(`build manifest generator missing ${requiredToken}`); + } +} + +const passingResults: Record = {}; +for (const gateId of expectedGateIds) passingResults[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< + keyof typeof PROMOTION_FORMULA +>) { + const gateIds = PROMOTION_FORMULA[readiness]; + const failedGate = gateIds[0]; + if (!failedGate) throw new Error(`${readiness} has no configured gates`); + const result = evaluatePromotionReadiness({ + ...passingResults, + [failedGate]: "FAIL", + }); + const passed = result[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 report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + providerAdapter: document.providerAdapter, + nodeVersion, + gateCount: configuredGateIds.length, + noDowngrade: failures.every( + (failure) => !failure.includes("downgrade"), + ), + durationStatus: document.retention.durationStatus, + negativeFixtures, + failures, + passed: failures.length === 0, +}; +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + "artifacts/quality/ci-contract.json", + `${JSON.stringify(report, null, 2)}\n`, +); +if (failures.length > 0) { + process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); + process.exit(1); +} +process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n"); + +function parseCiContractDocument(value: unknown): CiContractDocument { + if (!isRecord(value)) throw new TypeError("CI gate config must be an object"); + if ( + typeof value.providerAdapter !== "string" || + !isRecord(value.stages) || + !isRecord(value.gates) || + !isRecord(value.retention) + ) { + throw new TypeError("CI gate config is missing required registries"); + } + const stages: Record = {}; + for (const [stage, candidate] of Object.entries(value.stages)) { + if (!isRecord(candidate)) throw new TypeError(`Invalid CI stage: ${stage}`); + if ( + candidate.gates !== undefined && + (!Array.isArray(candidate.gates) || + !candidate.gates.every((gate) => typeof gate === "string")) + ) { + throw new TypeError(`Invalid gate list for CI stage: ${stage}`); + } + stages[stage] = { + gates: candidate.gates as readonly string[] | undefined, + }; + } + const gates: Record = {}; + for (const [gateId, candidate] of Object.entries(value.gates)) { + if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`); + if ( + candidate.evidence !== undefined && + (!Array.isArray(candidate.evidence) || + !candidate.evidence.every((path) => typeof path === "string")) + ) { + throw new TypeError(`Invalid evidence list for CI gate: ${gateId}`); + } + gates[gateId] = { + steps: Array.isArray(candidate.steps) ? candidate.steps : undefined, + evidence: candidate.evidence as readonly string[] | undefined, + retentionClass: + typeof candidate.retentionClass === "string" + ? candidate.retentionClass + : undefined, + }; + } + return { + providerAdapter: value.providerAdapter, + stages, + gates, + retention: { durationStatus: value.retention.durationStatus }, + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/scripts/check-compatibility.mjs b/scripts/check-compatibility.ts similarity index 72% rename from scripts/check-compatibility.mjs rename to scripts/check-compatibility.ts index 2b32bd1..3865fd9 100644 --- a/scripts/check-compatibility.mjs +++ b/scripts/check-compatibility.ts @@ -1,14 +1,30 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.js"; +import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.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"]) { + 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 }); diff --git a/scripts/check-design-system.mjs b/scripts/check-design-system.ts similarity index 90% rename from scripts/check-design-system.mjs rename to scripts/check-design-system.ts index 2e87141..556b1b7 100644 --- a/scripts/check-design-system.mjs +++ b/scripts/check-design-system.ts @@ -1,18 +1,16 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; -// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate. 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 = []; +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", }; -/** @type {Array} */ -const tokenLayers = [ +const tokenLayers: Array = [ ["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS], ["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS], ["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS], @@ -75,13 +73,12 @@ if (!componentSource.includes("@media (forced-colors: active)")) { failures.push("forced-colors token fallback is missing"); } -/** @param {string} directory @returns {Promise} */ -async function listSourceFiles(directory) { - const result = []; +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 (/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) result.push(target); + else if (/\.(?:ts|tsx|mts|cts)$/.test(entry.name)) result.push(target); } return result; } @@ -103,7 +100,7 @@ for (const file of sources) { } if ( !file.includes("src/presentation/design-system/") && - /presentation\/design-system\/(?!index(?:\.js)?["'])/.test(source) + /presentation\/design-system\/(?!index(?:\.tsx?)?["'])/.test(source) ) { failures.push(`design-system deep import in ${file}`); } diff --git a/scripts/check-diagnostics.mjs b/scripts/check-diagnostics.ts similarity index 81% rename from scripts/check-diagnostics.mjs rename to scripts/check-diagnostics.ts index 84c7721..84c6fcd 100644 --- a/scripts/check-diagnostics.mjs +++ b/scripts/check-diagnostics.ts @@ -1,17 +1,15 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; -// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate. import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts"; -import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.js"; +import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.ts"; const fixtureMode = process.argv.includes("--fixture"); -const failures = []; -const extensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/; +const failures: string[] = []; +const extensions = /\.(?:ts|tsx|mts|cts)$/; -/** @param {string} directory @returns {Promise} */ -async function filesBelow(directory) { - const result = []; +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))); @@ -20,28 +18,26 @@ async function filesBelow(directory) { return result; } -const telemetryProducerFiles = - /** @type {Readonly>} */ ({ +const telemetryProducerFiles: Readonly> = { "app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts", - "api.request.failed": "src/adapters/http/client.js", + "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.js", - }); -const diagnosticProducerFiles = - /** @type {Readonly>} */ ({ + "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.js", + "http.request.completed": "src/adapters/http/client.ts", "cache.operation.failed": - "src/adapters/query-cache/tanstack-query-cache.js", + "src/adapters/query-cache/tanstack-query-cache.ts", "storage.operation.failed": - "src/adapters/storage/browser-storage-adapter.js", + "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.js", - }); + "telemetry.delivery.dropped": "src/bootstrap/runtime-adapters.ts", + }; if (!fixtureMode) { for (const eventName of Object.keys(TELEMETRY_REGISTRY)) { @@ -75,7 +71,7 @@ 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.js")) continue; + if (file.includes("contracts/telemetry.ts")) continue; if (source.includes("console.")) { failures.push(`direct console diagnostics bypass in ${file}`); } diff --git a/scripts/check-frozen-lockfile-fixture.mjs b/scripts/check-frozen-lockfile-fixture.ts similarity index 100% rename from scripts/check-frozen-lockfile-fixture.mjs rename to scripts/check-frozen-lockfile-fixture.ts diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.ts similarity index 85% rename from scripts/check-i18n.mjs rename to scripts/check-i18n.ts index c626391..d408f82 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.ts @@ -1,19 +1,17 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; -// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate. import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts"; const fixtureMode = process.argv.includes("--fixture"); -const failures = []; -const sourceExtensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/; +const failures: string[] = []; +const sourceExtensions = /\.(?:ts|tsx|mts|cts)$/; const koreanLiteral = /[가-힣]/; const rawFailureRender = /(?} */ -async function listSourceFiles(directory) { - const result = []; +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))); @@ -22,8 +20,7 @@ async function listSourceFiles(directory) { return result; } -/** @param {string} template */ -function placeholders(template) { +function placeholders(template: string): string[] { return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)] .map((match) => match[1]) .sort(); @@ -32,10 +29,8 @@ function placeholders(template) { if (!fixtureMode) { const canonicalKeys = Object.keys(KO_MESSAGES).sort(); for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) { - const readableCatalog = - /** @type {Readonly>} */ (catalog); - const canonicalCatalog = - /** @type {Readonly>} */ (KO_MESSAGES); + 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`); diff --git a/scripts/check-optional-recipe-fixtures.mjs b/scripts/check-optional-recipe-fixtures.mjs deleted file mode 100644 index 3893f9b..0000000 --- a/scripts/check-optional-recipe-fixtures.mjs +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; - -import { - scanOptionalRecipeSources, - validateRecipeCatalog, -} from "./lib/optional-recipes.mjs"; - -const catalog = JSON.parse( - await readFile("config/recipes/frontend-capability-recipes.json", "utf8"), -); -const packageDocument = JSON.parse(await readFile("package.json", "utf8")); - -const cleanupCatalog = structuredClone(catalog); -cleanupCatalog.recipes.find( - /** @param {{id: string}} recipe */ (recipe) => recipe.id === "realtime", -).lifecycleMethods = []; - -const dependencyCatalog = structuredClone(catalog); -dependencyCatalog.productionRuntimeDependencies = ["zustand"]; - -const workflowCatalog = structuredClone(catalog); -workflowCatalog.recipes.find( - /** @param {{id: string}} recipe */ (recipe) => 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 }, -); -sourceViolations.push(...productionViolations); -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"), - }, -]; -const report = { - schemaVersion: 1, - results, - passed: results.every(({ passed }) => passed), -}; -await mkdir("artifacts/quality", { recursive: true }); -await writeFile( - "artifacts/quality/optional-recipe-fixtures.json", - `${JSON.stringify(report, null, 2)}\n`, -); -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-fixtures.ts b/scripts/check-optional-recipe-fixtures.ts new file mode 100644 index 0000000..b6e62e7 --- /dev/null +++ b/scripts/check-optional-recipe-fixtures.ts @@ -0,0 +1,201 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; + +import { measureOptionalRecipeBundle } from "./lib/optional-recipe-bundle.ts"; +import { + scanOptionalRecipeSources, + scanProductionBundle, + validateRecipeCatalog, +} from "./lib/optional-recipes.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 = { + schemaVersion: 1, + results, + bundleBudgetFixtures, + passed: results.every(({ passed }) => passed), +}; +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + "artifacts/quality/optional-recipe-fixtures.json", + `${JSON.stringify(report, null, 2)}\n`, +); +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.mjs b/scripts/check-optional-recipes.mjs deleted file mode 100644 index 04d24bf..0000000 --- a/scripts/check-optional-recipes.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; - -import { - scanOptionalRecipeSources, - scanProductionBundle, - validateRecipeCatalog, -} from "./lib/optional-recipes.mjs"; - -/** @param {string} name @param {string} fallback */ -const argument = (name, fallback) => { - const index = process.argv.indexOf(name); - return index === -1 ? fallback : process.argv[index + 1]; -}; - -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")); -const packageDocument = JSON.parse(await readFile("package.json", "utf8")); -const catalogViolations = validateRecipeCatalog(catalog, packageDocument); -const sourceViolations = await scanOptionalRecipeSources(sourceRoot); -const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`) - .then(() => true) - .catch(() => false); -const bundleViolations = await scanProductionBundle(distRoot); -const violations = [ - ...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })), - ...sourceViolations, - ...bundleViolations.map((path) => ({ - ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE", - path, - })), - ...(requireDist && !bundlePresent - ? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }] - : []), -]; -const report = { - schemaVersion: 1, - decisionId: "VD-10", - selectedCapabilities: [], - recipeCount: Array.isArray(catalog.recipes) ? catalog.recipes.length : 0, - productionRuntimeDependencies: - catalog.productionRuntimeDependencies ?? null, - bundleStatus: bundlePresent - ? bundleViolations.length === 0 - ? "PASS" - : "FAIL" - : "NOT_BUILT", - violations, - passed: violations.length === 0, -}; -await mkdir("artifacts/quality", { recursive: true }); -await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`); - -if (violations.length > 0) { - process.stderr.write( - `Optional recipe contract failed:\n${violations - .map((violation) => `${violation.ruleId}: ${violation.path}`) - .join("\n")}\n`, - ); - process.exit(1); -} -process.stdout.write( - `Optional recipes: PASS (${report.recipeCount} recipe-only capabilities, bundle=${report.bundleStatus})\n`, -); diff --git a/scripts/check-optional-recipes.ts b/scripts/check-optional-recipes.ts new file mode 100644 index 0000000..7515bbd --- /dev/null +++ b/scripts/check-optional-recipes.ts @@ -0,0 +1,246 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; + +import { + measureOptionalRecipeBundle, + type OptionalRecipeBundleMeasurement, +} from "./lib/optional-recipe-bundle.ts"; +import { + scanOptionalRecipeSources, + scanProductionBundle, + validateRecipeCatalog, +} from "./lib/optional-recipes.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 catalogViolations = 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 = { + 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 writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`); + +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..49cbc9c --- /dev/null +++ b/scripts/check-realtime-boundaries.ts @@ -0,0 +1,40 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { scanRealtimeBoundaries } from "./lib/realtime-boundaries.ts"; + +const sourceRoot = argument("--source-root") ?? "src"; +const artifact = + argument("--artifact") ?? + "artifacts/quality/realtime-boundaries.json"; +const violations = await scanRealtimeBoundaries(sourceRoot); +const report = Object.freeze({ + schemaVersion: 1, + sourceRoot, + violations, + passed: violations.length === 0, +}); + +await mkdir(path.dirname(artifact), { recursive: true }); +await writeFile( + artifact, + `${JSON.stringify(report, null, 2)}\n`, +); + +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.mjs b/scripts/check-registries.ts similarity index 73% rename from scripts/check-registries.mjs rename to scripts/check-registries.ts index f545d6c..5ff49c3 100644 --- a/scripts/check-registries.mjs +++ b/scripts/check-registries.ts @@ -14,10 +14,48 @@ import { registrySnapshotDigest, validateBreakingEvidence, verifyRegistryBaselineApproval, -} from "./lib/registry-compatibility.mjs"; +} from "./lib/registry-compatibility.ts"; -/** @param {string} name @param {string | undefined} fallback */ -function argumentValue(name, fallback) { +type RegistryRow = Record; +type RegistryRows = Record; +type RegistryReference = Readonly<{ + registryId: string; + field: string; + targetField: string; +}>; +type RegistryConsumer = Readonly<{ path: string; token: string }>; +type RegistrySpecification = Readonly<{ + registryId: string; + owner: string; + path: string; + exportName: string; + declaredRows?: unknown; + requiredFields: readonly string[]; + fieldTypes?: Readonly>; + keyField?: string; + uniqueFields?: readonly string[]; + 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] @@ -26,13 +64,11 @@ function argumentValue(name, fallback) { const defaultGovernancePath = "config/contracts/registry-governance.json"; const governancePath = - /** @type {string} */ ( - argumentValue("--governance", defaultGovernancePath) - ); + argumentValue("--governance", defaultGovernancePath) ?? + defaultGovernancePath; const artifactPath = - /** @type {string} */ ( - argumentValue("--artifact", "artifacts/quality/registries.json") - ); + argumentValue("--artifact", "artifacts/quality/registries.json") ?? + "artifacts/quality/registries.json"; const usesRepositoryBaseline = governancePath === defaultGovernancePath && !process.argv.includes("--no-baseline"); @@ -54,28 +90,31 @@ const evidencePath = argumentValue( ? "config/contracts/registry-change-evidence.json" : undefined, ); -const governance = JSON.parse(await readFile(governancePath, "utf8")); -const failures = []; -const owners = new Map(); -const snapshots = []; -const rowsByRegistry = new Map(); -const sourcesByRegistry = new Map(); -const registryExtensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"]; +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"]; -/** @param {string} declaredPath */ -async function resolveRegistrySource(declaredPath) { +async function resolveRegistrySource( + declaredPath: string, +): Promise { const extension = path.extname(declaredPath); const basePath = extension ? declaredPath.slice(0, -extension.length) : declaredPath; - const candidates = []; + const candidates: string[] = []; for (const candidateExtension of registryExtensions) { const candidate = `${basePath}${candidateExtension}`; try { await access(candidate); candidates.push(candidate); } catch { - // A TypeScript migration may replace the declared extension. + // Continue through the supported TypeScript source extensions. } } if (candidates.length > 1) { @@ -87,16 +126,14 @@ async function resolveRegistrySource(declaredPath) { return candidates[0] ?? null; } -/** @param {unknown} value */ -function runtimeType(value) { +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; } -/** @param {unknown} value @param {string} declaration */ -function matchesDeclaredType(value, declaration) { +function matchesDeclaredType(value: unknown, declaration: string): boolean { const actual = runtimeType(value); return declaration .split("|") @@ -107,8 +144,7 @@ function matchesDeclaredType(value, declaration) { ); } -/** @param {string} directory @returns {Promise} */ -async function filesBelow(directory) { +async function filesBelow(directory: string): Promise { try { const entries = await readdir(directory, { withFileTypes: true }); const groups = await Promise.all( @@ -118,7 +154,7 @@ async function filesBelow(directory) { }), ); return groups.flat().filter((file) => - /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(file), + /\.(?:ts|tsx|mts|cts)$/.test(file), ); } catch { return []; @@ -135,10 +171,10 @@ for (const specification of governance.registries) { const sourcePath = await resolveRegistrySource(specification.path); try { if (!sourcePath) throw new Error("missing registry source"); - const module = await import( + const registryModule = (await import( `${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}` - ); - rows = module[specification.exportName]; + )) as Record; + rows = registryModule[specification.exportName]; } catch { if (!rows) failures.push(`missing registry source ${specification.path}`); } @@ -148,13 +184,14 @@ for (const specification of governance.registries) { continue; } - rowsByRegistry.set(specification.registryId, rows); + const registryRows = rows as RegistryRows; + rowsByRegistry.set(specification.registryId, registryRows); sourcesByRegistry.set( specification.registryId, sourcePath ?? specification.path, ); - for (const [rowName, row] of Object.entries(rows)) { + 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; @@ -187,8 +224,8 @@ for (const specification of governance.registries) { } for (const field of specification.uniqueFields ?? []) { - const values = new Map(); - for (const [rowName, row] of Object.entries(rows)) { + const values = new Map(); + for (const [rowName, row] of Object.entries(registryRows)) { if (!row || typeof row !== "object" || Array.isArray(row)) continue; const value = row[field]; if (value === undefined) continue; @@ -206,13 +243,10 @@ for (const specification of governance.registries) { for (const [field, allowed] of Object.entries( specification.allowedValues ?? {}, )) { - for (const [rowName, row] of Object.entries(rows)) { + for (const [rowName, row] of Object.entries(registryRows)) { if (!row || typeof row !== "object" || Array.isArray(row)) continue; if ( - !allowed.some( - /** @param {unknown} value */ - (value) => Object.is(value, row[field]), - ) + !allowed.some((value) => Object.is(value, row[field])) ) { failures.push( `${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`, @@ -234,9 +268,9 @@ for (const specification of governance.registries) { registryId: specification.registryId, owner: specification.owner, source: sourcePath ?? specification.path, - rowCount: Object.keys(rows).length, + rowCount: Object.keys(registryRows).length, contract, - rows: canonicalizeRegistryValue(rows), + rows: canonicalizeRegistryValue(registryRows), }); } @@ -351,25 +385,25 @@ for (const sourceDirectory of sourceFiles) { } } -const currentSnapshot = - /** @type {Readonly>} */ ( - canonicalizeRegistryValue({ - schemaVersion: 2, - registries: snapshots, - }) - ); -let baselineDigest = null; -let currentDigest = registrySnapshotDigest(currentSnapshot); -let compatibility = - /** @type {{impact: string, changes: readonly Record[]}} */ ({ - impact: "not-evaluated", - changes: [], - }); +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")); - const approval = JSON.parse(await readFile(approvalPath, "utf8")); + 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) { @@ -377,9 +411,12 @@ if (baselinePath && approvalPath && evidencePath) { `registry baseline approval digest mismatch: approved=${approvalResult.approvedDigest} actual=${approvalResult.actualDigest}`, ); } - compatibility = diffRegistrySnapshots(baseline, currentSnapshot); - const evidence = JSON.parse(await readFile(evidencePath, "utf8")); - const evidenceResult = validateBreakingEvidence(compatibility, evidence); + 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( diff --git a/scripts/check-registry-compatibility-fixtures.mjs b/scripts/check-registry-compatibility-fixtures.ts similarity index 97% rename from scripts/check-registry-compatibility-fixtures.mjs rename to scripts/check-registry-compatibility-fixtures.ts index b1c2a4d..c49a5de 100644 --- a/scripts/check-registry-compatibility-fixtures.mjs +++ b/scripts/check-registry-compatibility-fixtures.ts @@ -4,7 +4,7 @@ import { diffRegistrySnapshots, validateBreakingEvidence, verifyRegistryBaselineApproval, -} from "./lib/registry-compatibility.mjs"; +} from "./lib/registry-compatibility.ts"; const fixtures = JSON.parse( await readFile( diff --git a/scripts/check-risk-coverage.mjs b/scripts/check-risk-coverage.ts similarity index 69% rename from scripts/check-risk-coverage.mjs rename to scripts/check-risk-coverage.ts index 83c4f78..ce1581f 100644 --- a/scripts/check-risk-coverage.mjs +++ b/scripts/check-risk-coverage.ts @@ -1,8 +1,24 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -/** @param {string} name @param {string} fallback */ -function argumentValue(name, fallback) { +type CoverageMetrics = Record; +type CoveragePolicy = Readonly<{ + summary: Record; + criticalModules: readonly Readonly<{ + path: string; + minimum: Record; + }>[]; +}>; +type CoverageSummary = Record; +type CoverageResult = Readonly<{ + scope: string; + metric: string; + threshold: number; + received: number | undefined; + passed: boolean; +}>; + +function argumentValue(name: string, fallback: string): string { const index = process.argv.indexOf(name); return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] @@ -21,24 +37,20 @@ const artifactPath = argumentValue( "--artifact", "artifacts/quality/risk-coverage.json", ); -const policy = JSON.parse(await readFile(policyPath, "utf8")); -const summary = JSON.parse(await readFile(summaryPath, "utf8")); -const failures = []; -/** @type {Array<{ - * scope: string, - * metric: string, - * threshold: number, - * received: number | undefined, - * passed: boolean - * }>} */ -const results = []; +const policy = JSON.parse( + await readFile(policyPath, "utf8"), +) as CoveragePolicy; +const summary = JSON.parse( + await readFile(summaryPath, "utf8"), +) as CoverageSummary; +const failures: string[] = []; +const results: CoverageResult[] = []; -/** - * @param {string} scope - * @param {Record} actual - * @param {Record} minimum - */ -function evaluate(scope, actual, minimum) { +function evaluate( + scope: string, + actual: CoverageMetrics, + minimum: Record, +): void { for (const [metric, threshold] of Object.entries(minimum)) { const received = actual?.[metric]?.pct; const passed = diff --git a/scripts/check-supply-chain-fixtures.mjs b/scripts/check-supply-chain-fixtures.ts similarity index 99% rename from scripts/check-supply-chain-fixtures.mjs rename to scripts/check-supply-chain-fixtures.ts index 2cc1b2a..910802e 100644 --- a/scripts/check-supply-chain-fixtures.mjs +++ b/scripts/check-supply-chain-fixtures.ts @@ -8,7 +8,7 @@ import { validateLicensePolicy, validateVulnerabilityReport, verifySupplyChainCoherence, -} from "./lib/supply-chain.mjs"; +} from "./lib/supply-chain.ts"; const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`; const baseDependency = { diff --git a/scripts/check-supply-chain-provider-fixtures.mjs b/scripts/check-supply-chain-provider-fixtures.ts similarity index 65% rename from scripts/check-supply-chain-provider-fixtures.mjs rename to scripts/check-supply-chain-provider-fixtures.ts index b052a3e..aa8938a 100644 --- a/scripts/check-supply-chain-provider-fixtures.mjs +++ b/scripts/check-supply-chain-provider-fixtures.ts @@ -2,17 +2,26 @@ import { spawnSync } from "node:child_process"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +type Document = Record; + +function isRecord(value: unknown): value is Document { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +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 fixtureDirectory = path.resolve(".tmp/supply-chain-provider-fixture"); await rm(fixtureDirectory, { recursive: true, force: true }); await mkdir(fixtureDirectory, { recursive: true }); -const inventory = JSON.parse( - await readFile("artifacts/release/dependency-inventory.json", "utf8"), +const inventory = await readDocument( + "artifacts/release/dependency-inventory.json", ); -const verification = JSON.parse( - await readFile( - "artifacts/security/supply-chain-verification.json", - "utf8", - ), +const verification = await readDocument( + "artifacts/security/supply-chain-verification.json", ); const vulnerabilityPath = path.join( fixtureDirectory, @@ -51,7 +60,7 @@ await writeFile( ); const providerRun = spawnSync( "node", - ["scripts/generate-supply-chain.mjs"], + ["scripts/generate-supply-chain.ts"], { env: { ...process.env, @@ -63,16 +72,17 @@ const providerRun = spawnSync( ); let promotionStatus = "MISSING"; if (providerRun.status === 0) { - promotionStatus = JSON.parse( - await readFile( - "artifacts/security/supply-chain-verification.json", - "utf8", - ), - ).promotionStatus; + const providerVerification = await readDocument( + "artifacts/security/supply-chain-verification.json", + ); + promotionStatus = + typeof providerVerification.promotionStatus === "string" + ? providerVerification.promotionStatus + : "MISSING"; } const restore = spawnSync( "node", - ["scripts/generate-supply-chain.mjs"], + ["scripts/generate-supply-chain.ts"], { encoding: "utf8" }, ); await rm(fixtureDirectory, { recursive: true, force: true }); @@ -95,8 +105,14 @@ await writeFile( )}\n`, ); if (!passed) { + const detail = + providerRun.stderr || + restore.stderr || + providerRun.stdout || + restore.stdout || + `providerStatus=${String(providerRun.status)}, promotionStatus=${promotionStatus}, restoreStatus=${String(restore.status)}`; process.stderr.write( - `Supply-chain provider fixture failed: ${providerRun.stderr || restore.stderr}\n`, + `Supply-chain provider fixture failed: ${detail}\n`, ); process.exit(1); } diff --git a/scripts/check-test-evidence.mjs b/scripts/check-test-evidence.mjs deleted file mode 100644 index c22a94c..0000000 --- a/scripts/check-test-evidence.mjs +++ /dev/null @@ -1,167 +0,0 @@ -import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; -import path from "node:path"; - -/** @param {string} name @param {string} fallback */ -function argumentValue(name, fallback) { - const index = process.argv.indexOf(name); - return index >= 0 && process.argv[index + 1] - ? process.argv[index + 1] - : fallback; -} - -const sourceRoot = argumentValue("--source-root", "tests"); -const artifactPath = argumentValue( - "--artifact", - "artifacts/quality/test-evidence.json", -); -const fixtureMode = sourceRoot !== "tests"; -const failures = []; -const facts = { - scannedFiles: 0, - visualBaselines: 0, - sharedScenarios: 0, -}; - -/** @param {string} target @returns {Promise} */ -async function filesBelow(target) { - 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 []; - } -} - -const sourceFiles = (await filesBelow(sourceRoot)).filter( - (file) => fixtureMode || !file.split(path.sep).includes("fixtures"), -); -for (const file of sourceFiles) { - if (!/\.(?:js|jsx|mjs|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`); - } -} - -if (!fixtureMode) { - const e2eConfig = await readFile("playwright.config.js", "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.js missing release evidence token ${token}`); - } - } - - const e2eFiles = (await filesBelow("tests/e2e")).filter((file) => - /\.spec\.(?:js|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 scenarioCatalog = await readFile( - "tests/mocks/scenarios/catalog.ts", - "utf8", - ); - const scenarioIdBlock = - scenarioCatalog.match( - /HTTP_SCENARIO_IDS\s*=\s*Object\.freeze\(\[([\s\S]*?)\]\s*as const\)/, - )?.[1] ?? ""; - facts.sharedScenarios = (scenarioIdBlock.match(/"[^"]+"/g) ?? []).length; - if (facts.sharedScenarios < 19) { - failures.push("shared MSW catalog must retain all 19 failure scenarios"); - } - const handler = await readFile( - "tests/mocks/handlers/reference-resources.ts", - "utf8", - ); - if ( - !handler.includes("assertOperationScenario") || - !handler.includes("../scenarios/catalog.js") - ) { - failures.push("MSW handler bypasses shared scenario catalog"); - } - - 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.js", - "playwright.visual.config.js", - "tests/storybook/workshop.spec.ts", - "artifacts/tests/storybook/results.xml", - "artifacts/tests/visual/results.xml", - ]) { - if ((await filesBelow(required)).length === 0) { - failures.push(`test evidence missing ${required}`); - } - } - - const requiredBuiltFiles = [ - "dist/index.html", - "dist/config.json", - "dist/release-manifest.json", - "dist/runtime-config.schema.json", - "dist/.vite/manifest.json", - ]; - for (const required of requiredBuiltFiles) { - 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: 1, - sourceRoot, - status: failures.length === 0 ? "PASS" : "FAIL", - facts, - failures, -}; -await mkdir(path.dirname(artifactPath), { recursive: true }); -await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`); -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.sharedScenarios} scenarios)\n`, -); diff --git a/scripts/check-test-evidence.ts b/scripts/check-test-evidence.ts new file mode 100644 index 0000000..25d82d4 --- /dev/null +++ b/scripts/check-test-evidence.ts @@ -0,0 +1,262 @@ +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type ScenarioCatalogContribution = Readonly<{ + owner: string; + path: string; + arrayExport: string; + minimumEntries: number; +}>; + +type SourceContractContribution = Readonly<{ + owner: string; + path: string; + requiredTokens: readonly string[]; +}>; + +type TestEvidencePolicy = Readonly<{ + schemaVersion: number; + scenarioCatalogs: readonly unknown[]; + sourceContracts: readonly unknown[]; +}>; + +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 sourceRoot = argumentValue("--source-root", "tests"); +const artifactPath = argumentValue( + "--artifact", + "artifacts/quality/test-evidence.json", +); +const policyPath = argumentValue( + "--policy", + "config/testing/test-evidence.json", +); +const fixtureMode = sourceRoot !== "tests"; +const sourceOnly = process.argv.includes("--source-only"); +const failures: string[] = []; +const facts = { + scannedFiles: 0, + visualBaselines: 0, + sharedScenarios: 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 escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +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`); + } +} + +if (!fixtureMode) { + 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`); + } + } + + let evidencePolicy: unknown; + try { + evidencePolicy = JSON.parse(await readFile(policyPath, "utf8")); + } catch (error) { + failures.push( + `${policyPath}: cannot read test evidence policy (${error instanceof Error ? error.message : String(error)})`, + ); + } + const policy = evidencePolicy as Partial | undefined; + if ( + policy?.schemaVersion !== 1 || + !Array.isArray(policy.scenarioCatalogs) || + !Array.isArray(policy.sourceContracts) + ) { + failures.push(`${policyPath}: invalid test evidence policy`); + } else { + for (const candidate of policy.scenarioCatalogs) { + const contribution = candidate as Partial; + const minimumEntries = contribution.minimumEntries; + if ( + typeof contribution?.owner !== "string" || + typeof contribution?.path !== "string" || + typeof contribution?.arrayExport !== "string" || + typeof minimumEntries !== "number" || + !Number.isInteger(minimumEntries) || + minimumEntries < 1 + ) { + failures.push(`${policyPath}: invalid scenario catalog contribution`); + continue; + } + let source: string; + try { + source = await readFile(contribution.path, "utf8"); + } catch (error) { + failures.push( + `${contribution.path}: cannot read scenario catalog (${error instanceof Error ? error.message : String(error)})`, + ); + continue; + } + const arrayPattern = new RegExp( + `${escapeRegExp(contribution.arrayExport)}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\s*as const\\)`, + ); + const entryCount = ( + arrayPattern.exec(source)?.[1]?.match(/"[^"]+"/g) ?? [] + ).length; + facts.sharedScenarios += entryCount; + if (entryCount < minimumEntries) { + failures.push( + `${contribution.path}: ${contribution.owner} requires at least ${minimumEntries} shared scenarios`, + ); + } + } + for (const candidate of policy.sourceContracts) { + const contract = candidate as Partial; + const requiredTokens = contract.requiredTokens; + if ( + typeof contract?.owner !== "string" || + typeof contract?.path !== "string" || + !Array.isArray(requiredTokens) || + requiredTokens.some((token: unknown) => typeof token !== "string") + ) { + failures.push(`${policyPath}: invalid source contract contribution`); + continue; + } + 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 requiredTokens as readonly string[]) { + if (!source.includes(token)) { + failures.push( + `${contract.path}: ${contract.owner} evidence contract is missing ${token}`, + ); + } + } + } + } + + 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}`); + } + } + + const requiredBuiltFiles = [ + "dist/index.html", + "dist/config.json", + "dist/release-manifest.json", + "dist/runtime-config.schema.json", + "dist/.vite/manifest.json", + ]; + for (const required of requiredBuiltFiles) { + 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: 1, + sourceRoot, + status: failures.length === 0 ? "PASS" : "FAIL", + facts, + failures, +}; +await mkdir(path.dirname(artifactPath), { recursive: true }); +await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`); +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.sharedScenarios} scenarios)\n`, +); diff --git a/scripts/collect-web-vitals-evidence.mjs b/scripts/collect-web-vitals-evidence.ts similarity index 89% rename from scripts/collect-web-vitals-evidence.mjs rename to scripts/collect-web-vitals-evidence.ts index 24e4551..2045774 100644 --- a/scripts/collect-web-vitals-evidence.mjs +++ b/scripts/collect-web-vitals-evidence.ts @@ -3,8 +3,8 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { evaluateFieldBudget, percentile75, -} from "../src/application/policies/performance-budgets.js"; -import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs"; +} from "../src/application/policies/performance-budgets.ts"; +import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.ts"; const inputPath = process.env.FIELD_WEB_VITALS_INPUT || @@ -17,15 +17,16 @@ const validation = validateFieldEvidenceInput( now, ); const input = validation.data; -const configured = - /** @type {{ - * p75LcpMs: number, - * p75Cls: number, - * p75InpMs: number, - * minimumEligibleSamples: number | null - * }} */ ( - JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field - ); +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); @@ -60,7 +61,7 @@ const routeSamples = Object.fromEntries( counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1; return counts; }, - /** @type {Record} */ ({}), + {} as Record, ), ).sort(([left], [right]) => left.localeCompare(right)), ); diff --git a/scripts/drill-runbook.mjs b/scripts/drill-runbook.ts similarity index 77% rename from scripts/drill-runbook.mjs rename to scripts/drill-runbook.ts index bba52f2..b89488d 100644 --- a/scripts/drill-runbook.mjs +++ b/scripts/drill-runbook.ts @@ -1,58 +1,72 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { shouldRetry } from "../src/adapters/http/retry-policy.js"; -import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.js"; -import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.js"; -import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js"; -import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.js"; -import { projectTelemetryEvent } from "../src/contracts/telemetry.js"; -import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js"; +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 { compareReleaseToRuntime } from "../src/contracts/release-tokens.ts"; +import { projectTelemetryEvent } from "../src/contracts/telemetry.ts"; -/** - * @typedef {{ - * triggerAsserted: boolean, - * containmentAsserted: boolean, - * recoveryAssertions: Array<{ - * assertion: string, - * evidence: string, - * passed: boolean - * }>, - * negativeFixtureFailedAsExpected: boolean, - * providerVerificationRequired: boolean - * }} DrillResult - */ +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; +}>; + +type ReleaseManifest = Record & { + buildId: string; + configSchemaVersion: string; + apiContractVersion: string; + assetManifestHash: string; + releaseId: string; +}; const runbookId = process.argv .slice(2) .find((argument) => /^FE-RB-00[1-5]$/.test(argument)); -const document = - /** @type {{ - * runbooks: Record - * }} */ ( - JSON.parse(await readFile("config/runbooks/runbooks.json", "utf8")) - ); +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); } -async function releaseManifest() { +async function releaseManifest(): Promise { for (const candidate of [ "dist/release-manifest.json", "public/release-manifest.json", ]) { try { - return JSON.parse(await readFile(candidate, "utf8")); + return JSON.parse( + await readFile(candidate, "utf8"), + ) as ReleaseManifest; } catch { // Continue to the source fallback. } @@ -74,12 +88,15 @@ const validConfig = { RELEASE_ID: "local-release", }; -/** @param {string} assertion @param {string} evidence @param {boolean} passed */ -function assertion(assertion, evidence, passed) { +function assertion( + assertion: string, + evidence: string, + passed: boolean, +): RecoveryAssertion { return { assertion, evidence, passed }; } -async function drillBoot() { +async function drillBoot(): Promise { const invalid = validateRuntimeConfig({ ...validConfig, APP_ENV: "production", @@ -103,23 +120,21 @@ async function drillBoot() { }; } -function memoryStorage() { - /** @type {unknown} */ - let value; +function memoryStorage(): StoragePort { + let value: unknown; return { - read: () => ({ ok: /** @type {const} */ (true), value }), - /** @param {string} _key @param {unknown} next */ - write: (_key, next) => { + read: () => ({ ok: true, value }), + write: (_key: string, next: unknown) => { value = next; - return { ok: /** @type {const} */ (true) }; + return { ok: true }; }, - remove: () => ({ ok: /** @type {const} */ (true) }), + remove: () => ({ ok: true }), }; } -async function drillChunkMismatch() { +async function drillChunkMismatch(): Promise { const storage = memoryStorage(); - const input = { + const input: Parameters[0] = { failureKind: "DEPLOY_MISMATCH", manifestLoaded: true, currentBuildId: "build-a", @@ -157,7 +172,7 @@ async function drillChunkMismatch() { }; } -async function drillApiDegradation() { +async function drillApiDegradation(): Promise { const unkeyedRetry = shouldRetry( { idempotency: "none" }, { kind: "SERVER_FAILURE", httpStatus: 503 }, @@ -182,7 +197,7 @@ async function drillApiDegradation() { }; } -async function drillTelemetry() { +async function drillTelemetry(): Promise { const adapter = createTelemetryAdapter({ enabled: true, endpoint: "https://telemetry.invalid/events", @@ -221,7 +236,7 @@ async function drillTelemetry() { }; } -async function drillRollback() { +async function drillRollback(): Promise { const release = await releaseManifest(); const runtime = JSON.parse( await readFile( @@ -255,21 +270,20 @@ async function drillRollback() { 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 artifact path", Boolean(release.releaseId)), + assertion("release ID in timeline", "drill record releaseId", Boolean(release.releaseId)), ], negativeFixtureFailedAsExpected: !mixed.compatible, providerVerificationRequired: true, }; } -const drillById = - /** @type {Record Promise>} */ ({ - "FE-RB-001": drillBoot, - "FE-RB-002": drillChunkMismatch, - "FE-RB-003": drillApiDegradation, - "FE-RB-004": drillTelemetry, - "FE-RB-005": drillRollback, - }); +const drillById: Record Promise> = { + "FE-RB-001": drillBoot, + "FE-RB-002": drillChunkMismatch, + "FE-RB-003": drillApiDegradation, + "FE-RB-004": drillTelemetry, + "FE-RB-005": drillRollback, +}; const drill = await drillById[runbookId](); const escalationPathAsserted = specification.escalation.length >= 2; const passed = @@ -294,7 +308,7 @@ const record = { providerVerificationRequired: drill.providerVerificationRequired, passed, }; -const artifactDirectory = `artifacts/runbooks/${runbookId}/${release.releaseId}`; +const artifactDirectory = `artifacts/runbooks/${runbookId}`; await mkdir(artifactDirectory, { recursive: true }); await writeFile( `${artifactDirectory}/record.json`, diff --git a/scripts/generate-build-manifest.mjs b/scripts/generate-build-manifest.mjs deleted file mode 100644 index 0fd01e8..0000000 --- a/scripts/generate-build-manifest.mjs +++ /dev/null @@ -1,102 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, writeFile } 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.js"; -import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js"; - -const packageJson = 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 = process.env.SOURCE_DATE_EPOCH - ? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1_000) - : new Date(); -if (!Number.isFinite(buildTime.getTime())) { - throw new Error("SOURCE_DATE_EPOCH must be epoch seconds"); -} -const builtAt = buildTime.toISOString(); -const viteManifest = await readFile("dist/.vite/manifest.json", "utf8"); -const viteManifestObject = - /** @type {Record} */ ( - JSON.parse(viteManifest) - ); -const assetManifestHash = createHash("sha256") - .update(viteManifest) - .digest("hex"); -const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); -/** @type {Record} */ -const routeChunks = {}; -for (const definition of Object.values(ROUTE_REGISTRY)) { - const runtime = - /** @type {Record} */ ( - ROUTE_RUNTIME_CONTRACT - )[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(runtimeConfigSchema); - -runtimeConfig.BUILD_ID = buildId; -runtimeConfig.RELEASE_ID = releaseId; - -const manifest = { - schemaVersion: 1, - buildId, - commitSha, - generatedAt: builtAt, - buildContext: { - nodeVersion: process.version, - packageManagerVersion, - runnerImage, - }, - outputs: { - directory: "dist", - viteManifest: "dist/.vite/manifest.json", - routeChunks, - runtimeConfigSchema: "dist/runtime-config.schema.json", - }, -}; - -const releaseManifest = { - schemaVersion: 1, - appVersion: packageJson.version, - buildId, - commitSha, - configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, - apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, - assetManifestHash, - releaseId, - builtAt, - routeChunks, -}; - -await mkdir("artifacts/release", { recursive: true }); -await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`); -await writeFile( - "dist/release-manifest.json", - `${JSON.stringify(releaseManifest, null, 2)}\n`, -); -await writeFile( - "dist/runtime-config.schema.json", - `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, -); -await writeFile( - "artifacts/release/runtime-config.schema.json", - `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, -); -await writeFile( - "artifacts/release/build-manifest.json", - `${JSON.stringify(manifest, null, 2)}\n`, -); diff --git a/scripts/generate-build-manifest.ts b/scripts/generate-build-manifest.ts new file mode 100644 index 0000000..42756a4 --- /dev/null +++ b/scripts/generate-build-manifest.ts @@ -0,0 +1,180 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } 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 { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.ts"; +import { + assertCiBuildEnvironment, + buildDate, +} from "./lib/build-environment.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("dist/.vite/manifest.json", "utf8"); +const viteManifestObject = parseViteManifest(JSON.parse(viteManifest)); +const moduleInventory = await readFile( + "dist/.vite/module-inventory.json", + "utf8", +); +parseModuleInventory(JSON.parse(moduleInventory)); +const assetManifestHash = createHash("sha256") + .update(viteManifest) + .digest("hex"); +const moduleInventoryHash = createHash("sha256") + .update(moduleInventory) + .digest("hex"); +const runtimeConfig = runtimeConfigSchema.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(runtimeConfigSchema); + +runtimeConfig.BUILD_ID = buildId; +runtimeConfig.RELEASE_ID = releaseId; + +const manifest = { + 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: "dist/.vite/manifest.json", + moduleInventory: "artifacts/quality/vite-module-inventory.json", + routeChunks, + runtimeConfigSchema: "dist/runtime-config.schema.json", + }, +}; + +const releaseManifest = { + schemaVersion: 1, + appVersion: packageJson.version, + buildId, + commitSha, + configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, + apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, + assetManifestHash, + releaseId, + builtAt, + routeChunks, +}; + +await mkdir("artifacts/release", { recursive: true }); +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + "artifacts/quality/vite-module-inventory.json", + moduleInventory, +); +await rm("dist/.vite/module-inventory.json"); +await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`); +await writeFile( + "dist/release-manifest.json", + `${JSON.stringify(releaseManifest, null, 2)}\n`, +); +await writeFile( + "dist/runtime-config.schema.json", + `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, +); +await writeFile( + "artifacts/release/runtime-config.schema.json", + `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, +); +await writeFile( + "artifacts/release/build-manifest.json", + `${JSON.stringify(manifest, null, 2)}\n`, +); + +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 parseModuleInventory(value: unknown): void { + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + !Array.isArray(value.chunks) || + value.chunks.some( + (chunk) => + !isRecord(chunk) || + typeof chunk.fileName !== "string" || + !Array.isArray(chunk.modules) || + chunk.modules.some((moduleId) => typeof moduleId !== "string"), + ) + ) { + throw new TypeError("Vite module inventory is invalid"); + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/scripts/generate-supply-chain.mjs b/scripts/generate-supply-chain.ts similarity index 83% rename from scripts/generate-supply-chain.mjs rename to scripts/generate-supply-chain.ts index e701968..5c869ac 100644 --- a/scripts/generate-supply-chain.mjs +++ b/scripts/generate-supply-chain.ts @@ -20,31 +20,54 @@ import { validateLicensePolicy, validateVulnerabilityReport, verifySupplyChainCoherence, -} from "./lib/supply-chain.mjs"; + type DependencyInventoryDiff, +} from "./lib/supply-chain.ts"; -/** @param {string} directory @returns {Promise} */ -async function filesWithin(directory) { +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 filesWithin(directory: string): Promise { try { const entries = await readdir(directory, { withFileTypes: true }); - const nested = /** @type {string[][]} */ (await Promise.all( + 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(); } catch { return []; } } -/** @param {string} file */ -async function sha256File(file) { +async function sha256File(file: string): Promise { return createHash("sha256").update(await readFile(file)).digest("hex"); } -/** @param {string[]} files */ -async function digestFileSet(files) { +async function digestFileSet(files: string[]): Promise { const rows = await Promise.all( files.sort().map(async (file) => ({ path: file.replaceAll("\\", "/"), @@ -54,17 +77,17 @@ async function digestFileSet(files) { return supplyChainDigest(rows); } -/** @param {string} file @returns {Promise | null>} */ -async function optionalJson(file) { +async function optionalJson(file: string): Promise { try { - return JSON.parse(await readFile(file, "utf8")); + const parsed: unknown = JSON.parse(await readFile(file, "utf8")); + return isRecord(parsed) ? parsed : null; } catch { return null; } } export async function buildDependencyInventory() { - const packageJson = JSON.parse(await readFile("package.json", "utf8")); + const packageJson = await jsonDocument("package.json"); const lockfileText = await readFile("pnpm-lock.yaml", "utf8"); const lockfileSha256 = createHash("sha256") .update(lockfileText) @@ -80,18 +103,19 @@ export async function buildDependencyInventory() { if (listed.status !== 0) { throw new Error(`pnpm dependency graph failed: ${listed.stderr}`); } - const roots = JSON.parse(listed.stdout); - const root = roots[0]; + 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, - packageJson.dependencies ?? {}, - packageJson.devDependencies ?? {}, + stringMap(packageJson.dependencies), + stringMap(packageJson.devDependencies), ); const lockRows = parsePnpmLockfilePackages(lockfileText); const lockByIdentity = new Map( lockRows.map((row) => [`${row.name}@${row.version}`, row]), ); - const failures = []; + const failures: string[] = []; const dependencies = flattened.map((dependency) => { const identity = `${dependency.name}@${dependency.version}`; const lockRow = lockByIdentity.get(identity); @@ -118,7 +142,7 @@ export async function buildDependencyInventory() { } return { schemaVersion: 2, - packageManager: packageJson.packageManager, + packageManager: String(packageJson.packageManager ?? ""), lockfileSha256, dependencyCount: dependencies.length, directDependencyCount: dependencies.filter((entry) => entry.direct).length, @@ -126,7 +150,7 @@ export async function buildDependencyInventory() { }; } -const packageJson = JSON.parse(await readFile("package.json", "utf8")); +const packageJson = await jsonDocument("package.json"); const outputFiles = await filesWithin("dist"); if (outputFiles.length === 0) { throw new Error("dist is missing; run the production build first"); @@ -169,19 +193,19 @@ const dependencyEvidence = JSON.parse( ), ); const skipsBaseline = process.argv.includes("--no-baseline"); -const baselineFailures = []; -let dependencyDiff = - /** @type {ReturnType} */ ({ - added: [], - removed: [], - changed: [], - upgrades: [], +const baselineFailures: string[] = []; +let dependencyDiff: DependencyInventoryDiff = Object.freeze({ + added: Object.freeze([]), + removed: Object.freeze([]), + changed: Object.freeze([]), + upgrades: Object.freeze([]), }); -let reviewResult = - /** @type {ReturnType} */ ({ +let reviewResult: ReturnType = Object.freeze({ passed: skipsBaseline, - highRisk: [], - failures: skipsBaseline ? [] : ["dependency baseline unavailable"], + highRisk: Object.freeze([]), + failures: Object.freeze( + skipsBaseline ? [] : ["dependency baseline unavailable"], + ), }); if (baseline && baselineApproval) { const actualBaselineDigest = supplyChainDigest(baseline); @@ -253,7 +277,7 @@ const sourceFiles = ( "schemas", "package.json", "pnpm-lock.yaml", - "vite.config.js", + "vite.config.ts", ].map(async (target) => { try { const metadata = await stat(target); @@ -299,8 +323,8 @@ const sbom = { metadata: { component: { type: "application", - name: packageJson.name, - version: packageJson.version, + name: String(packageJson.name ?? ""), + version: String(packageJson.version ?? ""), }, properties: [ { @@ -328,7 +352,7 @@ const provenance = { buildType: "https://vite.dev/build/v1", externalParameters: { nodeVersion: process.version, - packageManager: packageJson.packageManager, + packageManager: String(packageJson.packageManager ?? ""), }, internalParameters: { sourceSetSha256, @@ -361,12 +385,12 @@ const coherence = verifySupplyChainCoherence( const attestationInput = process.env.PROVENANCE_ATTESTATION_PATH ? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH) : null; -const attestationSubject = - /** @type {Record} */ ( - /** @type {Record} */ ( - attestationInput?.subject ?? {} - ).digest ?? {} - ); +const attestation = isRecord(attestationInput?.subject) + ? attestationInput.subject + : {}; +const attestationSubject = isRecord(attestation.digest) + ? attestation.digest + : {}; const attestationPassed = attestationSubject.sha256 === distDigest && typeof attestationInput?.provider === "string" && @@ -416,7 +440,7 @@ await writeFile( generatedAt: new Date().toISOString(), context: { nodeVersion: process.version, - packageManager: packageJson.packageManager, + packageManager: String(packageJson.packageManager ?? ""), runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`, }, 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/classify-vite-bundle.mjs b/scripts/lib/classify-vite-bundle.ts similarity index 71% rename from scripts/lib/classify-vite-bundle.mjs rename to scripts/lib/classify-vite-bundle.ts index 2d1cd45..4521b2e 100644 --- a/scripts/lib/classify-vite-bundle.mjs +++ b/scripts/lib/classify-vite-bundle.ts @@ -1,27 +1,27 @@ -/** - * @typedef {{ - * file: string, - * isEntry?: boolean, - * imports?: string[] - * }} ViteManifestEntry - */ +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. * - * @param {Record} manifest */ -export function classifyViteJavascript(manifest) { - const initialFiles = new Set(); - const visitedKeys = new Set(); +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 = []; + const missingImports: string[] = []; while (pendingKeys.length > 0) { - const key = /** @type {string} */ (pendingKeys.pop()); + const key = pendingKeys.pop(); + if (key === undefined) break; if (visitedKeys.has(key)) continue; visitedKeys.add(key); const entry = manifest[key]; diff --git a/scripts/lib/field-vitals-evidence.mjs b/scripts/lib/field-vitals-evidence.ts similarity index 95% rename from scripts/lib/field-vitals-evidence.mjs rename to scripts/lib/field-vitals-evidence.ts index c0dffb3..e923789 100644 --- a/scripts/lib/field-vitals-evidence.mjs +++ b/scripts/lib/field-vitals-evidence.ts @@ -68,15 +68,10 @@ const fieldEvidenceInputSchema = z } }); -/** - * @param {unknown} input - * @param {string | undefined} configuredMinimum - * @param {Date} [now] - */ export function validateFieldEvidenceInput( - input, - configuredMinimum, - now = new Date(), + input: unknown, + configuredMinimum: string | undefined, + now: Date = new Date(), ) { const parsed = fieldEvidenceInputSchema.safeParse(input); const failures = parsed.success diff --git a/scripts/lib/hosting-probe.mjs b/scripts/lib/hosting-probe.ts similarity index 82% rename from scripts/lib/hosting-probe.mjs rename to scripts/lib/hosting-probe.ts index 9db9380..ab934be 100644 --- a/scripts/lib/hosting-probe.mjs +++ b/scripts/lib/hosting-probe.ts @@ -4,15 +4,21 @@ const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/; * A release gate must not promote a local preview server as live hosting * evidence. * - * @param {string} value - * @returns { - * | { passed: true; reason: null; url: URL; observedOrigin: string } - * | { passed: false; reason: string; url: URL | null; observedOrigin: string | null } - * } */ -export function classifyLiveHostingBaseUrl(value) { - /** @type {URL} */ - let url; +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 { diff --git a/scripts/lib/manual-a11y-evidence.mjs b/scripts/lib/manual-a11y-evidence.ts similarity index 83% rename from scripts/lib/manual-a11y-evidence.mjs rename to scripts/lib/manual-a11y-evidence.ts index d67cd4d..1a40c8f 100644 --- a/scripts/lib/manual-a11y-evidence.mjs +++ b/scripts/lib/manual-a11y-evidence.ts @@ -1,4 +1,4 @@ -import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js"; +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), @@ -15,19 +15,18 @@ const REVIEW_FIELDS = Object.freeze([ "Screen reader", ]); -/** @param {string} content */ -export function validateManualA11yEvidence(content) { +export function validateManualA11yEvidence(content: string) { const fields = Object.fromEntries( content .split(/\r?\n/) .map((line) => /^([^:]+):\s*(.*)$/.exec(line)) - .filter(Boolean) + .filter((match): match is RegExpExecArray => match !== null) .map((match) => [ - /** @type {RegExpExecArray} */ (match)[1].trim(), - /** @type {RegExpExecArray} */ (match)[2].trim(), + match[1].trim(), + match[2].trim(), ]), ); - const failures = []; + 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"); 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.mjs b/scripts/lib/optional-recipes.mjs deleted file mode 100644 index 44609f1..0000000 --- a/scripts/lib/optional-recipes.mjs +++ /dev/null @@ -1,265 +0,0 @@ -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", -]); - -const lifecycleRecipes = new Set([ - "analytics-error-sink", - "browser-permission", - "client-workflow", - "file-transfer", - "generated-api", - "multi-tab", - "offline-indexeddb", - "realtime", - "service-worker-pwa", - "web-worker", -]); - -/** @param {unknown} value */ -function nonEmptyStrings(value) { - return ( - Array.isArray(value) && - value.length > 0 && - value.every((entry) => typeof entry === "string" && entry.trim().length > 0) - ); -} - -/** - * @param {unknown} input - * @param {Readonly>} packageDocument - * @returns {string[]} - */ -export function validateRecipeCatalog(input, packageDocument) { - const document = - /** @type {Record} */ ( - input && typeof input === "object" ? input : {} - ); - /** @type {string[]} */ - const violations = []; - 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 actualIds = document.recipes - .map(/** @param {Record} recipe */ (recipe) => 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 document.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", - ]) { - if (typeof recipe[field] !== "string" || recipe[field].trim().length === 0) { - violations.push(`${id}:MISSING_${field.toUpperCase()}`); - } - } - for (const field of [ - "forbiddenWhen", - "failureKinds", - "securityPrivacy", - "removal", - ]) { - if (!nonEmptyStrings(recipe[field])) { - violations.push(`${id}:MISSING_${field.toUpperCase()}`); - } - } - if ( - !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 = { - .../** @type {Record} */ (packageDocument.dependencies ?? {}), - .../** @type {Record} */ ( - packageDocument.devDependencies ?? {} - ), - }; - for (const pattern of document.vendorPackagePatterns ?? []) { - const wildcard = String(pattern).endsWith("*"); - const prefix = String(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; -} - -/** @param {string} directory @returns {Promise} */ -export async function sourceFiles(directory) { - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch (error) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { - return []; - } - throw error; - } - const groups = await Promise.all( - entries.map((entry) => { - const target = path.join(directory, entry.name); - return entry.isDirectory() - ? sourceFiles(target) - : /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name) - ? [target] - : []; - }), - ); - return groups.flat(); -} - -/** - * @param {string} root - * @param {{scanProductionBoundary?: boolean}} [options] - */ -export async function scanOptionalRecipeSources( - root, - { scanProductionBoundary = true } = {}, -) { - /** @type {Array<{ruleId: string; path: string}>} */ - const violations = []; - 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]); - - 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 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; -} - -/** @param {string} distRoot */ -export async function scanProductionBundle(distRoot) { - /** @type {string[]} */ - const violations = []; - for (const file of await sourceFiles(distRoot)) { - const content = await readFile(file, "utf8"); - if (content.includes("frontend-optional-recipe-must-not-reach-production")) { - violations.push(path.relative(process.cwd(), file)); - } - } - return violations; -} 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/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.mjs b/scripts/lib/registry-compatibility.ts similarity index 71% rename from scripts/lib/registry-compatibility.mjs rename to scripts/lib/registry-compatibility.ts index d42dc20..9245dce 100644 --- a/scripts/lib/registry-compatibility.mjs +++ b/scripts/lib/registry-compatibility.ts @@ -5,17 +5,36 @@ export const COMPATIBILITY_IMPACTS = Object.freeze([ "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]), ); -/** @param {unknown} value @returns {unknown} */ -export function canonicalizeRegistryValue(value) { +export function canonicalizeRegistryValue(value: unknown): unknown { if (Array.isArray(value)) { - const projected = - /** @type {unknown[]} */ (value.map(canonicalizeRegistryValue)); + const projected: unknown[] = value.map(canonicalizeRegistryValue); return projected.every( (item) => item === null || @@ -36,64 +55,64 @@ export function canonicalizeRegistryValue(value) { return value; } -/** @param {unknown} value @returns {string} */ -export function canonicalRegistryJson(value) { +export function canonicalRegistryJson(value: unknown): string { return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined"; } -/** @param {unknown} snapshot */ -export function registrySnapshotDigest(snapshot) { +export function registrySnapshotDigest(snapshot: unknown): string { return createHash("sha256") .update(canonicalRegistryJson(snapshot)) .digest("hex"); } -/** @param {string} current @param {string} candidate */ -function strongestImpact(current, candidate) { +function strongestImpact( + current: CompatibilityImpact, + candidate: CompatibilityImpact, +): CompatibilityImpact { return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0) ? candidate : current; } -/** @param {unknown} value */ -function valueType(value) { +function valueType(value: unknown): string { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } -/** - * @param {string} registryId - * @param {string} rowName - * @param {string} field - * @param {string} kind - */ -function changeId(registryId, rowName, field, kind) { +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. - * - * @param {Readonly>} before - * @param {Readonly>} after */ -export function diffRegistrySnapshots(before, after) { - const changes = /** @type {Array>} */ ([]); - let impact = "none"; - const beforeRegistries = - /** @type {Map>} */ (new Map( - /** @type {Array>} */ (before.registries ?? []).map( - (registry) => [String(registry.registryId), registry], - ), - )); - const afterRegistries = - /** @type {Map>} */ (new Map( - /** @type {Array>} */ (after.registries ?? []).map( - (registry) => [String(registry.registryId), registry], - ), - )); +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(), @@ -116,10 +135,8 @@ export function diffRegistrySnapshots(before, after) { continue; } - const previousContract = - /** @type {Record} */ (previous.contract ?? {}); - const currentContract = - /** @type {Record} */ (current.contract ?? {}); + const previousContract = (previous.contract ?? {}) as Record; + const currentContract = (current.contract ?? {}) as Record; const contractFields = new Set([ ...Object.keys(previousContract), ...Object.keys(currentContract), @@ -156,16 +173,16 @@ export function diffRegistrySnapshots(before, after) { } const breakingFields = new Set( - /** @type {string[]} */ ( - currentContract.breakingFields ?? [] - ), + (currentContract.breakingFields ?? []) as string[], ); - const beforeRows = - /** @type {Record>} */ ( - previous.rows ?? {} - ); - const afterRows = - /** @type {Record>} */ (current.rows ?? {}); + 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), @@ -209,8 +226,8 @@ export function diffRegistrySnapshots(before, after) { ) { continue; } - let kind; - let changeImpact; + let kind: string; + let changeImpact: CompatibilityImpact; if (!beforeHas) { kind = "field-added"; changeImpact = "additive"; @@ -261,11 +278,10 @@ export function diffRegistrySnapshots(before, after) { }); } -/** - * @param {Readonly>} snapshot - * @param {Readonly>} approval - */ -export function verifyRegistryBaselineApproval(snapshot, approval) { +export function verifyRegistryBaselineApproval( + snapshot: Readonly>, + approval: Readonly>, +) { const actualDigest = registrySnapshotDigest(snapshot); const approvedDigest = approval.snapshotDigest; return Object.freeze({ @@ -281,17 +297,15 @@ export function verifyRegistryBaselineApproval(snapshot, approval) { }); } -/** - * @param {ReturnType} diff - * @param {Readonly>} evidenceFile - */ -export function validateBreakingEvidence(diff, evidenceFile) { - const evidence = new Map( - /** @type {Array>} */ ( - evidenceFile.changes ?? [] - ).map((entry) => [entry.changeId, entry]), +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 = []; + const failures: string[] = []; for (const change of diff.changes.filter( (entry) => entry.impact === "breaking", )) { @@ -307,7 +321,8 @@ export function validateBreakingEvidence(diff, evidenceFile) { "rollback", "owner", ]) { - if (typeof entry[field] !== "string" || entry[field].trim().length === 0) { + const value = entry[field]; + if (typeof value !== "string" || value.trim().length === 0) { failures.push( `breaking change ${change.changeId} missing non-empty ${field}`, ); diff --git a/scripts/lib/supply-chain.mjs b/scripts/lib/supply-chain.mjs deleted file mode 100644 index 8201612..0000000 --- a/scripts/lib/supply-chain.mjs +++ /dev/null @@ -1,547 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; - -/** @param {unknown} value @returns {unknown} */ -export function canonicalizeSupplyChainValue(value) { - if (Array.isArray(value)) { - return value - .map(canonicalizeSupplyChainValue) - .sort((left, right) => - JSON.stringify(left).localeCompare(JSON.stringify(right)), - ); - } - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]), - ); - } - return value; -} - -/** @param {unknown} value */ -export function supplyChainDigest(value) { - return createHash("sha256") - .update(JSON.stringify(canonicalizeSupplyChainValue(value))) - .digest("hex"); -} - -/** @param {string} lockfile */ -export function parsePnpmLockfilePackages(lockfile) { - const entries = - /** @type {Array<{name: string, version: string, integrity: string}>} */ ( - [] - ); - let inPackages = false; - /** @type {{name: string, version: string, integrity: string} | null} */ - let current = 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) { - 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) { - current.integrity = integrityMatch[1]; - } - } - return entries.sort((left, right) => - `${left.name}@${left.version}`.localeCompare( - `${right.name}@${right.version}`, - ), - ); -} - -/** @param {string} integrity */ -export function isValidSha512Integrity(integrity) { - if (!integrity.startsWith("sha512-")) return false; - try { - return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64; - } catch { - return false; - } -} - -/** - * @param {unknown} raw - * @returns {string} - */ -export function normalizeLicense(raw) { - if (typeof raw === "string" && raw.trim()) return raw.trim(); - if ( - raw && - typeof raw === "object" && - "type" in 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"; -} - -/** - * @param {Record} root - * @param {Readonly>} directProduction - * @param {Readonly>} directDevelopment - */ -export async function flattenPnpmDependencyTree( - root, - directProduction, - directDevelopment, -) { - const records = - /** @type {Map - * }>} */ (new Map()); - const directIds = new Set(); - for (const [name, rawDependency] of Object.entries( - /** @type {Record} */ (root.dependencies ?? {}), - )) { - if ( - Object.hasOwn(directProduction, name) && - rawDependency && - typeof rawDependency === "object" && - !Array.isArray(rawDependency) - ) { - directIds.add( - `${name}@${String( - /** @type {Record} */ (rawDependency).version ?? "", - )}`, - ); - } - } - for (const [name, rawDependency] of Object.entries( - /** @type {Record} */ (root.devDependencies ?? {}), - )) { - if ( - Object.hasOwn(directDevelopment, name) && - rawDependency && - typeof rawDependency === "object" && - !Array.isArray(rawDependency) - ) { - directIds.add( - `${name}@${String( - /** @type {Record} */ (rawDependency).version ?? "", - )}`, - ); - } - } - - /** - * @param {Record} node - * @param {"production" | "development"} scope - * @param {boolean} optionalPath - */ - function visit(node, scope, optionalPath) { - for (const [groupName, group] of Object.entries({ - dependencies: node.dependencies, - devDependencies: node.devDependencies, - optionalDependencies: node.optionalDependencies, - })) { - if (!group || typeof group !== "object" || Array.isArray(group)) continue; - for (const [name, rawDependency] of Object.entries(group)) { - if ( - !rawDependency || - typeof rawDependency !== "object" || - Array.isArray(rawDependency) - ) { - continue; - } - const dependency = - /** @type {Record} */ (rawDependency); - const version = String(dependency.version ?? ""); - const packagePath = String(dependency.path ?? ""); - const identity = `${name}@${version}`; - const childScope = - 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 [ - dependency.dependencies, - dependency.optionalDependencies, - ]) { - if ( - !childGroup || - typeof childGroup !== "object" || - Array.isArray(childGroup) - ) { - continue; - } - for (const [childName, rawChild] of Object.entries(childGroup)) { - if ( - rawChild && - typeof rawChild === "object" && - !Array.isArray(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(dependency, childScope, childOptional); - } - } - } - - const productionRoot = { - dependencies: Object.fromEntries( - Object.entries( - /** @type {Record} */ (root.dependencies ?? {}), - ).filter(([name]) => Object.hasOwn(directProduction, name)), - ), - }; - const developmentRoot = { - devDependencies: Object.fromEntries( - Object.entries( - /** @type {Record} */ (root.devDependencies ?? {}), - ).filter(([name]) => Object.hasOwn(directDevelopment, name)), - ), - }; - visit(productionRoot, "production", false); - visit(developmentRoot, "development", false); - - const result = []; - for (const record of records.values()) { - let license = "NOASSERTION"; - let optional = record.optional; - if (record.packagePath) { - try { - const manifest = JSON.parse( - await readFile(`${record.packagePath}/package.json`, "utf8"), - ); - 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}`, - ), - ); -} - -/** - * @param {Readonly>} before - * @param {Readonly>} after - */ -export function diffDependencyInventories(before, after) { - const beforeRows = - /** @type {Array>} */ (before.dependencies ?? []); - const afterRows = - /** @type {Array>} */ (after.dependencies ?? []); - const beforeMap = new Map( - beforeRows.map((row) => [`${row.name}@${row.version}`, row]), - ); - const afterMap = new Map( - afterRows.map((row) => [`${row.name}@${row.version}`, row]), - ); - const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key)); - const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key)); - const changed = []; - 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 = []; - for (const removedKey of removed) { - const previous = beforeMap.get(removedKey); - const replacement = added.find( - (addedKey) => afterMap.get(addedKey)?.name === previous?.name, - ); - if (replacement) { - upgrades.push({ - name: previous?.name, - from: previous?.version, - to: afterMap.get(replacement)?.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) => - String(left.name).localeCompare(String(right.name)), - ), - ), - }); -} - -/** - * @param {Readonly>} inventory - * @param {Readonly>} policy - */ -export function validateLicensePolicy(inventory, policy) { - const allowed = new Set( - /** @type {string[]} */ (policy.allowedLicenses ?? []), - ); - const denied = /** @type {string[]} */ (policy.deniedLicensePatterns ?? []); - const failures = []; - const results = []; - for (const dependency of /** @type {Array>} */ ( - 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: `${dependency.name}@${dependency.version}`, - license, - passed, - reason: unknownAccepted ? "platform-optional-not-materialized" : null, - }); - if (!passed) { - failures.push( - `${dependency.name}@${dependency.version} has disallowed license ${license}`, - ); - } - } - return Object.freeze({ - passed: failures.length === 0, - failures: Object.freeze(failures), - results: Object.freeze(results), - }); -} - -/** - * @param {ReturnType} diff - * @param {Readonly>} inventory - * @param {Readonly>} evidenceFile - */ -export function validateDependencyReview(diff, inventory, evidenceFile) { - const rows = - /** @type {Array>} */ (inventory.dependencies ?? []); - const byIdentity = new Map( - rows.map((row) => [`${row.name}@${row.version}`, row]), - ); - const evidence = new Map( - /** @type {Array>} */ ( - evidenceFile.changes ?? [] - ).map((entry) => [entry.changeId, entry]), - ); - const highRisk = diff.added.filter((identity) => { - const row = byIdentity.get(identity); - return row?.direct === true && row.scope === "production"; - }); - const failures = []; - 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"]) { - if (typeof entry[field] !== "string" || !entry[field].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 = new Map([ - ["unknown", 0], - ["low", 1], - ["moderate", 2], - ["high", 3], - ["critical", 4], -]); - -/** - * @param {Readonly>} report - * @param {Readonly>} policy - * @param {Readonly>} exceptionFile - * @param {string} lockfileSha256 - * @param {Date} [now] - */ -export function validateVulnerabilityReport( - report, - policy, - exceptionFile, - lockfileSha256, - now = new Date(), -) { - const failures = []; - 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 = - /** @type {Array>} */ ( - exceptionFile.exceptions ?? [] - ); - const blocking = []; - for (const finding of /** @type {Array>} */ ( - 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 = - 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( - `${finding.id}:${finding.packageName}@${finding.version}:${severity}`, - ); - } - } - return Object.freeze({ - passed: failures.length === 0 && blocking.length === 0, - failures: Object.freeze(failures), - blocking: Object.freeze(blocking), - }); -} - -/** - * @param {Readonly>} sbom - * @param {Readonly>} inventory - * @param {Readonly>} provenance - * @param {string} distDigest - */ -export function verifySupplyChainCoherence( - sbom, - inventory, - provenance, - distDigest, -) { - const failures = []; - 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 = - /** @type {Record} */ (sbom.metadata ?? {}); - const properties = - /** @type {Array<{name?: string, value?: string}>} */ ( - metadata.properties ?? [] - ); - if (properties.find( - /** @param {{name?: string, value?: string}} property */ - (property) => - property.name === "ca:lockfileSha256" && - property.value === inventory.lockfileSha256, - ) === undefined) { - failures.push("SBOM lockfile digest does not match inventory"); - } - const subject = - /** @type {Array>} */ (provenance.subject ?? [])[0]; - const subjectDigest = - /** @type {Record} */ (subject?.digest ?? {}); - if (subjectDigest.sha256 !== distDigest) { - failures.push("provenance subject does not match built dist digest"); - } - const predicate = - /** @type {Record} */ (provenance.predicate ?? {}); - const materials = - /** @type {Record} */ (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/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/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-ci-gate.mjs b/scripts/run-ci-gate.mjs deleted file mode 100644 index b73f511..0000000 --- a/scripts/run-ci-gate.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; - -const gateId = process.argv - .slice(2) - .find((argument) => /^FE-GATE-\d{3}$/.test(argument)); -const document = - /** @type {{ - * gates: Record, - * logPath: string, - * evidence: string[], - * retentionClass: string, - * requiresEnvironment?: string[] - * }> - * }} */ (JSON.parse(await readFile("config/ci/gates.json", "utf8"))); -const gate = gateId ? document.gates[gateId] : undefined; -if (!gateId || !gate) { - process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n"); - process.exit(2); -} - -const output = []; -let passed = true; -for (const variable of gate.requiresEnvironment ?? []) { - if (!process.env[variable]) { - output.push(`missing required environment: ${variable}`); - passed = false; - } -} - -if (passed) { - for (const step of gate.steps) { - const result = spawnSync( - "corepack", - ["pnpm", step.script, ...(step.args ?? [])], - { encoding: "utf8", env: process.env }, - ); - output.push( - `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(), - result.stdout, - result.stderr, - ); - const exitedSuccessfully = result.status === 0; - const expectationMet = - step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully; - if (!expectationMet) { - output.push( - `expectation failed: expected ${step.expect}, exit=${result.status}`, - ); - passed = false; - break; - } - } -} - -await mkdir(path.dirname(gate.logPath), { recursive: true }); -await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); - -if (passed) { - for (const evidencePath of gate.evidence) { - try { - await access(evidencePath); - } catch { - output.push(`missing evidence: ${evidencePath}`); - passed = false; - } - } - if (!passed) { - await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); - } -} - -if (!passed) { - process.stderr.write(`${gateId} ${gate.name}: FAIL\n`); - process.exit(1); -} -process.stdout.write( - `${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`, -); diff --git a/scripts/run-ci-gate.ts b/scripts/run-ci-gate.ts new file mode 100644 index 0000000..d3d078f --- /dev/null +++ b/scripts/run-ci-gate.ts @@ -0,0 +1,208 @@ +import { spawnSync } from "node:child_process"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + ciCheckoutIdentityFailures, + ciBuildEnvironmentFailures, + isValidCommitSha, + isValidSourceDateEpoch, +} from "./lib/build-environment.ts"; + +type GateStep = Readonly<{ + script: string; + args?: readonly string[]; + expect: "pass" | "fail"; +}>; +type GateDefinition = Readonly<{ + name: string; + steps: readonly GateStep[]; + logPath: string; + evidence: readonly string[]; + retentionClass: string; + requiresEnvironment?: readonly string[]; +}>; +type GateDocument = Readonly<{ + gates: Readonly>; +}>; + +const gateId = process.argv + .slice(2) + .find((argument) => /^FE-GATE-\d{3}$/.test(argument)); +const document = parseGateDocument( + JSON.parse(await readFile("config/ci/gates.json", "utf8")), +); +const gate = gateId ? document.gates[gateId] : undefined; +if (!gateId || !gate) { + process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n"); + process.exit(2); +} + +const output: string[] = []; +let passed = 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; + output.push(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`); + } + for (const failure of ciCheckoutIdentityFailures(gateEnvironment, { + commitSha, + sourceDateEpoch, + })) { + output.push(failure); + passed = false; + } + } else { + output.push( + "unable to resolve the checked-out commit identity and timestamp", + commitMetadata.stderr, + ); + passed = false; + } +} + +for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) { + output.push(failure); + passed = false; +} + +for (const variable of gate.requiresEnvironment ?? []) { + if (!gateEnvironment[variable]) { + output.push(`missing required environment: ${variable}`); + passed = false; + } +} + +if (passed) { + for (const step of gate.steps) { + const result = spawnSync( + "corepack", + ["pnpm", step.script, ...(step.args ?? [])], + { encoding: "utf8", env: gateEnvironment }, + ); + output.push( + `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(), + result.stdout, + result.stderr, + ); + const exitedSuccessfully = result.status === 0; + const expectationMet = + step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully; + if (!expectationMet) { + output.push( + `expectation failed: expected ${step.expect}, exit=${result.status}`, + ); + passed = false; + break; + } + } +} + +await mkdir(path.dirname(gate.logPath), { recursive: true }); +await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); + +if (passed) { + for (const evidencePath of gate.evidence) { + try { + await access(evidencePath); + } catch { + output.push(`missing evidence: ${evidencePath}`); + passed = false; + } + } + if (!passed) { + await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); + } +} + +if (!passed) { + process.stderr.write(`${gateId} ${gate.name}: FAIL\n`); + process.exit(1); +} +process.stdout.write( + `${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`, +); + +function parseGateDocument(value: unknown): GateDocument { + if (!isRecord(value) || !isRecord(value.gates)) { + throw new TypeError("CI gate registry must be an object"); + } + const gates: Record = {}; + for (const [gateId, candidate] of Object.entries(value.gates)) { + if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`); + const steps = parseGateSteps(candidate.steps, gateId); + const evidence = parseStringArray(candidate.evidence, `${gateId}.evidence`); + const requiresEnvironment = + candidate.requiresEnvironment === undefined + ? undefined + : parseStringArray( + candidate.requiresEnvironment, + `${gateId}.requiresEnvironment`, + ); + if ( + typeof candidate.name !== "string" || + typeof candidate.logPath !== "string" || + typeof candidate.retentionClass !== "string" + ) { + throw new TypeError(`CI gate metadata is invalid: ${gateId}`); + } + gates[gateId] = { + name: candidate.name, + steps, + logPath: candidate.logPath, + evidence, + retentionClass: candidate.retentionClass, + ...(requiresEnvironment ? { requiresEnvironment } : {}), + }; + } + return { gates }; +} + +function parseGateSteps(value: unknown, gateId: string): GateStep[] { + if (!Array.isArray(value)) { + throw new TypeError(`CI gate steps are invalid: ${gateId}`); + } + return value.map((candidate, index) => { + if ( + !isRecord(candidate) || + typeof candidate.script !== "string" || + (candidate.expect !== "pass" && candidate.expect !== "fail") + ) { + throw new TypeError(`Invalid CI gate step: ${gateId}[${index}]`); + } + const args = + candidate.args === undefined + ? undefined + : parseStringArray(candidate.args, `${gateId}[${index}].args`); + return { + script: candidate.script, + expect: candidate.expect, + ...(args ? { args } : {}), + }; + }); +} + +function parseStringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + throw new TypeError(`${label} must be a string array`); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/scripts/security-scan.mjs b/scripts/security-scan.ts similarity index 60% rename from scripts/security-scan.mjs rename to scripts/security-scan.ts index 15b1338..c4d0ed8 100644 --- a/scripts/security-scan.mjs +++ b/scripts/security-scan.ts @@ -2,14 +2,66 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; -/** @param {string} name @param {string} fallback */ -function argumentValue(name, fallback) { +type SecretFinding = Readonly<{ + ruleId: string; + file: string; + line: number; + fingerprint: string; +}>; +type AllowlistEntry = Readonly<{ + path: string; + ruleId: string; + owner: string; + reason: string; + expiresAt: string; +}>; +type SecretPolicy = Readonly<{ + excludedPaths: readonly string[]; + trackedRoots: readonly string[]; + generatedRoots: readonly string[]; + allowlist: readonly AllowlistEntry[]; +}>; + +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 isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function strings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function parsePolicy(value: unknown): SecretPolicy { + const document = isRecord(value) ? value : {}; + const allowlist = Array.isArray(document.allowlist) + ? document.allowlist.map((rawEntry) => { + const entry = isRecord(rawEntry) ? rawEntry : {}; + return { + 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: Object.freeze(strings(document.trackedRoots)), + generatedRoots: Object.freeze(strings(document.generatedRoots)), + allowlist: Object.freeze(allowlist), + }); +} + const policyPath = argumentValue( "--policy", "config/security/secret-scan-policy.json", @@ -18,16 +70,11 @@ const artifactPath = argumentValue( "--artifact", "artifacts/security/scan.sarif", ); -const policy = JSON.parse(await readFile(policyPath, "utf8")); -const findings = - /** @type {Array<{ - * ruleId: string, - * file: string, - * line: number, - * fingerprint: string - * }>} */ ([]); -const policyFailures = []; -const patterns = [ +const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8")); +const policy = parsePolicy(rawPolicy); +const findings: SecretFinding[] = []; +const policyFailures: string[] = []; +const patterns: readonly Readonly<{ id: string; expression: RegExp }>[] = [ { id: "private-key", expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g, @@ -41,18 +88,17 @@ const patterns = [ }, ]; -/** @param {string} target @returns {Promise} */ -async function filesWithin(target) { +async function filesWithin(target: string): Promise { try { const metadata = await stat(target); if (metadata.isFile()) return [target]; const entries = await readdir(target, { withFileTypes: true }); - const nested = /** @type {string[][]} */ (await Promise.all( + const nested: string[][] = await Promise.all( entries.map((entry) => { const child = path.join(target, entry.name); return entry.isDirectory() ? filesWithin(child) : [child]; }), - )); + ); return nested.flat(); } catch { return []; @@ -60,24 +106,15 @@ async function filesWithin(target) { } const excluded = new Set( - /** @type {string[]} */ (policy.excludedPaths ?? []).map((entry) => - entry.replaceAll("\\", "/"), - ), + policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")), ); -const allowlist = - /** @type {Array<{ - * path: string, - * ruleId: string, - * owner: string, - * reason: string, - * expiresAt: string - * }>} */ (policy.allowlist ?? []); +const allowlist = policy.allowlist; for (const entry of allowlist) { const expiry = Date.parse(entry.expiresAt); if ( !entry.path.startsWith("tests/") || - !entry.owner?.trim() || - !entry.reason?.trim() || + !entry.owner.trim() || + !entry.reason.trim() || !Number.isFinite(expiry) || expiry <= Date.now() ) { @@ -87,10 +124,7 @@ for (const entry of allowlist) { } } -const roots = [ - ...(/** @type {string[]} */ (policy.trackedRoots ?? [])), - ...(/** @type {string[]} */ (policy.generatedRoots ?? [])), -]; +const roots = [...policy.trackedRoots, ...policy.generatedRoots]; const scanFiles = ( await Promise.all(roots.map((root) => filesWithin(root))) ).flat(); @@ -104,7 +138,7 @@ for (const scanFile of [...new Set(scanFiles)].sort()) { ) { continue; } - let content; + let content: string; try { content = await readFile(scanFile, "utf8"); } catch { @@ -120,13 +154,14 @@ for (const scanFile of [...new Set(scanFiles)].sort()) { Date.parse(entry.expiresAt) > Date.now(), ); if (isAllowed) continue; - const prefix = content.slice(0, match.index); + const matchIndex = match.index ?? 0; + const prefix = content.slice(0, matchIndex); findings.push({ ruleId: pattern.id, file: normalized, line: prefix.split(/\r?\n/).length, fingerprint: createHash("sha256") - .update(`${pattern.id}:${normalized}:${String(match.index)}`) + .update(`${pattern.id}:${normalized}:${String(matchIndex)}`) .digest("hex"), }); } @@ -150,9 +185,7 @@ const sarif = { results: [ ...findings.map((finding) => ({ ruleId: finding.ruleId, - message: { - text: "Potential secret material must be removed.", - }, + message: { text: "Potential secret material must be removed." }, partialFingerprints: { primaryLocationLineHash: finding.fingerprint, }, diff --git a/scripts/serve-static.mjs b/scripts/serve-static.ts similarity index 95% rename from scripts/serve-static.mjs rename to scripts/serve-static.ts index 34a0c67..86508c7 100644 --- a/scripts/serve-static.mjs +++ b/scripts/serve-static.ts @@ -5,14 +5,14 @@ import path from "node:path"; const root = path.resolve(process.argv[2] ?? "artifacts/storybook/static"); const port = Number(process.argv[3] ?? 6006); -const contentTypes = /** @type {Readonly>} */ ({ +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) => { 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..f7afb68 --- /dev/null +++ b/scripts/test-browser-file-storage-runtime-removal.ts @@ -0,0 +1,379 @@ +import { spawnSync } from "node:child_process"; +import { + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +const fixtureRoot = path.resolve( + ".tmp/browser-file-storage-runtime-removal", +); +const pnpmCli = requireEnvironment("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 copyTargets = [ + "src", + "tests", + "recipes", + "scripts", + "config", + "schemas", + "public", + ".gitea", + ".storybook", + "index.html", + "package.json", + "tsconfig.base.json", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "tsconfig.test.json", + "tsconfig.recipes.json", + "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; + +function requireEnvironment(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required for runtime removal verification`); + } + return value; +} + +function runPnpm(script: string): boolean { + return ( + spawnSync(process.execPath, [pnpmCli, script], { + cwd: fixtureRoot, + stdio: "inherit", + }).status === 0 + ); +} + +async function sourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + return ( + await Promise.all( + entries.map(async (entry): Promise => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + return await sourceFiles(target); + } + return /\.(?:[cm]?ts|tsx)$/u.test(entry.name) + ? [path.resolve(target)] + : []; + }), + ) + ).flat(); +} + +function staticImportSpecifiers(source: string): string[] { + return [ + ...source.matchAll( + /(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu, + ), + ] + .map((match) => match[1]) + .filter((specifier): specifier is string => + typeof specifier === "string", + ); +} + +function isWithin(target: string, root: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function resolvedImport( + importer: string, + specifier: string, + sourceSet: ReadonlySet, +): string | null { + if (!specifier.startsWith(".")) return null; + const base = path.resolve(path.dirname(importer), specifier); + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}.mts`, + `${base}.cts`, + path.join(base, "index.ts"), + path.join(base, "index.tsx"), + ]; + return candidates.find((candidate) => sourceSet.has(candidate)) ?? base; +} + +async function runtimeImportGraph(root: string): Promise> { + const files = await sourceFiles(root); + 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"); + imports.set( + file, + staticImportSpecifiers(source) + .map((specifier) => + resolvedImport(file, specifier, sourceSet), + ) + .filter((target): target is string => target !== null), + ); + } + + const memo = new Map(); + const reachesRuntime = ( + file: string, + visiting = new Set(), + ): boolean => { + if (runtimeRoots.some((root) => isWithin(file, root))) 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"); + const dependentTests = files.filter( + (file) => isWithin(file, testsRoot) && reachesRuntime(file), + ); + const importingFiles = files.filter( + (file) => + !runtimeRoots.some((runtimeRoot) => + isWithin(file, runtimeRoot), + ) && + (imports.get(file) ?? []).some((dependency) => + runtimeRoots.some((runtimeRoot) => + isWithin(dependency, runtimeRoot), + ), + ), + ); + return Object.freeze({ + dependentTests: Object.freeze(dependentTests), + importingFiles: Object.freeze(importingFiles), + }); +} + +async function assertNoRuntimeImports(root: string): Promise { + const graph = await runtimeImportGraph(root); + if (graph.importingFiles.length > 0) { + throw new Error( + `Removed browser file/storage runtime is still imported by: ${graph.importingFiles + .map((file) => path.relative(root, file)) + .join(", ")}`, + ); + } +} + +async function removeRuntimeDependentTests( + root: string, +): Promise { + const graph = await runtimeImportGraph(root); + await Promise.all( + graph.dependentTests.map(async (file) => { + if (isWithin(file, path.resolve(root, "tests"))) { + await rm(file, { force: true }); + } + }), + ); + return graph.dependentTests.length; +} + +await rm(fixtureRoot, { recursive: true, force: true }); +await mkdir(fixtureRoot, { recursive: true }); +for (const target of copyTargets) { + await cp(target, path.join(fixtureRoot, target), { recursive: true }); +} +await symlink( + path.resolve("node_modules"), + path.join(fixtureRoot, "node_modules"), + "dir", +); + +const removedRuntimeTests = + await removeRuntimeDependentTests(fixtureRoot); +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`); + +const packagePath = path.join(fixtureRoot, "package.json"); +const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { + scripts: Record; +}; +for (const script of [ + "test:browser-capabilities", + "verify:browser-capability-evidence", + "check:browser-file-storage-boundaries", + "test:browser-file-storage-removal", +]) { + delete packageDocument.scripts[script]; +} +await writeFile( + packagePath, + `${JSON.stringify(packageDocument, 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 }, +); + +const gatesPath = path.join(fixtureRoot, "config/ci/gates.json"); +const gatesDocument = JSON.parse( + await readFile(gatesPath, "utf8"), +) as { + gates: Record< + string, + { + steps: Array<{ script: string }>; + evidence: string[]; + } + >; +}; +for (const gate of Object.values(gatesDocument.gates)) { + gate.steps = gate.steps.filter( + ({ script }) => + ![ + "test:browser-capabilities", + "verify:browser-capability-evidence", + "check:browser-file-storage-boundaries", + "test:browser-file-storage-removal", + ].includes(script), + ); + gate.evidence = gate.evidence.filter( + (evidence) => + !evidence.includes("browser-capabilities") && + !evidence.includes("browser-file-storage-runtime-removal"), + ); +} +await writeFile( + gatesPath, + `${JSON.stringify(gatesDocument, null, 2)}\n`, +); +await assertNoRuntimeImports(fixtureRoot); + +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.mjs b/scripts/test-optional-recipe-removal.ts similarity index 80% rename from scripts/test-optional-recipe-removal.mjs rename to scripts/test-optional-recipe-removal.ts index ba23b06..30e03c3 100644 --- a/scripts/test-optional-recipe-removal.mjs +++ b/scripts/test-optional-recipe-removal.ts @@ -11,7 +11,7 @@ import { import path from "node:path"; const fixtureRoot = path.resolve(".tmp/optional-recipe-removal"); -const pnpmCli = /** @type {string} */ (process.env.npm_execpath); +const pnpmCli = requireEnvironment("npm_execpath"); const copyTargets = [ "src", "tests", @@ -19,6 +19,7 @@ const copyTargets = [ "scripts", "config", "public", + ".storybook", "index.html", "package.json", "tsconfig.base.json", @@ -27,15 +28,25 @@ const copyTargets = [ "tsconfig.node.json", "tsconfig.test.json", "tsconfig.recipes.json", - "vite.config.js", - "vitest.config.js", - "playwright.config.js", - "eslint.config.js", - ".dependency-cruiser.cjs", + "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", ]; -/** @param {string} script */ -function runPnpm(script) { +function requireEnvironment(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required to run removal verification`); + } + return value; +} + +function runPnpm(script: string): boolean { return ( spawnSync(process.execPath, [pnpmCli, script], { cwd: fixtureRoot, @@ -44,8 +55,7 @@ function runPnpm(script) { ); } -/** @param {string} directory @returns {Promise} */ -async function filesBelow(directory) { +async function filesBelow(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); const groups = await Promise.all( entries.map((entry) => { @@ -68,14 +78,13 @@ await rm(path.join(fixtureRoot, "tests/recipes"), { force: true, }); -const checks = [ +const checks: Array<[string, boolean]> = [ ["typecheck", runPnpm("check:types")], ["architecture", runPnpm("check:architecture")], ["test", runPnpm("test:all")], ["build", runPnpm("build")], ]; -/** @type {string[]} */ -const residue = []; +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"); diff --git a/scripts/test-performance.mjs b/scripts/test-performance.ts similarity index 84% rename from scripts/test-performance.mjs rename to scripts/test-performance.ts index b368466..815e46b 100644 --- a/scripts/test-performance.mjs +++ b/scripts/test-performance.ts @@ -5,8 +5,22 @@ import process from "node:process"; import { chromium } from "@playwright/test"; -import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js"; -import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.js"; +import { evaluateLabBudget } from "../src/application/policies/performance-budgets.ts"; +import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts"; + +type ContractPerformanceEvidence = { + lcpMs: number; + cls: number; +}; + +type ContractPerformanceWindow = Window & { + __contractPerformance?: ContractPerformanceEvidence; +}; + +type LayoutShiftEntry = PerformanceEntry & { + hadRecentInput: boolean; + value: number; +}; const server = spawn( "corepack", @@ -54,20 +68,25 @@ try { await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); await page.addInitScript(() => { const evidence = { lcpMs: 0, cls: 0 }; - /** @type {any} */ (window).__contractPerformance = evidence; + (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()) { - if (!(/** @type {any} */ (entry)).hadRecentInput) { - evidence.cls += /** @type {any} */ (entry).value; + 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 targetLabel = Object.values(ROUTE_REGISTRY).find( + const performanceRoutes: ReadonlyArray<{ + access: string; + navigationLabel: string | null; + }> = Object.values(ROUTE_REGISTRY); + const targetLabel = performanceRoutes.find( (definition) => definition.access === "integration-defined", )?.navigationLabel; if (!targetLabel) { @@ -78,8 +97,11 @@ try { await page.getByRole("heading", { name: "세션이 필요합니다." }).waitFor(); const namedInteractionMs = performance.now() - interactionStarted; const paint = await page.evaluate( - () => /** @type {any} */ (window).__contractPerformance, + () => (window as ContractPerformanceWindow).__contractPerformance, ); + if (!paint) { + throw new Error("Browser performance evidence was not initialized."); + } const contextMetadata = { runner: { platform: process.platform, diff --git a/scripts/test-realtime-runtime-removal.ts b/scripts/test-realtime-runtime-removal.ts new file mode 100644 index 0000000..3f7794d --- /dev/null +++ b/scripts/test-realtime-runtime-removal.ts @@ -0,0 +1,356 @@ +import { spawnSync } from "node:child_process"; +import { + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +const fixtureRoot = path.resolve(".tmp/realtime-runtime-removal"); +const pnpmCli = requireEnvironment("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 copyTargets = [ + "src", + "tests", + "recipes", + "scripts", + "config", + "schemas", + "public", + ".gitea", + ".storybook", + "index.html", + "package.json", + "tsconfig.base.json", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "tsconfig.test.json", + "tsconfig.recipes.json", + "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; + +function requireEnvironment(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required for runtime removal verification`); + } + return value; +} + +function runPnpm(script: string): boolean { + return ( + spawnSync(process.execPath, [pnpmCli, script], { + cwd: fixtureRoot, + stdio: "inherit", + }).status === 0 + ); +} + +async function sourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + return ( + await Promise.all( + entries.map(async (entry): Promise => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return await sourceFiles(target); + return /\.(?:[cm]?ts|tsx)$/u.test(entry.name) + ? [path.resolve(target)] + : []; + }), + ) + ).flat(); +} + +function staticImportSpecifiers(source: string): string[] { + return [ + ...source.matchAll( + /(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu, + ), + ] + .map((match) => match[1]) + .filter((specifier): specifier is string => + typeof specifier === "string", + ); +} + +function isWithin(target: string, root: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function resolvedImport( + importer: string, + specifier: string, + sourceSet: ReadonlySet, +): string | null { + if (!specifier.startsWith(".")) return null; + const base = path.resolve(path.dirname(importer), specifier); + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}.mts`, + `${base}.cts`, + path.join(base, "index.ts"), + path.join(base, "index.tsx"), + ]; + return candidates.find((candidate) => sourceSet.has(candidate)) ?? base; +} + +async function runtimeImportGraph(root: string): Promise> { + const files = await sourceFiles(root); + 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"); + imports.set( + file, + staticImportSpecifiers(source) + .map((specifier) => resolvedImport(file, specifier, sourceSet)) + .filter((target): target is string => target !== null), + ); + } + + const memo = new Map(); + const reachesRuntime = ( + file: string, + visiting = new Set(), + ): boolean => { + if (runtimeRoots.some((root) => isWithin(file, root))) 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), + ), + ), + ), + ), + }); +} + +async function removeRuntimeDependentTests(root: string): Promise { + const graph = await runtimeImportGraph(root); + await Promise.all( + graph.dependentTests.map(async (file) => { + if (isWithin(file, path.resolve(root, "tests"))) { + await rm(file, { force: true }); + } + }), + ); + return graph.dependentTests.length; +} + +async function assertNoRuntimeImports(root: string): Promise { + const graph = await runtimeImportGraph(root); + if (graph.importingFiles.length > 0) { + throw new Error( + `Removed realtime runtime is still imported by: ${graph.importingFiles + .map((file) => path.relative(root, file)) + .join(", ")}`, + ); + } +} + +await rm(fixtureRoot, { recursive: true, force: true }); +await mkdir(fixtureRoot, { recursive: true }); +for (const target of copyTargets) { + await cp(target, path.join(fixtureRoot, target), { recursive: true }); +} +await symlink( + path.resolve("node_modules"), + path.join(fixtureRoot, "node_modules"), + "dir", +); + +const removedRuntimeTests = + await removeRuntimeDependentTests(fixtureRoot); +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`); + +const packagePath = path.join(fixtureRoot, "package.json"); +const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { + scripts: Record; +}; +for (const script of runtimeScripts) { + delete packageDocument.scripts[script]; +} +await writeFile( + packagePath, + `${JSON.stringify(packageDocument, 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 }); +} + +const gatesPath = path.join(fixtureRoot, "config/ci/gates.json"); +const gatesDocument = JSON.parse(await readFile(gatesPath, "utf8")) as { + gates: Record< + string, + { + steps: Array<{ script: string }>; + evidence: string[]; + } + >; +}; +for (const gate of Object.values(gatesDocument.gates)) { + gate.steps = gate.steps.filter( + ({ script }) => + !runtimeScripts.some((runtimeScript) => runtimeScript === script), + ); + gate.evidence = gate.evidence.filter( + (evidence) => + !evidence.includes("realtime-boundaries") && + !evidence.includes("realtime-runtime-removal"), + ); +} +await writeFile( + gatesPath, + `${JSON.stringify(gatesDocument, null, 2)}\n`, +); +await assertNoRuntimeImports(fixtureRoot); + +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.mjs b/scripts/test-sample-removal.ts similarity index 58% rename from scripts/test-sample-removal.mjs rename to scripts/test-sample-removal.ts index fe4fa1f..1d8dffd 100644 --- a/scripts/test-sample-removal.mjs +++ b/scripts/test-sample-removal.ts @@ -11,15 +11,17 @@ import { import path from "node:path"; const fixtureRoot = path.resolve(".tmp/reference-feature-removal"); -const pnpmCli = /** @type {string} */ (process.env.npm_execpath); +const pnpmCli = requireEnvironment("npm_execpath"); const featureSource = "src/features/reference-feature"; const featureTests = "tests/features/reference-feature"; const featureOwnedPaths = [ featureSource, featureTests, - "tests/e2e/reference-form.spec.js", - "tests/e2e/reference-route.spec.js", + "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", @@ -28,6 +30,7 @@ const copyTargets = [ "scripts", "config", "public", + ".storybook", "index.html", "package.json", "tsconfig.base.json", @@ -36,19 +39,21 @@ const copyTargets = [ "tsconfig.node.json", "tsconfig.test.json", "tsconfig.recipes.json", - "vite.config.js", - "vitest.config.js", - "playwright.config.js", - "eslint.config.js", - ".dependency-cruiser.cjs", + "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", ]; -const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js"; -import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js"; -import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.js"; +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 = - /** @type {readonly unknown[]} */ (Object.freeze([])); +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({}); @@ -59,28 +64,22 @@ export const NAVIGATION_ROUTES = Object.freeze( .filter((definition) => definition.navigationOrder !== null) .sort( (left, right) => - /** @type {number} */ (left.navigationOrder) - - /** @type {number} */ (right.navigationOrder), + left.navigationOrder! - right.navigationOrder!, ), ); -/** @param {string} routeId */ -export function getRoute(routeId) { - const registry = - /** @type {Readonly>} */ ( - ROUTE_REGISTRY - ); +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; } -/** @param {string} routeId */ -export function routePath(routeId) { +export function routePath(routeId: string): string { return getRoute(routeId).path; } `; -const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js"; -import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js"; +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; @@ -101,8 +100,29 @@ const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ }); `; -/** @param {string} directory @returns {Promise} */ -async function filesBelow(directory) { +type CoveragePolicy = { + criticalModules: 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 requireEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required for sample removal`); + return value; +} + +async function filesBelow(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); const groups = await Promise.all( entries.map((entry) => { @@ -113,8 +133,7 @@ async function filesBelow(directory) { return groups.flat(); } -/** @param {string} script @param {string[]} [extra] */ -function runPnpm(script, extra = []) { +function runPnpm(script: string, extra: string[] = []): boolean { const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], { cwd: fixtureRoot, stdio: "inherit", @@ -136,7 +155,7 @@ for (const ownedPath of featureOwnedPaths) { }); } await writeFile( - path.join(fixtureRoot, "src/features/installed-feature-contracts.js"), + path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"), emptyContracts, ); await writeFile( @@ -148,32 +167,96 @@ await writeFile( emptyAdapters, ); await writeFile( - path.join(fixtureRoot, "src/features/installed-feature-messages.js"), + path.join(fixtureRoot, "src/features/installed-feature-messages.ts"), emptyMessages, ); + +const vitestConfigFile = path.join(fixtureRoot, "vitest.config.ts"); +const vitestConfig = await readFile(vitestConfigFile, "utf8"); +const featureCoverageInclude = + ` "${featureSource}/adapters/reference-http-gateway.ts",\n`; +if (!vitestConfig.includes(featureCoverageInclude)) { + throw new Error("Reference feature coverage include is not registered"); +} +await writeFile( + vitestConfigFile, + vitestConfig.replace(featureCoverageInclude, ""), +); + +const coveragePolicyFile = path.join( + fixtureRoot, + "config/testing/risk-coverage.json", +); +const coveragePolicy = JSON.parse( + await readFile(coveragePolicyFile, "utf8"), +) as CoveragePolicy; +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; +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")); +const removalGovernance = JSON.parse( + await readFile(governanceFile, "utf8"), +) as RemovalGovernance; removalGovernance.registries = removalGovernance.registries.map( - /** @param {Record} registry */ (registry) => ({ ...registry, ...(Array.isArray(registry.consumers) ? { consumers: registry.consumers.filter( - /** @param {{path?: string}} consumer */ - (consumer) => - !consumer.path?.includes("features/reference-feature"), + (candidate: unknown) => { + const consumer = candidate as GovernanceConsumer; + return !consumer.path?.includes("features/reference-feature"); + }, ), } : {}), ...(Array.isArray(registry.consumerDirectories) ? { consumerDirectories: registry.consumerDirectories.filter( - /** @param {string} directory */ - (directory) => + (directory: unknown) => + typeof directory !== "string" || !directory.includes("features/reference-feature"), ), } @@ -185,8 +268,7 @@ await writeFile( `${JSON.stringify(removalGovernance, null, 2)}\n`, ); -/** @type {string[]} */ -const residue = []; +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); @@ -201,24 +283,25 @@ for (const root of ["src", "tests"]) { } } -const checks = [ +const checks: Array<[string, 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")], [ "home-smoke", runPnpm("exec", [ "vitest", "run", - "tests/component/router.test.jsx", + "tests/component/router.test.tsx", "--reporter=default", ]), ], ["build", runPnpm("build")], ]; -/** @type {string[]} */ -const builtResidue = []; +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"); @@ -230,10 +313,10 @@ for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { } const routeCatalog = await import( `${new URL( - "../src/features/installed-feature-contracts.js", + "../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]); diff --git a/scripts/update-dependency-baseline.mjs b/scripts/update-dependency-baseline.ts similarity index 86% rename from scripts/update-dependency-baseline.mjs rename to scripts/update-dependency-baseline.ts index 5f14fe8..addca7b 100644 --- a/scripts/update-dependency-baseline.mjs +++ b/scripts/update-dependency-baseline.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { readFile, writeFile } from "node:fs/promises"; -import { supplyChainDigest } from "./lib/supply-chain.mjs"; +import { supplyChainDigest } from "./lib/supply-chain.ts"; const owner = process.env.DEPENDENCY_BASELINE_OWNER; const reason = process.env.DEPENDENCY_BASELINE_REASON; @@ -12,10 +12,10 @@ if (!owner?.trim() || !reason?.trim()) { process.exit(2); } -const commands = /** @type {Array<[string, string[]]>} */ ([ +const commands: Array<[string, string[]]> = [ ["corepack", ["pnpm", "build"]], - ["node", ["scripts/generate-supply-chain.mjs", "--no-baseline"]], -]); + ["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); diff --git a/scripts/update-registry-baseline.mjs b/scripts/update-registry-baseline.ts similarity index 99% rename from scripts/update-registry-baseline.mjs rename to scripts/update-registry-baseline.ts index 6bf9a3c..8e2236b 100644 --- a/scripts/update-registry-baseline.mjs +++ b/scripts/update-registry-baseline.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { registrySnapshotDigest } from "./lib/registry-compatibility.mjs"; +import { registrySnapshotDigest } from "./lib/registry-compatibility.ts"; const inputPath = process.argv[2] ?? "artifacts/quality/registry-current-snapshot.json"; diff --git a/scripts/verify-a11y-manual.mjs b/scripts/verify-a11y-manual.ts similarity index 86% rename from scripts/verify-a11y-manual.mjs rename to scripts/verify-a11y-manual.ts index d594eb2..01238b4 100644 --- a/scripts/verify-a11y-manual.mjs +++ b/scripts/verify-a11y-manual.ts @@ -3,18 +3,19 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { MANUAL_A11Y_ROUTE_IDS, validateManualA11yEvidence, -} from "./lib/manual-a11y-evidence.mjs"; +} from "./lib/manual-a11y-evidence.ts"; -/** @type {Array<{ - * routeId: string; - * path: string; - * reviewer: string | null; - * reviewedAt: string | null; - * releaseId: string | null; - * failures: readonly string[]; - * passed: boolean; - * }>} */ -const results = []; +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"); 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-documentation-readiness.mjs b/scripts/verify-documentation-readiness.ts similarity index 83% rename from scripts/verify-documentation-readiness.mjs rename to scripts/verify-documentation-readiness.ts index bb6ba48..12e5827 100644 --- a/scripts/verify-documentation-readiness.mjs +++ b/scripts/verify-documentation-readiness.ts @@ -1,8 +1,27 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; +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)) { diff --git a/scripts/verify-hosting-headers.mjs b/scripts/verify-hosting-headers.ts similarity index 60% rename from scripts/verify-hosting-headers.mjs rename to scripts/verify-hosting-headers.ts index 40f802c..2977f0c 100644 --- a/scripts/verify-hosting-headers.mjs +++ b/scripts/verify-hosting-headers.ts @@ -1,13 +1,62 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; -import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs"; +import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.ts"; -const cachePolicy = JSON.parse( - await readFile("config/hosting/cache-policy.json", "utf8"), -); -const securityPolicy = JSON.parse( - await readFile("config/hosting/security-headers.json", "utf8"), +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); @@ -16,18 +65,9 @@ const publicServiceWorkers = distFiles.filter((file) => /(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file), ); -/** @type {Record>} */ -let responses = {}; -let mode; -/** @type {Array<{ - * surface: string; - * header: string; - * expected: unknown; - * observed: unknown; - * reason?: string; - * passed: boolean; - * }>} */ -const probeResults = []; +let responses: ResponseHeaders = {}; +let mode: HostingMode; +const probeResults: ProbeResult[] = []; if (liveTarget?.passed) { mode = "live"; @@ -40,7 +80,6 @@ if (liveTarget?.passed) { releaseManifest: "/release-manifest.json", hashedAsset: `/assets/${hashedJavaScript}`, }; - responses = {}; for (const [surface, pathname] of Object.entries(paths)) { const requestedUrl = new URL(pathname, liveTarget.url); try { @@ -68,7 +107,7 @@ if (liveTarget?.passed) { value, ]), ); - } catch (error) { + } catch (error: unknown) { probeResults.push({ surface, header: "transport", @@ -90,14 +129,17 @@ if (liveTarget?.passed) { }); } else { mode = "fixture"; - responses = JSON.parse( - await readFile("config/hosting/response-headers.fixture.json", "utf8"), - ).responses; + const fixture = await readDocument( + "config/hosting/response-headers.fixture.json", + ); + responses = responseHeaders(fixture.responses); } -const results = [...probeResults]; -for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { - if (!("cacheControl" in policy)) continue; +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, @@ -109,17 +151,17 @@ for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { const observedContentType = responses[surface]?.["content-type"]; const observedMime = observedContentType ?.split(";", 1)[0] - .trim() + ?.trim() .toLowerCase(); results.push({ surface, header: "content-type", - expected: policy.contentTypes, + expected: contentTypes, observed: observedContentType, - passed: policy.contentTypes.includes(observedMime), + passed: observedMime !== undefined && contentTypes.includes(observedMime), }); - if (policy.securityHeaders) { - for (const [header, expected] of Object.entries(securityPolicy.headers)) { + if (policy.securityHeaders === true) { + for (const [header, expected] of Object.entries(securityHeaders)) { const observedSecurity = responses[surface]?.[header.toLowerCase()]; results.push({ surface, @@ -132,23 +174,22 @@ for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { } } +const sourceMapPolicy = recordValue(cacheSurfaces.sourceMap); results.push({ surface: "sourceMap", header: "public", expected: false, observed: publicSourceMaps.length > 0, - passed: - cachePolicy.surfaces.sourceMap.public === false && - 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: - cachePolicy.surfaces.serviceWorker.enabled === false && - publicServiceWorkers.length === 0, + serviceWorkerPolicy.enabled === false && publicServiceWorkers.length === 0, }); const passed = results.every((result) => result.passed); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs deleted file mode 100644 index 672ee1e..0000000 --- a/scripts/verify-release.mjs +++ /dev/null @@ -1,164 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; - -import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js"; -import { - compareReleaseToRuntime, - RELEASE_TOKEN_REGISTRY, -} from "../src/contracts/release-tokens.js"; -import { - ROUTE_REGISTRY, - ROUTE_RUNTIME_CONTRACT, -} from "../src/features/installed-feature-contracts.js"; - -const fixturesDocument = - /** @type {{ - * fixtures: Array<{ - * name: string, - * expectedCompatible: boolean, - * frontend: { - * buildId: string, - * configSchemaVersion: string, - * apiContractVersion: string, - * assetManifestHash: string, - * releaseId: string - * }, - * runtime: { - * buildId: string, - * configSchemaVersion: string, - * apiContractVersion: string, - * assetManifestHash: string, - * releaseId: string - * } - * }> - * }} */ ( - JSON.parse( - await readFile("config/release/coherence-fixtures.json", "utf8"), - ) - ); -const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8")); -const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); -const buildManifest = JSON.parse( - await readFile("artifacts/release/build-manifest.json", "utf8"), -); -const runtimeConfigJsonSchema = JSON.parse( - await readFile("dist/runtime-config.schema.json", "utf8"), -); -const viteManifest = await readFile("dist/.vite/manifest.json", "utf8"); -const viteManifestObject = - /** @type {Record} */ ( - JSON.parse(viteManifest) - ); -const actualAssetManifestHash = createHash("sha256") - .update(viteManifest) - .digest("hex"); - -const artifactComparison = compareReleaseToRuntime(release, runtimeConfig); -const artifactMismatches = [...artifactComparison.mismatches]; -for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) { - if (typeof release[token] !== "string" || release[token].length === 0) { - artifactMismatches.push(`releaseToken:${token}`); - } -} -if (!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"); -} - -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}`); - } -} -for (const definition of Object.values(ROUTE_REGISTRY)) { - const runtime = - /** @type {Record} */ ( - ROUTE_RUNTIME_CONTRACT - )[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: new Date().toISOString(), - artifact, - fixtures, - passed, -}; - -await mkdir("artifacts/release", { recursive: true }); -await writeFile( - "artifacts/release/verification.json", - `${JSON.stringify(report, null, 2)}\n`, -); - -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`, -); diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts new file mode 100644 index 0000000..96ba7ad --- /dev/null +++ b/scripts/verify-release.ts @@ -0,0 +1,320 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { + verifyCompatibilityTuple, + type CompatibilityTuple, +} from "../src/application/policies/compatibility.ts"; +import { + compareReleaseToRuntime, + RELEASE_TOKEN_REGISTRY, +} from "../src/contracts/release-tokens.ts"; +import { + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../src/features/installed-feature-contracts.ts"; + +type CoherenceFixture = Readonly<{ + name: string; + expectedCompatible: boolean; + frontend: CompatibilityTuple; + runtime: CompatibilityTuple; +}>; +type ReleaseDocument = CompatibilityTuple & + Record & + Readonly<{ releaseId: string; routeChunks: Readonly> }>; +type RuntimeConfigDocument = Readonly<{ + BUILD_ID: string; + CONFIG_SCHEMA_VERSION: string; + API_CONTRACT_VERSION: string; + RELEASE_ID: string; +}>; +type BuildManifestDocument = Readonly< + Record & { + outputs?: Readonly<{ + runtimeConfigSchema?: unknown; + routeChunks?: Readonly>; + }>; + } +>; +type ViteManifestEntry = Readonly<{ + file: string; + name?: string; + isDynamicEntry?: boolean; +}>; + +const fixturesDocument = parseFixturesDocument( + JSON.parse( + await readFile("config/release/coherence-fixtures.json", "utf8"), + ), +); +const release = parseReleaseDocument( + JSON.parse(await readFile("dist/release-manifest.json", "utf8")), +); +const runtimeConfig = parseRuntimeConfigDocument( + JSON.parse(await readFile("dist/config.json", "utf8")), +); +const buildManifest = parseBuildManifestDocument( + JSON.parse(await readFile("artifacts/release/build-manifest.json", "utf8")), +); +const runtimeConfigJsonSchema = requireRecord( + JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")), + "runtime config JSON schema", +); +const viteManifest = await readFile("dist/.vite/manifest.json", "utf8"); +const viteManifestObject = parseViteManifest(JSON.parse(viteManifest)); +const actualAssetManifestHash = createHash("sha256") + .update(viteManifest) + .digest("hex"); + +const artifactComparison = compareReleaseToRuntime(release, runtimeConfig); +const artifactMismatches: string[] = [...artifactComparison.mismatches]; +for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) { + if (typeof release[token] !== "string" || release[token].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 [buildToken, releaseToken] of [ + ["buildId", "buildId"], + ["commitSha", "commitSha"], + ["releaseId", "releaseId"], + ["generatedAt", "builtAt"], +]) { + if (buildManifest[buildToken] !== release[releaseToken]) { + artifactMismatches.push(`buildManifest:${buildToken}`); + } +} + +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: new Date().toISOString(), + artifact, + fixtures, + passed, +}; + +await mkdir("artifacts/release", { recursive: true }); +await writeFile( + "artifacts/release/verification.json", + `${JSON.stringify(report, null, 2)}\n`, +); + +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`, +); + +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): ReleaseDocument { + const document = requireRecord(value, "release manifest"); + const tuple = parseCompatibilityTuple(document, "release manifest"); + const routeChunks = isRecord(document.routeChunks) + ? document.routeChunks + : {}; + return { ...document, ...tuple, routeChunks }; +} + +function parseRuntimeConfigDocument(value: unknown): RuntimeConfigDocument { + const document = requireRecord(value, "runtime config"); + return { + BUILD_ID: requireString(document.BUILD_ID, "runtime config BUILD_ID"), + CONFIG_SCHEMA_VERSION: requireString( + document.CONFIG_SCHEMA_VERSION, + "runtime config CONFIG_SCHEMA_VERSION", + ), + API_CONTRACT_VERSION: requireString( + document.API_CONTRACT_VERSION, + "runtime config API_CONTRACT_VERSION", + ), + RELEASE_ID: requireString( + document.RELEASE_ID, + "runtime config RELEASE_ID", + ), + }; +} + +function parseBuildManifestDocument(value: unknown): BuildManifestDocument { + const document = requireRecord(value, "build manifest"); + const outputs = isRecord(document.outputs) + ? { + runtimeConfigSchema: document.outputs.runtimeConfigSchema, + routeChunks: isRecord(document.outputs.routeChunks) + ? document.outputs.routeChunks + : undefined, + } + : undefined; + return { ...document, ...(outputs ? { outputs } : {}) }; +} + +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.mjs b/scripts/verify-reproducible-build.ts similarity index 74% rename from scripts/verify-reproducible-build.mjs rename to scripts/verify-reproducible-build.ts index 74543d4..7681b17 100644 --- a/scripts/verify-reproducible-build.mjs +++ b/scripts/verify-reproducible-build.ts @@ -2,21 +2,23 @@ import { spawnSync } from "node:child_process"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { supplyChainDigest } from "./lib/supply-chain.mjs"; +import { assertCiBuildEnvironment } from "./lib/build-environment.ts"; +import { supplyChainDigest } from "./lib/supply-chain.ts"; -/** @param {string} directory @returns {Promise} */ -async function filesWithin(directory) { +assertCiBuildEnvironment(process.env); + +async function filesWithin(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); - const nested = /** @type {string[][]} */ (await Promise.all( + 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() { +async function distDigest(): Promise { const rows = await Promise.all( (await filesWithin("dist")).map(async (file) => ({ path: path.relative("dist", file).replaceAll("\\", "/"), @@ -27,7 +29,7 @@ async function distDigest() { return supplyChainDigest(rows); } -function build(environment = process.env) { +function build(environment: NodeJS.ProcessEnv = process.env) { return spawnSync("corepack", ["pnpm", "build"], { env: environment, encoding: "utf8", @@ -58,6 +60,11 @@ await writeFile( { 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, diff --git a/scripts/verify-supply-chain-artifacts.mjs b/scripts/verify-supply-chain-artifacts.ts similarity index 65% rename from scripts/verify-supply-chain-artifacts.mjs rename to scripts/verify-supply-chain-artifacts.ts index 6fcf41a..4f24fb6 100644 --- a/scripts/verify-supply-chain-artifacts.mjs +++ b/scripts/verify-supply-chain-artifacts.ts @@ -7,34 +7,46 @@ import { parsePnpmLockfilePackages, supplyChainDigest, verifySupplyChainCoherence, -} from "./lib/supply-chain.mjs"; +} from "./lib/supply-chain.ts"; -/** @param {string} directory @returns {Promise} */ -async function filesWithin(directory) { +type Document = Record; + +function isRecord(value: unknown): value is Document { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseDocument(text: string, label: string): Document { + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`); + return parsed; +} + +function recordRows(value: unknown): Document[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +async function readDocument(file: string): Promise { + return parseDocument(await readFile(file, "utf8"), file); +} + +async function filesWithin(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); - const nested = /** @type {string[][]} */ (await Promise.all( + 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(); } -const inventory = JSON.parse( - await readFile("artifacts/release/dependency-inventory.json", "utf8"), +const inventory = await readDocument( + "artifacts/release/dependency-inventory.json", ); -const sbom = JSON.parse( - await readFile("artifacts/release/sbom.cdx.json", "utf8"), -); -const provenance = JSON.parse( - await readFile("artifacts/release/provenance.json", "utf8"), -); -const verification = JSON.parse( - await readFile( - "artifacts/security/supply-chain-verification.json", - "utf8", - ), +const sbom = await readDocument("artifacts/release/sbom.cdx.json"); +const provenance = await readDocument("artifacts/release/provenance.json"); +const verification = await readDocument( + "artifacts/security/supply-chain-verification.json", ); const lockfileText = await readFile("pnpm-lock.yaml", "utf8"); const lockfileSha256 = createHash("sha256") @@ -57,7 +69,7 @@ const coherence = verifySupplyChainCoherence( provenance, distDigest, ); -const failures = [...coherence.failures]; +const failures: string[] = [...coherence.failures]; if ( inventory.lockfileSha256 !== lockfileSha256 || verification.lockfileSha256 !== lockfileSha256 @@ -71,15 +83,14 @@ if ( failures.push("verification digest set is incoherent"); } const lockRows = parsePnpmLockfilePackages(lockfileText); -const inventoryRows = - /** @type {Array>} */ ( - inventory.dependencies ?? [] - ); -const inventoryByIdentity = new Map( - inventoryRows.map((entry) => [ - `${entry.name}@${entry.version}`, - entry, - ]), +const inventoryRows = recordRows(inventory.dependencies); +const inventoryByIdentity = new Map( + inventoryRows.map( + (entry) => [ + `${String(entry.name ?? "")}@${String(entry.version ?? "")}`, + entry, + ] as const, + ), ); if (lockRows.length !== inventoryRows.length) { failures.push("transitive dependency count differs from lockfile"); diff --git a/scripts/verify-supply-chain-promotion.mjs b/scripts/verify-supply-chain-promotion.ts similarity index 100% rename from scripts/verify-supply-chain-promotion.mjs rename to scripts/verify-supply-chain-promotion.ts diff --git a/scripts/write-a11y-report.mjs b/scripts/write-a11y-report.ts similarity index 98% rename from scripts/write-a11y-report.mjs rename to scripts/write-a11y-report.ts index b686a33..42ec0ce 100644 --- a/scripts/write-a11y-report.mjs +++ b/scripts/write-a11y-report.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.mjs"; +import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.ts"; await mkdir("artifacts/tests", { recursive: true }); await writeFile( diff --git a/src/adapters/auth/external-session-adapter.js b/src/adapters/auth/external-session-adapter.js deleted file mode 100644 index c990868..0000000 --- a/src/adapters/auth/external-session-adapter.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Creates the skeleton-owned side of an external session integration. - * Credential acquisition and storage stay inside the supplied external owner. - * - * @param {{ - * readState(): import("../../application/ports/auth-session-port.js").SessionState, - * subscribe(listener: () => void): () => void, - * beginSignIn(returnTo?: string): Promise, - * signOut(): Promise, - * attachCredential(request: Request): Promise, - * recoverSession(): Promise<"restored" | "no-session">, - * notifyUnauthenticated(): void - * }} owner - * @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort} - */ -export function createExternalAuthSessionAdapter(owner) { - return Object.freeze({ - getState() { - return owner.readState(); - }, - subscribe(listener) { - return owner.subscribe(listener); - }, - async beginSignIn(returnTo) { - await owner.beginSignIn(returnTo); - }, - async signOut() { - await owner.signOut(); - }, - /** @param {Request} request */ - async attach(request) { - const attached = await owner.attachCredential(request); - if (!(attached instanceof Request)) { - throw new TypeError("Auth owner returned an invalid request"); - } - return attached; - }, - 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(); - }, - }); -} - -export function createAnonymousSessionAdapter() { - return createExternalAuthSessionAdapter({ - readState: () => "unauthenticated", - subscribe: () => () => {}, - beginSignIn: async () => {}, - signOut: async () => {}, - attachCredential: async (request) => request, - recoverSession: async () => "no-session", - notifyUnauthenticated: () => {}, - }); -} - -/** - * Local/test-only session seam. It never creates or stores credentials. - * - * @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState] - */ -export function createDemoSessionAdapter(initialState = "unauthenticated") { - let state = initialState; - const listeners = new Set(); - - function notify() { - for (const listener of listeners) listener(); - } - - /** @param {import("../../application/ports/auth-session-port.js").SessionState} next */ - function setState(next) { - state = next; - notify(); - } - - return Object.freeze({ - getState: () => state, - /** @param {() => void} listener */ - subscribe(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - async beginSignIn() { - setState("authenticated"); - }, - async signOut() { - setState("unauthenticated"); - }, - /** @param {Request} request */ - async attach(request) { - return request; - }, - async recover() { - if (state === "recovery-pending") { - setState("authenticated"); - return /** @type {const} */ ("restored"); - } - return /** @type {const} */ ("no-session"); - }, - onUnauthenticated() { - setState("unauthenticated"); - }, - setState, - }); -} - -export function createUnavailableSessionAdapter() { - return Object.freeze({ - getState: () => /** @type {const} */ ("integration-failed"), - subscribe: () => () => {}, - beginSignIn: async () => {}, - signOut: async () => {}, - /** @param {Request} request */ - attach: async (request) => request, - recover: async () => /** @type {const} */ ("no-session"), - onUnauthenticated: () => {}, - }); -} 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..68a8580 --- /dev/null +++ b/src/adapters/cross-context-invalidation/browser-cross-context-host.ts @@ -0,0 +1,246 @@ +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 { + 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 index 8f7285d..c0b8e13 100644 --- a/src/adapters/diagnostics/bounded-diagnostics.ts +++ b/src/adapters/diagnostics/bounded-diagnostics.ts @@ -1,11 +1,11 @@ -import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.js"; +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; import { projectDiagnosticRecord, safeErrorKind, type DiagnosticRecord, type DiagnosticRecordInput, -} from "../../contracts/diagnostics.js"; -import { projectTelemetryEvent } from "../../contracts/telemetry.js"; +} from "../../contracts/diagnostics.ts"; +import { projectTelemetryEvent } from "../../contracts/telemetry.ts"; export const noOpDiagnostics: DiagnosticsPort = Object.freeze({ record() {}, 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.js b/src/adapters/http/client.js deleted file mode 100644 index cb4a661..0000000 --- a/src/adapters/http/client.js +++ /dev/null @@ -1,589 +0,0 @@ -import { systemClock } from "../platform/system-clock.js"; -import { getApiOperation } from "../../contracts/api-operations.js"; -import { - createFailure as failure, - kindForStatus as statusKind, - normalizeUnknownFailure, - safeValidationIssues, -} from "../../contracts/errors.js"; -import { mapOperationPayload } from "./resource-mapper.js"; -import { retryDelay, shouldRetry } from "./retry-policy.js"; -import { - validateEnvelope, - validateOperationPayload, - validateOperationRequest, -} from "./schema-registry.js"; -import { buildRequestTarget } from "./request-builder.js"; -import { - attemptBucket, - durationBucket, - statusGroup, -} from "../../contracts/diagnostics.js"; - -const noAuthSession = - /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ - getState: () => /** @type {"unauthenticated"} */ ("unauthenticated"), - attach: async (request) => request, - recover: async () => /** @type {"no-session"} */ ("no-session"), - onUnauthenticated: () => {}, -}); - -/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */ -/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */ - -/** - * @typedef {{ - * setTimeout(callback: () => void, milliseconds: number): unknown, - * clearTimeout(handle: unknown): void - * }} Scheduler - */ - -/** - * @typedef {{ ok: true, value: unknown, meta: Record } | - * { ok: false, error: HttpFailure }} HttpResult - */ - -/** - * @param {{ - * baseUrl: string, - * fetcher?: typeof fetch, - * authSession?: import("../../application/ports/auth-session-port.js").AuthSessionPort, - * clock?: import("../../application/ports/clock-port.js").ClockPort, - * random?: () => number, - * validatePayload?: (schemaId: string, value: unknown) => - * { success: true, data: unknown } | { success: false }, - * validateRequest?: (schemaId: string, value: unknown) => - * { success: true, data: unknown } | { success: false }, - * mapPayload?: (operationId: string, payload: unknown) => unknown, - * idempotencyKeyFactory?: () => string, - * timeoutMs?: number, - * maxRetryAttempts?: number, - * scheduler?: Scheduler, - * getOperation?: typeof getApiOperation, - * diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort, - * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, - * correlationIdFactory?: () => string - * }} dependencies - */ -export function createHttpClient(dependencies) { - 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 mapPayload = dependencies.mapPayload ?? mapOperationPayload; - const idempotencyKeyFactory = - dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); - const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000; - const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2; - 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 ?? - /** @type {Scheduler} */ ({ - setTimeout: (callback, milliseconds) => - globalThis.setTimeout(callback, milliseconds), - clearTimeout: (handle) => - globalThis.clearTimeout( - /** @type {ReturnType} */ (handle), - ), - }); - - /** - * @param {string | OperationRequestInput} request - * @param {{ - * body?: unknown, - * routeId?: string, - * pathParams?: Record, - * searchParams?: unknown, - * signal?: AbortSignal, - * idempotencyKey?: string, - * correlationId?: string - * }} [legacyInput] - * @returns {Promise} - */ - async function execute(request, legacyInput = {}) { - 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 operation = selectOperation(input.operationId); - const startedAt = clock.now(); - const correlationId = input.correlationId ?? correlationIdFactory(); - /** - * @param {HttpResult} outcome - * @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind - */ - function finalize(outcome, outcomeKind) { - 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), - attempt_count_bucket: attemptBucket( - error?.attemptCount ?? retryCount + 1, - ), - 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; - } - const logicalIdempotencyKey = - operation.idempotency === "keyed" - ? input.idempotencyKey ?? idempotencyKeyFactory() - : undefined; - let retryCount = 0; - let recoveryUsed = false; - - while (true) { - const attempt = retryCount; - /** @type {HttpResult} */ - const outcome = await performAttempt({ - operation, - input, - attempt, - idempotencyKey: logicalIdempotencyKey, - }); - - if (outcome.ok) { - return finalize( - outcome, - retryCount > 0 || recoveryUsed ? "recovered" : "success", - ); - } - - if (outcome.error.httpStatus === 401 && !recoveryUsed) { - recoveryUsed = true; - const recovered = await recoverSession( - authSession, - operation, - outcome.error, - ); - 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; - - try { - await clock.sleep(delay, input.signal); - } catch { - return finalize( - { - ok: false, - error: failure("REQUEST_ABORTED", input.operationId, retryCount, { - code: "REQUEST_ABORTED", - }), - }, - "aborted", - ); - } - } - } - - /** - * @param {{ - * operation: ReturnType, - * input: OperationRequestInput, - * attempt: number, - * idempotencyKey?: string - * }} context - * @returns {Promise} - */ - async function performAttempt(context) { - const { operation, input, attempt, idempotencyKey } = context; - /** @type {unknown} */ - let parsedSearch = {}; - let parsedBody; - 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; - } - } - - const target = buildRequestTarget( - dependencies.baseUrl, - operation, - input.pathParams, - parsedSearch, - ); - if (!target.success) { - return { - ok: false, - error: failure("VALIDATION_REJECTED", operation.operationId, attempt, { - code: target.code, - }), - }; - } - - const controller = new AbortController(); - let timedOut = false; - const timeout = scheduler.setTimeout(() => { - timedOut = true; - controller.abort("timeout"); - }, operation.timeoutMs ?? defaultTimeoutMs); - const onExternalAbort = () => controller.abort(input.signal?.reason); - input.signal?.addEventListener("abort", onExternalAbort, { once: true }); - if (input.signal?.aborted) onExternalAbort(); - - const headers = new Headers({ Accept: "application/json" }); - if (parsedBody !== undefined) headers.set("Content-Type", "application/json"); - if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); - - let request = new Request(target.url, { - method: operation.method, - headers, - body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody), - signal: controller.signal, - }); - - try { - if (operation.auth === "external-session") { - try { - request = await authSession.attach(request); - } catch { - return { - ok: false, - error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, { - code: "AUTH_ATTACH_FAILED", - }), - }; - } - } - - const response = await fetcher(request); - return await parseResponse( - response, - operation, - attempt, - validatePayload, - mapPayload, - ); - } 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 }); -} - -/** - * @param {Response} response - * @param {import("../../contracts/api-operations.js").ApiOperation} operation - * @param {number} attempt - * @param {(schemaId: string, value: unknown) => - * { success: true, data: unknown } | { success: false }} validatePayload - * @param {(operationId: string, payload: unknown) => unknown} mapPayload - * @returns {Promise} - */ -async function parseResponse( - response, - operation, - attempt, - validatePayload, - mapPayload, -) { - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("application/json")) { - return { - ok: false, - error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, { - code: "CONTENT_TYPE_MISMATCH", - httpStatus: response.status, - }), - }; - } - - let envelope; - try { - envelope = await response.json(); - } catch { - return { - ok: false, - error: failure("MALFORMED_JSON", operation.operationId, attempt, { - code: "MALFORMED_JSON", - httpStatus: response.status, - }), - }; - } - - 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 = - /** @type {Record} */ (envelopeValidation.data); - if (response.ok && 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 { - return { - ok: true, - value: mapPayload(operation.operationId, payload.data), - meta: safeMeta(envelopeRecord.meta), - }; - } catch (error) { - return { - ok: false, - error: normalizeUnknownFailure(error, { - operationId: operation.operationId, - attempt, - }), - }; - } - } - - const kind = statusKind(response.status); - const retryAfter = response.headers.get("retry-after"); - const backendError = - envelopeRecord.error && typeof envelopeRecord.error === "object" - ? /** @type {Record} */ (envelopeRecord.error) - : {}; - 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) - : undefined, - validationIssues: - response.status === 422 - ? safeValidationIssues(backendError.details) - : undefined, - }), - }; -} - -/** - * @param {import("../../application/ports/auth-session-port.js").AuthSessionPort} authSession - * @param {import("../../contracts/api-operations.js").ApiOperation} operation - * @param {HttpFailure} originalFailure - * @returns {Promise<{ok: true} | {ok: false, error: HttpFailure}>} - */ -async function recoverSession(authSession, operation, originalFailure) { - 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, { - code: "AUTH_REQUIRED", - httpStatus: 401, - }), - }; - } - } catch { - // Normalized below. - } - - return { - ok: false, - error: failure( - "AUTH_INTEGRATION_FAILURE", - operation.operationId, - originalFailure.attemptCount, - { code: "AUTH_RECOVERY_FAILED" }, - ), - }; -} - -/** - * @param {string} kind - * @param {string} operationId - * @param {number} attempt - * @param {FailureDetails} [details] - * @returns {HttpFailure} - */ -/** @param {unknown} envelope */ -function safeBackendCode(envelope) { - if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE"; - const error = /** @type {Record} */ (envelope).error; - if (!error || typeof error !== "object") return "HTTP_FAILURE"; - const code = /** @type {Record} */ (error).code; - return typeof code === "string" ? code : "HTTP_FAILURE"; -} - -/** @param {unknown} meta @returns {Record} */ -function safeMeta(meta) { - if (!meta || typeof meta !== "object") return {}; - const metaRecord = /** @type {Record} */ (meta); - return { - ...(typeof metaRecord.requestId === "string" - ? { requestId: metaRecord.requestId } - : {}), - ...(typeof metaRecord.traceId === "string" ? { traceId: metaRecord.traceId } : {}), - }; -} - -/** @param {string} value */ -function parseRetryAfterHeader(value) { - 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 - Date.now()) : undefined; -} 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/request-builder.ts b/src/adapters/http/request-builder.ts index 527411b..c213279 100644 --- a/src/adapters/http/request-builder.ts +++ b/src/adapters/http/request-builder.ts @@ -1,4 +1,4 @@ -import type { ApiOperation } from "../../contracts/api-operations.js"; +import type { ApiOperation } from "../../contracts/api-operations.ts"; export type OperationRequestInput = Readonly<{ operationId: string; @@ -15,7 +15,12 @@ export type RequestTargetResult = | Readonly<{ success: true; url: URL }> | Readonly<{ success: false; - code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID"; + 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; @@ -26,7 +31,35 @@ export function buildRequestTarget( 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) => { @@ -36,12 +69,27 @@ export function buildRequestTarget( missingPathParameter = true; return ""; } - return encodeURIComponent(String(value)); + 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 || @@ -51,7 +99,12 @@ export function buildRequestTarget( return { success: false, code: "SEARCH_PARAMETER_INVALID" }; } - const url = new URL(pathname, baseUrl); + 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), @@ -70,5 +123,12 @@ export function buildRequestTarget( 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.js b/src/adapters/http/resource-mapper.js deleted file mode 100644 index 4486b05..0000000 --- a/src/adapters/http/resource-mapper.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @param {string} operationId @param {unknown} payload */ -export function mapOperationPayload(operationId, payload) { - void payload; - throw new TypeError(`No boundary mapper registered for ${operationId}`); -} 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.js b/src/adapters/http/retry-policy.ts similarity index 61% rename from src/adapters/http/retry-policy.js rename to src/adapters/http/retry-policy.ts index e89207e..ef5c529 100644 --- a/src/adapters/http/retry-policy.js +++ b/src/adapters/http/retry-policy.ts @@ -1,27 +1,23 @@ -const retryKinds = new Set([ +const retryKinds: ReadonlySet = new Set([ "NETWORK_UNREACHABLE", "REQUEST_TIMEOUT", "RATE_LIMITED", "SERVER_FAILURE", ]); -/** - * @param {number} retryIndex - * @param {() => number} [random] - * @param {number} [baseDelayMs] - * @param {number} [maxDelayMs] - */ export function calculateBackoff( - retryIndex, + retryIndex: number, random = Math.random, baseDelayMs = 250, maxDelayMs = 2_000, -) { +): number { return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random(); } -/** @param {string | null | undefined} value @param {number} [now] */ -export function parseRetryAfter(value, now = Date.now()) { +export function parseRetryAfter( + value: string | null | undefined, + now = Date.now(), +): number | null { if (!value) return null; const seconds = Number(value); @@ -34,13 +30,24 @@ export function parseRetryAfter(value, now = Date.now()) { return Math.max(0, timestamp - now); } -/** - * @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation - * @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure - * @param {number} retryCount - * @param {number} [maxRetries] - */ -export function shouldRetry(operation, failure, retryCount, maxRetries = 2) { +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; @@ -61,13 +68,12 @@ export function shouldRetry(operation, failure, retryCount, maxRetries = 2) { return operation.idempotency === "safe" || operation.idempotency === "keyed"; } -/** - * @param {{ kind: string, retryAfterMs?: number, retryAfter?: string }} failure - * @param {number} retryIndex - * @param {() => number} [random] - * @param {number} [now] - */ -export function retryDelay(failure, retryIndex, random = Math.random, now = Date.now()) { +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; diff --git a/src/adapters/http/schema-registry.js b/src/adapters/http/schema-registry.js deleted file mode 100644 index 13064b9..0000000 --- a/src/adapters/http/schema-registry.js +++ /dev/null @@ -1,92 +0,0 @@ -import { z } from "zod"; - -const metaSchema = z - .object({ - requestId: z.string().min(1), - traceId: z.string().min(1), - correlationId: z.string().min(1).optional(), - }) - .passthrough(); - -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().min(1), - category: z.string().min(1).optional(), - message: z.string().optional(), - retryable: z.boolean().optional(), - details: z.unknown().optional(), - }) - .strict(), - meta: metaSchema, - }) - .strict(); - -export const responseEnvelopeSchema = z.discriminatedUnion("success", [ - successEnvelopeSchema, - failureEnvelopeSchema, -]); - -const payloadSchemas = - /** @type {Readonly>} */ (Object.freeze({})); - -const requestSchemas = - /** @type {Readonly>} */ (Object.freeze({})); - -/** @param {unknown} value */ -export function validateEnvelope(value) { - return projectResult(responseEnvelopeSchema.safeParse(value)); -} - -/** @param {string} schemaId @param {unknown} value */ -export function validateOperationPayload(schemaId, value) { - const schema = payloadSchemas[schemaId]; - if (!schema) return missingSchema(schemaId); - return projectResult(schema.safeParse(value)); -} - -/** @param {string} schemaId @param {unknown} value */ -export function validateOperationRequest(schemaId, value) { - const schema = requestSchemas[schemaId]; - if (!schema) return missingSchema(schemaId); - return projectResult(schema.safeParse(value)); -} - -/** @param {string} schemaId */ -function missingSchema(schemaId) { - return { - success: /** @type {false} */ (false), - issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }], - }; -} - -/** - * @param {{ success: true, data: unknown } | - * { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result - */ -function projectResult(result) { - if (result.success) { - return { - success: /** @type {true} */ (true), - data: structuredClone(result.data), - }; - } - - return { - success: /** @type {false} */ (false), - issues: result.error.issues.map((issue) => ({ - path: issue.path.join("."), - code: issue.code, - })), - }; -} 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/system-clock.js b/src/adapters/platform/system-clock.ts similarity index 81% rename from src/adapters/platform/system-clock.js rename to src/adapters/platform/system-clock.ts index dbcb0df..b22a686 100644 --- a/src/adapters/platform/system-clock.js +++ b/src/adapters/platform/system-clock.ts @@ -1,5 +1,6 @@ -/** @type {import("../../application/ports/clock-port.js").ClockPort} */ -export const systemClock = Object.freeze({ +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) => { 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..e440b7b --- /dev/null +++ b/src/adapters/query-cache/server-state-scope-runtime.ts @@ -0,0 +1,86 @@ +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, + ServerStateScopeRuntime, +} from "../../contracts/server-state-scope.ts"; + +export function createServerStateScopeRuntime(dependencies: Readonly<{ + session: Pick; + queryInvalidation: QueryInvalidationCoordinator; + tokenFactory?: () => string; +}>): ServerStateScopeRuntime { + const listeners = new Set<() => void>(); + let generation = 1; + let identities = newIdentityRegistry(dependencies.tokenFactory); + let fingerprint = scopeFingerprint(dependencies.tokenFactory); + let disposed = false; + let resetChain = Promise.resolve(); + + function createSnapshot(): CacheScopeSnapshot { + const capturedGeneration = generation; + const capturedIdentities = identities; + return Object.freeze({ + generation: capturedGeneration, + fingerprint, + identities: capturedIdentities, + isCurrent: () => + !disposed && + generation === capturedGeneration && + identities === capturedIdentities, + }); + } + let currentSnapshot = createSnapshot(); + + const unsubscribe = dependencies.session.subscribe(() => { + if (disposed) return; + const previousIdentities = identities; + const targetGeneration = ++generation; + resetChain = resetChain + .then(() => dependencies.queryInvalidation.resetLocal()) + .catch(() => {}) + .finally(() => { + previousIdentities.close(); + if (disposed || generation !== targetGeneration) return; + identities = newIdentityRegistry(dependencies.tokenFactory); + fingerprint = scopeFingerprint(dependencies.tokenFactory); + currentSnapshot = createSnapshot(); + for (const listener of listeners) listener(); + }); + }); + + return Object.freeze({ + getSnapshot: () => currentSnapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + if (disposed) return; + disposed = true; + unsubscribe(); + listeners.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..311149a --- /dev/null +++ b/src/adapters/query-cache/tanstack-cache-coordinator.ts @@ -0,0 +1,329 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; +import type { + QueryInvalidationCoordinator, + QueryInvalidationTopic, + QueryMutationLease, +} from "../../contracts/query-invalidation.ts"; +import { isCacheInvalidationTopic } from "../../contracts/cache-invalidation.ts"; +import type { + BrowserCrossContextInvalidation, + CrossContextInvalidationDelivery, +} from "../cross-context-invalidation/index.ts"; + +export type InstalledQueryInvalidationDefinition = Readonly<{ + namespace: readonly unknown[]; + invalidationTopic: QueryInvalidationTopic; + crossContext: "invalidate-only"; + version: number; + persistence: "disabled"; +}>; + +export type TanStackCacheCoordinatorDependencies = Readonly<{ + queryClient: QueryClient; + queryRegistry: Readonly< + Record + >; + crossContext?: BrowserCrossContextInvalidation; + diagnostics?: DiagnosticsPort; +}>; + +type RuntimeDefinition = Readonly<{ + namespace: readonly unknown[]; + topic: QueryInvalidationTopic; + version: number; +}>; + +const MAX_NAMESPACE_PARTS = 8; +const MAX_NAMESPACE_BYTES = 1_024; + +/** + * 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 { + const definitions = buildDefinitions(dependencies.queryRegistry); + 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 definition(topic: QueryInvalidationTopic): RuntimeDefinition { + const selected = definitions.get(topic); + if (!selected) { + throw new TypeError("Unregistered query invalidation topic."); + } + return selected; + } + + async function invalidateLocal( + topic: QueryInvalidationTopic, + expectedGeneration = lifecycleGeneration, + ): Promise { + if ( + disposed || + resetting || + expectedGeneration !== lifecycleGeneration + ) { + return; + } + const selected = definition(topic); + try { + await dependencies.queryClient.invalidateQueries({ + queryKey: selected.namespace, + exact: false, + refetchType: "active", + }); + } catch { + report("invalidate"); + } + } + + function receiveRemote( + delivery: CrossContextInvalidationDelivery, + ): void { + if (disposed) return; + const selected = definitions.get(delivery.event.topic); + if (!selected) { + report("unknown-topic"); + return; + } + + if (delivery.ordering === "GAP") { + for (const candidate of definitions.values()) { + pendingRemote.add(candidate.topic); + } + report("sequence-gap"); + } else { + pendingRemote.add(selected.topic); + } + 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(topic, 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) definition(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); + for (const topic of selectedTopics) { + const selected = definition(topic); + await invalidateLocal(topic); + const published = dependencies.crossContext?.publish({ + topic, + topicVersion: selected.version, + }); + 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"); + } + try { + await dependencies.queryClient.cancelQueries(); + } catch { + report("reset-cancel"); + } + dependencies.queryClient.clear(); + })().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 buildDefinitions( + registry: Readonly< + Record + >, +): ReadonlyMap { + const definitions = new Map(); + for (const candidate of Object.values(registry)) { + if ( + !candidate || + !isCacheInvalidationTopic(candidate.invalidationTopic) || + candidate.crossContext !== "invalidate-only" || + candidate.persistence !== "disabled" || + !Number.isSafeInteger(candidate.version) || + candidate.version < 1 || + !isSafeNamespace(candidate.namespace) || + definitions.has(candidate.invalidationTopic) + ) { + throw new TypeError("Query invalidation registry is invalid."); + } + definitions.set( + candidate.invalidationTopic, + Object.freeze({ + namespace: Object.freeze(structuredClone(candidate.namespace)), + topic: candidate.invalidationTopic, + version: candidate.version, + }), + ); + } + if (definitions.size === 0) { + throw new TypeError( + "Query invalidation registry requires at least one topic.", + ); + } + return definitions; +} + +function isSafeNamespace(value: unknown): value is readonly unknown[] { + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > MAX_NAMESPACE_PARTS || + typeof value[0] !== "string" + ) { + return false; + } + try { + const serialized = JSON.stringify(value); + return ( + typeof serialized === "string" && + new TextEncoder().encode(serialized).byteLength <= + MAX_NAMESPACE_BYTES + ); + } catch { + return false; + } +} diff --git a/src/adapters/query-cache/tanstack-query-cache.js b/src/adapters/query-cache/tanstack-query-cache.ts similarity index 71% rename from src/adapters/query-cache/tanstack-query-cache.js rename to src/adapters/query-cache/tanstack-query-cache.ts index 83d6ab8..06d6eb0 100644 --- a/src/adapters/query-cache/tanstack-query-cache.js +++ b/src/adapters/query-cache/tanstack-query-cache.ts @@ -1,7 +1,13 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; -import { createFailure } from "../../contracts/errors.js"; -import { safeErrorKind } from "../../contracts/diagnostics.js"; +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, @@ -12,12 +18,10 @@ export const QUERY_CACHE_DEFAULTS = Object.freeze({ persistence: false, }); -/** - * @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies] - */ -export function createQueryClient(dependencies = {}) { - /** @param {string} operation @param {unknown} error */ - function report(operation, error) { +export function createQueryClient( + dependencies: QueryCacheDependencies = {}, +): QueryClient { + function report(operation: string, error: unknown): void { try { dependencies.diagnostics?.record({ level: "warn", @@ -52,12 +56,10 @@ export function createQueryClient(dependencies = {}) { }); } -/** - * @param {QueryClient} queryClient - * @param {{diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort}} [dependencies] - * @returns {import("../../application/ports/query-cache-port.js").QueryCachePort} - */ -export function createQueryCacheAdapter(queryClient, dependencies = {}) { +export function createQueryCacheAdapter( + queryClient: QueryClient, + dependencies: QueryCacheDependencies = {}, +): QueryCachePort { return Object.freeze({ read(key) { try { @@ -85,12 +87,11 @@ export function createQueryCacheAdapter(queryClient, dependencies = {}) { }); } -/** - * @param {string} phase - * @param {readonly unknown[]} key - * @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics - */ -function cacheFailure(phase, key, 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({ @@ -105,7 +106,7 @@ function cacheFailure(phase, key, diagnostics) { // Cache behavior remains independent from diagnostics. } return { - ok: /** @type {false} */ (false), + 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/storage/browser-storage-adapter.js b/src/adapters/storage/browser-storage-adapter.js deleted file mode 100644 index b00a247..0000000 --- a/src/adapters/storage/browser-storage-adapter.js +++ /dev/null @@ -1,178 +0,0 @@ -import { createFailure } from "../../contracts/errors.js"; -import { getStorageDefinition } from "../../contracts/storage-keys.js"; - -/** - * @param {{ - * localStorage?: Storage, - * sessionStorage?: Storage, - * now?: () => number, - * diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort - * }} [dependencies] - * @returns {import("../../application/ports/storage-port.js").StoragePort} - */ -export function createBrowserStorageAdapter(dependencies = {}) { - const memory = new Map(); - const now = dependencies.now ?? Date.now; - - /** @param {string} name */ - function backendFor(name) { - if (name === "localStorage") return dependencies.localStorage; - if (name === "sessionStorage") return dependencies.sessionStorage; - return undefined; - } - - return Object.freeze({ - read(logicalName) { - let definition; - try { - definition = getStorageDefinition(logicalName); - } catch { - return unavailable("read", logicalName, dependencies.diagnostics); - } - - const backend = backendFor(definition.backend); - try { - const raw = backend?.getItem(definition.physicalKey); - if (raw === null || raw === undefined) { - return { ok: true, value: memory.get(definition.physicalKey) }; - } - const envelope = JSON.parse(raw); - if ( - !envelope || - typeof envelope !== "object" || - envelope.schemaVersion !== definition.schemaVersion - ) { - backend?.removeItem(definition.physicalKey); - return { ok: true, value: undefined }; - } - if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) { - backend?.removeItem(definition.physicalKey); - return { ok: true, value: undefined }; - } - return { ok: true, value: structuredClone(envelope.value) }; - } catch { - return unavailable("read", logicalName, dependencies.diagnostics); - } - }, - - write(logicalName, value) { - let definition; - try { - definition = getStorageDefinition(logicalName); - } catch { - return unavailable("write", logicalName, dependencies.diagnostics); - } - - const expiresAt = - typeof definition.ttl === "number" ? now() + definition.ttl : null; - const envelope = { - schemaVersion: definition.schemaVersion, - expiresAt, - value: structuredClone(value), - }; - const backend = backendFor(definition.backend); - - try { - if (!backend) throw new DOMException("Storage unavailable", "SecurityError"); - backend.setItem(definition.physicalKey, JSON.stringify(envelope)); - return { ok: true }; - } catch (error) { - const quota = - error instanceof DOMException && - ["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name); - - if (definition.quotaFallback === "memory") { - memory.set(definition.physicalKey, structuredClone(value)); - recordStorageFailure( - dependencies.diagnostics, - "write", - logicalName, - quota, - ); - return { - ok: false, - error: storageFailure(quota, "write", logicalName), - fallback: "memory", - }; - } - recordStorageFailure( - dependencies.diagnostics, - "write", - logicalName, - quota, - ); - return { - ok: false, - error: storageFailure(quota, "write", logicalName), - fallback: definition.quotaFallback, - }; - } - }, - - remove(logicalName) { - let definition; - try { - definition = getStorageDefinition(logicalName); - } catch { - return unavailable("remove", logicalName, dependencies.diagnostics); - } - try { - backendFor(definition.backend)?.removeItem(definition.physicalKey); - memory.delete(definition.physicalKey); - return { ok: true }; - } catch { - return unavailable("remove", logicalName, dependencies.diagnostics); - } - }, - }); -} - -/** @param {boolean} quota @param {string} phase @param {string} logicalName */ -function storageFailure(quota, phase, logicalName) { - return createFailure( - quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE", - "STORAGE", - 0, - { - code: `${logicalName}_${phase.toUpperCase()}_${ - quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE" - }`, - }, - ); -} - -/** - * @param {string} phase - * @param {string} logicalName - * @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics - */ -function unavailable(phase, logicalName, diagnostics) { - recordStorageFailure(diagnostics, phase, logicalName, false); - return { - ok: /** @type {false} */ (false), - error: storageFailure(false, phase, logicalName), - }; -} - -/** - * @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics - * @param {string} phase - * @param {string} logicalName - * @param {boolean} quota - */ -function recordStorageFailure(diagnostics, phase, logicalName, quota) { - try { - diagnostics?.record({ - level: "warn", - eventId: "storage.operation.failed", - context: { - operation: `${phase}:${logicalName}`, - error_kind: quota - ? "STORAGE_QUOTA_EXCEEDED" - : "STORAGE_UNAVAILABLE", - }, - }); - } catch { - // Storage behavior remains independent from diagnostics. - } -} 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..9896762 --- /dev/null +++ b/src/adapters/storage/indexeddb/indexeddb-runtime.ts @@ -0,0 +1,2869 @@ +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 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" }); + } + + 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 (openingRequest) { + const result = + status.kind === "BLOCKED" + ? browserDataFailure("BLOCKED", operation, { + retryable: true, + recovery: "RELOAD_OTHER_CONTEXTS", + }) + : unavailable(operation); + return observeResult(operation, result); + } + + updateStatus({ + kind: "OPENING", + targetVersion: dependencies.schemaVersion, + }); + + return await new Promise>((resolve) => { + let request: IDBOpenDBRequest; + try { + request = factory.open( + databaseName, + dependencies.schemaVersion, + ); + } catch (error) { + const result = mapIndexedDbException(error, operation); + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + resolve(observeResult(operation, result)); + return; + } + + openingRequest = request; + let callerSettled = false; + let migrationFailed = false; + let policyBindingRejected = false; + let appliedMigrations = 0; + let blockedTimer: unknown; + + const clearBlockedTimer = () => { + if (blockedTimer !== undefined) { + scheduler.clearTimeout(blockedTimer); + blockedTimer = undefined; + } + }; + const detachAbort = () => signal?.removeEventListener("abort", onAbort); + const finishCaller = (result: BrowserDataResult) => { + if (callerSettled) return; + callerSettled = true; + clearBlockedTimer(); + detachAbort(); + resolve(observeResult(operation, result)); + }; + const settleLateRequest = () => { + openingRequest = null; + cancelPendingOpen = null; + }; + const abortUpgrade = () => { + try { + request.transaction?.abort(); + } catch { + // An open request cannot otherwise be cancelled. + } + }; + function onAbort(): void { + abortUpgrade(); + finishCaller(browserDataFailure("ABORTED", operation)); + } + cancelPendingOpen = () => { + abortUpgrade(); + finishCaller(unavailable(operation)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + 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(() => { + finishCaller( + 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); + finishCaller(result); + return; + } + if (policyBindingRejected) { + finishCaller( + browserDataFailure("POLICY_REJECTED", operation, { + recovery: storagePolicySnapshot.unavailableFallback, + }), + ); + return; + } + finishCaller(mapIndexedDbException(request.error, operation)); + }; + + request.onsuccess = () => { + const opened = request.result; + void (async () => { + if (callerSettled || 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); + finishCaller(result); + return; + } + const binding = await verifyIndexedDbDatasetBinding( + opened, + dependencies.governanceStore, + expectedBinding, + signal, + ); + settleLateRequest(); + if (!binding.ok) { + opened.close(); + if (!disposed) { + updateStatus({ kind: "CLOSED", reason: "NOT_OPENED" }); + } + if (!callerSettled) { + finishCaller( + binding.reason === "ABORTED" + ? browserDataFailure("ABORTED", operation) + : binding.reason === "NATIVE_ERROR" + ? mapIndexedDbException(binding.error, operation) + : browserDataFailure( + "POLICY_REJECTED", + operation, + { + recovery: + storagePolicySnapshot.unavailableFallback, + }, + ), + ); + } + return; + } + if (callerSettled || 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, + ); + } + finishCaller(browserDataSuccess(undefined)); + })(); + }; + }); + } + + 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..2d43932 --- /dev/null +++ b/src/adapters/storage/opfs/opfs-worker-client.ts @@ -0,0 +1,636 @@ +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; +} + +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 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); + }; + dependencies.worker.addEventListener("message", onMessage); + + 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(); + 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); + for (const request of pending.values()) { + clearTimeout(request.timeout); + request.removeAbortListener(); + request.reject(new OpfsRpcError("UNAVAILABLE")); + } + pending.clear(); + }, + }); +} + +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 gateway = createOpfsWorkerGateway({ + worker, + 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.js b/src/adapters/telemetry/best-effort-telemetry.ts similarity index 65% rename from src/adapters/telemetry/best-effort-telemetry.js rename to src/adapters/telemetry/best-effort-telemetry.ts index 0b17a13..933aaa3 100644 --- a/src/adapters/telemetry/best-effort-telemetry.js +++ b/src/adapters/telemetry/best-effort-telemetry.ts @@ -1,7 +1,33 @@ -import { projectTelemetryEvent } from "../../contracts/telemetry.js"; -import { queueSizeBucket } from "../../contracts/diagnostics.js"; +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 const noOpTelemetry = Object.freeze({ +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, @@ -11,37 +37,23 @@ export const noOpTelemetry = Object.freeze({ dispose: () => {}, }); -/** - * @param {{ - * enabled: boolean, - * endpoint?: string, - * fetcher?: typeof fetch, - * maxQueue?: number, - * schedule?: (callback: () => void) => void, - * now?: () => number, - * onDrop?: (event: Readonly>) => void, - * lifecycle?: Pick - * }} options - */ -export function createTelemetryAdapter(options) { +export function createTelemetryAdapter( + options: TelemetryAdapterOptions, +): TelemetryAdapter { if (!options.enabled || !options.endpoint) { return noOpTelemetry; } - const endpoint = /** @type {string} */ (options.endpoint); + const endpoint = options.endpoint; const fetcher = options.fetcher ?? fetch; const maxQueue = Math.max(1, options.maxQueue ?? 100); const schedule = options.schedule ?? queueMicrotask; - const queue = - /** @type {Array<{eventName: string, attributes: Readonly>}>} */ ( - [] - ); + const queue: TelemetryEvent[] = []; let scheduled = false; let flushing = false; let dropped = 0; - const dropReasons = new Map(); - let lastDeliveryEvidence = - /** @type {Readonly> | null} */ (null); + const dropReasons = new Map(); + let lastDeliveryEvidence: TelemetryEvent | null = null; const lifecycle = options.lifecycle ?? (typeof globalThis.addEventListener === "function" && @@ -49,8 +61,7 @@ export function createTelemetryAdapter(options) { ? globalThis : undefined); - /** @param {string} reason @param {number} count */ - function recordDrop(reason, count = 1) { + function recordDrop(reason: string, count = 1): void { const safeReason = { "queue-full": "queue-full", @@ -81,8 +92,19 @@ export function createTelemetryAdapter(options) { } } - /** @param {string} eventName @param {Record} attributes */ - function emit(eventName, attributes) { + 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, @@ -99,16 +121,10 @@ export function createTelemetryAdapter(options) { } queue.push(projected.event); - if (!scheduled) { - scheduled = true; - schedule(() => { - scheduled = false; - void flush(); - }); - } + scheduleFlush(); } - async function flush() { + async function flush(): Promise { if (flushing || queue.length === 0) return; flushing = true; const batch = queue.splice(0, queue.length); @@ -124,6 +140,9 @@ export function createTelemetryAdapter(options) { recordDrop("sink-failure", batch.length); } finally { flushing = false; + if (queue.length > 0) { + scheduleFlush(); + } } } @@ -132,7 +151,7 @@ export function createTelemetryAdapter(options) { }; lifecycle?.addEventListener("pagehide", flushBeforePageExit); - function dispose() { + function dispose(): void { lifecycle?.removeEventListener("pagehide", flushBeforePageExit); } @@ -152,9 +171,10 @@ export function createTelemetryAdapter(options) { * Propagates only a structurally valid W3C traceparent. Invalid/raw headers are * discarded rather than logged or surfaced. * - * @param {string | null | undefined} traceparent */ -export function safeTraceparent(traceparent) { +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() 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..e46f5e3 --- /dev/null +++ b/src/adapters/web-push/push-association-fence-store.ts @@ -0,0 +1,690 @@ +import type { + IndexedDbRepositoryPort, + IndexedDbWriteReceipt, +} from "../../application/ports/browser-file-storage/indexeddb-port.ts"; +import type { + BrowserDataFailure, + BrowserDataResult, +} from "../../application/ports/browser-file-storage/shared.ts"; +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; +}>; + +/** + * The generic repository owns IndexedDB connection, migration, transaction, + * timeout, codec and version-change policy. This adapter adds only the + * Web Push authority transition rules on top of its revisioned CAS. + */ +export type PushControlRepository = Pick< + IndexedDbRepositoryPort, + "open" | "read" | "compareAndSwap" | "remove" | "close" +>; + +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: BrowserDataFailure, + 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: IndexedDbWriteReceipt, +): value is IndexedDbWriteReceipt { + 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/application/create-application.ts b/src/application/create-application.ts index 74644cc..2d9b7e1 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -1,12 +1,14 @@ -import { normalizeColorSchemePreference } from "./policies/color-scheme.js"; +import { normalizeColorSchemePreference } from "./policies/color-scheme.ts"; import type { ApplicationApi, + ApplicationFeatureId, + ApplicationFeatureInputs, ColorSchemePreference, RenderFailureReport, RouteChangedReport, -} from "./ports/in/application-api.js"; -import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js"; -import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js"; +} 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 }; @@ -16,7 +18,7 @@ export type { ApplicationApi, ApplicationOutputPorts }; */ export function createApplication( outputPorts: ApplicationOutputPorts, - featureInputs: Readonly> = {}, + featureInputs: Readonly> = {}, ): ApplicationApi { const session = Object.freeze({ getSnapshot: () => outputPorts.session.getState(), @@ -169,14 +171,18 @@ export function createApplication( }); const installedFeatureInputs = Object.freeze({ ...featureInputs }); const features = Object.freeze({ - has(featureId: string) { + has(featureId: string): featureId is ApplicationFeatureId { return Object.hasOwn(installedFeatureInputs, featureId); }, - get(featureId: string) { + get( + featureId: FeatureId, + ): ApplicationFeatureInputs[FeatureId] { if (!Object.hasOwn(installedFeatureInputs, featureId)) { throw new Error(`Application feature is not installed: ${featureId}`); } - return installedFeatureInputs[featureId]; + return installedFeatureInputs[ + featureId + ] as ApplicationFeatureInputs[FeatureId]; }, }); 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.js b/src/application/policies/color-scheme.js deleted file mode 100644 index 97e15d6..0000000 --- a/src/application/policies/color-scheme.js +++ /dev/null @@ -1,21 +0,0 @@ -export const COLOR_SCHEME_PREFERENCES = Object.freeze([ - "system", - "light", - "dark", -]); - -/** @param {unknown} value */ -export function normalizeColorSchemePreference(value) { - return COLOR_SCHEME_PREFERENCES.includes(/** @type {string} */ (value)) - ? /** @type {"system" | "light" | "dark"} */ (value) - : "system"; -} - -/** - * @param {"system" | "light" | "dark"} preference - * @param {boolean} systemPrefersDark - */ -export function resolveColorScheme(preference, systemPrefersDark) { - if (preference === "system") return systemPrefersDark ? "dark" : "light"; - return preference; -} 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.js b/src/application/policies/compatibility.ts similarity index 66% rename from src/application/policies/compatibility.js rename to src/application/policies/compatibility.ts index a304abd..486a954 100644 --- a/src/application/policies/compatibility.js +++ b/src/application/policies/compatibility.ts @@ -4,10 +4,22 @@ export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([ "apiContractVersion", "assetManifestHash", "releaseId", -]); +] as const); -/** @param {string} version */ -export function parseNumericVersion(version) { +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 { @@ -17,8 +29,10 @@ export function parseNumericVersion(version) { }; } -/** @param {string} supported @param {string} actual */ -export function isVersionCompatible(supported, actual) { +export function isVersionCompatible( + supported: string, + actual: string, +): boolean { const expected = parseNumericVersion(supported); const candidate = parseNumericVersion(actual); if (!expected || !candidate) return false; @@ -28,26 +42,11 @@ export function isVersionCompatible(supported, actual) { ); } -/** - * @param {{ - * frontend: { - * buildId: string, - * configSchemaVersion: string, - * apiContractVersion: string, - * assetManifestHash: string, - * releaseId: string - * }, - * runtime: { - * buildId: string, - * configSchemaVersion: string, - * apiContractVersion: string, - * assetManifestHash: string, - * releaseId: string - * } - * }} input - */ -export function verifyCompatibilityTuple(input) { - const mismatches = []; +export function verifyCompatibilityTuple(input: Readonly<{ + frontend: CompatibilityTuple; + runtime: CompatibilityTuple; +}>) { + const mismatches: CompatibilityTupleField[] = []; if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId"); if ( !isVersionCompatible( @@ -69,7 +68,7 @@ export function verifyCompatibilityTuple(input) { mismatches.push("assetManifestHash"); } - const releaseWarning = + const releaseWarning: "releaseId" | null = input.frontend.releaseId === input.runtime.releaseId ? null : "releaseId"; @@ -80,11 +79,17 @@ export function verifyCompatibilityTuple(input) { }); } -/** - * @param {{ required?: string[], properties?: Record }} before - * @param {{ required?: string[], properties?: Record }} after - */ -export function classifyObjectSchemaChange(before, after) { +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( diff --git a/src/application/policies/performance-budgets.js b/src/application/policies/performance-budgets.ts similarity index 50% rename from src/application/policies/performance-budgets.js rename to src/application/policies/performance-budgets.ts index 3c2b411..d6caac3 100644 --- a/src/application/policies/performance-budgets.js +++ b/src/application/policies/performance-budgets.ts @@ -1,11 +1,17 @@ -/** - * @param {{ - * initialJsGzipBytes: number, - * lazyChunks: Array<{ path: string, gzipBytes: number }> - * }} measurements - * @param {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} thresholds - */ -export function evaluateBundleBudget(measurements, thresholds) { +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) => ({ @@ -20,14 +26,19 @@ export function evaluateBundleBudget(measurements, thresholds) { }); } -/** - * @param {{ - * context?: Record, - * metrics: { lcpMs: number, cls: number, namedInteractionMs: number } - * }} report - * @param {{ lcpMs: number, cls: number, namedInteractionMs: number }} thresholds - */ -export function evaluateLabBudget(report, thresholds) { +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", @@ -53,44 +64,55 @@ export function evaluateLabBudget(report, thresholds) { }); } -/** @param {number[]} values */ -export function percentile75(values) { +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]; } -/** - * @param {{ - * metrics: { p75LcpMs: number | null, p75Cls: number | null, p75InpMs: number | null }, - * eligibleSamples: number - * }} report - * @param {{ - * p75LcpMs: number, - * p75Cls: number, - * p75InpMs: number, - * minimumEligibleSamples: number | null - * }} thresholds - */ -export function evaluateFieldBudget(report, thresholds) { +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: /** @type {const} */ ("FAIL_UNVERIFIED"), + status: "FAIL_UNVERIFIED", passed: false, }); } + const metrics = report.metrics as Readonly<{ + p75LcpMs: number; + p75Cls: number; + p75InpMs: number; + }>; const passed = - /** @type {number} */ (report.metrics.p75LcpMs) <= thresholds.p75LcpMs && - /** @type {number} */ (report.metrics.p75Cls) <= thresholds.p75Cls && - /** @type {number} */ (report.metrics.p75InpMs) <= thresholds.p75InpMs; + metrics.p75LcpMs <= thresholds.p75LcpMs && + metrics.p75Cls <= thresholds.p75Cls && + metrics.p75InpMs <= thresholds.p75InpMs; return Object.freeze({ - status: passed - ? /** @type {const} */ ("PASS") - : /** @type {const} */ ("FAIL_THRESHOLD"), + status: passed ? "PASS" : "FAIL_THRESHOLD", passed, }); } diff --git a/src/application/policies/promotion-readiness.js b/src/application/policies/promotion-readiness.ts similarity index 87% rename from src/application/policies/promotion-readiness.js rename to src/application/policies/promotion-readiness.ts index 2a676a2..9dcaf52 100644 --- a/src/application/policies/promotion-readiness.js +++ b/src/application/policies/promotion-readiness.ts @@ -33,10 +33,12 @@ export const PROMOTION_FORMULA = Object.freeze({ DOCUMENTATION_READY: Object.freeze(["FE-GATE-017"]), }); -/** @param {Record} gateResults */ -export function evaluatePromotionReadiness(gateResults) { - /** @param {readonly string[]} gateIds */ - const allPass = (gateIds) => +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); diff --git a/src/application/ports/auth-session-port.js b/src/application/ports/auth-session-port.js deleted file mode 100644 index 14931cf..0000000 --- a/src/application/ports/auth-session-port.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @typedef {"authenticated" | "unauthenticated" | "recovery-pending" | "integration-failed"} SessionState - */ - -/** - * The session is opaque: credentials are attached without exposing tokens. - * - * @typedef {{ - * getState(): SessionState, - * subscribe(listener: () => void): () => void, - * beginSignIn(returnTo?: string): Promise, - * signOut(): Promise, - * recover(): Promise<"restored" | "no-session"> - * }} SessionGateway - */ - -/** - * Credential attachment is an HTTP-adapter collaboration, not an application - * input capability. - * - * @typedef {{ - * attach(request: Request): Promise, - * onUnauthenticated(): void - * }} CredentialAttacher - */ - -/** - * External auth adapters implement both segregated capabilities. - * - * @typedef {SessionGateway & CredentialAttacher} AuthSessionPort - */ - -export {}; 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.js b/src/application/ports/clock-port.js deleted file mode 100644 index 12b6b1b..0000000 --- a/src/application/ports/clock-port.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @typedef {{ - * now(): number, - * sleep(milliseconds: number, signal?: AbortSignal): Promise - * }} ClockPort - */ - -export {}; 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 index 125d5ed..a2b100d 100644 --- a/src/application/ports/diagnostics-port.ts +++ b/src/application/ports/diagnostics-port.ts @@ -1,4 +1,4 @@ -import type { DiagnosticRecordInput } from "../../contracts/diagnostics.js"; +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 index ec13f7a..19d87bc 100644 --- a/src/application/ports/in/application-api.ts +++ b/src/application/ports/in/application-api.ts @@ -1,7 +1,18 @@ -import type { SessionState } from "../auth-session-port.js"; -import type { StoragePort } from "../storage-port.js"; +import type { SessionState } from "../auth-session-port.ts"; +import type { StoragePort } from "../storage-port.ts"; -export type { SessionState } from "../auth-session-port.js"; +export type { SessionState } from "../auth-session-port.ts"; + +/** + * 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"; @@ -54,7 +65,9 @@ export type ApplicationApi = Readonly<{ >; }>; features: Readonly<{ - has(featureId: string): boolean; - get(featureId: string): unknown; + 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 index 5c5028c..e309bfa 100644 --- a/src/application/ports/in/index.ts +++ b/src/application/ports/in/index.ts @@ -1,7 +1,9 @@ export type { ApplicationApi, + ApplicationFeatureId, + ApplicationFeatureInputs, ColorSchemePreference, ReleaseSummary, RenderFailureReport, SessionState, -} from "./application-api.js"; +} from "./application-api.ts"; diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts index 417af46..1737d78 100644 --- a/src/application/ports/out/application-output-ports.ts +++ b/src/application/ports/out/application-output-ports.ts @@ -1,8 +1,8 @@ -import type { AuthSessionPort } from "../auth-session-port.js"; -import type { ReleaseInfoPort } from "../release-info-port.js"; -import type { StoragePort } from "../storage-port.js"; -import type { TelemetryPort } from "../telemetry-port.js"; -import type { DiagnosticsPort } from "../diagnostics-port.js"; +import type { AuthSessionPort } from "../auth-session-port.ts"; +import type { ReleaseInfoPort } from "../release-info-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 diff --git a/src/application/ports/out/index.ts b/src/application/ports/out/index.ts index e83c511..6f107f7 100644 --- a/src/application/ports/out/index.ts +++ b/src/application/ports/out/index.ts @@ -1,12 +1,19 @@ -export type { ApplicationOutputPorts } from "./application-output-ports.js"; +export type { ApplicationOutputPorts } from "./application-output-ports.ts"; export type { AuthSessionPort, CredentialAttacher, SessionGateway, -} from "../auth-session-port.js"; -export type { ClockPort } from "../clock-port.js"; -export type { QueryCachePort } from "../query-cache-port.js"; -export type { ReleaseInfoPort } from "../release-info-port.js"; -export type { StoragePort } from "../storage-port.js"; -export type { TelemetryPort } from "../telemetry-port.js"; -export type { DiagnosticsPort } from "../diagnostics-port.js"; +} 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.js b/src/application/ports/query-cache-port.js deleted file mode 100644 index f06905b..0000000 --- a/src/application/ports/query-cache-port.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @typedef {{ - * read(key: readonly unknown[]): { ok: true, value: unknown } | - * { ok: false, error: import("../../contracts/errors.js").ApiFailure }, - * write(key: readonly unknown[], value: unknown): { ok: true } | - * { ok: false, error: import("../../contracts/errors.js").ApiFailure }, - * invalidate(namespace: readonly unknown[]): Promise<{ ok: true } | - * { ok: false, error: import("../../contracts/errors.js").ApiFailure }> - * }} QueryCachePort - */ - -export {}; 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.js b/src/application/ports/release-info-port.js deleted file mode 100644 index a28a91d..0000000 --- a/src/application/ports/release-info-port.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @typedef {{ - * getCurrent(): Promise<{ - * schemaVersion?: number, - * appVersion?: string, - * buildId: string, - * commitSha?: string, - * configSchemaVersion: string, - * apiContractVersion: string, - * assetManifestHash: string, - * releaseId: string, - * builtAt?: string, - * routeChunks: Record - * }>, - * refresh(): Promise<{ - * buildId: string, - * configSchemaVersion: string, - * apiContractVersion: string, - * assetManifestHash: string, - * releaseId: string, - * routeChunks: Record - * }> - * }} ReleaseInfoPort - */ - -export {}; diff --git a/src/application/ports/release-info-port.ts b/src/application/ports/release-info-port.ts new file mode 100644 index 0000000..6593bdd --- /dev/null +++ b/src/application/ports/release-info-port.ts @@ -0,0 +1,29 @@ +export type ReleaseInfo = Readonly<{ + schemaVersion?: number; + appVersion?: string; + buildId: string; + commitSha?: string; + configSchemaVersion: string; + apiContractVersion: string; + assetManifestHash: string; + releaseId: string; + builtAt?: string; + routeChunks: Readonly>; +}>; + +export type ActiveReleaseInfo = Readonly< + Pick< + ReleaseInfo, + | "buildId" + | "configSchemaVersion" + | "apiContractVersion" + | "assetManifestHash" + | "releaseId" + | "routeChunks" + > +>; + +export type ReleaseInfoPort = Readonly<{ + getCurrent(): Promise; + refresh(): Promise; +}>; diff --git a/src/application/ports/storage-port.js b/src/application/ports/storage-port.js deleted file mode 100644 index e200efd..0000000 --- a/src/application/ports/storage-port.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @typedef {{ - * read(logicalName: string): { ok: true, value: unknown } | - * { ok: false, error: import("../../contracts/errors.js").ApiFailure }, - * write(logicalName: string, value: unknown): { ok: true } | - * { ok: false, error: import("../../contracts/errors.js").ApiFailure, - * fallback?: string }, - * remove(logicalName: string): { ok: true } | - * { ok: false, error: import("../../contracts/errors.js").ApiFailure } - * }} StoragePort - */ - -export {}; 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 index a19d97e..38222a5 100644 --- a/src/application/ports/telemetry-port.ts +++ b/src/application/ports/telemetry-port.ts @@ -1,4 +1,4 @@ -import type { TelemetryEventName } from "../../contracts/telemetry.js"; +import type { TelemetryEventName } from "../../contracts/telemetry.ts"; export type { TelemetryEventName }; 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.js b/src/application/use-cases/decide-chunk-recovery.ts similarity index 67% rename from src/application/use-cases/decide-chunk-recovery.js rename to src/application/use-cases/decide-chunk-recovery.ts index f19201f..dd51469 100644 --- a/src/application/use-cases/decide-chunk-recovery.js +++ b/src/application/use-cases/decide-chunk-recovery.ts @@ -1,23 +1,24 @@ +import type { StoragePort } from "../ports/storage-port.ts"; + const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]); -/** - * @typedef {{action: "reload-once", releasePair: string} | - * {action: "support", reason: string}} ChunkRecoveryDecision - */ +export type ChunkRecoveryDecision = + | Readonly<{ action: "reload-once"; releasePair: string }> + | Readonly<{ action: "support"; reason: string }>; -/** - * @param {{ - * failureKind: string, - * manifestLoaded: boolean, - * currentBuildId: string, - * currentReleaseId: string, - * activeBuildId: string, - * activeReleaseId: string, - * storage: import("../ports/storage-port.js").StoragePort - * }} input - * @returns {ChunkRecoveryDecision} - */ -export function decideChunkRecovery(input) { +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" }; } diff --git a/src/application/view-models/async-state.ts b/src/application/view-models/async-state.ts index 74e689d..17e6ab7 100644 --- a/src/application/view-models/async-state.ts +++ b/src/application/view-models/async-state.ts @@ -1,4 +1,4 @@ -import type { ApiFailure } from "../../contracts/errors.js"; +import type { AppFailure } from "../../contracts/errors.ts"; export const ASYNC_BASE_STATES = Object.freeze([ "initial-loading", @@ -49,7 +49,7 @@ export type AsyncOverlay = export type AsyncSignals = Readonly<{ data?: unknown; isInitialLoading?: boolean; - failure?: ApiFailure; + failure?: AppFailure; isFetching?: boolean; isStale?: boolean; isDegraded?: boolean; @@ -60,7 +60,7 @@ export type AsyncSignals = Readonly<{ export type AsyncState = Readonly<{ base: (typeof ASYNC_BASE_STATES)[number]; data?: unknown; - failure?: ApiFailure; + failure?: AppFailure; overlay: AsyncOverlay; indicator: (typeof ASYNC_OVERLAYS)[number] | null; }>; diff --git a/src/bootstrap/composition-root.js b/src/bootstrap/composition-root.js deleted file mode 100644 index db54934..0000000 --- a/src/bootstrap/composition-root.js +++ /dev/null @@ -1,45 +0,0 @@ -import { createApplication } from "../application/create-application.js"; - -/** - * This is the only module allowed to join concrete adapters to application - * ports. Boot phases are explicit so failures can stop before product mount. - * - * @template Config - * @template Release - * @template {Parameters[0]} OutputPorts - * @template Infrastructure - * @param {{ - * loadConfig(): Promise, - * loadRelease(config: Config): Promise, - * createAdapters(context: { - * config: Config, - * release: Release - * }): Promise<{ - * outputPorts: OutputPorts, - * infrastructure: Infrastructure, - * featureInputs?: Readonly> - * }> - * }} factories - * @returns {Promise - * }>>} - */ -export async function createCompositionRoot(factories) { - 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/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.js b/src/bootstrap/create-runtime-composition.js deleted file mode 100644 index b58f385..0000000 --- a/src/bootstrap/create-runtime-composition.js +++ /dev/null @@ -1,32 +0,0 @@ -import { createCompositionRoot } from "./composition-root.js"; -import { loadReleaseManifest } from "./load-release-manifest.js"; -import { loadRuntimeConfig } from "./load-runtime-config.js"; -import { createRuntimeAdapters } from "./runtime-adapters.js"; - -/** - * @param {{ - * fetcher?: typeof fetch, - * host?: Record - * }} [dependencies] - */ -export function createRuntimeComposition(dependencies = {}) { - return createCompositionRoot({ - loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }), - loadRelease: (runtime) => - loadReleaseManifest( - /** @type {Awaited>} */ (runtime), - { fetcher: dependencies.fetcher }, - ), - createAdapters: ({ config: runtime, release }) => - createRuntimeAdapters({ - runtime: - /** @type {Awaited>} */ (runtime), - release: - /** @type {Awaited>} */ ( - release - ), - fetcher: dependencies.fetcher, - host: dependencies.host, - }), - }); -} diff --git a/src/bootstrap/create-runtime-composition.ts b/src/bootstrap/create-runtime-composition.ts new file mode 100644 index 0000000..3056f45 --- /dev/null +++ b/src/bootstrap/create-runtime-composition.ts @@ -0,0 +1,30 @@ +import { createCompositionRoot } from "./composition-root.ts"; +import { loadReleaseManifest } from "./load-release-manifest.ts"; +import { loadRuntimeConfig } from "./load-runtime-config.ts"; +import { createRuntimeAdapters } from "./runtime-adapters.ts"; + +export type RuntimeCompositionDependencies = Readonly<{ + fetcher?: typeof fetch; + host?: Record; +}>; + +export function createRuntimeComposition( + dependencies: RuntimeCompositionDependencies = {}, +) { + return 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, + }), + }); +} + +export type RuntimeComposition = Awaited< + ReturnType +>; diff --git a/src/bootstrap/initialize-color-scheme.js b/src/bootstrap/initialize-color-scheme.ts similarity index 56% rename from src/bootstrap/initialize-color-scheme.js rename to src/bootstrap/initialize-color-scheme.ts index fd29180..7478170 100644 --- a/src/bootstrap/initialize-color-scheme.js +++ b/src/bootstrap/initialize-color-scheme.ts @@ -1,24 +1,25 @@ import { normalizeColorSchemePreference, resolveColorScheme, -} from "../application/policies/color-scheme.js"; +} from "../application/policies/color-scheme.ts"; +import type { ApplicationApi } from "../application/ports/in/application-api.ts"; -/** - * Applies the persisted public preference before React paints. - * - * @param {Pick} preferences - * @param {{ - * documentElement?: HTMLElement, - * matchMedia?: (query: string) => MediaQueryList - * }} [browser] - */ -export function initializeColorScheme(preferences, browser = {}) { +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) - : () => /** @type {MediaQueryList} */ ({ matches: false })); + : () => ({ matches: false })); const preference = normalizeColorSchemePreference( preferences.getColorScheme(), ); diff --git a/src/bootstrap/load-release-manifest.js b/src/bootstrap/load-release-manifest.ts similarity index 56% rename from src/bootstrap/load-release-manifest.js rename to src/bootstrap/load-release-manifest.ts index e0dd7c0..0f0a50a 100644 --- a/src/bootstrap/load-release-manifest.js +++ b/src/bootstrap/load-release-manifest.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts"; + const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/); export const releaseManifestSchema = z .object({ @@ -16,19 +18,65 @@ export const releaseManifestSchema = z }) .strict(); +export type ReleaseManifest = z.output; +export type ReleaseManifestErrorCode = + | "MANIFEST_BUILD_MISMATCH" + | "MANIFEST_CONFIG_SCHEMA_MISMATCH" + | "MANIFEST_API_CONTRACT_MISMATCH" + | "MANIFEST_RELEASE_MISMATCH" + | "MANIFEST_ASSET_MISMATCH" + | "MANIFEST_FETCH_FAILED" + | "MANIFEST_HTTP_FAILED" + | "MANIFEST_JSON_INVALID" + | "MANIFEST_SCHEMA_INVALID"; +export type ReleaseManifestFailureKind = + | "BUILD_MISMATCH" + | "CONFIG_MISMATCH" + | "API_CONTRACT_MISMATCH" + | "RELEASE_MISMATCH" + | "ASSET_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_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 "RELEASE_MANIFEST_FAILURE"; + } +} + export class ReleaseManifestError extends Error { - /** @param {string} code @param {{buildId: string, releaseId?: string}} safe */ - constructor(code, safe) { + 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 = - { - MANIFEST_BUILD_MISMATCH: "BUILD_MISMATCH", - MANIFEST_CONFIG_SCHEMA_MISMATCH: "CONFIG_MISMATCH", - MANIFEST_API_CONTRACT_MISMATCH: "API_CONTRACT_MISMATCH", - MANIFEST_RELEASE_MISMATCH: "RELEASE_MISMATCH", - MANIFEST_ASSET_MISMATCH: "ASSET_MISMATCH", - }[code] ?? "RELEASE_MANIFEST_FAILURE"; + this.kind = failureKindFor(code); this.code = code; this.safe = Object.freeze({ kind: this.kind, @@ -40,20 +88,22 @@ export class ReleaseManifestError extends Error { } } +export type FetchReleaseManifestOptions = Readonly<{ + fetcher?: typeof fetch; + buildId: string; + releaseId?: string; +}>; + /** * Fetches and validates the active manifest without imposing the current * build tuple. Chunk recovery uses this no-store view to detect a new release. - * - * @param {string} url - * @param {{ - * fetcher?: typeof fetch, - * buildId: string, - * releaseId?: string - * }} options */ -export async function fetchReleaseManifest(url, options) { +export async function fetchReleaseManifest( + url: string, + options: FetchReleaseManifestOptions, +): Promise> { const fetcher = options.fetcher ?? fetch; - let response; + let response: Response; try { response = await fetcher(url, { cache: "no-store", @@ -65,7 +115,7 @@ export async function fetchReleaseManifest(url, options) { if (!response.ok) { throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options); } - let raw; + let raw: unknown; try { raw = await response.json(); } catch { @@ -78,11 +128,15 @@ export async function fetchReleaseManifest(url, options) { return Object.freeze(structuredClone(parsed.data)); } -/** - * @param {Awaited>} runtime - * @param {{fetcher?: typeof fetch, expectedAssetManifestHash?: string}} [options] - */ -export async function loadReleaseManifest(runtime, options = {}) { +export type LoadReleaseManifestOptions = Readonly<{ + fetcher?: typeof fetch; + expectedAssetManifestHash?: string; +}>; + +export async function loadReleaseManifest( + runtime: RuntimeConfigLoadResult, + options: LoadReleaseManifestOptions = {}, +): Promise> { const manifest = await fetchReleaseManifest( runtime.config.RELEASE_MANIFEST_URL, { @@ -91,7 +145,7 @@ export async function loadReleaseManifest(runtime, options = {}) { releaseId: runtime.config.RELEASE_ID, }, ); - let mismatchCode = null; + let mismatchCode: ReleaseManifestErrorCode | null = null; if (manifest.buildId !== runtime.build.buildId) { mismatchCode = "MANIFEST_BUILD_MISMATCH"; } diff --git a/src/bootstrap/load-runtime-config.js b/src/bootstrap/load-runtime-config.ts similarity index 62% rename from src/bootstrap/load-runtime-config.js rename to src/bootstrap/load-runtime-config.ts index 286b2b0..8d31e58 100644 --- a/src/bootstrap/load-runtime-config.js +++ b/src/bootstrap/load-runtime-config.ts @@ -1,15 +1,32 @@ -import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.js"; -import { validateRuntimeConfig } from "./runtime-config-schema.js"; +import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.ts"; +import { + validateRuntimeConfig, + type RuntimeConfig, +} from "./runtime-config-schema.ts"; + +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 { - /** - * @param {string} code - * @param {{ buildId: string, configSchemaVersion?: string, releaseId?: string }} safe - */ - constructor(code, safe) { + 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.kind = "BOOT_CONFIG_FAILURE"; this.code = code; this.safe = Object.freeze({ kind: this.kind, @@ -22,20 +39,31 @@ export class BootConfigError extends Error { } } -/** - * @param {{ - * fetcher?: typeof fetch, - * buildConfig?: ReturnType, - * now?: () => number - * }} [options] - */ -export async function loadRuntimeConfig(options = {}) { +export type RuntimeConfigLoadOptions = Readonly<{ + fetcher?: typeof fetch; + buildConfig?: ReturnType; + now?: () => number; +}>; + +export type RuntimeConfigLoadResult = Readonly<{ + config: RuntimeConfig; + build: ReturnType; + validationDurationMs: number; +}>; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export async function loadRuntimeConfig( + options: RuntimeConfigLoadOptions = {}, +): Promise { const fetcher = options.fetcher ?? fetch; const buildConfig = options.buildConfig ?? getBuildConfig(); const now = options.now ?? performance.now.bind(performance); const startedAt = now(); - let response; + let response: Response; try { response = await fetcher(buildConfig.runtimeConfigUrl, { cache: "no-store", @@ -53,7 +81,7 @@ export async function loadRuntimeConfig(options = {}) { }); } - let rawConfig; + let rawConfig: unknown; try { rawConfig = await response.json(); } catch { @@ -62,7 +90,7 @@ export async function loadRuntimeConfig(options = {}) { }); } - if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) { + if (!isRecord(rawConfig)) { throw new BootConfigError("CONFIG_SHAPE_INVALID", { buildId: buildConfig.buildId, }); @@ -84,7 +112,10 @@ export async function loadRuntimeConfig(options = {}) { typeof rawConfig.CONFIG_SCHEMA_VERSION === "string" ? rawConfig.CONFIG_SCHEMA_VERSION : undefined, - releaseId: typeof rawConfig.RELEASE_ID === "string" ? rawConfig.RELEASE_ID : undefined, + releaseId: + typeof rawConfig.RELEASE_ID === "string" + ? rawConfig.RELEASE_ID + : undefined, }); } diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.tsx similarity index 73% rename from src/bootstrap/main.jsx rename to src/bootstrap/main.tsx index 2a57e4c..5dcf68a 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.tsx @@ -1,13 +1,13 @@ import { createRoot } from "react-dom/client"; -import { recordBootFailure } from "../adapters/diagnostics/bounded-diagnostics.js"; -import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; -import { createRuntimeComposition } from "./create-runtime-composition.js"; -import { initializeColorScheme } from "./initialize-color-scheme.js"; -import { BootConfigError } from "./load-runtime-config.js"; -import { ReleaseManifestError } from "./load-release-manifest.js"; -import { RuntimeApplication } from "./runtime-application.jsx"; +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"); @@ -17,14 +17,17 @@ if (!rootElement) { const root = createRoot(rootElement); -async function boot() { +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(); - } catch (error) { + import.meta.hot?.dispose(() => { + composition.infrastructure.dispose(); + }); + } catch (error: unknown) { const safe = error instanceof BootConfigError || error instanceof ReleaseManifestError ? error.safe diff --git a/src/bootstrap/runtime-adapters.js b/src/bootstrap/runtime-adapters.js deleted file mode 100644 index 454ca4c..0000000 --- a/src/bootstrap/runtime-adapters.js +++ /dev/null @@ -1,167 +0,0 @@ -import { - createDemoSessionAdapter, - createExternalAuthSessionAdapter, - createUnavailableSessionAdapter, -} from "../adapters/auth/external-session-adapter.js"; -import { createHttpClient } from "../adapters/http/client.js"; -import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.js"; -import { - createQueryClient, -} from "../adapters/query-cache/tanstack-query-cache.js"; -import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js"; -import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js"; -import { fetchReleaseManifest } from "./load-release-manifest.js"; -import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.js"; - -/** - * @param {Record} host - * @returns {Parameters[0] | null} - */ -function externalOwnerFrom(host) { - const candidate = host.__CA_FRONTEND_AUTH_OWNER__; - if (!candidate || typeof candidate !== "object") return null; - const owner = /** @type {Record} */ (candidate); - const required = [ - "readState", - "subscribe", - "beginSignIn", - "signOut", - "attachCredential", - "recoverSession", - "notifyUnauthenticated", - ]; - return required.every((name) => typeof owner[name] === "function") - ? /** @type {Parameters[0]} */ ( - candidate - ) - : null; -} - -/** @param {unknown} value */ -function storageOrUndefined(value) { - return typeof Storage !== "undefined" && value instanceof Storage - ? value - : undefined; -} - -/** - * Runtime-aware transport factory. Feature gateway composition calls this - * factory when a registered API capability is installed. - * - * @param {{ - * runtime: Awaited>, - * authSession: import("../application/ports/auth-session-port.js").AuthSessionPort, - * fetcher?: typeof fetch, - * clock?: import("../application/ports/clock-port.js").ClockPort, - * scheduler?: Parameters[0]["scheduler"] - * diagnostics?: Parameters[0]["diagnostics"], - * telemetry?: Parameters[0]["telemetry"] - * }} context - */ -export function createRuntimeHttpClient(context, contract = {}) { - 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, - fetcher: context.fetcher, - clock: context.clock, - scheduler: context.scheduler, - diagnostics: context.diagnostics, - telemetry: context.telemetry, - ...contract, - }); -} - -/** - * @param {{ - * runtime: Awaited>, - * release: Awaited>, - * host?: Record, - * fetcher?: typeof fetch - * }} context - */ -export async function createRuntimeAdapters(context) { - const host = context.host ?? /** @type {Record} */ (globalThis); - const config = context.runtime.config; - 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: /** @type {Record} */ (attributes), - }); - }, - }); - const queryClient = createQueryClient({ diagnostics }); - const storage = createBrowserStorageAdapter({ - localStorage: storageOrUndefined(host.localStorage), - sessionStorage: storageOrUndefined(host.sessionStorage), - diagnostics, - }); - const releaseInfo = Object.freeze({ - async getCurrent() { - return structuredClone(context.release); - }, - async refresh() { - return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, { - fetcher: context.fetcher, - buildId: context.release.buildId, - releaseId: context.release.releaseId, - }); - }, - }); - const navigation = Object.freeze({ - reload() { - const location = - /** @type {{reload?: () => void} | undefined} */ (host.location); - if (typeof location?.reload !== "function") { - throw new Error("Browser reload is unavailable"); - } - location.reload(); - }, - }); - const featureInputs = createInstalledFeatureInputs({ - createHttpClient: (contract) => - createRuntimeHttpClient( - { - runtime: context.runtime, - authSession, - fetcher: context.fetcher, - diagnostics, - telemetry, - }, - contract, - ), - }); - - return Object.freeze({ - outputPorts: Object.freeze({ - session: authSession, - preferences: storage, - diagnostics, - telemetry, - releaseInfo, - navigation, - }), - infrastructure: Object.freeze({ - queryClient, - }), - featureInputs, - }); -} diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts new file mode 100644 index 0000000..d9c5a5d --- /dev/null +++ b/src/bootstrap/runtime-adapters.ts @@ -0,0 +1,268 @@ +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 { 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 { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts"; +import type { AuthSessionPort } from "../application/ports/auth-session-port.ts"; +import { createRestProviderProfile } from "../contracts/rest-profiles.ts"; +import type { ClockPort } from "../application/ports/clock-port.ts"; +import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts"; +import { QUERY_REGISTRY } from "../features/installed-feature-contracts.ts"; +import { + fetchReleaseManifest, + type ReleaseManifest, +} from "./load-release-manifest.ts"; +import type { RuntimeConfigLoadResult } from "./load-runtime-config.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; +}>; + +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 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 queryClient = createQueryClient({ diagnostics }); + const crossContextInvalidation = + createBrowserCrossContextInvalidationFromHost({ + ...(context.host === undefined ? {} : { host: context.host }), + cacheEpoch: `release.${context.release.releaseId}`, + topics: Object.values(QUERY_REGISTRY).map((definition) => + Object.freeze({ + topic: definition.invalidationTopic, + topicVersion: definition.version, + }), + ), + 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, + queryRegistry: QUERY_REGISTRY, + crossContext: crossContextInvalidation, + diagnostics, + }); + const serverStateScope = createServerStateScopeRuntime({ + session: authSession, + queryInvalidation, + }); + const conditionalValidators = createConditionalValidatorStore(); + const unsubscribeConditionalScope = serverStateScope.subscribe(() => { + conditionalValidators.clear(); + }); + const storage = createBrowserStorageAdapter({ + localStorage: storageOrUndefined(hostValue(host, "localStorage")), + sessionStorage: storageOrUndefined( + hostValue(host, "sessionStorage"), + ), + diagnostics, + }); + const releaseInfo = Object.freeze({ + async getCurrent() { + return structuredClone(context.release); + }, + async refresh() { + return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, { + fetcher: context.fetcher, + buildId: context.release.buildId, + releaseId: context.release.releaseId, + }); + }, + }); + 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 featureInputs = createInstalledFeatureInputs({ + createHttpClient: (contract) => + createRuntimeHttpClient( + { + runtime: context.runtime, + authSession, + fetcher: context.fetcher, + diagnostics, + telemetry, + }, + contract, + ), + }); + + return Object.freeze({ + outputPorts: Object.freeze({ + session: authSession, + preferences: storage, + diagnostics, + telemetry, + releaseInfo, + navigation, + }), + infrastructure: Object.freeze({ + queryClient, + queryInvalidation, + serverStateScope, + conditionalValidators, + crossContextInvalidationStatus: () => + crossContextInvalidation?.getStatus() ?? "DEGRADED_LOCAL_ONLY", + dispose() { + unsubscribeConditionalScope(); + conditionalValidators.clear(); + serverStateScope.dispose(); + queryInvalidation.dispose(); + }, + }), + featureInputs, + }); +} diff --git a/src/bootstrap/runtime-application.jsx b/src/bootstrap/runtime-application.jsx deleted file mode 100644 index 20560b8..0000000 --- a/src/bootstrap/runtime-application.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import { StrictMode } from "react"; -import { QueryClientProvider } from "@tanstack/react-query"; - -import { ApplicationProvider } from "../presentation/providers/application-provider.js"; -import { AppRouter } from "../presentation/routes/app-router.jsx"; - -/** - * Production provider tree. Tests import this component so the validated - * composition is proven against the same provider order used by main. - * - * @param {{ - * composition: Awaited> - * }} props - */ -export function RuntimeApplication({ composition }) { - return ( - - - - - - - - ); -} diff --git a/src/bootstrap/runtime-application.tsx b/src/bootstrap/runtime-application.tsx new file mode 100644 index 0000000..452d53b --- /dev/null +++ b/src/bootstrap/runtime-application.tsx @@ -0,0 +1,37 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; + +import { ApplicationProvider } from "../presentation/providers/application-provider.tsx"; +import { QueryInvalidationProvider } from "../presentation/adapters/query/query-invalidation-provider.tsx"; +import { ServerStateScopeProvider } from "../presentation/adapters/query/server-state-scope-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. + */ +export function RuntimeApplication({ + composition, +}: Readonly<{ composition: RuntimeComposition }>) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/bootstrap/runtime-config-schema.js b/src/bootstrap/runtime-config-schema.ts similarity index 77% rename from src/bootstrap/runtime-config-schema.js rename to src/bootstrap/runtime-config-schema.ts index c96431c..921c497 100644 --- a/src/bootstrap/runtime-config-schema.js +++ b/src/bootstrap/runtime-config-schema.ts @@ -35,11 +35,10 @@ export const runtimeConfigSchema = z message: "demo authentication is limited to local environments", }); } - const endpointEntries = - /** @type {Array<[string, string | undefined]>} */ ([ - ["API_BASE_URL", config.API_BASE_URL], - ["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT], - ]); + const endpointEntries = [ + ["API_BASE_URL", config.API_BASE_URL], + ["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT], + ] as const; for (const [key, value] of endpointEntries) { if (value && !local && new URL(value).protocol !== "https:") { @@ -52,13 +51,20 @@ export const runtimeConfigSchema = z } }); -/** @param {unknown} value */ -export function validateRuntimeConfig(value) { +export type RuntimeConfig = z.output; +export type RuntimeConfigValidation = + | Readonly<{ success: true; data: RuntimeConfig }> + | Readonly<{ + success: false; + issues: readonly Readonly<{ path: string; code: string }>[]; + }>; + +export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation { const result = runtimeConfigSchema.safeParse(value); if (!result.success) { return { - success: /** @type {false} */ (false), + success: false, issues: result.error.issues.map((issue) => ({ path: issue.path.join("."), code: issue.code, @@ -67,7 +73,7 @@ export function validateRuntimeConfig(value) { } return { - success: /** @type {true} */ (true), + success: true, data: structuredClone(result.data), }; } diff --git a/src/contracts/api-operations.js b/src/contracts/api-operations.js deleted file mode 100644 index e4196f1..0000000 --- a/src/contracts/api-operations.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @typedef {{ - * 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 - * }} ApiOperation - */ - -export const API_OPERATIONS = Object.freeze({}); - -/** @param {string} operationId */ -export function getApiOperation(operationId, operations = API_OPERATIONS) { - const registry = /** @type {Record} */ (operations); - const selected = registry[operationId]; - if (!selected) { - throw new Error(`Unregistered API operation: ${operationId}`); - } - return selected; -} 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/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 index 1ce571d..bbbeb1b 100644 --- a/src/contracts/diagnostics.ts +++ b/src/contracts/diagnostics.ts @@ -4,6 +4,7 @@ export const DIAGNOSTIC_LEVELS = Object.freeze([ "warn", "error", ] as const); +export { queueSizeBucket } from "./diagnostic-buckets.ts"; export type DiagnosticLevel = (typeof DIAGNOSTIC_LEVELS)[number]; export const DIAGNOSTIC_EVENT_REGISTRY = Object.freeze({ @@ -174,10 +175,3 @@ export function durationBucket(durationMs: number): string { if (durationMs < 2_000) return "500-1999ms"; return "gte2000ms"; } - -export function queueSizeBucket(size: number): string { - if (size <= 0) return "0"; - if (size <= 10) return "1-10"; - if (size <= 50) return "11-50"; - return "51+"; -} diff --git a/src/contracts/env.js b/src/contracts/env.ts similarity index 66% rename from src/contracts/env.js rename to src/contracts/env.ts index 8b94302..84d28a9 100644 --- a/src/contracts/env.js +++ b/src/contracts/env.ts @@ -1,5 +1,13 @@ const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i; +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"), @@ -17,26 +25,25 @@ export const ENV_REGISTRY = Object.freeze({ RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"), }); -/** - * @param {string} classification - * @param {boolean} required - * @param {unknown} defaultValue - */ -function build(classification, required, defaultValue) { +function build( + classification: string, + required: boolean, + defaultValue: unknown, +): EnvironmentDefinition { return Object.freeze({ phase: "build", classification, required, defaultValue }); } -/** - * @param {string} classification - * @param {boolean} required - * @param {unknown} defaultValue - */ -function runtime(classification, required, defaultValue) { +function runtime( + classification: string, + required: boolean, + defaultValue: unknown, +): EnvironmentDefinition { return Object.freeze({ phase: "runtime", classification, required, defaultValue }); } -/** @param {Record} config */ -export function assertSafeConfigNames(config) { +export function assertSafeConfigNames( + config: Readonly>, +): void { for (const name of Object.keys(config)) { if (forbiddenConfigName.test(name)) { throw new Error(`Forbidden client configuration key: ${name}`); @@ -44,7 +51,16 @@ export function assertSafeConfigNames(config) { } } -export function getBuildConfig(environment = import.meta.env) { +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 || "/"; diff --git a/src/contracts/errors.js b/src/contracts/errors.ts similarity index 67% rename from src/contracts/errors.js rename to src/contracts/errors.ts index fb7429d..c8b374d 100644 --- a/src/contracts/errors.js +++ b/src/contracts/errors.ts @@ -7,40 +7,33 @@ const DROP_SENSITIVE = Object.freeze([ "query", "stack", "storageValue", -]); +] as const); -/** - * @typedef {"retry" | "reauth" | "navigate" | "reload-once" | - * "contact-support" | "none"} ErrorAction - */ +export type ErrorAction = + | "retry" + | "reauth" + | "navigate" + | "reload-once" + | "contact-support" + | "none"; -/** - * @typedef {{ - * kind: string, - * defaultRetryable: boolean, - * severity: string, - * userMessageKey: string, - * action: ErrorAction, - * telemetryEvent: string, - * redaction: readonly string[] - * }} ErrorDefinition - */ +export type ErrorDefinition = Readonly<{ + kind: Kind; + defaultRetryable: boolean; + severity: string; + userMessageKey: string; + action: ErrorAction; + telemetryEvent: string; + redaction: readonly string[]; +}>; -/** - * @param {string} kind - * @param {boolean} defaultRetryable - * @param {string} severity - * @param {ErrorAction} action - * @param {string} [telemetryEvent] - * @returns {Readonly} - */ -const row = ( - kind, - defaultRetryable, - severity, - action, +const row = ( + kind: Kind, + defaultRetryable: boolean, + severity: string, + action: ErrorAction, telemetryEvent = "api.request.failed", -) => +): ErrorDefinition => Object.freeze({ kind, defaultRetryable, @@ -62,8 +55,50 @@ export const ERROR_REGISTRY = Object.freeze({ "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", @@ -185,45 +220,54 @@ export const ERROR_REGISTRY = Object.freeze({ }); /** - * @typedef {{ - * kind: string, - * code: string, - * httpStatus?: number, - * retryable: boolean, - * operationId: string, - * attemptCount: number, - * requestId?: string, - * traceId?: string, - * retryAfterMs?: number, - * validationIssues?: readonly Readonly<{path: string, code: string}>[], - * userMessageKey: string, - * action: ErrorAction, - * causeClass?: string - * }} ApiFailure + * 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 AppFailure = Readonly<{ + kind: FailureKind; + code: string; + httpStatus?: number; + retryable: boolean; + operationId: string; + attemptCount: number; + requestId?: string; + traceId?: string; + retryAfterMs?: number; + validationIssues?: readonly ValidationIssue[]; + userMessageKey: string; + action: ErrorAction; + causeClass?: string; +}>; /** - * @param {string} kind - * @param {string} operationId - * @param {number} attempt - * @param {{ - * code?: string, - * httpStatus?: number, - * requestId?: string, - * traceId?: string, - * retryAfterMs?: number, - * validationIssues?: readonly Readonly<{path: string, code: string}>[], - * causeClass?: string - * }} [details] - * @returns {ApiFailure} + * Backward-compatible transport-facing name. New application and presentation + * code should prefer AppFailure. + * */ -export function createFailure(kind, operationId, attempt, details = {}) { - const registry = - /** @type {Readonly>>} */ ( - ERROR_REGISTRY - ); - const definition = - registry[kind] ?? ERROR_REGISTRY.UNKNOWN_FAILURE; +export type ApiFailure = AppFailure; + +export type FailureDetails = Readonly<{ + code?: string; + httpStatus?: number; + requestId?: string; + traceId?: string; + retryAfterMs?: number; + 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, @@ -269,13 +313,15 @@ export function createFailure(kind, operationId, attempt, details = {}) { * allowed to cross the HTTP boundary. Backend copy and additional values are * deliberately discarded. * - * @param {unknown} value - * @returns {readonly Readonly<{path: string, code: string}>[]} */ -export function safeValidationIssues(value) { +export function safeValidationIssues( + value: unknown, +): readonly ValidationIssue[] { if (!value || typeof value !== "object") return Object.freeze([]); - const candidate = - /** @type {{issues?: unknown, fieldErrors?: unknown}} */ (value); + const candidate = value as Readonly<{ + issues?: unknown; + fieldErrors?: unknown; + }>; const issues = Array.isArray(candidate.issues) ? candidate.issues : Array.isArray(candidate.fieldErrors) @@ -284,9 +330,11 @@ export function safeValidationIssues(value) { return Object.freeze( issues .filter( - (issue) => + (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 && @@ -302,8 +350,7 @@ export function safeValidationIssues(value) { ); } -/** @param {number} status */ -export function kindForStatus(status) { +export function kindForStatus(status: number): FailureKind { if (status === 401) return "AUTH_REQUIRED"; if (status === 403) return "FORBIDDEN"; if (status === 404) return "NOT_FOUND"; @@ -318,10 +365,11 @@ export function kindForStatus(status) { /** * Total catch-all that intentionally discards the thrown value. * - * @param {unknown} value - * @param {{ operationId?: string, attempt?: number }} [context] */ -export function normalizeUnknownFailure(value, context = {}) { +export function normalizeUnknownFailure( + value: unknown, + context: Readonly<{ operationId?: string; attempt?: number }> = {}, +): AppFailure { const causeClass = value instanceof Error ? value.name diff --git a/src/contracts/query-invalidation.ts b/src/contracts/query-invalidation.ts new file mode 100644 index 0000000..2569a9b --- /dev/null +++ b/src/contracts/query-invalidation.ts @@ -0,0 +1,45 @@ +import { isCacheInvalidationTopic } from "./cache-invalidation.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; +} + +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.js b/src/contracts/query-keys.js deleted file mode 100644 index 24f4be7..0000000 --- a/src/contracts/query-keys.js +++ /dev/null @@ -1,14 +0,0 @@ -export const QUERY_REGISTRY = Object.freeze({}); - -/** @param {unknown} value @returns {unknown} */ -export function canonicalize(value) { - 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; -} diff --git a/src/contracts/query-keys.ts b/src/contracts/query-keys.ts new file mode 100644 index 0000000..6a15257 --- /dev/null +++ b/src/contracts/query-keys.ts @@ -0,0 +1,234 @@ +export const QUERY_REGISTRY: Readonly> = + Object.freeze({}); + +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-tokens.js b/src/contracts/release-tokens.ts similarity index 72% rename from src/contracts/release-tokens.js rename to src/contracts/release-tokens.ts index 8a20817..1a5c6b8 100644 --- a/src/contracts/release-tokens.js +++ b/src/contracts/release-tokens.ts @@ -1,4 +1,4 @@ -import { verifyCompatibilityTuple } from "../application/policies/compatibility.js"; +import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts"; export const RELEASE_TOKEN_REGISTRY = Object.freeze({ appVersion: token("appVersion", "manifest", "human release label"), @@ -23,12 +23,7 @@ export const RELEASE_TOKEN_REGISTRY = Object.freeze({ builtAt: token("builtAt", "CI", "diagnostics only; never cache identity"), }); -/** - * @param {string} name - * @param {string} source - * @param {string} compatibilityRole - */ -function token(name, source, compatibilityRole) { +function token(name: string, source: string, compatibilityRole: string) { return Object.freeze({ token: name, source, compatibilityRole }); } @@ -37,21 +32,22 @@ function token(name, source, compatibilityRole) { * fields are delegated to the numeric compatibility policy, never compared * lexically. * - * @param {{ - * buildId: string, - * configSchemaVersion: string, - * apiContractVersion: string, - * assetManifestHash: string, - * releaseId: string - * }} release - * @param {{ - * BUILD_ID: string, - * CONFIG_SCHEMA_VERSION: string, - * API_CONTRACT_VERSION: string, - * RELEASE_ID: string - * }} runtimeConfig */ -export function compareReleaseToRuntime(release, runtimeConfig) { +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: { 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.js b/src/contracts/route-runtime-contract.ts similarity index 70% rename from src/contracts/route-runtime-contract.js rename to src/contracts/route-runtime-contract.ts index 6f79606..a76981e 100644 --- a/src/contracts/route-runtime-contract.js +++ b/src/contracts/route-runtime-contract.ts @@ -1,9 +1,14 @@ -/** - * @typedef {"none" | "NotFoundSplat"} RouteCodecId - */ +export type RouteCodecId = "none" | "NotFoundSplat"; +export type RouteRuntimeDefinition = Readonly<{ + routeId: string; + moduleId: string; + paramsCodec: RouteCodecId; + searchCodec: RouteCodecId; +}>; -/** @param {Readonly<{routeId: string, moduleId: string, paramsCodec: RouteCodecId, searchCodec: RouteCodecId}>} value */ -const runtime = (value) => Object.freeze(value); +const runtime = ( + value: Definition, +): Readonly => Object.freeze(value); export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({ APP_HOME: runtime({ diff --git a/src/contracts/routes.js b/src/contracts/routes.ts similarity index 78% rename from src/contracts/routes.js rename to src/contracts/routes.ts index 8dba85e..aae12c8 100644 --- a/src/contracts/routes.js +++ b/src/contracts/routes.ts @@ -1,21 +1,20 @@ -/** - * @typedef {{ - * routeId: string, - * path: string, - * paramsSchema: string | null, - * searchSchema: string | null, - * access: "public" | "session-required" | "integration-defined", - * loadingSurface: string, - * errorSurface: string, - * chunkId: string, - * title: string, - * navigationLabel: string | null, - * navigationOrder: number | null - * }} RouteDefinition - */ +export type RouteDefinition = Readonly<{ + routeId: string; + path: string; + paramsSchema: string | null; + searchSchema: string | null; + access: "public" | "session-required" | "integration-defined"; + loadingSurface: string; + errorSurface: string; + chunkId: string; + title: string; + navigationLabel: string | null; + navigationOrder: number | null; +}>; -/** @param {RouteDefinition} definition */ -const route = (definition) => Object.freeze(definition); +const route = ( + definition: Definition, +): Readonly => Object.freeze(definition); export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ APP_HOME: route({ diff --git a/src/contracts/schema-registry.js b/src/contracts/schema-registry.js deleted file mode 100644 index 459f7b0..0000000 --- a/src/contracts/schema-registry.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @typedef {{ - * schemaId: string, - * boundary: "route-params" | "route-search" | - * "route-search-api-request" | "api-request" | "api-response", - * owner: string, - * runtime: "zod" - * }} SchemaDefinition - */ - -export const PLATFORM_SCHEMA_REGISTRY = - /** @type {Readonly>>} */ ( - 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/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..2d545dc --- /dev/null +++ b/src/contracts/server-state-scope.ts @@ -0,0 +1,14 @@ +import type { RuntimeIdentityRegistry } from "./query-keys.ts"; + +export type CacheScopeSnapshot = Readonly<{ + generation: number; + fingerprint: string; + identities: RuntimeIdentityRegistry; + isCurrent(): boolean; +}>; + +export type ServerStateScopeRuntime = Readonly<{ + getSnapshot(): CacheScopeSnapshot; + subscribe(listener: () => void): () => void; + dispose(): void; +}>; diff --git a/src/contracts/server-state.ts b/src/contracts/server-state.ts new file mode 100644 index 0000000..9750879 --- /dev/null +++ b/src/contracts/server-state.ts @@ -0,0 +1,98 @@ +import type { Result } from "../application/result.ts"; +import type { QueryInvalidationTopic } from "./query-invalidation.ts"; +import type { RuntimeIdentityBinding } from "./query-keys.ts"; +import type { CacheScopeSnapshot } from "./server-state-scope.ts"; + +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 type BoundQuery = Readonly<{ + definitionId: string; + queryKey: readonly unknown[]; + profile: ServerStateProfile; + identity: RuntimeIdentityBinding; + scope: CacheScopeSnapshot; + execute(context: Readonly<{ signal: AbortSignal }>): Promise>; +}>; + +export type QueryDefinition = Readonly<{ + definitionId: string; + definitionVersion: number; + owner: string; + namespace: string; + namespaceVersion: number; + operationId: string; + profile: ServerStateProfile; + execute( + input: Input, + context: Readonly<{ signal: AbortSignal }>, + ): Promise>; +}>; + +export function bindQuery( + definition: QueryDefinition, + input: Input, + scope: CacheScopeSnapshot, +): BoundQuery { + const identity = scope.identities.intern(input); + return Object.freeze({ + definitionId: definition.definitionId, + queryKey: Object.freeze([ + "query", + 1, + scope.fingerprint, + definition.namespace, + definition.namespaceVersion, + definition.definitionVersion, + identity, + ]), + profile: definition.profile, + identity, + scope, + execute: (context) => definition.execute(input, context), + }); +} + +export type BoundMutation = Readonly<{ + definitionId: string; + definitionVersion: number; + operationId: string; + owner: string; + duplicatePolicy: "JOIN_IDENTICAL" | "REJECT_DUPLICATE" | "ALLOW_INDEPENDENT"; + scope: CacheScopeSnapshot; + execute(input: Input): 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/storage-keys.js b/src/contracts/storage-keys.js deleted file mode 100644 index 2e3e2fc..0000000 --- a/src/contracts/storage-keys.js +++ /dev/null @@ -1,102 +0,0 @@ -const APP_NAMESPACE = "ca-frontend"; - -export const STORAGE_REGISTRY = Object.freeze({ - COLOR_SCHEME: defineStorageKey({ - logicalName: "COLOR_SCHEME", - scope: "preference", - name: "color-scheme", - backend: "localStorage", - classification: "public-preference", - schemaVersion: 1, - 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, - 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, - 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, - ttl: null, - migration: "discard", - quotaFallback: "feature-disable", - }), -}); - -/** - * @typedef {{ - * logicalName: string, - * scope: string, - * name: string, - * backend: "memory" | "sessionStorage" | "localStorage" | "indexedDB" | - * "disabled" | "forbidden", - * classification: "public-preference" | "opaque-cache" | "sensitive-forbidden", - * schemaVersion: number, - * ttl: number | "session" | null, - * migration: "discard" | ((value: unknown) => unknown), - * quotaFallback: "memory" | "no-persist" | "feature-disable" - * }} StorageKeyInput - */ - -/** @param {StorageKeyInput} definition */ -export function defineStorageKey(definition) { - if (definition.classification === "sensitive-forbidden") { - if (!["disabled", "forbidden"].includes(definition.backend)) { - throw new Error("Sensitive client storage registration is forbidden"); - } - } - 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, - ), - }); -} - -/** @param {string} scope @param {number} schemaVersion @param {string} name */ -export function buildPhysicalKey(scope, schemaVersion, name) { - return `${APP_NAMESPACE}:${scope}:v${schemaVersion}:${name}`; -} - -/** @param {string} logicalName */ -export function getStorageDefinition(logicalName) { - const registry = /** @type {Record>} */ ( - 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; -} 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.d.ts b/src/contracts/telemetry.d.ts deleted file mode 100644 index 42decb6..0000000 --- a/src/contracts/telemetry.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type TelemetryEventName = - | "app.boot.failed" - | "api.request.failed" - | "ui.render.failed" - | "release.mismatch.detected" - | "telemetry.delivery.dropped"; - -export type TelemetryDefinition = Readonly<{ - eventName: TelemetryEventName; - trigger: string; - requiredAttributes: readonly string[]; - optionalAttributes: readonly string[]; - forbiddenAttributes: readonly string[]; - sampling: string; - delivery: "best-effort"; -}>; - -export type TelemetryEvent = Readonly<{ - eventName: TelemetryEventName; - timestamp: string; - attributes: Readonly>; -}>; - -export const TELEMETRY_ATTRIBUTE_ALLOWLIST: readonly string[]; -export const TELEMETRY_FORBIDDEN_ATTRIBUTES: readonly string[]; -export const TELEMETRY_REGISTRY: Readonly< - Record ->; - -export function projectTelemetryEvent( - eventName: string, - attributes: Record, - now?: () => number, -): - | Readonly<{ success: true; event: TelemetryEvent }> - | Readonly<{ success: false; reason: string }>; diff --git a/src/contracts/telemetry.js b/src/contracts/telemetry.ts similarity index 65% rename from src/contracts/telemetry.js rename to src/contracts/telemetry.ts index df3466c..66ba272 100644 --- a/src/contracts/telemetry.js +++ b/src/contracts/telemetry.ts @@ -15,7 +15,7 @@ export const TELEMETRY_ATTRIBUTE_ALLOWLIST = Object.freeze([ "mismatch_kind", "reason", "queue_size_bucket", -]); +] as const); export const TELEMETRY_FORBIDDEN_ATTRIBUTES = Object.freeze([ "access_token", @@ -31,35 +31,25 @@ export const TELEMETRY_FORBIDDEN_ATTRIBUTES = Object.freeze([ "response_body", "storage_value", "stack_in_user_message", -]); +] as const); -/** - * @typedef {{ - * eventName: string, - * trigger: string, - * requiredAttributes: readonly string[], - * optionalAttributes: readonly string[], - * forbiddenAttributes: readonly string[], - * sampling: string, - * delivery: string - * }} TelemetryDefinition - */ +type TelemetryDefinitionFor = Readonly<{ + eventName: Name; + trigger: string; + requiredAttributes: readonly string[]; + optionalAttributes: readonly string[]; + forbiddenAttributes: readonly string[]; + sampling: string; + delivery: "best-effort"; +}>; -/** - * @param {string} eventName - * @param {string} trigger - * @param {string[]} requiredAttributes - * @param {string[]} [optionalAttributes] - * @param {string} [sampling] - * @returns {Readonly} - */ -const event = ( - eventName, - trigger, - requiredAttributes, - optionalAttributes = [], +const event = ( + eventName: Name, + trigger: string, + requiredAttributes: readonly string[], + optionalAttributes: readonly string[] = [], sampling = "all", -) => +): TelemetryDefinitionFor => Object.freeze({ eventName, trigger, @@ -101,10 +91,22 @@ export const TELEMETRY_REGISTRY = Object.freeze({ ), }); +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 = - /** @type {Readonly boolean>>} */ ( - Object.freeze({ +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), @@ -123,33 +125,34 @@ const ATTRIBUTE_VALUE_POLICIES = ), queue_size_bucket: (value) => /^(?:0|1-10|11-50|51\+)$/.test(value), - }) - ); + }); -/** @param {string} key @param {unknown} value */ -function validAttributeValue(key, 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); } -/** - * @param {string} eventName - * @param {Record} attributes - * @param {() => number} [now] - */ -function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) { - const registry = - /** @type {Record} */ ( - TELEMETRY_REGISTRY - ); - const definition = registry[eventName]; - if (!definition) { +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: /** @type {false} */ (false), + success: false, reason: "unregistered-event", }; } + const definition = TELEMETRY_REGISTRY[eventName]; const attributeKeys = Object.keys(attributes); if ( @@ -158,37 +161,37 @@ function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) { TELEMETRY_FORBIDDEN_ATTRIBUTES.length ) { return { - success: /** @type {false} */ (false), + success: false, reason: "invalid-attribute-value", }; } const unknown = attributeKeys.filter( (key) => - !TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) && - !TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key), + !includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) && + !includesAttribute(TELEMETRY_FORBIDDEN_ATTRIBUTES, key), ); if (unknown.length > 0) { return { - success: /** @type {false} */ (false), + success: false, reason: "unknown-attributes", }; } const projected = Object.fromEntries( Object.entries(attributes).filter( ([key, value]) => - TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) && - !TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key) && + includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) && + !includesAttribute(TELEMETRY_FORBIDDEN_ATTRIBUTES, key) && validAttributeValue(key, value), ), ); const invalid = Object.entries(attributes).filter( ([key, value]) => - TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) && + includesAttribute(TELEMETRY_ATTRIBUTE_ALLOWLIST, key) && !validAttributeValue(key, value), ); if (invalid.length > 0) { return { - success: /** @type {false} */ (false), + success: false, reason: "invalid-attribute-value", }; } @@ -197,19 +200,19 @@ function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) { ); if (missing.length > 0) { return { - success: /** @type {false} */ (false), + success: false, reason: "missing-required-attributes", }; } - let timestamp; + let timestamp: string; try { timestamp = new Date(now()).toISOString(); } catch { timestamp = new Date(0).toISOString(); } return { - success: /** @type {true} */ (true), + success: true, event: Object.freeze({ eventName, timestamp, @@ -218,17 +221,16 @@ function projectTelemetryEventUnsafe(eventName, attributes, now = Date.now) { }; } -/** - * @param {string} eventName - * @param {Record} attributes - * @param {() => number} [now] - */ -export function projectTelemetryEvent(eventName, attributes, now = Date.now) { +export function projectTelemetryEvent( + eventName: string, + attributes: Readonly>, + now: () => number = Date.now, +): TelemetryProjectionResult { try { return projectTelemetryEventUnsafe(eventName, attributes, now); } catch { return { - success: /** @type {false} */ (false), + 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/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index 99de178..ba6c8c9 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -1,15 +1,16 @@ -import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.js"; +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], -): Readonly> { - const installed = [createReferenceFeatureInstalledInput(context)]; - return Object.freeze( - Object.fromEntries( - installed.map((contribution) => [ - contribution.featureId, - contribution.input, - ]), - ), - ); +): InstalledFeatureInputs { + const referenceFeature = createReferenceFeatureInstalledInput(context); + return Object.freeze({ + [referenceFeature.featureId]: referenceFeature.input, + }); } diff --git a/src/features/installed-feature-contracts.js b/src/features/installed-feature-contracts.js deleted file mode 100644 index 5d59893..0000000 --- a/src/features/installed-feature-contracts.js +++ /dev/null @@ -1,53 +0,0 @@ -import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js"; -import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js"; -import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.js"; -import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.js"; - -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 = Object.freeze({ - ...REFERENCE_FEATURE_CONTRACT.apiOperations, -}); -export const QUERY_REGISTRY = Object.freeze({ - ...REFERENCE_FEATURE_CONTRACT.queryRegistry, -}); -export const SCHEMA_REGISTRY = Object.freeze({ - ...PLATFORM_SCHEMA_REGISTRY, - ...REFERENCE_FEATURE_CONTRACT.schemas, -}); - -export const NAVIGATION_ROUTES = Object.freeze( - Object.values(ROUTE_REGISTRY) - .filter((definition) => definition.navigationOrder !== null) - .sort( - (left, right) => - /** @type {number} */ (left.navigationOrder) - - /** @type {number} */ (right.navigationOrder), - ), -); - -/** @param {string} routeId */ -export function getRoute(routeId) { - const registry = - /** @type {Readonly>} */ ( - ROUTE_REGISTRY - ); - const selected = registry[routeId]; - if (!selected) throw new Error(`Unregistered route: ${routeId}`); - return selected; -} - -/** @param {string} routeId */ -export function routePath(routeId) { - return getRoute(routeId).path; -} diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts new file mode 100644 index 0000000..486e4f3 --- /dev/null +++ b/src/features/installed-feature-contracts.ts @@ -0,0 +1,82 @@ +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 QUERY_REGISTRY = Object.freeze({ + ...REFERENCE_FEATURE_CONTRACT.queryRegistry, +}); +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.js b/src/features/installed-feature-messages.ts similarity index 83% rename from src/features/installed-feature-messages.js rename to src/features/installed-feature-messages.ts index 8584a9f..99ddc3f 100644 --- a/src/features/installed-feature-messages.js +++ b/src/features/installed-feature-messages.ts @@ -1,4 +1,4 @@ -import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.js"; +import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts"; export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ "ko-KR": Object.freeze({ @@ -7,4 +7,4 @@ export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ "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 index c545c3b..cc14993 100644 --- a/src/features/installed-feature-runtimes.tsx +++ b/src/features/installed-feature-runtimes.tsx @@ -1,9 +1,9 @@ -import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js"; -import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js"; +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.js"; +} from "./reference-feature/presentation/reference-feature-runtime.tsx"; export const ROUTE_CODECS = Object.freeze({ ...PLATFORM_ROUTE_CODECS, diff --git a/src/features/reference-feature/README.md b/src/features/reference-feature/README.md index 80be3c8..1062d0c 100644 --- a/src/features/reference-feature/README.md +++ b/src/features/reference-feature/README.md @@ -12,13 +12,34 @@ reference implementation이다. - `presentation`: route input을 query/form controller로 연결하는 inbound adapter, 독립 form schema/command mapper와 list/detail/form/status page -generic application은 `features.get(featureId)` catalog만 제공한다. feature hook이 -자신의 input shape를 확인하며 page는 HTTP client, storage, auth owner, output -port나 TanStack API를 직접 import하지 않는다. +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.js` +- 직렬화 계약: `src/features/installed-feature-contracts.ts` - component/codec: `src/features/installed-feature-runtimes.tsx` - bootstrap input 조립: `src/features/installed-feature-adapters.ts` @@ -35,5 +56,13 @@ corepack pnpm test:sample-removal 첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든 query/mutation/form 상태와 production composition을 검증한다. 두 번째 명령은 임시 복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로 -재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke, -production build와 source/built fixture ID 잔여 0개를 검사한다. +재생성한다. 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 index 36a9698..2272025 100644 --- a/src/features/reference-feature/adapters/create-reference-feature-input.ts +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -1,30 +1,45 @@ -import { createReferenceFeatureInput } from "../application/reference-feature-api.js"; +import type { ApiOperation } from "../../../contracts/api-operations.ts"; +import { createReferenceFeatureInput } from "../application/reference-feature-api.ts"; import { REFERENCE_FEATURE_CONTRACT, REFERENCE_FEATURE_ID, -} from "../contracts/reference-feature-contract.js"; -import { mapReferenceOperation } from "../contracts/reference-mapper.js"; +} from "../contracts/reference-feature-contract.ts"; import { - validateReferencePayload, - validateReferenceRequest, -} from "../contracts/reference-schemas.js"; -import { createReferenceHttpGateway } from "./reference-http-gateway.js"; + mapWithBoundaryRegistry, + type MappingResult, +} from "../../../contracts/boundary-mapper.ts"; +import { validateWithRuntimeSchemaRegistry } from "../../../contracts/schema-registry.ts"; +import { + createReferenceHttpGateway, + type RawReferenceHttpExecutor, +} from "./reference-http-gateway.ts"; type HttpContract = Readonly<{ - getOperation(operationId: string): unknown; - validatePayload: typeof validateReferencePayload; - validateRequest: typeof validateReferenceRequest; - mapPayload: typeof mapReferenceOperation; + getOperation(operationId: string): ApiOperation; + validatePayload(schemaId: string, value: unknown): ReturnType< + typeof validateWithRuntimeSchemaRegistry + >; + validateRequest(schemaId: string, value: unknown): ReturnType< + typeof validateWithRuntimeSchemaRegistry + >; + validatePath(schemaId: string, value: unknown): ReturnType< + typeof validateWithRuntimeSchemaRegistry + >; + mapPayload(operationId: string, payload: unknown): MappingResult; }>; -type HttpExecutor = Parameters[0]; +type HttpExecutor = RawReferenceHttpExecutor; export function createReferenceFeatureInstalledInput(context: Readonly<{ createHttpClient(contract: HttpContract): HttpExecutor; }>) { const operations = - REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly>; - const http = context.createHttpClient({ + REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly>; + const schemas = REFERENCE_FEATURE_CONTRACT.runtimeSchemas; + const mappers = REFERENCE_FEATURE_CONTRACT.mappers; + const validate = (schemaId: string, value: unknown) => + validateWithRuntimeSchemaRegistry(schemaId, value, schemas); + const rawHttp = context.createHttpClient({ getOperation(operationId) { const operation = operations[operationId]; if (!operation) { @@ -32,12 +47,19 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{ } return operation; }, - validatePayload: validateReferencePayload, - validateRequest: validateReferenceRequest, - mapPayload: mapReferenceOperation, + validatePayload: validate, + validateRequest: validate, + validatePath: validate, + mapPayload(operationId, payload) { + const operation = operations[operationId]; + if (!operation?.mapperId) { + return { ok: false, code: "MAPPING_INVARIANT_REJECTED" }; + } + return mapWithBoundaryRegistry(operation.mapperId, payload, mappers); + }, }); return Object.freeze({ featureId: REFERENCE_FEATURE_ID, - input: createReferenceFeatureInput(createReferenceHttpGateway(http)), + input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)), }); } diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts index 6063c94..765fb54 100644 --- a/src/features/reference-feature/adapters/reference-http-gateway.ts +++ b/src/features/reference-feature/adapters/reference-http-gateway.ts @@ -1,29 +1,62 @@ -import type { ApiFailure } from "../../../contracts/errors.js"; +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.js"; -import type { ReferenceResource } from "../domain/reference-resource.js"; +} from "../application/reference-feature-api.ts"; +import type { ReferenceResource } from "../domain/reference-resource.ts"; -type HttpResult = - | Readonly<{ ok: true; value: unknown }> - | Readonly<{ ok: false; error: ApiFailure }>; - -type HttpExecutor = Readonly<{ - execute( +type ReferenceOperationMap = Readonly<{ + LIST_REFERENCE_RESOURCES: Readonly<{ request: Readonly<{ - operationId: string; - routeId: string; - pathParams?: Record; - searchParams?: unknown; - body?: unknown; + operationId: "LIST_REFERENCE_RESOURCES"; + routeId: "REFERENCE_RESOURCE_LIST"; + searchParams: ReferenceListFilters; signal?: AbortSignal; - }>, - ): Promise; + }>; + value: readonly ReferenceResource[]; + }>; + CREATE_REFERENCE_RESOURCE: Readonly<{ + request: Readonly<{ + operationId: "CREATE_REFERENCE_RESOURCE"; + routeId: "REFERENCE_RESOURCE_LIST"; + body: ReferenceCreateCommand; + }>; + 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: HttpExecutor, + http: RawReferenceHttpExecutor, ): ReferenceGateway { return Object.freeze({ async list( @@ -36,22 +69,15 @@ export function createReferenceHttpGateway( searchParams: filters, signal: context?.signal, }); - return result.ok - ? { - ok: true as const, - value: result.value as readonly ReferenceResource[], - } - : result; + return projectListResult(result); }, - async create(command: Readonly<{ name: string; note?: string }>) { + async create(command: ReferenceCreateCommand) { const result = await http.execute({ operationId: "CREATE_REFERENCE_RESOURCE", routeId: "REFERENCE_RESOURCE_LIST", body: command, }); - return result.ok - ? { ok: true as const, value: result.value as ReferenceResource } - : result; + return projectResourceResult("CREATE_REFERENCE_RESOURCE", result); }, async get( resourceId: string, @@ -63,9 +89,61 @@ export function createReferenceHttpGateway( pathParams: { resourceId }, signal: context?.signal, }); - return result.ok - ? { ok: true as const, value: result.value as ReferenceResource } - : result; + 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 index 0eade92..a07d957 100644 --- a/src/features/reference-feature/application/reference-feature-api.ts +++ b/src/features/reference-feature/application/reference-feature-api.ts @@ -1,9 +1,10 @@ -import type { ApiFailure } from "../../../contracts/errors.js"; +import type { Result } from "../../../application/result.ts"; +import type {} from "../../../application/ports/in/application-api.ts"; import { toReferenceView, type ReferenceResourceView, -} from "../contracts/reference-mapper.js"; -import type { ReferenceResource } from "../domain/reference-resource.js"; +} from "../contracts/reference-mapper.ts"; +import type { ReferenceResource } from "../domain/reference-resource.ts"; export type ReferenceListFilters = Readonly<{ cursor?: string; @@ -11,9 +12,12 @@ export type ReferenceListFilters = Readonly<{ tags?: readonly string[]; }>; -export type ReferenceResult = - | Readonly<{ ok: true; value: Value }> - | Readonly<{ ok: false; error: ApiFailure }>; +export type ReferenceResult = Result; + +export type ReferenceCreateCommand = Readonly<{ + name: string; + note?: string; +}>; export type ReferenceFeatureInput = Readonly<{ listResources( @@ -21,7 +25,7 @@ export type ReferenceFeatureInput = Readonly<{ context?: Readonly<{ signal?: AbortSignal }>, ): Promise>; createResource( - command: Readonly<{ name: string; note?: string }>, + command: ReferenceCreateCommand, ): Promise>; getResource( resourceId: string, @@ -29,13 +33,19 @@ export type ReferenceFeatureInput = Readonly<{ ): 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: Readonly<{ name: string; note?: string }>, + command: ReferenceCreateCommand, ): Promise>; get( resourceId: string, diff --git a/src/features/reference-feature/contracts/reference-feature-contract.js b/src/features/reference-feature/contracts/reference-feature-contract.ts similarity index 66% rename from src/features/reference-feature/contracts/reference-feature-contract.js rename to src/features/reference-feature/contracts/reference-feature-contract.ts index c808f34..142046e 100644 --- a/src/features/reference-feature/contracts/reference-feature-contract.js +++ b/src/features/reference-feature/contracts/reference-feature-contract.ts @@ -1,55 +1,81 @@ -import { canonicalize } from "../../../contracts/query-keys.js"; +import { canonicalize } 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"; -const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1]); +const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1] as const); +export const REFERENCE_RESOURCE_INVALIDATION_TOPIC = + defineQueryInvalidationTopic("qinv.01k10f7m3w9p6r2c8v5n4x"); export const referenceQueryKeys = Object.freeze({ all: () => REFERENCE_NAMESPACE, - list: (filters = {}) => + list: (filters: Readonly = {}) => Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]), - /** @param {string} resourceId */ - detail: (resourceId) => + + detail: (resourceId: string) => Object.freeze([...REFERENCE_NAMESPACE, "detail", String(resourceId)]), }); 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({ @@ -133,7 +159,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ }), }), apiOperations: Object.freeze({ - LIST_REFERENCE_RESOURCES: Object.freeze({ + LIST_REFERENCE_RESOURCES: defineRestOperation({ method: "GET", path: "/api/reference-resources", operationId: "LIST_REFERENCE_RESOURCES", @@ -145,8 +171,23 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ 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: Object.freeze({ + CREATE_REFERENCE_RESOURCE: defineRestOperation({ method: "POST", path: "/api/reference-resources", operationId: "CREATE_REFERENCE_RESOURCE", @@ -158,8 +199,23 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ 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: Object.freeze({ + GET_REFERENCE_RESOURCE: defineRestOperation({ method: "GET", path: "/api/reference-resources/{resourceId}", operationId: "GET_REFERENCE_RESOURCE", @@ -170,7 +226,22 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ requestSource: "none", requestSchema: "NoRequest", responseSchema: "ReferenceResourcePayload", - owner: "feature-frontend-form-page-platform", + 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, }), }), queryRegistry: Object.freeze({ @@ -179,8 +250,10 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ serialization: "canonical-object-order", identity: "no-pii-token-or-raw-url", invalidation: "reference resource namespace after successful mutation", + invalidationTopic: REFERENCE_RESOURCE_INVALIDATION_TOPIC, + crossContext: "invalidate-only", version: 1, persistence: "disabled", }), }), -}); +} as const); diff --git a/src/features/reference-feature/contracts/reference-mapper.ts b/src/features/reference-feature/contracts/reference-mapper.ts index dd6098e..11b226e 100644 --- a/src/features/reference-feature/contracts/reference-mapper.ts +++ b/src/features/reference-feature/contracts/reference-mapper.ts @@ -1,7 +1,13 @@ import { createReferenceResource, type ReferenceResource, -} from "../domain/reference-resource.js"; +} from "../domain/reference-resource.ts"; +import { + mappingFailure, + mappingSuccess, + type MappingResult, + type InstalledBoundaryMapper, +} from "../../../contracts/boundary-mapper.ts"; export type ReferenceResourceView = Readonly<{ resourceId: string; @@ -10,28 +16,43 @@ export type ReferenceResourceView = Readonly<{ optimistic?: boolean; }>; -function mapReferenceDto(value: unknown): ReferenceResource { +function mapReferenceDto(value: unknown): MappingResult { if (!value || typeof value !== "object") { - throw new TypeError("Validated reference DTO is required"); + return mappingFailure("MAPPING_INVARIANT_REJECTED"); } const dto = value as Record; if (typeof dto.id !== "string" || typeof dto.name !== "string") { - throw new TypeError("Validated reference DTO invariants were breached"); + 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"); } - return createReferenceResource({ - id: dto.id, - displayName: dto.name, - createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null, - }); } export function mapReferenceOperation( operationId: string, payload: unknown, -): ReferenceResource | readonly ReferenceResource[] { +): MappingResult { if (operationId === "LIST_REFERENCE_RESOURCES") { - if (!Array.isArray(payload)) throw new TypeError("Expected a reference list"); - return payload.map(mapReferenceDto); + 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" || @@ -39,9 +60,32 @@ export function mapReferenceOperation( ) { return mapReferenceDto(payload); } - throw new TypeError(`No reference mapper registered for ${operationId}`); + 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 { diff --git a/src/features/reference-feature/contracts/reference-message-catalog.js b/src/features/reference-feature/contracts/reference-message-catalog.ts similarity index 98% rename from src/features/reference-feature/contracts/reference-message-catalog.js rename to src/features/reference-feature/contracts/reference-message-catalog.ts index c8bfa8c..a7880c9 100644 --- a/src/features/reference-feature/contracts/reference-message-catalog.js +++ b/src/features/reference-feature/contracts/reference-message-catalog.ts @@ -19,4 +19,4 @@ export const REFERENCE_MESSAGE_CATALOGS = Object.freeze({ "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 index 9cd7654..2300a49 100644 --- a/src/features/reference-feature/contracts/reference-schemas.ts +++ b/src/features/reference-feature/contracts/reference-schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { RuntimeSchemaCodec } from "../../../contracts/schema-registry.ts"; export const referenceResourceListQuerySchema = z .object({ @@ -26,18 +27,19 @@ export const referenceResourceParamsSchema = z const referenceResourceDtoSchema = z .object({ - id: z.string().min(1), - name: z.string().min(1), + id: z.string().min(1).max(120), + name: z.string().min(1).max(240), createdAt: z.string().datetime().optional(), }) - .strict(); + .strip(); const payloadSchemas = { - ReferenceResourceListPayload: z.array(referenceResourceDtoSchema), + ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100), ReferenceResourcePayload: referenceResourceDtoSchema, } satisfies Record; const requestSchemas = { + ReferenceResourceParams: referenceResourceParamsSchema, ReferenceResourceListQuery: referenceResourceListQuerySchema, NoRequest: z.object({}).strict(), CreateReferenceResourceCommand: z @@ -60,6 +62,40 @@ function project(result: z.ZodSafeParseResult) { }); } +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 diff --git a/src/features/reference-feature/presentation/reference-feature-runtime.tsx b/src/features/reference-feature/presentation/reference-feature-runtime.tsx index 68da388..5e2055c 100644 --- a/src/features/reference-feature/presentation/reference-feature-runtime.tsx +++ b/src/features/reference-feature/presentation/reference-feature-runtime.tsx @@ -3,7 +3,7 @@ import { lazy } from "react"; import { referenceResourceListQuerySchema, referenceResourceParamsSchema, -} from "../contracts/reference-schemas.js"; +} from "../contracts/reference-schemas.ts"; export const REFERENCE_FEATURE_ROUTE_CODECS = { ReferenceResourceListQuery: referenceResourceListQuerySchema, @@ -13,18 +13,18 @@ export const REFERENCE_FEATURE_ROUTE_CODECS = { export const REFERENCE_FEATURE_ROUTE_RUNTIME = { REFERENCE_RESOURCE_LIST: Object.freeze({ moduleId: "reference-resource-page", - Component: lazy(() => import("./reference-resource-page.js")), + Component: lazy(() => import("./reference-resource-page.tsx")), }), REFERENCE_RESOURCE_DETAIL: Object.freeze({ moduleId: "reference-resource-detail-page", - Component: lazy(() => import("./reference-resource-detail-page.js")), + 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.js")), + 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.js")), + 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 index 15104b7..008bd25 100644 --- a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx +++ b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx @@ -3,10 +3,11 @@ import { Link } from "react-router-dom"; import { AsyncSurface, DetailPage, -} from "../../../presentation/design-system/index.js"; -import { useRouteInput } from "../../../presentation/routes/app-router.js"; -import { useLocale } from "../../../presentation/i18n/index.js"; -import { useReferenceDetail } from "./use-reference-feature.js"; +} 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(); @@ -14,6 +15,7 @@ export default function ReferenceResourceDetailPage() { const resourceId = String(route.params.resourceId); const { query } = useReferenceDetail(resourceId); const resource = query.data; + const failureAction = useReferenceFailureAction(query.state.failure); return ( + {resource ? (

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

) : null} diff --git a/src/features/reference-feature/presentation/reference-resource-form-page.tsx b/src/features/reference-feature/presentation/reference-resource-form-page.tsx index 53334a8..2459a75 100644 --- a/src/features/reference-feature/presentation/reference-resource-form-page.tsx +++ b/src/features/reference-feature/presentation/reference-resource-form-page.tsx @@ -11,14 +11,14 @@ import { FormField, useAppForm, useDirtyNavigationGuard, -} from "../../../presentation/design-system/index.js"; +} from "../../../presentation/design-system/index.ts"; import { REFERENCE_FORM_DEFAULTS, referenceResourceFormSchema, toCreateReferenceCommand, type ReferenceResourceFormValues, -} from "./reference-resource-form.js"; -import { useReferenceCreate } from "./use-reference-feature.js"; +} from "./reference-resource-form.ts"; +import { useReferenceCreate } from "./use-reference-feature.ts"; const FIELD_LABELS = Object.freeze({ name: "새 항목 이름", diff --git a/src/features/reference-feature/presentation/reference-resource-page.tsx b/src/features/reference-feature/presentation/reference-resource-page.tsx index 65ed671..d3ac363 100644 --- a/src/features/reference-feature/presentation/reference-resource-page.tsx +++ b/src/features/reference-feature/presentation/reference-resource-page.tsx @@ -4,12 +4,14 @@ import { AsyncSurface, Button, CollectionPage, -} from "../../../presentation/design-system/index.js"; -import { useReferenceFeature } from "./use-reference-feature.js"; +} 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 ( - +
    {(query.data ?? []).map((resource) => (
  • diff --git a/src/features/reference-feature/presentation/reference-resource-status-page.tsx b/src/features/reference-feature/presentation/reference-resource-status-page.tsx index 1a6a61f..b98764a 100644 --- a/src/features/reference-feature/presentation/reference-resource-status-page.tsx +++ b/src/features/reference-feature/presentation/reference-resource-status-page.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { StatusPage } from "../../../presentation/design-system/index.js"; +import { StatusPage } from "../../../presentation/design-system/index.ts"; export default function ReferenceResourceStatusPage() { const navigate = useNavigate(); 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 index e9ce422..75a6c6b 100644 --- a/src/features/reference-feature/presentation/use-reference-feature.ts +++ b/src/features/reference-feature/presentation/use-reference-feature.ts @@ -1,81 +1,115 @@ -import { useApplication } from "../../../presentation/providers/application-provider.js"; +import { useApplication } from "../../../presentation/providers/application-provider.tsx"; import { useApplicationMutation, useApplicationQuery, -} from "../../../presentation/adapters/query/application-query.js"; -import { useRouteInput } from "../../../presentation/routes/app-router.js"; -import type { ReferenceResourceView } from "../contracts/reference-mapper.js"; +} from "../../../presentation/adapters/query/application-query.ts"; +import { useRouteInput } from "../../../presentation/routes/route-input.tsx"; import { REFERENCE_FEATURE_ID, - referenceQueryKeys, -} from "../contracts/reference-feature-contract.js"; + REFERENCE_RESOURCE_INVALIDATION_TOPIC, +} from "../contracts/reference-feature-contract.ts"; import type { + ReferenceCreateCommand, ReferenceFeatureInput, ReferenceListFilters, -} from "../application/reference-feature-api.js"; +} from "../application/reference-feature-api.ts"; +import type { ReferenceResourceView } from "../contracts/reference-mapper.ts"; +import { + bindQuery, + defineServerStateProfile, + type BoundMutation, +} from "../../../contracts/server-state.ts"; +import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx"; + +const REFERENCE_READ_PROFILE = defineServerStateProfile({ + profileId: "reference-resource-read-v1", + staleTimeMs: 30_000, + gcTimeMs: 300_000, + refetchOnMount: true, + refetchOnFocus: true, + refetchOnReconnect: true, + retryOwner: "TRANSPORT", + maxResultItems: 100, + maxEstimatedResultBytes: 262_144, +}); export function useReferenceFeatureInput(): ReferenceFeatureInput { - const candidate = useApplication().features.get(REFERENCE_FEATURE_ID); - if ( - !candidate || - typeof candidate !== "object" || - typeof (candidate as ReferenceFeatureInput).listResources !== "function" || - typeof (candidate as ReferenceFeatureInput).createResource !== "function" || - typeof (candidate as ReferenceFeatureInput).getResource !== "function" - ) { - throw new Error("Reference feature application input is invalid"); - } - return candidate as ReferenceFeatureInput; + return useApplication().features.get(REFERENCE_FEATURE_ID); } export function useReferenceDetail(resourceId: string) { const input = useReferenceFeatureInput(); - const query = useApplicationQuery({ - queryKey: referenceQueryKeys.detail(resourceId), - execute: ({ signal }) => input.getResource(resourceId, { signal }), - }); + const scope = useServerStateScope(); + const query = useApplicationQuery( + bindQuery( + { + definitionId: "reference-resource-detail-v1", + definitionVersion: 1, + owner: REFERENCE_FEATURE_ID, + namespace: "reference-resource", + namespaceVersion: 1, + operationId: "GET_REFERENCE_RESOURCE", + profile: REFERENCE_READ_PROFILE, + execute: (selectedResourceId: string, { signal }) => + input.getResource(selectedResourceId, { signal }), + }, + resourceId, + scope, + ), + ); return Object.freeze({ query }); } export function useReferenceCreate() { const input = useReferenceFeatureInput(); - return useApplicationMutation({ + const scope = useServerStateScope(); + const mutation: BoundMutation< + ReferenceCreateCommand, + ReferenceResourceView + > = { + definitionId: "reference-resource-create-v1", + definitionVersion: 1, + operationId: "CREATE_REFERENCE_RESOURCE", + owner: REFERENCE_FEATURE_ID, + duplicatePolicy: "JOIN_IDENTICAL", + scope, execute: input.createResource, - invalidate: [referenceQueryKeys.all()], - currentData: true, - }); + 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 queryKey = referenceQueryKeys.list(filters); - const query = useApplicationQuery({ - queryKey, - execute: ({ signal }) => input.listResources(filters, { signal }), - }); - const mutation = useApplicationMutation({ - execute: input.createResource, - invalidate: [referenceQueryKeys.all()], - currentData: true, - optimistic: { - queryKey, - update(previous, command: Readonly<{ name: string }>) { - const current = Array.isArray(previous) - ? (previous as readonly ReferenceResourceView[]) - : []; - return [ - ...current, - { - resourceId: `optimistic:${command.name}`, - title: command.name, - createdAt: null, - optimistic: true, - }, - ]; + const query = useApplicationQuery( + bindQuery( + { + definitionId: "reference-resource-list-v1", + definitionVersion: 1, + owner: REFERENCE_FEATURE_ID, + namespace: "reference-resource", + namespaceVersion: 1, + operationId: "LIST_REFERENCE_RESOURCES", + profile: REFERENCE_READ_PROFILE, + execute: (selectedFilters: ReferenceListFilters, { signal }) => + input.listResources(selectedFilters, { signal }), }, - }, + filters, + scope, + ), + ); + const mutation = useApplicationMutation({ + definitionId: "reference-resource-create-v1", + definitionVersion: 1, + operationId: "CREATE_REFERENCE_RESOURCE", + owner: REFERENCE_FEATURE_ID, + duplicatePolicy: "JOIN_IDENTICAL", + 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 index 10173a0..eafab2c 100644 --- a/src/presentation/adapters/query/application-query.ts +++ b/src/presentation/adapters/query/application-query.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, - useRef, + useMemo, useState, } from "react"; import { @@ -13,17 +13,31 @@ import { import { deriveAsyncState, type AsyncState, -} from "../../../application/view-models/async-state.js"; -import type { ApiFailure } from "../../../contracts/errors.js"; +} from "../../../application/view-models/async-state.ts"; +import type { Result } from "../../../application/result.ts"; +import { + createFailure, + normalizeUnknownFailure, + type AppFailure, +} from "../../../contracts/errors.ts"; +import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts"; +import type { + BoundMutation, + BoundQuery, +} from "../../../contracts/server-state.ts"; +import { runtimeIdentityToken } from "../../../contracts/query-keys.ts"; +import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx"; +import { + createOptimisticLayerRuntime, + type OptimisticLayerLease, +} from "./optimistic-layer-runtime.ts"; -export type ApplicationResult = - | Readonly<{ ok: true; value: Value }> - | Readonly<{ ok: false; error: ApiFailure }>; +export type ApplicationResult = Result; class ApplicationQueryError extends Error { - readonly failure: ApiFailure; + readonly failure: AppFailure; - constructor(failure: ApiFailure) { + constructor(failure: AppFailure) { super(failure.kind); this.name = "ApplicationQueryError"; this.failure = failure; @@ -31,31 +45,102 @@ class ApplicationQueryError extends Error { } export function useApplicationQuery( - options: Readonly<{ - queryKey: readonly unknown[]; - execute(context: Readonly<{ signal: AbortSignal }>): Promise< - ApplicationResult - >; - enabled?: boolean; - }>, + 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, enabled = true } = options; + 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 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 }) => { - const result = await execute({ signal }); - if (result.ok) return result.value; - if (signal.aborted || result.error.kind === "REQUEST_ABORTED") { - throw new DOMException("Query cancelled", "AbortError"); + 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 && + !isAdmissibleResult( + result.value, + profile.maxResultItems, + profile.maxEstimatedResultBytes, + ) + ) { + throw new ApplicationQueryError( + createFailure( + "RESULT_LIMIT_EXCEEDED", + "APPLICATION_QUERY", + 0, + { code: "RESULT_ADMISSION_LIMIT_EXCEEDED" }, + ), + ); + } + 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(); } - throw new ApplicationQueryError(result.error); }, }); const hasData = query.data !== undefined && query.data !== null; @@ -91,28 +176,61 @@ export function useApplicationQuery( } export function useApplicationMutation( - options: Readonly<{ - execute(input: Input): Promise>; - invalidate?: readonly (readonly unknown[])[]; - optimistic?: Readonly<{ - queryKey: readonly unknown[]; - update(previous: unknown, input: Input): unknown; - }>; - currentData?: unknown; - }>, + options: + | BoundMutation + | Readonly<{ + execute(input: Input): Promise>; + invalidate?: readonly QueryInvalidationTopic[]; + optimistic?: Readonly<{ + queryKey: readonly unknown[]; + update(previous: unknown, input: Input): unknown; + }>; + currentData?: unknown; + }>, ): Readonly<{ state: AsyncState; submit(input: Input): Promise>; resolveConflict(): Promise; }> { const queryClient = useQueryClient(); - const { execute, invalidate = [], optimistic, currentData } = options; - const [conflict, setConflict] = useState(null); - const inFlight = useRef> | null>(null); + const invalidationCoordinator = useQueryInvalidationCoordinator(); + const { execute } = options; + 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"; + const duplicatePolicy = + "duplicatePolicy" in options ? options.duplicatePolicy : "JOIN_IDENTICAL"; + const [conflict, setConflict] = useState(null); + const scope = "scope" in options ? options.scope : undefined; const mutation = useMutation({ retry: false, mutationFn: async (input) => { + if (scope && !scope.isCurrent()) { + throw new ApplicationQueryError( + createFailure( + "SCOPE_GENERATION_CHANGED", + definitionId, + 0, + { code: "MUTATION_SCOPE_STALE" }, + ), + ); + } const result = await execute(input); + if (scope && !scope.isCurrent()) { + throw new ApplicationQueryError( + createFailure( + "SCOPE_GENERATION_CHANGED", + definitionId, + 0, + { code: "MUTATION_SCOPE_CHANGED" }, + ), + ); + } if (result.ok) return result.value; throw new ApplicationQueryError(result.error); }, @@ -120,55 +238,172 @@ export function useApplicationMutation( const submit = useCallback( (input: Input): Promise> => { - if (inFlight.current) return inFlight.current; + 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" }, + ), + }); + } + 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: normalizeUnknownFailure(error, { + operationId: definitionId, + }), + }); + } + const active = mutationExecutions(queryClient).get(identity) as + | Promise> + | undefined; + if (active && duplicatePolicy === "JOIN_IDENTICAL") { + identityLease?.release(); + return active; + } + if (active && duplicatePolicy === "REJECT_DUPLICATE") { + identityLease?.release(); + return Promise.resolve({ + ok: false, + error: createFailure( + "DUPLICATE_IN_FLIGHT", + definitionId, + 0, + { code: "DUPLICATE_IN_FLIGHT" }, + ), + }); + } setConflict(null); mutation.reset(); - const previous = optimistic - ? queryClient.getQueryData(optimistic.queryKey) - : undefined; - if (optimistic) { - queryClient.setQueryData( - optimistic.queryKey, - optimistic.update(previous, input), - ); - } - - const pending = mutation - .mutateAsync(input) - .then(async (value) => { - for (const queryKey of invalidate) { - await queryClient.invalidateQueries({ queryKey, exact: false }); - } - return { ok: true as const, value }; - }) - .catch((error: unknown) => { + const pending = (async (): Promise> => { + 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 previous: unknown; + let hadPreviousData = false; + let optimisticLayer: OptimisticLayerLease | null = null; if (optimistic) { - queryClient.setQueryData(optimistic.queryKey, previous); + await queryClient.cancelQueries({ + queryKey: optimistic.queryKey, + exact: true, + }); + if (scope) { + optimisticLayer = optimisticLayers(queryClient).begin( + optimistic.queryKey, + input, + optimistic.update, + scope, + ); + } else { + previous = queryClient.getQueryData(optimistic.queryKey); + hadPreviousData = previous !== undefined; + queryClient.setQueryData( + optimistic.queryKey, + optimistic.update(previous, input), + ); + } } - const failure = - error instanceof ApplicationQueryError - ? error.failure - : unexpectedMutationFailure(); + + let value: Value; + try { + value = await mutation.mutateAsync(input); + } catch (error: unknown) { + if (optimistic) { + if (optimisticLayer) { + optimisticLayer.rollback(); + } else if (hadPreviousData) { + queryClient.setQueryData(optimistic.queryKey, previous); + } else { + queryClient.removeQueries({ + queryKey: optimistic.queryKey, + exact: true, + }); + } + } + const failure = + error instanceof ApplicationQueryError + ? error.failure + : normalizeUnknownFailure(error, { + operationId: "APPLICATION_MUTATION", + }); + if (failure.kind === "CONFLICT") setConflict(failure); + return { ok: false, error: failure }; + } + + optimisticLayer?.commit(); + try { + await invalidationCoordinator?.invalidate(invalidate); + } catch { + // Cache refresh remains best effort after the server has committed. + } + return { ok: true, value }; + } finally { + try { + await mutationLease?.release(); + } catch { + // A cache coordination defect cannot change the committed command. + } + } + })() + .catch((error: unknown) => { + const failure = normalizeUnknownFailure(error, { + operationId: "APPLICATION_MUTATION", + }); if (failure.kind === "CONFLICT") setConflict(failure); return { ok: false as const, error: failure }; }) .finally(() => { - inFlight.current = null; + identityLease?.release(); + if (mutationExecutions(queryClient).get(identity) === pending) { + mutationExecutions(queryClient).delete(identity); + } }); - inFlight.current = pending; + if (duplicatePolicy !== "ALLOW_INDEPENDENT") { + mutationExecutions(queryClient).set(identity, pending); + } return pending; }, - [invalidate, mutation, optimistic, queryClient], + [ + invalidate, + invalidationCoordinator, + mutation, + optimistic, + queryClient, + definitionId, + duplicatePolicy, + scope, + ], ); const resolveConflict = useCallback(async () => { setConflict(null); mutation.reset(); - for (const queryKey of invalidate) { - await queryClient.invalidateQueries({ queryKey, exact: false }); + if (invalidate.length > 0 && !invalidationCoordinator) { + throw new Error("Query invalidation coordinator is not installed."); } - }, [invalidate, mutation, queryClient]); + await invalidationCoordinator?.invalidate(invalidate); + }, [invalidate, invalidationCoordinator, mutation]); return Object.freeze({ state: deriveAsyncState({ @@ -181,14 +416,65 @@ export function useApplicationMutation( }); } -function unexpectedMutationFailure(): ApiFailure { - return { - kind: "UNKNOWN_FAILURE", - code: "UNKNOWN_FAILURE", - retryable: false, - operationId: "APPLICATION_MUTATION", - attemptCount: 1, - userMessageKey: "error.unknown_failure", - action: "contact-support", - }; +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; +} + +function isAdmissibleResult( + value: unknown, + maxItems: number, + maxBytes: number, +): boolean { + try { + const seen = new WeakSet(); + let items = 0; + const visit = (candidate: unknown): boolean => { + if (candidate === null || ["string", "number", "boolean"].includes(typeof candidate)) { + return true; + } + if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return false; + seen.add(candidate); + if (Array.isArray(candidate)) { + items += candidate.length; + return items <= maxItems && candidate.every(visit); + } + const prototype = Object.getPrototypeOf(candidate); + return ( + (prototype === Object.prototype || prototype === null) && + Object.values(candidate).every(visit) + ); + }; + return ( + visit(value) && + new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes + ); + } catch { + return false; + } } diff --git a/src/presentation/adapters/query/index.ts b/src/presentation/adapters/query/index.ts index 36a7637..64a1b2f 100644 --- a/src/presentation/adapters/query/index.ts +++ b/src/presentation/adapters/query/index.ts @@ -2,4 +2,8 @@ export { useApplicationMutation, useApplicationQuery, type ApplicationResult, -} from "./application-query.js"; +} from "./application-query.ts"; +export { + QueryInvalidationProvider, + useQueryInvalidationCoordinator, +} from "./query-invalidation-provider.tsx"; 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..2ff0ebb --- /dev/null +++ b/src/presentation/adapters/query/optimistic-layer-runtime.ts @@ -0,0 +1,122 @@ +import { hashKey, type QueryClient } from "@tanstack/react-query"; + +import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts"; + +export type OptimisticLayerLease = Readonly<{ + commit(): void; + rollback(): void; +}>; + +type Layer = { + id: number; + status: "pending" | "committed"; + apply(value: unknown): unknown; +}; + +type EntryState = { + queryKey: readonly unknown[]; + scope: CacheScopeSnapshot; + base: unknown; + layers: Layer[]; +}; + +export function createOptimisticLayerRuntime(queryClient: QueryClient) { + const entries = new Map(); + let nextId = 1; + let 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()) { + entries.delete(key); + queryClient.removeQueries({ queryKey: entry.queryKey, exact: true }); + return; + } + let value = entry.base; + try { + for (const layer of entry.layers) value = layer.apply(value); + } catch { + entries.delete(key); + return; + } + writing = true; + try { + queryClient.setQueryData(entry.queryKey, value); + } finally { + writing = false; + } + } + + 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) entries.delete(key); + } + + return Object.freeze({ + begin( + queryKey: readonly unknown[], + input: Input, + update: (previous: unknown, input: Input) => unknown, + scope: CacheScopeSnapshot, + ): OptimisticLayerLease | null { + if (!scope.isCurrent()) return null; + const current = queryClient.getQueryData(queryKey); + if (current === undefined) return null; + const key = hashKey(queryKey); + let entry = entries.get(key); + if (!entry) { + entry = { queryKey, scope, base: current, layers: [] }; + entries.set(key, entry); + } else if (entry.scope !== scope) { + return null; + } + const layer: Layer = { + id: nextId++, + status: "pending", + apply: (value) => update(value, input), + }; + entry.layers.push(layer); + project(key, entry); + let settled = false; + return Object.freeze({ + commit() { + if (settled) return; + settled = true; + const selected = entry?.layers.find( + (candidate) => candidate.id === layer.id, + ); + if (!entry || !selected) return; + selected.status = "committed"; + collapse(key, entry); + }, + rollback() { + if (settled) return; + settled = true; + if (!entry) return; + entry.layers = entry.layers.filter( + (candidate) => candidate.id !== layer.id, + ); + collapse(key, entry); + }, + }); + }, + }); +} 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-scope-provider.tsx b/src/presentation/adapters/query/server-state-scope-provider.tsx new file mode 100644 index 0000000..e5e5e48 --- /dev/null +++ b/src/presentation/adapters/query/server-state-scope-provider.tsx @@ -0,0 +1,38 @@ +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, +}: Readonly<{ + runtime: ServerStateScopeRuntime; + children: ReactNode; +}>) { + return ( + + {children} + + ); +} + +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.jsx b/src/presentation/boundaries/boot-error-shell.tsx similarity index 80% rename from src/presentation/boundaries/boot-error-shell.jsx rename to src/presentation/boundaries/boot-error-shell.tsx index f97b45a..d3fe5ec 100644 --- a/src/presentation/boundaries/boot-error-shell.jsx +++ b/src/presentation/boundaries/boot-error-shell.tsx @@ -1,15 +1,14 @@ -import { formatMessage } from "../i18n/index.js"; +import { formatMessage } from "../i18n/index.ts"; + +export type BootErrorShellProps = Readonly<{ + kind?: string; + code?: string; + buildId?: string; + configSchemaVersion?: string; + releaseId?: string; + supportReference: string; +}>; -/** - * @param {{ - * kind?: string, - * code?: string, - * buildId?: string, - * configSchemaVersion?: string, - * releaseId?: string, - * supportReference: string - * }} props - */ export function BootErrorShell({ kind = "BOOT_CONFIG_FAILURE", code = "BOOT_FAILED", @@ -17,7 +16,7 @@ export function BootErrorShell({ configSchemaVersion, releaseId, supportReference, -}) { +}: BootErrorShellProps) { return (

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

    diff --git a/src/presentation/boundaries/chunk-recovery-boundary.tsx b/src/presentation/boundaries/chunk-recovery-boundary.tsx index d9f25c9..1ae0bea 100644 --- a/src/presentation/boundaries/chunk-recovery-boundary.tsx +++ b/src/presentation/boundaries/chunk-recovery-boundary.tsx @@ -3,7 +3,7 @@ import { type ErrorInfo, type ReactNode, } from "react"; -import { useLocale } from "../i18n/index.js"; +import { useLocale } from "../i18n/index.ts"; type RecoveryResult = | Readonly<{ action: "reload-once"; releasePair: string }> diff --git a/src/presentation/boundaries/render-error-boundary.jsx b/src/presentation/boundaries/render-error-boundary.jsx deleted file mode 100644 index d795398..0000000 --- a/src/presentation/boundaries/render-error-boundary.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Component } from "react"; -import { formatMessage } from "../i18n/index.js"; - -/** - * @typedef {{ - * children: React.ReactNode, - * boundaryName: string, - * routeId: string, - * buildId: string, - * resetKey?: string, - * onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void, - * fallback?: React.ReactNode - * }} RenderBoundaryProps - * @typedef {{ hasError: boolean }} RenderBoundaryState - */ - -/** @extends {Component} */ -export class RenderErrorBoundary extends Component { - /** @param {RenderBoundaryProps} props */ - constructor(props) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - componentDidCatch() { - try { - this.props.onRenderFailure?.({ - routeId: this.props.routeId, - buildId: this.props.buildId, - boundaryName: - /** @type {"route" | "feature"} */ (this.props.boundaryName), - }); - } catch { - // Diagnostics must never recurse into another render failure. - } - } - - /** @param {RenderBoundaryProps} previous */ - componentDidUpdate(previous) { - if ( - this.state.hasError && - previous.resetKey !== this.props.resetKey - ) { - this.setState({ hasError: false }); - } - } - - reset = () => { - this.setState({ hasError: false }); - }; - - render() { - if (this.state.hasError) { - return ( - this.props.fallback ?? ( -
    -

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

    - -
    - ) - ); - } - return this.props.children; - } -} - -/** @param {Omit} props */ -export function RouteBoundary(props) { - return ; -} - -/** @param {Omit} props */ -export function FeatureBoundary(props) { - return ; -} 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.jsx b/src/presentation/components/async-surface.tsx similarity index 70% rename from src/presentation/components/async-surface.jsx rename to src/presentation/components/async-surface.tsx index 5a739ed..e4486a6 100644 --- a/src/presentation/components/async-surface.jsx +++ b/src/presentation/components/async-surface.tsx @@ -1,11 +1,12 @@ -import { useId } from "react"; +import { useId, type ReactNode } from "react"; -import { errorMessage } from "./error-copy.js"; -import { Button } from "./ui/button.jsx"; -import { useLocale } from "../i18n/index.js"; +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"; -/** @param {{ label?: string }} props */ -export function LoadingSurface({ label }) { +export function LoadingSurface({ label }: Readonly<{ label?: string }>) { const { message } = useLocale(); const accessibleLabel = label ?? message("async.loading"); return ( @@ -22,18 +23,17 @@ export function LoadingSurface({ label }) { ); } -/** - * @param {{ - * title?: string, - * description?: string, - * action?: React.ReactNode - * }} props - */ +export type EmptySurfaceProps = Readonly<{ + title?: string; + description?: string; + action?: ReactNode; +}>; + export function EmptySurface({ title, description, action, -}) { +}: EmptySurfaceProps) { const { message } = useLocale(); return (
    @@ -44,18 +44,22 @@ export function EmptySurface({ ); } -/** - * @param {{ - * userMessageKey: string, - * action: "retry" | "reauth" | "navigate" | "reload-once" | - * "contact-support" | "none", - * onAction?: () => void - * }} props - */ -export function TerminalErrorSurface({ userMessageKey, action, onAction }) { +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 = Object.freeze({ + const actionLabels: Readonly< + Record, string> + > = Object.freeze({ retry: message("action.retry"), reauth: message("action.reauth"), navigate: message("action.navigateSafe"), @@ -77,22 +81,21 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) { ); } -/** - * @param {{ - * state: ReturnType, - * children?: React.ReactNode, - * onAction?: () => void, - * onRetry?: () => void, - * onResolveConflict?: () => void - * }} props - */ +export type AsyncSurfaceProps = Readonly<{ + state: AsyncState; + children?: ReactNode; + onAction?: () => void; + onRetry?: () => void; + onResolveConflict?: () => void; +}>; + export function AsyncSurface({ state, children, onAction, onRetry, onResolveConflict, -}) { +}: AsyncSurfaceProps) { const { message } = useLocale(); if (state.base === "initial-loading") return ; if (state.base === "empty") return ; @@ -101,7 +104,11 @@ export function AsyncSurface({ ); } diff --git a/src/presentation/components/error-copy.js b/src/presentation/components/error-copy.js deleted file mode 100644 index 78cb3f8..0000000 --- a/src/presentation/components/error-copy.js +++ /dev/null @@ -1,6 +0,0 @@ -import { resolveMessage } from "../i18n/index.js"; - -/** @param {string} messageKey @param {string} [locale] */ -export function errorMessage(messageKey, locale = "ko-KR") { - return resolveMessage(locale, messageKey); -} 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.jsx b/src/presentation/components/page-header.jsx deleted file mode 100644 index 1a06fa8..0000000 --- a/src/presentation/components/page-header.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useRef } from "react"; - -/** - * @param {{ - * title: string, - * description?: string, - * eyebrow?: string - * }} props - */ -export function PageHeader({ title, description, eyebrow }) { - const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null)); - - useEffect(() => { - headingRef.current?.focus(); - }, [title]); - - return ( -
    - {eyebrow ?

    {eyebrow}

    : null} -

    - {title} -

    - {description ?

    {description}

    : null} -
    - ); -} 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.jsx b/src/presentation/components/state-surfaces.tsx similarity index 69% rename from src/presentation/components/state-surfaces.jsx rename to src/presentation/components/state-surfaces.tsx index 5395920..d48c546 100644 --- a/src/presentation/components/state-surfaces.jsx +++ b/src/presentation/components/state-surfaces.tsx @@ -1,16 +1,15 @@ -import { Button } from "./ui/button.jsx"; -import { useLocale } from "../i18n/index.js"; +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"; +}>; -/** - * @param {{ - * eyebrow: string, - * title: string, - * description: string, - * actionLabel?: string, - * onAction?: () => void, - * tone?: "neutral" | "danger" | "warning" - * }} props - */ function StateSurface({ eyebrow, title, @@ -18,7 +17,7 @@ function StateSurface({ actionLabel, onAction, tone = "neutral", -}) { +}: StateSurfaceProps) { return (

    {eyebrow}

    @@ -29,8 +28,9 @@ function StateSurface({ ); } -/** @param {{ onSignIn?: () => void }} props */ -export function AuthRequiredSurface({ onSignIn }) { +export function AuthRequiredSurface({ + onSignIn, +}: Readonly<{ onSignIn?: () => void }>) { const { message } = useLocale(); return ( void }} props */ -export function ForbiddenSurface({ onNavigate }) { +export function ForbiddenSurface({ + onNavigate, +}: Readonly<{ onNavigate?: () => void }>) { const { message } = useLocale(); return ( void }} props */ -export function NotFoundSurface({ onNavigate }) { +export function NotFoundSurface({ + onNavigate, +}: Readonly<{ onNavigate?: () => void }>) { const { message } = useLocale(); return ( = Readonly<{ id: string; diff --git a/src/presentation/design-system/primitives/core.tsx b/src/presentation/design-system/primitives/core.tsx index b814be1..d11a09c 100644 --- a/src/presentation/design-system/primitives/core.tsx +++ b/src/presentation/design-system/primitives/core.tsx @@ -7,8 +7,8 @@ import { } from "react"; import { createPortal } from "react-dom"; -import { CloseIcon } from "../icons/semantic-icons.js"; -import { useLocale } from "../../i18n/index.js"; +import { CloseIcon } from "../icons/semantic-icons.tsx"; +import { useLocale } from "../../i18n/index.ts"; export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; export type ButtonSize = "default" | "compact"; @@ -322,6 +322,7 @@ export type DialogProps = Readonly<{ children?: React.ReactNode; actions?: React.ReactNode; className?: string; + returnFocusRef?: React.RefObject; }>; export const Dialog = forwardRef( @@ -335,6 +336,7 @@ export const Dialog = forwardRef( children, actions, className = "", + returnFocusRef, }, forwardedRef, ) { @@ -354,9 +356,10 @@ export const Dialog = forwardRef( if (open) { previousFocusRef.current = - document.activeElement instanceof HTMLElement + returnFocusRef?.current ?? + (document.activeElement instanceof HTMLElement ? document.activeElement - : null; + : null); if (!dialog.open) { if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); @@ -382,12 +385,11 @@ export const Dialog = forwardRef( previousFocus.focus(); } }; - if (typeof globalThis.requestAnimationFrame === "function") { - const frame = globalThis.requestAnimationFrame(restoreFocus); - return () => globalThis.cancelAnimationFrame(frame); - } - queueMicrotask(restoreFocus); - }, [open]); + const timer = globalThis.setTimeout(restoreFocus, 0); + return () => { + globalThis.clearTimeout(timer); + }; + }, [open, returnFocusRef]); return ( ; children: React.ReactNode; }>; @@ -28,6 +29,7 @@ export function Drawer({ title, closeLabel, placement = "start", + returnFocusRef, children, }: DrawerProps) { return ( @@ -36,6 +38,7 @@ export function Drawer({ closeLabel={closeLabel} onClose={onClose} open={open} + returnFocusRef={returnFocusRef} title={title} > {children} diff --git a/src/presentation/examples/auth-example-page.jsx b/src/presentation/examples/auth-example-page.tsx similarity index 92% rename from src/presentation/examples/auth-example-page.jsx rename to src/presentation/examples/auth-example-page.tsx index 6ebbe37..aa7a1b3 100644 --- a/src/presentation/examples/auth-example-page.jsx +++ b/src/presentation/examples/auth-example-page.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; import { useLocation } from "react-router-dom"; -import { PageHeader } from "../design-system/index.js"; -import { useSession } from "../providers/session-provider.jsx"; +import { PageHeader } from "../design-system/index.ts"; +import { useSession } from "../providers/session-provider.tsx"; export default function AuthExamplePage() { const location = useLocation(); @@ -10,8 +10,7 @@ export default function AuthExamplePage() { const [pending, setPending] = useState(false); const [failed, setFailed] = useState(false); - /** @param {() => Promise} action */ - async function execute(action) { + async function execute(action: () => Promise): Promise { setPending(true); setFailed(false); try { diff --git a/src/presentation/examples/state-gallery-page.jsx b/src/presentation/examples/state-gallery-page.tsx similarity index 96% rename from src/presentation/examples/state-gallery-page.jsx rename to src/presentation/examples/state-gallery-page.tsx index 6749db9..0250bc7 100644 --- a/src/presentation/examples/state-gallery-page.jsx +++ b/src/presentation/examples/state-gallery-page.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { deriveAsyncState } from "../../application/view-models/async-state.js"; -import { createFailure } from "../../contracts/errors.js"; +import { deriveAsyncState } from "../../application/view-models/async-state.ts"; +import { createFailure } from "../../contracts/errors.ts"; import { AsyncSurface, EmptySurface, @@ -13,7 +13,7 @@ import { Button, Card, PageHeader, -} from "../design-system/index.js"; +} from "../design-system/index.ts"; export default function StateGalleryPage() { const [lastAction, setLastAction] = useState( diff --git a/src/presentation/examples/ui-gallery-page.jsx b/src/presentation/examples/ui-gallery-page.tsx similarity index 98% rename from src/presentation/examples/ui-gallery-page.jsx rename to src/presentation/examples/ui-gallery-page.tsx index 21ecbba..5f2935f 100644 --- a/src/presentation/examples/ui-gallery-page.jsx +++ b/src/presentation/examples/ui-gallery-page.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { Alert, @@ -19,7 +19,7 @@ import { ToastProvider, Tooltip, useToast, -} from "../design-system/index.js"; +} from "../design-system/index.ts"; const COLOR_TOKENS = Object.freeze([ ["Surface", "--color-surface"], @@ -58,8 +58,7 @@ function UiGalleryContent() { ? "프로젝트 이름을 입력해 주세요." : undefined; - /** @param {React.FormEvent} event */ - function submitExample(event) { + function submitExample(event: FormEvent): void { event.preventDefault(); setFieldTouched(true); if (projectName.trim().length === 0) { diff --git a/src/presentation/forms/form-components.tsx b/src/presentation/forms/form-components.tsx index 3976408..16bc9b8 100644 --- a/src/presentation/forms/form-components.tsx +++ b/src/presentation/forms/form-components.tsx @@ -1,12 +1,12 @@ import { useId, type FormHTMLAttributes, type ReactNode } from "react"; -import { TextField } from "../components/ui/text-field.jsx"; -import { useLocale } from "../i18n/index.js"; +import { TextField } from "../components/ui/text-field.ts"; +import { useLocale } from "../i18n/index.ts"; import type { FieldErrors, FieldName, FormValues, -} from "./form-contracts.js"; +} from "./form-contracts.ts"; export function Form( props: FormHTMLAttributes & Readonly<{ pending?: boolean }>, diff --git a/src/presentation/forms/form-contracts.ts b/src/presentation/forms/form-contracts.ts index 42cde97..0cd2f74 100644 --- a/src/presentation/forms/form-contracts.ts +++ b/src/presentation/forms/form-contracts.ts @@ -1,8 +1,9 @@ -import type { ApiFailure } from "../../contracts/errors.js"; +import type { Result } from "../../application/result.ts"; +import type { AppFailure } from "../../contracts/errors.ts"; import { formatMessage, type ParameterlessMessageKey, -} from "../i18n/index.js"; +} from "../i18n/index.ts"; export type FormValues = Readonly>; export type FieldName = Extract; @@ -10,9 +11,7 @@ export type FieldErrors = Readonly< Partial, string>> >; -export type FormResult = - | Readonly<{ ok: true; value: Value }> - | Readonly<{ ok: false; error: ApiFailure }>; +export type FormResult = Result; export type FormResultState = | "idle" @@ -50,7 +49,7 @@ export function validationMessage( } export function mapValidationFailureToFields( - failure: ApiFailure, + failure: AppFailure, allowedFields: readonly FieldName[], message: MessageResolver = defaultMessage, ): MappedValidationFailure { diff --git a/src/presentation/forms/index.ts b/src/presentation/forms/index.ts index 7ea1e7f..3b0c5ed 100644 --- a/src/presentation/forms/index.ts +++ b/src/presentation/forms/index.ts @@ -1,4 +1,4 @@ -export * from "./form-components.js"; -export * from "./form-contracts.js"; -export * from "./use-app-form.js"; -export * from "./use-dirty-navigation-guard.js"; +export * from "./form-components.tsx"; +export * from "./form-contracts.ts"; +export * from "./use-app-form.ts"; +export * from "./use-dirty-navigation-guard.tsx"; diff --git a/src/presentation/forms/use-app-form.ts b/src/presentation/forms/use-app-form.ts index 9cd55cd..5518e56 100644 --- a/src/presentation/forms/use-app-form.ts +++ b/src/presentation/forms/use-app-form.ts @@ -11,7 +11,7 @@ import type { ZodType, ZodIssue } from "zod"; import { useLocale, type ParameterlessMessageKey, -} from "../i18n/index.js"; +} from "../i18n/index.ts"; import { mapValidationFailureToFields, @@ -21,7 +21,7 @@ import { type FormResult, type FormResultState, type FormValues, -} from "./form-contracts.js"; +} from "./form-contracts.ts"; type AppFormOptions< Values extends FormValues, diff --git a/src/presentation/forms/use-dirty-navigation-guard.tsx b/src/presentation/forms/use-dirty-navigation-guard.tsx index d47938a..8f7bf3d 100644 --- a/src/presentation/forms/use-dirty-navigation-guard.tsx +++ b/src/presentation/forms/use-dirty-navigation-guard.tsx @@ -1,9 +1,9 @@ import { useCallback } from "react"; import { useBeforeUnload, useBlocker } from "react-router-dom"; -import { Button } from "../components/ui/button.jsx"; -import { Dialog } from "../components/ui/dialog.jsx"; -import { useLocale } from "../i18n/index.js"; +import { Button } from "../components/ui/button.ts"; +import { Dialog } from "../components/ui/dialog.ts"; +import { useLocale } from "../i18n/index.ts"; export function useDirtyNavigationGuard(when: boolean) { const blocker = useBlocker(when); diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts index 1487269..c5750f4 100644 --- a/src/presentation/i18n/catalog.ts +++ b/src/presentation/i18n/catalog.ts @@ -1,4 +1,4 @@ -import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.js"; +import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.ts"; const PLATFORM_KO_MESSAGES = { "common.unavailable": "요청한 문구를 표시할 수 없습니다.", diff --git a/src/presentation/i18n/formatters.ts b/src/presentation/i18n/formatters.ts index 8ae0b78..0d9041f 100644 --- a/src/presentation/i18n/formatters.ts +++ b/src/presentation/i18n/formatters.ts @@ -1,4 +1,4 @@ -import { normalizeLocale, type SupportedLocale } from "./message-contract.js"; +import { normalizeLocale, type SupportedLocale } from "./message-contract.ts"; const FORMAT_FALLBACK = "—"; diff --git a/src/presentation/i18n/index.ts b/src/presentation/i18n/index.ts index f94602c..989b4b8 100644 --- a/src/presentation/i18n/index.ts +++ b/src/presentation/i18n/index.ts @@ -1,4 +1,4 @@ -export { LocaleProvider, useLocale } from "./locale-provider.js"; +export { LocaleProvider, useLocale } from "./locale-provider.tsx"; export { catalogKeys, fallbackMessage, @@ -9,15 +9,15 @@ export { normalizeLocale, resolveMessage, SUPPORTED_LOCALES, -} from "./message-contract.js"; +} from "./message-contract.ts"; export type { MessageArguments, MessageParameters, ParameterlessMessageKey, SupportedLocale, TextDirection, -} from "./message-contract.js"; -export type { MessageKey } from "./catalog.js"; +} from "./message-contract.ts"; +export type { MessageKey } from "./catalog.ts"; export { FORMAT_FALLBACK, formatDate, @@ -26,4 +26,4 @@ export { formatRelativeTime, selectMessage, selectPlural, -} from "./formatters.js"; +} from "./formatters.ts"; diff --git a/src/presentation/i18n/locale-provider.tsx b/src/presentation/i18n/locale-provider.tsx index 2162a14..23d2540 100644 --- a/src/presentation/i18n/locale-provider.tsx +++ b/src/presentation/i18n/locale-provider.tsx @@ -13,7 +13,7 @@ import { formatRelativeTime, selectMessage, selectPlural, -} from "./formatters.js"; +} from "./formatters.ts"; import { formatMessage, localeDirection, @@ -22,8 +22,8 @@ import { type MessageArguments, type SupportedLocale, type TextDirection, -} from "./message-contract.js"; -import type { MessageKey } from "./catalog.js"; +} from "./message-contract.ts"; +import type { MessageKey } from "./catalog.ts"; type LocaleContextValue = Readonly<{ locale: SupportedLocale; diff --git a/src/presentation/i18n/message-contract.ts b/src/presentation/i18n/message-contract.ts index 9341247..a5dc294 100644 --- a/src/presentation/i18n/message-contract.ts +++ b/src/presentation/i18n/message-contract.ts @@ -3,7 +3,7 @@ import { KO_MESSAGES, MESSAGE_CATALOGS, type MessageKey, -} from "./catalog.js"; +} from "./catalog.ts"; export const SUPPORTED_LOCALES = Object.freeze([ "ko-KR", diff --git a/src/presentation/layouts/app-shell.jsx b/src/presentation/layouts/app-shell.tsx similarity index 84% rename from src/presentation/layouts/app-shell.jsx rename to src/presentation/layouts/app-shell.tsx index 56c479c..ff7be35 100644 --- a/src/presentation/layouts/app-shell.jsx +++ b/src/presentation/layouts/app-shell.tsx @@ -1,27 +1,33 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; +import type { SessionState } from "../../application/ports/in/application-api.ts"; +import { normalizeColorSchemePreference } from "../../application/policies/color-scheme.ts"; import { NAVIGATION_ROUTES, routePath, -} from "../../features/installed-feature-contracts.js"; +} from "../../features/installed-feature-contracts.ts"; import { Button, Drawer, IconButton, MenuIcon, Select, -} from "../design-system/index.js"; -import { useLocale } from "../i18n/index.js"; -import { useSession } from "../providers/session-provider.jsx"; -import { useTheme } from "../providers/theme-provider.jsx"; +} from "../design-system/index.ts"; +import { + normalizeLocale, + useLocale, + type MessageKey, +} from "../i18n/index.ts"; +import { useSession } from "../providers/session-provider.tsx"; +import { useTheme } from "../providers/theme-provider.tsx"; const SESSION_MESSAGE_KEYS = Object.freeze({ authenticated: "shell.session.authenticated", unauthenticated: "shell.session.unauthenticated", "recovery-pending": "shell.session.recoveryPending", "integration-failed": "shell.session.integrationFailed", -}); +} satisfies Readonly>); export function AppShell() { const location = useLocation(); @@ -29,6 +35,7 @@ export function AppShell() { const { preference, setPreference } = useTheme(); const { locale, setLocale, message } = useLocale(); const [navigationOpen, setNavigationOpen] = useState(false); + const navigationTriggerRef = useRef(null); const [sessionActionPending, setSessionActionPending] = useState(false); const [sessionActionFailed, setSessionActionFailed] = useState(false); @@ -76,6 +83,7 @@ export function AppShell() { aria-controls="mobile-primary-navigation" aria-expanded={navigationOpen} onClick={() => setNavigationOpen((open) => !open)} + ref={navigationTriggerRef} variant="secondary" > @@ -95,11 +103,7 @@ export function AppShell() { ]} value={preference} onChange={(event) => - setPreference( - /** @type {"system" | "light" | "dark"} */ ( - event.currentTarget.value - ), - ) + setPreference(normalizeColorSchemePreference(event.currentTarget.value)) } />