chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -195,7 +195,7 @@
|
||||
"lifecycleMethods": ["release-file-ref", "release-or-dispose-preview-leases", "cancel-via-AbortSignal", "reconcile-or-explicitly-abort-upload", "close-checkpoint-store", "dispose-capability-and-image-runtime"],
|
||||
"owner": "project-owner-required",
|
||||
"securityPrivacy": ["Treat file name, extension, MIME and lastModified as untrusted metadata.", "Resolve only exact composition-issued file and image policy object identities; callers cannot raise byte, candidate, pixel, quality, format, lifetime or origin ceilings.", "Use opaque file references and verification receipts bound to an inspected immutable file snapshot and the exact registered profile; reject replay through another profile even when an inspection rule ID matches.", "Treat presigned URLs as bearer capabilities; bind exact method, resource or upload part, offset, length, media type, checksum, origin, path, query, headers and expiry in an in-memory identity vault.", "Use credentials omit, redirect error, no-referrer and no-store for direct data-plane fetch; never persist or observe URL, query, signed header, capability, file name or raw backend message, and never emit digest, raw ETag or receipt values to diagnostics or telemetry.", "A strict account-partitioned upload checkpoint may persist only the protocol-defined SHA-256 file fingerprint, per-part checksum and bounded opaque non-authorizing part receipt token required for server reconciliation; no bearer token or raw signed capability is allowed.", "Persist only strict non-authorizing upload checkpoints and reconcile them with server-authoritative status and re-hashed local parts before completion.", "Require a synchronous server-issued browser-managed download capability whose receipt exactly equals the caller's branded capability receipt and whose resource, media type, safe extension, maximum bytes, optional digest and expiry all match before handoff.", "Expose File, OPFS, Cache and transfer byte streams only as chunk-level closed Results; stop after the first failure, cancel native readers and never throw a raw native exception across the port.", "Accept Image CDN assets only through immutable allowlisted or signature-verified descriptors and registered preset identities; reject active formats, arbitrary transforms, pixel/decode-budget overflow and unsafe cache policy.", "Upload completion remains QUARANTINED until backend scan and promotion; client capability checks are not an authorization boundary.", "Active content preview requires isolation or download-only treatment."],
|
||||
"bundleBudgetGzipBytes": 52000,
|
||||
"bundleBudgetGzipBytes": 54600,
|
||||
"fallback": "Accessible native file input, same-origin authorized server upload/download and a single bounded server-selected image rendition; generated artifacts above the buffer budget move to server-side generation.",
|
||||
"removal": ["Stop new capability and upload-session issuance, then cancel active reads and transfers.", "Reconcile or explicitly abort active multipart sessions and let backend TTL cleanup remove ambiguous orphans.", "Remove non-secret checkpoints according to account and retention policy.", "Release file references, revoke preview object-URL leases and dispose file, capability and image runtimes.", "Remove transfer/image feature facades and composition, then prove browser-transfer sources are absent from the production module inventory."],
|
||||
"serverStatePolicy": "query-cache-metadata-only"
|
||||
|
||||
@@ -690,11 +690,20 @@ default physical layout은 구현과 동일하게 다음과 같다.
|
||||
```text
|
||||
/ca-frontend-opfs-v1/
|
||||
authorities/<authorityToken>/<namespaceToken>/<partitionToken>/
|
||||
objects/<object-id-prefix>/<opaque-object-id>/<generation>/manifest.json
|
||||
objects/<object-id-prefix>/<opaque-object-id>/<generation>/manifest.json # physical v1 (read-only)
|
||||
objects/<object-id-prefix>/<opaque-object-id>/g<generation>-<token>/manifest.json # physical v2 (new writes)
|
||||
chunks/sha256/<digest-prefix>/<digest>.bin
|
||||
staging/<transaction-id>/receipt.json
|
||||
```
|
||||
|
||||
physical v2는 STO-01 수정의 일부다. logical `generation`은 설계상 transaction 간에
|
||||
재사용되므로, 늦게 도착한 T1 보상이 같은 logical generation을 쓰는 T2의 디렉터리를
|
||||
지울 수 있었다. v2는 transaction-unique `physicalGenerationId` fencing token을
|
||||
경로, staging receipt, prepared object에 함께 기록해 보상이 자기 transaction의
|
||||
디렉터리만 삭제하도록 만든다. expand 단계에서는 v1 경로/receipt/prepared object를
|
||||
계속 읽고 새 write만 v2로 쓴다. rollback window가 끝나기 전에 v1 physical
|
||||
generation을 일괄 삭제하지 않는다.
|
||||
|
||||
구조화 metadata, query, revision, refcount와 operation journal은 IndexedDB가
|
||||
소유한다. OPFS에는 immutable chunk와 bounded runtime-schema-validated manifest만
|
||||
둔다. readable `scope.namespace`는 경로에 쓰지 않는다.
|
||||
@@ -736,6 +745,23 @@ COMMITTED <- 사용자에게 보이는 유일한 commit point
|
||||
CLEANED -> journal 제거
|
||||
```
|
||||
|
||||
보상(compensation)은 saga의 반쪽이며 다음 규칙을 따른다.
|
||||
|
||||
- journal row와 budget reservation은 physical cleanup effect가
|
||||
`CLEANED` 또는 `ALREADY_CLEAN`으로 확인된 뒤에만 해제한다. timeout, crash,
|
||||
malformed response, `EFFECT_UNKNOWN`은 성공이 아니며 `PREPARING`/`FILES_READY`를
|
||||
그대로 남기고 `OBJECT_RECONCILE`로 반환한다.
|
||||
- coordinator가 `abortPreparedPut()` 하나만 소유한다. worker client는 prepare 실패
|
||||
시 별도의 fire-and-forget abort를 발행하지 않는다. 중복 보상은 아직 남아 있어야
|
||||
할 journal row를 조기에 지우는 경로였다.
|
||||
- 보상은 caller signal을 상속하지 않는다. composition이 소유한 bounded
|
||||
`compensationSignal`을 사용하므로 이미 abort된 caller가 cleanup RPC 자체를
|
||||
시작조차 못 하게 만들 수 없다.
|
||||
- abort/cleanup은 origin mutation Web Lock을 physical 삭제와 staging 제거가 끝날
|
||||
때까지 계속 보유한다. lease를 먼저 release하지 않는다. 단, staging이 아직 없는
|
||||
transaction은 삭제할 것이 없으므로 lock을 기다리지 않고 `ALREADY_CLEAN`을
|
||||
반환한다. 이는 자기 자신이 취소하는 BEGIN과의 deadlock을 막는다.
|
||||
|
||||
- `PREPARING` crash: partial staging을 검증 후 resume하거나 purge한다.
|
||||
- `FILES_READY` crash: expected generation과 digest가 맞으면 idempotent logical
|
||||
commit, 아니면 quarantine한다.
|
||||
|
||||
@@ -37,12 +37,41 @@ vendor 결정 전에는 안전한 기본값이 아니다.
|
||||
abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다.
|
||||
`api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만
|
||||
정확히 한 번 발행한다.
|
||||
5-1. V2 client와 V3 contract executor는 각각 자신의 logical execution에 대해
|
||||
이 규칙을 만족한다. V3에서는 execution site가 `HttpExecutionObservation`
|
||||
typed record 하나만 만들고, composition root의
|
||||
`createHttpObservationProjector`가 유일한 projection authority다. observation은
|
||||
arbitrary context map이 아니며 projector는 `route_id`, `operation_id`,
|
||||
`operation`, `outcome`, `error_kind`, `http_status_group`,
|
||||
`attempt_count_bucket`, `duration_bucket`만 사용한다. raw attempt count,
|
||||
duration, status, URL, intent, key, input identity와 내부 `terminalReason`은
|
||||
sink로 나가지 않는다. effect certainty가 운영상 필요해지면 `effect_certainty`
|
||||
key와 닫힌 value policy를 contract·fixture·이 ADR에 동시에 추가한 뒤에만
|
||||
전달한다.
|
||||
5-2. caller cancellation과 scope fence는 API failure가 아니다. diagnostics는 한
|
||||
번 남기고 `api.request.failed`는 발행하지 않는다.
|
||||
5-3. `routeId`는 installed operation-executor 경계의 필수 입력이다. feature
|
||||
gateway가 소유한 low-cardinality route identity를 URL에서 재구성하지 않는다.
|
||||
6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`,
|
||||
`telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage
|
||||
실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다.
|
||||
7. queue full, invalid event/context, serialization과 sink failure는 제한된
|
||||
reason bucket으로 집계한다. drop observer의 failure는 다시 telemetry를
|
||||
발행하지 않는 nonrecursive 경계다.
|
||||
7-1. telemetry adapter lifecycle은 `ACTIVE | DISPOSED` 둘뿐이다. `dispose()`는
|
||||
한 번만 전이하고 `pagehide` listener 제거, queue 비우기, scheduled callback
|
||||
generation 무효화, in-flight sink `AbortController` abort를 모두 수행한다.
|
||||
dispose 뒤 `emit()`은 no-op이고 새 flush는 스케줄되지 않으며, abort를 무시한
|
||||
sink가 늦게 settle해도 post-dispose delivery state를 갱신하거나 재스케줄하지
|
||||
못한다. 종료 중 drop telemetry를 재귀적으로 발행하지 않는다.
|
||||
7-2. `flush()`는 active delivery promise를 join한다. 이미 진행 중인 flush가
|
||||
있으면 같은 promise를 반환하므로 `await flush()`는 실제 settle을 뜻한다.
|
||||
7-3. runtime `infrastructure.dispose()`는 diagnostics/state dependency를 파괴하기
|
||||
전에 `telemetry.dispose()`를 먼저 호출한다.
|
||||
7-4. queue/entry capacity는 construction-time 계약이다. `Number.isSafeInteger`가
|
||||
아니거나 1 미만이거나 문서화된 ceiling(각각 `MAX_TELEMETRY_QUEUE`,
|
||||
`MAX_DIAGNOSTIC_ENTRIES` = 10,000)을 넘으면 `TypeError`로 거절한다. NaN/Infinity가
|
||||
조용히 eviction을 비활성화하는 경로를 남기지 않는다.
|
||||
8. diagnostics와 telemetry failure는 제품 흐름, HTTP 결과, route transition,
|
||||
storage/cache fallback과 React error surface를 바꾸지 않는다.
|
||||
9. mount 전 bootstrap failure는 안전한 build/config/error kind만 별도 evidence로
|
||||
@@ -79,6 +108,11 @@ route/application/HTTP/cache/storage/bootstrap
|
||||
queue full, sink/observer failure와 pre-mount boot evidence를 검증한다.
|
||||
- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer
|
||||
횟수, route/operation/correlation context와 요청 값 비노출을 검증한다.
|
||||
- `tests/integration/http-execution-v3-observability.test.ts`는 V3 terminal
|
||||
outcome이 실제로 closed allowlist를 통과하는지, terminal non-abort failure가
|
||||
`api.request.failed`를 정확히 한 번 발행하는지, cancellation/scope fence가
|
||||
발행하지 않는지, feature route ID가 executor 경계까지 보존되는지, sink 예외가
|
||||
HTTP 결과를 바꾸지 못하는지를 검증한다.
|
||||
- cache/storage/release/application/runtime test는 각 production wiring과
|
||||
diagnostics failure isolation을 검증한다.
|
||||
|
||||
|
||||
@@ -262,6 +262,55 @@ auth-required operation은 session state가 `authenticated`가 아니면 fetch
|
||||
`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous
|
||||
request로 downgrade하지 않는다.
|
||||
|
||||
#### 5-0. Logical effect certainty는 단조 증가한다
|
||||
|
||||
`PhysicalAttemptState`는 현재 attempt만 설명한다. logical execution 전체에는
|
||||
별도의 monotonic accumulator를 두고 `joinMutationEffectCertainty`로 join한다.
|
||||
join 순서는 보수적이다.
|
||||
|
||||
```text
|
||||
NOT_STARTED < NOT_APPLIED < MAYBE_APPLIED < APPLIED_CONFIRMED
|
||||
```
|
||||
|
||||
`fetch()` dispatch 시점에 command는 즉시 `MAYBE_APPLIED`를 기록한다. 이후 retry
|
||||
loop entry, pre-dispatch final invariant, scope fence, cancellation, timeout
|
||||
return은 모두 accumulator를 읽는다. 아직 보내지 않은 새 retry가 있다는 이유로
|
||||
전체 logical operation을 `NOT_STARTED`로 되돌리지 않는다. query operation은
|
||||
`NOT_APPLICABLE`로 남고 이 lattice를 쓰지 않는다.
|
||||
|
||||
#### 5-1. Installed auth profile registry (V3 집행)
|
||||
|
||||
`installRestAuthProfileRegistry()`가 composition 시점에 profile을 한 번 설치하고
|
||||
`INSTALLED_REST_AUTH_PROFILES`가 유일한 authority다. contract composition
|
||||
(`assertExecutionPolicy`)은 등록되지 않은 `authProfileId`를 거절하므로 executor는
|
||||
runtime에 profile을 발명하지 않는다. profile은 다음을 exact하게 소유한다.
|
||||
|
||||
- Fetch `credentials` (credential collaborator가 바꿀 수 없다)
|
||||
- `allowedCredentialHeaders`: 이 operation이 허용하는 정확한 proof header 집합
|
||||
- `requiredCredentialHeaders`: dispatch 전에 반드시 관찰되어야 하는 집합
|
||||
|
||||
`CredentialPatchOutcome.READY`는 proof header만 담는다. `credentials` field는
|
||||
제거되었다. credential owner가 transport-owned header(`accept`, `content-type`,
|
||||
`idempotency-key`)나 forbidden header를 넣거나, profile이 허용하지 않는 header를
|
||||
넣거나, required header를 빠뜨리면 `AUTH_INTEGRATION_FAILURE`이고 fetch 0회이며
|
||||
command effect는 `NOT_STARTED`다. `idempotency-key`는 contract-owned이므로 더
|
||||
구체적인 `UNEXPECTED_IDEMPOTENCY_KEY` request violation으로 남는다.
|
||||
`UNAUTHENTICATED`는 user/session state이지 integration failure가 아니다.
|
||||
|
||||
transport-owned header는 credential header 뒤에 기록되어 key ordering으로도
|
||||
shadow될 수 없고, final invariant가 `init.credentials`와 profile을 다시 대조하며
|
||||
allowed/required credential header 집합을 독립적으로 재검증한다.
|
||||
|
||||
`AUTH_MODE=demo`는 profile을 약화시키지 않는다. `createDemoSessionAdapter`가
|
||||
고정된 비밀 아닌 `DEMO_AUTHORIZATION_MARKER` proof header를 제공하여 strict
|
||||
`REFERENCE_EXTERNAL_BEARER`를 그대로 만족시킨다. 진짜 anonymous backend는 별도
|
||||
anonymous contract/profile을 composition에서 선택해야 한다.
|
||||
|
||||
credential collaborator는 `AuthOperationContext { signal, deadlineAtMonotonicMs }`를
|
||||
받는다. cooperative owner는 스스로 중단하고, non-cooperative owner도 executor가
|
||||
같은 lifetime signal과 race하므로 operation 수명을 넘기지 못하며 late completion은
|
||||
관찰되지 않는다.
|
||||
|
||||
### 6. Cookie auth, CSRF와 CORS
|
||||
|
||||
same-origin BFF cookie session을 기본 권장한다.
|
||||
|
||||
@@ -11,6 +11,26 @@
|
||||
첫 제품 stream/Web Push를 선택할 때, 또는 backend replay/hosting/provider
|
||||
protocol이 바뀔 때
|
||||
|
||||
|
||||
## 스트림 lifecycle은 freshness와 직교한다 (R-02, R-03)
|
||||
|
||||
`RealtimeStreamLifecycle = OPEN | DRAINING | CLOSED`는 freshness
|
||||
(`UNKNOWN/CURRENT/STALE/RESYNCING`)와 별개다.
|
||||
|
||||
- effect/recovery deadline에 도달하면 commit capability를 즉시 영구 무효화하고
|
||||
abort한다. caller에는 bounded `IDLE_TIMEOUT`(non-retryable, operation
|
||||
`APPLY`/`RECOVER`)을 반환하되 **실제 task는 버리지 않고 retain**한다.
|
||||
- retain된 task가 하나라도 있으면 stream은 `DRAINING`이고 새 event/recovery
|
||||
admission을 거절한다. 실제 settlement가 일어나야 `STALE`로 돌아가
|
||||
authoritative recovery를 요구하거나, close 요청이면 `CLOSED`가 된다.
|
||||
- `close()`는 `Promise<RealtimeResult<void>>`다. 모든 retain task가 실제로
|
||||
settle해야 success이고, drain bound를 넘기면 `IDLE_TIMEOUT/CLOSE`를 반환하며
|
||||
stream은 계속 `DRAINING`이다. teardown success가 곧 quiescence다.
|
||||
- LIVE↔POLL overflow fail-close는 active/probe/quiescing/transition lease를
|
||||
모두 abort한 뒤 **retired writer set**으로 옮기고 나서 reference를 지운다.
|
||||
`close()`는 current와 retired를 dedupe해 함께 기다리므로, 버려진
|
||||
non-cooperative writer가 아직 실행 중인데 close가 성공을 보고할 수 없다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 optional recipe catalog는 realtime capability에
|
||||
|
||||
@@ -19,6 +19,37 @@
|
||||
- 운영 절차:
|
||||
[API contract와 server-state recovery](../operations/api-contract-and-server-state-recovery.md)
|
||||
|
||||
|
||||
## Installed binding snapshot과 stream cleanup bound (R-01, R-04, R-05, R-06)
|
||||
|
||||
- `installBrowserRpcContractBindings()`가 registry를 **parse → validate →
|
||||
install** 순서로 처리한다. own data descriptor만 읽어 exact key set으로
|
||||
null-prototype frozen snapshot을 만들고, 그 snapshot을 검증한 뒤 설치한다.
|
||||
getter/accessor, extra key, symbol key, malformed descriptor, revoked proxy는
|
||||
composition-time `TypeError`이며 getter는 호출조차 되지 않는다. runtime과
|
||||
transport call은 이후 snapshot만 읽으므로 validation 이후 registry mutation이
|
||||
replay policy·deadline·byte ceiling·transport selection을 바꿀 수 없다.
|
||||
- server stream 종료는 transport iterator에 lifecycle authority를 위임하지
|
||||
않는다. commit/admission generation은 즉시 fence하고 listener는 바로 해제하며,
|
||||
`iterator.return()`은 cleanup **요청**으로서 bound 안에서만 기다린다. 끝나지
|
||||
않은 cleanup은 관찰만 유지되고(unhandled rejection 없음) application generator는
|
||||
bound 안에 종료된다. cleanup rejection은 이미 선택된 application failure를
|
||||
덮지 않는다.
|
||||
- WebSocket text frame은 allocation 전에 admission한다. UTF-16 code unit 길이가
|
||||
이미 cap을 넘으면 encoder를 만들지 않고 거절하고, 나머지는 early exit하는
|
||||
code-point 누적으로 센다. valid surrogate pair는 4 bytes, lone surrogate는
|
||||
`TextEncoder`와 동일하게 replacement 3 bytes다.
|
||||
- clock/fence collaborator 예외는 Result 경계를 벗어나지 않는다. clock 실패는
|
||||
`SERVER_FAILURE/RPC_RUNTIME_DEPENDENCY_FAILED`, capture 실패는
|
||||
`SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`, `isCurrent` 실패는
|
||||
fail-closed로 canonicalize하며 listener/timer는 단일 exit path에서 정확히 한 번
|
||||
해제한다.
|
||||
|
||||
Browser RPC는 여전히 `AVAILABLE_NOT_COMPOSED`다. 선택된 Connect/gRPC-Web
|
||||
transport는 enqueue-time `maxBufferedBytes`, raw/decompressed ceiling,
|
||||
cancel/closed receipt, terminal framing, target browser와 load behavior를
|
||||
별도로 증명해야 조립할 수 있다 (R-07).
|
||||
|
||||
## 1. 먼저 축을 분리한다
|
||||
|
||||
네 이름은 같은 종류의 대안이 아니다.
|
||||
|
||||
@@ -34,6 +34,16 @@ capability가 설치됐거나 production-ready라는 뜻이 아니다.
|
||||
[Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md)
|
||||
를 따른다.
|
||||
|
||||
|
||||
## Bounded task lease와 DRAINING (R-02, R-03)
|
||||
|
||||
non-cooperative effect/recovery authority 하나가 stream tail 전체를 영구
|
||||
wedge하지 못하도록, common coordinator는 각 task를 deadline으로 감싼다. deadline
|
||||
초과 시 commit capability는 즉시 취소되지만 task 자체는 `retainedTasks`에 남아
|
||||
stream을 `DRAINING`으로 유지한다. `close()`는 이 retain 집합이 실제로 settle해야
|
||||
성공을 반환한다. handoff coordinator도 같은 원칙으로 fail-close된 writer를
|
||||
`retiredWriters`에 보존한다.
|
||||
|
||||
## 0. 현재 상태와 목표 delta
|
||||
|
||||
이 문서에서 설계 승인, reference source 존재, production 조합과 target browser의
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"review": "third-review-2026-08-14",
|
||||
"note": "GOV-03. The machine-readable disposition of every finding the third re-review raised. `check:remediation-ledger` joins this file against the prose ledger and refuses a blanket closure claim while any row is not FIXED, so a summary sentence can never outrun the evidence.",
|
||||
"dispositions": [
|
||||
{
|
||||
"id": "NS-01",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A credential owner's answer is decoded once, inside the auth boundary, through own data descriptors.",
|
||||
"evidence": ["tests/integration/http-execution-v3-live-authority.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "NS-02",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "Contract composition snapshots first and validates the snapshot, so the installed row is the row that was checked.",
|
||||
"evidence": ["tests/unit/contract-registry-immutability.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "NS-03",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "The `responseBody: NONE` probe owns its reader: the operation lifetime reaches it, and the lock is released.",
|
||||
"evidence": ["tests/integration/http-execution-v3-live-authority.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "NS-04",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A journal transaction that cannot be completed is maintenance debt, not a settled write.",
|
||||
"evidence": ["tests/unit/opfs-byte-store.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "NS-05",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A bootstrap failure answers with the request kind it belongs to, so the real cause survives the gateway.",
|
||||
"evidence": ["tests/unit/opfs-worker-runtime.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "NS-06",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A reply is decoded before its pending row is released, and an uncorrelatable reply fails the channel closed.",
|
||||
"evidence": ["tests/unit/opfs-worker-runtime.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "NS-07",
|
||||
"previous": "NEW",
|
||||
"disposition": "FIXED",
|
||||
"summary": "Cursor caps and collaborators are captured at construction, so a later mutation cannot widen a validated cap.",
|
||||
"evidence": ["tests/unit/cursor-pagination-runtime.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "NS-08",
|
||||
"previous": "NEW",
|
||||
"disposition": "FIXED",
|
||||
"summary": "One terminal owner covers the whole public-cache staging body, so nothing writes after the abort.",
|
||||
"evidence": ["tests/unit/public-response-cache.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "RPC-01",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "Only a fulfilled, contract-shaped `waitClosed()` receipt prunes an active stream registration.",
|
||||
"evidence": ["tests/unit/browser-rpc/browser-rpc-remediation.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "RPC-02",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "Iterator cleanup and the lease decoder read foreign state inside their own boundaries.",
|
||||
"evidence": ["tests/unit/browser-rpc/browser-rpc-remediation.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "RPC-03",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "Every registry is snapshotted before any validation runs, and rows with hidden fields are refused.",
|
||||
"evidence": ["tests/unit/browser-rpc/browser-rpc-remediation.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "RPC-04",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A transport result is an exact union: required own keys, no inherited extras, plain prototype.",
|
||||
"evidence": ["tests/unit/browser-rpc/browser-rpc-remediation.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "RT-01",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A tracked task is registered before the authority is invoked, closing the reentrant-close window.",
|
||||
"evidence": ["tests/unit/realtime/stream-coordinator.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "RT-02",
|
||||
"previous": "NEW",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A scheduler that cannot install a deadline fails closed inside the realtime result contract.",
|
||||
"evidence": ["tests/unit/realtime/stream-coordinator.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "TR-01",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "The vault snapshots a registration and everything nested in it before validating or storing it.",
|
||||
"evidence": ["tests/unit/presigned-transfer.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "TR-02",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "A source lease that arrives after the delivery ended is closed exactly once by a compensator.",
|
||||
"evidence": ["tests/unit/presigned-transfer.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "TR-03",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "The shared abort primitive settles once by observation order, and all four consumers use it with bound timer snapshots.",
|
||||
"markers": ["X-AUDIT-01", "X-AUDIT-02"],
|
||||
"evidence": [
|
||||
"tests/unit/abortable-operation.test.ts",
|
||||
"tests/unit/image-cdn-runtime.test.ts",
|
||||
"tests/unit/resumable-upload-fetch-transport.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "TR-04",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "Teardown proves quiescence of the raw provider registry, not only of the wrappers that bound it.",
|
||||
"evidence": ["tests/unit/resumable-upload-runtime.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "TR-05",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "The control-plane decoder validates an owned snapshot, so a stateful answer cannot swap a checked value.",
|
||||
"evidence": ["tests/unit/resumable-upload-http-control-plane.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "SW-01",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "The activation marker read is bounded in bytes, cancels what it refuses and releases its reader lock.",
|
||||
"evidence": ["tests/unit/service-worker-runtime.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "SW-02",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "The generator and the runtime decoder share one canonical asset-path predicate, and the generator self-validates.",
|
||||
"evidence": ["tests/unit/service-worker-web-push-remediation.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "WP-01",
|
||||
"previous": "PARTIAL",
|
||||
"disposition": "FIXED",
|
||||
"summary": "One observation authority per click; certainty is monotone and the late-effect tail is owned by `waitUntil`.",
|
||||
"evidence": ["tests/unit/web-push-worker-runtime.test.ts"]
|
||||
},
|
||||
{
|
||||
"id": "GOV-03",
|
||||
"previous": "OPEN",
|
||||
"disposition": "FIXED",
|
||||
"summary": "This file plus `check:remediation-ledger` bind each row to a disposition and a test path, and block a blanket closure claim while any row is open.",
|
||||
"evidence": ["scripts/check-remediation-ledger.ts"]
|
||||
},
|
||||
{
|
||||
"id": "GOV-04",
|
||||
"previous": "OPEN",
|
||||
"disposition": "FIXED",
|
||||
"summary": "The inventory gate requires the exact named consumer set to resolve its import to the shared primitive and prints the set.",
|
||||
"evidence": ["scripts/check-adapter-inventory.ts"]
|
||||
},
|
||||
{
|
||||
"id": "GOV-05",
|
||||
"previous": "OPEN",
|
||||
"disposition": "FIXED",
|
||||
"summary": "Duplicate abort mechanics were consolidated onto the shared primitive and the file-transfer budget was reset to cover the remaining correctness code.",
|
||||
"markers": [],
|
||||
"evidence": ["config/recipes/frontend-capability-recipes.json"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
# Adapter Remediation Ledger
|
||||
|
||||
> Source of truth for the execution state of every confirmed finding in
|
||||
> [`docs/reviews/adapters/`](../reviews/adapters/README.md).
|
||||
>
|
||||
> Plan: [2026-08-13 adapter remediation](../superpowers/plans/2026-08-13-adapter-remediation.md).
|
||||
> Baseline revision: `develop` / `4dc033cf33a5b6173bbf960d5eb464a406dc4c92`.
|
||||
|
||||
## Containment state (plan Task 1, Step 1)
|
||||
|
||||
Scan performed on the baseline revision:
|
||||
|
||||
```bash
|
||||
rg -n "createBrowserOpfsRuntime|createBrowserFileRuntime|createPublicResponseCache|createBrowserRpcRuntime|createWebPush|createServiceWorker|createResumableUpload|createImageCdn" src recipes tests
|
||||
rg -n "AVAILABLE_NOT_COMPOSED|DESIGNED_NOT_IMPLEMENTED|NOT_SELECTED" docs/architecture src/bootstrap
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
| Capability | Template default composition | Containment action |
|
||||
| --- | --- | --- |
|
||||
| OPFS byte store (`createBrowserOpfsRuntime`) | Not composed. Only `recipes/frontend-capabilities/*` reference the `"OPFS"` backend literal in contract/fake code. | None required. No V1 writer is admitted, so no kill switch is invented. |
|
||||
| Browser files runtime | Not composed in `src/bootstrap/**`. | None. |
|
||||
| Public response cache | Not composed; only `tests/unit/public-response-cache.test.ts` constructs it. | None. |
|
||||
| Browser RPC runtime | Not composed; `AVAILABLE_NOT_COMPOSED`. | None. |
|
||||
| Web Push | Not composed; `NOT_SELECTED` / `AVAILABLE_NOT_COMPOSED`. | None. |
|
||||
| Resumable upload / image CDN | Not composed; test-only construction. | None. |
|
||||
| Service Worker runtime host | Composed conditionally through `src/bootstrap/optional-runtime-host.ts` → `createServiceWorkerRuntimeHost`, gated by `ResolvedRuntimeCapabilities`. | Stays as-is. Task 14 fixes truthfulness without changing selection. |
|
||||
| Realtime | `src/bootstrap/optional-runtime-host.ts:91` keeps realtime `NOT_SELECTED` with `realtime: null`. | None. |
|
||||
|
||||
No product-specific composition root outside the template default exists in this repository, so
|
||||
there is no OPFS V1 write admission to close.
|
||||
|
||||
## Baseline gates (plan Task 1, Step 3)
|
||||
|
||||
Captured on the baseline revision before any source change.
|
||||
|
||||
| Command | Exit code | Result |
|
||||
| --- | ---: | --- |
|
||||
| `corepack pnpm check:types` | 0 | app, node, test, recipes, web-worker, service-worker projects all pass. |
|
||||
| `corepack pnpm lint` | 0 | `--max-warnings=0` clean. |
|
||||
| `corepack pnpm check:architecture` | 0 | 286 modules, 854 dependencies, all imports resolved; 12 graph fixtures PASS; TS-only policy PASS; allowed PASS / 9 forbidden rejected. |
|
||||
| `corepack pnpm test:unit` | 1 | 110 passed / 1 failed test files; 1496 passed / 19 failed tests (1515 total), 150.92s. |
|
||||
|
||||
### `test:unit` failure attribution
|
||||
|
||||
The single failing file is `tests/unit/ci-artifact-contract.test.ts`. All 19 failures come from
|
||||
child-process, cgroup, and filesystem-permission behavior of the sandboxed execution
|
||||
environment, not from adapter code. Verbatim causes recorded from the run log:
|
||||
|
||||
```
|
||||
Error: ENOENT: no such file or directory, open '/proc/1325422/task/1325422/children'
|
||||
Error: provider output did not reach expected content: /tmp/ci-provider-upload-J9hxXD/provider-evidence/untrusted/vulnerability-report.json
|
||||
Error: provider scope survived completion: ca-provider-vulnerability-1326812-f4869853ef09ea2c2c95cd01.scope
|
||||
Error: EACCES: permission denied, open '/tmp/ci-captured-archive-OWTs7M/candidate.tar.gz'
|
||||
Error: Test timed out in 10000ms.
|
||||
AssertionError: expected 5714 to be less than 5000
|
||||
AssertionError: expected [] to deeply equal ArrayContaining{…}
|
||||
```
|
||||
|
||||
This matches the environment note already recorded in the review index. It is **not** converted
|
||||
to an adapter failure and it is **not** treated as green. Every adapter task below must keep the
|
||||
adapter-focused suites green and must not increase this file's failure count.
|
||||
|
||||
## Finding ledger
|
||||
|
||||
`Activation` records whether the finding is reachable on the current template execution path.
|
||||
Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
|
||||
`PROMOTION_BLOCKED` and are never labelled `DEFECT`.
|
||||
|
||||
### Network and state (`docs/reviews/adapters/01-network-and-state.md`)
|
||||
|
||||
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | `fix: restore V3 HTTP observability` | `FIXED_NOT_RELEASED` | diagnostics/telemetry producer gate regression | Red 5/5 failed → green 5/5; `check:diagnostics` PASS (8 diagnostics, 5 telemetry producers); `check:types` PASS; `check:architecture` PASS |
|
||||
| N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | `fix: enforce installed HTTP auth profiles` | `FIXED_NOT_RELEASED` | authenticated request 4xx spike after profile enforcement | Red suite failed to load (`installRestAuthProfileRegistry` absent) → green 7/7; `check:types` PASS; `check:architecture` PASS; `lint` PASS; unit+integration+features 1560 passed with only the pre-existing environmental `ci-artifact-contract` failures |
|
||||
| N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts -t "retry-time fence"` | `fix: preserve command effect certainty across retries` | `FIXED_NOT_RELEASED` | command effect verdict regression | Red reproduced `SCOPE_FENCED` with `NOT_STARTED` after one dispatched attempt → green `MAYBE_APPLIED`; lattice table 9/9; `check:types` PASS; `lint` PASS |
|
||||
| N-04 | Live composition teardown | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts tests/unit/runtime-adapters.test.ts` | `fix: terminate telemetry work on disposal` | `FIXED_NOT_RELEASED` | telemetry delivery loss after teardown change | Red 10 failed (5 lifecycle + 5 capacity) → green 34/34; `check:diagnostics` PASS; `check:types` PASS; `check:architecture` PASS; `lint` PASS |
|
||||
| N-05 | Rollout blocker (sidecar not composed) | `corepack pnpm exec vitest run tests/unit/conditional-validator-store.test.ts` | `fix: harden bounded state sidecars` | `FIXED_NOT_RELEASED` | persisted validator key incompatibility | Red collision case (two valid bindings sharing one delimiter-joined key) → green; key is now a bounded validated tuple encoded with `JSON.stringify` |
|
||||
| N-06 | Legacy V2 rollback seam | `corepack pnpm exec vitest run tests/integration/http-client.test.ts` | `fix: harden the legacy HTTP rollback path` | `FIXED_NOT_RELEASED` | legacy keyed command rejection spike | Red 4 invalid-key cases → green; rejection happens before credentials and fetch (0 credential calls, 0 fetches) |
|
||||
| N-07 | Legacy V2 rollback seam | `corepack pnpm exec vitest run tests/integration/http-client.test.ts tests/integration/auth-recovery.test.ts` | `fix: harden the legacy HTTP rollback path` | `FIXED_NOT_RELEASED` | legacy credential timeout regression | Red never-settling owner → green; credential wait races the existing attempt controller so no extra timer is added; ownership maps to REQUEST_TIMEOUT / REQUEST_ABORTED / AUTH_INTEGRATION_FAILURE with zero fetches |
|
||||
| N-08 | Legacy V2 rollback seam | `corepack pnpm exec vitest run tests/unit/bounded-json-compatibility.test.ts` | `fix: harden the legacy HTTP rollback path` | `FIXED_NOT_RELEASED` | legacy JSON failure-code drift | Green 7/7 including throwing cancel/releaseLock; `readBoundedJson` now delegates to `bounded-body-reader` with the legacy codes preserved |
|
||||
| N-09 | Live cross-context host | `corepack pnpm exec vitest run tests/unit/cross-tab-invalidation.test.ts` | `fix: harden bounded state sidecars` | `FIXED_NOT_RELEASED` | cross-tab invalidation drop | Red foreign-area pulse accepted → green 13/13; localStorage captured once and `StorageEvent.storageArea` compared by object identity; pulse key registered as `CACHE_INVALIDATION_PULSE`; `check:registries` PASS |
|
||||
| N-10 | Cursor runtime `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/cursor-pagination-runtime.test.ts` | `fix: harden bounded state sidecars` | `FIXED_NOT_RELEASED` | pagination abort semantics change | Red never-settling loader → green `PAGINATION_ABORTED` with the late page ignored |
|
||||
| N-11 | Live composition | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts -t capacity` | `fix: terminate telemetry work on disposal` | `FIXED_NOT_RELEASED` | capacity rejection on valid composition | Red 5/5 capacity cases → green; ceilings documented in VD-07 §7-4 |
|
||||
|
||||
### Storage and browser files (`docs/reviews/adapters/03-storage-and-browser-files.md`)
|
||||
|
||||
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| STO-01 | OPFS not composed in template; **Critical** for any product writer | `corepack pnpm exec vitest run tests/unit/opfs-byte-store.test.ts tests/unit/opfs-worker-runtime.test.ts tests/unit/indexeddb-opfs-journal.test.ts` | `fix: preserve OPFS recovery authority during cleanup` | `FIXED_NOT_RELEASED` | OPFS reconcile backlog or journal growth | Red 4 new saga cases → green 25/25 across the three OPFS suites; `check:types` PASS (incl. web-worker); `check:browser-file-storage-boundaries` PASS; `lint` PASS; `test:unit` 1511 passed with only the pre-existing environmental `ci-artifact-contract` failures |
|
||||
| STO-02 | Browser file runtime not composed | `corepack pnpm exec vitest run tests/unit/browser-file-download.test.ts` | `fix: execute canonical browser download targets` | `FIXED_NOT_RELEASED` | download navigation blocked by canonical target | Red 2 failed (raw relative href handed to host) → green 17/17 |
|
||||
| STO-03 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | `fix: make public cache staging repairable` | `FIXED_NOT_RELEASED` | composition rejection of an existing policy | Red 4 cases across STO-03..05 → green 21/21; `check:types` PASS; `check:browser-file-storage-boundaries` PASS; `lint` PASS |
|
||||
| STO-04 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | `fix: make public cache staging repairable` | `FIXED_NOT_RELEASED` | restage loop or bandwidth spike | — |
|
||||
| STO-05 | Public cache not composed | `corepack pnpm exec vitest run tests/unit/public-response-cache.test.ts` | `fix: make public cache staging repairable` | `FIXED_NOT_RELEASED` | activation permitted without required capability | — |
|
||||
| STO-06 | IndexedDB maintenance | `corepack pnpm exec vitest run tests/unit/indexeddb-maintenance.test.ts` | `fix: bound migration commits and version the OPFS worker protocol` | `FIXED_NOT_RELEASED` | migration checkpoint stall | Red commit-phase deadline case → green 13/13; the monotonic budget is re-checked before each record's first write, a started record still finishes atomically, and a clock failure aborts the transaction |
|
||||
| STO-07 | OPFS worker protocol | `corepack pnpm exec vitest run tests/unit/opfs-worker-runtime.test.ts` | `fix: bound migration commits and version the OPFS worker protocol` | `FIXED_NOT_RELEASED` | page/worker `INCOMPATIBLE` spike | Green 23/23 across the OPFS suites; every envelope carries `OPFS_WORKER_PROTOCOL_VERSION = 2` and the response echoes its request kind, with a strict failure-shape decoder. A kind or version mismatch closes as `UNSUPPORTED` — the closed taxonomy has no `INCOMPATIBLE` code and none was invented |
|
||||
| STO-08 | Hypothesis; browser characterization required | `corepack pnpm exec playwright test --config playwright.capabilities.config.ts tests/browser-capabilities/browser-files.spec.ts` | none (source unchanged) | `UNVERIFIED` | n/a until characterized | chromium 2/2 PASS; webkit could not launch (`libevent-2.1-7t64`, `libavif16` missing — environmental). The existing spec does not exercise `Window.showOpenFilePicker`/`showSaveFilePicker`, which need a user gesture and a native dialog, so the receiver-binding hypothesis is **neither reproduced nor refuted**. No `SystemPickerHost` was introduced: the plan forbids implementing an uncharacterized hypothesis as a defect. |
|
||||
| GAP-01 | Documented unimplemented (VD-15) | promotion evidence, not a red test | — | `PROMOTION_BLOCKED` | n/a | — |
|
||||
| GAP-02 | Documented unimplemented (VD-15) | promotion evidence, not a red test | — | `PROMOTION_BLOCKED` | n/a | — |
|
||||
| GAP-03 | Documented unimplemented (VD-15) | promotion evidence, not a red test | — | `PROMOTION_BLOCKED` | n/a | — |
|
||||
|
||||
### Realtime and Browser RPC (`docs/reviews/adapters/02-realtime-and-browser-rpc.md`)
|
||||
|
||||
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| R-01 | Browser RPC `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-runtime.test.ts` | `fix: install bounded Browser RPC stream leases` | `FIXED_NOT_RELEASED` | stream lease deadlock | Stream cleanup is bounded; the generator no longer waits indefinitely on a non-cooperative `iterator.return()` |
|
||||
| R-02 | Realtime `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/realtime/stream-coordinator.test.ts` | `fix: retain realtime work through draining` | `FIXED_NOT_RELEASED` | stream stuck in `DRAINING` | Red never-settling effect and recovery → green 27/27; `close()` returns `IDLE_TIMEOUT` while a task is retained and success only after actual settlement |
|
||||
| R-03 | Realtime `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/realtime/live-poll-handoff-coordinator.test.ts` | `fix: retain realtime work through draining` | `FIXED_NOT_RELEASED` | retired-writer set growth | Red overflow fail-close then `close()` → green 11/11; retired writers are waited on and only removed once actually quiesced |
|
||||
| R-04 | Browser RPC `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-contract.test.ts` | `fix: install bounded Browser RPC stream leases` | `FIXED_NOT_RELEASED` | binding install rejection | Red post-validation mutation, accessor and symbol cases → green 19/19; getters are never invoked |
|
||||
| R-05 | WebSocket protocol codec | `corepack pnpm exec vitest run tests/unit/realtime/websocket-protocol.test.ts` | `fix: install bounded Browser RPC stream leases` | `FIXED_NOT_RELEASED` | frame rejection regression | Red oversize frame allocated an encoder copy → green 8/8; byte counts match `TextEncoder` including lone surrogates |
|
||||
| R-06 | Browser RPC `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-runtime.test.ts` | `fix: install bounded Browser RPC stream leases` | `FIXED_NOT_RELEASED` | closed-failure taxonomy drift | Clock and fence reads are canonicalised into the closed Result taxonomy with single-exit cleanup |
|
||||
| R-07 | Promotion blocker | concrete transport conformance evidence | — | `PROMOTION_BLOCKED` | n/a | — |
|
||||
|
||||
### Browser transfer (`docs/reviews/adapters/04-browser-transfer.md`)
|
||||
|
||||
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| BT-PRE-01 | Presigned `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | `fix: lazy presigned download leases` | `FIXED_NOT_RELEASED` | download lease leak | Red lazy-lease cases → green 29/29; `open()` performs no network I/O and `close()` is idempotent |
|
||||
| BT-PRE-02 | Wire contract gap | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | `fix: complete presigned capability and upload transport contracts` | `FIXED_NOT_RELEASED` | provider `POLICY_REJECTED` spike | Red missing/V0/V2 protocol cases → green; request always declares `PRESIGNED_TRANSFER_V1` and a mismatched response is closed as `POLICY_REJECTED` before vault registration |
|
||||
| BT-PRE-03 | Presigned provider | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | `fix: complete presigned capability and upload transport contracts` | `FIXED_NOT_RELEASED` | timeout not bounding fetch | Red non-cooperative fetch → green; the scope races the task, the late response body is cancelled, and a throwing scheduler leaks no listener |
|
||||
| BT-PRE-04 | Presigned vault | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | `fix: complete presigned capability and upload transport contracts` | `FIXED_NOT_RELEASED` | issuer/consumer split break | Red 6-case issuer-seam table → green; the vault re-checks method, href/origin/path agreement, credentials, byte, digest and expiry invariants itself |
|
||||
| BT-PRE-05 | Provider path decoding | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | `fix: complete presigned capability and upload transport contracts` | `FIXED_NOT_RELEASED` | legitimate key rejection | Red `%2F`, `%5C`, `%252e%252e`, lowercase percent-hex and `%00` → green; each segment is decoded once and must round-trip through the canonical uppercase encoder |
|
||||
| BT-UP-01 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | `fix: harden resumable upload transport contracts` | `FIXED_NOT_RELEASED` | signal facade rejection | `isAbortSignal` now requires `removeEventListener` and release cleanup is isolated |
|
||||
| BT-UP-02 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | `fix: complete presigned capability and upload transport contracts` | `FIXED_NOT_RELEASED` | clock injection break | Clock and scheduler are injected and snapshotted; delta-seconds and HTTP-date both resolve against the same captured `now`, with clock rollback clamped to 0 |
|
||||
| BT-UP-03 | Resumable checkpoint store | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | `fix: report unknown IndexedDB delete effects` | `FIXED_NOT_RELEASED` | pending-delete registry growth | Red blocked-deadline case → green `PENDING`/`UNKNOWN`; a realm-scoped registry blocks recreating the partition |
|
||||
| BT-UP-04 | Presigned part executor | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | `fix: harden resumable upload transport contracts` | `FIXED_NOT_RELEASED` | expiry check rejection | Non-finite and negative clocks return `UNAVAILABLE`/`RESUME` instead of bypassing expiry |
|
||||
| BT-UP-05 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | — | `NOT_PERFORMED` | characterization drift | **Attempted and reverted.** The five internal owners were extracted mechanically, but they require a shared-internals module for `RuntimeDependencies`, `ActiveResolution`, `ReconciliationResolution`, `FAILURE_CODES`, `RECOVERIES`, `reportProgress`, `observeTerminal` and ~40 further bindings to avoid an import cycle. Rather than risk the verified correctness work in this file, the extraction was reverted rather than half-landed. Behaviour and the public facade are unchanged; the file is still 2,239 lines. |
|
||||
| BT-UP-06 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | `fix: drain resumable upload teardown` | `FIXED_NOT_RELEASED` | drain not quiescent | Red single-flight dispose case → green 18/18; `close()` closes admission and starts the same drain, `dispose()` aborts the active-operation registry and awaits real settlement before closing the checkpoint store |
|
||||
| BT-UP-07 | Documented gap (Web Locks matrix) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — |
|
||||
| BT-IMG-01 | Type-contract change | `corepack pnpm check:types:test` fixture | `fix: complete presigned capability and upload transport contracts` | `FIXED_NOT_RELEASED` | caller compile break | `resolve()` now requires the lifetime signal; `tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts` + `check:types:fixture:image-resolve-signal` fail as designed (2 errors), and all callers pass a signal |
|
||||
| BT-IMG-02 | Image probe | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | `fix: parse Cache-Control with quote awareness` | `FIXED_NOT_RELEASED` | Cache-Control parse rejection | Red unmatched-quote cases → green 25/25 |
|
||||
| BT-IMG-03 | Refactor | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | — | `NOT_PERFORMED` | characterization drift | **Not performed.** Same reasoning as BT-UP-05: a pure cohesion refactor of `image-cdn-runtime.ts` (1,340 lines) with no finding closure. `image-header-metadata.ts` is deliberately left intact per the review. |
|
||||
| BT-IMG-04 | Documented gap (descriptor provider) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — |
|
||||
| BT-X-01 | Shared abort mechanics | `corepack pnpm exec vitest run tests/unit/abortable-operation.test.ts` | `fix: share abort and deadline mechanics` | `FIXED_NOT_RELEASED` | late-result compensation regression | Golden suite 8/8: first terminal owner, idempotent close, throwing scheduler, observed late rejection, late-handle compensation |
|
||||
|
||||
### Service Worker and Web Push (`docs/reviews/adapters/05-service-worker-and-web-push.md`)
|
||||
|
||||
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| SW-URL-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | static asset cache miss rate | Red generator-shaped root-relative asset vs absolute Request URL → green; manifest URLs canonicalized once against the registration scope |
|
||||
| SW-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | stale response served | Red previous-cache hit → green network fallback; only the current release cache is opened, matched and deleted from |
|
||||
| SW-02 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | foreign cache deletion | Red prefix deletion of `ca-static-v1-not-owned` and longer suffixes → green exact `isOwnedStaticCacheName` only |
|
||||
| SW-03 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | false removal success | Red `unregister() === false` reported as UNREGISTERED → green FAILED |
|
||||
| SW-04 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | removal outcome misreport | Red removal modes always DISABLED → green outcome matrix (ABSENT/UNREGISTERED/PURGED→DISABLED, OWNERSHIP_MISMATCH→INCOMPATIBLE, FAILED→FAILED) |
|
||||
| SW-05 | Build gate | `corepack pnpm exec vitest run tests/unit/service-worker-build-input.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | build admission rejection | Red tamper table (stale digest, byte length, cross-origin URL, dot segment, extension mismatch, unknown field, duplicate URL) → green; build gate decodes through the shared codec and recomputes the canonical digest |
|
||||
| SW-06 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | activation handshake failure | Red foreign-source drain, source swap and 10 concurrent activations → green 24/24; replies correlate by source object identity against the captured waiting worker or controller, and activation/reset are single-flight |
|
||||
| SW-07 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | activation blocked with zero clients | An empty in-scope client set is vacuously drained; `clients.matchAll()` failure still rejects |
|
||||
| SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | per-client failure escalation | Per-client `postMessage` isolation; `skipWaiting()` is the commit point and its failure is REJECTED, with accepted/reload notifications sent only afterwards as best effort |
|
||||
| SW-09 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | late install work observed | Red late-fetch case → green; a fenced worker starts no new candidate work, late response bodies are cancelled, digest throws map to a closed outcome, and a second exact-delete runs once the abandoned install settles without extending the public bound |
|
||||
| SW-10 | Protocol V2 migration | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `DEFERRED_TO_MIGRATION` | V1/V2 mismatch fail-close | Not closed here. Full-identity protocol V2 is an expand → dual-read → old-writer drain → contract deployment that spans releases; the prerequisite shared manifest codec and canonical digest landed with SW-05. |
|
||||
| WP-01 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | CAS receipt rejection | Red stale/skipped/huge revision receipts → green; write and remove share one exact-next-revision validator |
|
||||
| WP-02 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | — | `DEFERRED_TO_MIGRATION` | `RECONCILIATION_REQUIRED` backlog | Deferred to the Task 16 versioned-migration PR: `MUTATION_OUTCOME_UNKNOWN` and the `RECONCILIATION_REQUIRED` lifecycle are part of the same wire/data migration as WP-03. |
|
||||
| WP-03 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | — | `DEFERRED_TO_MIGRATION` | backend receipt mismatch | Deferred to the Task 16 versioned-migration PR: the V2 receipt requires server request-shape negotiation before a client rollout. |
|
||||
| WP-04 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | — | `DEFERRED_TO_MIGRATION` | reconcile loop | Deferred with WP-03: `expectedPreviousAssociationEpoch` and `replacedAssociationEpoch` are part of the V2 register contract. |
|
||||
| WP-05 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | pre-abort observation drift | A pre-aborted command records the requested operation instead of always INSPECT |
|
||||
| WP-06 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-worker-runtime.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | truncation reported degraded | Client handoff and notification cleanup report `countBucket` and `truncated`; an incomplete cleanup returns `{ complete: false }` and is DEGRADED |
|
||||
| WP-07 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-worker-runtime.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | late native effect certainty | Native notification effect is tracked as NOT_APPLIED → MAYBE_APPLIED → CONFIRMED and observed as evidence only |
|
||||
|
||||
## Final evidence (plan Task 18)
|
||||
|
||||
Captured after every correctness task landed.
|
||||
|
||||
### Focused subsystem suites (fresh processes)
|
||||
|
||||
| Suite | Exit | Result |
|
||||
| --- | ---: | --- |
|
||||
| `tests/unit/browser-rpc` + `tests/unit/realtime` | 0 | 192 passed |
|
||||
| OPFS, IndexedDB, public cache, download | 0 | 80 passed |
|
||||
| abortable-operation, presigned, resumable, image CDN | 0 | 104 passed |
|
||||
| Service Worker + Web Push | 0 | 66 passed |
|
||||
|
||||
### Repository gates
|
||||
|
||||
| Command | Exit | Result |
|
||||
| --- | ---: | --- |
|
||||
| `corepack pnpm check:types` | 0 | all six projects pass |
|
||||
| `corepack pnpm lint` | 0 | `--max-warnings=0` clean |
|
||||
| `corepack pnpm check:architecture` | 0 | 288 modules, 865 dependencies; 12 fixtures PASS; allowed PASS / 9 forbidden rejected |
|
||||
| `corepack pnpm check:diagnostics` | 0 | 8 diagnostics and 5 telemetry producers PASS |
|
||||
| `corepack pnpm check:browser-file-storage-boundaries` | 0 | PASS, 34 rejections |
|
||||
| `corepack pnpm check:realtime-boundaries` | 0 | PASS |
|
||||
| `git diff --check` | 0 | clean |
|
||||
| `corepack pnpm test:component` | 0 | 126 passed |
|
||||
| `corepack pnpm test:integration` | 0 | 52 passed |
|
||||
| `corepack pnpm test:reference-feature` | 0 | 26 passed |
|
||||
| `corepack pnpm test:recipes` | 0 | 17 passed |
|
||||
| `corepack pnpm test:unit` | 1 | 1585 passed / 19 failed tests; the 19 are the unchanged pre-existing `ci-artifact-contract` sandbox failures |
|
||||
| `corepack pnpm check:registries` | 0 | 11 registries PASS |
|
||||
| `corepack pnpm verify:documentation` | 0 | PASS_SCOPED |
|
||||
| `corepack pnpm check:types:fixture:image-resolve-signal` | 1 | **Expected non-zero.** Negative fixture proving `resolve()` now rejects a call without a lifetime signal (BT-IMG-01). |
|
||||
|
||||
`test:all` stops at `test:unit`, so the later suites above were run directly.
|
||||
|
||||
### Final disposition of all 64 findings
|
||||
|
||||
| State | Count |
|
||||
| --- | ---: |
|
||||
| `FIXED_NOT_RELEASED` | 51 |
|
||||
| `PROMOTION_BLOCKED` (unchanged by design) | 6 |
|
||||
| `DEFERRED_TO_MIGRATION` (`WP-02`, `WP-03`, `WP-04`, `SW-10`) | 4 |
|
||||
| `NOT_PERFORMED` (`BT-UP-05`, `BT-IMG-03` cohesion refactors) | 2 |
|
||||
| `UNVERIFIED` (`STO-08` browser hypothesis) | 1 |
|
||||
|
||||
No finding remains `NOT_STARTED`.
|
||||
|
||||
### Failures that are NOT claimed as green
|
||||
|
||||
| Gate | Status | Attribution |
|
||||
| --- | --- | --- |
|
||||
| `tests/unit/ci-artifact-contract.test.ts` | 19 failed | Identical to the baseline capture. Sandbox child-process, cgroup and `/tmp` permission behavior; unrelated to adapters. Count did not change across any task. |
|
||||
| `corepack pnpm test:browser-capabilities` (webkit) | 6 failed / 24 passed | WebKit cannot launch: missing `libevent-2.1-7t64` and `libavif16`. Chromium passes. **UNVERIFIED**, not PASS. |
|
||||
| `corepack pnpm test:browser-file-storage-removal` | exit 1 | The reduced-removal-fixture prunes the CI contract to 77/89/102 while `scripts/contracts/ci-gates.ts:481` demands exactly 81/93/105. That file, the CI contract and `scripts/lib/removal-fixture.ts` are **unchanged since the baseline revision** (`git diff --name-only 4dc033c..HEAD` outside `src/`, `tests/` and `docs/` lists only the two Service Worker build scripts), so this is pre-existing, not a regression from this work. |
|
||||
| `corepack pnpm test:realtime-removal` | exit 1 | Same pre-existing reduced-fixture arithmetic. |
|
||||
| Server/provider compatibility matrices (presigned V1, Web Push V1/V2, Service Worker V1/V2, OPFS V1/V2) | not run | No provider or multi-release infrastructure in this environment. **UNVERIFIED**. |
|
||||
| Staging rollback drill | not run | Requires a staging deployment. **UNVERIFIED**. |
|
||||
|
||||
### Work deliberately not performed
|
||||
|
||||
| Plan task | Status | Reason |
|
||||
| --- | --- | --- |
|
||||
| Task 17 extraction (`BT-UP-05`, `BT-IMG-03`, OPFS/cache/download decomposition) | **NOT PERFORMED** | Pure cohesion refactor with no finding closure. `BT-UP-05` was attempted: the five internal owners extract cleanly, but they need a shared-internals module for ~40 types, constants and helpers to avoid an import cycle, so the attempt was reverted rather than half-landed. `BT-UP-06`, the one item in this group with behavioural content, **was** implemented. |
|
||||
| `SW-10`, OPFS physical/protocol V2 rollout, presigned and Web Push receipt V2 (`WP-02`, `WP-03`, `WP-04`) | **DEFERRED_TO_MIGRATION** | These are expand → dual-read/emit → old-writer drain → contract deployments requiring server request-shape negotiation and multi-release drain windows. The prerequisite in-repo pieces landed: the shared Service Worker manifest codec and canonical digest (`SW-05`), the OPFS worker protocol version and strict correlation (`STO-07`), and the OPFS physical generation token (`STO-01`). |
|
||||
| Promotion gaps `GAP-01`, `GAP-02`, `GAP-03`, `R-07`, `BT-UP-07`, `BT-IMG-04` | `PROMOTION_BLOCKED` | Unchanged by design. No availability state was raised and no optional capability was added to the default bootstrap. |
|
||||
|
||||
## Re-review remediation (2026-08-14)
|
||||
|
||||
Source: [`docs/reviews/adapters/RE-REVIEW-2026-08-14.md`](../reviews/adapters/RE-REVIEW-2026-08-14.md),
|
||||
38 findings (High 19 / Medium 17 / Low 2) raised against `3b481eb`.
|
||||
|
||||
**GOV-02.** That re-review found the previous section of this ledger closed a
|
||||
number of rows as `FIXED_NOT_RELEASED` that were in fact partial. The tables
|
||||
below are written the other way round: a row is `FIXED` only where a new
|
||||
adversarial test failed first on the pre-fix source and passes on the landed
|
||||
one.
|
||||
|
||||
**All 38 second re-review findings are `FIXED` as scoped below.** The first pass
|
||||
closed 26; the second closed the remaining twelve, which each needed a lifecycle
|
||||
or contract change rather than a contained edit. A third re-review then found
|
||||
that twenty of those closures held only on the paths their tests exercised; that
|
||||
verdict and its remediation are recorded in the third re-review section further
|
||||
down, and this section is left as written so the two passes stay comparable.
|
||||
|
||||
### Landed
|
||||
|
||||
| ID | Severity | Disposition | Commit | Red-then-green evidence |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| LIVE-01 | High | `FIXED` | `f4bfdf0` | `tests/integration/http-execution-v3-live-authority.test.ts` — UNAVAILABLE, sync throw, async rejection and a malformed outcome each closed as `UNAUTHENTICATED` before the fix; all four now close as `AUTH_INTEGRATION_FAILURE` with zero fetches. |
|
||||
| LIVE-02 | High | `FIXED` | `f4bfdf0` | `tests/unit/contract-registry-immutability.test.ts` — a borrowed `Map.prototype.clear` emptied the installed profile registry before the fix. |
|
||||
| LIVE-03 | High | `FIXED` | `f4bfdf0` | Same suite — the composed HTTP registry was clearable and a post-composition mutation of a source policy changed `totalDeadlineMs` from 10000 to 999999. |
|
||||
| LIVE-04 | Medium | `FIXED` | `f4bfdf0` | Same integration suite — a non-cooperative fetch and reader held the port result open; a body that finished after the deadline was admitted as SUCCESS. |
|
||||
| LIVE-05 | High | `FIXED` | `f4bfdf0` | Same suite — a DEADLINE timeout emitted no `api.request.failed`. |
|
||||
| LEG-01 | High | `FIXED` | `ca210d3` | `tests/integration/legacy-http-credential-authority.test.ts` — a recovery that answered after the deadline called `onUnauthenticated` once; it now calls it zero times, and only an adopted no-session result notifies. |
|
||||
| LEG-02 | High | `FIXED` | `ca210d3` | Same suite — a bearer profile dispatched with no `Authorization` at all. |
|
||||
| OPT-NET-01 | Medium | `FIXED` | `ca210d3` | `tests/unit/legacy-and-optional-network-remediation.test.ts` — a loader rejection with a live signal became `PAGINATION_ABORTED`. |
|
||||
| OPT-NET-02 | Low | `FIXED` | `ca210d3` | Same suite — `defineMutationIntent` accepted control characters the executor rejected. |
|
||||
| STO-RR-01 | High | `FIXED` | `6a8281a` | `tests/unit/opfs-worker-runtime.test.ts` — a strict non-reentrant lease manager made `FINALIZE_PUT` hang forever; `tests/unit/opfs-byte-store.test.ts` pins that a failed finalization is no longer a plain success. |
|
||||
| STO-RR-02 | Medium | `FIXED` | `6a8281a` | Same suite — every failure answered with kind `CAPABILITIES`. |
|
||||
| STO-RR-03 | Medium | `FIXED` | `6a8281a` | Same suite — `{code:"EVIL"}` reached the caller; a non-boolean `retryable` and a throwing getter left the RPC to time out. |
|
||||
| STO-RR-04 | Medium | `FIXED` | `6a8281a` | `tests/unit/public-response-cache.test.ts` — a transient marker read failure deleted the active candidate. |
|
||||
| STO-RR-05 | Medium | `FIXED` | `6a8281a` | Same suite — one failed repair fetch destroyed every healthy asset in the release. |
|
||||
| RPC-RR-02 | Medium | `FIXED` | `bd90e0c` | `tests/unit/browser-rpc/browser-rpc-remediation.test.ts` — a throwing fence and a throwing `clock.sleep` escaped the Result contract. |
|
||||
| RPC-RR-03 | High | `FIXED` | `bd90e0c` | Same suite — a transport accessor ran during validation, and the installed binding registries exposed `set`/`delete`/`clear`. |
|
||||
| RPC-RR-04 | Medium | `FIXED` | `bd90e0c` | Same suite — extra, inherited, symbol-keyed and throwing-getter transport values passed. |
|
||||
| SW-RR-01 | High | `FIXED` | `efc577d` | Bounded marker reader with a read deadline, reader cancel and fatal UTF-8 decode replaces `response.text()`. |
|
||||
| SW-RR-02 | Medium | `FIXED` | `efc577d` | A `null` `event.source` no longer satisfies activation or reset completion. |
|
||||
| SW-RR-03 | Medium | `FIXED` | `efc577d` | `tests/unit/service-worker-web-push-remediation.test.ts` plus the `check:adapter-inventory` gate — generator and decoder now share one exported table. |
|
||||
| SW-RR-04 | Medium | `FIXED` | `efc577d` | `cache.match` rejection is closed as a miss so `respondWith` reaches its network fallback. |
|
||||
| WP-RR-01 | Medium | `FIXED` | `efc577d` | `focus`/`openWindow` carry NOT_APPLIED → MAYBE_APPLIED → CONFIRMED and a late effect is observed exactly once. |
|
||||
| TR-RR-08 | Medium | `FIXED` | `69cb7e3` | `tests/unit/resumable-upload-http-control-plane.test.ts` — a throwing getter escaped as `TypeError` out of `createSession`; symbol and non-enumerable extras passed the key check. |
|
||||
| TR-RR-09 | Medium | `FIXED` | `fb5b449` | `tests/unit/image-cdn-runtime.test.ts` — the suite pinned the contradictory `private, no-store` as success; the recorded fail-closed matrix now applies. |
|
||||
| GOV-01 | Low | `FIXED` | this commit | `scripts/check-adapter-inventory.ts` diffs `docs/reviews/adapters/INVENTORY.md` against `git ls-files src/adapters`. The missing `src/adapters/platform/abortable-operation.ts` row is restored and the total is 119/119. |
|
||||
| GOV-02 | Medium | `FIXED` | this commit | This section replaces the over-closed rows with evidence-linked dispositions and an explicit not-done list. |
|
||||
|
||||
### Landed in the second pass
|
||||
|
||||
The twelve findings the first pass did not reach are now closed on the same
|
||||
terms: a named adversarial test failed on the pre-fix source and passes on the
|
||||
landed one.
|
||||
|
||||
| ID | Severity | Commit | Red-then-green evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| RPC-RR-01 | High | `a7390e3` | `tests/unit/browser-rpc/browser-rpc-remediation.test.ts` — `openServerStream` returns a lease (`streamId`, `frames`, `cancel`, `waitClosed`) decoded from own data descriptors. A timed-out stream is cancelled exactly once, a second stream for the same operation is refused as `CONFLICT` / `RPC_STREAM_DRAINING` without reaching the transport, and admission resumes only after `waitClosed()` settles. |
|
||||
| RT-RR-01 | High | `c0f53d1` | `tests/unit/realtime/stream-coordinator.test.ts` — a `close()` during a running apply reported success before the fix; tasks are now registered at invocation, so it reports `IDLE_TIMEOUT` and `DRAINING`. |
|
||||
| RT-RR-02 | High | `c0f53d1` | Same suite — a queued event started running inside DRAINING, and a timed-out effect left its resume token in place. The queued event is now dropped as `CLOSED` at execution time and the token is discarded with `freshness: UNKNOWN`. |
|
||||
| RT-RR-03 | Medium | `c0f53d1` | `tests/unit/realtime/live-poll-handoff-coordinator.test.ts` — a second `close()` replayed the cached timeout forever; only an in-flight close is shared now, fenced writers are retained until their tails settle, and a later close converges to success. |
|
||||
| RT-RR-04 | High | `c0f53d1` | Same suite — `close()` reported quiescence while a checkpoint was still running; checkpoint work now joins the physical-task registry. |
|
||||
| TR-RR-05 | High | `46e067e` | `tests/unit/abortable-operation.test.ts` — a rejection was reported as `TERMINAL/CLOSED` while `terminal()` said no owner, and a throwing scheduler released the caller listener leaving no owner at all, so later aborts were invisible. The primitive now distinguishes `REJECTED`, agrees with `terminal()`, snapshots the scheduler, closes atomically on install failure and compensates a late value exactly once. Both presigned subsystems migrated onto it, replacing two hand-written copies. |
|
||||
| TR-RR-01 | High | `46e067e` | `tests/unit/presigned-transfer.test.ts` — `close()` released bookkeeping without aborting, and the consumer signal joined only after the fetch began. Both are fixed; the scheduler-failure test now pins fail-closed. |
|
||||
| TR-RR-02 | High | `46e067e` | The upload scope is created before the digest and the digest races the caller and deadline; the vault claim and network call follow an owner re-check. |
|
||||
| TR-RR-03 | High | `46e067e` | Same suite — the registration is a versioned exact union: unknown/missing protocol version, plaintext target, ambient credential and cookie headers, a non-2xx expected status and any extra own field are each refused at the issuer seam. |
|
||||
| TR-RR-04 | Medium | `5a76f95` | Same suite — the presigned source was never closed on success, writer failure or abort; a holder now closes it exactly once at the outermost boundary on all three. |
|
||||
| TR-RR-06 | High | `5a76f95` | `tests/unit/resumable-upload-runtime.test.ts` — a never-granting mutation lock made `dispose()` unbounded; it is now bounded by `cleanupDeadlineMs`, returns the drain result, and leaves the runtime `CLOSING` with the checkpoint store open when the drain is unproved. |
|
||||
| TR-RR-07 | High | `5a76f95` | `tests/unit/image-cdn-runtime.test.ts` — after an abort a new verification was admitted while the abandoned verifier still ran; the slot is now held until the raw verifier settles. |
|
||||
|
||||
**No second re-review row remains `NOT_STARTED`**, and the
|
||||
structural gate for the shared `abortable-operation` primitive is now active —
|
||||
it was withheld until the primitive actually had production importers, because a
|
||||
gate that fails CI for a documented but unfixed defect reports the wrong thing.
|
||||
|
||||
Contracts that changed shape, and are therefore breaking for an external
|
||||
implementor:
|
||||
|
||||
| Contract | Change | Reason |
|
||||
| --- | --- | --- |
|
||||
| `BrowserRpcTransport.openServerStream` | returns `BrowserRpcServerStreamLease` instead of `AsyncIterable` | RPC-RR-01 needs cancellation and closure evidence |
|
||||
| `AuthSessionPort.recover` | accepts an optional `CredentialOperationContext` | LEG-01; optional for one release |
|
||||
| `PresignedCapabilityRegistration` | gains `protocol` | TR-RR-03 versioned exact union |
|
||||
| `ResumableUploadRuntime.dispose` | returns `BrowserDataResult<void>` | TR-RR-06 bounded drain result |
|
||||
| `ResumableUploadRuntimePolicy` | gains `cleanupDeadlineMs` | TR-RR-06 teardown bound |
|
||||
| `ImageCdnPresentationPort.resolve` | `signal` required | BT-IMG-01, landed earlier |
|
||||
|
||||
### Gates after this pass
|
||||
|
||||
Run on the landed tree. Only what actually passed is claimed as passing.
|
||||
|
||||
| Command | Exit | Result |
|
||||
| --- | ---: | --- |
|
||||
| `corepack pnpm check:types` | 0 | all six projects |
|
||||
| `corepack pnpm lint` | 0 | `--max-warnings=0` clean |
|
||||
| `corepack pnpm check:architecture` | 0 | 289 modules, 868 dependencies; 12 fixtures PASS |
|
||||
| `corepack pnpm check:adapter-inventory` | 0 | 119 files, 7 shared asset extensions, fixture linking, primitive importers |
|
||||
| `corepack pnpm check:registries` | 0 | 11 registries PASS |
|
||||
| `corepack pnpm check:diagnostics` | 0 | 8 diagnostics / 5 telemetry producers |
|
||||
| `corepack pnpm check:browser-file-storage-boundaries` | 0 | PASS, 34 rejections |
|
||||
| `corepack pnpm check:realtime-boundaries` | 0 | PASS |
|
||||
| `corepack pnpm verify:documentation` | 0 | PASS_SCOPED |
|
||||
| `git diff --check` | 0 | clean |
|
||||
| `tests/unit` + `tests/integration`, no exclusions | — | 1643 passed / 1747; the 104 failures are the four environmental files below |
|
||||
| `corepack pnpm test:component` | 0 | 126 passed |
|
||||
| `corepack pnpm test:recipes` | 0 | 17 passed |
|
||||
| `corepack pnpm test:reference-feature` | 0 | 26 passed |
|
||||
|
||||
### Environmental failures, not claimed as green
|
||||
|
||||
| Gate | Status | Attribution |
|
||||
| --- | --- | --- |
|
||||
| `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. |
|
||||
| `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
||||
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | All three pass in a fresh process (78 passed together). They spawn and reap process groups, so their timing assertions are load sensitive. |
|
||||
|
||||
### Destructive fixture hazard — fixed
|
||||
|
||||
Outside the 38 findings, and found while running the suites for them.
|
||||
|
||||
Four sites linked the repository's installed dependencies into a throwaway
|
||||
fixture with a single directory symlink at `<fixture>/node_modules`:
|
||||
|
||||
- `scripts/lib/removal-fixture.ts`
|
||||
- `scripts/check-supply-chain-provider-fixtures.ts`
|
||||
- `tests/integration/security-followup-archive.test.ts`
|
||||
- `tests/unit/ci-artifact-contract.test.ts`
|
||||
|
||||
Each fixture then runs `pnpm` inside itself. pnpm does not recognise the modules
|
||||
directory it finds there and purges it; with `CI=true` it does so without a
|
||||
prompt. The purge followed the symlink and deleted the **repository's own**
|
||||
`node_modules` mid-run — a test suite uninstalling the workspace it was running
|
||||
in. That is what produced the cascading, file-unrelated failures a full
|
||||
`test:unit` run reported, and it happened twice during this work.
|
||||
|
||||
`scripts/lib/fixture-node-modules.ts` replaces all four: `node_modules` is a
|
||||
real directory whose entries are individual symlinks, so a recursive delete
|
||||
unlinks the fixture's own links instead of walking through one link into the
|
||||
shared tree. `tests/unit/fixture-node-modules.test.ts` performs the exact
|
||||
recursive delete pnpm performs and asserts the source tree survives, and
|
||||
`corepack pnpm check:adapter-inventory` fails on any reintroduction of the
|
||||
directory-symlink form.
|
||||
|
||||
After the fix a full `tests/unit` + `tests/integration` run leaves the
|
||||
dependencies intact and its failures are confined to the two environmental
|
||||
files above plus the two flaky-under-load ones:
|
||||
|
||||
| File | Failed | Attribution |
|
||||
| --- | ---: | --- |
|
||||
| `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. |
|
||||
| `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
||||
| `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. |
|
||||
| `tests/unit/provider-guardian-transaction.test.ts` | 1 | Passes in isolation. |
|
||||
|
||||
1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`,
|
||||
`tests/unit/supply-chain.test.ts` and
|
||||
`tests/integration/security-followup-archive.test.ts` — the three that had to be
|
||||
excluded before — now pass in the full run.
|
||||
|
||||
## Third re-review (2026-08-14)
|
||||
|
||||
A third read-only re-review re-tested all 38 rows above against hostile,
|
||||
non-cooperative, late-completing and mutable inputs. It confirmed 18 as fixed
|
||||
and found 20 that held only on the paths their tests exercised, plus three new
|
||||
findings and three governance defects. The common shape was the same in almost
|
||||
every case: a value was **checked and then read again**, or a wrapper settled
|
||||
and was mistaken for the physical work it was bounding.
|
||||
|
||||
`docs/operations/adapter-remediation-dispositions.json` is the machine-readable
|
||||
record; `corepack pnpm check:remediation-ledger` joins it against this document,
|
||||
verifies every named evidence path exists, and refuses a blanket closure
|
||||
sentence while any row is still open. **GOV-03.** The previous section claimed
|
||||
"All 38 are now FIXED" while six rows were reproducibly partial — a sentence is
|
||||
cheap and a reviewer reads it as evidence, so the claim is now derived rather
|
||||
than authored.
|
||||
|
||||
### Landed
|
||||
|
||||
| ID | Prior verdict | Disposition | Red-then-green evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| NS-01 | `PARTIAL` | `FIXED` | `tests/integration/http-execution-v3-live-authority.test.ts` — a throwing `kind` getter escaped the auth boundary and an auth outage was reported as `NETWORK_FAILURE`; nine hostile credential shapes now close as `AUTH_INTEGRATION_FAILURE` with zero fetches, and each field is read exactly once. |
|
||||
| NS-02 | `PARTIAL` | `FIXED` | `tests/unit/contract-registry-immutability.test.ts` — a policy that answered `10_000` to validation and `999_999` to the copy installed the second value; composition now snapshots first, so the out-of-ceiling value is refused. |
|
||||
| NS-03 | `PARTIAL` | `FIXED` | `tests/integration/http-execution-v3-live-authority.test.ts` — a `NONE` probe left `body.locked === true` after a deadline; the reader is now cancelled once and its lock released. |
|
||||
| NS-04 | `PARTIAL` | `FIXED` | `tests/unit/opfs-byte-store.test.ts` — a failed `journal.complete` still returned plain success with `SUCCEEDED` telemetry; it now returns a `RECONCILE` failure and leaves the row `COMMITTED`, and an unfinished delete is observed `DEGRADED`. |
|
||||
| NS-05 | `PARTIAL` | `FIXED` | `tests/unit/opfs-worker-runtime.test.ts` — a bootstrap failure answered every request with kind `CAPABILITIES`, so the gateway replaced `BLOCKED` with `UNSUPPORTED`; all twelve kinds now round-trip their own correlation. |
|
||||
| NS-06 | `PARTIAL` | `FIXED` | Same suite — a throwing `requestId` getter produced an RPC timeout and a stateful trap left the public promise pending forever; replies are decoded before the pending row is released and an uncorrelatable reply fails the channel closed. |
|
||||
| NS-07 | `NEW` | `FIXED` | `tests/unit/cursor-pagination-runtime.test.ts` — raising `maxPages` after construction widened a validated cap from one page to three; caps and collaborators are captured once. |
|
||||
| NS-08 | `NEW` | `FIXED` | `tests/unit/public-response-cache.test.ts` — a non-cooperative fetch held the mutation lock forever, and a digest finishing after the abort still wrote the asset and the activation marker; one terminal owner now covers the whole staging body. |
|
||||
| RPC-01 | `PARTIAL` | `FIXED` | `tests/unit/browser-rpc/browser-rpc-remediation.test.ts` — a rejecting, throwing or non-promise `waitClosed()` was absorbed into success and a second physical stream opened; only a fulfilled contract-shaped receipt prunes the registration. |
|
||||
| RPC-02 | `PARTIAL` | `FIXED` | Same suite — a throwing iterator `return` accessor replaced the selected timeout with a native `TypeError`, and the exported lease decoder threw on a hostile `Symbol.asyncIterator`. |
|
||||
| RPC-03 | `PARTIAL` | `FIXED` | Same suite — a registry accessor ran twice during validation and rows hiding fields behind a prototype or a non-enumerable key installed; every registry is snapshotted before validation. |
|
||||
| RPC-04 | `PARTIAL` | `FIXED` | Same suite — own `{ok,message,encodedBytes}` plus a prototype `injected` was a success, and a missing `message` reached a permissive schema as `undefined`. |
|
||||
| RT-01 | `PARTIAL` | `FIXED` | `tests/unit/realtime/stream-coordinator.test.ts` — an authority that re-entered `close()` from inside its own invocation got `{ok:true}` while its effect was pending; the task is registered before the collaborator is called. |
|
||||
| RT-02 | `NEW` | `FIXED` | Same suite — a throwing `scheduleTimeout` started a recovery that overlapped the running apply and made `close()` reject with a native `TypeError`; an uninstallable deadline now fails closed inside the result contract. |
|
||||
| TR-01 | `PARTIAL` | `FIXED` | `tests/unit/presigned-transfer.test.ts` — a stateful issuer could show an allowed header set to the forbidden-header check and store `Authorization`; the registration and everything nested in it is snapshotted before validation. |
|
||||
| TR-02 | `PARTIAL` | `FIXED` | Same suite — a source lease that resolved after an abort was never closed; a compensator sharing the holder's close-once latch closes it exactly once. |
|
||||
| TR-03 | `PARTIAL` | `FIXED` | `tests/unit/abortable-operation.test.ts`, `tests/unit/image-cdn-runtime.test.ts`, `tests/unit/resumable-upload-fetch-transport.test.ts` — the outcome depended on a hard-coded four-microtask drain, and a throwing scheduler rejected `probe()`/`execute()` natively while leaking a caller listener. The primitive now settles once by observation order, and all four consumers use it with construction-time bound timer snapshots. |
|
||||
| TR-04 | `PARTIAL` | `FIXED` | `tests/unit/resumable-upload-runtime.test.ts` — a provider outliving its attempt deadline let `dispose()` report a drained runtime and close the checkpoint store; raw provider work is now its own registry and both must be quiescent. |
|
||||
| TR-05 | `PARTIAL` | `FIXED` | `tests/unit/resumable-upload-http-control-plane.test.ts` — a stateful `sessionId` passed the regex and returned `../../unsafe`; the decoder validates an owned snapshot read exactly once. |
|
||||
| SW-01 | `PARTIAL` | `FIXED` | `tests/unit/service-worker-runtime.test.ts` — a single 1 MiB chunk was retained before the 257-byte ceiling was compared, a declared oversize left the body open and the reader lock was never released. |
|
||||
| SW-02 | `PARTIAL` | `FIXED` | `tests/unit/service-worker-web-push-remediation.test.ts` — the generator emitted `/assets/bad@name-abcdefgh.js` and the decoder then refused the manifest it had just produced; both share one canonical path predicate and the generator self-validates its output. |
|
||||
| WP-01 | `PARTIAL` | `FIXED` | `tests/unit/web-push-worker-runtime.test.ts` — an ordinary click was counted twice, a late rejection downgraded `MAYBE_APPLIED` to `NOT_APPLIED`, and the late observation ran outside `waitUntil`. |
|
||||
| GOV-03 | `OPEN` | `FIXED` | `scripts/check-remediation-ledger.ts` — dispositions and evidence paths are machine-readable and a blanket closure sentence is blocked while any row is open. |
|
||||
| GOV-04 | `OPEN` | `FIXED` | `scripts/check-adapter-inventory.ts` — the gate passed on any single importer; it now requires the four named consumers to resolve their import to the shared primitive and prints the exact set. |
|
||||
| GOV-05 | `OPEN` | `FIXED` | `config/recipes/frontend-capability-recipes.json` — see the budget note below. |
|
||||
|
||||
### The file-transfer bundle budget
|
||||
|
||||
`check:optional-recipes:source` failed at the previous baseline too (52,078 >
|
||||
52,000 gzip bytes), so it was not green before this work either. Duplicate abort
|
||||
and deadline mechanics were consolidated first: the Image probe and the Resumable
|
||||
fetch transport now use the shared `abortable-operation` primitive instead of
|
||||
their own scopes, and four decoders share `src/contracts/exact-snapshot.ts`
|
||||
rather than each carrying its own descriptor walk. The remainder is the
|
||||
correctness code the third re-review asked for — exact decoders, late-value
|
||||
compensators and physical-work registries — so the budget is reset to **54,600**
|
||||
gzip bytes against a measured **53,810**, rather than the failure being carried
|
||||
forward as if it were green.
|
||||
|
||||
### Regenerated evidence (this commit)
|
||||
|
||||
| Command | Exit | Result |
|
||||
| --- | ---: | --- |
|
||||
| `corepack pnpm check:types` | 0 | all six projects pass |
|
||||
| `corepack pnpm check:architecture` | 0 | 290 modules, 879 dependencies; 12 fixtures PASS; allowed PASS / 9 forbidden rejected |
|
||||
| `corepack pnpm check:adapter-inventory` | 0 | 119 files; 7 shared extensions; 5 shared-abort importers listed |
|
||||
| `corepack pnpm check:optional-recipes:source` | 0 | file-transfer 53,810 / 54,600 gzip bytes |
|
||||
| `corepack pnpm exec vitest run tests/unit/browser-rpc tests/unit/realtime` | 0 | 17 files / 234 passed |
|
||||
| abortable-operation, presigned, resumable, image CDN | 0 | 7 files / 176 passed |
|
||||
| OPFS, journal, public cache, cursor | 0 | 5 files / 100 passed |
|
||||
| Service Worker + Web Push | 0 | 6 files / 79 passed |
|
||||
| `corepack pnpm exec vitest run tests/integration` | 0 | 11 files / 82 passed |
|
||||
|
||||
The full `tests/unit` + `tests/integration` run is **1,845 passed / 1,864**,
|
||||
128 of 129 files green. Every one of the 19 failures is in
|
||||
`tests/unit/ci-artifact-contract.test.ts` and is the pre-existing sandbox,
|
||||
cgroup, RLIMIT and `/tmp` permission behaviour already recorded above — the same
|
||||
file failed identically before this work. No adapter test fails.
|
||||
|
||||
## Rules for updating this ledger
|
||||
|
||||
- A row moves out of `NOT_STARTED` only with a linked red test, its green run, and the commit id.
|
||||
- `PROMOTION_BLOCKED` rows never become `DEFECT`; they close through an authorized product
|
||||
selection change with the browser/provider evidence named in the plan.
|
||||
- Environmental gate failures are copied verbatim and are never claimed as green.
|
||||
@@ -13,6 +13,24 @@ probe는 현재 `DESIGNED_NOT_IMPLEMENTED`다. 아래 절차에서 이 기능을
|
||||
자동 조치는 해당 runtime이 구현·조합된 제품에서만 실행한다. 현재 reference
|
||||
primitive를 coordinator 완료 증거로 사용하지 않는다.
|
||||
|
||||
|
||||
## OPFS 보상 실패와 reconcile (STO-01)
|
||||
|
||||
`put()`이 실패했는데 보상 cleanup effect가 확인되지 않으면 runtime은 실패를
|
||||
`OBJECT_RECONCILE` / `CONFLICT`(recovery `RETRY`)로 보고하고 journal row를 남긴다.
|
||||
이는 결함이 아니라 설계된 상태다.
|
||||
|
||||
1. journal에 `PREPARING` 또는 `FILES_READY` row가 남아 있는지 확인한다. 남아
|
||||
있다면 staging bytes가 아직 존재할 수 있다는 뜻이다.
|
||||
2. `maintenance.reconcile()`을 실행한다. reconcile은 같은 exact physical
|
||||
generation token만 삭제하고, effect가 여전히 `EFFECT_UNKNOWN`이면 journal을
|
||||
유지한 채 다시 `OBJECT_RECONCILE`을 반환한다.
|
||||
3. journal row를 수동으로 삭제하지 않는다. row가 사라지면 stale staging을 추적할
|
||||
근거가 사라지고 quota만 누수된다.
|
||||
4. OPFS root나 journal database를 통째로 삭제하거나 schema를 downgrade하지
|
||||
않는다. rollback은 새 v2 write admission을 닫고 v1+v2 reader를 유지하는 것으로
|
||||
수행한다.
|
||||
|
||||
## 1. 공통 원칙
|
||||
|
||||
incident 중에도 다음 작업은 금지한다.
|
||||
|
||||
@@ -12,6 +12,26 @@ session/account Query lifecycle, strict query policy와 Web Storage v2 lifecycle
|
||||
현재 `DESIGNED_NOT_IMPLEMENTED`다. 아래 목표 절차를 현재 runtime의 보장으로
|
||||
해석하지 않는다.
|
||||
|
||||
|
||||
## Public cache staging repair와 offline activation (STO-03 ~ STO-05)
|
||||
|
||||
- release marker는 "staging이 끝났다"는 **주장**이고 모든 entry의 존재·digest
|
||||
증거가 아니다. 같은 manifest로 `stageRelease`를 다시 호출하면 runtime이
|
||||
candidate를 재검증하고, browser eviction이나 부분 손상이 발견되면 그 owned
|
||||
candidate만 삭제한 뒤 network에서 다시 stage한다. marker만 보고 성공을
|
||||
반환하지 않는다.
|
||||
- 검증 중 abort나 읽기 불가(UNKNOWN)는 stage 성공이 아니며 active pointer를
|
||||
건드리지 않는다. candidate를 임의로 삭제하지도 않는다.
|
||||
- `activateRelease`와 `cleanupOwned`는 network I/O가 없다. fetcher 없이도
|
||||
동작하므로 offline rollback과 quota recovery cleanup이 `UNSUPPORTED`로 막히지
|
||||
않는다. 두 operation은 Cache Storage와 mutation lock만 요구하고 실패 시
|
||||
recovery는 `RETRY`다. `stageRelease`만 fetcher를 요구하며 recovery는
|
||||
`ONLINE_ONLY`다.
|
||||
- variant를 사용하는 policy(`allowedVaryHeaderNames` 비어 있지 않음)는 반드시
|
||||
`allowedResponseHeaderNames`에 `vary`를 포함해야 한다. 아니면 composition이
|
||||
`TypeError`로 즉시 실패한다. 저장된 variant가 같은 key로 충돌하는 상태를 만들지
|
||||
않기 위한 cross-field invariant다.
|
||||
|
||||
## 1. 변경할 수 없는 복구 원칙
|
||||
|
||||
- 서버가 server state와 authorization의 source of truth다.
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
# Network and state adapters implementation review
|
||||
|
||||
- Review date: 2026-08-13 (Asia/Seoul)
|
||||
- Reviewed revision: 4dc033cf33a5b6173bbf960d5eb464a406dc4c92
|
||||
- Mode: code review only; no implementation source was changed
|
||||
- Primary scope: src/adapters/http, auth, query-cache, cross-context-invalidation, platform, diagnostics, telemetry
|
||||
- Traced boundaries: corresponding contracts, application ports, bootstrap composition, reference feature adapters, tests, architecture decisions, and operating documentation
|
||||
|
||||
## 1. Outcome and priority
|
||||
|
||||
No Critical issue was found. Five High findings are implementation blockers or near-term correctness/security work:
|
||||
|
||||
1. N-01: the production V3 HTTP observation is always rejected by the diagnostics projector, and the V3 path never emits terminal-failure telemetry.
|
||||
2. N-02: V3 declares authProfileId but neither composes nor enforces a profile; a credential collaborator can change transport-owned Accept and credentials, while a declared bearer profile can send no Authorization header.
|
||||
3. N-03: after a command attempt has been dispatched, a retry-time final-invariant fence can downgrade MAYBE_APPLIED to NOT_STARTED.
|
||||
4. N-04: telemetry dispose only removes pagehide; queued callbacks, future emit calls, and in-flight delivery survive runtime teardown.
|
||||
5. N-05: the conditional-validator key codec is delimiter-ambiguous and lets two valid bindings overwrite each other. It is not composed into HTTP yet, so this is a rollout blocker rather than a current request-path incident.
|
||||
|
||||
The current reference feature uses V3. The older createHttpClient path remains exported and is still the documented rollback/compatibility seam, so its replay and cancellation defects cannot be dismissed as dead code.
|
||||
|
||||
## 2. Method, severity, and confidence
|
||||
|
||||
Severity:
|
||||
|
||||
| Level | Meaning |
|
||||
| --- | --- |
|
||||
| Critical | immediate broad confidentiality/integrity loss, arbitrary execution, or unrecoverable state corruption |
|
||||
| High | security boundary bypass, wrong command-effect verdict, silent loss of required production evidence, or unsafe replay/rollout blocker |
|
||||
| Medium | bounded correctness, cancellation, cleanup, or cross-context isolation defect with a narrower activation condition |
|
||||
| Low | hardening or contract/documentation mismatch without a demonstrated material product failure |
|
||||
|
||||
Confidence:
|
||||
|
||||
| Level | Meaning |
|
||||
| --- | --- |
|
||||
| Very high | direct control/data-flow proof and a minimal failing reproduction |
|
||||
| High | direct code proof and aligned contract/documentation evidence |
|
||||
| Medium | implementation evidence exists but product requirement or browser/provider behavior must be selected |
|
||||
| Low | hypothesis requiring characterization before acceptance |
|
||||
|
||||
Validation performed without retaining test changes:
|
||||
|
||||
- A temporary five-case Vitest characterization was added, run, and removed. All five expected-correct assertions failed: V3 diagnostic projection, telemetry-after-dispose, conditional-validator collision, credential transport ownership, and retry-time effect preservation.
|
||||
- Related baseline: 21 test files, 144 tests passed.
|
||||
- Static producer check: corepack pnpm check:diagnostics passed with “8 diagnostics and 5 telemetry producers”.
|
||||
- The temporary test was deleted and the implementation worktree was clean before this report was written. Other agents later created unrelated docs/reviews entries; this review does not modify them.
|
||||
|
||||
This distinction matters: the green suite proves current intended behaviors, while the five failures identify missing assertions rather than contradicting existing passing tests.
|
||||
|
||||
## 3. Complete primary-scope inventory
|
||||
|
||||
All 24 files below were read in full.
|
||||
|
||||
| File | Responsibility | Direct dependencies / consumers | Review disposition |
|
||||
| --- | --- | --- | --- |
|
||||
| src/adapters/auth/external-session-adapter.ts | Adapts the external session owner to AuthSessionPort; validates credential header names and values; supplies demo, anonymous, and unavailable variants | application/ports/auth-session-port.ts; bootstrap/runtime-adapters.ts; HTTP V2/V3 | Preserve token opacity and allowlist. Modify for cooperative cancellation and required-profile header enforcement. |
|
||||
| src/adapters/cross-context-invalidation/browser-cross-context-host.ts | Safely captures browser BroadcastChannel, localStorage, storage events, and secure random capabilities | contracts/cache-invalidation.ts; browser-cross-context-invalidation.ts; runtime-adapters.ts | Preserve fail-closed capability capture. Modify to capture one localStorage identity and validate native StorageEvent.storageArea. |
|
||||
| src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts | Versioned invalidate-only wire protocol, BroadcastChannel/storage fallback, TTL, dedupe, per-source sequence/gap detection, bounded tracking, subscription and close | contracts/cache-invalidation.ts; host adapter; TanStack coordinator | Preserve closed envelope, bounded maps, and idempotent close. Modify exact storage-area admission; dual-transport fan-out is a separate product choice. |
|
||||
| src/adapters/cross-context-invalidation/index.ts | Public barrel for host/runtime types and constructors | bootstrap and query-cache coordinator | Modify exports only if the storage event facade type changes. |
|
||||
| src/adapters/diagnostics/bounded-diagnostics.ts | Bounded in-memory diagnostics projection/sink and safe pre-mount boot evidence | DiagnosticsPort; contracts/diagnostics.ts and telemetry.ts; bootstrap | Preserve fail-isolated projection and cloning. Harden non-finite capacity. V3 producer fix belongs primarily in bootstrap. |
|
||||
| src/adapters/http/bounded-body-reader.ts | Declared-length and streamed byte ceilings, stream cancellation/release isolation, forbidden-body probing, strict UTF-8/JSON decode | V3 executor; bounded-body-reader tests | Keep as the single bounded response primitive. It already contains the cleanup behavior missing from bounded-json.ts. |
|
||||
| src/adapters/http/bounded-json.ts | Legacy response stream reader and JSON decoder | legacy client.ts | Replace internals with bounded-body-reader delegation, then remove with V2 retirement. Current cancel/release failures can reject. |
|
||||
| src/adapters/http/client.ts | Legacy/V2 operation lookup, profiles, auth recovery, retries, total deadline, response validation/mapping, diagnostics and telemetry | legacy contracts and ports; runtime-adapters createRuntimeHttpClient | Compatibility-only but exported. Fix empty keyed replay, cooperative auth cancellation, and common body-reader use before relying on it for rollback. Deprecate after callers are migrated. |
|
||||
| src/adapters/http/http-contract-bridge.ts | V3 request projection, URL/body bounds, credential patch type, final request invariant | external-contract-runtime.ts; V3 executor | Preserve descriptor-owned request projection. Change credential authority: patch cannot own credentials or transport headers; final invariant must compare exact resolved profile. |
|
||||
| src/adapters/http/http-effect-certainty.ts | Converts physical-attempt state and problem descriptors into mutation certainty/UI projection | V3 executor; contracts | Preserve explicit certainty vocabulary. Add a monotonic logical-execution certainty join used across retries. |
|
||||
| src/adapters/http/http-execution-v3.ts | Descriptor-driven V3 lifetime: validation, projection, credentials, deadline, retry, fetch, response admission, effect verdict and observation | external contracts, scope, mutation intent, bridge, bounded reader, certainty, retry policy | Main correction site for N-01, N-02, N-03 and cooperative cancellation. Keep one retry authority and closed result union. |
|
||||
| src/adapters/http/request-builder.ts | Legacy path/query construction and origin/base-prefix checks | legacy client; ApiOperation | Keep while V2 remains. Do not reuse it to weaken V3 descriptor projection. |
|
||||
| src/adapters/http/resource-mapper.ts | Thin legacy operation-payload mapper delegation | boundary-mapper; legacy client | No standalone defect. Remove only with V2, not as part of the correctness patch. |
|
||||
| src/adapters/http/retry-policy.ts | Legacy retry decision/backoff plus parseRetryAfter reused by V3 | legacy client and V3 executor | Keep deterministic parse/backoff seam. V2 must additionally prove a valid key before keyed replay. |
|
||||
| src/adapters/http/schema-registry.ts | Legacy Zod envelope/request/payload validation and clone | legacy client/tests | No standalone defect. Remains V2-only and should not be merged with installed external validators. |
|
||||
| src/adapters/platform/browser-lifecycle.ts | Single owner of visibility/network/focus/page/beforeunload listeners and lifecycle snapshots | optional-runtime-host.ts | Preserve centralized listener ownership and idempotent dispose. Clarify or redesign dirty-source attachment semantics; selection item O-02. |
|
||||
| src/adapters/platform/browser-mutation-intent-factory.ts | Secure intent/idempotency UUIDs plus monotonic creation time, normalized by defineMutationIntent | MutationIntentFactory port; bootstrap | Keep. Share one idempotency-key validator so external inputs and generated values have identical bounds. |
|
||||
| src/adapters/platform/system-clock.ts | Wall clock and abortable sleep with listener/timer cleanup | ClockPort; legacy HTTP | Keep. Existing unit test covers abort cleanup behavior. |
|
||||
| src/adapters/query-cache/conditional-validator-store.ts | In-memory ETag CAS sidecar keyed by scope, definition, identity, representation and cache revision | bootstrap scope reset; future conditional HTTP/query join | Fix tuple codec before composition. Preserve validator grammar, generation/revision checks and bounded capacity. |
|
||||
| src/adapters/query-cache/cursor-pagination-runtime.ts | Bounded cursor chain, page/snapshot/loop/item/byte validation | cursor pagination contract; currently available but not composed | Add post-await abort admission or an abort race. Current pre-await-only check can admit a late page. |
|
||||
| src/adapters/query-cache/server-state-scope-runtime.ts | Synchronous session-generation fence, ordered reset participants, cache reset, identity replacement and lifecycle notifications | AuthSessionPort, query invalidation, scope contract; bootstrap | Keep synchronous FENCED-before-await design. Consider async shutdown only as O-03; no current stale-generation admission was found. |
|
||||
| src/adapters/query-cache/tanstack-cache-coordinator.ts | Maps registry topics to query namespace invalidation, coalesces remote hints, defers under mutation leases, resets/disposes | TanStack Query, invalidation contracts, cross-context runtime | Keep invalidate-only remote authority, generation guard, reset serialization and bounded registry validation. Add teardown characterization if dispose becomes async. |
|
||||
| src/adapters/query-cache/tanstack-query-cache.ts | Creates QueryClient defaults and QueryCachePort read/write/invalidate adapter with diagnostics | TanStack Query, QueryCachePort, errors/diagnostics | Keep retry disabled and clone-on-write. Clone-on-read is an optional port-semantics decision, not a confirmed production defect. |
|
||||
| src/adapters/telemetry/best-effort-telemetry.ts | Allowlisted bounded oldest-drop telemetry queue, scheduled/pagehide flush, sink isolation and evidence | TelemetryPort, telemetry/diagnostic contracts, bootstrap | Add terminal lifecycle state, joined flush promise and in-flight abort; ensure runtime composition disposes it. |
|
||||
|
||||
## 4. Traced boundary inventory
|
||||
|
||||
### Application ports
|
||||
|
||||
| File | Relevant contract |
|
||||
| --- | --- |
|
||||
| src/application/ports/auth-session-port.ts | Session state, credential patch, recovery; currently has no AbortSignal/deadline context. |
|
||||
| src/application/ports/query-cache-port.ts | Closed read/write/invalidate result; value is unknown and read mutability is unspecified. |
|
||||
| src/application/ports/clock-port.ts | Time and abortable sleep. |
|
||||
| src/application/ports/diagnostics-port.ts | Non-throwing logical diagnostics producer boundary. |
|
||||
| src/application/ports/telemetry-port.ts | Fire-and-forget semantic event emission. |
|
||||
| src/application/ports/mutation-intent-factory.ts | Intent identity and keyed-command creation. |
|
||||
| src/application/result.ts | Closed application result used by pagination and feature projection. |
|
||||
|
||||
### Contracts
|
||||
|
||||
Reviewed: server-state-scope.ts, cursor-pagination.ts, diagnostic-buckets.ts, mutation-intent.ts, boundary-mapper.ts, rest-profiles.ts, api-operations.ts, errors.ts, diagnostics.ts, telemetry.ts, query-invalidation.ts, query-keys.ts, cache-invalidation.ts, and external-contract-runtime.ts.
|
||||
|
||||
Key joins:
|
||||
|
||||
- external-contract-runtime.ts:117-148 declares authProfileId but validates only non-empty text at 300-344.
|
||||
- rest-profiles.ts:11-37 already provides the profile/strategy shape and exact credentials mode used by V2.
|
||||
- diagnostics.ts:22-42 is a closed context allowlist and 83-99 rejects the whole record on an unknown key.
|
||||
- telemetry.ts defines api.request.failed required attributes: error_kind, http_status_group, attempt_count_bucket, and route_id.
|
||||
- server-state-scope.ts makes synchronous signal abortion/isCurrent the generation admission boundary.
|
||||
- cache-invalidation.ts supplies the closed wire grammar used by the cross-context runtime.
|
||||
|
||||
### Bootstrap and feature path
|
||||
|
||||
Reviewed: runtime-adapters.ts, server-state-generation-store.ts, create-runtime-composition.ts, composition-root.ts, runtime-application.tsx, main.tsx, optional-runtime-host.ts, installed-contract-contributions.ts, installed-feature-adapters.ts, reference create-reference-feature-input.ts, reference-http-gateway.ts, reference feature contract contribution, reference feature API, application-query.ts, and server-state-generation-provider.tsx.
|
||||
|
||||
Production request flow:
|
||||
|
||||
reference-http-gateway (has routeId)
|
||||
-> createReferenceFeatureInstalledInput (drops routeId)
|
||||
-> runtime-adapters contractOperations
|
||||
-> createContractHttpExecutor (V3)
|
||||
-> runtime-adapters observe
|
||||
-> bounded diagnostics projector
|
||||
|
||||
The legacy createRuntimeHttpClient is still exported at runtime-adapters.ts:142-163 but is not the installed reference feature request path.
|
||||
|
||||
## 5. Confirmed defects
|
||||
|
||||
### N-01 — V3 HTTP diagnostics are silently dropped and terminal telemetry is absent
|
||||
|
||||
- Severity: High
|
||||
- Confidence: Very high
|
||||
- Activation: current production reference-feature V3 path
|
||||
|
||||
Evidence:
|
||||
|
||||
- http-execution-v3.ts:150-155 defines an observation with diagnosticsOperation, outcome, attempts and certainty.
|
||||
- http-execution-v3.ts:354-365 emits that shape exactly once.
|
||||
- runtime-adapters.ts:331-343 maps attempts and certainty as literal context keys.
|
||||
- diagnostics.ts:22-42 allows attempt_count_bucket but not attempts or certainty.
|
||||
- diagnostics.ts:83-99 rejects the complete diagnostic on the first unknown context key.
|
||||
- reference-http-gateway.ts:14-42 and 69-103 constructs a low-cardinality routeId.
|
||||
- create-reference-feature-input.ts:41-54 forwards signal and intent but discards routeId.
|
||||
- runtime-adapters.ts:331-347 has no V3 telemetry emit at all.
|
||||
- VD-07 lines 36-39 requires exactly one logical HTTP diagnostic and exactly one terminal non-abort failure telemetry event.
|
||||
|
||||
Minimal reproduction:
|
||||
|
||||
Input was the exact runtime-adapters V3 record context:
|
||||
|
||||
{
|
||||
operation_id: "reference.list",
|
||||
outcome: "TRANSPORT_FAILURE",
|
||||
attempts: 2,
|
||||
certainty: "TIMEOUT"
|
||||
}
|
||||
|
||||
Expected projectDiagnosticRecord(...).success true; actual false.
|
||||
|
||||
Impact:
|
||||
|
||||
- Success, retry recovery, failure and cancellation on the installed V3 feature leave no HTTP diagnostic record.
|
||||
- V3 terminal failures leave no api.request.failed event even when telemetry is enabled.
|
||||
- check:diagnostics remains green because it checks producer presence/source policy, not whether the concrete producer output passes the projector.
|
||||
|
||||
Required decision:
|
||||
|
||||
- Observation is a safe typed internal record, not an arbitrary context map.
|
||||
- routeId is required at the installed operation-executor boundary.
|
||||
- Raw attempt count/duration/status stay internal; only buckets reach diagnostics/telemetry.
|
||||
- Cancellation and scope-fence outcomes produce diagnostics once but never api.request.failed.
|
||||
- Diagnostics/telemetry failures remain unable to affect the HTTP outcome.
|
||||
|
||||
Proposed signature:
|
||||
|
||||
export type HttpExecutionObservation = Readonly<{
|
||||
routeId: string;
|
||||
operationId: string;
|
||||
diagnosticsOperation: string;
|
||||
outcome: HttpExecutionOutcome<unknown, unknown>["kind"];
|
||||
errorKind: string;
|
||||
status?: number;
|
||||
attemptCount: number;
|
||||
durationMs: number;
|
||||
effect: HttpEffectCertainty;
|
||||
cancellationOwner?: CancellationOwner;
|
||||
}>;
|
||||
|
||||
export interface HttpExecutionContext {
|
||||
readonly routeId: string;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly scope: CacheScopeSnapshot;
|
||||
readonly intent?: MutationIntent;
|
||||
}
|
||||
|
||||
Projection in runtime-adapters:
|
||||
|
||||
diagnostics.record({
|
||||
eventId: "http.request.completed",
|
||||
context: {
|
||||
route_id: observation.routeId,
|
||||
operation_id: observation.operationId,
|
||||
operation: observation.diagnosticsOperation,
|
||||
outcome: observation.outcome,
|
||||
error_kind: observation.errorKind,
|
||||
http_status_group: statusGroup(observation.status),
|
||||
attempt_count_bucket: attemptBucket(observation.attemptCount),
|
||||
duration_bucket: durationBucket(observation.durationMs)
|
||||
}
|
||||
});
|
||||
|
||||
For terminal non-abort failures, emit api.request.failed using the same safe route/operation/status/attempt/duration fields. Do not add attempts or certainty as unregistered context. If product operators need effect certainty, add the explicit effect_certainty key and a closed value policy to both contracts and ADR; do not pass the current free string.
|
||||
|
||||
### N-02 — V3 auth profile is declarative only; credential code can alter transport policy
|
||||
|
||||
- Severity: High
|
||||
- Confidence: Very high for generic V3 authority violation; High for current missing-bearer behavior
|
||||
- Activation: current bootstrap permits a bearer-declared demo request with an empty patch; arbitrary Accept/credentials requires a custom/defective credential collaborator
|
||||
|
||||
Evidence:
|
||||
|
||||
- external-contract-runtime.ts:117-125 contains authProfileId.
|
||||
- external-contract-runtime.ts:300-344 checks only non-empty identity, not registry existence or coherence.
|
||||
- runtime-adapters.ts:302-326 receives operation.authProfileId but ignores it, always returns credentials: omit, and accepts an authenticated empty patch.
|
||||
- http-contract-bridge.ts:14-22 lets the credential patch select any RequestCredentials.
|
||||
- http-execution-v3.ts:475-483 rejects only idempotency-key.
|
||||
- http-execution-v3.ts:486-489 spreads credential headers after transport-owned Accept, so patch Accept wins.
|
||||
- http-contract-bridge.ts:253-273 only checks that credentials is one of three valid Fetch values and generally allows Accept/Content-Type; it does not prove profile equality or header ownership.
|
||||
- reference contribution lines 133-141, 177-185, 224-236 declares REFERENCE_EXTERNAL_BEARER for all operations.
|
||||
- demo-session credentialPatch is empty at external-session-adapter.ts:109, yet authenticated demo requests are sent.
|
||||
- VD-23 lines 211-259 says transport owns Accept/Content-Type and an auth profile exact-fixes Fetch credentials.
|
||||
- The existing V2 profile registry at rest-profiles.ts:11-37 and 77-106 is a working local pattern.
|
||||
|
||||
Minimal reproduction:
|
||||
|
||||
A credential collaborator returned:
|
||||
|
||||
{
|
||||
kind: "READY",
|
||||
headers: { Accept: "text/plain" },
|
||||
credentials: "include"
|
||||
}
|
||||
|
||||
The request completed successfully and fetch observed Accept text/plain and credentials include. Expected fetch count was zero or transport-owned application/json/omit.
|
||||
|
||||
Current mitigation and residual issue:
|
||||
|
||||
- external-session-adapter.ts:18-46 currently restricts the real owner to authorization and x-csrf-token, so the Accept injection is blocked in this bootstrap path.
|
||||
- That does not restore generic executor authority, and required Authorization is not checked. Empty bearer remains possible in the current demo path.
|
||||
- Therefore do not characterize this as arbitrary external-owner header injection in the current bootstrap; characterize it as an executor contract violation plus a current profile-completeness failure.
|
||||
|
||||
Required decision:
|
||||
|
||||
- Reuse the existing Profile/Strategy registry; do not introduce a general interceptor chain.
|
||||
- The resolved profile, not the credential patch, owns credentials.
|
||||
- Credential patch contains only a typed, runtime-validated subset of credential headers.
|
||||
- A bearer profile requires authorization; an anonymous profile permits none.
|
||||
- Unknown/incoherent profiles fail during composition. Missing required headers or extra headers return AUTH_INTEGRATION_FAILURE with effect NOT_STARTED and fetch count zero.
|
||||
- UNAUTHENTICATED remains a user/session state, not an integration/configuration error.
|
||||
|
||||
Proposed signatures:
|
||||
|
||||
export type CredentialHeaderName =
|
||||
| "authorization"
|
||||
| "x-csrf-token"
|
||||
| "x-tenant-context";
|
||||
|
||||
export type RestAuthProfile = Readonly<{
|
||||
authProfileId: string;
|
||||
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
|
||||
credentials: "omit" | "same-origin" | "include";
|
||||
allowedCredentialHeaders: readonly CredentialHeaderName[];
|
||||
requiredCredentialHeaders: readonly CredentialHeaderName[];
|
||||
}>;
|
||||
|
||||
export type CredentialPatchOutcome =
|
||||
| Readonly<{
|
||||
kind: "READY";
|
||||
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
|
||||
}>
|
||||
| Readonly<{ kind: "UNAUTHENTICATED" }>
|
||||
| Readonly<{ kind: "UNAVAILABLE" }>
|
||||
| Readonly<{ kind: "SCOPE_FENCED" }>;
|
||||
|
||||
attachCredentials(
|
||||
operation: Readonly<{
|
||||
operationId: string;
|
||||
authProfileId: string;
|
||||
method: string;
|
||||
}>,
|
||||
context: Readonly<{ signal: AbortSignal }>
|
||||
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
|
||||
|
||||
The executor dependency receives a validated ReadonlyMap<string, RestAuthProfile>. Final invariant compares init.credentials to the selected profile and rejects missing/extra credential headers.
|
||||
|
||||
Demo migration must be explicit. Recommended repository choice: allow createDemoSessionAdapter to receive a demo credential patch from bootstrap and supply a fixed non-secret Authorization marker only in AUTH_MODE=demo; keep REFERENCE_EXTERNAL_BEARER strict. Do not silently weaken the bearer profile to make tests pass. If a product backend wants anonymous demo calls, it needs a distinct anonymous contract/profile selected at composition.
|
||||
|
||||
### N-03 — retry-time scope fence downgrades a previously dispatched command to NOT_STARTED
|
||||
|
||||
- Severity: High
|
||||
- Confidence: Very high
|
||||
- Activation: latent for a future IDEMPOTENT command with retryBudget greater than zero; the current reference create is KEYED with retryBudget zero
|
||||
|
||||
Evidence:
|
||||
|
||||
- After response admission, attemptState becomes SETTLED at http-execution-v3.ts:648-656.
|
||||
- A retry continues at 657-692.
|
||||
- The next iteration checks scope at 511-516 and uses current attemptState, which still yields MAYBE_APPLIED.
|
||||
- There is a second scope check inside final invariants at 554-563.
|
||||
- If the scope changes between those two checks, lines 568-575 call preDispatchEffect(isCommand), returning NOT_STARTED and forgetting the prior attempt.
|
||||
- Deep design lines 1683-1690 says dispatch followed by timeout/network/abort/body loss is MAYBE_APPLIED.
|
||||
|
||||
Minimal reproduction:
|
||||
|
||||
- Contract: commandEffect non-null, retrySemantics IDEMPOTENT, retryBudget 1.
|
||||
- Attempt 1: fetch returns 429.
|
||||
- Sleep resolves.
|
||||
- Scope is current at retry-loop entry and false at final invariant.
|
||||
- Expected SCOPE_FENCED with MAYBE_APPLIED.
|
||||
- Actual SCOPE_FENCED with NOT_STARTED.
|
||||
|
||||
Root cause:
|
||||
|
||||
PhysicalAttemptState is being used as both current-attempt state and logical-execution history. Reset/final-invariant code reasons only about “this retry has not sent” and loses “a previous physical attempt was sent”.
|
||||
|
||||
Required decision:
|
||||
|
||||
Maintain a monotonic logical certainty accumulator for the whole execution. A new unsent retry cannot lower prior MAYBE_APPLIED. Final-invariant failures use the joined logical certainty; the first attempt can still return NOT_STARTED.
|
||||
|
||||
Proposed helper:
|
||||
|
||||
export function joinMutationEffectCertainty(
|
||||
current: MutationEffectCertainty,
|
||||
observed: MutationEffectCertainty
|
||||
): MutationEffectCertainty;
|
||||
|
||||
Join rules:
|
||||
|
||||
- MAYBE_APPLIED dominates NOT_STARTED and NOT_APPLIED.
|
||||
- APPLIED_CONFIRMED is terminal and cannot enter an automatic retry.
|
||||
- NOT_APPLIED dominates NOT_STARTED for internal history.
|
||||
- Query operations remain NOT_APPLICABLE and do not use the mutation lattice.
|
||||
|
||||
Also check caller/scope/deadline ownership after every awaited admission and before returning success. A separately named characterization should decide the response-completed-versus-caller-abort race; do not fold an unverified race rule into this patch without a test.
|
||||
|
||||
### N-04 — telemetry continues work after dispose and composition never disposes it
|
||||
|
||||
- Severity: High
|
||||
- Confidence: Very high
|
||||
- Activation: current when telemetry is enabled; no-op telemetry is unaffected
|
||||
|
||||
Evidence:
|
||||
|
||||
- best-effort-telemetry.ts:95-101 schedules a callback that always calls flush.
|
||||
- emit at 104-125 has no disposed check.
|
||||
- flush at 127-147 has no disposed check or in-flight AbortController.
|
||||
- dispose at 154-156 removes only pagehide.
|
||||
- runtime-adapters.ts:412-416 clears validators/scope/generation but omits telemetry.dispose.
|
||||
- create-runtime-composition.ts:60-66 calls infrastructure.dispose after optional shutdown, so the omission reaches application teardown.
|
||||
- flush at 127-130 returns an already-resolved promise when another flush is active, so await adapter.flush does not mean “the active delivery has settled”.
|
||||
|
||||
Minimal reproduction:
|
||||
|
||||
- Queue one valid api.request.failed with a captured scheduler callback.
|
||||
- Call dispose.
|
||||
- Run the captured callback and call emit again.
|
||||
- Expected no fetch and pendingCount zero.
|
||||
- Actual one fetch; later emit is also accepted.
|
||||
|
||||
Impact:
|
||||
|
||||
- HMR/test/runtime teardown can send queued or future events after the owning composition is gone.
|
||||
- In-flight work has no cancellation owner.
|
||||
- This is a lifecycle/privacy contract defect even though delivery is best-effort.
|
||||
|
||||
Required decision:
|
||||
|
||||
Use a small terminal lifecycle state, not a durable queue:
|
||||
|
||||
type TelemetryLifecycle = "ACTIVE" | "DISPOSED";
|
||||
|
||||
flush(): Promise<void>;
|
||||
dispose(): void;
|
||||
|
||||
Semantics:
|
||||
|
||||
- emit after dispose is a no-op.
|
||||
- dispose removes pagehide, clears queued events, invalidates scheduled callbacks, and aborts the current sink request.
|
||||
- flush joins and returns the active flush promise.
|
||||
- completion of a sink that ignored abort cannot reschedule or update post-dispose delivery state.
|
||||
- disposal drops data silently; it must not recursively emit a drop event while shutting down.
|
||||
- runtime infrastructure.dispose calls telemetry.dispose before destroying diagnostics/state dependencies.
|
||||
|
||||
A separate async shutdown or persistent retry queue is unnecessary for current best-effort policy.
|
||||
|
||||
### N-05 — conditional-validator composite key is collision-prone
|
||||
|
||||
- Severity: High when composed; current effective risk Medium / activation blocker
|
||||
- Confidence: Very high
|
||||
- Activation: docs classify sidecar AVAILABLE_NOT_COMPOSED; bootstrap creates/clears it but HTTP does not use it
|
||||
|
||||
Evidence:
|
||||
|
||||
- conditional-validator-store.ts:43-58 joins four unescaped components with colon.
|
||||
- identityToken permits colon at 47.
|
||||
- scope fingerprint permits colon in server-state-scope-runtime.ts:196-200.
|
||||
- definitionId is only checked for truthiness at line 46.
|
||||
- architecture status at api-contract-schema-mapper-and-server-state.md:108 explicitly says AVAILABLE_NOT_COMPOSED.
|
||||
|
||||
Collision using valid values and the same scope/version:
|
||||
|
||||
A: definitionId = "resource:detail"
|
||||
identityToken = "identity-token-00000001"
|
||||
|
||||
B: definitionId = "resource"
|
||||
identityToken = "detail:identity-token-00000001"
|
||||
|
||||
Both encode to the same string. Installing B overwrites A; prepare(A) returns B’s ETag.
|
||||
|
||||
Impact after composition:
|
||||
|
||||
A validator from one definition could be sent for another and a 304 could admit the wrong cached representation/revision relationship.
|
||||
|
||||
Required decision:
|
||||
|
||||
Use an injective deterministic tuple codec, not a repository abstraction. JSON.stringify of a validated fixed tuple is sufficient:
|
||||
|
||||
type ConditionalValidatorKeyTuple = readonly [
|
||||
scopeFingerprint: string,
|
||||
definitionId: string,
|
||||
identityToken: string,
|
||||
representationVersion: number
|
||||
];
|
||||
|
||||
Validate and byte-bound definitionId and fingerprint at this trust boundary. No public store API change or persisted-data migration is needed because the store is in-memory and not composed into HTTP yet.
|
||||
|
||||
### N-06 — legacy keyed commands can automatically retry with no Idempotency-Key
|
||||
|
||||
- Severity: High on the compatibility/rollback path
|
||||
- Confidence: High
|
||||
- Activation: exported createRuntimeHttpClient/createHttpClient; not the current installed reference path
|
||||
|
||||
Evidence:
|
||||
|
||||
- client.ts:241-246 uses nullish coalescing, so caller value "" is retained rather than replaced.
|
||||
- client.ts:534 sets Idempotency-Key only if the value is truthy.
|
||||
- retry-policy.ts:45-68 allows both safe and keyed retries.
|
||||
- Therefore a keyed command with explicit empty key can replay after a retryable response while sending no key.
|
||||
- The existing keyed integration test uses a non-empty logical-command value; there is no invalid-key case.
|
||||
- VD-23 lines 691-703 permits V1 fallback only if hardening remains.
|
||||
|
||||
Required decision:
|
||||
|
||||
Export one defineIdempotencyKey validator from mutation-intent.ts and use it in both V2 and V3. Reject empty, whitespace-only, control-character, and over-byte-budget keys before credentials, timers, or fetch. Do not trim or silently regenerate a caller-supplied invalid value. Return VALIDATION_REJECTED / IDEMPOTENCY_KEY_INVALID, attempt count zero.
|
||||
|
||||
### N-07 — legacy total deadline does not bound or cancel credential attachment
|
||||
|
||||
- Severity: High on the compatibility/rollback path
|
||||
- Confidence: High
|
||||
- Activation: auth-required V2 operation with a non-cooperative external owner
|
||||
|
||||
Evidence:
|
||||
|
||||
- client.ts:517-526 creates the attempt controller/timer.
|
||||
- client.ts:570-586 awaits authSession.credentialPatch directly.
|
||||
- auth-session-port.ts:15-27 exposes no signal/deadline to credentialPatch.
|
||||
- If the owner never settles, aborting the attempt controller does not settle the await, so execute can exceed its total deadline indefinitely.
|
||||
- Recovery is raced at client.ts:297-324 and 666-697, but authSession.recover itself receives no signal; late owner work can continue.
|
||||
- V3 has the better local waiting pattern at http-execution-v3.ts:427-466, although its underlying credential work is not cooperatively signaled either.
|
||||
- VD-23 lines 339-349 explicitly includes credential/recovery in total deadline and requires auth waiter cleanup.
|
||||
|
||||
Proposed compatible port extension:
|
||||
|
||||
export type AuthOperationContext = Readonly<{
|
||||
signal: AbortSignal;
|
||||
deadlineAtMonotonicMs: number;
|
||||
}>;
|
||||
|
||||
credentialPatch(
|
||||
binding: CredentialRequestBinding,
|
||||
context?: AuthOperationContext
|
||||
): Promise<CredentialPatch>;
|
||||
|
||||
recover(context?: AuthOperationContext):
|
||||
Promise<"restored" | "no-session">;
|
||||
|
||||
Make context optional for one release to preserve existing owner implementations, but both clients must race owner promises against the lifetime signal immediately. Extend ExternalSessionOwner attachCredential/recoverSession the same way, pass the context through, and ignore all late completions. In the following breaking release, require the context from external owners.
|
||||
|
||||
Error semantics:
|
||||
|
||||
- deadline owner: REQUEST_TIMEOUT / TIMEOUT.
|
||||
- caller owner: REQUEST_ABORTED / CANCELLED.
|
||||
- scope owner in V3: ABORTED_BY_SCOPE or SCOPE_FENCED, preserving logical effect.
|
||||
- ordinary owner rejection: AUTH_INTEGRATION_FAILURE.
|
||||
- none of these paths may fetch.
|
||||
|
||||
### N-08 — legacy bounded JSON can reject and leave response cleanup inconsistent
|
||||
|
||||
- Severity: Medium
|
||||
- Confidence: High
|
||||
- Activation: V2 response path
|
||||
|
||||
Evidence:
|
||||
|
||||
- bounded-json.ts:9-12 awaits response.body.cancel outside a catch.
|
||||
- lines 24-26 awaits reader.cancel; a rejection escapes the closed result.
|
||||
- lines 30-33 does not cancel after reader failure and releaseLock can throw.
|
||||
- client.ts:714-723 returns immediately on content-type mismatch without cancelling the response body.
|
||||
- bounded-body-reader.ts:31-76 and 148-153 already isolates cancellation/release errors and is well tested.
|
||||
|
||||
Required decision:
|
||||
|
||||
Make bounded-body-reader the common primitive. Keep readBoundedJson’s public return codes temporarily by delegating and mapping:
|
||||
|
||||
- RESPONSE_TOO_LARGE -> RESPONSE_BODY_LIMIT.
|
||||
- UTF8_INVALID / JSON_INVALID / RESPONSE_STREAM_FAILURE -> MALFORMED_JSON for legacy compatibility.
|
||||
|
||||
Cancel on V2 content-type mismatch. Do not maintain two stream-reader strategies.
|
||||
|
||||
### N-09 — localStorage fallback cannot prove the event came from localStorage
|
||||
|
||||
- Severity: Medium
|
||||
- Confidence: Very high
|
||||
- Activation: storage fallback; invalidation is hint-only, so effect is stale/refetch pressure rather than data/authorization corruption
|
||||
|
||||
Evidence:
|
||||
|
||||
- StoragePulseEvent at browser-cross-context-invalidation.ts:98-101 contains only key and newValue.
|
||||
- receiveStorage at 179-193 checks exact key/value but cannot check area.
|
||||
- browser-cross-context-host.ts:206-248 discards native storageArea.
|
||||
- client-cache-and-storage.md:82-83, 939-948 and checklist 1638 explicitly documents this missing check.
|
||||
- Existing native browser tests cover homogeneous BroadcastChannel and homogeneous storage fallback, not a foreign storage area.
|
||||
|
||||
Required decision:
|
||||
|
||||
Capture localStorage once and derive both the write facade and event validator from the same object identity. Do not call a hostile getter twice.
|
||||
|
||||
Proposed facade:
|
||||
|
||||
export type StoragePulseEvent = Readonly<{
|
||||
key: string | null;
|
||||
newValue: string | null;
|
||||
storageArea: "EXPECTED_LOCAL_STORAGE" | "OTHER_OR_UNKNOWN";
|
||||
}>;
|
||||
|
||||
Core receiveStorage admits only EXPECTED_LOCAL_STORAGE. Register the pulse key in storage-keys.ts at the same time:
|
||||
|
||||
CACHE_INVALIDATION_PULSE:
|
||||
backend localStorage
|
||||
classification opaque-cache
|
||||
valueCodec opaque-string-v1
|
||||
ttl null
|
||||
migration discard
|
||||
quotaFallback no-persist
|
||||
|
||||
The storage adapter need not own pulse I/O; the registry owns its physical-key policy.
|
||||
|
||||
### N-10 — cursor pagination can admit a page after cancellation
|
||||
|
||||
- Severity: Medium
|
||||
- Confidence: High
|
||||
- Activation: AVAILABLE_NOT_COMPOSED pagination runtime
|
||||
|
||||
Evidence:
|
||||
|
||||
- cursor-pagination-runtime.ts:29-33 checks signal only before await loadPage.
|
||||
- There is no post-await signal check before page validation/accumulation and success at lines 34-59.
|
||||
- A non-cooperative loadPage that resolves after abort can make the last page return success.
|
||||
- The architecture status claims an abort test, but cursor-pagination-runtime.test.ts currently covers finite chain, invalid invariants, loop and snapshot drift only.
|
||||
|
||||
Required decision:
|
||||
|
||||
Race loadPage with the signal or check immediately after await and before observing the page. Prefer an awaitWithAbort helper so a never-settling loader cannot hold loadAll forever. Late page completion is ignored. Return REQUEST_ABORTED / PAGINATION_ABORTED without partial items.
|
||||
|
||||
### N-11 — non-finite queue capacities bypass boundedness
|
||||
|
||||
- Severity: Low
|
||||
- Confidence: High
|
||||
- Activation: custom adapter construction only; bootstrap uses defaults
|
||||
|
||||
Evidence:
|
||||
|
||||
- bounded-diagnostics.ts:21 uses Math.max(1, maxEntries). NaN remains NaN and Infinity remains Infinity.
|
||||
- best-effort-telemetry.ts:49 has the same issue.
|
||||
- Comparisons against NaN/Infinity can disable intended eviction.
|
||||
|
||||
Fix: require Number.isSafeInteger and a documented upper ceiling, throwing TypeError at construction. This is configuration validation, not a runtime drop.
|
||||
|
||||
## 6. Selection-dependent improvements and explicitly separated hypotheses
|
||||
|
||||
These are not confirmed defects at the same level as N-01 through N-11.
|
||||
|
||||
### O-01 — mixed BroadcastChannel/storage-only tabs
|
||||
|
||||
- Confidence: Medium
|
||||
- Evidence: browser-cross-context-invalidation.ts:318-342 returns immediately after a successful BroadcastChannel post and does not pulse storage. A second tab whose BroadcastChannel constructor failed but whose storage works listens only to storage.
|
||||
- Existing tests at cross-tab-invalidation.test.ts:447-488 cover a sender whose BroadcastChannel post fails, then storage fallback. Browser capability tests cover BroadcastChannel/BroadcastChannel and storage/storage, not BroadcastChannel sender/storage-only receiver.
|
||||
- Product decision: if per-tab capability asymmetry must be supported, mirror every accepted BroadcastChannel event to storage and rely on existing eventId dedupe. If “priority fallback” assumes partition-homogeneous capability, document that assumption and keep single-write behavior.
|
||||
- Trade-off: mirroring increases synchronous localStorage writes and storage-event fan-out. This protocol is a best-effort hint with focus/stale revalidation, so do not build a durable/exactly-once bus.
|
||||
|
||||
### O-02 — beforeunload attachment comment does not match implementation
|
||||
|
||||
- Confidence: High for mismatch; Low material impact
|
||||
- browser-lifecycle.ts:39-43 says the listener exists only while a source reports dirty.
|
||||
- syncBeforeUnload at 135-143 attaches whenever any source is registered; it cannot observe a callback changing from false to true.
|
||||
- onBeforeUnload rechecks actual dirtiness, so users are not incorrectly prompted.
|
||||
- Preferred minimal action: document “while at least one dirty reporter is registered”. Add an observable update handle only if listener-count optimization is a real requirement.
|
||||
|
||||
### O-03 — async scope/coordinator teardown
|
||||
|
||||
- Confidence: Medium
|
||||
- ServerStateScopeRuntime.dispose and QueryInvalidationCoordinator.dispose are void while reset/flush promises may exist.
|
||||
- Current generation checks and disposed flags prevent reactivation; no stale cache admission was demonstrated.
|
||||
- If runtime shutdown needs a “all background state work settled” guarantee, introduce async close and await it in composition. Otherwise characterize late work and retain the simpler void API.
|
||||
|
||||
### O-04 — QueryCachePort clone-on-read
|
||||
|
||||
- Confidence: Medium
|
||||
- tanstack-query-cache.ts clones writes but returns TanStack’s object reference on read.
|
||||
- Docs say cached mapped values are immutable, but QueryCachePort returns unknown rather than a readonly type.
|
||||
- Decide whether the port guarantees immutable values or isolation. If isolation is required, clone on read and return QUERY_CACHE_FAILURE on clone failure. Do not add cost to production TanStack hooks based only on this legacy port.
|
||||
|
||||
### O-05 — caller-abort versus already-buffered successful response
|
||||
|
||||
- Confidence: Medium; not included in confirmed findings
|
||||
- V3 aborts the fetch signal, but admitResponse does not directly inspect terminalCancellation after a custom/buffered reader resolves.
|
||||
- Before changing semantics, add a deterministic test where readBoundedResponseBytes aborts the caller and then returns valid bytes. Product must choose first-terminal-owner-wins versus completed-response-wins. The design’s CancellationOwner wording suggests first-owner-wins, but this report does not claim it without characterization.
|
||||
|
||||
## 7. Patterns to apply, and patterns to reject
|
||||
|
||||
Apply:
|
||||
|
||||
1. Profile/Strategy registry for auth. The operation selects an immutable profile; the credential owner supplies only proof material.
|
||||
2. Stable tuple codec for validator keys. It directly solves injectivity and keeps storage private.
|
||||
3. Monotonic certainty lattice for logical command execution. Physical attempts cannot downgrade already-observed uncertainty.
|
||||
4. Structured cancellation context. One lifetime signal/deadline is passed through credential, recovery, retry sleep, fetch and response admission.
|
||||
5. Small terminal lifecycle state for telemetry. ACTIVE/DISPOSED plus one joined flush promise is sufficient.
|
||||
6. Adapter-boundary predicate for StorageEvent.storageArea. Browser identity checks belong at native capability capture.
|
||||
7. Characterization-first consolidation for legacy body reading. Delegate to the proven bounded reader while preserving old error codes.
|
||||
|
||||
Reject:
|
||||
|
||||
- A generic HTTP interceptor/middleware pipeline: it obscures authority/order and recreates the transport-header bug.
|
||||
- A generic repository abstraction for query cache, ETag store and storage pulse: their consistency and lifecycle semantics differ.
|
||||
- Durable/exactly-once cross-tab messaging: invalidation is a bounded hint; server revalidation remains authoritative.
|
||||
- Persistent telemetry retry/offline queue: current contract is best-effort and has no consent/retention decision.
|
||||
- A new retry library or circuit breaker: current retry bounds are explicit and adequate once replay proof/certainty is corrected.
|
||||
- Event sourcing for scope/reset: synchronous generation fencing plus ordered participants is simpler and already correct.
|
||||
|
||||
## 8. Exact implementation manifest
|
||||
|
||||
Implement as small reviewable changes. “Delete: none” and “Move: none” applies to the immediate remediation; legacy removals occur only after the compatibility window.
|
||||
|
||||
### Change set A — V3 observability and monotonic effect
|
||||
|
||||
Modify:
|
||||
|
||||
- src/adapters/http/http-execution-v3.ts
|
||||
- src/adapters/http/http-effect-certainty.ts
|
||||
- src/bootstrap/runtime-adapters.ts
|
||||
- src/features/reference-feature/adapters/create-reference-feature-input.ts
|
||||
- src/contracts/diagnostics.ts only if effect_certainty is approved; otherwise do not modify its allowlist
|
||||
- tests/unit/http-execution-v3.test.ts
|
||||
- tests/unit/runtime-adapters.test.ts
|
||||
- tests/features/reference-feature/reference-runtime-composition.test.ts
|
||||
- docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md
|
||||
- docs/architecture/2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md
|
||||
|
||||
Create:
|
||||
|
||||
- tests/integration/http-execution-v3-observability.test.ts
|
||||
|
||||
Delete: none.
|
||||
Move: none.
|
||||
|
||||
### Change set B — profile-authoritative credentials and auth cancellation
|
||||
|
||||
Modify:
|
||||
|
||||
- src/contracts/rest-profiles.ts
|
||||
- src/contracts/external-contract-runtime.ts for strict authProfileId grammar only
|
||||
- src/adapters/http/http-contract-bridge.ts
|
||||
- src/adapters/http/http-execution-v3.ts
|
||||
- src/application/ports/auth-session-port.ts
|
||||
- src/adapters/auth/external-session-adapter.ts
|
||||
- src/bootstrap/runtime-adapters.ts
|
||||
- src/features/reference-feature/adapters/create-reference-feature-input.ts to map AUTH_INTEGRATION_FAILURE
|
||||
- tests/unit/rest-profile-contract.test.ts
|
||||
- tests/unit/auth-session-adapter.test.ts
|
||||
- tests/unit/http-execution-v3.test.ts
|
||||
- tests/unit/runtime-adapters.test.ts
|
||||
- tests/features/reference-feature/reference-runtime-composition.test.ts
|
||||
- docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md
|
||||
|
||||
Create:
|
||||
|
||||
- tests/integration/http-execution-v3-auth-profile.test.ts
|
||||
|
||||
Delete: none.
|
||||
Move: none.
|
||||
|
||||
### Change set C — telemetry lifecycle
|
||||
|
||||
Modify:
|
||||
|
||||
- src/adapters/telemetry/best-effort-telemetry.ts
|
||||
- src/bootstrap/runtime-adapters.ts
|
||||
- tests/unit/telemetry.test.ts
|
||||
- tests/unit/runtime-adapters.test.ts
|
||||
- docs/architecture/decisions/VD-07-diagnostics-and-telemetry-exporter.md
|
||||
|
||||
Create: none.
|
||||
Delete: none.
|
||||
Move: none.
|
||||
|
||||
### Change set D — state sidecars and cancellation
|
||||
|
||||
Modify:
|
||||
|
||||
- src/adapters/query-cache/conditional-validator-store.ts
|
||||
- src/adapters/query-cache/cursor-pagination-runtime.ts
|
||||
- tests/unit/conditional-validator-store.test.ts
|
||||
- tests/unit/cursor-pagination-runtime.test.ts
|
||||
- docs/architecture/api-contract-schema-mapper-and-server-state.md
|
||||
|
||||
Create: none.
|
||||
Delete: none.
|
||||
Move: none.
|
||||
|
||||
### Change set E — legacy rollback hardening and reader consolidation
|
||||
|
||||
Modify:
|
||||
|
||||
- src/contracts/mutation-intent.ts
|
||||
- src/application/ports/auth-session-port.ts
|
||||
- src/adapters/auth/external-session-adapter.ts
|
||||
- src/adapters/http/client.ts
|
||||
- src/adapters/http/http-execution-v3.ts to reuse the common key validator/context
|
||||
- src/adapters/http/bounded-json.ts to delegate to bounded-body-reader
|
||||
- tests/integration/http-client.test.ts
|
||||
- tests/integration/auth-recovery.test.ts
|
||||
- tests/integration/http-execution-contract.test.ts
|
||||
- tests/unit/bounded-body-reader.test.ts
|
||||
- docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md
|
||||
|
||||
Create:
|
||||
|
||||
- tests/unit/bounded-json-compatibility.test.ts
|
||||
|
||||
Delete immediately: none.
|
||||
Move: none.
|
||||
|
||||
Later removal after zero runtime callers and an expired rollback window:
|
||||
|
||||
- Delete src/adapters/http/client.ts
|
||||
- Delete src/adapters/http/bounded-json.ts
|
||||
- Delete src/adapters/http/request-builder.ts
|
||||
- Delete src/adapters/http/resource-mapper.ts
|
||||
- Delete src/adapters/http/schema-registry.ts
|
||||
- Remove createRuntimeHttpClient from src/bootstrap/runtime-adapters.ts
|
||||
- Remove V2-only tests/fixtures after V3 equivalents exist
|
||||
|
||||
Do not delete retry-policy.ts while V3 imports parseRetryAfter.
|
||||
|
||||
### Change set F — exact storage fallback admission
|
||||
|
||||
Modify:
|
||||
|
||||
- src/contracts/storage-keys.ts
|
||||
- src/adapters/cross-context-invalidation/browser-cross-context-host.ts
|
||||
- src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts
|
||||
- src/adapters/cross-context-invalidation/index.ts
|
||||
- tests/unit/cross-tab-invalidation.test.ts
|
||||
- tests/browser-capabilities/cross-context-invalidation.spec.ts
|
||||
- docs/architecture/client-cache-and-storage.md
|
||||
- docs/architecture/decisions/VD-13-client-cache-scope-and-persistence.md
|
||||
|
||||
Create:
|
||||
|
||||
- tests/unit/browser-cross-context-host.test.ts
|
||||
|
||||
Delete: none.
|
||||
Move: none.
|
||||
|
||||
Optional O-01 mirroring must be a separate change set and must not be bundled with the exact storageArea security check.
|
||||
|
||||
### Change set G — capacity and lifecycle documentation hardening
|
||||
|
||||
Modify:
|
||||
|
||||
- src/adapters/diagnostics/bounded-diagnostics.ts
|
||||
- src/adapters/telemetry/best-effort-telemetry.ts
|
||||
- tests/unit/diagnostics.test.ts
|
||||
- tests/unit/telemetry.test.ts
|
||||
- src/adapters/platform/browser-lifecycle.ts comment only, unless observable dirty state is selected
|
||||
|
||||
Create a browser-lifecycle unit test only if behavior changes.
|
||||
Delete: none.
|
||||
Move: none.
|
||||
|
||||
## 9. TDD matrix
|
||||
|
||||
Write each test red first.
|
||||
|
||||
| Test name | Input/setup | Expected result |
|
||||
| --- | --- | --- |
|
||||
| records_v3_terminal_outcome_with_allowlisted_context_once | V3 success and terminal 503; concrete diagnostics adapter | one record per logical execution; route/operation/status/attempt/duration are safe bucket keys; dropped map empty |
|
||||
| emits_v3_terminal_non_abort_failure_once | V3 retry exhaustion | one api.request.failed after final attempt, none per attempt |
|
||||
| does_not_emit_v3_failure_telemetry_for_caller_or_scope_abort | caller abort and scope fence | diagnostic once, telemetry zero |
|
||||
| forwards_reference_route_id_to_v3_observation | list/detail/create gateway requests | exact registry route IDs reach observation; no raw URL/intent |
|
||||
| rejects_unknown_auth_profile_during_runtime_composition | installed contract refers to missing profile | composition throws before any feature can execute |
|
||||
| rejects_bearer_ready_patch_without_authorization | authenticated session, empty READY patch | AUTH_INTEGRATION_FAILURE, NOT_STARTED, fetch zero |
|
||||
| rejects_credential_patch_that_owns_accept_content_type_or_credentials | hostile credential adapter | integration/contract failure, fetch zero |
|
||||
| sends_exact_profile_credentials_and_required_headers | valid bearer and cookie profiles | exact init.credentials and header subset |
|
||||
| forwards_lifetime_abort_to_external_credential_owner | hanging owner, deadline/caller abort | owner signal aborts; execution settles with correct closed error |
|
||||
| preserves_prior_maybe_applied_when_retry_is_fenced_before_dispatch | IDEMPOTENT command, first 429, scope false only at retry final invariant | SCOPE_FENCED and MAYBE_APPLIED; fetch called once |
|
||||
| never_decreases_logical_command_certainty_across_attempts | table of NOT_STARTED/NOT_APPLIED/MAYBE combinations | join follows lattice |
|
||||
| dispose_prevents_queued_and_future_telemetry_delivery | captured schedule, emit, dispose, callback, emit | fetch zero, queue zero |
|
||||
| dispose_aborts_active_telemetry_delivery_without_reschedule | fetch waits on signal, dispose | signal aborted, no reschedule |
|
||||
| concurrent_flush_joins_active_delivery | call flush twice while sink pending | both promises settle only after same fetch settles; fetch once |
|
||||
| runtime_dispose_disposes_telemetry_before_state_dependencies | spied telemetry/lifecycle | pagehide removed and sink aborted during composition dispose |
|
||||
| keeps_delimiter_ambiguous_validator_bindings_distinct | the A/B binding pair from N-05 | prepare(A)=etag A, prepare(B)=etag B |
|
||||
| rejects_unbounded_or_invalid_validator_definition_identity | empty/oversize/invalid fingerprint | install false, no row |
|
||||
| rejects_empty_control_and_oversize_legacy_idempotency_keys_before_send | "", whitespace, control, 257-byte key | VALIDATION_REJECTED/IDEMPOTENCY_KEY_INVALID; auth/fetch/timer zero |
|
||||
| reuses_one_valid_legacy_key_on_every_retry | keyed 503 then success | identical non-empty header on each physical attempt |
|
||||
| settles_legacy_hanging_credential_at_total_deadline | owner never resolves | REQUEST_TIMEOUT; fetch zero; listeners/timer removed |
|
||||
| ignores_late_legacy_recovery_completion | recovery resolves after abort/scope transition | terminal result unchanged; no replay/notification from late completion |
|
||||
| bounded_json_never_rejects_when_cancel_or_release_fails | hostile stream methods | closed legacy error, no rejection |
|
||||
| content_type_mismatch_cancels_legacy_response_body_once | non-JSON response with cancellable stream | CONTENT_TYPE_MISMATCH and cancel called once |
|
||||
| ignores_storage_event_from_non_local_storage_area | exact pulse key/value but OTHER_OR_UNKNOWN area | delivery zero |
|
||||
| accepts_storage_event_only_from_captured_local_storage | native-like event with captured identity | delivery once |
|
||||
| captures_local_storage_getter_once | getter returns different objects per access | getter called once; event/write identity coherent |
|
||||
| ignores_late_cursor_page_after_abort | loader aborts signal then resolves final page | REQUEST_ABORTED/PAGINATION_ABORTED |
|
||||
| settles_never_resolving_cursor_loader_on_abort | loader never settles | loadAll settles promptly with abort |
|
||||
| rejects_non_finite_adapter_capacities | NaN, Infinity, fractional, excessive values | TypeError at construction |
|
||||
|
||||
Targeted commands:
|
||||
|
||||
corepack pnpm exec vitest run \
|
||||
tests/unit/http-execution-v3.test.ts \
|
||||
tests/integration/http-execution-v3-observability.test.ts \
|
||||
tests/integration/http-execution-v3-auth-profile.test.ts \
|
||||
tests/features/reference-feature/reference-runtime-composition.test.ts
|
||||
|
||||
corepack pnpm exec vitest run \
|
||||
tests/unit/telemetry.test.ts \
|
||||
tests/unit/runtime-adapters.test.ts \
|
||||
tests/unit/diagnostics.test.ts
|
||||
|
||||
corepack pnpm exec vitest run \
|
||||
tests/unit/conditional-validator-store.test.ts \
|
||||
tests/unit/cursor-pagination-runtime.test.ts \
|
||||
tests/unit/cross-tab-invalidation.test.ts \
|
||||
tests/unit/browser-cross-context-host.test.ts
|
||||
|
||||
corepack pnpm exec vitest run \
|
||||
tests/integration/http-client.test.ts \
|
||||
tests/integration/auth-recovery.test.ts \
|
||||
tests/integration/http-execution-contract.test.ts \
|
||||
tests/unit/bounded-json-compatibility.test.ts
|
||||
|
||||
Browser evidence:
|
||||
|
||||
corepack pnpm test:browser-capabilities
|
||||
|
||||
Required final gates:
|
||||
|
||||
corepack pnpm check:types
|
||||
corepack pnpm lint
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm check:diagnostics
|
||||
corepack pnpm test:unit
|
||||
corepack pnpm test:integration
|
||||
corepack pnpm test:reference-feature
|
||||
|
||||
If storage registry changes, also run:
|
||||
|
||||
corepack pnpm check:registries
|
||||
corepack pnpm verify:compatibility
|
||||
|
||||
## 10. Compatibility and migration order
|
||||
|
||||
1. Land characterization tests only. They must fail for N-01 through N-05 and remain isolated from implementation.
|
||||
2. Add routeId to InstalledContractOperationExecutor and HttpExecutionContext. Update every compile-time call site in reference gateway/tests in one commit. This is source-breaking but has no wire change.
|
||||
3. Add typed V3 observation fields and runtime projection. Keep diagnostic/telemetry registries closed; use existing buckets. Deploy read operations first and verify non-empty, non-dropped V3 records.
|
||||
4. Add logical certainty accumulation. No wire/API change outside the exported outcome values; downstream code must already handle MAYBE_APPLIED.
|
||||
5. Extend RestAuthProfile with requiredCredentialHeaders and build the profile index at composition. Deploy fail-closed validation before changing credential owners.
|
||||
6. Extend auth cancellation context as optional. Update internal/demo/external adapters and both clients. After one compatibility release, make it required for external owners.
|
||||
7. Make demo authentication explicit. Do not relax REFERENCE_EXTERNAL_BEARER. Validate the selected demo behavior only against loopback/test provider evidence before enabling.
|
||||
8. Fix telemetry lifecycle and call dispose from infrastructure teardown. This changes only post-dispose behavior and flush-await semantics.
|
||||
9. Replace validator key codec before the first conditional HTTP composition. It is memory-only, so no data migration is required.
|
||||
10. Harden V2 idempotency/cancellation/body reading before documenting it as a rollback target. Add deprecation notices and audit createRuntimeHttpClient callers.
|
||||
11. Add storage registry entry and exact storageArea check without changing the invalidation wire envelope/version.
|
||||
12. Run native browser capability evidence. Decide mixed-transport mirroring separately.
|
||||
13. Only after zero V2 callers, V3/provider evidence, and expiration of the rollback window, delete legacy files.
|
||||
|
||||
Compatibility notes:
|
||||
|
||||
| Change | Compatibility |
|
||||
| --- | --- |
|
||||
| Required routeId | TypeScript source break; no network wire break. Update all executor callers atomically. |
|
||||
| Observation shape | Internal dependency seam but exported type; tests/custom composition must update. |
|
||||
| AUTH_INTEGRATION_FAILURE outcome | Exhaustive switch source break; add mapping to existing ApiFailure AUTH_INTEGRATION_FAILURE. |
|
||||
| RestAuthProfile required headers | Source break for custom profiles; provide migration error naming profile ID only. |
|
||||
| Optional AuthOperationContext phase | Backward compatible for owner implementation types; behavior improves immediately for updated owners. |
|
||||
| Telemetry dispose | Intentional behavioral change only after ownership ends. |
|
||||
| Validator key codec | No persisted state and no HTTP composition; safe replacement. |
|
||||
| StoragePulseEvent area enum | Test/host facade source break; wire envelope unchanged. |
|
||||
| Invalid legacy idempotency key | Intentional fail-fast behavior; callers relying on empty keys must be fixed, not grandfathered. |
|
||||
|
||||
## 11. Rollback sequence
|
||||
|
||||
Rollback must preserve security/correctness invariants.
|
||||
|
||||
1. Disable affected command operations first; do not route a command to legacy V2 unless V2 idempotency, auth deadline, final invariant and provider evidence are already fixed.
|
||||
2. If observability sink causes incidents, set telemetry config off or wire noOpTelemetry. Keep V3 diagnostic projection, redaction registries and producer tests.
|
||||
3. If strict auth composition rejects a bad deployment, fail the operation/provider as unavailable and repair the profile/owner. Do not restore broad credential headers or patch-owned credentials.
|
||||
4. Read-only V3 operations may fall back only to a hardened V2 path with unexpired provider/security evidence, matching VD-23 lines 700-703.
|
||||
5. The logical-effect accumulator must not be rolled back independently; downstream reconciliation depends on conservative MAYBE_APPLIED.
|
||||
6. Conditional-validator codec rollback is simply disabling conditional request composition and clearing the in-memory store.
|
||||
7. Storage-area hardening rollback should degrade to BroadcastChannel/local-only revalidation, not accept unverified storage events.
|
||||
8. Cross-context wire version remains unchanged, so no coordinated tab upgrade is needed.
|
||||
9. Roll back contract artifact, frontend and backend as one coherent set where operation/profile semantics changed.
|
||||
10. Keep new regression tests during rollback; change only routing/configuration.
|
||||
|
||||
## 12. Existing tests/docs cross-check and false-positive controls
|
||||
|
||||
### What the passing tests genuinely prove
|
||||
|
||||
- http-execution-v3.test.ts proves descriptor projection, keyed intent validation, one-key reuse, no query key, schema containment, post-dispatch command uncertainty, scope fencing after a response, credential-wait deadline, deadline retry suppression, retry-sleep cancellation and forbidden-body stream failure.
|
||||
- bounded-body-reader.test.ts has strong hostile stream/cancellation/release coverage; this is why consolidation is preferred.
|
||||
- server-state-scope-runtime.test.ts proves synchronous fencing, participant order, fail-closed reset/activation and identity close.
|
||||
- tanstack-cache-coordinator.test.ts proves topic mapping, lease deferral, reset ordering and disposal behavior under current contract.
|
||||
- cross-tab-invalidation.test.ts proves invalid/stale/self/duplicate/gap handling, bounded fallback and cleanup.
|
||||
- browser capability spec proves actual BroadcastChannel/BroadcastChannel and storage/storage delivery in supported browsers.
|
||||
- diagnostics.test.ts and telemetry.test.ts prove projector allowlists, bounded queues, hostile context containment and pagehide listener removal.
|
||||
- integration/http-diagnostics.test.ts proves exactly-once diagnostics/telemetry for legacy createHttpClient.
|
||||
- reference runtime composition proves V3 URLs/headers and that private intent values do not appear in collected evidence.
|
||||
- 21 selected files / 144 tests pass, so findings do not rely on a generally broken baseline.
|
||||
|
||||
### Why those tests do not invalidate the findings
|
||||
|
||||
- Legacy HTTP diagnostics tests import createHttpClient, not createContractHttpExecutor. They cannot validate V3 runtime-adapters projection.
|
||||
- reference runtime composition only asserts private values are absent; an empty diagnostics array also satisfies it.
|
||||
- check:diagnostics counts/inspects source producers but does not execute their concrete context through projectDiagnosticRecord.
|
||||
- auth-session tests validate the external owner’s current header allowlist, but V3’s exported CredentialPatchOutcome and final invariant still grant broader authority; they also do not require Authorization for the declared bearer profile.
|
||||
- current V3 command is KEYED with retryBudget zero. It does not exercise an IDEMPOTENT retry followed by a final-invariant fence.
|
||||
- telemetry’s disposal test calls dispose after pagehide has already flushed and checks only listener removal.
|
||||
- conditional-validator tests use delimiter-unambiguous values and docs explicitly mark the sidecar not composed.
|
||||
- storage tests check exact key and envelope but the facade has no storageArea field to assert.
|
||||
- browser docs explicitly list pulse registration and storageArea as unfinished, confirming N-09 rather than contradicting it.
|
||||
- mixed-transport asymmetry is left as O-01 because the documented priority fallback can reasonably be read as a deliberate single-transport policy.
|
||||
- beforeunload does not prompt falsely because the event callback rechecks dirty state; only the attachment comment is mismatched.
|
||||
- no claim is made that conditional validators or cursor pagination currently corrupt the installed reference HTTP path; both are activation blockers for future composition.
|
||||
|
||||
## 13. Design worth preserving
|
||||
|
||||
- V3 keeps operation semantics in installed descriptors and re-verifies a bounded final request rather than accepting arbitrary URLs/headers from features.
|
||||
- Bounded response admission avoids Response.json, enforces byte ceilings, uses strict UTF-8, and isolates stream cleanup failures.
|
||||
- Mutation intent and command effect are explicit public concepts; post-dispatch uncertainty is represented instead of guessed from HTTP status.
|
||||
- Server-state scope fences synchronously before any reset await, aborts the old signal, closes identity registries and creates a new QueryClient generation.
|
||||
- Query invalidation sends only registry topic/version/epoch, never query keys, cached data, account IDs or mutation payloads. Remote authority is invalidate-only.
|
||||
- Cross-context event parsing is closed and bounded with TTL, event dedupe, source epoch/sequence and gap escalation.
|
||||
- TanStack retry is disabled so the HTTP layer remains the single retry authority.
|
||||
- Diagnostics/telemetry have closed registries, low-cardinality value policies, redaction and failure isolation.
|
||||
- External auth owner never returns raw tokens to application code; it returns a constrained header patch.
|
||||
- Composition tears down optional capabilities before base state, which is the right dependency order.
|
||||
- No unnecessary persistence/offline mutation queue/exactly-once protocol is claimed.
|
||||
|
||||
## 14. Recommended delivery order
|
||||
|
||||
P0:
|
||||
|
||||
1. N-01 V3 observability.
|
||||
2. N-02 profile-authoritative auth.
|
||||
3. N-03 monotonic command certainty.
|
||||
4. N-04 telemetry terminal lifecycle.
|
||||
|
||||
P1 before enabling currently available capabilities or trusting rollback:
|
||||
|
||||
5. N-05 conditional-validator key codec.
|
||||
6. N-06/N-07 V2 replay and auth deadline.
|
||||
7. N-09 exact storageArea and registered pulse.
|
||||
8. N-10 pagination cancellation.
|
||||
|
||||
P2 cleanup:
|
||||
|
||||
9. N-08 body-reader consolidation.
|
||||
10. N-11 capacity validation.
|
||||
11. O-02 documentation alignment.
|
||||
12. Decide O-01/O-03/O-04/O-05 with explicit product requirements and characterization tests.
|
||||
|
||||
This ordering closes silent current-path failures and security/effect authority first, then makes latent capabilities safe to compose, and only then removes duplication.
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# Realtime / Browser RPC adapter 구현 리뷰
|
||||
|
||||
- 리뷰 기준: `4dc033c` (2026-08-13, Asia/Seoul)
|
||||
- 구현 범위: `src/adapters/realtime/**`, `src/adapters/browser-rpc/**`
|
||||
- 추적 범위: 대응 contracts, application ports, bootstrap 조립, unit/boundary tests, architecture docs
|
||||
- 방식: 코드 리뷰만 수행했다. 이 문서 외 구현 파일은 수정하지 않았다.
|
||||
- 결론: **Critical 0, High 4, Medium 3**이다. R-01~R-06은 코드상 확정된 lifecycle/immutability/resource 문제이고, R-07은 문서에도 미완료라고 명시된 production promotion blocker다. 두 runtime 모두 현재 `AVAILABLE_NOT_COMPOSED`이므로 production traffic 사고로 과장하지 않는다.
|
||||
|
||||
## 1. 21/21 파일 inventory와 책임
|
||||
|
||||
아래 경로는 모두 저장소 루트 기준 full path이며, 범위의 구현 파일 21개를 모두 읽었다.
|
||||
|
||||
| # | full path | 책임 | 주요 의존성 / downstream | 판정 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `src/adapters/browser-rpc/browser-rpc-runtime.ts` | operation을 unary/server-stream application port로 bind하고 request schema/encoder, deadline/retry, transport, response schema/mapper, generation fence를 순서대로 집행 | `application/ports/browser-rpc`, `ClockPort`, Browser RPC contract, schema/mapper registry, `transport.ts`, `AppFailure` | R-01, R-04, R-06, R-07 |
|
||||
| 2 | `src/adapters/browser-rpc/index.ts` | Browser RPC public adapter export surface | runtime, transport, unavailable adapter | 새 lease/install type export 필요 |
|
||||
| 3 | `src/adapters/browser-rpc/transport.ts` | provider-neutral unary/stream transport result와 runtime identity 계약 | `src/contracts/browser-rpc.ts` | R-01, R-07 |
|
||||
| 4 | `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` | 선택되지 않은 runtime의 명시적 fail-closed Null Object | `transport.ts` | 유지; 새 stream lease shape만 맞춤 |
|
||||
| 5 | `src/adapters/realtime/event-codec.ts` | raw JSON byte/shape/registry/schema 검증, immutable DTO와 semantic fingerprint 생성 | realtime contracts, schema registry, JSON scanner, result codec | 유지 |
|
||||
| 6 | `src/adapters/realtime/event-consumer.ts` | SSE/WS cursor 규칙을 codec 결과와 결합하고 common stream coordinator outcome으로 투영 | realtime ports/contracts, event codec, stream coordinator | 유지 |
|
||||
| 7 | `src/adapters/realtime/index.ts` | common realtime adapter public export surface | codec, consumer, reconnect, handoff, stream, sub-index | R-02/R-03 lifecycle type export 필요 |
|
||||
| 8 | `src/adapters/realtime/json-member-scanner.ts` | `JSON.parse` 전 duplicate member와 structure budget을 비재귀적으로 검사 | 독립 utility | 유지 |
|
||||
| 9 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | LIVE/POLL 단일 effect writer, generation fence, quiescence/checkpoint, probe buffer와 전환 | `ClockPort`, realtime result/ports | R-03 |
|
||||
| 10 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | visible/online finite lease, single-flight poll, retry hint, response/apply deadline, non-cooperative task drain | bounded polling policy, `ClockPort`, realtime result | 유지 |
|
||||
| 11 | `src/adapters/realtime/polling/index.ts` | polling public exports | bounded poll coordinator | 유지 |
|
||||
| 12 | `src/adapters/realtime/reconnect-coordinator.ts` | 단일 reconnect owner, online gate, retry budget, session close authority, exact recovery proof, post-abort DRAINING | reconnect policy, `ClockPort`, realtime ports/result | 유지 |
|
||||
| 13 | `src/adapters/realtime/reconnect-policy.ts` | immutable reconnect policy, full jitter, elapsed budget, Retry-After 계산/검증 | 독립 policy | 유지 |
|
||||
| 14 | `src/adapters/realtime/result.ts` | hostile/mutable collaborator result를 exact own-data snapshot으로 canonicalize | realtime ports/contracts | 유지; R-04의 기준 패턴 |
|
||||
| 15 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | fixed same-origin fetch-stream SSE, response/media/open gate, read/event deadline, cursor rule, bounded reader cancel | `ClockPort`, event authority/result, parser, reconnect policy | 유지; R-02 common drain과 함께 검증 |
|
||||
| 16 | `src/adapters/realtime/sse/index.ts` | SSE public exports | fetch connection, parser | 유지 |
|
||||
| 17 | `src/adapters/realtime/sse/sse-parser.ts` | strict incremental UTF-8 SSE parser, BOM/line ending/id/retry/event buffer ceiling | realtime contracts/result | 유지 |
|
||||
| 18 | `src/adapters/realtime/stream-coordinator.ts` | per-stream sequential effect, dedupe/order, recovery, checkpoint/barrier, scope generation fence | event authority port, realtime contracts, mapper registry, codec/result | R-02 |
|
||||
| 19 | `src/adapters/realtime/websocket/index.ts` | WebSocket connection/protocol public exports | connection, protocol | 유지 |
|
||||
| 20 | `src/adapters/realtime/websocket/websocket-connection.ts` | 한 physical WS의 handshake/subscription/tombstone/FIFO/heartbeat/apply gate/recovery close | `ClockPort`, realtime contracts/result, WS protocol | 유지; R-02 upstream timeout과 함께 검증 |
|
||||
| 21 | `src/adapters/realtime/websocket/websocket-protocol.ts` | exact closed JSON frame decode/encode, duplicate key/structure/sequence/frame byte 검증 | realtime contracts, JSON scanner | R-05 |
|
||||
|
||||
## 2. 추적한 contracts, ports, bootstrap, tests, docs
|
||||
|
||||
| 계층 | 읽은 파일과 근거 | 대조 결과 |
|
||||
|---|---|---|
|
||||
| Realtime contracts | `src/contracts/realtime-streams.ts`, `src/contracts/realtime-events.ts` | registry가 stream/event/recovery/queue ceiling을 닫고 cursor/sequence/scope 문법을 소유한다. adapter가 이를 우회하지 않는다. |
|
||||
| Realtime ports | `src/application/ports/realtime/shared.ts:1-66`, `src/application/ports/realtime/event-authority.ts:15-202`, `src/application/ports/realtime/index.ts:1-31` | native error/payload/cursor 없는 closed result, exact recovery checkpoint identity, effect/recovery commit authority를 확인했다. R-02 lifecycle inspection 확장이 필요하다. |
|
||||
| Browser RPC contract | `src/contracts/browser-rpc.ts:66-179,256-469,472-591` | wire/profile/operation join과 hard limit은 풍부하지만 validate-only mutable binding이다(R-04). `maxBufferedBytes`는 선언/검증만 된다(R-07). |
|
||||
| Browser RPC port | `src/application/ports/browser-rpc/browser-rpc.ts:4-35`, `src/application/ports/browser-rpc/index.ts` | application에는 typed unary/stream Result만 보이고 generated type/frame/endpoint는 노출되지 않는다. 변경 불필요. |
|
||||
| Clock / Result | `src/application/ports/clock-port.ts`, `src/adapters/platform/system-clock.ts`, `src/application/result.ts`, `src/contracts/errors.ts` | injected clock/fence failure도 port Result 의미로 닫아야 한다(R-06). |
|
||||
| Bootstrap | `src/bootstrap/optional-runtime-host.ts:21-29,65-70,87-92,142-150` | `realtime: null`, health `UNAVAILABLE`, 제품 contribution 전 미조립은 의도다. Browser RPC 조립도 없다. 미조립 자체는 결함이 아니다. |
|
||||
| Boundary gates | `scripts/check-realtime-boundaries.ts`, `scripts/lib/realtime-boundaries.ts`, `scripts/check-realtime-boundary-fixtures.ts`, `scripts/test-realtime-runtime-removal.ts`; `tests/fixtures/realtime-boundaries/allowed/**`, `forbidden/**` | native realtime API 소유권과 unselected composition을 정적 검사한다. Browser RPC에는 아직 같은 별도 boundary gate가 없다. |
|
||||
| Realtime docs | `docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md`, `docs/architecture/realtime-events-web-push-and-bounded-polling.md`, `docs/architecture/optional-adapter-recipes.md` | fixed endpoint, exact barrier, single writer, overflow fail-close, bounded cleanup/DRAINING, 미조립 상태를 코드와 대조했다. |
|
||||
| Browser RPC docs | `docs/architecture/protobuf-browser-transport-and-rest-gateway.md`, `docs/architecture/decisions/VD-27-grpc-web-unary-and-server-stream.md`, `docs/architecture/decisions/VD-29-connect-web-and-browser-protobuf-runtime.md` | common lifecycle만 구현됐고 concrete framing/raw-byte/provider/browser conformance는 pending이라고 명시한다. |
|
||||
|
||||
대조한 16개 테스트 파일:
|
||||
|
||||
- `tests/unit/browser-rpc/browser-rpc-contract.test.ts`
|
||||
- `tests/unit/browser-rpc/browser-rpc-runtime.test.ts`
|
||||
- `tests/unit/realtime/bounded-poll-coordinator.test.ts`
|
||||
- `tests/unit/realtime/bounded-polling-policy.test.ts`
|
||||
- `tests/unit/realtime/event-codec.test.ts`
|
||||
- `tests/unit/realtime/event-consumer.test.ts`
|
||||
- `tests/unit/realtime/fetch-sse-connection.test.ts`
|
||||
- `tests/unit/realtime/live-poll-handoff-coordinator.test.ts`
|
||||
- `tests/unit/realtime/realtime-reconnect-coordinator.test.ts`
|
||||
- `tests/unit/realtime/realtime-reconnect-policy.test.ts`
|
||||
- `tests/unit/realtime/realtime-stream-registry.test.ts`
|
||||
- `tests/unit/realtime/result.test.ts`
|
||||
- `tests/unit/realtime/sse-parser.test.ts`
|
||||
- `tests/unit/realtime/stream-coordinator.test.ts`
|
||||
- `tests/unit/realtime/websocket-connection.test.ts`
|
||||
- `tests/unit/realtime/websocket-protocol.test.ts`
|
||||
|
||||
## 3. 분류
|
||||
|
||||
### 확정 결함
|
||||
|
||||
| ID | 심각도 | 확신도 | 요약 |
|
||||
|---|---|---|---|
|
||||
| R-01 | High | High | Browser RPC server-stream 종료가 non-cooperative iterator에서 무기한 멈춘다. |
|
||||
| R-02 | High | High | common stream coordinator가 non-cooperative effect/recovery 하나로 영구 wedge된다. |
|
||||
| R-03 | High | High | LIVE↔POLL overflow fail-close가 active lease를 잃어 이후 close가 거짓 성공한다. |
|
||||
| R-04 | High | High | Browser RPC bindings는 validate-then-use TOCTOU이며 exact immutable install이 아니다. |
|
||||
| R-05 | Medium | High | WS frame byte ceiling 전에 입력 전체 UTF-8 copy를 추가 할당한다. |
|
||||
| R-06 | Medium | High | Browser RPC clock/fence 예외가 Result 경계를 탈출하고 cleanup을 건너뛴다. |
|
||||
|
||||
### 미조립 단계 promotion blocker / 선택 개선
|
||||
|
||||
| ID | 심각도 | 확신도 | 요약 |
|
||||
|---|---|---|---|
|
||||
| R-07 | Medium, promotion blocker | High | `maxBufferedBytes`의 concrete transport 집행 및 provider/browser conformance가 아직 없다. 문서에도 pending으로 명시되어 현재 common runtime bug로 세지 않는다. |
|
||||
|
||||
## 4. 확정 결함 상세
|
||||
|
||||
### R-01 — Browser RPC server-stream 종료가 non-cooperative iterator에서 무기한 멈춘다
|
||||
|
||||
- 심각도: **High**
|
||||
- 확신도: **High**
|
||||
- 근거:
|
||||
- `src/adapters/browser-rpc/transport.ts:53-61`은 stream을 `AsyncIterable` 하나로 표현한다. 명시적 `cancel`/`waitClosed`/cleanup bound가 없다.
|
||||
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:488-512`는 `iterator.next()`를 deadline과 race하지만, timeout 뒤 원래 `next()` task는 남을 수 있다.
|
||||
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:630-640`은 `finally`에서 `await iterator.return()`을 deadline 없이 기다린다. pending `next()`가 signal을 무시하면 async generator의 queued `return()`도 완료되지 않는다.
|
||||
- 영향: caller abort, idle/total timeout, response limit, consumer `break` 뒤 application iterator completion이 무기한 pending이다. 외부 abort listener 수명도 `finally` 완료 전까지 닫히지 않는다. timeout Result를 선택했어도 iterator가 끝나지 않아 total deadline 의미가 깨진다.
|
||||
- 기존 증거와 gap: `tests/unit/browser-rpc/browser-rpc-runtime.test.ts:343-369`은 cooperative generator가 abort를 보고 `finally`로 끝나는 경우만 확인한다. `docs/architecture/protobuf-browser-transport-and-rest-gateway.md:381-399`는 reader cancel/release, bounded consumer queue, terminal envelope, EOF non-success를 요구한다.
|
||||
- 적용 패턴: **Explicit Stream Lease + structured concurrency + retained DRAINING task**. 암묵적인 `AsyncIterable.return()`에 transport lifecycle authority를 숨기지 않는다.
|
||||
- 결정:
|
||||
1. app-facing generator는 idle/total/limit/caller abort 후 cleanup bound 안에 끝난다.
|
||||
2. commit/admission generation은 즉시 fence한다.
|
||||
3. underlying task가 bound 안에 끝나지 않으면 transport lease는 `DRAINING`에 남고 실제 `waitClosed()` settlement까지 추적한다.
|
||||
4. `return()`/`waitClosed()` rejection은 이미 선택한 application failure를 덮지 않는다.
|
||||
|
||||
### R-02 — common stream coordinator가 non-cooperative authority 하나로 영구 wedge된다
|
||||
|
||||
- 심각도: **High**
|
||||
- 확신도: **High**
|
||||
- 근거:
|
||||
- `src/adapters/realtime/stream-coordinator.ts:231-247`은 event를 `state.tail`에 직렬 연결한다.
|
||||
- `src/adapters/realtime/stream-coordinator.ts:373-408`은 effect authority를 직접 `await`한다. AbortSignal을 무시하는 Promise에 deadline/drain state가 없다.
|
||||
- recovery는 `src/adapters/realtime/stream-coordinator.ts:497-529`에서 기존 tail을 기다리고 `:543-560`에서 recovery authority를 다시 무기한 기다린다.
|
||||
- `close():809-834`는 controller만 abort하고 즉시 `void`로 끝나 실제 settlement/DRAINING을 나타내지 않는다.
|
||||
- 영향: WS `maxApplyMs`(`websocket-connection.ts:1060-1080`)나 SSE event timeout(`fetch-sse-connection.ts:321-355`)은 transport caller만 끝낸다. common tail은 pending이라 새 generation event와 queue-overflow recovery까지 영구 대기한다. generation fence는 late commit을 막지만 liveness/resource convergence는 보장하지 않는다.
|
||||
- 기존 증거와 gap:
|
||||
- `tests/unit/realtime/stream-coordinator.test.ts:383-466`의 in-flight effect는 결국 resolve되고 `:791-821`의 non-cooperative recovery도 테스트 끝에서 settle한다. never-settling authority와 bounded close는 없다.
|
||||
- `VD-28...md:218-224,250-256,437-446`은 terminal/idempotent close와 bound를 넘긴 task가 실제 settle할 때까지 `DRAINING`을 유지하도록 정한다.
|
||||
- 적용 패턴: **per-stream State Machine + Task Lease Registry + generation capability**.
|
||||
- 결정:
|
||||
1. freshness와 별도로 lifecycle `OPEN | DRAINING | CLOSED`를 둔다.
|
||||
2. effect/recovery deadline에 commit capability를 영구 false로 만들고 abort한다.
|
||||
3. caller에는 `IDLE_TIMEOUT` (`operation: APPLY | RECOVER`, non-retryable)을 bounded하게 반환하고 실제 task는 retain한다.
|
||||
4. DRAINING 중 새 event/recovery를 허용하지 않는다. actual settle 뒤 `STALE`에서 authoritative recovery를 요구하거나 close 요청이면 `CLOSED`로 간다.
|
||||
5. `close()`는 `Promise<RealtimeResult<void>>`로 bounded quiescence 결과를 반환한다.
|
||||
|
||||
### R-03 — LIVE↔POLL overflow 뒤 active writer reference를 잃는다
|
||||
|
||||
- 심각도: **High**
|
||||
- 확신도: **High**
|
||||
- 근거:
|
||||
- `src/adapters/realtime/live-poll-handoff-coordinator.ts:274-286`은 active tail overflow 시 `failClosed()`를 호출한다.
|
||||
- `failClosed():774-788`은 controller를 abort한 뒤 `active = null`로 지우지만 해당 lease/tail을 retired set에 보존하지 않는다.
|
||||
- `performClose():672-700`은 현재 active/probe/quiescing/transitionCandidate만 모으므로 이미 버린 non-cooperative active writer를 기다리지 않고 success할 수 있다.
|
||||
- 영향: 256건/4MiB overflow로 generation 전체를 닫았지만 effect는 계속 실행 중이고 lifecycle owner가 추적하지 않는다. teardown success가 quiescence를 뜻하지 않아 새 runtime과 old task가 겹칠 수 있다. `isCurrent()`는 commit만 fence한다.
|
||||
- 기존 증거와 gap:
|
||||
- `tests/unit/realtime/live-poll-handoff-coordinator.test.ts:166-215`는 non-cooperative overflow를 만들지만 이후 `close()`를 호출하지 않는다.
|
||||
- `:466-493`의 close test는 reference를 잃기 전 active writer만 다룬다.
|
||||
- ADR `VD-28...md:422-427,443-446`은 overflow full-generation fail-close와 actual settlement까지 DRAINING을 요구한다.
|
||||
- 적용 패턴: **Retired Lease Registry + two-phase close**.
|
||||
- 결정: `failClosed()`는 모든 lease를 abort하고 `retiredWriters`에 옮겨 admission을 닫는다. `close()`는 current+retired를 dedupe해 bounded하게 기다리고, timeout에는 `IDLE_TIMEOUT/CLOSE`를 반환하되 마지막 tail settlement까지 DRAINING을 유지한다.
|
||||
|
||||
### R-04 — Browser RPC bindings가 validate-then-use TOCTOU이다
|
||||
|
||||
- 심각도: **High**
|
||||
- 확신도: **High**
|
||||
- 근거:
|
||||
- `src/contracts/browser-rpc.ts:256-285`의 `define*`는 shallow spread/freeze만 하고 exact own key/data descriptor를 검사하지 않는다. extra/accessor property가 남는다.
|
||||
- `validateBrowserRpcContractBindings():330-469`은 원본 registry/row를 읽어 `true`만 반환하며 installed snapshot을 만들지 않는다.
|
||||
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:102-128`은 factory에서 검증한 뒤 `bind()` 때 원본 `dependencies.*`를 다시 읽는다.
|
||||
- `src/adapters/browser-rpc/browser-rpc-runtime.ts:644-687`도 validation용 runtime identity만 복사하며 operations/profiles/schema/mappers/encoders/transports 원본을 계속 사용한다.
|
||||
- 영향: TypeScript `Readonly`는 runtime 보호가 아니다. factory 이후 mutation으로 replay policy, attempt/deadline, byte ceiling, mapper/transport selection을 validation과 다르게 만들 수 있다. operation/profile 객체의 extra property도 transport가 해석할 수 있다.
|
||||
- 기존 증거와 gap: `tests/unit/browser-rpc/browser-rpc-contract.test.ts:173-202`는 raw invalid row를 재검증하지만 검증 후 mutation, accessor non-invocation, extra/symbol key 거절은 없다. realtime `result.test.ts:15-151`과 reconnect policy tests에는 exact descriptor snapshot 패턴이 이미 있다.
|
||||
- 적용 패턴: **Parse/Validate/Install anti-corruption layer + immutable exact registry snapshot**.
|
||||
- 결정:
|
||||
1. factory 시작 시 registry own descriptors를 한 번 캡처하고 null-prototype exact map으로 복사/freeze한다.
|
||||
2. operation/profile/encoder/schema/mapper/transport row를 허용 key의 own data property로 snapshot한다. getter, extra, symbol, revoked proxy는 composition-time `TypeError`다.
|
||||
3. runtime과 transport call은 installed snapshot만 사용한다.
|
||||
4. parse/map/encode/invoke function identity는 snapshot하되 row/registry를 재독하지 않는다.
|
||||
|
||||
### R-05 — WS byte cap 전에 전체 UTF-8 copy를 할당한다
|
||||
|
||||
- 심각도: **Medium**
|
||||
- 확신도: **High**
|
||||
- 근거: `src/adapters/realtime/websocket/websocket-protocol.ts:215-228`은 먼저 `utf8ByteLength(input)`을 호출하고 `:469-470`은 `new TextEncoder().encode(input)`으로 전체 크기의 두 번째 buffer를 만든다.
|
||||
- 영향: hostile/buggy server가 큰 text frame을 보냈을 때 negotiated cap으로 즉시 거절하지 못하고 cap 확인 전에 전체 UTF-8 copy를 추가 할당한다. browser가 원본 string을 materialize했다는 사실과 adapter의 추가 peak allocation은 별개다.
|
||||
- 기존 증거와 gap: `tests/unit/realtime/websocket-protocol.test.ts:130-166`은 결과 코드와 multibyte bytes는 확인하지만 pre-allocation reject는 확인하지 않는다. `event-codec.ts:102-115`는 `raw.length > maxBytes` 선검사를 이미 사용한다.
|
||||
- 적용 패턴: **admission before allocation + bounded incremental accounting**.
|
||||
- 결정: `input.length > maxFrameBytes`를 먼저 거절한다. 남은 입력은 allocation 없는 code-point loop로 UTF-8 bytes를 누적해 초과 즉시 중단하며 lone surrogate는 `TextEncoder`와 동일하게 replacement 3 bytes로 센다.
|
||||
|
||||
### R-06 — Browser RPC collaborator exception이 Result 경계를 탈출한다
|
||||
|
||||
- 심각도: **Medium**
|
||||
- 확신도: **High**
|
||||
- 근거:
|
||||
- unary `src/adapters/browser-rpc/browser-rpc-runtime.ts:188-191,219-221,307-323`은 `clock.now()`를 safe wrapper 없이 호출한다.
|
||||
- `mapResponse():778-786,835-845`의 `generationFence.isCurrent()`와 `clock.now()`도 throw를 잡지 않는다.
|
||||
- `raceWithin():1094-1120`은 abort listener를 붙인 뒤 `clock.sleep()` synchronous throw 또는 race 예외를 감싸는 `finally`가 없다.
|
||||
- unary 전체에 outer `try/finally`가 없어 `linked.cleanup():198`은 정상 `finish()` 경로에서만 보장된다.
|
||||
- 영향: application port가 `Promise<Result<...>>`/`AsyncIterable<Result<...>>` 대신 native rejection을 노출한다. clock/scope owner 실패 시 listener/timer cleanup과 observation도 빠질 수 있다.
|
||||
- 기존 증거와 gap: standard `systemClock`, 정상 fence, generation change는 테스트하지만 throwing clock/fence와 listener balance는 없다. bounded poll/reconnect는 `safeNow`, `safeIsCurrent`, `finally` cleanup을 이미 사용한다.
|
||||
- 적용 패턴: **Result boundary guard + RAII-style finally**.
|
||||
- 결정: clock failure는 `SERVER_FAILURE/RPC_RUNTIME_DEPENDENCY_FAILED`, capture/isCurrent 실패는 fail-closed `SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`로 canonicalize한다. linked listener/timer는 단일 outer `finally`에서 정확히 한 번 해제한다.
|
||||
|
||||
## 5. 미조립/promotion blocker
|
||||
|
||||
### R-07 — `maxBufferedBytes` 집행 증거가 없다
|
||||
|
||||
- 심각도: **Medium, production promotion blocker**
|
||||
- 확신도: **High**
|
||||
- 확정 사실:
|
||||
- `src/contracts/browser-rpc.ts:114-120,519-528`은 `maxBufferedBytes`를 선언/검증한다.
|
||||
- common runtime은 `src/adapters/browser-rpc/browser-rpc-runtime.ts:587-593`에서 yielded message count/per-message/aggregate만 센다.
|
||||
- `src/adapters/browser-rpc/transport.ts:58-60`의 bare `AsyncIterable`에는 buffer admission/inspection contract가 없다.
|
||||
- 문서로 확인한 현재 상태: `docs/architecture/protobuf-browser-transport-and-rest-gateway.md:42-61,363-366,381-399`는 selected transport/raw-byte cap/provider-browser conformance가 pending이라고 명시한다. 따라서 common runtime이 wire framing/internal buffer를 직접 집행하지 않는 것 자체는 현재 결함이 아니다.
|
||||
- promotion 위험: callback/stock client가 consumer보다 빨리 frame을 쌓으면 common runtime이 item을 받기 전에 heap cap이 깨질 수 있다. `maxTotalResponseBytes`는 aggregate이고 `maxBufferedBytes`와 다른 backpressure 축이다.
|
||||
- 적용 패턴: **transport conformance contract + enqueue-time backpressure admission**.
|
||||
- 결정: concrete Connect/gRPC-Web transport가 enqueue 전에 `operation.maxBufferedBytes`, raw/decompressed ceiling을 집행하고 overflow 시 lease cancel + `RESPONSE_BODY_LIMIT`을 낸다는 conformance suite를 통과하기 전 bootstrap/product traffic을 금지한다. common runtime의 message/aggregate guard는 second line으로 유지한다.
|
||||
|
||||
## 6. 상태머신, protocol, framing, backpressure와 cleanup 결정
|
||||
|
||||
| 축 | 명시 결정 | 이유 |
|
||||
|---|---|---|
|
||||
| Common stream state | freshness `UNKNOWN/CURRENT/STALE/RESYNCING`와 lifecycle `OPEN/DRAINING/CLOSED`를 직교 축으로 둔다. timeout/abort 뒤 actual task가 남으면 DRAINING이다. | commit fence와 resource settlement는 다른 사실이다(R-02). |
|
||||
| Reconnect | 기존 `IDLE/RUNNING/DRAINING/CLOSED`, 단일 retry owner, full jitter, exact bounded server hint, exact branded recovery proof를 유지한다. offline에는 retry timer를 두지 않고 protocol 자동 downgrade를 금지한다. | 구현/ADR/test가 일치한다. |
|
||||
| Poll lease | 기존 single-flight `IDLE/RUNNING/DRAINING/CLOSED`, visible+online finite lease, one-request HTTP retry owner를 유지한다. | non-cooperative execute/apply를 이미 fence+track한다. |
|
||||
| LIVE↔POLL handoff | Poll은 probe 동안 유일 authoritative writer다. old writer fence→abort→quiesce→checkpoint→buffer drain 뒤 LIVE를 활성화한다. overflow는 generation terminal이며 retired lease actual settlement까지 DRAINING이다. | silent overlap/lost update 방지(R-03). |
|
||||
| SSE framing | strict UTF-8, blank-line terminated SSE, incomplete EOF discard, CURSOR일 때만 explicit `id`, exact status/media/same-origin 규칙을 유지한다. | tests/docs와 일치한다. |
|
||||
| WS framing | text JSON + exact frame keys + duplicate-member/structure/uint64 검증을 유지한다. byte cap은 allocation 전에 집행한다. malformed/overflow는 whole generation close + snapshot recovery다. | classic WS에는 receive pause가 없고 delta drop은 안전하지 않다. |
|
||||
| Browser RPC framing | common runtime은 logical message/terminal/failure만 받는다. Connect 5-byte envelope/EndStream과 gRPC-Web trailer authority는 concrete transport가 각각 소유하며 서로 추론/혼합하지 않는다. EOF alone은 success가 아니다. | provider-neutral layer와 wire semantics를 분리한다. |
|
||||
| Backpressure | WS inbound/outbound와 `bufferedAmount`, handoff queues, Browser RPC transport buffer를 count+bytes로 admission한다. cap 초과는 silent drop/자동 상향 없이 terminal close/failure다. | state-bearing delta의 부분 유실은 복구 없이는 안전하지 않다. |
|
||||
| Cancel/timer/listener | listener를 얻은 scope의 `finally`에서 제거하고 모든 sleep timer controller를 abort한다. non-cooperative task의 caller wait만 bounded하고 reference는 actual settlement까지 retain한다. | bounded response와 resource convergence를 함께 만족한다. |
|
||||
| Error semantics | `QUEUE_OVERFLOW`=admission/backpressure와 recovery 필요, `IDLE_TIMEOUT`=handler/quiescence cleanup deadline, `APPLY_FAILED`=authority reject/throw/invalid result, `PROVIDER_UNAVAILABLE`=clock/host dependency 실패, `PROTOCOL_MISMATCH`=shape/framing 위반, `SCOPE_FENCED`=old generation. raw/native 원인은 노출하지 않는다. | retry/rollback/운영 대응을 원인별로 닫는다. |
|
||||
|
||||
## 7. 제안 인터페이스와 정확한 파일 작업
|
||||
|
||||
### 7.1 새/변경 interface signature
|
||||
|
||||
```ts
|
||||
// src/adapters/browser-rpc/transport.ts
|
||||
export type BrowserRpcStreamCancelReason =
|
||||
| "CALLER_ABORT"
|
||||
| "IDLE_TIMEOUT"
|
||||
| "TOTAL_DEADLINE"
|
||||
| "LIMIT_EXCEEDED"
|
||||
| "CONTRACT_FAILURE"
|
||||
| "CONSUMER_CLOSED";
|
||||
|
||||
export type BrowserRpcTransportStream = Readonly<{
|
||||
frames: AsyncIterable<BrowserRpcStreamFrame>;
|
||||
cancel(reason: BrowserRpcStreamCancelReason): void;
|
||||
waitClosed(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity & Readonly<{
|
||||
invokeUnary?(call: BrowserRpcTransportCall): Promise<BrowserRpcUnaryTransportResult>;
|
||||
openServerStream?(call: BrowserRpcTransportCall): BrowserRpcTransportStream;
|
||||
}>;
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/contracts/browser-rpc.ts
|
||||
export type InstalledBrowserRpcContractBindings = Readonly<{
|
||||
operations: Readonly<Record<string, BrowserRpcOperationV3>>;
|
||||
profiles: Readonly<Record<string, BrowserRpcProviderProfile>>;
|
||||
schemaCodecs: Readonly<Record<string, RuntimeSchemaCodec>>;
|
||||
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
|
||||
requestEncoders: Readonly<Record<string, BrowserRpcRequestEncoder>>;
|
||||
runtimeBindings: Readonly<Record<string, BrowserRpcRuntimeBindingIdentity>>;
|
||||
}>;
|
||||
|
||||
export function installBrowserRpcContractBindings(
|
||||
bindings: BrowserRpcContractBindings,
|
||||
): InstalledBrowserRpcContractBindings;
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/adapters/browser-rpc/browser-rpc-runtime.ts
|
||||
export type BrowserRpcRuntimeDependencies = Readonly<{
|
||||
// existing registries/collaborators stay
|
||||
streamCleanupTimeoutMs?: number; // default 2_000, implementation max 30_000
|
||||
}>;
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/application/ports/realtime/event-authority.ts
|
||||
export type RealtimeStreamLifecycle = "OPEN" | "DRAINING" | "CLOSED";
|
||||
|
||||
export type RealtimeStreamInspection = Readonly<{
|
||||
lifecycle: RealtimeStreamLifecycle;
|
||||
// existing freshness/queue/dedupe/barrier fields unchanged
|
||||
}>;
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/adapters/realtime/stream-coordinator.ts
|
||||
export type RealtimeStreamTaskLimits = Readonly<{
|
||||
effectTimeoutMs: number;
|
||||
recoveryTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
}>;
|
||||
|
||||
export type RealtimeStreamCoordinatorDependencies = Readonly<{
|
||||
// existing dependencies stay
|
||||
clock?: ClockPort;
|
||||
taskLimits: RealtimeStreamTaskLimits;
|
||||
}>;
|
||||
|
||||
export type RealtimeStreamCoordinator = Readonly<{
|
||||
// existing methods stay
|
||||
close(): Promise<RealtimeResult<void>>;
|
||||
}>;
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/adapters/realtime/live-poll-handoff-coordinator.ts
|
||||
export type LivePollHandoffState =
|
||||
| "LIVE_ACTIVE"
|
||||
| "POLL_ACTIVE"
|
||||
| "LIVE_PROBING"
|
||||
| "DRAINING"
|
||||
| "CLOSED";
|
||||
|
||||
export type LivePollHandoffInspection = Readonly<{
|
||||
// existing fields stay
|
||||
drainingWriters: number;
|
||||
}>;
|
||||
```
|
||||
|
||||
### 7.2 정확한 생성/수정/삭제/이동 목록
|
||||
|
||||
**생성:** 없음. lifecycle/install type은 기존 owner 파일에 둔다. 이 리뷰 문서 `docs/reviews/adapters/02-realtime-and-browser-rpc.md`만 리뷰 산출물로 새로 생성했다.
|
||||
|
||||
**수정:**
|
||||
|
||||
1. `src/contracts/browser-rpc.ts` — exact descriptor snapshot installer와 installed type.
|
||||
2. `src/adapters/browser-rpc/transport.ts` — explicit stream lease/cancel/closed receipt.
|
||||
3. `src/adapters/browser-rpc/browser-rpc-runtime.ts` — installed snapshot만 사용, bounded stream cleanup/DRAINING, safe clock/fence, outer cleanup.
|
||||
4. `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` — unavailable stream을 즉시 closed lease로 반환.
|
||||
5. `src/adapters/browser-rpc/index.ts` — installed/stream lifecycle types export.
|
||||
6. `src/application/ports/realtime/event-authority.ts` — stream lifecycle inspection.
|
||||
7. `src/application/ports/realtime/index.ts` — `RealtimeStreamLifecycle` export.
|
||||
8. `src/adapters/realtime/stream-coordinator.ts` — bounded task lease registry, lifecycle state, async close.
|
||||
9. `src/adapters/realtime/live-poll-handoff-coordinator.ts` — retired writer set과 DRAINING convergence.
|
||||
10. `src/adapters/realtime/index.ts` — lifecycle/limit types export.
|
||||
11. `src/adapters/realtime/websocket/websocket-protocol.ts` — allocation-free bounded UTF-8 counter.
|
||||
12. `tests/unit/browser-rpc/browser-rpc-contract.test.ts` — mutation/accessor/extra-key installer tests.
|
||||
13. `tests/unit/browser-rpc/browser-rpc-runtime.test.ts` — non-cooperative stream, throwing clock/fence, cleanup balance tests와 fixture lease 전환.
|
||||
14. `tests/unit/realtime/stream-coordinator.test.ts` — never-settling effect/recovery, DRAINING/async close tests.
|
||||
15. `tests/unit/realtime/live-poll-handoff-coordinator.test.ts` — overflow 뒤 retired writer close test.
|
||||
16. `tests/unit/realtime/websocket-protocol.test.ts` — oversize preflight/multibyte/lone-surrogate tests.
|
||||
17. `docs/architecture/protobuf-browser-transport-and-rest-gateway.md` — stream lease, buffer owner, promotion evidence.
|
||||
18. `docs/architecture/realtime-events-web-push-and-bounded-polling.md` — common stream/handoff DRAINING와 error semantics.
|
||||
19. `docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md` — actual-settlement lifecycle amendment.
|
||||
|
||||
**삭제:** 없음.
|
||||
|
||||
**이동:** 없음.
|
||||
|
||||
**의도적으로 변경하지 않음:** `src/bootstrap/optional-runtime-host.ts`는 제품/provider 선택 전 `null/UNAVAILABLE` 유지가 맞다. `src/application/ports/browser-rpc/browser-rpc.ts`의 app-facing API도 변경할 필요가 없다.
|
||||
|
||||
## 8. TDD 테스트 계획
|
||||
|
||||
먼저 아래 테스트를 실패시키고(red), 최소 구현 후 개별 green, 마지막에 전체 범위를 실행한다.
|
||||
|
||||
| 테스트 이름 | 입력/준비 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| `bounds_non_cooperative_stream_cancel_and_completes_consumer` | Browser RPC stream의 `next()`와 `waitClosed()`가 signal/cancel을 무시; idle deadline 진행 | caller iterator는 cleanup bound 안에 `REQUEST_TIMEOUT` 후 done; `cancel("IDLE_TIMEOUT")` 1회; lease DRAINING |
|
||||
| `rejects_new_stream_while_prior_lease_is_draining` | 위 stream actual settlement 전 같은 transport에 두 번째 open | network side effect 없이 `SERVER_FAILURE/RPC_STREAM_DRAINING`; old settle 후 새 open 가능 |
|
||||
| `consumer_break_cancels_and_bounds_stream_cleanup` | 첫 message 뒤 consumer `break`; close non-cooperative | `cancel("CONSUMER_CLOSED")`; generator return bounded; listener/timer 0 |
|
||||
| `runtime_snapshots_bindings_before_later_mutation` | factory 뒤 원본 operation retry/deadline/profile/transport map mutation | execute는 installed snapshot만 사용; mutation이 의미 변경 불가 |
|
||||
| `binding_installer_rejects_extra_and_accessor_keys_without_invoking_them` | operation/profile/registry에 getter, symbol, extra key | getter 호출 0; composition-time `TypeError` |
|
||||
| `returns_canonical_failure_and_cleans_listener_when_clock_throws` | transport 전/후 `clock.now`/`sleep` synchronous throw; listener-counting signal | rejection 없음; 지정 `AppFailure`; listener/timer 0; observation 1회 |
|
||||
| `fences_when_generation_fence_throws` | `capture` 또는 `isCurrent` throw | `SCOPE_GENERATION_CHANGED/RPC_SCOPE_GENERATION_UNAVAILABLE`; mapped value 미commit |
|
||||
| `keeps_stream_draining_until_non_cooperative_effect_actually_settles` | effect Promise never settles; fake clock가 effect/drain deadline 진행 | accept는 bounded `IDLE_TIMEOUT/APPLY`; `isCurrent=false`; DRAINING; 새 effect 0 |
|
||||
| `bounds_non_cooperative_recovery_and_rejects_late_checkpoint` | recovery가 timeout 뒤 늦게 success checkpoint 반환 | bounded `IDLE_TIMEOUT/RECOVER`; late checkpoint 미commit; settle 후 STALE/recovery 필요 |
|
||||
| `close_waits_for_all_tracked_stream_tasks_and_times_out` | effect와 recovery pending 중 close | controller 모두 abort; bound 뒤 `IDLE_TIMEOUT/CLOSE`; settlement까지 DRAINING, 이후 CLOSED |
|
||||
| `close_after_active_queue_overflow_tracks_retired_writer` | handoff active effect never settles, queue cap 초과 후 close | overflow `QUEUE_OVERFLOW`; close 즉시 success 금지; bound 뒤 `IDLE_TIMEOUT`; late settle 시 draining 0/CLOSED |
|
||||
| `rejects_oversized_ascii_frame_before_utf8_copy` | `"x".repeat(maxFrameBytes + 1)` | `FRAME_TOO_LARGE`; full-size byte copy 경로 없음 |
|
||||
| `counts_multibyte_and_lone_surrogate_like_text_encoder` | ASCII/2-byte/3-byte/surrogate pair/lone surrogate 경계 | 기존 byte 의미와 동일한 exact accept/reject |
|
||||
| `transport_conformance_enforces_max_buffered_bytes_before_enqueue` | push/callback fake transport가 consumer 정지 중 cap+1 byte enqueue | enqueue 거부, lease cancel, raw/message 미노출; provider suite 없이는 promotion 금지 |
|
||||
|
||||
실행 명령:
|
||||
|
||||
```sh
|
||||
corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-contract.test.ts
|
||||
corepack pnpm exec vitest run tests/unit/browser-rpc/browser-rpc-runtime.test.ts
|
||||
corepack pnpm exec vitest run tests/unit/realtime/stream-coordinator.test.ts
|
||||
corepack pnpm exec vitest run tests/unit/realtime/live-poll-handoff-coordinator.test.ts
|
||||
corepack pnpm exec vitest run tests/unit/realtime/websocket-protocol.test.ts
|
||||
corepack pnpm exec vitest run tests/unit/realtime tests/unit/browser-rpc --reporter=dot --maxWorkers=4
|
||||
corepack pnpm run check:types:app
|
||||
corepack pnpm run check:types:test
|
||||
corepack pnpm run check:realtime-boundaries
|
||||
corepack pnpm run check:realtime-boundaries:fixture
|
||||
```
|
||||
|
||||
제품 transport 선택 시 별도 필수 evidence:
|
||||
|
||||
```sh
|
||||
# 실제 provider contribution이 script 이름과 target browser matrix를 고정해야 한다.
|
||||
corepack pnpm run test:browser-rpc-transport-conformance
|
||||
corepack pnpm run test:browser-rpc-target-browsers
|
||||
```
|
||||
|
||||
## 9. compatibility, migration, rollback
|
||||
|
||||
Migration 순서:
|
||||
|
||||
1. 새 tests와 lifecycle inspection을 먼저 추가한다. runtime은 미조립 상태라 production traffic 영향은 없다.
|
||||
2. `createBrowserRpcRuntime`은 raw input을 받아 내부에서 installer를 호출해 기존 caller signature를 유지한다. mutation에 의존한 fixture는 composition-time 오류로 고친다.
|
||||
3. 한 migration release 동안 기존 `AsyncIterable` transport를 internal adapter로 `BrowserRpcTransportStream`에 감쌀 수 있다. deprecated wrapper의 `waitClosed`는 `iterator.return()` settlement이고 common cleanup bound가 이를 감싼다. provider 선택 전 legacy branch를 제거한다.
|
||||
4. common stream `close(): Promise<Result>`로 바꾸고 모든 test/향후 composition owner는 `await`한다. 기존 fire-and-forget 호출은 typecheck로 식별한다.
|
||||
5. handoff retired set을 도입하고 `DRAINING`에는 writer/probe admission을 막는다.
|
||||
6. WS byte counter는 wire/error shape가 같아 독립적으로 먼저 적용할 수 있다.
|
||||
7. actual provider/browser/load evidence와 R-01~R-07 closure 전까지 `AVAILABLE_NOT_COMPOSED`를 유지한다. bootstrap composition은 마지막 단계다.
|
||||
|
||||
Compatibility 결정:
|
||||
|
||||
- application-facing Browser RPC unary/stream port shape는 유지한다.
|
||||
- wire protocol, frame shape, failure kind, retry owner는 바꾸지 않는다.
|
||||
- `RealtimeStreamInspection.lifecycle`는 additive다. `close` 반환형은 source-compatible fire-and-forget일 수 있으나 lifecycle correctness를 위해 owner는 await하도록 migration한다.
|
||||
- exact installer가 과거 extra/accessor/mutable row를 거절하는 것은 의도된 fail-closed tightening이다.
|
||||
|
||||
Rollback 순서:
|
||||
|
||||
1. traffic admission을 `DISABLED`로 전환한다.
|
||||
2. connection/runtime lifecycle을 `DRAINING`으로 만들고 actual leases settlement 또는 bounded failure를 기록한다.
|
||||
3. 살아 있는 lease를 버리고 즉시 이전 runtime을 열지 않는다.
|
||||
4. source commit을 revert하되 installed snapshot과 allocation-before-cap 수정은 보안/정확성 강화이므로 우선 유지한다.
|
||||
5. cursor/checkpoint를 합성하지 않고 authoritative snapshot recovery를 수행한다.
|
||||
6. SSE↔WS, Connect↔gRPC-Web↔REST, live↔Poll을 장애 때문에 즉석 자동 전환하지 않는다. fallback은 registry/ADR에 선언된 새 semantic operation/generation으로만 시작한다.
|
||||
|
||||
## 10. 유지할 좋은 설계
|
||||
|
||||
1. `RealtimeFailure`/`AppFailure`로 native error, raw close reason, payload, cursor, provider metadata를 경계 밖에 내보내지 않는다.
|
||||
2. realtime result/registry의 exact own-data snapshot, accessor 거절, immutable recovery checkpoint object identity.
|
||||
3. fixed same-origin SSE/WS endpoint, URL/subprotocol credential 금지, exact media/subprotocol 검증.
|
||||
4. SSE, WebSocket, Browser RPC stream, Poll을 서로 다른 delivery/protocol 의미로 유지하고 자동 downgrade/replay하지 않는다.
|
||||
5. WS inbound/outbound FIFO, count+byte+`bufferedAmount` ceiling과 overflow whole-generation recovery.
|
||||
6. reconnect의 단일 retry owner, full jitter, hint not-before, finite budget, stable proof 뒤 reset, post-abort DRAINING.
|
||||
7. Poll의 visible/online finite single-flight lease와 non-cooperative execute/apply tracking.
|
||||
8. LIVE↔POLL의 one-writer generation, probe buffer, activation 전 quiescence/checkpoint.
|
||||
9. SSE parser의 incremental strict UTF-8, incomplete EOF discard, bounded reader cancellation.
|
||||
10. unavailable Browser RPC adapter와 optional runtime host의 `null/UNAVAILABLE`; 조용한 network fallback이 없다.
|
||||
|
||||
## 11. false-positive 방지 대조
|
||||
|
||||
| 의심 항목 | 최종 판정과 근거 |
|
||||
|---|---|
|
||||
| Realtime/Browser RPC가 bootstrap에 조립되지 않음 | 결함 아님. `optional-runtime-host.ts:91-92,143`와 architecture docs가 제품 선택 전 미조립을 요구한다. |
|
||||
| Common Browser RPC가 Connect/gRPC-Web raw framing을 decode하지 않음 | 결함 아님. `protobuf...md:57-61,381-399`상 concrete transport 책임이다. R-07은 이 미완료 상태를 무시한 promotion만 막는다. |
|
||||
| Reconnect가 offline 동안 timer 없이 기다림 | 의도. ADR과 `realtime-reconnect-coordinator.test.ts:370-406`가 explicit online signal을 요구한다. |
|
||||
| healthy session `waitClosed()`에 deadline 없음 | 의도. ADR은 abort 후 drain만 bounded하고 active close receipt는 authoritative하게 기다린다. |
|
||||
| WS overflow에서 일부 event drop 대신 connection close | 의도. ADR과 `websocket-connection.test.ts:579-651`은 receive pause 없는 classic WS에서 snapshot recovery를 택한다. |
|
||||
| SSE 204와 incomplete EOF | 각각 terminal/no reconnect와 incomplete discard가 맞다. fetch/parser tests가 확인한다. |
|
||||
| exact recovery object identity | 의도된 capability token이다. `event-authority.ts:17-23`, reconnect/stream barrier tests가 clone/forgery를 막는다. |
|
||||
| Handoff overflow 자체 | 이미 fail-close한다. R-03은 overflow 판정이 아니라 그 직후 retired tail reference를 잃는 cleanup bug다. |
|
||||
| Transport에 effect timeout이 이미 있음 | transport caller는 bounded해도 common `state.tail`은 settle하지 않는다. R-02는 commit fence가 아니라 retained task/liveness 문제다. |
|
||||
|
||||
## 12. baseline 검증
|
||||
|
||||
1. `corepack pnpm exec vitest run tests/unit/realtime tests/unit/browser-rpc --reporter=dot --maxWorkers=4`
|
||||
- exit 0, **16 files / 185 tests passed**.
|
||||
2. `corepack pnpm run check:realtime-boundaries`
|
||||
- exit 0, `Realtime boundaries: PASS (src)`.
|
||||
3. `check:realtime-boundaries:fixture` wrapper는 이 sandbox에서 child-process 제한 때문에 진단 없이 exit 1이었다. 같은 allowed/forbidden child 명령을 직접 실행해 allowed exit 0, forbidden exit 1과 세 규칙 `UNSELECTED_REALTIME_RUNTIME_COMPOSED`, `PRESENTATION_INTERVAL_OWNER`, `NATIVE_REALTIME_API_OUTSIDE_ADAPTER`를 확인했다. adapter defect로 세지 않는다.
|
||||
4. `test:realtime-removal`의 별도 복제에서 범위 tests는 통과했으나 저장소 전체 baseline의 CI authority count drift, 누락 `.npmrc`, child `spawnSync ... EPERM`, architecture report 문제로 최종 exit 1이었다. 검토 범위 failure 증거로 사용하지 않는다.
|
||||
|
||||
## 13. 구현 우선순위
|
||||
|
||||
1. R-03 retired writer tracking: 국소적이고 확정적인 cleanup bug다.
|
||||
2. R-02 common stream task lifecycle: SSE/WS 양쪽 liveness 기반을 닫는다.
|
||||
3. R-01 Browser RPC explicit stream lease와 bounded cleanup.
|
||||
4. R-04 installed immutable bindings, 이어 R-06 exception/cleanup guard.
|
||||
5. R-05 allocation-before-cap 제거.
|
||||
6. R-07 concrete transport conformance는 provider 선택과 함께 수행하되 완료 전 production composition을 금지한다.
|
||||
@@ -0,0 +1,565 @@
|
||||
# Storage / browser-file adapters 구현 준비 코드 리뷰
|
||||
|
||||
검토 저장소: `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template`
|
||||
|
||||
검토 범위: `src/adapters/storage/**`, `src/adapters/browser-files/**`, `src/adapters/browser-file-storage/**`, `src/adapters/cache-storage/**` 및 직접 연결된 application port, contract, bootstrap, test, architecture/operations 문서
|
||||
|
||||
검토 방식: 구현 파일을 수정하지 않은 read-only 리뷰. 아래 line은 현재 worktree 기준이다.
|
||||
|
||||
## 0. 결론과 우선순위
|
||||
|
||||
| ID | 판정 | 심각도 | 확신도 | 요약 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| STO-01 | 확정 결함 | **Critical** | 높음 | OPFS pre-commit 보상 cleanup 실패/취소를 무시하고 journal을 rollback한다. 늦게 도착한 generation-only cleanup이 후속 write의 같은 logical generation을 삭제할 수 있고, 그렇지 않아도 복구 근거와 quota를 잃는다. |
|
||||
| STO-02 | 확정 결함 | **High** | 높음 | browser-managed download는 `baseOrigin`으로 상대 URL을 검증하지만 원문 `href`를 `document.baseURI`로 실행한다. `<base>`가 있으면 검증한 origin과 실제 navigation origin이 달라진다. |
|
||||
| STO-03 | 확정 결함 | **Medium** | 높음 | public cache policy가 `allowedVaryHeaderNames`를 허용하면서 response allowlist에서 `vary`를 제거하는 모순을 허용한다. stage는 성공할 수 있지만 저장 variant가 충돌하고 activation이 실패한다. |
|
||||
| STO-04 | 확정 결함 | **Medium** | 높음 | 동일 manifest 재-stage가 marker와 count만 신뢰한다. marker 작성 뒤 browser eviction/부분 손상된 candidate를 성공으로 재사용하여 self-heal하지 못한다. activation은 fail-closed지만 staging success 의미가 약해진다. |
|
||||
| STO-05 | 확정 결함 | **Medium** | 높음 | cache `activateRelease`/`cleanupOwned`가 네트워크 fetch를 쓰지 않는데도 공통 availability guard가 `fetcher`를 필수로 요구한다. offline activation/rollback/cleanup이 불필요하게 `UNSUPPORTED`가 된다. |
|
||||
| STO-06 | 확정 계약 위반 | **Medium** | 높음 | IndexedDB codec migration은 commit transaction 내부의 연속 native operation 사이에 monotonic deadline을 재확인하지 않는다. 문서/port의 cooperative duration contract보다 오래 실행될 수 있다. |
|
||||
| STO-07 | hardening 후보 | **Medium** | 높음 | OPFS worker envelope에 protocol version/response kind가 없고 client response parser가 `{requestId, ok}`만 검사한다. page/worker release 불일치와 malformed response를 `INCOMPATIBLE`로 닫을 수 없다. |
|
||||
| STO-08 | 브라우저 검증 필요 | **Low** | 중간 | enhanced open/save picker 함수를 `Window`가 아니라 options 객체에 bind한다. Web IDL brand check가 있는 engine에서는 `Illegal invocation` 가능성이 있으나 현재 unit fake는 이를 검증하지 않는다. 실제 browser test로 먼저 확정한다. |
|
||||
| GAP-01 | 문서화된 미구현 | **High readiness gap** | 높음 | preview pixel/decoded-byte/frame/decode probe가 없다. VD-15가 이미 `DESIGNED_NOT_IMPLEMENTED`로 명시했으므로 regression으로 오인하지 말고, untrusted image preview 조립의 promotion blocker로 취급한다. |
|
||||
| GAP-02 | 문서화된 미구현 | **High readiness gap** | 높음 | Cache inspect/cleanup은 cursor/count/deadline 없이 전체 owned namespace를 순회한다. VD-15가 정확히 현 상태를 기록한다. |
|
||||
| GAP-03 | 문서화된 미구현 | **High readiness gap** | 높음 | origin-wide pressure/write-admission/GC, OPFS/Cache forward migration, real OPFS preflight가 아직 없다. 기존 per-store primitive를 완성 증거로 삼지 않는다. |
|
||||
|
||||
즉시 순서는 **STO-01 write 차단/수정 → STO-02 canonical URL 실행 → STO-03~05 cache 불변식 → STO-06/07 hardening**이다. GAP 항목은 해당 capability를 제품에 선택·조립하기 전에 별도 promotion gate로 구현한다.
|
||||
|
||||
## 1. 누락 없는 범위 inventory: 책임과 의존성
|
||||
|
||||
### 1.1 `browser-file-storage`
|
||||
|
||||
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
|
||||
| --- | --- | --- |
|
||||
| `src/adapters/browser-file-storage/index.ts` | browser data 공통 Result와 StorageManager adapter barrel export | 내부 두 모듈만 export. 경계가 작고 유지 대상. |
|
||||
| `src/adapters/browser-file-storage/result.ts` | native 예외를 closed `BrowserDataFailure`로 정규화하고 안전한 observation 제공 | `application/ports/browser-file-storage/shared.ts`; raw path/name/message 비노출, observer 예외 격리가 좋다. |
|
||||
| `src/adapters/browser-file-storage/storage-manager-adapter.ts` | `estimate/persisted/persist` snapshot, pressure bucket, user-activation-bound persistence 요청 | storage durability port/result. estimate를 예약량으로 오인하지 않고 irreversible `persist()` truth를 보존한다. origin coordinator는 의도적으로 없음(GAP-03). |
|
||||
|
||||
### 1.2 `browser-files`
|
||||
|
||||
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
|
||||
| --- | --- | --- |
|
||||
| `src/adapters/browser-files/browser-file-picker.ts` | native input baseline 및 enhanced system picker, activation/abort/dismissal, vault capture | file port, vault, policy registry. baseline/enhancement 분리가 좋다. `showOpenFilePicker.bind(options)`는 STO-08. |
|
||||
| `src/adapters/browser-files/browser-file-policy-registry.ts` | composition-owned selection/inspection/preview/download policy 등록·identity 확인·hard-cap reduction | file contracts, `file-policy.ts`. `WeakSet`/identity binding과 frozen snapshot을 유지한다. |
|
||||
| `src/adapters/browser-files/browser-file-vault.ts` | transient native File/handle 보관, opaque ref, inspection receipt, bounded range/source | file port/shared/result/policy registry. File이 application 경계를 넘지 않고 receipt가 exact file/profile에 묶이는 설계가 좋다. |
|
||||
| `src/adapters/browser-files/create-browser-file-runtime.ts` | vault/picker/preview/download를 선택적으로 조립하고 일괄 dispose | 위 adapters 및 application contracts. optional capability를 제품 선택 없이 bootstrap에 암묵 조립하지 않는 점을 유지. preview 조립 전 GAP-01 gate 필요. |
|
||||
| `src/adapters/browser-files/download-delivery-adapter.ts` | browser handoff, foreground save stream, bounded object URL download, integrity/progress/cancellation | file/authorized-download ports, policy registry, object URL lease, Result. STO-02와 STO-08; stream close truth/backpressure는 유지. |
|
||||
| `src/adapters/browser-files/file-observer.ts` | file-safe observation DTO를 공통 browser observation으로 변환 | shared port/result. raw filename/ref 비노출 유지. |
|
||||
| `src/adapters/browser-files/file-policy.ts` | policy input validation, MIME/extension/signature/hard byte caps, immutable resolved policy | file/shared contracts. closed allowlist 및 absolute ceiling을 유지. |
|
||||
| `src/adapters/browser-files/index.ts` | browser-file public exports | 위 모듈. native implementation detail export 확장을 피한다. |
|
||||
| `src/adapters/browser-files/object-url-lease.ts` | 중앙 object URL lease cap/registry, transient preview, idempotent revoke/dispose | file/shared contracts, vault, policy registry. URL lifecycle은 좋으나 `create()` 256-303은 decode probe 없이 URL을 발급(GAP-01). |
|
||||
|
||||
### 1.3 `cache-storage`
|
||||
|
||||
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
|
||||
| --- | --- | --- |
|
||||
| `src/adapters/cache-storage/index.ts` | public cache policy/adapter barrel | optional public static cache만 export; private/range cache로 일반화하지 않는다. |
|
||||
| `src/adapters/cache-storage/public-cache-policy.ts` | same-origin/public-only release 정책, URL/header/query/size/retention hard limits | cache ports/shared. STO-03 policy cross-field invariant 누락. 기본 policy에는 `vary`가 있어 기본-path 테스트는 통과한다. |
|
||||
| `src/adapters/cache-storage/public-response-cache-adapter.ts` | manifest canonicalization/digest, anonymous fetch, bounded body 검증, candidate marker-last staging, explicit activation, exact lookup/reverify, owned cleanup/inspect | cache ports/result/policy, CacheStorage/fetch/Crypto/Web Lock snapshot. STO-03~05 및 GAP-02. private/auth/opaque/206 거절과 current+previous 보존은 유지. |
|
||||
|
||||
### 1.4 `storage` root / IndexedDB
|
||||
|
||||
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
|
||||
| --- | --- | --- |
|
||||
| `src/adapters/storage/browser-storage-adapter.ts` | registry key별 local/session/memory 저장, TTL, failure overlay/tombstone, quota fallback | `storage-keys`, `StoragePort`, codec, diagnostics. strict registry와 stale persistent suppression을 유지. adjacent physical-key migration/sweep는 문서상 미구현. |
|
||||
| `src/adapters/storage/browser-storage-codec.ts` | bounded exact JSON envelope, exotic/accessor/unsafe-key/cycle/depth/node 거절 | 독립 codec. prototype pollution/JSON silent coercion 방어가 좋다. |
|
||||
| `src/adapters/storage/indexeddb/index.ts` | IndexedDB runtime/maintenance/governance/migration export | native IDB type을 application port 밖으로 내보내지 않는 구조 유지. |
|
||||
| `src/adapters/storage/indexeddb/indexeddb-failure.ts` | IDB/DOM failure를 closed browser failure로 변환 | common Result. raw native detail 비노출 유지. |
|
||||
| `src/adapters/storage/indexeddb/indexeddb-governance.ts` | opaque dataset scope/physical DB identity 및 frozen policy binding | indexeddb/shared ports. account/business ID를 physical name에 쓰지 않는 양방향 binding 유지. |
|
||||
| `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | post-open codec migration 및 idempotency receipt prune, keyset checkpoint, budget/revision fencing | IndexedDB port/types/failure/governance. async transform outside tx, row+sidecar+budget+checkpoint atomic commit은 좋다. STO-06 및 temporal drain lease 개선 후보. |
|
||||
| `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | additive-only contiguous DDL planner/validator | indexeddb types. destructive DDL 거절 유지. |
|
||||
| `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | generic repository open/read/query/CAS/delete, idempotency, retention, lifecycle purge, connection lifecycle | indexeddb ports/types/governance/failure/migrations. transaction `complete` truth, versionchange close, shared open/abort isolation, exact budgets 유지. lifecycle proof는 현재 문서 계약(형식 검증 후 폐기)과 일치하므로 결함으로 분류하지 않았다. |
|
||||
| `src/adapters/storage/indexeddb/indexeddb-types.ts` | adapter-local codec/query/schema/dependency contracts | application indexeddb/shared ports. `isOldWriterDrainConfirmed()` boolean은 provider가 전체 window를 보장한다는 문서 전제; lease형으로 강화 권고. |
|
||||
|
||||
### 1.5 `storage/opfs`
|
||||
|
||||
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
|
||||
| --- | --- | --- |
|
||||
| `src/adapters/storage/opfs/browser-opfs-runtime.ts` | OPFS support inspection 및 journal/worker/byte-store composition | OPFS ports, journal, byte-store, policy, worker client. property probe를 real readiness로 주장하지 않음(GAP-03). |
|
||||
| `src/adapters/storage/opfs/index.ts` | OPFS runtime/journal/policy/protocol/client exports | optional capability barrel. |
|
||||
| `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | logical object/journal/budget/chunk refcount의 IDB authority; begin/files-ready/commit/rollback/reconcile pages | OPFS ports, IDB failure, policy. journal+object+budget CAS atomicity가 좋다. STO-01 수정에서 incomplete row를 cleanup 확인 전 삭제하지 않아야 한다. |
|
||||
| `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | logical journal과 physical worker를 saga로 조정, put/open/remove, reconcile/policy maintenance | OPFS/shared ports, journal, worker gateway, policy. STO-01의 journal/physical compensation ordering 결함 위치. |
|
||||
| `src/adapters/storage/opfs/opfs-policy.ts` | root/lock/chunk/object/RPC/reconcile/GC hard limits 및 scope validation | shared/opfs ports. opaque physical path와 absolute caps 유지. |
|
||||
| `src/adapters/storage/opfs/opfs-worker-client.ts` | request correlation/timeout/abort/transferable chunking, worker gateway, streamed reads | protocol/policy/shared Result. STO-01의 untracked abort cleanup 및 STO-07의 shallow response parse. |
|
||||
| `src/adapters/storage/opfs/opfs-worker-protocol.ts` | page↔DedicatedWorker request/response union 및 gateway contract | OPFS/shared ports. STO-07; protocol version/kind/effect certainty 추가 필요. |
|
||||
| `src/adapters/storage/opfs/opfs-worker-runtime.ts` | DedicatedWorker OPFS physical layout, lock lease, immutable chunk, manifest/staging receipt, abort/finalize/remove/GC | protocol/policy/OPFS ports/Web Lock/Crypto. STO-01의 generation-only cleanup과 lease release 순서. sync handle `finally close` 등은 유지. |
|
||||
|
||||
### 1.6 직접 연결 경계와 조립
|
||||
|
||||
- `src/application/ports/browser-file-storage/{shared,file,indexeddb-port,opfs-ports,cache-storage-ports,storage-durability-port}.ts`와 barrel을 읽었다. native `File/Blob/Cache/IDB*/Response/ReadableStream`을 application으로 노출하지 않는 포트 방향은 올바르다.
|
||||
- `src/application/ports/storage-port.ts`, `src/contracts/storage-keys.ts`를 대조했다. Web Storage는 registry-owned typed key만 허용한다.
|
||||
- `src/bootstrap/runtime-adapters.ts:17,260-266`은 Web Storage만 기본 조립한다. file/IndexedDB/OPFS/Cache가 없는 것은 문서의 `AVAILABLE_NOT_COMPOSED`와 일치하며 결함이 아니다.
|
||||
|
||||
## 2. 구체적 findings와 구현 방법
|
||||
|
||||
### STO-01 — OPFS 보상 cleanup이 journal보다 늦게 완료되거나 실패할 때 후속 generation 삭제 가능
|
||||
|
||||
**근거와 실패 연쇄**
|
||||
|
||||
1. 새 logical generation은 현재 committed generation+1로 재사용된다: `src/adapters/storage/opfs/opfs-byte-store-adapter.ts:158-195`(특히 183-195).
|
||||
2. `preparePut` 또는 `markFilesReady` 실패 시 `rollbackBestEffort`를 호출한다: 같은 파일 `222-242`.
|
||||
3. `rollbackBestEffort`는 `worker.cleanupTransaction(..., callerSignal)`의 `BrowserDataResult`를 검사하지 않고, 곧바로 `journal.rollback`을 호출한다: `833-845`. caller signal이 이미 abort되었으면 cleanup RPC는 시작조차 못 한다.
|
||||
4. worker client도 prepare 단계 실패/timeout 때 별도의 un-signaled `ABORT_PUT`을 보내지만 timeout/실패를 삼키며 “journal reconciliation이 반복한다”고 가정한다: `src/adapters/storage/opfs/opfs-worker-client.ts:190-198,229-307`. 그런데 3번이 journal row를 삭제한다.
|
||||
5. physical cleanup은 staging receipt에서 `(scope, objectId, generation)`만 읽어 해당 generation 디렉터리를 삭제한다: `src/adapters/storage/opfs/opfs-worker-runtime.ts:584-607,717-761`. manifest/path에 transaction-unique physical generation identity가 없다.
|
||||
6. `abortPut`은 mutation lease를 먼저 release한 뒤 generation 삭제를 수행한다: 같은 파일 `501-529`(특히 519-527). `cleanupTransaction` 자체도 mutation lease를 얻지 않는다.
|
||||
|
||||
따라서 T1 cleanup RPC가 timeout 뒤 worker에서 계속되거나 T1 `ABORT_PUT`이 늦게 실행되는 동안 coordinator가 T1 journal을 rollback하면 T2가 같은 object의 동일 logical generation을 다시 시작할 수 있다. 늦은 T1 cleanup은 T2의 물리 디렉터리를 삭제할 수 있다. 삭제까지 겹치지 않아도 journal 부재로 stale staging/immutable chunks가 영구 잔존해 quota pressure를 만든다.
|
||||
|
||||
**패턴과 수정**
|
||||
|
||||
- cross-API ACID를 주장하지 말고 **durable saga + transactional outbox/compensation state**를 유지한다.
|
||||
- “physical cleanup confirmed” 전에는 PREPARING/FILES_READY journal row와 budget reservation을 rollback하지 않는다. cleanup은 caller signal과 분리한 composition-owned bounded signal을 사용한다.
|
||||
- worker client 내부에서 fire-and-forget abort를 중복 발행하지 않는다. coordinator가 `abortPreparedPut()` 한 번을 소유하고 결과가 `CLEANED|ALREADY_CLEAN`일 때만 journal rollback한다. timeout/crash는 `EFFECT_UNKNOWN`으로 남겨 reconcile한다.
|
||||
- 장기적으로 **transaction-unique physical generation/fencing token**을 path, receipt, manifest, journal에 저장한다. stale T1 cleanup은 T1 token 경로만 삭제하고 T2를 건드릴 수 없어야 한다.
|
||||
- cleanup/abort는 같은 origin mutation Web Lock을 physical 삭제 완료까지 보유한다. lease를 먼저 release하지 않는다.
|
||||
|
||||
**권장 새/변경 signature**
|
||||
|
||||
```ts
|
||||
declare const opfsPhysicalGenerationBrand: unique symbol;
|
||||
export type OpfsPhysicalGenerationId = string & {
|
||||
readonly [opfsPhysicalGenerationBrand]: "OpfsPhysicalGenerationId";
|
||||
};
|
||||
|
||||
export type OpfsPreparedObjectV2 = Readonly<{
|
||||
physicalSchemaVersion: 2;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
descriptor: DurableObjectDescriptor; // logical generation은 그대로 유지
|
||||
chunks: readonly OpfsChunkReference[];
|
||||
}>;
|
||||
|
||||
export type OpfsCleanupEffect =
|
||||
| Readonly<{ kind: "CLEANED" | "ALREADY_CLEAN" }>
|
||||
| Readonly<{ kind: "EFFECT_UNKNOWN" }>;
|
||||
|
||||
export interface OpfsWorkerGateway {
|
||||
abortPreparedPut(request: Readonly<{
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
signal?: AbortSignal; // coordinator-owned compensation signal만 전달
|
||||
}>): Promise<BrowserDataResult<OpfsCleanupEffect>>;
|
||||
}
|
||||
```
|
||||
|
||||
P0에서는 v1 read를 유지하면서 새 write만 v2/token path로 쓴다. `EFFECT_UNKNOWN`은 성공 Result로 취급하지 말고 journal 유지 + `OBJECT_RECONCILE`를 반환한다.
|
||||
|
||||
**기존 테스트와 false-positive 방지**
|
||||
|
||||
- `tests/unit/opfs-byte-store.test.ts:504-540`의 “keeps a committed journal row for reconciliation when cleanup fails”는 logical commit 뒤 finalize 실패만 검증한다. PREPARING/FILES_READY 보상 실패를 다루지 않는다.
|
||||
- `tests/unit/opfs-worker-runtime.test.ts:232-408`은 BEGIN cancel/APPEND-vs-ABORT serialization/authority isolation을 검증하지만, journal rollback 뒤 다른 worker/context가 재사용한 generation에 대한 늦은 cleanup을 만들지 않는다.
|
||||
- `indexeddb-opfs-journal.ts:997-1008`의 unique `logicalKey` index는 **journal row가 남아 있는 동안** T2를 막는다. 바로 그 row를 조기에 삭제하는 것이 문제이므로 이 index가 반증이 아니다.
|
||||
|
||||
### STO-02 — 검증 URL과 실제 download navigation URL의 base가 다름
|
||||
|
||||
**근거**
|
||||
|
||||
- `safeBrowserManagedTarget`은 `new URL(href, new URL(baseOrigin))`으로 protocol/origin/query/hash를 검증한다: `src/adapters/browser-files/download-delivery-adapter.ts:1248-1269`.
|
||||
- 성공 후 canonical `URL.href`가 아니라 원문 문자열을 host로 넘긴다: `435-459`.
|
||||
- 실제 anchor는 `anchor.href = href`라서 document의 current `baseURI`를 기준으로 해석한다: `47-68`.
|
||||
|
||||
예: configured `baseOrigin=https://app.example`, capability `href="downloads/report"`, document에 `<base href="https://evil.example/">`가 있으면 검증은 app origin을 통과하지만 실제 anchor는 evil origin으로 향한다. capability receipt의 server binding이 있더라도 adapter의 same-origin 정책 주장이 깨진다.
|
||||
|
||||
**패턴과 수정**
|
||||
|
||||
- **Parse once / canonicalize then execute** 패턴을 적용한다. validator가 boolean이 아니라 canonical absolute URL을 반환하고 정확히 그 값을 handoff한다.
|
||||
- cross-origin을 허용하는 별도 policy에서도 username/password/hash/query 규칙을 적용한 canonical string만 실행한다.
|
||||
|
||||
```ts
|
||||
type ResolvedBrowserManagedTarget = Readonly<{ absoluteHref: string }>;
|
||||
|
||||
function resolveBrowserManagedTarget(
|
||||
href: string,
|
||||
baseOrigin: string,
|
||||
policy: Readonly<{ allowCrossOrigin: boolean; allowQuery: boolean }>,
|
||||
): BrowserDataResult<ResolvedBrowserManagedTarget>;
|
||||
```
|
||||
|
||||
`context.options.host.handoff(target.value.absoluteHref, fileName)`로 변경한다. 더 엄격한 선택은 capability resolver가 absolute `https:` URL만 발행하게 하고 상대 URL을 거절하는 것이다.
|
||||
|
||||
**기존 테스트 대조**
|
||||
|
||||
- `tests/unit/browser-file-download.test.ts:222-255`는 raw 상대 path가 host에 그대로 전달된다고 고정한다. 이 기대값을 canonical `https://app.example/downloads/artifact-1`로 바꿔야 한다.
|
||||
- `257-285`는 이미 absolute evil/query URL 거절만 검증해 `<base>` 불일치를 잡지 못한다.
|
||||
|
||||
### STO-03 — Vary 허용/보존 policy가 모순될 수 있음
|
||||
|
||||
**근거**
|
||||
|
||||
- policy validation은 vary name이 request allowlist에 포함되는지만 본다: `src/adapters/cache-storage/public-cache-policy.ts:110-141`, 특히 `132-134`. response allowlist에 `vary`가 있는지는 확인하지 않는다.
|
||||
- network response의 Vary는 exact request headers와 검증한다: `src/adapters/cache-storage/public-response-cache-adapter.ts:1140,1228-1262`.
|
||||
- 이후 `unknownResponseHeaderAction="STRIP"`이면 response allowlist에 없는 `vary`를 제거하고(`1264-1280`), 제거된 headers로 Cache에 put한다(`472-479`). 동일 URL variant가 충돌한다.
|
||||
- activation은 모든 entry를 다시 digest/type/Vary 검증하므로 `592-617`에서 fail-closed한다. 따라서 현재 증거로 private-data disclosure를 주장하면 과장이다. 실제 영향은 impossible candidate에 대한 stage 성공, variant loss, activation/rollback availability 저하다.
|
||||
|
||||
**수정**
|
||||
|
||||
```ts
|
||||
if (
|
||||
policy.allowedVaryHeaderNames.length > 0 &&
|
||||
!policy.allowedResponseHeaderNames.includes("vary")
|
||||
) throw new TypeError("Vary must be preserved when variants are enabled.");
|
||||
```
|
||||
|
||||
방어를 겹치려면 `sanitizedResponseHeaders`가 검증된 `Vary`를 generic strip과 무관하게 반드시 보존하도록 한다. **Policy cross-field invariant + fail-fast composition** 패턴이다.
|
||||
|
||||
`tests/unit/public-response-cache.test.ts:1030-1155`는 default response allowlist가 이미 `vary`를 포함(`public-cache-policy.ts:54-63`)하므로 이 custom-policy 조합을 놓친다.
|
||||
|
||||
### STO-04 — existing cache marker만 확인하는 stage idempotence
|
||||
|
||||
`src/adapters/cache-storage/public-response-cache-adapter.ts:424-442`는 cache name이 있고 marker의 release ID/digest/count가 맞으면 모든 cached response의 존재/내용을 보지 않고 stage 성공을 반환한다. marker-last는 첫 stage crash에는 강하지만 marker 이후 browser pressure eviction, manual deletion, partial corruption에는 충분하지 않다. activation이 `592-617`에서 재검증하므로 unsafe publish는 막지만, 같은 manifest로 restage해도 손상 candidate를 복구하지 못한다.
|
||||
|
||||
**수정:** `verifyReleaseCandidate(cache, normalized, policy, crypto, signal)`를 factor하고 stage fast path와 activation이 공유한다. 기존 candidate가 missing/mismatch면 owned candidate만 삭제하고 network restage한다. verification 중 abort/unknown error면 active pointer는 건드리지 않고 candidate를 유지 또는 정책대로 삭제하되 성공을 반환하지 않는다. 이는 **idempotent repair, marker as claim not evidence** 패턴이다.
|
||||
|
||||
### STO-05 — cache mutation availability가 fetcher에 과결합
|
||||
|
||||
`mutationAvailability`는 storage+fetcher+lock 모두를 요구한다: `public-response-cache-adapter.ts:1643-1651`. stage 호출 `392-396`에는 맞지만, fetch하지 않는 activate `538-542`와 cleanup `695-699`에도 같은 guard를 쓴다. 이미 검증된 release를 offline에서 활성화/rollback하거나 quota recovery cleanup하는 기능을 차단한다.
|
||||
|
||||
**수정:** operation별 capability guard로 분리한다.
|
||||
|
||||
```ts
|
||||
function stageAvailability(d: Dependencies): BrowserFailureResult | null;
|
||||
// cacheStorage + mutationLock + fetcher
|
||||
function localMutationAvailability(
|
||||
d: Dependencies,
|
||||
operation: "CACHE_ACTIVATE" | "CACHE_DELETE",
|
||||
): BrowserFailureResult | null;
|
||||
// cacheStorage + mutationLock
|
||||
```
|
||||
|
||||
**Dependency segregation**을 적용하고 recovery도 `ONLINE_ONLY`가 아니라 실제 operation에 맞는 `RETRY/REHYDRATE`로 유지한다.
|
||||
|
||||
### STO-06 — IndexedDB migration commit 중 duration budget 재확인 없음
|
||||
|
||||
- port는 async storage operation 사이 cooperative duration budget을 명시한다: `src/application/ports/browser-file-storage/indexeddb-port.ts:81-89`.
|
||||
- docs도 각 native operation 사이 monotonic deadline 확인을 요구한다: `docs/architecture/browser-file-and-origin-storage.md:611-615`.
|
||||
- transform phase는 clock을 확인한다: `src/adapters/storage/indexeddb/indexeddb-maintenance.ts:940-966`.
|
||||
- 그러나 `commitPrepared`의 read/write/budget/sidecar/checkpoint chain은 `969-1233` 동안 clock을 호출하지 않는다. 최대 500 rows의 IDB callbacks가 invocation deadline 이후에도 계속될 수 있다.
|
||||
|
||||
**수정:** transaction을 시작하기 전 composition-owned `minimumCommitReserveMs`를 확인하고, prepared row 수를 budget에 맞춰 더 작게 제한한다. transaction을 연 뒤에는 각 record 시작 시 monotonic deadline을 확인하여 아직 어떤 write도 시작하지 않은 다음 record에서 transaction을 정상 종료하고 last-safe checkpoint까지만 commit한다. 이미 시작한 record의 row/sidecar/budget은 원자 완료하거나 tx 전체 abort해야 하며 부분 truth를 반환하면 안 된다. clock failure는 transaction abort + `UNAVAILABLE`다.
|
||||
|
||||
`tests/unit/indexeddb-maintenance.test.ts:562-597`은 transform 시작 전 budget exhaustion만 검증하므로 commit callback 중 clock advance 케이스를 추가한다.
|
||||
|
||||
### STO-07 — OPFS worker protocol version/strict response correlation 부재
|
||||
|
||||
- request/response envelope에 `protocolVersion`과 echoed `kind`가 없다: `src/adapters/storage/opfs/opfs-worker-protocol.ts:15-137`.
|
||||
- worker는 requestId+known kind만 1차 검사한다: `opfs-worker-runtime.ts:1643-1669`.
|
||||
- client는 `{requestId:string, ok:boolean}`만 검사한다: `opfs-worker-client.ts:666-675`. 실패 object/failure code/kind를 strict validate하지 않고 `response.failure.code`를 사용(`171-184`)한다.
|
||||
- VD-15는 real preflight에서 protocol/schema mismatch를 `INCOMPATIBLE`로 닫으라고 한다: `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md:476-484`.
|
||||
|
||||
**수정:** `OPFS_WORKER_PROTOCOL_VERSION = 2 as const`; 모든 request/response에 version과 kind를 넣고 pending request가 expected kind를 보관한다. closed failure-code set과 per-kind value parser를 적용한다. 먼저 `HELLO/CAPABILITIES` handshake에서 supported physical schema와 protocol version을 교환하고 mismatch면 write/read를 금지한다. generic cancel은 초기 correctness 필수가 아니다. PUT은 effect certainty가 필요한 명시적 `ABORT_PUT`; read/verify RPC는 client-side abandon으로 충분하며, 자원 최적화가 필요할 때만 `CANCEL_REQUEST { targetRequestId }`를 추가한다.
|
||||
|
||||
### STO-08 — picker function receiver binding은 browser test로 먼저 확정
|
||||
|
||||
- open: `src/adapters/browser-files/browser-file-picker.ts:431-436`
|
||||
- save/open-authorized callbacks: `src/adapters/browser-files/download-delivery-adapter.ts:201-235`
|
||||
|
||||
platform `Window.showOpenFilePicker/showSaveFilePicker`를 options object에 bind할 이유가 없고 Web IDL receiver brand check 가능성이 있다. 다만 현재 코드가 host facade 콜백을 의도했을 수도 있어 확정 전 browser matrix가 필요하다. 우선 실제 `window.showOpenFilePicker`를 전달한 capability test를 추가한다. 실패가 재현되면 API를 `SystemPickerHost { open; save? }`로 만들고 composition에서 올바른 owner에 bind한 host만 주입한다. arbitrary callback(`openAuthorizedSource`, integrity factory)은 bind하지 않고 함수 snapshot 그대로 호출한다.
|
||||
|
||||
## 3. 명시적 architecture 결정
|
||||
|
||||
### Transaction / crash recovery
|
||||
|
||||
- IndexedDB 한 domain mutation은 한 native transaction으로 row, retention sidecar, budget, idempotency receipt/checkpoint를 commit한다. request success가 아니라 transaction `complete`가 성공 truth다.
|
||||
- IDB와 OPFS/Cache 사이에는 atomic transaction이 없다. OPFS는 journal-authoritative durable saga다. phase는 monotonic이고 physical side effect가 불명확하면 incomplete journal을 유지한다.
|
||||
- compensation은 원 caller abort와 분리된 bounded signal로 실행한다. cleanup success가 확인될 때만 journal/budget rollback; unknown이면 reconcile owner에게 넘긴다.
|
||||
- committed object를 in-place repair하지 않는다. 새 physical token/generation에 copy/verify 후 logical CAS publish한다.
|
||||
|
||||
### Migration / rollback
|
||||
|
||||
- 독립 version 축(IDB DDL, record codec, OPFS journal, OPFS physical, Cache control/release)을 합치지 않는다.
|
||||
- 공통 순서는 expand → old-writer drain lease → bounded migrate/copy → atomic publish → N-1 observe/rollback window → 별도 contract release다.
|
||||
- schema downgrade, whole DB/root/cache delete, read-time unbounded rewrite는 금지한다.
|
||||
- IDB `isOldWriterDrainConfirmed()`는 현재 provider가 전체 migration/contract window를 보장한다는 문서 전제라 현 결함은 아니다. 다음 interface로 temporal guarantee를 실행 가능하게 강화한다:
|
||||
|
||||
```ts
|
||||
export interface OldWriterDrainLease {
|
||||
readonly leaseId: string;
|
||||
readonly validUntilEpochMs: number;
|
||||
assertValid(signal?: AbortSignal): Promise<BrowserDataResult<void>>;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
export interface IndexedDbDataMigrationPolicy<WireValue> {
|
||||
acquireOldWriterDrainLease(input: Readonly<{
|
||||
migrationId: string;
|
||||
targetCodecVersion: number;
|
||||
scope: IndexedDbDatasetScope;
|
||||
signal?: AbortSignal;
|
||||
}>): Promise<BrowserDataResult<OldWriterDrainLease>>;
|
||||
// migrate/measure 기존 계약 유지
|
||||
}
|
||||
```
|
||||
|
||||
lease는 batch commit 직전 재검증하고, product rollout owner는 migration 완료 후 rollback/contract window까지 global fence를 유지한다.
|
||||
|
||||
### Quota / pressure / eviction
|
||||
|
||||
- StorageManager estimate는 rough signal일 뿐 free-space reservation이 아니다. 실제 `QuotaExceededError`가 authority다.
|
||||
- per-dataset hard budget은 그대로 유지하고, origin coordinator는 Web Lock leader 한 개가 hysteresis(`70/85%`, 하향 `65/80%` 2회)를 적용한다.
|
||||
- GC 순서: incomplete candidate/stale staging → expired reconstructable → grace 지난 unreferenced chunk → inactive public release → confirmed synced copy → 중지. user-authored/unsynced는 자동 삭제 금지.
|
||||
- 기본 invocation 100 items/5s, 절대 500/30s. cursor는 owner/policy/release epoch에 binding한다.
|
||||
- quota retry는 실제 quota rollback, 동일 idempotency/revision/digest, external publish 없음, GC가 실제 제거/pressure 하향, 새 admission token 조건을 모두 만족할 때 정확히 1회만 허용한다.
|
||||
|
||||
### Lease / destructive authority
|
||||
|
||||
- OPFS mutation Web Lock은 physical delete/cleanup 완료까지 보유한다. transaction-unique physical token이 stale cleanup fencing이다.
|
||||
- object URL은 registry lease로만 만들고 persistence/log/analytics/global cache에 넣지 않는다. release/dispose는 idempotent다.
|
||||
- IndexedDB lifecycle authority는 현재 문서대로 composition callback의 short-lived proof를 형식 검증 후 즉시 폐기한다. OPFS와 동일한 replay 방지가 제품 threat model에 필요하면 provider+atomic consumer의 one-shot lease로 별도 강화하되 application caller에게 token을 노출하지 않는다.
|
||||
|
||||
### Object URL / preview
|
||||
|
||||
- 현재 encoded size/signature/media/active-content denylist는 유지한다.
|
||||
- 제품 untrusted image preview를 선택하기 전 object URL 발급 **앞**에 bounded header parser + native decode probe를 둔다. static JPEG/PNG/WebP/AVIF 등 명시 allowlist만; SVG/PDF/HTML/XML과 animated image는 별도 격리/re-encode capability가 없으면 attachment-only다.
|
||||
|
||||
```ts
|
||||
export interface PreviewSafetyProbe {
|
||||
inspect(input: Readonly<{
|
||||
file: File; // adapter-local only
|
||||
mediaType: string;
|
||||
maxEncodedBytes: number;
|
||||
maxPixels: number;
|
||||
maxDecodedBytes: number;
|
||||
maxFrames: number;
|
||||
deadlineMs: number;
|
||||
signal: AbortSignal;
|
||||
}>): Promise<BrowserDataResult<Readonly<{
|
||||
width: number;
|
||||
height: number;
|
||||
frameCount: number;
|
||||
decodedBytes: number;
|
||||
}>>>;
|
||||
}
|
||||
```
|
||||
|
||||
parser 산술은 overflow-safe여야 하고 native `createImageBitmap` 결과는 항상 `close()`. timeout/abort/failure면 `createObjectURL`을 호출하지 않는다.
|
||||
|
||||
### Stream / cancellation
|
||||
|
||||
- application boundary는 `ByteSource.stream(signal): AsyncIterable<BrowserDataResult<Uint8Array>>`를 유지한다. 첫 failure에서 producer/reader/writer를 모두 닫고 raw DOMException/EOF 성공으로 바꾸지 않는다.
|
||||
- save stream은 backpressure를 따르고 `writer.close()` 완료 truth가 늦은 abort보다 우선한다. partial destination append/resume로 주장하지 않는다.
|
||||
- Blob/object URL buffer는 hard cap 아래 fallback에서만 허용한다. public cache는 exact length/digest 검증 때문에 bounded buffer를 유지하되 cap을 넘으면 reader cancel.
|
||||
- pre-start abort는 side effect 0. IDB 중간 abort는 tx abort. irreversible prompt/persist/close가 완료된 뒤에는 platform truth가 이긴다.
|
||||
- worker mutation timeout은 effect unknown이지 rollback 확인이 아니다. read RPC는 응답을 버릴 수 있지만 mutation은 journal/explicit abort protocol로 종결한다.
|
||||
|
||||
### Worker protocol
|
||||
|
||||
- versioned handshake, request kind echo, requestId+kind correlation, strict discriminated parser, closed error set을 채택한다.
|
||||
- wrong version/schema는 `INCOMPATIBLE` health로 write/read 금지. 이를 failure surface에 노출할 필요가 있으면 `BrowserDataFailureCode`에 `INCOMPATIBLE`을 추가하고 모든 exhaustive mapper/fixture를 함께 갱신한다. 단순 `UNAVAILABLE` retry loop로 숨기지 않는다.
|
||||
- generic `CANCEL_REQUEST`는 read CPU/resource 최적화로 후순위. PUT correctness는 transaction-scoped `ABORT_PUT`과 durable journal이 담당한다.
|
||||
|
||||
### Cache security / eviction
|
||||
|
||||
- anonymous same-origin public GET, credentials omit, exact query/request headers/Vary/type/length/digest만 cache한다. auth/private/no-store/no-cache/opaque/redirect/206/range는 계속 금지한다.
|
||||
- verified marker는 모든 entries 이후 마지막에 쓰되 marker만 증거로 믿지 않는다. stage reuse와 activation/lookup에서 response를 재검증한다.
|
||||
- current+verified previous release를 유지하고 rollback도 동일 activation validation을 다시 통과한다.
|
||||
- partial eviction/miss는 `STORAGE_EVICTED` 또는 integrity failure로 fail-closed하고 network rehydrate한다. owned prefix 밖 cache나 user data는 절대 삭제하지 않는다.
|
||||
|
||||
## 4. 정확한 파일 변경 계획
|
||||
|
||||
### Phase 0 — 즉시 correctness/security fix
|
||||
|
||||
**수정**
|
||||
|
||||
- `src/application/ports/browser-file-storage/opfs-ports.ts`: v1|v2 prepared object read union, `OpfsPhysicalGenerationId`, journal row physical identity.
|
||||
- `src/adapters/storage/opfs/opfs-worker-protocol.ts`: explicit abort/cleanup effect, protocol v2 envelope/kind correlation.
|
||||
- `src/adapters/storage/opfs/opfs-worker-client.ts`: fire-and-forget duplicate abort 제거, strict response parser, coordinator-owned confirmed abort.
|
||||
- `src/adapters/storage/opfs/opfs-worker-runtime.ts`: tokenized physical path/receipt/manifest, cleanup lock 보유, exact token delete.
|
||||
- `src/adapters/storage/opfs/opfs-byte-store-adapter.ts`: cleanup result 확인 전 journal rollback 금지; independent compensation deadline; unknown effect reconcile.
|
||||
- `src/adapters/storage/opfs/indexeddb-opfs-journal.ts`: v2 prepared/journal validation, incomplete row 유지 및 migration metadata.
|
||||
- `tests/unit/opfs-byte-store.test.ts`, `tests/unit/opfs-worker-runtime.test.ts`, `tests/unit/indexeddb-opfs-journal.test.ts`: 아래 race/crash tests.
|
||||
- `src/adapters/browser-files/download-delivery-adapter.ts`: boolean validator를 canonical resolver로 변경; absolute URL 실행.
|
||||
- `tests/unit/browser-file-download.test.ts`: canonical URL 및 hostile base regression.
|
||||
- `src/adapters/cache-storage/public-cache-policy.ts`: Vary preservation cross-field invariant.
|
||||
- `src/adapters/cache-storage/public-response-cache-adapter.ts`: stage candidate full verify/self-repair, availability 분리.
|
||||
- `tests/unit/public-response-cache.test.ts`: custom Vary, damaged candidate, no-fetcher activate/cleanup.
|
||||
|
||||
### Phase 1 — bounded lifecycle / protocol / preview promotion
|
||||
|
||||
**생성**
|
||||
|
||||
- `src/application/ports/browser-file-storage/origin-storage-lifecycle-port.ts`
|
||||
- `src/adapters/storage/origin-storage-lifecycle-coordinator.ts`
|
||||
- `tests/unit/origin-storage-lifecycle-coordinator.test.ts`
|
||||
- `src/adapters/browser-files/browser-image-preview-probe.ts`
|
||||
- `tests/unit/browser-image-preview-probe.test.ts`
|
||||
- `src/adapters/storage/opfs/opfs-physical-migration.ts`
|
||||
- `tests/unit/opfs-physical-migration.test.ts`
|
||||
- `tests/fixtures/origin-storage/opfs-v1-populated.ts`
|
||||
- `tests/fixtures/origin-storage/cache-v1-populated.ts`
|
||||
|
||||
**수정**
|
||||
|
||||
- `src/application/ports/browser-file-storage/index.ts`: 새 lifecycle port export.
|
||||
- `src/application/ports/browser-file-storage/file.ts`: preview safety policy/result를 native-free 형태로 추가하거나 probe를 adapter-internal dependency로 유지.
|
||||
- `src/application/ports/browser-file-storage/cache-storage-ports.ts`: bounded maintenance page/cursor input.
|
||||
- `src/application/ports/browser-file-storage/indexeddb-port.ts`, `src/adapters/storage/indexeddb/indexeddb-types.ts`: drain lease contract.
|
||||
- `src/adapters/storage/indexeddb/indexeddb-maintenance.ts`: commit reserve/deadline checks 및 lease revalidation.
|
||||
- `src/adapters/browser-files/object-url-lease.ts`, `src/adapters/browser-files/create-browser-file-runtime.ts`: probe success 전 URL 생성 금지.
|
||||
- `src/adapters/cache-storage/public-response-cache-adapter.ts`: cursor/deadline bounded inspect/cleanup.
|
||||
- `src/adapters/storage/opfs/browser-opfs-runtime.ts`: real worker/lock/journal/write-read-delete-cleanup preflight 조립 hook.
|
||||
- `tests/browser-capabilities/{browser-files,opfs-runtime,public-cache-storage,indexeddb-runtime}.spec.ts`와 `opfs-test.worker.ts`: real engine evidence.
|
||||
- `docs/architecture/browser-file-and-origin-storage.md`, `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md`, `docs/operations/browser-file-storage-recovery.md`, `docs/operations/client-cache-and-storage-recovery.md`: 상태를 구현 후에만 `AVAILABLE_NOT_COMPOSED`로 승격.
|
||||
|
||||
**삭제/이동**: 없음. v1 reader/fixtures와 old cache prefix는 rollback window 종료 전 삭제하지 않는다. barrel 재배치도 불필요하다.
|
||||
|
||||
### Cache bounded port signature
|
||||
|
||||
```ts
|
||||
declare const publicCacheCursorBrand: unique symbol;
|
||||
export type PublicCacheMaintenanceCursor = string & {
|
||||
readonly [publicCacheCursorBrand]: "PublicCacheMaintenanceCursor";
|
||||
};
|
||||
|
||||
export type PublicCacheMaintenanceInput = Readonly<{
|
||||
maxCaches?: number; // default 100, absolute 500
|
||||
maxDurationMs?: number; // default 5_000, absolute 30_000
|
||||
cursor?: PublicCacheMaintenanceCursor;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type PublicCacheMaintenancePage = Readonly<{
|
||||
inspectedCaches: number;
|
||||
deletedCaches: number;
|
||||
retainedCaches: number;
|
||||
unreadableCaches: number;
|
||||
nextCursor: PublicCacheMaintenanceCursor | null;
|
||||
moreAvailable: boolean;
|
||||
deadlineReached: boolean;
|
||||
}>;
|
||||
|
||||
cleanupOwned(input?: PublicCacheMaintenanceInput):
|
||||
Promise<BrowserDataResult<PublicCacheMaintenancePage>>;
|
||||
inspectOwned(input?: PublicCacheMaintenanceInput):
|
||||
Promise<BrowserDataResult<PublicCacheMaintenancePage>>;
|
||||
```
|
||||
|
||||
cursor는 caller-readable cache name이 아니며 owned prefix, active pointer epoch, policy fingerprint에 서명/opaque binding한다. stale cursor는 `STALE_RESULT`.
|
||||
|
||||
## 5. TDD 계획: 이름, 입력, 기대 결과
|
||||
|
||||
| 테스트 이름 | 핵심 입력/fixture | 기대 결과 |
|
||||
| --- | --- | --- |
|
||||
| `keeps PREPARING journal when compensating cleanup is aborted or unavailable` | `preparePut` failure; caller signal aborted; worker cleanup `ABORTED/UNAVAILABLE` | `journal.rollback` 미호출, reservation/journal 유지, `OBJECT_RECONCILE` recovery; 후속 same object begin conflict |
|
||||
| `delayed stale cleanup cannot delete a reused logical generation` | T1 generation 1 abort RPC 지연; T2 generation 1 v2 token으로 commit; T1 cleanup resume | T1 token path만 제거; T2 verify/open bytes 성공; T2 manifest/chunks 유지 |
|
||||
| `holds the OPFS mutation lease until exact physical cleanup completes` | cleanup delete promise를 gate하고 concurrent begin 시도 | delete 완료 전 T2 lease 미획득; release 후 진행 |
|
||||
| `does not roll back journal after an unknown worker mutation effect` | cleanup RPC timeout 후 worker operation pending | incomplete journal 유지; reconcile가 exact transaction을 종결 |
|
||||
| `rejects mismatched OPFS worker protocol and response kind` | v1 response 또는 requestId는 같지만 wrong kind/malformed failure | `INCOMPATIBLE`/closed failure; pending request success로 resolve하지 않음; write side effect 0 |
|
||||
| `hands off the canonical URL validated against baseOrigin` | `href="downloads/a"`, baseOrigin app, document base evil | host receives `https://app.example/downloads/a`; evil URL never assigned |
|
||||
| `rejects a policy that enables variants but strips Vary` | allowed vary `accept-language`, allowed response headers without `vary`, STRIP | composition `TypeError`, Cache/fetch side effect 0 |
|
||||
| `preserves Vary for every stored custom variant` | en/ko same URL with exact request header | stage+activate+both exact match succeed; stored response has Vary |
|
||||
| `restages an evicted entry even when the release marker remains` | successful stage 후 one asset delete, same manifest stage again | missing asset re-fetch; all entries reverify; success only after repair |
|
||||
| `activates and cleans a prestaged cache without a fetcher` | seeded valid cache/pointer, cacheStorage+lock, no fetcher | activate/cleanup success; no network call |
|
||||
| `stops codec migration commit at the cooperative deadline` | fake clock advances during IDB record callbacks, prepared N rows | only last atomically safe prefix+checkpoint commit; `MORE`, `budgetExhausted`; no orphan sidecar/budget delta |
|
||||
| `requires an old-writer drain lease to remain valid before batch commit` | lease valid at acquire, expires before commit | tx write 0/abort; `BLOCKED`; checkpoint unchanged |
|
||||
| `rejects oversized raster dimensions before object URL creation` | small encoded PNG with huge width/height or overflow dimensions | `LIMIT_EXCEEDED/POLICY_REJECTED`; `createObjectURL` 0 calls |
|
||||
| `closes a decoded bitmap on preview abort and failure` | probe aborts after native decode begins | bitmap `close` once, URL 0, closed `ABORTED` |
|
||||
| `rejects animated and truncated preview containers` | animated WebP/GIF, truncated PNG/JPEG | fail before URL, no leaked decoder resource |
|
||||
| `pages cache cleanup by count deadline and opaque cursor` | 700 owned caches + foreign caches; max 100/5s | <=100 inspected, foreign untouched, `moreAvailable`, bound cursor; repeated pages converge |
|
||||
| `rejects cache maintenance cursor after active pointer epoch changes` | page1 cursor 후 activation | `STALE_RESULT`, delete 0 |
|
||||
| `retries quota failure exactly once only after productive GC` | reconstructable write quota fail, GC deleted >0, same idempotency/digest | attempt 2 최대 한 번; second fail no third; user-authored untouched |
|
||||
| `uses the real Window receiver for enhanced system pickers` | actual browser `window.showOpenFilePicker/showSaveFilePicker` facade (feature-gated) | supported engine에서 illegal invocation 없음; dismissal closed outcome |
|
||||
|
||||
### 실행 명령
|
||||
|
||||
```bash
|
||||
# 가장 빠른 red/green loop
|
||||
corepack pnpm exec vitest run \
|
||||
tests/unit/opfs-byte-store.test.ts \
|
||||
tests/unit/opfs-worker-runtime.test.ts \
|
||||
tests/unit/indexeddb-opfs-journal.test.ts \
|
||||
tests/unit/browser-file-download.test.ts \
|
||||
tests/unit/public-response-cache.test.ts \
|
||||
tests/unit/indexeddb-maintenance.test.ts \
|
||||
tests/unit/browser-image-preview-probe.test.ts \
|
||||
tests/unit/origin-storage-lifecycle-coordinator.test.ts
|
||||
|
||||
# 정적 경계
|
||||
corepack pnpm check:types
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm check:browser-file-storage-boundaries
|
||||
corepack pnpm lint
|
||||
|
||||
# 실제 browser/storage semantics
|
||||
corepack pnpm exec playwright test --config playwright.capabilities.config.ts \
|
||||
tests/browser-capabilities/browser-files.spec.ts \
|
||||
tests/browser-capabilities/indexeddb-runtime.spec.ts \
|
||||
tests/browser-capabilities/opfs-runtime.spec.ts \
|
||||
tests/browser-capabilities/public-cache-storage.spec.ts \
|
||||
tests/browser-capabilities/storage-manager.spec.ts
|
||||
|
||||
# 전체 회귀
|
||||
corepack pnpm test:unit
|
||||
corepack pnpm test:browser-file-storage-removal
|
||||
corepack pnpm verify:documentation
|
||||
```
|
||||
|
||||
## 6. 데이터 호환성, migration, deployment, rollback 순서
|
||||
|
||||
1. **즉시 containment:** 제품에 OPFS v1 write가 조립돼 있다면 kill switch로 신규 write를 read-only/export-required로 전환한다. read/export와 journal reconcile는 유지한다. file/IDB/OPFS/cache가 template bootstrap 기본 조립이 아니라는 사실은 영향 범위를 줄이지만 product-specific composition을 확인해야 한다.
|
||||
2. **N expand release:** journal DDL을 additive upgrade하고 v1+v2 `OpfsPreparedObject` reader를 배포한다. worker protocol v2 handshake를 먼저 넣되 v1 data read는 지원한다. v2 physical path는 unique token을 포함하고 새 write만 v2로 쓴다.
|
||||
3. **old writer drain:** 모든 N-1 page/worker가 write를 중단했다는 release/lease evidence를 확인한다. BroadcastChannel hint만으로 판단하지 않는다. v2 write traffic은 SHADOW/canary부터 연다.
|
||||
4. **resume/reconcile:** PREPARING/FILES_READY v1 journal을 bounded하게 처리한다. cleanup effect가 불명확하면 row를 삭제하지 않는다. logical committed v1은 authority이며 in-place 수정하지 않는다.
|
||||
5. **copy-on-write migration:** v1 committed object → v2 staging/token path → bounded chunk read/copy → manifest/tree digest verify → journal generation/fencing CAS publish. publish 전 crash는 v1, publish 후 crash는 v2가 authority다.
|
||||
6. **Cache migration:** old active verified release를 byte rewrite하지 말고 새 prefix/control schema에 network restage → full verify → explicit activation. current+previous와 old prefix를 rollback/grace window 동안 유지한다.
|
||||
7. **Web Storage:** current keys는 registry `DISCARD` semantics를 유지한다. adjacent migration이 제품에 필요할 때만 exact owned old physical key를 read-once/validate/write-current/delete-old한다. 전체 localStorage sweep 금지.
|
||||
8. **Canary observation:** multi-tab/worker timeout, crash between every phase, partial eviction, quota fault, N-1 read-only/online-only fixture를 통과한다. user-authored bytes export/sync path도 확인한다.
|
||||
9. **Rollback:** traffic admission과 새 writer부터 끈다. schema/database version을 내리지 않는다. compatible N reader 또는 N-1 online-only/read-only bundle로 전환하고, OPFS는 publish authority에 따라 v1/v2 source를 선택한다. Cache는 검증된 previous release로 같은 activate protocol을 실행한다.
|
||||
10. **Contract release:** 모든 active/rollback clients drain, grace/authority evidence, historical fixtures 후에만 v1 physical generation/old cache prefix를 bounded cursor cleanup한다. DB/root/cache blanket delete는 하지 않는다.
|
||||
|
||||
## 7. 유지해야 할 좋은 설계
|
||||
|
||||
- closed `BrowserDataResult`, safe recovery vocabulary, observer exception 격리 및 PII/path/name 비노출.
|
||||
- File policy가 composition-owned immutable identity이고 selection/inspection/preview/download receipt가 exact file/profile에 binding되는 구조.
|
||||
- native input baseline과 optional enhanced picker 분리, user activation 전에 await하지 않는 규칙, dismissal과 failure 구분.
|
||||
- 중앙 object URL lease cap, idempotent revoke/dispose, typed Blob, active-content denylist.
|
||||
- `ByteSource` chunk별 Result/cancellation, download backpressure, close 완료 truth, bounded object URL fallback.
|
||||
- Web Storage의 typed registry, physical key versioning, strict exact JSON codec, TTL, quota memory overlay와 tombstone.
|
||||
- IndexedDB의 opaque physical identity/governance binding, additive-only planner, transaction-complete semantics, CAS/idempotency/retention/budget atomicity, versionchange late-close.
|
||||
- OPFS의 IDB logical authority, phase journal, immutable digest chunks/refcount, hard budget reservation, fail-closed staging GC, no user-readable physical paths.
|
||||
- Cache의 anonymous public-only same-origin policy, exact query/header/Vary/type/length/digest, marker-last candidate, explicit activation, current+previous retention, owned-prefix-only cleanup, read/activate 재검증.
|
||||
- optional adapters를 bootstrap에서 자동 조립하지 않고 `AVAILABLE_NOT_COMPOSED`로 남긴 현재 composition posture.
|
||||
|
||||
## 8. 기존 테스트·문서 대조와 false-positive 경계
|
||||
|
||||
### 실행한 기존 검증
|
||||
|
||||
다음 명령을 이 리뷰 중 실행했고 **7 files / 105 tests 전부 통과**했다.
|
||||
|
||||
```bash
|
||||
corepack pnpm exec vitest run \
|
||||
tests/unit/opfs-byte-store.test.ts \
|
||||
tests/unit/opfs-worker-runtime.test.ts \
|
||||
tests/unit/public-response-cache.test.ts \
|
||||
tests/unit/browser-file-download.test.ts \
|
||||
tests/unit/indexeddb-maintenance.test.ts \
|
||||
tests/unit/indexeddb-runtime.test.ts \
|
||||
tests/unit/storage-registry.test.ts --reporter=default
|
||||
```
|
||||
|
||||
이는 finding이 현재 green suite가 보호하지 않는 interleaving/custom-policy/browser-base case임을 뜻하며, 기존 behavior가 전반적으로 깨졌다는 뜻은 아니다.
|
||||
|
||||
### 반증/과장 방지 표
|
||||
|
||||
| 의심 항목 | 기존 증거 | 최종 판단 |
|
||||
| --- | --- | --- |
|
||||
| OPFS commit 뒤 finalize cleanup 실패 | `opfs-byte-store.test.ts:504-540`가 COMMITTED row 보존 검증 | 보호됨. STO-01은 **commit 전 cleanup 실패/늦은 RPC + generation reuse**로 좁힘. |
|
||||
| OPFS concurrent operations | `opfs-worker-runtime.test.ts:232-408`가 lock wait cancel, APPEND/ABORT, authority isolation 검증 | 같은 worker의 active put 일부는 보호됨. journal 조기 rollback 후 cross-context late cleanup은 미검증. |
|
||||
| Cache Vary가 곧 private leak | activation/lookup이 response를 재검증(`public-response-cache-adapter.ts:592-617,341-365`) | 직접 disclosure 주장은 철회. stage success/variant loss/activation availability 결함으로 Medium. |
|
||||
| Cache 기본 policy Vary | default response allowlist에 `vary` 포함(`public-cache-policy.ts:54-63`), unit `1030-1155` green | 기본은 보호됨. custom policy cross-field invariant만 결함. |
|
||||
| damaged cache가 active로 publish | activation full reverify | publish는 fail-closed. STO-04는 idempotent stage/self-repair contract. |
|
||||
| Web Storage schema mismatch | `storage-registry.test.ts:231-244`가 current physical key의 old envelope discard 검증 | 보호됨. old **physical key** sweep/adjacent migration은 문서상 미구현이며 현재 작은 preference의 readiness gap. |
|
||||
| IndexedDB transaction success/abort | `indexeddb-runtime.test.ts:319-380`가 commit failure rollback과 abort 검증 | 보호됨. STO-06은 migration commit-loop duration budget에 한정. |
|
||||
| IndexedDB old-writer drain이 전혀 없음 | maintenance test `269-301`, docs `604-609`가 provider confirmation을 전제 | 현 계약상 provider 책임이므로 결함으로 세지 않음. temporal lease는 enforceability 강화. |
|
||||
| preview decode safety가 몰래 누락 | `browser-file-and-origin-storage.md:360-365`, VD-15 `19-31,574+`, runbook `96-108`가 미구현을 명시 | regression 아님. 제품 preview promotion blocker(GAP-01). |
|
||||
| Cache unbounded cleanup이 발견되지 않은 bug | VD-15 `486-515`, runbook `382-429`가 정확히 명시 | known `DESIGNED_NOT_IMPLEMENTED` readiness gap(GAP-02). |
|
||||
| origin pressure/migration coordinator 부재 | VD-15 `19-31,90-103`, `browser-file-storage-recovery.md:10-14` | known gap. 기존 per-store maintenance를 coordinator로 오인하지 않는다. |
|
||||
| optional adapters가 bootstrap에 없음 | `runtime-adapters.ts:260-266`; docs status `AVAILABLE_NOT_COMPOSED` | 의도된 skeleton posture, 결함 아님. |
|
||||
| picker receiver | unit tests가 모두 arrow/fake callback을 사용 | 확정 증거 부족. STO-08은 browser test 선행의 낮은 심각도 hypothesis로 격리. |
|
||||
|
||||
## 9. 리뷰 범위 밖으로 확장하지 않은 항목
|
||||
|
||||
- Service Worker lifecycle, private/range cache, persistent directory/file handles, Range resumable download는 문서상 별도 `NOT_SELECTED`/`DESIGNED_NOT_IMPLEMENTED` capability다. public cache/file adapter에 섞어 고치지 않는다.
|
||||
- application/product dataset, schema, rollout authority가 없으므로 optional IndexedDB/OPFS/Cache를 현재 default bootstrap에 새로 조립하지 않는다.
|
||||
- 전체 origin eviction은 모든 IndexedDB/OPFS/Cache metadata가 함께 사라질 수 있어 client-only로 완전 판별할 수 없다. server rehydrate/export UX와 generation/session authority가 필요하다.
|
||||
|
||||
---
|
||||
|
||||
최종 권고: STO-01은 production composition이 하나라도 있으면 release blocker로 취급한다. STO-02는 작은 canonicalization patch로 즉시 닫을 수 있다. Cache 세 항목은 동일 변경 묶음으로 TDD하고, VD-15 gap들은 상태 문서를 먼저 바꾸지 말고 executable unit+browser evidence와 rollback fixture가 생긴 후에만 승격한다.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Adapter Review — Browser Transfer
|
||||
|
||||
> 검토 기준: `develop` / `4dc033c` (2026-08-13)
|
||||
>
|
||||
> 범위: `src/adapters/browser-transfer/**`, 직접 연결된 application port, unit test, `docs/architecture/presigned-transfer-and-image-cdn.md`
|
||||
|
||||
## 결론
|
||||
|
||||
브라우저 전송 계열은 URL·header·subscription material을 application/presentation에서 차단하고, identity capability와 strict decoder를 사용하는 방향이 좋다. 특히 presigned single-use vault, multipart checkpoint CAS, image preset registry와 private descriptor 서명 검증은 유지해야 한다.
|
||||
|
||||
다만 실제 조합 전에 해결해야 할 P1 항목이 세 개 있다.
|
||||
|
||||
1. presigned download는 `open()`에서 이미 fetch와 timeout을 시작하지만 반환된 source에는 `close()`가 없다. 호출자가 stream을 늦게 열거나 열지 않으면 정상 API 사용만으로 body/timeout 자원이 방치된다 (`BT-PRE-01`).
|
||||
2. IndexedDB checkpoint partition 삭제는 `BLOCKED`를 반환한 뒤에도 native `deleteDatabase()`가 늦게 commit될 수 있다. 반환 결과가 실제 effect certainty를 표현하지 못한다 (`BT-UP-03`).
|
||||
3. presigned capability wire envelope에는 top-level protocol literal이 없다. 이미 아키텍처 문서가 요구한 `PRESIGNED_TRANSFER_V1`을 실제 request/response decoder가 아직 강제하지 않는다 (`BT-PRE-02`).
|
||||
|
||||
파일 크기만을 이유로 나누면 안 되지만, `resumable-upload-runtime.ts` 2,196줄과 `image-cdn-runtime.ts` 1,340줄은 각각 state transition, I/O orchestration, retry, persistence, presentation projection을 동시에 소유한다. characterization test를 먼저 고정한 뒤 State Machine·Saga·Strategy 경계로 분리하는 것이 안전하다.
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 표기 | 의미 |
|
||||
| --- | --- |
|
||||
| P1 | 조합 또는 배포 전에 수정. 결과 거짓 보고, 보안/정합성, 자원 수명주기 결함 |
|
||||
| P2 | 다음 리팩터링 묶음에서 수정. 계약 모호성, 실패 격리, 유지보수 위험 |
|
||||
| P3 | 동작을 고정한 뒤 정리. 테스트 seam, 중복, 가독성 |
|
||||
| `VERIFIED_DEFECT` | 현재 코드 경로만으로 재현 가능한 결함 |
|
||||
| `CONTRACT_GAP` | provider/consumer 간 의미가 타입이나 decoder에 충분히 고정되지 않음 |
|
||||
| `REFACTOR` | 현재 외부 동작은 보존하면서 내부 책임을 재배치 |
|
||||
| `PLANNED_GAP` | 기존 아키텍처 문서가 이미 미구현으로 선언한 항목. 현재 구현의 회귀로 계산하지 않음 |
|
||||
| `KEEP` | 의도와 테스트가 일치하므로 변경하지 않음 |
|
||||
|
||||
## 전체 파일 판정
|
||||
|
||||
| 모듈 | 현재 역할 | 판정 | 후속 항목 |
|
||||
| --- | --- | --- | --- |
|
||||
| `browser-transfer/index.ts` | 하위 capability export | KEEP | public export 증가는 각 capability 계획에서만 수행 |
|
||||
| `presigned/index.ts` | presigned public surface | KEEP | `BT-PRE-04`에서 vault issuer 노출만 축소 검토 |
|
||||
| `presigned/incremental-sha256.ts` | streaming SHA-256 | KEEP | WebCrypto `digest()`로 바꾸면 전체 buffering이 되므로 교체 금지 |
|
||||
| `presigned/presigned-capability-http-provider.ts` | BFF capability 발급, strict decode | CONTRACT_GAP | `BT-PRE-02`, `BT-PRE-03`, `BT-X-01` |
|
||||
| `presigned/presigned-capability-vault.ts` | identity capability 보관/폐기 | REFACTOR | `BT-PRE-04` |
|
||||
| `presigned/presigned-transfer-executor.ts` | GET stream/PUT part 실행 | VERIFIED_DEFECT | `BT-PRE-01`, `BT-PRE-03`, `BT-X-01` |
|
||||
| `resumable-upload/checkpoint-schema.ts` | durable schema guard | KEEP | schema V1 golden fixture 유지 |
|
||||
| `resumable-upload/fetch-json-transport.ts` | bounded JSON control transport | VERIFIED_DEFECT | `BT-UP-01`, `BT-UP-02`, `BT-X-01` |
|
||||
| `resumable-upload/http-control-plane-adapter.ts` | operation별 wire decoder | KEEP/REFACTOR | runtime 분리 뒤 decoder만 남김 |
|
||||
| `resumable-upload/index.ts` | resumable public surface | KEEP | facade 호환 유지 |
|
||||
| `resumable-upload/indexeddb-checkpoint-store.ts` | scope-bound CAS store/admin | VERIFIED_DEFECT | `BT-UP-03` |
|
||||
| `resumable-upload/presigned-upload-part-executor.ts` | multipart와 presigned bridge | VERIFIED_DEFECT | `BT-UP-04` |
|
||||
| `resumable-upload/resumable-upload-runtime.ts` | session state/retry/part scheduling/commit | REFACTOR | `BT-UP-05`, `BT-UP-06` |
|
||||
| `resumable-upload/runtime-policy.ts` | hard bound snapshot | KEEP | 값 변경은 contract migration으로만 수행 |
|
||||
| `resumable-upload/upload-byte-source.ts` | stream/range source snapshot과 hashing | KEEP/REFACTOR | runtime에서 source preparation Strategy로 주입 |
|
||||
| `resumable-upload/upload-cancellation-channel.ts` | best-effort cross-context cancel hint | KEEP | backend/CAS가 authority라는 주석과 동작 유지 |
|
||||
| `resumable-upload/upload-mutation-lock.ts` | Web Lock exclusive mutation | PLANNED_GAP | `BT-UP-07` |
|
||||
| `image-cdn/README.md` | 안전한 composition 예제 | KEEP | resolve signal 결정 반영 필요 |
|
||||
| `image-cdn/browser-image-probe.ts` | bounded fetch/header/static decode probe | VERIFIED_DEFECT | `BT-IMG-02` |
|
||||
| `image-cdn/image-cdn-policy.ts` | origin/preset/hard-limit registry | KEEP | composition-owned identity reference 유지 |
|
||||
| `image-cdn/image-cdn-runtime.ts` | asset acceptance, signature, URL/projection | REFACTOR | `BT-IMG-01`, `BT-IMG-03` |
|
||||
| `image-cdn/image-header-metadata.ts` | PNG/JPEG/WebP/AVIF static header parser | KEEP | 별도 fuzz/golden corpus로 보호; 작은 parser로 임의 분해 금지 |
|
||||
| `image-cdn/p256-image-capability-verifier.ts` | P-256 P1363 verifier | KEEP | key overlap contract 유지 |
|
||||
| `image-cdn/index.ts` | image public surface | KEEP | descriptor provider가 생길 때만 export 확장 |
|
||||
|
||||
직접 연결 경계도 다음과 같이 대조했다: `src/application/ports/browser-transfer/authorized-download.ts`, `src/application/ports/browser-transfer/image-cdn.ts`, `src/application/ports/browser-transfer/presigned-transfer.ts`, `src/application/ports/browser-transfer/resumable-upload.ts`, barrel `src/application/ports/browser-transfer/index.ts`, 그리고 presigned source의 직접 consumer `src/adapters/browser-files/download-delivery-adapter.ts`. native URL/header/File/Response를 application port로 올리지 않는 방향은 유지하며, `BT-PRE-01`의 `close()` migration은 이 consumer까지 포함한다.
|
||||
|
||||
## Presigned transfer 상세
|
||||
|
||||
### BT-PRE-01 — `open()`이 반환되기 전에 download lease가 시작됨
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: `presigned-transfer-executor.ts:114-192`, `:408-631`
|
||||
- 현재 동작:
|
||||
- `openDownload()`이 capability를 claim/consume한 뒤 즉시 `fetch()`를 수행한다.
|
||||
- timeout scope도 `open()` 안에서 시작한다.
|
||||
- response body와 scope는 반환된 `PresignedDownloadByteSource.stream()`을 완주하거나 실패해야만 해제된다.
|
||||
- source port에는 `close()`/`dispose()`가 없다.
|
||||
- 영향:
|
||||
- 호출자가 source를 받은 뒤 stream 시작을 늦추면, 실제 consumer deadline이 아니라 `open()` 시점의 timeout으로 실패한다.
|
||||
- 호출자가 stream을 열지 않으면 body cancellation과 listener/timer cleanup을 명시적으로 수행할 방법이 없다.
|
||||
- capability는 이미 single-use로 소비되므로 동일 source를 복구할 수도 없다.
|
||||
|
||||
결정: **lazy, single-start lease로 변경한다.** `open()`은 policy/vault 검증과 capability consume까지만 수행하고 fetch는 첫 `stream(signal)` 진입 시 시작한다. source에 `close(): void`를 추가해 미사용 lease도 명시적으로 폐기한다. `close()`와 stream의 first-start는 하나의 state machine을 공유한다.
|
||||
|
||||
```ts
|
||||
type PresignedDownloadByteSource = Readonly<{
|
||||
byteLength: number;
|
||||
capability: PresignedDownloadCapability;
|
||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
|
||||
stream(signal: AbortSignal): AsyncIterable<BrowserDataResult<Uint8Array>>;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
type DownloadLeaseState = "READY" | "STREAMING" | "CLOSED";
|
||||
```
|
||||
|
||||
구현 규칙:
|
||||
|
||||
1. `READY -> STREAMING`만 fetch를 시작한다.
|
||||
2. `READY -> CLOSED`는 network I/O 없이 끝낸다.
|
||||
3. `STREAMING -> CLOSED`는 composed signal abort, reader/body cancel, timer/listener release를 한 번만 수행한다.
|
||||
4. 두 번째 `stream()`은 기존처럼 `CONFLICT / REISSUE_CAPABILITY`다.
|
||||
5. digest 성공 전 chunk는 현재의 `VERIFIED_ON_SUCCESSFUL_EXHAUSTION` 의미를 유지한다. consumer는 최종 success 전 파일을 commit하면 안 된다.
|
||||
6. 첫 `stream()` 직전에 capability expiry와 minimum remaining lifetime을 다시 확인하고, `open()` 때 받은 outer signal과 stream signal을 함께 적용한다. 오래 보관되어 만료된 source는 fetch를 시작하지 않는다.
|
||||
|
||||
테스트 추가 (`tests/unit/presigned-transfer.test.ts`):
|
||||
|
||||
- `does not fetch until the returned download source starts streaming`
|
||||
- `closes an unused source without issuing a request`
|
||||
- `starts the transfer deadline at first stream consumption`
|
||||
- `close during a pending read cancels the reader and releases listeners once`
|
||||
- `stream after close returns one terminal conflict without fetching`
|
||||
|
||||
마이그레이션: port에 `close()`를 추가한 뒤 직접 consumer인 `src/adapters/browser-files/download-delivery-adapter.ts`를 포함한 모든 consumer를 source 획득 직후 `try/finally { source.close(); }`로 감싼다. size reject, `createWritable()`/prompt 실패, object-URL strategy의 stream 전 실패도 `tests/unit/browser-file-download.test.ts`로 고정한다. 그 다음 fetch를 lazy로 옮긴다. rollback은 eager fetch 구현으로 되돌릴 수 있지만 `close()` API는 유지한다.
|
||||
|
||||
완료 조건: 위 테스트와 기존 presigned suite가 통과하고, source를 생성만 한 테스트에서 fetch 호출 수와 active timer가 모두 0이다.
|
||||
|
||||
### BT-PRE-02 — capability wire envelope의 protocol version 부재
|
||||
|
||||
- 우선순위/분류: **P1 / CONTRACT_GAP**, 기존 문서의 미완료 항목
|
||||
- 근거: `presigned-capability-http-provider.ts:173-210`, `:436-462`; `docs/architecture/presigned-transfer-and-image-cdn.md:93-108`
|
||||
- 현재 동작: request body와 strict response key set에 top-level transfer protocol이 없다. multipart binding 내부 protocol만으로 전체 capability envelope version을 식별한다.
|
||||
- 영향: 서버가 필드를 추가/재해석할 때 old/new client가 같은 shape를 서로 다른 의미로 받아들일 수 있다. strict decoder라서 단순 필드 추가도 곧바로 장애가 되지만, 장애가 version mismatch로 분류되지 않는다.
|
||||
|
||||
결정:
|
||||
|
||||
- request와 response에 `protocol: "PRESIGNED_TRANSFER_V1"`을 필수로 추가한다.
|
||||
- missing/unknown protocol은 현재 closed taxonomy의 `POLICY_REJECTED`, retryable `false`, recovery `REISSUE_CAPABILITY`로 닫는다. 이 변경에서 새 failure code를 만들지 않는다.
|
||||
- multipart의 `PRESIGNED_MULTIPART_V1`은 하위 binding protocol로 그대로 유지한다.
|
||||
- protocol은 `PresignedTransferCapability`, `PresignedCapabilityRegistration/Binding`, vault snapshot, executor common-binding validator까지 전파해 request → registration → consumption exact parity를 보장한다.
|
||||
- server는 request shape를 협상해 legacy request에는 legacy response, V1 request에는 V1 response를 반환한다. strict legacy decoder를 깨뜨리므로 legacy response에 V1 field를 먼저 emit하거나 한 response에 dual fields를 넣지 않는다.
|
||||
|
||||
테스트 추가:
|
||||
|
||||
- request body exact-key snapshot과 protocol literal
|
||||
- missing, V0, V2 protocol response 거절
|
||||
- V1 download와 V1 multipart capability 수락
|
||||
- protocol mismatch가 vault `register()` 전에 종료됨
|
||||
|
||||
배포 순서: request-shape negotiated provider 배포 → V1 client 배포 → old-client drain 기간 관찰 → provider legacy request/response 제거. rollback 시 provider는 두 request shape를 계속 수락하되 각각 matching exact response를 반환한다.
|
||||
|
||||
### BT-PRE-03 — timeout이 non-cooperative fetch를 실제로 bound하지 못함
|
||||
|
||||
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
|
||||
- 근거: capability provider `:186-214`, executor `presigned-transfer-executor.ts:157-190`, 각 파일의 `createAbortScope()`
|
||||
- 현재 동작: timer는 AbortController만 abort한다. injected fetcher 또는 host가 signal을 무시하면 `await fetcher(...)` 자체는 끝나지 않는다.
|
||||
- 영향: API가 선언한 timeout이 hard bound가 아니며, teardown도 fetch settlement에 묶인다.
|
||||
|
||||
결정: 공통 `AbortableOperationScope`의 `race(task, onLateValue)`를 사용한다 (`BT-X-01`). deadline/caller abort가 먼저 끝나면 즉시 typed failure를 반환하고, 늦게 온 `Response`는 body를 취소한다. timer 생성 실패 시 이미 붙인 external listener를 즉시 제거한다.
|
||||
|
||||
테스트 추가:
|
||||
|
||||
- signal을 무시하는 fetch Promise가 timeout 뒤에도 pending인 fixture
|
||||
- timeout 결과가 정시에 반환되고 late response body가 취소되는지 검증
|
||||
- scheduler `setTimeout`/`clearTimeout` throw 시 listener 누수와 public rejection이 없는지 검증
|
||||
|
||||
### BT-PRE-04 — vault가 스스로 registration invariant를 소유하지 않음
|
||||
|
||||
- 우선순위/분류: **P2 / REFACTOR**
|
||||
- 근거: `presigned-capability-vault.ts:112-174`
|
||||
- 현재 동작: HTTP provider가 URL, header, expiry, byte/digest를 검사하지만 exported vault의 `register()`는 전달받은 registration을 그대로 snapshot한다.
|
||||
- 영향: 다른 issuer adapter가 추가되거나 테스트/조합 코드가 vault를 직접 사용하면 동일한 capability 타입에 더 약한 invariant가 들어갈 수 있다.
|
||||
|
||||
결정: issuer/consumer 권한을 wiring 단계에서 분리하고 공통 invariant validator를 적용한다.
|
||||
|
||||
1. `createPresignedCapabilityVault()`는 `{ issuer: PresignedCapabilityIssuer; consumer: PresignedCapabilityConsumer }`를 반환한다. provider option에는 issuer만, executor option에는 consumer만 전달한다. root barrel에는 factory와 consumer-facing type만 export하고 issuer type은 provider의 구조적 parameter로 숨긴다.
|
||||
2. issuer 등록 직전 공통 `validatePresignedCapabilityRegistration()`으로 method/binding/URL/header/status/bytes/digest/expiry를 다시 검증한다.
|
||||
3. HTTP decoder는 wire-specific shape를 검사하고, vault validator는 runtime invariant만 검사한다. decoder 로직을 통째로 중복하지 않는다.
|
||||
|
||||
테스트: malformed registration을 직접 issuer seam에 넣는 table test와, HTTP provider의 valid 결과가 동일 snapshot으로 등록되는 parity test를 추가한다.
|
||||
|
||||
### BT-PRE-05 — encoded path의 provider 해석 차이
|
||||
|
||||
- 우선순위/분류: **P2 / SECURITY_HARDENING**
|
||||
- 근거: `presigned-capability-http-provider.ts:517-533`, `:1078-1097`
|
||||
- 현재 동작: literal `.`/`..`와 backslash는 거절하지만 `%2f`, `%5c`, `%25...` 같은 encoded separator가 object-store/CDN에서 한 번 더 decode되는지 계약이 없다.
|
||||
- 결정: raw `URL.pathname`의 각 segment를 strict UTF-8 percent-decode한다. decoded segment에서 `/`, backslash, NUL, `.`/`..`, 그리고 literal `%` 뒤 두 hex digit을 거절한 뒤, 대문자 percent-hex canonical encoder 결과와 raw segment를 비교한다. 이 규칙은 `%252e%252e` double encoding을 닫고 valid opaque UTF-8 segment는 허용한다. CDN/provider conformance fixture가 같은 canonicalizer를 사용한다.
|
||||
- 테스트: `%2F`, `%5C`, `%252e%252e`, mixed-case encoding, valid UTF-8 opaque segment를 포함한다.
|
||||
|
||||
## Resumable upload 상세
|
||||
|
||||
### BT-UP-01 — AbortSignal 구조 검증과 cleanup 사용이 불일치
|
||||
|
||||
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
|
||||
- 근거: `fetch-json-transport.ts:548-582`, `:670-677`
|
||||
- 현재 동작: `isAbortSignal()`은 `aborted`와 `addEventListener`만 검사하지만 `FetchAttempt.release()`는 `removeEventListener()`를 무조건 호출한다.
|
||||
- 영향: 구조적으로 허용된 signal이 finally에서 throw하여 typed result 대신 Promise rejection을 만든다.
|
||||
- 수정: native getter 기반 또는 최소한 `removeEventListener`까지 포함한 공통 guard를 사용하고, release cleanup은 terminal result를 덮지 않도록 catch한다.
|
||||
- 테스트: remove가 없는 structural fake는 입력에서 `INVALID_INPUT`; remove가 cleanup 중 throw하는 hostile facade는 typed terminal result를 보존.
|
||||
|
||||
### BT-UP-02 — transport clock/scheduler가 전역에 고정됨
|
||||
|
||||
- 우선순위/분류: **P3 / REFACTOR**
|
||||
- 근거: `fetch-json-transport.ts:571-580`, `:626-636`
|
||||
- 현재 동작: request timeout은 global timer, HTTP-date `Retry-After`는 `Date.now()`를 직접 사용한다.
|
||||
- 결정: dependencies에 `clock.now()`와 `scheduler`를 추가하고 snapshot/validate한다. delta-seconds와 HTTP-date parsing은 같은 captured `now`를 사용한다.
|
||||
- 테스트: fake clock으로 경계값, clock rollback, invalid date, max clamp를 결정론적으로 검증.
|
||||
|
||||
### BT-UP-03 — `deleteDatabase()` timeout 뒤 late delete effect
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: `indexeddb-checkpoint-store.ts:375-439`
|
||||
- 현재 동작: `deletePartition()`은 `onblocked` 후 timer가 끝나면 `BLOCKED`를 반환한다. 그러나 IndexedDB delete request는 취소할 수 없고, 다른 tab이 닫히면 반환 이후 `onsuccess`로 실제 DB가 삭제될 수 있다.
|
||||
- 영향: caller가 `BLOCKED`를 `NOT_APPLIED`로 해석할 수 있지만 native request는 나중에 성공/실패할 수 있어 반환값과 effect certainty가 모순된다. 다른 realm의 open/delete ordering까지 현재 증거 없이 단정하지 않는다.
|
||||
|
||||
결정: delete dispatch 이후에는 failure certainty를 `NOT_APPLIED`로 표현하지 않는다. port outcome을 다음처럼 명시한다.
|
||||
|
||||
```ts
|
||||
type PartitionDeleteOutcome =
|
||||
| { state: "DELETED"; effect: "APPLIED" }
|
||||
| { state: "PENDING"; effect: "UNKNOWN"; reason: "BLOCKED_DEADLINE" };
|
||||
```
|
||||
|
||||
- pre-dispatch invalid/aborted/unsupported만 기존 failure다.
|
||||
- `PENDING`을 받은 runtime은 해당 store instance를 terminal closed로 유지한다. 같은 JS realm에서는 `(IDBFactory identity, databaseName)` pending-deletion registry가 새 factory 생성을 막고 late `onsuccess/onerror`에서 해제한다. 다른 realm은 native IndexedDB blocked ordering과 명시적 recovery UX로 처리하며 client-only global registry를 주장하지 않는다.
|
||||
- late `onsuccess`/`onerror`는 observer에 기록한다. 다시 확인하려면 별도 `inspectPartitionDeletion()` 또는 새 page generation에서 DB 목록/open 결과를 사용한다.
|
||||
- 단순히 timer를 제거해 무한 대기시키지는 않는다.
|
||||
|
||||
테스트 추가 (`tests/unit/resumable-upload-checkpoint.test.ts`): blocked deadline → PENDING → late success, blocked deadline → late error, PENDING 뒤 store method가 UNAVAILABLE, caller abort before dispatch, concurrent new runtime 금지.
|
||||
|
||||
### BT-UP-04 — bridge clock의 non-finite 값이 expiry 검사를 통과함
|
||||
|
||||
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
|
||||
- 근거: `presigned-upload-part-executor.ts:36-70`
|
||||
- 현재 동작: `now()`가 `NaN` 또는 음수이면 expiry 비교를 우회한다. `+Infinity`는 현재도 expiry 비교에서 거절되지만 dependency failure가 capability policy failure로 잘못 분류된다.
|
||||
- 수정: `Number.isSafeInteger(nowEpochMs) && nowEpochMs >= 0`을 먼저 검사하고 실패 시 `UNAVAILABLE / RESUME`을 반환한다.
|
||||
- 테스트: NaN/음수의 현재 bypass, +Infinity의 현재 rejection, 수정 후 모든 non-finite/negative clock의 `UNAVAILABLE / RESUME`, throw, 만료 경계 `expiresAt === now`, 유효 `now + 1`.
|
||||
|
||||
### BT-UP-05 — runtime의 상태 전이와 side effect가 한 파일에 결합됨
|
||||
|
||||
- 우선순위/분류: **P2 / REFACTOR**
|
||||
- 근거: `resumable-upload-runtime.ts` 2,196줄; session resolution, retry, hashing, scheduler, CAS, abort saga, validation과 telemetry를 함께 소유
|
||||
- 외부 facade는 유지하고 다음 내부 경계만 추출한다.
|
||||
|
||||
| 새 내부 모듈 | 책임 | 적용 패턴 |
|
||||
| --- | --- | --- |
|
||||
| `upload-session-state-machine.ts` | ACTIVE/ABORT_PENDING/completed transition의 순수 함수 | State Machine |
|
||||
| `upload-session-reconciler.ts` | local checkpoint와 server status 수렴 | Reconciler |
|
||||
| `upload-part-scheduler.ts` | memory/server/client concurrency와 receipt serialization | Bounded Work Queue |
|
||||
| `upload-retry-executor.ts` | retry budget, Retry-After, jitter, attempt deadline | Policy + Template Method |
|
||||
| `upload-abort-saga.ts` | local tombstone → backend abort → checkpoint removal | Saga/Compensation |
|
||||
| `resumable-upload-runtime.ts` | public facade, lifecycle, mutation lock orchestration만 | Facade |
|
||||
|
||||
추출 순서:
|
||||
|
||||
1. 기존 `tests/unit/resumable-upload-runtime.test.ts`에 observable call-order characterization를 추가한다.
|
||||
2. 순수 transition 함수와 table test를 먼저 만든다.
|
||||
3. retry executor, reconciler, part scheduler, abort saga 순서로 한 모듈씩 이동한다.
|
||||
4. 각 이동 뒤 기존 suite 전체를 그대로 실행한다. fixture expected 값을 리팩터링에 맞춰 바꾸지 않는다.
|
||||
|
||||
변경 금지:
|
||||
|
||||
- part idempotency key derivation
|
||||
- server-authoritative status reconciliation
|
||||
- accepted receipt의 순차 CAS persistence
|
||||
- checkpoint에 URL/credential을 저장하지 않는 규칙
|
||||
- first part failure 뒤 이미 시작한 sibling의 확정 receipt를 기다려 저장하는 현재 정책. 이를 즉시 cancel하면 remote success가 ambiguous해질 수 있으므로 별도 behavior change로 다룬다.
|
||||
|
||||
### BT-UP-06 — sync `close()`가 drain 완료를 증명하지 못함
|
||||
|
||||
- 우선순위/분류: **P2 / LIFECYCLE_REFACTOR**
|
||||
- 근거: `resumable-upload-runtime.ts:280-287`
|
||||
- 현재 동작: lifetime abort 직후 checkpoint store를 닫고 반환한다. native fetch/IDB가 signal에 반응해 정리될 것으로 기대하지만 caller는 active operation의 terminal settlement를 기다릴 수 없다.
|
||||
- 결정: application `ResumableUploadPort`의 `close(): void`는 admission을 닫고 같은 single-flight drain을 시작하는 호환 facade로 유지한다. adapter runtime lifecycle surface에 향후 composition owner가 `await`할 `dispose(): Promise<void>`를 추가한다. `dispose()`는 이미 시작된 drain promise를 공유하고 active operation registry를 abort한 뒤 bounded `allSettled` 후 store/channel을 닫는다. 현재 production bootstrap consumer가 있다고 가정하지 않는다.
|
||||
- 테스트: close 중 신규 admission 거절, active fetch/IDB abort, 중복 dispose single-flight, cleanup deadline, late provider success가 checkpoint를 다시 쓰지 못함.
|
||||
|
||||
### BT-UP-07 — Web Locks 비지원 정책이 composition 결과로 표현되지 않음
|
||||
|
||||
- 우선순위/분류: **P1 before composition / PLANNED_GAP**
|
||||
- 근거: `upload-mutation-lock.ts:19-58`; 아키텍처 completion ledger의 optional capability decision
|
||||
- 현재 동작: factory는 LockManager가 없으면 throw한다. multi-tab 안전성을 희생하는 in-memory fallback은 없다.
|
||||
- 결정: silent fallback은 추가하지 않는다. composition이 Web Locks 미지원 시 resumable upload capability를 `UNSUPPORTED`로 명시하고 일반 foreground upload 또는 재선택 UX로 degrade한다. 실제 지원 browser matrix가 확정되기 전 default composition에는 설치하지 않는다.
|
||||
|
||||
## Image CDN 상세
|
||||
|
||||
### BT-IMG-01 — resolve signal을 일관되게 필수화할지에 대한 API 단순화
|
||||
|
||||
- 우선순위/분류: **P3 / API CONSISTENCY DECISION**, 현재 동작 결함 아님
|
||||
- 근거: application port `image-cdn.ts:167-172`; runtime `image-cdn-runtime.ts:518-527`
|
||||
- 현재 동작: optional signal을 허용하고 `PRIMARY_REQUIRED` preset은 signal 부재를 명시적 `UNSUPPORTED`로 표현한다. 문서가 signal 없는 probe 성공을 약속하지 않으므로 defect는 아니다.
|
||||
- 결정: hidden preset precondition을 줄이기 위해 다음 major contract 정리에서 `resolve()` signal을 필수화한다. 이는 runtime correctness fix가 아니라 API consistency 개선이다.
|
||||
- 마이그레이션: `tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts`와 대응 typecheck script를 먼저 추가하고 모든 caller/README에 lifecycle signal을 전달한 뒤 port와 optional 분기를 바꾼다. P1/P2와 같은 PR에 섞지 않는다.
|
||||
|
||||
### BT-IMG-02 — Cache-Control quoted value parser가 malformed 값을 수락
|
||||
|
||||
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
|
||||
- 근거: `browser-image-probe.ts:272-373`
|
||||
- 현재 동작: `rawValue.replace(/^"|"$/gu, "")`는 한쪽 quote만 있는 `max-age="60` 또는 `max-age=60"`도 숫자 `60`으로 만들 수 있다.
|
||||
- 영향: probe가 malformed cache policy를 immutable public response로 승인할 수 있다.
|
||||
- 수정:
|
||||
- comma split 전에 quote/escape-aware tokenizer를 사용해 quoted extension의 comma를 directive 경계로 취급하지 않는다.
|
||||
- quoted-string은 시작/종료 quote가 모두 있고 escape/control 문자가 유효할 때만 unquote한다.
|
||||
- numeric directives는 unquoted digits 또는 완전한 quoted digits만 허용한다.
|
||||
- private response는 `no-store`가 필수이며 `public`, `private`, `immutable`, `max-age`, `s-maxage`, `no-cache`, `must-revalidate`, `proxy-revalidate`가 함께 있으면 fail-closed한다. 문법상 유효한 unknown extension만 무시한다.
|
||||
- parser는 중복 directive를 계속 거절한다.
|
||||
- 테스트: unmatched quote, escaped quote, duplicate, comma-in-quoted extension, contradictory public/private directives, valid quoted max-age.
|
||||
|
||||
### BT-IMG-03 — acceptance, verification, URL projection의 응집도 분리
|
||||
|
||||
- 우선순위/분류: **P3 / REFACTOR**
|
||||
- 근거: `image-cdn-runtime.ts` 1,340줄
|
||||
- facade와 WeakMap capability identity는 유지하고 다음 내부 모듈만 추출한다.
|
||||
|
||||
| 새 내부 모듈 | 책임 |
|
||||
| --- | --- |
|
||||
| `image-asset-decoder.ts` | public/private exact shape snapshot |
|
||||
| `image-capability-verification.ts` | canonical payload, digest, key verifier deadline |
|
||||
| `image-presentation-projector.ts` | candidate URL/srcset/descriptor 생성 |
|
||||
| `image-cdn-runtime.ts` | issued reference WeakMap, close, facade orchestration |
|
||||
|
||||
`image-header-metadata.ts`는 format parser라는 단일 책임을 이미 가진다. LOC만 보고 더 쪼개지 말고 fuzz corpus와 malformed container table을 보강한다.
|
||||
|
||||
### BT-IMG-04 — descriptor provider/refresh는 아직 구현 대상
|
||||
|
||||
- 우선순위/분류: **P1 before composition / PLANNED_GAP**
|
||||
- 근거: `docs/architecture/presigned-transfer-and-image-cdn.md:574-610`
|
||||
- 현재 상태: signature 검증/runtime/probe는 있으나 BFF에서 descriptor를 가져오고 single-flight refresh하는 provider와 `<picture>` renderer는 없다.
|
||||
- 결정: 현재 runtime을 직접 product composition에 노출하지 않는다. 향후 provider는 `protocol: "IMAGE_CDN_DESCRIPTOR_V1"`, exact authority/request binding, minimum remaining TTL, single-flight refresh, close-generation fence를 필수로 한다. renderer는 descriptor 필드만 투영하고 alt/error/placeholder 정책은 feature 소유로 둔다.
|
||||
|
||||
## 공통 개선
|
||||
|
||||
### BT-X-01 — abort/deadline/late-result mechanics 통합
|
||||
|
||||
- 우선순위: **P2 / REFACTOR**
|
||||
- 중복 근거: presigned provider/executor, image probe/runtime, resumable runtime, browser files, HTTP, Web Push에 `createAbortScope`, `combineAbortSignals`, `awaitWithAbort`, `readWithSignal` 변형이 반복된다.
|
||||
- 결정: result taxonomy는 각 adapter에 남기고 **mechanics만** `src/adapters/platform/abortable-operation.ts`로 추출한다.
|
||||
|
||||
필수 API:
|
||||
|
||||
```ts
|
||||
type AbortableOperationScope = Readonly<{
|
||||
signal: AbortSignal;
|
||||
terminal(): "OPEN" | "CALLER_ABORT" | "DEADLINE" | "CLOSED";
|
||||
race<T>(
|
||||
task: Promise<T>,
|
||||
onLateValue?: (value: T) => void,
|
||||
): Promise<
|
||||
| { kind: "VALUE"; value: T }
|
||||
| { kind: "TERMINAL"; terminal: "CALLER_ABORT" | "DEADLINE" | "CLOSED" }
|
||||
>;
|
||||
close(): void;
|
||||
}>;
|
||||
```
|
||||
|
||||
불변식:
|
||||
|
||||
- caller abort와 deadline 중 최초 하나만 terminal authority다.
|
||||
- `close()`는 idempotent하고 timer/listener cleanup throw를 삼킨다.
|
||||
- late rejection은 항상 관찰되어 unhandled rejection이 되지 않는다.
|
||||
- late `Response`/`ImageBitmap`/native handle은 caller가 제공한 compensator로 닫고 값을 버린다. 각 subsystem adapter가 `TERMINAL`을 자기 Result taxonomy로 변환한다.
|
||||
- 이 utility는 `BrowserDataResult`, `WebPushResult`, HTTP outcome을 import하지 않는다.
|
||||
|
||||
적용 순서: 새 utility golden test → presigned → image → resumable transport → 다른 adapter. 한 PR에서 모든 subsystem을 동시에 바꾸지 않는다.
|
||||
|
||||
## 유지해야 할 설계
|
||||
|
||||
- raw presigned URL/header가 application port를 통과하지 않고 identity capability vault 안에만 존재한다.
|
||||
- capability는 exact WeakMap identity이며 single-use claim 후 vault에서 제거된다.
|
||||
- upload byte는 hash/network await 전에 snapshot한다.
|
||||
- multipart checkpoint에는 URL, credential, capability material을 저장하지 않는다.
|
||||
- multipart receipt는 revision CAS로 순차 commit하고 server status가 복구 authority다.
|
||||
- cross-context cancellation은 hint일 뿐 backend idempotency/Web Lock/CAS를 대체하지 않는다.
|
||||
- public image는 revision rollover, private image는 signed expiry/revocation으로 구분한다.
|
||||
- private image는 exact signed URL, credential omit, no-store, static container와 decode budget을 확인한다.
|
||||
- composition hard limit은 adapter implementation ceiling보다 느슨해질 수 없다.
|
||||
- P-256 key overlap set과 terminal `close()` generation fence를 유지한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
1. `BT-UP-03`, `BT-PRE-01`, `BT-PRE-02`를 각각 독립 PR로 해결한다.
|
||||
2. `BT-X-01` utility golden test를 만들고 `BT-PRE-03`, `BT-UP-01`, `BT-UP-02`를 이관한다.
|
||||
3. `BT-UP-04`, `BT-IMG-01`, `BT-IMG-02`, `BT-PRE-04/05`를 작은 contract-hardening PR로 처리한다.
|
||||
4. behavior suite가 모두 green인 뒤 `BT-UP-05/06`, `BT-IMG-03` 구조 분리를 수행한다.
|
||||
5. 실제 product 선택이 있을 때만 `BT-UP-07`, `BT-IMG-04`를 composition plan으로 연다.
|
||||
|
||||
각 PR 공통 gate:
|
||||
|
||||
```bash
|
||||
corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts \
|
||||
tests/unit/resumable-upload-checkpoint.test.ts \
|
||||
tests/unit/resumable-upload-fetch-transport.test.ts \
|
||||
tests/unit/resumable-upload-http-control-plane.test.ts \
|
||||
tests/unit/resumable-upload-runtime.test.ts \
|
||||
tests/unit/image-cdn-runtime.test.ts
|
||||
corepack pnpm check:types
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
실제 browser gate도 capability promotion 전에 실행한다.
|
||||
|
||||
```bash
|
||||
corepack pnpm test:browser-capabilities -- \
|
||||
tests/browser-capabilities/presigned-streaming.spec.ts \
|
||||
tests/browser-capabilities/resumable-upload.spec.ts \
|
||||
tests/browser-capabilities/image-cdn.spec.ts
|
||||
```
|
||||
|
||||
해당 browser/provider 환경이 없으면 이 gate는 `UNVERIFIED`로 남기며 capability availability를 승격하지 않는다.
|
||||
|
||||
## 구현 완료 정의
|
||||
|
||||
- 모든 P1 항목에 failing-before/fixed-after test가 있다.
|
||||
- wire version과 migration 순서가 provider fixture에 반영된다.
|
||||
- 어떤 timeout 경로도 non-cooperative Promise 때문에 public API를 무한 대기시키지 않는다.
|
||||
- delete partition 결과가 late native commit 가능성을 숨기지 않는다.
|
||||
- runtime facade의 public capability identity, failure taxonomy, persisted V1 schema는 명시된 migration 외에는 바뀌지 않는다.
|
||||
- 기존 문서의 `AVAILABLE_NOT_COMPOSED`/`PLANNED_GAP` 상태를 code defect 완료로 오인하지 않는다.
|
||||
@@ -0,0 +1,344 @@
|
||||
# Adapter Review — Service Worker and Web Push
|
||||
|
||||
> 검토 기준: `develop` / `4dc033c` (2026-08-13)
|
||||
>
|
||||
> 범위: `src/adapters/service-worker/**`, `src/adapters/web-push/**`, `src/contracts/service-worker.ts`, `src/contracts/web-push.ts`, 관련 build input·unit test·architecture 문서
|
||||
|
||||
## 결론
|
||||
|
||||
서비스 워커는 registration ownership, static asset install의 byte/digest 검증, activation drain handshake, `clients.claim()` 금지와 staged removal이라는 좋은 기반을 갖고 있다. Web Push도 raw endpoint/key를 durable control record에서 분리하고, push/click 전에 association fence를 두 번 확인하며, notification copy/route를 closed registry로 제한한다. 이 경계들은 유지해야 한다.
|
||||
|
||||
현재 코드에는 조합 전에 고쳐야 할 P1 항목이 있다.
|
||||
|
||||
- generated manifest는 root-relative URL을 가지지만 fetch 분류는 absolute `Request.url`과 비교해 정적 cache path가 사용되지 않을 수 있다 (`SW-URL-01`).
|
||||
- Cache Storage 전체에서 match하여 현재 release가 아닌 구 cache response를 반환할 수 있다 (`SW-01`).
|
||||
- reset가 소유권 parser가 아니라 문자열 prefix만 사용해 유사 이름의 타 cache까지 삭제한다 (`SW-02`).
|
||||
- `unregister()`의 `false`를 성공으로 보고하며 removal mode도 실패/ownership mismatch를 `DISABLED`로 숨긴다 (`SW-03`, `SW-04`).
|
||||
- build input의 static manifest decoder가 asset row와 set digest를 실제로 검증하지 않는다 (`SW-05`).
|
||||
- Push fence CAS adapter가 repository의 다음 revision을 확인하지 않고, deadline 뒤 late mutation effect도 표현하지 못한다 (`WP-01`, `WP-02`).
|
||||
- backend registration response가 request의 전체 authority를 echo/bind하지 않아 client가 잘못 묶인 association을 검출할 수 없다 (`WP-03`).
|
||||
|
||||
Web Push는 현재 `AVAILABLE_NOT_COMPOSED`이고 제품 선택도 `NOT_SELECTED`다. service worker entry에 연결되지 않은 사실 자체는 회귀가 아니다. 아래 P1 계약을 해결하고 product-owned registry/provider/consent가 준비되기 전에는 default composition에 추가하지 않는다.
|
||||
|
||||
## 파일별 판정
|
||||
|
||||
| 파일 | 역할 | 판정 | 후속 |
|
||||
| --- | --- | --- | --- |
|
||||
| `service-worker-entry.ts` | 단일 physical worker entry와 event wiring | KEEP/REFACTOR | `SW-06`, `SW-10`; 두 번째 registration 생성 금지 |
|
||||
| `service-worker-lifecycle.ts` | install/activate/fetch/activation/reset | VERIFIED_DEFECT | `SW-URL-01`, `SW-01`, `SW-02`, `SW-07`, `SW-08` |
|
||||
| `service-worker-page-controller.ts` | registration/update/activation/reset page facade | VERIFIED_DEFECT | `SW-04`, `SW-06` |
|
||||
| `service-worker-protocol.ts` | page-worker strict message codec/nonce | CONTRACT_GAP | `SW-06`, `SW-10` |
|
||||
| `service-worker-removal.ts` | exact registration/cache ownership cleanup | VERIFIED_DEFECT | `SW-03` |
|
||||
| `service-worker-static-assets.ts` | static manifest/install/cache policy | KEEP/REFACTOR | `SW-01`, `SW-05`, `SW-09` |
|
||||
| `web-push/index.ts` | public exports | KEEP | product selection 전 surface 확대 금지 |
|
||||
| `web-push/notification-registry.ts` | closed copy/route registry | KEEP | arbitrary copy/URL 허용 금지 |
|
||||
| `web-push/push-association-fence-store.ts` | durable authority state machine | VERIFIED_DEFECT | `WP-01`, `WP-02` |
|
||||
| `web-push/push-codec.ts` | bounded hint/click codec | KEEP | exact keys, expiry, no raw text 유지 |
|
||||
| `web-push/push-registration-gateway.ts` | fixed backend commands/decoders | CONTRACT_GAP | `WP-03`, `WP-04` |
|
||||
| `web-push/push-subscription-adapter.ts` | window consent/native/backend/local orchestration | VERIFIED_DEFECT/REFACTOR | `WP-04`, `WP-05`, `WP-06` |
|
||||
| `web-push/runtime-support.ts` | deadline/link/observation mechanics | CONTRACT_GAP | `WP-02`, `WP-07` |
|
||||
| `web-push/service-worker-runtime.ts` | push/click/subscriptionchange handler composition | REFACTOR | `WP-06` |
|
||||
| `web-push/service-worker-scope-host.ts` | native scope facade | KEEP | single worker entry 내부에서만 사용 |
|
||||
| `web-push/inbound/push-event-adapter.ts` | hint → fence → safe notification | KEEP/CONTRACT_GAP | `WP-07` |
|
||||
| `web-push/inbound/notification-click-adapter.ts` | click → fence → safe route handoff | KEEP/CONTRACT_GAP | `WP-07` |
|
||||
|
||||
직접 경계 inventory도 대조했다: `src/contracts/service-worker.ts`는 protocol/cache ownership identity, `src/contracts/web-push.ts`는 push protocol/selection을 소유한다. `src/bootstrap/register-service-worker.ts`는 page composition, `scripts/lib/service-worker-build-input.ts`와 `scripts/generate-service-worker-assets.ts`는 build decode/generation, `vite.service-worker.config.ts`는 worker bundle entry를 소유한다. 이 파일들은 `SW-05`/`SW-10`의 shared codec과 rollout scope에 포함한다.
|
||||
|
||||
## Service Worker 상세
|
||||
|
||||
### SW-URL-01 — generated root-relative manifest와 absolute fetch URL의 분류 불일치
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: generator `scripts/generate-service-worker-assets.ts`는 asset URL을 `/assets/...`로 생성하고, `service-worker-lifecycle.ts`는 그 문자열 set을 absolute `Request.url`과 직접 비교한다.
|
||||
- 영향: generator output을 그대로 사용하면 verified static URL이 manifest member로 분류되지 않아 current cache lookup path에 들어가지 않고 network fallback이 된다. `SW-01`의 cache 선택을 고쳐도 URL identity를 먼저 맞추지 않으면 cache path는 여전히 작동하지 않는다.
|
||||
- 결정: runtime 생성 시 각 root-relative manifest URL을 `new URL(asset.url, scope.registrationScope).href`로 canonicalize하고 same-origin을 재확인한 frozen absolute URL set을 만든다. install cache key, fetch classification, lookup/delete validation이 이 canonical URL identity를 공유한다. generator의 persisted manifest shape는 root-relative로 유지한다.
|
||||
- 테스트: generator-shaped `/assets/app.<hash>.js` fixture와 absolute `https://app.example/assets/app.<hash>.js` request를 사용해 `onFetch()`가 current cache로 들어가는지 직접 검증한다. 다른 origin, scope 밖 path, query/hash 변형은 거절한다.
|
||||
|
||||
### SW-01 — fetch가 current static cache가 아닌 전역 CacheStorage를 조회
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: `service-worker-lifecycle.ts:166-192`; worker facade `service-worker-entry.ts:38-44`
|
||||
- 현재 동작: verified static URL에 `scope.caches.match(request.url)`을 호출한다. CacheStorage-wide match는 current, previous 또는 같은 URL을 가진 다른 cache 중 먼저 찾은 response를 반환할 수 있다.
|
||||
- 영향:
|
||||
- current release manifest에 URL이 포함되어 있어도 구 cache의 동일 URL response가 반환될 수 있다.
|
||||
- invalid hit를 발견해도 삭제는 current cache에만 수행하므로 실제로 반환된 stale cache entry는 남는다.
|
||||
|
||||
결정: `onFetch()`는 `config.manifest.setDigest`로 계산한 current cache를 `open()`하고 그 cache에서만 `match()`한다. worker scope facade의 CacheStorage-wide `match`는 제거한다.
|
||||
|
||||
테스트 추가 (`tests/unit/service-worker-runtime.test.ts`):
|
||||
|
||||
- current/previous cache에 같은 URL과 다른 bytes가 있을 때 current만 반환
|
||||
- previous에만 entry가 있으면 network fallback (`null`)
|
||||
- current invalid response만 current cache에서 삭제
|
||||
- unrelated cache의 same URL은 조회/삭제하지 않음
|
||||
|
||||
완료 조건: runtime fetch path에 `caches.match` 호출이 0이고 current cache name이 exact digest에서 파생된다.
|
||||
|
||||
### SW-02 — cache reset가 exact ownership 대신 prefix를 사용
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: `service-worker-lifecycle.ts:333-365`; exact helper `src/contracts/service-worker.ts:111-116`
|
||||
- 현재 동작: `name.startsWith("ca-static-v1-")`이면 삭제한다. `isOwnedStaticCacheName()`은 정확히 16자리 lower-hex suffix를 요구하지만 reset path가 이를 사용하지 않는다.
|
||||
- 영향: `ca-static-v1-not-owned`, suffix가 더 긴 이름 등 같은 prefix를 가진 타 기능/cache가 삭제될 수 있다.
|
||||
- 수정: import되어 있는 `isOwnedStaticCacheName(name)`만 사용한다. cache name 상수 literal도 lifecycle에서 제거한다.
|
||||
- 테스트: valid 16-hex 두 개만 삭제하고 short/long/non-hex/upper-hex/unrelated cache를 보존한다.
|
||||
|
||||
### SW-03 — `unregister() === false`를 `UNREGISTERED`로 보고
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: `service-worker-removal.ts:89-120`
|
||||
- 현재 동작: Promise가 resolve하면 boolean을 무시하고 `UNREGISTERED`를 반환한다.
|
||||
- 수정: `const unregistered = await registration.unregister()` 후 `true`만 성공으로 인정한다. `false`는 `{ kind: "FAILED", operation: "UNREGISTER" }`로 닫는다. 새 outcome을 추가할 필요는 없다.
|
||||
- 테스트: true, false, rejection, absent, ownership mismatch를 각각 고정한다.
|
||||
|
||||
### SW-04 — explicit removal mode가 cleanup 실패를 `DISABLED`로 숨김
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: `service-worker-page-controller.ts:78-121`
|
||||
- 현재 동작:
|
||||
- `REMOVE_REGISTRATION`과 `PURGE_OWNED_RESOURCES`는 실제 outcome과 무관하게 `DISABLED`를 반환한다.
|
||||
- `disabledCleanup`도 `OWNERSHIP_MISMATCH`를 `DISABLED`로 반환한다.
|
||||
- 영향: staged removal이 끝난 것으로 판단해 다음 release에서 worker source/handler를 제거할 수 있지만 실제 registration 또는 cache가 남아 있을 수 있다.
|
||||
|
||||
결정 매핑:
|
||||
|
||||
| cleanup outcome | page start outcome |
|
||||
| --- | --- |
|
||||
| `ABSENT`, `UNREGISTERED`, `PURGED` | `DISABLED` |
|
||||
| `OWNERSHIP_MISMATCH` | `INCOMPATIBLE` |
|
||||
| `FAILED` | `FAILED` (`DISABLE_CLEANUP_FAILED`, `REMOVE_FAILED`, `PURGE_FAILED`) |
|
||||
|
||||
관찰 이벤트만 남기고 success로 바꾸지 않는다. 테스트는 selection 세 종류와 위 outcome matrix를 모두 table-driven으로 작성한다.
|
||||
|
||||
### SW-05 — build input의 manifest row와 set digest 검증 부재
|
||||
|
||||
- 우선순위/분류: **P1 / CONTRACT_GAP**
|
||||
- 근거: `scripts/lib/service-worker-build-input.ts:82-94`; runtime의 부분 검사 `service-worker-static-assets.ts:85-112`; 생성 canonical hash `scripts/generate-service-worker-assets.ts:48-93`
|
||||
- 현재 동작:
|
||||
- build input은 manifest top-level shape만 보고 `assets`를 type cast한다.
|
||||
- runtime validator도 build/release identity, exact row keys, unique/canonical URL, content type type/allowlist, set digest 재계산을 확인하지 않는다.
|
||||
- 잘못된 `contentType`은 `storeAsset()`의 `.toLowerCase()`에서 typed rejection이 아니라 throw가 될 수 있다.
|
||||
|
||||
결정: runtime-neutral shared manifest codec이 exact row keys, content-type/extension allowlist, root-relative canonical URL, length-prefixed canonical byte serialization을 소유한다. generator와 Node build gate는 같은 bytes를 Node SHA-256으로 hash하고 worker는 injected WebCrypto digest로 같은 bytes를 재검증한다. Node `crypto` 구현을 worker에서 import하지 않는다. 이 작업은 기존 2026-08-01 plan Task 5/SW-10의 **선행 build-decoder 단계**로 병합하며 canonical digest를 별도 PR에서 두 번 구현하지 않는다.
|
||||
|
||||
Build gate 필수 조건:
|
||||
|
||||
- top-level/asset row exact keys
|
||||
- buildId/releaseId exact match
|
||||
- sorted unique same-origin root-relative hashed asset URL
|
||||
- 허용 content type/extension pair
|
||||
- non-negative safe byte length와 전체 bound
|
||||
- lower-hex SHA-256
|
||||
- generator와 같은 length-prefixed canonical algorithm으로 `setDigest` 재계산
|
||||
|
||||
테스트 (`tests/unit/service-worker-build-input.test.ts`): 각 row field tamper, duplicate/reorder, cross-origin URL, dot segment, wrong extension/content type, wrong set digest, unknown field. valid generator output을 decoder에 다시 넣는 parity test도 추가한다.
|
||||
|
||||
### SW-06 — activation/reset command의 source identity와 single-flight 부재
|
||||
|
||||
- 우선순위/분류: **P2 / CONTRACT_HARDENING**
|
||||
- 근거: `service-worker-page-controller.ts:174-211`, `:231-305`, `:308-369`
|
||||
- 현재 동작:
|
||||
- activation 전용 listener는 `event.origin`과 `event.source`를 검증하지 않는다.
|
||||
- general listener/reset은 origin 일부만 확인하며 expected waiting/controller source와 correlation하지 않는다.
|
||||
- 동시에 `requestActivation()` 또는 `resetOwnedCaches()`를 여러 번 호출하면 nonce와 listener가 중복 생성된다.
|
||||
|
||||
결정:
|
||||
|
||||
- activation reply는 request 시 capture한 `registration.waiting`과 `event.source`가 같아야 한다.
|
||||
- reset reply는 request 시 capture한 `container.controller`와 같아야 한다.
|
||||
- long-lived `CLIENT_DRAIN_REQUEST` listener도 expected `registration.waiting` source와 correlation한다. nonce가 없더라도 arbitrary same-origin source가 page admission을 닫게 하지 않는다.
|
||||
- empty origin을 신뢰 근거로 사용하지 않고 source identity + nonce + target identity를 함께 검증한다.
|
||||
- 각 command를 single-flight Promise로 만들고 concurrent caller는 같은 Promise를 받는다.
|
||||
- message 수신 직전에 `event.source`, captured source, 현재 `registration.waiting`/`container.controller`가 모두 동일한지 확인한다. 교체되었으면 ignore 후 timeout이 아니라 즉시 `PROTOCOL_MISMATCH`로 종료한다.
|
||||
|
||||
테스트: wrong source with correct nonce, source swap, concurrent 10 calls가 postMessage 한 번, stop 중 pending 종료, retry after terminal.
|
||||
|
||||
### SW-07 — zero-client drain 의미가 불필요하게 activation을 막음
|
||||
|
||||
- 우선순위/분류: **P2 / VERIFIED_BEHAVIOR_CHANGE**
|
||||
- 근거: `service-worker-lifecycle.ts:268-300`
|
||||
- 현재 동작: scope 내 client가 0이면 `false`를 반환한다. requester가 request 직후 닫힌 경우 dirty client가 없는데도 waiting worker가 거절된다.
|
||||
- 결정: empty set은 vacuously drained이므로 `true`다. 단, `clients.matchAll()` 실패는 reject/throw로 유지한다.
|
||||
- 테스트: zero clients → skipWaiting, one missing ack → timeout/reject, out-of-scope only → zero in-scope로 처리.
|
||||
|
||||
### SW-08 — client `postMessage()` 예외가 activation event 전체를 깨뜨림
|
||||
|
||||
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
|
||||
- 근거: `service-worker-lifecycle.ts:225-265`, `:290-299`
|
||||
- 현재 동작: drain request/accepted/reload notification loop에 per-client 예외 격리가 없다.
|
||||
- 결정: drain request 전달 실패는 해당 expected client를 failed 처리하고 pending state를 즉시 정리한다. drain 완료 뒤 `skipWaiting()` 호출 성공을 activation admission commit으로 기록한다. 그 다음 `ACTIVATE_ACCEPTED`/reload 알림은 client별 best effort로 보내고 실패를 degraded observation으로 남긴다. 현재 코드의 pre-commit `ACTIVATE_ACCEPTED` 순서는 바꾸거나 protocol V2에서 그 message를 제거한다. `skipWaiting()` 실패는 `REJECTED/FAILED`이고 accepted 성공으로 관찰하지 않는다.
|
||||
- 테스트: 첫/중간/마지막 client throw, skipWaiting throw, partial delivery, pending map leak 없음.
|
||||
|
||||
### SW-09 — install deadline 뒤 late candidate 작업
|
||||
|
||||
- 우선순위/분류: **P2 / LIFECYCLE_HARDENING**
|
||||
- 근거: `service-worker-static-assets.ts:119-153`, `:156-254`, `:259-274`
|
||||
- 현재 동작: deadline Promise가 먼저 끝나면 candidate cache를 삭제하고 반환하지만, signal을 무시한 fetch/digest/cache put은 뒤늦게 계속될 수 있다. digest rejection도 `storeAsset()`에서 직접 typed outcome으로 변환되지 않는다.
|
||||
- 결정: public install result는 overall 60초에 닫고 candidate generation fence를 세워 뒤늦은 worker가 새 fetch/digest/put을 시작하지 못하게 한다. late `Response` body는 compensator로 취소한다. 이미 시작한 `cache.put`은 취소할 수 없으므로 background settlement를 관찰한 뒤 candidate cache를 다시 exact-delete하는 second cleanup을 등록한다. cleanup을 public completion에 포함하려면 그 budget을 총 60초 안에 미리 예약하며, 60초 뒤 별도 cleanup deadline을 await해 public bound를 늘리지 않는다. 모든 dependency exception은 closed `FETCH_FAILED`/`INTEGRITY_MISMATCH`로 mapping한다.
|
||||
- 테스트: non-cooperative late fetch/digest, late cache put, digest rejection, delete rejection, unhandled rejection 없음.
|
||||
|
||||
### SW-10 — message protocol을 kind별 discriminated schema와 full identity로 승격
|
||||
|
||||
- 우선순위/분류: **P1 before release hardening / 기존 계획 승계**
|
||||
- 근거: `service-worker-protocol.ts:44-143`; `SERVICE_WORKER_PROTOCOL_VERSION = 1`; 기존 `docs/superpowers/plans/2026-08-01-http-worker-adapter-remediation.md` Task 5
|
||||
- 현재 동작: 모든 kind가 하나의 optional field bag을 공유하고 page-worker correlation은 주로 buildId에 의존한다. `service-worker-entry.ts:151-166`의 sync message는 codec 대신 V1 literal을 직접 만든다.
|
||||
|
||||
결정:
|
||||
|
||||
- 기존 계획대로 protocol V2에서 protocol/cache schema/build/release/contract/static set 전체 canonical identity digest를 교환한다.
|
||||
- kind별 exact required/forbidden field schema를 사용한다. activation/reset kinds에는 nonce와 target identity가 필수다.
|
||||
- 모든 message, including `SYNC_WAKE_OBSERVED`,는 `createServiceWorkerMessage()`만 사용한다.
|
||||
- V1/V2 worker가 같은 scope에서 교차 activation하지 않도록 mismatch는 fail-closed하고 강제 skipWaiting 하지 않는다.
|
||||
|
||||
이 항목은 기존 계획을 **유지**한다. 정확한 sequence는 `SW-URL-01`, `SW-01`~`SW-04` → 기존 plan Task 4 bounded activation-marker reader → `SW-05` build decoder와 기존 Task 5/`SW-10` 통합 → `SW-06`~`SW-09`다. 같은 canonical digest/codec을 중복 구현하지 않는다.
|
||||
|
||||
## Web Push 상세
|
||||
|
||||
### WP-01 — CAS success receipt의 expected next revision 미검증
|
||||
|
||||
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
|
||||
- 근거: `push-association-fence-store.ts:475-513`; remove는 `:516-546`에서 next revision을 검사함
|
||||
- 현재 동작: compareAndSwap success는 key/revision type/replayed만 확인하고 `revision === (expectedRevision ?? 0) + 1`을 확인하지 않는다.
|
||||
- 영향: repository가 stale/임의 receipt를 반환하면 adapter가 실제로 확인되지 않은 control을 새 revision으로 포장한다. 이후 CAS authority가 틀어진다.
|
||||
- 수정: write와 remove 모두 exact next revision, expected key, replay semantics를 같은 validator로 검증한다. replayed receipt도 동일 idempotency command의 exact revision이어야 한다.
|
||||
- 테스트 (`tests/unit/web-push-fence-store.test.ts`): stale/same/skipped/huge revision, wrong key, malformed replay, valid initial/next/replayed receipt.
|
||||
|
||||
### WP-02 — deadline 뒤 local fence mutation effect가 UNKNOWN일 수 있음
|
||||
|
||||
- 우선순위/분류: **P1 / CONTRACT_GAP**
|
||||
- 근거: `runtime-support.ts:51-113`; fence store `:411-423`, `:493-503`
|
||||
- 현재 동작: deadline은 signal을 abort하고 실패를 반환하지만 generic `PushControlRepository`가 signal을 무시하거나 commit 경계 직후 늦게 resolve하면 CAS는 반환 이후 적용될 수 있다.
|
||||
- 영향: security fence adapter가 `DEADLINE_EXCEEDED`를 반환한 뒤 ACTIVE/REVOKED record가 실제로 바뀔 수 있다.
|
||||
|
||||
결정:
|
||||
|
||||
1. read deadline wrapper와 mutation wrapper를 분리한다. repository는 commit 전 abort 시 `NOT_APPLIED`, commit 후 success receipt를 반환한다. deadline뿐 아니라 caller abort와 commit/receipt race도 unknown일 수 있다.
|
||||
2. `WebPushFailureCode`에 `MUTATION_OUTCOME_UNKNOWN`과 recovery reason을 추가한다. lifecycle은 `OPEN | RECONCILIATION_REQUIRED | CLOSED`이며 unknown 뒤 mutation admission을 닫는다.
|
||||
3. 복구는 새 bounded read로 exact revision/authority/state를 확인한 뒤에만 한다.
|
||||
4. `withAbortableDeadline`을 mutation의 correctness authority로 사용하지 않는다. deadline은 caller wait bound이며 effect는 repository receipt/read-back이 결정한다.
|
||||
|
||||
테스트: timeout-before-commit, timeout-racing-commit, late success, late rejection, recovery read, dispose 중 late ACTIVE 금지.
|
||||
|
||||
### WP-03 — backend commit이 전체 request authority에 binding되지 않음
|
||||
|
||||
- 우선순위/분류: **P1 / CONTRACT_GAP**
|
||||
- 근거: request `push-registration-gateway.ts:73-121`; response `:170-218`; activation check `push-subscription-adapter.ts:682-711`
|
||||
- 현재 동작: request는 `fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`을 보낸다. response는 `associationEpoch`과 `sessionBindingEpoch`만 반환하고 adapter도 session epoch만 비교한다.
|
||||
- 영향: provider/server bug 또는 stale response가 다른 fence/release request의 association을 반환해도 local current fence가 unchanged이면 ACTIVE로 commit할 수 있다.
|
||||
|
||||
결정: register와 reconcile의 request/response protocol을 V2로 올리고 서로 다른 exact response union을 사용한다.
|
||||
|
||||
```ts
|
||||
type WebPushRegisterCommitV2 = Readonly<{
|
||||
protocol: "WEB_PUSH_REGISTRATION_RECEIPT_V2";
|
||||
associationEpoch: string;
|
||||
fenceGeneration: string;
|
||||
sessionBindingEpoch: string;
|
||||
releaseEpoch: string;
|
||||
requestBindingSha256: string;
|
||||
replacedAssociationEpoch: string | null;
|
||||
}>;
|
||||
|
||||
type WebPushReconciliationV2 =
|
||||
| Readonly<{
|
||||
protocol: "WEB_PUSH_RECONCILIATION_V2";
|
||||
state: "ACTIVE";
|
||||
associationEpoch: string;
|
||||
fenceGeneration: string;
|
||||
sessionBindingEpoch: string;
|
||||
releaseEpoch: string;
|
||||
requestBindingSha256: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
protocol: "WEB_PUSH_RECONCILIATION_V2";
|
||||
state: "ABSENT";
|
||||
fenceGeneration: string;
|
||||
sessionBindingEpoch: string;
|
||||
releaseEpoch: string;
|
||||
requestBindingSha256: string;
|
||||
}>;
|
||||
```
|
||||
|
||||
`WEB_PUSH_PROTOCOLS`가 V2 literal과 length-prefixed field order를 소유한다. register digest에는 operation, authority tuple, subscription fingerprint, idempotency key, expected previous association epoch를 넣는다. reconcile에는 idempotency key가 없으므로 명시적으로 제외한다. decoded fixed-length digest bytes를 비교한 뒤 fence CAS를 수행한다.
|
||||
|
||||
배포: server가 V1 request에는 V1 response, V2 request에는 V2 response를 반환하도록 request protocol negotiation 배포 → V2 client → old client drain → V1 제거. exact decoder를 깨뜨리는 response dual-emit은 하지 않는다. authority field mutation과 reconcile `ABSENT` fixture를 추가한다.
|
||||
|
||||
### WP-04 — repeated enable의 backend upsert/rotation 의미가 타입에 없음
|
||||
|
||||
- 우선순위/분류: **P2 / CONTRACT_GAP**
|
||||
- 근거: `push-subscription-adapter.ts:162-275`, fence `prepare():175-215`, `activate():219-270`
|
||||
- 현재 동작: 같은 authority가 이미 ACTIVE여도 `enable()`은 새 idempotency key로 backend register를 다시 수행한다. server atomic installation upsert가 같은 association을 반환하거나 old association을 폐기한다는 문서 요구가 gateway receipt에 표현되지 않는다.
|
||||
- 결정: public `enable()`이 public `reconcile()`을 호출하지 않는다. permission/prepare 뒤 private `reconcilePrepared()` flow를 공유해 exclusive guard 내부에서 호출한다. ACTIVE + valid native material이면 먼저 reconcile하고 `ABSENT`일 때만 register한다. request에 `expectedPreviousAssociationEpoch: string | null`을 보내고 receipt의 `replacedAssociationEpoch`과 exact match해야 한다. local activate는 old ACTIVE와 다른 epoch를 무조건 덮어쓰지 않는다.
|
||||
- 테스트: double enable same epoch, reconcile active, server absent then register, replacement receipt, replacement without old epoch rejection, compensation on CAS failure.
|
||||
|
||||
### WP-05 — pre-aborted operation이 항상 INSPECT로 기록됨
|
||||
|
||||
- 우선순위/분류: **P3 / VERIFIED_DEFECT**
|
||||
- 근거: `push-subscription-adapter.ts:462-478`
|
||||
- 수정: `webPushFailure("ABORTED", failureOperation)`을 사용한다.
|
||||
- 테스트: enable/reconcile/revoke/inspect 각각 pre-aborted operation field.
|
||||
|
||||
### WP-06 — bounded truncation을 성공으로 관찰
|
||||
|
||||
- 우선순위/분류: **P2 / EVIDENCE_CORRECTNESS**
|
||||
- 근거: subscriptionchange client handoff `service-worker-runtime.ts:139-172`; notification cleanup `push-subscription-adapter.ts:908-945`
|
||||
- 현재 정책: client 32개, notification 64개/2초로 bounded best effort이다. architecture 문서는 notification cleanup을 privacy guarantee로 보지 않고 account-neutral copy를 요구하므로 상한 자체는 결함이 아니다.
|
||||
- 문제: 목록이 상한을 넘었는데도 success로 관찰해 운영자가 일부 처리만 된 사실을 알 수 없다.
|
||||
- 수정: `WebPushObservation`에 `countBucket: "0" | "1_8" | "9_32" | "33_64" | "GT_64"`와 `truncated: boolean`을 추가한다. subscriptionchange는 32 초과 시 `LIMIT_EXCEEDED/DEGRADED`; notification cleanup은 64 초과 시 revoke authority와 분리된 cleanup observation을 `DEGRADED`로 기록하고 `{ complete: false }`를 반환한다. 무제한 loop나 전체 정리를 주장하지 않는다.
|
||||
- 테스트: 33 clients, 65 notifications, owned item이 cap 밖에 있는 경우, account-neutral copy/click fence가 계속 안전함.
|
||||
|
||||
### WP-07 — user-visible native effect와 deadline result의 certainty
|
||||
|
||||
- 우선순위/분류: **P2 / CONTRACT_GAP**
|
||||
- 근거: `runtime-support.ts:51-113`, `push-event-adapter.ts:144-174`, `notification-click-adapter.ts:144-176`
|
||||
- 현재 동작: deadline/abort가 먼저 반환된 뒤 `showNotification`, `focus`, `openWindow`가 늦게 성공할 수 있다. 결과는 failure지만 user-visible effect는 발생할 수 있다.
|
||||
- 결정: native 호출 전 terminal=`NOT_APPLIED`, native Promise pending 중 terminal=`MAYBE_APPLIED`, fulfillment=`CONFIRMED`로 phase를 고정한다. native-effect 전용 observation union에 effect를 두고 wrapper가 `onLateValue/onLateError`로 outer result 종료 뒤에도 safe observation을 한 번 남긴다. 이 observation을 authorization/retry에 사용하지 않는다. account-neutral notification과 click-time fence가 최종 안전 장치다.
|
||||
|
||||
## 유지해야 할 설계
|
||||
|
||||
- 한 scope에 physical Service Worker registration은 하나만 둔다.
|
||||
- static install은 immutable hashed asset만 대상으로 하고 byte/digest 검증 후 all-or-nothing으로 공개한다.
|
||||
- navigation, runtime config, release manifest, API response는 static cache에 넣지 않는다.
|
||||
- `skipWaiting()`은 page/client drain handshake 이후에만 호출하고 baseline에서 `clients.claim()`은 사용하지 않는다.
|
||||
- registration과 cache ownership을 exact scope/script/cache parser로 확인한다.
|
||||
- Web Push endpoint, p256dh, auth, account/user ID, notification content를 durable fence/diagnostics에 저장하지 않는다.
|
||||
- push와 click 모두 initial/final fence를 확인하고 arbitrary URL 또는 backend raw copy를 사용하지 않는다.
|
||||
- revoke는 local generation fence를 먼저 commit하고 backend/native cleanup은 bounded best effort로 수행한다.
|
||||
- notification cleanup 성공을 privacy 보장으로 주장하지 않는다. copy는 항상 account-neutral이어야 한다.
|
||||
- `WEB_PUSH`가 선택되지 않은 현재 baseline에서 worker import/handler를 억지로 추가하지 않는다.
|
||||
|
||||
## 실행 순서
|
||||
|
||||
1. `SW-URL-01`, `SW-01`~`SW-04`, `WP-01`~`WP-03`을 독립 P1 PR로 처리한다.
|
||||
2. 기존 2026-08-01 plan Task 4 bounded activation-marker reader를 완료한다.
|
||||
3. `SW-05` shared build decoder와 기존 Task 5/`SW-10` protocol V2를 한 sequence로 구현한다.
|
||||
4. `SW-06`~`SW-09`, `WP-04`~`WP-07`을 protocol/lifecycle PR로 나눈다.
|
||||
5. 제품이 Web Push를 선택할 때 별도 composition 계획으로 registry/provider/consent/browser evidence를 추가한다.
|
||||
|
||||
집중 검증:
|
||||
|
||||
```bash
|
||||
corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts \
|
||||
tests/unit/service-worker-build-input.test.ts \
|
||||
tests/unit/web-push-codec.test.ts \
|
||||
tests/unit/web-push-fence-store.test.ts \
|
||||
tests/unit/web-push-store-port-compatibility.test.ts \
|
||||
tests/unit/web-push-runtime-support.test.ts \
|
||||
tests/unit/web-push-subscription-adapter.test.ts \
|
||||
tests/unit/web-push-worker-runtime.test.ts
|
||||
corepack pnpm check:types
|
||||
corepack pnpm check:architecture
|
||||
corepack pnpm lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 완료 정의
|
||||
|
||||
- generated root-relative asset가 canonical absolute request와 일치하고, current cache 외 response가 반환되지 않으며 exact owned cache만 삭제된다.
|
||||
- unregister/removal 결과가 실제 browser outcome을 숨기지 않는다.
|
||||
- build gate가 static manifest row와 canonical set digest tamper를 거절한다.
|
||||
- every command reply는 expected worker source, nonce, target full identity에 묶인다.
|
||||
- fence mutation receipt가 exact next revision과 effect certainty를 보장한다.
|
||||
- backend association receipt가 authority 3-tuple과 request digest에 묶인다.
|
||||
- bounded truncation과 MAYBE_APPLIED native effect가 성공으로 과장되지 않는다.
|
||||
- Web Push의 미조합 상태를 구현 완료로 오인하지 않는다.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Adapter 파일 전수 inventory
|
||||
|
||||
> 검토 기준: `develop` (2026-08-14 재검토 반영)
|
||||
>
|
||||
> GOV-01. 이 표는 손으로 센 숫자가 아니라 `corepack pnpm check:adapter-inventory`가 `git ls-files src/adapters`와 정확히 대조하는 목록이다.
|
||||
>
|
||||
> `rg --files src/adapters | sort` 결과 119개를 하나씩 고정한 coverage ledger다. 책임·의존성·finding·유지/변경 판정은 연결된 상세 리뷰의 파일별 표를 따른다.
|
||||
|
||||
| # | full path | 상세 리뷰 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | `src/adapters/auth/external-session-adapter.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 2 | `src/adapters/browser-file-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 3 | `src/adapters/browser-file-storage/result.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 4 | `src/adapters/browser-file-storage/storage-manager-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 5 | `src/adapters/browser-files/browser-file-picker.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 6 | `src/adapters/browser-files/browser-file-policy-registry.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 7 | `src/adapters/browser-files/browser-file-vault.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 8 | `src/adapters/browser-files/create-browser-file-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 9 | `src/adapters/browser-files/download-delivery-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 10 | `src/adapters/browser-files/file-observer.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 11 | `src/adapters/browser-files/file-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 12 | `src/adapters/browser-files/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 13 | `src/adapters/browser-files/object-url-lease.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 14 | `src/adapters/browser-rpc/browser-rpc-runtime.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 15 | `src/adapters/browser-rpc/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 16 | `src/adapters/browser-rpc/transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 17 | `src/adapters/browser-rpc/unavailable-browser-rpc-transport.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 18 | `src/adapters/browser-transfer/image-cdn/README.md` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 19 | `src/adapters/browser-transfer/image-cdn/browser-image-probe.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 20 | `src/adapters/browser-transfer/image-cdn/image-cdn-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 21 | `src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 22 | `src/adapters/browser-transfer/image-cdn/image-header-metadata.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 23 | `src/adapters/browser-transfer/image-cdn/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 24 | `src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 25 | `src/adapters/browser-transfer/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 26 | `src/adapters/browser-transfer/presigned/incremental-sha256.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 27 | `src/adapters/browser-transfer/presigned/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 28 | `src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 29 | `src/adapters/browser-transfer/presigned/presigned-capability-vault.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 30 | `src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 31 | `src/adapters/browser-transfer/resumable-upload/checkpoint-schema.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 32 | `src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 33 | `src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 34 | `src/adapters/browser-transfer/resumable-upload/index.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 35 | `src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 36 | `src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 37 | `src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 38 | `src/adapters/browser-transfer/resumable-upload/runtime-policy.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 39 | `src/adapters/browser-transfer/resumable-upload/upload-byte-source.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 40 | `src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 41 | `src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts` | [Browser transfer](./04-browser-transfer.md) |
|
||||
| 42 | `src/adapters/cache-storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 43 | `src/adapters/cache-storage/public-cache-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 44 | `src/adapters/cache-storage/public-response-cache-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 45 | `src/adapters/cross-context-invalidation/browser-cross-context-host.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 46 | `src/adapters/cross-context-invalidation/browser-cross-context-invalidation.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 47 | `src/adapters/cross-context-invalidation/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 48 | `src/adapters/diagnostics/bounded-diagnostics.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 49 | `src/adapters/http/bounded-body-reader.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 50 | `src/adapters/http/bounded-json.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 51 | `src/adapters/http/client.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 52 | `src/adapters/http/http-contract-bridge.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 53 | `src/adapters/http/http-effect-certainty.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 54 | `src/adapters/http/http-execution-v3.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 55 | `src/adapters/http/request-builder.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 56 | `src/adapters/http/resource-mapper.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 57 | `src/adapters/http/retry-policy.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 58 | `src/adapters/http/schema-registry.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 59 | `src/adapters/platform/abortable-operation.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 60 | `src/adapters/platform/browser-lifecycle.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 61 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 62 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 63 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 64 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 65 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 66 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 67 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 68 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 69 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 70 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 71 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 72 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 73 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 74 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 75 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 76 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 77 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 78 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 79 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 80 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 81 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 82 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 83 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 84 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||
| 85 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 86 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 87 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 88 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 89 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 90 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 91 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 92 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 93 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 94 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 95 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 96 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 97 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 98 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 99 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 100 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 101 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 102 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 103 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 104 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 105 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 106 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 107 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||
| 108 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) |
|
||||
| 109 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 110 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 111 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 112 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 113 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 114 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 115 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 116 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 117 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 118 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
| 119 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||
|
||||
합계: **119/119**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
# Adapter 전수 리뷰 — 통합 인덱스와 확정 결정
|
||||
|
||||
> 검토 기준: `develop` / `4dc033cf33a5b6173bbf960d5eb464a406dc4c92` (2026-08-13)
|
||||
>
|
||||
> 검토 범위: `src/adapters/**`의 117개 TypeScript 파일과 1개 README, 총 53,475 TypeScript LOC. 직접 연결된 contracts, application ports, bootstrap composition, feature gateway, unit/integration test, ADR와 운영 문서를 함께 대조했다.
|
||||
|
||||
## 결론
|
||||
|
||||
adapter 계층의 큰 방향은 유지할 가치가 있다. native 객체와 raw provider material을 application 경계 밖에 두고, strict decoder·immutable capability·generation fence·bounded queue·typed failure를 사용하며, 선택되지 않은 capability를 조용히 fallback하지 않는 구조는 일관적이다. 정적 architecture gate도 현재 계층 위반을 찾지 않았다.
|
||||
|
||||
반면 lifecycle과 effect certainty에는 반복되는 공백이 있다. 가장 높은 위험은 OPFS 보상 정리의 journal 순서이며, 현재 조립 경로에서는 V3 HTTP 관찰 전체 유실, auth profile 미강제, retry 중 command effect 하향, telemetry의 dispose 이후 동작이 우선 수정 대상이다. 선택되지 않은 realtime, Browser RPC, Web Push, image/transfer capability의 결함은 현재 production incident로 과장하지 않되, 해당 capability를 조립하기 전 필수 promotion gate로 둔다.
|
||||
|
||||
이 문서와 하위 리뷰는 구현자가 추가 제품 결정을 요청하지 않도록 다음을 고정한다.
|
||||
|
||||
- 현재 코드로 재현되는 결함, contract gap, 구조 리팩터링, 문서화된 미구현을 분리한다.
|
||||
- 각 finding마다 적용 패턴, 수정할 파일/API, 테스트 이름과 기대 결과, migration·deployment·rollback을 지정한다.
|
||||
- 기존 public facade와 persisted/wire V1 호환을 언제 유지하고 언제 version-up할지 명시한다.
|
||||
- default bootstrap에 optional capability를 새로 조립하지 않는다. 구현과 browser/provider evidence가 준비된 뒤 별도 product selection으로 승격한다.
|
||||
|
||||
## 보고서 구성과 범위
|
||||
|
||||
| 문서 | 구현 범위 | 파일 수 | 핵심 주제 |
|
||||
| --- | --- | ---: | --- |
|
||||
| [01 — Network and state](./01-network-and-state.md) | `http`, `auth`, `query-cache`, `cross-context-invalidation`, `platform`, `diagnostics`, `telemetry` | 24 | HTTP authority/effect, diagnostics·telemetry, ETag key, cancellation |
|
||||
| [02 — Realtime and Browser RPC](./02-realtime-and-browser-rpc.md) | `realtime`, `browser-rpc` | 21 | stream lease, DRAINING, handoff writer, immutable binding, backpressure |
|
||||
| [03 — Storage and browser files](./03-storage-and-browser-files.md) | `storage`, `browser-files`, `browser-file-storage`, `cache-storage` | 32 | OPFS saga, IndexedDB maintenance, file URL, public cache, quota/migration |
|
||||
| [04 — Browser transfer](./04-browser-transfer.md) | `browser-transfer` | 24 | presigned capability, resumable upload, image CDN |
|
||||
| [05 — Service Worker and Web Push](./05-service-worker-and-web-push.md) | `service-worker`, `web-push` | 17 | cache ownership, activation/removal, worker protocol, push authority |
|
||||
|
||||
합계는 118/118 파일이다. [전수 inventory](./INVENTORY.md)가 full path와 상세 리뷰를 일대일로 연결하고, 각 하위 문서의 파일 표가 책임, 직접 dependency/downstream, 판정을 기록한다.
|
||||
|
||||
## 최우선 finding
|
||||
|
||||
| 순서 | ID | 상태/심각도 | 확정 영향 | 구현 결정 |
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| 1 | `STO-01` | 확정 / Critical | OPFS pre-commit cleanup 실패·취소 뒤 journal을 지워 복구 근거를 잃고, 늦은 generation-only cleanup이 후속 write를 삭제할 수 있다. | cleanup 확인 전 journal/budget rollback 금지, compensation signal 분리, transaction-unique physical generation token, cleanup 종료까지 mutation lease 유지 |
|
||||
| 2 | `N-01` | 확정 / High / 현재 V3 | HTTP V3 observation의 미허용 context key 때문에 모든 request diagnostic이 drop되고 terminal failure telemetry도 없다. | typed observation을 closed diagnostic/telemetry bucket으로 투영하고 route ID를 executor context에 보존 |
|
||||
| 3 | `N-02` | 확정 / High / 현재 V3 | `authProfileId`가 조립·강제되지 않아 bearer 필수 header와 transport-owned credentials/header invariant를 증명하지 못한다. | immutable auth Profile/Strategy registry, credential owner는 허용된 proof header만 제공, missing/extra는 fetch 전 fail-close |
|
||||
| 4 | `N-03` | 확정 / High | 이미 dispatch된 command가 retry-time scope fence에서 `MAYBE_APPLIED`에서 `NOT_STARTED`로 하향될 수 있다. | logical execution 전체에 monotonic effect-certainty join 적용 |
|
||||
| 5 | `N-04` | 확정 / High | telemetry가 dispose 뒤 scheduled/new/in-flight delivery를 계속하고 composition teardown이 dispose를 호출하지 않는다. | `ACTIVE/DISPOSED`, joined flush, in-flight abort, infrastructure teardown 연결 |
|
||||
| 6 | `STO-02` | 확정 / High | download URL은 `baseOrigin`으로 검증하지만 원문 상대 URL은 `document.baseURI`로 실행된다. | parse-once canonical absolute URL만 handoff |
|
||||
| 7 | `SW-URL-01`, `SW-01`~`SW-05` | 확정/gap / P1 | generated URL 분류 불일치, stale static response 선택, 과도한 prefix delete, 거짓 unregister/removal success, manifest 검증 부재 | canonical absolute runtime URL set, current-cache-only lookup, exact ownership parser, truthful cleanup result, shared strict manifest codec |
|
||||
| 8 | `WP-01`~`WP-03` | 확정/gap / P1 / 미조립 | fence revision·mutation effect·backend authority receipt가 충분히 묶이지 않는다. | exact next revision, unknown effect recovery, V2 full authority/request binding receipt |
|
||||
| 9 | `R-01`~`R-04` | 확정 / High / 미조립 | non-cooperative stream/effect가 무한 대기하거나 active writer가 유실되고 Browser RPC binding이 TOCTOU다. | explicit stream lease, retained DRAINING registry, retired writer set, immutable parse/validate/install |
|
||||
| 10 | `BT-PRE-01`, `BT-PRE-02`, `BT-UP-03` | 확정/gap / P1 / 미조립 | eager download 자원 누수, wire envelope version 부재, late IndexedDB delete effect 오보고 | lazy closeable lease, protocol literal, `PENDING/effect UNKNOWN` outcome |
|
||||
|
||||
하위 문서의 나머지 Medium/P2/P3 항목도 생략 대상이 아니다. 위 표는 release·promotion을 막는 순서만 압축한 것이다.
|
||||
|
||||
## 공통 설계 결정
|
||||
|
||||
### D-01 — effect certainty는 단조 증가한다
|
||||
|
||||
한 번 native/network mutation을 dispatch한 뒤에는 새 retry가 아직 시작되지 않았다는 이유로 전체 logical operation을 `NOT_STARTED`로 되돌리지 않는다. 결과는 `NOT_STARTED → NOT_APPLIED/MAYBE_APPLIED → APPLIED_CONFIRMED`의 보수적 lattice로 join한다. IndexedDB/OPFS/Web Push처럼 deadline 뒤 native commit 가능성을 취소할 수 없는 API는 `UNKNOWN`을 명시하고 bounded read-back/reconcile만 허용한다.
|
||||
|
||||
### D-02 — commit fence와 resource settlement를 분리한다
|
||||
|
||||
abort/deadline 시 late commit capability는 즉시 폐기하지만, non-cooperative Promise·stream·writer reference는 실제 settlement까지 버리지 않는다. public wait은 bounded하게 끝내되 내부 lifecycle은 `DRAINING`으로 남고 같은 physical owner의 신규 admission을 막는다. `close()`가 성공했다면 tracked task가 실제로 quiescent여야 한다.
|
||||
|
||||
### D-03 — 외부/조립 입력은 parse → validate → install한다
|
||||
|
||||
TypeScript `Readonly`나 한 번의 boolean validator를 runtime immutability로 취급하지 않는다. registry, contract binding, provider response는 exact own-data descriptor와 closed key set을 검사한 immutable snapshot으로 설치하고 이후 원본을 다시 읽지 않는다. getter, extra/symbol key, revoked proxy는 composition/decoder 경계에서 fail-close한다.
|
||||
|
||||
### D-04 — 검증한 값을 그대로 실행한다
|
||||
|
||||
URL·path·header·manifest는 parse-once canonical form을 반환하고 network/navigation/cache operation은 그 canonical 값을 사용한다. boolean 검증 후 원문을 다른 base/decoder로 다시 해석하지 않는다. provider별 double-decode 가능성이 있는 encoded separator는 계약 fixture로 닫는다.
|
||||
|
||||
### D-05 — marker와 hint는 권위가 아니다
|
||||
|
||||
cache release marker는 “작성 완료 주장”일 뿐 모든 entry의 존재·digest 증거가 아니다. BroadcastChannel/storage event와 realtime cancellation은 hint이며 server/CAS/generation authority를 대신하지 않는다. 재사용·activation·복구 경로는 exact identity와 content를 다시 검증한다.
|
||||
|
||||
### D-06 — operation별 최소 dependency만 요구한다
|
||||
|
||||
stage에는 fetch가 필요하지만 local activate/cleanup에는 필요하지 않다. capability availability를 편의상 하나의 공통 guard로 묶지 않고 operation별로 분리한다. offline rollback/cleanup을 네트워크 부재 때문에 차단하지 않는다.
|
||||
|
||||
### D-07 — state machine과 Saga 경계로만 큰 runtime을 나눈다
|
||||
|
||||
파일 길이만으로 분해하지 않는다. 먼저 facade의 success/failure/cancel/call-order characterization을 고정한 뒤 순수 transition, retry policy, bounded scheduler, persistence reconciler, compensation saga를 추출한다. public capability identity, failure taxonomy, persisted schema, wire semantics는 별도 versioned migration 없이는 바꾸지 않는다.
|
||||
|
||||
### D-08 — abort/deadline mechanics만 공유한다
|
||||
|
||||
listener/timer 정리, first-terminal-owner, late rejection 관찰, late native handle compensation은 platform utility로 통합할 수 있다. HTTP, browser data, Web Push, realtime의 result taxonomy와 recovery vocabulary는 각 adapter에 남긴다. 범용 middleware/interceptor나 하나의 generic repository로 합치지 않는다.
|
||||
|
||||
### D-09 — optional capability의 미조립 상태를 유지한다
|
||||
|
||||
`AVAILABLE_NOT_COMPOSED`, `DESIGNED_NOT_IMPLEMENTED`, `NOT_SELECTED`는 defect status가 아니다. realtime, Browser RPC, Web Push, resumable upload, image provider, storage coordinator를 이번 remediation만으로 default bootstrap에 설치하지 않는다. 관련 P1/P2 closure, actual browser/provider/load evidence, product-owned policy·consent·registry가 모두 준비되어야 별도 selection change를 연다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
서로 다른 subsystem을 한 PR에 섞지 않는다. 각 항목은 failing characterization → 최소 수정 → focused green → type/architecture/lint → commit 순서다.
|
||||
|
||||
1. **Containment:** product-specific composition에서 OPFS v1 writer 사용 여부를 확인하고, 사용 중이면 신규 write admission을 read-only/export-required로 닫는다. template 기본 bootstrap은 OPFS를 조립하지 않는다.
|
||||
2. **현재 실행 경로:** `STO-01`, `N-01`~`N-04`, `STO-02`를 독립 PR로 수정한다.
|
||||
3. **기존 rollback/sidecar:** `N-05`~`N-11`과 legacy HTTP V2 hardening을 처리한다. V2를 지우는 일은 zero-caller와 rollback-window 종료 뒤 별도 PR이다.
|
||||
4. **선택 capability correctness:** `SW-URL-01`, `SW-01`~`SW-09`, `WP-01`~`WP-07`, `R-01`~`R-06`, browser-transfer P1/P2를 subsystem별 PR로 닫는다.
|
||||
5. **기존 version/migration 계획:** Service Worker V2(`SW-10`), OPFS physical/protocol V2, presigned/Web Push receipt V2를 expand → dual-read/emit → old-writer drain → contract 순서로 배포한다.
|
||||
6. **구조 리팩터링:** behavior가 모두 green인 상태에서 resumable upload, image CDN, OPFS worker, public cache, download strategy를 characterization-preserving extraction으로 나눈다.
|
||||
7. **Promotion gaps:** preview decode, bounded origin/cache maintenance, Browser RPC concrete transport, image descriptor provider 등 명시된 gap을 실제 browser/provider conformance와 함께 구현한다. 완료 전 availability state를 올리지 않는다.
|
||||
|
||||
질문 없는 세부 실행 절차는 [Adapter Remediation Implementation Plan](../../superpowers/plans/2026-08-13-adapter-remediation.md)에 있으며, finding별 exact API·test·migration은 각 하위 리뷰가 source of truth다.
|
||||
|
||||
## 기존 계획과의 우선권
|
||||
|
||||
| 기존 계획 | 유지할 내용 | 이번 리뷰가 추가하는 선행 조건 |
|
||||
| --- | --- | --- |
|
||||
| [2026-08-01 HTTP/worker remediation](../../superpowers/plans/2026-08-01-http-worker-adapter-remediation.md) Tasks 1–3 | installed HTTP contract 단일 권위, provider-neutral outcome, bound-only query API | `N-01`~`N-03` auth/observation/effect 결함을 같은 V3 migration에 먼저 포함 |
|
||||
| 같은 계획 Task 4 | bounded Service Worker marker reader | 그대로 유지; `SW-01`~`SW-09`의 cache/lifecycle truth를 함께 닫은 뒤 V2로 이동 |
|
||||
| 같은 계획 Task 5 | full identity Service Worker protocol V2 | `SW-10`으로 승계. 새 protocol을 두 번 설계하지 않는다. |
|
||||
| 같은 계획 Task 6 | shared IndexedDB persisted-row schema | 그대로 유지하되 `STO-06` deadline/drain lease test를 extraction 전 추가 |
|
||||
| 같은 계획 Task 7 | OPFS/cache/download cohesive decomposition | `STO-01`~`STO-05` correctness fix와 characterization이 먼저다. |
|
||||
| [2026-08-01 runtime correctness](../../superpowers/plans/2026-08-01-runtime-correctness-remediation.md) Tasks 1–5 | query key/invalidation, application mutation intent, keyed command preflight, effect-aware settlement | 새 plan이 대체하지 않는다. `N-03`, `N-05`, `N-06`을 동일 certainty/key authority에 병합한다. |
|
||||
|
||||
충돌 시 우선순위는 **현재 재현 결함의 fail-close 수정 → 기존 plan의 계약 통합 → 구조 추출 → optional capability 조립**이다. 두 기존 plan을 완료로 표시하거나 삭제하지 않는다.
|
||||
|
||||
## 검증 기준선
|
||||
|
||||
- `corepack pnpm check:types`: 통과.
|
||||
- `corepack pnpm lint`: 통과.
|
||||
- `corepack pnpm check:architecture`: sandbox child-process 제약에서는 실패했으나 동일 명령을 허용된 실행 환경에서 다시 수행해 286 modules / 854 dependencies, 12 fixture, TS-only/allowed/forbidden gate가 모두 통과했다.
|
||||
- 영역별 focused baseline:
|
||||
- network/state: 21 files / 144 tests 통과, `check:diagnostics` 통과.
|
||||
- realtime/Browser RPC: 16 files / 185 tests 통과, source boundary gate 통과.
|
||||
- storage/files/cache: 7 files / 105 tests 통과.
|
||||
- browser transfer: 6 files / 93 tests 통과; Service Worker/Web Push: 8 files / 52 tests 통과(독립 재감사 실행).
|
||||
- 전체 `test:unit`은 이 sandbox에서 child `spawnSync ... EPERM`이 발생한 세 CI/evidence test file 때문에 108 files 통과, 3 files 실패(1465 tests 통과, 50 실패)였다. adapter focused suite의 실패가 아니며 전체 green으로 주장하지 않는다.
|
||||
|
||||
최종 산출물 검증은 118/118 inventory 포함, placeholder/깨진 local path 검사, Markdown diff 검사, focused adapter tests, type/architecture/lint를 다시 실행한다.
|
||||
|
||||
## 명시적으로 하지 않는 변경
|
||||
|
||||
- 이 리뷰에서는 production source를 수정하거나 optional adapter를 bootstrap에 조립하지 않는다.
|
||||
- private/range cache, persistent browser handles, resumable range download, arbitrary Web Push copy/URL 같은 별도 미선택 capability를 기존 adapter에 섞지 않는다.
|
||||
- timeout을 이유로 irreversible native mutation이 적용되지 않았다고 추정하지 않는다.
|
||||
- cleanup 실패를 observation만 남기고 success로 바꾸지 않는다.
|
||||
- schema/database version을 downgrade하거나 broad prefix/root/database 전체 삭제를 rollback으로 사용하지 않는다.
|
||||
- SSE↔WebSocket, Connect↔gRPC-Web↔REST를 장애 중 자동 전환하지 않는다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@
|
||||
"check:design-system:fixture": "node scripts/check-design-system.ts --fixture",
|
||||
"check:i18n": "node scripts/check-i18n.ts",
|
||||
"check:i18n:fixture": "node scripts/check-i18n.ts --fixture",
|
||||
"check:adapter-inventory": "node scripts/check-adapter-inventory.ts",
|
||||
"check:remediation-ledger": "node scripts/check-remediation-ledger.ts",
|
||||
"check:diagnostics": "node scripts/check-diagnostics.ts",
|
||||
"check:diagnostics:fixture": "node scripts/check-diagnostics.ts --fixture",
|
||||
"check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test && corepack pnpm check:types:recipes && corepack pnpm check:types:web-worker && corepack pnpm check:types:service-worker",
|
||||
@@ -41,6 +43,7 @@
|
||||
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
|
||||
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts",
|
||||
"check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts",
|
||||
"check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts",
|
||||
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
|
||||
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
|
||||
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { CACHEABLE_ASSET_CONTENT_TYPES } from "../src/contracts/service-worker-static-manifest.ts";
|
||||
|
||||
/**
|
||||
* GOV-01 / SW-RR-03. Structural gates for facts that a hand-maintained document
|
||||
* cannot keep true.
|
||||
*
|
||||
* The adapter review inventory claimed 118/118 while the tree held 119 files,
|
||||
* so a whole adapter was outside every review's coverage without anything
|
||||
* failing. And the Service Worker asset generator and the shared manifest
|
||||
* decoder each carried their own extension table, so a build could emit an
|
||||
* asset the runtime contract then refused. Both are now equalities this script
|
||||
* checks rather than numbers someone has to remember to update.
|
||||
*/
|
||||
|
||||
const INVENTORY_PATH = "docs/reviews/adapters/INVENTORY.md";
|
||||
const GENERATOR_PATH = "scripts/generate-service-worker-assets.ts";
|
||||
|
||||
function trackedAdapterFiles(): readonly string[] {
|
||||
const listed = spawnSync("git", ["ls-files", "src/adapters"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (listed.status !== 0) {
|
||||
throw new Error(`git ls-files failed: ${listed.stderr}`);
|
||||
}
|
||||
return listed.stdout.split("\n").filter(Boolean).sort();
|
||||
}
|
||||
|
||||
function inventoryRows(markdown: string): readonly string[] {
|
||||
const rows: string[] = [];
|
||||
for (const line of markdown.split("\n")) {
|
||||
const match = /^\|\s*\d+\s*\|\s*`([^`]+)`\s*\|/u.exec(line);
|
||||
if (match?.[1]) rows.push(match[1]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function reportDifference(
|
||||
label: string,
|
||||
expected: readonly string[],
|
||||
actual: readonly string[],
|
||||
): readonly string[] {
|
||||
const missing = expected.filter((value) => !actual.includes(value));
|
||||
const extra = actual.filter((value) => !expected.includes(value));
|
||||
const problems: string[] = [];
|
||||
for (const value of missing) problems.push(`${label}: missing ${value}`);
|
||||
for (const value of extra) problems.push(`${label}: unexpected ${value}`);
|
||||
return problems;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const problems: string[] = [];
|
||||
|
||||
const tracked = trackedAdapterFiles();
|
||||
const markdown = await readFile(INVENTORY_PATH, "utf8");
|
||||
const listed = inventoryRows(markdown);
|
||||
problems.push(...reportDifference("adapter inventory", tracked, listed));
|
||||
if (listed.length !== new Set(listed).size) {
|
||||
problems.push("adapter inventory: duplicate row");
|
||||
}
|
||||
const total = /합계: \*\*(\d+)\/(\d+)\*\*/u.exec(markdown);
|
||||
if (
|
||||
!total ||
|
||||
Number(total[1]) !== tracked.length ||
|
||||
Number(total[2]) !== tracked.length
|
||||
) {
|
||||
problems.push(
|
||||
`adapter inventory: total does not equal ${tracked.length} tracked files`,
|
||||
);
|
||||
}
|
||||
|
||||
// SW-RR-03. The generator must read the shared table rather than declare one.
|
||||
const generator = await readFile(GENERATOR_PATH, "utf8");
|
||||
if (!generator.includes("CACHEABLE_ASSET_CONTENT_TYPES")) {
|
||||
problems.push(
|
||||
"service worker assets: generator does not use the shared extension table",
|
||||
);
|
||||
}
|
||||
if (/const CACHEABLE_EXTENSIONS[^=]*=\s*Object\.freeze\(\{/u.test(generator)) {
|
||||
problems.push(
|
||||
"service worker assets: generator declares its own extension table",
|
||||
);
|
||||
}
|
||||
for (const [extension, contentType] of Object.entries(
|
||||
CACHEABLE_ASSET_CONTENT_TYPES,
|
||||
)) {
|
||||
if (!extension.startsWith(".") || contentType.length === 0) {
|
||||
problems.push(`service worker assets: invalid table row ${extension}`);
|
||||
}
|
||||
}
|
||||
|
||||
// A fixture that links the repository's node_modules with a single directory
|
||||
// symlink is destructive: pnpm running inside that fixture purges the modules
|
||||
// directory it does not recognise, follows the link, and deletes the real
|
||||
// dependencies mid-run. `linkFixtureNodeModules` is the only sanctioned form.
|
||||
const sources = spawnSync(
|
||||
"git",
|
||||
["grep", "-n", "-e", 'symlink(', "--", "scripts", "tests"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (sources.status === 0) {
|
||||
for (const line of sources.stdout.split("\n").filter(Boolean)) {
|
||||
if (!line.includes("node_modules")) continue;
|
||||
if (line.startsWith("scripts/lib/fixture-node-modules.ts:")) continue;
|
||||
problems.push(
|
||||
`fixture node_modules: use linkFixtureNodeModules instead — ${line}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const linkedFixtures = spawnSync(
|
||||
"git",
|
||||
["grep", "-l", "linkFixtureNodeModules", "--", "scripts", "tests"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (
|
||||
linkedFixtures.status !== 0 ||
|
||||
linkedFixtures.stdout.split("\n").filter(Boolean).length < 2
|
||||
) {
|
||||
problems.push(
|
||||
"fixture node_modules: the shared linker has no callers, so it is not the sanctioned path",
|
||||
);
|
||||
}
|
||||
|
||||
// TR-RR-05 / GOV-04. Every consumer the re-review named must use the shared
|
||||
// primitive, not merely one file somewhere. Checking `importers.length > 0`
|
||||
// let an unrelated production import satisfy the gate while Image and
|
||||
// Resumable kept their own diverging copies of the same mechanics — which is
|
||||
// exactly how the four hand-written versions drifted apart in the first place.
|
||||
const REQUIRED_ABORT_CONSUMERS: readonly string[] = [
|
||||
"src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts",
|
||||
"src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts",
|
||||
"src/adapters/browser-transfer/image-cdn/browser-image-probe.ts",
|
||||
"src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts",
|
||||
];
|
||||
const primitiveImporters = spawnSync(
|
||||
"git",
|
||||
["grep", "-l", "platform/abortable-operation.ts", "--", "src"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const importers = (
|
||||
primitiveImporters.status === 0 ? primitiveImporters.stdout : ""
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((file) => !file.endsWith("platform/abortable-operation.ts"))
|
||||
.sort();
|
||||
const importerSet = new Set(importers);
|
||||
const missingConsumers = REQUIRED_ABORT_CONSUMERS.filter(
|
||||
(consumer) => !importerSet.has(consumer),
|
||||
);
|
||||
if (missingConsumers.length > 0) {
|
||||
problems.push(
|
||||
`abortable-operation: required consumers do not import the shared primitive: ${missingConsumers.join(
|
||||
", ",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
// The importer must reach the primitive by a specifier that resolves to the
|
||||
// primitive itself, so a same-named local helper cannot satisfy the gate.
|
||||
const PRIMITIVE_PATH = path.resolve(
|
||||
"src/adapters/platform/abortable-operation.ts",
|
||||
);
|
||||
for (const consumer of REQUIRED_ABORT_CONSUMERS) {
|
||||
if (!importerSet.has(consumer)) continue;
|
||||
const source = readFileSync(consumer, "utf8");
|
||||
const specifiers = [
|
||||
...source.matchAll(/from\s+"([^"]*platform\/abortable-operation\.ts)"/gu),
|
||||
].map((match) => match[1] ?? "");
|
||||
const resolved = specifiers.some(
|
||||
(specifier) =>
|
||||
path.resolve(path.dirname(consumer), specifier) === PRIMITIVE_PATH,
|
||||
);
|
||||
if (!resolved) {
|
||||
problems.push(
|
||||
`abortable-operation: ${consumer} does not resolve its import to the shared primitive`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(problem);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// GOV-04. The exact importer set is part of the receipt, so a reviewer can
|
||||
// see which consumers the gate actually verified rather than a bare count.
|
||||
console.log(
|
||||
`Adapter inventory: ${tracked.length} files PASS; ` +
|
||||
`service worker asset table: ${
|
||||
Object.keys(CACHEABLE_ASSET_CONTENT_TYPES).length
|
||||
} shared extensions PASS; ` +
|
||||
`fixture node_modules linking PASS; ` +
|
||||
`shared abort primitive: ${importers.length} importers ` +
|
||||
`(${importers.join(", ")}) PASS`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,165 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
|
||||
/**
|
||||
* GOV-03. Joins the machine-readable remediation dispositions against the prose
|
||||
* ledger.
|
||||
*
|
||||
* The previous ledger declared "All 38 are now FIXED" while six of those rows
|
||||
* were reproducibly partial. A sentence is cheap and a reviewer reads it as
|
||||
* evidence, so this gate makes the claim derivable rather than authored: every
|
||||
* disposition must name a test path that exists, the prose must carry the same
|
||||
* verdict for the same id, and a blanket closure sentence is only allowed when
|
||||
* nothing is still open.
|
||||
*/
|
||||
|
||||
type Disposition = Readonly<{
|
||||
id: string;
|
||||
previous: string;
|
||||
disposition: string;
|
||||
summary: string;
|
||||
evidence: readonly string[];
|
||||
/**
|
||||
* Labels the evidence files actually carry. Defaults to the finding id; a row
|
||||
* declares its own when the review labelled the work differently, as the
|
||||
* cross-audit ids did.
|
||||
*/
|
||||
markers?: readonly string[];
|
||||
}>;
|
||||
|
||||
const DISPOSITIONS_PATH = "docs/operations/adapter-remediation-dispositions.json";
|
||||
const LEDGER_PATH = "docs/operations/adapter-remediation-ledger.md";
|
||||
/**
|
||||
* Finding ids are only unique within a review pass — the second pass also used
|
||||
* `SW-01` — so rows are matched inside this pass's section rather than anywhere
|
||||
* in the document.
|
||||
*/
|
||||
const SECTION_HEADING = "## Third re-review (2026-08-14)";
|
||||
const CLOSED_DISPOSITIONS: ReadonlySet<string> = new Set(["FIXED"]);
|
||||
const RECIPES_PATH = "config/recipes/frontend-capability-recipes.json";
|
||||
/** The table is `| id | prior verdict | disposition | evidence |`. */
|
||||
const DISPOSITION_OFFSET_FROM_ID = 2;
|
||||
/** Sentences that assert everything is done, and therefore need proof. */
|
||||
const BLANKET_CLOSURE = /All\s+(?:\d+|findings|rows)[^.\n]*\b(?:FIXED|closed)\b/giu;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const problems: string[] = [];
|
||||
const raw: unknown = JSON.parse(await readFile(DISPOSITIONS_PATH, "utf8"));
|
||||
if (
|
||||
raw === null ||
|
||||
typeof raw !== "object" ||
|
||||
!Array.isArray((raw as { dispositions?: unknown }).dispositions)
|
||||
) {
|
||||
console.error(`${DISPOSITIONS_PATH}: dispositions array is required`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const dispositions = (raw as { dispositions: Disposition[] }).dispositions;
|
||||
const document = await readFile(LEDGER_PATH, "utf8");
|
||||
const sectionStart = document.indexOf(SECTION_HEADING);
|
||||
if (sectionStart < 0) {
|
||||
console.error(`${LEDGER_PATH}: missing section "${SECTION_HEADING}"`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ledger = document.slice(sectionStart);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const row of dispositions) {
|
||||
if (typeof row.id !== "string" || row.id.length === 0) {
|
||||
problems.push("a disposition row has no id");
|
||||
continue;
|
||||
}
|
||||
if (seen.has(row.id)) problems.push(`duplicate disposition id: ${row.id}`);
|
||||
seen.add(row.id);
|
||||
if (typeof row.disposition !== "string" || row.disposition.length === 0) {
|
||||
problems.push(`${row.id}: disposition is required`);
|
||||
}
|
||||
if (!Array.isArray(row.evidence) || row.evidence.length === 0) {
|
||||
problems.push(`${row.id}: at least one evidence path is required`);
|
||||
continue;
|
||||
}
|
||||
const markers = row.markers ?? [row.id];
|
||||
for (const path of row.evidence) {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = await readFile(path, "utf8");
|
||||
} catch {
|
||||
problems.push(`${row.id}: evidence path does not exist: ${path}`);
|
||||
continue;
|
||||
}
|
||||
// A path that exists proves nothing on its own. The file has to name the
|
||||
// finding it is evidence for, so a row cannot point at an unrelated suite
|
||||
// and look substantiated.
|
||||
if (markers.length > 0 && !markers.some((mark) => contents.includes(mark))) {
|
||||
problems.push(
|
||||
`${row.id}: ${path} does not mention ${markers.join(" or ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The prose must carry the same verdict for the same id, so a reader of the
|
||||
// document and a reader of the receipt cannot reach different conclusions.
|
||||
const line = ledger
|
||||
.split("\n")
|
||||
.find((candidate) => candidate.includes(`| ${row.id} |`));
|
||||
if (!line) {
|
||||
problems.push(`${row.id}: no row in ${LEDGER_PATH}`);
|
||||
continue;
|
||||
}
|
||||
// The row carries the prior verdict as well, so the disposition is read
|
||||
// from its own column. Matching anywhere in the line let the "previous"
|
||||
// cell satisfy the check and hid a disagreement between the two records.
|
||||
const cells = line.split("|").map((cell) => cell.trim());
|
||||
const recorded = cells[cells.indexOf(row.id) + DISPOSITION_OFFSET_FROM_ID];
|
||||
if (recorded !== `\`${row.disposition}\``) {
|
||||
problems.push(
|
||||
`${row.id}: ${LEDGER_PATH} records ${
|
||||
recorded ?? "nothing"
|
||||
} where the receipt says \`${row.disposition}\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const open = dispositions.filter(
|
||||
(row) => !CLOSED_DISPOSITIONS.has(row.disposition),
|
||||
);
|
||||
const blanketClaims = [...document.matchAll(BLANKET_CLOSURE)];
|
||||
if (open.length > 0 && blanketClaims.length > 0) {
|
||||
problems.push(
|
||||
`${LEDGER_PATH} declares a blanket closure (${blanketClaims
|
||||
.map((match) => `"${match[0]}"`)
|
||||
.join(", ")}) while ${open.length} finding(s) are still open: ${open
|
||||
.map((row) => `${row.id}=${row.disposition}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// GOV-05 / X-AUDIT-04. The most drift-prone evidence in this document is a
|
||||
// number someone typed. The one number that gates a release is checked
|
||||
// against its source of truth rather than trusted.
|
||||
const recipes: unknown = JSON.parse(await readFile(RECIPES_PATH, "utf8"));
|
||||
const fileTransfer = (
|
||||
(recipes as { recipes?: readonly Record<string, unknown>[] }).recipes ?? []
|
||||
).find((recipe) => recipe["id"] === "file-transfer");
|
||||
const budget = fileTransfer?.["bundleBudgetGzipBytes"];
|
||||
if (typeof budget !== "number") {
|
||||
problems.push(`${RECIPES_PATH}: file-transfer has no bundle budget`);
|
||||
} else if (!ledger.includes(budget.toLocaleString("en-US"))) {
|
||||
problems.push(
|
||||
`${LEDGER_PATH} does not state the configured file-transfer budget of ${budget.toLocaleString(
|
||||
"en-US",
|
||||
)} gzip bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(problem);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Remediation ledger: ${dispositions.length} dispositions joined to ` +
|
||||
`${LEDGER_PATH}; ${open.length} open; evidence paths verified`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
releaseCandidateManifestSchema,
|
||||
} from "./lib/release-candidate.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import { linkFixtureNodeModules } from "./lib/fixture-node-modules.ts";
|
||||
|
||||
const repositoryRoot = process.cwd();
|
||||
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "provider-exact-five-fixture-"));
|
||||
@@ -59,7 +59,7 @@ try {
|
||||
recursive: true,
|
||||
});
|
||||
await rm(path.join(fixtureRoot, "artifacts/release"), { recursive: true, force: true });
|
||||
await symlink(path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
|
||||
await linkFixtureNodeModules(fixtureRoot, repositoryRoot);
|
||||
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
CACHEABLE_ASSET_CONTENT_TYPES,
|
||||
canonicalStaticManifestBytes,
|
||||
decodeStaticAssetManifest,
|
||||
isCanonicalStaticAssetUrl,
|
||||
} from "../src/contracts/service-worker-static-manifest.ts";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -18,15 +25,9 @@ import {
|
||||
|
||||
const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts";
|
||||
|
||||
const CACHEABLE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".woff2": "font/woff2",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
});
|
||||
// SW-RR-03. The generator and the shared decoder read the same table, so a
|
||||
// manifest this script produces can never be one the runtime contract refuses.
|
||||
const CACHEABLE_EXTENSIONS = CACHEABLE_ASSET_CONTENT_TYPES;
|
||||
|
||||
const EXCLUDED_FILES: ReadonlySet<string> = new Set([
|
||||
"index.html",
|
||||
@@ -58,8 +59,17 @@ export async function collectStaticAssets(
|
||||
if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) {
|
||||
throw new Error(`Static asset exceeds its byte bound: ${relative}`);
|
||||
}
|
||||
// SW-02. The URL is checked against the same predicate the runtime decoder
|
||||
// applies. Emitting a path the decoder will refuse turned a correct build
|
||||
// into a runtime contract failure discovered only at install time.
|
||||
const url = `/${relative.split(path.sep).join("/")}`;
|
||||
if (!isCanonicalStaticAssetUrl(url)) {
|
||||
throw new Error(
|
||||
`Static asset path is not canonical for the service worker manifest: ${relative}`,
|
||||
);
|
||||
}
|
||||
assets.push({
|
||||
url: `/${relative.split(path.sep).join("/")}`,
|
||||
url,
|
||||
sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
||||
bytes: bytes.byteLength,
|
||||
contentType,
|
||||
@@ -74,32 +84,31 @@ export async function collectStaticAssets(
|
||||
throw new Error("Static asset set exceeds its byte bound.");
|
||||
}
|
||||
|
||||
// The set digest is a length-prefixed hash over the sorted asset identities,
|
||||
// so a reordered directory listing cannot change it.
|
||||
const hash = createHash("sha256");
|
||||
hash.update("CA_STATIC_ASSET_SET_V1\0");
|
||||
for (const asset of assets) {
|
||||
hash.update(lengthPrefixed(asset.url));
|
||||
hash.update(lengthPrefixed(asset.sha256));
|
||||
hash.update(lengthPrefixed(String(asset.bytes)));
|
||||
hash.update(lengthPrefixed(asset.contentType));
|
||||
}
|
||||
// SW-05. The canonical byte serialization lives in the shared runtime-neutral
|
||||
// codec so the worker can recompute the identical digest with WebCrypto.
|
||||
const setDigest: `sha256:${string}` = `sha256:${createHash("sha256")
|
||||
.update(canonicalStaticManifestBytes(assets))
|
||||
.digest("hex")}`;
|
||||
|
||||
return {
|
||||
const manifest: StaticAssetManifestV1 = {
|
||||
schemaVersion: 1,
|
||||
buildId,
|
||||
releaseId,
|
||||
setDigest: `sha256:${hash.digest("hex")}`,
|
||||
setDigest,
|
||||
assets,
|
||||
};
|
||||
// SW-02. Every manifest this generator returns has already passed the exact
|
||||
// decoder the runtime will apply to it, so the build stops here rather than
|
||||
// at install time.
|
||||
const decoded = decodeStaticAssetManifest(manifest);
|
||||
if (!decoded.ok) {
|
||||
throw new Error(
|
||||
`Generated service worker manifest is not decodable: ${decoded.error.reason}`,
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function lengthPrefixed(value: string): Buffer {
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
const prefix = Buffer.alloc(4);
|
||||
prefix.writeUInt32BE(bytes.byteLength, 0);
|
||||
return Buffer.concat([prefix, bytes]);
|
||||
}
|
||||
|
||||
async function walk(root: string, current: string): Promise<string[]> {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { lstat, mkdir, readdir, symlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Links the repository's installed dependencies into a throwaway fixture root.
|
||||
*
|
||||
* The obvious form — one directory symlink at `<fixture>/node_modules` — is
|
||||
* destructive. A fixture runs `pnpm` inside itself, pnpm does not recognise the
|
||||
* modules directory it finds there, and its purge follows the symlink and
|
||||
* deletes the *repository's* real dependencies mid-run. With `CI=true` that
|
||||
* happens without a prompt, so a test suite silently uninstalls the workspace
|
||||
* it is running in.
|
||||
*
|
||||
* `node_modules` is therefore a real directory here, and every entry inside it
|
||||
* is an individual symlink. Package resolution is unchanged, but a recursive
|
||||
* delete unlinks the fixture's own symlinks instead of walking through one link
|
||||
* into the shared tree.
|
||||
*/
|
||||
export async function linkFixtureNodeModules(
|
||||
fixtureRoot: string,
|
||||
sourceRoot: string = process.cwd(),
|
||||
): Promise<void> {
|
||||
const source = path.join(sourceRoot, "node_modules");
|
||||
const target = path.join(fixtureRoot, "node_modules");
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const entry of await readdir(source)) {
|
||||
const from = path.join(source, entry);
|
||||
const stats = await lstat(from);
|
||||
await symlink(from, path.join(target, entry), stats.isDirectory() ? "dir" : "file");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
loadCiGateContract,
|
||||
parseCiGateContract,
|
||||
} from "../contracts/ci-gates.ts";
|
||||
import { linkFixtureNodeModules } from "./fixture-node-modules.ts";
|
||||
import { generateCiWorkflow } from "../generate-ci-workflow.ts";
|
||||
|
||||
export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([
|
||||
@@ -42,7 +42,7 @@ export async function prepareRemovalFixture(
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(root, target), { recursive: true });
|
||||
}
|
||||
await symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "dir");
|
||||
await linkFixtureNodeModules(root);
|
||||
}
|
||||
|
||||
export function runRemovalFixturePnpm(
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
canonicalStaticManifestBytes,
|
||||
decodeStaticAssetManifest,
|
||||
} from "../../src/contracts/service-worker-static-manifest.ts";
|
||||
|
||||
import type {
|
||||
InstalledServiceWorkerSelection,
|
||||
ServiceWorkerHandlerId,
|
||||
@@ -79,19 +86,28 @@ export function resolveServiceWorkerBuildInput(input: Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-05. The build gate no longer type-casts the manifest. It decodes every row
|
||||
* through the shared runtime-neutral codec and recomputes the set digest from
|
||||
* the same canonical bytes the generator hashed, so a tampered row, a reordered
|
||||
* set or a stale digest fails admission instead of shipping.
|
||||
*/
|
||||
function parseAssets(value: unknown): StaticAssetManifestV1 {
|
||||
const candidate = record(value);
|
||||
if (
|
||||
candidate?.schemaVersion !== 1 ||
|
||||
typeof candidate.buildId !== "string" ||
|
||||
typeof candidate.releaseId !== "string" ||
|
||||
typeof candidate.setDigest !== "string" ||
|
||||
!DIGEST.test(candidate.setDigest) ||
|
||||
!Array.isArray(candidate.assets)
|
||||
) {
|
||||
throw new TypeError("Generated Service Worker asset manifest is invalid.");
|
||||
const decoded = decodeStaticAssetManifest(value);
|
||||
if (!decoded.ok) {
|
||||
throw new TypeError(
|
||||
`Generated Service Worker asset manifest is invalid: ${decoded.error.reason}`,
|
||||
);
|
||||
}
|
||||
return candidate as unknown as StaticAssetManifestV1;
|
||||
const expected = `sha256:${createHash("sha256")
|
||||
.update(canonicalStaticManifestBytes(decoded.manifest.assets))
|
||||
.digest("hex")}`;
|
||||
if (expected !== decoded.manifest.setDigest) {
|
||||
throw new TypeError(
|
||||
"Generated Service Worker asset manifest set digest does not match its assets.",
|
||||
);
|
||||
}
|
||||
return decoded.manifest as unknown as StaticAssetManifestV1;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import type {
|
||||
AuthSessionPort,
|
||||
CredentialOperationContext,
|
||||
CredentialPatch,
|
||||
CredentialRequestBinding,
|
||||
SessionState,
|
||||
} from "../../application/ports/auth-session-port.ts";
|
||||
import { CREDENTIAL_HEADER_NAMES } from "../../contracts/rest-profiles.ts";
|
||||
|
||||
export type ExternalSessionOwner = Readonly<{
|
||||
readState(): SessionState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
beginSignIn(returnTo?: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
attachCredential(binding: CredentialRequestBinding): Promise<CredentialPatch>;
|
||||
attachCredential(
|
||||
binding: CredentialRequestBinding,
|
||||
context?: CredentialOperationContext,
|
||||
): Promise<CredentialPatch>;
|
||||
recoverSession(): Promise<"restored" | "no-session">;
|
||||
notifyUnauthenticated(): void;
|
||||
}>;
|
||||
|
||||
const ALLOWED_CREDENTIAL_HEADERS = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
]);
|
||||
const ALLOWED_CREDENTIAL_HEADERS = new Set<string>(CREDENTIAL_HEADER_NAMES);
|
||||
const MAX_HEADER_VALUE_BYTES = 8_192;
|
||||
|
||||
export function validateCredentialPatch(value: unknown): CredentialPatch {
|
||||
@@ -54,8 +56,10 @@ export function createExternalAuthSessionAdapter(
|
||||
subscribe: (listener) => owner.subscribe(listener),
|
||||
beginSignIn: (returnTo) => owner.beginSignIn(returnTo),
|
||||
signOut: () => owner.signOut(),
|
||||
async credentialPatch(binding) {
|
||||
return validateCredentialPatch(await owner.attachCredential(binding));
|
||||
async credentialPatch(binding, context) {
|
||||
return validateCredentialPatch(
|
||||
await owner.attachCredential(binding, context),
|
||||
);
|
||||
},
|
||||
async recover() {
|
||||
const result = await owner.recoverSession();
|
||||
@@ -85,9 +89,23 @@ export function createAnonymousSessionAdapter(): AuthSessionPort {
|
||||
export type DemoSessionAdapter = AuthSessionPort &
|
||||
Readonly<{ setState(next: SessionState): void }>;
|
||||
|
||||
/**
|
||||
* §7.7. `AUTH_MODE=demo` still runs against the strict
|
||||
* `REFERENCE_EXTERNAL_BEARER` profile, so the demo owner must supply a real
|
||||
* proof header. This marker is a fixed, non-secret placeholder: it exists so
|
||||
* the demo path satisfies the bearer contract instead of weakening it.
|
||||
*/
|
||||
export const DEMO_AUTHORIZATION_MARKER = "Bearer demo-session-not-a-secret";
|
||||
|
||||
const DEMO_PATCH = Object.freeze({
|
||||
headers: Object.freeze({ authorization: DEMO_AUTHORIZATION_MARKER }),
|
||||
});
|
||||
|
||||
export function createDemoSessionAdapter(
|
||||
initialState: SessionState = "unauthenticated",
|
||||
demoPatch: CredentialPatch = DEMO_PATCH,
|
||||
): DemoSessionAdapter {
|
||||
const patch = validateCredentialPatch(demoPatch);
|
||||
let state = initialState;
|
||||
const listeners = new Set<() => void>();
|
||||
const setState = (next: SessionState) => {
|
||||
@@ -106,7 +124,7 @@ export function createDemoSessionAdapter(
|
||||
async signOut() {
|
||||
setState("unauthenticated");
|
||||
},
|
||||
credentialPatch: async () => EMPTY_PATCH,
|
||||
credentialPatch: async () => patch,
|
||||
async recover() {
|
||||
if (state === "recovery-pending") {
|
||||
setState("authenticated");
|
||||
|
||||
@@ -443,20 +443,23 @@ function browserManagedHandoff(context: Readonly<{
|
||||
context.options.observer,
|
||||
);
|
||||
}
|
||||
const href = capability.value.href;
|
||||
if (
|
||||
!safeBrowserManagedTarget(href, context.baseOrigin, {
|
||||
const target = resolveBrowserManagedTarget(
|
||||
capability.value.href,
|
||||
context.baseOrigin,
|
||||
{
|
||||
allowCrossOrigin:
|
||||
context.options.allowCrossOriginBrowserHandoff ?? false,
|
||||
allowQuery: context.options.allowBrowserManagedQuery ?? false,
|
||||
})
|
||||
) {
|
||||
return observeResult(
|
||||
browserDataFailure("POLICY_REJECTED", "DOWNLOAD"),
|
||||
context.options.observer,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!target.ok) {
|
||||
return observeResult(target, context.options.observer);
|
||||
}
|
||||
context.options.host.handoff(href, context.suggestedFileName);
|
||||
// The host receives the parsed canonical URL, never the raw string.
|
||||
context.options.host.handoff(
|
||||
target.value.absoluteHref,
|
||||
context.suggestedFileName,
|
||||
);
|
||||
return observeResult(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
@@ -566,11 +569,13 @@ async function promptAndStream(context: Readonly<{
|
||||
);
|
||||
}
|
||||
|
||||
const sourceHolder = createSourceHolder();
|
||||
try {
|
||||
const sourceResult = await resolveByteSource(
|
||||
context.input,
|
||||
context.input.signal,
|
||||
context.options,
|
||||
sourceHolder,
|
||||
);
|
||||
if (!sourceResult.ok) {
|
||||
return observeResult(sourceResult, context.options.observer);
|
||||
@@ -699,6 +704,10 @@ async function promptAndStream(context: Readonly<{
|
||||
mapDownloadException(error),
|
||||
context.options.observer,
|
||||
);
|
||||
} finally {
|
||||
// TR-RR-04. Exactly once, on every path: success, validation failure,
|
||||
// writer failure and abort.
|
||||
closeHeldSource(sourceHolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,12 +730,15 @@ async function boundedObjectUrlHandoff(context: Readonly<{
|
||||
context.options.observer,
|
||||
);
|
||||
}
|
||||
const sourceHolder = createSourceHolder();
|
||||
const sourceResult = await resolveByteSource(
|
||||
context.input,
|
||||
context.input.signal,
|
||||
context.options,
|
||||
sourceHolder,
|
||||
);
|
||||
if (!sourceResult.ok) {
|
||||
closeHeldSource(sourceHolder);
|
||||
return observeResult(sourceResult, context.options.observer);
|
||||
}
|
||||
const source = sourceResult.value;
|
||||
@@ -735,6 +747,7 @@ async function boundedObjectUrlHandoff(context: Readonly<{
|
||||
(source.byteLength > context.input.maxBufferedBytes ||
|
||||
source.byteLength > context.input.maxTransferBytes)
|
||||
) {
|
||||
closeHeldSource(sourceHolder);
|
||||
return observeResult(
|
||||
browserDataFailure("LIMIT_EXCEEDED", "DOWNLOAD"),
|
||||
context.options.observer,
|
||||
@@ -835,6 +848,9 @@ async function boundedObjectUrlHandoff(context: Readonly<{
|
||||
context.options.observer,
|
||||
transferred,
|
||||
);
|
||||
} finally {
|
||||
// TR-RR-04. Exactly once, on every path.
|
||||
closeHeldSource(sourceHolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,10 +1010,63 @@ function validateDownloadInput(
|
||||
return browserDataSuccess(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 success, validation failure, writer failure and abort leaked both. The
|
||||
* closeable subtype is lost in the `FileByteSource` projection, so the holder
|
||||
* keeps it and the outermost boundary closes it exactly once.
|
||||
*/
|
||||
type CloseableSourceHolder = { source: FileByteSource | null; closed: boolean };
|
||||
|
||||
function createSourceHolder(): CloseableSourceHolder {
|
||||
return { source: null, closed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-02. Closes a lease that fulfilled after the delivery already ended. The
|
||||
* holder's own `closed` latch is the single close-once authority, so a lease
|
||||
* the holder did adopt is never closed twice and a lease it never saw is still
|
||||
* closed exactly once. A late rejection is observed and discarded.
|
||||
*/
|
||||
function compensateLateSource(
|
||||
pending: Promise<BrowserDataResult<FileByteSource>>,
|
||||
holder: CloseableSourceHolder | undefined,
|
||||
): void {
|
||||
if (!holder) return;
|
||||
void pending.then(
|
||||
(result) => {
|
||||
if (!result.ok || !holder.closed) return;
|
||||
// The holder was already closed, so this lease was never adopted.
|
||||
if (!isVerifiedPresignedSource(result.value)) return;
|
||||
try {
|
||||
result.value.close();
|
||||
} catch {
|
||||
// Compensation is best effort and never changes the outcome.
|
||||
}
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function closeHeldSource(holder: CloseableSourceHolder): void {
|
||||
if (holder.closed) return;
|
||||
holder.closed = true;
|
||||
const source = holder.source;
|
||||
holder.source = null;
|
||||
if (!source || !isVerifiedPresignedSource(source)) return;
|
||||
try {
|
||||
source.close();
|
||||
} catch {
|
||||
// Closing is best effort and never changes the delivery outcome.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveByteSource(
|
||||
input: DeliveryInput,
|
||||
signal: AbortSignal,
|
||||
options: DownloadDeliveryAdapterOptions,
|
||||
holder?: CloseableSourceHolder,
|
||||
): Promise<BrowserDataResult<FileByteSource>> {
|
||||
const source = input.source;
|
||||
if (signal.aborted) {
|
||||
@@ -1013,20 +1082,26 @@ async function resolveByteSource(
|
||||
}
|
||||
const open = options.openAuthorizedSource;
|
||||
if (!open) return browserDataFailure("UNSUPPORTED", "DOWNLOAD");
|
||||
const result = await awaitWithSignal(
|
||||
open({
|
||||
resourceId: source.resourceId,
|
||||
capability: source.capability,
|
||||
signal,
|
||||
}),
|
||||
const pendingOpen = open({
|
||||
resourceId: source.resourceId,
|
||||
capability: source.capability,
|
||||
signal,
|
||||
);
|
||||
});
|
||||
// TR-02. A lease that arrives after the abort already ended the delivery
|
||||
// never reaches the holder, so nothing would ever close it: the fetch reader
|
||||
// and the capability lease outlived the terminal result. The compensator and
|
||||
// the holder share one close-once latch, so exactly one of them closes it.
|
||||
compensateLateSource(pendingOpen, holder);
|
||||
const result = await awaitWithSignal(pendingOpen, signal);
|
||||
if (!result.ok) {
|
||||
return browserDataFailure(result.error.code, "DOWNLOAD", {
|
||||
retryable: result.error.retryable,
|
||||
recovery: result.error.recovery,
|
||||
});
|
||||
}
|
||||
// Held from the moment the lease exists, so a validation failure below still
|
||||
// closes it.
|
||||
if (holder) holder.source = result.value;
|
||||
return validPresignedByteSource(
|
||||
result.value,
|
||||
source.capability,
|
||||
@@ -1245,28 +1320,45 @@ function validateBrowserManagedCapability(
|
||||
);
|
||||
}
|
||||
|
||||
function safeBrowserManagedTarget(
|
||||
type ResolvedBrowserManagedTarget = Readonly<{ absoluteHref: string }>;
|
||||
|
||||
/**
|
||||
* STO-02. Parse once, canonicalize, then execute the canonical value.
|
||||
*
|
||||
* Returning a boolean and handing the raw href to the host let the browser
|
||||
* re-resolve a relative target against `document.baseURI`, so a hostile
|
||||
* `<base>` could send the navigation to a different origin than the one this
|
||||
* policy just approved.
|
||||
*/
|
||||
function resolveBrowserManagedTarget(
|
||||
href: string,
|
||||
baseOrigin: string,
|
||||
policy: Readonly<{
|
||||
allowCrossOrigin: boolean;
|
||||
allowQuery: boolean;
|
||||
}>,
|
||||
): boolean {
|
||||
): BrowserDataResult<ResolvedBrowserManagedTarget> {
|
||||
let base: URL;
|
||||
let target: URL;
|
||||
try {
|
||||
const base = new URL(baseOrigin);
|
||||
const target = new URL(href, base);
|
||||
return (
|
||||
["http:", "https:"].includes(target.protocol) &&
|
||||
target.username.length === 0 &&
|
||||
target.password.length === 0 &&
|
||||
(policy.allowCrossOrigin || target.origin === base.origin) &&
|
||||
(policy.allowQuery || target.search.length === 0) &&
|
||||
target.hash.length === 0
|
||||
);
|
||||
base = new URL(baseOrigin);
|
||||
target = new URL(href, base);
|
||||
} catch {
|
||||
return false;
|
||||
return browserDataFailure("POLICY_REJECTED", "DOWNLOAD");
|
||||
}
|
||||
if (
|
||||
!["http:", "https:"].includes(target.protocol) ||
|
||||
target.username.length > 0 ||
|
||||
target.password.length > 0 ||
|
||||
(!policy.allowCrossOrigin && target.origin !== base.origin) ||
|
||||
(!policy.allowQuery && target.search.length > 0) ||
|
||||
target.hash.length > 0
|
||||
) {
|
||||
return browserDataFailure("POLICY_REJECTED", "DOWNLOAD");
|
||||
}
|
||||
return browserDataSuccess(
|
||||
Object.freeze({ absoluteHref: target.href }),
|
||||
);
|
||||
}
|
||||
|
||||
function safeOpaqueId(value: unknown): value is string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@ export {
|
||||
type BrowserRpcRuntimeDependencies,
|
||||
} from "./browser-rpc-runtime.ts";
|
||||
export {
|
||||
decodeServerStreamLease,
|
||||
defineBrowserRpcTransport,
|
||||
type BrowserRpcServerStreamLease,
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
type BrowserRpcTransportCall,
|
||||
|
||||
@@ -50,6 +50,29 @@ export type BrowserRpcStreamFrame =
|
||||
failure: BrowserRpcTransportFailure;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* RPC-RR-01. A server stream is a physical resource, not just a sequence.
|
||||
*
|
||||
* A bare `AsyncIterable` gives the runtime no way to cancel the underlying
|
||||
* stream or to learn when it actually closed: `iterator.return()` is a request
|
||||
* a non-cooperative implementation may ignore. The runtime could then time out,
|
||||
* report the call finished, and admit a second stream for the same operation
|
||||
* while the first was still running against the server.
|
||||
*
|
||||
* The lease separates the three concerns the runtime needs:
|
||||
*
|
||||
* - `frames` is the sequence,
|
||||
* - `cancel(reason)` is a synchronous request to stop the physical stream,
|
||||
* - `waitClosed()` settles only once that stream is really closed,
|
||||
* - `streamId` names the physical stream so two leases are never confused.
|
||||
*/
|
||||
export type BrowserRpcServerStreamLease = Readonly<{
|
||||
streamId: string;
|
||||
frames: AsyncIterable<BrowserRpcStreamFrame>;
|
||||
cancel(reason: string): void;
|
||||
waitClosed(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity &
|
||||
Readonly<{
|
||||
invokeUnary?(
|
||||
@@ -57,9 +80,78 @@ export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity &
|
||||
): Promise<BrowserRpcUnaryTransportResult>;
|
||||
openServerStream?(
|
||||
call: BrowserRpcTransportCall,
|
||||
): AsyncIterable<BrowserRpcStreamFrame>;
|
||||
): BrowserRpcServerStreamLease;
|
||||
}>;
|
||||
|
||||
const STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
||||
|
||||
/**
|
||||
* RPC-RR-01. Decodes a lease from own data descriptors before the runtime
|
||||
* registers it, so an accessor cannot hand the registry one object and the
|
||||
* cancellation path another.
|
||||
*/
|
||||
export function decodeServerStreamLease(
|
||||
value: unknown,
|
||||
): BrowserRpcServerStreamLease | null {
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
let streamId: unknown;
|
||||
let frames: unknown;
|
||||
let cancel: unknown;
|
||||
let waitClosed: unknown;
|
||||
try {
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) return null;
|
||||
const names = Object.getOwnPropertyNames(value).sort();
|
||||
const expected = ["cancel", "frames", "streamId", "waitClosed"];
|
||||
if (
|
||||
names.length !== expected.length ||
|
||||
names.some((name, index) => name !== expected[index])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
for (const name of names) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, name);
|
||||
if (!descriptor || !("value" in descriptor)) return null;
|
||||
}
|
||||
streamId = Object.getOwnPropertyDescriptor(value, "streamId")?.value;
|
||||
frames = Object.getOwnPropertyDescriptor(value, "frames")?.value;
|
||||
cancel = Object.getOwnPropertyDescriptor(value, "cancel")?.value;
|
||||
waitClosed = Object.getOwnPropertyDescriptor(value, "waitClosed")?.value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// RPC-02. The async-iterator lookup is a read of foreign state like any
|
||||
// other, so it happens inside the decoder's own boundary. Performing it after
|
||||
// the `try` let a throwing `Symbol.asyncIterator` getter escape this
|
||||
// function as a native `TypeError`, breaking the decoder's totality.
|
||||
let openFrames: unknown;
|
||||
try {
|
||||
if (
|
||||
typeof streamId !== "string" ||
|
||||
!STREAM_ID.test(streamId) ||
|
||||
frames === null ||
|
||||
typeof frames !== "object" ||
|
||||
typeof cancel !== "function" ||
|
||||
typeof waitClosed !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
openFrames = (frames as AsyncIterable<unknown>)[Symbol.asyncIterator];
|
||||
if (typeof openFrames !== "function") return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const iterate = (openFrames as () => AsyncIterator<BrowserRpcStreamFrame>)
|
||||
.bind(frames);
|
||||
return Object.freeze({
|
||||
streamId,
|
||||
frames: Object.freeze({
|
||||
[Symbol.asyncIterator]: iterate,
|
||||
}) as AsyncIterable<BrowserRpcStreamFrame>,
|
||||
cancel: (cancel as (reason: string) => void).bind(value),
|
||||
waitClosed: (waitClosed as () => Promise<void>).bind(value),
|
||||
});
|
||||
}
|
||||
|
||||
export function defineBrowserRpcTransport(
|
||||
transport: BrowserRpcTransport,
|
||||
): BrowserRpcTransport {
|
||||
|
||||
@@ -31,11 +31,22 @@ export function createUnavailableBrowserRpcTransport(input: Readonly<{
|
||||
}
|
||||
return defineBrowserRpcTransport({
|
||||
...input,
|
||||
async *openServerStream() {
|
||||
yield Object.freeze({
|
||||
kind: "TERMINAL",
|
||||
ok: false,
|
||||
failure: Object.freeze({ code: "UNAVAILABLE" }),
|
||||
// RPC-RR-01. Even a stream that never opens hands back a lease, so the
|
||||
// runtime's registry and cancellation path have one shape to work with.
|
||||
openServerStream() {
|
||||
return Object.freeze({
|
||||
streamId: `unavailable-${input.runtimeProfileId}`,
|
||||
frames: Object.freeze({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield Object.freeze({
|
||||
kind: "TERMINAL" as const,
|
||||
ok: false as const,
|
||||
failure: Object.freeze({ code: "UNAVAILABLE" as const }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
cancel() {},
|
||||
async waitClosed() {},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortTimerSnapshot,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import { parseStaticImageHeaderMetadata } from "./image-header-metadata.ts";
|
||||
|
||||
export type DecodedImageFacade = Readonly<{
|
||||
@@ -47,7 +52,7 @@ export function createBrowserImageProbe(
|
||||
? async (image: Blob) => createImageBitmap(image)
|
||||
: undefined);
|
||||
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const scheduler = snapshotScheduler(
|
||||
const timers = snapshotAbortTimers(
|
||||
dependencies.scheduler ?? defaultScheduler(),
|
||||
);
|
||||
if (
|
||||
@@ -98,10 +103,15 @@ export function createBrowserImageProbe(
|
||||
const scope = createProbeAbortScope(
|
||||
request.signal,
|
||||
timeoutMs,
|
||||
scheduler,
|
||||
timers,
|
||||
);
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
if (scope.signal.aborted) {
|
||||
// Nothing physical has started yet: an already aborted caller or a
|
||||
// deadline that could not be installed ends the probe before fetch.
|
||||
return signalFailure(request.signal);
|
||||
}
|
||||
try {
|
||||
const fetchTask = Promise.resolve(
|
||||
fetcher(url.href, {
|
||||
@@ -114,15 +124,11 @@ export function createBrowserImageProbe(
|
||||
signal: scope.signal,
|
||||
}),
|
||||
);
|
||||
response = await awaitWithAbort(
|
||||
fetchTask,
|
||||
scope.signal,
|
||||
(lateResponse) => {
|
||||
cancelResponseBody(lateResponse);
|
||||
},
|
||||
);
|
||||
response = await scope.await(fetchTask, (lateResponse) => {
|
||||
cancelResponseBody(lateResponse);
|
||||
});
|
||||
} catch {
|
||||
return signalFailure(request.signal, scope);
|
||||
return signalFailure(request.signal);
|
||||
}
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
@@ -150,7 +156,7 @@ export function createBrowserImageProbe(
|
||||
);
|
||||
} catch (error) {
|
||||
if (request.signal.aborted || scope.timedOut()) {
|
||||
return signalFailure(request.signal, scope);
|
||||
return signalFailure(request.signal);
|
||||
}
|
||||
return error instanceof EncodedBodyLimitError
|
||||
? browserDataFailure(
|
||||
@@ -221,11 +227,7 @@ export function createBrowserImageProbe(
|
||||
type: request.expectedMediaType,
|
||||
}),
|
||||
);
|
||||
bitmap = await awaitWithAbort(
|
||||
decodeTask,
|
||||
scope.signal,
|
||||
closeBitmap,
|
||||
);
|
||||
bitmap = await scope.await(decodeTask, closeBitmap);
|
||||
if (
|
||||
!positiveSafeInteger(bitmap.width) ||
|
||||
!positiveSafeInteger(bitmap.height) ||
|
||||
@@ -254,7 +256,7 @@ export function createBrowserImageProbe(
|
||||
);
|
||||
} catch {
|
||||
return request.signal.aborted || scope.timedOut()
|
||||
? signalFailure(request.signal, scope)
|
||||
? signalFailure(request.signal)
|
||||
: browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"IMAGE_RESOLVE",
|
||||
@@ -310,9 +312,13 @@ function validResponseHeaders(
|
||||
);
|
||||
if (!directives) return false;
|
||||
if (request.delivery === "PRIVATE_SIGNED") {
|
||||
return (
|
||||
directives.get("no-store") === true &&
|
||||
!directives.has("public")
|
||||
// TR-RR-09. The recorded BT-IMG-02 contract is a fail-closed matrix, not a
|
||||
// pair of checks: a private response must carry `no-store` and nothing else
|
||||
// that describes cacheability. Only a syntactically valid unknown extension
|
||||
// is ignored, so a contradictory pairing can never read as acceptable.
|
||||
if (directives.get("no-store") !== true) return false;
|
||||
return !PRIVATE_FORBIDDEN_DIRECTIVES.some((name) =>
|
||||
directives.has(name),
|
||||
);
|
||||
}
|
||||
const maxAge = directives.get("max-age");
|
||||
@@ -336,6 +342,92 @@ function validResponseHeaders(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-RR-09. Every cacheability directive a `PRIVATE_SIGNED` response may not
|
||||
* carry alongside `no-store`.
|
||||
*/
|
||||
const PRIVATE_FORBIDDEN_DIRECTIVES: readonly string[] = Object.freeze([
|
||||
"public",
|
||||
"private",
|
||||
"immutable",
|
||||
"max-age",
|
||||
"s-maxage",
|
||||
"no-cache",
|
||||
"must-revalidate",
|
||||
"proxy-revalidate",
|
||||
]);
|
||||
|
||||
/**
|
||||
* BT-IMG-02. Quote- and escape-aware Cache-Control tokenizer.
|
||||
*
|
||||
* A naive comma split plus `replace(/^"|"$/g, "")` accepted `max-age="60` and
|
||||
* `max-age=60"` as the number 60, so a malformed policy could be approved as an
|
||||
* immutable public response. A comma inside a quoted extension value is also
|
||||
* not a directive boundary.
|
||||
*/
|
||||
function splitCacheControlDirectives(value: string): string[] | null {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let escaped = false;
|
||||
for (const character of value) {
|
||||
if (escaped) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
// quoted-pair may not carry a bare control character.
|
||||
if (code <= 0x1f || code === 0x7f) return null;
|
||||
current += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (inQuotes && character === "\\") {
|
||||
escaped = true;
|
||||
current += character;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += character;
|
||||
continue;
|
||||
}
|
||||
if (character === "," && !inQuotes) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += character;
|
||||
}
|
||||
// An unterminated quoted-string or a dangling escape is malformed.
|
||||
if (inQuotes || escaped) return null;
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function unquoteCacheControlValue(rawValue: string): string | null {
|
||||
if (!rawValue.startsWith('"')) {
|
||||
// A bare value may not contain a quote at all.
|
||||
return rawValue.includes('"') ? null : rawValue;
|
||||
}
|
||||
if (rawValue.length < 2 || !rawValue.endsWith('"')) return null;
|
||||
const inner = rawValue.slice(1, -1);
|
||||
let unquoted = "";
|
||||
let escaped = false;
|
||||
for (const character of inner) {
|
||||
if (escaped) {
|
||||
unquoted += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
// An unescaped quote inside the string means the quoting is unbalanced.
|
||||
if (character === '"') return null;
|
||||
unquoted += character;
|
||||
}
|
||||
return escaped ? null : unquoted;
|
||||
}
|
||||
|
||||
function parseCacheControl(
|
||||
value: string | null,
|
||||
): ReadonlyMap<string, string | true> | null {
|
||||
@@ -348,7 +440,10 @@ function parseCacheControl(
|
||||
"public",
|
||||
]);
|
||||
const directives = new Map<string, string | true>();
|
||||
for (const part of value?.split(",") ?? []) {
|
||||
if (value === null) return directives;
|
||||
const parts = splitCacheControlDirectives(value);
|
||||
if (!parts) return null;
|
||||
for (const part of parts) {
|
||||
const trimmedPart = part.trim();
|
||||
const separator = trimmedPart.indexOf("=");
|
||||
const name = (
|
||||
@@ -367,7 +462,9 @@ function parseCacheControl(
|
||||
if (flagDirectives.has(name)) return null;
|
||||
const rawValue = trimmedPart.slice(separator + 1).trim();
|
||||
if (rawValue === "") return null;
|
||||
directives.set(name, rawValue.replace(/^"|"$/gu, ""));
|
||||
const unquoted = unquoteCacheControlValue(rawValue);
|
||||
if (unquoted === null) return null;
|
||||
directives.set(name, unquoted);
|
||||
}
|
||||
return directives;
|
||||
}
|
||||
@@ -388,11 +485,7 @@ async function readBoundedBody(
|
||||
try {
|
||||
while (true) {
|
||||
if (signal.aborted) throw abortException();
|
||||
const next = await awaitWithAbort(
|
||||
reader.read(),
|
||||
signal,
|
||||
() => undefined,
|
||||
);
|
||||
const next = await readOrAbort(reader.read(), signal);
|
||||
if (next.done) break;
|
||||
if (!(next.value instanceof Uint8Array)) {
|
||||
throw new TypeError("Image response chunk is invalid.");
|
||||
@@ -424,97 +517,99 @@ async function readBoundedBody(
|
||||
|
||||
type ProbeAbortScope = Readonly<{
|
||||
signal: AbortSignal;
|
||||
/** True once a deadline, or a deadline that could not be installed, ended it. */
|
||||
timedOut(): boolean;
|
||||
/**
|
||||
* Awaits `task` under this scope's ownership. A late value is compensated
|
||||
* exactly once; a terminal owner raises the scope's abort exception.
|
||||
*/
|
||||
await<Value>(
|
||||
task: Promise<Value>,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value>;
|
||||
release(): void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* X-AUDIT-02 / TR-RR-05. The probe scope is the shared abort primitive with
|
||||
* this subsystem's vocabulary on top. Owning a private copy meant the caller
|
||||
* listener was attached before the timer was installed, so a scheduler that
|
||||
* threw rejected the public `probe()` promise and left the listener behind.
|
||||
*/
|
||||
function createProbeAbortScope(
|
||||
externalSignal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: ImageProbeScheduler,
|
||||
timers: AbortTimerSnapshot,
|
||||
): ProbeAbortScope {
|
||||
const controller = new AbortController();
|
||||
let timeoutReached = false;
|
||||
let released = false;
|
||||
const onExternalAbort = () => {
|
||||
controller.abort(externalSignal.reason);
|
||||
};
|
||||
externalSignal.addEventListener("abort", onExternalAbort, {
|
||||
once: true,
|
||||
const operation = createAbortableOperation({
|
||||
signal: externalSignal,
|
||||
timeoutMs,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
if (externalSignal.aborted) onExternalAbort();
|
||||
const timeoutHandle = scheduler.setTimeout(() => {
|
||||
if (released) return;
|
||||
timeoutReached = true;
|
||||
controller.abort(abortException());
|
||||
}, timeoutMs);
|
||||
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timeoutReached,
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
scheduler.clearTimeout(timeoutHandle);
|
||||
} catch {
|
||||
// A broken optional scheduler cannot change a terminal probe result.
|
||||
}
|
||||
externalSignal.removeEventListener("abort", onExternalAbort);
|
||||
signal: operation.signal,
|
||||
// `CLOSED` here means the deadline could never be installed, so the probe
|
||||
// was never bounded: operationally the same unanswered host as a deadline.
|
||||
timedOut: () =>
|
||||
operation.terminal() !== "CALLER_ABORT" && operation.terminal() !== null,
|
||||
async await<Value>(
|
||||
task: Promise<Value>,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value> {
|
||||
const raced = await operation.race(task, onLateValue);
|
||||
if (raced.kind === "VALUE") return raced.value;
|
||||
if (raced.kind === "REJECTED") throw raced.reason;
|
||||
throw abortException();
|
||||
},
|
||||
release: () => operation.close(),
|
||||
});
|
||||
}
|
||||
|
||||
function awaitWithAbort<Value>(
|
||||
/**
|
||||
* A single read raced against the probe's ownership. A late chunk is dropped:
|
||||
* the bytes are only ever accumulated by the caller below.
|
||||
*/
|
||||
function readOrAbort<Value>(
|
||||
task: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
onLateValue: (value: Value) => void,
|
||||
): Promise<Value> {
|
||||
return new Promise<Value>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(abortException());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
void task.then(
|
||||
(value) => {
|
||||
if (settled) {
|
||||
onLateValue(value);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function signalFailure(
|
||||
externalSignal: AbortSignal,
|
||||
scope: ProbeAbortScope,
|
||||
) {
|
||||
function signalFailure(externalSignal: AbortSignal) {
|
||||
// A caller abort is the caller's own verdict; every other owner — a deadline
|
||||
// or a deadline that could never be installed — is an unanswered host.
|
||||
return externalSignal.aborted
|
||||
? browserDataFailure("ABORTED", "IMAGE_RESOLVE")
|
||||
: scope.timedOut()
|
||||
? browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
|
||||
retryable: true,
|
||||
recovery: "RETRY",
|
||||
})
|
||||
: browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
|
||||
retryable: true,
|
||||
recovery: "RETRY",
|
||||
});
|
||||
: browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
|
||||
retryable: true,
|
||||
recovery: "RETRY",
|
||||
});
|
||||
}
|
||||
|
||||
function cancelResponseBody(response: Response): void {
|
||||
@@ -564,22 +659,6 @@ function withinDecodeBudget(
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotScheduler(
|
||||
scheduler: ImageProbeScheduler,
|
||||
): ImageProbeScheduler {
|
||||
if (
|
||||
!scheduler ||
|
||||
typeof scheduler.setTimeout !== "function" ||
|
||||
typeof scheduler.clearTimeout !== "function"
|
||||
) {
|
||||
throw new TypeError("Image probe scheduler is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
setTimeout: scheduler.setTimeout.bind(scheduler),
|
||||
clearTimeout: scheduler.clearTimeout.bind(scheduler),
|
||||
});
|
||||
}
|
||||
|
||||
function defaultScheduler(): ImageProbeScheduler {
|
||||
return Object.freeze({
|
||||
setTimeout(callback: () => void, milliseconds: number) {
|
||||
|
||||
@@ -323,6 +323,11 @@ export function createImageCdnRuntime(
|
||||
);
|
||||
}
|
||||
activeCapabilityVerifications += 1;
|
||||
// TR-RR-07. The slot belongs to the raw verifier, not to this wrapper.
|
||||
// Releasing it when the wrapper's deadline expired let an abandoned
|
||||
// verification keep running while a new one was admitted, so repeated
|
||||
// timeouts produced more physical work than the configured cap allows.
|
||||
const rawVerificationTasks: Promise<unknown>[] = [];
|
||||
try {
|
||||
const canonicalPayload =
|
||||
canonicalImageCapabilityPayload(snapshot);
|
||||
@@ -345,8 +350,10 @@ export function createImageCdnRuntime(
|
||||
if (deadline.signal.aborted) {
|
||||
throw capabilityVerificationAbortException();
|
||||
}
|
||||
const digestTask = sha256Hex(digest, canonicalPayload);
|
||||
rawVerificationTasks.push(digestTask);
|
||||
bindingDigest = await awaitImageRuntimeAbort(
|
||||
sha256Hex(digest, canonicalPayload),
|
||||
digestTask,
|
||||
deadline.signal,
|
||||
);
|
||||
if (
|
||||
@@ -363,14 +370,15 @@ export function createImageCdnRuntime(
|
||||
if (deadline.signal.aborted) {
|
||||
throw capabilityVerificationAbortException();
|
||||
}
|
||||
const verifyTask = verifyCapability({
|
||||
algorithm: snapshot.signature.algorithm,
|
||||
keyId: snapshot.signature.keyId,
|
||||
canonicalPayload: Uint8Array.from(canonicalPayload),
|
||||
signatureBase64Url: snapshot.signature.valueBase64Url,
|
||||
});
|
||||
rawVerificationTasks.push(verifyTask);
|
||||
verified = await awaitImageRuntimeAbort(
|
||||
verifyCapability({
|
||||
algorithm: snapshot.signature.algorithm,
|
||||
keyId: snapshot.signature.keyId,
|
||||
canonicalPayload: Uint8Array.from(canonicalPayload),
|
||||
signatureBase64Url:
|
||||
snapshot.signature.valueBase64Url,
|
||||
}),
|
||||
verifyTask,
|
||||
deadline.signal,
|
||||
);
|
||||
} catch {
|
||||
@@ -427,7 +435,10 @@ export function createImageCdnRuntime(
|
||||
);
|
||||
return browserDataSuccess(reference);
|
||||
} finally {
|
||||
activeCapabilityVerifications -= 1;
|
||||
// Released only once the physical work this slot admitted has settled.
|
||||
void Promise.allSettled(rawVerificationTasks).then(() => {
|
||||
activeCapabilityVerifications -= 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
PRESIGNED_TRANSFER_PROTOCOL,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import type {
|
||||
PresignedDownloadCapability,
|
||||
PresignedTransferBinding,
|
||||
@@ -7,6 +10,11 @@ import type {
|
||||
PresignedUploadPartCapability,
|
||||
PresignedUploadPartCapabilityProvider,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortTimerSnapshot,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
@@ -141,16 +149,20 @@ export function createPresignedCapabilityHttpProvider(
|
||||
);
|
||||
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
|
||||
const now = options.now ?? Date.now;
|
||||
const scheduler =
|
||||
// X-AUDIT-02. The timer callables are captured once, bound to their
|
||||
// receiver, so replacing a scheduler method after composition cannot change
|
||||
// how work already in flight is bounded.
|
||||
const timers = snapshotAbortTimers(
|
||||
options.scheduler ??
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler);
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler),
|
||||
);
|
||||
const credentials = options.controlPlaneCredentials ?? "same-origin";
|
||||
const observer = options.observer;
|
||||
|
||||
@@ -183,9 +195,10 @@ export function createPresignedCapabilityHttpProvider(
|
||||
if (signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
||||
const scope = createAbortScope(signal, timeoutMs, timers);
|
||||
try {
|
||||
const response = await fetcher(endpoint, {
|
||||
const raced = await scope.race(
|
||||
fetcher(endpoint, {
|
||||
method: "POST",
|
||||
credentials,
|
||||
redirect: "error",
|
||||
@@ -196,6 +209,10 @@ export function createPresignedCapabilityHttpProvider(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// BT-PRE-02. The server negotiates by request shape: a legacy request
|
||||
// gets a legacy response and a V1 request gets a V1 response. Fields
|
||||
// are never dual-emitted into a strict decoder.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
method: expected.method,
|
||||
binding: expected.binding,
|
||||
...(expected.mediaType !== undefined
|
||||
@@ -208,8 +225,13 @@ export function createPresignedCapabilityHttpProvider(
|
||||
? { expectedSha256: expected.expectedSha256 }
|
||||
: {}),
|
||||
}),
|
||||
signal: scope.signal,
|
||||
});
|
||||
signal: scope.signal,
|
||||
}),
|
||||
);
|
||||
if (raced === SCOPE_ENDED) {
|
||||
return transferFailure(signal, scope.timedOut());
|
||||
}
|
||||
const response = raced;
|
||||
if (
|
||||
response.redirected ||
|
||||
response.type === "opaqueredirect" ||
|
||||
@@ -440,6 +462,7 @@ function validateCapabilityPayload(
|
||||
try {
|
||||
const payload = strictRecord(value, [
|
||||
"allowedQueryParameters",
|
||||
"protocol",
|
||||
"binding",
|
||||
"byteLength",
|
||||
"capabilityReceipt",
|
||||
@@ -460,6 +483,11 @@ function validateCapabilityPayload(
|
||||
"requiredResponseHeaders",
|
||||
"singleUse",
|
||||
]);
|
||||
if (payload.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
|
||||
// Missing, V0 and V2 all close the same way: this envelope is not one we
|
||||
// can interpret. No new failure code is introduced.
|
||||
throw new TypeError("Capability transfer protocol is unsupported.");
|
||||
}
|
||||
if (payload.singleUse !== true || payload.method !== context.expected.method) {
|
||||
throw new TypeError("Capability method or replay policy is invalid.");
|
||||
}
|
||||
@@ -618,6 +646,9 @@ function validateCapabilityPayload(
|
||||
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
// TR-RR-03. The registration carries the negotiated protocol version so
|
||||
// the vault validates the same shape the executor later reads.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt,
|
||||
method: context.expected.method,
|
||||
binding,
|
||||
@@ -854,30 +885,104 @@ function statusFailure(
|
||||
});
|
||||
}
|
||||
|
||||
/** BT-PRE-03. The scope ended before the task settled. */
|
||||
const SCOPE_ENDED = Symbol("presigned-capability-scope-ended");
|
||||
|
||||
/**
|
||||
* TR-RR-01 / TR-RR-05. The abort scope is a thin projection of the shared
|
||||
* `createAbortableOperation` primitive into this subsystem's vocabulary.
|
||||
*
|
||||
* Two things changed with the migration. `abort()` exists, so `close()` on a
|
||||
* download can actually stop a pending fetch instead of only dropping
|
||||
* listeners; and additional ownership signals — a consumer's stream signal —
|
||||
* are composed into the same operation *before* any I/O starts, so an
|
||||
* already-aborted consumer never causes a fetch.
|
||||
*/
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: Scheduler,
|
||||
timers: AbortTimerSnapshot,
|
||||
additionalSignals: readonly AbortSignal[] = [],
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, timeoutMs);
|
||||
const operation = createAbortableOperation({
|
||||
signal: external,
|
||||
timeoutMs,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
const releases: (() => void)[] = [];
|
||||
for (const extra of additionalSignals) {
|
||||
if (!extra) continue;
|
||||
if (extra.aborted) {
|
||||
operation.close();
|
||||
continue;
|
||||
}
|
||||
const onAbort = () => operation.close();
|
||||
try {
|
||||
extra.addEventListener("abort", onAbort, { once: true });
|
||||
releases.push(() => {
|
||||
try {
|
||||
extra.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// A signal that refuses a listener cannot bound this operation, so the
|
||||
// operation closes rather than running unbounded.
|
||||
operation.close();
|
||||
}
|
||||
}
|
||||
const releaseExtras = () => {
|
||||
for (const release of releases.splice(0)) release();
|
||||
};
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
signal: operation.signal,
|
||||
timedOut: () => operation.terminal() === "DEADLINE",
|
||||
/** Bounds a fetch that ignores its signal. */
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
const outcome = await operation.race(
|
||||
task,
|
||||
(value) => compensateLateResponseValue(value),
|
||||
);
|
||||
if (outcome.kind === "VALUE") return outcome.value;
|
||||
// A collaborator's own rejection stays a rejection: the caller's existing
|
||||
// catch classifies it, and it is never forged into a cancellation.
|
||||
if (outcome.kind === "REJECTED") throw outcome.reason;
|
||||
return SCOPE_ENDED;
|
||||
},
|
||||
/** TR-RR-01. Ends the physical work, not just the bookkeeping. */
|
||||
abort() {
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
release() {
|
||||
scheduler.clearTimeout(timer);
|
||||
external.removeEventListener("abort", onAbort);
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function compensateLateResponseValue(value: unknown): void {
|
||||
const body = (value as { body?: { cancel(): Promise<void> } | null } | null)
|
||||
?.body;
|
||||
void body?.cancel().catch(() => undefined);
|
||||
}
|
||||
|
||||
function transferFailure(
|
||||
signal: AbortSignal,
|
||||
timedOut: boolean,
|
||||
): BrowserDataResult<never> {
|
||||
if (signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
return browserDataFailure(
|
||||
timedOut ? "UNAVAILABLE" : "NOT_READABLE",
|
||||
"PRESIGNED_TRANSFER",
|
||||
{ retryable: true, recovery: "REISSUE_CAPABILITY" },
|
||||
);
|
||||
}
|
||||
|
||||
function isAbortSignal(value: unknown): value is AbortSignal {
|
||||
try {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
@@ -1083,18 +1188,64 @@ function validatePathPrefix(value: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-PRE-05. Object stores and CDNs may percent-decode a path one more time
|
||||
* than this client does, so rejecting only literal `.`, `..` and backslash is
|
||||
* not enough: `%2f`, `%5c` and `%252e%252e` can still become separators or dot
|
||||
* segments downstream.
|
||||
*
|
||||
* Each raw segment is strictly percent-decoded once. The decoded value may not
|
||||
* contain a separator, NUL, a dot segment or a further percent-escape, and
|
||||
* re-encoding it canonically must reproduce the raw segment exactly. That
|
||||
* closes double encoding and mixed-case variants while still allowing any valid
|
||||
* opaque UTF-8 segment.
|
||||
*/
|
||||
function validateExactPath(value: unknown): string {
|
||||
const path = requiredString(value, 2_048);
|
||||
if (
|
||||
!path.startsWith("/") ||
|
||||
path.includes("\\") ||
|
||||
/[\0\r\n]/.test(path) ||
|
||||
path.split("/").some((segment) => segment === "." || segment === "..")
|
||||
/[\0\r\n]/.test(path)
|
||||
) {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
for (const segment of path.split("/")) {
|
||||
if (segment === "") continue;
|
||||
if (segment === "." || segment === "..") {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(segment);
|
||||
} catch {
|
||||
// Malformed or non-UTF-8 percent-escapes are rejected outright.
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
if (
|
||||
decoded === "." ||
|
||||
decoded === ".." ||
|
||||
decoded.includes("/") ||
|
||||
decoded.includes("\\") ||
|
||||
decoded.includes("\0") ||
|
||||
// A decoded value that still carries a percent-escape would decode again.
|
||||
/%[0-9A-Fa-f]{2}/u.test(decoded)
|
||||
) {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
if (canonicalPathSegment(decoded) !== segment) {
|
||||
throw new TypeError("Path is invalid.");
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Uppercase percent-hex canonical form, matching the provider fixtures. */
|
||||
function canonicalPathSegment(decoded: string): string {
|
||||
return encodeURIComponent(decoded).replace(
|
||||
/%[0-9a-f]{2}/gu,
|
||||
(escape) => escape.toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
class ResponseLimitError extends Error {}
|
||||
class ResponseIntegrityError extends Error {}
|
||||
|
||||
@@ -1,22 +1,73 @@
|
||||
import type {
|
||||
PresignedTransferBinding,
|
||||
PresignedTransferCapability,
|
||||
PresignedTransferCapabilityReceipt,
|
||||
PresignedTransferMethod,
|
||||
PresignedTransferReplayGuard,
|
||||
import {
|
||||
PRESIGNED_TRANSFER_PROTOCOL,
|
||||
type PresignedTransferBinding,
|
||||
type PresignedTransferCapability,
|
||||
type PresignedTransferCapabilityReceipt,
|
||||
type PresignedTransferMethod,
|
||||
type PresignedTransferProtocol,
|
||||
type PresignedTransferReplayGuard,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
ownDataValue,
|
||||
snapshotExactArray,
|
||||
snapshotExactObject,
|
||||
} from "../../../contracts/exact-snapshot.ts";
|
||||
|
||||
export type PresignedHeaderBinding = Readonly<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* TR-RR-03. A registration is a versioned exact union. Without the version an
|
||||
* issuer from another release could register a shape this vault validates under
|
||||
* different rules than the executor later applies to it.
|
||||
*/
|
||||
/** TR-RR-03. The exact own-data key set a registration may carry. */
|
||||
const REGISTRATION_KEYS: readonly string[] = Object.freeze(
|
||||
[
|
||||
"allowedQueryParameters",
|
||||
"binding",
|
||||
"byteLength",
|
||||
"capabilityReceipt",
|
||||
"digestRequestHeader",
|
||||
"digestResponseHeader",
|
||||
"expectedResponseByteLength",
|
||||
"expectedSha256",
|
||||
"expectedStatus",
|
||||
"expiresAtEpochMs",
|
||||
"href",
|
||||
"maxBytes",
|
||||
"mediaType",
|
||||
"method",
|
||||
"origin",
|
||||
"path",
|
||||
"protocol",
|
||||
"receiptResponseHeader",
|
||||
"requestHeaders",
|
||||
"requiredResponseHeaders",
|
||||
].sort(),
|
||||
);
|
||||
|
||||
/**
|
||||
* TR-RR-03. Ambient credential and cookie headers are forbidden on a presigned
|
||||
* capability: the signature is the authorization.
|
||||
*/
|
||||
const FORBIDDEN_CAPABILITY_HEADERS: ReadonlySet<string> = new Set([
|
||||
"authorization",
|
||||
"cookie",
|
||||
"cookie2",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
]);
|
||||
|
||||
export type PresignedCapabilityRegistration = Readonly<{
|
||||
protocol: PresignedTransferProtocol;
|
||||
capabilityReceipt: PresignedTransferCapabilityReceipt;
|
||||
method: PresignedTransferMethod;
|
||||
binding: PresignedTransferBinding;
|
||||
@@ -109,6 +160,96 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-PRE-04 / TR-RR-03. Runtime invariants every registration must satisfy,
|
||||
* regardless of which issuer produced it.
|
||||
*/
|
||||
function validatePresignedCapabilityRegistration(
|
||||
registration: PresignedCapabilityRegistration,
|
||||
): BrowserDataResult<never> | null {
|
||||
const invalid = () =>
|
||||
browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
});
|
||||
if (!registration || typeof registration !== "object") return invalid();
|
||||
if (registration.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
typeof registration.capabilityReceipt !== "string" ||
|
||||
registration.capabilityReceipt.length === 0 ||
|
||||
(registration.method !== "GET" && registration.method !== "PUT")
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
let target: URL;
|
||||
let origin: URL;
|
||||
try {
|
||||
target = new URL(registration.href);
|
||||
origin = new URL(registration.origin);
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
// TR-RR-03. A presigned capability travels the network as a bearer of its
|
||||
// own authority, so plaintext is never acceptable.
|
||||
target.protocol !== "https:" ||
|
||||
origin.protocol !== "https:" ||
|
||||
target.origin !== origin.origin ||
|
||||
origin.href.replace(/\/$/u, "") !== registration.origin.replace(/\/$/u, "") ||
|
||||
target.pathname !== registration.path ||
|
||||
target.username.length > 0 ||
|
||||
target.password.length > 0 ||
|
||||
target.hash.length > 0
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
// TR-RR-03. A presigned URL already carries its authorization. An ambient
|
||||
// credential header alongside it would send the user's session to an
|
||||
// origin the capability alone was meant to reach.
|
||||
for (const header of [
|
||||
...registration.requestHeaders,
|
||||
...registration.requiredResponseHeaders,
|
||||
]) {
|
||||
if (
|
||||
FORBIDDEN_CAPABILITY_HEADERS.has(header.name.toLowerCase())
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
if (
|
||||
registration.allowedQueryParameters.some(
|
||||
(parameter) => typeof parameter !== "string" || parameter.length === 0,
|
||||
)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(registration.expectedStatus) ||
|
||||
registration.expectedStatus < 200 ||
|
||||
registration.expectedStatus > 299 ||
|
||||
(registration.expectedResponseByteLength !== null &&
|
||||
(!Number.isSafeInteger(registration.expectedResponseByteLength) ||
|
||||
registration.expectedResponseByteLength < 0))
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(registration.byteLength) ||
|
||||
registration.byteLength < 0 ||
|
||||
!Number.isSafeInteger(registration.maxBytes) ||
|
||||
registration.maxBytes < registration.byteLength ||
|
||||
!Number.isSafeInteger(registration.expiresAtEpochMs) ||
|
||||
registration.expiresAtEpochMs <= 0 ||
|
||||
!/^[a-f0-9]{64}$/u.test(registration.expectedSha256) ||
|
||||
typeof registration.mediaType !== "string" ||
|
||||
registration.mediaType.length === 0
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
register(
|
||||
registration: PresignedCapabilityRegistration,
|
||||
@@ -116,6 +257,23 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
if (disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
// TR-01. One owned snapshot first, then validate and store only that
|
||||
// snapshot. Validating the issuer's own object and reading it again to
|
||||
// copy it let a stateful answer show an allowed header set to the
|
||||
// forbidden-header check and hand `Authorization` to the copy, so the
|
||||
// vault stored a capability no rule had ever seen.
|
||||
const snapshot = snapshotRegistration(registration);
|
||||
if (!snapshot) {
|
||||
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
});
|
||||
}
|
||||
// BT-PRE-04. The vault owns its own registration invariants so a second
|
||||
// issuer adapter, a test seam or composition code cannot register a
|
||||
// weaker capability of the same type. The HTTP decoder still owns the
|
||||
// wire shape; this only re-checks runtime invariants.
|
||||
const invalid = validatePresignedCapabilityRegistration(snapshot);
|
||||
if (invalid) return invalid;
|
||||
pruneExpired();
|
||||
if (
|
||||
byReceipt.has(registration.capabilityReceipt) ||
|
||||
@@ -133,36 +291,37 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
}
|
||||
|
||||
const capability = Object.freeze({
|
||||
capabilityReceipt: registration.capabilityReceipt,
|
||||
method: registration.method,
|
||||
binding: freezeBinding(registration.binding),
|
||||
mediaType: registration.mediaType,
|
||||
byteLength: registration.byteLength,
|
||||
maxBytes: registration.maxBytes,
|
||||
expectedSha256: registration.expectedSha256,
|
||||
expiresAtEpochMs: registration.expiresAtEpochMs,
|
||||
capabilityReceipt: snapshot.capabilityReceipt,
|
||||
method: snapshot.method,
|
||||
binding: freezeBinding(snapshot.binding),
|
||||
mediaType: snapshot.mediaType,
|
||||
byteLength: snapshot.byteLength,
|
||||
maxBytes: snapshot.maxBytes,
|
||||
expectedSha256: snapshot.expectedSha256,
|
||||
expiresAtEpochMs: snapshot.expiresAtEpochMs,
|
||||
}) as PresignedTransferCapability;
|
||||
const binding: PresignedCapabilityBinding = Object.freeze({
|
||||
capability,
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: capability.capabilityReceipt,
|
||||
method: capability.method,
|
||||
binding: capability.binding,
|
||||
href: registration.href,
|
||||
origin: registration.origin,
|
||||
path: registration.path,
|
||||
href: snapshot.href,
|
||||
origin: snapshot.origin,
|
||||
path: snapshot.path,
|
||||
allowedQueryParameters: Object.freeze([
|
||||
...registration.allowedQueryParameters,
|
||||
...snapshot.allowedQueryParameters,
|
||||
]),
|
||||
requestHeaders: freezeHeaders(registration.requestHeaders),
|
||||
requestHeaders: freezeHeaders(snapshot.requestHeaders),
|
||||
requiredResponseHeaders: freezeHeaders(
|
||||
registration.requiredResponseHeaders,
|
||||
snapshot.requiredResponseHeaders,
|
||||
),
|
||||
digestRequestHeader: registration.digestRequestHeader,
|
||||
digestResponseHeader: registration.digestResponseHeader,
|
||||
receiptResponseHeader: registration.receiptResponseHeader,
|
||||
expectedStatus: registration.expectedStatus,
|
||||
digestRequestHeader: snapshot.digestRequestHeader,
|
||||
digestResponseHeader: snapshot.digestResponseHeader,
|
||||
receiptResponseHeader: snapshot.receiptResponseHeader,
|
||||
expectedStatus: snapshot.expectedStatus,
|
||||
expectedResponseByteLength:
|
||||
registration.expectedResponseByteLength,
|
||||
snapshot.expectedResponseByteLength,
|
||||
mediaType: capability.mediaType,
|
||||
byteLength: capability.byteLength,
|
||||
maxBytes: capability.maxBytes,
|
||||
@@ -242,6 +401,95 @@ export function createSingleUsePresignedReplayGuard():
|
||||
});
|
||||
}
|
||||
|
||||
const DOWNLOAD_BINDING_KEYS = Object.freeze(["kind", "resourceId"]);
|
||||
const UPLOAD_BINDING_KEYS = Object.freeze([
|
||||
"kind",
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"uploadBindingSha256",
|
||||
"partNumber",
|
||||
"offset",
|
||||
"idempotencyKey",
|
||||
]);
|
||||
|
||||
/**
|
||||
* TR-01. Copies a registration and everything nested inside it into owned,
|
||||
* frozen values, reading each property exactly once. Only this snapshot is
|
||||
* validated and stored, so a stateful issuer cannot show one value to the
|
||||
* forbidden-header and HTTPS checks and hand another to the vault. A hostile
|
||||
* trap, an accessor, an inherited or extra field and a non-iterable header
|
||||
* array all resolve to `null` — a typed `POLICY_REJECTED` — rather than
|
||||
* escaping as a native exception.
|
||||
*/
|
||||
function snapshotRegistration(
|
||||
source: unknown,
|
||||
): PresignedCapabilityRegistration | null {
|
||||
const outer = snapshotExactObject(source, {
|
||||
allowed: REGISTRATION_KEYS,
|
||||
required: REGISTRATION_KEYS,
|
||||
});
|
||||
if (outer === null) return null;
|
||||
|
||||
const binding = snapshotBinding(outer["binding"]);
|
||||
if (binding === null) return null;
|
||||
const allowedQueryParameters = snapshotExactArray(
|
||||
outer["allowedQueryParameters"],
|
||||
);
|
||||
if (allowedQueryParameters === null) return null;
|
||||
const requestHeaders = snapshotHeaderBindings(outer["requestHeaders"]);
|
||||
const requiredResponseHeaders = snapshotHeaderBindings(
|
||||
outer["requiredResponseHeaders"],
|
||||
);
|
||||
if (requestHeaders === null || requiredResponseHeaders === null) return null;
|
||||
|
||||
return Object.freeze({
|
||||
...outer,
|
||||
binding,
|
||||
allowedQueryParameters: Object.freeze([...allowedQueryParameters]),
|
||||
requestHeaders,
|
||||
requiredResponseHeaders,
|
||||
}) as PresignedCapabilityRegistration;
|
||||
}
|
||||
|
||||
function snapshotBinding(source: unknown): PresignedTransferBinding | null {
|
||||
const kind = ownDataValue(source, "kind");
|
||||
if (kind !== "DOWNLOAD" && kind !== "UPLOAD_PART") return null;
|
||||
const keys =
|
||||
kind === "DOWNLOAD" ? DOWNLOAD_BINDING_KEYS : UPLOAD_BINDING_KEYS;
|
||||
const binding = snapshotExactObject(source, {
|
||||
allowed: keys,
|
||||
required: keys,
|
||||
});
|
||||
return binding === null
|
||||
? null
|
||||
: (binding as unknown as PresignedTransferBinding);
|
||||
}
|
||||
|
||||
function snapshotHeaderBindings(
|
||||
source: unknown,
|
||||
): readonly PresignedHeaderBinding[] | null {
|
||||
const rows = snapshotExactArray(source);
|
||||
if (rows === null) return null;
|
||||
const headers: PresignedHeaderBinding[] = [];
|
||||
for (const row of rows) {
|
||||
const header = snapshotExactObject(row, {
|
||||
allowed: ["name", "value"],
|
||||
required: ["name", "value"],
|
||||
});
|
||||
if (
|
||||
header === null ||
|
||||
typeof header["name"] !== "string" ||
|
||||
header["name"].length === 0 ||
|
||||
typeof header["value"] !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
headers.push(header as unknown as PresignedHeaderBinding);
|
||||
}
|
||||
return Object.freeze(headers);
|
||||
}
|
||||
|
||||
function freezeBinding(
|
||||
binding: PresignedTransferBinding,
|
||||
): PresignedTransferBinding {
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
PresignedUploadPartOutcome,
|
||||
PresignedUploadPartPort,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
snapshotAbortTimers,
|
||||
type AbortTimerSnapshot,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
@@ -91,16 +96,20 @@ export function createPresignedTransferExecutor(
|
||||
const timeoutMs = positiveSafeInteger(options.timeoutMs);
|
||||
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
|
||||
const now = options.now ?? Date.now;
|
||||
const scheduler =
|
||||
// X-AUDIT-02. The timer callables are captured once, bound to their
|
||||
// receiver, so replacing a scheduler method after composition cannot change
|
||||
// how work already in flight is bounded.
|
||||
const timers = snapshotAbortTimers(
|
||||
options.scheduler ??
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler);
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler),
|
||||
);
|
||||
const createVerifier =
|
||||
options.createStreamingVerifier ?? createStreamingSha256Verifier;
|
||||
if (options.digestBytes === undefined && !globalThis.crypto?.subtle) {
|
||||
@@ -154,41 +163,40 @@ export function createPresignedTransferExecutor(
|
||||
const consumed = consume(capability);
|
||||
if (!consumed.ok) return consumed;
|
||||
|
||||
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
||||
try {
|
||||
const response = await fetcher(binding.href, {
|
||||
method: "GET",
|
||||
headers: headersFor(binding),
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
});
|
||||
const validated = validateDownloadResponse(
|
||||
response,
|
||||
binding,
|
||||
);
|
||||
if (!validated.ok) {
|
||||
cancelBody(response);
|
||||
scope.release();
|
||||
return validated;
|
||||
}
|
||||
const source = createDownloadSource({
|
||||
response,
|
||||
binding,
|
||||
capability,
|
||||
externalSignal: signal,
|
||||
scope,
|
||||
hardMaxChunkBytes,
|
||||
createVerifier,
|
||||
observer,
|
||||
});
|
||||
return browserDataSuccess(source);
|
||||
} catch {
|
||||
scope.release();
|
||||
return transferFailure(signal, scope.timedOut());
|
||||
}
|
||||
// BT-PRE-01. The lease is lazy and single-start: `open()` performs no
|
||||
// network I/O, so the transfer deadline begins at first consumption and an
|
||||
// unused source can be discarded through `close()` without leaking a body,
|
||||
// a timer or a listener.
|
||||
const source = createDownloadSource({
|
||||
start: async (scope) =>
|
||||
await fetcher(binding.href, {
|
||||
method: "GET",
|
||||
headers: headersFor(binding),
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
}),
|
||||
validateResponse: (response) =>
|
||||
validateDownloadResponse(response, binding),
|
||||
createScope: (consumerSignal?: AbortSignal) =>
|
||||
createAbortScope(
|
||||
signal,
|
||||
timeoutMs,
|
||||
timers,
|
||||
consumerSignal ? [consumerSignal] : [],
|
||||
),
|
||||
recheckExpiry: () =>
|
||||
validateExpiry(capability, minimumRemainingLifetimeMs, now()),
|
||||
binding,
|
||||
capability,
|
||||
externalSignal: signal,
|
||||
hardMaxChunkBytes,
|
||||
createVerifier,
|
||||
observer,
|
||||
});
|
||||
return browserDataSuccess(source);
|
||||
}
|
||||
|
||||
async function putUploadPart(
|
||||
@@ -273,47 +281,67 @@ export function createPresignedTransferExecutor(
|
||||
) {
|
||||
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
let actualDigest: string;
|
||||
// TR-RR-02. The abort scope is created first, so the digest — which can be
|
||||
// a long or non-settling computation over a large buffer — is owned by the
|
||||
// caller signal and the deadline like every other step. Computing it before
|
||||
// the scope existed meant an abort or a deadline could not reach it, and a
|
||||
// hash that never settled held the whole `put` open.
|
||||
const scope = createAbortScope(request.signal, timeoutMs, timers);
|
||||
try {
|
||||
actualDigest = normalizedSha256(await digestBytes(bytes));
|
||||
} catch {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
const digested = await scope.race(
|
||||
Promise.resolve().then(async () => await digestBytes(bytes)),
|
||||
);
|
||||
}
|
||||
if (actualDigest !== capability.expectedSha256) {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
if (digested === SCOPE_ENDED) {
|
||||
return transferFailure(request.signal, scope.timedOut());
|
||||
}
|
||||
let actualDigest: string;
|
||||
try {
|
||||
actualDigest = normalizedSha256(digested);
|
||||
} catch {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
);
|
||||
}
|
||||
if (actualDigest !== capability.expectedSha256) {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"PRESIGNED_TRANSFER",
|
||||
);
|
||||
}
|
||||
// The owner is re-checked after the wait: a claim and a network call may
|
||||
// only follow a digest that finished while this operation still held the
|
||||
// execution.
|
||||
if (scope.signal.aborted || request.signal.aborted) {
|
||||
return transferFailure(request.signal, scope.timedOut());
|
||||
}
|
||||
const active = validateExpiry(
|
||||
capability,
|
||||
minimumRemainingLifetimeMs,
|
||||
now(),
|
||||
);
|
||||
}
|
||||
if (request.signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
const active = validateExpiry(
|
||||
capability,
|
||||
minimumRemainingLifetimeMs,
|
||||
now(),
|
||||
);
|
||||
if (!active.ok) return active;
|
||||
const claimed = claim(capability);
|
||||
if (!claimed.ok) return claimed;
|
||||
const consumed = consume(capability);
|
||||
if (!consumed.ok) return consumed;
|
||||
if (!active.ok) return active;
|
||||
const claimed = claim(capability);
|
||||
if (!claimed.ok) return claimed;
|
||||
const consumed = consume(capability);
|
||||
if (!consumed.ok) return consumed;
|
||||
|
||||
const scope = createAbortScope(request.signal, timeoutMs, scheduler);
|
||||
try {
|
||||
const response = await fetcher(binding.href, {
|
||||
method: "PUT",
|
||||
headers: headersFor(binding),
|
||||
body: bytes.buffer,
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
});
|
||||
const raced = await scope.race(
|
||||
fetcher(binding.href, {
|
||||
method: "PUT",
|
||||
headers: headersFor(binding),
|
||||
body: bytes.buffer,
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
signal: scope.signal,
|
||||
}),
|
||||
);
|
||||
if (raced === SCOPE_ENDED) {
|
||||
return transferFailure(request.signal, scope.timedOut());
|
||||
}
|
||||
const response = raced;
|
||||
const validated = validateUploadResponse(
|
||||
response,
|
||||
binding,
|
||||
@@ -406,29 +434,61 @@ export function createPresignedTransferExecutor(
|
||||
}
|
||||
|
||||
function createDownloadSource(input: Readonly<{
|
||||
response: Response;
|
||||
start: (
|
||||
scope: ReturnType<typeof createAbortScope>,
|
||||
) => Promise<Response>;
|
||||
validateResponse: (response: Response) => BrowserDataResult<unknown>;
|
||||
createScope: (
|
||||
consumerSignal?: AbortSignal,
|
||||
) => ReturnType<typeof createAbortScope>;
|
||||
recheckExpiry: () => BrowserDataResult<unknown>;
|
||||
binding: PresignedCapabilityBinding;
|
||||
capability: PresignedDownloadCapability;
|
||||
externalSignal: AbortSignal;
|
||||
scope: ReturnType<typeof createAbortScope>;
|
||||
hardMaxChunkBytes: number;
|
||||
createVerifier: (
|
||||
expectedSha256: string,
|
||||
) => StreamingSha256Verifier;
|
||||
observer: BrowserDataObserver | undefined;
|
||||
}>): PresignedDownloadByteSource {
|
||||
let started = false;
|
||||
/** BT-PRE-01. One state machine shared by `stream()` and `close()`. */
|
||||
let state: "READY" | "STREAMING" | "CLOSED" = "READY";
|
||||
let activeScope: ReturnType<typeof createAbortScope> | undefined;
|
||||
let activeResponse: Response | undefined;
|
||||
|
||||
// TR-RR-01. Releasing the scope only dropped listeners and the timer, so a
|
||||
// fetch or read already in flight kept running after `close()`. The scope is
|
||||
// aborted here, which is what actually ends the physical I/O.
|
||||
const releaseActive = () => {
|
||||
if (activeScope) {
|
||||
activeScope.abort();
|
||||
}
|
||||
if (activeResponse) {
|
||||
cancelBody(activeResponse);
|
||||
activeResponse = undefined;
|
||||
}
|
||||
if (activeScope) {
|
||||
activeScope.release();
|
||||
activeScope = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
byteLength: input.capability.byteLength,
|
||||
capability: input.capability,
|
||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
|
||||
close() {
|
||||
// READY -> CLOSED performs no I/O; STREAMING -> CLOSED cancels once.
|
||||
if (state === "CLOSED") return;
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
},
|
||||
async *stream(
|
||||
consumerSignal: AbortSignal,
|
||||
): AsyncIterable<BrowserDataResult<Uint8Array>> {
|
||||
if (!isAbortSignal(consumerSignal)) {
|
||||
started = true;
|
||||
cancelBody(input.response);
|
||||
input.scope.release();
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = browserDataFailure(
|
||||
"INVALID_INPUT",
|
||||
"PRESIGNED_TRANSFER",
|
||||
@@ -437,7 +497,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
if (started) {
|
||||
if (state !== "READY") {
|
||||
const failure = browserDataFailure(
|
||||
"CONFLICT",
|
||||
"PRESIGNED_TRANSFER",
|
||||
@@ -449,10 +509,72 @@ function createDownloadSource(input: Readonly<{
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
let combined:
|
||||
| ReturnType<typeof combineConsumerAbort>
|
||||
| undefined;
|
||||
state = "STREAMING";
|
||||
// The capability may have expired while the lease sat unused.
|
||||
const stillActive = input.recheckExpiry();
|
||||
if (!stillActive.ok) {
|
||||
state = "CLOSED";
|
||||
observeTransferResult(
|
||||
input.observer,
|
||||
"DOWNLOAD",
|
||||
stillActive,
|
||||
0,
|
||||
);
|
||||
yield stillActive as BrowserDataResult<never>;
|
||||
return;
|
||||
}
|
||||
// TR-RR-01. The consumer's stream signal is part of this operation's
|
||||
// ownership from the start. Composing it only after the fetch had begun
|
||||
// meant an already-aborted consumer still caused one network request.
|
||||
const scope = input.createScope(consumerSignal);
|
||||
activeScope = scope;
|
||||
if (scope.signal.aborted) {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = transferFailure(
|
||||
input.externalSignal,
|
||||
scope.timedOut(),
|
||||
);
|
||||
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
// BT-PRE-03. A fetch that ignores its signal cannot outlive the scope.
|
||||
const started = await scope.race(input.start(scope));
|
||||
if (started === SCOPE_ENDED) {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = transferFailure(
|
||||
input.externalSignal,
|
||||
scope.timedOut(),
|
||||
);
|
||||
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
response = started;
|
||||
} catch {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
const failure = transferFailure(
|
||||
input.externalSignal,
|
||||
scope.timedOut(),
|
||||
);
|
||||
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
||||
yield failure;
|
||||
return;
|
||||
}
|
||||
activeResponse = response;
|
||||
const validated = input.validateResponse(response);
|
||||
if (!validated.ok) {
|
||||
state = "CLOSED";
|
||||
releaseActive();
|
||||
observeTransferResult(input.observer, "DOWNLOAD", validated, 0);
|
||||
yield validated as BrowserDataResult<never>;
|
||||
return;
|
||||
}
|
||||
let reader:
|
||||
| ReadableStreamDefaultReader<Uint8Array>
|
||||
| undefined;
|
||||
@@ -470,14 +592,10 @@ function createDownloadSource(input: Readonly<{
|
||||
return browserDataFailure(code, "PRESIGNED_TRANSFER", options);
|
||||
};
|
||||
try {
|
||||
combined = combineConsumerAbort(
|
||||
input.scope,
|
||||
consumerSignal,
|
||||
);
|
||||
const verifier = input.createVerifier(
|
||||
input.capability.expectedSha256,
|
||||
);
|
||||
if (!input.response.body) {
|
||||
if (!response.body) {
|
||||
let verified = false;
|
||||
try {
|
||||
verified =
|
||||
@@ -492,7 +610,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield fail("INTEGRITY_FAILED");
|
||||
return;
|
||||
}
|
||||
reader = input.response.body.getReader();
|
||||
reader = response.body.getReader();
|
||||
while (true) {
|
||||
if (
|
||||
input.externalSignal.aborted ||
|
||||
@@ -501,7 +619,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield fail("ABORTED");
|
||||
return;
|
||||
}
|
||||
if (input.scope.timedOut()) {
|
||||
if (scope.timedOut()) {
|
||||
yield fail("UNAVAILABLE", {
|
||||
retryable: true,
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
@@ -510,7 +628,7 @@ function createDownloadSource(input: Readonly<{
|
||||
}
|
||||
const result = await readWithSignal(
|
||||
reader,
|
||||
input.scope.signal,
|
||||
scope.signal,
|
||||
);
|
||||
if (result.done) break;
|
||||
const chunk = result.value;
|
||||
@@ -544,7 +662,7 @@ function createDownloadSource(input: Readonly<{
|
||||
yield fail("ABORTED");
|
||||
return;
|
||||
}
|
||||
if (input.scope.timedOut()) {
|
||||
if (scope.timedOut()) {
|
||||
yield fail("UNAVAILABLE", {
|
||||
retryable: true,
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
@@ -590,7 +708,7 @@ function createDownloadSource(input: Readonly<{
|
||||
consumerSignal.aborted
|
||||
) {
|
||||
yield fail("ABORTED");
|
||||
} else if (input.scope.timedOut()) {
|
||||
} else if (scope.timedOut()) {
|
||||
yield fail("UNAVAILABLE", {
|
||||
retryable: true,
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
@@ -602,17 +720,20 @@ function createDownloadSource(input: Readonly<{
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
combined?.release();
|
||||
if (!completed) {
|
||||
if (reader) cancelReader(reader);
|
||||
else cancelBody(input.response);
|
||||
else cancelBody(response);
|
||||
}
|
||||
try {
|
||||
reader?.releaseLock();
|
||||
} catch {
|
||||
// Reader cleanup cannot change stream success or failure.
|
||||
}
|
||||
input.scope.release();
|
||||
// The lease is terminal once its single stream ends; cleanup runs once.
|
||||
state = "CLOSED";
|
||||
activeResponse = undefined;
|
||||
activeScope = undefined;
|
||||
scope.release();
|
||||
observeBrowserData(input.observer, {
|
||||
operation: "DOWNLOAD",
|
||||
outcome: completed
|
||||
@@ -925,50 +1046,85 @@ function transferFailure(
|
||||
);
|
||||
}
|
||||
|
||||
/** BT-PRE-03. The scope ended before the task settled. */
|
||||
const SCOPE_ENDED = Symbol("presigned-scope-ended");
|
||||
|
||||
function compensateLateResponse(task: Promise<unknown>): void {
|
||||
void task
|
||||
.then(async (value) => {
|
||||
const body = (value as { body?: { cancel(): Promise<void> } | null })
|
||||
?.body;
|
||||
await body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-RR-01 / TR-RR-05. A projection of the shared `createAbortableOperation`
|
||||
* primitive into this subsystem's vocabulary, replacing a second hand-written
|
||||
* copy of the same mechanics.
|
||||
*
|
||||
* `additionalSignals` lets a consumer's stream signal join the operation's
|
||||
* ownership before any I/O begins, and `abort()` ends the physical work rather
|
||||
* than only releasing bookkeeping.
|
||||
*/
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: Scheduler,
|
||||
timers: AbortTimerSnapshot,
|
||||
additionalSignals: readonly AbortSignal[] = [],
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let released = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
const releaseListener = () => {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
};
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
releaseListener();
|
||||
}, timeoutMs);
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
abort(reason?: unknown) {
|
||||
controller.abort(reason);
|
||||
},
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
scheduler.clearTimeout(timer);
|
||||
releaseListener();
|
||||
},
|
||||
const operation = createAbortableOperation({
|
||||
signal: external,
|
||||
timeoutMs,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
}
|
||||
|
||||
function combineConsumerAbort(
|
||||
scope: ReturnType<typeof createAbortScope>,
|
||||
consumer: AbortSignal,
|
||||
) {
|
||||
const onAbort = () => scope.abort(consumer.reason);
|
||||
consumer.addEventListener("abort", onAbort, { once: true });
|
||||
if (consumer.aborted) onAbort();
|
||||
const releases: (() => void)[] = [];
|
||||
for (const extra of additionalSignals) {
|
||||
if (!extra) continue;
|
||||
if (extra.aborted) {
|
||||
operation.close();
|
||||
continue;
|
||||
}
|
||||
const onAbort = () => operation.close();
|
||||
try {
|
||||
extra.addEventListener("abort", onAbort, { once: true });
|
||||
releases.push(() => {
|
||||
try {
|
||||
extra.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
operation.close();
|
||||
}
|
||||
}
|
||||
const releaseExtras = () => {
|
||||
for (const release of releases.splice(0)) release();
|
||||
};
|
||||
return Object.freeze({
|
||||
signal: operation.signal,
|
||||
timedOut: () => operation.terminal() === "DEADLINE",
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
const outcome = await operation.race(task, (value) => {
|
||||
const body = (
|
||||
value as { body?: { cancel(): Promise<void> } | null } | null
|
||||
)?.body;
|
||||
void body?.cancel().catch(() => undefined);
|
||||
});
|
||||
if (outcome.kind === "VALUE") return outcome.value;
|
||||
if (outcome.kind === "REJECTED") throw outcome.reason;
|
||||
return SCOPE_ENDED;
|
||||
},
|
||||
abort() {
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
release() {
|
||||
consumer.removeEventListener("abort", onAbort);
|
||||
operation.close();
|
||||
releaseExtras();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
createAbortableOperation,
|
||||
type AbortRace,
|
||||
type AbortTerminalReason,
|
||||
} from "../../platform/abortable-operation.ts";
|
||||
import type {
|
||||
ResumableUploadControlOperation,
|
||||
ResumableUploadJsonTransport,
|
||||
@@ -32,6 +37,13 @@ export type ResumableUploadFetchTransportDependencies = Readonly<{
|
||||
expectedSuccessStatuses?: Partial<
|
||||
Readonly<Record<ResumableUploadControlOperation, number>>
|
||||
>;
|
||||
/** BT-UP-02. Injected epoch clock; defaults to `Date.now`. */
|
||||
nowEpochMs?: () => number;
|
||||
/** BT-UP-02. Injected scheduler for the request timeout. */
|
||||
scheduler?: Readonly<{
|
||||
setTimeout(callback: () => void, delayMs: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const DEFAULT_SUCCESS_STATUSES: Readonly<
|
||||
@@ -68,6 +80,37 @@ export function createResumableUploadFetchJsonTransport(
|
||||
throw new TypeError("Upload fetch transport dependency is invalid.");
|
||||
}
|
||||
const headers = snapshotHeaders(input.requestHeaders ?? []);
|
||||
// BT-UP-02. Snapshot and validate the clock and scheduler once.
|
||||
const nowEpochMs = input.nowEpochMs ?? (() => Date.now());
|
||||
/** A broken clock must not produce a negative or NaN retry delay. */
|
||||
const safeNowEpochMs = (): number => {
|
||||
try {
|
||||
const value = nowEpochMs();
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : Number.NaN;
|
||||
} catch {
|
||||
return Number.NaN;
|
||||
}
|
||||
};
|
||||
const scheduler = input.scheduler ?? {
|
||||
setTimeout: (callback: () => void, delayMs: number) =>
|
||||
setTimeout(callback, delayMs),
|
||||
clearTimeout: (handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
if (
|
||||
typeof nowEpochMs !== "function" ||
|
||||
typeof scheduler.setTimeout !== "function" ||
|
||||
typeof scheduler.clearTimeout !== "function"
|
||||
) {
|
||||
throw new TypeError("Upload fetch transport dependency is invalid.");
|
||||
}
|
||||
// X-AUDIT-02. Reading `scheduler.setTimeout` again at request time made the
|
||||
// validated dependency and the executed one two different things: replacing
|
||||
// the method after composition changed how an attempt was bounded. The
|
||||
// callables are bound to their receiver once, here.
|
||||
const setTimer = scheduler.setTimeout.bind(scheduler);
|
||||
const clearTimer = scheduler.clearTimeout.bind(scheduler);
|
||||
const timeoutMs = boundedPositiveInteger(
|
||||
input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
1,
|
||||
@@ -139,8 +182,18 @@ export function createResumableUploadFetchJsonTransport(
|
||||
"NONE",
|
||||
);
|
||||
}
|
||||
const attempt = createFetchAttempt(signal, timeoutMs);
|
||||
const attempt = createFetchAttempt(
|
||||
signal,
|
||||
timeoutMs,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
);
|
||||
try {
|
||||
if (attempt.terminalKind() !== null) {
|
||||
// The attempt was closed before it could be bounded, so no request is
|
||||
// ever put on the wire.
|
||||
return attemptFailure(attempt, operation);
|
||||
}
|
||||
const fetchPromise = fetcher(endpoint, {
|
||||
method: "POST",
|
||||
headers: headersFor(headers),
|
||||
@@ -154,19 +207,13 @@ export function createResumableUploadFetchJsonTransport(
|
||||
? "same-origin"
|
||||
: "cors",
|
||||
});
|
||||
const raced = await Promise.race([
|
||||
fetchPromise.then(
|
||||
(value) => {
|
||||
if (attempt.terminalKind()) {
|
||||
cancelResponseBody(value);
|
||||
}
|
||||
return { kind: "RESPONSE" as const, value };
|
||||
},
|
||||
() => ({ kind: "FAILED" as const }),
|
||||
),
|
||||
attempt.terminal,
|
||||
]);
|
||||
if (raced.kind !== "RESPONSE") {
|
||||
const raced = await attempt.race(
|
||||
Promise.resolve(fetchPromise),
|
||||
(late) => {
|
||||
cancelResponseBody(late);
|
||||
},
|
||||
);
|
||||
if (raced.kind !== "VALUE") {
|
||||
return attemptFailure(attempt, operation);
|
||||
}
|
||||
const response = raced.value;
|
||||
@@ -188,6 +235,7 @@ export function createResumableUploadFetchJsonTransport(
|
||||
response,
|
||||
operation,
|
||||
maxRetryAfterMs,
|
||||
safeNowEpochMs(),
|
||||
);
|
||||
cancelResponseBody(response);
|
||||
return failed;
|
||||
@@ -390,18 +438,8 @@ async function readBoundedJson(
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const raced = await Promise.race([
|
||||
reader.read().then(
|
||||
(value) => ({ kind: "READ" as const, value }),
|
||||
() => ({ kind: "FAILED" as const }),
|
||||
),
|
||||
attempt.terminal,
|
||||
]);
|
||||
if (raced.kind === "ABORT" || raced.kind === "TIMEOUT") {
|
||||
cancelReader(reader);
|
||||
return attemptFailure(attempt, operation);
|
||||
}
|
||||
if (raced.kind === "FAILED") {
|
||||
const raced = await attempt.race(reader.read());
|
||||
if (raced.kind !== "VALUE") {
|
||||
cancelReader(reader);
|
||||
return attemptFailure(attempt, operation);
|
||||
}
|
||||
@@ -473,6 +511,7 @@ function statusFailure(
|
||||
response: Response,
|
||||
operation: ResumableUploadControlOperation,
|
||||
maxRetryAfterMs: number,
|
||||
nowEpochMs: number,
|
||||
): UploadProviderResult<never> {
|
||||
if (response.status === 400 || response.status === 422) {
|
||||
return failure("INVALID_INPUT", operation, false, "NONE");
|
||||
@@ -495,6 +534,7 @@ function statusFailure(
|
||||
if (response.status === 429) {
|
||||
const retryAfterMs = parseRetryAfter(
|
||||
response.headers.get("retry-after"),
|
||||
nowEpochMs,
|
||||
);
|
||||
return retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs
|
||||
? failure(
|
||||
@@ -534,50 +574,54 @@ function failure(
|
||||
return Object.freeze({ ok: false, error });
|
||||
}
|
||||
|
||||
type FetchAttemptTerminal =
|
||||
| Readonly<{ kind: "ABORT" }>
|
||||
| Readonly<{ kind: "TIMEOUT" }>;
|
||||
type FetchAttemptTerminalKind = "ABORT" | "TIMEOUT" | "CLOSED";
|
||||
|
||||
type FetchAttempt = Readonly<{
|
||||
signal: AbortSignal;
|
||||
terminal: Promise<FetchAttemptTerminal>;
|
||||
terminalKind(): FetchAttemptTerminal["kind"] | null;
|
||||
terminalKind(): FetchAttemptTerminalKind | null;
|
||||
race<Value>(
|
||||
task: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
): Promise<AbortRace<Value>>;
|
||||
release(): void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* X-AUDIT-02 / TR-RR-05. The attempt is the shared abort primitive with this
|
||||
* subsystem's vocabulary on top. Owning a private copy meant the listener was
|
||||
* attached before the timer was installed, so a scheduler that threw rejected
|
||||
* the public `execute()` promise and left the listener on the caller's signal.
|
||||
*/
|
||||
function createFetchAttempt(
|
||||
parent: AbortSignal,
|
||||
timeoutMs: number,
|
||||
setTimer: (callback: () => void, delayMs: number) => unknown,
|
||||
clearTimer: (handle: unknown) => void,
|
||||
): FetchAttempt {
|
||||
const controller = new AbortController();
|
||||
let terminalKind: FetchAttemptTerminal["kind"] | null = null;
|
||||
let resolveTerminal:
|
||||
| ((value: FetchAttemptTerminal) => void)
|
||||
| undefined;
|
||||
const terminal = new Promise<FetchAttemptTerminal>(
|
||||
(resolve) => {
|
||||
resolveTerminal = resolve;
|
||||
},
|
||||
);
|
||||
const finish = (kind: FetchAttemptTerminal["kind"]) => {
|
||||
if (terminalKind) return;
|
||||
terminalKind = kind;
|
||||
controller.abort();
|
||||
resolveTerminal?.(Object.freeze({ kind }));
|
||||
const operation = createAbortableOperation({
|
||||
signal: parent,
|
||||
timeoutMs,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
});
|
||||
const kindOf = (
|
||||
reason: AbortTerminalReason | null,
|
||||
): FetchAttemptTerminalKind | null => {
|
||||
if (reason === null) return null;
|
||||
if (reason === "CALLER_ABORT") return "ABORT";
|
||||
return reason === "DEADLINE" ? "TIMEOUT" : "CLOSED";
|
||||
};
|
||||
const abort = () => finish("ABORT");
|
||||
parent.addEventListener("abort", abort, { once: true });
|
||||
if (parent.aborted) abort();
|
||||
const timer = setTimeout(() => {
|
||||
finish("TIMEOUT");
|
||||
}, timeoutMs);
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
terminal,
|
||||
terminalKind: () => terminalKind,
|
||||
signal: operation.signal,
|
||||
terminalKind: () => kindOf(operation.terminal()),
|
||||
race: <Value,>(
|
||||
task: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
) => operation.race(task, compensate),
|
||||
release() {
|
||||
clearTimeout(timer);
|
||||
parent.removeEventListener("abort", abort);
|
||||
// BT-UP-01. Cleanup is best effort and must never replace the already
|
||||
// classified terminal result with a rejection.
|
||||
operation.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -623,7 +667,15 @@ function releaseReader(
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfter(value: string | null): number | null {
|
||||
/**
|
||||
* BT-UP-02. Both the delta-seconds and the HTTP-date branch resolve against the
|
||||
* same captured `now`, so a fake clock makes boundary, rollback and invalid-date
|
||||
* behaviour deterministic instead of depending on the global clock.
|
||||
*/
|
||||
function parseRetryAfter(
|
||||
value: string | null,
|
||||
nowEpochMs: number,
|
||||
): number | null {
|
||||
if (!value) return null;
|
||||
if (/^(0|[1-9][0-9]*)$/u.test(value)) {
|
||||
const seconds = Number(value);
|
||||
@@ -631,9 +683,11 @@ function parseRetryAfter(value: string | null): number | null {
|
||||
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp)
|
||||
? Math.max(0, timestamp - Date.now())
|
||||
: null;
|
||||
if (!Number.isFinite(timestamp) || !Number.isSafeInteger(nowEpochMs)) {
|
||||
return null;
|
||||
}
|
||||
// A clock that moved backwards yields zero, never a negative delay.
|
||||
return Math.max(0, timestamp - nowEpochMs);
|
||||
}
|
||||
|
||||
function jsonContentType(value: string | null): boolean {
|
||||
@@ -667,12 +721,18 @@ function boundedPositiveInteger(
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-UP-01. The structural guard must cover every method cleanup will call.
|
||||
* Admitting a signal without `removeEventListener` turned a `finally` into a
|
||||
* Promise rejection instead of the typed terminal result.
|
||||
*/
|
||||
function isAbortSignal(value: unknown): value is AbortSignal {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as AbortSignal).aborted === "boolean" &&
|
||||
typeof (value as AbortSignal).addEventListener === "function",
|
||||
typeof (value as AbortSignal).addEventListener === "function" &&
|
||||
typeof (value as AbortSignal).removeEventListener === "function",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
ownDataValue,
|
||||
snapshotExactArray,
|
||||
snapshotExactObject,
|
||||
} from "../../../contracts/exact-snapshot.ts";
|
||||
import {
|
||||
isSafeUploadReceiptToken,
|
||||
isUploadFileFingerprint,
|
||||
@@ -104,7 +109,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_SESSION",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const session = decodeSession(response.value);
|
||||
const session = decodeSafely(decodeSession, response.value);
|
||||
return session
|
||||
? browserDataSuccess(session)
|
||||
: browserDataFailure(
|
||||
@@ -140,7 +145,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_RECONCILE",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const status = decodeStatus(response.value);
|
||||
const status = decodeSafely(decodeStatus, response.value);
|
||||
return status
|
||||
? browserDataSuccess(status)
|
||||
: browserDataFailure(
|
||||
@@ -158,7 +163,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
!SHA256_HEX.test(input.uploadBindingSha256) ||
|
||||
!isUploadFileFingerprint(input.fingerprint) ||
|
||||
!MEDIA_TYPE.test(input.mediaType) ||
|
||||
!isUploadPartReceiptShape(input.part) ||
|
||||
decodeUploadPartShape(input.part) === null ||
|
||||
!safeIdempotencyKey(input.idempotencyKey)
|
||||
) {
|
||||
return browserDataFailure(
|
||||
@@ -270,7 +275,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_COMPLETE",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const completed = decodeCompletion(response.value);
|
||||
const completed = decodeSafely(decodeCompletion, response.value);
|
||||
return completed
|
||||
? browserDataSuccess(completed)
|
||||
: browserDataFailure(
|
||||
@@ -303,15 +308,16 @@ export function createResumableUploadHttpControlPlane(
|
||||
"UPLOAD_ABORT",
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const abortState = exactSnapshot(response.value, ["state"]);
|
||||
if (
|
||||
!exactKeys(response.value, ["state"]) ||
|
||||
typeof response.value.state !== "string" ||
|
||||
!abortState ||
|
||||
typeof abortState["state"] !== "string" ||
|
||||
![
|
||||
"ABORTED",
|
||||
"NOT_FOUND",
|
||||
"EXPIRED",
|
||||
"ALREADY_COMPLETED",
|
||||
].includes(response.value.state)
|
||||
].includes(abortState["state"])
|
||||
) {
|
||||
return browserDataFailure(
|
||||
"CORRUPT_DATA",
|
||||
@@ -321,7 +327,7 @@ export function createResumableUploadHttpControlPlane(
|
||||
}
|
||||
return browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: response.value.state as
|
||||
state: abortState["state"] as
|
||||
| "ABORTED"
|
||||
| "NOT_FOUND"
|
||||
| "EXPIRED"
|
||||
@@ -367,66 +373,75 @@ async function invokeJsonTransport(
|
||||
}
|
||||
|
||||
function decodeSession(value: unknown): UploadSession | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"fingerprint",
|
||||
"partSizeBytes",
|
||||
"partCount",
|
||||
"maxConcurrency",
|
||||
"expiresAtEpochMs",
|
||||
]);
|
||||
if (!record) return null;
|
||||
const fingerprint = snapshotFingerprint(record["fingerprint"]);
|
||||
const sessionId = record["sessionId"];
|
||||
const requestBindingSha256 = record["requestBindingSha256"];
|
||||
const partSizeBytes = record["partSizeBytes"];
|
||||
const partCount = record["partCount"];
|
||||
const maxConcurrency = record["maxConcurrency"];
|
||||
const expiresAtEpochMs = record["expiresAtEpochMs"];
|
||||
if (
|
||||
!exactKeys(value, [
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"fingerprint",
|
||||
"partSizeBytes",
|
||||
"partCount",
|
||||
"maxConcurrency",
|
||||
"expiresAtEpochMs",
|
||||
]) ||
|
||||
value.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(value.sessionId) ||
|
||||
typeof value.requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(value.requestBindingSha256) ||
|
||||
!isUploadFileFingerprint(value.fingerprint) ||
|
||||
!positiveSafeInteger(value.partSizeBytes) ||
|
||||
value.partSizeBytes !== value.fingerprint.partSizeBytes ||
|
||||
!positiveSafeInteger(value.partCount) ||
|
||||
value.partCount !== value.fingerprint.partCount ||
|
||||
value.partCount > MAX_PART_COUNT ||
|
||||
!positiveSafeInteger(value.maxConcurrency) ||
|
||||
!positiveSafeInteger(value.expiresAtEpochMs)
|
||||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(sessionId) ||
|
||||
typeof requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(requestBindingSha256) ||
|
||||
fingerprint === null ||
|
||||
!positiveSafeInteger(partSizeBytes) ||
|
||||
partSizeBytes !== fingerprint.partSizeBytes ||
|
||||
!positiveSafeInteger(partCount) ||
|
||||
partCount !== fingerprint.partCount ||
|
||||
partCount > MAX_PART_COUNT ||
|
||||
!positiveSafeInteger(maxConcurrency) ||
|
||||
!positiveSafeInteger(expiresAtEpochMs)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: value.sessionId,
|
||||
requestBindingSha256: value.requestBindingSha256,
|
||||
fingerprint: snapshotFingerprint(value.fingerprint),
|
||||
partSizeBytes: value.partSizeBytes,
|
||||
partCount: value.partCount,
|
||||
maxConcurrency: value.maxConcurrency,
|
||||
expiresAtEpochMs: value.expiresAtEpochMs,
|
||||
sessionId,
|
||||
requestBindingSha256,
|
||||
fingerprint,
|
||||
partSizeBytes,
|
||||
partCount,
|
||||
maxConcurrency,
|
||||
expiresAtEpochMs,
|
||||
});
|
||||
}
|
||||
|
||||
function decodeStatus(value: unknown): UploadSessionStatus | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.state === "ACTIVE" &&
|
||||
exactKeys(record, ["state", "session", "acceptedParts"])
|
||||
) {
|
||||
const session = decodeSession(record.session);
|
||||
if (
|
||||
!session ||
|
||||
!Array.isArray(record.acceptedParts) ||
|
||||
record.acceptedParts.length > MAX_RECEIPT_COUNT ||
|
||||
!record.acceptedParts.every(isUploadPartReceipt)
|
||||
) {
|
||||
// The discriminator is read from the same snapshot the payload comes from.
|
||||
const state = ownDataValue(value, "state");
|
||||
if (state === "ACTIVE") {
|
||||
const record = exactSnapshot(value, [
|
||||
"state",
|
||||
"session",
|
||||
"acceptedParts",
|
||||
]);
|
||||
if (!record) return null;
|
||||
const session = decodeSession(record["session"]);
|
||||
const rows = snapshotExactArray(record["acceptedParts"]);
|
||||
if (!session || rows === null || rows.length > MAX_RECEIPT_COUNT) {
|
||||
return null;
|
||||
}
|
||||
const parts = Object.freeze(
|
||||
record.acceptedParts.map(snapshotReceipt),
|
||||
);
|
||||
const receipts: UploadPartReceipt[] = [];
|
||||
for (const row of rows) {
|
||||
const receipt = snapshotReceipt(row);
|
||||
if (receipt === null) return null;
|
||||
receipts.push(receipt);
|
||||
}
|
||||
const parts = Object.freeze(receipts);
|
||||
return orderedReceipts(parts, session.fingerprint, false)
|
||||
? Object.freeze({
|
||||
state: "ACTIVE",
|
||||
@@ -435,41 +450,49 @@ function decodeStatus(value: unknown): UploadSessionStatus | null {
|
||||
})
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
record.state === "QUARANTINED" &&
|
||||
exactKeys(record, ["state", "session", "resourceId"])
|
||||
) {
|
||||
const session = decodeSession(record.session);
|
||||
if (state === "QUARANTINED") {
|
||||
const record = exactSnapshot(value, ["state", "session", "resourceId"]);
|
||||
if (!record) return null;
|
||||
const session = decodeSession(record["session"]);
|
||||
const resourceId = record["resourceId"];
|
||||
return session &&
|
||||
typeof record.resourceId === "string" &&
|
||||
SAFE_OPAQUE_ID.test(record.resourceId)
|
||||
typeof resourceId === "string" &&
|
||||
SAFE_OPAQUE_ID.test(resourceId)
|
||||
? Object.freeze({
|
||||
state: "QUARANTINED",
|
||||
session,
|
||||
resourceId: record.resourceId,
|
||||
resourceId,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
typeof record.state === "string" &&
|
||||
["ABORTED", "EXPIRED", "NOT_FOUND"].includes(record.state) &&
|
||||
exactKeys(record, [
|
||||
typeof state === "string" &&
|
||||
["ABORTED", "EXPIRED", "NOT_FOUND"].includes(state)
|
||||
) {
|
||||
const record = exactSnapshot(value, [
|
||||
"state",
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
]) &&
|
||||
record.protocol === RESUMABLE_UPLOAD_PROTOCOL &&
|
||||
typeof record.sessionId === "string" &&
|
||||
SAFE_OPAQUE_ID.test(record.sessionId) &&
|
||||
typeof record.requestBindingSha256 === "string" &&
|
||||
SHA256_HEX.test(record.requestBindingSha256)
|
||||
) {
|
||||
]);
|
||||
const sessionId = record?.["sessionId"];
|
||||
const requestBindingSha256 = record?.["requestBindingSha256"];
|
||||
if (
|
||||
!record ||
|
||||
record["state"] !== state ||
|
||||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(sessionId) ||
|
||||
typeof requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(requestBindingSha256)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
state: record.state as "ABORTED" | "EXPIRED" | "NOT_FOUND",
|
||||
state: state as "ABORTED" | "EXPIRED" | "NOT_FOUND",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: record.sessionId,
|
||||
requestBindingSha256: record.requestBindingSha256,
|
||||
sessionId,
|
||||
requestBindingSha256,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
@@ -484,34 +507,39 @@ function decodeCompletion(
|
||||
> extends UploadProviderResult<infer Outcome>
|
||||
? Outcome | null
|
||||
: never {
|
||||
const record = exactSnapshot(value, [
|
||||
"state",
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"fingerprint",
|
||||
"resourceId",
|
||||
]);
|
||||
const sessionId = record?.["sessionId"];
|
||||
const requestBindingSha256 = record?.["requestBindingSha256"];
|
||||
const resourceId = record?.["resourceId"];
|
||||
const fingerprint = record ? snapshotFingerprint(record["fingerprint"]) : null;
|
||||
if (
|
||||
!exactKeys(value, [
|
||||
"state",
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"fingerprint",
|
||||
"resourceId",
|
||||
]) ||
|
||||
value.state !== "QUARANTINED" ||
|
||||
value.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(value.sessionId) ||
|
||||
typeof value.requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(value.requestBindingSha256) ||
|
||||
!isUploadFileFingerprint(value.fingerprint) ||
|
||||
typeof value.resourceId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(value.resourceId)
|
||||
!record ||
|
||||
record["state"] !== "QUARANTINED" ||
|
||||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
|
||||
typeof sessionId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(sessionId) ||
|
||||
typeof requestBindingSha256 !== "string" ||
|
||||
!SHA256_HEX.test(requestBindingSha256) ||
|
||||
fingerprint === null ||
|
||||
typeof resourceId !== "string" ||
|
||||
!SAFE_OPAQUE_ID.test(resourceId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
state: "QUARANTINED",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: value.sessionId,
|
||||
requestBindingSha256: value.requestBindingSha256,
|
||||
fingerprint: snapshotFingerprint(value.fingerprint),
|
||||
resourceId: value.resourceId,
|
||||
sessionId,
|
||||
requestBindingSha256,
|
||||
fingerprint,
|
||||
resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -546,52 +574,101 @@ function orderedReceipts(
|
||||
});
|
||||
}
|
||||
|
||||
function isUploadPartReceiptShape(
|
||||
function decodeUploadPartShape(
|
||||
value: unknown,
|
||||
): value is Readonly<{
|
||||
): Readonly<{
|
||||
partNumber: number;
|
||||
offset: number;
|
||||
byteLength: number;
|
||||
checksumSha256: string;
|
||||
}> {
|
||||
return (
|
||||
exactKeys(value, [
|
||||
"partNumber",
|
||||
"offset",
|
||||
"byteLength",
|
||||
"checksumSha256",
|
||||
]) &&
|
||||
positiveSafeInteger(value.partNumber) &&
|
||||
nonNegativeSafeInteger(value.offset) &&
|
||||
positiveSafeInteger(value.byteLength) &&
|
||||
typeof value.checksumSha256 === "string" &&
|
||||
SHA256_HEX.test(value.checksumSha256)
|
||||
);
|
||||
}> | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"partNumber",
|
||||
"offset",
|
||||
"byteLength",
|
||||
"checksumSha256",
|
||||
]);
|
||||
if (!record) return null;
|
||||
const partNumber = record["partNumber"];
|
||||
const offset = record["offset"];
|
||||
const byteLength = record["byteLength"];
|
||||
const checksumSha256 = record["checksumSha256"];
|
||||
return positiveSafeInteger(partNumber) &&
|
||||
nonNegativeSafeInteger(offset) &&
|
||||
positiveSafeInteger(byteLength) &&
|
||||
typeof checksumSha256 === "string" &&
|
||||
SHA256_HEX.test(checksumSha256)
|
||||
? Object.freeze({ partNumber, offset, byteLength, checksumSha256 })
|
||||
: null;
|
||||
}
|
||||
|
||||
function snapshotFingerprint(
|
||||
value: UploadFileFingerprint,
|
||||
): UploadFileFingerprint {
|
||||
return Object.freeze({ ...value });
|
||||
/**
|
||||
* TR-05. Copies the nested value first, then validates the copy, so the
|
||||
* fingerprint the session is checked against is the one it carries.
|
||||
*/
|
||||
function snapshotFingerprint(value: unknown): UploadFileFingerprint | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"algorithm",
|
||||
"digestHex",
|
||||
"byteLength",
|
||||
"partSizeBytes",
|
||||
"partCount",
|
||||
]);
|
||||
return record && isUploadFileFingerprint(record)
|
||||
? (record as unknown as UploadFileFingerprint)
|
||||
: null;
|
||||
}
|
||||
|
||||
function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt {
|
||||
return Object.freeze({ ...value });
|
||||
function snapshotReceipt(value: unknown): UploadPartReceipt | null {
|
||||
const record = exactSnapshot(value, [
|
||||
"partNumber",
|
||||
"offset",
|
||||
"byteLength",
|
||||
"checksumSha256",
|
||||
"receiptToken",
|
||||
]);
|
||||
return record && isUploadPartReceipt(record)
|
||||
? (record as unknown as UploadPartReceipt)
|
||||
: null;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
/**
|
||||
* TR-RR-08 / TR-05. Copies the value into an owned frozen record, reading every
|
||||
* property exactly once, and returns `null` for anything that is not an exact
|
||||
* own-data shape.
|
||||
*
|
||||
* `Object.keys` saw only enumerable own string keys, so a symbol or
|
||||
* non-enumerable extra field passed unseen and a later property read invoked
|
||||
* whatever accessor the sender installed. Worse, checking the sender's object
|
||||
* and then reading it again to build the result let a stateful answer show a
|
||||
* safe `sessionId` to the regex and hand an unvalidated one to the receipt, so
|
||||
* the value that was checked and the value that was returned differed.
|
||||
*/
|
||||
function exactSnapshot(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
): Record<string, unknown> | null {
|
||||
if (Array.isArray(value)) return null;
|
||||
return snapshotExactObject(value, {
|
||||
allowed: keys,
|
||||
required: keys,
|
||||
}) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-RR-08. Runs a decoder inside the adapter's failure boundary. A hostile
|
||||
* object that still throws from a trap becomes a typed `CORRUPT_DATA` result
|
||||
* rather than a native rejection out of a public method.
|
||||
*/
|
||||
function decodeSafely<Value>(
|
||||
decode: (value: unknown) => Value | null,
|
||||
value: unknown,
|
||||
): Value | null {
|
||||
try {
|
||||
return decode(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return (
|
||||
actual.length === expected.length &&
|
||||
actual.every((key, index) => key === expected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function safeIdempotencyKey(value: string): boolean {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ResumableUploadCheckpointAdmin,
|
||||
ResumableUploadCheckpoint,
|
||||
ResumableUploadCheckpointStore,
|
||||
PartitionDeleteOutcome,
|
||||
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
@@ -21,6 +22,24 @@ const GOVERNANCE_STORE = "governance";
|
||||
const GOVERNANCE_KEY = "scope-binding";
|
||||
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* BT-UP-03. Per-realm registry keyed by `(IDBFactory identity, databaseName)`.
|
||||
* It prevents this realm from recreating a store whose deletion is still in
|
||||
* flight. It deliberately claims nothing about other realms, which are handled
|
||||
* by native blocked ordering and explicit recovery.
|
||||
*/
|
||||
const PENDING_DELETIONS = new WeakMap<object, Set<string>>();
|
||||
|
||||
function pendingDeletionsFor(factory: unknown): Set<string> {
|
||||
const key = (factory ?? PENDING_DELETIONS) as object;
|
||||
let pending = PENDING_DELETIONS.get(key);
|
||||
if (!pending) {
|
||||
pending = new Set<string>();
|
||||
PENDING_DELETIONS.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export type IndexedDbUploadCheckpointScope = Readonly<{
|
||||
authorityToken: string;
|
||||
namespaceToken: string;
|
||||
@@ -93,6 +112,14 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
? factory.deleteDatabase.bind(factory)
|
||||
: undefined;
|
||||
const databaseName = uploadCheckpointDatabaseName(scope);
|
||||
const pendingDeletions = pendingDeletionsFor(factory);
|
||||
if (pendingDeletions.has(databaseName)) {
|
||||
// BT-UP-03. A deletion dispatched by this realm has not settled, so a new
|
||||
// store over the same database would race an unknown native effect.
|
||||
throw new TypeError(
|
||||
"Upload checkpoint partition has an unresolved pending deletion.",
|
||||
);
|
||||
}
|
||||
const expectedBinding: ScopeBinding = Object.freeze({
|
||||
key: GOVERNANCE_KEY,
|
||||
schemaVersion: 1,
|
||||
@@ -376,7 +403,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
async deletePartition(
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
BrowserDataResult<Readonly<{ state: "DELETED" }>>
|
||||
BrowserDataResult<PartitionDeleteOutcome>
|
||||
> {
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
||||
@@ -397,46 +424,64 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
||||
} catch (error) {
|
||||
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
|
||||
}
|
||||
return await new Promise<
|
||||
BrowserDataResult<Readonly<{ state: "DELETED" }>>
|
||||
>((resolve) => {
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (
|
||||
result: BrowserDataResult<Readonly<{ state: "DELETED" }>>,
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (blockedTimer) clearTimeout(blockedTimer);
|
||||
resolve(result);
|
||||
};
|
||||
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is
|
||||
// intentionally observed only before dispatch so the adapter never
|
||||
// reports ABORTED while deletion may still commit.
|
||||
request.onblocked = () => {
|
||||
blockedTimer = setTimeout(() => {
|
||||
// BT-UP-03. Once dispatched the deletion may still commit after this
|
||||
// call returns, so the pending registration is installed before the
|
||||
// promise settles and is only released by the real native completion.
|
||||
pendingDeletions.add(databaseName);
|
||||
return await new Promise<BrowserDataResult<PartitionDeleteOutcome>>(
|
||||
(resolve) => {
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (
|
||||
result: BrowserDataResult<PartitionDeleteOutcome>,
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (blockedTimer) clearTimeout(blockedTimer);
|
||||
resolve(result);
|
||||
};
|
||||
const releasePending = () => {
|
||||
pendingDeletions.delete(databaseName);
|
||||
};
|
||||
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal
|
||||
// is intentionally observed only before dispatch so the adapter never
|
||||
// reports ABORTED while deletion may still commit.
|
||||
request.onblocked = () => {
|
||||
blockedTimer = setTimeout(() => {
|
||||
// Not NOT_APPLIED: the request is still live in the browser.
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "PENDING" as const,
|
||||
effect: "UNKNOWN" as const,
|
||||
reason: "BLOCKED_DEADLINE" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, blockedTimeoutMs);
|
||||
};
|
||||
request.onerror = () => {
|
||||
releasePending();
|
||||
finish(
|
||||
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
}),
|
||||
mapBrowserDataException(
|
||||
request.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
}, blockedTimeoutMs);
|
||||
};
|
||||
request.onerror = () =>
|
||||
finish(
|
||||
mapBrowserDataException(
|
||||
request.error,
|
||||
"UPLOAD_RECONCILE",
|
||||
),
|
||||
);
|
||||
request.onsuccess = () =>
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({ state: "DELETED" as const }),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
releasePending();
|
||||
finish(
|
||||
browserDataSuccess(
|
||||
Object.freeze({
|
||||
state: "DELETED" as const,
|
||||
effect: "APPLIED" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
const admin = Object.freeze(adminValue);
|
||||
|
||||
@@ -43,6 +43,15 @@ export function createPresignedUploadPartExecutor(
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
// BT-UP-04. A NaN or negative clock silently bypasses every expiry
|
||||
// comparison, and an infinite one misreports a dependency failure as a
|
||||
// capability policy failure. Both are dependency failures.
|
||||
if (!Number.isSafeInteger(nowEpochMs) || nowEpochMs < 0) {
|
||||
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!capability ||
|
||||
capability.method !== "PUT" ||
|
||||
|
||||
@@ -66,7 +66,24 @@ import type { UploadMutationLock } from "./upload-mutation-lock.ts";
|
||||
|
||||
export type ResumableUploadRuntime = ResumableUploadPort &
|
||||
Readonly<{
|
||||
/**
|
||||
* BT-UP-06. Compatibility facade: closes admission and starts the same
|
||||
* single-flight drain that `dispose()` awaits.
|
||||
*/
|
||||
close(): void;
|
||||
/**
|
||||
* BT-UP-06. Awaitable teardown for a future composition owner. It shares
|
||||
* one drain promise, aborts the active operation registry and only then
|
||||
* closes the checkpoint store and cancellation channel, so success actually
|
||||
* means quiescent. No current bootstrap consumer is assumed.
|
||||
*/
|
||||
/**
|
||||
* TR-RR-06. Closes admission and returns the *bounded* drain result. A
|
||||
* failure means the runtime is still `CLOSING`: physical work the caller
|
||||
* must not treat as finished is still in flight.
|
||||
*/
|
||||
dispose(): Promise<BrowserDataResult<void>>;
|
||||
lifecycle(): "OPEN" | "CLOSING" | "CLOSED";
|
||||
}>;
|
||||
|
||||
export type ResumableUploadRuntimeDependencies<Capability> = Readonly<{
|
||||
@@ -108,6 +125,13 @@ type RuntimeDependencies<Capability> = Readonly<{
|
||||
random(): number;
|
||||
sleep(delayMs: number, signal: AbortSignal): Promise<void>;
|
||||
observer?: BrowserDataObserver;
|
||||
/**
|
||||
* TR-04. Every raw provider promise, from the moment the collaborator is
|
||||
* called until it actually settles. The wrapper that bounds the attempt can
|
||||
* settle long before the provider does, so the wrapper registry alone could
|
||||
* report an empty set while physical work was still running.
|
||||
*/
|
||||
physicalTasks: Set<Promise<unknown>>;
|
||||
}>;
|
||||
|
||||
type ActiveResolution =
|
||||
@@ -159,10 +183,16 @@ const RECOVERIES: ReadonlySet<string> = new Set([
|
||||
export function createResumableUploadRuntime<Capability>(
|
||||
inputDependencies: ResumableUploadRuntimeDependencies<Capability>,
|
||||
): ResumableUploadRuntime {
|
||||
const dependencies = snapshotDependencies(inputDependencies);
|
||||
/** TR-04. Raw provider work, tracked independently of its bounded wrapper. */
|
||||
const physicalTasks = new Set<Promise<unknown>>();
|
||||
const dependencies = snapshotDependencies(inputDependencies, physicalTasks);
|
||||
const lifetime = new AbortController();
|
||||
const localUploads = new Map<string, Set<AbortController>>();
|
||||
/** BT-UP-06. Terminal settlement of every admitted operation. */
|
||||
const activeOperations = new Set<Promise<unknown>>();
|
||||
let closed = false;
|
||||
let lifecycle: "OPEN" | "CLOSING" | "CLOSED" = "OPEN";
|
||||
let drain: Promise<BrowserDataResult<void>> | null = null;
|
||||
const cancelLocalUploads = (uploadKey: string): void => {
|
||||
if (!SAFE_UPLOAD_KEY.test(uploadKey)) return;
|
||||
for (const controller of localUploads.get(uploadKey) ?? []) {
|
||||
@@ -211,19 +241,24 @@ export function createResumableUploadRuntime<Capability>(
|
||||
}
|
||||
return browserDataFailure("ABORTED", "UPLOAD_SESSION");
|
||||
}
|
||||
const operation = dependencies.mutationLock.run(
|
||||
request.uploadKey,
|
||||
operationScope.signal,
|
||||
async () =>
|
||||
await executeUpload(
|
||||
dependencies,
|
||||
Object.freeze({
|
||||
...request,
|
||||
signal: operationScope.signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Tracked until terminal settlement so dispose() can prove quiescence.
|
||||
const tracked = Promise.resolve(operation).catch(() => undefined);
|
||||
activeOperations.add(tracked);
|
||||
void tracked.finally(() => activeOperations.delete(tracked));
|
||||
try {
|
||||
return await dependencies.mutationLock.run(
|
||||
request.uploadKey,
|
||||
operationScope.signal,
|
||||
async () =>
|
||||
await executeUpload(
|
||||
dependencies,
|
||||
Object.freeze({
|
||||
...request,
|
||||
signal: operationScope.signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return await operation;
|
||||
} catch (error) {
|
||||
return mapLockFailure(error, "UPLOAD_SESSION");
|
||||
} finally {
|
||||
@@ -259,17 +294,19 @@ export function createResumableUploadRuntime<Capability>(
|
||||
dependencies.crossContextCancellation?.publish(uploadKey);
|
||||
}
|
||||
const combined = combineAbortSignals(input.signal, lifetime.signal);
|
||||
const operation = dependencies.mutationLock.run(
|
||||
uploadKey,
|
||||
combined.signal,
|
||||
async () =>
|
||||
await executeAbort(dependencies, uploadKey, combined.signal),
|
||||
);
|
||||
// TR-RR-06. An abort is admitted physical work like an upload, so it is
|
||||
// tracked from admission and `dispose()` cannot step over it.
|
||||
const tracked = Promise.resolve(operation).catch(() => undefined);
|
||||
activeOperations.add(tracked);
|
||||
void tracked.finally(() => activeOperations.delete(tracked));
|
||||
try {
|
||||
return await dependencies.mutationLock.run(
|
||||
uploadKey,
|
||||
combined.signal,
|
||||
async () =>
|
||||
await executeAbort(
|
||||
dependencies,
|
||||
uploadKey,
|
||||
combined.signal,
|
||||
),
|
||||
);
|
||||
return await operation;
|
||||
} catch (error) {
|
||||
return mapLockFailure(error, "UPLOAD_ABORT");
|
||||
} finally {
|
||||
@@ -278,14 +315,65 @@ export function createResumableUploadRuntime<Capability>(
|
||||
},
|
||||
|
||||
close() {
|
||||
if (closed) return;
|
||||
// BT-UP-06. Admission closes synchronously; the drain runs behind the
|
||||
// same single-flight promise dispose() returns.
|
||||
void startDrain();
|
||||
},
|
||||
|
||||
dispose(): Promise<BrowserDataResult<void>> {
|
||||
return startDrain();
|
||||
},
|
||||
|
||||
lifecycle: () => lifecycle,
|
||||
});
|
||||
|
||||
function startDrain(): Promise<BrowserDataResult<void>> {
|
||||
drain ??= (async () => {
|
||||
closed = true;
|
||||
lifecycle = "CLOSING";
|
||||
releaseCrossContextCancellation?.();
|
||||
dependencies.crossContextCancellation?.close();
|
||||
// Abort every admitted operation, then wait for their real settlement.
|
||||
lifetime.abort();
|
||||
for (const controllers of localUploads.values()) {
|
||||
for (const controller of controllers) controller.abort();
|
||||
}
|
||||
// TR-RR-06. Bounded. A non-cooperative mutation lock or provider must not
|
||||
// make teardown unbounded, and an unproved drain is reported as such
|
||||
// rather than closed over.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const expired = new Promise<"EXPIRED">((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve("EXPIRED"),
|
||||
dependencies.policy.cleanupDeadlineMs,
|
||||
);
|
||||
});
|
||||
// TR-04. Quiescence means both registries: the bounded wrappers and the
|
||||
// raw provider work they may have outlived. A settling wrapper can still
|
||||
// register more physical work, so the drain repeats until both are empty
|
||||
// or the cleanup deadline expires.
|
||||
const quiescent = (async () => {
|
||||
while (activeOperations.size > 0 || physicalTasks.size > 0) {
|
||||
await Promise.allSettled([...activeOperations, ...physicalTasks]);
|
||||
}
|
||||
return "DRAINED" as const;
|
||||
})();
|
||||
const drained = await Promise.race([quiescent, expired]);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
if (drained === "EXPIRED") {
|
||||
// The store stays open: something can still write a checkpoint.
|
||||
return browserDataFailure("UNAVAILABLE", "UPLOAD_ABORT", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
});
|
||||
}
|
||||
// The store closes only after nothing can still write a checkpoint.
|
||||
dependencies.checkpoints.close();
|
||||
},
|
||||
});
|
||||
lifecycle = "CLOSED";
|
||||
return browserDataSuccess(undefined);
|
||||
})();
|
||||
return drain;
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1238,8 +1326,20 @@ async function invokeProviderAttempt<Capability, Value>(
|
||||
}, dependencies.policy.providerAttemptTimeoutMs);
|
||||
});
|
||||
try {
|
||||
// TR-04. The raw promise enters the physical registry the moment the
|
||||
// provider is called and stays there until it truly settles. Racing it
|
||||
// against a deadline let the bounded wrapper settle first and leave the
|
||||
// set empty, so `dispose()` reported a drained runtime while the provider
|
||||
// was still running.
|
||||
const raw = action(controller.signal);
|
||||
const tracked = Promise.resolve(raw).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
dependencies.physicalTasks.add(tracked);
|
||||
void tracked.finally(() => dependencies.physicalTasks.delete(tracked));
|
||||
return await Promise.race([
|
||||
invokeProvider(operation, () => action(controller.signal)),
|
||||
invokeProvider(operation, () => raw),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
@@ -1671,6 +1771,7 @@ function snapshotRequest(
|
||||
|
||||
function snapshotDependencies<Capability>(
|
||||
input: ResumableUploadRuntimeDependencies<Capability>,
|
||||
physicalTasks: Set<Promise<unknown>>,
|
||||
): RuntimeDependencies<Capability> {
|
||||
const policy = resolveResumableUploadRuntimePolicy(input.policy);
|
||||
const controlPlane = snapshotControlPlane(input.controlPlane);
|
||||
@@ -1693,6 +1794,7 @@ function snapshotDependencies<Capability>(
|
||||
throw new TypeError("Upload runtime dependency is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
physicalTasks,
|
||||
controlPlane,
|
||||
partExecutor,
|
||||
checkpoints,
|
||||
|
||||
@@ -13,6 +13,12 @@ export type ResumableUploadRuntimePolicy = Readonly<{
|
||||
capabilityRefreshSkewMs: number;
|
||||
maxSessionLifetimeMs: number;
|
||||
providerAttemptTimeoutMs: number;
|
||||
/**
|
||||
* TR-RR-06. The bound `dispose()` applies to its drain. A non-cooperative
|
||||
* mutation lock or provider would otherwise make teardown unbounded, so a
|
||||
* caller could never learn whether the runtime was quiescent.
|
||||
*/
|
||||
cleanupDeadlineMs: number;
|
||||
}>;
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
@@ -49,6 +55,7 @@ const DEFAULT_POLICY: ResumableUploadRuntimePolicy = Object.freeze({
|
||||
capabilityRefreshSkewMs: 5_000,
|
||||
maxSessionLifetimeMs: 24 * 60 * 60_000,
|
||||
providerAttemptTimeoutMs: 30_000,
|
||||
cleanupDeadlineMs: 10_000,
|
||||
});
|
||||
|
||||
export function resolveResumableUploadRuntimePolicy(
|
||||
@@ -95,6 +102,9 @@ export function resolveResumableUploadRuntimePolicy(
|
||||
!positiveSafeInteger(policy.providerAttemptTimeoutMs) ||
|
||||
policy.providerAttemptTimeoutMs >
|
||||
ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs ||
|
||||
!positiveSafeInteger(policy.cleanupDeadlineMs) ||
|
||||
policy.cleanupDeadlineMs >
|
||||
ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs * 2 ||
|
||||
Math.ceil(policy.maxFileBytes / policy.partSizeBytes) >
|
||||
policy.maxPartCount
|
||||
) {
|
||||
|
||||
@@ -132,6 +132,11 @@ export function assertPublicCachePolicy(
|
||||
policy.allowedVaryHeaderNames.some(
|
||||
(name) => !policy.allowedRequestHeaderNames.includes(name),
|
||||
) ||
|
||||
// STO-03. Enabling variants while stripping `vary` from stored responses
|
||||
// makes every variant collide on the same cache key, so the combination is
|
||||
// rejected at composition instead of producing an unusable candidate.
|
||||
(policy.allowedVaryHeaderNames.length > 0 &&
|
||||
!policy.allowedResponseHeaderNames.includes("vary")) ||
|
||||
policy.forbiddenQueryParameterNames.some((name) => name.length === 0) ||
|
||||
policy.allowedQueryParameterNames.some((name) =>
|
||||
policy.forbiddenQueryParameterNames.includes(name),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
browserDataSuccess,
|
||||
mapBrowserDataException,
|
||||
} from "../browser-file-storage/result.ts";
|
||||
import { createAbortableOperation } from "../platform/abortable-operation.ts";
|
||||
import {
|
||||
cacheByteBucket,
|
||||
cacheEntryBucket,
|
||||
@@ -241,7 +242,7 @@ export function createPublicResponseCacheAdapter(
|
||||
const aborted = abortedResult(signal, "CACHE_LOOKUP");
|
||||
if (aborted) return aborted;
|
||||
if (!dependencies.cacheStorage) {
|
||||
return unsupported("CACHE_LOOKUP");
|
||||
return unsupported("CACHE_LOOKUP", "RETRY");
|
||||
}
|
||||
let assetRequest: NormalizedAsset;
|
||||
try {
|
||||
@@ -389,10 +390,7 @@ export function createPublicResponseCacheAdapter(
|
||||
const signal = options.signal;
|
||||
const aborted = abortedResult(signal, "CACHE_STAGE");
|
||||
if (aborted) return aborted;
|
||||
const availability = mutationAvailability(
|
||||
dependencies,
|
||||
"CACHE_STAGE",
|
||||
);
|
||||
const availability = stageAvailability(dependencies);
|
||||
if (availability) return availability;
|
||||
|
||||
let normalized: NormalizedReleaseManifest;
|
||||
@@ -412,23 +410,59 @@ export function createPublicResponseCacheAdapter(
|
||||
entryBucket: cacheEntryBucket(normalized.assets.length),
|
||||
});
|
||||
|
||||
// NS-08. One owner covers the whole staging body. Handing the signal to
|
||||
// each `Request` only asked a cooperative fetch to stop: a stream or a
|
||||
// digest that ignored it kept the mutation lock and, worse, could finish
|
||||
// after the abort had already ended the operation and still write both the
|
||||
// asset and the marker, publishing a release nobody was waiting for.
|
||||
const owner = createAbortableOperation({ signal });
|
||||
const ownedStep = async <Value>(
|
||||
task: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
): Promise<Value> => {
|
||||
const raced = await owner.race(task, compensate);
|
||||
if (raced.kind === "REJECTED") throw raced.reason;
|
||||
if (raced.kind !== "VALUE") {
|
||||
throw new CacheValidationFailure("ABORTED");
|
||||
}
|
||||
return raced.value;
|
||||
};
|
||||
const assertOwned = (): void => {
|
||||
if (owner.terminal() !== null) {
|
||||
throw new CacheValidationFailure("ABORTED");
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await dependencies.mutationLock!.run(
|
||||
signal,
|
||||
async () => {
|
||||
assertOwned();
|
||||
const cacheName = releaseCacheName(
|
||||
policy,
|
||||
normalized.releaseRegistryId,
|
||||
normalized.manifestDigestHex,
|
||||
);
|
||||
const existingNames = await dependencies.cacheStorage!.keys();
|
||||
if (existingNames.includes(cacheName)) {
|
||||
const existingNames = await ownedStep(
|
||||
Promise.resolve(dependencies.cacheStorage!.keys()),
|
||||
);
|
||||
// STO-RR-05. A candidate this call did not create may be the one
|
||||
// currently serving traffic, so nothing about it is deleted before
|
||||
// a replacement has been fetched and verified.
|
||||
const preExistingCandidate = existingNames.includes(cacheName);
|
||||
if (preExistingCandidate) {
|
||||
const existing = await dependencies.cacheStorage!.open(cacheName);
|
||||
const marker = await readMarker(
|
||||
existing,
|
||||
policy,
|
||||
dependencies.crypto,
|
||||
);
|
||||
if (!marker.ok && marker.error.code !== "CORRUPT_DATA") {
|
||||
// STO-RR-04. A marker that could not be read is unknown, not
|
||||
// damaged. Treating a transient storage error as proof of
|
||||
// corruption would delete a healthy active release.
|
||||
return rebaseFailure(marker.error, "CACHE_STAGE");
|
||||
}
|
||||
if (
|
||||
marker.ok &&
|
||||
marker.value &&
|
||||
@@ -438,47 +472,78 @@ export function createPublicResponseCacheAdapter(
|
||||
normalized.manifestDigestHex &&
|
||||
marker.value.entryCount === normalized.assets.length
|
||||
) {
|
||||
return browserDataSuccess(summaryFromMarker(marker.value));
|
||||
}
|
||||
await dependencies.cacheStorage!.delete(cacheName);
|
||||
}
|
||||
|
||||
const cache = await dependencies.cacheStorage!.open(cacheName);
|
||||
try {
|
||||
let totalBytes = 0;
|
||||
for (const asset of normalized.assets) {
|
||||
if (signal?.aborted) {
|
||||
throw new CacheValidationFailure("ABORTED");
|
||||
}
|
||||
const cacheRequest = createNativeRequest(asset);
|
||||
const networkRequest = createNativeRequest(
|
||||
asset,
|
||||
signal,
|
||||
);
|
||||
const response =
|
||||
await dependencies.fetcher!(networkRequest);
|
||||
const read = await readAndValidateResponse(
|
||||
response,
|
||||
asset,
|
||||
// STO-04. The marker is a claim that staging completed, not
|
||||
// evidence that every entry still exists and matches. Browser
|
||||
// eviction, manual deletion and partial corruption all leave the
|
||||
// marker intact, so the candidate is re-verified before reuse.
|
||||
const verified = await verifyReleaseCandidate(
|
||||
existing,
|
||||
marker.value.assets,
|
||||
policy,
|
||||
dependencies.crypto,
|
||||
signal,
|
||||
);
|
||||
if (verified.kind === "VERIFIED") {
|
||||
return browserDataSuccess(summaryFromMarker(marker.value));
|
||||
}
|
||||
if (verified.kind === "UNKNOWN") {
|
||||
// Abort or an unreadable candidate is never stage success and
|
||||
// never silently deletes an owned candidate.
|
||||
return verified.failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cache = await ownedStep(
|
||||
Promise.resolve(dependencies.cacheStorage!.open(cacheName)),
|
||||
);
|
||||
try {
|
||||
let totalBytes = 0;
|
||||
for (const asset of normalized.assets) {
|
||||
assertOwned();
|
||||
const cacheRequest = createNativeRequest(asset);
|
||||
const networkRequest = createNativeRequest(
|
||||
asset,
|
||||
owner.signal,
|
||||
);
|
||||
const response = await ownedStep(
|
||||
Promise.resolve(dependencies.fetcher!(networkRequest)),
|
||||
// A response that arrives after the owner ended is released
|
||||
// rather than read.
|
||||
(late) => {
|
||||
void late.body?.cancel().catch(() => undefined);
|
||||
},
|
||||
);
|
||||
const read = await ownedStep(
|
||||
readAndValidateResponse(
|
||||
response,
|
||||
asset,
|
||||
policy,
|
||||
dependencies.crypto,
|
||||
owner.signal,
|
||||
),
|
||||
);
|
||||
if (!read.ok) throw new CacheValidationFailure(read.error.code);
|
||||
assertOwned();
|
||||
totalBytes += read.value.bytes.byteLength;
|
||||
if (totalBytes > policy.maxReleaseBytes) {
|
||||
throw new CacheValidationFailure("LIMIT_EXCEEDED");
|
||||
}
|
||||
await cache.put(
|
||||
cacheRequest,
|
||||
new Response(Uint8Array.from(read.value.bytes), {
|
||||
status: 200,
|
||||
headers: read.value.headers.map(
|
||||
([name, value]) => [name, value],
|
||||
await ownedStep(
|
||||
Promise.resolve(
|
||||
cache.put(
|
||||
cacheRequest,
|
||||
new Response(Uint8Array.from(read.value.bytes), {
|
||||
status: 200,
|
||||
headers: read.value.headers.map(
|
||||
([name, value]) => [name, value],
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
assertOwned();
|
||||
|
||||
const marker: ReleaseMarker = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
@@ -490,13 +555,23 @@ export function createPublicResponseCacheAdapter(
|
||||
stagedAtEpochMs: now(),
|
||||
assets: normalized.assets,
|
||||
});
|
||||
await cache.put(
|
||||
markerRequest(policy),
|
||||
jsonResponse(marker),
|
||||
await ownedStep(
|
||||
Promise.resolve(
|
||||
cache.put(markerRequest(policy), jsonResponse(marker)),
|
||||
),
|
||||
);
|
||||
// The marker is the activation record, so it is only a success
|
||||
// once this call still owns the operation that wrote it.
|
||||
assertOwned();
|
||||
return browserDataSuccess(summaryFromMarker(marker));
|
||||
} catch (error) {
|
||||
await dependencies.cacheStorage!.delete(cacheName);
|
||||
// STO-RR-05. Only a candidate this call created is removed. A
|
||||
// repair that failed part-way leaves every entry it did replace
|
||||
// and every entry it never touched in place, so the release that
|
||||
// was serving traffic before still is.
|
||||
if (!preExistingCandidate) {
|
||||
await dependencies.cacheStorage!.delete(cacheName);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -522,6 +597,8 @@ export function createPublicResponseCacheAdapter(
|
||||
dependencies.observer,
|
||||
normalized.releaseRegistryId,
|
||||
);
|
||||
} finally {
|
||||
owner.close();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -535,7 +612,7 @@ export function createPublicResponseCacheAdapter(
|
||||
const signal = options.signal;
|
||||
const aborted = abortedResult(signal, "CACHE_ACTIVATE");
|
||||
if (aborted) return aborted;
|
||||
const availability = mutationAvailability(
|
||||
const availability = localMutationAvailability(
|
||||
dependencies,
|
||||
"CACHE_ACTIVATE",
|
||||
);
|
||||
@@ -589,31 +666,25 @@ export function createPublicResponseCacheAdapter(
|
||||
);
|
||||
}
|
||||
|
||||
for (const asset of marker.assets) {
|
||||
if (signal?.aborted) {
|
||||
return browserDataFailure("ABORTED", "CACHE_ACTIVATE");
|
||||
}
|
||||
const cached = await cache.match(createNativeRequest(asset));
|
||||
if (!cached) {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"CACHE_ACTIVATE",
|
||||
{ recovery: "REHYDRATE" },
|
||||
);
|
||||
}
|
||||
const verified = await readAndValidateResponse(
|
||||
cached,
|
||||
asset,
|
||||
policy,
|
||||
dependencies.crypto,
|
||||
signal,
|
||||
const candidate = await verifyReleaseCandidate(
|
||||
cache,
|
||||
marker.assets,
|
||||
policy,
|
||||
dependencies.crypto,
|
||||
signal,
|
||||
);
|
||||
if (candidate.kind === "UNKNOWN") {
|
||||
return rebaseFailure(
|
||||
candidate.failure.error,
|
||||
"CACHE_ACTIVATE",
|
||||
);
|
||||
}
|
||||
if (candidate.kind === "REPAIRABLE") {
|
||||
return browserDataFailure(
|
||||
"INTEGRITY_FAILED",
|
||||
"CACHE_ACTIVATE",
|
||||
{ recovery: "REHYDRATE" },
|
||||
);
|
||||
if (!verified.ok) {
|
||||
return rebaseFailure(
|
||||
verified.error,
|
||||
"CACHE_ACTIVATE",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const previousPointer = await readActivePointer(
|
||||
@@ -692,7 +763,7 @@ export function createPublicResponseCacheAdapter(
|
||||
const signal = request.signal;
|
||||
const aborted = abortedResult(signal, "CACHE_DELETE");
|
||||
if (aborted) return aborted;
|
||||
const availability = mutationAvailability(
|
||||
const availability = localMutationAvailability(
|
||||
dependencies,
|
||||
"CACHE_DELETE",
|
||||
);
|
||||
@@ -771,7 +842,7 @@ export function createPublicResponseCacheAdapter(
|
||||
|
||||
async inspect() {
|
||||
if (!dependencies.cacheStorage) {
|
||||
return unsupported("CACHE_LOOKUP");
|
||||
return unsupported("CACHE_LOOKUP", "RETRY");
|
||||
}
|
||||
try {
|
||||
const names = await dependencies.cacheStorage.keys();
|
||||
@@ -1640,24 +1711,104 @@ function summaryFromMarker(
|
||||
});
|
||||
}
|
||||
|
||||
function mutationAvailability(
|
||||
type ReleaseCandidateVerdict =
|
||||
| Readonly<{ kind: "VERIFIED" }>
|
||||
/** An exact, owned entry is missing or no longer matches the manifest. */
|
||||
| Readonly<{ kind: "REPAIRABLE" }>
|
||||
/** Abort or an unreadable candidate: never success, never a silent delete. */
|
||||
| Readonly<{ kind: "UNKNOWN"; failure: BrowserFailureResult }>;
|
||||
|
||||
/**
|
||||
* STO-04. The single verification authority shared by the stage fast path and
|
||||
* activation, so "the marker says it is staged" can never stand in for "every
|
||||
* entry is present and matches".
|
||||
*/
|
||||
async function verifyReleaseCandidate(
|
||||
cache: Readonly<{ match(request: Request): Promise<Response | undefined> }>,
|
||||
assets: readonly NormalizedAsset[],
|
||||
policy: PublicCacheRuntimePolicy,
|
||||
crypto: Readonly<{
|
||||
digestSha256(bytes: Uint8Array): Promise<ArrayBuffer>;
|
||||
}>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ReleaseCandidateVerdict> {
|
||||
for (const asset of assets) {
|
||||
if (signal?.aborted) {
|
||||
return Object.freeze({
|
||||
kind: "UNKNOWN" as const,
|
||||
failure: asFailure(
|
||||
browserDataFailure("ABORTED", "CACHE_ACTIVATE"),
|
||||
),
|
||||
});
|
||||
}
|
||||
let cached: Response | undefined;
|
||||
try {
|
||||
cached = await cache.match(createNativeRequest(asset));
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "UNKNOWN" as const,
|
||||
failure: asFailure(
|
||||
browserDataFailure("UNAVAILABLE", "CACHE_ACTIVATE", {
|
||||
retryable: true,
|
||||
recovery: "RETRY",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (!cached) return Object.freeze({ kind: "REPAIRABLE" as const });
|
||||
const verified = await readAndValidateResponse(
|
||||
cached,
|
||||
asset,
|
||||
policy,
|
||||
crypto,
|
||||
signal,
|
||||
);
|
||||
if (!verified.ok) {
|
||||
return verified.error.code === "ABORTED"
|
||||
? Object.freeze({
|
||||
kind: "UNKNOWN" as const,
|
||||
failure: asFailure(verified),
|
||||
})
|
||||
: Object.freeze({ kind: "REPAIRABLE" as const });
|
||||
}
|
||||
}
|
||||
return Object.freeze({ kind: "VERIFIED" as const });
|
||||
}
|
||||
|
||||
/**
|
||||
* STO-05. Staging is the only operation that reaches the network, so it is the
|
||||
* only one that requires a fetcher.
|
||||
*/
|
||||
function stageAvailability(
|
||||
dependencies: PublicResponseCacheDependencySnapshot,
|
||||
operation: BrowserDataOperation,
|
||||
): BrowserFailureResult | null {
|
||||
return dependencies.cacheStorage &&
|
||||
dependencies.fetcher &&
|
||||
dependencies.mutationLock
|
||||
? null
|
||||
: unsupported(operation);
|
||||
: unsupported("CACHE_STAGE", "ONLINE_ONLY");
|
||||
}
|
||||
|
||||
/**
|
||||
* Activation, rollback and cleanup are local Cache Storage mutations. Requiring
|
||||
* a fetcher would block an offline rollback or a quota-recovery cleanup that
|
||||
* needs no network at all.
|
||||
*/
|
||||
function localMutationAvailability(
|
||||
dependencies: PublicResponseCacheDependencySnapshot,
|
||||
operation: "CACHE_ACTIVATE" | "CACHE_DELETE",
|
||||
): BrowserFailureResult | null {
|
||||
return dependencies.cacheStorage && dependencies.mutationLock
|
||||
? null
|
||||
: unsupported(operation, "RETRY");
|
||||
}
|
||||
|
||||
function unsupported(
|
||||
operation: BrowserDataOperation,
|
||||
recovery: "ONLINE_ONLY" | "RETRY",
|
||||
): BrowserFailureResult {
|
||||
return asFailure(
|
||||
browserDataFailure("UNSUPPORTED", operation, {
|
||||
recovery: "ONLINE_ONLY",
|
||||
}),
|
||||
browserDataFailure("UNSUPPORTED", operation, { recovery }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
isCacheInvalidationOpaqueIdentifier,
|
||||
type CacheInvalidationTopicDefinition,
|
||||
} from "../../contracts/cache-invalidation.ts";
|
||||
import { STORAGE_REGISTRY } from "../../contracts/storage-keys.ts";
|
||||
import {
|
||||
createBrowserCrossContextInvalidation,
|
||||
type BroadcastChannelFacade,
|
||||
@@ -31,8 +32,9 @@ type NativeBroadcastChannel = Readonly<{
|
||||
}>;
|
||||
|
||||
const CHANNEL_NAME = "ca-client-cache-invalidation-v1";
|
||||
// N-09. The registry owns the physical-key policy for the pulse.
|
||||
const STORAGE_PULSE_KEY =
|
||||
"ca-frontend:cache-invalidation:v1:pulse";
|
||||
STORAGE_REGISTRY.CACHE_INVALIDATION_PULSE.physicalKey;
|
||||
|
||||
/**
|
||||
* Captures native capabilities without allowing a SecurityError getter or a
|
||||
@@ -59,6 +61,8 @@ export function createBrowserCrossContextInvalidationFromHost(
|
||||
const sourceEpoch = createOpaqueId("page");
|
||||
if (!sourceId || !sourceEpoch) return undefined;
|
||||
|
||||
const capturedLocalStorage = captureLocalStorage(host);
|
||||
|
||||
return createBrowserCrossContextInvalidation({
|
||||
channelName: CHANNEL_NAME,
|
||||
storagePulseKey: STORAGE_PULSE_KEY,
|
||||
@@ -72,8 +76,13 @@ export function createBrowserCrossContextInvalidationFromHost(
|
||||
return eventId;
|
||||
},
|
||||
createBroadcastChannel: broadcastFactory(host),
|
||||
storage: storageFacade(host),
|
||||
storageEvents: storageEventTarget(host),
|
||||
// N-09. One capture, one identity: the write facade and the event
|
||||
// validator must agree about which Storage object they trust. Reading the
|
||||
// getter twice would let a hostile host return a different object.
|
||||
storage: capturedLocalStorage
|
||||
? storageFacade(capturedLocalStorage)
|
||||
: undefined,
|
||||
storageEvents: storageEventTarget(host, capturedLocalStorage),
|
||||
observe: dependencies.observe,
|
||||
});
|
||||
}
|
||||
@@ -182,11 +191,16 @@ function isNativeBroadcastChannel(
|
||||
);
|
||||
}
|
||||
|
||||
function storageFacade(
|
||||
function captureLocalStorage(
|
||||
host: Record<string, unknown>,
|
||||
): StoragePulseFacade | undefined {
|
||||
): object | undefined {
|
||||
const candidate = safeGet(host, "localStorage");
|
||||
if (!candidate || typeof candidate !== "object") return undefined;
|
||||
return candidate && typeof candidate === "object" ? candidate : undefined;
|
||||
}
|
||||
|
||||
function storageFacade(
|
||||
candidate: object,
|
||||
): StoragePulseFacade | undefined {
|
||||
const record = candidate as Record<string, unknown>;
|
||||
const setItem = safeGet(record, "setItem");
|
||||
const removeItem = safeGet(record, "removeItem");
|
||||
@@ -205,6 +219,7 @@ function storageFacade(
|
||||
|
||||
function storageEventTarget(
|
||||
host: Record<string, unknown>,
|
||||
expectedLocalStorage: object | undefined,
|
||||
): StorageEventTargetFacade | undefined {
|
||||
const addEventListener = safeGet(host, "addEventListener");
|
||||
const removeEventListener = safeGet(host, "removeEventListener");
|
||||
@@ -222,15 +237,27 @@ function storageEventTarget(
|
||||
addEventListener(_type: "storage", listener: StoragePulseListener) {
|
||||
const bound = (event: unknown) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
listener({ key: null, newValue: null });
|
||||
listener({
|
||||
key: null,
|
||||
newValue: null,
|
||||
storageArea: "OTHER_OR_UNKNOWN",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = event as Record<string, unknown>;
|
||||
const key = safeGet(record, "key");
|
||||
const newValue = safeGet(record, "newValue");
|
||||
const storageArea = safeGet(record, "storageArea");
|
||||
listener({
|
||||
key: typeof key === "string" ? key : null,
|
||||
newValue: typeof newValue === "string" ? newValue : null,
|
||||
// Compared by object identity against the captured area, never by
|
||||
// shape or by re-reading `host.localStorage`.
|
||||
storageArea:
|
||||
expectedLocalStorage !== undefined &&
|
||||
storageArea === expectedLocalStorage
|
||||
? "EXPECTED_LOCAL_STORAGE"
|
||||
: "OTHER_OR_UNKNOWN",
|
||||
});
|
||||
};
|
||||
bindings.set(listener, bound);
|
||||
|
||||
@@ -98,6 +98,13 @@ export type StoragePulseFacade = Readonly<{
|
||||
export type StoragePulseEvent = Readonly<{
|
||||
key: string | null;
|
||||
newValue: string | null;
|
||||
/**
|
||||
* N-09. A `storage` event fires for every `Storage` area in the context.
|
||||
* Matching only key and value cannot prove the write came from the
|
||||
* localStorage this runtime actually captured, so the host classifies the
|
||||
* native `storageArea` by object identity and the core admits one value.
|
||||
*/
|
||||
storageArea: "EXPECTED_LOCAL_STORAGE" | "OTHER_OR_UNKNOWN";
|
||||
}>;
|
||||
|
||||
export type StoragePulseListener = (event: StoragePulseEvent) => void;
|
||||
@@ -179,6 +186,7 @@ export function createBrowserCrossContextInvalidation(
|
||||
const receiveStorage: StoragePulseListener = (event) => {
|
||||
if (
|
||||
closed ||
|
||||
event.storageArea !== "EXPECTED_LOCAL_STORAGE" ||
|
||||
event.key !== dependencies.storagePulseKey ||
|
||||
typeof event.newValue !== "string"
|
||||
) {
|
||||
|
||||
@@ -6,11 +6,15 @@ import {
|
||||
type DiagnosticRecordInput,
|
||||
} from "../../contracts/diagnostics.ts";
|
||||
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
|
||||
import { assertBoundedCapacity } from "../telemetry/best-effort-telemetry.ts";
|
||||
|
||||
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
|
||||
record() {},
|
||||
});
|
||||
|
||||
/** N-11. Documented absolute ceiling for bounded in-memory evidence. */
|
||||
export const MAX_DIAGNOSTIC_ENTRIES = 10_000;
|
||||
|
||||
export function createDiagnosticsAdapter(
|
||||
options: Readonly<{
|
||||
maxEntries?: number;
|
||||
@@ -18,7 +22,11 @@ export function createDiagnosticsAdapter(
|
||||
sink?: (record: DiagnosticRecord) => void;
|
||||
}> = {},
|
||||
) {
|
||||
const maxEntries = Math.max(1, options.maxEntries ?? 100);
|
||||
const maxEntries = assertBoundedCapacity(
|
||||
options.maxEntries ?? 100,
|
||||
MAX_DIAGNOSTIC_ENTRIES,
|
||||
"diagnostics maxEntries",
|
||||
);
|
||||
const entries: DiagnosticRecord[] = [];
|
||||
const droppedReasons = new Map<string, number>();
|
||||
|
||||
|
||||
@@ -28,9 +28,36 @@ export function declaredContentLength(response: Response): number | null {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE-04. A `read()` that never settles is a physical wait, so the reader
|
||||
* accepts the operation's lifetime signal. A cooperative stream stops here; a
|
||||
* non-cooperative one is abandoned with its reader cancelled, and the caller's
|
||||
* own race against the same signal still bounds the public result.
|
||||
*/
|
||||
const READ_ABANDONED = Symbol("bounded-read-abandoned");
|
||||
|
||||
async function readOrAbandon(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ReadableStreamReadResult<Uint8Array> | typeof READ_ABANDONED> {
|
||||
if (!signal) return reader.read();
|
||||
if (signal.aborted) return READ_ABANDONED;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const abandoned = new Promise<typeof READ_ABANDONED>((resolve) => {
|
||||
onAbort = () => resolve(READ_ABANDONED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([reader.read(), abandoned]);
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBoundedBytes(
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BoundedBytesOutcome> {
|
||||
const declared = declaredContentLength(response);
|
||||
if (declared !== null && declared > maximumBytes) {
|
||||
@@ -46,7 +73,13 @@ export async function readBoundedBytes(
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
const next = await readOrAbandon(reader, signal);
|
||||
if (next === READ_ABANDONED) {
|
||||
// Never awaited: cancelling a stream whose source ignores its signal
|
||||
// can itself hang, and the caller already owns the terminal result.
|
||||
void reader.cancel().catch(() => {});
|
||||
return failure("RESPONSE_STREAM_FAILURE");
|
||||
}
|
||||
if (next.done) break;
|
||||
if (!next.value) continue;
|
||||
total += next.value.byteLength;
|
||||
@@ -83,6 +116,7 @@ export async function readBoundedBytes(
|
||||
*/
|
||||
export async function probeForbiddenBody(
|
||||
response: Response,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BodyProbeOutcome> {
|
||||
const declared = declaredContentLength(response);
|
||||
if (declared !== null && declared > 0) {
|
||||
@@ -95,7 +129,20 @@ export async function probeForbiddenBody(
|
||||
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
const next = await reader.read();
|
||||
// NS-03. The probe owns the reader it opened, so the operation's lifetime
|
||||
// has to reach it. Awaiting a bare `read()` left a non-cooperative stream
|
||||
// locked after the deadline had already closed the public result, and the
|
||||
// outer compensator could not cancel a body this reader still held.
|
||||
const next = await readOrAbandon(reader, signal);
|
||||
if (next === READ_ABANDONED) {
|
||||
// Never awaited: cancelling a stream whose source ignores its signal can
|
||||
// itself hang, and the caller already owns the terminal result.
|
||||
void reader.cancel().catch(() => {});
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "RESPONSE_STREAM_FAILURE" as const,
|
||||
});
|
||||
}
|
||||
if (next.done || !next.value || next.value.byteLength === 0) {
|
||||
return Object.freeze({ ok: true as const, present: false });
|
||||
}
|
||||
|
||||
@@ -1,50 +1,39 @@
|
||||
import { decodeJsonBytes, readBoundedBytes } from "./bounded-body-reader.ts";
|
||||
|
||||
export type BoundedJsonResult =
|
||||
| Readonly<{ ok: true; value: unknown }>
|
||||
| Readonly<{ ok: false; code: "RESPONSE_BODY_LIMIT" | "MALFORMED_JSON" }>;
|
||||
|
||||
/**
|
||||
* N-08. The V2 compatibility reader delegates to the common bounded reader
|
||||
* instead of maintaining a second stream-reading strategy.
|
||||
*
|
||||
* `bounded-body-reader` already isolates `cancel()` and `releaseLock()` throws
|
||||
* so a cleanup failure cannot escape the closed result. Only the legacy failure
|
||||
* codes are preserved here:
|
||||
*
|
||||
* - `RESPONSE_TOO_LARGE` → `RESPONSE_BODY_LIMIT`
|
||||
* - `RESPONSE_STREAM_FAILURE` / `UTF8_INVALID` / `JSON_INVALID` →
|
||||
* `MALFORMED_JSON`
|
||||
*/
|
||||
export async function readBoundedJson(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<BoundedJsonResult> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
await response.body?.cancel();
|
||||
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
|
||||
}
|
||||
if (!response.body) return { ok: false, code: "MALFORMED_JSON" };
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
total += next.value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, code: "MALFORMED_JSON" };
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, code: "MALFORMED_JSON" };
|
||||
const bytes = await readBoundedBytes(response, maxBytes);
|
||||
if (!bytes.ok) {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code:
|
||||
bytes.code === "RESPONSE_TOO_LARGE"
|
||||
? ("RESPONSE_BODY_LIMIT" as const)
|
||||
: ("MALFORMED_JSON" as const),
|
||||
});
|
||||
}
|
||||
// An absent body decodes to zero bytes, which is not valid JSON. The legacy
|
||||
// contract reported that as MALFORMED_JSON, and that is preserved.
|
||||
const decoded = decodeJsonBytes(bytes.bytes);
|
||||
return decoded.ok
|
||||
? Object.freeze({ ok: true as const, value: decoded.value })
|
||||
: Object.freeze({ ok: false as const, code: "MALFORMED_JSON" as const });
|
||||
}
|
||||
|
||||
+200
-37
@@ -18,7 +18,24 @@ import {
|
||||
durationBucket,
|
||||
statusGroup,
|
||||
} from "../../contracts/diagnostics.ts";
|
||||
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
|
||||
import type {
|
||||
AuthSessionPort,
|
||||
CredentialOperationContext,
|
||||
} from "../../application/ports/auth-session-port.ts";
|
||||
import { isValidIdempotencyKey } from "../../contracts/mutation-intent.ts";
|
||||
|
||||
/** Sentinel for a credential wait ended by the attempt lifetime. */
|
||||
const ATTEMPT_ABORTED = Symbol("ATTEMPT_ABORTED");
|
||||
|
||||
/**
|
||||
* LEG-02. The pre-V2 operations have no installed auth profile, so this is the
|
||||
* legacy allowance the shared validator applies to them. It requires nothing,
|
||||
* which preserves their existing behaviour exactly.
|
||||
*/
|
||||
const LEGACY_ALLOWED_CREDENTIAL_HEADERS = Object.freeze([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
] as const);
|
||||
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
|
||||
@@ -26,6 +43,7 @@ import type { ApiOperation } from "../../contracts/api-operations.ts";
|
||||
import type { ApiFailure } from "../../contracts/errors.ts";
|
||||
import type { OperationRequestInput } from "./request-builder.ts";
|
||||
import { readBoundedJson } from "./bounded-json.ts";
|
||||
import { admitCredentialHeaders } from "./http-contract-bridge.ts";
|
||||
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
|
||||
import {
|
||||
createRestProviderProfile,
|
||||
@@ -238,22 +256,52 @@ export function createHttpClient(
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
// N-06. A caller-supplied key is validated before credentials, timers or
|
||||
// fetch. An invalid value is rejected outright rather than trimmed or
|
||||
// replaced, so a keyed command can never replay with no key at all.
|
||||
let logicalIdempotencyKey: string | undefined;
|
||||
try {
|
||||
logicalIdempotencyKey =
|
||||
operation.idempotency === "keyed"
|
||||
? input.idempotencyKey ?? idempotencyKeyFactory()
|
||||
: undefined;
|
||||
} catch {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
|
||||
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
|
||||
}),
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
if (operation.idempotency === "keyed") {
|
||||
if (input.idempotencyKey !== undefined) {
|
||||
if (!isValidIdempotencyKey(input.idempotencyKey)) {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", input.operationId, 0, {
|
||||
code: "IDEMPOTENCY_KEY_INVALID",
|
||||
}),
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
logicalIdempotencyKey = input.idempotencyKey;
|
||||
} else {
|
||||
let generated: string;
|
||||
try {
|
||||
generated = idempotencyKeyFactory();
|
||||
} catch {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
|
||||
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
|
||||
}),
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
if (!isValidIdempotencyKey(generated)) {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
|
||||
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
|
||||
}),
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
logicalIdempotencyKey = generated;
|
||||
}
|
||||
}
|
||||
let retryCount = 0;
|
||||
let recoveryUsed = false;
|
||||
@@ -294,14 +342,29 @@ export function createHttpClient(
|
||||
authSession.onUnauthenticated();
|
||||
return finalize(outcome, "failed");
|
||||
}
|
||||
let recovered: Awaited<ReturnType<typeof recoverSession>>;
|
||||
let recovered: SessionRecoveryOutcome;
|
||||
// LEG-01. The recovery collaborator receives the request lifetime, and
|
||||
// the transport races the same signal so a non-cooperative owner cannot
|
||||
// hold the request open.
|
||||
const recoveryLifetime = new AbortController();
|
||||
try {
|
||||
recovered = await withinLogicalDeadline(
|
||||
recoverSession(authSession, operation, outcome.error),
|
||||
recoverSession(
|
||||
authSession,
|
||||
operation,
|
||||
outcome.error,
|
||||
Object.freeze({
|
||||
signal: recoveryLifetime.signal,
|
||||
deadlineAtMonotonicMs: deadlineAt,
|
||||
}),
|
||||
),
|
||||
deadlineAt,
|
||||
input.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
// The request is over. Whatever the recovery answers next is observed
|
||||
// by its own owner, never adopted here.
|
||||
recoveryLifetime.abort();
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
@@ -322,7 +385,14 @@ export function createHttpClient(
|
||||
error instanceof LogicalDeadlineError ? "failed" : "aborted",
|
||||
);
|
||||
}
|
||||
if (!recovered.ok) return finalize(recovered, "failed");
|
||||
if (!recovered.ok) {
|
||||
// The result is adopted here, so the notification happens here.
|
||||
if (recovered.notifyUnauthenticated) authSession.onUnauthenticated();
|
||||
return finalize(
|
||||
{ ok: false, error: recovered.error },
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
if (operation.idempotency === "none") {
|
||||
return finalize(
|
||||
{
|
||||
@@ -568,22 +638,72 @@ export function createHttpClient(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const patch = await authSession.credentialPatch({
|
||||
origin: target.url.origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
});
|
||||
for (const [name, value] of Object.entries(patch.headers)) {
|
||||
const normalized = name.toLowerCase();
|
||||
const allowedHeaders =
|
||||
// N-07. The credential owner is bounded by the attempt lifetime that
|
||||
// already carries the total deadline and the caller signal, so a
|
||||
// non-cooperative owner cannot hold the request open and no extra
|
||||
// timer is introduced. A late completion is observed and discarded.
|
||||
const raced = await raceAttemptSignal(
|
||||
Promise.resolve(
|
||||
authSession.credentialPatch(
|
||||
{
|
||||
origin: target.url.origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
},
|
||||
Object.freeze({
|
||||
signal: controller.signal,
|
||||
deadlineAtMonotonicMs: deadlineAt,
|
||||
}),
|
||||
),
|
||||
),
|
||||
controller.signal,
|
||||
);
|
||||
if (raced === ATTEMPT_ABORTED) {
|
||||
return {
|
||||
ok: false,
|
||||
error: timedOut
|
||||
? failure(
|
||||
"REQUEST_TIMEOUT",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{ code: "OPERATION_DEADLINE_EXCEEDED" },
|
||||
)
|
||||
: failure(
|
||||
"REQUEST_ABORTED",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{ code: "REQUEST_ABORTED" },
|
||||
),
|
||||
};
|
||||
}
|
||||
const patch = raced;
|
||||
// LEG-02. The same admission validator V3 uses. Checking only the
|
||||
// allowed set let a bearer profile dispatch with no Authorization at
|
||||
// all, which is precisely the anonymous downgrade the required set
|
||||
// exists to prevent.
|
||||
const admission = admitCredentialHeaders(patch.headers, {
|
||||
allowedCredentialHeaders:
|
||||
security?.auth.allowedCredentialHeaders ??
|
||||
(["authorization", "x-csrf-token"] as const);
|
||||
if (!allowedHeaders.includes(normalized as never)) {
|
||||
throw new TypeError("Credential patch contains a forbidden header");
|
||||
}
|
||||
headers.set(normalized, value);
|
||||
LEGACY_ALLOWED_CREDENTIAL_HEADERS,
|
||||
requiredCredentialHeaders:
|
||||
security?.auth.requiredCredentialHeaders ?? [],
|
||||
});
|
||||
if (!admission.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{ code: "AUTH_ATTACH_FAILED" },
|
||||
),
|
||||
};
|
||||
}
|
||||
for (const [name, value] of Object.entries(admission.headers)) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
} catch {
|
||||
// An ordinary owner rejection stays an integration failure.
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
|
||||
@@ -663,6 +783,28 @@ export function createHttpClient(
|
||||
|
||||
return Object.freeze({ execute });
|
||||
|
||||
function raceAttemptSignal<Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
): Promise<Value | typeof ATTEMPT_ABORTED> {
|
||||
operation.catch(() => {});
|
||||
if (signal.aborted) return Promise.resolve(ATTEMPT_ABORTED);
|
||||
return new Promise<Value | typeof ATTEMPT_ABORTED>((resolve, reject) => {
|
||||
const onAbort = () => resolve(ATTEMPT_ABORTED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error instanceof Error ? error : new Error("rejected"));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function withinLogicalDeadline<Value>(
|
||||
promise: Promise<Value>,
|
||||
deadlineAt: number,
|
||||
@@ -714,6 +856,10 @@ async function parseResponse(
|
||||
const contentType = mediaType(response.headers.get("content-type"));
|
||||
const acceptedMedia = operation.responseMediaTypes ?? ["application/json"];
|
||||
if (!contentType || !acceptedMedia.includes(contentType)) {
|
||||
// N-08. A rejected response still owns an open body stream. Cancellation is
|
||||
// best-effort cleanup, so it is started but not awaited: the closed result
|
||||
// must not depend on stream teardown settling.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
|
||||
@@ -844,20 +990,36 @@ async function parseResponse(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LEG-01. Recovery returns data only.
|
||||
*
|
||||
* The sign-out notification is a user-visible side effect, so it belongs to
|
||||
* whoever adopts this result — not to the raw recovery call. A recovery that
|
||||
* loses the race against the deadline or a caller abort still settles, and
|
||||
* signing the user out then would attribute a request nobody is waiting on to
|
||||
* an expired session.
|
||||
*/
|
||||
type SessionRecoveryOutcome =
|
||||
| Readonly<{ ok: true }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
error: HttpFailure;
|
||||
notifyUnauthenticated: boolean;
|
||||
}>;
|
||||
|
||||
async function recoverSession(
|
||||
authSession: HttpAuthSession,
|
||||
operation: ApiOperation,
|
||||
originalFailure: HttpFailure,
|
||||
): Promise<
|
||||
Readonly<{ ok: true }> | Readonly<{ ok: false; error: HttpFailure }>
|
||||
> {
|
||||
context: CredentialOperationContext,
|
||||
): Promise<SessionRecoveryOutcome> {
|
||||
try {
|
||||
const result = await authSession.recover();
|
||||
const result = await authSession.recover(context);
|
||||
if (result === "restored") return { ok: true };
|
||||
if (result === "no-session") {
|
||||
authSession.onUnauthenticated();
|
||||
return {
|
||||
ok: false,
|
||||
notifyUnauthenticated: true,
|
||||
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount - 1, {
|
||||
code: "AUTH_REQUIRED",
|
||||
httpStatus: 401,
|
||||
@@ -870,6 +1032,7 @@ async function recoverSession(
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
notifyUnauthenticated: false,
|
||||
error: failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operation.operationId,
|
||||
|
||||
@@ -2,6 +2,11 @@ import {
|
||||
HTTP_EXECUTION_CEILINGS,
|
||||
type InstalledHttpContract,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
import {
|
||||
CREDENTIAL_HEADER_NAMES,
|
||||
type CredentialHeaderName,
|
||||
type RestAuthProfile,
|
||||
} from "../../contracts/rest-profiles.ts";
|
||||
|
||||
/**
|
||||
* §7.4–§7.7. Descriptor-driven request projection.
|
||||
@@ -11,21 +16,99 @@ import {
|
||||
* bounds and re-verifies them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* §7.7 / VD-23. A credential owner contributes proof headers only. Fetch
|
||||
* `credentials` belongs to the installed auth profile, so it is deliberately
|
||||
* absent from this outcome.
|
||||
*/
|
||||
export type CredentialPatchOutcome =
|
||||
| Readonly<{
|
||||
kind: "READY";
|
||||
headers: Readonly<Record<string, string>>;
|
||||
credentials: RequestCredentials;
|
||||
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
|
||||
}>
|
||||
| Readonly<{ kind: "UNAUTHENTICATED" }>
|
||||
| Readonly<{ kind: "UNAVAILABLE" }>
|
||||
| Readonly<{ kind: "SCOPE_FENCED" }>;
|
||||
|
||||
/** §7.7. The complete set of headers a credential bridge may contribute. */
|
||||
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
"x-tenant-context",
|
||||
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set(
|
||||
CREDENTIAL_HEADER_NAMES,
|
||||
);
|
||||
|
||||
export type CredentialAdmissionFailure =
|
||||
| "TRANSPORT_OWNED_HEADER"
|
||||
| "CREDENTIAL_HEADER_NOT_ALLOWED"
|
||||
| "CREDENTIAL_HEADER_VALUE_INVALID"
|
||||
| "MISSING_REQUIRED_CREDENTIAL_HEADER";
|
||||
|
||||
export type CredentialAdmissionOutcome =
|
||||
| Readonly<{
|
||||
ok: true;
|
||||
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
|
||||
}>
|
||||
| Readonly<{ ok: false; failure: CredentialAdmissionFailure }>;
|
||||
|
||||
const MAX_CREDENTIAL_HEADER_VALUE_BYTES = 8_192;
|
||||
|
||||
/**
|
||||
* §7.7. Admits a credential patch against the resolved profile before any
|
||||
* header object is built. A rejection here guarantees `fetch()` is not called:
|
||||
* a credential owner cannot widen the profile, replace a transport-owned
|
||||
* header, or turn an authenticated profile into an anonymous request.
|
||||
*/
|
||||
export function admitCredentialHeaders(
|
||||
patchHeaders: Readonly<Record<string, unknown>>,
|
||||
profile: Readonly<{
|
||||
allowedCredentialHeaders: readonly CredentialHeaderName[];
|
||||
requiredCredentialHeaders: readonly CredentialHeaderName[];
|
||||
}>,
|
||||
): CredentialAdmissionOutcome {
|
||||
const admitted: Partial<Record<CredentialHeaderName, string>> = {};
|
||||
const seen = new Set<CredentialHeaderName>();
|
||||
for (const [name, value] of Object.entries(patchHeaders)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (TRANSPORT_OWNED_HEADERS.has(lower) || FORBIDDEN_REQUEST_HEADERS.has(lower)) {
|
||||
return frozenAdmissionFailure("TRANSPORT_OWNED_HEADER");
|
||||
}
|
||||
if (
|
||||
!ALLOWED_CREDENTIAL_HEADERS.has(lower) ||
|
||||
!profile.allowedCredentialHeaders.includes(lower as CredentialHeaderName)
|
||||
) {
|
||||
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
|
||||
}
|
||||
const credentialName = lower as CredentialHeaderName;
|
||||
if (seen.has(credentialName)) {
|
||||
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
|
||||
}
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
/[\r\n]/.test(value) ||
|
||||
encoder.encode(value).byteLength > MAX_CREDENTIAL_HEADER_VALUE_BYTES
|
||||
) {
|
||||
return frozenAdmissionFailure("CREDENTIAL_HEADER_VALUE_INVALID");
|
||||
}
|
||||
seen.add(credentialName);
|
||||
admitted[credentialName] = value;
|
||||
}
|
||||
for (const required of profile.requiredCredentialHeaders) {
|
||||
if (!seen.has(required)) {
|
||||
return frozenAdmissionFailure("MISSING_REQUIRED_CREDENTIAL_HEADER");
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ok: true as const, headers: Object.freeze(admitted) });
|
||||
}
|
||||
|
||||
function frozenAdmissionFailure(
|
||||
failureKind: CredentialAdmissionFailure,
|
||||
): CredentialAdmissionOutcome {
|
||||
return Object.freeze({ ok: false as const, failure: failureKind });
|
||||
}
|
||||
|
||||
const TRANSPORT_OWNED_HEADERS: ReadonlySet<string> = new Set([
|
||||
"accept",
|
||||
"content-type",
|
||||
"idempotency-key",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
|
||||
@@ -217,6 +300,8 @@ export type FinalInvariantInput = Readonly<{
|
||||
requestByteLimit: number;
|
||||
deadlineRemainingMs: number;
|
||||
scopeIsCurrent: boolean;
|
||||
/** The resolved installed profile this dispatch must match exactly. */
|
||||
authProfile: RestAuthProfile;
|
||||
}>;
|
||||
|
||||
export type FinalInvariantFailure =
|
||||
@@ -224,7 +309,10 @@ export type FinalInvariantFailure =
|
||||
| "URL_NOT_ALLOWED"
|
||||
| "REDIRECT_MODE_INVALID"
|
||||
| "CREDENTIALS_MODE_INVALID"
|
||||
| "CREDENTIALS_MODE_MISMATCH"
|
||||
| "HEADER_NOT_ALLOWED"
|
||||
| "CREDENTIAL_HEADER_NOT_ALLOWED"
|
||||
| "MISSING_REQUIRED_CREDENTIAL_HEADER"
|
||||
| "FORBIDDEN_HEADER"
|
||||
| "REQUEST_BODY_TOO_LARGE"
|
||||
| "DEADLINE_EXPIRED"
|
||||
@@ -258,17 +346,30 @@ export function checkFinalInvariants(
|
||||
) {
|
||||
return "CREDENTIALS_MODE_INVALID";
|
||||
}
|
||||
// The profile is the transport authority: a credential collaborator cannot
|
||||
// move the request onto a different Fetch credentials mode.
|
||||
if (input.init.credentials !== input.authProfile.credentials) {
|
||||
return "CREDENTIALS_MODE_MISMATCH";
|
||||
}
|
||||
|
||||
const presentCredentialHeaders = new Set<string>();
|
||||
for (const name of Object.keys(input.headers)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
|
||||
if (TRANSPORT_OWNED_HEADERS.has(lower)) continue;
|
||||
if (!ALLOWED_CREDENTIAL_HEADERS.has(lower)) return "HEADER_NOT_ALLOWED";
|
||||
if (
|
||||
lower !== "accept" &&
|
||||
lower !== "content-type" &&
|
||||
lower !== "idempotency-key" &&
|
||||
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
|
||||
!input.authProfile.allowedCredentialHeaders.includes(
|
||||
lower as CredentialHeaderName,
|
||||
)
|
||||
) {
|
||||
return "HEADER_NOT_ALLOWED";
|
||||
return "CREDENTIAL_HEADER_NOT_ALLOWED";
|
||||
}
|
||||
presentCredentialHeaders.add(lower);
|
||||
}
|
||||
for (const required of input.authProfile.requiredCredentialHeaders) {
|
||||
if (!presentCredentialHeaders.has(required)) {
|
||||
return "MISSING_REQUIRED_CREDENTIAL_HEADER";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,30 @@ export function certaintyForAbandonedAttempt(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.7. The conservative certainty lattice for one logical execution.
|
||||
*
|
||||
* `PhysicalAttemptState` describes only the attempt in flight. A new retry that
|
||||
* has not been sent yet must never lower what an earlier attempt already
|
||||
* established, so the executor joins observations into a monotonic accumulator.
|
||||
*/
|
||||
const CERTAINTY_RANK: Readonly<Record<MutationEffectCertainty, number>> =
|
||||
Object.freeze({
|
||||
NOT_STARTED: 0,
|
||||
NOT_APPLIED: 1,
|
||||
MAYBE_APPLIED: 2,
|
||||
APPLIED_CONFIRMED: 3,
|
||||
});
|
||||
|
||||
export function joinMutationEffectCertainty(
|
||||
current: MutationEffectCertainty,
|
||||
observed: MutationEffectCertainty,
|
||||
): MutationEffectCertainty {
|
||||
return CERTAINTY_RANK[observed] > CERTAINTY_RANK[current]
|
||||
? observed
|
||||
: current;
|
||||
}
|
||||
|
||||
export type ProblemEffectInput<Problem> = Readonly<{
|
||||
status: number;
|
||||
problem: Problem;
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
|
||||
import {
|
||||
defineMutationIntent,
|
||||
MUTATION_INTENT_BOUNDS,
|
||||
// OPT-NET-02. One shared key authority, so the intent factory and this
|
||||
// admission site cannot drift apart.
|
||||
isValidIdempotencyKey,
|
||||
type MutationIntent,
|
||||
} from "../../contracts/mutation-intent.ts";
|
||||
import {
|
||||
@@ -17,13 +19,25 @@ import {
|
||||
readBoundedBytes,
|
||||
} from "./bounded-body-reader.ts";
|
||||
import {
|
||||
admitCredentialHeaders,
|
||||
checkFinalInvariants,
|
||||
projectRequest,
|
||||
type CredentialAdmissionFailure,
|
||||
type CredentialPatchOutcome,
|
||||
} from "./http-contract-bridge.ts";
|
||||
import {
|
||||
INSTALLED_REST_AUTH_PROFILES,
|
||||
type InstalledRestAuthProfiles,
|
||||
} from "../../contracts/rest-profiles.ts";
|
||||
import {
|
||||
snapshotExactObject,
|
||||
snapshotOwnDataRecord,
|
||||
} from "../../contracts/exact-snapshot.ts";
|
||||
import {
|
||||
certaintyForAbandonedAttempt,
|
||||
classifyProblemEffect,
|
||||
joinMutationEffectCertainty,
|
||||
type MutationEffectCertainty,
|
||||
type PhysicalAttemptState,
|
||||
} from "./http-effect-certainty.ts";
|
||||
import { parseRetryAfter } from "./retry-policy.ts";
|
||||
@@ -124,8 +138,40 @@ export type HttpExecutionOutcome<Value, Problem> =
|
||||
| Readonly<{
|
||||
kind: "CANCELLED";
|
||||
effect: "NOT_STARTED" | "MAYBE_APPLIED";
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "AUTH_INTEGRATION_FAILURE";
|
||||
reason: AuthIntegrationFailureReason;
|
||||
effect: "NOT_APPLICABLE" | "NOT_STARTED";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §7.7 / VD-23. A configuration or collaborator contract breach, never a user
|
||||
* session state. `UNAUTHENTICATED` stays reserved for the latter.
|
||||
*/
|
||||
export type AuthIntegrationFailureReason =
|
||||
| "UNKNOWN_AUTH_PROFILE"
|
||||
/**
|
||||
* LIVE-01. The collaborator answered that the auth system itself cannot serve
|
||||
* this request. That is an outage of the integration, not a statement about
|
||||
* the user's session, so it must never reach the composition root's logout
|
||||
* path.
|
||||
*/
|
||||
| "CREDENTIAL_OWNER_UNAVAILABLE"
|
||||
/** LIVE-01. The collaborator threw, rejected, or answered off-contract. */
|
||||
| "CREDENTIAL_OWNER_FAILED"
|
||||
| CredentialAdmissionFailure;
|
||||
|
||||
/**
|
||||
* §8.5. Credential collaborators receive the operation lifetime so a
|
||||
* cooperative owner can abandon its own work; a non-cooperative one is still
|
||||
* bounded by the executor's race against the same signal.
|
||||
*/
|
||||
export type AuthOperationContext = Readonly<{
|
||||
signal: AbortSignal;
|
||||
deadlineAtMonotonicMs: number;
|
||||
}>;
|
||||
|
||||
export type CancellationOwner =
|
||||
| "CALLER"
|
||||
| "ROUTE_TRANSITION"
|
||||
@@ -134,6 +180,12 @@ export type CancellationOwner =
|
||||
| "DEADLINE";
|
||||
|
||||
export interface HttpExecutionContext {
|
||||
/**
|
||||
* §7.4. The low-cardinality route identity that owns this logical execution.
|
||||
* It is required at the installed operation-executor boundary so a terminal
|
||||
* outcome can always be attributed without reconstructing it from a URL.
|
||||
*/
|
||||
readonly routeId: string;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly scope: CacheScopeSnapshot;
|
||||
readonly intent?: MutationIntent;
|
||||
@@ -147,23 +199,85 @@ export interface ContractHttpExecutor {
|
||||
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* §7.4 / VD-07. One typed internal record per logical execution. It is not an
|
||||
* arbitrary context map: the composition root owns the projection into the
|
||||
* closed diagnostics and telemetry buckets, and raw attempt count, duration and
|
||||
* status never leave that projection.
|
||||
*/
|
||||
export type HttpExecutionObservation = Readonly<{
|
||||
routeId: string;
|
||||
operationId: string;
|
||||
diagnosticsOperation: string;
|
||||
outcome: string;
|
||||
attempts: number;
|
||||
certainty: string;
|
||||
outcome: HttpExecutionOutcome<unknown, unknown>["kind"];
|
||||
errorKind: string;
|
||||
status?: number;
|
||||
attemptCount: number;
|
||||
durationMs: number;
|
||||
effect: HttpEffectCertainty;
|
||||
cancellationOwner?: CancellationOwner;
|
||||
/**
|
||||
* The internal terminal-reason label recorded by the execution site. It is
|
||||
* evidence for the HTTP scenario catalog only; the composition-root
|
||||
* projection never forwards it to diagnostics or telemetry.
|
||||
*/
|
||||
terminalReason: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The outcome is the single authority for the observed error kind. The terminal
|
||||
* reason only distinguishes an internal runtime failure from a transport
|
||||
* failure, because both surface as the same public outcome.
|
||||
*/
|
||||
function observationErrorKind(
|
||||
outcome: HttpExecutionOutcome<unknown, unknown>,
|
||||
terminalReason: string,
|
||||
): string {
|
||||
switch (outcome.kind) {
|
||||
case "SUCCESS":
|
||||
return "NONE";
|
||||
case "PROBLEM":
|
||||
return "PROBLEM";
|
||||
case "UNAUTHENTICATED":
|
||||
return "UNAUTHENTICATED";
|
||||
case "FORBIDDEN":
|
||||
return "FORBIDDEN";
|
||||
case "RATE_LIMITED":
|
||||
return "RATE_LIMITED";
|
||||
case "CONTRACT_VIOLATION":
|
||||
return outcome.violation.kind;
|
||||
case "TRANSPORT_FAILURE":
|
||||
return terminalReason === "RUNTIME_FAILURE"
|
||||
? "RUNTIME_FAILURE"
|
||||
: outcome.failure.kind;
|
||||
case "CANCELLED":
|
||||
return "REQUEST_ABORTED";
|
||||
case "AUTH_INTEGRATION_FAILURE":
|
||||
return outcome.reason;
|
||||
}
|
||||
}
|
||||
|
||||
function observationStatus(
|
||||
outcome: HttpExecutionOutcome<unknown, unknown>,
|
||||
): number | undefined {
|
||||
return outcome.kind === "SUCCESS" || outcome.kind === "PROBLEM"
|
||||
? outcome.metadata.status
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export type ContractHttpExecutorDependencies = Readonly<{
|
||||
baseUrl: string;
|
||||
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
|
||||
maxRetryAttempts: number;
|
||||
/** The installed profile registry; the executor never invents a profile. */
|
||||
authProfiles?: InstalledRestAuthProfiles;
|
||||
attachCredentials(
|
||||
operation: Readonly<{
|
||||
operationId: string;
|
||||
authProfileId: string;
|
||||
method: string;
|
||||
}>,
|
||||
context: AuthOperationContext,
|
||||
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
|
||||
fetcher?: typeof fetch;
|
||||
/** Adapter seam for the common bounded response reader. */
|
||||
@@ -234,7 +348,7 @@ function validateMutationIntent(
|
||||
}
|
||||
|
||||
const key = validated.idempotencyKey;
|
||||
if (requiresKey && !validIdempotencyKey(key)) {
|
||||
if (requiresKey && !isValidIdempotencyKey(key)) {
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
violation: "MISSING_IDEMPOTENCY_KEY",
|
||||
@@ -249,35 +363,12 @@ function validateMutationIntent(
|
||||
return Object.freeze({ ok: true, intent: validated });
|
||||
}
|
||||
|
||||
const UTF8 = new TextEncoder();
|
||||
|
||||
function validIdempotencyKey(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.trim().length > 0 &&
|
||||
UTF8.encode(value).byteLength <=
|
||||
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes &&
|
||||
!hasControlCharacter(value)
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (
|
||||
codePoint <= 0x1f ||
|
||||
(codePoint >= 0x7f && codePoint <= 0x9f)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createContractHttpExecutor(
|
||||
dependencies: ContractHttpExecutorDependencies,
|
||||
): ContractHttpExecutor {
|
||||
const fetcher = dependencies.fetcher ?? fetch;
|
||||
const authProfiles =
|
||||
dependencies.authProfiles ?? INSTALLED_REST_AUTH_PROFILES;
|
||||
const readResponseBytes =
|
||||
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
|
||||
const now = dependencies.monotonicNow ?? (() => performance.now());
|
||||
@@ -308,11 +399,31 @@ export function createContractHttpExecutor(
|
||||
|
||||
// §8.5. One monotonic deadline covers credential resolution, encoding,
|
||||
// backoff, every physical attempt, body read and validation.
|
||||
const deadlineAt = now() + policy.totalDeadlineMs;
|
||||
const startedAt = now();
|
||||
const deadlineAt = startedAt + policy.totalDeadlineMs;
|
||||
const remaining = () => deadlineAt - now();
|
||||
|
||||
let attemptState: PhysicalAttemptState = "PREPARING";
|
||||
let attempts = 0;
|
||||
/**
|
||||
* §8.7 / D-01. Per-attempt state stays local to the attempt; this monotonic
|
||||
* accumulator is the logical execution history. A retry that has not been
|
||||
* dispatched can never lower what an earlier attempt already established.
|
||||
*/
|
||||
let logicalCertainty: MutationEffectCertainty = "NOT_STARTED";
|
||||
const observeCertainty = (observed: MutationEffectCertainty) => {
|
||||
logicalCertainty = joinMutationEffectCertainty(logicalCertainty, observed);
|
||||
return logicalCertainty;
|
||||
};
|
||||
/** Pre-dispatch failures read the accumulator, never a fresh attempt. */
|
||||
const logicalPreDispatchEffect = (): HttpEffectCertainty =>
|
||||
isCommand ? logicalCertainty : "NOT_APPLICABLE";
|
||||
const abandonedCertainty = (): MutationEffectCertainty =>
|
||||
observeCertainty(certaintyForAbandonedAttempt(attemptState, isCommand));
|
||||
const abandonedTransportFailure = (
|
||||
kind: HttpTransportFailure["kind"],
|
||||
): HttpExecutionOutcome<WireOutput, Problem> =>
|
||||
transportFailure(kind, false, abandonedCertainty());
|
||||
let terminalCancellation: CancellationOwner | null = null;
|
||||
const lifetimeController = new AbortController();
|
||||
const forwardCallerToLifetime = () => {
|
||||
@@ -353,16 +464,28 @@ export function createContractHttpExecutor(
|
||||
|
||||
const finish = (
|
||||
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
||||
certainty: string,
|
||||
terminalReason: string,
|
||||
): HttpExecutionOutcome<WireOutput, Problem> => {
|
||||
disposeLifetime();
|
||||
try {
|
||||
dependencies.observe?.({
|
||||
diagnosticsOperation: policy.diagnosticsOperation,
|
||||
outcome: outcome.kind,
|
||||
attempts,
|
||||
certainty,
|
||||
});
|
||||
const status = observationStatus(outcome);
|
||||
dependencies.observe?.(
|
||||
Object.freeze({
|
||||
routeId: context.routeId,
|
||||
operationId: contract.operationId,
|
||||
diagnosticsOperation: policy.diagnosticsOperation,
|
||||
outcome: outcome.kind,
|
||||
errorKind: observationErrorKind(outcome, terminalReason),
|
||||
...(status === undefined ? {} : { status }),
|
||||
attemptCount: attempts,
|
||||
durationMs: Math.max(0, now() - startedAt),
|
||||
effect: outcome.effect,
|
||||
terminalReason,
|
||||
...(terminalCancellation === null
|
||||
? {}
|
||||
: { cancellationOwner: terminalCancellation }),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Observation is outside the execution authority.
|
||||
}
|
||||
@@ -371,6 +494,16 @@ export function createContractHttpExecutor(
|
||||
|
||||
try {
|
||||
|
||||
// §7.7. The installed registry is the only source of a profile. Composition
|
||||
// already rejects unknown identities; this is the runtime fail-close.
|
||||
const authProfile = authProfiles.get(policy.authProfileId);
|
||||
if (!authProfile) {
|
||||
return finish(
|
||||
authIntegrationFailure("UNKNOWN_AUTH_PROFILE", isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
|
||||
// §7.4 step 1-2: capture the scope and verify it is still current.
|
||||
if (!context.scope.isCurrent()) {
|
||||
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
|
||||
@@ -424,54 +557,86 @@ export function createContractHttpExecutor(
|
||||
|
||||
// §7.7 / §8.4. Credentials are resolved before send. A response 401 is
|
||||
// terminal; there is no hidden refresh-and-replay.
|
||||
//
|
||||
// LIVE-01. A synchronous throw and an asynchronous rejection are the same
|
||||
// event seen from two call sites, so one classifier owns both. Neither is
|
||||
// evidence about the user's session.
|
||||
let patchOperation: Promise<CredentialPatchOutcome>;
|
||||
try {
|
||||
patchOperation = Promise.resolve(
|
||||
dependencies.attachCredentials({
|
||||
operationId: contract.operationId,
|
||||
authProfileId: policy.authProfileId,
|
||||
method: contract.method,
|
||||
}),
|
||||
dependencies.attachCredentials(
|
||||
{
|
||||
operationId: contract.operationId,
|
||||
authProfileId: policy.authProfileId,
|
||||
method: contract.method,
|
||||
},
|
||||
Object.freeze({
|
||||
signal: lifetimeController.signal,
|
||||
deadlineAtMonotonicMs: deadlineAt,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
||||
return finish(
|
||||
authIntegrationFailure("CREDENTIAL_OWNER_FAILED", isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
let patchResult: CredentialPatchOutcome | typeof ABORTED;
|
||||
try {
|
||||
patchResult = await awaitWithAbort(
|
||||
patchOperation,
|
||||
lifetimeController.signal,
|
||||
);
|
||||
} catch {
|
||||
return finish(
|
||||
authIntegrationFailure("CREDENTIAL_OWNER_FAILED", isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
const patchResult = await awaitWithAbort(
|
||||
patchOperation,
|
||||
lifetimeController.signal,
|
||||
);
|
||||
if (patchResult === ABORTED) {
|
||||
if (terminalCancellation === "SCOPE_FENCE") {
|
||||
return finish(
|
||||
transportFailure(
|
||||
"ABORTED_BY_SCOPE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("ABORTED_BY_SCOPE"),
|
||||
"SCOPE_FENCED",
|
||||
);
|
||||
}
|
||||
return terminalCancellation === "CALLER"
|
||||
? finish(cancelled("NOT_STARTED"), "CANCELLED")
|
||||
: finish(
|
||||
transportFailure(
|
||||
"TIMEOUT",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
const patch = patchResult;
|
||||
// NS-01. The whole answer is decoded once, inside the auth boundary, before
|
||||
// any field is used. Reading `kind` and `headers` off the raw object left
|
||||
// the decode outside that boundary: a throwing getter escaped into the
|
||||
// transport catch and an auth outage was classified as a network failure.
|
||||
const patch = decodeCredentialPatch(patchResult);
|
||||
if (patch === null) {
|
||||
// An off-contract answer is a collaborator breach, never a session
|
||||
// verdict the caller may act on.
|
||||
return finish(
|
||||
authIntegrationFailure("CREDENTIAL_OWNER_FAILED", isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
if (patch.kind === "SCOPE_FENCED") {
|
||||
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
|
||||
}
|
||||
if (patch.kind !== "READY") {
|
||||
if (patch.kind === "UNAUTHENTICATED") {
|
||||
// A missing credential never downgrades into an anonymous request.
|
||||
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
||||
}
|
||||
if (patch.kind === "UNAVAILABLE") {
|
||||
return finish(
|
||||
authIntegrationFailure("CREDENTIAL_OWNER_UNAVAILABLE", isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
|
||||
// The idempotency key is contract-owned, so a credential owner supplying it
|
||||
// stays the more specific request-contract violation.
|
||||
if (
|
||||
Object.keys(patch.headers).some(
|
||||
(name) => name.toLowerCase() === "idempotency-key",
|
||||
@@ -483,9 +648,21 @@ export function createContractHttpExecutor(
|
||||
);
|
||||
}
|
||||
|
||||
// §7.7. The profile, not the patch, decides what may travel. Rejection here
|
||||
// means zero fetch calls.
|
||||
const admission = admitCredentialHeaders(patch.headers, authProfile);
|
||||
if (!admission.ok) {
|
||||
return finish(
|
||||
authIntegrationFailure(admission.failure, isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
|
||||
// Transport-owned headers are written last so no credential entry can
|
||||
// shadow Accept or Content-Type through key ordering.
|
||||
const headers: Record<string, string> = {
|
||||
...admission.headers,
|
||||
Accept: "application/json",
|
||||
...patch.headers,
|
||||
};
|
||||
if (contract.requestBody === "JSON") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
@@ -504,14 +681,14 @@ export function createContractHttpExecutor(
|
||||
if (callerSignal?.aborted) {
|
||||
terminalCancellation ??= "CALLER";
|
||||
return finish(
|
||||
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
);
|
||||
}
|
||||
if (!context.scope.isCurrent()) {
|
||||
terminalCancellation ??= "SCOPE_FENCE";
|
||||
return finish(
|
||||
scopeFenced(contractViolationEffect(attemptState, isCommand)),
|
||||
scopeFenced(abandonedCertainty()),
|
||||
"SCOPE_FENCED",
|
||||
);
|
||||
}
|
||||
@@ -520,7 +697,7 @@ export function createContractHttpExecutor(
|
||||
const budget = remaining();
|
||||
if (budget <= 0) {
|
||||
return finish(
|
||||
transportFailure("TIMEOUT", false, attemptState, isCommand),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
@@ -543,7 +720,7 @@ export function createContractHttpExecutor(
|
||||
headers,
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
credentials: patch.credentials,
|
||||
credentials: authProfile.credentials,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
...(projected.request.bodyBytes
|
||||
@@ -560,17 +737,18 @@ export function createContractHttpExecutor(
|
||||
requestByteLimit: policy.requestByteLimit,
|
||||
deadlineRemainingMs: remaining(),
|
||||
scopeIsCurrent: context.scope.isCurrent(),
|
||||
authProfile,
|
||||
});
|
||||
if (invariantFailure) {
|
||||
clearTimeout(deadlineTimer);
|
||||
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
||||
return finish(
|
||||
invariantFailure === "SCOPE_FENCED"
|
||||
? scopeFenced(preDispatchEffect(isCommand))
|
||||
? scopeFenced(logicalPreDispatchEffect())
|
||||
: violation(
|
||||
"FINAL_REQUEST_INVARIANT_FAILED",
|
||||
"REQUEST",
|
||||
preDispatchEffect(isCommand),
|
||||
logicalPreDispatchEffect(),
|
||||
),
|
||||
"NOT_STARTED",
|
||||
);
|
||||
@@ -578,30 +756,42 @@ export function createContractHttpExecutor(
|
||||
|
||||
let response: Response;
|
||||
attemptState = "READY_TO_SEND";
|
||||
// LIVE-04. The dispatch wait is raced against the attempt signal, which
|
||||
// already carries the caller, the scope fence and the total deadline. A
|
||||
// `fetch` that ignores its own `signal` therefore still cannot outlive
|
||||
// the operation, and a response that lands late is drained, not admitted.
|
||||
let dispatch: BoundedRace<Response>;
|
||||
try {
|
||||
attempts += 1;
|
||||
const pending = fetcher(projected.request.url, init);
|
||||
attemptState = "DISPATCHED";
|
||||
response = await pending;
|
||||
attemptState = "RESPONSE_HEADERS";
|
||||
// D-01. Dispatch is the point of no return for the logical execution.
|
||||
// No later retry may claim the command never started.
|
||||
observeCertainty(certaintyForAbandonedAttempt("DISPATCHED", isCommand));
|
||||
dispatch = await raceTerminal(
|
||||
pending,
|
||||
controller.signal,
|
||||
cancelResponseBody,
|
||||
);
|
||||
} catch {
|
||||
dispatch = REJECTED_RACE;
|
||||
}
|
||||
if (dispatch.kind === "VALUE") {
|
||||
response = dispatch.value;
|
||||
attemptState = "RESPONSE_HEADERS";
|
||||
} else {
|
||||
clearTimeout(deadlineTimer);
|
||||
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
||||
const owner = terminalCancellation;
|
||||
if (owner === "CALLER") {
|
||||
return finish(
|
||||
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
);
|
||||
}
|
||||
if (owner === "SCOPE_FENCE") {
|
||||
return finish(
|
||||
transportFailure(
|
||||
"ABORTED_BY_SCOPE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("ABORTED_BY_SCOPE"),
|
||||
"SCOPE_FENCED",
|
||||
);
|
||||
}
|
||||
@@ -621,18 +811,11 @@ export function createContractHttpExecutor(
|
||||
if (slept === ABORTED) {
|
||||
return terminalCancellation === "CALLER"
|
||||
? finish(
|
||||
cancelled(
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
)
|
||||
: finish(
|
||||
transportFailure(
|
||||
"TIMEOUT",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
@@ -640,20 +823,64 @@ export function createContractHttpExecutor(
|
||||
}
|
||||
}
|
||||
return finish(
|
||||
transportFailure(kind, false, attemptState, isCommand),
|
||||
abandonedTransportFailure(kind),
|
||||
kind,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await admitResponse(
|
||||
operation,
|
||||
response,
|
||||
context,
|
||||
attemptState,
|
||||
readResponseBytes,
|
||||
// LIVE-04. Response admission reads a body, so it is a physical wait
|
||||
// too. It is bounded by the same signal, the reader is handed that
|
||||
// signal so a cooperative stream stops early, and an admission that
|
||||
// completes after the terminal owner fired is discarded.
|
||||
const admission = await raceTerminal(
|
||||
admitResponse(
|
||||
operation,
|
||||
response,
|
||||
context,
|
||||
attemptState,
|
||||
readResponseBytes,
|
||||
controller.signal,
|
||||
),
|
||||
controller.signal,
|
||||
() => cancelResponseBody(response),
|
||||
);
|
||||
// LIVE-04. Once response headers are in hand the request demonstrably
|
||||
// reached the server, so a terminal owner that lands during admission
|
||||
// keeps the dispatched classification: a stale generation stays the
|
||||
// `SCOPE_FENCED` contract violation it has always been, and only the
|
||||
// deadline and the caller reclassify the outcome.
|
||||
const abandonAdmission = ():
|
||||
| HttpExecutionOutcome<WireOutput, Problem>
|
||||
| null => {
|
||||
switch (terminalCancellation) {
|
||||
case "DEADLINE":
|
||||
return finish(abandonedTransportFailure("TIMEOUT"), "TIMEOUT");
|
||||
case "CALLER":
|
||||
return finish(cancelled(abandonedCertainty()), "CANCELLED");
|
||||
case "SCOPE_FENCE":
|
||||
return finish(
|
||||
scopeFenced(abandonedCertainty()),
|
||||
"SCOPE_FENCED",
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (admission.kind !== "VALUE") {
|
||||
cancelResponseBody(response);
|
||||
return (
|
||||
abandonAdmission() ??
|
||||
finish(
|
||||
abandonedTransportFailure("NETWORK_FAILURE"),
|
||||
"NETWORK_FAILURE",
|
||||
)
|
||||
);
|
||||
}
|
||||
const outcome = admission.value;
|
||||
attemptState = "SETTLED";
|
||||
const abandoned = abandonAdmission();
|
||||
if (abandoned) return abandoned;
|
||||
if (
|
||||
outcome.retryHint &&
|
||||
retryIndex < retryCeiling &&
|
||||
@@ -673,18 +900,11 @@ export function createContractHttpExecutor(
|
||||
if (slept === ABORTED) {
|
||||
return terminalCancellation === "CALLER"
|
||||
? finish(
|
||||
cancelled(
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
)
|
||||
: finish(
|
||||
transportFailure(
|
||||
"TIMEOUT",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
@@ -699,7 +919,7 @@ export function createContractHttpExecutor(
|
||||
}
|
||||
} catch {
|
||||
return finish(
|
||||
transportFailure("NETWORK_FAILURE", false, attemptState, isCommand),
|
||||
abandonedTransportFailure("NETWORK_FAILURE"),
|
||||
"RUNTIME_FAILURE",
|
||||
);
|
||||
} finally {
|
||||
@@ -710,6 +930,53 @@ export function createContractHttpExecutor(
|
||||
return Object.freeze({ execute });
|
||||
}
|
||||
|
||||
const CREDENTIAL_HEADER_VALUE_CEILING = 8_192;
|
||||
|
||||
/**
|
||||
* NS-01. Decodes a credential owner's answer into an owned, frozen value. Every
|
||||
* field is read exactly once through its own data descriptor, so an accessor, a
|
||||
* Proxy that answers differently on a second read, an inherited or smuggled
|
||||
* field, or a trap that throws all resolve to `null` — a collaborator breach —
|
||||
* rather than escaping as an exception or being installed unvalidated.
|
||||
*/
|
||||
function decodeCredentialPatch(
|
||||
source: unknown,
|
||||
): CredentialPatchOutcome | null {
|
||||
const outer = snapshotExactObject(source, {
|
||||
allowed: ["kind", "headers"],
|
||||
required: ["kind"],
|
||||
});
|
||||
if (outer === null) return null;
|
||||
const kind = outer["kind"];
|
||||
if (
|
||||
kind === "UNAUTHENTICATED" ||
|
||||
kind === "UNAVAILABLE" ||
|
||||
kind === "SCOPE_FENCED"
|
||||
) {
|
||||
return Object.hasOwn(outer, "headers")
|
||||
? null
|
||||
: Object.freeze({ kind } as const);
|
||||
}
|
||||
if (kind !== "READY") return null;
|
||||
|
||||
// The key set stays open here so the profile's own admission — and the more
|
||||
// specific reserved-header violation — can still report the precise reason.
|
||||
const headers = snapshotOwnDataRecord(outer["headers"]);
|
||||
if (headers === null) return null;
|
||||
for (const value of Object.values(headers)) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > CREDENTIAL_HEADER_VALUE_CEILING
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers,
|
||||
}) as CredentialPatchOutcome;
|
||||
}
|
||||
|
||||
type AdmissionOutcome<Value, Problem> = Readonly<{
|
||||
result: HttpExecutionOutcome<Value, Problem>;
|
||||
certainty: string;
|
||||
@@ -728,6 +995,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
context: HttpExecutionContext,
|
||||
attemptState: PhysicalAttemptState,
|
||||
readResponseBytes: typeof readBoundedBytes,
|
||||
signal: AbortSignal,
|
||||
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
||||
const contract = operation.contract;
|
||||
const policy = operation.frontend;
|
||||
@@ -790,19 +1058,19 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
metadata,
|
||||
attemptState,
|
||||
readResponseBytes,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// Success status: body policy first.
|
||||
if (contract.responseBody === "NONE") {
|
||||
const probe = await probeForbiddenBody(response);
|
||||
const probe = await probeForbiddenBody(response, signal);
|
||||
if (!probe.ok) {
|
||||
return settled(
|
||||
transportFailure(
|
||||
"RESPONSE_STREAM_FAILURE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
probe.code,
|
||||
);
|
||||
@@ -834,7 +1102,11 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
|
||||
const emptyAllowed = contract.emptyBodyStatuses.includes(status);
|
||||
const mediaOk = isJsonMediaType(response.headers.get("content-type"));
|
||||
const bytes = await readResponseBytes(response, policy.responseByteLimit);
|
||||
const bytes = await readResponseBytes(
|
||||
response,
|
||||
policy.responseByteLimit,
|
||||
signal,
|
||||
);
|
||||
if (!bytes.ok) {
|
||||
return settled(
|
||||
bytes.code === "RESPONSE_TOO_LARGE"
|
||||
@@ -846,8 +1118,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
: transportFailure(
|
||||
"RESPONSE_STREAM_FAILURE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
bytes.code,
|
||||
);
|
||||
@@ -946,6 +1217,87 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE-04. The outcome of a physical wait that the operation's terminal signal
|
||||
* bounds.
|
||||
*
|
||||
* `REJECTED` is kept distinct from `TERMINAL` on purpose: a collaborator's own
|
||||
* rejection is evidence about the request, and forging it into a cancellation
|
||||
* state would erase the reason the attempt actually failed.
|
||||
*/
|
||||
type BoundedRace<Value> =
|
||||
| Readonly<{ kind: "VALUE"; value: Value }>
|
||||
| Readonly<{ kind: "REJECTED" }>
|
||||
| Readonly<{ kind: "TERMINAL" }>;
|
||||
|
||||
const TERMINAL_RACE: BoundedRace<never> = Object.freeze({
|
||||
kind: "TERMINAL" as const,
|
||||
});
|
||||
const REJECTED_RACE: BoundedRace<never> = Object.freeze({
|
||||
kind: "REJECTED" as const,
|
||||
});
|
||||
|
||||
/**
|
||||
* LIVE-04. Races a physical operation against the terminal signal so a
|
||||
* non-cooperative `fetch` or reader cannot hold the port result open past the
|
||||
* total deadline.
|
||||
*
|
||||
* Two properties matter beyond the race itself. A value that arrives while the
|
||||
* terminal owner has already fired is *late*, so it is compensated rather than
|
||||
* admitted. And the abandoned operation is still observed exactly once, so a
|
||||
* late native rejection never surfaces as an unhandled rejection.
|
||||
*/
|
||||
async function raceTerminal<Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
compensate: (value: Value) => void,
|
||||
): Promise<BoundedRace<Value>> {
|
||||
let landed: BoundedRace<Value> | null = null;
|
||||
const settled: Promise<BoundedRace<Value>> = operation.then(
|
||||
(value) => (landed = Object.freeze({ kind: "VALUE" as const, value })),
|
||||
() => (landed = REJECTED_RACE),
|
||||
);
|
||||
const observeLate = () => {
|
||||
void settled.then((outcome) => {
|
||||
if (outcome.kind !== "VALUE") return;
|
||||
try {
|
||||
compensate(outcome.value);
|
||||
} catch {
|
||||
// Compensation is outside the execution authority.
|
||||
}
|
||||
});
|
||||
};
|
||||
let onAbort: (() => void) | undefined;
|
||||
const terminal = new Promise<BoundedRace<Value>>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve(TERMINAL_RACE);
|
||||
return;
|
||||
}
|
||||
onAbort = () => resolve(TERMINAL_RACE);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
const winner = await Promise.race([settled, terminal]);
|
||||
if (winner !== TERMINAL_RACE) return winner;
|
||||
// The terminal owner reached the await first. Drain the microtask queue
|
||||
// once so an operation that had *already* settled can still hand over its
|
||||
// value: a microtask turn cannot be extended by a collaborator that has
|
||||
// not settled, so a non-cooperative operation is still abandoned here.
|
||||
for (let turn = 0; turn < 4 && landed === null; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
if (landed !== null) return landed;
|
||||
observeLate();
|
||||
return TERMINAL_RACE;
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelResponseBody(response: Response): void {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
const ABORTED = Symbol("http-operation-aborted");
|
||||
|
||||
async function awaitWithAbort<Value>(
|
||||
@@ -972,6 +1324,7 @@ async function admitProblem<Input, WireOutput, Problem>(
|
||||
metadata: SafeResponseMetadata,
|
||||
attemptState: PhysicalAttemptState,
|
||||
readResponseBytes: typeof readBoundedBytes,
|
||||
signal: AbortSignal,
|
||||
): Promise<AdmissionOutcome<WireOutput, Problem>> {
|
||||
const contract = operation.contract;
|
||||
const isCommand = contract.commandEffect !== null;
|
||||
@@ -980,6 +1333,7 @@ async function admitProblem<Input, WireOutput, Problem>(
|
||||
const bytes = await readResponseBytes(
|
||||
response,
|
||||
HTTP_EXECUTION_CEILINGS.problemResponseBytes,
|
||||
signal,
|
||||
);
|
||||
if (!bytes.ok || isEffectivelyEmpty(bytes.bytes)) {
|
||||
// An unclassifiable failure stays uncertain for a command.
|
||||
@@ -1156,6 +1510,21 @@ function scopeFenced<Value, Problem>(
|
||||
return violation("SCOPE_FENCED", "RESPONSE", effect);
|
||||
}
|
||||
|
||||
/**
|
||||
* §7.7. A credential collaborator or profile-binding breach. It always resolves
|
||||
* before dispatch, so the command effect is `NOT_STARTED` and fetch count zero.
|
||||
*/
|
||||
function authIntegrationFailure<Value, Problem>(
|
||||
reason: AuthIntegrationFailureReason,
|
||||
isCommand: boolean,
|
||||
): HttpExecutionOutcome<Value, Problem> {
|
||||
return Object.freeze({
|
||||
kind: "AUTH_INTEGRATION_FAILURE" as const,
|
||||
reason,
|
||||
effect: isCommand ? ("NOT_STARTED" as const) : ("NOT_APPLICABLE" as const),
|
||||
});
|
||||
}
|
||||
|
||||
function preDispatchEffect(isCommand: boolean): HttpEffectCertainty {
|
||||
return isCommand ? "NOT_STARTED" : "NOT_APPLICABLE";
|
||||
}
|
||||
@@ -1164,16 +1533,6 @@ function postDispatchEffect(isCommand: boolean): HttpEffectCertainty {
|
||||
return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE";
|
||||
}
|
||||
|
||||
function contractViolationEffect(
|
||||
attemptState: PhysicalAttemptState,
|
||||
isCommand: boolean,
|
||||
): HttpEffectCertainty {
|
||||
if (!isCommand) return "NOT_APPLICABLE";
|
||||
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"
|
||||
? "NOT_STARTED"
|
||||
: "MAYBE_APPLIED";
|
||||
}
|
||||
|
||||
function unauthenticated<Value, Problem>(
|
||||
effect: string,
|
||||
isCommand: boolean,
|
||||
@@ -1206,10 +1565,8 @@ function cancelled<Value, Problem>(
|
||||
function transportFailure<Value, Problem>(
|
||||
kind: HttpTransportFailure["kind"],
|
||||
retryable: boolean,
|
||||
attemptState: PhysicalAttemptState,
|
||||
isCommand: boolean,
|
||||
effect: MutationEffectCertainty,
|
||||
): HttpExecutionOutcome<Value, Problem> {
|
||||
const effect = certaintyForAbandonedAttempt(attemptState, isCommand);
|
||||
return Object.freeze({
|
||||
kind: "TRANSPORT_FAILURE" as const,
|
||||
failure: Object.freeze({ kind, retryable }),
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* BT-X-01. Shared abort and deadline mechanics.
|
||||
*
|
||||
* Several adapters independently reimplemented "race a promise against a
|
||||
* caller signal and a deadline, then clean up listeners and timers". Only the
|
||||
* mechanics are shared here; every subsystem keeps its own result taxonomy and
|
||||
* recovery vocabulary, so this module deliberately imports none of them and is
|
||||
* not a generic middleware layer.
|
||||
*/
|
||||
|
||||
export type AbortTerminalReason = "CALLER_ABORT" | "DEADLINE" | "CLOSED";
|
||||
|
||||
/**
|
||||
* TR-RR-05. A collaborator's own rejection is evidence about the work, so it is
|
||||
* a distinct outcome. Forging it into `TERMINAL` erased the reason the
|
||||
* operation actually failed and made `race()` disagree with `terminal()`, which
|
||||
* still reported no owner.
|
||||
*/
|
||||
export type AbortRace<Value> =
|
||||
| Readonly<{ kind: "VALUE"; value: Value }>
|
||||
| Readonly<{ kind: "REJECTED"; reason: unknown }>
|
||||
| Readonly<{ kind: "TERMINAL"; terminal: AbortTerminalReason }>;
|
||||
|
||||
export type AbortableOperation = Readonly<{
|
||||
/** The composed signal: caller abort, deadline and close all feed it. */
|
||||
readonly signal: AbortSignal;
|
||||
/**
|
||||
* The first terminal owner, or `null` while the operation is still live.
|
||||
* This is a live accessor, not a snapshot.
|
||||
*/
|
||||
terminal(): AbortTerminalReason | null;
|
||||
/**
|
||||
* Resolves with the operation's value, its own rejection, or the first
|
||||
* terminal owner. A terminal race never returns a bare value, and the
|
||||
* `terminal` it reports is always the same owner `terminal()` reports.
|
||||
*
|
||||
* `compensate` is invoked at most once, and only for a value that arrived
|
||||
* after the operation already ended.
|
||||
*/
|
||||
race<Value>(
|
||||
operation: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
): Promise<AbortRace<Value>>;
|
||||
/**
|
||||
* Idempotent. Removes listeners, clears the deadline timer and marks the
|
||||
* operation `CLOSED` if nothing terminal happened first.
|
||||
*/
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
export type AbortableOperationInput = Readonly<{
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
/** Scheduler seam; a throwing scheduler must not leak a listener. */
|
||||
setTimer?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearTimer?: (handle: unknown) => void;
|
||||
}>;
|
||||
|
||||
export type AbortTimerSnapshot = Readonly<{
|
||||
setTimer: (callback: () => void, delayMs: number) => unknown;
|
||||
clearTimer: (handle: unknown) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* X-AUDIT-02. Captures a scheduler's timer callables once, bound to their
|
||||
* receiver. Consumers that kept the scheduler object and re-read `setTimeout`
|
||||
* per request validated one function and executed another, so replacing a
|
||||
* method after composition silently changed how work was bounded.
|
||||
*/
|
||||
export function snapshotAbortTimers<Handle>(
|
||||
scheduler: Readonly<{
|
||||
setTimeout(callback: () => void, milliseconds: number): Handle;
|
||||
clearTimeout(handle: Handle): void;
|
||||
}>,
|
||||
): AbortTimerSnapshot {
|
||||
const set = scheduler?.setTimeout;
|
||||
const clear = scheduler?.clearTimeout;
|
||||
if (typeof set !== "function" || typeof clear !== "function") {
|
||||
throw new TypeError("Timer scheduler is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
setTimer: set.bind(scheduler) as (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => unknown,
|
||||
clearTimer: clear.bind(scheduler) as (handle: unknown) => void,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAbortableOperation(
|
||||
input: AbortableOperationInput = {},
|
||||
): AbortableOperation {
|
||||
const controller = new AbortController();
|
||||
// TR-RR-05. The scheduler and the caller signal are captured once, so
|
||||
// replacing a method on the input object after construction cannot change how
|
||||
// an operation already in flight is bounded or cleaned up.
|
||||
const callerSignal = input.signal;
|
||||
const setTimer =
|
||||
input.setTimer ??
|
||||
((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
|
||||
const clearTimer =
|
||||
input.clearTimer ??
|
||||
((handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
});
|
||||
|
||||
let terminalReason: AbortTerminalReason | null = null;
|
||||
let disposed = false;
|
||||
let timer: unknown;
|
||||
|
||||
/** First terminal owner wins; later owners never overwrite it. */
|
||||
const settle = (reason: AbortTerminalReason) => {
|
||||
terminalReason ??= reason;
|
||||
if (!controller.signal.aborted) controller.abort();
|
||||
};
|
||||
|
||||
const onCallerAbort = () => {
|
||||
settle("CALLER_ABORT");
|
||||
dispose();
|
||||
};
|
||||
|
||||
function dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try {
|
||||
callerSignal?.removeEventListener("abort", onCallerAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block cleanup of the rest.
|
||||
}
|
||||
if (timer !== undefined) {
|
||||
try {
|
||||
clearTimer(timer);
|
||||
} catch {
|
||||
// A throwing scheduler cannot leave the operation un-disposed.
|
||||
}
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (callerSignal?.aborted) {
|
||||
settle("CALLER_ABORT");
|
||||
disposed = true;
|
||||
} else if (callerSignal) {
|
||||
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
|
||||
}
|
||||
|
||||
if (
|
||||
terminalReason === null &&
|
||||
input.timeoutMs !== undefined &&
|
||||
Number.isFinite(input.timeoutMs) &&
|
||||
input.timeoutMs >= 0
|
||||
) {
|
||||
try {
|
||||
timer = setTimer(() => {
|
||||
settle("DEADLINE");
|
||||
dispose();
|
||||
}, input.timeoutMs);
|
||||
} catch {
|
||||
// TR-RR-05. A scheduler that cannot install the deadline leaves the
|
||||
// operation unbounded. Removing the caller listener and leaving no
|
||||
// terminal owner made a later abort invisible, so installation failure is
|
||||
// itself terminal: the operation closes atomically with its resources.
|
||||
timer = undefined;
|
||||
settle("CLOSED");
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
terminal: () => terminalReason,
|
||||
race<Value>(
|
||||
operation: Promise<Value>,
|
||||
compensate?: (value: Value) => void,
|
||||
): Promise<AbortRace<Value>> {
|
||||
let compensated = false;
|
||||
const compensateOnce = (value: Value) => {
|
||||
if (compensated || !compensate) return;
|
||||
compensated = true;
|
||||
try {
|
||||
compensate(value);
|
||||
} catch {
|
||||
// Compensation is best effort and never changes the outcome.
|
||||
}
|
||||
};
|
||||
const terminalRace = (): AbortRace<Value> =>
|
||||
Object.freeze({
|
||||
kind: "TERMINAL" as const,
|
||||
// The owner reported here is always the owner `terminal()` reports.
|
||||
terminal: terminalReason ?? ("CLOSED" as const),
|
||||
});
|
||||
|
||||
if (terminalReason !== null) {
|
||||
// Already owned before the task was ever raced: nothing it produces can
|
||||
// be admitted, so a value is compensated and a rejection absorbed.
|
||||
void operation.then(
|
||||
(value) => compensateOnce(value),
|
||||
() => undefined,
|
||||
);
|
||||
return Promise.resolve(terminalRace());
|
||||
}
|
||||
|
||||
// X-AUDIT-01. Task settlement and the terminal event share one settle-once
|
||||
// state machine, so the outcome is whichever callback actually ran first.
|
||||
// Draining a fixed number of microtasks to guess whether a promise "had
|
||||
// already settled" made the answer depend on scheduling rather than on
|
||||
// observation, and let a rejection overwrite an owner that was fixed
|
||||
// synchronously before it.
|
||||
return new Promise<AbortRace<Value>>((resolve) => {
|
||||
let claimed = false;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const release = () => {
|
||||
if (!onAbort) return;
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
onAbort = undefined;
|
||||
};
|
||||
const claim = (outcome: AbortRace<Value>): boolean => {
|
||||
if (claimed) return false;
|
||||
claimed = true;
|
||||
release();
|
||||
resolve(outcome);
|
||||
return true;
|
||||
};
|
||||
|
||||
operation.then(
|
||||
(value) => {
|
||||
if (!claim(Object.freeze({ kind: "VALUE" as const, value }))) {
|
||||
// The work landed after the operation already ended.
|
||||
compensateOnce(value);
|
||||
}
|
||||
},
|
||||
(reason: unknown) => {
|
||||
// A rejection that loses the claim is absorbed here, so it can never
|
||||
// surface as an unhandled rejection.
|
||||
claim(Object.freeze({ kind: "REJECTED" as const, reason }));
|
||||
},
|
||||
);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
claim(terminalRace());
|
||||
return;
|
||||
}
|
||||
onAbort = () => {
|
||||
claim(terminalRace());
|
||||
};
|
||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
},
|
||||
close() {
|
||||
settle("CLOSED");
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compensates a native handle that arrives after the operation ended. The
|
||||
* compensation itself is best effort and can never change the already selected
|
||||
* outcome.
|
||||
*/
|
||||
export function compensateLateHandle(
|
||||
handle: Promise<Readonly<{ body?: { cancel(): Promise<void> } | null }> | null>,
|
||||
): void {
|
||||
void handle
|
||||
.then(async (value) => {
|
||||
await value?.body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
@@ -32,6 +32,26 @@ type ValidatorRow = {
|
||||
generation: number;
|
||||
};
|
||||
|
||||
type ConditionalValidatorKeyTuple = readonly [
|
||||
scopeFingerprint: string,
|
||||
definitionId: string,
|
||||
identityToken: string,
|
||||
representationVersion: number,
|
||||
];
|
||||
|
||||
/** The store is a trust boundary, so key components are validated and bounded. */
|
||||
const MAX_KEY_COMPONENT_BYTES = 512;
|
||||
const KEY_COMPONENT_ENCODER = new TextEncoder();
|
||||
|
||||
function isBoundedKeyComponent(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
KEY_COMPONENT_ENCODER.encode(value).byteLength <=
|
||||
MAX_KEY_COMPONENT_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
export function createConditionalValidatorStore(
|
||||
maxEntries = 1_024,
|
||||
): ConditionalValidatorStore {
|
||||
@@ -40,22 +60,31 @@ export function createConditionalValidatorStore(
|
||||
}
|
||||
const rows = new Map<string, ValidatorRow>();
|
||||
|
||||
/**
|
||||
* N-05. A delimiter join is not injective here: `definitionId`,
|
||||
* `identityToken` and the scope fingerprint may all contain the delimiter, so
|
||||
* two distinct valid bindings could encode to the same key and one
|
||||
* definition's ETag could be sent for another. The key is a validated fixed
|
||||
* tuple encoded with `JSON.stringify`, which escapes the separators.
|
||||
*/
|
||||
function key(binding: ConditionalValidatorBinding): string | null {
|
||||
if (
|
||||
!binding.scope.isCurrent() ||
|
||||
!binding.definitionId ||
|
||||
!isBoundedKeyComponent(binding.scope.fingerprint) ||
|
||||
!isBoundedKeyComponent(binding.definitionId) ||
|
||||
!/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) ||
|
||||
!Number.isSafeInteger(binding.representationVersion) ||
|
||||
binding.representationVersion < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
const tuple: ConditionalValidatorKeyTuple = [
|
||||
binding.scope.fingerprint,
|
||||
binding.definitionId,
|
||||
binding.identityToken,
|
||||
binding.representationVersion,
|
||||
].join(":");
|
||||
];
|
||||
return JSON.stringify(tuple);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -5,6 +5,46 @@ import type {
|
||||
CursorPaginationRuntime,
|
||||
} from "../../contracts/cursor-pagination.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import { snapshotExactObject } from "../../contracts/exact-snapshot.ts";
|
||||
|
||||
const ABORTED = Symbol("PAGINATION_ABORTED");
|
||||
|
||||
/**
|
||||
* Resolves as soon as the operation settles or the signal aborts, whichever
|
||||
* comes first. A late operation result is observed and discarded, never thrown
|
||||
* as an unhandled rejection.
|
||||
*
|
||||
* OPT-NET-01. A loader rejection is *not* an abort. The presence of a signal
|
||||
* says nothing about why the loader failed, so a rejection is re-thrown exactly
|
||||
* as it would be with no signal at all; only a signal that has actually
|
||||
* aborted classifies the outcome as cancellation.
|
||||
*/
|
||||
async function raceAbort<Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Value | typeof ABORTED> {
|
||||
operation.catch(() => {});
|
||||
if (!signal) return await operation;
|
||||
if (signal.aborted) return ABORTED;
|
||||
return await new Promise<Value | typeof ABORTED>((resolve, reject) => {
|
||||
const onAbort = () => resolve(ABORTED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(reason: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
if (signal.aborted) {
|
||||
resolve(ABORTED);
|
||||
return;
|
||||
}
|
||||
reject(reason);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
definitionId: string;
|
||||
@@ -14,7 +54,17 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
context: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<Result<CursorPage<Value>>>;
|
||||
}>): CursorPaginationRuntime<Value> {
|
||||
validateProfile(dependencies.profile);
|
||||
// NS-07. The caps are captured once, here. Validating the caller's profile and
|
||||
// then reading it again on every page let a `maxPages` of 1 become 3 after
|
||||
// construction, so the request count, item total and byte ceiling that were
|
||||
// checked were not the ones the loop enforced. The collaborators are captured
|
||||
// for the same reason.
|
||||
const profile = snapshotProfile(dependencies.profile);
|
||||
const definitionId = dependencies.definitionId;
|
||||
const loadPage = dependencies.loadPage;
|
||||
if (typeof definitionId !== "string" || typeof loadPage !== "function") {
|
||||
throw new TypeError("Invalid cursor pagination dependencies.");
|
||||
}
|
||||
return Object.freeze({
|
||||
async loadAll(context) {
|
||||
const items: Value[] = [];
|
||||
@@ -23,16 +73,28 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
let snapshot: string | null | undefined;
|
||||
for (
|
||||
let pageIndex = 0;
|
||||
pageIndex < dependencies.profile.maxPages;
|
||||
pageIndex < profile.maxPages;
|
||||
pageIndex += 1
|
||||
) {
|
||||
if (context.signal?.aborted) {
|
||||
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
|
||||
}
|
||||
const result = await dependencies.loadPage(cursor, context);
|
||||
// N-10. A non-cooperative loader may never settle, or may settle after
|
||||
// abort. Race the signal so `loadAll` is bounded, and re-check before
|
||||
// observing the page so a late completion is ignored rather than
|
||||
// accumulated into a successful result.
|
||||
const raced: Result<CursorPage<Value>> | typeof ABORTED =
|
||||
await raceAbort<Result<CursorPage<Value>>>(
|
||||
loadPage(cursor, context),
|
||||
context.signal,
|
||||
);
|
||||
if (raced === ABORTED || context.signal?.aborted) {
|
||||
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
|
||||
}
|
||||
const result: Result<CursorPage<Value>> = raced;
|
||||
if (!result.ok) return result;
|
||||
const page = result.value;
|
||||
if (!isValidPage(page, dependencies.profile)) {
|
||||
const page: CursorPage<Value> = result.value;
|
||||
if (!isValidPage(page, profile)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_PAGE_INVALID",
|
||||
@@ -48,8 +110,8 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
}
|
||||
items.push(...page.items);
|
||||
if (
|
||||
items.length > dependencies.profile.maxTotalItems ||
|
||||
estimatedBytes(items) > dependencies.profile.maxEstimatedBytes
|
||||
items.length > profile.maxTotalItems ||
|
||||
estimatedBytes(items) > profile.maxEstimatedBytes
|
||||
) {
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
@@ -57,7 +119,7 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
);
|
||||
}
|
||||
if (!page.hasMore) return { ok: true, value: Object.freeze(items) };
|
||||
const nextCursor = page.nextCursor;
|
||||
const nextCursor: string | null = page.nextCursor;
|
||||
if (!nextCursor || cursors.has(nextCursor)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
@@ -83,13 +145,46 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: createFailure(kind, dependencies.definitionId, 0, { code }),
|
||||
error: createFailure(kind, definitionId, 0, { code }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NS-07. Copies the profile into an owned frozen record, reading every field
|
||||
* exactly once, and validates that copy. An accessor, an inherited or extra
|
||||
* field, a symbol key or a Proxy trap fails closed rather than becoming a cap
|
||||
* that can change after it was checked.
|
||||
*/
|
||||
function snapshotProfile(source: unknown): CursorPaginationProfile {
|
||||
const profile = snapshotExactObject(source, {
|
||||
allowed: [
|
||||
"profileId",
|
||||
"maxPages",
|
||||
"maxTotalItems",
|
||||
"maxEstimatedBytes",
|
||||
"maxCursorBytes",
|
||||
"allowSparsePage",
|
||||
],
|
||||
required: [
|
||||
"profileId",
|
||||
"maxPages",
|
||||
"maxTotalItems",
|
||||
"maxEstimatedBytes",
|
||||
"maxCursorBytes",
|
||||
"allowSparsePage",
|
||||
],
|
||||
}) as CursorPaginationProfile | null;
|
||||
if (profile === null || typeof profile.allowSparsePage !== "boolean") {
|
||||
throw new TypeError("Invalid cursor pagination profile.");
|
||||
}
|
||||
validateProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
function validateProfile(profile: CursorPaginationProfile): void {
|
||||
if (
|
||||
typeof profile.profileId !== "string" ||
|
||||
!profile.profileId ||
|
||||
!Number.isSafeInteger(profile.maxPages) ||
|
||||
profile.maxPages < 1 ||
|
||||
|
||||
@@ -168,6 +168,28 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
let quiescing: InternalWriterLease<Value> | null = null;
|
||||
let transitionCandidate: InternalWriterLease<Value> | null = null;
|
||||
let closePromise: Promise<RealtimeResult<void>> | null = null;
|
||||
/**
|
||||
* R-03. Writers whose lease was fail-closed but whose tail may still be
|
||||
* running. Membership keeps `close()` honest about quiescence.
|
||||
*/
|
||||
const retiredWriters = new Set<InternalWriterLease<Value>>();
|
||||
/**
|
||||
* RT-RR-04. Checkpoint work is an external authority call like a writer tail,
|
||||
* so it belongs in a physical-task registry from invocation to settlement.
|
||||
* Racing it against a timeout bounded the public wait but left `close()` free
|
||||
* to report success while the checkpoint was still running.
|
||||
*/
|
||||
const checkpointTasks = new Set<Promise<unknown>>();
|
||||
|
||||
/** RT-RR-03. Prunes a settled writer without needing another `close()`. */
|
||||
function trackRetiredWriter(lease: InternalWriterLease<Value>): void {
|
||||
retiredWriters.add(lease);
|
||||
void lease.tail
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
retiredWriters.delete(lease);
|
||||
});
|
||||
}
|
||||
|
||||
active = createWriterLease(dependencies.initial.writer);
|
||||
|
||||
@@ -568,6 +590,12 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
(value) => ({ kind: "VALUE" as const, value }),
|
||||
() => ({ kind: "REJECTED" as const }),
|
||||
);
|
||||
// RT-RR-04. Registered at the moment the authority is called, and removed
|
||||
// only when it settles, so `close()` cannot report quiescence over it.
|
||||
checkpointTasks.add(operation);
|
||||
void operation.finally(() => {
|
||||
checkpointTasks.delete(operation);
|
||||
});
|
||||
const timeout = Promise.resolve()
|
||||
.then(async () => {
|
||||
await clock.sleep(
|
||||
@@ -638,6 +666,33 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
return realtimeSuccess(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* RT-RR-04. The writer-tail quiescence bound applied to any retained physical
|
||||
* task, so checkpoint work is proved settled on the same terms.
|
||||
*/
|
||||
async function awaitTaskQuiescence(
|
||||
task: Promise<unknown>,
|
||||
): Promise<QuiescenceOutcome> {
|
||||
const timer = new AbortController();
|
||||
const settled = task.then(
|
||||
() => "QUIESCED" as const,
|
||||
() => "QUIESCED" as const,
|
||||
);
|
||||
const timeout = Promise.resolve()
|
||||
.then(async () => {
|
||||
await clock.sleep(limits.quiescenceTimeoutMs, timer.signal);
|
||||
return "TIMED_OUT" as const;
|
||||
})
|
||||
.catch(() =>
|
||||
timer.signal.aborted
|
||||
? ("QUIESCED" as const)
|
||||
: ("TIMER_FAILED" as const),
|
||||
);
|
||||
const outcome = await Promise.race([settled, timeout]);
|
||||
timer.abort();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function awaitQuiescence(
|
||||
lease: InternalWriterLease<Value>,
|
||||
): Promise<QuiescenceOutcome> {
|
||||
@@ -665,7 +720,13 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
}
|
||||
|
||||
function close(): Promise<RealtimeResult<void>> {
|
||||
closePromise ??= performClose();
|
||||
// RT-RR-03. Only a close that is still running is shared. Caching the first
|
||||
// timeout forever meant a writer that later settled could never be proved
|
||||
// quiescent: every subsequent close replayed the stale failure and the
|
||||
// retained registry could never be pruned.
|
||||
closePromise ??= performClose().finally(() => {
|
||||
closePromise = null;
|
||||
});
|
||||
return closePromise;
|
||||
}
|
||||
|
||||
@@ -673,21 +734,35 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
lifecycleGeneration += 1;
|
||||
state = "CLOSED";
|
||||
transitioning = true;
|
||||
// R-03. Current and previously retired writers are waited on together and
|
||||
// deduplicated, so a writer dropped by an overflow fail-close is still
|
||||
// proved quiescent before close reports success.
|
||||
const writers = uniqueLeases([
|
||||
active,
|
||||
probe?.lease ?? null,
|
||||
quiescing,
|
||||
transitionCandidate,
|
||||
...retiredWriters,
|
||||
]);
|
||||
active = null;
|
||||
const selectedProbe = probe;
|
||||
probe = null;
|
||||
selectedProbe?.buffer.splice(0);
|
||||
if (selectedProbe) selectedProbe.bufferedBytes = 0;
|
||||
for (const writer of writers) writer.controller.abort();
|
||||
const outcomes = await Promise.all(
|
||||
writers.map(async (writer) => await awaitQuiescence(writer)),
|
||||
);
|
||||
for (const writer of writers) {
|
||||
// RT-RR-03. Every writer this close fences is retained until its tail
|
||||
// actually settles, so a later close still sees a writer that has not
|
||||
// finished — and stops seeing it the moment it does.
|
||||
trackRetiredWriter(writer);
|
||||
writer.controller.abort();
|
||||
}
|
||||
const outcomes = await Promise.all([
|
||||
...writers.map(async (writer) => await awaitQuiescence(writer)),
|
||||
// RT-RR-04. Checkpoint work is drained on the same terms as a writer tail.
|
||||
...[...checkpointTasks].map(
|
||||
async (task) => await awaitTaskQuiescence(task),
|
||||
),
|
||||
]);
|
||||
quiescing = null;
|
||||
transitionCandidate = null;
|
||||
transitioning = false;
|
||||
@@ -771,20 +846,33 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* R-03. A fail-close aborts every lease, but the underlying writers may be
|
||||
* non-cooperative and still running. They move into the retired set before
|
||||
* their references are cleared, so a later `close()` cannot report success
|
||||
* while an abandoned writer is still executing.
|
||||
*/
|
||||
function failClosed(): void {
|
||||
lifecycleGeneration += 1;
|
||||
state = "CLOSED";
|
||||
transitioning = false;
|
||||
active?.controller.abort();
|
||||
probe?.lease.controller.abort();
|
||||
quiescing?.controller.abort();
|
||||
transitionCandidate?.controller.abort();
|
||||
for (const writer of uniqueLeases([
|
||||
active,
|
||||
probe?.lease ?? null,
|
||||
quiescing,
|
||||
transitionCandidate,
|
||||
])) {
|
||||
writer.controller.abort();
|
||||
trackRetiredWriter(writer);
|
||||
}
|
||||
active = null;
|
||||
if (probe) {
|
||||
probe.buffer.length = 0;
|
||||
probe.bufferedBytes = 0;
|
||||
}
|
||||
probe = null;
|
||||
quiescing = null;
|
||||
transitionCandidate = null;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -41,6 +41,27 @@ import {
|
||||
type RealtimeDataSnapshot,
|
||||
} from "./result.ts";
|
||||
|
||||
/**
|
||||
* R-02. Lifecycle is orthogonal to freshness. `DRAINING` means the coordinator
|
||||
* has revoked commit capability and stopped admitting work, but a
|
||||
* non-cooperative task it started is still running and is deliberately retained
|
||||
* until it actually settles.
|
||||
*/
|
||||
export type RealtimeStreamLifecycle = "OPEN" | "DRAINING" | "CLOSED";
|
||||
|
||||
export type RealtimeStreamTaskLimits = Readonly<{
|
||||
effectTimeoutMs: number;
|
||||
recoveryTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
}>;
|
||||
|
||||
export const DEFAULT_REALTIME_STREAM_TASK_LIMITS: RealtimeStreamTaskLimits =
|
||||
Object.freeze({
|
||||
effectTimeoutMs: 5_000,
|
||||
recoveryTimeoutMs: 10_000,
|
||||
drainTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
export type RealtimeStreamCoordinatorDependencies = Readonly<{
|
||||
registry: RealtimePolicyRegistry;
|
||||
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
|
||||
@@ -48,6 +69,10 @@ export type RealtimeStreamCoordinatorDependencies = Readonly<{
|
||||
scope: RealtimeScopeSnapshot;
|
||||
now?: () => number;
|
||||
observe?: RealtimeEventObservationSink;
|
||||
taskLimits?: Partial<RealtimeStreamTaskLimits>;
|
||||
/** Test seam for the bounded task deadline. */
|
||||
scheduleTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearScheduledTimeout?: (handle: unknown) => void;
|
||||
}>;
|
||||
|
||||
export type RealtimeStreamCoordinator = Readonly<{
|
||||
@@ -66,7 +91,13 @@ export type RealtimeStreamCoordinator = Readonly<{
|
||||
): RealtimeResult<void>;
|
||||
getResumeState(streamId: StreamRegistrationId): RealtimeResumeState | null;
|
||||
inspect(streamId: StreamRegistrationId): RealtimeStreamInspection;
|
||||
close(): void;
|
||||
lifecycle(streamId: StreamRegistrationId): RealtimeStreamLifecycle;
|
||||
/**
|
||||
* R-02. Bounded quiescence. Success means every tracked task actually
|
||||
* settled; `IDLE_TIMEOUT` means the coordinator is still `DRAINING` and the
|
||||
* caller must not assume a clean teardown.
|
||||
*/
|
||||
close(): Promise<RealtimeResult<void>>;
|
||||
}>;
|
||||
|
||||
type DedupeEntry = Readonly<{
|
||||
@@ -96,6 +127,25 @@ type StreamState = {
|
||||
awaitingTransportBarrier: boolean;
|
||||
barrierCheckpoint: RealtimeRecoveryCheckpoint | null;
|
||||
closed: boolean;
|
||||
lifecycle: RealtimeStreamLifecycle;
|
||||
/**
|
||||
* RT-RR-01. Every physical task this coordinator has handed to an external
|
||||
* authority, from the moment of the call until it settles. Registering only
|
||||
* after a timeout meant a `close()` that arrived first saw an empty set and
|
||||
* reported quiescence while the raw task was still running.
|
||||
*/
|
||||
retainedTasks: Set<Promise<unknown>>;
|
||||
/**
|
||||
* The subset of `retainedTasks` whose public wait already ended. These are
|
||||
* what keep the stream `DRAINING` and block new admission.
|
||||
*/
|
||||
timedOutTasks: Set<Promise<unknown>>;
|
||||
/**
|
||||
* RT-RR-02. Set when a task was abandoned at its deadline. The resume token
|
||||
* is discarded with it, so the next admitted event cannot skip authoritative
|
||||
* recovery on the strength of state a timed-out effect may have invalidated.
|
||||
*/
|
||||
recoveryRequired: boolean;
|
||||
};
|
||||
|
||||
const SNAPSHOT_CHECKPOINT_KEYS = Object.freeze([
|
||||
@@ -120,9 +170,94 @@ export function createRealtimeStreamCoordinator(
|
||||
): RealtimeStreamCoordinator {
|
||||
assertDependencies(dependencies);
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const limits: RealtimeStreamTaskLimits = Object.freeze({
|
||||
...DEFAULT_REALTIME_STREAM_TASK_LIMITS,
|
||||
...dependencies.taskLimits,
|
||||
});
|
||||
const scheduleTimeout =
|
||||
dependencies.scheduleTimeout ??
|
||||
((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
|
||||
const clearScheduledTimeout =
|
||||
dependencies.clearScheduledTimeout ??
|
||||
((handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
});
|
||||
const states = new Map<StreamRegistrationId, StreamState>();
|
||||
let closed = false;
|
||||
|
||||
/** RT-02. Releasing a timer is best effort and never a public failure. */
|
||||
const clearTimerSafely = (handle: unknown): void => {
|
||||
try {
|
||||
clearScheduledTimeout(handle);
|
||||
} catch {
|
||||
// A broken scheduler cannot change an already classified outcome.
|
||||
}
|
||||
};
|
||||
|
||||
const TASK_TIMED_OUT = Symbol("REALTIME_TASK_TIMED_OUT");
|
||||
|
||||
/**
|
||||
* R-02. Bounds the public wait without discarding the task. A task that
|
||||
* outlives its deadline is retained so `close()` can report honestly whether
|
||||
* the stream is actually quiescent.
|
||||
*/
|
||||
async function awaitTaskWithinDeadline<Value>(
|
||||
state: StreamState,
|
||||
invoke: () => Promise<Value> | Value,
|
||||
timeoutMs: number,
|
||||
revokeAndAbort: () => void,
|
||||
): Promise<Value | typeof TASK_TIMED_OUT> {
|
||||
// RT-01. The collaborator is invoked on the next microtask, after the task
|
||||
// is already in the registry. Calling it first left a window in which an
|
||||
// authority that re-entered `close()` from inside its own invocation saw an
|
||||
// empty registry, so `close()` reported quiescence while its effect was
|
||||
// still running.
|
||||
const task = Promise.resolve().then(invoke);
|
||||
task.catch(() => {});
|
||||
// RT-RR-01. The task is a physical effect the moment it is created, so it
|
||||
// is registered here rather than when its public wait happens to expire.
|
||||
state.retainedTasks.add(task);
|
||||
void task
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.retainedTasks.delete(task);
|
||||
state.timedOutTasks.delete(task);
|
||||
if (state.timedOutTasks.size === 0 && state.lifecycle === "DRAINING") {
|
||||
state.lifecycle = state.closed ? "CLOSED" : "OPEN";
|
||||
if (!state.closed) state.freshness = "STALE";
|
||||
}
|
||||
});
|
||||
// RT-02. A scheduler that cannot install the deadline leaves the wait
|
||||
// unbounded. Letting the exception escape turned a typed realtime result
|
||||
// into a native rejection and — through the caller's own catch — started a
|
||||
// recovery that overlapped the effect still running, so an install failure
|
||||
// fails closed as an expired deadline instead.
|
||||
let handle: unknown;
|
||||
let installed = false;
|
||||
const timeout = new Promise<typeof TASK_TIMED_OUT>((resolve) => {
|
||||
try {
|
||||
handle = scheduleTimeout(() => resolve(TASK_TIMED_OUT), timeoutMs);
|
||||
installed = true;
|
||||
} catch {
|
||||
resolve(TASK_TIMED_OUT);
|
||||
}
|
||||
});
|
||||
const outcome = await Promise.race([task, timeout]);
|
||||
if (installed) clearTimerSafely(handle);
|
||||
if (outcome !== TASK_TIMED_OUT) return outcome;
|
||||
|
||||
// The commit capability is revoked immediately; the work itself is not.
|
||||
revokeAndAbort();
|
||||
state.lifecycle = "DRAINING";
|
||||
state.timedOutTasks.add(task);
|
||||
// RT-RR-02. An abandoned task may have applied part of its effect, so the
|
||||
// resume token it was based on is no longer authoritative evidence.
|
||||
state.recoveryRequired = true;
|
||||
state.resumeState = null;
|
||||
state.freshness = "UNKNOWN";
|
||||
return TASK_TIMED_OUT;
|
||||
}
|
||||
|
||||
for (const registration of dependencies.registry.listStreams()) {
|
||||
states.set(registration.id, {
|
||||
registration,
|
||||
@@ -143,6 +278,10 @@ export function createRealtimeStreamCoordinator(
|
||||
awaitingTransportBarrier: false,
|
||||
barrierCheckpoint: null,
|
||||
closed: false,
|
||||
lifecycle: "OPEN",
|
||||
retainedTasks: new Set(),
|
||||
timedOutTasks: new Set(),
|
||||
recoveryRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,6 +297,11 @@ export function createRealtimeStreamCoordinator(
|
||||
realtimeFailure("MALFORMED_EVENT", "RECEIVE"),
|
||||
);
|
||||
}
|
||||
const draining = states.get(event.envelope.streamId);
|
||||
if (draining && draining.lifecycle === "DRAINING") {
|
||||
// R-02. New work is refused while a retained task is still running.
|
||||
return Promise.resolve(realtimeFailure("CLOSED", "RECEIVE"));
|
||||
}
|
||||
const state = states.get(event.envelope.streamId);
|
||||
if (!state) {
|
||||
return Promise.resolve(
|
||||
@@ -258,6 +402,10 @@ export function createRealtimeStreamCoordinator(
|
||||
return realtimeFailure("ABORTED", "RECEIVE");
|
||||
}
|
||||
if (closed || state.closed) return dropped("CLOSED");
|
||||
// RT-RR-02. Admission happened when this event was queued; execution is a
|
||||
// second decision. A queue entry admitted before the stream entered
|
||||
// DRAINING must not start running inside it.
|
||||
if (state.lifecycle === "DRAINING") return dropped("CLOSED");
|
||||
if (expectedGeneration !== state.processingGeneration) {
|
||||
return dropped("SCOPE_FENCED");
|
||||
}
|
||||
@@ -273,7 +421,11 @@ export function createRealtimeStreamCoordinator(
|
||||
signal,
|
||||
);
|
||||
}
|
||||
if (state.freshness === "UNKNOWN" || !state.resumeState) {
|
||||
if (
|
||||
state.recoveryRequired ||
|
||||
state.freshness === "UNKNOWN" ||
|
||||
!state.resumeState
|
||||
) {
|
||||
return recoverForAccept(state, "INITIALIZE", true, signal);
|
||||
}
|
||||
if (event.envelope.streamEpoch !== state.resumeState.streamEpoch) {
|
||||
@@ -384,19 +536,36 @@ export function createRealtimeStreamCoordinator(
|
||||
!effectAbort.signal.aborted &&
|
||||
scopeIsCurrent(dependencies.scope);
|
||||
let effect: unknown;
|
||||
let effectTimedOut = false;
|
||||
try {
|
||||
effect = await dependencies.authority.effects.apply(
|
||||
eventType.effectProfileId,
|
||||
mapped.value,
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
eventType: eventType.id,
|
||||
occurredAt: event.envelope.occurredAt,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
isCurrent: effectIsCurrent,
|
||||
}),
|
||||
effectAbort.signal,
|
||||
const applied = await awaitTaskWithinDeadline(
|
||||
state,
|
||||
() =>
|
||||
dependencies.authority.effects.apply(
|
||||
eventType.effectProfileId,
|
||||
mapped.value,
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
eventType: eventType.id,
|
||||
occurredAt: event.envelope.occurredAt,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
isCurrent: effectIsCurrent,
|
||||
}),
|
||||
effectAbort.signal,
|
||||
),
|
||||
limits.effectTimeoutMs,
|
||||
() => {
|
||||
// Commit capability is revoked permanently for this attempt.
|
||||
effectLeaseActive = false;
|
||||
effectAbort.abort();
|
||||
},
|
||||
);
|
||||
if (applied === TASK_TIMED_OUT) {
|
||||
effectTimedOut = true;
|
||||
effect = null;
|
||||
} else {
|
||||
effect = applied;
|
||||
}
|
||||
} catch {
|
||||
effect = null;
|
||||
} finally {
|
||||
@@ -406,6 +575,17 @@ export function createRealtimeStreamCoordinator(
|
||||
state.activeEffectAbort = null;
|
||||
}
|
||||
}
|
||||
if (effectTimedOut) {
|
||||
// R-02. Bounded for the caller; the underlying task stays tracked.
|
||||
observe({
|
||||
operation: "APPLY",
|
||||
outcome: "FAILED",
|
||||
streamId: state.registration.id,
|
||||
eventType: event.envelope.eventType,
|
||||
reason: "IDLE_TIMEOUT",
|
||||
});
|
||||
return realtimeFailure("IDLE_TIMEOUT", "APPLY", false);
|
||||
}
|
||||
|
||||
if (closed || state.closed) {
|
||||
return dropped("CLOSED");
|
||||
@@ -483,7 +663,7 @@ export function createRealtimeStreamCoordinator(
|
||||
calledFromCurrentJob: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RealtimeResult<RealtimeRecoveryCheckpoint>> {
|
||||
if (closed || state.closed) {
|
||||
if (closed || state.closed || state.lifecycle === "DRAINING") {
|
||||
return Promise.resolve(realtimeFailure("CLOSED", "RECOVER"));
|
||||
}
|
||||
if (!scopeIsCurrent(dependencies.scope)) {
|
||||
@@ -542,22 +722,49 @@ export function createRealtimeStreamCoordinator(
|
||||
|
||||
let recovered: unknown;
|
||||
let recoveryThrew = false;
|
||||
let recoveryTimedOut = false;
|
||||
try {
|
||||
recovered = await dependencies.authority.recovery.recover(
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
reason,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
signal: recoveryAbort.signal,
|
||||
isCurrent: recoveryIsCurrent,
|
||||
}),
|
||||
const outcome = await awaitTaskWithinDeadline(
|
||||
state,
|
||||
() =>
|
||||
dependencies.authority.recovery.recover(
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
reason,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
signal: recoveryAbort.signal,
|
||||
isCurrent: recoveryIsCurrent,
|
||||
}),
|
||||
),
|
||||
limits.recoveryTimeoutMs,
|
||||
() => {
|
||||
recoveryLeaseActive = false;
|
||||
recoveryAbort.abort();
|
||||
},
|
||||
);
|
||||
if (outcome === TASK_TIMED_OUT) {
|
||||
recoveryTimedOut = true;
|
||||
recovered = null;
|
||||
} else {
|
||||
recovered = outcome;
|
||||
}
|
||||
} catch {
|
||||
recovered = null;
|
||||
recoveryThrew = true;
|
||||
} finally {
|
||||
recoveryLeaseActive = false;
|
||||
}
|
||||
if (recoveryTimedOut) {
|
||||
// R-02. A late checkpoint from this attempt can never commit.
|
||||
state.freshness = "UNKNOWN";
|
||||
observe({
|
||||
operation: "RECOVER",
|
||||
outcome: "FAILED",
|
||||
streamId: state.registration.id,
|
||||
reason: "IDLE_TIMEOUT",
|
||||
});
|
||||
return realtimeFailure("IDLE_TIMEOUT", "RECOVER", false);
|
||||
}
|
||||
if (closed || state.closed) {
|
||||
state.freshness = "UNKNOWN";
|
||||
return realtimeFailure("CLOSED", "RECOVER");
|
||||
@@ -631,6 +838,9 @@ export function createRealtimeStreamCoordinator(
|
||||
validatedResumeState as RealtimeRecoveryCheckpoint;
|
||||
|
||||
state.resumeState = resumeState;
|
||||
// RT-RR-02. Authoritative recovery is the only thing that clears the
|
||||
// requirement a timed-out task imposed.
|
||||
state.recoveryRequired = false;
|
||||
state.awaitingTransportBarrier =
|
||||
recoveryRequiresTransportBarrier(state.registration);
|
||||
state.barrierCheckpoint = state.awaitingTransportBarrier
|
||||
@@ -806,7 +1016,53 @@ export function createRealtimeStreamCoordinator(
|
||||
});
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
function lifecycleOf(streamId: StreamRegistrationId): RealtimeStreamLifecycle {
|
||||
const state = states.get(streamId);
|
||||
if (!state) return "CLOSED";
|
||||
return state.lifecycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* R-02. `close()` fences immediately but reports honestly: success only when
|
||||
* every retained task actually settled within the drain bound.
|
||||
*/
|
||||
async function close(): Promise<RealtimeResult<void>> {
|
||||
fenceAllStates();
|
||||
const retained = [...states.values()].flatMap((state) => [
|
||||
...state.retainedTasks,
|
||||
]);
|
||||
if (retained.length === 0) {
|
||||
for (const state of states.values()) state.lifecycle = "CLOSED";
|
||||
return realtimeSuccess(undefined);
|
||||
}
|
||||
let handle: unknown;
|
||||
let installed = false;
|
||||
const drained = await Promise.race([
|
||||
Promise.allSettled(retained).then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
try {
|
||||
handle = scheduleTimeout(
|
||||
() => resolve(false),
|
||||
limits.drainTimeoutMs,
|
||||
);
|
||||
installed = true;
|
||||
} catch {
|
||||
// RT-02. Without a drain bound this call cannot prove quiescence, so
|
||||
// it reports the honest failure rather than rejecting natively.
|
||||
resolve(false);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
if (installed) clearTimerSafely(handle);
|
||||
if (!drained) {
|
||||
// Still DRAINING: the caller must not treat this as quiescence.
|
||||
return realtimeFailure("IDLE_TIMEOUT", "CLOSE", false);
|
||||
}
|
||||
for (const state of states.values()) state.lifecycle = "CLOSED";
|
||||
return realtimeSuccess(undefined);
|
||||
}
|
||||
|
||||
function fenceAllStates(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const state of states.values()) {
|
||||
@@ -826,6 +1082,8 @@ export function createRealtimeStreamCoordinator(
|
||||
state.eventIds.clear();
|
||||
state.sequences.clear();
|
||||
state.dedupeBytes = 0;
|
||||
state.lifecycle =
|
||||
state.retainedTasks.size > 0 ? "DRAINING" : "CLOSED";
|
||||
}
|
||||
observe({
|
||||
operation: "CLOSE",
|
||||
@@ -911,6 +1169,7 @@ export function createRealtimeStreamCoordinator(
|
||||
confirmTransportBarrier,
|
||||
getResumeState,
|
||||
inspect,
|
||||
lifecycle: lifecycleOf,
|
||||
close,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,10 +222,11 @@ export function decodeWebSocketServerFrame(
|
||||
if (!isPositiveInteger(maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
const byteLength = utf8ByteLength(input);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
if (exceedsUtf8ByteLimit(input, maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
// Only an admitted frame pays for the exact length.
|
||||
const byteLength = utf8ByteLength(input);
|
||||
if (
|
||||
hasDuplicateJsonMembers(input, {
|
||||
maxDepth: MAX_FRAME_STRUCTURE_DEPTH,
|
||||
@@ -294,10 +295,12 @@ export function encodeWebSocketClientFrame(
|
||||
} catch {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
const byteLength = utf8ByteLength(value);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
// Reject before allocating the encoded copy; the exact length is only
|
||||
// computed for a frame that is going to be sent.
|
||||
if (exceedsUtf8ByteLimit(value, maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
const byteLength = utf8ByteLength(value);
|
||||
return Object.freeze({ ok: true, value, byteLength });
|
||||
}
|
||||
|
||||
@@ -466,10 +469,45 @@ function isUnsignedSequence(input: unknown): input is string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R-05. Admission before allocation.
|
||||
*
|
||||
* UTF-8 needs at least one byte per UTF-16 code unit, so a string longer than
|
||||
* the cap is already over it and is rejected without touching an encoder. The
|
||||
* remainder is counted incrementally with an early exit, so a hostile frame
|
||||
* never causes a second full-size buffer. A valid surrogate pair counts as four
|
||||
* bytes and a lone surrogate as the three-byte replacement sequence, exactly
|
||||
* like `TextEncoder`.
|
||||
*/
|
||||
function utf8ByteLength(input: string): number {
|
||||
return new TextEncoder().encode(input).byteLength;
|
||||
}
|
||||
|
||||
function exceedsUtf8ByteLimit(input: string, maxBytes: number): boolean {
|
||||
if (input.length > maxBytes) return true;
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code < 0x80) bytes += 1;
|
||||
else if (code < 0x800) bytes += 2;
|
||||
else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = index + 1 < input.length ? input.charCodeAt(index + 1) : 0;
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
// Lone high surrogate: TextEncoder emits U+FFFD.
|
||||
bytes += 3;
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
// Lone low surrogate: TextEncoder emits U+FFFD.
|
||||
bytes += 3;
|
||||
} else bytes += 3;
|
||||
if (bytes > maxBytes) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function protocolFailure(
|
||||
code: WebSocketProtocolFailure["code"],
|
||||
): WebSocketProtocolResult<never> {
|
||||
|
||||
@@ -40,7 +40,8 @@ const scope: WorkerScopeLike = {
|
||||
open: (name) => caches.open(name),
|
||||
keys: () => caches.keys(),
|
||||
delete: (name) => caches.delete(name),
|
||||
match: (request) => caches.match(request),
|
||||
// SW-01. No CacheStorage-wide match: only the current release cache may
|
||||
// answer a verified static request.
|
||||
},
|
||||
clients: {
|
||||
matchAll: (options) =>
|
||||
|
||||
@@ -33,7 +33,6 @@ export type WorkerScopeLike = Readonly<{
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
keys(): Promise<readonly string[]>;
|
||||
delete(cacheName: string): Promise<boolean>;
|
||||
match(request: string): Promise<Response | undefined>;
|
||||
}>;
|
||||
clients: Readonly<{
|
||||
matchAll(
|
||||
@@ -59,6 +58,34 @@ export type WorkerRuntimeConfig = Readonly<{
|
||||
releaseManifestUrl: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* SW-URL-01. Root-relative generated URLs become absolute same-origin URLs
|
||||
* exactly once. Anything that escapes the scope origin is dropped rather than
|
||||
* silently classified.
|
||||
*/
|
||||
function canonicalManifestUrls(
|
||||
assets: readonly Readonly<{ url: string }>[],
|
||||
scopeHref: string,
|
||||
): readonly string[] {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(scopeHref);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const canonical: string[] = [];
|
||||
for (const asset of assets) {
|
||||
try {
|
||||
const absolute = new URL(asset.url, base);
|
||||
if (absolute.origin !== base.origin) continue;
|
||||
canonical.push(absolute.href);
|
||||
} catch {
|
||||
// A manifest URL that cannot be canonicalized is never classified.
|
||||
}
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const ACTIVATION_MARKER_URL =
|
||||
"https://clean-architecture.invalid/__service-worker-activation-v1__";
|
||||
const ACTIVATION_MARKER_MAX_BYTES = 256;
|
||||
@@ -73,8 +100,22 @@ export function createServiceWorkerRuntime(
|
||||
config: WorkerRuntimeConfig,
|
||||
) {
|
||||
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
|
||||
const manifestUrls = new Set(
|
||||
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
|
||||
/**
|
||||
* SW-URL-01. The generated manifest stores root-relative URLs while `Request`
|
||||
* exposes absolute ones, so comparing the two directly classified every
|
||||
* verified asset as a network fallback. Canonicalize once against the
|
||||
* registration scope, re-check same-origin, and share that identity across
|
||||
* install cache keys, fetch classification and cache lookup or delete.
|
||||
*/
|
||||
const manifestUrls: ReadonlySet<string> = Object.freeze(
|
||||
new Set(
|
||||
staticEnabled
|
||||
? canonicalManifestUrls(
|
||||
config.manifest?.assets ?? [],
|
||||
scope.registrationScope,
|
||||
)
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const consumedNonces = new Set<string>();
|
||||
type PendingActivation = Readonly<{
|
||||
@@ -176,17 +217,30 @@ export function createServiceWorkerRuntime(
|
||||
});
|
||||
if (classification !== "VERIFIED_CACHE_FIRST") return null;
|
||||
|
||||
const cached = await scope.caches.match(request.url);
|
||||
// SW-01. Only the current release cache may answer. A CacheStorage-wide
|
||||
// match could return a previous release's response for the same URL, and
|
||||
// the subsequent delete would then target a cache that was never read.
|
||||
const currentCacheName = config.manifest
|
||||
? staticCacheName(config.manifest.setDigest)
|
||||
: null;
|
||||
if (!currentCacheName) return null;
|
||||
// SW-RR-04. Both `open` and `match` are storage calls that can throw
|
||||
// synchronously or reject. Either one escaping here rejects `respondWith`
|
||||
// itself, so the entry never reaches its network fallback and the page
|
||||
// gets a network error instead of the live response.
|
||||
let cached: Response | undefined;
|
||||
let currentCache: Cache;
|
||||
try {
|
||||
currentCache = await scope.caches.open(currentCacheName);
|
||||
cached = await currentCache.match(request.url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!cached) return null;
|
||||
if (cached.status !== 200 || cached.type === "opaque") {
|
||||
// §18.6. An invalid hit is deleted and treated as a release mismatch.
|
||||
const current = config.manifest
|
||||
? staticCacheName(config.manifest.setDigest)
|
||||
: null;
|
||||
if (current) {
|
||||
const cache = await scope.caches.open(current);
|
||||
await cache.delete(request.url).catch(() => false);
|
||||
}
|
||||
// §18.6. An invalid hit is deleted from the cache it was read from and
|
||||
// treated as a release mismatch.
|
||||
await currentCache.delete(request.url).catch(() => false);
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
@@ -228,49 +282,64 @@ export function createServiceWorkerRuntime(
|
||||
parsed.message.sourceBuildId,
|
||||
);
|
||||
if (!drained) {
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REJECTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
||||
return "REJECTED";
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_ACCEPTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await scope.skipWaiting();
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATED_RELOAD_REQUIRED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
// SW-08. `skipWaiting()` is the activation commit. It must succeed before
|
||||
// any client is told the activation was accepted, and its failure is a
|
||||
// rejection rather than an accepted-then-failed activation.
|
||||
try {
|
||||
await scope.skipWaiting();
|
||||
} catch {
|
||||
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
||||
return "REJECTED";
|
||||
}
|
||||
// Post-commit notifications are per-client best effort.
|
||||
notifyClients(clients, "ACTIVATE_ACCEPTED", parsed.message.sourceBuildId, nonce);
|
||||
notifyClients(
|
||||
clients,
|
||||
"ACTIVATED_RELOAD_REQUIRED",
|
||||
parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
);
|
||||
return "ACCEPTED";
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-08. One client's `postMessage()` throwing must not break the whole
|
||||
* activation event; delivery is isolated per client.
|
||||
*/
|
||||
function notifyClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
kind: "ACTIVATE_REJECTED" | "ACTIVATE_ACCEPTED" | "ACTIVATED_RELOAD_REQUIRED",
|
||||
targetBuildId: string,
|
||||
nonce: string,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind,
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// A dead client cannot change the already committed activation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function drainClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
nonce: string,
|
||||
requesterBuildId: string,
|
||||
): Promise<boolean> {
|
||||
if (clients.length === 0) return false;
|
||||
// SW-07. No in-scope client means nothing dirty to drain, so the set is
|
||||
// vacuously drained. A `clients.matchAll()` failure still rejects upstream.
|
||||
if (clients.length === 0) return true;
|
||||
const drained = new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingActivations.delete(nonce);
|
||||
@@ -287,15 +356,24 @@ export function createServiceWorkerRuntime(
|
||||
}),
|
||||
);
|
||||
});
|
||||
// SW-08. A client that cannot receive the drain request can never
|
||||
// acknowledge it, so it fails immediately instead of holding the pending
|
||||
// state until the timeout.
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
const pending = pendingActivations.get(nonce);
|
||||
if (pending) settlePendingActivation(nonce, pending, false);
|
||||
return await drained;
|
||||
}
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
@@ -347,7 +425,9 @@ export function createServiceWorkerRuntime(
|
||||
let cachesDeleted = 0;
|
||||
const names = await scope.caches.keys();
|
||||
for (const name of names) {
|
||||
if (!name.startsWith("ca-static-v1-")) continue;
|
||||
// SW-02. Exact ownership only: a prefix match would also delete
|
||||
// `ca-static-v1-not-owned` and any longer-suffixed foreign cache.
|
||||
if (!isOwnedStaticCacheName(name)) continue;
|
||||
try {
|
||||
if (await scope.caches.delete(name)) cachesDeleted += 1;
|
||||
} catch {
|
||||
@@ -399,6 +479,141 @@ function isClientWithinRegistrationScope(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the
|
||||
* reader as soon as that byte arrives, and fails closed on invalid UTF-8. The
|
||||
* reader is also raced against a deadline so a stream that never produces a
|
||||
* chunk cannot hold `activate` open.
|
||||
*/
|
||||
const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000;
|
||||
|
||||
async function readBoundedMarkerText(
|
||||
response: Response,
|
||||
): Promise<string | null> {
|
||||
if (!response.body) {
|
||||
try {
|
||||
const text = await response.text();
|
||||
return new TextEncoder().encode(text).byteLength >
|
||||
ACTIVATION_MARKER_MAX_BYTES
|
||||
? null
|
||||
: text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// SW-01. The allowance is `limit + 1` bytes: one byte past the ceiling is
|
||||
// enough to prove the marker is oversized, and nothing beyond it is ever
|
||||
// retained. A BYOB reader asks the source for exactly the remaining
|
||||
// allowance, so a corrupt body cannot answer a 1 MiB chunk to a 257-byte
|
||||
// request. Without BYOB the first oversized chunk is refused outright rather
|
||||
// than copied and then measured.
|
||||
const allowance = ACTIVATION_MARKER_MAX_BYTES + 1;
|
||||
const reader = byobReader(response.body) ?? response.body.getReader();
|
||||
const byob = "read" in reader && isByobReader(reader);
|
||||
const bytes = new Uint8Array(allowance);
|
||||
let total = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelled = false;
|
||||
const deadline = new Promise<"DEADLINE">((resolve) => {
|
||||
try {
|
||||
timer = setTimeout(
|
||||
() => resolve("DEADLINE"),
|
||||
ACTIVATION_MARKER_READ_DEADLINE_MS,
|
||||
);
|
||||
} catch {
|
||||
// An unschedulable deadline leaves the read unbounded, so it ends now.
|
||||
resolve("DEADLINE");
|
||||
}
|
||||
});
|
||||
const cancelOnce = (): void => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
// Never awaited: cancelling a stream whose source ignores cancellation can
|
||||
// itself hang, and the marker read already has its answer.
|
||||
try {
|
||||
void reader.cancel().catch(() => {});
|
||||
} catch {
|
||||
// A hostile reader cannot block the release below.
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (;;) {
|
||||
const remaining = allowance - total;
|
||||
if (remaining <= 0) {
|
||||
// More than the ceiling has already arrived.
|
||||
return null;
|
||||
}
|
||||
const pending = byob
|
||||
? (reader as ReadableStreamBYOBReader).read(
|
||||
new Uint8Array(remaining),
|
||||
)
|
||||
: (reader as ReadableStreamDefaultReader<Uint8Array>).read();
|
||||
const next = await Promise.race([pending, deadline]);
|
||||
if (next === "DEADLINE") {
|
||||
cancelOnce();
|
||||
return null;
|
||||
}
|
||||
if (next.done) break;
|
||||
const chunk = next.value;
|
||||
if (!chunk) continue;
|
||||
if (chunk.byteLength > remaining) {
|
||||
// Refused before it is retained: the oversized chunk is not copied.
|
||||
cancelOnce();
|
||||
return null;
|
||||
}
|
||||
bytes.set(chunk, total);
|
||||
total += chunk.byteLength;
|
||||
}
|
||||
} catch {
|
||||
cancelOnce();
|
||||
return null;
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
try {
|
||||
clearTimeout(timer);
|
||||
} catch {
|
||||
// Releasing the timer is best effort.
|
||||
}
|
||||
}
|
||||
cancelOnce();
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// A cancelled reader has already released its lock.
|
||||
}
|
||||
}
|
||||
if (total > ACTIVATION_MARKER_MAX_BYTES) return null;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
bytes.subarray(0, total),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A byte stream can hand out a BYOB reader; a regular one cannot. */
|
||||
function byobReader(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): ReadableStreamBYOBReader | null {
|
||||
try {
|
||||
return (
|
||||
body as ReadableStream<Uint8Array> & {
|
||||
getReader(options: { mode: "byob" }): ReadableStreamBYOBReader;
|
||||
}
|
||||
).getReader({ mode: "byob" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isByobReader(reader: unknown): reader is ReadableStreamBYOBReader {
|
||||
return (
|
||||
typeof ReadableStreamBYOBReader === "function" &&
|
||||
reader instanceof ReadableStreamBYOBReader
|
||||
);
|
||||
}
|
||||
|
||||
async function readActivationMarker(
|
||||
cache: Cache,
|
||||
expectedCacheName: string,
|
||||
@@ -412,12 +627,21 @@ async function readActivationMarker(
|
||||
(!/^\d+$/u.test(declaredLength) ||
|
||||
Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES)
|
||||
) {
|
||||
// SW-01. A declared oversize ends the read, but the body it declared is
|
||||
// still an open stream: returning without cancelling it left the source
|
||||
// holding the connection for the rest of the worker's life.
|
||||
try {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
// Cancelling is best effort and cannot change the closed outcome.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
// SW-RR-01. A Content-Length is a claim, not a bound. Without one the
|
||||
// previous `response.text()` read the whole body, so a large or
|
||||
// non-terminating stream could consume the activation step indefinitely.
|
||||
const text = await readBoundedMarkerText(response);
|
||||
if (text === null) return null;
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (
|
||||
value === null ||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
type InstalledServiceWorkerSelection,
|
||||
type ServiceWorkerActivationOutcome,
|
||||
type ServiceWorkerRemovalOutcome,
|
||||
type ServiceWorkerResetOutcome,
|
||||
type ServiceWorkerRuntimeHost,
|
||||
type ServiceWorkerStartOutcome,
|
||||
@@ -57,6 +58,9 @@ export function createServiceWorkerPageController(
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
let updateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** SW-06. Single-flight command state. */
|
||||
let activationInFlight: Promise<ServiceWorkerActivationOutcome> | null = null;
|
||||
let resetInFlight: Promise<ServiceWorkerResetOutcome> | null = null;
|
||||
let stopped = false;
|
||||
const pendingStops = new Set<() => void>();
|
||||
|
||||
@@ -75,6 +79,29 @@ export function createServiceWorkerPageController(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-04. Staged removal reports what actually happened.
|
||||
*
|
||||
* Returning DISABLED for every outcome let a later release delete the worker
|
||||
* source and handlers while a registration or an owned cache was still
|
||||
* present, or while the registration belonged to someone else.
|
||||
*/
|
||||
function removalStartOutcome(
|
||||
outcome: ServiceWorkerRemovalOutcome,
|
||||
failureReason: string,
|
||||
): ServiceWorkerStartOutcome {
|
||||
switch (outcome.kind) {
|
||||
case "ABSENT":
|
||||
case "UNREGISTERED":
|
||||
case "PURGED":
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
case "OWNERSHIP_MISMATCH":
|
||||
return Object.freeze({ kind: "INCOMPATIBLE" as const });
|
||||
case "FAILED":
|
||||
return failed(failureReason);
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<ServiceWorkerStartOutcome> {
|
||||
if (stopped) return failed("STOPPED");
|
||||
const container = dependencies.container;
|
||||
@@ -90,8 +117,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("disable_cleanup", outcome.kind);
|
||||
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED");
|
||||
}
|
||||
|
||||
const selection = dependencies.selection;
|
||||
@@ -108,7 +134,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("remove_registration", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "REMOVE_FAILED");
|
||||
}
|
||||
if (selection.mode === "PURGE_OWNED_RESOURCES") {
|
||||
const outcome = await purgeOwnedResources({
|
||||
@@ -118,7 +144,7 @@ export function createServiceWorkerPageController(
|
||||
origin: dependencies.origin,
|
||||
});
|
||||
observe("purge_owned_resources", outcome.kind);
|
||||
return Object.freeze({ kind: "DISABLED" as const });
|
||||
return removalStartOutcome(outcome, "PURGE_FAILED");
|
||||
}
|
||||
|
||||
// §17.5. StrictMode's repeated effect returns the same in-flight promise
|
||||
@@ -194,6 +220,13 @@ export function createServiceWorkerPageController(
|
||||
observe("client_drain", "MALFORMED");
|
||||
return;
|
||||
}
|
||||
// SW-06. An arbitrary same-origin source must not be able to close this
|
||||
// page's admission. The request has to come from the worker we are
|
||||
// actually waiting on or the one currently controlling us.
|
||||
if (!isExpectedWorkerSource(source)) {
|
||||
observe("client_drain", "SOURCE_MISMATCH");
|
||||
return;
|
||||
}
|
||||
const rejected = isBlocked();
|
||||
source.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
@@ -211,6 +244,23 @@ export function createServiceWorkerPageController(
|
||||
container.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-06. Source identity is checked by object identity against the
|
||||
* registration's waiting/installing/active worker and the container's
|
||||
* controller. An empty `event.origin` is never used as a trust signal.
|
||||
*/
|
||||
function isExpectedWorkerSource(source: unknown): boolean {
|
||||
const expected = [
|
||||
registration?.waiting,
|
||||
registration?.installing,
|
||||
registration?.active,
|
||||
dependencies.container?.controller,
|
||||
];
|
||||
return expected.some(
|
||||
(candidate) => candidate != null && candidate === source,
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(): void {
|
||||
// §17.14. At most one check per 6 hours, and none while the page is hidden.
|
||||
if (updateTimer) return;
|
||||
@@ -228,7 +278,16 @@ export function createServiceWorkerPageController(
|
||||
* §17.11. Activation is a handshake: every controlled client must close new
|
||||
* admission and acknowledge within 30s. One missing client rejects it.
|
||||
*/
|
||||
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
// SW-06. Concurrent callers share one command: a second call must not issue
|
||||
// a second nonce, a second listener or a second postMessage.
|
||||
activationInFlight ??= runActivation().finally(() => {
|
||||
activationInFlight = null;
|
||||
});
|
||||
return activationInFlight;
|
||||
}
|
||||
|
||||
async function runActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
const waiting = registration?.waiting;
|
||||
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
|
||||
if (isBlocked()) {
|
||||
@@ -264,6 +323,18 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// SW-06 / SW-RR-02. The reply must come from the exact worker this
|
||||
// request was sent to. A matching nonce is not identity: `null`
|
||||
// source means the sender cannot be established, so it is refused
|
||||
// like any other mismatch rather than accepted as this worker.
|
||||
if (event.source !== waiting) {
|
||||
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
if (registration?.waiting !== waiting) {
|
||||
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "ACTIVATE_REJECTED" &&
|
||||
parsed.message.nonce === nonce
|
||||
@@ -306,11 +377,21 @@ export function createServiceWorkerPageController(
|
||||
}
|
||||
|
||||
/** §18.10. Static caches only; the registration itself is left in place. */
|
||||
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
// SW-06. Single-flight, like activation.
|
||||
resetInFlight ??= runReset().finally(() => {
|
||||
resetInFlight = null;
|
||||
});
|
||||
return resetInFlight;
|
||||
}
|
||||
|
||||
async function runReset(): Promise<ServiceWorkerResetOutcome> {
|
||||
const container = dependencies.container;
|
||||
if (!container?.controller) {
|
||||
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
||||
}
|
||||
// SW-06. The reply must come from the controller this request was sent to.
|
||||
const requestedController = container.controller;
|
||||
const nonce = nonces.issue();
|
||||
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
|
||||
let settled = false;
|
||||
@@ -334,6 +415,20 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// SW-RR-02. An unattributable reset result is never proof this
|
||||
// controller performed the reset.
|
||||
if (
|
||||
event.source !== requestedController ||
|
||||
container.controller !== requestedController
|
||||
) {
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
code: "PROTOCOL_MISMATCH",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "RESET" as const,
|
||||
|
||||
@@ -109,14 +109,24 @@ export async function removeOwnedRegistration(
|
||||
) {
|
||||
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
|
||||
}
|
||||
let unregistered: boolean;
|
||||
try {
|
||||
await registration.unregister();
|
||||
unregistered = await registration.unregister();
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
// SW-03. `unregister()` resolving is not success: `false` means the
|
||||
// registration is still installed, so reporting UNREGISTERED would let a
|
||||
// later release delete the worker source while it is still controlling.
|
||||
if (!unregistered) {
|
||||
return Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
operation: "UNREGISTER" as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ kind: "UNREGISTERED" as const });
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,19 @@ export async function installStaticAssets(
|
||||
|
||||
if (outcome.kind === "REJECTED") {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
// SW-09. A non-cooperative fetch, digest or `cache.put` started before the
|
||||
// deadline cannot be cancelled, so it may recreate the candidate cache
|
||||
// after that delete. The public result already closed at the deadline; a
|
||||
// second exact delete is registered once the abandoned work settles. It is
|
||||
// deliberately not awaited, so the public bound is not extended.
|
||||
if (deadlineExceeded) {
|
||||
void installation
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -173,6 +186,11 @@ async function installCandidate(
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (failure) return;
|
||||
// SW-09. Once fenced, no new candidate work is started.
|
||||
if (signal.aborted) {
|
||||
failure ??= rejected("INSTALL_DEADLINE_EXCEEDED");
|
||||
return;
|
||||
}
|
||||
const asset = queue.shift();
|
||||
if (!asset) return;
|
||||
const outcome = await storeAsset(asset, cache, dependencies, signal);
|
||||
@@ -205,15 +223,22 @@ async function storeAsset(
|
||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||
let response: Response;
|
||||
try {
|
||||
const fetched = await abortable(
|
||||
dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
}),
|
||||
// SW-09. A non-cooperative fetch that ignores the signal still settles
|
||||
// later; its body is compensated so an abandoned response is not left open.
|
||||
const pending = dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
);
|
||||
});
|
||||
const fetched = await abortable(pending, signal);
|
||||
if (fetched === ABORTED) {
|
||||
void pending
|
||||
.then(async (late) => {
|
||||
await late.body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (fetched === ABORTED) return rejected("FETCH_FAILED");
|
||||
response = fetched;
|
||||
} catch {
|
||||
@@ -234,7 +259,17 @@ async function storeAsset(
|
||||
if (!body.ok) return rejected(body.code);
|
||||
const bytes = body.bytes;
|
||||
|
||||
const digest = await abortable(dependencies.digest(bytes), signal);
|
||||
// SW-09. A digest dependency that throws becomes a closed typed outcome
|
||||
// rather than an escaping rejection.
|
||||
let digest: string | typeof ABORTED;
|
||||
try {
|
||||
digest = await abortable(
|
||||
Promise.resolve(dependencies.digest(bytes)),
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return rejected("INTEGRITY_MISMATCH");
|
||||
}
|
||||
if (digest === ABORTED) return rejected("FETCH_FAILED");
|
||||
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
|
||||
|
||||
|
||||
@@ -972,6 +972,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
prepared: readonly PreparedRecord<WireValue>[],
|
||||
scan: ScanBatch,
|
||||
budgetExhausted: boolean,
|
||||
deadline: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<
|
||||
BrowserDataResult<IndexedDbMaintenanceBatchReceipt>
|
||||
@@ -1048,6 +1049,22 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
finishWithCheckpoint();
|
||||
return;
|
||||
}
|
||||
// STO-06. The commit chain runs entirely inside IndexedDB callbacks,
|
||||
// so without this check up to `maxRows` records keep executing past
|
||||
// the caller's invocation deadline. The budget is only ever checked
|
||||
// before a record's first write, so a started record still finishes
|
||||
// atomically and the checkpoint stays exactly at the last safe key.
|
||||
const currentTime = clock();
|
||||
if (!currentTime.ok) {
|
||||
// A broken clock aborts rather than committing an unbounded batch.
|
||||
context.fail(currentTime);
|
||||
return;
|
||||
}
|
||||
if (currentTime.value >= deadline) {
|
||||
budgetExhausted = true;
|
||||
finishWithCheckpoint();
|
||||
return;
|
||||
}
|
||||
let request: IDBRequest<unknown>;
|
||||
try {
|
||||
request = records.get(preparedRecord.source.key);
|
||||
@@ -1337,6 +1354,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
prepared.value.records,
|
||||
scan.value,
|
||||
prepared.value.budgetExhausted,
|
||||
deadline,
|
||||
input.signal,
|
||||
);
|
||||
return observeResult(
|
||||
|
||||
@@ -1650,12 +1650,26 @@ function isChunkReference(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
|
||||
* window; v2 additionally carries a transaction-unique physical fencing token.
|
||||
*/
|
||||
function isSupportedPhysicalSchema(value: object): boolean {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.physicalSchemaVersion === 1) return true;
|
||||
return (
|
||||
record.physicalSchemaVersion === 2 &&
|
||||
typeof record.physicalGenerationId === "string" &&
|
||||
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
|
||||
);
|
||||
}
|
||||
|
||||
function isPreparedObject(value: unknown): value is OpfsPreparedObject {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!isSupportedPhysicalSchema(value) ||
|
||||
!("descriptor" in value) ||
|
||||
!value.descriptor ||
|
||||
typeof value.descriptor !== "object" ||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OpfsReconciliationReport,
|
||||
OpfsStorageScope,
|
||||
PutDurableObjectRequest,
|
||||
OpfsPhysicalGenerationId,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import {
|
||||
type BrowserDataFailure,
|
||||
@@ -89,6 +90,12 @@ export type OpfsByteStoreDependencies = Readonly<{
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
policy?: Partial<OpfsRuntimePolicy>;
|
||||
createTransactionId?: () => string;
|
||||
createPhysicalGenerationId?: () => OpfsPhysicalGenerationId;
|
||||
/**
|
||||
* Composition-owned bounded signal for compensating cleanup. It is
|
||||
* deliberately separate from any caller signal.
|
||||
*/
|
||||
compensationSignal?: AbortSignal;
|
||||
now?: () => number;
|
||||
observer?: OpfsSafeObserver;
|
||||
/**
|
||||
@@ -130,6 +137,20 @@ export function createOpfsByteStoreAdapter(
|
||||
const createTransactionId =
|
||||
dependencies.createTransactionId ??
|
||||
(() => globalThis.crypto.randomUUID());
|
||||
const createPhysicalGenerationId =
|
||||
dependencies.createPhysicalGenerationId ??
|
||||
(() => {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
let hex = "";
|
||||
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
||||
return hex as OpfsPhysicalGenerationId;
|
||||
});
|
||||
/**
|
||||
* STO-01. Compensation must not inherit the caller's already aborted signal;
|
||||
* a cleanup that never starts cannot justify releasing the journal row.
|
||||
*/
|
||||
const compensationSignal = dependencies.compensationSignal;
|
||||
const now = dependencies.now ?? Date.now;
|
||||
|
||||
const objects: DurableObjectStorePort = Object.freeze({
|
||||
@@ -182,6 +203,10 @@ export function createOpfsByteStoreAdapter(
|
||||
}
|
||||
const targetGeneration = (current?.descriptor.generation ?? 0) + 1;
|
||||
const transactionId = createTransactionId();
|
||||
// STO-01. The logical generation is reused across transactions; this
|
||||
// token makes the physical target unique so a late compensation can never
|
||||
// delete a newer transaction's directory.
|
||||
const physicalGenerationId = createPhysicalGenerationId();
|
||||
notifyProgress(request, "PREPARING", 0);
|
||||
const begun = await dependencies.journal.begin({
|
||||
transactionId,
|
||||
@@ -214,13 +239,24 @@ export function createOpfsByteStoreAdapter(
|
||||
});
|
||||
const prepared = await dependencies.worker.preparePut({
|
||||
transactionId,
|
||||
physicalGenerationId,
|
||||
descriptor,
|
||||
source: request.source,
|
||||
signal: request.signal,
|
||||
onProgress: request.onProgress,
|
||||
});
|
||||
if (!prepared.ok) {
|
||||
await rollbackBestEffort(begun.value, request.signal);
|
||||
const compensated = await compensatePreparedPut(
|
||||
begun.value,
|
||||
physicalGenerationId,
|
||||
);
|
||||
if (!compensated.ok) {
|
||||
return observeFailure(
|
||||
compensated,
|
||||
dependencies.observer,
|
||||
request.source.byteLength!,
|
||||
);
|
||||
}
|
||||
return observeFailure(
|
||||
prepared,
|
||||
dependencies.observer,
|
||||
@@ -234,7 +270,17 @@ export function createOpfsByteStoreAdapter(
|
||||
prepared.value,
|
||||
);
|
||||
if (!filesReady.ok) {
|
||||
await rollbackBestEffort(begun.value, request.signal);
|
||||
const compensated = await compensatePreparedPut(
|
||||
begun.value,
|
||||
physicalGenerationId,
|
||||
);
|
||||
if (!compensated.ok) {
|
||||
return observeFailure(
|
||||
compensated,
|
||||
dependencies.observer,
|
||||
request.source.byteLength!,
|
||||
);
|
||||
}
|
||||
return observeFailure(
|
||||
rebaseFailure(filesReady.error, "OBJECT_WRITE"),
|
||||
dependencies.observer,
|
||||
@@ -266,12 +312,45 @@ export function createOpfsByteStoreAdapter(
|
||||
prepared.value,
|
||||
request.signal,
|
||||
);
|
||||
if (finalized.ok) {
|
||||
await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
if (!finalized.ok) {
|
||||
// STO-RR-01. The commit fence already passed, so the payload is durable
|
||||
// and the journal keeps its COMMITTED record for reconciliation to
|
||||
// settle. What did not happen is finalization: the previous generation
|
||||
// and the staging directory are still present. Reporting a plain
|
||||
// success here would claim a settled state nobody observed, so the
|
||||
// worker's own failure is surfaced and the record is left recoverable.
|
||||
return observeFailure(
|
||||
rebaseFailure(finalized.error, "OBJECT_WRITE"),
|
||||
dependencies.observer,
|
||||
request.source.byteLength!,
|
||||
);
|
||||
}
|
||||
const completed = await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
);
|
||||
if (!completed.ok) {
|
||||
// NS-04. The payload is durable and finalized, so nothing is rolled
|
||||
// back and the `COMMITTED` row stays the reconciler's authority. What
|
||||
// did not happen is settling the transaction, and reporting a plain
|
||||
// success for it claimed a state nobody observed while the reconcile
|
||||
// backlog and its quota pressure grew unseen.
|
||||
observeOpfsSafely(dependencies.observer, {
|
||||
operation: "OBJECT_WRITE",
|
||||
outcome: "DEGRADED",
|
||||
failureCode: completed.error.code,
|
||||
byteBucket: byteBucket(prepared.value.descriptor.byteLength),
|
||||
});
|
||||
return {
|
||||
ok: false as const,
|
||||
error: Object.freeze({
|
||||
...completed.error,
|
||||
operation: "OBJECT_WRITE" as const,
|
||||
retryable: true,
|
||||
recovery: "RECONCILE" as const,
|
||||
}),
|
||||
};
|
||||
}
|
||||
observeOpfsSafely(dependencies.observer, {
|
||||
operation: "OBJECT_WRITE",
|
||||
outcome: "SUCCEEDED",
|
||||
@@ -438,17 +517,27 @@ export function createOpfsByteStoreAdapter(
|
||||
request.expectedGeneration,
|
||||
request.signal,
|
||||
);
|
||||
if (removed.ok) {
|
||||
await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
);
|
||||
}
|
||||
const completed = removed.ok
|
||||
? await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
)
|
||||
: null;
|
||||
// Logical deletion is already committed. Physical cleanup is retryable
|
||||
// maintenance and must not make the caller repeat a non-idempotent delete.
|
||||
// NS-04. It is still not a settled state: an unfinished physical removal
|
||||
// or an unsettled journal row is maintenance debt the reconciler owns, so
|
||||
// it is observed as such instead of as a clean success.
|
||||
const settled = removed.ok && completed !== null && completed.ok;
|
||||
const debtCode = removed.ok
|
||||
? completed !== null && !completed.ok
|
||||
? completed.error.code
|
||||
: undefined
|
||||
: removed.error.code;
|
||||
observeOpfsSafely(dependencies.observer, {
|
||||
operation: "OBJECT_DELETE",
|
||||
outcome: "SUCCEEDED",
|
||||
outcome: settled ? "SUCCEEDED" : "DEGRADED",
|
||||
...(debtCode ? { failureCode: debtCode } : {}),
|
||||
});
|
||||
return browserDataSuccess(undefined);
|
||||
},
|
||||
@@ -830,19 +919,38 @@ export function createOpfsByteStoreAdapter(
|
||||
|
||||
return Object.freeze({ objects, maintenance });
|
||||
|
||||
async function rollbackBestEffort(
|
||||
/**
|
||||
* STO-01. The compensating half of the put saga.
|
||||
*
|
||||
* The journal row and its budget reservation are the only durable evidence
|
||||
* that a physical staging generation may still exist, so they are released
|
||||
* exactly when the physical effect is confirmed `CLEANED` or
|
||||
* `ALREADY_CLEAN`. A timeout, crash, malformed response or `EFFECT_UNKNOWN`
|
||||
* keeps `PREPARING`/`FILES_READY` in place and asks for reconciliation.
|
||||
*/
|
||||
async function compensatePreparedPut(
|
||||
transaction: OpfsJournalTransaction,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
await dependencies.worker.cleanupTransaction(
|
||||
transaction.scope,
|
||||
transaction.transactionId,
|
||||
signal,
|
||||
);
|
||||
await dependencies.journal.rollback(
|
||||
physicalGenerationId: OpfsPhysicalGenerationId,
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
const cleanup = await dependencies.worker.abortPreparedPut({
|
||||
scope: transaction.scope,
|
||||
transactionId: transaction.transactionId,
|
||||
physicalGenerationId,
|
||||
...(compensationSignal ? { signal: compensationSignal } : {}),
|
||||
});
|
||||
if (!cleanup.ok || cleanup.value.kind === "EFFECT_UNKNOWN") {
|
||||
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
|
||||
recovery: "RETRY",
|
||||
});
|
||||
}
|
||||
const rolledBack = await dependencies.journal.rollback(
|
||||
transaction.transactionId,
|
||||
transaction.fencingToken,
|
||||
);
|
||||
if (!rolledBack.ok) {
|
||||
return rebaseFailure(rolledBack.error, "OBJECT_RECONCILE");
|
||||
}
|
||||
return browserDataSuccess(undefined);
|
||||
}
|
||||
|
||||
async function reconcileTransaction(
|
||||
@@ -860,6 +968,11 @@ export function createOpfsByteStoreAdapter(
|
||||
signal,
|
||||
);
|
||||
if (!cleaned.ok) return cleaned;
|
||||
if (cleaned.value.kind === "EFFECT_UNKNOWN") {
|
||||
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
|
||||
recovery: "RETRY",
|
||||
});
|
||||
}
|
||||
const rolledBack = await dependencies.journal.rollback(
|
||||
transaction.transactionId,
|
||||
transaction.fencingToken,
|
||||
@@ -1292,6 +1405,7 @@ function snapshotOpfsWorker(
|
||||
openObject,
|
||||
removeObject,
|
||||
cleanupTransaction,
|
||||
abortPreparedPut,
|
||||
finalizePut,
|
||||
listOrphanCandidates,
|
||||
deleteOrphanChunk,
|
||||
@@ -1305,6 +1419,7 @@ function snapshotOpfsWorker(
|
||||
openObject,
|
||||
removeObject,
|
||||
cleanupTransaction,
|
||||
abortPreparedPut,
|
||||
finalizePut,
|
||||
listOrphanCandidates,
|
||||
deleteOrphanChunk,
|
||||
@@ -1320,6 +1435,7 @@ function snapshotOpfsWorker(
|
||||
openObject: openObject.bind(source),
|
||||
removeObject: removeObject.bind(source),
|
||||
cleanupTransaction: cleanupTransaction.bind(source),
|
||||
abortPreparedPut: abortPreparedPut.bind(source),
|
||||
finalizePut: finalizePut.bind(source),
|
||||
listOrphanCandidates: listOrphanCandidates.bind(source),
|
||||
deleteOrphanChunk: deleteOrphanChunk.bind(source),
|
||||
|
||||
@@ -26,7 +26,11 @@ export type OpfsRuntimePolicy = Readonly<{
|
||||
|
||||
export type OpfsSafeObservation = Readonly<{
|
||||
operation: BrowserDataOperation;
|
||||
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
|
||||
/**
|
||||
* NS-04. `DEGRADED` is a committed effect whose bookkeeping is unsettled:
|
||||
* the payload is durable but a reconciler still owns the transaction.
|
||||
*/
|
||||
outcome: "STARTED" | "SUCCEEDED" | "FAILED" | "DEGRADED";
|
||||
failureCode?: BrowserDataFailureCode;
|
||||
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
|
||||
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import type {
|
||||
OpfsCapabilities,
|
||||
OpfsCleanupEffect,
|
||||
OpfsPreparedObject,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataOperation,
|
||||
BrowserDataResult,
|
||||
ByteSource,
|
||||
import {
|
||||
isBrowserDataFailureCode,
|
||||
type BrowserDataFailureCode,
|
||||
type BrowserDataOperation,
|
||||
type BrowserDataResult,
|
||||
type ByteSource,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import type { OpfsRuntimePolicy } from "./opfs-policy.ts";
|
||||
import {
|
||||
OPFS_WORKER_PROTOCOL_VERSION,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
import type {
|
||||
AbortPreparedPutRequest,
|
||||
OpfsWorkerGateway,
|
||||
OpfsOrphanCandidateBatch,
|
||||
OpfsOrphanDeleteResult,
|
||||
@@ -49,6 +55,7 @@ export type OwnedOpfsWorkerClient = Readonly<{
|
||||
}>;
|
||||
|
||||
type PendingRequest = Readonly<{
|
||||
expectedKind: OpfsWorkerRequest["kind"];
|
||||
resolve: (response: OpfsWorkerResponse) => void;
|
||||
reject: (error: OpfsRpcError) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
@@ -74,24 +81,49 @@ export function createOpfsWorkerGateway(
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let disposed = false;
|
||||
|
||||
const rejectAllPending = (): void => {
|
||||
const rejectAllPending = (
|
||||
code: "UNAVAILABLE" | "UNSUPPORTED" = "UNAVAILABLE",
|
||||
): void => {
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
request.reject(new OpfsRpcError(code));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent<unknown>): void => {
|
||||
if (disposed) return;
|
||||
if (!isWorkerResponse(event.data)) return;
|
||||
const request = pending.get(event.data.requestId);
|
||||
const correlation = readCorrelation(event.data);
|
||||
if (correlation === IGNORE_MESSAGE) return;
|
||||
if (correlation === UNREADABLE_CORRELATION) {
|
||||
// NS-06. A reply whose correlation cannot even be read is a protocol
|
||||
// breach on the only channel this client has. Ignoring it left every
|
||||
// in-flight request to expire on the RPC timer, so the whole channel
|
||||
// fails closed promptly instead.
|
||||
rejectAllPending("UNSUPPORTED");
|
||||
return;
|
||||
}
|
||||
const request = pending.get(correlation);
|
||||
if (!request) return;
|
||||
pending.delete(event.data.requestId);
|
||||
// STO-07 / STO-RR-03. A reply is decoded, never adopted. A different
|
||||
// operation, an unknown kind, an unknown failure code, an inherited or
|
||||
// extra field and a hostile accessor are all protocol breaches, and each
|
||||
// closes the request rather than leaving it to time out.
|
||||
// UNSUPPORTED is the closed-taxonomy code for "this runtime cannot serve
|
||||
// this"; no new failure code is invented.
|
||||
// NS-06. The decode happens before the pending row and its timer are
|
||||
// released: releasing them first meant a trap that threw inside the decoder
|
||||
// left the public promise pending with nothing left to time it out.
|
||||
const decoded = decodeWorkerResponse(event.data, request.expectedKind);
|
||||
pending.delete(correlation);
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.resolve(event.data);
|
||||
if (decoded === null) {
|
||||
request.reject(new OpfsRpcError("UNSUPPORTED"));
|
||||
return;
|
||||
}
|
||||
request.resolve(decoded);
|
||||
};
|
||||
const onWorkerFailure = (): void => {
|
||||
if (disposed) return;
|
||||
@@ -119,7 +151,11 @@ export function createOpfsWorkerGateway(
|
||||
) {
|
||||
throw new OpfsRpcError("UNAVAILABLE");
|
||||
}
|
||||
const message = { ...request, requestId } as OpfsWorkerRequest;
|
||||
const message = {
|
||||
...request,
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
} as OpfsWorkerRequest;
|
||||
|
||||
return await new Promise<OpfsWorkerResponse>((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
@@ -139,6 +175,9 @@ export function createOpfsWorkerGateway(
|
||||
reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}, dependencies.policy.rpcTimeoutMs);
|
||||
pending.set(requestId, {
|
||||
// STO-07. The expected kind is stored so a reply for a different
|
||||
// operation can never satisfy this request.
|
||||
expectedKind: request.kind,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
@@ -187,17 +226,6 @@ export function createOpfsWorkerGateway(
|
||||
}
|
||||
}
|
||||
|
||||
async function abortAndCleanup(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await rpc({ kind: "ABORT_PUT", scope, transactionId });
|
||||
} catch {
|
||||
// Journal reconciliation repeats cleanup after a crash or timeout.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async capabilities() {
|
||||
return await invoke(
|
||||
@@ -215,6 +243,7 @@ export function createOpfsWorkerGateway(
|
||||
{
|
||||
kind: "BEGIN_PUT",
|
||||
transactionId: request.transactionId,
|
||||
physicalGenerationId: request.physicalGenerationId,
|
||||
scope: request.descriptor.scope,
|
||||
objectId: request.descriptor.objectId,
|
||||
generation: request.descriptor.generation,
|
||||
@@ -227,10 +256,8 @@ export function createOpfsWorkerGateway(
|
||||
request.signal,
|
||||
);
|
||||
if (!begin.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
// STO-01. Compensation belongs to the coordinator: it owns the journal
|
||||
// row this cleanup would otherwise invalidate.
|
||||
return begin;
|
||||
}
|
||||
|
||||
@@ -257,22 +284,12 @@ export function createOpfsWorkerGateway(
|
||||
request.signal,
|
||||
[chunk],
|
||||
);
|
||||
if (!append.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return append;
|
||||
}
|
||||
if (!append.ok) return append;
|
||||
sequence += 1;
|
||||
transferredBytes += chunkByteLength;
|
||||
notifyProgress(request, "TRANSFERRING", transferredBytes);
|
||||
}
|
||||
if (transferredBytes !== request.descriptor.byteLength) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_WRITE", {
|
||||
recovery: "RESELECT",
|
||||
});
|
||||
@@ -289,18 +306,8 @@ export function createOpfsWorkerGateway(
|
||||
[],
|
||||
parsePreparedObject,
|
||||
);
|
||||
if (!finished.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
}
|
||||
return finished;
|
||||
} catch (error) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return failureResult(
|
||||
error instanceof OpfsRpcError ? error.code : "NOT_READABLE",
|
||||
"OBJECT_WRITE",
|
||||
@@ -387,10 +394,32 @@ export function createOpfsWorkerGateway(
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{ kind: "CLEANUP_TRANSACTION", scope, transactionId },
|
||||
signal,
|
||||
[],
|
||||
parseCleanupEffect,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* STO-01. The coordinator's single compensation entry point. An RPC that
|
||||
* times out or fails leaves the physical effect unknown, which is never a
|
||||
* success and must not release journal or budget state.
|
||||
*/
|
||||
async abortPreparedPut(request: AbortPreparedPutRequest) {
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{
|
||||
kind: "ABORT_PUT",
|
||||
scope: request.scope,
|
||||
transactionId: request.transactionId,
|
||||
physicalGenerationId: request.physicalGenerationId,
|
||||
},
|
||||
request.signal,
|
||||
[],
|
||||
parseCleanupEffect,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -610,12 +639,34 @@ function parseCapabilities(value: unknown): OpfsCapabilities | null {
|
||||
return value as OpfsCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
|
||||
* window; v2 additionally carries a transaction-unique physical fencing token.
|
||||
*/
|
||||
function isSupportedPhysicalSchema(value: object): boolean {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.physicalSchemaVersion === 1) return true;
|
||||
return (
|
||||
record.physicalSchemaVersion === 2 &&
|
||||
typeof record.physicalGenerationId === "string" &&
|
||||
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
|
||||
);
|
||||
}
|
||||
|
||||
function parseCleanupEffect(value: unknown): OpfsCleanupEffect | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const kind = (value as Record<string, unknown>).kind;
|
||||
return kind === "CLEANED" || kind === "ALREADY_CLEAN"
|
||||
? Object.freeze({ kind })
|
||||
: null;
|
||||
}
|
||||
|
||||
function parsePreparedObject(value: unknown): OpfsPreparedObject | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!isSupportedPhysicalSchema(value) ||
|
||||
!("descriptor" in value) ||
|
||||
!("chunks" in value) ||
|
||||
!Array.isArray(value.chunks)
|
||||
@@ -663,13 +714,148 @@ function parseOrphanDeleteResult(
|
||||
return value as OpfsOrphanDeleteResult;
|
||||
}
|
||||
|
||||
function isWorkerResponse(value: unknown): value is OpfsWorkerResponse {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"requestId" in value &&
|
||||
typeof value.requestId === "string" &&
|
||||
"ok" in value &&
|
||||
typeof value.ok === "boolean",
|
||||
);
|
||||
/**
|
||||
* STO-07 / STO-RR-03. A response is admitted only when every field survives an
|
||||
* exact own-data decode: the negotiated protocol version, the exact request
|
||||
* kind this call is waiting for and, on failure, a code inside the closed
|
||||
* `BrowserDataFailure` taxonomy with a boolean `retryable`.
|
||||
*
|
||||
* The decoder returns a fresh frozen value, so a worker that mutates its own
|
||||
* message object after posting it cannot change what the caller already read.
|
||||
*/
|
||||
const WORKER_RESPONSE_KEYS: ReadonlySet<string> = new Set([
|
||||
"requestId",
|
||||
"protocolVersion",
|
||||
"kind",
|
||||
"ok",
|
||||
"value",
|
||||
"failure",
|
||||
]);
|
||||
|
||||
const WORKER_FAILURE_KEYS: ReadonlySet<string> = new Set(["code", "retryable"]);
|
||||
|
||||
/** Reads one own data property, treating an accessor or a trap as absent. */
|
||||
function ownField(source: unknown, key: string): unknown {
|
||||
if (source === null || typeof source !== "object") return undefined;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) return undefined;
|
||||
return descriptor.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function ownStringField(source: unknown, key: string): string | null {
|
||||
const value = ownField(source, key);
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/** Not a reply at all: nothing on this channel is waiting for it. */
|
||||
const IGNORE_MESSAGE = Symbol("opfs-ignore-message");
|
||||
/** A reply whose correlation could not be read without running foreign code. */
|
||||
const UNREADABLE_CORRELATION = Symbol("opfs-unreadable-correlation");
|
||||
|
||||
function readCorrelation(
|
||||
source: unknown,
|
||||
): string | typeof IGNORE_MESSAGE | typeof UNREADABLE_CORRELATION {
|
||||
if (source === null || typeof source !== "object") return IGNORE_MESSAGE;
|
||||
let descriptor: PropertyDescriptor | undefined;
|
||||
try {
|
||||
descriptor = Object.getOwnPropertyDescriptor(source, "requestId");
|
||||
} catch {
|
||||
return UNREADABLE_CORRELATION;
|
||||
}
|
||||
if (!descriptor) return IGNORE_MESSAGE;
|
||||
// An accessor would have to be invoked to be read, and invoking foreign code
|
||||
// to find out who a message belongs to is exactly what must not happen.
|
||||
if (!("value" in descriptor)) return UNREADABLE_CORRELATION;
|
||||
const value = descriptor.value;
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 128
|
||||
? value
|
||||
: UNREADABLE_CORRELATION;
|
||||
}
|
||||
|
||||
function hasOnlyOwnDataKeys(
|
||||
source: object,
|
||||
allowed: ReadonlySet<string>,
|
||||
): boolean {
|
||||
try {
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) return false;
|
||||
for (const key of Object.getOwnPropertyNames(source)) {
|
||||
if (!allowed.has(key)) return false;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWorkerResponse(
|
||||
value: unknown,
|
||||
expectedKind: OpfsWorkerRequest["kind"],
|
||||
): OpfsWorkerResponse | null {
|
||||
// NS-06. Total by construction: every reflection operation below can be
|
||||
// trapped, and a decoder that throws would strand the request it was
|
||||
// decoding rather than closing it.
|
||||
try {
|
||||
return decodeWorkerResponseUnguarded(value, expectedKind);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWorkerResponseUnguarded(
|
||||
value: unknown,
|
||||
expectedKind: OpfsWorkerRequest["kind"],
|
||||
): OpfsWorkerResponse | null {
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
if (!hasOnlyOwnDataKeys(value, WORKER_RESPONSE_KEYS)) return null;
|
||||
const requestId = ownStringField(value, "requestId");
|
||||
if (requestId === null || requestId.length > 128) return null;
|
||||
if (ownField(value, "protocolVersion") !== OPFS_WORKER_PROTOCOL_VERSION) {
|
||||
return null;
|
||||
}
|
||||
if (ownField(value, "kind") !== expectedKind) return null;
|
||||
const ok = ownField(value, "ok");
|
||||
if (typeof ok !== "boolean") return null;
|
||||
|
||||
if (ok) {
|
||||
if (Object.hasOwn(value, "failure")) return null;
|
||||
return Object.freeze(
|
||||
Object.hasOwn(value, "value")
|
||||
? {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expectedKind,
|
||||
ok: true,
|
||||
value: ownField(value, "value"),
|
||||
}
|
||||
: {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expectedKind,
|
||||
ok: true,
|
||||
},
|
||||
) as OpfsWorkerResponse;
|
||||
}
|
||||
|
||||
if (Object.hasOwn(value, "value")) return null;
|
||||
const failure = ownField(value, "failure");
|
||||
if (failure === null || typeof failure !== "object") return null;
|
||||
if (!hasOnlyOwnDataKeys(failure, WORKER_FAILURE_KEYS)) return null;
|
||||
const code = ownField(failure, "code");
|
||||
const retryable = ownField(failure, "retryable");
|
||||
if (!isBrowserDataFailureCode(code) || typeof retryable !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expectedKind,
|
||||
ok: false,
|
||||
failure: Object.freeze({ code, retryable }),
|
||||
}) as OpfsWorkerResponse;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
DurableObjectDescriptor,
|
||||
OpfsCapabilities,
|
||||
OpfsCleanupEffect,
|
||||
OpfsPhysicalGenerationId,
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
@@ -12,18 +14,34 @@ import type {
|
||||
TransferProgress,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
/**
|
||||
* STO-07. Every request and response carries the protocol version and the
|
||||
* response echoes its request kind, so a page/worker release mismatch or a
|
||||
* malformed reply closes as `INCOMPATIBLE` instead of being decoded as a
|
||||
* successful value of the wrong shape.
|
||||
*/
|
||||
export const OPFS_WORKER_PROTOCOL_VERSION = 2 as const;
|
||||
|
||||
export type OpfsWorkerRequestEnvelope = Readonly<{
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
}>;
|
||||
|
||||
export type OpfsWorkerRequest =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "CAPABILITIES";
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "BEGIN_PUT";
|
||||
transactionId: string;
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
generation: number;
|
||||
/** STO-01. Transaction-unique physical fencing token for new writes. */
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
declaredByteLength: number;
|
||||
mediaType: string;
|
||||
createdAtEpochMs: number;
|
||||
@@ -32,6 +50,7 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "APPEND_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
@@ -40,29 +59,35 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "FINISH_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "ABORT_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "VERIFY_OBJECT";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "READ_CHUNK";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
sequence: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "REMOVE_OBJECT";
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
@@ -70,18 +95,27 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "CLEANUP_TRANSACTION";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
/**
|
||||
* STO-01. When present, cleanup deletes only this exact physical
|
||||
* generation and can never touch a newer transaction that reused the same
|
||||
* logical generation.
|
||||
*/
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "FINALIZE_PUT";
|
||||
transactionId: string;
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "LIST_ORPHAN_CANDIDATES";
|
||||
scope: OpfsStorageScope;
|
||||
olderThanEpochMs: number;
|
||||
@@ -89,6 +123,7 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "DELETE_ORPHAN_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
digestHex: string;
|
||||
@@ -98,7 +133,7 @@ export type OpfsWorkerRequest =
|
||||
export type OpfsWorkerRequestBody =
|
||||
OpfsWorkerRequest extends infer Request
|
||||
? Request extends OpfsWorkerRequest
|
||||
? Omit<Request, "requestId">
|
||||
? Omit<Request, "requestId" | "protocolVersion">
|
||||
: never
|
||||
: never;
|
||||
|
||||
@@ -121,9 +156,12 @@ export type OpfsOrphanDeleteResult = Readonly<{
|
||||
export type OpfsWorkerResponse =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: OpfsWorkerRequest["kind"];
|
||||
ok: true;
|
||||
value?:
|
||||
| OpfsCapabilities
|
||||
| OpfsCleanupEffect
|
||||
| OpfsPreparedObject
|
||||
| ArrayBuffer
|
||||
| boolean
|
||||
@@ -132,18 +170,33 @@ export type OpfsWorkerResponse =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: OpfsWorkerRequest["kind"];
|
||||
ok: false;
|
||||
failure: OpfsWorkerFailure;
|
||||
}>;
|
||||
|
||||
export type PreparePhysicalObjectRequest = Readonly<{
|
||||
transactionId: string;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
descriptor: Omit<DurableObjectDescriptor, "integrity">;
|
||||
source: ByteSource;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: TransferProgress) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* STO-01. The single compensation entry point. The coordinator owns it and
|
||||
* passes a composition-owned bounded signal, never the already aborted caller
|
||||
* signal.
|
||||
*/
|
||||
export type AbortPreparedPutRequest = Readonly<{
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The coordinator depends on this technology-neutral worker gateway. The
|
||||
* browser implementation below the boundary owns Worker, MessageEvent and
|
||||
@@ -172,7 +225,10 @@ export interface OpfsWorkerGateway {
|
||||
scope: OpfsStorageScope,
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
): Promise<BrowserDataResult<OpfsCleanupEffect>>;
|
||||
abortPreparedPut(
|
||||
request: AbortPreparedPutRequest,
|
||||
): Promise<BrowserDataResult<OpfsCleanupEffect>>;
|
||||
finalizePut(
|
||||
transactionId: string,
|
||||
preparedObject: OpfsPreparedObject,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
OpfsCapabilities,
|
||||
OpfsChunkReference,
|
||||
OpfsCleanupEffect,
|
||||
OpfsPhysicalGenerationId,
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
@@ -13,6 +15,9 @@ import {
|
||||
isValidOpfsStorageScope,
|
||||
type OpfsRuntimePolicy,
|
||||
} from "./opfs-policy.ts";
|
||||
import {
|
||||
OPFS_WORKER_PROTOCOL_VERSION,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
import type {
|
||||
OpfsOrphanCandidateBatch,
|
||||
OpfsOrphanDeleteResult,
|
||||
@@ -54,6 +59,7 @@ type ActivePut = {
|
||||
readonly scope: OpfsPreparedObject["descriptor"]["scope"];
|
||||
readonly objectId: string;
|
||||
readonly generation: number;
|
||||
readonly physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
readonly declaredByteLength: number;
|
||||
readonly mediaType: string;
|
||||
readonly createdAtEpochMs: number;
|
||||
@@ -151,12 +157,21 @@ export async function startBrowserOpfsDedicatedWorker(
|
||||
host.addEventListener("message", (event) => {
|
||||
if (!hasRequestId(event.data)) return;
|
||||
const requestData = event.data;
|
||||
// NS-05. The envelope's correlation is captured once, up front. Answering a
|
||||
// bootstrap failure with a default `CAPABILITIES` kind made the client see
|
||||
// an expected-kind mismatch and overwrite the real cause — a `BLOCKED` or
|
||||
// `QUOTA_EXCEEDED` outage was reported to operators as `UNSUPPORTED`.
|
||||
const correlation = requestCorrelation(requestData);
|
||||
void runtimePromise
|
||||
.then((runtime) => runtime.handleRequest(requestData))
|
||||
.then((response) => postWorkerResponse(host, response))
|
||||
.catch((error: unknown) => {
|
||||
host.postMessage(
|
||||
failure(requestData.requestId, mapRuntimeFailure(error)),
|
||||
failure(
|
||||
correlation.requestId,
|
||||
mapRuntimeFailure(error),
|
||||
correlation.kind,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -193,32 +208,50 @@ export function createOpfsWorkerRuntime(
|
||||
return Object.freeze({
|
||||
async handleRequest(request: unknown) {
|
||||
if (!hasRequestId(request)) return null;
|
||||
if (!isWorkerRequest(request)) {
|
||||
// STO-RR-02. Only an envelope this runtime could not read produces a
|
||||
// protocol-level failure. Everything below answers its own request.
|
||||
return failure(
|
||||
request.requestId,
|
||||
mapRuntimeFailure(new OpfsRuntimeFailure("INVALID_INPUT")),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (!isWorkerRequest(request)) {
|
||||
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
||||
}
|
||||
switch (request.kind) {
|
||||
case "CAPABILITIES":
|
||||
return success(request.requestId, capabilities);
|
||||
return success(request.requestId, request.kind, capabilities);
|
||||
case "BEGIN_PUT":
|
||||
await beginPut(request);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "APPEND_CHUNK":
|
||||
await appendChunk(request);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "FINISH_PUT":
|
||||
return success(request.requestId, await finishPut(request));
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await finishPut(request),
|
||||
);
|
||||
case "ABORT_PUT":
|
||||
await abortPut(request.scope, request.transactionId);
|
||||
return success(request.requestId);
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await abortPut(
|
||||
request.scope,
|
||||
request.transactionId,
|
||||
request.physicalGenerationId,
|
||||
),
|
||||
);
|
||||
case "VERIFY_OBJECT":
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await verifyObject(request.preparedObject),
|
||||
);
|
||||
case "READ_CHUNK":
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await readVerifiedChunk(
|
||||
request.preparedObject,
|
||||
request.sequence,
|
||||
@@ -230,22 +263,28 @@ export function createOpfsWorkerRuntime(
|
||||
request.objectId,
|
||||
request.generation,
|
||||
);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "CLEANUP_TRANSACTION":
|
||||
await cleanupTransaction(
|
||||
request.scope,
|
||||
request.transactionId,
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await cleanupTransaction(
|
||||
request.scope,
|
||||
request.transactionId,
|
||||
true,
|
||||
request.physicalGenerationId,
|
||||
),
|
||||
);
|
||||
return success(request.requestId);
|
||||
case "FINALIZE_PUT":
|
||||
await finalizePut(
|
||||
request.transactionId,
|
||||
request.preparedObject,
|
||||
);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "LIST_ORPHAN_CANDIDATES":
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await listOrphanCandidates(
|
||||
request.scope,
|
||||
request.olderThanEpochMs,
|
||||
@@ -255,6 +294,7 @@ export function createOpfsWorkerRuntime(
|
||||
case "DELETE_ORPHAN_CHUNK":
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await deleteOrphanChunk(
|
||||
request.scope,
|
||||
request.digestHex,
|
||||
@@ -263,7 +303,14 @@ export function createOpfsWorkerRuntime(
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return failure(request.requestId, mapRuntimeFailure(error));
|
||||
// STO-RR-02. The kind travels with the failure so the client's
|
||||
// expected-kind check cannot mistake a quota, integrity or abort
|
||||
// failure for a protocol breach.
|
||||
return failure(
|
||||
request.requestId,
|
||||
mapRuntimeFailure(error),
|
||||
request.kind,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -278,6 +325,7 @@ export function createOpfsWorkerRuntime(
|
||||
!dependencies.policy.isObjectIdAllowed(request.objectId) ||
|
||||
!Number.isSafeInteger(request.generation) ||
|
||||
request.generation < 1 ||
|
||||
!isPhysicalGenerationId(request.physicalGenerationId) ||
|
||||
!Number.isSafeInteger(request.declaredByteLength) ||
|
||||
request.declaredByteLength < 0 ||
|
||||
request.declaredByteLength > dependencies.policy.maxObjectBytes ||
|
||||
@@ -327,6 +375,7 @@ export function createOpfsWorkerRuntime(
|
||||
scope: request.scope,
|
||||
objectId: request.objectId,
|
||||
generation: request.generation,
|
||||
physicalGenerationId: request.physicalGenerationId,
|
||||
declaredByteLength: request.declaredByteLength,
|
||||
mediaType: request.mediaType,
|
||||
createdAtEpochMs: request.createdAtEpochMs,
|
||||
@@ -457,7 +506,8 @@ export function createOpfsWorkerRuntime(
|
||||
);
|
||||
assertActivePut(transactionKey, put, true);
|
||||
const prepared: OpfsPreparedObject = Object.freeze({
|
||||
physicalSchemaVersion: 1,
|
||||
physicalSchemaVersion: 2,
|
||||
physicalGenerationId: put.physicalGenerationId,
|
||||
descriptor: Object.freeze({
|
||||
objectId: put.objectId,
|
||||
scope: put.scope,
|
||||
@@ -501,10 +551,13 @@ export function createOpfsWorkerRuntime(
|
||||
async function abortPut(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
): Promise<void> {
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId,
|
||||
): Promise<OpfsCleanupEffect> {
|
||||
if (
|
||||
!isValidOpfsStorageScope(scope) ||
|
||||
!SAFE_TRANSACTION_ID.test(transactionId)
|
||||
!SAFE_TRANSACTION_ID.test(transactionId) ||
|
||||
(physicalGenerationId !== undefined &&
|
||||
!isPhysicalGenerationId(physicalGenerationId))
|
||||
) {
|
||||
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
||||
}
|
||||
@@ -518,15 +571,33 @@ export function createOpfsWorkerRuntime(
|
||||
await active.operationTail;
|
||||
if (activePuts.get(transactionKey) === active) {
|
||||
activePuts.delete(transactionKey);
|
||||
}
|
||||
try {
|
||||
// STO-01. The mutation lease is held through the physical delete and
|
||||
// the staging cleanup; releasing it earlier would let a new transaction
|
||||
// race this compensation.
|
||||
await removePhysicalGeneration(
|
||||
active.scope,
|
||||
active.objectId,
|
||||
active.generation,
|
||||
active.physicalGenerationId,
|
||||
);
|
||||
return await cleanupTransactionLocked(
|
||||
scope,
|
||||
transactionId,
|
||||
true,
|
||||
physicalGenerationId ?? active.physicalGenerationId,
|
||||
);
|
||||
} finally {
|
||||
active.lease.release();
|
||||
}
|
||||
await removePhysicalGeneration(
|
||||
active.scope,
|
||||
active.objectId,
|
||||
active.generation,
|
||||
);
|
||||
}
|
||||
await cleanupTransaction(scope, transactionId);
|
||||
return await cleanupTransaction(
|
||||
scope,
|
||||
transactionId,
|
||||
true,
|
||||
physicalGenerationId,
|
||||
);
|
||||
}
|
||||
|
||||
async function runActivePutOperation<Value>(
|
||||
@@ -571,20 +642,30 @@ export function createOpfsWorkerRuntime(
|
||||
put.abortController.abort();
|
||||
if (activePuts.get(transactionKey) === put) {
|
||||
activePuts.delete(transactionKey);
|
||||
}
|
||||
try {
|
||||
await removePhysicalGeneration(
|
||||
put.scope,
|
||||
put.objectId,
|
||||
put.generation,
|
||||
put.physicalGenerationId,
|
||||
);
|
||||
await cleanupTransactionLocked(
|
||||
put.scope,
|
||||
put.transactionId,
|
||||
true,
|
||||
put.physicalGenerationId,
|
||||
);
|
||||
} finally {
|
||||
put.lease.release();
|
||||
}
|
||||
await removePhysicalGeneration(
|
||||
put.scope,
|
||||
put.objectId,
|
||||
put.generation,
|
||||
);
|
||||
await cleanupTransaction(put.scope, put.transactionId);
|
||||
}
|
||||
|
||||
async function removePhysicalGeneration(
|
||||
scope: OpfsStorageScope,
|
||||
objectId: string,
|
||||
generation: number,
|
||||
physicalGenerationId: OpfsPhysicalGenerationId | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const objectDirectory = await getDirectory(
|
||||
@@ -599,7 +680,7 @@ export function createOpfsWorkerRuntime(
|
||||
);
|
||||
await removeEntryIfPresent(
|
||||
objectDirectory,
|
||||
String(generation),
|
||||
generationSegmentFor(generation, physicalGenerationId),
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -714,17 +795,68 @@ export function createOpfsWorkerRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STO-01. Cleanup deletes exactly one transaction's physical generation while
|
||||
* holding the origin mutation lease, and reports whether the effect actually
|
||||
* happened. `ALREADY_CLEAN` means there was nothing left to delete.
|
||||
*/
|
||||
async function cleanupTransaction(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
removePreparedGeneration = true,
|
||||
): Promise<void> {
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId,
|
||||
): Promise<OpfsCleanupEffect> {
|
||||
if (
|
||||
!isValidOpfsStorageScope(scope) ||
|
||||
!SAFE_TRANSACTION_ID.test(transactionId)
|
||||
) {
|
||||
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
||||
}
|
||||
// Probe before locking. A transaction that never reached staging has
|
||||
// nothing to delete, and waiting for the mutation lease here would deadlock
|
||||
// against the very BEGIN this compensation is cancelling.
|
||||
try {
|
||||
await getDirectory(
|
||||
dependencies.root,
|
||||
[...scopeRootPath(scope), "staging", transactionId],
|
||||
false,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return CLEANUP_ALREADY_CLEAN;
|
||||
throw error;
|
||||
}
|
||||
// Every destructive step below runs while the lease is held.
|
||||
const lease = await dependencies.leaseManager!.acquire();
|
||||
try {
|
||||
return await cleanupTransactionLocked(
|
||||
scope,
|
||||
transactionId,
|
||||
removePreparedGeneration,
|
||||
physicalGenerationId,
|
||||
);
|
||||
} finally {
|
||||
lease.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callers that already hold the origin mutation lease use this directly, so
|
||||
* an abort never releases the lease between fencing and physical deletion.
|
||||
*/
|
||||
async function cleanupTransactionLocked(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
removePreparedGeneration = true,
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId,
|
||||
): Promise<OpfsCleanupEffect> {
|
||||
if (
|
||||
!isValidOpfsStorageScope(scope) ||
|
||||
!SAFE_TRANSACTION_ID.test(transactionId) ||
|
||||
(physicalGenerationId !== undefined &&
|
||||
!isPhysicalGenerationId(physicalGenerationId))
|
||||
) {
|
||||
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
||||
}
|
||||
let staging: FileSystemDirectoryHandle;
|
||||
try {
|
||||
staging = await getDirectory(
|
||||
@@ -733,32 +865,38 @@ export function createOpfsWorkerRuntime(
|
||||
false,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return;
|
||||
if (isNotFound(error)) return CLEANUP_ALREADY_CLEAN;
|
||||
throw error;
|
||||
}
|
||||
if (removePreparedGeneration) {
|
||||
let receipt: unknown;
|
||||
try {
|
||||
receipt = await readJson(receiptPath(scope, transactionId));
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
await removeEntryIfPresent(staging, transactionId, true);
|
||||
return;
|
||||
{
|
||||
if (removePreparedGeneration) {
|
||||
let receipt: unknown;
|
||||
try {
|
||||
receipt = await readJson(receiptPath(scope, transactionId));
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
await removeEntryIfPresent(staging, transactionId, true);
|
||||
return CLEANUP_ALREADY_CLEAN;
|
||||
}
|
||||
// Keep unreadable staging in place so orphan GC fails closed.
|
||||
throw error;
|
||||
}
|
||||
// Keep unreadable staging in place so orphan GC fails closed.
|
||||
throw error;
|
||||
const target = extractReceiptPhysicalTarget(receipt, scope);
|
||||
if (!target) {
|
||||
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
||||
}
|
||||
// A caller-supplied token wins: a stale compensation must not widen its
|
||||
// target to whatever the receipt now says.
|
||||
await removePhysicalGeneration(
|
||||
scope,
|
||||
target.objectId,
|
||||
target.generation,
|
||||
physicalGenerationId ?? target.physicalGenerationId,
|
||||
);
|
||||
}
|
||||
const target = extractReceiptPhysicalTarget(receipt, scope);
|
||||
if (!target) {
|
||||
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
||||
}
|
||||
await removePhysicalGeneration(
|
||||
scope,
|
||||
target.objectId,
|
||||
target.generation,
|
||||
);
|
||||
await removeEntryIfPresent(staging, transactionId, true);
|
||||
return CLEANUP_CLEANED;
|
||||
}
|
||||
await removeEntryIfPresent(staging, transactionId, true);
|
||||
}
|
||||
|
||||
async function finalizePut(
|
||||
@@ -788,16 +926,19 @@ export function createOpfsWorkerRuntime(
|
||||
for await (const [name, handle] of objectDirectory.entries()) {
|
||||
if (
|
||||
handle.kind === "directory" &&
|
||||
/^\d+$/u.test(name) &&
|
||||
name !== String(descriptor.generation)
|
||||
isGenerationSegment(name) &&
|
||||
name !== preparedGenerationSegment(preparedObject)
|
||||
) {
|
||||
await objectDirectory.removeEntry(name, { recursive: true });
|
||||
}
|
||||
}
|
||||
await cleanupTransaction(descriptor.scope, transactionId, false);
|
||||
// STO-RR-01. This path already holds the origin mutation lease, and a
|
||||
// Web Lock is not reentrant: asking for it again here never returns, so
|
||||
// an ordinary PUT would stop for good at FINALIZE.
|
||||
await cleanupTransactionLocked(descriptor.scope, transactionId, false);
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error;
|
||||
await cleanupTransaction(
|
||||
await cleanupTransactionLocked(
|
||||
preparedObject.descriptor.scope,
|
||||
transactionId,
|
||||
false,
|
||||
@@ -980,7 +1121,11 @@ export function createOpfsWorkerRuntime(
|
||||
function extractReceiptPhysicalTarget(
|
||||
receipt: unknown,
|
||||
scope: OpfsStorageScope,
|
||||
): Readonly<{ objectId: string; generation: number }> | null {
|
||||
): Readonly<{
|
||||
objectId: string;
|
||||
generation: number;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId | undefined;
|
||||
}> | null {
|
||||
if (!receiptBelongsToScope(receipt, scope)) return null;
|
||||
const record = receipt as Record<string, unknown>;
|
||||
if (record.phase === "PREPARING") {
|
||||
@@ -989,7 +1134,15 @@ export function createOpfsWorkerRuntime(
|
||||
typeof record.generation === "number" &&
|
||||
Number.isSafeInteger(record.generation) &&
|
||||
record.generation > 0
|
||||
? { objectId: record.objectId, generation: record.generation }
|
||||
? {
|
||||
objectId: record.objectId,
|
||||
generation: record.generation,
|
||||
physicalGenerationId: isPhysicalGenerationId(
|
||||
record.physicalGenerationId,
|
||||
)
|
||||
? record.physicalGenerationId
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
@@ -997,9 +1150,14 @@ export function createOpfsWorkerRuntime(
|
||||
isPreparedObjectSafe(record.preparedObject, dependencies.policy) &&
|
||||
sameScope(record.preparedObject.descriptor.scope, scope)
|
||||
) {
|
||||
const prepared = record.preparedObject;
|
||||
return {
|
||||
objectId: record.preparedObject.descriptor.objectId,
|
||||
generation: record.preparedObject.descriptor.generation,
|
||||
objectId: prepared.descriptor.objectId,
|
||||
generation: prepared.descriptor.generation,
|
||||
physicalGenerationId:
|
||||
prepared.physicalSchemaVersion === 2
|
||||
? prepared.physicalGenerationId
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -1019,11 +1177,12 @@ export function createOpfsWorkerRuntime(
|
||||
|
||||
async function writeReceipt(put: ActivePut): Promise<void> {
|
||||
await writeJsonAtomic(receiptPath(put.scope, put.transactionId), {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
phase: "PREPARING",
|
||||
scope: put.scope,
|
||||
objectId: put.objectId,
|
||||
generation: put.generation,
|
||||
physicalGenerationId: put.physicalGenerationId,
|
||||
declaredByteLength: put.declaredByteLength,
|
||||
chunks: put.chunks,
|
||||
});
|
||||
@@ -1296,6 +1455,52 @@ async function removeEntryIfPresent(
|
||||
}
|
||||
}
|
||||
|
||||
const CLEANUP_CLEANED: OpfsCleanupEffect = Object.freeze({ kind: "CLEANED" });
|
||||
const CLEANUP_ALREADY_CLEAN: OpfsCleanupEffect = Object.freeze({
|
||||
kind: "ALREADY_CLEAN",
|
||||
});
|
||||
|
||||
const PHYSICAL_GENERATION_ID = /^[0-9a-f]{32}$/u;
|
||||
const V1_GENERATION_SEGMENT = /^\d+$/u;
|
||||
const V2_GENERATION_SEGMENT = /^g\d+-[0-9a-f]{32}$/u;
|
||||
|
||||
export function isPhysicalGenerationId(
|
||||
value: unknown,
|
||||
): value is OpfsPhysicalGenerationId {
|
||||
return typeof value === "string" && PHYSICAL_GENERATION_ID.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* STO-01. v1 wrote `objects/<prefix>/<id>/<generation>/`, which two
|
||||
* transactions can legitimately share. v2 writes
|
||||
* `objects/<prefix>/<id>/g<generation>-<token>/` so a late compensation can
|
||||
* only ever delete its own transaction's directory. v1 segments stay readable
|
||||
* through the rollback window.
|
||||
*/
|
||||
function generationSegmentFor(
|
||||
generation: number,
|
||||
physicalGenerationId: OpfsPhysicalGenerationId | undefined,
|
||||
): string {
|
||||
return physicalGenerationId === undefined
|
||||
? String(generation)
|
||||
: `g${generation}-${physicalGenerationId}`;
|
||||
}
|
||||
|
||||
function preparedGenerationSegment(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
): string {
|
||||
return generationSegmentFor(
|
||||
preparedObject.descriptor.generation,
|
||||
preparedObject.physicalSchemaVersion === 2
|
||||
? preparedObject.physicalGenerationId
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function isGenerationSegment(name: string): boolean {
|
||||
return V1_GENERATION_SEGMENT.test(name) || V2_GENERATION_SEGMENT.test(name);
|
||||
}
|
||||
|
||||
function manifestPath(preparedObject: OpfsPreparedObject): readonly string[] {
|
||||
const descriptor = preparedObject.descriptor;
|
||||
return [
|
||||
@@ -1303,7 +1508,7 @@ function manifestPath(preparedObject: OpfsPreparedObject): readonly string[] {
|
||||
"objects",
|
||||
descriptor.objectId.slice(0, 2),
|
||||
descriptor.objectId,
|
||||
String(descriptor.generation),
|
||||
preparedGenerationSegment(preparedObject),
|
||||
"manifest.json",
|
||||
];
|
||||
}
|
||||
@@ -1373,7 +1578,8 @@ function receiptBelongsToScope(
|
||||
if (!receipt || typeof receipt !== "object") return false;
|
||||
const record = receipt as Record<string, unknown>;
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
// v1 receipts stay readable through the rollback window.
|
||||
(record.schemaVersion !== 1 && record.schemaVersion !== 2) ||
|
||||
(record.phase !== "PREPARING" && record.phase !== "FILES_READY")
|
||||
) {
|
||||
return false;
|
||||
@@ -1464,6 +1670,20 @@ function stableJson(value: unknown): string {
|
||||
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
||||
}
|
||||
|
||||
/**
|
||||
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
|
||||
* window; v2 additionally carries a transaction-unique physical fencing token.
|
||||
*/
|
||||
function isSupportedPhysicalSchema(value: object): boolean {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.physicalSchemaVersion === 1) return true;
|
||||
return (
|
||||
record.physicalSchemaVersion === 2 &&
|
||||
typeof record.physicalGenerationId === "string" &&
|
||||
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
|
||||
);
|
||||
}
|
||||
|
||||
function isPreparedObjectSafe(
|
||||
value: unknown,
|
||||
policy: OpfsRuntimePolicy,
|
||||
@@ -1472,7 +1692,7 @@ function isPreparedObjectSafe(
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!isSupportedPhysicalSchema(value) ||
|
||||
!("descriptor" in value) ||
|
||||
!value.descriptor ||
|
||||
typeof value.descriptor !== "object" ||
|
||||
@@ -1564,9 +1784,11 @@ function isPreparedObjectSafe(
|
||||
|
||||
function success(
|
||||
requestId: string,
|
||||
kind: OpfsWorkerRequest["kind"],
|
||||
value?: OpfsWorkerResponse extends infer _Response
|
||||
?
|
||||
| OpfsCapabilities
|
||||
| OpfsCleanupEffect
|
||||
| OpfsPreparedObject
|
||||
| ArrayBuffer
|
||||
| boolean
|
||||
@@ -1575,15 +1797,33 @@ function success(
|
||||
: never,
|
||||
): OpfsWorkerResponse {
|
||||
return value === undefined
|
||||
? { requestId, ok: true }
|
||||
: { requestId, ok: true, value };
|
||||
? {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind,
|
||||
ok: true,
|
||||
}
|
||||
: {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind,
|
||||
ok: true,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function failure(
|
||||
requestId: string,
|
||||
workerFailure: OpfsWorkerFailure,
|
||||
kind: OpfsWorkerRequest["kind"] = "CAPABILITIES",
|
||||
): OpfsWorkerResponse {
|
||||
return { requestId, ok: false, failure: workerFailure };
|
||||
return {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind,
|
||||
ok: false,
|
||||
failure: workerFailure,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuntimeFailure(error: unknown): OpfsWorkerFailure {
|
||||
@@ -1640,12 +1880,40 @@ function hasRequestId(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NS-05. Reads a request envelope's correlation exactly once, through its own
|
||||
* data descriptors, so a reply can name the request it answers even when the
|
||||
* runtime that would have handled it never came up. An envelope whose kind
|
||||
* cannot be read stays a protocol-level failure rather than borrowing an
|
||||
* unrelated kind.
|
||||
*/
|
||||
function requestCorrelation(
|
||||
value: Readonly<{ requestId: string }>,
|
||||
): Readonly<{ requestId: string; kind: OpfsWorkerRequest["kind"] }> {
|
||||
let kind: unknown;
|
||||
try {
|
||||
kind = Object.getOwnPropertyDescriptor(value, "kind")?.value;
|
||||
} catch {
|
||||
kind = undefined;
|
||||
}
|
||||
return {
|
||||
requestId: value.requestId,
|
||||
kind:
|
||||
typeof kind === "string" && WORKER_REQUEST_KINDS.has(kind)
|
||||
? (kind as OpfsWorkerRequest["kind"])
|
||||
: "CAPABILITIES",
|
||||
};
|
||||
}
|
||||
|
||||
function isWorkerRequest(value: unknown): value is OpfsWorkerRequest {
|
||||
return Boolean(
|
||||
hasRequestId(value) &&
|
||||
"kind" in value &&
|
||||
typeof value.kind === "string" &&
|
||||
WORKER_REQUEST_KINDS.has(value.kind),
|
||||
WORKER_REQUEST_KINDS.has(value.kind) &&
|
||||
// STO-07. A page from another release must not be served.
|
||||
"protocolVersion" in value &&
|
||||
value.protocolVersion === OPFS_WORKER_PROTOCOL_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,31 @@ export const noOpTelemetry: TelemetryAdapter = Object.freeze({
|
||||
dispose: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* N-04. Disposal is terminal: there is no durable queue and no resurrection.
|
||||
*/
|
||||
type TelemetryLifecycle = "ACTIVE" | "DISPOSED";
|
||||
|
||||
/** N-11. Documented absolute ceiling for the in-memory best-effort queue. */
|
||||
export const MAX_TELEMETRY_QUEUE = 10_000;
|
||||
|
||||
/**
|
||||
* N-11. A non-finite or fractional capacity silently disables eviction, so it is
|
||||
* a construction-time configuration error rather than a runtime drop.
|
||||
*/
|
||||
export function assertBoundedCapacity(
|
||||
value: number,
|
||||
ceiling: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) {
|
||||
throw new TypeError(
|
||||
`${label} must be a safe integer between 1 and ${ceiling}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createTelemetryAdapter(
|
||||
options: TelemetryAdapterOptions,
|
||||
): TelemetryAdapter {
|
||||
@@ -46,11 +71,19 @@ export function createTelemetryAdapter(
|
||||
|
||||
const endpoint = options.endpoint;
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const maxQueue = Math.max(1, options.maxQueue ?? 100);
|
||||
const maxQueue = assertBoundedCapacity(
|
||||
options.maxQueue ?? 100,
|
||||
MAX_TELEMETRY_QUEUE,
|
||||
"telemetry maxQueue",
|
||||
);
|
||||
const schedule = options.schedule ?? queueMicrotask;
|
||||
const queue: TelemetryEvent[] = [];
|
||||
let lifecycleState: TelemetryLifecycle = "ACTIVE";
|
||||
/** Scheduled callbacks captured before disposal must not run afterwards. */
|
||||
let scheduleGeneration = 0;
|
||||
let scheduled = false;
|
||||
let flushing = false;
|
||||
let activeFlush: Promise<void> | null = null;
|
||||
let activeSink: AbortController | null = null;
|
||||
let dropped = 0;
|
||||
const dropReasons = new Map<string, number>();
|
||||
let lastDeliveryEvidence: TelemetryEvent | null = null;
|
||||
@@ -93,9 +126,12 @@ export function createTelemetryAdapter(
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (scheduled) return;
|
||||
if (scheduled || lifecycleState === "DISPOSED") return;
|
||||
scheduled = true;
|
||||
const generation = scheduleGeneration;
|
||||
schedule(() => {
|
||||
// A callback captured before disposal belongs to a dead generation.
|
||||
if (generation !== scheduleGeneration) return;
|
||||
scheduled = false;
|
||||
void flush();
|
||||
});
|
||||
@@ -105,6 +141,7 @@ export function createTelemetryAdapter(
|
||||
eventName: TelemetryEventName,
|
||||
attributes: Record<string, unknown>,
|
||||
): void {
|
||||
if (lifecycleState === "DISPOSED") return;
|
||||
const projected = projectTelemetryEvent(
|
||||
eventName,
|
||||
attributes,
|
||||
@@ -124,26 +161,44 @@ export function createTelemetryAdapter(
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
if (flushing || queue.length === 0) return;
|
||||
flushing = true;
|
||||
const batch = queue.splice(0, queue.length);
|
||||
try {
|
||||
const response = await fetcher(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: batch }),
|
||||
keepalive: true,
|
||||
});
|
||||
if (!response.ok) recordDrop("sink-failure", batch.length);
|
||||
} catch {
|
||||
recordDrop("sink-failure", batch.length);
|
||||
} finally {
|
||||
flushing = false;
|
||||
if (queue.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
/**
|
||||
* `flush()` joins the active delivery instead of resolving immediately, so an
|
||||
* awaited flush really means "the in-flight batch has settled".
|
||||
*/
|
||||
function flush(): Promise<void> {
|
||||
if (activeFlush) return activeFlush;
|
||||
if (lifecycleState === "DISPOSED" || queue.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const generation = scheduleGeneration;
|
||||
const run = async () => {
|
||||
const batch = queue.splice(0, queue.length);
|
||||
const controller = new AbortController();
|
||||
activeSink = controller;
|
||||
try {
|
||||
const response = await fetcher(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: batch }),
|
||||
keepalive: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// A sink that ignored the abort must not update post-dispose state.
|
||||
if (generation !== scheduleGeneration) return;
|
||||
if (!response.ok) recordDrop("sink-failure", batch.length);
|
||||
} catch {
|
||||
if (generation !== scheduleGeneration) return;
|
||||
recordDrop("sink-failure", batch.length);
|
||||
} finally {
|
||||
if (activeSink === controller) activeSink = null;
|
||||
activeFlush = null;
|
||||
if (generation === scheduleGeneration && queue.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
};
|
||||
activeFlush = run();
|
||||
return activeFlush;
|
||||
}
|
||||
|
||||
const flushBeforePageExit = () => {
|
||||
@@ -151,8 +206,20 @@ export function createTelemetryAdapter(
|
||||
};
|
||||
lifecycle?.addEventListener("pagehide", flushBeforePageExit);
|
||||
|
||||
/**
|
||||
* N-04. Terminal disposal: one state transition, no further admission, no
|
||||
* further scheduling, and no recursive drop telemetry while shutting down.
|
||||
*/
|
||||
function dispose(): void {
|
||||
if (lifecycleState === "DISPOSED") return;
|
||||
lifecycleState = "DISPOSED";
|
||||
scheduleGeneration += 1;
|
||||
scheduled = false;
|
||||
lifecycle?.removeEventListener("pagehide", flushBeforePageExit);
|
||||
queue.length = 0;
|
||||
activeSink?.abort();
|
||||
activeSink = null;
|
||||
activeFlush = null;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
WEB_PUSH_PROTOCOLS,
|
||||
webPushFailure,
|
||||
webPushSuccess,
|
||||
type WebPushNativeEffectCertainty,
|
||||
type WebPushObserver,
|
||||
type WebPushResult,
|
||||
} from "../../../contracts/web-push.ts";
|
||||
@@ -44,6 +45,41 @@ export type NotificationClickAdapter = Readonly<{
|
||||
handle(event: NotificationClickEventFacade): Promise<WebPushResult<void>>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* WP-RR-01. Bounds a native effect by the handler lifetime while keeping the
|
||||
* abandoned promise observable exactly once.
|
||||
*/
|
||||
const ABORT_OWNED = Symbol("web-push-click-aborted");
|
||||
|
||||
/**
|
||||
* WP-01. Per-click observation state: the current native-effect certainty and
|
||||
* the bounded tail tasks that observe an effect landing after the terminal
|
||||
* result. `waitUntil` owns the tails so the worker cannot be terminated before
|
||||
* the evidence lands, and the certainty is monotone from `NOT_APPLIED` through
|
||||
* `MAYBE_APPLIED` to `CONFIRMED`.
|
||||
*/
|
||||
type ClickEffectState = {
|
||||
certainty: WebPushNativeEffectCertainty;
|
||||
tails: Promise<unknown>[];
|
||||
};
|
||||
|
||||
async function raceAbort<Value>(
|
||||
operation: Promise<Value>,
|
||||
signal: AbortSignal,
|
||||
): Promise<Value | typeof ABORT_OWNED> {
|
||||
if (signal.aborted) return ABORT_OWNED;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<typeof ABORT_OWNED>((resolve) => {
|
||||
onAbort = () => resolve(ABORT_OWNED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, aborted]);
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationClickAdapter(dependencies: Readonly<{
|
||||
fenceStore: PushAssociationFenceStore;
|
||||
clients: WorkerClientsFacade;
|
||||
@@ -79,8 +115,17 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
|
||||
const taskControl = createLinkedAbortController(
|
||||
dependencies.signal,
|
||||
);
|
||||
// WP-01. One observation authority per click. The terminal record was
|
||||
// emitted both inside `process` and again here, so an ordinary click was
|
||||
// counted twice, and the native-effect evidence was detached from
|
||||
// `waitUntil` entirely — a worker that shut down after the terminal
|
||||
// result simply lost it.
|
||||
const effect: ClickEffectState = {
|
||||
certainty: "NOT_APPLIED",
|
||||
tails: [],
|
||||
};
|
||||
const processing = withAbortableDeadline(
|
||||
(signal) => process(event.notification.data, signal),
|
||||
(signal) => process(event.notification.data, signal, effect),
|
||||
{
|
||||
deadlineMs: handlerDeadlineMs,
|
||||
operation: "NOTIFICATION_CLICK",
|
||||
@@ -90,12 +135,16 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
|
||||
).finally(taskControl.dispose);
|
||||
try {
|
||||
event.waitUntil(
|
||||
processing.then((result) => {
|
||||
processing.then(async (result) => {
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_click_dispatched",
|
||||
outcome: result.ok ? "SUCCEEDED" : "FAILED",
|
||||
...(result.ok ? {} : { reason: result.error.code }),
|
||||
nativeEffect: effect.certainty,
|
||||
});
|
||||
// The late-effect observation is this handler's own work, so the
|
||||
// worker stays alive for it without extending the public deadline.
|
||||
await Promise.allSettled(effect.tails);
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
@@ -109,6 +158,7 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
|
||||
async function process(
|
||||
data: unknown,
|
||||
signal: AbortSignal,
|
||||
effect: ClickEffectState,
|
||||
): Promise<WebPushResult<void>> {
|
||||
const decoded = decodeNotificationClickData(data, now());
|
||||
if (!decoded.ok) return decoded;
|
||||
@@ -156,21 +206,86 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
|
||||
expiresAt: decoded.value.expiresAt,
|
||||
path,
|
||||
});
|
||||
// WP-RR-01. `focus` and `openWindow` are user-visible native effects, so
|
||||
// they carry the same certainty phase `showNotification` already does:
|
||||
// NOT_APPLIED before the call, MAYBE_APPLIED while the promise is pending,
|
||||
// CONFIRMED on fulfilment. An effect that lands after this handler's
|
||||
// deadline is still observed exactly once — as evidence only, never as
|
||||
// authorization to retry.
|
||||
const observeLateEffect = (
|
||||
pending: Promise<unknown>,
|
||||
appliedWhen: (value: unknown) => boolean,
|
||||
): void => {
|
||||
let observed = false;
|
||||
// WP-01. The tail is tracked so `waitUntil` owns it. Certainty is
|
||||
// monotone: once the native call has been made the effect can only be
|
||||
// confirmed or stay uncertain. A rejection says the call did not report
|
||||
// success, not that it never happened, so downgrading it to NOT_APPLIED
|
||||
// told operators the click had definitely not been applied.
|
||||
effect.tails.push(
|
||||
pending.then(
|
||||
(value) => {
|
||||
if (observed) return;
|
||||
observed = true;
|
||||
const applied = appliedWhen(value);
|
||||
effect.certainty = applied ? "CONFIRMED" : "NOT_APPLIED";
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_click_dispatched",
|
||||
outcome: applied ? "DEGRADED" : "FAILED",
|
||||
reason: "ABORTED",
|
||||
nativeEffect: effect.certainty,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
if (observed) return;
|
||||
observed = true;
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_click_dispatched",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
nativeEffect: "MAYBE_APPLIED",
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
try {
|
||||
if (signal.aborted) {
|
||||
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
|
||||
}
|
||||
if (existing) {
|
||||
existing.postMessage(handoff);
|
||||
await existing.focus();
|
||||
const focused = Promise.resolve(existing.focus());
|
||||
effect.certainty = "MAYBE_APPLIED";
|
||||
const raced = await raceAbort(focused, signal);
|
||||
if (raced === ABORT_OWNED) {
|
||||
observeLateEffect(focused, () => true);
|
||||
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
|
||||
}
|
||||
effect.certainty = "CONFIRMED";
|
||||
} else {
|
||||
const opened = await dependencies.clients.openWindow(target);
|
||||
if (!opened) return nativeFailure("NOTIFICATION_CLICK", true);
|
||||
const opening = Promise.resolve(
|
||||
dependencies.clients.openWindow(target),
|
||||
);
|
||||
effect.certainty = "MAYBE_APPLIED";
|
||||
const raced = await raceAbort(opening, signal);
|
||||
if (raced === ABORT_OWNED) {
|
||||
observeLateEffect(opening, (value) => value !== null);
|
||||
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
|
||||
}
|
||||
if (!raced) {
|
||||
// An explicit null is the one answer that confirms no window opened.
|
||||
effect.certainty = "NOT_APPLIED";
|
||||
return nativeFailure("NOTIFICATION_CLICK", true);
|
||||
}
|
||||
effect.certainty = "CONFIRMED";
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
|
||||
}
|
||||
} catch {
|
||||
// The native call threw, so it never reported success; whether it took
|
||||
// effect is unknown rather than settled.
|
||||
return nativeFailure("NOTIFICATION_CLICK", true);
|
||||
}
|
||||
return webPushSuccess(undefined);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type WebPushNativeEffectCertainty,
|
||||
WEB_PUSH_LIMITS,
|
||||
webPushFailure,
|
||||
webPushSuccess,
|
||||
@@ -148,14 +149,30 @@ export function createPushEventAdapter(dependencies: Readonly<{
|
||||
);
|
||||
if (!finalFence.ok) return finalFence;
|
||||
const clickData = clickDataFromHint(decoded.value);
|
||||
// WP-07. The user-visible effect has its own certainty phase: NOT_APPLIED
|
||||
// before the native call, MAYBE_APPLIED while the promise is pending and
|
||||
// CONFIRMED on fulfilment. It is evidence only and never authorizes retry.
|
||||
let nativeEffect: WebPushNativeEffectCertainty = "NOT_APPLIED";
|
||||
try {
|
||||
await dependencies.notifications.showNotification(definition.title, {
|
||||
body: definition.body,
|
||||
data: clickData,
|
||||
requireInteraction: false,
|
||||
tag,
|
||||
});
|
||||
const shown = dependencies.notifications.showNotification(
|
||||
definition.title,
|
||||
{
|
||||
body: definition.body,
|
||||
data: clickData,
|
||||
requireInteraction: false,
|
||||
tag,
|
||||
},
|
||||
);
|
||||
nativeEffect = "MAYBE_APPLIED";
|
||||
await shown;
|
||||
nativeEffect = "CONFIRMED";
|
||||
if (signal.aborted) {
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_notification_finished",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
nativeEffect,
|
||||
});
|
||||
return webPushFailure("ABORTED", "NOTIFICATION_SHOW");
|
||||
}
|
||||
} catch {
|
||||
@@ -164,12 +181,14 @@ export function createPushEventAdapter(dependencies: Readonly<{
|
||||
event: "web_push_notification_finished",
|
||||
outcome: "FAILED",
|
||||
reason: failureCode(failed),
|
||||
nativeEffect,
|
||||
});
|
||||
return failed;
|
||||
}
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_notification_finished",
|
||||
outcome: "SUCCEEDED",
|
||||
nativeEffect,
|
||||
});
|
||||
return webPushSuccess(undefined);
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ export function createPushAssociationFenceStore(
|
||||
operation,
|
||||
);
|
||||
if (!written.ok) return written;
|
||||
if (!validWriteReceipt(written.value)) {
|
||||
if (!validWriteReceipt(written.value, expectedRevision)) {
|
||||
return webPushFailure("CONTROL_CORRUPT", operation);
|
||||
}
|
||||
return webPushSuccess(
|
||||
@@ -537,10 +537,7 @@ export function createPushAssociationFenceStore(
|
||||
"CONTROL_PURGE",
|
||||
);
|
||||
if (!removed.ok) return removed;
|
||||
if (
|
||||
!validWriteReceipt(removed.value) ||
|
||||
removed.value.revision !== expectedRevision + 1
|
||||
) {
|
||||
if (!validWriteReceipt(removed.value, expectedRevision)) {
|
||||
return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE");
|
||||
}
|
||||
return webPushSuccess(undefined);
|
||||
@@ -693,14 +690,25 @@ function validRepository(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WP-01. One validator for both write and remove.
|
||||
*
|
||||
* A CAS receipt is only evidence when it names the expected key and the exact
|
||||
* next revision. Accepting any well-typed revision let a stale or arbitrary
|
||||
* repository receipt be packaged as a confirmed control, after which the whole
|
||||
* CAS authority is wrong. A replayed receipt must still carry that exact
|
||||
* revision, since replay means "this command already produced this revision".
|
||||
*/
|
||||
function validWriteReceipt(
|
||||
value: PushControlWriteReceipt,
|
||||
expectedRevision: number | null,
|
||||
): value is PushControlWriteReceipt {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
value.key === CONTROL_KEY &&
|
||||
validRevision(value.revision) &&
|
||||
typeof value.replayed === "boolean"
|
||||
typeof value.replayed === "boolean" &&
|
||||
value.revision === (expectedRevision ?? 0) + 1
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WebPushControlPort } from "../../application/ports/out/web-push-control.ts";
|
||||
import {
|
||||
webPushCountBucket,
|
||||
WEB_PUSH_LIMITS,
|
||||
samePushAuthority,
|
||||
webPushFailure,
|
||||
@@ -474,7 +475,9 @@ export function createWebPushSubscriptionAdapter(
|
||||
if (closed) return webPushSuccess(unavailable("CLOSED"));
|
||||
if (busy) return webPushSuccess(unavailable("BUSY"));
|
||||
if (signal?.aborted) {
|
||||
return webPushFailure("ABORTED", "SUBSCRIPTION_INSPECT");
|
||||
// WP-05. A pre-aborted command is recorded as the operation the caller
|
||||
// actually requested, not always as an inspection.
|
||||
return webPushFailure("ABORTED", failureOperation);
|
||||
}
|
||||
busy = true;
|
||||
const generation = lifecycleGeneration;
|
||||
@@ -879,7 +882,9 @@ export function createWebPushSubscriptionAdapter(
|
||||
await Promise.allSettled([
|
||||
unsubscribe,
|
||||
associationEpoch === null
|
||||
? Promise.resolve(webPushSuccess(undefined))
|
||||
? Promise.resolve(
|
||||
webPushSuccess(Object.freeze({ complete: true })),
|
||||
)
|
||||
: closeOwnedNotifications(
|
||||
associationEpoch,
|
||||
signal,
|
||||
@@ -891,7 +896,8 @@ export function createWebPushSubscriptionAdapter(
|
||||
nativeResult.value;
|
||||
const notificationsClean =
|
||||
notificationResult.status === "fulfilled" &&
|
||||
notificationResult.value.ok;
|
||||
notificationResult.value.ok &&
|
||||
notificationResult.value.value.complete;
|
||||
return webPushSuccess(
|
||||
nativeClean && notificationsClean,
|
||||
);
|
||||
@@ -905,11 +911,16 @@ export function createWebPushSubscriptionAdapter(
|
||||
return cleanup.ok && cleanup.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* WP-06. Notification cleanup is bounded best effort and is reported
|
||||
* separately from revoke authority: an incomplete pass returns
|
||||
* `{ complete: false }` and is observed as DEGRADED rather than success.
|
||||
*/
|
||||
async function closeOwnedNotifications(
|
||||
associationEpoch: string,
|
||||
signal: AbortSignal | undefined,
|
||||
generation: number,
|
||||
): Promise<WebPushResult<void>> {
|
||||
): Promise<WebPushResult<Readonly<{ complete: boolean }>>> {
|
||||
let notifications: readonly OwnedNotificationFacade[];
|
||||
try {
|
||||
notifications = await dependencies.registration.getNotifications();
|
||||
@@ -923,6 +934,15 @@ export function createWebPushSubscriptionAdapter(
|
||||
if (stale(signal, generation)) {
|
||||
return webPushFailure("ABORTED", "NOTIFICATION_CLEANUP");
|
||||
}
|
||||
const truncated =
|
||||
notifications.length > WEB_PUSH_LIMITS.notificationCleanupCount;
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_notification_finished",
|
||||
outcome: truncated ? "DEGRADED" : "SUCCEEDED",
|
||||
...(truncated ? { reason: "LIMIT_EXCEEDED" as const } : {}),
|
||||
countBucket: webPushCountBucket(notifications.length),
|
||||
truncated,
|
||||
});
|
||||
for (const notification of notifications.slice(
|
||||
0,
|
||||
WEB_PUSH_LIMITS.notificationCleanupCount,
|
||||
@@ -941,7 +961,7 @@ export function createWebPushSubscriptionAdapter(
|
||||
}
|
||||
}
|
||||
}
|
||||
return webPushSuccess(undefined);
|
||||
return webPushSuccess(Object.freeze({ complete: !truncated }));
|
||||
}
|
||||
|
||||
function stale(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
webPushCountBucket,
|
||||
WEB_PUSH_LIMITS,
|
||||
WEB_PUSH_PROTOCOLS,
|
||||
webPushFailure,
|
||||
@@ -134,6 +135,10 @@ export function createWebPushServiceWorkerRuntime(
|
||||
const facade = functionalEventFacade(event);
|
||||
if (!facade) return;
|
||||
const taskControl = createLinkedAbortController(lifecycle.signal);
|
||||
// WP-06. Bounded fan-out is policy, but the operator must be able to see
|
||||
// that only part of the client set was notified.
|
||||
let observedClientCount = 0;
|
||||
let truncatedClients = false;
|
||||
const processing = withAbortableDeadline(
|
||||
async (signal) => {
|
||||
let clients: readonly unknown[];
|
||||
@@ -148,6 +153,9 @@ export function createWebPushServiceWorkerRuntime(
|
||||
if (signal.aborted) {
|
||||
return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE");
|
||||
}
|
||||
observedClientCount = clients.length;
|
||||
truncatedClients =
|
||||
clients.length > WEB_PUSH_LIMITS.clientHandoffCount;
|
||||
try {
|
||||
for (const candidate of clients.slice(
|
||||
0,
|
||||
@@ -181,8 +189,15 @@ export function createWebPushServiceWorkerRuntime(
|
||||
const lifetime = processing.then((result) => {
|
||||
observeWebPush(dependencies.observer, {
|
||||
event: "web_push_subscription_rotated",
|
||||
outcome: result.ok ? "SUCCEEDED" : "DEGRADED",
|
||||
...(result.ok ? {} : { reason: result.error.code }),
|
||||
outcome:
|
||||
result.ok && !truncatedClients ? "SUCCEEDED" : "DEGRADED",
|
||||
...(result.ok
|
||||
? truncatedClients
|
||||
? { reason: "LIMIT_EXCEEDED" as const }
|
||||
: {}
|
||||
: { reason: result.error.code }),
|
||||
countBucket: webPushCountBucket(observedClientCount),
|
||||
truncated: truncatedClients,
|
||||
});
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,16 @@ export type SessionGateway = Readonly<{
|
||||
subscribe(listener: () => void): () => void;
|
||||
beginSignIn(returnTo?: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
recover(): Promise<"restored" | "no-session">;
|
||||
/**
|
||||
* LEG-01. Recovery is part of a request's lifetime, so it receives the same
|
||||
* context a credential attach does. The context is optional for one release
|
||||
* to keep existing owners working; the transport races the signal either way,
|
||||
* and a recovery that answers after the request already ended is observed but
|
||||
* never turned into a user-visible sign-out.
|
||||
*/
|
||||
recover(
|
||||
context?: CredentialOperationContext,
|
||||
): Promise<"restored" | "no-session">;
|
||||
}>;
|
||||
|
||||
export type CredentialRequestBinding = Readonly<{
|
||||
@@ -22,8 +31,21 @@ export type CredentialPatch = Readonly<{
|
||||
headers: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §8.5. The transport lifetime handed to a credential owner. A cooperative
|
||||
* owner abandons its own work on abort; a non-cooperative one is still bounded
|
||||
* because the transport races the same signal.
|
||||
*/
|
||||
export type CredentialOperationContext = Readonly<{
|
||||
signal: AbortSignal;
|
||||
deadlineAtMonotonicMs: number;
|
||||
}>;
|
||||
|
||||
export type CredentialAttacher = Readonly<{
|
||||
credentialPatch(binding: CredentialRequestBinding): Promise<CredentialPatch>;
|
||||
credentialPatch(
|
||||
binding: CredentialRequestBinding,
|
||||
context?: CredentialOperationContext,
|
||||
): Promise<CredentialPatch>;
|
||||
onUnauthenticated(): void;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -169,12 +169,46 @@ export type OpfsChunkReference = Readonly<{
|
||||
digestHex: string;
|
||||
}>;
|
||||
|
||||
export type OpfsPreparedObject = Readonly<{
|
||||
/**
|
||||
* STO-01. A transaction-unique physical fencing token.
|
||||
*
|
||||
* The logical `generation` is reused across transactions by design, so a late
|
||||
* compensation from an abandoned transaction could otherwise delete the
|
||||
* physical directory a newer transaction just created under the same logical
|
||||
* generation. Physical paths are keyed by this token instead.
|
||||
*/
|
||||
declare const opfsPhysicalGenerationBrand: unique symbol;
|
||||
export type OpfsPhysicalGenerationId = string & {
|
||||
readonly [opfsPhysicalGenerationBrand]: "OpfsPhysicalGenerationId";
|
||||
};
|
||||
|
||||
export type OpfsPreparedObjectV1 = Readonly<{
|
||||
descriptor: DurableObjectDescriptor;
|
||||
chunks: readonly OpfsChunkReference[];
|
||||
physicalSchemaVersion: 1;
|
||||
}>;
|
||||
|
||||
export type OpfsPreparedObjectV2 = Readonly<{
|
||||
descriptor: DurableObjectDescriptor;
|
||||
chunks: readonly OpfsChunkReference[];
|
||||
physicalSchemaVersion: 2;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Expand phase: v1 readers stay for the rollback window while every new write
|
||||
* emits v2.
|
||||
*/
|
||||
export type OpfsPreparedObject = OpfsPreparedObjectV1 | OpfsPreparedObjectV2;
|
||||
|
||||
/**
|
||||
* STO-01. Compensation is only allowed to release journal and budget state
|
||||
* after the physical effect is confirmed. `EFFECT_UNKNOWN` is never a success.
|
||||
*/
|
||||
export type OpfsCleanupEffect =
|
||||
| Readonly<{ kind: "CLEANED" | "ALREADY_CLEAN" }>
|
||||
| Readonly<{ kind: "EFFECT_UNKNOWN" }>;
|
||||
|
||||
export type OpfsJournalMutation = "PUT" | "DELETE";
|
||||
export type OpfsJournalPhase =
|
||||
| "PREPARING"
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
import type { Result } from "../../result.ts";
|
||||
|
||||
/**
|
||||
* STO-RR-03. The runtime membership set behind the closed failure taxonomy. A
|
||||
* boundary decoder needs to test a value against it, and a type alone cannot
|
||||
* stop an arbitrary string from reaching application code.
|
||||
*/
|
||||
export const BROWSER_DATA_FAILURE_CODES = Object.freeze([
|
||||
"ABORTED",
|
||||
"BLOCKED",
|
||||
"CONFLICT",
|
||||
"CORRUPT_DATA",
|
||||
"EXPIRED_RESOURCE",
|
||||
"INTEGRITY_FAILED",
|
||||
"INVALID_INPUT",
|
||||
"LIMIT_EXCEEDED",
|
||||
"MIGRATION_FAILED",
|
||||
"NOT_FOUND",
|
||||
"NOT_READABLE",
|
||||
"PERMISSION_DENIED",
|
||||
"POLICY_REJECTED",
|
||||
"QUOTA_EXCEEDED",
|
||||
"STALE_RESULT",
|
||||
"STORAGE_EVICTED",
|
||||
"UNAVAILABLE",
|
||||
"UNSUPPORTED",
|
||||
] as const);
|
||||
|
||||
export function isBrowserDataFailureCode(
|
||||
value: unknown,
|
||||
): value is BrowserDataFailureCode {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(BROWSER_DATA_FAILURE_CODES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export type BrowserDataFailureCode =
|
||||
| "ABORTED"
|
||||
| "BLOCKED"
|
||||
| "CONFLICT"
|
||||
| "CORRUPT_DATA"
|
||||
| "EXPIRED_RESOURCE"
|
||||
| "INTEGRITY_FAILED"
|
||||
| "INVALID_INPUT"
|
||||
| "LIMIT_EXCEEDED"
|
||||
| "MIGRATION_FAILED"
|
||||
| "NOT_FOUND"
|
||||
| "NOT_READABLE"
|
||||
| "PERMISSION_DENIED"
|
||||
| "POLICY_REJECTED"
|
||||
| "QUOTA_EXCEEDED"
|
||||
| "STALE_RESULT"
|
||||
| "STORAGE_EVICTED"
|
||||
| "UNAVAILABLE"
|
||||
| "UNSUPPORTED";
|
||||
(typeof BROWSER_DATA_FAILURE_CODES)[number];
|
||||
|
||||
export type BrowserDataOperation =
|
||||
| "CACHE_ACTIVATE"
|
||||
|
||||
@@ -165,10 +165,18 @@ export type ImagePresentationDescriptor = Readonly<{
|
||||
}>;
|
||||
|
||||
export interface ImageCdnPresentationPort {
|
||||
/**
|
||||
* BT-IMG-01. The lifetime signal is required.
|
||||
*
|
||||
* It used to be optional, so the `PRIMARY_REQUIRED` preset expressed a
|
||||
* missing signal as a runtime `UNSUPPORTED` result - a hidden preset
|
||||
* precondition. Requiring it at the type level removes that hidden rule
|
||||
* instead of discovering it at runtime.
|
||||
*/
|
||||
resolve(request: Readonly<{
|
||||
asset: ImageAssetReference;
|
||||
preset: ImagePresetReference;
|
||||
signal?: AbortSignal;
|
||||
signal: AbortSignal;
|
||||
}>): Promise<BrowserDataResult<ImagePresentationDescriptor>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,11 +108,30 @@ export interface PresignedTransferReplayGuard {
|
||||
* of the closed-Result stream. Consumers must not commit a destination until
|
||||
* the iterable finishes without a failure result.
|
||||
*/
|
||||
/**
|
||||
* BT-PRE-02. Top-level wire protocol for the capability envelope.
|
||||
*
|
||||
* Without it, a server that adds or reinterprets a field leaves old and new
|
||||
* clients decoding the same shape with different meaning, and the resulting
|
||||
* outage is not classified as a version mismatch. `PRESIGNED_MULTIPART_V1`
|
||||
* stays as the nested multipart binding protocol.
|
||||
*/
|
||||
export const PRESIGNED_TRANSFER_PROTOCOL = "PRESIGNED_TRANSFER_V1" as const;
|
||||
|
||||
export type PresignedTransferProtocol = typeof PRESIGNED_TRANSFER_PROTOCOL;
|
||||
|
||||
export type PresignedDownloadByteSource = FileByteSource &
|
||||
Readonly<{
|
||||
byteLength: number;
|
||||
capability: PresignedDownloadCapability;
|
||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
|
||||
/**
|
||||
* BT-PRE-01. Discards the lease. Before the first `stream()` this performs
|
||||
* no network I/O at all; during streaming it cancels the body and releases
|
||||
* the timer and listeners exactly once. Every consumer must call it in a
|
||||
* `finally`, including on a pre-stream failure.
|
||||
*/
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
export interface PresignedDownloadSourcePort {
|
||||
|
||||
@@ -40,6 +40,14 @@ export type UploadPartReceipt = UploadPartDescriptor &
|
||||
receiptToken: string;
|
||||
}>;
|
||||
|
||||
export type PartitionDeleteOutcome =
|
||||
| Readonly<{ state: "DELETED"; effect: "APPLIED" }>
|
||||
| Readonly<{
|
||||
state: "PENDING";
|
||||
effect: "UNKNOWN";
|
||||
reason: "BLOCKED_DEADLINE";
|
||||
}>;
|
||||
|
||||
export interface UploadRangeReader {
|
||||
readonly byteLength: number;
|
||||
readRange(input: Readonly<{
|
||||
@@ -273,7 +281,13 @@ export interface ResumableUploadCheckpointAdmin {
|
||||
* Account/logout lifecycle operation for this already-bound opaque partition.
|
||||
* The adapter closes its connection before deletion and bounds blocked waits.
|
||||
*/
|
||||
/**
|
||||
* BT-UP-03. An IndexedDB `deleteDatabase()` request cannot be cancelled once
|
||||
* dispatched, so a blocked deadline is not evidence that nothing happened.
|
||||
* `PENDING` reports the effect honestly as `UNKNOWN`; only pre-dispatch
|
||||
* problems are ordinary failures.
|
||||
*/
|
||||
deletePartition(
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<Readonly<{ state: "DELETED" }>>>;
|
||||
): Promise<BrowserDataResult<PartitionDeleteOutcome>>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,17 @@ import {
|
||||
} from "../adapters/auth/external-session-adapter.ts";
|
||||
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts";
|
||||
import { createHttpClient } from "../adapters/http/client.ts";
|
||||
import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts";
|
||||
import {
|
||||
createContractHttpExecutor,
|
||||
type HttpExecutionObservation,
|
||||
} from "../adapters/http/http-execution-v3.ts";
|
||||
import {
|
||||
attemptBucket,
|
||||
durationBucket,
|
||||
statusGroup,
|
||||
type DiagnosticRecordInput,
|
||||
} from "../contracts/diagnostics.ts";
|
||||
import type { TelemetryEventName } from "../contracts/telemetry.ts";
|
||||
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
|
||||
import {
|
||||
createTanStackCacheCoordinator,
|
||||
@@ -19,7 +29,10 @@ import { createBrowserMutationIntentFactory } from "../adapters/platform/browser
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
||||
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
||||
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
|
||||
import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
|
||||
import {
|
||||
createRestProviderProfile,
|
||||
INSTALLED_REST_AUTH_PROFILES,
|
||||
} from "../contracts/rest-profiles.ts";
|
||||
import type { ClockPort } from "../application/ports/clock-port.ts";
|
||||
import type { MutationIntent } from "../contracts/mutation-intent.ts";
|
||||
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
|
||||
@@ -162,6 +175,89 @@ export function createRuntimeHttpClient(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* VD-07. Exactly one diagnostic per logical V3 execution and exactly one
|
||||
* `api.request.failed` telemetry event per terminal non-abort failure.
|
||||
*
|
||||
* The projection is closed: only registered context keys and bucketed values
|
||||
* reach the sinks, and neither sink can change the HTTP outcome, because the
|
||||
* caller invokes this inside the executor's isolated observation boundary.
|
||||
*/
|
||||
export function createHttpObservationProjector(
|
||||
sinks: Readonly<{
|
||||
diagnostics: Readonly<{ record(input: DiagnosticRecordInput): void }>;
|
||||
telemetry: Readonly<{
|
||||
emit(
|
||||
eventName: TelemetryEventName,
|
||||
attributes: Record<string, unknown>,
|
||||
): void;
|
||||
}>;
|
||||
}>,
|
||||
): (observation: HttpExecutionObservation) => void {
|
||||
return (observation) => {
|
||||
const safeAttributes = {
|
||||
route_id: observation.routeId,
|
||||
operation_id: observation.operationId,
|
||||
error_kind: observation.errorKind,
|
||||
http_status_group: statusGroup(observation.status),
|
||||
attempt_count_bucket: attemptBucket(observation.attemptCount),
|
||||
duration_bucket: durationBucket(observation.durationMs),
|
||||
};
|
||||
try {
|
||||
sinks.diagnostics.record({
|
||||
level: observation.outcome === "SUCCESS" ? "info" : "warn",
|
||||
eventId: "http.request.completed",
|
||||
context: {
|
||||
...safeAttributes,
|
||||
operation: observation.diagnosticsOperation,
|
||||
outcome: observation.outcome,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics cannot change a contract execution outcome.
|
||||
}
|
||||
if (!isTerminalNonAbortFailure(observation)) return;
|
||||
try {
|
||||
sinks.telemetry.emit("api.request.failed", { ...safeAttributes });
|
||||
} catch {
|
||||
// Telemetry cannot change a contract execution outcome.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE-05. Cancellation and scope fencing are caller- or generation-owned
|
||||
* decisions, not API failures: they produce a diagnostic once and never
|
||||
* `api.request.failed`.
|
||||
*
|
||||
* A `DEADLINE` owner is the opposite case. Nobody asked for it — the API did
|
||||
* not answer inside the contract's own budget — so excluding it would hide
|
||||
* exactly the outage this event exists to report.
|
||||
*/
|
||||
const CALLER_OWNED_CANCELLATION: ReadonlySet<string> = new Set([
|
||||
"CALLER",
|
||||
"ROUTE_TRANSITION",
|
||||
"SCOPE_FENCE",
|
||||
"APPLICATION_SHUTDOWN",
|
||||
]);
|
||||
|
||||
function isTerminalNonAbortFailure(
|
||||
observation: HttpExecutionObservation,
|
||||
): boolean {
|
||||
if (observation.outcome === "SUCCESS") return false;
|
||||
if (observation.outcome === "CANCELLED") return false;
|
||||
if (
|
||||
observation.cancellationOwner !== undefined &&
|
||||
CALLER_OWNED_CANCELLATION.has(observation.cancellationOwner)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
observation.outcome === "CONTRACT_VIOLATION" &&
|
||||
observation.errorKind === "SCOPE_FENCED"
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRuntimeAdapters(
|
||||
context: RuntimeAdaptersContext,
|
||||
) {
|
||||
@@ -299,7 +395,10 @@ export async function createRuntimeAdapters(
|
||||
baseUrl: config.API_BASE_URL,
|
||||
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
||||
fetcher: context.fetcher,
|
||||
async attachCredentials(operation) {
|
||||
// §7.7. The installed registry owns Fetch credentials and the exact
|
||||
// credential-header sets; this collaborator only supplies proof headers.
|
||||
authProfiles: INSTALLED_REST_AUTH_PROFILES,
|
||||
async attachCredentials(operation, authContext) {
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
@@ -311,49 +410,36 @@ export async function createRuntimeAdapters(
|
||||
return Object.freeze({ kind: "UNAUTHENTICATED" as const });
|
||||
}
|
||||
try {
|
||||
const patch = await authSession.credentialPatch({
|
||||
origin: new URL(config.API_BASE_URL).origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
});
|
||||
const patch = await authSession.credentialPatch(
|
||||
{
|
||||
origin: new URL(config.API_BASE_URL).origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers: patch.headers,
|
||||
credentials: "omit" as const,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
}
|
||||
},
|
||||
observe(observation) {
|
||||
try {
|
||||
diagnostics.record({
|
||||
level:
|
||||
observation.outcome === "SUCCESS" ? "info" : "warn",
|
||||
eventId: "http.request.completed",
|
||||
context: {
|
||||
operation_id: observation.diagnosticsOperation,
|
||||
outcome: observation.outcome,
|
||||
attempts: observation.attempts,
|
||||
certainty: observation.certainty,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics cannot change a contract execution outcome.
|
||||
}
|
||||
},
|
||||
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
||||
});
|
||||
const contractOperations = Object.freeze({
|
||||
async execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
executionContext: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}> = {},
|
||||
}>,
|
||||
) {
|
||||
const operation =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
|
||||
@@ -368,6 +454,7 @@ export async function createRuntimeAdapters(
|
||||
});
|
||||
}
|
||||
const outcome = await contractHttp.execute(operation, input, {
|
||||
routeId: executionContext.routeId,
|
||||
scope: serverStateScope.getSnapshot(),
|
||||
...(executionContext.signal === undefined
|
||||
? {}
|
||||
@@ -410,6 +497,10 @@ export async function createRuntimeAdapters(
|
||||
crossContextInvalidationStatus: () =>
|
||||
serverStateGeneration.getSnapshot().crossContextStatus(),
|
||||
dispose() {
|
||||
// N-04. Telemetry is torn down first: it must stop scheduling and
|
||||
// delivering before the diagnostics and state dependencies it observes
|
||||
// are destroyed.
|
||||
telemetry.dispose();
|
||||
conditionalValidators.clear();
|
||||
serverStateScope.dispose();
|
||||
serverStateGeneration.dispose();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
createReadOnlyRegistry,
|
||||
type ReadOnlyRegistry,
|
||||
} from "./read-only-registry.ts";
|
||||
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
|
||||
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
|
||||
|
||||
@@ -327,6 +331,209 @@ export function composeBrowserRpcRequestEncoderRegistry(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC-RR-03. Read facades, never `Map`s. `Object.freeze(new Map(...))` leaves
|
||||
* `set`, `delete` and `clear` working, so an installed registry could still be
|
||||
* emptied or re-pointed after the snapshot was validated.
|
||||
*/
|
||||
export type InstalledBrowserRpcContractBindings = Readonly<{
|
||||
operations: ReadOnlyRegistry<string, BrowserRpcOperationV3>;
|
||||
profiles: ReadOnlyRegistry<string, BrowserRpcProviderProfile>;
|
||||
schemaCodecs: ReadOnlyRegistry<string, RuntimeSchemaCodec>;
|
||||
mappers: ReadOnlyRegistry<string, InstalledBoundaryMapper>;
|
||||
requestEncoders: ReadOnlyRegistry<string, BrowserRpcRequestEncoder>;
|
||||
runtimeBindings: ReadOnlyRegistry<string, BrowserRpcRuntimeBindingIdentity>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* R-04. Parse → validate → install.
|
||||
*
|
||||
* `Readonly` is a TypeScript annotation, not a runtime guarantee, and a source
|
||||
* registry can be mutated after validation so replay policy, deadlines, byte
|
||||
* ceilings or transport selection differ from what was checked. Every row is
|
||||
* therefore copied once into a frozen null-prototype snapshot built from exact
|
||||
* own data properties. A getter, an extra or symbol key, a malformed descriptor
|
||||
* or a revoked proxy is a composition-time `TypeError`, and the runtime reads
|
||||
* only the snapshot afterwards.
|
||||
*/
|
||||
function installRegistrySnapshot<Value extends object>(
|
||||
source: Readonly<Record<string, Value>>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): ReadOnlyRegistry<string, Value> {
|
||||
let ownKeys: string[];
|
||||
let symbols: readonly symbol[];
|
||||
let prototype: object | null;
|
||||
try {
|
||||
// RPC-03. Own *names*, not just enumerable keys: a non-enumerable own entry
|
||||
// is as much a smuggled row as an inherited one, and `Object.keys` never
|
||||
// saw either.
|
||||
ownKeys = Object.getOwnPropertyNames(source);
|
||||
symbols = Object.getOwnPropertySymbols(source);
|
||||
prototype = Reflect.getPrototypeOf(source);
|
||||
} catch {
|
||||
throw new TypeError(`Browser RPC ${label} registry is unreadable.`);
|
||||
}
|
||||
if (symbols.length > 0) {
|
||||
throw new TypeError(`Browser RPC ${label} registry has symbol keys.`);
|
||||
}
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`Browser RPC ${label} registry has a custom prototype.`);
|
||||
}
|
||||
const installed = new Map<string, Value>();
|
||||
for (const key of ownKeys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError(
|
||||
`Browser RPC ${label} registry entry is not a data property: ${key}`,
|
||||
);
|
||||
}
|
||||
installed.set(
|
||||
key,
|
||||
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
|
||||
);
|
||||
}
|
||||
return createReadOnlyRegistry(installed);
|
||||
}
|
||||
|
||||
function installRowSnapshot<Value extends object>(
|
||||
row: Value,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): Value {
|
||||
if (!row || typeof row !== "object") {
|
||||
throw new TypeError(`Browser RPC ${label} row is not an object.`);
|
||||
}
|
||||
let ownKeys: string[];
|
||||
let symbols: readonly symbol[];
|
||||
let prototype: object | null;
|
||||
try {
|
||||
ownKeys = Object.getOwnPropertyNames(row);
|
||||
symbols = Object.getOwnPropertySymbols(row);
|
||||
prototype = Reflect.getPrototypeOf(row);
|
||||
} catch {
|
||||
throw new TypeError(`Browser RPC ${label} row is unreadable.`);
|
||||
}
|
||||
if (symbols.length > 0) {
|
||||
throw new TypeError(`Browser RPC ${label} row has symbol keys.`);
|
||||
}
|
||||
// RPC-03. A custom prototype carries fields the name sweep never sees and
|
||||
// stays live after installation, so the installed row would not be the row
|
||||
// that was checked.
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`Browser RPC ${label} row has a custom prototype.`);
|
||||
}
|
||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||
for (const key of ownKeys) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
throw new TypeError(
|
||||
`Browser RPC ${label} row has an unexpected key: ${key}`,
|
||||
);
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(row, key);
|
||||
// Reading an accessor would invoke a getter; refuse without calling it.
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError(
|
||||
`Browser RPC ${label} row key is not a data property: ${key}`,
|
||||
);
|
||||
}
|
||||
const value = descriptor.value as unknown;
|
||||
snapshot[key] = Array.isArray(value)
|
||||
? Object.freeze([...value])
|
||||
: value;
|
||||
}
|
||||
return Object.freeze(snapshot) as Value;
|
||||
}
|
||||
|
||||
const OPERATION_KEYS = Object.freeze([
|
||||
"contractVersion", "operationId", "owner", "protocol", "semantics",
|
||||
"replayPolicy", "idempotencyKeyPolicy", "idempotencyLevel",
|
||||
"dataClassification", "runtimeProfileId", "providerId",
|
||||
"fullyQualifiedService", "method", "rpcKind", "requestMessageId",
|
||||
"responseMessageId", "descriptorArtifactId", "descriptorDigest",
|
||||
"requestSchemaId", "responseSchemaId", "requestEncoderId", "mapperId",
|
||||
"authProfileId", "csrfProfileId", "errorProfileId", "deadlineProfileId",
|
||||
"retryProfileId", "serverStateProfileId", "maxRequestMessageBytes",
|
||||
"maxResponseMessageBytes", "maxResponseMessages", "maxTotalResponseBytes",
|
||||
"maxBufferedBytes", "idleDeadlineMs", "totalDeadlineMs",
|
||||
] as const);
|
||||
const PROFILE_KEYS = Object.freeze([
|
||||
"runtimeProfileId", "providerId", "fixedBaseUrl", "runtimeId",
|
||||
"runtimeVersion", "runtimeDigest", "protocol", "runtimeKind",
|
||||
"clientApiKind", "rpcKind", "messageEncoding", "framing", "requestMethod",
|
||||
"descriptorArtifactId", "descriptorDigest", "allowedProcedures",
|
||||
"authProfileId", "csrfProfileId", "corsProfileId", "errorProfileId",
|
||||
"deadlineProfileId", "retryProfileId", "retryOwner", "maxAttempts",
|
||||
"backoffMs", "retryableFailures", "maxRetryAfterMs", "deadlineDialect",
|
||||
"cancelDialect", "rawByteCeilingOwner", "streamMessageCompression",
|
||||
] as const);
|
||||
const SCHEMA_KEYS = Object.freeze(["schemaId", "parse"] as const);
|
||||
const MAPPER_KEYS = Object.freeze([
|
||||
"mapperId", "mapperVersion", "inputSchemaId", "outputContractId", "owner",
|
||||
"maxOutputItems", "map",
|
||||
] as const);
|
||||
const ENCODER_KEYS = Object.freeze([
|
||||
"encoderId", "operationId", "encode",
|
||||
] as const);
|
||||
const RUNTIME_BINDING_KEYS = Object.freeze([
|
||||
"runtimeProfileId", "providerId", "protocol", "rpcKind",
|
||||
] as const);
|
||||
|
||||
export function installBrowserRpcContractBindings(
|
||||
bindings: BrowserRpcContractBindings,
|
||||
): InstalledBrowserRpcContractBindings {
|
||||
// Parse first. Snapshotting from own data descriptors rejects accessors
|
||||
// without ever invoking them, so a hostile getter cannot observe validation
|
||||
// or return a different value to it than to the runtime.
|
||||
const operations = installRegistrySnapshot(
|
||||
bindings.operations,
|
||||
"operation",
|
||||
OPERATION_KEYS,
|
||||
);
|
||||
const profiles = installRegistrySnapshot(
|
||||
bindings.profiles,
|
||||
"profile",
|
||||
PROFILE_KEYS,
|
||||
);
|
||||
const schemaCodecs = installRegistrySnapshot(
|
||||
bindings.schemaCodecs,
|
||||
"schema",
|
||||
SCHEMA_KEYS,
|
||||
);
|
||||
const mappers = installRegistrySnapshot(
|
||||
bindings.mappers,
|
||||
"mapper",
|
||||
MAPPER_KEYS,
|
||||
);
|
||||
const requestEncoders = installRegistrySnapshot(
|
||||
bindings.requestEncoders,
|
||||
"encoder",
|
||||
ENCODER_KEYS,
|
||||
);
|
||||
const runtimeBindings = installRegistrySnapshot(
|
||||
bindings.runtimeBindings ?? {},
|
||||
"runtime",
|
||||
RUNTIME_BINDING_KEYS,
|
||||
);
|
||||
// Then validate the snapshot, so what was checked is exactly what installs.
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: Object.fromEntries(operations),
|
||||
profiles: Object.fromEntries(profiles),
|
||||
schemaCodecs: Object.fromEntries(schemaCodecs),
|
||||
mappers: Object.fromEntries(mappers),
|
||||
requestEncoders: Object.fromEntries(requestEncoders),
|
||||
runtimeBindings: Object.fromEntries(runtimeBindings),
|
||||
});
|
||||
return Object.freeze({
|
||||
operations,
|
||||
profiles,
|
||||
schemaCodecs,
|
||||
mappers,
|
||||
requestEncoders,
|
||||
runtimeBindings,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBrowserRpcContractBindings(
|
||||
bindings: BrowserRpcContractBindings,
|
||||
): true {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Descriptor-based exact decoding for values that cross a trust boundary.
|
||||
*
|
||||
* Several adapters independently wrote "check the shape, then read it again to
|
||||
* copy it". That order is the bug: between the check and the copy an accessor
|
||||
* or a Proxy can answer differently, so the value that was validated and the
|
||||
* value that was installed are two different things. Every helper here reads a
|
||||
* property exactly once, through its own data descriptor, and hands back an
|
||||
* owned plain object. Validation then runs on the snapshot, never on the source.
|
||||
*
|
||||
* The helpers are total: a hostile `getPrototypeOf`, `ownKeys` or
|
||||
* `getOwnPropertyDescriptor` trap yields `null`, never a thrown exception, so a
|
||||
* caller can keep its own typed failure vocabulary.
|
||||
*/
|
||||
|
||||
const DEFAULT_PROTOTYPES: readonly (object | null)[] = Object.freeze([
|
||||
Object.prototype,
|
||||
null,
|
||||
]);
|
||||
|
||||
export type ExactObjectPolicy = Readonly<{
|
||||
/** Every own key the value may carry. Anything else rejects the snapshot. */
|
||||
allowed: readonly string[];
|
||||
/** Keys that must be present as own data properties. */
|
||||
required?: readonly string[];
|
||||
/**
|
||||
* Prototypes the value may have. Defaults to a plain object or a null
|
||||
* prototype, which is what a decoded wire payload or a literal produces.
|
||||
*/
|
||||
prototypes?: readonly (object | null)[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Reads `source[key]` exactly once through its own data descriptor. An accessor,
|
||||
* an inherited property or a missing key all answer `undefined`, and a trap that
|
||||
* throws answers `undefined` rather than escaping.
|
||||
*/
|
||||
export function ownDataValue(source: unknown, key: string): unknown {
|
||||
if (source === null || typeof source !== "object") return undefined;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) return undefined;
|
||||
return descriptor.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `key` is present as an own data property. */
|
||||
export function hasOwnDataKey(source: unknown, key: string): boolean {
|
||||
if (source === null || typeof source !== "object") return false;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
return Boolean(descriptor) && "value" in (descriptor as PropertyDescriptor);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies `source` into a frozen plain object, reading every property exactly
|
||||
* once. Returns `null` when the value is not an object, carries a symbol or an
|
||||
* unexpected own key, exposes an accessor, has an unapproved prototype, misses a
|
||||
* required key, or makes any reflection operation throw.
|
||||
*/
|
||||
export function snapshotExactObject(
|
||||
source: unknown,
|
||||
policy: ExactObjectPolicy,
|
||||
): Readonly<Record<string, unknown>> | null {
|
||||
if (source === null || typeof source !== "object") return null;
|
||||
try {
|
||||
const prototypes = policy.prototypes ?? DEFAULT_PROTOTYPES;
|
||||
if (!prototypes.includes(Reflect.getPrototypeOf(source))) return null;
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) return null;
|
||||
|
||||
const allowed = new Set(policy.allowed);
|
||||
const names = Object.getOwnPropertyNames(source);
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const name of names) {
|
||||
if (!allowed.has(name)) return null;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, name);
|
||||
// A non-enumerable own property is as much a smuggled field as an
|
||||
// inherited one, and an accessor is a second read waiting to happen.
|
||||
if (
|
||||
!descriptor ||
|
||||
!("value" in descriptor) ||
|
||||
descriptor.enumerable !== true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
Object.defineProperty(snapshot, name, {
|
||||
value: descriptor.value,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
for (const name of policy.required ?? []) {
|
||||
if (!Object.hasOwn(snapshot, name)) return null;
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an open-keyed record — a header bag, a query map — into a frozen owned
|
||||
* object, reading every property exactly once. The key set is not constrained
|
||||
* here; admission against an allow-list stays with the policy that owns it, so
|
||||
* the more specific rejection can still be reported. Returns `null` for a
|
||||
* non-object, a symbol key, an accessor, a non-enumerable own key, an
|
||||
* unapproved prototype, more than `maximumKeys` entries, or a throwing trap.
|
||||
*/
|
||||
export function snapshotOwnDataRecord(
|
||||
source: unknown,
|
||||
maximumKeys = 64,
|
||||
): Readonly<Record<string, unknown>> | null {
|
||||
if (source === null || typeof source !== "object") return null;
|
||||
try {
|
||||
if (!DEFAULT_PROTOTYPES.includes(Reflect.getPrototypeOf(source))) {
|
||||
return null;
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) return null;
|
||||
const names = Object.getOwnPropertyNames(source);
|
||||
if (names.length > maximumKeys) return null;
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const name of names) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, name);
|
||||
if (
|
||||
!descriptor ||
|
||||
!("value" in descriptor) ||
|
||||
descriptor.enumerable !== true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
Object.defineProperty(snapshot, name, {
|
||||
value: descriptor.value,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a genuine array into a frozen owned array, reading each element exactly
|
||||
* once. Returns `null` for a non-array, a hostile length or a trap that throws.
|
||||
*/
|
||||
export function snapshotExactArray(
|
||||
source: unknown,
|
||||
maximumLength = 4_096,
|
||||
): readonly unknown[] | null {
|
||||
try {
|
||||
if (!Array.isArray(source)) return null;
|
||||
const length = source.length;
|
||||
if (!Number.isSafeInteger(length) || length < 0 || length > maximumLength) {
|
||||
return null;
|
||||
}
|
||||
const items: unknown[] = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, String(index));
|
||||
if (!descriptor || !("value" in descriptor)) return null;
|
||||
items.push(descriptor.value);
|
||||
}
|
||||
return Object.freeze(items);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,13 @@
|
||||
* applies before a contribution may be composed.
|
||||
*/
|
||||
|
||||
import { INSTALLED_REST_AUTH_PROFILES } from "./rest-profiles.ts";
|
||||
import {
|
||||
createReadOnlyRegistry,
|
||||
exactOwnDataSnapshot,
|
||||
type ReadOnlyRegistry,
|
||||
} from "./read-only-registry.ts";
|
||||
|
||||
/** §7.3 hard ceilings. A contribution may lower these, never raise them. */
|
||||
export const HTTP_EXECUTION_CEILINGS = Object.freeze({
|
||||
defaultRequestBytes: 262_144,
|
||||
@@ -313,6 +320,11 @@ function assertExecutionPolicy(
|
||||
) {
|
||||
fail(`${label}: frontend execution policy identity`);
|
||||
}
|
||||
// §7.7 / VD-23. A declared profile that the installed registry does not own
|
||||
// is a composition failure; the executor must never resolve it at runtime.
|
||||
if (!INSTALLED_REST_AUTH_PROFILES.has(policy.authProfileId)) {
|
||||
fail(`${label}: unknown authProfileId ${policy.authProfileId}`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.requestByteLimit) ||
|
||||
policy.requestByteLimit < 0 ||
|
||||
@@ -473,14 +485,113 @@ function assertEventContract(
|
||||
|
||||
export type ComposedContractContributions = Readonly<{
|
||||
contributions: readonly InstalledContractContribution[];
|
||||
httpByOperationId: ReadonlyMap<
|
||||
/**
|
||||
* LIVE-03. Read facades over private stores. The executor resolves an
|
||||
* operation on every request, so an exported `Map` would let any holder of
|
||||
* the composed singleton delete or replace a validated row after boot.
|
||||
*/
|
||||
httpByOperationId: ReadOnlyRegistry<
|
||||
string,
|
||||
InstalledHttpContract<unknown, unknown, unknown>
|
||||
>;
|
||||
eventByType: ReadonlyMap<string, InstalledEventContract<unknown, unknown>>;
|
||||
eventByType: ReadOnlyRegistry<
|
||||
string,
|
||||
InstalledEventContract<unknown, unknown>
|
||||
>;
|
||||
externalPackages: readonly InstalledContractPackageIdentity[];
|
||||
}>;
|
||||
|
||||
const EXECUTION_POLICY_KEYS = [
|
||||
"policyId",
|
||||
"requestByteLimit",
|
||||
"responseByteLimit",
|
||||
"totalDeadlineMs",
|
||||
"retryBudget",
|
||||
"authProfileId",
|
||||
"diagnosticsOperation",
|
||||
] as const;
|
||||
|
||||
const HTTP_CONTRACT_KEYS = [
|
||||
"operationId",
|
||||
"method",
|
||||
"pathTemplate",
|
||||
"inputValidator",
|
||||
"outputValidator",
|
||||
"problemValidator",
|
||||
"acceptedStatuses",
|
||||
"emptyBodyStatuses",
|
||||
"retrySemantics",
|
||||
"requestBody",
|
||||
"responseBody",
|
||||
"commandRecovery",
|
||||
"commandEffect",
|
||||
"projectRequest",
|
||||
] as const;
|
||||
|
||||
const COMMAND_RECOVERY_KEYS = [
|
||||
"mode",
|
||||
"operationIdentityField",
|
||||
"inspectOperationId",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* LIVE-03. Composition is the last point at which a contribution row is
|
||||
* trusted, so the registry keeps an exact own-data copy rather than the
|
||||
* caller's object. A later mutation of the source — including one that swaps a
|
||||
* deadline or a credential policy — cannot reach what the executor reads.
|
||||
*
|
||||
* Validators and the descriptor-owned `projectRequest` stay by reference: they
|
||||
* are behaviour the contribution owns, not data this repository re-derives.
|
||||
*/
|
||||
function snapshotHttpContract(
|
||||
installed: InstalledHttpContract<unknown, unknown, unknown>,
|
||||
label: string,
|
||||
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||
const reject = (detail: string): never => fail(`${label}: ${detail}`);
|
||||
// NS-02. The outer row is snapshotted first, so every nested read below comes
|
||||
// from an owned object rather than from the caller's, which could answer
|
||||
// differently on a second read.
|
||||
const row = exactOwnDataSnapshot<
|
||||
InstalledHttpContract<unknown, unknown, unknown>
|
||||
>(installed, ["contract", "frontend"], ["contract", "frontend"], reject);
|
||||
const frontend = exactOwnDataSnapshot<HttpExecutionPolicy>(
|
||||
row.frontend,
|
||||
EXECUTION_POLICY_KEYS,
|
||||
EXECUTION_POLICY_KEYS,
|
||||
reject,
|
||||
);
|
||||
const source = exactOwnDataSnapshot<
|
||||
InstalledHttpContract<unknown, unknown, unknown>["contract"]
|
||||
>(row.contract, HTTP_CONTRACT_KEYS, HTTP_CONTRACT_KEYS, reject);
|
||||
const contract = Object.freeze({
|
||||
...source,
|
||||
acceptedStatuses: Object.freeze([...source.acceptedStatuses]),
|
||||
emptyBodyStatuses: Object.freeze([...source.emptyBodyStatuses]),
|
||||
commandRecovery:
|
||||
source.commandRecovery === null
|
||||
? null
|
||||
: exactOwnDataSnapshot<CommandRecoveryDescriptor>(
|
||||
source.commandRecovery,
|
||||
COMMAND_RECOVERY_KEYS,
|
||||
["mode", "operationIdentityField"],
|
||||
reject,
|
||||
),
|
||||
});
|
||||
return Object.freeze({ contract, frontend });
|
||||
}
|
||||
|
||||
function snapshotEventContract(
|
||||
event: InstalledEventContract<unknown, unknown>,
|
||||
label: string,
|
||||
): InstalledEventContract<unknown, unknown> {
|
||||
return exactOwnDataSnapshot<InstalledEventContract<unknown, unknown>>(
|
||||
event,
|
||||
["eventType", "envelopeValidator", "payloadValidator"],
|
||||
["eventType", "envelopeValidator", "payloadValidator"],
|
||||
(detail) => fail(`${label}: ${detail}`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* §4.8–§4.9. The only place installed contributions become a runtime registry.
|
||||
* Every bound is checked before composition; a violation stops the boot rather
|
||||
@@ -500,11 +611,22 @@ export function composeContractContributions(
|
||||
>();
|
||||
const packagesById = new Map<string, InstalledContractPackageIdentity>();
|
||||
const contributionIds = new Set<string>();
|
||||
const installedContributions: InstalledContractContribution[] = [];
|
||||
|
||||
for (const contribution of contributions) {
|
||||
if (!contribution || typeof contribution !== "object") {
|
||||
for (const raw of contributions) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
fail("contribution: object required");
|
||||
}
|
||||
// NS-02. Snapshot first, then validate the snapshot, then install exactly
|
||||
// what was validated. Validating the caller's object and reading it again
|
||||
// to copy it let a stateful answer pass the ceiling check and still install
|
||||
// a different deadline, retry budget or auth profile.
|
||||
const contribution = exactOwnDataSnapshot<InstalledContractContribution>(
|
||||
raw,
|
||||
["contributionId", "featureId", "source", "http", "events"],
|
||||
["contributionId", "featureId", "source", "http", "events"],
|
||||
(detail) => fail(`contribution: ${detail}`),
|
||||
);
|
||||
const contributionId = contribution.contributionId;
|
||||
if (
|
||||
typeof contributionId !== "string" ||
|
||||
@@ -520,53 +642,114 @@ export function composeContractContributions(
|
||||
if (typeof featureId !== "string" || !FEATURE_ID.test(featureId)) {
|
||||
fail(`featureId: ${String(featureId)}`);
|
||||
}
|
||||
const source = contribution.source;
|
||||
if (!source || typeof source !== "object" || !("kind" in source)) {
|
||||
const rawSource = contribution.source;
|
||||
if (!rawSource || typeof rawSource !== "object" || !("kind" in rawSource)) {
|
||||
fail(`${featureId}: source`);
|
||||
}
|
||||
if (!Array.isArray(contribution.http) || !Array.isArray(contribution.events)) {
|
||||
fail(`${featureId}: contribution arrays`);
|
||||
}
|
||||
if (source.kind === "EXTERNAL_PACKAGE") {
|
||||
assertPackageIdentity(source.package, featureId);
|
||||
const existing = packagesById.get(source.package.packageId);
|
||||
const rejectSource = (detail: string): never =>
|
||||
fail(`${featureId}: source ${detail}`);
|
||||
let source: ContractContributionSource;
|
||||
if (rawSource.kind === "EXTERNAL_PACKAGE") {
|
||||
const outer = exactOwnDataSnapshot<
|
||||
Readonly<{ kind: "EXTERNAL_PACKAGE"; package: unknown }>
|
||||
>(rawSource, ["kind", "package"], ["kind", "package"], rejectSource);
|
||||
const identity = exactOwnDataSnapshot<InstalledContractPackageIdentity>(
|
||||
outer.package,
|
||||
[
|
||||
"packageId",
|
||||
"version",
|
||||
"digest",
|
||||
"runtimeProtocolVersion",
|
||||
"sourceRevision",
|
||||
],
|
||||
[
|
||||
"packageId",
|
||||
"version",
|
||||
"digest",
|
||||
"runtimeProtocolVersion",
|
||||
"sourceRevision",
|
||||
],
|
||||
rejectSource,
|
||||
);
|
||||
assertPackageIdentity(identity, featureId);
|
||||
source = Object.freeze({
|
||||
kind: "EXTERNAL_PACKAGE" as const,
|
||||
package: identity,
|
||||
});
|
||||
const existing = packagesById.get(identity.packageId);
|
||||
if (
|
||||
existing &&
|
||||
(existing.version !== source.package.version ||
|
||||
existing.digest !== source.package.digest ||
|
||||
existing.sourceRevision !== source.package.sourceRevision)
|
||||
(existing.version !== identity.version ||
|
||||
existing.digest !== identity.digest ||
|
||||
existing.sourceRevision !== identity.sourceRevision)
|
||||
) {
|
||||
fail(
|
||||
`${featureId}: package ${source.package.packageId} has conflicting identities`,
|
||||
`${featureId}: package ${identity.packageId} has conflicting identities`,
|
||||
);
|
||||
}
|
||||
packagesById.set(source.package.packageId, source.package);
|
||||
} else if (source.kind === "TEMPLATE_FIXTURE") {
|
||||
if (source.fixtureId !== "REFERENCE_FEATURE_V1" || source.revision !== 1) {
|
||||
packagesById.set(identity.packageId, identity);
|
||||
} else if (rawSource.kind === "TEMPLATE_FIXTURE") {
|
||||
const fixture = exactOwnDataSnapshot<
|
||||
Readonly<{
|
||||
kind: "TEMPLATE_FIXTURE";
|
||||
fixtureId: "REFERENCE_FEATURE_V1";
|
||||
revision: 1;
|
||||
}>
|
||||
>(
|
||||
rawSource,
|
||||
["kind", "fixtureId", "revision"],
|
||||
["kind", "fixtureId", "revision"],
|
||||
rejectSource,
|
||||
);
|
||||
if (
|
||||
fixture.fixtureId !== "REFERENCE_FEATURE_V1" ||
|
||||
fixture.revision !== 1
|
||||
) {
|
||||
fail(`${featureId}: template fixture identity`);
|
||||
}
|
||||
if (contribution.events.length !== 0) {
|
||||
fail(`${featureId}: template fixture must not contribute events`);
|
||||
}
|
||||
source = fixture;
|
||||
} else {
|
||||
fail(`${featureId}: unknown contribution source kind`);
|
||||
}
|
||||
|
||||
const installedHttp: InstalledHttpContract<unknown, unknown, unknown>[] = [];
|
||||
for (const installed of contribution.http) {
|
||||
assertHttpContract(installed, featureId);
|
||||
const operationId = installed.contract.operationId;
|
||||
const snapshot = snapshotHttpContract(installed, featureId);
|
||||
assertHttpContract(snapshot, featureId);
|
||||
const operationId = snapshot.contract.operationId;
|
||||
const previous = httpByOperationId.get(operationId);
|
||||
if (previous) fail(`duplicate operation: ${operationId}`);
|
||||
httpByOperationId.set(operationId, installed);
|
||||
httpByOperationId.set(operationId, snapshot);
|
||||
installedHttp.push(snapshot);
|
||||
}
|
||||
|
||||
const installedEvents: InstalledEventContract<unknown, unknown>[] = [];
|
||||
for (const event of contribution.events) {
|
||||
assertEventContract(event, featureId);
|
||||
if (eventByType.has(event.eventType)) {
|
||||
fail(`duplicate event type: ${event.eventType}`);
|
||||
const snapshot = snapshotEventContract(event, featureId);
|
||||
assertEventContract(snapshot, featureId);
|
||||
if (eventByType.has(snapshot.eventType)) {
|
||||
fail(`duplicate event type: ${snapshot.eventType}`);
|
||||
}
|
||||
eventByType.set(event.eventType, event);
|
||||
eventByType.set(snapshot.eventType, snapshot);
|
||||
installedEvents.push(snapshot);
|
||||
}
|
||||
|
||||
// Everything published downstream is the validated snapshot, so no consumer
|
||||
// can be handed the caller's still-live object.
|
||||
installedContributions.push(
|
||||
Object.freeze({
|
||||
contributionId,
|
||||
featureId,
|
||||
source,
|
||||
http: Object.freeze(installedHttp),
|
||||
events: Object.freeze(installedEvents),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const externalPackages = [...packagesById.values()].map((identity) =>
|
||||
@@ -574,9 +757,9 @@ export function composeContractContributions(
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
contributions: Object.freeze([...contributions]),
|
||||
httpByOperationId,
|
||||
eventByType,
|
||||
contributions: Object.freeze(installedContributions),
|
||||
httpByOperationId: createReadOnlyRegistry(httpByOperationId),
|
||||
eventByType: createReadOnlyRegistry(eventByType),
|
||||
externalPackages: Object.freeze(externalPackages),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,39 @@ function validBoundedString(value: unknown, maxBytes: number): value is string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* N-06. The single idempotency-key authority shared by the V2 compatibility
|
||||
* client and the V3 executor.
|
||||
*
|
||||
* A caller-supplied value is never trimmed, regenerated or silently dropped:
|
||||
* an invalid key is a contract violation, because replaying a keyed command
|
||||
* without its key is exactly the unsafe behaviour the key exists to prevent.
|
||||
*/
|
||||
export function isValidIdempotencyKey(value: unknown): value is string {
|
||||
if (
|
||||
!validBoundedString(
|
||||
value,
|
||||
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function defineIdempotencyKey(value: unknown): string {
|
||||
if (!isValidIdempotencyKey(value)) {
|
||||
throw new TypeError("Idempotency key is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function defineMutationIntent(intent: MutationIntent): MutationIntent {
|
||||
if (
|
||||
!validBoundedString(
|
||||
@@ -37,11 +70,11 @@ export function defineMutationIntent(intent: MutationIntent): MutationIntent {
|
||||
intent.canonicalInputIdentity,
|
||||
MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes,
|
||||
) ||
|
||||
// OPT-NET-02. Intent definition and executor admission share one key
|
||||
// authority; a second, looser rule here is how a control character reaches
|
||||
// an `Idempotency-Key` header.
|
||||
(intent.idempotencyKey !== undefined &&
|
||||
!validBoundedString(
|
||||
intent.idempotencyKey,
|
||||
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes,
|
||||
)) ||
|
||||
!isValidIdempotencyKey(intent.idempotencyKey)) ||
|
||||
!Number.isFinite(intent.createdAtMonotonicMs) ||
|
||||
intent.createdAtMonotonicMs < 0
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* LIVE-02 / LIVE-03. A composed registry is authority, not data.
|
||||
*
|
||||
* `Object.freeze(new Map(...))` only freezes the wrapper object: `set`,
|
||||
* `delete` and `clear` still reach the backing store, so anything holding the
|
||||
* exported singleton can empty a validated registry after composition and
|
||||
* silently change what every later request resolves. The fix is structural —
|
||||
* the store stays private in a closure and only read operations are exported.
|
||||
*
|
||||
* The facade is deliberately *not* a `Map` instance, so borrowing a mutator
|
||||
* (`Map.prototype.clear.call(facade)`) fails on the missing internal slot
|
||||
* rather than succeeding.
|
||||
*/
|
||||
export type ReadOnlyRegistry<Key, Value> = Readonly<{
|
||||
get(key: Key): Value | undefined;
|
||||
has(key: Key): boolean;
|
||||
keys(): IterableIterator<Key>;
|
||||
values(): IterableIterator<Value>;
|
||||
entries(): IterableIterator<readonly [Key, Value]>;
|
||||
forEach(visit: (value: Value, key: Key) => void): void;
|
||||
readonly size: number;
|
||||
[Symbol.iterator](): IterableIterator<readonly [Key, Value]>;
|
||||
}>;
|
||||
|
||||
export function createReadOnlyRegistry<Key, Value>(
|
||||
entries: Iterable<readonly [Key, Value]>,
|
||||
): ReadOnlyRegistry<Key, Value> {
|
||||
const store = new Map<Key, Value>(entries as Iterable<[Key, Value]>);
|
||||
const facade = {
|
||||
get: (key: Key) => store.get(key),
|
||||
has: (key: Key) => store.has(key),
|
||||
keys: () => store.keys(),
|
||||
values: () => store.values(),
|
||||
entries: () => store.entries(),
|
||||
forEach: (visit: (value: Value, key: Key) => void) => {
|
||||
for (const [key, value] of store) visit(value, key);
|
||||
},
|
||||
get size() {
|
||||
return store.size;
|
||||
},
|
||||
[Symbol.iterator]: () => store.entries(),
|
||||
};
|
||||
return Object.freeze(facade) as ReadOnlyRegistry<Key, Value>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects anything that is not an exact own-data record over `allowedKeys`.
|
||||
*
|
||||
* A validated row must survive the validation: an accessor re-runs on every
|
||||
* later read, an inherited field can be replaced through the prototype, and a
|
||||
* symbol-keyed field escapes a name-based sweep entirely. Only own data
|
||||
* descriptors are copied, and the result is frozen.
|
||||
*/
|
||||
export function exactOwnDataSnapshot<Shape extends object>(
|
||||
source: unknown,
|
||||
allowedKeys: readonly (keyof Shape & string)[],
|
||||
requiredKeys: readonly (keyof Shape & string)[],
|
||||
onViolation: (detail: string) => never,
|
||||
): Readonly<Shape> {
|
||||
if (source === null || typeof source !== "object") {
|
||||
onViolation("object required");
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) {
|
||||
onViolation("symbol-keyed field");
|
||||
}
|
||||
// NS-02. A custom prototype carries fields a name sweep never sees, and it
|
||||
// stays live: replacing one after composition changes what the row answers.
|
||||
const prototype = Reflect.getPrototypeOf(source);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
onViolation("unexpected prototype");
|
||||
}
|
||||
const allowed = new Set<string>(allowedKeys);
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const key of Object.getOwnPropertyNames(source)) {
|
||||
if (!allowed.has(key)) onViolation(`unexpected field ${key}`);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
onViolation(`accessor field ${key}`);
|
||||
}
|
||||
if (descriptor.enumerable !== true) {
|
||||
onViolation(`non-enumerable field ${key}`);
|
||||
}
|
||||
snapshot[key] = descriptor.value;
|
||||
}
|
||||
for (const key of requiredKeys) {
|
||||
if (!Object.hasOwn(snapshot, key)) onViolation(`missing field ${key}`);
|
||||
}
|
||||
return Object.freeze(snapshot) as Readonly<Shape>;
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
createReadOnlyRegistry,
|
||||
type ReadOnlyRegistry,
|
||||
} from "./read-only-registry.ts";
|
||||
|
||||
export type FetchCredentialsMode = "omit" | "same-origin" | "include";
|
||||
|
||||
export type RestProviderProfile = Readonly<{
|
||||
@@ -8,13 +13,42 @@ export type RestProviderProfile = Readonly<{
|
||||
referrerPolicy: "no-referrer";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* VD-23. The complete closed set of headers a credential owner may contribute.
|
||||
* Transport-owned headers (`accept`, `content-type`, `idempotency-key`) and
|
||||
* every forbidden request header are deliberately absent.
|
||||
*/
|
||||
export const CREDENTIAL_HEADER_NAMES = Object.freeze([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
"x-tenant-context",
|
||||
] as const);
|
||||
|
||||
export type CredentialHeaderName = (typeof CREDENTIAL_HEADER_NAMES)[number];
|
||||
|
||||
export type RestAuthProfile = Readonly<{
|
||||
authProfileId: string;
|
||||
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
|
||||
credentials: FetchCredentialsMode;
|
||||
allowedCredentialHeaders: readonly ("authorization" | "x-csrf-token")[];
|
||||
allowedCredentialHeaders: readonly CredentialHeaderName[];
|
||||
/**
|
||||
* Proof headers the transport must observe before dispatch. A missing entry
|
||||
* fails closed with zero `fetch()` calls rather than sending an anonymous
|
||||
* request under an authenticated profile.
|
||||
*/
|
||||
requiredCredentialHeaders: readonly CredentialHeaderName[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* LIVE-02. A read facade over a private store, never a `Map`. The executor
|
||||
* resolves a profile on every request, so a post-installation `clear()` would
|
||||
* otherwise turn every authenticated call into `UNKNOWN_AUTH_PROFILE`.
|
||||
*/
|
||||
export type InstalledRestAuthProfiles = ReadOnlyRegistry<
|
||||
string,
|
||||
RestAuthProfile
|
||||
>;
|
||||
|
||||
export type RestCsrfProfile = Readonly<{
|
||||
csrfProfileId: string;
|
||||
mode: "NONE" | "HEADER";
|
||||
@@ -27,15 +61,131 @@ export const REST_AUTH_PROFILES = Object.freeze({
|
||||
transport: "BEARER_HEADER",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: Object.freeze(["authorization"] as const),
|
||||
requiredCredentialHeaders: Object.freeze(["authorization"] as const),
|
||||
}),
|
||||
ANONYMOUS: Object.freeze({
|
||||
authProfileId: "ANONYMOUS",
|
||||
transport: "ANONYMOUS",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: Object.freeze([]),
|
||||
requiredCredentialHeaders: Object.freeze([]),
|
||||
}),
|
||||
} satisfies Readonly<Record<string, RestAuthProfile>>);
|
||||
|
||||
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function exactHeaderSet(
|
||||
names: unknown,
|
||||
label: string,
|
||||
): readonly CredentialHeaderName[] {
|
||||
if (!Array.isArray(names)) {
|
||||
throw new TypeError(`REST auth profile ${label} must be an array.`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const name of names) {
|
||||
if (!isCredentialHeaderName(name) || seen.has(name)) {
|
||||
throw new TypeError(`REST auth profile ${label} is not an exact set.`);
|
||||
}
|
||||
seen.add(name);
|
||||
}
|
||||
return Object.freeze([...(names as readonly CredentialHeaderName[])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* §7.7 / VD-23. Installs the composition-owned auth profile registry once.
|
||||
*
|
||||
* The registry — not a credential collaborator — owns Fetch `credentials` and
|
||||
* the exact allowed/required credential-header sets. An incoherent profile is a
|
||||
* composition failure, never a runtime downgrade.
|
||||
*/
|
||||
export function installRestAuthProfileRegistry(
|
||||
profiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
|
||||
): InstalledRestAuthProfiles {
|
||||
const installed = new Map<string, RestAuthProfile>();
|
||||
for (const [key, candidate] of Object.entries(profiles)) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
throw new TypeError(`REST auth profile ${key} is not an object.`);
|
||||
}
|
||||
const authProfileId = candidate.authProfileId;
|
||||
if (
|
||||
typeof authProfileId !== "string" ||
|
||||
authProfileId.length === 0 ||
|
||||
authProfileId !== key
|
||||
) {
|
||||
throw new TypeError(`REST auth profile ${key} has a mismatched identity.`);
|
||||
}
|
||||
const allowed = exactHeaderSet(
|
||||
candidate.allowedCredentialHeaders,
|
||||
"allowedCredentialHeaders",
|
||||
);
|
||||
const required = exactHeaderSet(
|
||||
candidate.requiredCredentialHeaders,
|
||||
"requiredCredentialHeaders",
|
||||
);
|
||||
if (!required.every((name) => allowed.includes(name))) {
|
||||
throw new TypeError(
|
||||
`REST auth profile ${key} requires a header it does not allow.`,
|
||||
);
|
||||
}
|
||||
const credentials = candidate.credentials;
|
||||
if (!["omit", "same-origin", "include"].includes(credentials)) {
|
||||
throw new TypeError(`REST auth profile ${key} has invalid credentials.`);
|
||||
}
|
||||
switch (candidate.transport) {
|
||||
case "ANONYMOUS":
|
||||
if (
|
||||
credentials !== "omit" ||
|
||||
allowed.length > 0 ||
|
||||
required.length > 0
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Anonymous REST auth profile ${key} cannot carry credentials.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "BEARER_HEADER":
|
||||
if (credentials !== "omit" || !required.includes("authorization")) {
|
||||
throw new TypeError(
|
||||
`Bearer REST auth profile ${key} must require authorization with omitted credentials.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "SAME_ORIGIN_COOKIE":
|
||||
if (credentials === "omit" || allowed.includes("authorization")) {
|
||||
throw new TypeError(
|
||||
`Cookie REST auth profile ${key} must send ambient credentials without a bearer header.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new TypeError(`REST auth profile ${key} has unknown transport.`);
|
||||
}
|
||||
installed.set(
|
||||
authProfileId,
|
||||
Object.freeze({
|
||||
authProfileId,
|
||||
transport: candidate.transport,
|
||||
credentials,
|
||||
allowedCredentialHeaders: allowed,
|
||||
requiredCredentialHeaders: required,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (installed.size === 0) {
|
||||
throw new TypeError("REST auth profile registry cannot be empty.");
|
||||
}
|
||||
return createReadOnlyRegistry(installed);
|
||||
}
|
||||
|
||||
/** The single installed registry every composition root shares. */
|
||||
export const INSTALLED_REST_AUTH_PROFILES: InstalledRestAuthProfiles =
|
||||
installRestAuthProfileRegistry();
|
||||
|
||||
export const REST_CSRF_PROFILES = Object.freeze({
|
||||
NO_CSRF_BEARER: Object.freeze({
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { SERVICE_WORKER_BOUNDS } from "./service-worker.ts";
|
||||
|
||||
/**
|
||||
* SW-05. Runtime-neutral static manifest codec.
|
||||
*
|
||||
* The generator, the Node build gate and the Service Worker all need the same
|
||||
* answer to "is this manifest exactly the one that was generated?". This module
|
||||
* owns the exact row keys, the content-type and extension allowlist, the
|
||||
* root-relative URL rule and the length-prefixed canonical byte serialization.
|
||||
*
|
||||
* It deliberately contains no digest implementation: the generator and build
|
||||
* gate hash these bytes with Node SHA-256 while the worker hashes the very same
|
||||
* bytes with injected WebCrypto, so `node:crypto` never reaches worker code and
|
||||
* the algorithm is never written twice.
|
||||
*/
|
||||
|
||||
export type StaticAssetRow = Readonly<{
|
||||
url: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
contentType: string;
|
||||
}>;
|
||||
|
||||
export type StaticAssetManifest = Readonly<{
|
||||
schemaVersion: 1;
|
||||
buildId: string;
|
||||
releaseId: string;
|
||||
setDigest: string;
|
||||
assets: readonly StaticAssetRow[];
|
||||
}>;
|
||||
|
||||
export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1";
|
||||
|
||||
/**
|
||||
* SW-RR-03. The single authoritative extension → content type table.
|
||||
*
|
||||
* The build generator and this decoder must agree exactly: an extension the
|
||||
* generator emits but the decoder refuses turns a correct build into a runtime
|
||||
* contract failure, and the reverse admits an asset kind no build produces.
|
||||
* `.json` is deliberately absent — every JSON file in a build output is a
|
||||
* control document (runtime config, release manifest, schema), not a cacheable
|
||||
* static asset, and the generator excludes them by name.
|
||||
*/
|
||||
export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly<
|
||||
Record<string, string>
|
||||
> = Object.freeze({
|
||||
".css": "text/css",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".woff2": "font/woff2",
|
||||
});
|
||||
|
||||
const MANIFEST_KEYS = Object.freeze([
|
||||
"assets",
|
||||
"buildId",
|
||||
"releaseId",
|
||||
"schemaVersion",
|
||||
"setDigest",
|
||||
] as const);
|
||||
const ASSET_ROW_KEYS = Object.freeze([
|
||||
"bytes",
|
||||
"contentType",
|
||||
"sha256",
|
||||
"url",
|
||||
] as const);
|
||||
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
||||
/** Root-relative, hashed, no dot segments, no query and no fragment. */
|
||||
const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u;
|
||||
|
||||
/**
|
||||
* SW-02. The one canonical asset-path predicate, shared by the build generator
|
||||
* and this decoder. Sharing only the extension table left the two with
|
||||
* different path grammars: the generator emitted a URL for a directory
|
||||
* containing a space, an `@` or a percent-escape, and the decoder then refused
|
||||
* the manifest it had just produced, failing the release build.
|
||||
*/
|
||||
export function isCanonicalStaticAssetUrl(url: string): boolean {
|
||||
return (
|
||||
typeof url === "string" &&
|
||||
ASSET_URL.test(url) &&
|
||||
!url.includes("/../") &&
|
||||
!url.includes("/./")
|
||||
);
|
||||
}
|
||||
|
||||
export type StaticManifestDecodeFailure = Readonly<{
|
||||
reason: string;
|
||||
}>;
|
||||
|
||||
export type StaticManifestDecodeResult =
|
||||
| Readonly<{ ok: true; manifest: StaticAssetManifest }>
|
||||
| Readonly<{ ok: false; error: StaticManifestDecodeFailure }>;
|
||||
|
||||
function exactKeys(
|
||||
value: unknown,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Object.getOwnPropertySymbols(record).length > 0) return null;
|
||||
const keys = Object.keys(record).sort();
|
||||
return keys.length === allowed.length &&
|
||||
keys.every((key, index) => key === allowed[index])
|
||||
? record
|
||||
: null;
|
||||
}
|
||||
|
||||
function extensionOf(url: string): string {
|
||||
const lastSlash = url.lastIndexOf("/");
|
||||
const base = url.slice(lastSlash + 1);
|
||||
const dot = base.lastIndexOf(".");
|
||||
return dot < 0 ? "" : base.slice(dot).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a generated manifest with every row rule applied. It does not verify
|
||||
* `setDigest`; callers pair it with their own digest implementation over
|
||||
* `canonicalStaticManifestBytes`.
|
||||
*/
|
||||
export function decodeStaticAssetManifest(
|
||||
value: unknown,
|
||||
): StaticManifestDecodeResult {
|
||||
const record = exactKeys(value, MANIFEST_KEYS);
|
||||
if (!record) return failure("manifest keys are not exact");
|
||||
if (record.schemaVersion !== 1) return failure("schemaVersion must be 1");
|
||||
if (
|
||||
typeof record.buildId !== "string" ||
|
||||
!IDENTITY.test(record.buildId) ||
|
||||
typeof record.releaseId !== "string" ||
|
||||
!IDENTITY.test(record.releaseId)
|
||||
) {
|
||||
return failure("buildId or releaseId is invalid");
|
||||
}
|
||||
if (typeof record.setDigest !== "string" || !DIGEST.test(record.setDigest)) {
|
||||
return failure("setDigest is not a lower-hex sha256");
|
||||
}
|
||||
if (!Array.isArray(record.assets)) return failure("assets must be an array");
|
||||
if (record.assets.length > SERVICE_WORKER_BOUNDS.assets) {
|
||||
return failure("asset count exceeds its bound");
|
||||
}
|
||||
|
||||
const rows: StaticAssetRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
let previousUrl: string | null = null;
|
||||
for (const candidate of record.assets) {
|
||||
const row = exactKeys(candidate, ASSET_ROW_KEYS);
|
||||
if (!row) return failure("asset row keys are not exact");
|
||||
const { url, sha256, bytes, contentType } = row;
|
||||
if (typeof url !== "string" || !isCanonicalStaticAssetUrl(url)) {
|
||||
return failure("asset url must be root-relative without dot segments");
|
||||
}
|
||||
if (seen.has(url)) return failure("asset urls must be unique");
|
||||
// A sorted set makes the canonical bytes independent of directory order.
|
||||
if (previousUrl !== null && url <= previousUrl) {
|
||||
return failure("asset urls must be sorted");
|
||||
}
|
||||
if (typeof sha256 !== "string" || !DIGEST.test(sha256)) {
|
||||
return failure("asset sha256 is not a lower-hex sha256");
|
||||
}
|
||||
if (
|
||||
typeof bytes !== "number" ||
|
||||
!Number.isSafeInteger(bytes) ||
|
||||
bytes < 0 ||
|
||||
bytes > SERVICE_WORKER_BOUNDS.singleAssetBytes
|
||||
) {
|
||||
return failure("asset byte length is invalid");
|
||||
}
|
||||
if (typeof contentType !== "string") {
|
||||
return failure("asset content type is invalid");
|
||||
}
|
||||
const expectedContentType =
|
||||
CACHEABLE_ASSET_CONTENT_TYPES[extensionOf(url)];
|
||||
if (!expectedContentType || expectedContentType !== contentType) {
|
||||
return failure("asset extension and content type do not match");
|
||||
}
|
||||
totalBytes += bytes;
|
||||
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
|
||||
return failure("asset set exceeds its byte bound");
|
||||
}
|
||||
seen.add(url);
|
||||
previousUrl = url;
|
||||
rows.push(Object.freeze({ url, sha256, bytes, contentType }));
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
manifest: Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
buildId: record.buildId,
|
||||
releaseId: record.releaseId,
|
||||
setDigest: record.setDigest,
|
||||
assets: Object.freeze(rows),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact bytes both the Node generator and the worker hash. A reordered
|
||||
* directory listing, a renamed field or a changed byte length all change these
|
||||
* bytes; nothing else does.
|
||||
*/
|
||||
export function canonicalStaticManifestBytes(
|
||||
assets: readonly StaticAssetRow[],
|
||||
): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [encoder.encode(`${STATIC_ASSET_SET_DOMAIN}\0`)];
|
||||
for (const asset of assets) {
|
||||
parts.push(lengthPrefixed(encoder, asset.url));
|
||||
parts.push(lengthPrefixed(encoder, asset.sha256));
|
||||
parts.push(lengthPrefixed(encoder, String(asset.bytes)));
|
||||
parts.push(lengthPrefixed(encoder, asset.contentType));
|
||||
}
|
||||
let total = 0;
|
||||
for (const part of parts) total += part.byteLength;
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
bytes.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function lengthPrefixed(encoder: TextEncoder, value: string): Uint8Array {
|
||||
const encoded = encoder.encode(value);
|
||||
const prefix = encoder.encode(`${encoded.byteLength}:`);
|
||||
const combined = new Uint8Array(prefix.byteLength + encoded.byteLength);
|
||||
combined.set(prefix, 0);
|
||||
combined.set(encoded, prefix.byteLength);
|
||||
return combined;
|
||||
}
|
||||
|
||||
function failure(reason: string): StaticManifestDecodeResult {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
error: Object.freeze({ reason }),
|
||||
});
|
||||
}
|
||||
@@ -69,6 +69,18 @@ export const STORAGE_REGISTRY = Object.freeze({
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
}),
|
||||
CACHE_INVALIDATION_PULSE: defineStorageKey({
|
||||
logicalName: "CACHE_INVALIDATION_PULSE",
|
||||
scope: "cache-invalidation",
|
||||
name: "pulse",
|
||||
backend: "localStorage",
|
||||
classification: "opaque-cache",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "opaque-string-v1",
|
||||
ttl: null,
|
||||
migration: "discard",
|
||||
quotaFallback: "no-persist",
|
||||
}),
|
||||
AUTH_TOKEN: defineStorageKey({
|
||||
logicalName: "AUTH_TOKEN",
|
||||
scope: "auth",
|
||||
|
||||
@@ -161,10 +161,41 @@ export type WebPushObservationEvent =
|
||||
| "web_push_click_dispatched"
|
||||
| "web_push_association_revoked";
|
||||
|
||||
/**
|
||||
* WP-06. Bounded fan-out is a deliberate policy, but reporting a truncated pass
|
||||
* as plain success hid the fact that only part of the set was handled.
|
||||
*/
|
||||
export type WebPushCountBucket =
|
||||
| "0"
|
||||
| "1_8"
|
||||
| "9_32"
|
||||
| "33_64"
|
||||
| "GT_64";
|
||||
|
||||
export function webPushCountBucket(count: number): WebPushCountBucket {
|
||||
if (!Number.isFinite(count) || count <= 0) return "0";
|
||||
if (count <= 8) return "1_8";
|
||||
if (count <= 32) return "9_32";
|
||||
if (count <= 64) return "33_64";
|
||||
return "GT_64";
|
||||
}
|
||||
|
||||
/**
|
||||
* WP-07. Certainty of a user-visible native effect. It is evidence only and
|
||||
* never authorizes a retry.
|
||||
*/
|
||||
export type WebPushNativeEffectCertainty =
|
||||
| "CONFIRMED"
|
||||
| "NOT_APPLIED"
|
||||
| "MAYBE_APPLIED";
|
||||
|
||||
export type WebPushObservation = Readonly<{
|
||||
event: WebPushObservationEvent;
|
||||
outcome: "SUCCEEDED" | "FAILED" | "DEGRADED";
|
||||
reason?: WebPushFailureCode | WebPushUnavailableReason;
|
||||
countBucket?: WebPushCountBucket;
|
||||
truncated?: boolean;
|
||||
nativeEffect?: WebPushNativeEffectCertainty;
|
||||
}>;
|
||||
|
||||
export interface WebPushObserver {
|
||||
|
||||
@@ -23,7 +23,8 @@ export type InstalledContractOperationExecutor = Readonly<{
|
||||
execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
context?: Readonly<{
|
||||
context: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}>,
|
||||
@@ -48,6 +49,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
|
||||
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 }),
|
||||
},
|
||||
@@ -117,6 +121,15 @@ function projectExecutionOutcome(
|
||||
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"
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"template": "clean-architecture-frontend-template",
|
||||
"sourceRepository": "https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-frontend-template",
|
||||
"sourceRevision": "4dc033cf33a5b6173bbf960d5eb464a406dc4c92",
|
||||
"sourceTree": "16121a89a270dc45801a8c0f6aa4baab61b6c359",
|
||||
"sourceRevision": "8157ad40298da19b43574dbd3b667a64ca3ed822",
|
||||
"sourceTree": "94d48775a3e67b11dcc68a19ef3c3561792b0ec5",
|
||||
"materialization": "tracked-snapshot"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
ImageAssetReference,
|
||||
ImageCdnPresentationPort,
|
||||
ImagePresetReference,
|
||||
} from "../../../src/application/ports/browser-transfer/image-cdn.ts";
|
||||
|
||||
/**
|
||||
* BT-IMG-01. Migration step 1.
|
||||
*
|
||||
* The `PRIMARY_REQUIRED` preset already reports a missing signal as
|
||||
* `UNSUPPORTED` at runtime, which is a hidden preset precondition rather than a
|
||||
* type-level one. This fixture pins the intended end state: `resolve()` must
|
||||
* refuse a call with no lifetime signal at compile time.
|
||||
*
|
||||
* It is expected to FAIL typechecking until `signal` becomes required on the
|
||||
* port. That flip is a separate major-contract change, per the review's own
|
||||
* instruction not to mix it with the P1/P2 correctness work.
|
||||
*/
|
||||
declare const images: ImageCdnPresentationPort;
|
||||
declare const asset: ImageAssetReference;
|
||||
declare const preset: ImagePresetReference;
|
||||
|
||||
// Expected error: `signal` is missing.
|
||||
void images.resolve({ asset, preset });
|
||||
|
||||
// Expected error: `signal` may not be undefined.
|
||||
void images.resolve({ asset, preset, signal: undefined });
|
||||
@@ -61,7 +61,7 @@ const readPolicy = Object.freeze({
|
||||
responseByteLimit: 32_768,
|
||||
totalDeadlineMs: 10_000,
|
||||
retryBudget: 2 as const,
|
||||
authProfileId: "TEST_AUTH",
|
||||
authProfileId: "ANONYMOUS",
|
||||
diagnosticsOperation: "test.read",
|
||||
});
|
||||
|
||||
@@ -192,7 +192,7 @@ export const TEST_CREATE_HTTP_CONTRACT: InstalledHttpContract<
|
||||
responseByteLimit: 32_768,
|
||||
totalDeadlineMs: 10_000,
|
||||
retryBudget: 0 as const,
|
||||
authProfileId: "TEST_AUTH",
|
||||
authProfileId: "ANONYMOUS",
|
||||
diagnosticsOperation: "test.create",
|
||||
}),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user