refactor: 프론트 템플릿 리펙토링
This commit is contained in:
@@ -47,21 +47,28 @@ authoritative.
|
|||||||
|
|
||||||
## Architecture
|
## 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:
|
Dependencies point inward:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
presentation -> application -> domain
|
presentation -> application -> domain
|
||||||
adapters -----^
|
adapters -----^
|
||||||
bootstrap composes concrete 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
|
`docs/architecture/starter-experience.md`. The removable vertical slice is
|
||||||
under `src/features/reference-feature`; its domain, application input, HTTP
|
under `src/features/reference-feature`; its domain, application input, HTTP
|
||||||
adapter, contracts, route runtime, and presentation are installed through the
|
binding, contracts, route runtime, and presentation own their contributions.
|
||||||
feature contribution files in `src/features`. The generic starter routes
|
The central installed catalogs only aggregate selected contributions. The
|
||||||
continue to typecheck, test, and build after that contribution is removed.
|
generic starter routes continue to typecheck, test, and build after that
|
||||||
|
feature is removed.
|
||||||
|
|
||||||
### Platform capability review
|
### Platform capability review
|
||||||
|
|
||||||
@@ -103,6 +110,7 @@ corepack pnpm check:types:node
|
|||||||
corepack pnpm check:types:test
|
corepack pnpm check:types:test
|
||||||
corepack pnpm check:architecture
|
corepack pnpm check:architecture
|
||||||
corepack pnpm test:all
|
corepack pnpm test:all
|
||||||
|
corepack pnpm test:contract
|
||||||
corepack pnpm test:e2e
|
corepack pnpm test:e2e
|
||||||
corepack pnpm test:a11y
|
corepack pnpm test:a11y
|
||||||
corepack pnpm build
|
corepack pnpm build
|
||||||
@@ -115,6 +123,11 @@ corepack pnpm drill:runbooks
|
|||||||
corepack pnpm check:ci
|
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
|
`check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript
|
||||||
project로 모두 검사한다. type/architecture/security/registry의 invalid
|
project로 모두 검사한다. type/architecture/security/registry의 invalid
|
||||||
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
|
fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로
|
||||||
@@ -153,7 +166,7 @@ Live release verification additionally requires `HOSTING_BASE_URL`.
|
|||||||
|
|
||||||
## CI and evidence
|
## 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:
|
`.gitea/workflows/quality-gates.yml`. It follows:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -142,6 +142,11 @@
|
|||||||
"script": "test:unit",
|
"script": "test:unit",
|
||||||
"expect": "pass"
|
"expect": "pass"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "test-contract",
|
||||||
|
"script": "test:contract",
|
||||||
|
"expect": "pass"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "test-coverage",
|
"id": "test-coverage",
|
||||||
"script": "test:coverage",
|
"script": "test:coverage",
|
||||||
@@ -164,6 +169,11 @@
|
|||||||
"script": "test:integration",
|
"script": "test:integration",
|
||||||
"expect": "pass"
|
"expect": "pass"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "test-system",
|
||||||
|
"script": "test:system",
|
||||||
|
"expect": "pass"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "test-http-scenario-evidence",
|
"id": "test-http-scenario-evidence",
|
||||||
"script": "test:http-scenario-evidence",
|
"script": "test:http-scenario-evidence",
|
||||||
@@ -794,6 +804,15 @@
|
|||||||
"test-unit"
|
"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",
|
"id": "artifact-artifacts-tests-coverage-xml",
|
||||||
"path": "artifacts/tests/coverage.xml",
|
"path": "artifacts/tests/coverage.xml",
|
||||||
@@ -860,6 +879,15 @@
|
|||||||
"test-integration"
|
"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",
|
"id": "artifact-artifacts-tests-http-scenario-executions-json",
|
||||||
"path": "artifacts/tests/http-scenario-executions.json",
|
"path": "artifacts/tests/http-scenario-executions.json",
|
||||||
@@ -1701,12 +1729,14 @@
|
|||||||
"name": "unit",
|
"name": "unit",
|
||||||
"commandIds": [
|
"commandIds": [
|
||||||
"test-unit",
|
"test-unit",
|
||||||
|
"test-contract",
|
||||||
"test-coverage",
|
"test-coverage",
|
||||||
"check-coverage-fixture"
|
"check-coverage-fixture"
|
||||||
],
|
],
|
||||||
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-005-txt",
|
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-005-txt",
|
||||||
"evidenceArtifactIds": [
|
"evidenceArtifactIds": [
|
||||||
"artifact-artifacts-tests-unit-xml",
|
"artifact-artifacts-tests-unit-xml",
|
||||||
|
"artifact-artifacts-tests-contract-xml",
|
||||||
"artifact-artifacts-tests-coverage-xml",
|
"artifact-artifacts-tests-coverage-xml",
|
||||||
"artifact-artifacts-tests-coverage-coverage-summary-json",
|
"artifact-artifacts-tests-coverage-coverage-summary-json",
|
||||||
"artifact-artifacts-quality-risk-coverage-json",
|
"artifact-artifacts-quality-risk-coverage-json",
|
||||||
@@ -1871,6 +1901,7 @@
|
|||||||
"id": "FE-GATE-013",
|
"id": "FE-GATE-013",
|
||||||
"name": "security",
|
"name": "security",
|
||||||
"commandIds": [
|
"commandIds": [
|
||||||
|
"test-system",
|
||||||
"verify-reproducible-build",
|
"verify-reproducible-build",
|
||||||
"build-release-candidate",
|
"build-release-candidate",
|
||||||
"verify-local-evidence",
|
"verify-local-evidence",
|
||||||
@@ -1881,6 +1912,7 @@
|
|||||||
],
|
],
|
||||||
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-013-txt",
|
"logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-013-txt",
|
||||||
"evidenceArtifactIds": [
|
"evidenceArtifactIds": [
|
||||||
|
"artifact-artifacts-tests-system-xml",
|
||||||
"artifact-artifacts-security-scan-sarif",
|
"artifact-artifacts-security-scan-sarif",
|
||||||
"artifact-artifacts-release-dependency-inventory-json",
|
"artifact-artifacts-release-dependency-inventory-json",
|
||||||
"artifact-artifacts-release-sbom-cdx-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.
|
||||||
@@ -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 기반
|
#### TanStack Query의 React integration test 기반
|
||||||
|
|
||||||
`tests/component/application-query.test.tsx`는 production query inbound
|
production query inbound adapter의 React integration은 behavior owner별
|
||||||
adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal
|
component suite로 분리되어 있다.
|
||||||
failure, background stale-failure latch와 retry 복구, duplicate submit,
|
|
||||||
optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제
|
- `application-query-bridge.test.tsx`: initial/background state, cancellation
|
||||||
QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의
|
- `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는 꺼져 있다.
|
query/mutation vendor retry는 꺼져 있다.
|
||||||
|
|
||||||
#### Form과 route 위험
|
#### Form과 route 위험
|
||||||
|
|||||||
+65
-14
@@ -3,14 +3,68 @@
|
|||||||
Each gate is blocking in its declared scope. Failures are not downgraded with
|
Each gate is blocking in its declared scope. Failures are not downgraded with
|
||||||
`continue-on-error` or warning-only scripts.
|
`continue-on-error` or warning-only scripts.
|
||||||
|
|
||||||
| Level | Command | Evidence |
|
## Executable levels
|
||||||
| --- | --- | --- |
|
|
||||||
| runtime schema | `pnpm test:runtime-schema` | `artifacts/tests/runtime-schema.xml` |
|
| Level | Command | Ownership / prerequisite | Evidence |
|
||||||
| unit | `pnpm test:unit` | `artifacts/tests/unit.xml` |
|
| --- | --- | --- | --- |
|
||||||
| component | `pnpm test:component` | `artifacts/tests/component.xml` |
|
| runtime schema | `pnpm test:runtime-schema` | pure runtime schema contracts | `artifacts/tests/runtime-schema.xml` |
|
||||||
| integration | `pnpm test:integration` | `artifacts/tests/integration.xml` |
|
| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` |
|
||||||
| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
|
| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` |
|
||||||
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` |
|
| 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,
|
End-to-end and automated accessibility scenarios run on the pinned Chromium,
|
||||||
Firefox, and WebKit engines. The responsive contract explicitly exercises
|
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
|
3. release gates plus rollback/runbook drills
|
||||||
4. production promotion plus eligible field Web Vitals evidence
|
4. production promotion plus eligible field Web Vitals evidence
|
||||||
|
|
||||||
This file describes the currently registered taxonomy. The
|
The [frontend platform testing strategy](./frontend-platform-testing-strategy.md)
|
||||||
[frontend platform testing strategy](./frontend-platform-testing-strategy.md)
|
contains the broader testing design. This file is the executable taxonomy for
|
||||||
documents the target additions: test TypeScript projects, real bootstrap
|
where a test belongs and which environment is allowed to run it.
|
||||||
composition tests, shared MSW scenarios, query/mutation/form/router coverage,
|
|
||||||
Storybook interaction and accessibility checks, visual regression, and a
|
|
||||||
built-output Playwright profile.
|
|
||||||
|
|||||||
+11
-9
@@ -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: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: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",
|
"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: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": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
|
"test:unit": "node scripts/run-vitest.ts 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:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml",
|
||||||
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.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:http-scenario-catalog": "vitest run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1",
|
"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: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": "playwright test",
|
||||||
"test:e2e:dev": "playwright test --config playwright.dev.config.ts",
|
"test:e2e:dev": "playwright test --config playwright.dev.config.ts",
|
||||||
"test:browser-capabilities": "playwright test --config playwright.capabilities.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: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:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts",
|
||||||
"test:realtime-removal": "node scripts/test-realtime-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",
|
"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",
|
"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",
|
"verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts",
|
||||||
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
|
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
|
||||||
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, readFile } from "node:fs/promises";
|
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 { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.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");
|
||||||
@@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
const CANONICAL_GATE_SHAPE_SHA256 =
|
const CANONICAL_GATE_SHAPE_SHA256 =
|
||||||
"4617ada21cbdeb217d118146bd572860d7c58ad222142a52d41916b26577239a";
|
"372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4";
|
||||||
|
|
||||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||||
const normalized = gates.map(
|
const normalized = gates.map(
|
||||||
@@ -477,13 +477,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
|||||||
if (contract.gates.length !== 27) {
|
if (contract.gates.length !== 27) {
|
||||||
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
|
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(
|
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) {
|
if (contract.artifacts.length !== 109) {
|
||||||
failures.push(`artifact authority baseline must contain exactly 107 artifacts; received ${contract.artifacts.length}`);
|
failures.push(`artifact authority baseline must contain exactly 109 artifacts; received ${contract.artifacts.length}`);
|
||||||
}
|
}
|
||||||
if (contract.stages.length !== 5) {
|
if (contract.stages.length !== 5) {
|
||||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url";
|
|||||||
|
|
||||||
import { shouldRetry } from "../src/adapters/http/retry-policy.ts";
|
import { shouldRetry } from "../src/adapters/http/retry-policy.ts";
|
||||||
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.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 type { StoragePort } from "../src/application/ports/storage-port.ts";
|
||||||
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
|
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
|
||||||
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
|
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
|
||||||
|
|||||||
@@ -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 { verifyContractSet } from "../../src/contracts/contract-set.ts";
|
||||||
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
|
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
|
||||||
import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts";
|
import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts";
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url";
|
|||||||
import {
|
import {
|
||||||
verifyCompatibilityTuple,
|
verifyCompatibilityTuple,
|
||||||
type CompatibilityTuple,
|
type CompatibilityTuple,
|
||||||
} from "../src/application/policies/compatibility.ts";
|
} from "../src/contracts/compatibility.ts";
|
||||||
import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
|
import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
|
||||||
import {
|
import {
|
||||||
parseBuildManifestArtifact,
|
parseBuildManifestArtifact,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
BrowserRpcUnaryPort,
|
BrowserRpcUnaryPort,
|
||||||
} from "../../application/ports/browser-rpc/index.ts";
|
} from "../../application/ports/browser-rpc/index.ts";
|
||||||
import type { ClockPort } from "../../application/ports/clock-port.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 {
|
import {
|
||||||
installBrowserRpcContractBindings,
|
installBrowserRpcContractBindings,
|
||||||
type InstalledBrowserRpcContractBindings,
|
type InstalledBrowserRpcContractBindings,
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ import {
|
|||||||
type PhysicalAttemptState,
|
type PhysicalAttemptState,
|
||||||
} from "./http-effect-certainty.ts";
|
} from "./http-effect-certainty.ts";
|
||||||
import { parseRetryAfter } from "./retry-policy.ts";
|
import { parseRetryAfter } from "./retry-policy.ts";
|
||||||
|
import {
|
||||||
|
canRetryTransport,
|
||||||
|
isRetryableHttpStatus,
|
||||||
|
isRetryableSemantics,
|
||||||
|
jitteredDelay,
|
||||||
|
retryDelayFor,
|
||||||
|
} from "./http-retry-lifecycle.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* §7–§8. Descriptor-driven HTTP execution.
|
* §7–§8. Descriptor-driven HTTP execution.
|
||||||
@@ -299,14 +306,6 @@ export type ContractHttpExecutorDependencies = Readonly<{
|
|||||||
observe?: (observation: HttpExecutionObservation) => void;
|
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 =
|
type MutationIntentValidation =
|
||||||
| Readonly<{ ok: true; intent?: MutationIntent }>
|
| Readonly<{ ok: true; intent?: MutationIntent }>
|
||||||
| Readonly<{
|
| Readonly<{
|
||||||
@@ -1347,7 +1346,7 @@ async function admitProblem<Input, WireOutput, Problem>(
|
|||||||
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
||||||
const contract = operation.contract;
|
const contract = operation.contract;
|
||||||
const isCommand = contract.commandEffect !== null;
|
const isCommand = contract.commandEffect !== null;
|
||||||
const retryable = RETRYABLE_STATUSES.has(status);
|
const retryable = isRetryableHttpStatus(status);
|
||||||
|
|
||||||
const bytes = await readResponseBytes(
|
const bytes = await readResponseBytes(
|
||||||
response,
|
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. */
|
/** §7.12. No raw header map, URL, cookie, traceparent or ETag value escapes. */
|
||||||
function safeMetadata(
|
function safeMetadata(
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,13 @@ export {
|
|||||||
type HttpTransportFailure,
|
type HttpTransportFailure,
|
||||||
type SafeResponseMetadata,
|
type SafeResponseMetadata,
|
||||||
} from "./http-execution-v3.ts";
|
} from "./http-execution-v3.ts";
|
||||||
|
export {
|
||||||
|
createFeatureHttpBinding,
|
||||||
|
defineFeatureHttpOperation,
|
||||||
|
type FeatureHttpBinding,
|
||||||
|
type FeatureHttpOperation,
|
||||||
|
type InstalledHttpOperationExecutor,
|
||||||
|
} from "./feature-http-binding.ts";
|
||||||
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
||||||
export type { CredentialPatchOutcome } from "./http-contract-bridge.ts";
|
export type { CredentialPatchOutcome } from "./http-contract-bridge.ts";
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Result } from "./result.ts";
|
import type { Result } from "../../contracts/result.ts";
|
||||||
|
|
||||||
export type CursorPage<Value> = Readonly<{
|
export type CursorPage<Value> = Readonly<{
|
||||||
items: readonly Value[];
|
items: readonly Value[];
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Result } from "../../application/result.ts";
|
import type { Result } from "../../contracts/result.ts";
|
||||||
import type {
|
import type {
|
||||||
CursorPage,
|
CursorPage,
|
||||||
CursorPaginationProfile,
|
CursorPaginationProfile,
|
||||||
CursorPaginationRuntime,
|
CursorPaginationRuntime,
|
||||||
} from "../../contracts/cursor-pagination.ts";
|
} from "./cursor-pagination-contract.ts";
|
||||||
import { createFailure } from "../../contracts/errors.ts";
|
import { createFailure } from "../../contracts/errors.ts";
|
||||||
import { snapshotExactObject } from "../../contracts/exact-snapshot.ts";
|
import { snapshotExactObject } from "../../contracts/exact-snapshot.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export {
|
|||||||
type ConditionalValidatorStore,
|
type ConditionalValidatorStore,
|
||||||
} from "./conditional-validator-store.ts";
|
} from "./conditional-validator-store.ts";
|
||||||
export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts";
|
export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts";
|
||||||
|
export type {
|
||||||
|
CursorPage,
|
||||||
|
CursorPaginationProfile,
|
||||||
|
CursorPaginationRuntime,
|
||||||
|
} from "./cursor-pagination-contract.ts";
|
||||||
export {
|
export {
|
||||||
createServerStateScopeRuntime,
|
createServerStateScopeRuntime,
|
||||||
type ScopeResetParticipant,
|
type ScopeResetParticipant,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
* The implementation lives in `src/contracts/compatibility.ts`: it is a pure
|
* The implementation lives in `src/contracts/compatibility.ts`: it is a pure
|
||||||
* predicate over release tokens with no application state, and
|
* predicate over release tokens with no application state, and
|
||||||
* `src/contracts/release-tokens.ts` needs it, which previously made contracts
|
* `src/contracts/release-tokens.ts` needs it, which previously made contracts
|
||||||
* import the application layer. This module re-exports it for application-side
|
* import the application layer. This module is retained only as a migration
|
||||||
* and script-side callers.
|
* shim; internal callers use src/contracts/compatibility.ts directly.
|
||||||
|
*
|
||||||
|
* @deprecated Import compatibility contracts from ../../contracts/compatibility.ts.
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
COMPATIBILITY_TUPLE_FIELDS,
|
COMPATIBILITY_TUPLE_FIELDS,
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* The single success/failure carrier used across application input boundaries.
|
* Compatibility re-export for application-side callers from older revisions.
|
||||||
* Adapters map technology-specific errors to an application failure before
|
|
||||||
* constructing this value.
|
|
||||||
*
|
*
|
||||||
* The type itself lives in `src/contracts` because both layers need it and
|
* The canonical Result definition is src/contracts/result.ts. New code should
|
||||||
* neither owns it: `src/contracts/server-state.ts` and
|
* import it from that module so the shared carrier has one public authority.
|
||||||
* `src/contracts/cursor-pagination.ts` reached back into the application layer
|
*
|
||||||
* for it, which made the ownership of the shared vocabulary ambiguous in both
|
* @deprecated Import Result from ../contracts/result.ts.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export type { Result } from "../contracts/result.ts";
|
export type { Result } from "../contracts/result.ts";
|
||||||
|
|||||||
@@ -3,22 +3,25 @@ import {
|
|||||||
type InstalledContractContribution,
|
type InstalledContractContribution,
|
||||||
type InstalledContractPackageIdentity,
|
type InstalledContractPackageIdentity,
|
||||||
} from "../contracts/external-contract-runtime.ts";
|
} 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 { 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
|
* Each feature owns its concrete service/package contribution. This catalog
|
||||||
* the generated package only from
|
* selects contributions whose feature is installed; it does not contain
|
||||||
* `src/features/<feature>/contracts/<service>-contract-contribution.ts`.
|
* feature-specific branching or service wiring.
|
||||||
*/
|
*/
|
||||||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||||
Object.freeze(
|
Object.freeze(
|
||||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) =>
|
||||||
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION]
|
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||||
: [],
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts";
|
import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts";
|
||||||
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
||||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
|
||||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
|
||||||
/**
|
const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([
|
||||||
* §3.5. Partial on purpose: a feature the manifest did not select supplies no
|
REFERENCE_FEATURE_ADAPTER_CONTRIBUTION,
|
||||||
* driving input, so consumers have to narrow before calling one. A total type
|
] as const);
|
||||||
* here would let feature code compile against an input that is not there.
|
|
||||||
*/
|
|
||||||
type InstalledFeatureInputs = Readonly<
|
|
||||||
Partial<Pick<ApplicationFeatureInputs, typeof REFERENCE_FEATURE_ID>>
|
|
||||||
>;
|
|
||||||
|
|
||||||
|
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(
|
export function createInstalledFeatureInputs(
|
||||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
|
context: FeatureAdapterContext,
|
||||||
): InstalledFeatureInputs {
|
): InstalledFeatureInputs {
|
||||||
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
|
const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
|
||||||
return Object.freeze({});
|
.filter((contribution) =>
|
||||||
}
|
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||||
const referenceFeature = createReferenceFeatureInstalledInput(context);
|
)
|
||||||
return Object.freeze({
|
.map((contribution) => {
|
||||||
[referenceFeature.featureId]: referenceFeature.input,
|
const installed = contribution.createInput(context);
|
||||||
|
return [installed.featureId, installed.input] as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,38 @@
|
|||||||
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
||||||
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
||||||
import {
|
import { REFERENCE_FEATURE_RUNTIME_CONTRIBUTION } from "./reference-feature/presentation/reference-feature-runtime.tsx";
|
||||||
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 { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
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
|
* Central runtime catalogs only aggregate feature-owned contributions.
|
||||||
* component, so the router has nothing to mount for it. The module is still
|
* Adding route codecs/components to a feature no longer requires duplicating
|
||||||
* linked — a static import cannot be undone by a value — which is why physical
|
* reference-specific selection logic in this composition file.
|
||||||
* removal is FE-GATE-020's job and this is deselection, not deletion.
|
|
||||||
*/
|
*/
|
||||||
const referenceSelected = INSTALLED_PRODUCT_FEATURE_IDS.includes(
|
export const ROUTE_CODECS = Object.freeze(
|
||||||
REFERENCE_FEATURE_ID,
|
Object.assign(
|
||||||
|
{},
|
||||||
|
PLATFORM_ROUTE_CODECS,
|
||||||
|
...INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS.map(
|
||||||
|
(contribution) => contribution.routeCodecs,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ROUTE_CODECS = Object.freeze({
|
export const ROUTE_RUNTIME = Object.freeze(
|
||||||
...PLATFORM_ROUTE_CODECS,
|
Object.assign(
|
||||||
...(referenceSelected ? REFERENCE_FEATURE_ROUTE_CODECS : {}),
|
{},
|
||||||
});
|
PLATFORM_ROUTE_RUNTIME,
|
||||||
|
...INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS.map(
|
||||||
export const ROUTE_RUNTIME = Object.freeze({
|
(contribution) => contribution.routeRuntime,
|
||||||
...PLATFORM_ROUTE_RUNTIME,
|
),
|
||||||
...(referenceSelected ? REFERENCE_FEATURE_ROUTE_RUNTIME : {}),
|
),
|
||||||
});
|
);
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므
|
|||||||
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
|
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
|
||||||
`ApiFailure`는 같은 type의 alias다.
|
`ApiFailure`는 같은 type의 alias다.
|
||||||
|
|
||||||
HTTP adapter의 operation map은 operation ID마다 허용된 route ID, request shape와
|
HTTP adapter는 `src/adapters/http/feature-http-binding.ts`의 capability-specific
|
||||||
성공 value type을 함께 묶는다. raw HTTP executor의 성공값은 Zod request/response
|
typed binding을 사용한다. feature는 operation ID, route ID, request type, 성공
|
||||||
검증과 feature mapper를 통과한 뒤 operation별 runtime result guard에서 typed
|
value type, mapper만 소유한다. timeout/cancellation/auth/transport/contract
|
||||||
executor로 승격된다. 따라서 gateway 메서드는 개별 응답 cast 없이 정확한 결과를
|
violation을 `AppFailure`로 정규화하는 책임은 reusable HTTP capability가 소유한다.
|
||||||
반환하고, operation/route/request 조합 오류는 typecheck에서 차단된다.
|
따라서 gateway 메서드는 transport outcome이나 runtime result guard를 반복 구현하지
|
||||||
|
않고도 정확한 결과 타입을 반환한다.
|
||||||
|
|
||||||
route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route type은
|
route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route type은
|
||||||
`route-contract.ts`, React context/provider/hook은 `route-input.tsx`가 각각
|
`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`
|
feature가 실제 contribution을 소유한다.
|
||||||
- component/codec: `src/features/installed-feature-runtimes.tsx`
|
|
||||||
- bootstrap input 조립: `src/features/installed-feature-adapters.ts`
|
|
||||||
|
|
||||||
새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application,
|
- contract: `reference-feature-contract.ts`
|
||||||
router나 HTTP client에 하드코딩하지 않는다.
|
- 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 {
|
import {
|
||||||
createFailure,
|
createFeatureHttpBinding,
|
||||||
kindForStatus,
|
type InstalledHttpOperationExecutor,
|
||||||
} from "../../../contracts/errors.ts";
|
} from "../../../adapters/http/index.ts";
|
||||||
import type { HttpExecutionOutcome } from "../../../adapters/http/index.ts";
|
|
||||||
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
|
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
|
||||||
import {
|
import {
|
||||||
REFERENCE_FEATURE_ID,
|
REFERENCE_FEATURE_ID,
|
||||||
} from "../contracts/reference-feature-contract.ts";
|
} from "../contracts/reference-feature-contract.ts";
|
||||||
import { mapReferenceOperation } from "../contracts/reference-mapper.ts";
|
|
||||||
import {
|
import {
|
||||||
createReferenceHttpGateway,
|
createReferenceHttpGateway,
|
||||||
type RawReferenceHttpExecutor,
|
REFERENCE_HTTP_OPERATIONS,
|
||||||
type ReferenceHttpRequest,
|
|
||||||
type ReferenceOperationId,
|
|
||||||
} from "./reference-http-gateway.ts";
|
} from "./reference-http-gateway.ts";
|
||||||
|
|
||||||
export type InstalledContractOperationExecutor = Readonly<{
|
export type InstalledContractOperationExecutor =
|
||||||
execute(
|
InstalledHttpOperationExecutor;
|
||||||
operationId: string,
|
|
||||||
input: unknown,
|
|
||||||
context: Readonly<{
|
|
||||||
routeId: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
intent?: MutationIntent;
|
|
||||||
}>,
|
|
||||||
): Promise<HttpExecutionOutcome<unknown, unknown>>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installed feature consumes the composed external-contract operation
|
* Feature-owned composition seam.
|
||||||
* registry through one descriptor-driven executor. Legacy ApiOperation/schema
|
*
|
||||||
* registries are intentionally absent from this production 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<{
|
export function createReferenceFeatureInstalledInput(context: Readonly<{
|
||||||
contractOperations: InstalledContractOperationExecutor;
|
contractOperations: InstalledHttpOperationExecutor;
|
||||||
}>) {
|
}>) {
|
||||||
const rawHttp: RawReferenceHttpExecutor = Object.freeze({
|
const http = createFeatureHttpBinding(
|
||||||
async execute(request) {
|
context.contractOperations,
|
||||||
const operationId = request.operationId;
|
REFERENCE_HTTP_OPERATIONS,
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
featureId: REFERENCE_FEATURE_ID,
|
featureId: REFERENCE_FEATURE_ID,
|
||||||
input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)),
|
input: createReferenceFeatureInput(createReferenceHttpGateway(http)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function inputFor(
|
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({
|
||||||
request: ReferenceHttpRequest<ReferenceOperationId>,
|
featureId: REFERENCE_FEATURE_ID,
|
||||||
): unknown {
|
createInput: createReferenceFeatureInstalledInput,
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,160 +1,65 @@
|
|||||||
import type { Result } from "../../../application/result.ts";
|
|
||||||
import {
|
import {
|
||||||
createFailure,
|
defineFeatureHttpOperation,
|
||||||
type ApiFailure,
|
type FeatureHttpBinding,
|
||||||
} from "../../../contracts/errors.ts";
|
} from "../../../adapters/http/index.ts";
|
||||||
import type {
|
import type {
|
||||||
ReferenceCreateCommand,
|
ReferenceCreateCommand,
|
||||||
ReferenceGateway,
|
ReferenceGateway,
|
||||||
ReferenceListFilters,
|
ReferenceListFilters,
|
||||||
} from "../application/reference-feature-api.ts";
|
} from "../application/reference-feature-api.ts";
|
||||||
import type { ReferenceResource } from "../domain/reference-resource.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<{
|
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
|
||||||
LIST_REFERENCE_RESOURCES: Readonly<{
|
LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation<
|
||||||
request: Readonly<{
|
ReferenceListFilters,
|
||||||
operationId: "LIST_REFERENCE_RESOURCES";
|
readonly ReferenceResource[]
|
||||||
routeId: "REFERENCE_RESOURCE_LIST";
|
>({
|
||||||
searchParams: ReferenceListFilters;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}>;
|
|
||||||
value: readonly ReferenceResource[];
|
|
||||||
}>;
|
|
||||||
CREATE_REFERENCE_RESOURCE: Readonly<{
|
|
||||||
request: Readonly<{
|
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE";
|
|
||||||
routeId: "REFERENCE_RESOURCE_LIST";
|
|
||||||
body: ReferenceCreateCommand;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
intent?: MutationIntent;
|
|
||||||
}>;
|
|
||||||
value: ReferenceResource;
|
|
||||||
}>;
|
|
||||||
GET_REFERENCE_RESOURCE: Readonly<{
|
|
||||||
request: Readonly<{
|
|
||||||
operationId: "GET_REFERENCE_RESOURCE";
|
|
||||||
routeId: "REFERENCE_RESOURCE_DETAIL";
|
|
||||||
pathParams: Readonly<{ resourceId: string }>;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}>;
|
|
||||||
value: ReferenceResource;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type ReferenceOperationId = keyof ReferenceOperationMap;
|
|
||||||
|
|
||||||
export type ReferenceHttpRequest<
|
|
||||||
OperationId extends ReferenceOperationId,
|
|
||||||
> = ReferenceOperationMap[OperationId]["request"];
|
|
||||||
|
|
||||||
export type ReferenceHttpResult<
|
|
||||||
OperationId extends ReferenceOperationId,
|
|
||||||
> = Result<ReferenceOperationMap[OperationId]["value"], ApiFailure>;
|
|
||||||
|
|
||||||
export type RawReferenceHttpExecutor = Readonly<{
|
|
||||||
execute(
|
|
||||||
request: ReferenceHttpRequest<ReferenceOperationId>,
|
|
||||||
): Promise<Result<unknown, ApiFailure>>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export function createReferenceHttpGateway(
|
|
||||||
http: RawReferenceHttpExecutor,
|
|
||||||
): ReferenceGateway {
|
|
||||||
return Object.freeze({
|
|
||||||
async list(
|
|
||||||
filters: ReferenceListFilters,
|
|
||||||
context?: Readonly<{ signal?: AbortSignal }>,
|
|
||||||
) {
|
|
||||||
const result = await http.execute({
|
|
||||||
operationId: "LIST_REFERENCE_RESOURCES",
|
operationId: "LIST_REFERENCE_RESOURCES",
|
||||||
routeId: "REFERENCE_RESOURCE_LIST",
|
routeId: "REFERENCE_RESOURCE_LIST",
|
||||||
searchParams: filters,
|
mapSuccess: mapReferenceResourceListPayload,
|
||||||
signal: context?.signal,
|
}),
|
||||||
});
|
CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation<
|
||||||
return projectListResult(result);
|
ReferenceCreateCommand,
|
||||||
},
|
ReferenceResource
|
||||||
async create(
|
>({
|
||||||
command: ReferenceCreateCommand,
|
|
||||||
context?: Readonly<{
|
|
||||||
signal?: AbortSignal;
|
|
||||||
intent?: MutationIntent;
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const result = await http.execute({
|
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
operationId: "CREATE_REFERENCE_RESOURCE",
|
||||||
routeId: "REFERENCE_RESOURCE_LIST",
|
routeId: "REFERENCE_RESOURCE_LIST",
|
||||||
body: command,
|
mapSuccess: mapReferenceResourcePayload,
|
||||||
signal: context?.signal,
|
}),
|
||||||
intent: context?.intent,
|
GET_REFERENCE_RESOURCE: defineFeatureHttpOperation<
|
||||||
});
|
Readonly<{ resourceId: string }>,
|
||||||
return projectResourceResult("CREATE_REFERENCE_RESOURCE", result);
|
ReferenceResource
|
||||||
},
|
>({
|
||||||
async get(
|
|
||||||
resourceId: string,
|
|
||||||
context?: Readonly<{ signal?: AbortSignal }>,
|
|
||||||
) {
|
|
||||||
const result = await http.execute({
|
|
||||||
operationId: "GET_REFERENCE_RESOURCE",
|
operationId: "GET_REFERENCE_RESOURCE",
|
||||||
routeId: "REFERENCE_RESOURCE_DETAIL",
|
routeId: "REFERENCE_RESOURCE_DETAIL",
|
||||||
pathParams: { resourceId },
|
mapSuccess: mapReferenceResourcePayload,
|
||||||
signal: context?.signal,
|
}),
|
||||||
});
|
} as const);
|
||||||
return projectResourceResult("GET_REFERENCE_RESOURCE", result);
|
|
||||||
|
export type ReferenceHttpBinding = FeatureHttpBinding<
|
||||||
|
typeof REFERENCE_HTTP_OPERATIONS
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createReferenceHttpGateway(
|
||||||
|
http: ReferenceHttpBinding,
|
||||||
|
): ReferenceGateway {
|
||||||
|
return Object.freeze({
|
||||||
|
list(filters, context) {
|
||||||
|
return http.execute("LIST_REFERENCE_RESOURCES", filters, context);
|
||||||
|
},
|
||||||
|
create(command, context) {
|
||||||
|
return http.execute("CREATE_REFERENCE_RESOURCE", command, context);
|
||||||
|
},
|
||||||
|
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 type {} from "../../../application/ports/in/application-api.ts";
|
||||||
import {
|
import {
|
||||||
toReferenceView,
|
toReferenceView,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
mappingFailure,
|
mappingFailure,
|
||||||
mappingSuccess,
|
mappingSuccess,
|
||||||
type MappingResult,
|
|
||||||
type InstalledBoundaryMapper,
|
type InstalledBoundaryMapper,
|
||||||
|
type MappingResult,
|
||||||
} from "../../../contracts/boundary-mapper.ts";
|
} from "../../../contracts/boundary-mapper.ts";
|
||||||
|
|
||||||
export type ReferenceResourceView = Readonly<{
|
export type ReferenceResourceView = Readonly<{
|
||||||
@@ -16,7 +16,9 @@ export type ReferenceResourceView = Readonly<{
|
|||||||
optimistic?: boolean;
|
optimistic?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function mapReferenceDto(value: unknown): MappingResult<ReferenceResource> {
|
export function mapReferenceResourcePayload(
|
||||||
|
value: unknown,
|
||||||
|
): MappingResult<ReferenceResource> {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
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(
|
export function mapReferenceOperation(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
|
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
|
||||||
if (operationId === "LIST_REFERENCE_RESOURCES") {
|
if (operationId === "LIST_REFERENCE_RESOURCES") {
|
||||||
if (!Array.isArray(payload)) {
|
return mapReferenceResourceListPayload(payload);
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
|
||||||
}
|
|
||||||
if (payload.length > 100) return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
|
|
||||||
const output: ReferenceResource[] = [];
|
|
||||||
for (const item of payload) {
|
|
||||||
const mapped = mapReferenceDto(item);
|
|
||||||
if (!mapped.ok) return mapped;
|
|
||||||
output.push(mapped.value);
|
|
||||||
}
|
|
||||||
return mappingSuccess(Object.freeze(output));
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
operationId === "CREATE_REFERENCE_RESOURCE" ||
|
operationId === "CREATE_REFERENCE_RESOURCE" ||
|
||||||
operationId === "GET_REFERENCE_RESOURCE"
|
operationId === "GET_REFERENCE_RESOURCE"
|
||||||
) {
|
) {
|
||||||
return mapReferenceDto(payload);
|
return mapReferenceResourcePayload(payload);
|
||||||
}
|
}
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||||
}
|
}
|
||||||
@@ -71,8 +85,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
|
|||||||
outputContractId: "ReferenceResourceList",
|
outputContractId: "ReferenceResourceList",
|
||||||
owner: "feature-frontend-reference-feature-vertical-slice",
|
owner: "feature-frontend-reference-feature-vertical-slice",
|
||||||
maxOutputItems: 100,
|
maxOutputItems: 100,
|
||||||
map: (input: unknown) =>
|
map: mapReferenceResourceListPayload,
|
||||||
mapReferenceOperation("LIST_REFERENCE_RESOURCES", input),
|
|
||||||
}),
|
}),
|
||||||
ReferenceResourceMapper: Object.freeze({
|
ReferenceResourceMapper: Object.freeze({
|
||||||
mapperId: "ReferenceResourceMapper",
|
mapperId: "ReferenceResourceMapper",
|
||||||
@@ -81,8 +94,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
|
|||||||
outputContractId: "ReferenceResource",
|
outputContractId: "ReferenceResource",
|
||||||
owner: "feature-frontend-reference-feature-vertical-slice",
|
owner: "feature-frontend-reference-feature-vertical-slice",
|
||||||
maxOutputItems: 1,
|
maxOutputItems: 1,
|
||||||
map: (input: unknown) =>
|
map: mapReferenceResourcePayload,
|
||||||
mapReferenceOperation("GET_REFERENCE_RESOURCE", input),
|
|
||||||
}),
|
}),
|
||||||
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
|
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
import { REFERENCE_FEATURE_ID } from "../contracts/reference-feature-contract.ts";
|
||||||
import {
|
import {
|
||||||
referenceResourceListQuerySchema,
|
referenceResourceListQuerySchema,
|
||||||
referenceResourceParamsSchema,
|
referenceResourceParamsSchema,
|
||||||
@@ -28,3 +29,9 @@ export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
|
|||||||
Component: lazy(() => import("./reference-resource-status-page.tsx")),
|
Component: lazy(() => import("./reference-resource-status-page.tsx")),
|
||||||
}),
|
}),
|
||||||
} as const;
|
} 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 {
|
import {
|
||||||
useApplicationMutation,
|
useApplicationMutation,
|
||||||
useApplicationQuery,
|
useApplicationQuery,
|
||||||
@@ -50,8 +50,32 @@ function measureResourceList(
|
|||||||
return { itemCount: views.length, estimatedBytes };
|
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 {
|
export function useReferenceFeatureInput(): ReferenceFeatureInput {
|
||||||
return useApplication().features.get(REFERENCE_FEATURE_ID);
|
return useApplicationFeature(REFERENCE_FEATURE_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReferenceDetail(resourceId: string) {
|
export function useReferenceDetail(resourceId: string) {
|
||||||
@@ -82,20 +106,10 @@ export function useReferenceDetail(resourceId: string) {
|
|||||||
export function useReferenceCreate() {
|
export function useReferenceCreate() {
|
||||||
const input = useReferenceFeatureInput();
|
const input = useReferenceFeatureInput();
|
||||||
const scope = useServerStateScope();
|
const scope = useServerStateScope();
|
||||||
const mutation: BoundMutation<
|
const mutation = bindReferenceCreateMutation({
|
||||||
ReferenceCreateCommand,
|
|
||||||
ReferenceResourceView
|
|
||||||
> = {
|
|
||||||
definitionId: "reference-resource-create-v1",
|
|
||||||
definitionVersion: 1,
|
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
|
||||||
requiresIdempotencyKey: true,
|
|
||||||
owner: REFERENCE_FEATURE_ID,
|
|
||||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
|
||||||
scope,
|
scope,
|
||||||
execute: input.createResource,
|
execute: input.createResource,
|
||||||
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
|
});
|
||||||
};
|
|
||||||
return useApplicationMutation(mutation);
|
return useApplicationMutation(mutation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,16 +137,11 @@ export function useReferenceFeature() {
|
|||||||
scope,
|
scope,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const mutation = useApplicationMutation({
|
const mutation = useApplicationMutation(
|
||||||
definitionId: "reference-resource-create-v1",
|
bindReferenceCreateMutation({
|
||||||
definitionVersion: 1,
|
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
|
||||||
requiresIdempotencyKey: true,
|
|
||||||
owner: REFERENCE_FEATURE_ID,
|
|
||||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
|
||||||
scope,
|
scope,
|
||||||
execute: input.createResource,
|
execute: input.createResource,
|
||||||
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
|
}),
|
||||||
});
|
);
|
||||||
return Object.freeze({ filters, query, mutation });
|
return Object.freeze({ filters, query, mutation });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
deriveAsyncState,
|
deriveAsyncState,
|
||||||
type AsyncState,
|
type AsyncState,
|
||||||
} from "../../../application/view-models/async-state.ts";
|
} from "../../../application/view-models/async-state.ts";
|
||||||
import type { Result } from "../../../application/result.ts";
|
import type { Result } from "../../../contracts/result.ts";
|
||||||
import {
|
import {
|
||||||
createFailure,
|
createFailure,
|
||||||
normalizeUnknownFailure,
|
normalizeUnknownFailure,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
PageHeader,
|
PageHeader,
|
||||||
type DataTableColumn,
|
type DataTableColumn,
|
||||||
} from "../design-system/index.ts";
|
} 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
|
* 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() {
|
export default function PlatformOverviewPage() {
|
||||||
const { runtime } = useApplication();
|
const runtime = useApplicationRuntime();
|
||||||
const [release, setRelease] = useState<
|
const [release, setRelease] = useState<
|
||||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|||||||
@@ -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 type { AppFailure } from "../../contracts/errors.ts";
|
||||||
import {
|
import {
|
||||||
formatMessage,
|
formatMessage,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
useLocale,
|
useLocale,
|
||||||
type MessageKey,
|
type MessageKey,
|
||||||
} from "../i18n/index.ts";
|
} 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 { useSession } from "../providers/session-provider.tsx";
|
||||||
import { useTheme } from "../providers/theme-provider.tsx";
|
import { useTheme } from "../providers/theme-provider.tsx";
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ export function AppShell() {
|
|||||||
|
|
||||||
function PrimaryNavigation({ id }: Readonly<{ id: string }>) {
|
function PrimaryNavigation({ id }: Readonly<{ id: string }>) {
|
||||||
const { resolve, message } = useLocale();
|
const { resolve, message } = useLocale();
|
||||||
const { runtime } = useApplication();
|
const runtime = useApplicationRuntime();
|
||||||
// §3.5. A feature the runtime document disabled does not advertise itself.
|
// §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.
|
// The router refuses its routes too, so this is presentation, not the switch.
|
||||||
const routes = NAVIGATION_ROUTES.filter((definition) => {
|
const routes = NAVIGATION_ROUTES.filter((definition) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
import { routePath } from "../../features/installed-feature-contracts.ts";
|
import { routePath } from "../../features/installed-feature-contracts.ts";
|
||||||
import { PageHeader } from "../design-system/index.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([
|
const READINESS_ITEMS = Object.freeze([
|
||||||
{
|
{
|
||||||
@@ -21,7 +21,7 @@ const READINESS_ITEMS = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { runtime } = useApplication();
|
const runtime = useApplicationRuntime();
|
||||||
const [release, setRelease] = useState<
|
const [release, setRelease] = useState<
|
||||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
} from "react";
|
} 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);
|
const ApplicationContext = createContext<ApplicationApi | null>(null);
|
||||||
|
|
||||||
@@ -22,10 +26,47 @@ export function ApplicationProvider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApplication(): ApplicationApi {
|
function useApplicationContext(): ApplicationApi {
|
||||||
const application = useContext(ApplicationContext);
|
const application = useContext(ApplicationContext);
|
||||||
if (!application) {
|
if (!application) {
|
||||||
throw new Error("ApplicationProvider is required");
|
throw new Error("ApplicationProvider is required");
|
||||||
}
|
}
|
||||||
return application;
|
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,
|
ApplicationApi,
|
||||||
SessionState,
|
SessionState,
|
||||||
} from "../../application/ports/in/application-api.ts";
|
} from "../../application/ports/in/application-api.ts";
|
||||||
import { useApplication } from "./application-provider.tsx";
|
import { useApplicationSession } from "./application-provider.tsx";
|
||||||
|
|
||||||
export type SessionContextValue = Readonly<{
|
export type SessionContextValue = Readonly<{
|
||||||
sessionState: SessionState;
|
sessionState: SessionState;
|
||||||
@@ -24,7 +24,7 @@ const SessionContext = createContext<SessionContextValue | null>(null);
|
|||||||
export function SessionProvider({
|
export function SessionProvider({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: ReactNode }>) {
|
}: Readonly<{ children: ReactNode }>) {
|
||||||
const { session } = useApplication();
|
const session = useApplicationSession();
|
||||||
const sessionState = useSyncExternalStore(
|
const sessionState = useSyncExternalStore(
|
||||||
session.subscribe,
|
session.subscribe,
|
||||||
session.getSnapshot,
|
session.getSnapshot,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
normalizeColorSchemePreference,
|
normalizeColorSchemePreference,
|
||||||
resolveColorScheme,
|
resolveColorScheme,
|
||||||
} from "../../application/policies/color-scheme.ts";
|
} from "../../application/policies/color-scheme.ts";
|
||||||
import { useApplication } from "./application-provider.tsx";
|
import { useApplicationPreferences } from "./application-provider.tsx";
|
||||||
|
|
||||||
export type ThemeContextValue = Readonly<{
|
export type ThemeContextValue = Readonly<{
|
||||||
preference: ColorSchemePreference;
|
preference: ColorSchemePreference;
|
||||||
@@ -33,7 +33,7 @@ function systemPrefersDark(): boolean {
|
|||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: ReactNode }>) {
|
}: Readonly<{ children: ReactNode }>) {
|
||||||
const { preferences } = useApplication();
|
const preferences = useApplicationPreferences();
|
||||||
const [preference, updatePreference] = useState<ColorSchemePreference>(
|
const [preference, updatePreference] = useState<ColorSchemePreference>(
|
||||||
preferences.getColorScheme,
|
preferences.getColorScheme,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx
|
|||||||
import { Button, PageHeader } from "../design-system/index.ts";
|
import { Button, PageHeader } from "../design-system/index.ts";
|
||||||
import { LocaleProvider, useLocale } from "../i18n/index.ts";
|
import { LocaleProvider, useLocale } from "../i18n/index.ts";
|
||||||
import { AppShell } from "../layouts/app-shell.tsx";
|
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 { SessionProvider, useSession } from "../providers/session-provider.tsx";
|
||||||
import { ThemeProvider } from "../providers/theme-provider.tsx";
|
import { ThemeProvider } from "../providers/theme-provider.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -126,7 +130,7 @@ function RouteLifecycle({
|
|||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { message, resolve } = useLocale();
|
const { message, resolve } = useLocale();
|
||||||
const { diagnostics } = useApplication();
|
const diagnostics = useApplicationDiagnostics();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = message("route.documentTitle", {
|
document.title = message("route.documentTitle", {
|
||||||
title: resolve(`route.${definition.routeId}.title`),
|
title: resolve(`route.${definition.routeId}.title`),
|
||||||
@@ -269,7 +273,9 @@ function RegisteredRoute({
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [search] = useSearchParams();
|
const [search] = useSearchParams();
|
||||||
const location = useLocation();
|
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
|
// §3.5. A feature the runtime document disabled is out of service, not
|
||||||
// merely hidden: withdrawing it from navigation alone would leave a typed
|
// merely hidden: withdrawing it from navigation alone would leave a typed
|
||||||
// deep link that still mounts it.
|
// 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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { createHttpClient } from "../../../src/adapters/http/client.ts";
|
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
|
||||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts";
|
||||||
import {
|
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
||||||
createReferenceHttpGateway,
|
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
|
||||||
} 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";
|
|
||||||
|
|
||||||
describe("reference feature diagnostics correlation", () => {
|
function scopeSnapshot() {
|
||||||
it("preserves route, operation and request correlation through the vertical path", async () => {
|
return Object.freeze({
|
||||||
const record = vi.fn();
|
generation: 1,
|
||||||
const operations =
|
fingerprint: "reference-scope",
|
||||||
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<
|
identities: Object.freeze({}) as never,
|
||||||
Record<
|
signal: new AbortController().signal,
|
||||||
string,
|
isCurrent: () => true,
|
||||||
ReturnType<
|
|
||||||
NonNullable<Parameters<typeof createHttpClient>[0]["getOperation"]>
|
|
||||||
>
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
const client = createHttpClient({
|
|
||||||
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: () => {},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const application = createReferenceFeatureInput(
|
}
|
||||||
createReferenceHttpGateway(client),
|
|
||||||
|
describe("reference feature HTTP diagnostics", () => {
|
||||||
|
it("preserves route and operation identity through the installed V3 path", async () => {
|
||||||
|
const record = vi.fn();
|
||||||
|
const contractHttp = createContractHttpExecutor({
|
||||||
|
baseUrl: "https://api.test",
|
||||||
|
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 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(
|
await expect(
|
||||||
application.listResources({ limit: 20 }),
|
installed.input.listResources({ limit: 20 }),
|
||||||
).resolves.toMatchObject({ ok: true });
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
|
||||||
expect(record).toHaveBeenCalledOnce();
|
expect(record).toHaveBeenCalledOnce();
|
||||||
expect(record).toHaveBeenCalledWith({
|
expect(record).toHaveBeenCalledWith({
|
||||||
level: "info",
|
level: "info",
|
||||||
@@ -63,8 +70,7 @@ describe("reference feature diagnostics correlation", () => {
|
|||||||
context: expect.objectContaining({
|
context: expect.objectContaining({
|
||||||
route_id: "REFERENCE_RESOURCE_LIST",
|
route_id: "REFERENCE_RESOURCE_LIST",
|
||||||
operation_id: "LIST_REFERENCE_RESOURCES",
|
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 {
|
import {
|
||||||
createReferenceHttpGateway,
|
createReferenceHttpGateway,
|
||||||
type RawReferenceHttpExecutor,
|
type ReferenceHttpBinding,
|
||||||
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||||
import type { ReferenceResource } from "../../../src/features/reference-feature/domain/reference-resource.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>>;
|
}) 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", () => {
|
describe("reference HTTP operation gateway", () => {
|
||||||
it("builds the exact registered request for every gateway operation", async () => {
|
it("delegates exact typed feature inputs to the capability binding", async () => {
|
||||||
const execute = vi
|
const scripted = scriptedBinding([
|
||||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
{ ok: true, value: [resources.first] },
|
||||||
.mockResolvedValueOnce({ ok: true, value: [resources.first] })
|
{ ok: true, value: resources.created },
|
||||||
.mockResolvedValueOnce({ ok: true, value: resources.created })
|
{ ok: true, value: resources.first },
|
||||||
.mockResolvedValueOnce({ ok: true, value: resources.first });
|
]);
|
||||||
const gateway = createReferenceHttpGateway({ execute });
|
const gateway = createReferenceHttpGateway(scripted.binding);
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -40,67 +69,37 @@ describe("reference HTTP operation gateway", () => {
|
|||||||
gateway.get("reference-1", { signal }),
|
gateway.get("reference-1", { signal }),
|
||||||
).resolves.toEqual({ ok: true, value: resources.first });
|
).resolves.toEqual({ ok: true, value: resources.first });
|
||||||
|
|
||||||
expect(execute.mock.calls).toEqual([
|
expect(scripted.calls).toEqual([
|
||||||
[
|
[
|
||||||
{
|
"LIST_REFERENCE_RESOURCES",
|
||||||
operationId: "LIST_REFERENCE_RESOURCES",
|
{ cursor: "next", limit: 20, tags: ["active"] },
|
||||||
routeId: "REFERENCE_RESOURCE_LIST",
|
{ signal },
|
||||||
searchParams: {
|
|
||||||
cursor: "next",
|
|
||||||
limit: 20,
|
|
||||||
tags: ["active"],
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
"CREATE_REFERENCE_RESOURCE",
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
{ name: "Created", note: "safe note" },
|
||||||
routeId: "REFERENCE_RESOURCE_LIST",
|
undefined,
|
||||||
body: { name: "Created", note: "safe note" },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
"GET_REFERENCE_RESOURCE",
|
||||||
operationId: "GET_REFERENCE_RESOURCE",
|
{ resourceId: "reference-1" },
|
||||||
routeId: "REFERENCE_RESOURCE_DETAIL",
|
{ signal },
|
||||||
pathParams: { 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(
|
const failure = createFailure(
|
||||||
"SCHEMA_MISMATCH",
|
"SCHEMA_MISMATCH",
|
||||||
"GET_REFERENCE_RESOURCE",
|
"GET_REFERENCE_RESOURCE",
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const execute = vi
|
const scripted = scriptedBinding([{ ok: false, error: failure }]);
|
||||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
const gateway = createReferenceHttpGateway(scripted.binding);
|
||||||
.mockResolvedValue({ ok: false, error: failure });
|
|
||||||
const gateway = createReferenceHttpGateway({ execute });
|
|
||||||
|
|
||||||
await expect(gateway.get("invalid")).resolves.toEqual({
|
await expect(gateway.get("invalid")).resolves.toEqual({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: failure,
|
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
@@ -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({
|
const listInput = { limit: 20 };
|
||||||
operationId: "GET_REFERENCE_RESOURCE",
|
|
||||||
routeId: "REFERENCE_RESOURCE_LIST",
|
// GET is bound to { resourceId: string }; list input must be rejected.
|
||||||
pathParams: { resourceId: "resource-1" },
|
http.execute("GET_REFERENCE_RESOURCE", listInput);
|
||||||
});
|
|
||||||
|
|||||||
+1
-1
@@ -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 {
|
export function invalidUnwrap(result: Result<number, string>): number {
|
||||||
return result.value;
|
return result.value;
|
||||||
|
|||||||
+16
-16
@@ -11,41 +11,41 @@ import { afterAll, afterEach, describe, expect, it } from "vitest";
|
|||||||
import type {
|
import type {
|
||||||
CiGateArtifact,
|
CiGateArtifact,
|
||||||
CiGateArtifactSchema,
|
CiGateArtifactSchema,
|
||||||
} from "../../scripts/contracts/ci-gates.ts";
|
} from "../../../scripts/contracts/ci-gates.ts";
|
||||||
import { parseCiGateContract } from "../../scripts/contracts/ci-gates.ts";
|
import { parseCiGateContract } from "../../../scripts/contracts/ci-gates.ts";
|
||||||
import {
|
import {
|
||||||
hasCiArtifactSemanticValidator,
|
hasCiArtifactSemanticValidator,
|
||||||
readBoundedRegularFile,
|
readBoundedRegularFile,
|
||||||
validateCiArtifact,
|
validateCiArtifact,
|
||||||
} from "../../scripts/lib/ci-artifact-validator.ts";
|
} from "../../../scripts/lib/ci-artifact-validator.ts";
|
||||||
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
|
import { writeCiGateLogAtomic } from "../../../scripts/lib/ci-gate-log.ts";
|
||||||
import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts";
|
import { linkFixtureNodeModules } from "../../../scripts/lib/fixture-node-modules.ts";
|
||||||
import { copyReleaseEvidenceTree } from "../../scripts/lib/removal-fixture.ts";
|
import { copyReleaseEvidenceTree } from "../../../scripts/lib/removal-fixture.ts";
|
||||||
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
|
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../../scripts/lib/ci-candidate-archive.ts";
|
||||||
import {
|
import {
|
||||||
CANDIDATE_ARCHIVE_USAGE,
|
CANDIDATE_ARCHIVE_USAGE,
|
||||||
parseCandidateArchiveArguments,
|
parseCandidateArchiveArguments,
|
||||||
} from "../../scripts/lib/ci-candidate-archive-cli.ts";
|
} from "../../../scripts/lib/ci-candidate-archive-cli.ts";
|
||||||
import { validateProviderUpload } from "../../scripts/lib/provider-upload-validator.ts";
|
import { validateProviderUpload } from "../../../scripts/lib/provider-upload-validator.ts";
|
||||||
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
|
import { verifyExactPromotionBundle } from "../../../scripts/lib/exact-promotion-bundle.ts";
|
||||||
import {
|
import {
|
||||||
cleanupFinalizedPromotion,
|
cleanupFinalizedPromotion,
|
||||||
stageVerifiedPromotion,
|
stageVerifiedPromotion,
|
||||||
} from "../../scripts/lib/promotion-stager.ts";
|
} from "../../../scripts/lib/promotion-stager.ts";
|
||||||
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
import { PROMOTED_FILE_NAMES } from "../../../scripts/contracts/promotion-artifacts.ts";
|
||||||
import {
|
import {
|
||||||
providerEvidenceSignaturePayload,
|
providerEvidenceSignaturePayload,
|
||||||
providerPublicKeyFingerprint,
|
providerPublicKeyFingerprint,
|
||||||
providerVerificationArtifactSchema,
|
providerVerificationArtifactSchema,
|
||||||
} from "../../scripts/lib/provider-evidence.ts";
|
} from "../../../scripts/lib/provider-evidence.ts";
|
||||||
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
|
import { localEvidenceAssessmentArtifactSchema } from "../../../scripts/contracts/release-artifacts.ts";
|
||||||
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
|
import { readProviderTrust } from "../../../scripts/lib/provider-trust.ts";
|
||||||
import {
|
import {
|
||||||
createReleaseCandidateManifest,
|
createReleaseCandidateManifest,
|
||||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
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
|
* 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 BrowserRpcStreamFrame,
|
||||||
type BrowserRpcTransport,
|
type BrowserRpcTransport,
|
||||||
} from "../../../src/adapters/browser-rpc/index.ts";
|
} 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 type { AppFailure } from "../../../src/contracts/errors.ts";
|
||||||
import {
|
import {
|
||||||
MAPPERS,
|
MAPPERS,
|
||||||
|
|||||||
@@ -189,11 +189,11 @@ describe("CI gate contract", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(contract.jobs).toHaveLength(9);
|
expect(contract.jobs).toHaveLength(9);
|
||||||
expect(contract.commands).toHaveLength(82);
|
expect(contract.commands).toHaveLength(84);
|
||||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94);
|
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96);
|
||||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(86);
|
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
|
||||||
expect(contract.artifacts).toHaveLength(107);
|
expect(contract.artifacts).toHaveLength(109);
|
||||||
expect(contract.stages).toHaveLength(5);
|
expect(contract.stages).toHaveLength(5);
|
||||||
expect(contract.retention.classes).toHaveLength(5);
|
expect(contract.retention.classes).toHaveLength(5);
|
||||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
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", () => {
|
describe("CI workflow generation", () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
parseNumericVersion,
|
parseNumericVersion,
|
||||||
verifyCompatibilityTuple,
|
verifyCompatibilityTuple,
|
||||||
} from "../../src/application/policies/compatibility.ts";
|
} from "../../src/contracts/compatibility.ts";
|
||||||
|
|
||||||
describe("contract compatibility", () => {
|
describe("contract compatibility", () => {
|
||||||
it("uses numeric version parsing rather than lexical comparison", () => {
|
it("uses numeric version parsing rather than lexical comparison", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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 = {
|
const profile = {
|
||||||
profileId: "bounded-cursor-v1",
|
profileId: "bounded-cursor-v1",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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 { 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 {
|
import {
|
||||||
defineMutationIntent,
|
defineMutationIntent,
|
||||||
isValidIdempotencyKey,
|
isValidIdempotencyKey,
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CursorPage,
|
CursorPage,
|
||||||
CursorPaginationProfile,
|
CursorPaginationProfile,
|
||||||
} from "../../src/contracts/cursor-pagination.ts";
|
} from "../../src/adapters/query-cache/index.ts";
|
||||||
import type { Result } from "../../src/application/result.ts";
|
import type { Result } from "../../src/contracts/result.ts";
|
||||||
|
|
||||||
const PROFILE: CursorPaginationProfile = Object.freeze({
|
const PROFILE: CursorPaginationProfile = Object.freeze({
|
||||||
profileId: "TEST_PAGINATION_V1",
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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
@@ -965,36 +965,6 @@ describe("security follow-up contracts", () => {
|
|||||||
await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u);
|
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"])(
|
it.each(["open failure", "partial write failure"])(
|
||||||
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
|
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
|
||||||
async (failureKind) => {
|
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 () => {
|
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
||||||
const canonical = await loadCiGateContract(process.cwd());
|
const canonical = await loadCiGateContract(process.cwd());
|
||||||
expect(canonical.gates).toHaveLength(27);
|
expect(canonical.gates).toHaveLength(27);
|
||||||
expect(canonical.commands).toHaveLength(82);
|
expect(canonical.commands).toHaveLength(84);
|
||||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
|
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96);
|
||||||
expect(canonical.artifacts).toHaveLength(107);
|
expect(canonical.artifacts).toHaveLength(109);
|
||||||
expect(canonical.stages).toHaveLength(5);
|
expect(canonical.stages).toHaveLength(5);
|
||||||
expect(canonical.retention.classes).toHaveLength(5);
|
expect(canonical.retention.classes).toHaveLength(5);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { configDefaults, defineConfig } from "vitest/config";
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: false,
|
globals: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user