refactor: 프론트 템플릿 리펙토링

This commit is contained in:
donghyeon-ka
2026-09-18 15:16:58 +09:00
parent c10a709f2c
commit 5cc41467ae
80 changed files with 7227 additions and 4672 deletions
+19 -6
View File
@@ -47,21 +47,28 @@ authoritative.
## Architecture
This repository is a **Frontend Application Foundation**: a starter/composition
skeleton plus a reusable capability platform. Platform capabilities stay
horizontal while product business features use vertical slices.
Dependencies point inward:
```text
presentation -> application -> domain
adapters -----^
bootstrap composes concrete adapters
contracts own cross-cutting registries
feature use case -> feature port <- feature-owned capability binding
```
See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and
See
`docs/architecture/frontend-application-foundation.md`,
`docs/architecture/overview.md`, `docs/architecture/layers.md`, and
`docs/architecture/starter-experience.md`. The removable vertical slice is
under `src/features/reference-feature`; its domain, application input, HTTP
adapter, contracts, route runtime, and presentation are installed through the
feature contribution files in `src/features`. The generic starter routes
continue to typecheck, test, and build after that contribution is removed.
binding, contracts, route runtime, and presentation own their contributions.
The central installed catalogs only aggregate selected contributions. The
generic starter routes continue to typecheck, test, and build after that
feature is removed.
### Platform capability review
@@ -103,6 +110,7 @@ corepack pnpm check:types:node
corepack pnpm check:types:test
corepack pnpm check:architecture
corepack pnpm test:all
corepack pnpm test:contract
corepack pnpm test:e2e
corepack pnpm test:a11y
corepack pnpm build
@@ -115,6 +123,11 @@ corepack pnpm drill:runbooks
corepack pnpm check:ci
```
`test:all` is the ordinary product-development loop and intentionally excludes
host-level CI-runner assurance. Run `corepack pnpm test:system` only on the
compatible Linux assurance host described in
[`docs/testing/taxonomy.md`](docs/testing/taxonomy.md).
`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript
project로 모두 검사한다. type/architecture/security/registry의 invalid
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
@@ -153,7 +166,7 @@ Live release verification additionally requires `HOSTING_BASE_URL`.
## CI and evidence
The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is
The 27-gate registry is `config/ci/gates.json`; the Gitea workflow is
`.gitea/workflows/quality-gates.yml`. It follows:
```text
+32
View File
@@ -142,6 +142,11 @@
"script": "test:unit",
"expect": "pass"
},
{
"id": "test-contract",
"script": "test:contract",
"expect": "pass"
},
{
"id": "test-coverage",
"script": "test:coverage",
@@ -164,6 +169,11 @@
"script": "test:integration",
"expect": "pass"
},
{
"id": "test-system",
"script": "test:system",
"expect": "pass"
},
{
"id": "test-http-scenario-evidence",
"script": "test:http-scenario-evidence",
@@ -794,6 +804,15 @@
"test-unit"
]
},
{
"id": "artifact-artifacts-tests-contract-xml",
"path": "artifacts/tests/contract.xml",
"schemaId": "junit",
"production": "command-generated",
"producerCommandIds": [
"test-contract"
]
},
{
"id": "artifact-artifacts-tests-coverage-xml",
"path": "artifacts/tests/coverage.xml",
@@ -860,6 +879,15 @@
"test-integration"
]
},
{
"id": "artifact-artifacts-tests-system-xml",
"path": "artifacts/tests/system.xml",
"schemaId": "junit",
"production": "command-generated",
"producerCommandIds": [
"test-system"
]
},
{
"id": "artifact-artifacts-tests-http-scenario-executions-json",
"path": "artifacts/tests/http-scenario-executions.json",
@@ -1701,12 +1729,14 @@
"name": "unit",
"commandIds": [
"test-unit",
"test-contract",
"test-coverage",
"check-coverage-fixture"
],
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-005-txt",
"evidenceArtifactIds": [
"artifact-artifacts-tests-unit-xml",
"artifact-artifacts-tests-contract-xml",
"artifact-artifacts-tests-coverage-xml",
"artifact-artifacts-tests-coverage-coverage-summary-json",
"artifact-artifacts-quality-risk-coverage-json",
@@ -1871,6 +1901,7 @@
"id": "FE-GATE-013",
"name": "security",
"commandIds": [
"test-system",
"verify-reproducible-build",
"build-release-candidate",
"verify-local-evidence",
@@ -1881,6 +1912,7 @@
],
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-013-txt",
"evidenceArtifactIds": [
"artifact-artifacts-tests-system-xml",
"artifact-artifacts-security-scan-sarif",
"artifact-artifacts-release-dependency-inventory-json",
"artifact-artifacts-release-sbom-cdx-json",
@@ -0,0 +1,127 @@
# Capability consumer experience baseline
Correctness inside a reusable capability is not sufficient. The platform is
also evaluated by how much of that correctness a normal feature developer must
understand.
These measurements are baselines, not score targets. A lower line count is not
automatically better if it hides business semantics or creates a universal
repository abstraction.
## Scenario A — reference REST feature
The existing reference feature is the executable REST consumer.
It covers list/detail/create behavior, mapping, typed operation inputs,
application ports and an optimistic mutation path.
Current source size:
| Feature-owned area | LOC |
| --- | ---: |
| `application/reference-feature-api.ts` | 91 |
| `contracts/reference-mapper.ts` | 109 |
| `adapters/reference-http-gateway.ts` | 65 |
| `adapters/create-reference-feature-input.ts` | 41 |
| Total measured boundary/application source | 306 |
The important boundary metric is not the raw total. It is what those files need
to know about the platform.
Current result:
- HTTP platform imports in the feature adapter layer: 2 files,
- canonical import path used by both:
`src/adapters/http/index.ts`,
- direct imports of `http-execution-v3.ts`, retry scheduler, response reader,
auth admission or effect-certainty internals: 0,
- feature gateway owns transport failure projection: 0,
- feature gateway chooses operation ID, route ID, exact input/value type and
mapper: yes,
- central installed files own feature-specific adapter/runtime wiring: no;
feature-owned contributions are aggregated centrally.
The reusable HTTP capability now owns execution-outcome normalization through
`createFeatureHttpBinding`. A feature may still implement a custom outbound
adapter behind its application port when the reusable contract does not match
its business requirement.
## Scenario B — IndexedDB local draft
An executable consumer probe lives at:
`tests/contract/consumer-experience/indexeddb-local-draft.test.ts`
with the feature-owned fixture:
`tests/contract/consumer-experience/fixtures/local-draft-feature.ts`
The fixture models a small local-draft feature with save/find/remove and
optimistic revision checking.
Measured result:
| Metric | Result |
| --- | ---: |
| feature-owned fixture LOC | 97 |
| platform import statements | 1 |
| native IndexedDB API references | 0 |
| `src/adapters/storage/indexeddb/**` imports | 0 |
| runtime-internal IndexedDB types imported by feature | 0 |
The feature depends only on the public application boundary:
`src/application/ports/browser-file-storage/index.ts`
and specifically `IndexedDbRepositoryPort<LocalDraft, never>`.
The contract test rejects feature source that reaches for
`globalThis.indexedDB`, `IDBFactory`, `IDBDatabase`, `IDBTransaction`,
`IDBObjectStore` or the concrete IndexedDB adapter directory.
## What this does and does not prove
The probe confirms that **feature business code does not need native IndexedDB
knowledge** once an `IndexedDbRepositoryPort` has been composed.
It does not prove that composition of `createIndexedDbRuntime` is cheap.
That constructor still owns substantial infrastructure policy:
- dataset scope and storage policy,
- physical store governance,
- retention/idempotency stores,
- schema migrations,
- codec and query policy,
- lifecycle authority,
- durability, scheduling and observation.
That complexity belongs at the composition/platform boundary, not in the
feature. A new `create...Repository<T>` convenience factory should be added
only when a second real product consumer demonstrates which subset is stable
enough to become a reusable profile. Creating one now would guess at policy and
risk producing a universal storage abstraction.
## Consumer-quality review checklist
For each new product feature, record:
- feature-owned adapter LOC,
- platform glue LOC,
- files changed,
- central catalog edits,
- direct imports from capability-internal modules,
- native browser/network API references,
- duplicated failure/retry/lifecycle policy.
A healthy feature path should look like:
1. domain type and invariant,
2. use case,
3. business port,
4. feature-owned mapper/codec and policy,
5. capability-specific binding,
6. presentation controller/page.
The feature should not need the retry scheduler, abort ownership,
effect-certainty machinery, IndexedDB transaction lifecycle, OPFS journal,
reconnect coordinator or provider process model.
+110
View File
@@ -0,0 +1,110 @@
# Contract ownership
`src/contracts` is not a default destination for every shared-looking type.
A contract belongs there only when the change authority is genuinely shared
across layers, capabilities, build tooling, or runtime composition.
## Decision rule
For every proposed contract, ask:
1. Which requirement can cause this type or policy to change?
2. Is there one clear capability or feature owner?
3. Does another runtime/tooling boundary consume the same semantic contract?
4. Is the contract a wire/artifact authority shared by browser runtime and
build/release tooling?
The placement rule is:
- one feature owner -> keep it under that feature,
- one reusable capability owner -> keep it under that adapter/capability and
export it through the capability public entry point,
- multiple independent capability/layer owners -> `src/contracts`,
- browser/build/release wire or artifact authority -> `src/contracts` even
when the browser source graph alone looks small.
Consumer count alone is not sufficient. Scripts, generated artifacts and
release gates are semantic consumers too.
## Audit result
The September 2026 architecture review triggered an import-graph audit of all
39 contract files.
### Capability-owned contract moved
`cursor-pagination.ts` had one production owner:
`src/adapters/query-cache/cursor-pagination-runtime.ts`.
It moved to:
`src/adapters/query-cache/cursor-pagination-contract.ts`
and is exported through:
`src/adapters/query-cache/index.ts`
Tests use that public capability entry point. Pagination vocabulary no longer
occupies the global contract bucket merely because it is reusable inside one
adapter.
### Contracts intentionally kept global
The following examples have multiple semantic owners and remain global:
- `errors.ts` — application, presentation and several adapters,
- `result.ts` — common success/failure carrier below application,
- `boundary-mapper.ts` — HTTP, browser RPC, realtime and feature registries,
- `mutation-intent.ts` — application, presentation, HTTP, platform and
bootstrap,
- `exact-snapshot.ts` — HTTP, query-cache and browser-transfer,
- `rest-profiles.ts` — auth, HTTP, bootstrap and feature contract validation,
- `query-invalidation.ts` / `query-keys.ts` — query-cache, presentation,
bootstrap and features,
- `cache-invalidation.ts` — cross-context wire protocol plus query
invalidation policy,
- `storage-keys.ts` — browser storage and cross-context invalidation,
- `telemetry.ts` / `diagnostics.ts` — runtime adapters, application and
bootstrap.
Some contracts appear to have few browser-source consumers but are still shared
authorities:
- `env.ts` is consumed by bootstrap, runtime-schema/security tests and
registry governance,
- `deployment-admission.ts` is shared by runtime-config generation and release
admission,
- `release-tokens.ts` is shared by runtime coherence tooling and tests,
- `service-worker-static-manifest.ts` is a runtime-neutral canonical format
shared by build generation, validation and service-worker evidence.
Moving those based only on `src/**` import counts would split one semantic
authority across processes.
## Feature contracts
Feature-specific contracts stay inside the vertical slice:
`features/<feature>/contracts`
The reference feature owns routes, schemas, mapper definitions, message
catalogs and contribution identities. Central installed files aggregate those
contributions; they do not own their semantics.
The message catalog is a deliberate special case: the compiled catalog remains
total even when a feature is build-time disabled so the typed message lookup
does not become partial. The central message file therefore aggregates compiled
message keys rather than treating runtime installation as message ownership.
## Review rule for future additions
A new file under `src/contracts` should be rejected during review when all of
the following are true:
- one feature or one capability is the only semantic owner,
- no build/release/runtime wire authority needs the same definition,
- moving the definition to that owner does not create an inward dependency
violation.
Do not move a contract merely to reduce the number 39. The goal is explicit
ownership, not a smaller directory.
@@ -0,0 +1,219 @@
# Frontend Application Foundation
This repository is not treated as a minimal React project template and it is not
an independent general-purpose SDK. Its architectural role is:
> Frontend Application Foundation = Starter / Composition Skeleton + Reusable Capability Platform
The starter side owns bootstrap, routing, providers, project conventions and a
removable reference feature. The capability side owns reusable technical
problems such as HTTP execution, Server State, authentication boundaries,
IndexedDB/OPFS, Cache Storage, realtime, browser RPC, transfer and diagnostics.
The cost of a sophisticated capability is acceptable only when product features
do not have to understand that internal sophistication.
## Hybrid architecture
Platform code is horizontal:
- `application`: generic application inputs/policies/ports
- `contracts`: genuinely cross-capability shared vocabulary and registries
- `adapters`: reusable capability runtimes
- `presentation`: generic UI/routing/query integration
- `bootstrap`: concrete composition
Product business code is vertical:
- `features/<feature>/domain`
- `features/<feature>/application`
- `features/<feature>/contracts`
- `features/<feature>/adapters`
- `features/<feature>/presentation`
The dependency model is:
Domain / Use case
|
| owns
v
Business port
^
| implements / binds
|
Feature-owned adapter binding
|
| generic type + mapper/codec + policy
v
Reusable capability runtime
|
v
Browser / network / native API
A use case never imports a platform adapter. Generic binding happens in the
feature adapter/composition seam.
## Capability-specific typed bindings
Do not introduce one universal `Repository<TKey, TValue>` abstraction for HTTP,
storage, realtime and transfer. Their lifecycle and failure semantics differ.
A reusable capability boundary is composed from:
- generic input/output types,
- feature-owned mapper or codec,
- feature-selected policy,
- one capability-specific runtime.
The HTTP reference path is the first concrete example.
`src/adapters/http/feature-http-binding.ts` owns transport/outcome
normalization. The reference feature contributes only:
- operation ID,
- route ID,
- exact request input type,
- exact success value type,
- wire-to-domain mapper.
The feature gateway therefore does not reimplement timeout, cancellation,
transport failure, authentication failure or contract-violation projection.
Storage, realtime and transfer may gain their own typed binders only after
actual feature repetition demonstrates the need. They must not be forced
through the HTTP abstraction.
## Custom adapter escape hatch
A product feature uses a reusable capability when the capability preserves the
business requirement.
If a platform contract would require changing or weakening the business model,
the feature implements a custom outbound adapter behind the same application
port. The architecture boundary remains stable; platform reuse is optional.
## Feature installation
A feature owns its contract, runtime and adapter contributions.
Central installed catalogs are aggregation points only:
- `installed-product-manifest.ts`: which product features are compiled/selected
- `installed-feature-contracts.ts`: contract aggregation
- `installed-feature-runtimes.tsx`: runtime contribution aggregation
- `installed-feature-adapters.ts`: application-input contribution aggregation
Feature-specific composition belongs under the feature itself. Central
catalogs must not grow feature-specific branching logic.
## Presentation consumer surface
`ApplicationProvider` remains the composition root for presentation, but new
consumers should not navigate a root `ApplicationApi` service locator.
Use the narrow hooks in
`src/presentation/providers/application-provider.tsx`:
- `useApplicationSession`
- `useApplicationPreferences`
- `useApplicationDiagnostics`
- `useApplicationRuntime`
- `useApplicationRecovery`
- `useApplicationFeature`
`useApplication` exists only as a deprecated compatibility escape hatch.
## Canonical imports
New feature code should use capability public entry points rather than deep
runtime modules. For HTTP the canonical path is
`src/adapters/http/index.ts`.
Compatibility re-exports may exist during a migration window, but they must be
marked as compatibility/deprecated paths and should not expand into an
unbounded public barrel.
## Policy ownership
Duplication is judged by ownership, not by syntax percentage.
Small local validators can remain duplicated when locality improves auditing.
Business or concurrency policy must have one owner. For example the reference
create mutation keeps definition ID, idempotency requirement, duplicate policy
and invalidation policy in one feature-owned definition and binds only
`scope` and `execute` per usage site.
## Runtime decomposition rule
Large runtime files are not split by line count.
Extract a boundary when it has its own state machine, lifecycle owner, failure
model or compensation/recovery responsibility. Candidate seams include:
- connection/open/upgrade lifecycle,
- transaction ownership,
- migration state machine,
- reconnect/backoff and heartbeat,
- subscription ownership,
- retry/deadline/cancellation ownership,
- settlement/reconciliation/cleanup.
A cohesive 2,000-line state machine can remain together. A 300-line file with
multiple lifecycle owners is a better extraction candidate.
### Current runtime boundary audit
The current large-runtime inventory was reviewed using that rule.
- IndexedDB remains large, but connection, transaction, migration, maintenance
and failure translation already have separate owners/modules.
- resumable upload already separates runtime policy, checkpoint persistence,
HTTP control-plane transport, part execution, cancellation and mutation
locking.
- realtime already separates reconnect policy/coordinator, event codec/consumer
and stream coordination.
- OPFS is separated into browser runtime, journal, byte-store, policy and worker
protocol/runtime responsibilities.
- HTTP V3 still owned retry eligibility/backoff inside the execution state
machine, so that responsibility moved to
`src/adapters/http/http-retry-lifecycle.ts`.
No other runtime is split merely because of its line count.
## Consumer quality metrics
Before adding another abstraction, implement or model multiple real feature
uses and measure:
- feature-owned adapter LOC,
- repeated platform glue,
- number of platform-internal types exposed to the feature,
- central catalog edits,
- files changed for one normal query/command,
- whether native browser/network APIs leak into the feature.
The target feature-development path is:
1. domain type and invariant,
2. use case,
3. port,
4. transport/storage schema plus mapper/codec,
5. capability binding,
6. presentation controller/page.
A product feature should not need to know the retry scheduler, abort ownership,
effect-certainty machinery, transaction leases, reconnect coordinator, OPFS
journal or provider lifecycle.
The executable REST and IndexedDB consumer baselines are recorded in
[`capability-consumer-experience.md`](./capability-consumer-experience.md).
Contract placement and the global-vs-owner-local audit are recorded in
[`contract-ownership.md`](./contract-ownership.md).
## Verification paths
Product-development verification, capability verification and release assurance
are intentionally separate. See
[`docs/testing/taxonomy.md`](../testing/taxonomy.md).
Host-level CI-runner tests belong to `tests/system`, not `tests/unit`.
Reusable capability consumer contracts belong to `tests/contract`.
File diff suppressed because it is too large Load Diff
@@ -90,11 +90,19 @@ production Playwright profile은 source fixture가 아니라 `build` + `preview`
#### TanStack Query의 React integration test 기반
`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이 실제
QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의
production query inbound adapter의 React integration은 behavior owner별
component suite로 분리되어 있다.
- `application-query-bridge.test.tsx`: initial/background state, cancellation
- `application-query-scope-fence.test.tsx`: scope commit fence와 result budget
- `application-mutation-scope-fence.test.tsx`: mutation scope fence
- `application-query.test.tsx`: unknown-effect reconciliation
- `application-mutation-admission.test.tsx`: intent/duplicate admission
- `application-mutation-optimistic-cache.test.tsx`: optimistic cache commit/rollback
- `application-query-fixture.tsx`: 공통 QueryClient/scope fixture
각 실패 파일명이 깨진 behavior contract를 직접 드러내며, 동일한 52개 계약을
실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의
query/mutation vendor retry는 꺼져 있다.
#### Form과 route 위험
+65 -14
View File
@@ -3,14 +3,68 @@
Each gate is blocking in its declared scope. Failures are not downgraded with
`continue-on-error` or warning-only scripts.
| Level | Command | Evidence |
| --- | --- | --- |
| runtime schema | `pnpm test:runtime-schema` | `artifacts/tests/runtime-schema.xml` |
| unit | `pnpm test:unit` | `artifacts/tests/unit.xml` |
| component | `pnpm test:component` | `artifacts/tests/component.xml` |
| integration | `pnpm test:integration` | `artifacts/tests/integration.xml` |
| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` |
## Executable levels
| Level | Command | Ownership / prerequisite | Evidence |
| --- | --- | --- | --- |
| runtime schema | `pnpm test:runtime-schema` | pure runtime schema contracts | `artifacts/tests/runtime-schema.xml` |
| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` |
| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` |
| component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` |
| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB and composed browser-runtime boundaries | `artifacts/tests/integration.xml` |
| system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` |
| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` |
`test:all` is the normal product-development loop. It intentionally includes
runtime-schema, unit, capability-contract, component, integration, reference
feature and recipe suites, but does not include `test:system`. CI-runner and
supply-chain assurance has different host prerequisites and is invoked
explicitly in the assurance path.
## Deterministic test process
All Vitest package scripts launch through `scripts/run-vitest.ts`.
That runner:
1. rejects Node versions outside the repository-supported
`>=24.11.0 <25.0.0` range before the suite starts,
2. owns `NODE_ENV=test` rather than trusting the parent shell,
3. removes host-specific `npm_config_userconfig`, `npm_config_prefix` and
`npm_config_globalconfig` values before Vitest starts.
`vitest.config.ts` also fixes `NODE_ENV=test` so a direct Vitest invocation
cannot accidentally select React's production behavior.
The system suite additionally runs
`scripts/check-system-test-prerequisites.ts` and fails with one prerequisite
report when the CI-runner host does not provide its required Linux facilities.
## Development paths
Product feature:
- focused feature/unit/component test
- capability contract test when a reusable boundary changes
- type/lint/architecture
- `test:all`
Reusable capability:
- focused unit tests
- capability contract tests
- integration tests
- type/lint/architecture
CI / release assurance:
- `test:system`
- supply-chain / promotion / release gates
A host-level process/sandbox test must not be placed in `tests/unit` merely
because it uses Vitest. The classification follows the system boundary and
prerequisites, not the test framework.
End-to-end and automated accessibility scenarios run on the pinned Chromium,
Firefox, and WebKit engines. The responsive contract explicitly exercises
@@ -30,9 +84,6 @@ Promotion is an AND graph:
3. release gates plus rollback/runbook drills
4. production promotion plus eligible field Web Vitals evidence
This file describes the currently registered taxonomy. The
[frontend platform testing strategy](./frontend-platform-testing-strategy.md)
documents the target additions: test TypeScript projects, real bootstrap
composition tests, shared MSW scenarios, query/mutation/form/router coverage,
Storybook interaction and accessibility checks, visual regression, and a
built-output Playwright profile.
The [frontend platform testing strategy](./frontend-platform-testing-strategy.md)
contains the broader testing design. This file is the executable taxonomy for
where a test belongs and which environment is allowed to run it.
+11 -9
View File
@@ -46,13 +46,15 @@
"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",
"check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:http-scenario-catalog": "vitest run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1",
"test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml",
"test:system": "node scripts/check-system-test-prerequisites.ts && node scripts/run-vitest.ts run tests/system --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/system.xml",
"test:component": "node scripts/run-vitest.ts run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
"test:integration": "node scripts/run-vitest.ts run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:http-scenario-catalog": "node scripts/run-vitest.ts run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1",
"test:http-scenario-evidence": "node scripts/run-http-scenario-evidence.ts",
"test:recipes": "vitest run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests",
"test:recipes": "node scripts/run-vitest.ts run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests",
"test:e2e": "playwright test",
"test:e2e:dev": "playwright test --config playwright.dev.config.ts",
"test:browser-capabilities": "playwright test --config playwright.capabilities.config.ts",
@@ -74,11 +76,11 @@
"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:reference-feature": "node scripts/run-vitest.ts run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts",
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && node scripts/run-vitest.ts run tests/runtime-schema tests/unit tests/contract tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
"check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:contract && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes",
"verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts",
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
import { mkdir, readFile } from "node:fs/promises";
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts";
import { classifyObjectSchemaChange } from "../src/contracts/compatibility.ts";
import { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
@@ -0,0 +1,55 @@
import { constants } from "node:fs";
import { access, readFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
const failures: string[] = [];
async function requireExecutable(path: string, label: string): Promise<void> {
try {
await access(path, constants.X_OK);
} catch {
failures.push(`${label} is required at ${path}`);
}
}
await Promise.all([
requireExecutable("/usr/bin/bwrap", "bubblewrap"),
requireExecutable("/usr/bin/systemctl", "systemctl"),
requireExecutable("/usr/bin/tar", "tar"),
]);
try {
const controllers = await readFile("/sys/fs/cgroup/cgroup.controllers", "utf8");
if (controllers.trim().length === 0) {
failures.push("cgroup v2 controllers are unavailable");
}
} catch {
failures.push("cgroup v2 is required at /sys/fs/cgroup/cgroup.controllers");
}
if (!failures.some((failure) => failure.includes("systemctl"))) {
const probe = spawnSync("/usr/bin/systemctl", ["show-environment"], {
encoding: "utf8",
timeout: 3_000,
});
if (probe.error || probe.status !== 0) {
failures.push("a reachable systemd manager bus is required");
}
}
if (failures.length > 0) {
process.stderr.write(
[
"System/CI-runner test prerequisites are unavailable:",
...failures.map((failure) => ` - ${failure}`),
"",
"Run test:unit/test:contract/test:component/test:integration for the",
"developer loop. test:system is intentionally reserved for a compatible",
"Linux CI-runner host.",
"",
].join("\n"),
);
process.exit(1);
}
process.stdout.write("System test prerequisites: PASS\n");
+5 -5
View File
@@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
}>;
const CANONICAL_GATE_SHAPE_SHA256 =
"4617ada21cbdeb217d118146bd572860d7c58ad222142a52d41916b26577239a";
"372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -477,13 +477,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
}
if (contract.commands.length !== 82 || commandReferenceCount !== 94) {
if (contract.commands.length !== 84 || commandReferenceCount !== 96) {
failures.push(
`command authority baseline must contain exactly 82 definitions and 94 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
`command authority baseline must contain exactly 84 definitions and 96 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
);
}
if (contract.artifacts.length !== 107) {
failures.push(`artifact authority baseline must contain exactly 107 artifacts; received ${contract.artifacts.length}`);
if (contract.artifacts.length !== 109) {
failures.push(`artifact authority baseline must contain exactly 109 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
+1 -1
View File
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url";
import { shouldRetry } from "../src/adapters/http/retry-policy.ts";
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.ts";
import { verifyCompatibilityTuple } from "../src/contracts/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";
+1 -1
View File
@@ -1,4 +1,4 @@
import { isVersionCompatible } from "../../src/application/policies/compatibility.ts";
import { isVersionCompatible } from "../../src/contracts/compatibility.ts";
import { verifyContractSet } from "../../src/contracts/contract-set.ts";
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts";
+67
View File
@@ -0,0 +1,67 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
function assertSupportedNode(): void {
const [majorText = "0", minorText = "0"] = process.versions.node.split(".");
const major = Number(majorText);
const minor = Number(minorText);
if (major !== 24 || minor < 11) {
process.stderr.write(
[
`Unsupported Node.js runtime for tests: ${process.versions.node}`,
"Required by package.json: >=24.11.0 <25.0.0",
"Use the repository-supported Node.js runtime before running a test suite.",
"",
].join("\n"),
);
process.exit(1);
}
}
assertSupportedNode();
const vitestEntry = fileURLToPath(
new URL("../node_modules/vitest/vitest.mjs", import.meta.url),
);
if (!existsSync(vitestEntry)) {
process.stderr.write(
"Vitest is not installed. Run the repository package installation first.\n",
);
process.exit(1);
}
const testEnvironment: NodeJS.ProcessEnv = {
...process.env,
NODE_ENV: "test",
};
for (const key of [
"npm_config_userconfig",
"npm_config_prefix",
"npm_config_globalconfig",
"NPM_CONFIG_USERCONFIG",
"NPM_CONFIG_PREFIX",
"NPM_CONFIG_GLOBALCONFIG",
]) {
delete testEnvironment[key];
}
const result = spawnSync(
process.execPath,
[vitestEntry, ...process.argv.slice(2)],
{
stdio: "inherit",
env: testEnvironment,
},
);
if (result.error) {
throw result.error;
}
if (result.signal) {
process.stderr.write(`Vitest terminated by signal ${result.signal}.\n`);
process.exit(1);
}
process.exit(result.status ?? 1);
+1 -1
View File
@@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url";
import {
verifyCompatibilityTuple,
type CompatibilityTuple,
} from "../src/application/policies/compatibility.ts";
} from "../src/contracts/compatibility.ts";
import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
import {
parseBuildManifestArtifact,
@@ -8,7 +8,7 @@ import type {
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 type { Result } from "../../contracts/result.ts";
import {
installBrowserRpcContractBindings,
type InstalledBrowserRpcContractBindings,
+237
View File
@@ -0,0 +1,237 @@
import type { Result } from "../../contracts/result.ts";
import type {
MappingResult,
} from "../../contracts/boundary-mapper.ts";
import {
createFailure,
kindForStatus,
type ApiFailure,
type FailureEffectCertainty,
type FailureKind,
} from "../../contracts/errors.ts";
import type { MutationIntent } from "../../contracts/mutation-intent.ts";
import type { HttpExecutionOutcome } from "./http-execution-v3.ts";
export type InstalledHttpOperationExecutor = Readonly<{
execute(
operationId: string,
input: unknown,
context: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<HttpExecutionOutcome<unknown, unknown>>;
}>;
type FeatureHttpOperationSpec<Value> = Readonly<{
operationId: string;
routeId: string;
mapSuccess(value: unknown): MappingResult<Value>;
}>;
export type FeatureHttpOperation<Input, Value> =
FeatureHttpOperationSpec<Value> &
Readonly<{
/**
* Compile-time only carrier. Feature bindings remain plain frozen objects
* at runtime while preserving each operation's input/value pair.
*/
__types?: Readonly<{
input: Input;
value: Value;
}>;
}>;
export function defineFeatureHttpOperation<Input, Value>(
spec: FeatureHttpOperationSpec<Value>,
): FeatureHttpOperation<Input, Value> {
return Object.freeze(spec) as FeatureHttpOperation<Input, Value>;
}
type OperationInput<Operation> =
Operation extends FeatureHttpOperation<infer Input, unknown> ? Input : never;
type OperationValue<Operation> =
Operation extends FeatureHttpOperation<unknown, infer Value> ? Value : never;
export type FeatureHttpBinding<
Operations extends Readonly<
Record<string, FeatureHttpOperation<unknown, unknown>>
>,
> = Readonly<{
execute<OperationId extends keyof Operations & string>(
operationId: OperationId,
input: OperationInput<Operations[OperationId]>,
context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>>;
}>;
/**
* Capability-specific feature binding for the installed HTTP runtime.
*
* The platform owns transport/outcome normalization. A feature contributes only
* operation identity, route identity, typed input and its wire-to-domain mapper.
* This keeps HTTP lifecycle/failure semantics out of feature application code
* without collapsing storage/realtime/transfer into a universal repository.
*/
export function createFeatureHttpBinding<
const Operations extends Readonly<
Record<string, FeatureHttpOperation<unknown, unknown>>
>,
>(
executor: InstalledHttpOperationExecutor,
operations: Operations,
): FeatureHttpBinding<Operations> {
for (const [registryId, operation] of Object.entries(operations)) {
if (registryId !== operation.operationId) {
throw new TypeError(
`Feature HTTP operation key mismatch: ${registryId} !== ${operation.operationId}`,
);
}
}
return Object.freeze({
async execute<OperationId extends keyof Operations & string>(
operationId: OperationId,
input: OperationInput<Operations[OperationId]>,
context: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}> = {},
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> {
const operation = operations[operationId];
const outcome = await executor.execute(operation.operationId, input, {
routeId: operation.routeId,
...(context.signal === undefined ? {} : { signal: context.signal }),
...(context.intent === undefined ? {} : { intent: context.intent }),
});
return projectExecutionOutcome<
OperationValue<Operations[OperationId]>
>(
operation.operationId,
operation.mapSuccess as (
value: unknown,
) => MappingResult<OperationValue<Operations[OperationId]>>,
outcome,
);
},
});
}
function projectExecutionOutcome<Value>(
operationId: string,
mapSuccess: (value: unknown) => MappingResult<Value>,
outcome: HttpExecutionOutcome<unknown, unknown>,
): Result<Value, ApiFailure> {
switch (outcome.kind) {
case "SUCCESS": {
const mapped = mapSuccess(outcome.value);
return mapped.ok
? Object.freeze({ ok: true as const, value: mapped.value })
: failure(
"MAPPING_CONTRACT_VIOLATION",
operationId,
mapped.code,
{ effect: outcome.effect },
);
}
case "PROBLEM":
return failure(
kindForStatus(outcome.metadata.status),
operationId,
"CONTRACT_PROBLEM",
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
);
case "UNAUTHENTICATED":
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
effect: outcome.effect,
});
case "FORBIDDEN":
return failure("FORBIDDEN", operationId, "FORBIDDEN", {
effect: outcome.effect,
});
case "RATE_LIMITED":
return failure("RATE_LIMITED", operationId, "RATE_LIMITED", {
...(outcome.retryAfterMs === undefined
? {}
: { retryAfterMs: outcome.retryAfterMs }),
effect: outcome.effect,
});
case "CANCELLED":
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
effect: outcome.effect,
});
case "AUTH_INTEGRATION_FAILURE":
return failure(
"AUTH_INTEGRATION_FAILURE",
operationId,
outcome.reason,
{ effect: outcome.effect },
);
case "TRANSPORT_FAILURE":
return failure(
outcome.failure.kind === "TIMEOUT"
? "REQUEST_TIMEOUT"
: outcome.failure.kind === "ABORTED_BY_SCOPE"
? "SCOPE_GENERATION_CHANGED"
: "NETWORK_UNREACHABLE",
operationId,
outcome.failure.kind,
{ effect: outcome.effect },
);
case "CONTRACT_VIOLATION":
return failure(
failureKindForViolation(outcome.violation.kind),
operationId,
outcome.violation.kind,
{ effect: outcome.effect },
);
}
}
function failureKindForViolation(
violation: Extract<
HttpExecutionOutcome<unknown, unknown>,
{ kind: "CONTRACT_VIOLATION" }
>["violation"]["kind"],
): FailureKind {
switch (violation) {
case "CONTENT_TYPE_MISMATCH":
return "CONTENT_TYPE_MISMATCH";
case "RESPONSE_TOO_LARGE":
return "RESPONSE_BODY_LIMIT";
case "UTF8_INVALID":
case "JSON_INVALID":
return "MALFORMED_JSON";
case "MAPPING_CONTRACT_VIOLATION":
return "MAPPING_CONTRACT_VIOLATION";
case "SCOPE_FENCED":
return "SCOPE_GENERATION_CHANGED";
case "SUCCESS_SCHEMA_INVALID":
case "PROBLEM_SCHEMA_INVALID":
case "VALIDATOR_RUNTIME_FAILURE":
return "SCHEMA_MISMATCH";
default:
return "ENVELOPE_MISMATCH";
}
}
function failure(
kind: FailureKind,
operationId: string,
code: string,
details: Readonly<{
httpStatus?: number;
retryAfterMs?: number;
effect?: FailureEffectCertainty;
}> = {},
): Result<never, ApiFailure> {
return Object.freeze({
ok: false as const,
error: createFailure(kind, operationId, 0, { code, ...details }),
});
}
+8 -59
View File
@@ -41,6 +41,13 @@ import {
type PhysicalAttemptState,
} from "./http-effect-certainty.ts";
import { parseRetryAfter } from "./retry-policy.ts";
import {
canRetryTransport,
isRetryableHttpStatus,
isRetryableSemantics,
jitteredDelay,
retryDelayFor,
} from "./http-retry-lifecycle.ts";
/**
* §7–§8. Descriptor-driven HTTP execution.
@@ -299,14 +306,6 @@ export type ContractHttpExecutorDependencies = Readonly<{
observe?: (observation: HttpExecutionObservation) => void;
}>;
const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([
408, 425, 429, 502, 503, 504,
]);
const RETRY_BASE_DELAY_MS = 250;
const RETRY_MAX_LOCAL_DELAY_MS = 2_000;
const RETRY_AFTER_CEILING_MS = 5_000;
type MutationIntentValidation =
| Readonly<{ ok: true; intent?: MutationIntent }>
| Readonly<{
@@ -1347,7 +1346,7 @@ async function admitProblem<Input, WireOutput, Problem>(
): Promise<AdmissionOutcome<WireOutput, Problem>> {
const contract = operation.contract;
const isCommand = contract.commandEffect !== null;
const retryable = RETRYABLE_STATUSES.has(status);
const retryable = isRetryableHttpStatus(status);
const bytes = await readResponseBytes(
response,
@@ -1434,56 +1433,6 @@ async function admitProblem<Input, WireOutput, Problem>(
});
}
/**
* §8.3. `SAFE` and `IDEMPOTENT` may replay the same frozen request. `KEYED`
* must not automatically retry once an attempt was dispatched and its response
* was lost; that path goes to inspect/reconciliation instead. `NEVER` is zero.
*/
function canRetryTransport(
semantics: InstalledHttpContract<
unknown,
unknown,
unknown
>["contract"]["retrySemantics"],
attemptState: PhysicalAttemptState,
): boolean {
if (semantics === "NEVER") return false;
if (semantics === "KEYED") {
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND";
}
return true;
}
function isRetryableSemantics(
semantics: InstalledHttpContract<
unknown,
unknown,
unknown
>["contract"]["retrySemantics"],
): boolean {
return semantics === "SAFE" || semantics === "IDEMPOTENT";
}
/** §8.2. Full jitter over `min(2000, 250 * 2^index)`. */
function jitteredDelay(retryIndex: number, random: () => number): number {
const ceiling = Math.min(
RETRY_MAX_LOCAL_DELAY_MS,
RETRY_BASE_DELAY_MS * 2 ** retryIndex,
);
return Math.floor(random() * ceiling);
}
function retryDelayFor(
retryAfterMs: number | null,
retryIndex: number,
random: () => number,
): number | null {
const local = jitteredDelay(retryIndex, random);
if (retryAfterMs === null) return local;
if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null;
return Math.max(local, retryAfterMs);
}
/** §7.12. No raw header map, URL, cookie, traceparent or ETag value escapes. */
function safeMetadata(
response: Response,
+57
View File
@@ -0,0 +1,57 @@
import type { RetrySemantics } from "../../contracts/external-contract-runtime.ts";
import type { PhysicalAttemptState } from "./http-effect-certainty.ts";
const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([
408, 425, 429, 502, 503, 504,
]);
const RETRY_BASE_DELAY_MS = 250;
const RETRY_MAX_LOCAL_DELAY_MS = 2_000;
const RETRY_AFTER_CEILING_MS = 5_000;
export function isRetryableHttpStatus(status: number): boolean {
return RETRYABLE_STATUSES.has(status);
}
/**
* Transport replay authority.
*
* KEYED commands may retry only before a physical dispatch. Once dispatched,
* an uncertain result belongs to reconciliation rather than automatic replay.
*/
export function canRetryTransport(
semantics: RetrySemantics,
attemptState: PhysicalAttemptState,
): boolean {
if (semantics === "NEVER") return false;
if (semantics === "KEYED") {
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND";
}
return true;
}
export function isRetryableSemantics(semantics: RetrySemantics): boolean {
return semantics === "SAFE" || semantics === "IDEMPOTENT";
}
/** Full jitter over min(2000, 250 * 2^retryIndex). */
export function jitteredDelay(
retryIndex: number,
random: () => number,
): number {
const ceiling = Math.min(
RETRY_MAX_LOCAL_DELAY_MS,
RETRY_BASE_DELAY_MS * 2 ** retryIndex,
);
return Math.floor(random() * ceiling);
}
export function retryDelayFor(
retryAfterMs: number | null,
retryIndex: number,
random: () => number,
): number | null {
const local = jitteredDelay(retryIndex, random);
if (retryAfterMs === null) return local;
if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null;
return Math.max(local, retryAfterMs);
}
+7
View File
@@ -19,6 +19,13 @@ export {
type HttpTransportFailure,
type SafeResponseMetadata,
} from "./http-execution-v3.ts";
export {
createFeatureHttpBinding,
defineFeatureHttpOperation,
type FeatureHttpBinding,
type FeatureHttpOperation,
type InstalledHttpOperationExecutor,
} from "./feature-http-binding.ts";
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
export type { CredentialPatchOutcome } from "./http-contract-bridge.ts";
/**
@@ -1,4 +1,4 @@
import type { Result } from "./result.ts";
import type { Result } from "../../contracts/result.ts";
export type CursorPage<Value> = Readonly<{
items: readonly Value[];
@@ -1,9 +1,9 @@
import type { Result } from "../../application/result.ts";
import type { Result } from "../../contracts/result.ts";
import type {
CursorPage,
CursorPaginationProfile,
CursorPaginationRuntime,
} from "../../contracts/cursor-pagination.ts";
} from "./cursor-pagination-contract.ts";
import { createFailure } from "../../contracts/errors.ts";
import { snapshotExactObject } from "../../contracts/exact-snapshot.ts";
+5
View File
@@ -4,6 +4,11 @@ export {
type ConditionalValidatorStore,
} from "./conditional-validator-store.ts";
export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts";
export type {
CursorPage,
CursorPaginationProfile,
CursorPaginationRuntime,
} from "./cursor-pagination-contract.ts";
export {
createServerStateScopeRuntime,
type ScopeResetParticipant,
+4 -2
View File
@@ -4,8 +4,10 @@
* The implementation lives in `src/contracts/compatibility.ts`: it is a pure
* predicate over release tokens with no application state, and
* `src/contracts/release-tokens.ts` needs it, which previously made contracts
* import the application layer. This module re-exports it for application-side
* and script-side callers.
* import the application layer. This module is retained only as a migration
* shim; internal callers use src/contracts/compatibility.ts directly.
*
* @deprecated Import compatibility contracts from ../../contracts/compatibility.ts.
*/
export {
COMPATIBILITY_TUPLE_FIELDS,
+5 -9
View File
@@ -1,13 +1,9 @@
/**
* The single success/failure carrier used across application input boundaries.
* Adapters map technology-specific errors to an application failure before
* constructing this value.
* Compatibility re-export for application-side callers from older revisions.
*
* The type itself lives in `src/contracts` because both layers need it and
* neither owns it: `src/contracts/server-state.ts` and
* `src/contracts/cursor-pagination.ts` reached back into the application layer
* for it, which made the ownership of the shared vocabulary ambiguous in both
* directions. Contracts is the lower of the two, so the shared shape sits there
* and this module re-exports it for every existing application-side importer.
* The canonical Result definition is src/contracts/result.ts. New code should
* import it from that module so the shared carrier has one public authority.
*
* @deprecated Import Result from ../contracts/result.ts.
*/
export type { Result } from "../contracts/result.ts";
@@ -3,22 +3,25 @@ import {
type InstalledContractContribution,
type InstalledContractPackageIdentity,
} from "../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
const COMPILED_CONTRACT_CONTRIBUTIONS = Object.freeze([
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
] as const);
/**
* §4.8. Static contract selection SSOT.
* Central contract installation is aggregation only.
*
* A product feature adds exactly one entry per service package here and imports
* the generated package only from
* `src/features/<feature>/contracts/<service>-contract-contribution.ts`.
* Each feature owns its concrete service/package contribution. This catalog
* selects contributions whose feature is installed; it does not contain
* feature-specific branching or service wiring.
*/
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze(
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]
: [],
COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
),
);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
+26 -18
View File
@@ -1,25 +1,33 @@
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";
import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
/**
* §3.5. Partial on purpose: a feature the manifest did not select supplies no
* driving input, so consumers have to narrow before calling one. A total type
* here would let feature code compile against an input that is not there.
*/
type InstalledFeatureInputs = Readonly<
Partial<Pick<ApplicationFeatureInputs, typeof REFERENCE_FEATURE_ID>>
>;
const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([
REFERENCE_FEATURE_ADAPTER_CONTRIBUTION,
] as const);
type FeatureAdapterContext = Parameters<
(typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS)[number]["createInput"]
>[0];
type InstalledFeatureInputs = Readonly<Partial<ApplicationFeatureInputs>>;
/**
* Central adapter composition only selects and aggregates feature-owned
* contributions. The feature owns how its application input is bound to
* reusable platform capabilities.
*/
export function createInstalledFeatureInputs(
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
context: FeatureAdapterContext,
): InstalledFeatureInputs {
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
return Object.freeze({});
}
const referenceFeature = createReferenceFeatureInstalledInput(context);
return Object.freeze({
[referenceFeature.featureId]: referenceFeature.input,
});
const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
)
.map((contribution) => {
const installed = contribution.createInput(context);
return [installed.featureId, installed.input] as const;
});
return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs;
}
+30 -20
View File
@@ -1,28 +1,38 @@
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
import {
REFERENCE_FEATURE_ROUTE_CODECS,
REFERENCE_FEATURE_ROUTE_RUNTIME,
} from "./reference-feature/presentation/reference-feature-runtime.tsx";
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { REFERENCE_FEATURE_RUNTIME_CONTRIBUTION } from "./reference-feature/presentation/reference-feature-runtime.tsx";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
const COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS = Object.freeze([
REFERENCE_FEATURE_RUNTIME_CONTRIBUTION,
] as const);
const INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS =
COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
);
/**
* §3.5. A feature the manifest did not select contributes no codec and no route
* component, so the router has nothing to mount for it. The module is still
* linked — a static import cannot be undone by a value — which is why physical
* removal is FE-GATE-020's job and this is deselection, not deletion.
* Central runtime catalogs only aggregate feature-owned contributions.
* Adding route codecs/components to a feature no longer requires duplicating
* reference-specific selection logic in this composition file.
*/
const referenceSelected = INSTALLED_PRODUCT_FEATURE_IDS.includes(
REFERENCE_FEATURE_ID,
export const ROUTE_CODECS = Object.freeze(
Object.assign(
{},
PLATFORM_ROUTE_CODECS,
...INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS.map(
(contribution) => contribution.routeCodecs,
),
),
);
export const ROUTE_CODECS = Object.freeze({
...PLATFORM_ROUTE_CODECS,
...(referenceSelected ? REFERENCE_FEATURE_ROUTE_CODECS : {}),
});
export const ROUTE_RUNTIME = Object.freeze({
...PLATFORM_ROUTE_RUNTIME,
...(referenceSelected ? REFERENCE_FEATURE_ROUTE_RUNTIME : {}),
});
export const ROUTE_RUNTIME = Object.freeze(
Object.assign(
{},
PLATFORM_ROUTE_RUNTIME,
...INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS.map(
(contribution) => contribution.routeRuntime,
),
),
);
+15 -10
View File
@@ -24,11 +24,12 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므
`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에서 차단된다.
HTTP adapter`src/adapters/http/feature-http-binding.ts`의 capability-specific
typed binding을 사용한다. feature는 operation ID, route ID, request type, 성공
value type, mapper만 소유한다. timeout/cancellation/auth/transport/contract
violation을 `AppFailure`로 정규화하는 책임은 reusable HTTP capability가 소유한다.
따라서 gateway 메서드는 transport outcome이나 runtime result guard를 반복 구현하지
않고도 정확한 결과 타입을 반환한다.
route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route type은
`route-contract.ts`, React context/provider/hook은 `route-input.tsx`가 각각
@@ -39,12 +40,16 @@ route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route t
## 설치 지점
- 직렬화 계약: `src/features/installed-feature-contracts.ts`
- component/codec: `src/features/installed-feature-runtimes.tsx`
- bootstrap input 조립: `src/features/installed-feature-adapters.ts`
feature가 실제 contribution을 소유한다.
새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application,
router나 HTTP client에 하드코딩하지 않는다.
- contract: `reference-feature-contract.ts`
- component/codec: `REFERENCE_FEATURE_RUNTIME_CONTRIBUTION`
- bootstrap input: `REFERENCE_FEATURE_ADAPTER_CONTRIBUTION`
중앙 파일인 `installed-feature-contracts.ts`,
`installed-feature-runtimes.tsx`, `installed-feature-adapters.ts`는 선택된
contribution을 합치는 역할만 한다. 새 기능의 내부 조립 규칙을 중앙 catalog에
추가하지 않는다.
## 검증과 제거
@@ -1,195 +1,41 @@
import type { Result } from "../../../application/result.ts";
import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts";
import type { FailureEffectCertainty } from "../../../contracts/errors.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
import {
createFailure,
kindForStatus,
} from "../../../contracts/errors.ts";
import type { HttpExecutionOutcome } from "../../../adapters/http/index.ts";
createFeatureHttpBinding,
type InstalledHttpOperationExecutor,
} from "../../../adapters/http/index.ts";
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
import {
REFERENCE_FEATURE_ID,
} from "../contracts/reference-feature-contract.ts";
import { mapReferenceOperation } from "../contracts/reference-mapper.ts";
import {
createReferenceHttpGateway,
type RawReferenceHttpExecutor,
type ReferenceHttpRequest,
type ReferenceOperationId,
REFERENCE_HTTP_OPERATIONS,
} from "./reference-http-gateway.ts";
export type InstalledContractOperationExecutor = Readonly<{
execute(
operationId: string,
input: unknown,
context: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<HttpExecutionOutcome<unknown, unknown>>;
}>;
export type InstalledContractOperationExecutor =
InstalledHttpOperationExecutor;
/**
* The installed feature consumes the composed external-contract operation
* registry through one descriptor-driven executor. Legacy ApiOperation/schema
* registries are intentionally absent from this production composition seam.
* Feature-owned composition seam.
*
* The feature contributes typed operation descriptors and its application
* gateway. HTTP lifecycle/error normalization stays in the reusable capability
* binding rather than being repeated by every product feature.
*/
export function createReferenceFeatureInstalledInput(context: Readonly<{
contractOperations: InstalledContractOperationExecutor;
contractOperations: InstalledHttpOperationExecutor;
}>) {
const rawHttp: RawReferenceHttpExecutor = Object.freeze({
async execute(request) {
const operationId = request.operationId;
const input = inputFor(request);
const signal = "signal" in request ? request.signal : undefined;
const intent = "intent" in request ? request.intent : undefined;
const outcome = await context.contractOperations.execute(
operationId,
input,
{
// §7.4. The gateway owns the low-cardinality route identity; losing
// it here is what made every V3 diagnostic unattributable.
routeId: request.routeId,
...(signal === undefined ? {} : { signal }),
...(intent === undefined ? {} : { intent }),
},
);
return projectExecutionOutcome(operationId, outcome);
},
});
const http = createFeatureHttpBinding(
context.contractOperations,
REFERENCE_HTTP_OPERATIONS,
);
return Object.freeze({
featureId: REFERENCE_FEATURE_ID,
input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)),
input: createReferenceFeatureInput(createReferenceHttpGateway(http)),
});
}
function inputFor(
request: ReferenceHttpRequest<ReferenceOperationId>,
): unknown {
switch (request.operationId) {
case "LIST_REFERENCE_RESOURCES":
return request.searchParams;
case "CREATE_REFERENCE_RESOURCE":
return request.body;
case "GET_REFERENCE_RESOURCE":
return request.pathParams;
}
}
function projectExecutionOutcome(
operationId: ReferenceOperationId,
outcome: HttpExecutionOutcome<unknown, unknown>,
): Result<unknown, ApiFailure> {
switch (outcome.kind) {
case "SUCCESS": {
const mapped = mapReferenceOperation(operationId, outcome.value);
return mapped.ok
? Object.freeze({ ok: true as const, value: mapped.value })
: failure(
"MAPPING_CONTRACT_VIOLATION",
operationId,
mapped.code,
{ effect: outcome.effect },
);
}
case "PROBLEM":
return failure(
kindForStatus(outcome.metadata.status),
operationId,
"CONTRACT_PROBLEM",
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
);
case "UNAUTHENTICATED":
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
effect: outcome.effect,
});
case "FORBIDDEN":
return failure("FORBIDDEN", operationId, "FORBIDDEN", {
effect: outcome.effect,
});
case "RATE_LIMITED":
return failure("RATE_LIMITED", operationId, "RATE_LIMITED", {
...(outcome.retryAfterMs === undefined
? {}
: { retryAfterMs: outcome.retryAfterMs }),
effect: outcome.effect,
});
case "CANCELLED":
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
effect: outcome.effect,
});
case "AUTH_INTEGRATION_FAILURE":
// §7.7. A configuration or collaborator breach, not a session state, so
// it must not drive the re-authentication surface.
return failure(
"AUTH_INTEGRATION_FAILURE",
operationId,
outcome.reason,
{ effect: outcome.effect },
);
case "TRANSPORT_FAILURE":
return failure(
outcome.failure.kind === "TIMEOUT"
? "REQUEST_TIMEOUT"
: outcome.failure.kind === "ABORTED_BY_SCOPE"
? "SCOPE_GENERATION_CHANGED"
: "NETWORK_UNREACHABLE",
operationId,
outcome.failure.kind,
{ effect: outcome.effect },
);
case "CONTRACT_VIOLATION":
return failure(
failureKindForViolation(outcome.violation.kind),
operationId,
outcome.violation.kind,
{ effect: outcome.effect },
);
}
}
function failureKindForViolation(
violation: Extract<
HttpExecutionOutcome<unknown, unknown>,
{ kind: "CONTRACT_VIOLATION" }
>["violation"]["kind"],
): FailureKind {
switch (violation) {
case "CONTENT_TYPE_MISMATCH":
return "CONTENT_TYPE_MISMATCH";
case "RESPONSE_TOO_LARGE":
return "RESPONSE_BODY_LIMIT";
case "UTF8_INVALID":
case "JSON_INVALID":
return "MALFORMED_JSON";
case "MAPPING_CONTRACT_VIOLATION":
return "MAPPING_CONTRACT_VIOLATION";
case "SCOPE_FENCED":
return "SCOPE_GENERATION_CHANGED";
case "SUCCESS_SCHEMA_INVALID":
case "PROBLEM_SCHEMA_INVALID":
case "VALIDATOR_RUNTIME_FAILURE":
return "SCHEMA_MISMATCH";
default:
return "ENVELOPE_MISMATCH";
}
}
function failure(
kind: FailureKind,
operationId: string,
code: string,
details: Readonly<{
httpStatus?: number;
retryAfterMs?: number;
effect?: FailureEffectCertainty;
}> = {},
): Result<never, ApiFailure> {
return Object.freeze({
ok: false as const,
error: createFailure(kind, operationId, 0, { code, ...details }),
});
}
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({
featureId: REFERENCE_FEATURE_ID,
createInput: createReferenceFeatureInstalledInput,
});
@@ -1,160 +1,65 @@
import type { Result } from "../../../application/result.ts";
import {
createFailure,
type ApiFailure,
} from "../../../contracts/errors.ts";
defineFeatureHttpOperation,
type FeatureHttpBinding,
} from "../../../adapters/http/index.ts";
import type {
ReferenceCreateCommand,
ReferenceGateway,
ReferenceListFilters,
} from "../application/reference-feature-api.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
import {
mapReferenceResourceListPayload,
mapReferenceResourcePayload,
} from "../contracts/reference-mapper.ts";
type ReferenceOperationMap = Readonly<{
LIST_REFERENCE_RESOURCES: Readonly<{
request: Readonly<{
operationId: "LIST_REFERENCE_RESOURCES";
routeId: "REFERENCE_RESOURCE_LIST";
searchParams: ReferenceListFilters;
signal?: AbortSignal;
}>;
value: readonly ReferenceResource[];
}>;
CREATE_REFERENCE_RESOURCE: Readonly<{
request: Readonly<{
operationId: "CREATE_REFERENCE_RESOURCE";
routeId: "REFERENCE_RESOURCE_LIST";
body: ReferenceCreateCommand;
signal?: AbortSignal;
intent?: MutationIntent;
}>;
value: ReferenceResource;
}>;
GET_REFERENCE_RESOURCE: Readonly<{
request: Readonly<{
operationId: "GET_REFERENCE_RESOURCE";
routeId: "REFERENCE_RESOURCE_DETAIL";
pathParams: Readonly<{ resourceId: string }>;
signal?: AbortSignal;
}>;
value: ReferenceResource;
}>;
}>;
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation<
ReferenceListFilters,
readonly ReferenceResource[]
>({
operationId: "LIST_REFERENCE_RESOURCES",
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourceListPayload,
}),
CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation<
ReferenceCreateCommand,
ReferenceResource
>({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourcePayload,
}),
GET_REFERENCE_RESOURCE: defineFeatureHttpOperation<
Readonly<{ resourceId: string }>,
ReferenceResource
>({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload,
}),
} as const);
export type ReferenceOperationId = keyof ReferenceOperationMap;
export type ReferenceHttpRequest<
OperationId extends ReferenceOperationId,
> = ReferenceOperationMap[OperationId]["request"];
export type ReferenceHttpResult<
OperationId extends ReferenceOperationId,
> = Result<ReferenceOperationMap[OperationId]["value"], ApiFailure>;
export type RawReferenceHttpExecutor = Readonly<{
execute(
request: ReferenceHttpRequest<ReferenceOperationId>,
): Promise<Result<unknown, ApiFailure>>;
}>;
export type ReferenceHttpBinding = FeatureHttpBinding<
typeof REFERENCE_HTTP_OPERATIONS
>;
export function createReferenceHttpGateway(
http: RawReferenceHttpExecutor,
http: ReferenceHttpBinding,
): ReferenceGateway {
return Object.freeze({
async list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "LIST_REFERENCE_RESOURCES",
routeId: "REFERENCE_RESOURCE_LIST",
searchParams: filters,
signal: context?.signal,
});
return projectListResult(result);
list(filters, context) {
return http.execute("LIST_REFERENCE_RESOURCES", filters, context);
},
async create(
command: ReferenceCreateCommand,
context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
body: command,
signal: context?.signal,
intent: context?.intent,
});
return projectResourceResult("CREATE_REFERENCE_RESOURCE", result);
create(command, context) {
return http.execute("CREATE_REFERENCE_RESOURCE", command, context);
},
async get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL",
pathParams: { resourceId },
signal: context?.signal,
});
return projectResourceResult("GET_REFERENCE_RESOURCE", result);
get(resourceId, context) {
return http.execute(
"GET_REFERENCE_RESOURCE",
Object.freeze({ resourceId }),
context,
);
},
});
}
function projectListResult(
result: Result<unknown, ApiFailure>,
): 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<unknown, ApiFailure>,
): Result<ReferenceResource, ApiFailure> {
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<Record<string, unknown>>;
return (
typeof candidate.id === "string" &&
candidate.id.length > 0 &&
typeof candidate.displayName === "string" &&
candidate.displayName.length > 0 &&
(candidate.createdAt === null ||
typeof candidate.createdAt === "string")
);
}
@@ -1,4 +1,4 @@
import type { Result } from "../../../application/result.ts";
import type { Result } from "../../../contracts/result.ts";
import type {} from "../../../application/ports/in/application-api.ts";
import {
toReferenceView,
@@ -5,8 +5,8 @@ import {
import {
mappingFailure,
mappingSuccess,
type MappingResult,
type InstalledBoundaryMapper,
type MappingResult,
} from "../../../contracts/boundary-mapper.ts";
export type ReferenceResourceView = Readonly<{
@@ -16,7 +16,9 @@ export type ReferenceResourceView = Readonly<{
optimistic?: boolean;
}>;
function mapReferenceDto(value: unknown): MappingResult<ReferenceResource> {
export function mapReferenceResourcePayload(
value: unknown,
): MappingResult<ReferenceResource> {
if (!value || typeof value !== "object") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
@@ -37,28 +39,40 @@ function mapReferenceDto(value: unknown): MappingResult<ReferenceResource> {
}
}
export function mapReferenceResourceListPayload(
payload: unknown,
): MappingResult<readonly ReferenceResource[]> {
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 = mapReferenceResourcePayload(item);
if (!mapped.ok) return mapped;
output.push(mapped.value);
}
return mappingSuccess(Object.freeze(output));
}
/**
* Registry-facing compatibility projection. Feature adapters should prefer the
* operation-specific mapper functions above so their result type is exact.
*/
export function mapReferenceOperation(
operationId: string,
payload: unknown,
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
if (operationId === "LIST_REFERENCE_RESOURCES") {
if (!Array.isArray(payload)) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
if (payload.length > 100) return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
const output: ReferenceResource[] = [];
for (const item of payload) {
const mapped = mapReferenceDto(item);
if (!mapped.ok) return mapped;
output.push(mapped.value);
}
return mappingSuccess(Object.freeze(output));
return mapReferenceResourceListPayload(payload);
}
if (
operationId === "CREATE_REFERENCE_RESOURCE" ||
operationId === "GET_REFERENCE_RESOURCE"
) {
return mapReferenceDto(payload);
return mapReferenceResourcePayload(payload);
}
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
@@ -71,8 +85,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResourceList",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 100,
map: (input: unknown) =>
mapReferenceOperation("LIST_REFERENCE_RESOURCES", input),
map: mapReferenceResourceListPayload,
}),
ReferenceResourceMapper: Object.freeze({
mapperId: "ReferenceResourceMapper",
@@ -81,8 +94,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResource",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 1,
map: (input: unknown) =>
mapReferenceOperation("GET_REFERENCE_RESOURCE", input),
map: mapReferenceResourcePayload,
}),
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
@@ -1,5 +1,6 @@
import { lazy } from "react";
import { REFERENCE_FEATURE_ID } from "../contracts/reference-feature-contract.ts";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
@@ -28,3 +29,9 @@ export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
Component: lazy(() => import("./reference-resource-status-page.tsx")),
}),
} as const;
export const REFERENCE_FEATURE_RUNTIME_CONTRIBUTION = Object.freeze({
featureId: REFERENCE_FEATURE_ID,
routeCodecs: REFERENCE_FEATURE_ROUTE_CODECS,
routeRuntime: REFERENCE_FEATURE_ROUTE_RUNTIME,
});
@@ -1,4 +1,4 @@
import { useApplication } from "../../../presentation/providers/application-provider.tsx";
import { useApplicationFeature } from "../../../presentation/providers/application-provider.tsx";
import {
useApplicationMutation,
useApplicationQuery,
@@ -50,8 +50,32 @@ function measureResourceList(
return { itemCount: views.length, estimatedBytes };
}
const REFERENCE_CREATE_MUTATION_POLICY = Object.freeze({
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE" as const,
invalidate: Object.freeze([REFERENCE_RESOURCE_INVALIDATION_TOPIC]),
});
type ReferenceCreateMutationBinding = Pick<
BoundMutation<ReferenceCreateCommand, ReferenceResourceView>,
"scope" | "execute"
>;
function bindReferenceCreateMutation(
binding: ReferenceCreateMutationBinding,
): BoundMutation<ReferenceCreateCommand, ReferenceResourceView> {
return Object.freeze({
...REFERENCE_CREATE_MUTATION_POLICY,
...binding,
});
}
export function useReferenceFeatureInput(): ReferenceFeatureInput {
return useApplication().features.get(REFERENCE_FEATURE_ID);
return useApplicationFeature(REFERENCE_FEATURE_ID);
}
export function useReferenceDetail(resourceId: string) {
@@ -82,20 +106,10 @@ export function useReferenceDetail(resourceId: string) {
export function useReferenceCreate() {
const input = useReferenceFeatureInput();
const scope = useServerStateScope();
const mutation: BoundMutation<
ReferenceCreateCommand,
ReferenceResourceView
> = {
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE",
const mutation = bindReferenceCreateMutation({
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
};
});
return useApplicationMutation(mutation);
}
@@ -123,16 +137,11 @@ export function useReferenceFeature() {
scope,
),
);
const mutation = useApplicationMutation({
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
});
const mutation = useApplicationMutation(
bindReferenceCreateMutation({
scope,
execute: input.createResource,
}),
);
return Object.freeze({ filters, query, mutation });
}
@@ -17,7 +17,7 @@ import {
deriveAsyncState,
type AsyncState,
} from "../../../application/view-models/async-state.ts";
import type { Result } from "../../../application/result.ts";
import type { Result } from "../../../contracts/result.ts";
import {
createFailure,
normalizeUnknownFailure,
@@ -19,7 +19,7 @@ import {
PageHeader,
type DataTableColumn,
} from "../design-system/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
import { useApplicationRuntime } from "../providers/application-provider.tsx";
/**
* Every number and row on this page is read from an installed registry at
@@ -279,7 +279,7 @@ function buildOperationRows(): readonly OperationRow[] {
}
export default function PlatformOverviewPage() {
const { runtime } = useApplication();
const runtime = useApplicationRuntime();
const [release, setRelease] = useState<
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
>(null);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Result } from "../../application/result.ts";
import type { Result } from "../../contracts/result.ts";
import type { AppFailure } from "../../contracts/errors.ts";
import {
formatMessage,
+2 -2
View File
@@ -20,7 +20,7 @@ import {
useLocale,
type MessageKey,
} from "../i18n/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
import { useApplicationRuntime } from "../providers/application-provider.tsx";
import { useSession } from "../providers/session-provider.tsx";
import { useTheme } from "../providers/theme-provider.tsx";
@@ -168,7 +168,7 @@ export function AppShell() {
function PrimaryNavigation({ id }: Readonly<{ id: string }>) {
const { resolve, message } = useLocale();
const { runtime } = useApplication();
const runtime = useApplicationRuntime();
// §3.5. A feature the runtime document disabled does not advertise itself.
// The router refuses its routes too, so this is presentation, not the switch.
const routes = NAVIGATION_ROUTES.filter((definition) => {
+2 -2
View File
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.ts";
import { PageHeader } from "../design-system/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
import { useApplicationRuntime } from "../providers/application-provider.tsx";
const READINESS_ITEMS = Object.freeze([
{
@@ -21,7 +21,7 @@ const READINESS_ITEMS = Object.freeze([
]);
export default function HomePage() {
const { runtime } = useApplication();
const runtime = useApplicationRuntime();
const [release, setRelease] = useState<
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
>(null);
@@ -4,7 +4,11 @@ import {
useContext,
} from "react";
import type { ApplicationApi } from "../../application/create-application.ts";
import type {
ApplicationApi,
ApplicationFeatureId,
ApplicationFeatureInputs,
} from "../../application/ports/in/application-api.ts";
const ApplicationContext = createContext<ApplicationApi | null>(null);
@@ -22,10 +26,47 @@ export function ApplicationProvider({
);
}
export function useApplication(): ApplicationApi {
function useApplicationContext(): ApplicationApi {
const application = useContext(ApplicationContext);
if (!application) {
throw new Error("ApplicationProvider is required");
}
return application;
}
export function useApplicationSession(): ApplicationApi["session"] {
return useApplicationContext().session;
}
export function useApplicationPreferences(): ApplicationApi["preferences"] {
return useApplicationContext().preferences;
}
export function useApplicationDiagnostics(): ApplicationApi["diagnostics"] {
return useApplicationContext().diagnostics;
}
export function useApplicationRuntime(): ApplicationApi["runtime"] {
return useApplicationContext().runtime;
}
export function useApplicationRecovery(): ApplicationApi["recovery"] {
return useApplicationContext().recovery;
}
export function useApplicationFeature<
FeatureId extends ApplicationFeatureId,
>(featureId: FeatureId): ApplicationFeatureInputs[FeatureId] {
return useApplicationContext().features.get(featureId);
}
/**
* Compatibility escape hatch for composition-oriented presentation code.
* New consumers should use the narrow hooks above so dependencies remain
* explicit instead of growing a root-object service locator.
*
* @deprecated Prefer a capability-specific hook.
*/
export function useApplication(): ApplicationApi {
return useApplicationContext();
}
@@ -10,7 +10,7 @@ import type {
ApplicationApi,
SessionState,
} from "../../application/ports/in/application-api.ts";
import { useApplication } from "./application-provider.tsx";
import { useApplicationSession } from "./application-provider.tsx";
export type SessionContextValue = Readonly<{
sessionState: SessionState;
@@ -24,7 +24,7 @@ const SessionContext = createContext<SessionContextValue | null>(null);
export function SessionProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const { session } = useApplication();
const session = useApplicationSession();
const sessionState = useSyncExternalStore(
session.subscribe,
session.getSnapshot,
@@ -13,7 +13,7 @@ import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../application/policies/color-scheme.ts";
import { useApplication } from "./application-provider.tsx";
import { useApplicationPreferences } from "./application-provider.tsx";
export type ThemeContextValue = Readonly<{
preference: ColorSchemePreference;
@@ -33,7 +33,7 @@ function systemPrefersDark(): boolean {
export function ThemeProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const { preferences } = useApplication();
const preferences = useApplicationPreferences();
const [preference, updatePreference] = useState<ColorSchemePreference>(
preferences.getColorScheme,
);
+9 -3
View File
@@ -30,7 +30,11 @@ import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx
import { Button, PageHeader } from "../design-system/index.ts";
import { LocaleProvider, useLocale } from "../i18n/index.ts";
import { AppShell } from "../layouts/app-shell.tsx";
import { useApplication } from "../providers/application-provider.tsx";
import {
useApplicationDiagnostics,
useApplicationRecovery,
useApplicationRuntime,
} from "../providers/application-provider.tsx";
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
import { ThemeProvider } from "../providers/theme-provider.tsx";
import {
@@ -126,7 +130,7 @@ function RouteLifecycle({
}) {
const location = useLocation();
const { message, resolve } = useLocale();
const { diagnostics } = useApplication();
const diagnostics = useApplicationDiagnostics();
useEffect(() => {
document.title = message("route.documentTitle", {
title: resolve(`route.${definition.routeId}.title`),
@@ -269,7 +273,9 @@ function RegisteredRoute({
const params = useParams();
const [search] = useSearchParams();
const location = useLocation();
const { diagnostics, runtime: platformRuntime, recovery } = useApplication();
const diagnostics = useApplicationDiagnostics();
const platformRuntime = useApplicationRuntime();
const recovery = useApplicationRecovery();
// §3.5. A feature the runtime document disabled is out of service, not
// merely hidden: withdrawing it from navigation alone would leave a typed
// deep link that still mounts it.
@@ -0,0 +1,317 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
type ApplicationResult,
useApplicationMutation,
} from "../../src/presentation/adapters/query/application-query.ts";
import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts";
import {
RESOURCE_INVALIDATION_TOPIC,
deterministicMutationIntentFactory,
queryClient,
scopeSnapshot,
wrapper,
} from "./application-query-fixture.tsx";
describe("application mutation intent admission", () => {
it("retains optimistic data when execute throws after dispatch begins", async () => {
const client = queryClient();
const key = ["resource", "thrown-unknown-effect"];
client.setQueryData(key, ["base"]);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute: async () => {
throw new Error("private transport defect");
},
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
...(previous as string[]),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let outcome: ApplicationResult<string> | undefined;
await act(async () => {
outcome = await hook.result.current.submit("created");
});
expect(outcome).toMatchObject({
ok: false,
error: {
effect: "MAYBE_APPLIED",
retryable: false,
action: "contact-support",
},
});
expect(JSON.stringify(outcome)).not.toContain("private transport defect");
expect(client.getQueryData(key)).toEqual(["base", "created"]);
expect(hook.result.current.state.indicator).toBe(
"mutation-effect-unknown",
);
});
it("creates a distinct logical intent for each independently admitted submit", async () => {
const client = queryClient();
const scope = scopeSnapshot();
const observedIntents: unknown[] = [];
const execute = vi.fn(
async (
input: string,
context: Readonly<{ signal: AbortSignal; intent?: unknown }>,
) => {
observedIntents.push(context.intent);
return { ok: true as const, value: input };
},
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "independent-intent-v1",
definitionVersion: 1,
operationId: "CREATE_WITH_INTENT",
requiresIdempotencyKey: true,
owner: "platform-test",
duplicatePolicy: "ALLOW_PARALLEL",
scope,
execute,
invalidate: [],
}),
{ wrapper: wrapper(client) },
);
await act(async () => {
await hook.result.current.submit("same-input");
await hook.result.current.submit("same-input");
});
expect(observedIntents).toHaveLength(2);
expect(observedIntents[0]).toMatchObject({
intentId: "intent-1",
operationId: "CREATE_WITH_INTENT",
idempotencyKey: "key-1",
});
expect(observedIntents[1]).toMatchObject({
intentId: "intent-2",
operationId: "CREATE_WITH_INTENT",
idempotencyKey: "key-2",
});
expect(observedIntents[0]).not.toEqual(observedIntents[1]);
});
it("creates no second intent when JOIN_IDENTICAL shares an admitted submit", async () => {
const client = queryClient();
const scope = scopeSnapshot();
const deterministicFactory = deterministicMutationIntentFactory();
const createIntent = vi.fn(deterministicFactory.create);
const factory: MutationIntentFactory = Object.freeze({
create: createIntent,
});
let complete: (value: ApplicationResult<string>) => void = () => {};
const execute = vi.fn(
() =>
new Promise<ApplicationResult<string>>((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "joined-intent-v1",
definitionVersion: 1,
operationId: "CREATE_JOINED",
requiresIdempotencyKey: true,
owner: "platform-test",
duplicatePolicy: "JOIN_IDENTICAL",
scope,
execute,
invalidate: [],
}),
{ wrapper: wrapper(client, factory) },
);
let first: Promise<ApplicationResult<string>> | null = null;
let joined: Promise<ApplicationResult<string>> | null = null;
act(() => {
first = hook.result.current.submit("same-input");
joined = hook.result.current.submit("same-input");
});
expect(first).toBe(joined);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
expect(createIntent).toHaveBeenCalledOnce();
expect(createIntent).toHaveBeenCalledWith({
operationId: "CREATE_JOINED",
canonicalInputIdentity:
"scope-fingerprint-0001:joined-intent-v1:scope-identity-token-0001",
requiresIdempotencyKey: true,
});
complete({ ok: true, value: "same-input" });
if (!first) throw new Error("expected admitted mutation");
await act(() => first);
});
it("keeps the legacy raw mutation path outside the intent factory", async () => {
const client = queryClient();
const createIntent = vi.fn<MutationIntentFactory["create"]>();
const factory: MutationIntentFactory = Object.freeze({
create: createIntent,
});
const execute = vi.fn(async (input: string) => ({
ok: true as const,
value: input,
}));
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "legacy-raw-path-v1",
execute,
}),
{ wrapper: wrapper(client, factory) },
);
await act(() => hook.result.current.submit("legacy-input"));
expect(execute).toHaveBeenCalledOnce();
expect(createIntent).not.toHaveBeenCalled();
});
it("rejects a duplicate submit by default while one is active", async () => {
const client = queryClient();
let complete: (value: ApplicationResult<string>) => void = () => {};
const execute = vi.fn(
() =>
new Promise<ApplicationResult<string>>((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "legacy-duplicate-rejection-v1",
execute,
}),
{ wrapper: wrapper(client) },
);
let first: Promise<ApplicationResult<string>> | null = null;
let duplicate: Promise<ApplicationResult<string>> | null = null;
act(() => {
first = hook.result.current.submit("created");
duplicate = hook.result.current.submit("created");
});
if (!first || !duplicate) throw new Error("expected two submissions");
await expect(duplicate).resolves.toMatchObject({
ok: false,
error: { kind: "DUPLICATE_IN_FLIGHT" },
});
expect(execute).toHaveBeenCalledOnce();
complete({ ok: true, value: "created" });
await act(() => first as Promise<ApplicationResult<string>>);
});
it("deduplicates submit and commits one optimistic mutation", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
let complete: (value: ApplicationResult<string>) => void = () => {};
const execute = vi.fn(
() =>
new Promise<ApplicationResult<string>>((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
// §11.2: joining is opt-in. The default is REJECT_WHILE_ACTIVE.
duplicatePolicy: "JOIN_IDENTICAL",
execute,
invalidate: [RESOURCE_INVALIDATION_TOPIC],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
...(previous as string[]),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let first: Promise<ApplicationResult<string>> | null = null;
let duplicate: Promise<ApplicationResult<string>> | null = null;
act(() => {
first = hook.result.current.submit("created");
duplicate = hook.result.current.submit("created");
});
expect(first).toBe(duplicate);
await waitFor(() =>
expect(client.getQueryData(key)).toEqual(["existing", "created"]),
);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
);
complete({ ok: true, value: "created" });
if (!first) throw new Error("expected pending mutation");
await act(() => first);
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
await waitFor(() =>
expect(hook.result.current.state.indicator).toBeNull(),
);
});
it("never joins distinct mutation inputs to the same runtime promise", async () => {
const client = queryClient();
const resolvers = new Map<
string,
(value: ApplicationResult<string>) => void
>();
const execute = vi.fn(
(input: string) =>
new Promise<ApplicationResult<string>>((resolve) => {
resolvers.set(input, resolve);
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "legacy-parallel-distinct-inputs-v1",
execute,
currentData: true,
}),
{ wrapper: wrapper(client) },
);
let first: Promise<ApplicationResult<string>> | undefined;
let second: Promise<ApplicationResult<string>> | undefined;
act(() => {
first = hook.result.current.submit("first");
second = hook.result.current.submit("second");
});
expect(first).not.toBe(second);
await waitFor(() => expect(execute).toHaveBeenCalledTimes(2));
resolvers.get("first")?.({ ok: true, value: "first" });
resolvers.get("second")?.({ ok: true, value: "second" });
if (!first || !second) throw new Error("expected pending mutations");
await act(async () => {
await Promise.all([first, second]);
});
});
});
@@ -0,0 +1,213 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
type ApplicationResult,
useApplicationMutation,
} from "../../src/presentation/adapters/query/application-query.ts";
import { createFailure } from "../../src/contracts/errors.ts";
import {
RESOURCE_INVALIDATION_TOPIC,
queryClient,
wrapper,
} from "./application-query-fixture.tsx";
describe("application mutation optimistic cache lifecycle", () => {
it("cancels an in-flight query before taking the optimistic snapshot", async () => {
const client = queryClient();
const key = ["resource", "ordered-update"];
client.setQueryData(key, ["existing"]);
let finishCancellation: () => void = () => {};
const cancellation = new Promise<void>((resolve) => {
finishCancellation = resolve;
});
const cancelQueries = vi
.spyOn(client, "cancelQueries")
.mockImplementation(async () => cancellation);
const getQueryData = vi.spyOn(client, "getQueryData");
const update = vi.fn((previous, input) => [
...(previous as string[]),
input,
]);
const execute = vi.fn(async () => ({
ok: true as const,
value: "created",
}));
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute,
currentData: ["existing"],
optimistic: { queryKey: key, update },
}),
{ wrapper: wrapper(client) },
);
let pending: Promise<ApplicationResult<string>> | undefined;
act(() => {
pending = hook.result.current.submit("created");
});
expect(cancelQueries).toHaveBeenCalledWith({
queryKey: key,
exact: true,
});
expect(getQueryData).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
finishCancellation();
if (!pending) throw new Error("expected pending mutation");
await act(() => pending);
expect(getQueryData).toHaveBeenCalledWith(key);
expect(update).toHaveBeenCalledWith(["existing"], "created");
expect(execute).toHaveBeenCalledOnce();
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
});
it("keeps a committed optimistic update when invalidation fails", async () => {
const client = queryClient();
const key = ["resource", "committed-update"];
client.setQueryData(key, ["existing"]);
vi.spyOn(client, "invalidateQueries").mockRejectedValue(
new Error("cache refresh failed"),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute: async () => ({ ok: true, value: "created" }),
invalidate: [RESOURCE_INVALIDATION_TOPIC],
currentData: ["existing"],
optimistic: {
queryKey: key,
update: (previous, input) => [
...(previous as string[]),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("created");
});
expect(outcome).toEqual({ ok: true, value: "created" });
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
});
it("normalizes an optimistic preparation defect without running the command", async () => {
const client = queryClient();
const key = ["resource", "invalid-optimistic-update"];
client.setQueryData(key, ["existing"]);
const execute = vi.fn(async () => ({
ok: true as const,
value: "created",
}));
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute,
currentData: ["existing"],
optimistic: {
queryKey: key,
update: () => {
throw new Error("private optimistic detail");
},
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("created");
});
expect(outcome).toMatchObject({
ok: false,
error: {
kind: "UNKNOWN_FAILURE",
effect: "NOT_STARTED",
operationId: "APPLICATION_MUTATION",
userMessageKey: "error.unknown_failure",
},
});
expect(JSON.stringify(outcome)).not.toContain("private optimistic detail");
expect(execute).not.toHaveBeenCalled();
expect(client.getQueryData(key)).toEqual(["existing"]);
});
it("removes an optimistic cache entry when no prior data existed", async () => {
const client = queryClient();
const key = ["resource", "new-optimistic-entry"];
const failure = createFailure("SERVER_FAILURE", "CREATE", 0, {
effect: "NOT_APPLIED",
});
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute: async () => ({ ok: false, error: failure }),
currentData: true,
optimistic: {
queryKey: key,
update: (_previous, input) => [input],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("temporary");
});
expect(outcome).toEqual({ ok: false, error: failure });
expect(client.getQueryData(key)).toBeUndefined();
expect(client.getQueryState(key)).toBeUndefined();
});
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
const client = queryClient();
const key = ["resource", "list"];
client.setQueryData(key, ["existing"]);
const conflict = createFailure("CONFLICT", "CREATE", 0, {
effect: "NOT_APPLIED",
});
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
execute: async () => ({ ok: false, error: conflict }),
invalidate: [RESOURCE_INVALIDATION_TOPIC],
currentData: client.getQueryData(key),
optimistic: {
queryKey: key,
update: (previous, input) => [
...(previous as string[]),
input,
],
},
}),
{ wrapper: wrapper(client) },
);
let outcome;
await act(async () => {
outcome = await hook.result.current.submit("conflicting");
});
expect(outcome).toEqual({ ok: false, error: conflict });
expect(client.getQueryData(key)).toEqual(["existing"]);
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
expect(hook.result.current.state.overlay).toMatchObject({
mutationPending: false,
mutationConflict: true,
});
await act(() => hook.result.current.resolveConflict());
expect(hook.result.current.state.indicator).toBeNull();
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
});});
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
type ApplicationResult,
useApplicationMutation,
} from "../../src/presentation/adapters/query/application-query.ts";
import { createFailure } from "../../src/contracts/errors.ts";
import {
queryClient,
scopeSnapshot,
wrapper,
} from "./application-query-fixture.tsx";
describe("scope-bound mutation fence", () => {
it("rejects a submit whose scope is already fenced", async () => {
const client = queryClient();
const scope = scopeSnapshot();
const execute = vi.fn(async () => ({ ok: true as const, value: "v" }));
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "fenced-mutation-v1",
definitionVersion: 1,
operationId: "CREATE_FENCED",
requiresIdempotencyKey: false,
owner: "platform-test",
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute,
invalidate: [],
}),
{ wrapper: wrapper(client) },
);
scope.fence();
const outcome = await hook.result.current.submit("value");
expect(outcome).toMatchObject({
ok: false,
error: {
kind: "SCOPE_GENERATION_CHANGED",
effect: "NOT_STARTED",
},
});
expect(execute).not.toHaveBeenCalled();
});
it("discards a mutation result whose scope was fenced after dispatch", async () => {
const client = queryClient();
const scope = scopeSnapshot();
const execute = vi.fn(async () => {
scope.fence();
return { ok: true as const, value: "committed" };
});
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "late-mutation-v1",
definitionVersion: 1,
operationId: "CREATE_LATE",
requiresIdempotencyKey: false,
owner: "platform-test",
duplicatePolicy: "ALLOW_PARALLEL",
scope,
execute,
invalidate: [],
}),
{ wrapper: wrapper(client) },
);
const outcome = await hook.result.current.submit("value");
expect(outcome).toMatchObject({
ok: false,
error: { kind: "SCOPE_GENERATION_CHANGED" },
});
expect(execute).toHaveBeenCalledOnce();
});
it("aborts a hung mutation when its captured scope is fenced", async () => {
const client = queryClient();
const scope = scopeSnapshot();
let observedSignal: AbortSignal | undefined;
const execute = vi.fn(
(_input: string, context: Readonly<{ signal: AbortSignal }>) =>
new Promise<ApplicationResult<string>>((resolve) => {
observedSignal = context.signal;
context.signal.addEventListener(
"abort",
() =>
resolve({
ok: false,
error: createFailure("REQUEST_ABORTED", "CREATE_HUNG", 0),
}),
{ once: true },
);
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
definitionId: "hung-mutation-v1",
definitionVersion: 1,
operationId: "CREATE_HUNG",
requiresIdempotencyKey: false,
owner: "platform-test",
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute,
invalidate: [],
}),
{ wrapper: wrapper(client) },
);
const outcome = hook.result.current.submit("value");
await waitFor(() => expect(observedSignal).toBe(scope.signal));
scope.fence();
expect(observedSignal?.aborted).toBe(true);
await expect(outcome).resolves.toMatchObject({
ok: false,
error: {
kind: "SCOPE_GENERATION_CHANGED",
effect: "MAYBE_APPLIED",
retryable: false,
action: "contact-support",
},
});
});
});
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
type ApplicationResult,
useApplicationQuery,
} from "../../src/presentation/adapters/query/application-query.ts";
import { createFailure } from "../../src/contracts/errors.ts";
import { queryClient, wrapper } from "./application-query-fixture.tsx";
describe("application query inbound bridge", () => {
it("latches a background failure over stale data and clears it on retry success", async () => {
const client = queryClient();
const responses: ApplicationResult<string[]>[] = [
{ ok: true, value: ["first"] },
{
ok: false,
error: createFailure("SERVER_FAILURE", "LIST", 0),
},
{ ok: true, value: ["recovered"] },
];
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["resource", "list"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
);
expect(hook.result.current.state.base).toBe("success");
expect(hook.result.current.data).toEqual(["first"]);
await act(() => hook.result.current.retry());
await waitFor(() =>
expect(hook.result.current.data).toEqual(["recovered"]),
);
expect(hook.result.current.state.indicator).toBeNull();
expect(execute).toHaveBeenCalledTimes(3);
});
it("projects an initial application failure into terminal state", async () => {
const client = queryClient();
const failure = createFailure("FORBIDDEN", "LIST", 0);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["forbidden"],
execute: async () => ({ ok: false, error: failure }),
}),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.base).toBe("terminal-error"),
);
expect(hook.result.current.state.failure).toBe(failure);
});
it("normalizes an unexpected execute rejection into a safe terminal failure", async () => {
const client = queryClient();
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["unexpected-rejection"],
execute: async () => {
throw new Error("private upstream detail");
},
}),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.base).toBe("terminal-error"),
);
expect(hook.result.current.state.failure).toMatchObject({
kind: "UNKNOWN_FAILURE",
operationId: "APPLICATION_QUERY",
userMessageKey: "error.unknown_failure",
action: "contact-support",
});
expect(JSON.stringify(hook.result.current.state.failure)).not.toContain(
"private upstream detail",
);
});
it("passes cancellation to the application and does not retain an unmounted error", async () => {
const client = queryClient();
let aborted = false;
const execute = vi.fn(
({ signal }: { signal: AbortSignal }) =>
new Promise<ApplicationResult<unknown>>((resolve) => {
signal.addEventListener(
"abort",
() => {
aborted = true;
resolve({
ok: false,
error: createFailure("REQUEST_ABORTED", "LIST", 0),
});
},
{ once: true },
);
}),
);
const hook = renderHook(
() =>
useApplicationQuery({
queryKey: ["cancelled"],
execute,
}),
{ wrapper: wrapper(client) },
);
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
hook.unmount();
await waitFor(() => expect(aborted).toBe(true));
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
});
});
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts";
import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx";
import { MutationIntentProvider } from "../../src/presentation/adapters/query/mutation-intent-provider.tsx";
import {
defineQueryInvalidationTopic,
type QueryInvalidationCoordinator,
} from "../../src/contracts/query-invalidation.ts";
import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts";
import type { QueryResultMeasure } from "../../src/contracts/server-state.ts";
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
export const RESOURCE_INVALIDATION_TOPIC =
defineQueryInvalidationTopic("resource");
export function scopeSnapshot(
generation = 1,
fingerprint = "scope-fingerprint-0001",
): CacheScopeSnapshot & { fence(): void } {
let current = true;
const lifetime = new AbortController();
const identities = createRuntimeIdentityRegistry({
tokenFactory: () => "scope-identity-token-0001",
});
return {
generation,
fingerprint,
identities,
signal: lifetime.signal,
isCurrent: () => current,
fence() {
current = false;
lifetime.abort();
},
};
}
export function measureOne(): QueryResultMeasure {
return { itemCount: 1, estimatedBytes: 8 };
}
export function queryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, gcTime: Infinity },
mutations: { retry: false },
},
});
}
export function deterministicMutationIntentFactory(): MutationIntentFactory {
let sequence = 0;
return Object.freeze({
create(input) {
sequence += 1;
return Object.freeze({
intentId: `intent-${sequence}`,
operationId: input.operationId,
canonicalInputIdentity: input.canonicalInputIdentity,
...(input.requiresIdempotencyKey
? { idempotencyKey: `key-${sequence}` }
: {}),
createdAtMonotonicMs: sequence,
});
},
});
}
export function wrapper(
client: QueryClient,
mutationIntentFactory = deterministicMutationIntentFactory(),
) {
const coordinator: QueryInvalidationCoordinator = {
async invalidate(topics) {
for (const topic of topics) {
await client.invalidateQueries({
queryKey: [topic],
exact: false,
refetchType: "active",
});
}
},
beginMutation() {
return { release: async () => {} };
},
async resetLocal() {
await client.cancelQueries();
client.clear();
},
dispose() {},
};
return function QueryWrapper({ children }: { children: ReactNode }) {
return (
<MutationIntentProvider factory={mutationIntentFactory}>
<QueryClientProvider client={client}>
<QueryInvalidationProvider coordinator={coordinator}>
{children}
</QueryInvalidationProvider>
</QueryClientProvider>
</MutationIntentProvider>
);
};
}
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
type ApplicationResult,
useApplicationQuery,
} from "../../src/presentation/adapters/query/application-query.ts";
import {
createQueryInvalidationPrefix,
defineQueryNamespaceIdentity,
} from "../../src/contracts/query-keys.ts";
import {
bindQuery,
type QueryResultMeasure,
} from "../../src/contracts/server-state.ts";
import {
measureOne,
queryClient,
scopeSnapshot,
wrapper,
} from "./application-query-fixture.tsx";
describe("scope-bound query commit fence", () => {
it("binds the namespace-first V2 query key", () => {
const scope = scopeSnapshot();
const definition = {
definitionId: "resource-detail-v1",
definitionVersion: 1,
owner: "platform-test",
namespace: "resource",
namespaceVersion: 1,
operationId: "GET_RESOURCE",
profileId: "DETAIL_STANDARD" as const,
measureResult: measureOne,
execute: async () => ({ ok: true as const, value: "value" }),
};
const bound = bindQuery(definition, "resource-1", scope);
expect(bound.queryKey).toEqual([
"query",
2,
"resource",
1,
"scope-fingerprint-0001",
1,
"scope-identity-token-0001",
]);
expect(bound.queryKey.slice(0, 4)).toEqual(
createQueryInvalidationPrefix(
defineQueryNamespaceIdentity("resource", 1),
),
);
});
it("discards a successful result whose scope was fenced during execution", async () => {
const client = queryClient();
const scope = scopeSnapshot();
let complete: (value: ApplicationResult<string>) => void = () => {};
const hook = renderHook(
() =>
useApplicationQuery(
bindQuery(
{
definitionId: "fenced-detail-v1",
definitionVersion: 1,
owner: "platform-test",
namespace: "fenced",
namespaceVersion: 1,
operationId: "GET_FENCED",
profileId: "DETAIL_STANDARD",
measureResult: measureOne,
execute: () =>
new Promise<ApplicationResult<string>>((resolve) => {
complete = resolve;
}),
},
"input",
scope,
),
),
{ wrapper: wrapper(client) },
);
// §10.8: the scope goes stale after dispatch but before commit.
scope.fence();
await act(async () => {
complete({ ok: true, value: "late" });
});
await waitFor(() =>
expect(hook.result.current.state.failure?.kind).toBe(
"SCOPE_GENERATION_CHANGED",
),
);
expect(hook.result.current.data).toBeUndefined();
});
it("refuses to start when the captured scope is already stale", async () => {
const client = queryClient();
const scope = scopeSnapshot();
const execute = vi.fn(async () => ({ ok: true as const, value: "v" }));
scope.fence();
const hook = renderHook(
() =>
useApplicationQuery(
bindQuery(
{
definitionId: "stale-detail-v1",
definitionVersion: 1,
owner: "platform-test",
namespace: "stale",
namespaceVersion: 1,
operationId: "GET_STALE",
profileId: "DETAIL_STANDARD",
measureResult: measureOne,
execute,
},
"input",
scope,
),
),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.failure?.kind).toBe(
"SCOPE_GENERATION_CHANGED",
),
);
expect(execute).not.toHaveBeenCalled();
});
it("rejects a result that exceeds the profile budget instead of caching it", async () => {
const client = queryClient();
const scope = scopeSnapshot();
const hook = renderHook(
() =>
useApplicationQuery(
bindQuery(
{
definitionId: "oversized-list-v1",
definitionVersion: 1,
owner: "platform-test",
namespace: "oversized",
namespaceVersion: 1,
operationId: "LIST_OVERSIZED",
profileId: "VOLATILE_STATUS",
// §10.4: VOLATILE_STATUS admits 1 item and 64KiB.
measureResult: (): QueryResultMeasure => ({
itemCount: 2,
estimatedBytes: 8,
}),
execute: async () => ({ ok: true as const, value: "value" }),
},
"input",
scope,
),
),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.failure?.kind).toBe(
"RESULT_LIMIT_EXCEEDED",
),
);
expect(hook.result.current.data).toBeUndefined();
});
it("treats a throwing measurement as a measurement failure, not a cache commit", async () => {
const client = queryClient();
const scope = scopeSnapshot();
const hook = renderHook(
() =>
useApplicationQuery(
bindQuery(
{
definitionId: "unmeasurable-v1",
definitionVersion: 1,
owner: "platform-test",
namespace: "unmeasurable",
namespaceVersion: 1,
operationId: "GET_UNMEASURABLE",
profileId: "DETAIL_STANDARD",
measureResult: (): QueryResultMeasure => {
throw new Error("estimator defect");
},
execute: async () => ({ ok: true as const, value: "value" }),
},
"input",
scope,
),
),
{ wrapper: wrapper(client) },
);
await waitFor(() =>
expect(hook.result.current.state.failure?.kind).toBe(
"RESULT_LIMIT_EXCEEDED",
),
);
expect(hook.result.current.data).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
import type {
BrowserDataResult,
IndexedDbRepositoryPort,
} from "../../../../src/application/ports/browser-file-storage/index.ts";
export type LocalDraft = Readonly<{
draftId: string;
title: string;
body: string;
}>;
export type SaveLocalDraftCommand = Readonly<{
draft: LocalDraft;
expectedRevision: number | null;
idempotencyKey: string;
}>;
export type RemoveLocalDraftCommand = Readonly<{
draftId: string;
expectedRevision: number;
idempotencyKey: string;
}>;
export type LocalDraftRecord = Readonly<{
draft: LocalDraft;
revision: number;
}>;
export interface LocalDraftStore {
save(
command: SaveLocalDraftCommand,
signal?: AbortSignal,
): Promise<BrowserDataResult<Readonly<{ revision: number }>>>;
find(
draftId: string,
signal?: AbortSignal,
): Promise<BrowserDataResult<LocalDraftRecord | null>>;
remove(
command: RemoveLocalDraftCommand,
signal?: AbortSignal,
): Promise<BrowserDataResult<void>>;
}
/**
* Feature-owned binding over the technology-neutral IndexedDB application port.
*
* The feature knows its domain type and optimistic concurrency inputs. It does
* not know database names, stores, transactions, native IDB objects, codecs,
* migrations, quota handling or connection lifecycle.
*/
export function createLocalDraftStore(
repository: IndexedDbRepositoryPort<LocalDraft, never>,
): LocalDraftStore {
const store: LocalDraftStore = {
async save(command, signal) {
const result = await repository.compareAndSwap({
key: command.draft.draftId,
value: command.draft,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
if (!result.ok) return result;
return Object.freeze({
ok: true as const,
value: Object.freeze({ revision: result.value.revision }),
});
},
async find(draftId, signal) {
const result = await repository.read(draftId, signal);
if (!result.ok) return result;
return Object.freeze({
ok: true as const,
value:
result.value === null
? null
: Object.freeze({
draft: result.value.value,
revision: result.value.revision,
}),
});
},
async remove(command, signal) {
const result = await repository.remove({
key: command.draftId,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
if (!result.ok) return result;
return Object.freeze({ ok: true as const, value: undefined });
},
};
return Object.freeze(store);
}
@@ -0,0 +1,161 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import type {
BrowserDataResult,
IndexedDbRepositoryPort,
} from "../../../src/application/ports/browser-file-storage/index.ts";
import {
createLocalDraftStore,
type LocalDraft,
} from "./fixtures/local-draft-feature.ts";
function success<Value>(value: Value): BrowserDataResult<Value> {
return Object.freeze({ ok: true as const, value });
}
function repositoryFixture(): IndexedDbRepositoryPort<LocalDraft, never> {
let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null;
const repository: IndexedDbRepositoryPort<LocalDraft, never> = {
async open() {
return success(undefined);
},
async read(key) {
if (stored === null || stored.value.draftId !== key) {
return success(null);
}
return success(stored);
},
async query() {
return success(Object.freeze({ items: [], nextCursor: null }));
},
async compareAndSwap(input) {
const currentRevision = stored?.revision ?? null;
if (currentRevision !== input.expectedRevision) {
return Object.freeze({
ok: false as const,
error: Object.freeze({
code: "CONFLICT" as const,
operation: "INDEXEDDB_WRITE" as const,
retryable: false,
recovery: "RECONCILE" as const,
}),
});
}
const revision = (currentRevision ?? 0) + 1;
stored = Object.freeze({ value: input.value, revision });
return success(
Object.freeze({
key: input.key,
revision,
replayed: false,
}),
);
},
async remove(input) {
if (stored === null || stored.revision !== input.expectedRevision) {
return Object.freeze({
ok: false as const,
error: Object.freeze({
code: "CONFLICT" as const,
operation: "INDEXEDDB_WRITE" as const,
retryable: false,
recovery: "RECONCILE" as const,
}),
});
}
stored = null;
return success(
Object.freeze({
key: input.key,
revision: input.expectedRevision + 1,
replayed: false,
}),
);
},
async enforceLifecycleBatch() {
stored = null;
return success(
Object.freeze({
state: "COMPLETE" as const,
scannedRows: 0,
deletedRows: 0,
budgetExhausted: false,
}),
);
},
getStatus() {
return Object.freeze({ kind: "READY" as const, schemaVersion: 1 });
},
subscribeStatus() {
return () => {};
},
close() {},
};
return Object.freeze(repository);
}
describe("IndexedDB local-draft consumer experience", () => {
it("implements save/find/remove through the public application port", async () => {
const store = createLocalDraftStore(repositoryFixture());
const draft = Object.freeze({
draftId: "draft-1",
title: "Architecture notes",
body: "Feature code owns the draft model.",
});
await expect(
store.save({
draft,
expectedRevision: null,
idempotencyKey: "draft-save-0001",
}),
).resolves.toEqual({ ok: true, value: { revision: 1 } });
await expect(store.find("draft-1")).resolves.toEqual({
ok: true,
value: { draft, revision: 1 },
});
await expect(
store.remove({
draftId: "draft-1",
expectedRevision: 1,
idempotencyKey: "draft-remove-0001",
}),
).resolves.toEqual({ ok: true, value: undefined });
await expect(store.find("draft-1")).resolves.toEqual({
ok: true,
value: null,
});
});
it("keeps native IndexedDB and runtime internals out of feature-owned code", async () => {
const source = await readFile(
new URL("./fixtures/local-draft-feature.ts", import.meta.url),
"utf8",
);
const importLines = source
.split("\n")
.filter((line) => line.startsWith("import "));
expect(importLines).toHaveLength(1);
expect(source).toContain(
"src/application/ports/browser-file-storage/index.ts",
);
for (const forbidden of [
"src/adapters/storage/indexeddb",
"globalThis.indexedDB",
"IDBFactory",
"IDBDatabase",
"IDBTransaction",
"IDBObjectStore",
]) {
expect(source).not.toContain(forbidden);
}
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import {
createFeatureHttpBinding,
defineFeatureHttpOperation,
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import {
mappingFailure,
mappingSuccess,
} from "../../../src/contracts/boundary-mapper.ts";
type Resource = Readonly<{ id: string; title: string }>;
const OPERATIONS = Object.freeze({
LOAD_RESOURCE: defineFeatureHttpOperation<
Readonly<{ resourceId: string }>,
Resource
>({
operationId: "LOAD_RESOURCE",
routeId: "RESOURCE_DETAIL",
mapSuccess(value) {
if (
!value ||
typeof value !== "object" ||
typeof (value as Record<string, unknown>).id !== "string" ||
typeof (value as Record<string, unknown>).title !== "string"
) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const candidate = value as Readonly<{ id: string; title: string }>;
return mappingSuccess(
Object.freeze({ id: candidate.id, title: candidate.title }),
);
},
}),
} as const);
describe("feature HTTP binding", () => {
it("keeps typed feature input while platform owns route/context execution", async () => {
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>(
async (_operationId, input, context) => {
expect(input).toEqual({ resourceId: "resource-1" });
expect(context.routeId).toBe("RESOURCE_DETAIL");
return Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ id: "resource-1", title: "Reference" }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
);
const binding = createFeatureHttpBinding(
Object.freeze({ execute }),
OPERATIONS,
);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result).toEqual({
ok: true,
value: { id: "resource-1", title: "Reference" },
});
expect(execute).toHaveBeenCalledTimes(1);
});
it("normalizes transport failure before it crosses the feature gateway", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({
async execute() {
return Object.freeze({
kind: "TRANSPORT_FAILURE" as const,
failure: Object.freeze({
kind: "TIMEOUT" as const,
retryable: true,
}),
effect: "NOT_STARTED" as const,
});
},
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.kind).toBe("REQUEST_TIMEOUT");
expect(result.error.operationId).toBe("LOAD_RESOURCE");
expect(result.error.effect).toBe("NOT_STARTED");
});
it("turns feature mapper rejection into the shared mapping failure", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({
async execute() {
return Object.freeze({
kind: "SUCCESS" as const,
value: Object.freeze({ unexpected: true }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
});
});
@@ -1,61 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../../../src/adapters/http/client.ts";
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
import {
createReferenceHttpGateway,
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
import { createReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.ts";
import { REFERENCE_FEATURE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
import { mapReferenceOperation } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
import {
validateReferencePayload,
validateReferenceRequest,
} from "../../../src/features/reference-feature/contracts/reference-schemas.ts";
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
describe("reference feature diagnostics correlation", () => {
it("preserves route, operation and request correlation through the vertical path", async () => {
function scopeSnapshot() {
return Object.freeze({
generation: 1,
fingerprint: "reference-scope",
identities: Object.freeze({}) as never,
signal: new AbortController().signal,
isCurrent: () => true,
});
}
describe("reference feature HTTP diagnostics", () => {
it("preserves route and operation identity through the installed V3 path", async () => {
const record = vi.fn();
const operations =
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<
Record<
string,
ReturnType<
NonNullable<Parameters<typeof createHttpClient>[0]["getOperation"]>
>
>
>;
const client = createHttpClient({
const contractHttp = createContractHttpExecutor({
baseUrl: "https://api.test",
authSession: createDemoSessionAdapter("authenticated"),
fetcher: async () =>
Response.json({
success: true,
data: [{ id: "reference-1", name: "Reference" }],
meta: { requestId: "safe-request", traceId: "safe-trace" },
}),
getOperation(operationId) {
const operation = operations[operationId];
if (!operation) throw new Error("Unregistered reference operation");
return operation;
},
validatePayload: validateReferencePayload,
validateRequest: validateReferenceRequest,
mapPayload: mapReferenceOperation,
correlationIdFactory: () => "reference-correlation",
diagnostics: { record },
scheduler: {
setTimeout: () => 1,
clearTimeout: () => {},
},
maxRetryAttempts: 0,
attachCredentials: () => ({
kind: "READY" as const,
headers: { authorization: "Bearer diagnostics-test-token" },
}),
fetcher: (async () =>
Response.json([
{ id: "reference-1", name: "Reference" },
])) as unknown as typeof fetch,
observe: createHttpObservationProjector({
diagnostics: { record },
telemetry: { emit: vi.fn() },
}),
});
const application = createReferenceFeatureInput(
createReferenceHttpGateway(client),
const operations = new Map(
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [
operation.contract.operationId,
operation,
]),
);
const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({
async execute(operationId, input, context) {
const operation = operations.get(operationId);
if (!operation) throw new Error("Unregistered reference operation");
return contractHttp.execute(operation, input, {
routeId: context.routeId,
scope: scopeSnapshot(),
...(context.signal === undefined
? {}
: { signal: context.signal }),
...(context.intent === undefined
? {}
: { intent: context.intent }),
});
},
}),
});
await expect(
application.listResources({ limit: 20 }),
installed.input.listResources({ limit: 20 }),
).resolves.toMatchObject({ ok: true });
expect(record).toHaveBeenCalledOnce();
expect(record).toHaveBeenCalledWith({
level: "info",
@@ -63,8 +70,7 @@ describe("reference feature diagnostics correlation", () => {
context: expect.objectContaining({
route_id: "REFERENCE_RESOURCE_LIST",
operation_id: "LIST_REFERENCE_RESOURCES",
correlation_id: "reference-correlation",
outcome: "success",
outcome: "SUCCESS",
}),
});
});
@@ -1,9 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { createFailure } from "../../../src/contracts/errors.ts";
import type { Result } from "../../../src/contracts/result.ts";
import { createFailure, type ApiFailure } from "../../../src/contracts/errors.ts";
import {
createReferenceHttpGateway,
type RawReferenceHttpExecutor,
type ReferenceHttpBinding,
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
import type { ReferenceResource } from "../../../src/features/reference-feature/domain/reference-resource.ts";
@@ -20,14 +21,42 @@ const resources = Object.freeze({
}),
}) satisfies Readonly<Record<string, ReferenceResource>>;
type ScriptedResult = Result<
ReferenceResource | readonly ReferenceResource[],
ApiFailure
>;
function scriptedBinding(results: ScriptedResult[]) {
const calls: Array<
readonly [operationId: string, input: unknown, context: unknown]
> = [];
let index = 0;
const execute = (async (
operationId: string,
input: unknown,
context?: unknown,
) => {
calls.push([operationId, input, context]);
const result = results[index];
index += 1;
if (!result) throw new Error("Missing scripted result");
return result;
}) as ReferenceHttpBinding["execute"];
return {
binding: Object.freeze({ execute }) satisfies ReferenceHttpBinding,
calls,
};
}
describe("reference HTTP operation gateway", () => {
it("builds the exact registered request for every gateway operation", async () => {
const execute = vi
.fn<RawReferenceHttpExecutor["execute"]>()
.mockResolvedValueOnce({ ok: true, value: [resources.first] })
.mockResolvedValueOnce({ ok: true, value: resources.created })
.mockResolvedValueOnce({ ok: true, value: resources.first });
const gateway = createReferenceHttpGateway({ execute });
it("delegates exact typed feature inputs to the capability binding", async () => {
const scripted = scriptedBinding([
{ ok: true, value: [resources.first] },
{ ok: true, value: resources.created },
{ ok: true, value: resources.first },
]);
const gateway = createReferenceHttpGateway(scripted.binding);
const signal = new AbortController().signal;
await expect(
@@ -40,67 +69,37 @@ describe("reference HTTP operation gateway", () => {
gateway.get("reference-1", { signal }),
).resolves.toEqual({ ok: true, value: resources.first });
expect(execute.mock.calls).toEqual([
expect(scripted.calls).toEqual([
[
{
operationId: "LIST_REFERENCE_RESOURCES",
routeId: "REFERENCE_RESOURCE_LIST",
searchParams: {
cursor: "next",
limit: 20,
tags: ["active"],
},
signal,
},
"LIST_REFERENCE_RESOURCES",
{ cursor: "next", limit: 20, tags: ["active"] },
{ signal },
],
[
{
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
body: { name: "Created", note: "safe note" },
},
"CREATE_REFERENCE_RESOURCE",
{ name: "Created", note: "safe note" },
undefined,
],
[
{
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL",
pathParams: { resourceId: "reference-1" },
signal,
},
"GET_REFERENCE_RESOURCE",
{ resourceId: "reference-1" },
{ signal },
],
]);
});
it("preserves a validated raw failure without casting it into success", async () => {
it("preserves a capability-normalized failure across the feature gateway", async () => {
const failure = createFailure(
"SCHEMA_MISMATCH",
"GET_REFERENCE_RESOURCE",
0,
);
const execute = vi
.fn<RawReferenceHttpExecutor["execute"]>()
.mockResolvedValue({ ok: false, error: failure });
const gateway = createReferenceHttpGateway({ execute });
const scripted = scriptedBinding([{ ok: false, error: failure }]);
const gateway = createReferenceHttpGateway(scripted.binding);
await expect(gateway.get("invalid")).resolves.toEqual({
ok: false,
error: failure,
});
});
it("fails closed when a raw success does not match its operation result", async () => {
const execute = vi
.fn<RawReferenceHttpExecutor["execute"]>()
.mockResolvedValue({ ok: true, value: { id: "not-a-list" } });
const gateway = createReferenceHttpGateway({ execute });
await expect(gateway.list({ limit: 20 })).resolves.toMatchObject({
ok: false,
error: {
kind: "MAPPING_CONTRACT_VIOLATION",
code: "BOUND_RESULT_TYPE_MISMATCH",
operationId: "LIST_REFERENCE_RESOURCES",
},
});
});
});
+6 -7
View File
@@ -1,9 +1,8 @@
import type { RawReferenceHttpExecutor } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
import type { ReferenceHttpBinding } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
declare const http: RawReferenceHttpExecutor;
declare const http: ReferenceHttpBinding;
http.execute({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
pathParams: { resourceId: "resource-1" },
});
const listInput = { limit: 20 };
// GET is bound to { resourceId: string }; list input must be rejected.
http.execute("GET_REFERENCE_RESOURCE", listInput);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Result } from "../../../src/application/result.ts";
import type { Result } from "../../../src/contracts/result.ts";
export function invalidUnwrap(result: Result<number, string>): number {
return result.value;
@@ -11,41 +11,41 @@ import { afterAll, afterEach, describe, expect, it } from "vitest";
import type {
CiGateArtifact,
CiGateArtifactSchema,
} from "../../scripts/contracts/ci-gates.ts";
import { parseCiGateContract } from "../../scripts/contracts/ci-gates.ts";
} from "../../../scripts/contracts/ci-gates.ts";
import { parseCiGateContract } from "../../../scripts/contracts/ci-gates.ts";
import {
hasCiArtifactSemanticValidator,
readBoundedRegularFile,
validateCiArtifact,
} from "../../scripts/lib/ci-artifact-validator.ts";
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts";
import { copyReleaseEvidenceTree } from "../../scripts/lib/removal-fixture.ts";
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
} from "../../../scripts/lib/ci-artifact-validator.ts";
import { writeCiGateLogAtomic } from "../../../scripts/lib/ci-gate-log.ts";
import { linkFixtureNodeModules } from "../../../scripts/lib/fixture-node-modules.ts";
import { copyReleaseEvidenceTree } from "../../../scripts/lib/removal-fixture.ts";
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../../scripts/lib/ci-candidate-archive.ts";
import {
CANDIDATE_ARCHIVE_USAGE,
parseCandidateArchiveArguments,
} from "../../scripts/lib/ci-candidate-archive-cli.ts";
import { validateProviderUpload } from "../../scripts/lib/provider-upload-validator.ts";
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
} from "../../../scripts/lib/ci-candidate-archive-cli.ts";
import { validateProviderUpload } from "../../../scripts/lib/provider-upload-validator.ts";
import { verifyExactPromotionBundle } from "../../../scripts/lib/exact-promotion-bundle.ts";
import {
cleanupFinalizedPromotion,
stageVerifiedPromotion,
} from "../../scripts/lib/promotion-stager.ts";
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
} from "../../../scripts/lib/promotion-stager.ts";
import { PROMOTED_FILE_NAMES } from "../../../scripts/contracts/promotion-artifacts.ts";
import {
providerEvidenceSignaturePayload,
providerPublicKeyFingerprint,
providerVerificationArtifactSchema,
} from "../../scripts/lib/provider-evidence.ts";
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
} from "../../../scripts/lib/provider-evidence.ts";
import { localEvidenceAssessmentArtifactSchema } from "../../../scripts/contracts/release-artifacts.ts";
import { readProviderTrust } from "../../../scripts/lib/provider-trust.ts";
import {
createReleaseCandidateManifest,
LOCAL_EVIDENCE_ASSESSMENT_PATH,
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
} from "../../scripts/lib/release-candidate.ts";
} from "../../../scripts/lib/release-candidate.ts";
/**
* Budget for the provider suites specifically. They spawn a systemd scope, a
@@ -0,0 +1,168 @@
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadCiGateContract } from "../../../scripts/contracts/ci-gates.ts";
const temporaryRoots: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryRoots.splice(0).map((root) =>
rm(root, { recursive: true, force: true }),
),
);
});
describe("CI-runner command-generated evidence freshness", () => {
it("rejects stale command-generated evidence from a successful no-op producer", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const command = contract.commands.find(
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
);
command.script = "test:stale-evidence-noop";
const evidence = contract.artifacts.find(
(entry: Record<string, any>) =>
entry.path === "artifacts/tests/runtime-schema.xml",
);
const packageDocument = JSON.parse(
await readFile("package.json", "utf8"),
) as { scripts: Record<string, string> };
packageDocument.scripts[command.script] = "true";
await writeFile(
path.join(root, "config/ci/gates.json"),
`${JSON.stringify(contract)}\n`,
);
await writeFile(
path.join(root, "package.json"),
`${JSON.stringify(packageDocument)}\n`,
);
await writeFile(
path.join(root, evidence.path),
'<testsuite name="stale" tests="0" failures="0"/>\n',
);
const result = spawnSync(
process.execPath,
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
);
expect(result.status).toBe(1);
expect(
await readFile(
path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"),
"utf8",
),
).toMatch(/not freshly produced/i);
});
it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => {
const root = await mkdtemp(
path.join(tmpdir(), "ci-gate-identical-rewrite-"),
);
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const command = contract.commands.find(
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
);
command.script = "test:identical-evidence-rewrite";
const evidence = contract.artifacts.find(
(entry: Record<string, any>) =>
entry.path === "artifacts/tests/runtime-schema.xml",
);
const evidenceBytes =
'<testsuite name="deterministic" tests="0" failures="0"/>\n';
const packageDocument = JSON.parse(
await readFile("package.json", "utf8"),
) as { scripts: Record<string, string> };
packageDocument.scripts[command.script] =
`node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`;
await writeFile(
path.join(root, "config/ci/gates.json"),
`${JSON.stringify(contract)}\n`,
);
await writeFile(
path.join(root, "package.json"),
`${JSON.stringify(packageDocument)}\n`,
);
await writeFile(path.join(root, evidence.path), evidenceBytes);
const result = spawnSync(
process.execPath,
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/);
});
});
describe("CI-runner gate output budget", () => {
it("caps aggregate gate output at the log schema before later commands can accumulate", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-output-budget-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const gate = contract.gates.find(
(entry: Record<string, any>) => entry.id === "FE-GATE-001",
);
const command = contract.commands.find(
(entry: Record<string, any>) => entry.id === gate.commandIds[0],
);
command.script = "test:huge-output";
const logArtifact = contract.artifacts.find(
(entry: Record<string, any>) => entry.id === gate.logArtifactId,
);
const logSchema = contract.artifactSchemas.find(
(entry: Record<string, any>) => entry.id === logArtifact.schemaId,
);
logSchema.maxBytes = 8_192;
const packageDocument = JSON.parse(
await readFile("package.json", "utf8"),
) as { scripts: Record<string, string> };
packageDocument.scripts["test:huge-output"] =
"node -e \"process.stdout.write('x'.repeat(20000))\"";
await writeFile(
path.join(root, "config/ci/gates.json"),
`${JSON.stringify(contract)}\n`,
);
await writeFile(
path.join(root, "package.json"),
`${JSON.stringify(packageDocument)}\n`,
);
const result = spawnSync(
process.execPath,
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-001"],
{
cwd: root,
encoding: "utf8",
env: { ...process.env, CI: "false" },
timeout: 15_000,
},
);
expect(result.status).toBe(1);
const log = await readFile(path.join(root, logArtifact.path));
expect(log.byteLength).toBeLessThanOrEqual(8_192);
expect(log.toString("utf8")).toMatch(
/aggregate output|INFRASTRUCTURE_FAILURE/i,
);
}, 20_000);
});
@@ -0,0 +1,41 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { runProviderProcess } from "../../../scripts/lib/provider-process-runner.ts";
describe("CI-runner provider process-group lifecycle", () => {
it("kills and reaps a stubborn provider process group including its descendant", async () => {
const root = await mkdtemp(path.join(tmpdir(), "provider-process-group-"));
const descendantPidPath = path.join(root, "descendant.pid");
try {
const source = [
"const { spawn } = require('node:child_process');",
"const { writeFileSync } = require('node:fs');",
"const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'ignore' });",
"writeFileSync(process.env.DESCENDANT_PID_PATH, String(child.pid));",
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
const running = runProviderProcess({
executable: process.execPath,
arguments: ["-e", source],
environment: {
PATH: process.env.PATH,
DESCENDANT_PID_PATH: descendantPidPath,
},
timeoutMs: 250,
});
await expect(running).rejects.toThrow(/timed out.*process close/u);
const descendantPid = Number(await readFile(descendantPidPath, "utf8"));
expect(Number.isSafeInteger(descendantPid) && descendantPid > 0).toBe(true);
expect(() => process.kill(descendantPid, 0)).toThrow(/ESRCH|no such process/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
@@ -9,7 +9,7 @@ import {
type BrowserRpcStreamFrame,
type BrowserRpcTransport,
} from "../../../src/adapters/browser-rpc/index.ts";
import type { Result } from "../../../src/application/result.ts";
import type { Result } from "../../../src/contracts/result.ts";
import type { AppFailure } from "../../../src/contracts/errors.ts";
import {
MAPPERS,
+4 -104
View File
@@ -189,11 +189,11 @@ describe("CI gate contract", () => {
),
);
expect(contract.jobs).toHaveLength(9);
expect(contract.commands).toHaveLength(82);
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94);
expect(contract.commands).toHaveLength(84);
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96);
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(86);
expect(contract.artifacts).toHaveLength(107);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
expect(contract.artifacts).toHaveLength(109);
expect(contract.stages).toHaveLength(5);
expect(contract.retention.classes).toHaveLength(5);
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
@@ -1335,106 +1335,6 @@ describe("CI gate contract", () => {
);
});
it("caps aggregate gate output at the log schema before later commands can accumulate", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-output-budget-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
const contract = JSON.parse(JSON.stringify(await loadCiGateContract(process.cwd()))) as Record<string, any>;
const gate = contract.gates.find((entry: any) => entry.id === "FE-GATE-001");
const command = contract.commands.find((entry: any) => entry.id === gate.commandIds[0]);
command.script = "test:huge-output";
const logArtifact = contract.artifacts.find((entry: any) => entry.id === gate.logArtifactId);
const logSchema = contract.artifactSchemas.find((entry: any) => entry.id === logArtifact.schemaId);
logSchema.maxBytes = 8_192;
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { scripts: Record<string, string> };
packageDocument.scripts["test:huge-output"] = "node -e \"process.stdout.write('x'.repeat(20000))\"";
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
const environment = { ...process.env, CI: "false" };
const result = spawnSync(process.execPath, [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-001"], {
cwd: root,
encoding: "utf8",
env: environment,
timeout: 15_000,
});
expect(result.status).toBe(1);
const log = await readFile(path.join(root, logArtifact.path));
expect(log.byteLength).toBeLessThanOrEqual(8_192);
expect(log.toString("utf8")).toMatch(/aggregate output|INFRASTRUCTURE_FAILURE/i);
}, 20_000);
it("rejects stale command-generated evidence from a successful no-op producer", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const command = contract.commands.find(
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
);
command.script = "test:stale-evidence-noop";
const evidence = contract.artifacts.find(
(entry: Record<string, any>) => entry.path === "artifacts/tests/runtime-schema.xml",
);
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
scripts: Record<string, string>;
};
packageDocument.scripts[command.script] = "true";
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
await writeFile(
path.join(root, evidence.path),
'<testsuite name="stale" tests="0" failures="0"/>\n',
);
const result = spawnSync(
process.execPath,
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
);
expect(result.status).toBe(1);
expect(
await readFile(path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"), "utf8"),
).toMatch(/not freshly produced/i);
});
it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-identical-rewrite-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const command = contract.commands.find(
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
);
command.script = "test:identical-evidence-rewrite";
const evidence = contract.artifacts.find(
(entry: Record<string, any>) => entry.path === "artifacts/tests/runtime-schema.xml",
);
const evidenceBytes = '<testsuite name="deterministic" tests="0" failures="0"/>\n';
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
scripts: Record<string, string>;
};
packageDocument.scripts[command.script] =
`node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`;
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
await writeFile(path.join(root, evidence.path), evidenceBytes);
const result = spawnSync(
process.execPath,
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/);
});
});
describe("CI workflow generation", () => {
+1 -1
View File
@@ -5,7 +5,7 @@ import {
isVersionCompatible,
parseNumericVersion,
verifyCompatibilityTuple,
} from "../../src/application/policies/compatibility.ts";
} from "../../src/contracts/compatibility.ts";
describe("contract compatibility", () => {
it("uses numeric version parsing rather than lexical comparison", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts";
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/index.ts";
const profile = {
profileId: "bounded-cursor-v1",
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
canRetryTransport,
isRetryableHttpStatus,
isRetryableSemantics,
jitteredDelay,
retryDelayFor,
} from "../../src/adapters/http/http-retry-lifecycle.ts";
describe("HTTP retry lifecycle policy", () => {
it("allows KEYED replay only before a physical dispatch", () => {
expect(canRetryTransport("KEYED", "PREPARING")).toBe(true);
expect(canRetryTransport("KEYED", "READY_TO_SEND")).toBe(true);
expect(canRetryTransport("KEYED", "DISPATCHED")).toBe(false);
expect(canRetryTransport("KEYED", "RESPONSE_HEADERS")).toBe(false);
});
it("keeps SAFE and IDEMPOTENT retryable while NEVER is terminal", () => {
expect(isRetryableSemantics("SAFE")).toBe(true);
expect(isRetryableSemantics("IDEMPOTENT")).toBe(true);
expect(isRetryableSemantics("KEYED")).toBe(false);
expect(isRetryableSemantics("NEVER")).toBe(false);
expect(canRetryTransport("SAFE", "DISPATCHED")).toBe(true);
expect(canRetryTransport("IDEMPOTENT", "READING_BODY")).toBe(true);
expect(canRetryTransport("NEVER", "PREPARING")).toBe(false);
});
it("owns the closed retryable status vocabulary", () => {
for (const status of [408, 425, 429, 502, 503, 504]) {
expect(isRetryableHttpStatus(status)).toBe(true);
}
for (const status of [400, 401, 403, 404, 409, 500, 501]) {
expect(isRetryableHttpStatus(status)).toBe(false);
}
});
it("uses bounded full jitter and rejects excessive Retry-After", () => {
expect(jitteredDelay(0, () => 0)).toBe(0);
expect(jitteredDelay(0, () => 0.5)).toBe(125);
expect(jitteredDelay(8, () => 0.5)).toBe(1_000);
expect(retryDelayFor(null, 0, () => 0.5)).toBe(125);
expect(retryDelayFor(400, 0, () => 0.5)).toBe(400);
expect(retryDelayFor(5_001, 0, () => 0.5)).toBeNull();
});
});
+652
View File
@@ -0,0 +1,652 @@
import { describe, expect, it, vi } from "vitest";
import type {
ImageProbeRequest,
} from "../../src/application/ports/browser-transfer/image-cdn.ts";
import { createBrowserImageProbe } from "../../src/adapters/browser-transfer/image-cdn/browser-image-probe.ts";
import {
avifBytes,
jpegBytes,
manualImageProbeScheduler,
pngBytes,
publicImageHeaders,
responseAt,
webpBytes,
} from "./image-cdn-test-fixture.ts";
describe("browser image probe", () => {
const imageUrl =
"https://images.example.test/v1/assets/a/rev?format=png";
const request = (
overrides: Partial<ImageProbeRequest> = {},
): ImageProbeRequest => ({
absoluteUrl: imageUrl,
expectedMediaType: "image/png",
expectedWidth: 640,
expectedHeight: 360,
maxEncodedBytes: 1_024,
maxDecodedPixels: 230_400,
maxDecodedBytes: 921_600,
delivery: "PUBLIC_IMMUTABLE",
minimumPublicMaxAgeSeconds: 31_536_000,
referrerPolicy: "no-referrer",
signal: new AbortController().signal,
...overrides,
});
it("parses all supported static headers before decode and closes each bitmap", async () => {
const samples = [
{
mediaType: "image/png" as const,
bytes: pngBytes(640, 360),
},
{
mediaType: "image/jpeg" as const,
bytes: jpegBytes(640, 360),
},
{
mediaType: "image/webp" as const,
bytes: webpBytes(640, 360),
},
{
mediaType: "image/avif" as const,
bytes: avifBytes(640, 360),
},
];
const close = vi.fn();
for (const sample of samples) {
const exactUrl = imageUrl.replace(
"format=png",
`format=${sample.mediaType.slice("image/".length)}`,
);
const fetcher = vi.fn(async () =>
responseAt(exactUrl, sample.bytes, {
status: 200,
headers: publicImageHeaders(
sample.mediaType,
sample.bytes.byteLength,
),
}),
);
const probe = createBrowserImageProbe({
fetcher: fetcher as typeof fetch,
createBitmap: vi.fn(async () => ({
width: 640,
height: 360,
close,
})),
});
await expect(
probe.probe(
request({
absoluteUrl: exactUrl,
expectedMediaType: sample.mediaType,
}),
),
).resolves.toMatchObject({
ok: true,
value: {
absoluteUrl: exactUrl,
mediaType: sample.mediaType,
encodedBytes: sample.bytes.byteLength,
decodedWidth: 640,
decodedHeight: 360,
},
});
expect(fetcher).toHaveBeenCalledWith(
exactUrl,
expect.objectContaining({
credentials: "omit",
redirect: "error",
mode: "cors",
cache: "no-store",
referrerPolicy: "no-referrer",
}),
);
}
expect(close).toHaveBeenCalledTimes(samples.length);
});
it("fails closed on duplicate/conflicting cache directives and oversized bodies", async () => {
const png = pngBytes(640, 360);
const createBitmap = vi.fn(async () => ({
width: 640,
height: 360,
close: vi.fn(),
}));
for (const cacheControl of [
// BT-IMG-02. Unmatched quotes must not be unwrapped into a bare number.
'public, max-age="31536000, immutable',
'public, max-age=31536000", immutable',
'public, max-age="31536000\\", immutable',
"public, public, max-age=31536000, immutable",
"public, max-age=31536000, s-maxage=60, immutable",
"public, max-age=31536000, immutable, must-revalidate",
"public=1, max-age=31536000, immutable",
"public, max-age=31536000, immutable=true",
]) {
const probe = createBrowserImageProbe({
fetcher: (async () =>
responseAt(imageUrl, png, {
status: 200,
headers: {
"cache-control": cacheControl,
"content-type": "image/png",
},
})) as typeof fetch,
createBitmap,
});
await expect(probe.probe(request())).resolves.toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
}
const probe = createBrowserImageProbe({
fetcher: (async () =>
responseAt(imageUrl, new Uint8Array(2_048), {
status: 200,
headers: {
"cache-control":
"public, max-age=31536000, immutable",
"content-type": "image/png",
},
})) as typeof fetch,
createBitmap,
});
await expect(probe.probe(request())).resolves.toMatchObject({
ok: false,
error: { code: "LIMIT_EXCEEDED" },
});
expect(createBitmap).not.toHaveBeenCalled();
});
it("rejects non-identity content encoding and mismatched declared lengths", async () => {
const png = pngBytes(640, 360);
const createBitmap = vi.fn();
const cases = [
{
headers: {
"content-encoding": "gzip",
"content-length": String(png.byteLength),
},
code: "POLICY_REJECTED",
},
{
headers: {
"content-length": String(png.byteLength + 1),
},
code: "INTEGRITY_FAILED",
},
];
for (const invalid of cases) {
const headers = publicImageHeaders("image/png");
for (const [name, value] of Object.entries(invalid.headers)) {
headers.set(name, value);
}
const probe = createBrowserImageProbe({
fetcher: (async () =>
responseAt(imageUrl, png, {
status: 200,
headers,
})) as typeof fetch,
createBitmap,
});
await expect(probe.probe(request())).resolves.toMatchObject({
ok: false,
error: { code: invalid.code },
});
}
expect(createBitmap).not.toHaveBeenCalled();
});
it("rejects malicious dimensions and animated PNG/WebP before native decode", async () => {
const createBitmap = vi.fn(async () => ({
width: 640,
height: 360,
close: vi.fn(),
}));
const cases = [
{
bytes: pngBytes(20_000, 20_000),
mediaType: "image/png" as const,
code: "LIMIT_EXCEEDED",
},
{
bytes: pngBytes(320, 180),
mediaType: "image/png" as const,
code: "INTEGRITY_FAILED",
},
{
bytes: jpegBytes(320, 180),
mediaType: "image/jpeg" as const,
code: "INTEGRITY_FAILED",
},
{
bytes: webpBytes(10_000, 10_000),
mediaType: "image/webp" as const,
code: "LIMIT_EXCEEDED",
},
{
bytes: avifBytes(20_000, 20_000),
mediaType: "image/avif" as const,
code: "LIMIT_EXCEEDED",
},
{
bytes: pngBytes(640, 360, true),
mediaType: "image/png" as const,
code: "INTEGRITY_FAILED",
},
{
bytes: webpBytes(640, 360, true),
mediaType: "image/webp" as const,
code: "INTEGRITY_FAILED",
},
{
bytes: avifBytes(640, 360, "avis"),
mediaType: "image/avif" as const,
code: "INTEGRITY_FAILED",
},
];
for (const malicious of cases) {
const url = imageUrl.replace(
"format=png",
`format=${malicious.mediaType.slice("image/".length)}`,
);
const probe = createBrowserImageProbe({
fetcher: (async () =>
responseAt(url, malicious.bytes, {
status: 200,
headers: publicImageHeaders(
malicious.mediaType,
malicious.bytes.byteLength,
),
})) as typeof fetch,
createBitmap,
});
await expect(
probe.probe(
request({
absoluteUrl: url,
expectedMediaType: malicious.mediaType,
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: malicious.code },
});
}
expect(createBitmap).not.toHaveBeenCalled();
});
it("enforces private no-store, omitted credentials and the exact final URL", async () => {
const png = pngBytes(640, 360);
const fetcher = vi.fn(async () =>
responseAt(imageUrl, png, {
status: 200,
headers: {
// TR-RR-09. A private response carries `no-store` and nothing else
// that describes cacheability.
"cache-control": "no-store",
"content-type": "image/png",
},
}),
);
const probe = createBrowserImageProbe({
fetcher: fetcher as typeof fetch,
createBitmap: async () => ({
width: 640,
height: 360,
close: vi.fn(),
}),
});
await expect(
probe.probe(
request({
delivery: "PRIVATE_SIGNED",
minimumPublicMaxAgeSeconds: 0,
}),
),
).resolves.toMatchObject({ ok: true });
expect(fetcher).toHaveBeenCalledWith(
imageUrl,
expect.objectContaining({
cache: "no-store",
credentials: "omit",
redirect: "error",
}),
);
for (const response of [
responseAt(imageUrl, png, {
status: 200,
headers: {
"cache-control": "private, no-store=value",
"content-type": "image/png",
},
}),
responseAt(imageUrl, png, {
status: 200,
headers: {
"cache-control": "public, no-store",
"content-type": "image/png",
},
}),
responseAt(
"https://images.example.test/v1/assets/other",
png,
{
status: 200,
headers: {
"cache-control": "private, no-store",
"content-type": "image/png",
},
},
),
]) {
const rejectingProbe = createBrowserImageProbe({
fetcher: (async () => response) as typeof fetch,
createBitmap: async () => ({
width: 640,
height: 360,
close: vi.fn(),
}),
});
await expect(
rejectingProbe.probe(
request({
delivery: "PRIVATE_SIGNED",
minimumPublicMaxAgeSeconds: 0,
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
}
await expect(
probe.probe(
request({
expectedMediaType: "image/svg+xml",
} as unknown as Partial<ImageProbeRequest>),
),
).resolves.toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
});
/**
* TR-RR-09. The recorded BT-IMG-02 contract for a private response is a
* fail-closed matrix. Accepting `no-store` next to a directive that describes
* cacheability lets a self-contradictory policy read as acceptable.
*/
it("applies the full private Cache-Control matrix", async () => {
const png = pngBytes(640, 360);
const probeWith = async (cacheControl: string) => {
const probe = createBrowserImageProbe({
fetcher: (async () =>
responseAt(imageUrl, png, {
status: 200,
headers: {
"cache-control": cacheControl,
"content-type": "image/png",
},
})) as typeof fetch,
createBitmap: async () => ({
width: 640,
height: 360,
close: vi.fn(),
}),
});
return await probe.probe(
request({
delivery: "PRIVATE_SIGNED",
minimumPublicMaxAgeSeconds: 0,
}),
);
};
// Only `no-store`, plus a syntactically valid unknown extension.
expect(await probeWith("no-store")).toMatchObject({ ok: true });
expect(await probeWith('no-store, x-vendor="a,b"')).toMatchObject({
ok: true,
});
for (const companion of [
"public",
"private",
"immutable",
"max-age=60",
"s-maxage=60",
"no-cache",
"must-revalidate",
"proxy-revalidate",
]) {
expect(await probeWith(`no-store, ${companion}`)).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
}
for (const withoutNoStore of ["private", "no-cache", "max-age=0"]) {
expect(await probeWith(withoutNoStore)).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
}
});
it("times out a stalled body, aborts the composed signal and cancels its reader", async () => {
const manual = manualImageProbeScheduler();
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const read = vi.fn(
() =>
new Promise<ReadableStreamReadResult<Uint8Array>>(
() => undefined,
),
);
const response = {
body: {
getReader: () => ({ cancel, read, releaseLock }),
},
headers: publicImageHeaders("image/png"),
ok: true,
redirected: false,
status: 200,
type: "cors",
url: imageUrl,
} as unknown as Response;
const fetcher = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
response,
);
const probe = createBrowserImageProbe({
fetcher: fetcher as typeof fetch,
createBitmap: vi.fn(),
timeoutMs: 1_000,
scheduler: manual.scheduler,
});
const probeRequest = request();
const pending = probe.probe(probeRequest);
await vi.waitFor(() => {
expect(read).toHaveBeenCalledOnce();
});
const composedSignal = fetcher.mock.calls[0]?.[1]?.signal as
| AbortSignal
| null
| undefined;
expect(composedSignal).not.toBe(probeRequest.signal);
manual.fire();
await expect(pending).resolves.toMatchObject({
ok: false,
error: {
code: "UNAVAILABLE",
retryable: true,
recovery: "RETRY",
},
});
expect(cancel).toHaveBeenCalledOnce();
expect(releaseLock).toHaveBeenCalledOnce();
expect(composedSignal?.aborted).toBe(true);
});
it("times out stalled decode and closes a bitmap that resolves late", async () => {
const manual = manualImageProbeScheduler();
const close = vi.fn();
let finishDecode:
((bitmap: {
width: number;
height: number;
close(): void;
}) => void) | undefined;
const createBitmap = vi.fn(
() =>
new Promise<{
width: number;
height: number;
close(): void;
}>((resolve) => {
finishDecode = resolve;
}),
);
const png = pngBytes(640, 360);
const probe = createBrowserImageProbe({
fetcher: (async () =>
responseAt(imageUrl, png, {
status: 200,
headers: publicImageHeaders("image/png", png.byteLength),
})) as typeof fetch,
createBitmap,
timeoutMs: 1_000,
scheduler: manual.scheduler,
});
const pending = probe.probe(request());
await vi.waitFor(() => {
expect(createBitmap).toHaveBeenCalledOnce();
});
manual.fire();
await expect(pending).resolves.toMatchObject({
ok: false,
error: { code: "UNAVAILABLE" },
});
finishDecode?.({ width: 640, height: 360, close });
await vi.waitFor(() => {
expect(close).toHaveBeenCalledOnce();
});
});
/**
* X-AUDIT-02. `probe()` promises a `BrowserDataResult`. A scheduler that
* cannot install the probe deadline must close the probe inside that contract
* rather than rejecting it, and must not leave the caller's listener behind.
*/
describe("scheduler boundary", () => {
const trackedSignal = () => {
const controller = new AbortController();
const added: string[] = [];
const removed: string[] = [];
const add = controller.signal.addEventListener.bind(controller.signal);
const remove = controller.signal.removeEventListener.bind(
controller.signal,
);
Object.defineProperty(controller.signal, "addEventListener", {
configurable: true,
value: (type: string, ...rest: readonly unknown[]) => {
added.push(type);
return (add as (...args: readonly unknown[]) => unknown)(
type,
...rest,
);
},
});
Object.defineProperty(controller.signal, "removeEventListener", {
configurable: true,
value: (type: string, ...rest: readonly unknown[]) => {
removed.push(type);
return (remove as (...args: readonly unknown[]) => unknown)(
type,
...rest,
);
},
});
return { controller, added, removed };
};
it("closes the probe when the scheduler cannot install the deadline", async () => {
const fetcher = vi.fn(async () => new Response(null, { status: 200 }));
const { controller, added, removed } = trackedSignal();
const probe = createBrowserImageProbe({
fetcher: fetcher as unknown as typeof fetch,
createBitmap: vi.fn(),
timeoutMs: 1_000,
scheduler: {
setTimeout: () => {
throw new TypeError("image scheduler install exploded");
},
clearTimeout: vi.fn(),
},
});
await expect(
probe.probe({ ...request(), signal: controller.signal }),
).resolves.toMatchObject({
ok: false,
error: { code: "UNAVAILABLE", retryable: true, recovery: "RETRY" },
});
expect(fetcher).not.toHaveBeenCalled();
expect(added.filter((type) => type === "abort")).toHaveLength(1);
expect(removed.filter((type) => type === "abort")).toHaveLength(1);
});
it("starts no timer and no fetch for an already aborted caller", async () => {
const fetcher = vi.fn(async () => new Response(null, { status: 200 }));
const setTimeout_ = vi.fn(() => 1);
const controller = new AbortController();
controller.abort();
const probe = createBrowserImageProbe({
fetcher: fetcher as unknown as typeof fetch,
createBitmap: vi.fn(),
timeoutMs: 1_000,
scheduler: { setTimeout: setTimeout_, clearTimeout: vi.fn() },
});
await expect(
probe.probe({ ...request(), signal: controller.signal }),
).resolves.toMatchObject({ ok: false, error: { code: "ABORTED" } });
expect(fetcher).not.toHaveBeenCalled();
expect(setTimeout_).not.toHaveBeenCalled();
});
it("keeps the classified outcome when clearing the deadline throws", async () => {
const png = pngBytes(640, 360);
const probe = createBrowserImageProbe({
fetcher: (async () =>
responseAt(imageUrl, png, {
status: 200,
headers: publicImageHeaders("image/png", png.byteLength),
})) as typeof fetch,
createBitmap: vi.fn(async () => ({
width: 640,
height: 360,
close: vi.fn(),
})),
timeoutMs: 1_000,
scheduler: {
setTimeout: (callback: () => void, milliseconds: number) =>
setTimeout(callback, milliseconds),
clearTimeout: () => {
throw new TypeError("image scheduler clear exploded");
},
},
});
await expect(probe.probe(request())).resolves.toMatchObject({ ok: true });
});
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { createP256ImageCapabilityVerifier } from "../../src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts";
import { base64Url } from "./image-cdn-test-fixture.ts";
describe("P-256 image capability verifier", () => {
it("rejects an ECDSA public key on any curve other than P-256", async () => {
const generated = await globalThis.crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-384" },
false,
["sign", "verify"],
);
if (!("publicKey" in generated)) {
throw new TypeError("Expected an ECDSA key pair.");
}
expect(() =>
createP256ImageCapabilityVerifier({
subtle: globalThis.crypto.subtle,
publicKeys: [
{
keyId: "image-signing-wrong-curve",
key: generated.publicKey,
},
],
}),
).toThrow(/public key binding/u);
});
it("verifies the exact canonical payload and rejects tampering", async () => {
const generated = await globalThis.crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign", "verify"],
);
if (!("privateKey" in generated)) {
throw new TypeError("Expected an ECDSA key pair.");
}
const payload = new TextEncoder().encode(
'["image-cdn-capability-v1","bound"]',
);
const payloadBuffer = new Uint8Array(payload.byteLength);
payloadBuffer.set(payload);
const signature = await globalThis.crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
generated.privateKey,
payloadBuffer.buffer,
);
const verifier = createP256ImageCapabilityVerifier({
subtle: globalThis.crypto.subtle,
publicKeys: [
{
keyId: "image-signing-2026-01",
key: generated.publicKey,
},
],
});
expect(verifier.acceptsKey("image-signing-2026-01")).toBe(
true,
);
expect(verifier.acceptsKey("image-signing-unknown")).toBe(
false,
);
const signatureBase64Url = base64Url(
new Uint8Array(signature),
);
await expect(
verifier.verify({
algorithm: "ECDSA_P256_SHA256",
keyId: "image-signing-2026-01",
canonicalPayload: payload,
signatureBase64Url,
}),
).resolves.toBe(true);
const tampered = Uint8Array.from(payload);
tampered[0] ^= 1;
await expect(
verifier.verify({
algorithm: "ECDSA_P256_SHA256",
keyId: "image-signing-2026-01",
canonicalPayload: tampered,
signatureBase64Url,
}),
).resolves.toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
import { vi } from "vitest";
import type {
ImageProbeScheduler,
} from "../../src/adapters/browser-transfer/image-cdn/browser-image-probe.ts";
import type {
ImageCapabilityVerificationScheduler,
} from "../../src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts";
export function responseAt(
url: string,
body: Uint8Array,
init: ResponseInit,
): Response {
const responseBytes = new Uint8Array(body.byteLength);
responseBytes.set(body);
const response = new Response(responseBytes.buffer, init);
Object.defineProperty(response, "url", {
configurable: false,
enumerable: true,
value: url,
});
return response;
}
export function publicImageHeaders(
mediaType: string,
contentLength?: number,
): Headers {
const headers = new Headers({
"cache-control":
"public, max-age=31536000, s-maxage=31536000, immutable",
"content-type": mediaType,
vary: "Accept-Encoding",
});
if (contentLength !== undefined) {
headers.set("content-length", String(contentLength));
}
return headers;
}
export function pngBytes(
width: number,
height: number,
animated = false,
): Uint8Array {
const header = new Uint8Array(13);
const headerView = new DataView(header.buffer);
headerView.setUint32(0, width);
headerView.setUint32(4, height);
header[8] = 8;
header[9] = 6;
return concatenateBytes([
Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]),
pngChunk("IHDR", header),
...(animated
? [pngChunk("acTL", new Uint8Array(8))]
: []),
pngChunk("IDAT", new Uint8Array()),
pngChunk("IEND", new Uint8Array()),
]);
}
function pngChunk(type: string, payload: Uint8Array): Uint8Array {
const chunk = new Uint8Array(12 + payload.byteLength);
const view = new DataView(chunk.buffer);
view.setUint32(0, payload.byteLength);
writeAscii(chunk, 4, type);
chunk.set(payload, 8);
return chunk;
}
export function jpegBytes(width: number, height: number): Uint8Array {
return Uint8Array.from([
0xff,
0xd8,
0xff,
0xc0,
0x00,
0x11,
0x08,
(height >>> 8) & 0xff,
height & 0xff,
(width >>> 8) & 0xff,
width & 0xff,
0x03,
0x01,
0x11,
0x00,
0x02,
0x11,
0x00,
0x03,
0x11,
0x00,
0xff,
0xda,
]);
}
export function webpBytes(
width: number,
height: number,
animated = false,
): Uint8Array {
const chunkType = animated ? "VP8X" : "VP8 ";
const payload = new Uint8Array(10);
if (animated) {
payload[0] = 0x02;
writeUint24LittleEndian(payload, 4, width - 1);
writeUint24LittleEndian(payload, 7, height - 1);
} else {
payload.set([0x9d, 0x01, 0x2a], 3);
const view = new DataView(payload.buffer);
view.setUint16(6, width, true);
view.setUint16(8, height, true);
}
const chunk = concatenateBytes([
asciiBytes(chunkType),
littleEndianUint32(payload.byteLength),
payload,
]);
return concatenateBytes([
asciiBytes("RIFF"),
littleEndianUint32(4 + chunk.byteLength),
asciiBytes("WEBP"),
chunk,
]);
}
export function avifBytes(
width: number,
height: number,
brand = "avif",
): Uint8Array {
const fileType = isoBox(
"ftyp",
concatenateBytes([
asciiBytes(brand),
new Uint8Array(4),
asciiBytes(brand),
]),
);
const spatialExtent = new Uint8Array(12);
const extentView = new DataView(spatialExtent.buffer);
extentView.setUint32(4, width);
extentView.setUint32(8, height);
const primaryItem = new Uint8Array(6);
new DataView(primaryItem.buffer).setUint16(4, 1);
const itemInfoEntry = new Uint8Array(13);
itemInfoEntry[0] = 2;
const itemInfoView = new DataView(itemInfoEntry.buffer);
itemInfoView.setUint16(4, 1);
writeAscii(itemInfoEntry, 8, "av01");
const itemInfo = new Uint8Array(6);
new DataView(itemInfo.buffer).setUint16(4, 1);
const propertyAssociation = new Uint8Array(12);
const associationView = new DataView(
propertyAssociation.buffer,
);
associationView.setUint32(4, 1);
associationView.setUint16(8, 1);
propertyAssociation[10] = 1;
propertyAssociation[11] = 0x81;
const properties = isoBox(
"iprp",
concatenateBytes([
isoBox("ipco", isoBox("ispe", spatialExtent)),
isoBox("ipma", propertyAssociation),
]),
);
const metadata = isoBox(
"meta",
concatenateBytes([
new Uint8Array(4),
isoBox("pitm", primaryItem),
isoBox(
"iinf",
concatenateBytes([
itemInfo,
isoBox("infe", itemInfoEntry),
]),
),
properties,
]),
);
return concatenateBytes([
fileType,
metadata,
isoBox("mdat", Uint8Array.of(0)),
]);
}
export function isoBox(type: string, payload: Uint8Array): Uint8Array {
const box = new Uint8Array(8 + payload.byteLength);
const view = new DataView(box.buffer);
view.setUint32(0, box.byteLength);
writeAscii(box, 4, type);
box.set(payload, 8);
return box;
}
export function littleEndianUint32(value: number): Uint8Array {
const bytes = new Uint8Array(4);
new DataView(bytes.buffer).setUint32(0, value, true);
return bytes;
}
export function writeUint24LittleEndian(
bytes: Uint8Array,
offset: number,
value: number,
): void {
bytes[offset] = value & 0xff;
bytes[offset + 1] = (value >>> 8) & 0xff;
bytes[offset + 2] = (value >>> 16) & 0xff;
}
export function asciiBytes(value: string): Uint8Array {
return Uint8Array.from(
[...value].map((character) => character.charCodeAt(0)),
);
}
export function writeAscii(
target: Uint8Array,
offset: number,
value: string,
): void {
target.set(asciiBytes(value), offset);
}
export function concatenateBytes(
chunks: readonly Uint8Array[],
): Uint8Array {
const combined = new Uint8Array(
chunks.reduce((total, chunk) => total + chunk.byteLength, 0),
);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}
return combined;
}
export function manualImageProbeScheduler(): Readonly<{
scheduler: ImageProbeScheduler;
fire(): void;
}> {
let callback: (() => void) | undefined;
return {
scheduler: {
setTimeout(nextCallback) {
callback = nextCallback;
return 1;
},
clearTimeout: vi.fn(),
},
fire() {
if (!callback) {
throw new TypeError("No image probe timeout is scheduled.");
}
callback();
},
};
}
export function manualCapabilityVerificationScheduler(): Readonly<{
scheduler: ImageCapabilityVerificationScheduler;
delays: readonly number[];
clearTimeout: ReturnType<typeof vi.fn>;
fire(): void;
}> {
let callback: (() => void) | undefined;
const delays: number[] = [];
const clearTimeout = vi.fn();
return {
scheduler: {
setTimeout(nextCallback, milliseconds) {
callback = nextCallback;
delays.push(milliseconds);
return 1;
},
clearTimeout,
},
delays,
clearTimeout,
fire() {
if (!callback) {
throw new TypeError(
"No capability verification timeout is scheduled.",
);
}
callback();
},
};
}
export function base64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replace(/\+/gu, "-")
.replace(/\//gu, "_")
.replace(/=+$/gu, "");
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts";
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/index.ts";
import {
defineMutationIntent,
isValidIdempotencyKey,
@@ -8,8 +8,8 @@ import {
import type {
CursorPage,
CursorPaginationProfile,
} from "../../src/contracts/cursor-pagination.ts";
import type { Result } from "../../src/application/result.ts";
} from "../../src/adapters/query-cache/index.ts";
import type { Result } from "../../src/contracts/result.ts";
const PROFILE: CursorPaginationProfile = Object.freeze({
profileId: "TEST_PAGINATION_V1",
@@ -0,0 +1,420 @@
import { describe, expect, it, vi } from "vitest";
import {
BrowserFilePolicyRegistry,
browserFilePolicyReference,
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
import {
createDownloadDeliveryAdapter,
type SaveFileHandle,
} from "../../src/adapters/browser-files/download-delivery-adapter.ts";
import {
CHECKSUM_HEADER,
CONTROL_ENDPOINT,
DATA_ORIGIN,
DIGEST_HEADER,
DOWNLOAD_HREF,
DOWNLOAD_PATH,
NOW,
POLICY_HEADER,
REQUEST_BINDING_SHA256,
UPLOAD_SESSION_ID,
collect,
createHarness,
downloadCapabilityPayload,
downloadResponse,
jsonResponse,
responseWithUrl,
uploadCapabilityPayload,
} from "./presigned-transfer-fixture.ts";
describe("presigned DownloadDelivery integration", () => {
it("connects an issued capability to DownloadDeliveryPort without caller digest input", async () => {
const bytes = new TextEncoder().encode("verified");
const payload = downloadCapabilityPayload(bytes);
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
String(input) === CONTROL_ENDPOINT
? jsonResponse(payload)
: downloadResponse(bytes.slice().buffer, payload),
) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const policy = browserFilePolicyReference(
"download",
"presigned-stream",
);
const policies = new BrowserFilePolicyRegistry({
profiles: [
{
reference: policy,
download: {
strategy: "PROMPT_AND_STREAM",
mediaType: "application/octet-stream",
safeExtension: ".bin",
maxTransferBytes: 64,
maxBufferedBytes: 8,
integrity: "REQUIRED",
},
},
],
hardLimits: {
maxInspectionBytes: 64,
maxRetainedFileBytes: 64,
maxPreviewBytes: 64,
maxObjectUrlBytes: 64,
maxTransferBytes: 64,
},
});
const written: number[] = [];
const handle: SaveFileHandle = {
async createWritable() {
return new WritableStream<Uint8Array>({
write(chunk) {
written.push(...chunk);
},
});
},
};
const downloads = createDownloadDeliveryAdapter({
host: { handoff() {} },
policies,
hardMaxObjectUrlBytes: 64,
hardMaxTransferBytes: 64,
browserManagedCapabilities: {
resolve() {
throw new TypeError("not used");
},
},
openAuthorizedSource:
executor.downloadSources.open.bind(executor.downloadSources),
showSaveFilePicker: async () => handle,
userActivation: { isActive: true },
now: () => NOW,
});
const result = await downloads.deliver({
policy,
source: {
kind: "AUTHORIZED_STREAM_RESOURCE",
resourceId: "resource-1",
capability: issued.value,
},
suggestedFileName: "artifact.bin",
signal: new AbortController().signal,
onProgress() {},
});
expect(result).toMatchObject({
ok: true,
value: {
kind: "SAVED",
integrity: "VERIFIED",
bytesWritten: bytes.byteLength,
},
});
expect(written).toEqual([...bytes]);
});
/**
* TR-RR-04. A presigned byte source owns a fetch reader and a capability
* lease, and its port requires `close()`. The delivery consumer never called
* it, so every outcome success, validation failure, writer failure and
* abort leaked both.
*/
it.each([
{ label: "success", mode: "SUCCESS" as const },
{ label: "writer failure", mode: "WRITER_FAILURE" as const },
{ label: "abort", mode: "ABORT" as const },
])("closes the presigned source exactly once on $label", async ({ mode }) => {
const bytes = new Uint8Array([1, 2, 3]);
let closes = 0;
const controller = new AbortController();
const source = {
byteLength: bytes.byteLength,
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
capability: undefined as never,
close() {
closes += 1;
},
async *stream() {
if (mode === "ABORT") controller.abort();
yield { ok: true as const, value: bytes };
},
};
const capability = Object.freeze({
capabilityReceipt: "capability-close-1",
method: "GET" as const,
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
mediaType: "application/octet-stream",
byteLength: bytes.byteLength,
maxBytes: bytes.byteLength,
expectedSha256: "a".repeat(64),
expiresAtEpochMs: NOW + 60_000,
});
source.capability = capability as never;
const closePolicy = browserFilePolicyReference(
"download",
"presigned-close",
);
const policies = new BrowserFilePolicyRegistry({
profiles: [
{
reference: closePolicy,
download: {
strategy: "PROMPT_AND_STREAM",
mediaType: "application/octet-stream",
safeExtension: ".bin",
maxTransferBytes: 64,
maxBufferedBytes: 8,
integrity: "REQUIRED",
},
},
],
hardLimits: {
maxInspectionBytes: 64,
maxRetainedFileBytes: 64,
maxPreviewBytes: 64,
maxObjectUrlBytes: 64,
maxTransferBytes: 64,
},
});
const handle: SaveFileHandle = {
async createWritable() {
return new WritableStream<Uint8Array>({
write() {
if (mode === "WRITER_FAILURE") {
throw new TypeError("writer exploded");
}
},
});
},
};
const downloads = createDownloadDeliveryAdapter({
host: { handoff() {} },
policies,
hardMaxObjectUrlBytes: 64,
hardMaxTransferBytes: 64,
browserManagedCapabilities: {
resolve() {
throw new TypeError("not used");
},
},
openAuthorizedSource: async () =>
({ ok: true, value: source }) as never,
showSaveFilePicker: async () => handle,
userActivation: { isActive: true },
now: () => NOW,
});
const deliveryResult = await downloads.deliver({
policy: closePolicy,
source: {
kind: "AUTHORIZED_STREAM_RESOURCE",
resourceId: "resource-1",
capability: capability as never,
},
suggestedFileName: "artifact.bin",
signal: controller.signal,
onProgress() {},
});
void deliveryResult;
expect(closes).toBe(1);
});
/**
* TR-02. A lease that resolved after the abort already ended the delivery
* never reached the holder, so nothing closed it: the fetch reader and the
* capability lease outlived the terminal result.
*/
it("closes a source lease that arrives after the delivery was aborted", async () => {
const bytes = new Uint8Array([1, 2, 3]);
let closes = 0;
const controller = new AbortController();
let releaseOpen:
| ((value: { ok: true; value: unknown }) => void)
| undefined;
const source = {
byteLength: bytes.byteLength,
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
capability: undefined as never,
close() {
closes += 1;
},
async *stream() {
yield { ok: true as const, value: bytes };
},
};
const capability = Object.freeze({
capabilityReceipt: "capability-late-1",
method: "GET" as const,
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
mediaType: "application/octet-stream",
byteLength: bytes.byteLength,
maxBytes: bytes.byteLength,
expectedSha256: "a".repeat(64),
expiresAtEpochMs: NOW + 60_000,
});
source.capability = capability as never;
const latePolicy = browserFilePolicyReference("download", "presigned-late");
const policies = new BrowserFilePolicyRegistry({
profiles: [
{
reference: latePolicy,
download: {
strategy: "PROMPT_AND_STREAM",
mediaType: "application/octet-stream",
safeExtension: ".bin",
maxTransferBytes: 64,
maxBufferedBytes: 8,
integrity: "REQUIRED",
},
},
],
hardLimits: {
maxInspectionBytes: 64,
maxRetainedFileBytes: 64,
maxPreviewBytes: 64,
maxObjectUrlBytes: 64,
maxTransferBytes: 64,
},
});
const downloads = createDownloadDeliveryAdapter({
host: { handoff() {} },
policies,
hardMaxObjectUrlBytes: 64,
hardMaxTransferBytes: 64,
browserManagedCapabilities: {
resolve() {
throw new TypeError("not used");
},
},
// Ignores the signal entirely and resolves only when the test says so.
openAuthorizedSource: () =>
new Promise((resolve) => {
releaseOpen = resolve as never;
}) as never,
showSaveFilePicker: async () => ({
async createWritable() {
return new WritableStream<Uint8Array>({ write() {} });
},
}),
userActivation: { isActive: true },
now: () => NOW,
});
const delivering = downloads.deliver({
policy: latePolicy,
source: {
kind: "AUTHORIZED_STREAM_RESOURCE",
resourceId: "resource-1",
capability: capability as never,
},
suggestedFileName: "artifact.bin",
signal: controller.signal,
onProgress() {},
});
await new Promise((resolve) => setTimeout(resolve, 0));
controller.abort();
const delivered = await delivering;
expect(delivered.ok).toBe(false);
// The lease arrives only now, long after the terminal result.
releaseOpen?.({ ok: true, value: source });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(closes).toBe(1);
});
it("does not leave a late rejection unhandled after an abort", async () => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
const controller = new AbortController();
let rejectOpen: ((reason: unknown) => void) | undefined;
const rejectPolicy = browserFilePolicyReference(
"download",
"presigned-late-reject",
);
const policies = new BrowserFilePolicyRegistry({
profiles: [
{
reference: rejectPolicy,
download: {
strategy: "PROMPT_AND_STREAM",
mediaType: "application/octet-stream",
safeExtension: ".bin",
maxTransferBytes: 64,
maxBufferedBytes: 8,
integrity: "REQUIRED",
},
},
],
hardLimits: {
maxInspectionBytes: 64,
maxRetainedFileBytes: 64,
maxPreviewBytes: 64,
maxObjectUrlBytes: 64,
maxTransferBytes: 64,
},
});
const downloads = createDownloadDeliveryAdapter({
host: { handoff() {} },
policies,
hardMaxObjectUrlBytes: 64,
hardMaxTransferBytes: 64,
browserManagedCapabilities: {
resolve() {
throw new TypeError("not used");
},
},
openAuthorizedSource: () =>
new Promise((_resolve, reject) => {
rejectOpen = reject;
}) as never,
showSaveFilePicker: async () => ({
async createWritable() {
return new WritableStream<Uint8Array>({ write() {} });
},
}),
userActivation: { isActive: true },
now: () => NOW,
});
const delivering = downloads.deliver({
policy: rejectPolicy,
source: {
kind: "AUTHORIZED_STREAM_RESOURCE",
resourceId: "resource-1",
capability: Object.freeze({
capabilityReceipt: "capability-late-2",
method: "GET" as const,
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
mediaType: "application/octet-stream",
byteLength: 3,
maxBytes: 3,
expectedSha256: "a".repeat(64),
expiresAtEpochMs: NOW + 60_000,
}) as never,
},
suggestedFileName: "artifact.bin",
signal: controller.signal,
onProgress() {},
});
await new Promise((resolve) => setTimeout(resolve, 0));
controller.abort();
await delivering;
rejectOpen?.(new Error("late open failure"));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});});
@@ -0,0 +1,650 @@
import { describe, expect, it, vi } from "vitest";
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
import { createPresignedCapabilityVault } from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts";
import {
CHECKSUM_HEADER,
CONTROL_ENDPOINT,
DATA_ORIGIN,
DIGEST_HEADER,
DOWNLOAD_HREF,
DOWNLOAD_PATH,
NOW,
POLICY_HEADER,
REQUEST_BINDING_SHA256,
UPLOAD_SESSION_ID,
collect,
createHarness,
downloadCapabilityPayload,
downloadResponse,
jsonResponse,
responseWithUrl,
uploadCapabilityPayload,
} from "./presigned-transfer-fixture.ts";
describe("presigned download stream lifecycle", () => {
describe("TR-01 the stored capability is the one that was validated", () => {
const baseRegistration = () => ({
protocol: PRESIGNED_TRANSFER_PROTOCOL,
capabilityReceipt: "capability-snapshot-1",
method: "GET" as const,
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
href: `${DATA_ORIGIN}${DOWNLOAD_PATH}`,
origin: DATA_ORIGIN,
path: DOWNLOAD_PATH,
allowedQueryParameters: [],
requestHeaders: [{ name: "x-safe", value: "1" }],
requiredResponseHeaders: [],
digestRequestHeader: null,
digestResponseHeader: null,
receiptResponseHeader: null,
expectedStatus: 200,
expectedResponseByteLength: 3,
mediaType: "application/octet-stream",
byteLength: 3,
maxBytes: 3,
expectedSha256: "a".repeat(64),
expiresAtEpochMs: NOW + 60_000,
});
const freshVault = () =>
createPresignedCapabilityVault({
now: () => NOW,
maxActiveCapabilities: 4,
});
it("refuses a header row that answers differently on a second read", () => {
const vault = freshVault();
let nameReads = 0;
const header = new Proxy(
{ name: "x-safe", value: "1" },
{
getOwnPropertyDescriptor(target, key) {
if (key === "name") {
nameReads += 1;
return {
configurable: true,
enumerable: true,
value: nameReads > 1 ? "authorization" : "x-safe",
};
}
return Reflect.getOwnPropertyDescriptor(target, key);
},
},
);
const registered = vault.register({
...baseRegistration(),
requestHeaders: [header],
} as never);
if (registered.ok) {
// A single read means the value that was checked is the value stored.
const resolved = vault.resolve(registered.value);
expect(resolved.ok).toBe(true);
if (resolved.ok) {
expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([
"x-safe",
]);
}
}
vault.dispose();
});
const hostileRegistrations: readonly (readonly [string, () => unknown])[] = [
[
"an accessor field",
() =>
Object.defineProperty(baseRegistration(), "href", {
enumerable: true,
get: () => `${DATA_ORIGIN}${DOWNLOAD_PATH}`,
}),
],
[
"an inherited field",
() => Object.assign(Object.create({ injected: true }), baseRegistration()),
],
[
"a symbol field",
() => ({ ...baseRegistration(), [Symbol.for("injected")]: true }),
],
[
"a non-enumerable own field",
() =>
Object.defineProperty(baseRegistration(), "injected", {
enumerable: false,
value: true,
}),
],
[
"a throwing ownKeys trap",
() =>
new Proxy(baseRegistration(), {
ownKeys() {
throw new TypeError("hostile ownKeys trap");
},
}),
],
[
"a null header array",
() => ({ ...baseRegistration(), requestHeaders: null }),
],
[
"a non-iterable header array",
() => ({ ...baseRegistration(), requestHeaders: { length: 1 } }),
],
[
"a header row with an extra field",
() => ({
...baseRegistration(),
requestHeaders: [{ name: "x-safe", value: "1", injected: true }],
}),
],
[
"an accessor header name",
() => ({
...baseRegistration(),
requestHeaders: [
Object.defineProperty({ value: "1" }, "name", {
enumerable: true,
get: () => "x-safe",
}),
],
}),
],
[
"a binding with an extra field",
() => ({
...baseRegistration(),
binding: { kind: "DOWNLOAD", resourceId: "r", injected: true },
}),
],
[
"a null binding",
() => ({ ...baseRegistration(), binding: null }),
],
];
for (const [label, build] of hostileRegistrations) {
it(`rejects ${label} as POLICY_REJECTED`, () => {
const vault = freshVault();
expect(vault.register(build() as never)).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
vault.dispose();
});
}
it("does not observe a mutation of the issuer's object after registration", () => {
const vault = freshVault();
const registration = baseRegistration();
const registered = vault.register(registration as never);
expect(registered.ok).toBe(true);
if (!registered.ok) return;
registration.requestHeaders[0]!.name = "authorization";
registration.expiresAtEpochMs = NOW + 999_999;
const resolved = vault.resolve(registered.value);
expect(resolved.ok).toBe(true);
if (!resolved.ok) return;
expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([
"x-safe",
]);
expect(resolved.value.expiresAtEpochMs).toBe(NOW + 60_000);
vault.dispose();
});
});
it("does not fetch a presigned download until stream consumption", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const responsePayload = downloadCapabilityPayload(bytes);
let downloadFetches = 0;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(responsePayload);
}
downloadFetches += 1;
return downloadResponse(bytes.slice().buffer, responsePayload);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const signal = new AbortController().signal;
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
// BT-PRE-01. open() performs no network I/O.
expect(downloadFetches).toBe(0);
for await (const chunk of opened.value.stream(signal)) {
expect(chunk.ok).toBe(true);
}
expect(downloadFetches).toBe(1);
opened.value.close();
});
it("closes an unused download source without network I/O", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const responsePayload = downloadCapabilityPayload(bytes);
let downloadFetches = 0;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(responsePayload);
}
downloadFetches += 1;
return downloadResponse(bytes.slice().buffer, responsePayload);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const signal = new AbortController().signal;
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
opened.value.close();
// close() is idempotent and never starts the transfer.
opened.value.close();
expect(downloadFetches).toBe(0);
// A stream after close is one terminal conflict, still without fetching.
const results = [];
for await (const chunk of opened.value.stream(signal)) {
results.push(chunk);
}
expect(results).toMatchObject([
{ ok: false, error: { code: "CONFLICT" } },
]);
expect(downloadFetches).toBe(0);
});
it.each([
{
name: "truncation",
body: new Uint8Array([1, 2]),
expectedCode: "INTEGRITY_FAILED",
},
{
name: "overrun",
body: new Uint8Array([1, 2, 3, 4]),
expectedCode: "INTEGRITY_FAILED",
},
])("closes $name as a terminal stream failure", async ({ body, expectedCode }) => {
const declared = new Uint8Array([1, 2, 3]);
const payload = downloadCapabilityPayload(declared);
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
String(input) === CONTROL_ENDPOINT
? jsonResponse(payload)
: downloadResponse(body.slice().buffer, payload),
) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
const results = await collect(opened.value);
expect(results.at(-1)).toMatchObject({
ok: false,
error: { code: expectedCode },
});
const firstFailure = results.findIndex((result) => !result.ok);
expect(results.slice(firstFailure + 1)).toEqual([]);
});
it("closes native body errors without throwing across the port", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const payload = downloadCapabilityPayload(bytes);
const failingBody = new ReadableStream<Uint8Array>({
pull(controller) {
controller.error(new DOMException("secret native detail", "NetworkError"));
},
});
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
String(input) === CONTROL_ENDPOINT
? jsonResponse(payload)
: downloadResponse(failingBody, payload),
) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
await expect(collect(opened.value)).resolves.toMatchObject([
{
ok: false,
error: {
code: "NOT_READABLE",
recovery: "REISSUE_CAPABILITY",
},
},
]);
});
it("closes active abort and timeout without leaking native rejection", async () => {
const bytes = new Uint8Array([1]);
const payload = downloadCapabilityPayload(bytes);
const neverBody = () =>
new ReadableStream<Uint8Array>({ pull() {} });
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
String(input) === CONTROL_ENDPOINT
? jsonResponse(payload)
: downloadResponse(neverBody(), payload),
) as unknown as typeof fetch;
const controller = new AbortController();
let harness = createHarness({ fetcher });
let issued = await harness.provider.issueDownload({
resourceId: "resource-1",
signal: controller.signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
let opened = await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: controller.signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
const aborted = collect(opened.value, controller.signal);
controller.abort("user");
expect(await aborted).toMatchObject([
{ ok: false, error: { code: "ABORTED" } },
]);
let timeoutCallback: (() => void) | undefined;
const scheduler = {
setTimeout(callback: () => void) {
timeoutCallback = callback;
return 1;
},
clearTimeout() {},
};
harness = createHarness({ fetcher, scheduler });
issued = await harness.provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
opened = await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
const timedOut = collect(opened.value);
timeoutCallback?.();
expect(await timedOut).toMatchObject([
{
ok: false,
error: {
code: "UNAVAILABLE",
recovery: "REISSUE_CAPABILITY",
},
},
]);
});
it("rejects an expired capability before data-plane fetch", async () => {
const bytes = new Uint8Array([1]);
const payload = downloadCapabilityPayload(bytes);
let current = NOW;
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
String(input) === CONTROL_ENDPOINT
? jsonResponse(payload)
: downloadResponse(bytes.slice().buffer, payload),
) as unknown as typeof fetch;
const { provider, executor } = createHarness({
fetcher,
now: () => current,
});
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
current = Number(payload.expiresAtEpochMs) + 1;
expect(
await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
).toMatchObject({
ok: false,
error: {
code: "EXPIRED_RESOURCE",
recovery: "REISSUE_CAPABILITY",
},
});
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("rejects capabilities below the configured minimum remaining lifetime", async () => {
const bytes = new Uint8Array([1]);
const nearExpiryPayload = downloadCapabilityPayload(bytes, {
expiresAtEpochMs: NOW + 999,
});
let fetcher = vi.fn(async () =>
jsonResponse(nearExpiryPayload),
) as unknown as typeof fetch;
let harness = createHarness({ fetcher });
expect(
await harness.provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
}),
).toMatchObject({
ok: false,
error: {
code: "EXPIRED_RESOURCE",
recovery: "REISSUE_CAPABILITY",
},
});
const acceptedPayload = downloadCapabilityPayload(bytes, {
expiresAtEpochMs: NOW + 2_000,
});
let current = NOW;
fetcher = vi.fn(async (input: RequestInfo | URL) =>
String(input) === CONTROL_ENDPOINT
? jsonResponse(acceptedPayload)
: downloadResponse(bytes.slice().buffer, acceptedPayload),
) as unknown as typeof fetch;
harness = createHarness({
fetcher,
now: () => current,
});
const issued = await harness.provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
current = NOW + 1_001;
expect(
await harness.executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal: new AbortController().signal,
}),
).toMatchObject({
ok: false,
error: {
code: "EXPIRED_RESOURCE",
recovery: "REISSUE_CAPABILITY",
},
});
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("closes malformed AbortSignal inputs at every public boundary", async () => {
const bytes = new Uint8Array([1, 2]);
const payload = downloadCapabilityPayload(bytes);
const uploadChecksum = sha256Hex(bytes);
const uploadPayload = uploadCapabilityPayload({
bytes,
checksum: uploadChecksum,
});
const fetcher = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === CONTROL_ENDPOINT) {
const request = JSON.parse(String(init?.body)) as {
method: string;
};
return jsonResponse(
request.method === "GET" ? payload : uploadPayload,
);
}
if (String(input) === DOWNLOAD_HREF) {
return downloadResponse(bytes.slice().buffer, payload);
}
return responseWithUrl(
new Response(null, {
status: 200,
headers: {
[POLICY_HEADER]: "v1",
"Content-Length": "0",
ETag: "\"part-etag-1\"",
},
}),
String(uploadPayload.href),
);
},
) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const malformed = {} as AbortSignal;
expect(
await provider.issueDownload({
resourceId: "resource-1",
signal: malformed,
}),
).toMatchObject({
ok: false,
error: { code: "INVALID_INPUT" },
});
expect(
await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: uploadChecksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: malformed,
}),
).toMatchObject({
ok: false,
error: { code: "INVALID_INPUT" },
});
const issuedDownload = await provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issuedDownload.ok).toBe(true);
if (!issuedDownload.ok) return;
expect(
await executor.downloadSources.open({
resourceId: "resource-1",
capability: issuedDownload.value,
signal: malformed,
}),
).toMatchObject({
ok: false,
error: { code: "INVALID_INPUT" },
});
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issuedDownload.value,
signal: new AbortController().signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
expect(await collect(opened.value, malformed)).toMatchObject([
{ ok: false, error: { code: "INVALID_INPUT" } },
]);
expect(await collect(opened.value)).toMatchObject([
{ ok: false, error: { code: "CONFLICT" } },
]);
const issuedUpload = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: uploadChecksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issuedUpload.ok).toBe(true);
if (!issuedUpload.ok) return;
expect(
await executor.uploadParts.put({
capability: issuedUpload.value,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: uploadChecksum,
idempotencyKey: "part-attempt-1",
bytes,
signal: malformed,
}),
).toMatchObject({
ok: false,
error: { code: "INVALID_INPUT" },
});
});
});
+247
View File
@@ -0,0 +1,247 @@
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
import type {
PresignedDownloadByteSource,
} from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
import type { BrowserDataObservation } from "../../src/application/ports/browser-file-storage/shared.ts";
import {
createPresignedCapabilityVault,
createSingleUsePresignedReplayGuard,
} from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
import { createPresignedCapabilityHttpProvider } from "../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
import {
createPresignedTransferExecutor,
type PresignedTransferExecutorOptions,
} from "../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts";
export const NOW = 1_000_000;
export const CONTROL_ENDPOINT = "https://api.example/capabilities";
export const DATA_ORIGIN = "https://objects.example";
export const DOWNLOAD_PATH = "/files/resource-1";
export const DOWNLOAD_HREF =
`${DATA_ORIGIN}${DOWNLOAD_PATH}?sig=do-not-log-this`;
export const POLICY_HEADER = "x-policy-version";
export const DIGEST_HEADER = "x-content-sha256";
export const CHECKSUM_HEADER = "x-checksum-sha256";
export const UPLOAD_SESSION_ID = "upload-session-1";
export const REQUEST_BINDING_SHA256 = "c".repeat(64);
export function downloadCapabilityPayload(
bytes: Uint8Array,
overrides: Readonly<Record<string, unknown>> = {},
) {
const digest = sha256Hex(bytes);
return {
protocol: PRESIGNED_TRANSFER_PROTOCOL,
capabilityReceipt: "capability-download-1",
method: "GET",
binding: {
kind: "DOWNLOAD",
resourceId: "resource-1",
},
href: DOWNLOAD_HREF,
origin: DATA_ORIGIN,
path: DOWNLOAD_PATH,
allowedQueryParameters: ["sig"],
requestHeaders: [
{ name: "accept", value: "application/octet-stream" },
],
requiredResponseHeaders: [
{ name: POLICY_HEADER, value: "v1" },
],
digestRequestHeader: null,
digestResponseHeader: DIGEST_HEADER,
receiptResponseHeader: null,
expectedStatus: 200,
expectedResponseByteLength: null,
mediaType: "application/octet-stream",
byteLength: bytes.byteLength,
maxBytes: 64,
expectedSha256: digest,
expiresAtEpochMs: NOW + 30_000,
singleUse: true,
...overrides,
};
}
export type CapabilityPayload = ReturnType<typeof downloadCapabilityPayload>;
export function uploadCapabilityPayload(input: Readonly<{
bytes: Uint8Array;
checksum: string;
}>, overrides: Readonly<Record<string, unknown>> = {}) {
return {
protocol: PRESIGNED_TRANSFER_PROTOCOL,
capabilityReceipt: "capability-upload-1",
method: "PUT",
binding: {
kind: "UPLOAD_PART",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
idempotencyKey: "part-attempt-1",
},
href: `${DATA_ORIGIN}/uploads/session-1/part-1?sig=secret`,
origin: DATA_ORIGIN,
path: "/uploads/session-1/part-1",
allowedQueryParameters: ["sig"],
requestHeaders: [
{ name: "content-type", value: "application/octet-stream" },
{ name: CHECKSUM_HEADER, value: input.checksum },
],
requiredResponseHeaders: [
{ name: POLICY_HEADER, value: "v1" },
],
digestRequestHeader: CHECKSUM_HEADER,
digestResponseHeader: null,
receiptResponseHeader: "etag",
expectedStatus: 200,
expectedResponseByteLength: 0,
mediaType: "application/octet-stream",
byteLength: input.bytes.byteLength,
maxBytes: 64,
expectedSha256: input.checksum,
expiresAtEpochMs: NOW + 30_000,
singleUse: true,
...overrides,
};
}
export function jsonResponse(
value: unknown,
url = CONTROL_ENDPOINT,
): Response {
return responseWithUrl(
new Response(JSON.stringify(value), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url,
);
}
export function downloadResponse(
body: BodyInit | null,
payload: CapabilityPayload,
headers: Record<string, string> = {},
): Response {
return responseWithUrl(
new Response(body, {
status: payload.expectedStatus as number,
headers: {
"Content-Type": String(payload.mediaType),
"Content-Length": String(payload.byteLength),
[DIGEST_HEADER]: String(payload.expectedSha256),
[POLICY_HEADER]: "v1",
...headers,
},
}),
String(payload.href),
);
}
export function responseWithUrl(response: Response, href: string): Response {
Object.defineProperty(response, "url", {
configurable: true,
value: href,
});
return response;
}
export function createHarness(input: Readonly<{
fetcher: typeof fetch;
maxActiveCapabilities?: number;
now?: () => number;
digestBytes?: PresignedTransferExecutorOptions["digestBytes"];
scheduler?: PresignedTransferExecutorOptions["scheduler"];
observer?: Readonly<{
record(observation: BrowserDataObservation): void;
}>;
}>) {
const now = input.now ?? (() => NOW);
const vault = createPresignedCapabilityVault({
maxActiveCapabilities: input.maxActiveCapabilities ?? 16,
now,
});
const replayGuard = createSingleUsePresignedReplayGuard();
const provider = createPresignedCapabilityHttpProvider({
endpoint: CONTROL_ENDPOINT,
vault,
allowedDataOrigins: [DATA_ORIGIN],
allowedDataPathPrefixes: ["/files/", "/uploads/"],
allowedQueryParameters: ["sig"],
allowedRequestHeaders: [
"accept",
"content-type",
CHECKSUM_HEADER,
],
allowedResponseHeaders: [
POLICY_HEADER,
DIGEST_HEADER,
"etag",
],
hardMaxTransferBytes: 64,
hardMaxUploadResponseBytes: 16,
maxCapabilityTtlMs: 60_000,
minimumRemainingLifetimeMs: 1_000,
timeoutMs: 5_000,
fetcher: input.fetcher,
now,
scheduler: input.scheduler,
observer: input.observer,
});
const executor = createPresignedTransferExecutor({
vault,
replayGuard,
hardMaxTransferBytes: 64,
hardMaxChunkBytes: 2,
hardMaxUploadResponseBytes: 16,
minimumRemainingLifetimeMs: 1_000,
timeoutMs: 5_000,
fetcher: input.fetcher,
now,
scheduler: input.scheduler,
digestBytes: input.digestBytes,
observer: input.observer,
});
return { provider, executor, vault };
}
export async function collect(
source: PresignedDownloadByteSource,
signal = new AbortController().signal,
) {
const results = [];
for await (const result of source.stream(signal)) {
results.push(result);
}
return results;
}
/**
* BT-PRE-01. The download lease is lazy, so a response-shape rejection is
* observed on first consumption rather than at open().
*/
export async function firstStreamResult(
opened: Awaited<
ReturnType<
ReturnType<typeof createHarness>["executor"]["downloadSources"]["open"]
>
>,
): Promise<unknown> {
if (!opened.ok) return opened;
try {
for await (const chunk of opened.value.stream(
new AbortController().signal,
)) {
if (!chunk.ok) return chunk;
}
return { ok: true };
} finally {
opened.value.close();
}
}
@@ -0,0 +1,549 @@
import { describe, expect, it, vi } from "vitest";
import type { BrowserDataObservation } from "../../src/application/ports/browser-file-storage/shared.ts";
import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts";
import {
CHECKSUM_HEADER,
CONTROL_ENDPOINT,
DATA_ORIGIN,
DIGEST_HEADER,
DOWNLOAD_HREF,
DOWNLOAD_PATH,
NOW,
POLICY_HEADER,
REQUEST_BINDING_SHA256,
UPLOAD_SESSION_ID,
collect,
createHarness,
downloadCapabilityPayload,
downloadResponse,
jsonResponse,
responseWithUrl,
uploadCapabilityPayload,
} from "./presigned-transfer-fixture.ts";
describe("presigned upload part execution", () => {
it("snapshots, verifies and uploads a PUT part with a separate response receipt", async () => {
const original = new Uint8Array([9, 8, 7]);
const checksum = sha256Hex(original);
const payload = uploadCapabilityPayload({
bytes: original,
checksum,
});
let releaseDigest: (() => void) | undefined;
const digestGate = new Promise<void>((resolve) => {
releaseDigest = resolve;
});
const sentBodies: number[][] = [];
const dataCalls: RequestInit[] = [];
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === CONTROL_ENDPOINT) return jsonResponse(payload);
dataCalls.push(init ?? {});
sentBodies.push([
...new Uint8Array(init?.body as ArrayBuffer),
]);
return responseWithUrl(
new Response(null, {
status: 200,
headers: {
[POLICY_HEADER]: "v1",
"Content-Length": "0",
ETag: "\"part-etag-1\"",
},
}),
String(payload.href),
);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({
fetcher,
digestBytes: async (bytes) => {
await digestGate;
return sha256Hex(bytes);
},
});
const issued = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: original.byteLength,
checksumSha256: checksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const request = {
capability: issued.value,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: original.byteLength,
checksumSha256: checksum,
idempotencyKey: "part-attempt-1",
bytes: original,
signal: new AbortController().signal,
};
const pending = executor.uploadParts.put(request);
original.fill(0);
request.sessionId = "mutated-session";
request.requestBindingSha256 = "d".repeat(64);
request.checksumSha256 = "f".repeat(64);
releaseDigest?.();
expect(await pending).toEqual({
ok: true,
value: {
bytesWritten: 3,
checksumSha256: checksum,
receiptToken: "part-etag-1",
},
});
expect(sentBodies).toEqual([[9, 8, 7]]);
expect(dataCalls[0]).toMatchObject({
method: "PUT",
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
});
expect(
(dataCalls[0]?.headers as Headers).get(CHECKSUM_HEADER),
).toBe(checksum);
});
it.each(["sessionId", "requestBindingSha256"] as const)(
"rejects an actual PUT whose %s differs from the capability",
async (field) => {
const bytes = new Uint8Array([3, 2, 1]);
const checksum = sha256Hex(bytes);
const payload = uploadCapabilityPayload({ bytes, checksum });
const fetcher = vi.fn(async () =>
jsonResponse(payload),
) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await executor.uploadParts.put({
capability: issued.value,
sessionId:
field === "sessionId"
? "different-session"
: UPLOAD_SESSION_ID,
requestBindingSha256:
field === "requestBindingSha256"
? "d".repeat(64)
: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
idempotencyKey: "part-attempt-1",
bytes,
signal: new AbortController().signal,
}),
).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
expect(fetcher).toHaveBeenCalledTimes(1);
},
);
it("rejects URL-shaped upload receipts", async () => {
const bytes = new Uint8Array([4, 5, 6]);
const checksum = sha256Hex(bytes);
const payload = uploadCapabilityPayload({ bytes, checksum });
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(payload);
}
return responseWithUrl(
new Response(null, {
status: 200,
headers: {
[POLICY_HEADER]: "v1",
"Content-Length": "0",
ETag: "\"https://objects.example/authorizing-token\"",
},
}),
String(payload.href),
);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await executor.uploadParts.put({
capability: issued.value,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
idempotencyKey: "part-attempt-1",
bytes,
signal: new AbortController().signal,
}),
).toMatchObject({
ok: false,
error: { code: "INTEGRITY_FAILED" },
});
});
it("drains a bounded successful PUT acknowledgement without cancelling it", async () => {
const bytes = new Uint8Array([4, 5, 6]);
const checksum = sha256Hex(bytes);
const payload = uploadCapabilityPayload(
{ bytes, checksum },
{ expectedResponseByteLength: 2 },
);
let cancelled = false;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(payload);
}
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([8, 9]));
controller.close();
},
cancel() {
cancelled = true;
},
});
return responseWithUrl(
new Response(body, {
status: 200,
headers: {
[POLICY_HEADER]: "v1",
"Content-Length": "2",
ETag: "\"part-etag-1\"",
},
}),
String(payload.href),
);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await executor.uploadParts.put({
capability: issued.value,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
idempotencyKey: "part-attempt-1",
bytes,
signal: new AbortController().signal,
}),
).toMatchObject({
ok: true,
value: { receiptToken: "part-etag-1" },
});
expect(cancelled).toBe(false);
});
it("accepts an empty 204 PUT acknowledgement", async () => {
const bytes = new Uint8Array([4, 5, 6]);
const checksum = sha256Hex(bytes);
const payload = uploadCapabilityPayload(
{ bytes, checksum },
{
expectedStatus: 204,
expectedResponseByteLength: 0,
},
);
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
String(input) === CONTROL_ENDPOINT
? jsonResponse(payload)
: responseWithUrl(
new Response(null, {
status: 204,
headers: {
[POLICY_HEADER]: "v1",
ETag: "\"part-etag-204\"",
},
}),
String(payload.href),
),
) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await executor.uploadParts.put({
capability: issued.value,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
idempotencyKey: "part-attempt-1",
bytes,
signal: new AbortController().signal,
}),
).toMatchObject({
ok: true,
value: { receiptToken: "part-etag-204" },
});
});
it("cancels a PUT acknowledgement whose declared length violates its binding", async () => {
const bytes = new Uint8Array([4, 5, 6]);
const checksum = sha256Hex(bytes);
const payload = uploadCapabilityPayload({ bytes, checksum });
let cancelled = false;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(payload);
}
const body = new ReadableStream<Uint8Array>({
pull() {},
cancel() {
cancelled = true;
},
});
return responseWithUrl(
new Response(body, {
status: 200,
headers: {
[POLICY_HEADER]: "v1",
"Content-Length": "1",
ETag: "\"part-etag-1\"",
},
}),
String(payload.href),
);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const issued = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
expect(
await executor.uploadParts.put({
capability: issued.value,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
idempotencyKey: "part-attempt-1",
bytes,
signal: new AbortController().signal,
}),
).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
expect(cancelled).toBe(true);
});
it("observes only safe operation, outcome, failure and byte buckets", async () => {
const bytes = new Uint8Array([7, 8, 9]);
const downloadPayload = downloadCapabilityPayload(bytes);
const checksum = sha256Hex(bytes);
const uploadPayload = uploadCapabilityPayload({ bytes, checksum });
const observations: BrowserDataObservation[] = [];
const fetcher = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === CONTROL_ENDPOINT) {
const request = JSON.parse(String(init?.body)) as {
method: string;
};
return jsonResponse(
request.method === "GET"
? downloadPayload
: uploadPayload,
);
}
if (String(input) === DOWNLOAD_HREF) {
return downloadResponse(
bytes.slice().buffer,
downloadPayload,
);
}
return responseWithUrl(
new Response(null, {
status: 200,
headers: {
[POLICY_HEADER]: "v1",
"Content-Length": "0",
ETag: "\"part-etag-secret\"",
},
}),
String(uploadPayload.href),
);
},
) as unknown as typeof fetch;
const { provider, executor } = createHarness({
fetcher,
observer: {
record(observation) {
observations.push(observation);
},
},
});
const issuedDownload = await provider.issueDownload({
resourceId: "resource-1",
signal: new AbortController().signal,
});
expect(issuedDownload.ok).toBe(true);
if (!issuedDownload.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issuedDownload.value,
signal: new AbortController().signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
expect((await collect(opened.value)).every((result) => result.ok)).toBe(
true,
);
const issuedUpload = await provider.issueUploadPart({
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
mediaType: "application/octet-stream",
idempotencyKey: "part-attempt-1",
signal: new AbortController().signal,
});
expect(issuedUpload.ok).toBe(true);
if (!issuedUpload.ok) return;
expect(
await executor.uploadParts.put({
capability: issuedUpload.value,
sessionId: UPLOAD_SESSION_ID,
requestBindingSha256: REQUEST_BINDING_SHA256,
uploadBindingSha256: "b".repeat(64),
partNumber: 1,
offset: 0,
byteLength: bytes.byteLength,
checksumSha256: checksum,
idempotencyKey: "part-attempt-1",
bytes,
signal: new AbortController().signal,
}),
).toMatchObject({ ok: true });
expect(observations).toEqual([
{
operation: "PRESIGNED_TRANSFER",
outcome: "SUCCEEDED",
byteBucket: "LT1MIB",
},
{
operation: "PRESIGNED_TRANSFER",
outcome: "SUCCEEDED",
byteBucket: "LT1MIB",
},
{
operation: "DOWNLOAD",
outcome: "SUCCEEDED",
byteBucket: "LT1MIB",
},
{
operation: "PRESIGNED_TRANSFER",
outcome: "SUCCEEDED",
byteBucket: "LT1MIB",
},
{
operation: "UPLOAD_PART",
outcome: "SUCCEEDED",
byteBucket: "LT1MIB",
},
]);
const serialized = JSON.stringify(observations);
for (const secret of [
DOWNLOAD_HREF,
"do-not-log-this",
checksum,
"capability-download-1",
"capability-upload-1",
"part-etag-secret",
"resource-1",
]) {
expect(serialized).not.toContain(secret);
}
});
});
File diff suppressed because it is too large Load Diff
-30
View File
@@ -965,36 +965,6 @@ describe("security follow-up contracts", () => {
await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u);
});
it("kills and reaps a stubborn provider process group including its descendant", async () => {
const root = await mkdtemp(path.join(tmpdir(), "provider-process-group-"));
const descendantPidPath = path.join(root, "descendant.pid");
try {
const source = [
"const { spawn } = require('node:child_process');",
"const { writeFileSync } = require('node:fs');",
"const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'ignore' });",
"writeFileSync(process.env.DESCENDANT_PID_PATH, String(child.pid));",
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n");
const running = runProviderProcess({
executable: process.execPath,
arguments: ["-e", source],
environment: {
PATH: process.env.PATH,
DESCENDANT_PID_PATH: descendantPidPath,
},
timeoutMs: 250,
});
await expect(running).rejects.toThrow(/timed out.*process close/u);
const descendantPid = Number(await readFile(descendantPidPath, "utf8"));
expect(Number.isSafeInteger(descendantPid) && descendantPid > 0).toBe(true);
expect(() => process.kill(descendantPid, 0)).toThrow(/ESRCH|no such process/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it.each(["open failure", "partial write failure"])(
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
async (failureKind) => {
@@ -182,9 +182,9 @@ describe("selective Task 3 contract closure", () => {
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd());
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(82);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
expect(canonical.artifacts).toHaveLength(107);
expect(canonical.commands).toHaveLength(84);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96);
expect(canonical.artifacts).toHaveLength(109);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);
+6
View File
@@ -1,5 +1,11 @@
import { configDefaults, defineConfig } from "vitest/config";
// Tests must not inherit a caller's production React/runtime selection.
// Package scripts also launch Vitest through scripts/run-vitest.ts, which owns
// NODE_ENV before the Vitest process starts. Keeping the config deterministic
// protects direct `vitest` invocations as well.
process.env.NODE_ENV = "test";
export default defineConfig({
test: {
globals: false,