diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ffd51c1..dd49624a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; and the overlapping generic/signed 503 shapes use `anyOf` with a representative payload validation rather than a vacuous `oneOf`. The central App credential is prepared before the durable pre-dispatch commit and checked against its exact GitHub `expires_at` immediately before commit and send; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. Reservation creation and commit transactionally reject expired authority even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, allowing replacement only when the newly presented OIDC expiry remains live. Calendar-relative continuation state and Worker tests remove the fixed-2027 expiry time bomb. Legacy v1 terminal evidence remains replayable, and legacy reservations without exact expiry fail closed. If the prepared central token expires after the indeterminate commit but before the HTTP request starts, a typed no-effect result releases only the exact matching indeterminate receipt and preserves retry authority; transport ambiguity after a request begins remains terminal indeterminate. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. +- exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. +- Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - PR #737의 patch-validator static-runtime receipt verifier가 `process.versions.node`뿐 아니라 bundled Undici를 reviewed fixed version `7.29.1`로 exact-match한다. 서로 내부적으로 일치하더라도 Undici `7.29.0` inventory/component/PURL/raw scan evidence는 fail closed하며, simplified verifier fixtures도 실제 Node 24.21.0 runtime identity에 맞춘다. Source/test GREEN은 새 exact-head hosted image/SBOM/provenance/scanner, 독립 승인, ordinary merge, immutable release를 대신하지 않는다. - `packages/noema-core/README.md`의 `build_agent` 서명을 코드와 ADR-0014에 맞춘다. caller-visible `retries=3` 표기를 제거하고, 이 경계의 model-attempt retry는 `Agent(..., retries=0)`으로 고정되며 orchestration retry/failover는 contextual-orchestrator 소유임을 명시한다. 구현·ADR 상태·provider 정책은 바꾸지 않는다. - PR #724 makes private-vulnerability-reporting setting receipts a fail-closed strict-release prerequisite without turning setting evidence into release or deployment truth. The consumer validates bounded stable/no-follow receipt files with fatal UTF-8 and duplicate-key rejection, canonical repository/source/timestamp identity, bounded future skew and freshness, and explicit `PASS + enabled:true + failures:[]`; all observations are validated before ordering and the newest observation wins even when it is FAIL, so a historical PASS cannot mask later contradictory evidence. Protected CD recollects the setting in the same job from the exact immutable-release commit through a repository-scoped `Metadata: read` GitHub App capability, moves the short-lived bearer into an owner-only capability file, unsets the raw bootstrap value before Node execution, feeds the same-run receipt into `release:verify:strict`, and retains the receipt with deployment evidence. The ordinary pre-tag source gate remains `release:verify`; bare pre-publication `release:verify:strict` without a fresh matching receipt is not an authorized procedure. External reporter visibility, staffing/notification, benign private-case handling, immutable-release publication, deployment, KPI and acquisition authority remain independent evidence classes. diff --git a/README.md b/README.md index 0fa106f36..987d2bcce 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The Cloudflare Worker exposes three intentionally distinct HTTP surfaces: | `GET` | `/health` | Process liveness only | | `GET` / `HEAD` | `/ready` | Runtime readiness without reflecting secrets | | `POST` | `/exchange` | Exchange an authorized GitHub Actions OIDC bearer for short-lived repository capability | +| `POST` | `/v1/continuation-dispatches` | Emit one exact Noema/Strix continuation and return a credential-free signed receipt | The public contract is published as [`openapi.json`](./openapi.json), with narrative details in [`docs/api-spec.md`](./docs/api-spec.md). diff --git a/docs/PRD.md b/docs/PRD.md index b3758e9bd..0619f68d8 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -56,7 +56,7 @@ Noema is an evidence-producing credential and maintenance control plane. Its run ### 4.1 Credential exchange -The Cloudflare Worker exposes `/health`, `/ready`, and `/exchange`. +The Cloudflare Worker exposes `/health`, `/ready`, `/exchange`, and the Proposed `/v1/continuation-dispatches` broker. The broker performs one fixed central continuation without returning `Contents: write` capability: it binds exact workflow/source/PR evidence, retains terminal idempotency state, and returns only a credential-free signed receipt. Immutable Noema release must precede the `ContextualWisdomLab/.github#2540` consumer pin. The observed protected branch point uses `ALLOWED_WORKFLOW_REF_PREFIX` as one **exact full workflow ref** despite the legacy variable name and pairs it with immutable `ALLOWED_WORKFLOW_SHA` trust. Cryptographic OIDC verification validates issuer, audience, repository identity, exact workflow ref, the immutable `job_workflow_sha` workflow-source identity, token time semantics, and replay requirements before GitHub App token exchange. `wrangler.toml` pins the trusted central `.github` workflow source commit; that dependency remains read-only from the Noema writer and any future central movement requires an explicit Noema roll-forward plus fresh evidence. @@ -120,7 +120,7 @@ Procedural graph ownership follows existing CWL boundaries. Any released cross-s | ID | Requirement | | --- | --- | -| FR-001 | `/health`, `/ready`, and `/exchange` retain distinct liveness, readiness, and credential-exchange semantics. | +| FR-001 | `/health`, `/ready`, `/exchange`, and `/v1/continuation-dispatches` retain distinct liveness, readiness, credential-exchange, and fixed-continuation semantics. | | FR-002 | Validate OIDC issuer, audience, repository/organization, the configured exact full workflow ref, and immutable configured workflow-source SHA before credential minting. | | FR-003 | Treat workflow-ref/SHA configuration as exact authority and fail closed on absent, malformed, mismatched, stale, or non-canonical trust identity; do not promote a central source movement without explicit Noema roll-forward evidence. | | FR-004 | Restrict credential-bearing GitHub/OIDC requests by reviewed origin, path, method, redirect, timeout, and bounded body/response behavior. | diff --git a/docs/TRD.md b/docs/TRD.md index 084c5186a..85d1e8f9f 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -27,22 +27,29 @@ src/runtime-entrypoint.ts ├─ replay protection → NoemaOidcReplayGuard └─ src/index.ts ├─ /health - └─ /exchange core OIDC + GitHub App protocol + ├─ /exchange core OIDC + GitHub App protocol + └─ /v1/continuation-dispatches → live PR ACL → SQLite idempotency → fixed dispatch → Ed25519 receipt ``` 자세한 구현과 route ownership은 `ARCHITECTURE.md`, `docs/api-spec.md`를 따릅니다. -### 2.1 `/exchange` inbound body deadline +### 2.1 Continuation dispatch broker + +`POST /v1/continuation-dispatches`는 exact-URL, bearer envelope, distributed rate limit, workflow trust, OIDC verification/replay 계층을 재사용한다. Transport retry metadata를 제외한 logical-effect identity로 `NOEMA_CONTINUATION_DISPATCH_STATE`의 SQLite owner를 고르되 complete request digest는 retry attempt를 포함한다. Exact replay는 live PR 재조회보다 먼저 retained receipt를 반환하며, fresh reservation만 live PR을 검증한다. denied/indeterminate replay는 최초 결과와 동일한 `502`/`503` 상태를 유지하고 exact-replay header를 반환한다. Central App credential은 외부 effect가 없는 준비 단계에서 먼저 발급하고 opaque capability 내부에 GitHub `expires_at`을 보존한다. Capability는 durable commit 직전과 fixed event 전송 직전에 정확한 만료를 검사하며, exact dispatch body digest에 결속된 signed `indeterminate` evidence만 durable commit한다. 준비 또는 commit 실패는 best-effort reservation abort 뒤 실패-폐쇄하며 dispatch를 수행하지 않는다. Commit 이후에는 준비된 sender만 fixed event를 전송하고, 204/denial 뒤 같은 receipt authority를 accepted/denied로만 강화한다. 준비/commit 사이 crash로 남은 `reserved` record는 임의 TTL이 아니라 retained OIDC `exp`의 정확한 시각에 Durable Object alarm으로만 회수한다. Alarm 지연을 권위 연장으로 바꾸지 않도록 reservation 생성과 commit transaction 모두 `exp`를 재검사한다. 기존 v1 terminal record는 그대로 replay하고 exact expiry가 없는 legacy reserved record는 fail closed한다. Terminal record는 alarm이 삭제하지 않는다. 따라서 post-effect finalization failure나 crash는 두 번째 dispatch authority를 만들지 않는다. 별도 central App은 fixed `ContextualWisdomLab/.github` event만 호출하고 응답에는 GitHub credential이 없다. + +Required bindings: `CONTINUATION_DISPATCH_GITHUB_APP_ID`, `CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM`, `CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID`, `CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM`, `CONTINUATION_RECEIPT_SIGNING_KEY_ID`, `NOEMA_CONTINUATION_DISPATCH_STATE`. + +### 2.2 `/exchange` inbound body deadline `POST /exchange`의 JSON body는 UTF-8 wire bytes 기준 최대 **8,192 bytes**이고, body read가 시작된 뒤 전체 stream은 **10,000 ms의 절대 wall-clock deadline** 안에 완료되어야 합니다. 작은 chunk를 반복해서 보내더라도 deadline은 재설정되지 않습니다. 제한시간을 넘긴 incomplete stream은 best-effort로 취소하고 **HTTP 408**의 Noema 표준 JSON error envelope로 실패-폐쇄하며, 이 경계는 distributed rate-limit delegation, OIDC/JWKS 검증, GitHub App private-key 사용과 GitHub API 호출보다 앞에서 적용됩니다. 배포 acceptance는 기존 unauthenticated 401 contract와 별도로 `scripts/smoke-readiness.sh`의 stalled-body deployment smoke가 실제 408/JSON error response를 관찰해야 합니다. Executable proof는 `test/exchange-body-read-deadline.test.ts`, `test/smoke-readiness.test.ts`, `test/smoke-readiness-endpoint-safety.test.ts`, OpenAPI contract와 `docs/api-spec.md`를 함께 사용합니다. -### 2.2 External Claude plugin admission +### 2.3 External Claude plugin admission Tool / Capability Boundary의 로컬 포트 `src/tool-capability/external-extension-admission.ts`는 Claude community plugin 서술자를 exact repository/commit/path/digest와 독립적으로 pin된 AppGuardrail·격리 영수증에 결합한다. 가변 브랜치/태그, 로컬 경로, 마켓플레이스/카탈로그 불일치, 공급자 키, 광역 GitHub 권한, 미선언 셸/파일/네트워크/비밀/MCP, 다른 제품 승인, 만료·롤백, 카탈로그 drift, 관측 내용의 정책 승격, 제품 런타임 플러그인 래퍼는 실패-폐쇄한다. 이 포트는 HTTP API가 아니며 `/exchange` 권한을 바꾸지 않는다. `context-graph-contracts` 불변 계약이 나오기 전에는 로컬 ACL/테스트 더블이다. -### 2.3 Durable external-extension lifecycle evidence +### 2.4 Durable external-extension lifecycle evidence Protected source includes `DurableExternalExtensionLifecycleRepository` under the Tool Capability / State / Checkpoint boundary. A lifecycle stream is keyed by the canonical `external_extension_id` plus exact upstream repository/commit/path, artifact SHA-256, and marketplace-entry SHA-256. The implementation stores an append-only versioned event chain, a transition-ID idempotency index, and a compact current `head` projection in Durable Object storage; it does not reuse the bounded Workflow / Task observability ledger as canonical lifecycle history. @@ -52,7 +59,7 @@ Exact duplicate transition replay is returned only after immutable request/event Corrupt/truncated audit evidence is not repaired by the application path. Full recovery procedure, restore constraints, rollback semantics, future compaction constraints, and actual Durable Object recovery rehearsal requirements are defined in `docs/external-extension-lifecycle-recovery.md`. ADR 0015 remains `Proposed` while real-backend performance/recovery, immutable owner-issued activation evidence, release and deployment acceptance remain incomplete. -### 2.4 Protected procedural graph advisory runtime +### 2.5 Protected procedural graph advisory runtime Protected source includes four library-only Agent Runtime modules: `procedural-input.ts`, `procedural-graph.ts`, `procedural-evolution.ts`, and `procedural-execution.ts`. The admission path snapshots exact-key plain records and dense bounded arrays through data descriptors, rejects accessors/proxies/extra authority-shaped fields, applies canonical execution identity and bounded procedural identity rules, canonicalizes graph ordering, and computes SHA-256 graph and structure identities under an explicit serialized byte ceiling. These digests are local content identities, not signatures or a released cross-language wire standard. diff --git a/docs/adr/0019-continuation-dispatch-broker.md b/docs/adr/0019-continuation-dispatch-broker.md new file mode 100644 index 000000000..803ccc217 --- /dev/null +++ b/docs/adr/0019-continuation-dispatch-broker.md @@ -0,0 +1,37 @@ +# ADR 0019: Continuation dispatch broker + +Status: Proposed + +## Problem and constraints + +GitHub repository dispatch requires `Contents: write`; a consumer-visible “dispatch-only” installation token is therefore not least privilege. Noema/Strix continuations must cross from an exact source PR to `ContextualWisdomLab/.github` without exposing that mutation authority. Mutable refs, stale PR snapshots, replay, caller-selected events, duplicate effects, and uncertain network results must fail closed. + +## Decision + +Noema owns `POST /v1/continuation-dispatches`. It authenticates GitHub Actions OIDC, binds repository plus immutable reusable-workflow SHA, serializes transport attempts through one logical-effect identity, rereads the live PR after reservation, and uses a distinct central App only for one fixed repository-dispatch path/event mapping. Before the external call it commits a signed indeterminate receipt; accepted or denied evidence may strengthen that same retained authority afterward, while a failed final commit leaves replayable indeterminate evidence and never grants another dispatch. Exact replay returns retained evidence without another live-state read or dispatch. A central token that expires after the commit but before the fixed HTTP request begins produces a typed no-effect failure. Only that proof may delete the exact matching indeterminate receipt under the original reservation capability; an ordinary transport failure remains terminal indeterminate because the external effect may exist. + +The existing `/exchange` remains independent. `.github` owns the consumer workflow and event handler; Noema owns identity, idempotency, fixed dispatch, and receipt evidence only. Model verdict, formal review, merge, release, deployment, and product-domain truth remain separate authorities. + +## Alternatives rejected + +- Return a central installation token: rejected because `Contents: write` enables broader mutations. +- Consumer PAT/App secret: rejected for long-lived and cross-repository blast radius. +- Caller-selected target URL/event: rejected as an authority and SSRF boundary violation. +- Automatic retry after timeout/5xx: rejected because the external effect may already exist. +- Extend `/exchange`: rejected because credential delivery and fixed server-side dispatch have different response/idempotency contracts. + +## Effects and operational scenes + +Operators manage separate source and central Apps, an Ed25519 signing key/key id, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Key loss, Durable Object unavailability, stale PR evidence, or GitHub ambiguity blocks dispatch. Denied/indeterminate receipts require reconciliation rather than blind retry. A 503 without a receipt after the typed pre-effect-expiry path permits a fresh OIDC retry; a 503 carrying indeterminate receipt evidence does not. If exact pre-effect release itself is unavailable, Noema returns a generic 503 and the next call re-reads Durable Object authority rather than assuming deletion or retention. The current Draft does not publish a verification key. Release acceptance requires an immutable `key_id` to SPKI artifact plus digest mapping before any offline-verification claim; private material never appears in receipts or logs. + +## Acceptance and release order + +This ADR stays Proposed until exact-head tests/security/SBOM/provenance, independent review, protected integration, immutable Noema release, and deployment/recovery evidence exist. The immutable Noema release must be published before `ContextualWisdomLab/.github#2540` pins the endpoint/schema and removes its legacy exchange-plus-local-dispatch sequence. A real same-head continuation and fresh model verdict are downstream acceptance. + +## References + +Cloudflare. (2026). *Web Crypto*. Cloudflare Workers documentation. + +GitHub. (2026). *Configuring OpenID Connect in cloud providers*; *Create a repository dispatch event*. GitHub Docs. + +Rundgren, A., Jordan, B., & Erdtman, S. (2020). JSON Canonicalization Scheme (JCS) (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785 diff --git a/docs/adr/README.md b/docs/adr/README.md index ebb552cb6..255b40790 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,6 +21,7 @@ ADR은 **왜 이 구조를 선택했는지**를 기록합니다. 구현 상태 | [0015](./0015-external-extension-admission.md) | Proposed | Claude community plugin은 exact commit/path/digest와 별도 scan 영수증으로만 승인하고, 마켓플레이스 설치·Anthropic 리뷰·제품 런타임 래퍼는 실패-폐쇄한다. | | [0016](./0016-avoid-repository-git-conversion-execution-in-acquisition-preflight.md) | Proposed | acquisition preflight는 repository-owned Git clean/process conversion helper를 실행하지 않고 immutable exact-tree와 descriptor-bound raw bytes로 checkout identity를 검증한다. | | [0017](./0017-procedural-graph-guidance.md) | Proposed | procedural graph는 execution-pinned advisory context와 held-out non-regression screening만 제공하며 tool·policy·approval·activation authority를 부여하지 않는다. | +| [0019](./0019-continuation-dispatch-broker.md) | Proposed | Noema performs fixed central continuation and returns only a credential-free signed receipt. | ## ADR lifecycle diff --git a/docs/api-spec.md b/docs/api-spec.md index 1a69a3cc6..5734a7b69 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -49,6 +49,14 @@ - 헤더: 공통 no-store/nosniff/trace/latency에 더해 `X-Noema-Readiness: ready|not-ready` - 상세: `docs/runtime-readiness.md` +### `POST /v1/continuation-dispatches` + +`noema.continuation-dispatch.v1`의 닫힌 JSON 요청만 받으며 UTF-8 wire body는 8,192 bytes 이하이다. OIDC repository와 exact reusable-workflow ref/SHA, live open/non-draft/non-fork PR head/base/ref를 모두 결합하고 `ContextualWisdomLab/.github`의 `noema-review` 또는 `strix-scan` 이벤트만 서버 내부에서 선택한다. 같은 canonical digest는 저장된 terminal receipt를 반환하고 두 번째 dispatch를 만들지 않는다. 다만 pre-dispatch indeterminate commit 뒤 central installation token이 exact expiry에 도달했고 HTTP 요청이 아직 시작되지 않았음이 typed adapter result로 증명되면, Durable Object는 동일 reservation capability와 동일 receipt 전체가 일치할 때만 그 record를 제거한다. 이 503 응답에는 terminal receipt가 없으며 새 OIDC 호출은 재시도할 수 있다. 요청 시작 뒤 transport 결과가 불명확하면 indeterminate receipt는 terminal로 유지되어 blind retry를 차단한다. + +성공 응답은 `{ ok: true, data: { receipt }, trace_id }`이다. Receipt contains no token and no GitHub credential. `receipt_version`은 `noema.continuation-dispatch-receipt.v1`이다. Detached Ed25519 signature의 offline verification은 immutable release가 `key_id`에 대응하는 SPKI public-key artifact와 digest를 게시한 뒤에만 소비자 acceptance evidence가 된다. 이 Draft source에는 공개키나 retrieval URL이 아직 없으므로 현재 revision은 offline verification 가능성을 주장하지 않는다. 안정 오류 코드는 `ERR_DISPATCH_REQUEST_INVALID`, `ERR_DISPATCH_IDENTITY_DENIED`, `ERR_DISPATCH_LIVE_STATE_STALE`, `ERR_DISPATCH_REPLAY_CONFLICT`, `ERR_GITHUB_DISPATCH_AUTHORIZATION`, `ERR_GITHUB_DISPATCH_UPSTREAM`이다. + +Primary references: GitHub. (2026). *OpenID Connect reference*; GitHub. (2026). *Create a repository dispatch event*; Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785; Cloudflare. (2026). *Web Crypto*. + ### `POST /exchange` 헤더: - `authorization: Bearer ` diff --git a/docs/api-stability-contract.md b/docs/api-stability-contract.md index 405fd3cd1..d7e58cf0c 100644 --- a/docs/api-stability-contract.md +++ b/docs/api-stability-contract.md @@ -47,6 +47,15 @@ GitHub installation token 응답의 `token`/`expires_at` 결함은 `ERR_GITHUB_I ## 에러 코드 표준 +`POST /v1/continuation-dispatches`는 credential-free signed receipt만 반환하는 additive v1 surface다. Closed request, fixed action/event mapping, 8,192-byte ceiling, exact replay, and no-GitHub-credential response are compatibility commitments. + +- `ERR_DISPATCH_REQUEST_INVALID` +- `ERR_DISPATCH_IDENTITY_DENIED` +- `ERR_DISPATCH_LIVE_STATE_STALE` +- `ERR_DISPATCH_REPLAY_CONFLICT` +- `ERR_GITHUB_DISPATCH_AUTHORIZATION` +- `ERR_GITHUB_DISPATCH_UPSTREAM` + - `ERR_VALIDATION_INPUT` - `ERR_AUTH_MISSING` - `ERR_AUTH_INVALID` diff --git a/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md b/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md index 2fb01b3f4..7704d86b3 100644 --- a/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md +++ b/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md @@ -44,6 +44,12 @@ The primary V8 fix for Chromium issue 542403045 changes `JSCallReducer::ReduceAr The derived applicability receipt may remove `CVE-2026-85046` only for exact Node 24.19.0, exact V8 `13.6.233.17-node.51`, reviewed CPE `cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*`, normalized scanner artifact version `13.6.233.17`, Grype 0.116.1, and NVD-CPE provenance. A Node/V8/CPE/scanner/finding mismatch must fail closed. +## 2026-09-30 exact-runtime revalidation + +The exact-head image scan later exposed two blocking High-severity findings in Node 24.19.0's embedded `undici@7.29.0`: GHSA-rfgv-xxqx-mfg5 and GHSA-w293-vg96-wgc3. Node.js 24.21.0 updates the embedded Undici runtime to 7.29.1. Noema authenticates the official source archive with SHA-256 `a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc`; the signed `v24.21.0` tag dereferences to commit `955266bfdd854cd280dffd47548673914484e4c0`. The official Linux x64 distribution independently reports Node 24.21.0, npm 11.19.0, Undici 7.29.1, nghttp2 1.70.0, SQLite 3.53.4, V8 13.6.233.17-node.53, and zlib 1.3.2.1-motley-8002e91 through `process.versions` and bundled npm metadata. + +The prior applicability conclusions do not transfer by version-range assumption. Inspection of the authenticated 24.21.0 source again finds neither `JSCallReducer::ReduceArraySort` nor `MaglevGraphBuilder::TryReduceArrayPrototypeSort`; SQLite 3.53.4 remains above every reviewed affected range; the nghttp2 finding remains specific to the separate nghttpx proxy; and the zlib finding remains specific to the RubyGems wrapper. The executable contract therefore binds those new identities exactly. Any later Node or embedded-component change again invalidates the derived applicability receipt and must fail closed pending a new review. + ## Risk and follow-up Applicability review is deliberately exact-version rather than an open-ended semantic-version rule. A future Node, V8, SQLite, zlib, scanner, or CPE change loses the exception automatically and must be reviewed again. c-ares remains a source repair, not an exception. The image lane must rebuild from the new exact head and prove the patched c-ares version, raw scan retention, derived reviewed receipt, strict final verifier, isolation smoke, SBOM, and unchanged image/source binding before integration. @@ -67,3 +73,5 @@ Open Source Vulnerabilities. (2025; modified 2026). *BIT-sqlite-2025-6965*. http V8 Project. (2026, August 7). *[compiler] Don't inline Array.prototype.sort on mixed elements kinds* (commit `e0562d87ad9c17042b581582c99237d798572e67`). https://github.com/v8/v8/commit/e0562d87ad9c17042b581582c99237d798572e67 Node.js. (2026, August 3). *Node.js v24.19.0 Krypton (LTS) release* (signed tag; commit `cdc1b38d40cb567b7ad0b39c86addf830a0af0ae`). https://github.com/nodejs/node/releases/tag/v24.19.0 + +Node.js. (2026, September 8). *Node.js v24.21.0 Krypton (LTS) release*. https://nodejs.org/en/blog/release/v24.21.0 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 23c69c710..c41543a77 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,9 @@ # Noema Product and Technical Gap Baseline +## Proposed continuation dispatch broker (#735/#736) + +The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. Review repair adds the missing OpenAPI `408`/deadline declaration, Bearer challenge and stable 401 hint, outcome-preserving exact replay, a single canonical dispatch body, App-credential preparation before the pre-dispatch commit, exact GitHub App token-expiry checks before commit/send, best-effort pre-effect abort, and crash-safe reserved-state recovery at the exact retained OIDC `exp`. Reservation creation and commit enforce that expiry inside the serialized transaction even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, so only a newly presented still-live OIDC expiry can create a replacement while stale incoming authority remains a conflict. Calendar-relative state tests remove the fixed-2027 expiry time bomb. Existing v1 terminal evidence remains replayable while a legacy reservation without exact expiry fails closed. RED `2a4488c6` and GREEN `350a3665` close the post-commit/pre-request expiry race: only `ContinuationDispatchNotStartedError` may release the exact matching indeterminate receipt under the original capability, while any transport ambiguity after request start remains terminal. Exact-head predecessor `1bd5fce7` makes the overlapping continuation/generic 503 schemas executable with representative signed and generic payloads and replaces invalid `oneOf` exclusivity with `anyOf`. The expiry is authorization evidence, not an invented TTL or retry heuristic. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. + ## Proposed released gateway endpoint admission (2026-10-02) Open PR #737 proposes an exact-membership gate for every token-bearing @@ -26,6 +30,8 @@ Current protected source는 mutation·merge·release 시점에 live protected `m Dated central control-plane observation for this repair는 central `.github/main@cb0872c9a20d5584703dffacca65c096fc034c6c`다. Noema runtime의 reviewed immutable central consumer pin은 `c9052e607e5f3cc76e73207e7786b21500721b79`이고 runtime authority 표현은 `ALLOWED_WORKFLOW_SHA = c9052e607e5f3cc76e73207e7786b21500721b79`다. Moving foreign head와 reviewed immutable pin을 같은 권위로 취급하지 않으며 central moving head가 전진했다고 consumer pin을 자동 승격하지 않는다. +Open candidate PR #736의 predecessor exact head `6f7b7dc8e4d67dd8eb4827423f9e14fbeae7fd3f`에서 application CI `36731606439`, reviewer-ci `36731606039`, required Security Scan `36731605921`은 terminal SUCCESS였지만 patch-validator-image `36731605769`는 terminal FAILURE였다. Retained artifact `11104958913`의 exact embedded-runtime receipt는 Node 24.19.0에 포함된 `undici@7.29.0`에서 High-severity GHSA-rfgv-xxqx-mfg5와 GHSA-w293-vg96-wgc3를 보고했고 strict verifier는 `blocking embedded runtime vulnerabilities are not allowed`로 실패 폐쇄했다. Source-fix commit `6a297372d431df7057800740c8eb65480da29950`은 scanner waiver 없이 authenticated Node 24.21.0 source와 embedded `undici@7.29.1`, npm 11.19.0, 새 exact `process.versions` identity/applicability 계약으로 수리한다. 이 commit의 local evidence는 typecheck, `npm audit --audit-level=high` 0건, focused 53/53, repository 5,408/5,409 pass다. 남은 1건은 실행 환경이 Unix-domain socket 생성을 `EPERM`으로 금지해 fixture setup 전에 종료된 local-only 결과이며 성공으로 승격하지 않는다. PR #736의 다음 exact head는 hosted patch-validator image build/raw scan/strict receipt와 모든 required Checks를 새로 통과하기 전까지 Draft/Proposed candidate이며 merge 또는 immutable publication authority가 아니다. + Protected history에는 merged PR #535 exact `82b20b293f0a5f0ac0e69857c1b61dddfe478491`, merged PR #540 exact `05bc2d47c3899ebe17538070f9a30172f90307ac`, merged PR #542 exact `ca839298fcaeec409091dc909789b6f87eb67fdc`, merged PR #547 exact `30b7e7e5cdab8de65715834a16f994b2047eafa6`, merged PR #550 exact `f2ec2dc6709814070cc3e3d6932ce280aee966db`, merged PR #553 exact `3bd9f543e97ce856f78b1c608141436298ce9e74`, merged PR #558 exact `2f91bf8641212ecae435b5fbcc9084cc0acd6295`, merged PR #556 exact `860714cba46dba06260a5dce09d0e9152fcb0a8c`, merged PR #560 exact `5aab7c098f3478069127f34e398326415ec599a4`, merged PR #582 exact `0f20a4dc78e423fd5df49e137a4eb286c7075ea4`, documentation-authority integration #583, protected procedural graph source #585/#586, documentation convergence #587/#588/#590/#595, workflow-backed current-state integration #589, procedural decision/evaluation/authentication integrations #591/#592/#593/#594/#596, State / Checkpoint history #597, Policy / Approval CAS #601, publication preflight #603, Workflow / Task exact-object operability integration #605, documentation-authority convergence #606, merged PR #607 exact `0afd68d2e33b7fd9be2307ba78b370b534cc0f54`, merged PR #610 exact `0a516bc54d682817ffea3206ec8b24b214aa94ea`, merged PR #612 exact `a919be3bc2a04068e25a4592e0c9c26f8e63534b`, merged PR #614 exact `77c5116a2e58e8a154ee893a3825da19c9c1f357`, merged PR #616 exact `da8461f1f5a6d02d926dd295fc88abe2c6167dfa`, documentation-authority convergence PR #617 exact `38b3b56837d875d19ae458f3f5491951ad7afb1c`, merged PR #618 exact `fb166ec6ecc769a19fd4ae4502f6d0994fc89e33`, merged PR #620 exact `b746846e4890c84b568bb29937594e1b17364fa1`, documentation-authority convergence #639, merged PR #640 exact `72ff4897822b5a8122540509346be4f3278a1379`, merged PR #641 exact `73f13b3a3826c5d6f7aafc89d1ff23747f8cf8e9`, merged PR #642 exact `3427e5a66d7a0ea9379ddc7915ba2b3d1872c492`, merged PR #643 exact `b13903e3208232086871d7f117f8f20e753eb583`, merged PR #644 exact `79a89f5775d0fb56de9f1908c1927df0b498b70f`, merged PR #645 exact `1b4dc38581e99b92342769dd03059f5015ee9f04`, merged PR #646 exact `3005086fca2c6376fae951b774230fa1c06ba587`, merged PR #647 exact `ea3a36bc65df88153aa69a05e3144f0b5352b5a6`, merged PR #648 exact `133ce20c4bb7fb9ba05e3e4bedeb0492334faf38`, merged PR #649 exact `f334f40193da8811a6e15efcb2d589aa80104931`, merged PR #650 exact `f59fce008a4da70f89e9927f692b823506594ed5`, documentation-authority convergence #651, merged PR #652 exact `23fabd95f3ef886c6bef0ae9ff6ead50dc0ce342`, documentation-authority convergence PR #653 exact `41ff085c89e27a97a00ce85ced968931f506a147`, merged PR #654 exact `f8d4b6b6b095c7674037a9ea5281b9ffa2f0f864`, merged PR #655 exact `dc851fc9ea67e38799e8c30cf4916a31c9e9661e`, merged PR #657 exact `fa4a2ab25231fa4c8b9a8451a4da6f29e04abaf7`, documentation-authority convergence PR #658 exact `5fe54442068646ad9581d39b23dd6d60a737aa66`, merged PR #659 exact `6357ac65db6a0db9f3bbb3f82196853b787f33cc`, merged PR #661 exact `5a5e147c5a7168277332eb828652b4aea4f7f2ba`, merged PR #663 exact `29dfa79692ec55cdc23ed892081425630ac916b5`, merged PR #665 exact `592ae00f090f642e8cb71ffb132d4a8b7bfe993a`, merged PR #668 exact `efaaebae161a71b1274f5aa0f26e27376a3edb3a`, merged PR #670 exact `14b4c9a46692df0084a03b9a3b2ae4acf1178a83`, merged PR #673 exact `6f9dc4ca047817e4c9ef5ae8a55816493b1c558d`, merged PR #676 exact `f8de7fc27d475c76477271fa50dd7032afd82a6c`, merged PR #681 exact `7072822253b1975cea76dcd7922e1a01d67e7681`, merged PR #678 exact `ac367cb535525742a1b6f3682d3ab5b14ef66f23`, merged PR #683 exact `01adb5af3d26788926164003bc44a761f5ff3654`, merged PR #685 exact `93dcf4c50018ce051e3ca917a4e1d63cbca3102d`, merged PR #687 exact `98ec07552f4081bd4ff6b25f1f3ed691c0f3f2b9`, merged PR #689 exact `b838c352a7cd0f7f58a905857537a3a9675a56f2`, merged PR #708 exact `8e4a35c28418fa3049e1dc13470088e8dacf2306`, merged PR #710 exact `8bc768756a10bab1d32b14039cdcfdb48d001931`, merged PR #714 exact `a1228e72565d4280d01e1f877377ee0d3e107178`, merged PR #717 exact `8f1fe8acdf96e23e5f110e958b08499e8e3a9803`, 그리고 merged PR #719 exact `de91e24fdac66c9963c92ca77971d870a48778e4`가 포함돼 있다. 이 식별자는 역사 증거이지 open-candidate authority가 아니다. 이 baseline과 executable documentation-authority test는 active documentation-authority lane 하나만 write한다. mutation 직전 open PR/Issue/branch를 fresh-read해 writer를 결정하며 merged/closed historical PR 번호를 active sole writer로 고정하지 않는다. 다른 feature lane의 과거 baseline blob은 ordinary/non-force semantic convergence 때 current authority로 승계하지 않는다. @@ -150,8 +156,9 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | P0 | Procedural graph publication boundary | advisory/evaluation/preflight가 publication/activation으로 오인될 위험 | protected #585/#586/#589/#597/#714/#601/#719/#603/#652/#663/#678 + issue #584 | source integrated; #714 State / Checkpoint and #719 Policy / Approval minimal-transaction/complete-revalidation/type-admission hardening integrated; response-bound, cancellation-liveness and locked-reader hardening integrated; publication/activation unavailable | released graph contract + live signer trust + lifecycle/revocation + deployed evidence + graph publication/canary | prerequisites 전 graph publication fail closed 유지 | | P0 | Durable workflow/state production evidence | source Durable Object logic·object-size observation이 deployed recovery/SLO로 오인될 위험 | protected #542 + #605 / ADR 0013 / issue #541 | source + exact-object observation integrated; ADR 0013 Proposed | immutable release/deployment + exact-object transaction/restart/recovery + representative storage-growth denominator + p95 + PITR/rollback | approved deployment owner에서 exact protected release 대상으로 runtime/recovery receipt 확보 | | P0 | Protected-main governance closure | Security workflow 하나로 PR/review/history/deletion/bypass 통제를 과대 주장할 위험 | issue #27 | external control evidence open | live ruleset + PR/review/conversation/history/deletion + bypass evidence | admin/owner control을 독립 검증 | -| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 | source/image integrated; publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | operational receipt 뒤 publication/signing 검증 | -| P0 | Patch-validator bundled Undici vulnerability | protected Node 24.19.0 static image의 bundled Undici 7.29.0/GHSA-3wwx-pv8p-q78v가 exact-image scanner gate를 차단 | issue #734; active PR #737; dependent Draft PRs #733/#736 | PR #737 active candidate에 final receipt exact Undici assertion, released gateway endpoint admission, review-contract repair와 bounded scanner acquisition reliability repair가 통합됐지만 fresh exact-head evidence와 qualifying independent approval은 non-terminal | exact PR-head build/smoke + Node/Undici inventory + final verifier exact Undici assertion + raw scanner receipt with no blocking finding + full CI/typecheck/coverage + independent approval + ordinary merge + immutable release evidence | PR #737 fresh exact-head review/check를 통과시킨 뒤 ordinary merge; #733/#736은 prerequisite integration과 release 전 Draft 유지 | +| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66; active PR #737; dependent Draft PRs #733/#736 | #737 exact-head source/image/reviewer/Security evidence GREEN; independent approval, ordinary merge, immutable publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | #737 ordinary merge 뒤 #736을 retarget/revalidate하고 operational publication/signing 검증 | +| P0 | Patch-validator bundled Undici vulnerability | protected Node 24.19.0 static image의 bundled Undici 7.29.0/GHSA-3wwx-pv8p-q78v가 exact-image scanner gate를 차단하고, internally consistent한 non-reviewed Undici identity가 final verifier를 통과할 위험 | issue #734; active PR #737; dependent Draft PRs #733/#736 | #737 final receipt exact Undici assertion, released gateway endpoint admission, review-contract, bounded scanner acquisition, CI oracle repair가 exact-head hosted GREEN; qualifying independent approval과 ordinary merge open | exact PR-head build/smoke + Node/Undici inventory + final verifier exact Undici assertion + raw scanner receipt with no blocking finding + full CI/typecheck/coverage + independent approval + ordinary merge + immutable release evidence | #737 ordinary merge 후 #733/#736을 non-force restack하고 fresh exact-head evidence를 재검증 | +| P0 | Continuation dispatch pre-effect recovery | credential/commit failure, post-commit/pre-request token expiry, or Worker termination can strand a reservation, while replay status, OpenAPI response composition, or payload-digest drift can misstate retained authority | issue #735 / open PR #736 | expiry RED `2a4488c6`→GREEN `350a3665`; OpenAPI executable-schema successor `1bd5fce7`; prerequisite #737 final exact head가 ordinary two-parent merge로 통합됐고 dependent fresh exact-head review/Checks와 immutable release evidence open | executable replay/401/OpenAPI/body-digest/credential-order/exact-receipt-release/alarm contracts + exact-head hosted GREEN + independent approval + ordinary merge + immutable key artifact | fresh exact-head gates를 재검증한 뒤 release와 `.github#2540` immutable-contract consumption을 재검증 | | P0 | Authentic production KPI evidence | synthetic/source KPI가 실제 운영 성능으로 오인될 위험 | issue #3 | >=30-day production window absent | authenticated production bytes + provenance + strict KPI gate | 실제 production evidence만 수집 | | P0 | Acquisition coordination | source/docs completion이 buyer/legal/transfer readiness로 오인될 위험 | issue #5 | evidence families incomplete | exact release/deployment/operational/legal evidence | owner별 evidence family 수렴 | | P0 | External Maintainer/Reviewer App identity | source preflight가 실제 App installation/reviewer authority로 오인될 위험 | issues #29 / #227 | live identity evidence absent | installation/key custody/permission/reviewer eligibility | external control-plane에서 독립 검증 | diff --git a/docs/runbook.md b/docs/runbook.md index edfe249e9..5baaa1a4d 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -5,6 +5,12 @@ - `trace_id`를 기준으로 요청, 에러, 장애 대응을 상호 연결합니다. - 토큰 응답/비밀번호/비밀키를 절대로 로그에 출력하지 않습니다. +## Continuation dispatch configuration + +`POST /v1/continuation-dispatches` requires `CONTINUATION_DISPATCH_GITHUB_APP_ID`, `CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM`, `CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID`, `CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM`, `CONTINUATION_RECEIPT_SIGNING_KEY_ID`, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Never place a secret value in logs, issues, receipts, or source. Rotate central-App and receipt keys independently and retain the public receipt key by key id. + +For `ERR_DISPATCH_REPLAY_CONFLICT` or indeterminate evidence, inspect retained state and never blind-retry. For `ERR_GITHUB_DISPATCH_AUTHORIZATION`, repair the central App installation/permission and use a fresh identity. For `ERR_GITHUB_DISPATCH_UPSTREAM`, reconcile GitHub outcome before a new logical request. + ## 장애 대응 우선순위 ### 1) `/exchange` 5xx 급증 diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md new file mode 100644 index 000000000..abcfaa960 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -0,0 +1,159 @@ +# Continuation Dispatch Broker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a versioned Noema broker that validates an exact GitHub Actions identity and live PR, emits one allowlisted central continuation event without returning a GitHub credential, and returns an idempotent signed receipt. + +**Architecture:** Keep `/exchange` unchanged. Route `POST /v1/continuation-dispatches` through the existing bounded edge, rate-limit, workflow-trust, and OIDC verification layers; delegate closed-schema parsing, idempotency, central-only GitHub egress, and receipt signing to focused continuation-dispatch modules and a SQLite Durable Object. + +**Tech Stack:** TypeScript 5.9, Cloudflare Workers/SQLite Durable Objects, Web Crypto Ed25519/SHA-256, Vitest 4, OpenAPI 3. + +**Spec:** `docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md` + +## Global Constraints + +- No consumer-visible GitHub credential, PAT fallback, inherited central secret, raw OIDC logging, or mutable workflow authority. +- Only `ContextualWisdomLab/.github` and the two released continuation actions/events are selectable. +- Every trust-boundary parser fails closed on duplicate/unknown keys, typed lookalikes, whitespace, multiple JSON values, and unbounded input. +- Exact replay returns stored evidence without a second GitHub call; conflict and indeterminate state never auto-dispatch. +- Runtime secrets are typed Worker bindings, never `process.env`. +- `/exchange` behavior and response schema remain unchanged. + +--- + +### Task 1: Closed request and canonical digest + +**Files:** +- Create: `src/continuation-dispatch/contract.ts` +- Create: `test/continuation-dispatch-contract.test.ts` + +**Interfaces:** +- Produces: `parseContinuationDispatchRequest(text: string): ContinuationDispatchRequest` +- Produces: `canonicalContinuationRequest(request, workflowIdentity): string` +- Produces: `continuationRequestDigest(...): Promise` +- Produces: `dispatchMapping(action): { eventType: "noema-review" | "strix-scan" }` + +- [x] Write RED tests for the exact valid request and every rejected unknown, duplicate, typed, whitespace, multiple-value, SHA/ref, target, action, and retry case. +- [x] Run `corepack npm exec vitest run test/continuation-dispatch-contract.test.ts`; expect the module import to fail. +- [x] Implement the closed parser, lexicographic RFC 8785 canonicalizer for the admitted JSON value domain, action mapping, and lowercase hexadecimal SHA-256 digest without dependencies. +- [x] Run the focused test; expect PASS with no warning output. +- [x] Commit `test+feat(dispatch): define closed continuation contract`. + +### Task 2: Durable exactly-once state + +**Files:** +- Create: `src/continuation-dispatch/dispatch-state.ts` +- Create: `test/continuation-dispatch-state.test.ts` +- Modify: `src/runtime-entrypoint.ts` +- Modify: `wrangler.toml` + +**Interfaces:** +- Produces: `NoemaContinuationDispatchState` +- Produces: `reserveContinuationDispatch(env, identity, digest): Promise` +- Produces: `commitContinuationOutcome(env, reservation, receipt): Promise` + +- [x] Write RED tests for first reservation, exact replay, conflicting digest, accepted/denied/indeterminate persistence, and malformed stored records. +- [x] Run the focused state test; expect missing implementation failure. +- [x] Implement one SQLite transaction per logical identity, bounded stored JSON, and fail-closed private command parsing. +- [x] Export/bind `NOEMA_CONTINUATION_DISPATCH_STATE` in `runtime-entrypoint.ts` and `wrangler.toml`. +- [x] Run state tests and `corepack npm run typecheck`; expect PASS. +- [x] Commit `test+feat(dispatch): add exactly-once continuation state`. + +### Task 3: Live PR and fixed GitHub adapter + +**Files:** +- Create: `src/continuation-dispatch/github-adapter.ts` +- Create: `test/continuation-dispatch-github-adapter.test.ts` +- Modify: `src/index.ts` + +**Interfaces:** +- Consumes: verified `JwtPayload`, `ContinuationDispatchRequest`, and existing GitHub App JWT/token helpers. +- Produces: `readAndVerifyLivePullRequest(...)` +- Produces: `dispatchCentralContinuation(...)` + +- [x] Write RED tests for moved/closed/draft/fork PRs, wrong repository/base/ref, fixed target/path/event/payload, redirect denial, GitHub 403/404/422/5xx/network classification, and token absence from errors/logs. +- [x] Run the focused adapter test; expect missing implementation failure. +- [x] Extract only the existing App-JWT and bounded GitHub JSON helpers needed by both `/exchange` and the broker; do not duplicate them. +- [x] Implement read-only source App lookup and a separate central-only dispatch App token used solely for `POST /repos/ContextualWisdomLab/.github/dispatches`. +- [x] Run adapter tests, existing `test/worker.test.ts`, and typecheck; expect PASS. +- [x] Commit `test+feat(dispatch): bind live PR and fixed central event`. + +### Task 4: Credential-free signed receipt + +**Files:** +- Create: `src/continuation-dispatch/receipt.ts` +- Create: `test/continuation-dispatch-receipt.test.ts` + +**Interfaces:** +- Produces: `signContinuationReceipt(fields, env): Promise` +- Produces: `verifyContinuationReceipt(receipt, publicKey): Promise` for tests and documented consumer conformance. + +- [x] Write RED tests for deterministic RFC 8785 bytes, Ed25519 verification, OIDC-derived expiry, field mutation failure, and absence of token/bearer/assertion/private-key material. +- [x] Run the focused receipt test; expect missing implementation failure. +- [x] Implement dedicated Ed25519 PKCS#8 import, key-id validation, detached base64url signature, and public verification helper. +- [x] Run receipt tests and typecheck; expect PASS. +- [x] Commit `test+feat(dispatch): issue signed continuation receipts`. + +### Task 5: Public route integration + +**Files:** +- Create: `src/continuation-dispatch/handler.ts` +- Create: `test/continuation-dispatch-worker.test.ts` +- Modify: `src/index.ts` +- Modify: `src/worker.ts` +- Modify: `src/entrypoint.ts` +- Modify: `src/runtime-entrypoint.ts` +- Modify: `src/runtime-readiness.ts` +- Modify: `src/error-codes.ts` + +**Interfaces:** +- Consumes: Tasks 1-4. +- Produces: `POST /v1/continuation-dispatches` standard success/error envelope. + +- [x] Write end-to-end RED tests proving method/content-type/body bounds, exact URL, rate limit, workflow SHA, single-use OIDC, live PR, one dispatch, exact replay, conflict, receipt signature, and operational headers. +- [x] Run the focused worker test; expect 404/missing route failures. +- [x] Admit the new route through each existing layer, add the six dispatch error codes/hints, require all new secret/DO bindings in readiness, and preserve `/exchange` byte-for-byte behavior tests. +- [x] Run focused worker tests, all existing Worker tests, and typecheck; expect PASS. +- [x] Commit `test+feat(dispatch): expose versioned continuation broker`. + +### Task 6: Product, security, and operability contracts + +**Files:** +- Modify: `openapi.json` +- Modify: `docs/api-spec.md` +- Modify: `docs/api-stability-contract.md` +- Modify: `docs/PRD.md` +- Modify: `docs/TRD.md` +- Create: `docs/adr/0019-continuation-dispatch-broker.md` +- Modify: `docs/adr/README.md` +- Modify: `docs/threat-model.md` +- Modify: `docs/runbook.md` +- Modify: `docs/product-technical-gap-baseline.md` +- Modify: `CHANGELOG.md` +- Modify: `README.md` +- Create: `test/continuation-dispatch-docs-contract.test.ts` + +**Interfaces:** +- Publishes: v1 OpenAPI schema, public receipt verification contract, configuration/runbook, and immutable release prerequisites. + +- [x] Write RED docs-contract tests for endpoint/schema/error codes, required secret/DO bindings, no-token response, ADR status Proposed, and downstream `.github#2540` release order. +- [x] Run the docs-contract test; expect missing-contract failures. +- [x] Update every listed contract with the exact design and primary-source references; keep the ADR Proposed until protected integration. +- [ ] Run docs tests, typecheck, focused broker tests, `corepack npm test`, `corepack npm run security:scan`, and `corepack npm run release:verify`. +- [ ] Record the two pre-existing local baseline failures verbatim if they remain; do not filter, downgrade, or call the full suite GREEN. +- [ ] Commit `docs(dispatch): publish broker contracts and release gates`. + +### Task 7: Exact-head owner PR and downstream handoff + +**Files:** +- Modify only evidence text if exact SHA/check identifiers are added after publication. + +**Interfaces:** +- Produces: Draft Noema owner PR linked to #735 and an exact downstream handoff for `.github#2540`. + +- [ ] Fetch protected `main` and the live branch; non-force merge concurrent commits before publication. +- [ ] Re-run focused broker tests, typecheck, full suite, security, SBOM/provenance gates, and `git diff --check` on the exact candidate tree. +- [ ] Push without force, open Draft PR, and record head/tree/parents plus RED/GREEN evidence. +- [ ] Wait for exact-head required Checks and independent review without manual rerun; repair actual findings on the same branch. +- [ ] After ordinary merge, publish an immutable semantic version and verify release assets/provenance. +- [ ] Update `.github#2540` to the released endpoint/schema, remove the legacy exchange plus local `gh api` sequence, and prove a real consumer continuation and fresh exact-head model verdict. diff --git a/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md new file mode 100644 index 000000000..d094dfa1c --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md @@ -0,0 +1,203 @@ +# Continuation Dispatch Broker Design + +Status: Proposed. This document designs the canonical-owner repair for +`ContextualWisdomLab/noema#735`; it is not release, deployment, or merge +authority. + +## Problem + +Required Noema and Strix workflows execute in consumer repositories. Their +consumer-scoped `github.token` cannot create a `repository_dispatch` event in +`ContextualWisdomLab/.github`. Returning a broader installation token to the +consumer is also unacceptable: GitHub requires `Contents: write` for the +repository-dispatch endpoint, so the token would carry content mutation power +beyond the single intended action. + +The retained protected failure and the current recurrence are bound in +`ContextualWisdomLab/.github#2540`: the existing consumer OIDC exchange can +succeed and the following central dispatch can still fail HTTP 403. Therefore +Noema must own the target/action authorization and perform the fixed dispatch +server-side without returning a GitHub credential. + +## Context and responsibility + +Noema adds a separate public mutation endpoint: + +`POST /v1/continuation-dispatches` + +The existing `POST /exchange` contract is unchanged. The new endpoint is a +synchronous broker inside the Noema Identity/Federation bounded context: + +1. authenticate the GitHub Actions OIDC bearer; +2. bind the caller to the immutable reusable-workflow source; +3. validate one closed continuation request; +4. read the live source PR with the existing read-only source App; +5. atomically reserve the exact request identity; +6. mint a separate central-only dispatch App token inside Noema; +7. emit one internally mapped `repository_dispatch` request; +8. persist the outcome and return a signed, credential-free receipt. + +`.github` remains the consumer adapter and central event handler. It never +reads Noema storage or source and may consume only the released API/schema. + +## Closed request contract + +The body is exactly one UTF-8 JSON object. Duplicate keys, unknown keys, +additional JSON values, non-integer numbers, non-string typed lookalikes, +whitespace-bearing identifiers, and bodies over the existing public mutation +body bound fail before private-key use. + +Required members: + +| Member | Contract | +|---|---| +| `contract_version` | exact string `noema.continuation-dispatch.v1` | +| `dispatch_action` | `noema_review_continuation` or `strix_scan_continuation` | +| `central_repository` | exact string `ContextualWisdomLab/.github` | +| `source_repository` | canonical `owner/name`; equals verified OIDC `repository` | +| `pull_request_number` | positive safe integer | +| `expected_head_sha` | lowercase 40-hex commit SHA | +| `expected_base_sha` | lowercase 40-hex commit SHA | +| `expected_base_ref` | nonempty canonical Git ref name without whitespace | +| `transport_retry_attempt` | integer `1` or `2`, consuming the existing two-attempt contract in `.github` ADR-0031 rather than defining a new retry budget | + +The caller cannot provide a URL, REST path, event type, arbitrary payload, +installation id, permission set, or GitHub token. Noema maps actions internally: + +- `noema_review_continuation` -> `event_type=noema-review`; +- `strix_scan_continuation` -> `event_type=strix-scan`. + +The `client_payload` contains only the validated source repository, PR number, +head/base/ref, and retry attempt. + +## Identity and live-state admission + +The existing OIDC verifier remains the single cryptographic verification path. +Admission additionally requires: + +- exact GitHub issuer and configured audience; +- exact organization id and source repository identity; +- exact reusable-workflow path and `job_workflow_sha`; +- bounded temporal claims and unused `jti`; +- OIDC `repository` equals `source_repository`; +- a fresh GitHub PR read reports open, non-draft, same-organization source and + base repositories, exact head SHA, base SHA, and base ref. + +GitHub documents `job_workflow_sha` as the reusable workflow file's commit SHA, +so it is immutable workflow-source authority rather than a mutable branch +label: . + +## Credential separation and GitHub egress + +The existing GitHub App remains read-only for source PR inspection. A distinct +broker App is installed only on `ContextualWisdomLab/.github` and holds the +`Contents: write` permission GitHub requires for repository dispatch. Its App +id, private key, and central installation id are separate typed Worker secret +bindings. The installation token is created only after admission, stays inside +the fixed dispatch adapter, is never returned or logged, and is not cached +outside the request. + +GitHub's repository-dispatch permission contract is the reason for the broker +shape: . + +All GitHub requests use the exact configured GitHub Cloud origin, fixed paths, +`redirect: "error"`, bounded JSON responses, and existing secret-redaction +rules. No caller string participates in target origin or path selection. + +## Exactly-once state + +Add `NoemaContinuationDispatchState`, a SQLite-backed Durable Object selected +by the lowercase hexadecimal SHA-256 digest of an RFC 8785 logical-effect identity object. Its named members are +`contract_version`, verified `workflow_sha`, `source_repository`, +`pull_request_number`, exact head/base/ref, `dispatch_action`, +and `central_repository`. `transport_retry_attempt` remains in the complete +request digest but is excluded from the object-selection identity, so attempts +1 and 2 cannot acquire separate dispatch authority. Named canonical members +avoid the delimiter ambiguity of concatenated strings. + +Canonical bytes contain only that closed identity schema. RFC 8785 defines +deterministic JSON bytes suitable for hashing: +. + +The Durable Object transaction stores the request digest and one state: + +- `reserved`: the first admitted request owns the only dispatch attempt; +- `accepted`: GitHub returned 204; +- `denied`: GitHub returned an authorization/validation denial; +- `indeterminate`: a network error, 5xx, deadline, or unreadable upstream + response makes external side-effect completion unknowable. + +An exact replay returns the stored signed receipt before another live-PR read or +dispatch. The same logical key with a different complete request digest fails +closed. Before the external call, Noema durably commits a signed +`indeterminate` receipt; accepted or denied evidence may strengthen that same +authority afterward. A crash or finalization failure therefore retains +replayable evidence and cannot authorize a second dispatch. `indeterminate` is +never silently redispatched; operator reconciliation is required. + +## Signed receipt + +Noema returns `{ ok: true, data, trace_id }`; `data` contains no credential. +Receipt fields are: + +- contract/receipt version, receipt id, key id, and `Ed25519` algorithm; +- canonical request digest and idempotency identity; +- verified source repository, PR, head/base/ref; +- verified reusable-workflow ref and SHA; +- fixed central repository and mapped event type; +- exact emitted payload digest; +- outcome `accepted`, `denied`, or `indeterminate` and bounded upstream status; +- issued-at and expiry copied from the admitted OIDC lifetime bound; +- trace id and detached base64url signature. + +The signing private key and key id are dedicated Worker bindings and are not +the GitHub App key. Offline-verification acceptance begins only when an +immutable release publishes the exact key-id-to-SPKI artifact and digest; +Draft source does not claim that a verification key is already available. +Cloudflare Workers Web Crypto supports Ed25519, including the standards-based +Secure Curves form: . +The receipt is evidence only and authorizes no later dispatch, merge, release, +or deployment. + +## Error taxonomy + +The response envelope adds these stable codes: + +- `ERR_DISPATCH_REQUEST_INVALID` (400); +- `ERR_DISPATCH_IDENTITY_DENIED` (401/403); +- `ERR_DISPATCH_LIVE_STATE_STALE` (409); +- `ERR_DISPATCH_REPLAY_CONFLICT` (409); +- `ERR_GITHUB_DISPATCH_AUTHORIZATION` (502); +- `ERR_GITHUB_DISPATCH_UPSTREAM` (503). + +Provider capacity and user cancellation are never used for broker failures. +Logs contain only bounded identifiers, digests, status classes, and trace ids. + +## Rejected alternatives + +- Extend `/exchange`: rejected because it returns reusable credential + authority and would silently change an existing stable contract. +- Return a target installation token: rejected because GitHub requires + `Contents: write`, which exceeds a continuation-only consumer capability. +- PAT, inherited central secret, or consumer workflow secret: rejected because + it bypasses OIDC source identity and central owner policy. +- Fire-and-forget retry: rejected because an unknown upstream result can + duplicate an external side effect. +- Consumer copy of broker logic or direct Noema storage access: rejected by + owner/consumer and DDD boundaries. + +## Verification and release order + +1. RED unit/contract tests cover closed JSON, claims, live PR movement, + target/action closure, exact replay/conflict, GitHub denial/unknown outcome, + signature verification, and secret absence. +2. Implement the broker with focused tests and 100% owned-production coverage. +3. Update OpenAPI, API stability, PRD/TRD/ADR, threat model, runbook, + CHANGELOG, and product gap baseline. +4. Run typecheck, focused tests, complete coverage suite, security scan, SBOM, + and provenance on one exact owner head. The two pre-existing local baseline + failures (Unix-socket EPERM and strict-allow-scripts harness behavior) remain + explicit until hosted Linux evidence resolves them. +5. Merge ordinarily after exact-head required Checks and review, publish an + immutable semantic version, then update `.github#2540` to the released + contract and prove a real consumer continuation plus fresh model verdict. diff --git a/docs/threat-model.md b/docs/threat-model.md index 98d23d2d2..4794bcecd 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -7,6 +7,10 @@ - 감사 로그(trace_id, 에러 코드, 레이턴시) - Cloudflare Worker isolate의 128MB 메모리와 요청 처리 가용성 +## Continuation dispatch boundary + +`POST /v1/continuation-dispatches`는 mutable alias/TOCTOU, repository/workflow identity confusion, stale PR evidence, replay, caller-selected destination/event, broad GitHub capability exfiltration, duplicate effect와 indeterminate outcome을 위협으로 다룬다. Exact URL, closed JSON, OIDC workflow SHA, fresh PR, fixed event, terminal state와 RFC 8785/Ed25519 receipt를 결합하며 GitHub credential을 반환하지 않는다. Retry 1/2는 하나의 logical-effect owner에서 직렬화되고 complete-digest drift는 conflict다. Central App credential 준비는 durable pre-dispatch commit보다 먼저 끝내며, 준비/commit 실패는 reservation을 해제하고 external effect 없이 실패-폐쇄한다. Commit 뒤 crash/finalization failure는 retained indeterminate evidence 때문에 재dispatch를 허용하지 않는다. Commit 전 crash로 고립된 reservation만 retained OIDC authorization `exp`의 정확한 alarm에서 회수하며 terminal evidence는 보존한다. 임의 timeout·retry 횟수·fallback은 recovery authority가 아니다. GitHub live-read와 repository-dispatch 사이를 원자화할 수 없으므로 event는 exact expected head/base/ref를 보존하며, downstream `.github#2540`은 실행 직전 이를 다시 검증해야 한다. Missing state/signing/App/replay authority와 ambiguous outcome은 실패-폐쇄한다. + ## 위협 1. 위조된 OIDC 토큰으로 허가되지 않은 토큰 발급 시도 2. JWT 페이로드 위변조 또는 만료 토큰 재사용 diff --git a/openapi.json b/openapi.json index d92ed2f06..2f3de3938 100644 --- a/openapi.json +++ b/openapi.json @@ -153,6 +153,83 @@ "503": { "$ref": "#/components/responses/ServiceUnavailable" } } } + }, + "/v1/continuation-dispatches": { + "post": { + "tags": ["Exchange"], + "operationId": "createContinuationDispatch", + "summary": "Dispatch one exact allowlisted continuation", + "description": "Accepts only noema.continuation-dispatch.v1. Validates the exact GitHub Actions OIDC workflow SHA, live pull request state, distributed single-use identity, and durable idempotency reservation before emitting one fixed central event. The credential-free response contains only an Ed25519-signed receipt. Stable failures are ERR_DISPATCH_REQUEST_INVALID, ERR_DISPATCH_IDENTITY_DENIED, ERR_DISPATCH_LIVE_STATE_STALE, ERR_DISPATCH_REPLAY_CONFLICT, ERR_GITHUB_DISPATCH_AUTHORIZATION, and ERR_GITHUB_DISPATCH_UPSTREAM.", + "security": [{ "githubActionsOidc": [] }], + "x-request-body-limit-bytes": 8192, + "x-request-body-read-deadline-ms": 10000, + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationDispatchRequest" } } } + }, + "responses": { + "200": { + "description": "Accepted dispatch or exact replay evidence. X-Continuation-Replay is present only for exact replay.", + "headers": { + "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, + "Pragma": { "$ref": "#/components/headers/Pragma" }, + "X-Content-Type-Options": { "$ref": "#/components/headers/ContentTypeOptions" }, + "X-Trace-Id": { "$ref": "#/components/headers/TraceId" }, + "X-Latency-Ms": { "$ref": "#/components/headers/LatencyMs" }, + "X-Oidc-Replay-Protection": { "$ref": "#/components/headers/ContinuationOidcReplayProtection" }, + "X-Continuation-Replay": { "$ref": "#/components/headers/ContinuationReplay" } + }, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationDispatchSuccess" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { + "description": "Identity is missing, invalid, or already consumed.", + "headers": { + "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, + "Pragma": { "$ref": "#/components/headers/Pragma" }, + "X-Content-Type-Options": { "$ref": "#/components/headers/ContentTypeOptions" }, + "X-Trace-Id": { "$ref": "#/components/headers/TraceId" }, + "X-Latency-Ms": { "$ref": "#/components/headers/LatencyMs" }, + "WWW-Authenticate": { "$ref": "#/components/headers/WwwAuthenticate" } + }, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationErrorResponse" } } } + }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "405": { "description": "Only POST is admitted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "408": { "$ref": "#/components/responses/RequestTimeout" }, + "409": { "description": "Live state, in-progress state, or retained identity conflicts.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, + "429": { "description": "Distributed rate limit exceeded.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "502": { + "description": "GitHub denied the fixed dispatch. X-Continuation-Replay is present only for exact replay evidence.", + "headers": { + "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, + "Pragma": { "$ref": "#/components/headers/Pragma" }, + "X-Content-Type-Options": { "$ref": "#/components/headers/ContentTypeOptions" }, + "X-Trace-Id": { "$ref": "#/components/headers/TraceId" }, + "X-Latency-Ms": { "$ref": "#/components/headers/LatencyMs" }, + "X-Continuation-Replay": { "$ref": "#/components/headers/ContinuationReplay" } + }, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationErrorResponse" } } } + }, + "503": { + "description": "Continuation authority, evidence, or transport is unavailable or indeterminate. X-Continuation-Replay is present only for exact replay evidence.", + "headers": { + "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, + "Pragma": { "$ref": "#/components/headers/Pragma" }, + "X-Content-Type-Options": { "$ref": "#/components/headers/ContentTypeOptions" }, + "X-Trace-Id": { "$ref": "#/components/headers/TraceId" }, + "X-Latency-Ms": { "$ref": "#/components/headers/LatencyMs" }, + "X-Continuation-Replay": { "$ref": "#/components/headers/ContinuationReplay" } + }, + "content": { "application/json": { "schema": { "anyOf": [ + { "$ref": "#/components/schemas/ContinuationErrorResponse" }, + { "$ref": "#/components/schemas/ErrorResponse" } + ] } } } + } + } + } } }, "components": { @@ -212,6 +289,14 @@ "OidcReplayProtection": { "description": "Confirms that the successful exchange consumed the verified OIDC identity through the single-use replay boundary.", "schema": { "type": "string", "const": "single-use" } + }, + "ContinuationOidcReplayProtection": { + "description": "Confirms verification and single-use claim before continuation dispatch.", + "schema": { "type": "string", "const": "verified-before-dispatch" } + }, + "ContinuationReplay": { + "description": "Present with the value exact only when the response replays retained terminal evidence.", + "schema": { "type": "string", "const": "exact" } } }, "schemas": { @@ -283,6 +368,99 @@ "trace_id": { "type": "string" } } }, + "ContinuationDispatchRequest": { + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "dispatch_action", "central_repository", "source_repository", "pull_request_number", "expected_head_sha", "expected_base_sha", "expected_base_ref", "transport_retry_attempt"], + "properties": { + "contract_version": { "type": "string", "const": "noema.continuation-dispatch.v1" }, + "dispatch_action": { "type": "string", "enum": ["noema_review_continuation", "strix_scan_continuation"] }, + "central_repository": { "type": "string", "const": "ContextualWisdomLab/.github" }, + "source_repository": { "$ref": "#/components/schemas/RepositoryLocator" }, + "pull_request_number": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "expected_head_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "expected_base_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "expected_base_ref": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "transport_retry_attempt": { "type": "integer", "enum": [1, 2] } + } + }, + "SignedContinuationReceipt": { + "type": "object", + "additionalProperties": false, + "required": ["receipt_version", "contract_version", "algorithm", "key_id", "receipt_id", "trace_id", "request_digest", "idempotency_identity", "workflow_ref", "workflow_sha", "source_repository", "pull_request_number", "source_head_sha", "source_base_sha", "source_base_ref", "central_repository", "event_type", "emitted_payload_digest", "transport_retry_attempt", "outcome", "upstream_status", "issued_at", "expires_at", "signature"], + "properties": { + "receipt_version": { "type": "string", "const": "noema.continuation-dispatch-receipt.v1" }, + "contract_version": { "type": "string", "const": "noema.continuation-dispatch.v1" }, + "algorithm": { "type": "string", "const": "Ed25519" }, + "key_id": { "type": "string" }, + "receipt_id": { "type": "string", "format": "uuid" }, + "trace_id": { "type": "string" }, + "request_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "idempotency_identity": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "workflow_ref": { "type": "string" }, + "workflow_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "source_repository": { "$ref": "#/components/schemas/RepositoryLocator" }, + "pull_request_number": { "type": "integer", "minimum": 1 }, + "source_head_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "source_base_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "source_base_ref": { "type": "string" }, + "central_repository": { "type": "string", "const": "ContextualWisdomLab/.github" }, + "event_type": { "type": "string", "enum": ["noema-review", "strix-scan"] }, + "emitted_payload_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "transport_retry_attempt": { "type": "integer", "enum": [1, 2] }, + "outcome": { "type": "string", "enum": ["accepted", "denied", "indeterminate"] }, + "upstream_status": { "type": ["integer", "null"] }, + "issued_at": { "type": "integer" }, + "expires_at": { "type": "integer" }, + "signature": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" } + } + }, + "ContinuationDispatchSuccess": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "data", "trace_id"], + "properties": { + "ok": { "type": "boolean", "const": true }, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["receipt"], + "properties": { "receipt": { "$ref": "#/components/schemas/SignedContinuationReceipt" } } + }, + "trace_id": { "type": "string" } + } + }, + "ContinuationErrorDetails": { + "type": "object", + "additionalProperties": false, + "required": ["hint"], + "properties": { + "hint": { "type": "string" }, + "receipt": { "$ref": "#/components/schemas/SignedContinuationReceipt" } + } + }, + "ContinuationErrorResponse": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "error_code", "message", "details", "trace_id"], + "properties": { + "ok": { "type": "boolean", "const": false }, + "error_code": { + "type": "string", + "enum": [ + "ERR_DISPATCH_REQUEST_INVALID", + "ERR_DISPATCH_IDENTITY_DENIED", + "ERR_DISPATCH_LIVE_STATE_STALE", + "ERR_DISPATCH_REPLAY_CONFLICT", + "ERR_GITHUB_DISPATCH_AUTHORIZATION", + "ERR_GITHUB_DISPATCH_UPSTREAM" + ] + }, + "message": { "type": "string" }, + "details": { "$ref": "#/components/schemas/ContinuationErrorDetails" }, + "trace_id": { "type": "string" } + } + }, "ErrorResponse": { "type": "object", "required": ["ok", "error_code", "message", "trace_id"], @@ -374,7 +552,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "RequestTimeout": { - "description": "Exchange JSON wire body did not complete within the 10,000 ms absolute read deadline.", + "description": "JSON wire body did not complete within the operation's 10,000 ms absolute read deadline.", "headers": { "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, "Pragma": { "$ref": "#/components/headers/Pragma" }, @@ -385,7 +563,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "PayloadTooLarge": { - "description": "Exchange JSON wire body exceeds 8,192 bytes.", + "description": "JSON wire body exceeds the operation's 8,192-byte limit.", "headers": { "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, "Pragma": { "$ref": "#/components/headers/Pragma" }, @@ -396,7 +574,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "UnsupportedMediaType": { - "description": "Exchange request body is present but its media-type token is not application/json.", + "description": "Request body is present but its media-type token is not application/json.", "headers": { "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, "Pragma": { "$ref": "#/components/headers/Pragma" }, diff --git a/package.json b/package.json index 482c15713..8347cb25f 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,6 @@ }, "overrides": { "postcss": "^8.5.18", - "undici": "7.29.0" + "undici": "7.29.1" } } diff --git a/src/continuation-dispatch/contract.ts b/src/continuation-dispatch/contract.ts new file mode 100644 index 000000000..5f88c867a --- /dev/null +++ b/src/continuation-dispatch/contract.ts @@ -0,0 +1,340 @@ +const CONTRACT_VERSION = "noema.continuation-dispatch.v1" as const; +const CENTRAL_REPOSITORY = "ContextualWisdomLab/.github" as const; +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const SOURCE_REPOSITORY_PATTERN = /^ContextualWisdomLab\/[A-Za-z0-9._-]+$/u; +const FORBIDDEN_REF_CHARACTER_PATTERN = /[\u0000-\u0020~^:?*\[\\]/u; +const REQUEST_KEYS = [ + "central_repository", + "contract_version", + "dispatch_action", + "expected_base_ref", + "expected_base_sha", + "expected_head_sha", + "pull_request_number", + "source_repository", + "transport_retry_attempt", +] as const; +const REQUEST_KEY_SET = JSON.stringify([...REQUEST_KEYS].sort()); +const WORKFLOW_IDENTITY_KEY_SET = JSON.stringify(["workflow_sha"]); + +/** + * Names the only continuation actions released by the v1 Noema broker contract. + * Each value maps to one fixed central event and cannot select a provider or workflow. + */ +export type ContinuationDispatchAction = + | "noema_review_continuation" + | "strix_scan_continuation"; + +/** + * Carries one validated v1 request for an exact central continuation dispatch. + * Every identity remains immutable after admission so it can safely enter idempotency state. + */ +export interface ContinuationDispatchRequest { + readonly contract_version: typeof CONTRACT_VERSION; + readonly dispatch_action: ContinuationDispatchAction; + readonly central_repository: typeof CENTRAL_REPOSITORY; + readonly source_repository: string; + readonly pull_request_number: number; + readonly expected_head_sha: string; + readonly expected_base_sha: string; + readonly expected_base_ref: string; + readonly transport_retry_attempt: 1 | 2; +} + +/** Verified immutable reusable-workflow identity included in dispatch idempotency. */ +export interface ContinuationWorkflowIdentity { + readonly workflow_sha: string; +} + +/** + * Exposes the fixed central event selected by one released continuation action. + * Callers cannot supply an arbitrary GitHub event type through this mapping. + */ +export interface ContinuationDispatchMapping { + readonly eventType: "noema-review" | "strix-scan"; +} + +/** Raised when untrusted continuation request or identity material is not canonical. */ +export class ContinuationDispatchContractError extends Error { + /** Creates a fail-closed continuation contract error with a stable diagnostic. */ + constructor(message: string) { + super(message); + this.name = "ContinuationDispatchContractError"; + } +} + +function rejectContract(message: string): never { + throw new ContinuationDispatchContractError(message); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: string): boolean { + return JSON.stringify(Object.keys(value).sort()) === expected; +} + +function isCanonicalCommitSha(value: unknown): value is string { + return typeof value === "string" && SHA_PATTERN.test(value); +} + +function isCanonicalSourceRepository(value: unknown): value is string { + if (typeof value !== "string" || !SOURCE_REPOSITORY_PATTERN.test(value)) return false; + const repositoryName = value.slice(value.indexOf("/") + 1); + return repositoryName !== "." && repositoryName !== ".."; +} + +function containsUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = value.charCodeAt(index + 1); + if (index + 1 >= value.length || nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) return true; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +} + +function isCanonicalBaseRef(value: unknown): value is string { + if ( + typeof value !== "string" + || value.length === 0 + || value === "@" + || value.startsWith("-") + || value.startsWith("/") + || value.endsWith("/") + || value.endsWith(".") + || value.includes("..") + || value.includes("@{") + || value.includes("//") + || FORBIDDEN_REF_CHARACTER_PATTERN.test(value) + || /\s/u.test(value) + || containsUnpairedSurrogate(value) + ) { + return false; + } + return value.split("/").every( + (component) => component.length > 0 && !component.startsWith(".") && !component.endsWith(".lock"), + ); +} + +function decodedRootKeysContainDuplicate(text: string): boolean { + let depth = 0; + let inString = false; + let escaped = false; + let stringStart = -1; + const seen = new Set(); + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]!; + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character !== "\"") continue; + + inString = false; + if (depth !== 1) continue; + let lookahead = index + 1; + while (lookahead < text.length && /\s/u.test(text[lookahead]!)) lookahead += 1; + if (text[lookahead] !== ":") continue; + + const encodedKey = text.slice(stringStart + 1, index); + // The complete text has already passed JSON.parse, so this exact encoded key is valid JSON string content. + const decodedKey = JSON.parse(`"${encodedKey}"`) as string; + if (seen.has(decodedKey)) return true; + seen.add(decodedKey); + continue; + } + + if (character === "\"") { + inString = true; + stringStart = index; + } else if (character === "{" || character === "[") { + depth += 1; + } else if (character === "}" || character === "]") { + depth -= 1; + } + } + return false; +} + +function admitContinuationDispatchRequest(value: unknown): ContinuationDispatchRequest { + if (!isRecord(value) || !hasExactKeys(value, REQUEST_KEY_SET)) { + return rejectContract("continuation dispatch request must contain exactly the released members"); + } + if (value.contract_version !== CONTRACT_VERSION) { + return rejectContract("continuation dispatch request contract_version is not released"); + } + if ( + value.dispatch_action !== "noema_review_continuation" + && value.dispatch_action !== "strix_scan_continuation" + ) { + return rejectContract("continuation dispatch request dispatch_action is not released"); + } + if (value.central_repository !== CENTRAL_REPOSITORY) { + return rejectContract("continuation dispatch request central_repository is not canonical"); + } + if (!isCanonicalSourceRepository(value.source_repository)) { + return rejectContract("continuation dispatch request source_repository is not canonical"); + } + if ( + typeof value.pull_request_number !== "number" + || !Number.isSafeInteger(value.pull_request_number) + || value.pull_request_number <= 0 + ) { + return rejectContract("continuation dispatch request pull_request_number is not a positive safe integer"); + } + if (!isCanonicalCommitSha(value.expected_head_sha)) { + return rejectContract("continuation dispatch request expected_head_sha is not canonical"); + } + if (!isCanonicalCommitSha(value.expected_base_sha)) { + return rejectContract("continuation dispatch request expected_base_sha is not canonical"); + } + if (!isCanonicalBaseRef(value.expected_base_ref)) { + return rejectContract("continuation dispatch request expected_base_ref is not canonical"); + } + if (value.transport_retry_attempt !== 1 && value.transport_retry_attempt !== 2) { + return rejectContract("continuation dispatch request transport_retry_attempt is not released"); + } + + return Object.freeze({ + contract_version: value.contract_version, + dispatch_action: value.dispatch_action, + central_repository: value.central_repository, + source_repository: value.source_repository, + pull_request_number: value.pull_request_number, + expected_head_sha: value.expected_head_sha, + expected_base_sha: value.expected_base_sha, + expected_base_ref: value.expected_base_ref, + transport_retry_attempt: value.transport_retry_attempt, + }); +} + +function admitWorkflowIdentity(value: unknown): ContinuationWorkflowIdentity { + if ( + !isRecord(value) + || !hasExactKeys(value, WORKFLOW_IDENTITY_KEY_SET) + || !isCanonicalCommitSha(value.workflow_sha) + ) { + return rejectContract("continuation dispatch workflow_sha is not canonical"); + } + return Object.freeze({ workflow_sha: value.workflow_sha }); +} + +function canonicalFlatObject(value: Readonly>): string { + return `{${Object.keys(value).sort().map( + (key) => `${JSON.stringify(key)}:${JSON.stringify(value[key])}`, + ).join(",")}}`; +} + +/** + * Parses one UTF-8-decoded JSON text as the exact v1 continuation request. + * Duplicate decoded root keys, additional values, unknown members, typed lookalikes, and non-canonical identities fail closed. + * @param text Untrusted request text after the public request-body byte and UTF-8 boundary. + * @returns An immutable request containing only validated contract members. + * @throws {ContinuationDispatchContractError} When syntax, shape, type, or canonical identity validation fails. + */ +export function parseContinuationDispatchRequest(text: string): ContinuationDispatchRequest { + if (typeof text !== "string") { + return rejectContract("continuation dispatch request must be JSON text"); + } + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch { + return rejectContract("continuation dispatch request is not one complete JSON value"); + } + if (decodedRootKeysContainDuplicate(text)) { + return rejectContract("continuation dispatch request contains duplicate decoded members"); + } + return admitContinuationDispatchRequest(value); +} + +/** + * Maps one released caller action to the only central event type it may emit. + * @param action Candidate continuation action, revalidated at runtime despite its static type. + * @returns The immutable fixed central event mapping. + * @throws {ContinuationDispatchContractError} When the action is not released. + */ +export function dispatchMapping(action: ContinuationDispatchAction): ContinuationDispatchMapping { + if (action === "noema_review_continuation") return Object.freeze({ eventType: "noema-review" }); + if (action === "strix_scan_continuation") return Object.freeze({ eventType: "strix-scan" }); + return rejectContract("continuation dispatch action is not released"); +} + +/** + * Serializes the exact request and verified workflow SHA using RFC 8785 lexicographic object-key order. + * @param request Candidate request; every member is revalidated before it gains idempotency authority. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns Deterministic canonical JSON bytes represented as a string. + * @throws {ContinuationDispatchContractError} When request or workflow identity material is not canonical. + */ +export function canonicalContinuationRequest( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): string { + const admittedRequest = admitContinuationDispatchRequest(request); + const admittedWorkflow = admitWorkflowIdentity(workflowIdentity); + return canonicalFlatObject({ ...admittedRequest, workflow_sha: admittedWorkflow.workflow_sha }); +} + +/** + * Serializes the logical continuation identity independently of transport retry metadata. + * @param request Candidate request; every member is revalidated before it gains idempotency authority. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns Deterministic canonical JSON identifying one possible external continuation effect. + * @throws {ContinuationDispatchContractError} When request or workflow identity material is not canonical. + */ +export function canonicalContinuationIdentity( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): string { + const { transport_retry_attempt: _transportRetryAttempt, ...logicalRequest } = + admitContinuationDispatchRequest(request); + const admittedWorkflow = admitWorkflowIdentity(workflowIdentity); + return canonicalFlatObject({ ...logicalRequest, workflow_sha: admittedWorkflow.workflow_sha }); +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * Computes the lowercase SHA-256 identity of one external continuation effect. + * Transport retry attempt is intentionally excluded so attempts 1 and 2 serialize through one owner. + * @param request Candidate request revalidated by the canonical serializer. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns A 64-character lowercase hexadecimal identity. + * @throws {ContinuationDispatchContractError} When canonical request material is invalid. + */ +export async function continuationDispatchIdentity( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): Promise { + return sha256Hex(canonicalContinuationIdentity(request, workflowIdentity)); +} + +/** + * Computes the lowercase SHA-256 digest of the exact canonical continuation identity. + * @param request Candidate request revalidated by the canonical serializer. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns A 64-character lowercase hexadecimal digest. + * @throws {ContinuationDispatchContractError} When canonical request material is invalid. + */ +export async function continuationRequestDigest( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): Promise { + return sha256Hex(canonicalContinuationRequest(request, workflowIdentity)); +} diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts new file mode 100644 index 000000000..589d9f95b --- /dev/null +++ b/src/continuation-dispatch/dispatch-state.ts @@ -0,0 +1,1043 @@ +import { isJsonMediaType } from "../rate-limit"; + +const STATE_VERSION = "noema.continuation-dispatch-state.v1" as const; +const STATE_RECORD_KEY = "continuation-dispatch-state"; +const INTERNAL_ENDPOINT = "https://noema-continuation-dispatch-state.internal/command"; +// This private command and retained receipt inherit the released 8 KiB public mutation envelope. +const MAX_STATE_JSON_BYTES = 8_192; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/u; +const RESERVATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const TERMINAL_OUTCOMES = new Set([ + "accepted", + "denied", + "indeterminate", +]); + +/** + * Supplies the single Durable Object namespace that owns continuation-dispatch reservations. + * The binding is mandatory because an absent serialization authority must never fall back in process. + */ +export interface ContinuationDispatchStateEnv { + readonly NOEMA_CONTINUATION_DISPATCH_STATE: DurableObjectNamespace; +} + +/** + * Names the only persisted terminal results of one continuation dispatch attempt. + * Indeterminate outcomes remain terminal because retrying could duplicate an external side effect. + */ +export type ContinuationDispatchOutcome = "accepted" | "denied" | "indeterminate"; + +/** + * Describes the bounded JSON receipt persisted after one terminal dispatch attempt. + * Task 4 supplies the released signed receipt schema while this boundary enforces its terminal outcome. + */ +export type ContinuationDispatchReceipt = Readonly> & { + readonly outcome: ContinuationDispatchOutcome; +}; + +/** + * Reports whether a caller owns a fresh reservation, observed an in-progress exact replay, + * or received immutable evidence for a previously completed exact request. + */ +export type ContinuationDispatchReservation = + | { + readonly kind: "reserved"; + readonly identity: string; + readonly digest: string; + readonly reservationId: string; + } + | { + readonly kind: "in_progress"; + readonly identity: string; + readonly digest: string; + } + | { + readonly kind: "replay"; + readonly identity: string; + readonly digest: string; + readonly outcome: ContinuationDispatchOutcome; + readonly receipt: ContinuationDispatchReceipt; + }; + +type NewReservation = Extract; + +type StoredDispatchState = { + readonly version: typeof STATE_VERSION; + readonly identity: string; + readonly request_digest: string; + readonly status: "reserved" | ContinuationDispatchOutcome; + readonly reservation_id: string; + readonly reservation_expires_at?: number; + readonly receipt?: ContinuationDispatchReceipt; +}; + +type ReserveCommand = { + readonly operation: "reserve"; + readonly identity: string; + readonly digest: string; + readonly reservation_expires_at: number; +}; + +type CommitCommand = { + readonly operation: "commit"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; + readonly receipt: ContinuationDispatchReceipt; +}; + +type AbortCommand = { + readonly operation: "abort"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; +}; + +type FinalizeCommand = { + readonly operation: "finalize"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; + readonly receipt: ContinuationDispatchReceipt; +}; + +type ReleasePreEffectCommand = { + readonly operation: "release_pre_effect"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; + readonly receipt: ContinuationDispatchReceipt; +}; + +type DispatchStateCommand = + | ReserveCommand + | CommitCommand + | AbortCommand + | FinalizeCommand + | ReleasePreEffectCommand; + +type CommandRead = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly status: 400 | 413; readonly error: "invalid_request" | "request_too_large" }; + +/** Signals that one logical continuation identity is already bound to different immutable authority. */ +export class ContinuationDispatchStateConflict extends Error { + /** Creates a stable fail-closed conflict without exposing retained receipt contents. */ + constructor(message = "continuation dispatch state conflicts with retained authority") { + super(message); + this.name = "ContinuationDispatchStateConflict"; + Object.setPrototypeOf(this, ContinuationDispatchStateConflict.prototype); + } +} + +/** Signals that the continuation state authority could not return a trustworthy decision. */ +export class ContinuationDispatchStateUnavailable extends Error { + /** Creates a stable infrastructure failure that callers must treat as non-authoritative. */ + constructor(message: string) { + super(message); + this.name = "ContinuationDispatchStateUnavailable"; + Object.setPrototypeOf(this, ContinuationDispatchStateUnavailable.prototype); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function isDigest(value: unknown): value is string { + return typeof value === "string" && DIGEST_PATTERN.test(value); +} + +function isReservationId(value: unknown): value is string { + return typeof value === "string" && RESERVATION_ID_PATTERN.test(value); +} + +function isEpochSeconds(value: unknown): value is number { + return Number.isSafeInteger(value) + && (value as number) > 0 + && (value as number) <= Math.floor(Number.MAX_SAFE_INTEGER / 1_000); +} + +function isOutcome(value: unknown): value is ContinuationDispatchOutcome { + return typeof value === "string" && TERMINAL_OUTCOMES.has(value as ContinuationDispatchOutcome); +} + +function isJsonValue(value: unknown): boolean { + try { + const pending: unknown[] = [value]; + const visited = new WeakSet(); + while (pending.length > 0) { + const current = pending.pop(); + if ( + current === null + || typeof current === "string" + || typeof current === "boolean" + || (typeof current === "number" && Number.isFinite(current)) + ) { + continue; + } + if (typeof current !== "object") return false; + if (visited.has(current)) return false; + visited.add(current); + if (Array.isArray(current)) { + pending.push(...current); + continue; + } + if (Object.getPrototypeOf(current) !== Object.prototype) return false; + pending.push(...Object.values(current as Record)); + } + return true; + } catch { + return false; + } +} + +function encodedJsonBytes(value: unknown): number | undefined { + if (!isJsonValue(value)) return undefined; + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return undefined; + } +} + +function isReceipt(value: unknown): value is ContinuationDispatchReceipt { + const encodedBytes = encodedJsonBytes(value); + return ( + isRecord(value) + && isOutcome(value.outcome) + && encodedBytes !== undefined + && encodedBytes <= MAX_STATE_JSON_BYTES + ); +} + +function isStoredState(value: unknown): value is StoredDispatchState { + if (!isRecord(value)) return false; + if ( + value.version !== STATE_VERSION + || !isDigest(value.identity) + || !isDigest(value.request_digest) + || !isReservationId(value.reservation_id) + ) { + return false; + } + if (value.status === "reserved") { + return isEpochSeconds(value.reservation_expires_at) + && hasExactKeys(value, [ + "version", + "identity", + "request_digest", + "status", + "reservation_id", + "reservation_expires_at", + ]); + } + const encodedBytes = encodedJsonBytes(value); + return ( + isOutcome(value.status) + && hasExactKeys(value, [ + "version", + "identity", + "request_digest", + "status", + "reservation_id", + "receipt", + ]) + && isReceipt(value.receipt) + && value.receipt.outcome === value.status + && encodedBytes !== undefined + && encodedBytes <= MAX_STATE_JSON_BYTES + ); +} + +function duplicateDecodedObjectKey(text: string): boolean { + const stack: Array<{ readonly kind: "array" } | { readonly kind: "object"; readonly keys: Set }> = []; + let inString = false; + let escaped = false; + let stringStart = -1; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]!; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === "\"") { + inString = false; + const scope = stack[stack.length - 1]; + if (scope?.kind !== "object") continue; + let lookahead = index + 1; + while (lookahead < text.length && /\s/u.test(text[lookahead]!)) lookahead += 1; + if (text[lookahead] !== ":") continue; + const decoded = JSON.parse(`"${text.slice(stringStart + 1, index)}"`) as string; + if (scope.keys.has(decoded)) return true; + scope.keys.add(decoded); + } + continue; + } + if (character === "\"") { + inString = true; + stringStart = index; + } else if (character === "{") { + stack.push({ kind: "object", keys: new Set() }); + } else if (character === "[") { + stack.push({ kind: "array" }); + } else if (character === "}" || character === "]") { + stack.pop(); + } + } + return false; +} + +function ignoreCancellationBestEffort(cancel: () => Promise): void { + void Promise.resolve().then(cancel).catch(() => undefined); +} + +function cancelBodyBestEffort(body: ReadableStream | null, reason: string): void { + if (body !== null) ignoreCancellationBestEffort(() => body.cancel(reason)); +} + +async function readCommand(request: Request): Promise { + const declaredLength = request.headers.get("content-length"); + if ( + declaredLength !== null + && (!/^(?:0|[1-9][0-9]*)$/u.test(declaredLength) || Number(declaredLength) > MAX_STATE_JSON_BYTES) + ) { + cancelBodyBestEffort(request.body, "continuation state request has an invalid declared length"); + return { ok: false, status: 413, error: "request_too_large" }; + } + if (request.body === null) return { ok: false, status: 400, error: "invalid_request" }; + + let reader: ReadableStreamDefaultReader; + try { + reader = request.body.getReader(); + } catch { + return { ok: false, status: 400, error: "invalid_request" }; + } + const storage = new Uint8Array(MAX_STATE_JSON_BYTES); + let totalBytes = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + if (result.value.byteLength > MAX_STATE_JSON_BYTES - totalBytes) { + ignoreCancellationBestEffort(() => reader.cancel("continuation state request exceeded its byte envelope")); + return { ok: false, status: 413, error: "request_too_large" }; + } + storage.set(result.value, totalBytes); + totalBytes += result.value.byteLength; + } + } catch { + ignoreCancellationBestEffort(() => reader.cancel("continuation state request body could not be read")); + return { ok: false, status: 400, error: "invalid_request" }; + } finally { + reader.releaseLock(); + } + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + .decode(storage.subarray(0, totalBytes)); + const value = JSON.parse(text) as unknown; + if (duplicateDecodedObjectKey(text)) { + return { ok: false, status: 400, error: "invalid_request" }; + } + return { ok: true, value }; + } catch { + return { ok: false, status: 400, error: "invalid_request" }; + } +} + +function parseCommand(value: unknown): DispatchStateCommand | undefined { + if (!isRecord(value) || typeof value.operation !== "string") return undefined; + if (value.operation === "reserve") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_expires_at"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isEpochSeconds(value.reservation_expires_at) + ) { + return undefined; + } + return { + operation: "reserve", + identity: value.identity, + digest: value.digest, + reservation_expires_at: value.reservation_expires_at, + }; + } + if (value.operation === "commit") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id", "receipt"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isReservationId(value.reservation_id) + || !isReceipt(value.receipt) + ) { + return undefined; + } + return { + operation: "commit", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + receipt: value.receipt, + }; + } + if (value.operation === "abort") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id"])) return undefined; + if (!isDigest(value.identity) || !isDigest(value.digest) || !isReservationId(value.reservation_id)) { + return undefined; + } + return { + operation: "abort", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + }; + } + if (value.operation === "finalize") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id", "receipt"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isReservationId(value.reservation_id) + || !isReceipt(value.receipt) + || value.receipt.outcome === "indeterminate" + ) { + return undefined; + } + return { + operation: "finalize", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + receipt: value.receipt, + }; + } + if (value.operation === "release_pre_effect") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id", "receipt"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isReservationId(value.reservation_id) + || !isReceipt(value.receipt) + || value.receipt.outcome !== "indeterminate" + ) { + return undefined; + } + return { + operation: "release_pre_effect", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + receipt: value.receipt, + }; + } + return undefined; +} + +function sameReceiptExact( + retained: ContinuationDispatchReceipt, + candidate: ContinuationDispatchReceipt, +): boolean { + return JSON.stringify(retained) === JSON.stringify(candidate); +} + +function sameReceiptAuthority( + retained: ContinuationDispatchReceipt, + candidate: ContinuationDispatchReceipt, +): boolean { + const omitted = new Set(["outcome", "signature", "upstream_status"]); + const stable = (receipt: ContinuationDispatchReceipt) => Object.fromEntries( + Object.entries(receipt).filter(([key]) => !omitted.has(key)), + ); + return JSON.stringify(stable(retained)) === JSON.stringify(stable(candidate)); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + }, + }); +} + +function reservationFromStored(stored: StoredDispatchState): ContinuationDispatchReservation { + if (stored.status === "reserved") { + return { kind: "in_progress", identity: stored.identity, digest: stored.request_digest }; + } + return { + kind: "replay", + identity: stored.identity, + digest: stored.request_digest, + outcome: stored.status, + receipt: stored.receipt!, + }; +} + +function isReservation(value: unknown): value is ContinuationDispatchReservation { + if (!isRecord(value) || !isDigest(value.identity) || !isDigest(value.digest)) return false; + if (value.kind === "reserved") { + return hasExactKeys(value, ["kind", "identity", "digest", "reservationId"]) + && isReservationId(value.reservationId); + } + if (value.kind === "in_progress") { + return hasExactKeys(value, ["kind", "identity", "digest"]); + } + return value.kind === "replay" + && hasExactKeys(value, ["kind", "identity", "digest", "outcome", "receipt"]) + && isOutcome(value.outcome) + && isReceipt(value.receipt) + && value.receipt.outcome === value.outcome; +} + +async function readDecision(response: Response): Promise { + if (!isJsonMediaType(response.headers.get("content-type"))) { + cancelBodyBestEffort(response.body, "continuation state response has an invalid content type"); + throw new ContinuationDispatchStateUnavailable("continuation state returned an invalid content type"); + } + const declaredLength = response.headers.get("content-length"); + if (declaredLength !== null && Number(declaredLength) > MAX_STATE_JSON_BYTES) { + cancelBodyBestEffort(response.body, "continuation state response exceeded its declared byte envelope"); + throw new ContinuationDispatchStateUnavailable("continuation state response exceeded its byte envelope"); + } + if (response.body === null) { + throw new ContinuationDispatchStateUnavailable("continuation state returned an empty decision"); + } + let reader: ReadableStreamDefaultReader; + try { + reader = response.body.getReader(); + } catch { + throw new ContinuationDispatchStateUnavailable("continuation state decision body could not be read"); + } + const storage = new Uint8Array(MAX_STATE_JSON_BYTES); + let totalBytes = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + if (result.value.byteLength > MAX_STATE_JSON_BYTES - totalBytes) { + ignoreCancellationBestEffort(() => reader.cancel("continuation state response exceeded its byte envelope")); + throw new ContinuationDispatchStateUnavailable("continuation state response exceeded its byte envelope"); + } + storage.set(result.value, totalBytes); + totalBytes += result.value.byteLength; + } + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + .decode(storage.subarray(0, totalBytes)); + if (duplicateDecodedObjectKey(text)) { + throw new ContinuationDispatchStateUnavailable("continuation state returned ambiguous JSON"); + } + return JSON.parse(text) as unknown; + } catch (error) { + if (error instanceof ContinuationDispatchStateUnavailable) throw error; + ignoreCancellationBestEffort(() => reader.cancel("continuation state response body could not be read")); + throw new ContinuationDispatchStateUnavailable("continuation state returned malformed JSON"); + } finally { + reader.releaseLock(); + } +} + +async function stateStub( + env: ContinuationDispatchStateEnv, + identity: string, +): Promise { + if (!isDigest(identity)) { + throw new ContinuationDispatchStateUnavailable("continuation identity is not a lowercase SHA-256 digest"); + } + try { + const objectId = env.NOEMA_CONTINUATION_DISPATCH_STATE.idFromName(`continuation:${identity}`); + return env.NOEMA_CONTINUATION_DISPATCH_STATE.get(objectId); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } +} + +/** + * Atomically reserves one logical continuation identity or returns its exact retained state. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param identity Lowercase SHA-256 idempotency identity used only to select one serialization point. + * @param digest Lowercase SHA-256 digest of the complete canonical request and workflow identity. + * @param reservationExpiresAtEpochSeconds Exact OIDC authorization expiry that bounds crash recovery. + * @returns A fresh reservation capability, an in-progress replay, or a completed immutable replay receipt. + * @throws {ContinuationDispatchStateConflict} When the logical identity is retained with another digest. + * @throws {ContinuationDispatchStateUnavailable} When binding, transport, parsing, or retained state is untrustworthy. + */ +export async function reserveContinuationDispatch( + env: ContinuationDispatchStateEnv, + identity: string, + digest: string, + reservationExpiresAtEpochSeconds: number, +): Promise { + if (!isDigest(digest) || !isEpochSeconds(reservationExpiresAtEpochSeconds)) { + throw new ContinuationDispatchStateUnavailable("continuation reservation request is not canonical"); + } + const stub = await stateStub(env, identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "reserve", + identity, + digest, + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 && response.status !== 201) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } + if (!isRecord(decision) || !hasExactKeys(decision, ["ok", "data"]) || decision.ok !== true || !isReservation(decision.data)) { + throw new ContinuationDispatchStateUnavailable("continuation state returned an invalid reservation"); + } + if (decision.data.identity !== identity || decision.data.digest !== digest) { + throw new ContinuationDispatchStateUnavailable("continuation state returned mismatched authority"); + } + return decision.data; +} + +/** + * Commits one terminal receipt only for the capability returned by the first successful reservation. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Fresh reservation capability that owns the only allowed dispatch attempt. + * @param receipt Bounded JSON receipt whose outcome is accepted, denied, or indeterminate. + * @returns A promise that resolves only after the exact terminal state is durably committed. + * @throws {ContinuationDispatchStateConflict} When the reservation is stale, wrong, or already terminal. + * @throws {ContinuationDispatchStateUnavailable} When receipt, transport, parsing, or storage authority is untrustworthy. + */ +export async function commitContinuationOutcome( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, + receipt: ContinuationDispatchReceipt, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + || !isReceipt(receipt) + ) { + throw new ContinuationDispatchStateUnavailable("continuation outcome is not canonical bounded JSON"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "commit", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + receipt, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if ( + response.status !== 200 + || !isRecord(decision) + || !hasExactKeys(decision, ["ok"]) + || decision.ok !== true + ) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + +/** + * Releases a fresh reservation before any external side effect has been attempted. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Unguessable fresh reservation capability returned by reserve. + * @returns A promise that resolves only after the exact reservation is removed. + * @throws {ContinuationDispatchStateConflict} When the reservation is stale, wrong, or already terminal. + * @throws {ContinuationDispatchStateUnavailable} When transport or storage authority is unavailable. + */ +export async function abortContinuationReservation( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + ) { + throw new ContinuationDispatchStateUnavailable("continuation reservation is not canonical"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "abort", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 || !isRecord(decision) || !hasExactKeys(decision, ["ok"]) || decision.ok !== true) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + +/** + * Removes committed indeterminate evidence only when the caller proves that dispatch never started. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Original reservation capability retained across the pre-effect commit. + * @param receipt Exact indeterminate receipt committed before dispatch. + * @returns A promise that resolves only after the matching terminal record is removed. + * @throws {ContinuationDispatchStateConflict} When capability, state, or exact receipt differs. + * @throws {ContinuationDispatchStateUnavailable} When transport or storage authority is unavailable. + */ +export async function releasePreEffectContinuationOutcome( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, + receipt: ContinuationDispatchReceipt, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + || !isReceipt(receipt) + || receipt.outcome !== "indeterminate" + ) { + throw new ContinuationDispatchStateUnavailable("continuation pre-effect outcome is not canonical bounded JSON"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "release_pre_effect", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + receipt, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 || !isRecord(decision) || !hasExactKeys(decision, ["ok"]) || decision.ok !== true) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + +/** + * Replaces a pre-dispatch indeterminate receipt with the exact accepted or denied result. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Original reservation capability retained across the dispatch attempt. + * @param receipt Accepted or denied receipt with identical immutable authority fields. + * @returns A promise that resolves only after the stronger terminal evidence is durable. + * @throws {ContinuationDispatchStateConflict} When state or immutable receipt authority differs. + * @throws {ContinuationDispatchStateUnavailable} When transport or storage authority is unavailable. + */ +export async function finalizeContinuationOutcome( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, + receipt: ContinuationDispatchReceipt, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + || !isReceipt(receipt) + || receipt.outcome === "indeterminate" + ) { + throw new ContinuationDispatchStateUnavailable("continuation final outcome is not canonical bounded JSON"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "finalize", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + receipt, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 || !isRecord(decision) || !hasExactKeys(decision, ["ok"]) || decision.ok !== true) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + +/** + * Cloudflare Durable Object that serializes one logical continuation identity through reservation and terminal receipt. + * Its closed private protocol preserves indeterminate outcomes so concurrent or retried callers never duplicate dispatch. + */ +export class NoemaContinuationDispatchState { + private readonly storage: DurableObjectStorage; + private readonly objectName: string | undefined; + + /** Creates one identity-scoped state authority from Cloudflare's Durable Object state. */ + constructor(state: DurableObjectState) { + this.storage = state.storage; + this.objectName = state.id.name; + } + + /** + * Applies one bounded reserve or commit command at the fixed private endpoint. + * @param request Internal JSON command containing no bearer, App token, or private-key material. + * @returns A closed JSON decision; corrupt state and storage faults fail closed without replacing evidence. + */ + async fetch(request: Request): Promise { + if (request.method !== "POST" || request.url !== INTERNAL_ENDPOINT) { + cancelBodyBestEffort(request.body, "continuation state request used the wrong endpoint or method"); + return jsonResponse({ ok: false, error: "not_found" }, 404); + } + if (!isJsonMediaType(request.headers.get("content-type"))) { + cancelBodyBestEffort(request.body, "continuation state request requires JSON"); + return jsonResponse({ ok: false, error: "content_type_required" }, 415); + } + const read = await readCommand(request); + if (!read.ok) return jsonResponse({ ok: false, error: read.error }, read.status); + const command = parseCommand(read.value); + if (command === undefined || this.objectName !== `continuation:${command.identity}`) { + return jsonResponse({ ok: false, error: "invalid_request" }, 400); + } + + try { + if (command.operation === "reserve") { + const decision = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (stored !== undefined) { + if (!isStoredState(stored)) return { kind: "invalid" } as const; + if ( + stored.status === "reserved" + && stored.reservation_expires_at! * 1_000 <= Date.now() + ) { + await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); + } else { + if (stored.identity !== command.identity || stored.request_digest !== command.digest) { + return { kind: "conflict" } as const; + } + return { kind: "existing", reservation: reservationFromStored(stored) } as const; + } + } + if (command.reservation_expires_at * 1_000 <= Date.now()) { + return { kind: "expired" } as const; + } + const reservationId = crypto.randomUUID(); + const next: StoredDispatchState = { + version: STATE_VERSION, + identity: command.identity, + request_digest: command.digest, + status: "reserved", + reservation_id: reservationId, + reservation_expires_at: command.reservation_expires_at, + }; + await transaction.put(STATE_RECORD_KEY, next); + await transaction.setAlarm(command.reservation_expires_at * 1_000); + return { + kind: "created", + reservation: { + kind: "reserved", + identity: command.identity, + digest: command.digest, + reservationId, + } satisfies NewReservation, + } as const; + }); + if (decision.kind === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (decision.kind === "conflict" || decision.kind === "expired") { + return jsonResponse({ ok: false, error: "conflict" }, 409); + } + return jsonResponse({ ok: true, data: decision.reservation }, decision.kind === "created" ? 201 : 200); + } + + if (command.operation === "abort") { + const aborted = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "reserved" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + ) { + return "conflict"; + } + await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); + return "aborted"; + }); + if (aborted === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (aborted === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } + + if (command.operation === "finalize") { + const finalized = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "indeterminate" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + || stored.receipt === undefined + || !sameReceiptAuthority(stored.receipt, command.receipt) + ) { + return "conflict"; + } + const next: StoredDispatchState = { + ...stored, + status: command.receipt.outcome, + receipt: command.receipt, + }; + if (new TextEncoder().encode(JSON.stringify(next)).byteLength > MAX_STATE_JSON_BYTES) return "invalid"; + await transaction.put(STATE_RECORD_KEY, next); + return "finalized"; + }); + if (finalized === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (finalized === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } + + if (command.operation === "release_pre_effect") { + const released = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "indeterminate" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + || stored.receipt === undefined + || !sameReceiptExact(stored.receipt, command.receipt) + ) { + return "conflict"; + } + await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); + return "released"; + }); + if (released === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (released === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } + + const committed = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "reserved" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + ) { + return "conflict"; + } + if (stored.reservation_expires_at! * 1_000 <= Date.now()) { + await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); + return "conflict"; + } + const next: StoredDispatchState = { + version: stored.version, + identity: stored.identity, + request_digest: stored.request_digest, + status: command.receipt.outcome, + reservation_id: stored.reservation_id, + receipt: command.receipt, + }; + if (new TextEncoder().encode(JSON.stringify(next)).byteLength > MAX_STATE_JSON_BYTES) return "invalid"; + await transaction.put(STATE_RECORD_KEY, next); + await transaction.deleteAlarm(); + return "committed"; + }); + if (committed === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (committed === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } catch { + return jsonResponse({ ok: false, error: "storage_unavailable" }, 503); + } + } + + /** + * Recovers only an expired pre-dispatch reservation at its retained OIDC authorization boundary. + * Terminal evidence is immutable, corrupt state fails closed, and a premature alarm is rescheduled exactly. + * @returns A promise that resolves after cleanup or exact rescheduling is durably serialized. + */ + async alarm(): Promise { + await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (stored === undefined) { + await transaction.deleteAlarm(); + return; + } + if (!isStoredState(stored)) { + throw new Error("continuation dispatch alarm found invalid retained state"); + } + if (stored.status !== "reserved") { + await transaction.deleteAlarm(); + return; + } + const expiresAtMilliseconds = stored.reservation_expires_at! * 1_000; + if (expiresAtMilliseconds <= Date.now()) { + await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); + return; + } + await transaction.setAlarm(expiresAtMilliseconds); + }); + } +} diff --git a/src/continuation-dispatch/github-adapter.ts b/src/continuation-dispatch/github-adapter.ts new file mode 100644 index 000000000..1861744fe --- /dev/null +++ b/src/continuation-dispatch/github-adapter.ts @@ -0,0 +1,292 @@ +import { + ApiError, + createGitHubInstallationToken, + githubApiRequest, + githubJson, + type GitHubAppEnv, + type JwtPayload, +} from "../index"; +import { + dispatchMapping, + type ContinuationDispatchRequest, +} from "./contract"; + +const CENTRAL_REPOSITORY = "ContextualWisdomLab/.github"; + +/** Runtime bindings needed to read a source PR and emit one central continuation. */ +export interface ContinuationGitHubAdapterEnv extends GitHubAppEnv { + CONTINUATION_DISPATCH_GITHUB_APP_ID: string; + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM: string; + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: string; +} + +/** + * Stable credential-free adapter failure classes consumed by the public broker + * handler to distinguish authorization, live-state drift, and upstream outages. + */ +export type ContinuationGitHubFailureClassification = + | "identity_denied" + | "live_state_stale" + | "upstream_unavailable"; + +/** + * A credential-free GitHub adapter failure retaining only a stable classification + * and optional bounded upstream status for handler and persisted-outcome decisions. + */ +export class ContinuationGitHubAdapterError extends Error { + /** Creates a bounded error without retaining request or credential material. */ + constructor( + public readonly classification: ContinuationGitHubFailureClassification, + public readonly upstreamStatus?: number, + ) { + super("Continuation GitHub admission failed"); + this.name = "ContinuationGitHubAdapterError"; + } +} + +/** + * Signals that exact installation-token expiry was observed before the fixed + * central GitHub dispatch request could begin, preserving safe retry authority. + */ +export class ContinuationDispatchNotStartedError extends ContinuationGitHubAdapterError { + /** Creates a credential-free pre-effect failure. */ + constructor() { + super("upstream_unavailable"); + this.name = "ContinuationDispatchNotStartedError"; + } +} + +/** + * Exact live pull-request identity retained after source-state verification, + * excluding GitHub response metadata that is not part of continuation authority. + */ +export interface VerifiedLivePullRequest { + readonly repository: string; + readonly pullRequestNumber: number; + readonly headSha: string; + readonly baseSha: string; + readonly baseRef: string; +} + +/** + * Credential-free terminal result of the one allowed central repository-dispatch + * attempt, suitable for exactly-once persistence before a receipt is returned. + */ +export type CentralContinuationDispatchResult = { + readonly outcome: "accepted" | "denied" | "indeterminate"; + readonly upstreamStatus?: number; + readonly eventType: "noema-review" | "strix-scan"; +}; + +/** Opaque central-only dispatch capability prepared before any indeterminate receipt is committed. */ +export interface PreparedCentralContinuationDispatch { + /** Fails closed when the privately retained installation token has reached its exact expiry. */ + assertFresh(): void; + /** Sends one closed continuation request without exposing the captured installation token. */ + send(request: ContinuationDispatchRequest): Promise; +} + +type GithubRepository = { readonly full_name?: unknown }; +type GithubPullRequestSide = { + readonly sha?: unknown; + readonly ref?: unknown; + readonly repo?: GithubRepository | null; +}; +type GithubPullRequest = { + readonly state?: unknown; + readonly draft?: unknown; + readonly head?: GithubPullRequestSide | null; + readonly base?: GithubPullRequestSide | null; +}; + +/** + * Builds the only repository-dispatch body admitted by the continuation broker. + * @param request Closed continuation request whose allowlisted action selects the fixed event type. + * @returns The exact JSON bytes used by both GitHub transport and signed payload-digest evidence. + */ +export function centralContinuationDispatchBody(request: ContinuationDispatchRequest): string { + return JSON.stringify({ + event_type: dispatchMapping(request.dispatch_action).eventType, + client_payload: { + source_repository: request.source_repository, + pull_request_number: request.pull_request_number, + expected_head_sha: request.expected_head_sha, + expected_base_sha: request.expected_base_sha, + expected_base_ref: request.expected_base_ref, + transport_retry_attempt: request.transport_retry_attempt, + }, + }); +} + +function sourceReadFailure(error: unknown): ContinuationGitHubAdapterError { + if (error instanceof ApiError) { + if (error.upstreamStatus === 403) { + return new ContinuationGitHubAdapterError("identity_denied", 403); + } + if (error.upstreamStatus === 404 || error.upstreamStatus === 422) { + return new ContinuationGitHubAdapterError("live_state_stale", error.upstreamStatus); + } + return new ContinuationGitHubAdapterError("upstream_unavailable", error.upstreamStatus); + } + return new ContinuationGitHubAdapterError("upstream_unavailable"); +} + +function centralCredentialFailure(error: unknown): ContinuationGitHubAdapterError { + return new ContinuationGitHubAdapterError( + "upstream_unavailable", + error instanceof ApiError ? error.upstreamStatus : undefined, + ); +} + +function centralAppEnv(env: ContinuationGitHubAdapterEnv): GitHubAppEnv { + return { + ...env, + GITHUB_APP_ID: env.CONTINUATION_DISPATCH_GITHUB_APP_ID, + GITHUB_APP_PRIVATE_KEY_PEM: env.CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM, + GITHUB_APP_INSTALLATION_ID: env.CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID, + }; +} + +/** + * Reads the source PR through a read-only installation token and admits only the + * exact open, non-draft, non-fork state bound into the verified request. + * @param claims Cryptographically verified GitHub Actions OIDC claims. + * @param request Closed and canonical continuation dispatch request. + * @param env Separate source-read and central-dispatch GitHub App bindings. + * @returns The exact credential-free live PR identity admitted for dispatch. + * @throws {ContinuationGitHubAdapterError} When identity, live state, or GitHub access fails closed. + */ +export async function readAndVerifyLivePullRequest( + claims: JwtPayload, + request: ContinuationDispatchRequest, + env: ContinuationGitHubAdapterEnv, +): Promise { + if (claims.repository !== request.source_repository) { + throw new ContinuationGitHubAdapterError("identity_denied"); + } + + let installationToken: string; + try { + const installation = await createGitHubInstallationToken( + request.source_repository, + env, + { pull_requests: "read" }, + ); + installationToken = installation.token; + } catch (error) { + throw centralCredentialFailure(error); + } + + let pullRequest: GithubPullRequest; + try { + pullRequest = await githubJson( + `/repos/${request.source_repository}/pulls/${request.pull_request_number}`, + { + method: "GET", + headers: { authorization: `Bearer ${installationToken}` }, + }, + env, + 200, + ) as GithubPullRequest; + } catch (error) { + throw sourceReadFailure(error); + } + + if ( + pullRequest.state !== "open" + || pullRequest.draft !== false + || pullRequest.head?.repo?.full_name !== request.source_repository + || pullRequest.base?.repo?.full_name !== request.source_repository + || pullRequest.head.sha !== request.expected_head_sha + || pullRequest.base.sha !== request.expected_base_sha + || pullRequest.base.ref !== request.expected_base_ref + ) { + throw new ContinuationGitHubAdapterError("live_state_stale"); + } + + return Object.freeze({ + repository: request.source_repository, + pullRequestNumber: request.pull_request_number, + headSha: request.expected_head_sha, + baseSha: request.expected_base_sha, + baseRef: request.expected_base_ref, + }); +} + +/** + * Mints a distinct central-only installation token and uses it solely for the + * fixed `.github` repository-dispatch endpoint and closed event payload. + * @param env Separate source-read and central-dispatch GitHub App bindings. + * @returns An opaque prepared sender that retains the short-lived token without exposing it. + * @throws {ContinuationGitHubAdapterError} When central credential minting fails before dispatch. + */ +export async function prepareCentralContinuation( + env: ContinuationGitHubAdapterEnv, +): Promise { + let installationToken: string; + let installationTokenExpiresAtMilliseconds: number; + try { + const installation = await createGitHubInstallationToken( + CENTRAL_REPOSITORY, + centralAppEnv(env), + { contents: "write" }, + ); + installationToken = installation.token; + installationTokenExpiresAtMilliseconds = Date.parse(installation.expires_at); + } catch (error) { + throw centralCredentialFailure(error); + } + + const assertFresh = (): void => { + if (Date.now() >= installationTokenExpiresAtMilliseconds) { + throw new ContinuationDispatchNotStartedError(); + } + }; + + return Object.freeze({ + assertFresh, + async send(request: ContinuationDispatchRequest): Promise { + assertFresh(); + const eventType = dispatchMapping(request.dispatch_action).eventType; + let response: Response; + try { + response = await githubApiRequest( + "/repos/ContextualWisdomLab/.github/dispatches", + { + method: "POST", + headers: { + authorization: `Bearer ${installationToken}`, + "content-type": "application/json", + }, + body: centralContinuationDispatchBody(request), + }, + env, + ); + } catch { + return Object.freeze({ outcome: "indeterminate", eventType }); + } + + if (response.status === 204) { + return Object.freeze({ outcome: "accepted", upstreamStatus: 204, eventType }); + } + if (response.status === 403 || response.status === 404 || response.status === 422) { + return Object.freeze({ outcome: "denied", upstreamStatus: response.status, eventType }); + } + return Object.freeze({ outcome: "indeterminate", upstreamStatus: response.status, eventType }); + }, + }); +} + +/** + * Prepares the central credential and immediately sends one fixed dispatch for direct adapter callers. + * @param request Closed continuation request whose action maps to one released central event. + * @param env Separate source-read and central-dispatch GitHub App bindings. + * @returns A terminal accepted, denied, or indeterminate dispatch result without credentials. + * @throws {ContinuationGitHubAdapterError} When central credential preparation fails before dispatch. + */ +export async function dispatchCentralContinuation( + request: ContinuationDispatchRequest, + env: ContinuationGitHubAdapterEnv, +): Promise { + return (await prepareCentralContinuation(env)).send(request); +} diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts new file mode 100644 index 000000000..2386cd322 --- /dev/null +++ b/src/continuation-dispatch/handler.ts @@ -0,0 +1,537 @@ +import { parseExactBearerToken } from "../bearer-authorization"; +import type { JwtPayload } from "../index"; +import { errorHints, type ErrorCode } from "../error-codes"; +import { + continuationDispatchIdentity, + continuationRequestDigest, + dispatchMapping, + parseContinuationDispatchRequest, + type ContinuationDispatchRequest, +} from "./contract"; +import { + abortContinuationReservation, + commitContinuationOutcome, + finalizeContinuationOutcome, + releasePreEffectContinuationOutcome, + ContinuationDispatchStateConflict, + reserveContinuationDispatch, + type ContinuationDispatchReceipt, + type ContinuationDispatchReservation, + type ContinuationDispatchStateEnv, +} from "./dispatch-state"; +import { + centralContinuationDispatchBody, + ContinuationDispatchNotStartedError, + ContinuationGitHubAdapterError, + prepareCentralContinuation, + readAndVerifyLivePullRequest, + type CentralContinuationDispatchResult, + type PreparedCentralContinuationDispatch, + type ContinuationGitHubAdapterEnv, + type VerifiedLivePullRequest, +} from "./github-adapter"; +import { + signContinuationReceipt, + type ContinuationReceiptSigningEnv, + type SignedContinuationReceipt, +} from "./receipt"; + +const MAX_BODY_BYTES = 8_192; +const BODY_READ_DEADLINE_MS = 10_000; +const JSON_MEDIA_TYPE = /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/iu; + +/** Runtime authority required by the public continuation-dispatch handler, including state, GitHub adapters, receipt signing, and exact workflow revision. */ +export interface ContinuationDispatchHandlerEnv + extends ContinuationDispatchStateEnv, + ContinuationGitHubAdapterEnv, + ContinuationReceiptSigningEnv { + readonly ALLOWED_WORKFLOW_SHA?: string; +} + +/** Injected cryptographic and external boundaries used by the production handler and focused tests. */ +export interface ContinuationDispatchHandlerDependencies { + verifyOidc(token: string, env: ContinuationDispatchHandlerEnv): Promise; + claimOidc(claims: JwtPayload, env: ContinuationDispatchHandlerEnv): Promise; + readLivePullRequest( + claims: JwtPayload, + request: ContinuationDispatchRequest, + env: ContinuationDispatchHandlerEnv, + ): Promise; + reserve( + env: ContinuationDispatchStateEnv, + identity: string, + digest: string, + reservationExpiresAtEpochSeconds: number, + ): Promise; + prepareDispatch( + env: ContinuationGitHubAdapterEnv, + ): Promise; + dispatch( + request: ContinuationDispatchRequest, + prepared: PreparedCentralContinuationDispatch, + ): Promise; + commit: typeof commitContinuationOutcome; + abort: typeof abortContinuationReservation; + releasePreEffect: typeof releasePreEffectContinuationOutcome; + finalize: typeof finalizeContinuationOutcome; +} + +/** Default owner implementations for every boundary except the existing OIDC verifier/replay claimant. */ +export const continuationDispatchDependencies = { + readLivePullRequest: readAndVerifyLivePullRequest, + reserve: reserveContinuationDispatch, + prepareDispatch: prepareCentralContinuation, + dispatch: (request: ContinuationDispatchRequest, prepared: PreparedCentralContinuationDispatch) => ( + prepared.send(request) + ), + commit: commitContinuationOutcome, + abort: abortContinuationReservation, + releasePreEffect: releasePreEffectContinuationOutcome, + finalize: finalizeContinuationOutcome, +}; + +type HandlerErrorCode = Extract< + ErrorCode, + | "ERR_DISPATCH_REQUEST_INVALID" + | "ERR_DISPATCH_IDENTITY_DENIED" + | "ERR_DISPATCH_LIVE_STATE_STALE" + | "ERR_DISPATCH_REPLAY_CONFLICT" + | "ERR_GITHUB_DISPATCH_AUTHORIZATION" + | "ERR_GITHUB_DISPATCH_UPSTREAM" +>; + +function responseHeaders(traceId: string, startedAt: number): Headers { + return new Headers({ + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "x-trace-id": traceId, + "x-latency-ms": String(Math.round(performance.now() - startedAt)), + }); +} + +function errorResponse( + code: HandlerErrorCode, + status: number, + message: string, + traceId: string, + startedAt: number, + details?: Record, +): Response { + const headers = responseHeaders(traceId, startedAt); + if (status === 405) headers.set("allow", "POST"); + if (status === 401) { + headers.set("www-authenticate", 'Bearer realm="noema", error="invalid_token"'); + } + return new Response(JSON.stringify({ + ok: false, + error_code: code, + message, + details: { hint: errorHints[code], ...(details ?? {}) }, + trace_id: traceId, + }), { status, headers }); +} + +function successResponse( + receipt: unknown, + traceId: string, + startedAt: number, + replay = false, +): Response { + const headers = responseHeaders(traceId, startedAt); + headers.set("x-oidc-replay-protection", "verified-before-dispatch"); + if (replay) headers.set("x-continuation-replay", "exact"); + return new Response(JSON.stringify({ ok: true, data: { receipt }, trace_id: traceId }), { + status: 200, + headers, + }); +} + +function replayResponse( + reservation: Extract, + traceId: string, + startedAt: number, +): Response { + if (reservation.outcome === "accepted") { + return successResponse(reservation.receipt, traceId, startedAt, true); + } + const response = errorResponse( + reservation.outcome === "denied" + ? "ERR_GITHUB_DISPATCH_AUTHORIZATION" + : "ERR_GITHUB_DISPATCH_UPSTREAM", + reservation.outcome === "denied" ? 502 : 503, + reservation.outcome === "denied" + ? "GitHub rejected the fixed continuation dispatch" + : "GitHub continuation dispatch outcome is indeterminate", + traceId, + startedAt, + { receipt: reservation.receipt }, + ); + response.headers.set("x-continuation-replay", "exact"); + return response; +} + +function cancelBestEffort(body: ReadableStream | null, reason: string): void { + if (body === null) return; + try { + void body.cancel(reason).catch(() => undefined); + } catch { + // Rejection is already authoritative; cleanup must not replace it. + } +} + +function isUnavailableBoundary(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const candidate = error as { status?: unknown }; + return typeof candidate.status === "number" + && Number.isInteger(candidate.status) + && candidate.status >= 500 + && candidate.status <= 599; +} + +async function readBoundedBody(request: Request): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!JSON_MEDIA_TYPE.test(contentType)) { + cancelBestEffort(request.body, "continuation dispatch requires application/json"); + return new Response(null, { status: 415 }); + } + const length = request.headers.get("content-length"); + if (length !== null && (!/^(?:0|[1-9][0-9]*)$/u.test(length) || Number(length) > MAX_BODY_BYTES)) { + cancelBestEffort(request.body, "continuation dispatch body exceeds its byte envelope"); + return new Response(null, { status: 413 }); + } + if (request.body === null) return ""; + let reader: ReadableStreamDefaultReader; + try { + reader = request.body.getReader(); + } catch { + return new Response(null, { status: 400 }); + } + const bytes = new Uint8Array(MAX_BODY_BYTES); + let used = 0; + let timeoutHandle!: ReturnType; + const cancelReaderBestEffort = (reason: string) => { + try { + void reader.cancel(reason).catch(() => undefined); + } catch { + // Cleanup must not replace an established body decision. + } + }; + const deadline = new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + cancelReaderBestEffort("continuation dispatch body read deadline exceeded"); + resolve(new Response(null, { status: 408 })); + }, BODY_READ_DEADLINE_MS); + }); + const readBody = (async (): Promise => { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + if (chunk.value.byteLength > MAX_BODY_BYTES - used) { + cancelReaderBestEffort("continuation dispatch body exceeds its byte envelope"); + return new Response(null, { status: 413 }); + } + bytes.set(chunk.value, used); + used += chunk.value.byteLength; + } + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, used)); + })(); + try { + return await Promise.race([readBody, deadline]); + } catch { + return new Response(null, { status: 400 }); + } finally { + clearTimeout(timeoutHandle); + try { + reader.releaseLock(); + } catch { + // A pending cancellation can retain the lock briefly without changing the response. + } + } +} + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function emittedPayloadDigest(request: ContinuationDispatchRequest): Promise { + return sha256(centralContinuationDispatchBody(request)); +} + +function workflowIdentity(claims: JwtPayload, env: ContinuationDispatchHandlerEnv): { + workflowRef: string; + workflowSha: string; + iat: number; + exp: number; +} | undefined { + const workflowRef = claims.job_workflow_ref ?? claims.workflow_ref; + const workflowSha = claims.job_workflow_ref ? claims.job_workflow_sha : claims.workflow_sha; + if ( + typeof workflowRef !== "string" + || typeof workflowSha !== "string" + || workflowSha !== env.ALLOWED_WORKFLOW_SHA + || !Number.isSafeInteger(claims.iat) + || !Number.isSafeInteger(claims.exp) + ) { + return undefined; + } + return { workflowRef, workflowSha, iat: claims.iat!, exp: claims.exp! }; +} + +/** + * Executes one versioned, credential-free continuation dispatch from bounded request through signed evidence. + * Exact replays return retained evidence and all uncertain external outcomes become terminal before responding. + * @param request Bounded public HTTP request whose method, media type, bearer, and closed JSON body are admitted here. + * @param env Typed Worker bindings for exact workflow trust, GitHub adapters, durable state, and receipt signing. + * @param traceId Non-secret canonical request correlation identifier. + * @param dependencies Injected verification, state, and dispatch boundaries used by production and tests. + * @returns A standard no-store JSON response containing either one signed receipt or a stable dispatch error. + */ +export async function handleContinuationDispatch( + request: Request, + env: ContinuationDispatchHandlerEnv, + traceId: string, + dependencies: ContinuationDispatchHandlerDependencies, +): Promise { + const startedAt = performance.now(); + if (request.method !== "POST") { + cancelBestEffort(request.body, "continuation dispatch method is not allowed"); + return errorResponse("ERR_DISPATCH_REQUEST_INVALID", 405, "Method not allowed", traceId, startedAt); + } + + const body = await readBoundedBody(request); + if (body instanceof Response) { + const status = body.status; + return errorResponse( + "ERR_DISPATCH_REQUEST_INVALID", + status, + status === 415 + ? "Continuation dispatch requires application/json" + : status === 408 + ? "Continuation dispatch body read deadline exceeded" + : "Continuation dispatch body is invalid", + traceId, + startedAt, + ); + } + + let parsed: ContinuationDispatchRequest; + try { + parsed = parseContinuationDispatchRequest(body); + } catch { + return errorResponse("ERR_DISPATCH_REQUEST_INVALID", 400, "Continuation dispatch request is invalid", traceId, startedAt); + } + + const bearer = parseExactBearerToken(request.headers.get("authorization") ?? ""); + if (bearer === undefined) { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Missing or invalid bearer token", traceId, startedAt); + } + + let claims: JwtPayload; + try { + claims = await dependencies.verifyOidc(bearer, env); + } catch (error) { + if (isUnavailableBoundary(error)) { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "OIDC verification unavailable", traceId, startedAt); + } + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Continuation identity was denied", traceId, startedAt); + } + const workflow = workflowIdentity(claims, env); + if (workflow === undefined || claims.repository !== parsed.source_repository) { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 403, "Continuation identity was denied", traceId, startedAt); + } + + try { + if (!await dependencies.claimOidc(claims, env)) { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Continuation identity was already used or unavailable", traceId, startedAt); + } + } catch (error) { + if (isUnavailableBoundary(error)) { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "OIDC replay protection unavailable", traceId, startedAt); + } + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Continuation identity was already used or unavailable", traceId, startedAt); + } + + const digest = await continuationRequestDigest(parsed, { workflow_sha: workflow.workflowSha }); + const identity = await continuationDispatchIdentity(parsed, { workflow_sha: workflow.workflowSha }); + let reservation: ContinuationDispatchReservation; + try { + reservation = await dependencies.reserve(env, identity, digest, workflow.exp); + } catch (error) { + if (error instanceof ContinuationDispatchStateConflict) { + return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation replay conflicts with retained authority", traceId, startedAt); + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation state unavailable", traceId, startedAt); + } + if (reservation.kind === "in_progress") { + return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation dispatch is already in progress", traceId, startedAt); + } + if (reservation.kind === "replay") { + return replayResponse(reservation, traceId, startedAt); + } + + try { + await dependencies.readLivePullRequest(claims, parsed, env); + } catch (error) { + try { + await dependencies.abort(env, reservation); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation reservation could not be released", traceId, startedAt); + } + if (error instanceof ContinuationGitHubAdapterError) { + if (error.classification === "identity_denied") { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 403, "Continuation identity was denied", traceId, startedAt); + } + if (error.classification === "live_state_stale") { + return errorResponse("ERR_DISPATCH_LIVE_STATE_STALE", 409, "Pull request state no longer matches", traceId, startedAt); + } + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "GitHub live-state verification unavailable", traceId, startedAt); + } + + const receiptId = crypto.randomUUID(); + const payloadDigest = await emittedPayloadDigest(parsed); + let indeterminateReceipt: SignedContinuationReceipt; + try { + indeterminateReceipt = await signContinuationReceipt({ + request: parsed, + requestDigest: digest, + idempotencyIdentity: identity, + workflowRef: workflow.workflowRef, + workflowSha: workflow.workflowSha, + emittedPayloadDigest: payloadDigest, + dispatchResult: { + outcome: "indeterminate", + eventType: dispatchMapping(parsed.dispatch_action).eventType, + }, + oidcLifetime: { iat: workflow.iat, exp: workflow.exp }, + receiptId, + traceId, + }, env); + } catch { + try { + await dependencies.abort(env, reservation); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation reservation could not be released", traceId, startedAt); + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be signed", traceId, startedAt); + } + + let preparedDispatch: PreparedCentralContinuationDispatch; + try { + preparedDispatch = await dependencies.prepareDispatch(env); + preparedDispatch.assertFresh(); + } catch { + try { + await dependencies.abort(env, reservation); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation reservation could not be released", traceId, startedAt); + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Central dispatch credential unavailable", traceId, startedAt); + } + + try { + await dependencies.commit( + env, + reservation, + indeterminateReceipt as unknown as ContinuationDispatchReceipt, + ); + } catch { + try { + await dependencies.abort(env, reservation); + } catch { + // A concurrent durable commit remains authoritative and must not be replaced. + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be committed", traceId, startedAt); + } + + let dispatchResult: CentralContinuationDispatchResult; + try { + dispatchResult = await dependencies.dispatch(parsed, preparedDispatch); + } catch (error) { + if (error instanceof ContinuationDispatchNotStartedError) { + try { + await dependencies.releasePreEffect( + env, + reservation, + indeterminateReceipt as unknown as ContinuationDispatchReceipt, + ); + } catch { + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "Pre-effect continuation evidence could not be released", + traceId, + startedAt, + ); + } + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "Central dispatch did not start", + traceId, + startedAt, + ); + } + dispatchResult = { outcome: "indeterminate", eventType: dispatchMapping(parsed.dispatch_action).eventType }; + } + + if (!(["accepted", "denied", "indeterminate"] as readonly unknown[]).includes(dispatchResult.outcome)) { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Unexpected continuation outcome", traceId, startedAt); + } + + if (dispatchResult.outcome === "indeterminate") { + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "GitHub continuation dispatch outcome is indeterminate", + traceId, + startedAt, + { receipt: indeterminateReceipt }, + ); + } + + let receipt: SignedContinuationReceipt; + try { + receipt = await signContinuationReceipt({ + request: parsed, + requestDigest: digest, + idempotencyIdentity: identity, + workflowRef: workflow.workflowRef, + workflowSha: workflow.workflowSha, + emittedPayloadDigest: payloadDigest, + dispatchResult, + oidcLifetime: { iat: workflow.iat, exp: workflow.exp }, + receiptId, + traceId, + }, env); + await dependencies.finalize( + env, + reservation, + receipt as unknown as ContinuationDispatchReceipt, + ); + } catch { + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "Continuation final evidence could not be committed", + traceId, + startedAt, + { receipt: indeterminateReceipt }, + ); + } + + if (dispatchResult.outcome === "accepted") { + return successResponse(receipt, traceId, startedAt); + } + return errorResponse( + "ERR_GITHUB_DISPATCH_AUTHORIZATION", + 502, + "GitHub rejected the fixed continuation dispatch", + traceId, + startedAt, + { receipt }, + ); +} diff --git a/src/continuation-dispatch/receipt.ts b/src/continuation-dispatch/receipt.ts new file mode 100644 index 000000000..51f779af4 --- /dev/null +++ b/src/continuation-dispatch/receipt.ts @@ -0,0 +1,422 @@ +import type { JwtPayload } from "../index"; +import { + canonicalContinuationRequest, + continuationRequestDigest, + dispatchMapping, + type ContinuationDispatchRequest, +} from "./contract"; +import type { CentralContinuationDispatchResult } from "./github-adapter"; + +const RECEIPT_VERSION = "noema.continuation-dispatch-receipt.v1" as const; +const SIGNATURE_ALGORITHM = "Ed25519" as const; +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const KEY_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u; +const TRACE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u; +const WORKFLOW_REF_PATTERN = /^[^\u0000-\u0020\u007f]+$/u; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; +const FIELD_KEYS = [ + "dispatchResult", + "emittedPayloadDigest", + "idempotencyIdentity", + "oidcLifetime", + "receiptId", + "request", + "requestDigest", + "traceId", + "workflowRef", + "workflowSha", +] as const; +const RECEIPT_KEYS = [ + "algorithm", + "central_repository", + "contract_version", + "emitted_payload_digest", + "event_type", + "expires_at", + "idempotency_identity", + "issued_at", + "key_id", + "outcome", + "pull_request_number", + "receipt_id", + "receipt_version", + "request_digest", + "signature", + "source_base_ref", + "source_base_sha", + "source_head_sha", + "source_repository", + "trace_id", + "transport_retry_attempt", + "upstream_status", + "workflow_ref", + "workflow_sha", +] as const; + +/** + * Dedicated Worker secret bindings for continuation-receipt signing. + * The private key never crosses the signing boundary, while the key id names the public verification key. + */ +export interface ContinuationReceiptSigningEnv { + readonly CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: string; + readonly CONTINUATION_RECEIPT_SIGNING_KEY_ID: string; +} + +/** Exact admitted values from which one credential-free continuation receipt is built. */ +export interface ContinuationReceiptFields { + readonly request: ContinuationDispatchRequest; + readonly requestDigest: string; + readonly idempotencyIdentity: string; + readonly workflowRef: string; + readonly workflowSha: string; + readonly emittedPayloadDigest: string; + readonly dispatchResult: CentralContinuationDispatchResult; + readonly oidcLifetime: Pick; + readonly receiptId: string; + readonly traceId: string; +} + +/** + * Signed v1 evidence for one exact, terminal continuation dispatch attempt. + * The detached signature authenticates every credential-free field but grants no later dispatch authority. + */ +export interface SignedContinuationReceipt { + readonly algorithm: typeof SIGNATURE_ALGORITHM; + readonly central_repository: "ContextualWisdomLab/.github"; + readonly contract_version: "noema.continuation-dispatch.v1"; + readonly emitted_payload_digest: string; + readonly event_type: "noema-review" | "strix-scan"; + readonly expires_at: number; + readonly idempotency_identity: string; + readonly issued_at: number; + readonly key_id: string; + readonly outcome: "accepted" | "denied" | "indeterminate"; + readonly pull_request_number: number; + readonly receipt_id: string; + readonly receipt_version: typeof RECEIPT_VERSION; + readonly request_digest: string; + readonly source_base_ref: string; + readonly source_base_sha: string; + readonly source_head_sha: string; + readonly source_repository: string; + readonly trace_id: string; + readonly transport_retry_attempt: 1 | 2; + readonly upstream_status: number | null; + readonly workflow_ref: string; + readonly workflow_sha: string; + readonly signature: string; +} + +type UnsignedContinuationReceipt = Omit; + +/** Fail-closed receipt construction error that never retains signing material. */ +export class ContinuationReceiptError extends Error { + /** Creates one stable receipt error without embedding candidate field values. */ + constructor(message: string) { + super(message); + this.name = "ContinuationReceiptError"; + Object.setPrototypeOf(this, ContinuationReceiptError.prototype); + } +} + +function rejectReceipt(message: string): never { + throw new ContinuationReceiptError(message); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function containsUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = value.charCodeAt(index + 1); + if (index + 1 >= value.length || nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) return true; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +} + +function canonicalJson(value: Readonly>): string { + return `{${Object.keys(value).sort().map( + (key) => `${JSON.stringify(key)}:${JSON.stringify(value[key])}`, + ).join(",")}}`; +} + +function pemDer(value: unknown, label: "PRIVATE KEY" | "PUBLIC KEY"): Uint8Array | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.replace(/\r\n/gu, "\n"); + const match = new RegExp( + `^-----BEGIN ${label}-----\\n([A-Za-z0-9+/=\\n]+)\\n-----END ${label}-----\\n?$`, + "u", + ).exec(normalized); + if (match === null) return undefined; + const encoded = match[1]!.replace(/\n/gu, ""); + try { + const binary = atob(encoded); + if (btoa(binary) !== encoded) return undefined; + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return undefined; + } +} + +function encodeBase64Url(value: ArrayBuffer): string { + const binary = String.fromCharCode(...new Uint8Array(value)); + return btoa(binary).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, ""); +} + +function decodeBase64Url(value: unknown): Uint8Array | undefined { + if (typeof value !== "string" || !BASE64URL_PATTERN.test(value)) return undefined; + try { + const padded = value.replace(/-/gu, "+").replace(/_/gu, "/") + + "=".repeat((4 - value.length % 4) % 4); + const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + return encodeBase64Url(bytes.buffer) === value ? bytes : undefined; + } catch { + return undefined; + } +} + +function canonicalOidcLifetime(value: unknown): { readonly iat: number; readonly exp: number } { + if ( + !isRecord(value) + || !hasExactKeys(value, ["exp", "iat"]) + || !Number.isSafeInteger(value.iat) + || !Number.isSafeInteger(value.exp) + || (value.iat as number) <= 0 + || (value.exp as number) <= (value.iat as number) + ) { + return rejectReceipt("continuation receipt OIDC lifetime must be one ordered positive integer interval"); + } + return { iat: value.iat as number, exp: value.exp as number }; +} + +function canonicalDispatchResult( + value: unknown, + expectedEventType: "noema-review" | "strix-scan", +): CentralContinuationDispatchResult { + if (!isRecord(value)) return rejectReceipt("continuation receipt dispatch result is not canonical"); + const hasStatus = Object.hasOwn(value, "upstreamStatus"); + if (!hasExactKeys(value, hasStatus ? ["eventType", "outcome", "upstreamStatus"] : ["eventType", "outcome"])) { + return rejectReceipt("continuation receipt dispatch result members are not canonical"); + } + if (value.eventType !== expectedEventType) { + return rejectReceipt("continuation receipt event type does not match the released action"); + } + if (value.outcome === "accepted" && value.upstreamStatus === 204) { + return { outcome: "accepted", upstreamStatus: 204, eventType: expectedEventType }; + } + if ( + value.outcome === "denied" + && (value.upstreamStatus === 403 || value.upstreamStatus === 404 || value.upstreamStatus === 422) + ) { + return { outcome: "denied", upstreamStatus: value.upstreamStatus, eventType: expectedEventType }; + } + if ( + value.outcome === "indeterminate" + && ( + value.upstreamStatus === undefined + || (Number.isSafeInteger(value.upstreamStatus) + && (value.upstreamStatus as number) >= 100 + && (value.upstreamStatus as number) <= 599) + ) + ) { + return value.upstreamStatus === undefined + ? { outcome: "indeterminate", eventType: expectedEventType } + : { outcome: "indeterminate", upstreamStatus: value.upstreamStatus as number, eventType: expectedEventType }; + } + return rejectReceipt("continuation receipt dispatch outcome is not canonical"); +} + +function canonicalUnsignedReceipt(value: Record): UnsignedContinuationReceipt | undefined { + if ( + value.algorithm !== SIGNATURE_ALGORITHM + || value.central_repository !== "ContextualWisdomLab/.github" + || value.contract_version !== "noema.continuation-dispatch.v1" + || value.receipt_version !== RECEIPT_VERSION + || typeof value.emitted_payload_digest !== "string" + || !SHA256_PATTERN.test(value.emitted_payload_digest) + || (value.event_type !== "noema-review" && value.event_type !== "strix-scan") + || !Number.isSafeInteger(value.issued_at) + || !Number.isSafeInteger(value.expires_at) + || (value.issued_at as number) <= 0 + || (value.expires_at as number) <= (value.issued_at as number) + || typeof value.idempotency_identity !== "string" + || !SHA256_PATTERN.test(value.idempotency_identity) + || typeof value.key_id !== "string" + || !KEY_ID_PATTERN.test(value.key_id) + || (value.outcome !== "accepted" && value.outcome !== "denied" && value.outcome !== "indeterminate") + || !Number.isSafeInteger(value.pull_request_number) + || (value.pull_request_number as number) <= 0 + || typeof value.receipt_id !== "string" + || !UUID_V4_PATTERN.test(value.receipt_id) + || typeof value.request_digest !== "string" + || !SHA256_PATTERN.test(value.request_digest) + || typeof value.source_base_ref !== "string" + || value.source_base_ref.length === 0 + || typeof value.source_base_sha !== "string" + || !SHA_PATTERN.test(value.source_base_sha) + || typeof value.source_head_sha !== "string" + || !SHA_PATTERN.test(value.source_head_sha) + || typeof value.source_repository !== "string" + || typeof value.trace_id !== "string" + || !TRACE_ID_PATTERN.test(value.trace_id) + || (value.transport_retry_attempt !== 1 && value.transport_retry_attempt !== 2) + || typeof value.workflow_ref !== "string" + || !WORKFLOW_REF_PATTERN.test(value.workflow_ref) + || containsUnpairedSurrogate(value.workflow_ref) + || typeof value.workflow_sha !== "string" + || !SHA_PATTERN.test(value.workflow_sha) + ) { + return undefined; + } + if ( + (value.outcome === "accepted" && value.upstream_status !== 204) + || (value.outcome === "denied" && value.upstream_status !== 403 + && value.upstream_status !== 404 && value.upstream_status !== 422) + || (value.outcome === "indeterminate" && value.upstream_status !== null + && (!Number.isSafeInteger(value.upstream_status) + || (value.upstream_status as number) < 100 || (value.upstream_status as number) > 599)) + ) { + return undefined; + } + return value as unknown as UnsignedContinuationReceipt; +} + +function unsignedReceipt(receipt: unknown): UnsignedContinuationReceipt | undefined { + if (!isRecord(receipt) || !hasExactKeys(receipt, RECEIPT_KEYS)) return undefined; + const { signature: _signature, ...unsigned } = receipt; + return canonicalUnsignedReceipt(unsigned); +} + +/** + * Creates one deterministic detached Ed25519 signature over the exact canonical v1 receipt. + * OIDC issued-at and expiry are copied without introducing a broker timeout, and no credential enters the result. + * @param fields Exact request, workflow, dispatch, digest, OIDC lifetime, and trace evidence. + * @param env Dedicated Ed25519 PKCS#8 signing key and canonical public key identifier. + * @returns A deeply immutable, credential-free signed continuation receipt. + * @throws {ContinuationReceiptError} When any input, key, or signing operation is not canonical. + */ +export async function signContinuationReceipt( + fields: ContinuationReceiptFields, + env: ContinuationReceiptSigningEnv, +): Promise { + if (!isRecord(fields) || !hasExactKeys(fields, FIELD_KEYS)) { + return rejectReceipt("continuation receipt fields must contain exactly the released members"); + } + if (!isRecord(env)) return rejectReceipt("continuation receipt signing environment is not configured"); + canonicalContinuationRequest(fields.request, { workflow_sha: fields.workflowSha }); + if (!SHA256_PATTERN.test(fields.requestDigest) || !SHA256_PATTERN.test(fields.idempotencyIdentity)) { + return rejectReceipt("continuation receipt request identity is not canonical"); + } + if ( + fields.requestDigest + !== await continuationRequestDigest(fields.request, { workflow_sha: fields.workflowSha }) + ) { + return rejectReceipt("continuation receipt request digest does not bind the canonical request"); + } + if (!SHA256_PATTERN.test(fields.emittedPayloadDigest)) { + return rejectReceipt("continuation receipt emitted payload digest is not canonical"); + } + if ( + typeof fields.workflowRef !== "string" + || !WORKFLOW_REF_PATTERN.test(fields.workflowRef) + || containsUnpairedSurrogate(fields.workflowRef) + ) { + return rejectReceipt("continuation receipt workflow ref is not canonical"); + } + if (!UUID_V4_PATTERN.test(fields.receiptId) || !TRACE_ID_PATTERN.test(fields.traceId)) { + return rejectReceipt("continuation receipt or trace identity is not canonical"); + } + if ( + typeof env.CONTINUATION_RECEIPT_SIGNING_KEY_ID !== "string" + || !KEY_ID_PATTERN.test(env.CONTINUATION_RECEIPT_SIGNING_KEY_ID) + ) { + return rejectReceipt("continuation receipt signing key id is not canonical"); + } + + const lifetime = canonicalOidcLifetime(fields.oidcLifetime); + const eventType = dispatchMapping(fields.request.dispatch_action).eventType; + const dispatchResult = canonicalDispatchResult(fields.dispatchResult, eventType); + const unsigned: UnsignedContinuationReceipt = Object.freeze({ + algorithm: SIGNATURE_ALGORITHM, + central_repository: fields.request.central_repository, + contract_version: fields.request.contract_version, + emitted_payload_digest: fields.emittedPayloadDigest, + event_type: dispatchResult.eventType, + expires_at: lifetime.exp, + idempotency_identity: fields.idempotencyIdentity, + issued_at: lifetime.iat, + key_id: env.CONTINUATION_RECEIPT_SIGNING_KEY_ID, + outcome: dispatchResult.outcome, + pull_request_number: fields.request.pull_request_number, + receipt_id: fields.receiptId, + receipt_version: RECEIPT_VERSION, + request_digest: fields.requestDigest, + source_base_ref: fields.request.expected_base_ref, + source_base_sha: fields.request.expected_base_sha, + source_head_sha: fields.request.expected_head_sha, + source_repository: fields.request.source_repository, + trace_id: fields.traceId, + transport_retry_attempt: fields.request.transport_retry_attempt, + upstream_status: dispatchResult.upstreamStatus ?? null, + workflow_ref: fields.workflowRef, + workflow_sha: fields.workflowSha, + }); + const der = pemDer(env.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM, "PRIVATE KEY"); + if (der === undefined) return rejectReceipt("continuation receipt signing private key is not canonical PKCS8 PEM"); + + try { + const key = await crypto.subtle.importKey("pkcs8", der, SIGNATURE_ALGORITHM, false, ["sign"]); + const signature = await crypto.subtle.sign( + SIGNATURE_ALGORITHM, + key, + new TextEncoder().encode(canonicalJson(unsigned)), + ); + return Object.freeze({ ...unsigned, signature: encodeBase64Url(signature) }); + } catch { + return rejectReceipt("continuation receipt signing private key or operation is invalid"); + } +} + +/** + * Verifies one untrusted signed receipt with an Ed25519 SubjectPublicKeyInfo PEM key. + * Malformed fields, unknown members, non-canonical base64url, key errors, and mutations return false. + * @param receipt Candidate v1 receipt received from the broker or retained state. + * @param publicKey Exact Ed25519 SPKI PEM public key from the released consumer contract. + * @returns Whether the detached signature authenticates the complete canonical receipt. + */ +export async function verifyContinuationReceipt( + receipt: unknown, + publicKey: string, +): Promise { + if (!isRecord(receipt) || typeof receipt.signature !== "string") return false; + const unsigned = unsignedReceipt(receipt); + const signature = decodeBase64Url(receipt.signature); + const der = pemDer(publicKey, "PUBLIC KEY"); + if (unsigned === undefined || signature === undefined || der === undefined) return false; + try { + const key = await crypto.subtle.importKey("spki", der, SIGNATURE_ALGORITHM, false, ["verify"]); + return crypto.subtle.verify( + SIGNATURE_ALGORITHM, + key, + signature, + new TextEncoder().encode(canonicalJson(unsigned)), + ); + } catch { + return false; + } +} diff --git a/src/entrypoint.ts b/src/entrypoint.ts index d7a32885d..682a4006d 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -1,4 +1,4 @@ -import { type ErrorCode } from "./error-codes"; +import { errorHints, type ErrorCode } from "./error-codes"; import { ensureGlobalOutboundFetchPolicy, } from "./outbound-fetch-policy"; @@ -36,6 +36,8 @@ type EgressFailure = { | "github-cloud-exact-origin" | "github-app-id-canonical" | "github-app-installation-id-canonical" + | "continuation-dispatch-app-id-canonical" + | "continuation-dispatch-installation-id-canonical" | "credential-fetch-no-redirect"; }; @@ -515,12 +517,13 @@ function exchangeBodyResponse(request: Request, failure: ExchangeBodyFailure): R function recordConfigurationFailure( request: Request, + route: string, failure: EgressFailure, ): void { try { console.log(JSON.stringify({ event: "github_api_egress", - route: "/exchange", + route, method: request.method, status_code: 503, error_code: "ERR_GITHUB_API" satisfies ErrorCode, @@ -532,11 +535,11 @@ function recordConfigurationFailure( } } -function recordOidcEnvelopeFailure(request: Request): void { +function recordOidcEnvelopeFailure(request: Request, route: string): void { try { console.log(JSON.stringify({ event: "oidc_token_envelope", - route: "/exchange", + route, method: request.method, status_code: 400, error_code: "ERR_TOKEN_MALFORMED" satisfies ErrorCode, @@ -567,6 +570,36 @@ function recordExchangeBodyFailure(request: Request, failure: ExchangeBodyFailur } } +function dispatchBoundaryResponse( + request: Request, + status: 400 | 401 | 405, + message: string, +): Response { + const traceId = traceIdFromRequest(request); + const headers = new Headers({ + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "x-trace-id": traceId, + "x-latency-ms": "0", + }); + if (status === 405) headers.set("allow", "POST"); + if (status === 401) { + headers.set("www-authenticate", 'Bearer realm="noema", error="invalid_token"'); + } + const code = (status === 401 + ? "ERR_DISPATCH_IDENTITY_DENIED" + : "ERR_DISPATCH_REQUEST_INVALID") satisfies ErrorCode; + return new Response(JSON.stringify({ + ok: false, + error_code: code, + message, + details: { hint: errorHints[code] }, + trace_id: traceId, + }), { status, headers }); +} + /** * Public Cloudflare Worker entrypoint that enforces request-body, OIDC-envelope, and * GitHub egress policy before delegating to the credential-exchange worker. It fails closed @@ -575,21 +608,29 @@ function recordExchangeBodyFailure(request: Request, failure: ExchangeBodyFailur export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - if (url.pathname === "/exchange") { + const isExchange = url.pathname === "/exchange"; + const isContinuationDispatch = url.pathname === "/v1/continuation-dispatches"; + if (isExchange || isContinuationDispatch) { if (request.method !== "POST") { - return exchangeMethodResponse(request); + return isExchange + ? exchangeMethodResponse(request) + : dispatchBoundaryResponse(request, 405, "Method not allowed"); } if (!isBoundedOidcBearer(request.headers.get("authorization"))) { - recordOidcEnvelopeFailure(request); - return oidcEnvelopeResponse(request); + recordOidcEnvelopeFailure(request, url.pathname); + return isExchange + ? oidcEnvelopeResponse(request) + : dispatchBoundaryResponse(request, 401, "Continuation identity is malformed"); } - const boundedRequest = await boundExchangeJsonBody(request); - if ("failure" in boundedRequest) { - recordExchangeBodyFailure(request, boundedRequest.failure); - return exchangeBodyResponse(request, boundedRequest.failure); + if (isExchange) { + const boundedRequest = await boundExchangeJsonBody(request); + if ("failure" in boundedRequest) { + recordExchangeBodyFailure(request, boundedRequest.failure); + return exchangeBodyResponse(request, boundedRequest.failure); + } + request = boundedRequest.request; } - request = boundedRequest.request; const appIdFailure: EgressFailure = { hint: "Configure GITHUB_APP_ID as a canonical positive decimal safe integer.", @@ -597,7 +638,7 @@ export default { policy: "github-app-id-canonical", }; if (!isCanonicalPositiveSafeInteger(env.GITHUB_APP_ID)) { - recordConfigurationFailure(request, appIdFailure); + recordConfigurationFailure(request, url.pathname, appIdFailure); return githubApiConfigurationResponse(request, appIdFailure); } @@ -608,17 +649,38 @@ export default { outcome: "misconfigured", policy: "github-app-installation-id-canonical", }; - recordConfigurationFailure(request, installationIdFailure); + recordConfigurationFailure(request, url.pathname, installationIdFailure); return githubApiConfigurationResponse(request, installationIdFailure); } + if (isContinuationDispatch) { + const centralAppIdFailure: EgressFailure = { + hint: "Configure CONTINUATION_DISPATCH_GITHUB_APP_ID as a canonical positive decimal safe integer.", + outcome: "misconfigured", + policy: "continuation-dispatch-app-id-canonical", + }; + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_ID ?? "")) { + recordConfigurationFailure(request, url.pathname, centralAppIdFailure); + return githubApiConfigurationResponse(request, centralAppIdFailure); + } + const centralInstallationFailure: EgressFailure = { + hint: "Configure CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID as a canonical positive decimal safe integer.", + outcome: "misconfigured", + policy: "continuation-dispatch-installation-id-canonical", + }; + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID ?? "")) { + recordConfigurationFailure(request, url.pathname, centralInstallationFailure); + return githubApiConfigurationResponse(request, centralInstallationFailure); + } + } + const originFailure: EgressFailure = { hint: "Configure GITHUB_API_BASE as the exact GitHub Cloud REST API origin.", outcome: "misconfigured", policy: "github-cloud-exact-origin", }; if (!isTrustedGithubApiBase(env.GITHUB_API_BASE)) { - recordConfigurationFailure(request, originFailure); + recordConfigurationFailure(request, url.pathname, originFailure); return githubApiConfigurationResponse(request, originFailure); } @@ -628,7 +690,7 @@ export default { policy: "credential-fetch-no-redirect", }; if (!ensureGlobalOutboundFetchPolicy()) { - recordConfigurationFailure(request, redirectFailure); + recordConfigurationFailure(request, url.pathname, redirectFailure); return githubApiConfigurationResponse(request, redirectFailure); } @@ -639,4 +701,4 @@ export default { } return worker.fetch(request, env); }, -}; \ No newline at end of file +}; diff --git a/src/error-codes.ts b/src/error-codes.ts index 5300ce4ce..85af6f6fb 100644 --- a/src/error-codes.ts +++ b/src/error-codes.ts @@ -19,6 +19,12 @@ export type ErrorCode = | "ERR_GITHUB_API" | "ERR_GITHUB_INSTALLATION" | "ERR_RATE_LIMIT" + | "ERR_DISPATCH_REQUEST_INVALID" + | "ERR_DISPATCH_IDENTITY_DENIED" + | "ERR_DISPATCH_LIVE_STATE_STALE" + | "ERR_DISPATCH_REPLAY_CONFLICT" + | "ERR_GITHUB_DISPATCH_AUTHORIZATION" + | "ERR_GITHUB_DISPATCH_UPSTREAM" | "ERR_SERVICE_NOT_READY" | "ERR_INTERNAL"; @@ -39,6 +45,12 @@ export const errorHints: Record = { ERR_GITHUB_API: "Retry after checking GitHub API availability and the app installation state.", ERR_GITHUB_INSTALLATION: "Verify the GitHub App is installed on the target repository.", ERR_RATE_LIMIT: "Back off and retry after the rate-limit window resets.", + ERR_DISPATCH_REQUEST_INVALID: "Send one bounded request matching noema.continuation-dispatch.v1.", + ERR_DISPATCH_IDENTITY_DENIED: "Request a fresh OIDC token from the exact released reusable workflow and source repository.", + ERR_DISPATCH_LIVE_STATE_STALE: "Refresh the pull request head, base, ref, and draft state before requesting continuation.", + ERR_DISPATCH_REPLAY_CONFLICT: "Do not redispatch; reconcile the retained continuation identity and terminal receipt.", + ERR_GITHUB_DISPATCH_AUTHORIZATION: "Repair the central dispatch App installation or fixed repository permission before retrying with a new identity.", + ERR_GITHUB_DISPATCH_UPSTREAM: "Reconcile the retained continuation state before any retry because the external outcome may be unknown.", ERR_SERVICE_NOT_READY: "Repair the listed configuration checks before routing credential-exchange traffic.", ERR_INTERNAL: "Use trace_id to find the matching operational log entry.", }; diff --git a/src/index.ts b/src/index.ts index 4e9ab35d4..1631152cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,11 @@ import { OidcReplayUnavailable, type OidcReplayProtectionEnv, } from "./oidc-replay"; +import { + continuationDispatchDependencies, + handleContinuationDispatch, + type ContinuationDispatchHandlerEnv, +} from "./continuation-dispatch/handler"; /** * Runtime configuration consumed by Noema's base credential-exchange worker. These @@ -24,12 +29,28 @@ export interface Env extends OidcReplayProtectionEnv { GITHUB_APP_ID: string; GITHUB_APP_PRIVATE_KEY_PEM: string; GITHUB_APP_INSTALLATION_ID?: string; + CONTINUATION_DISPATCH_GITHUB_APP_ID?: string; + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM?: string; + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID?: string; + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM?: string; + CONTINUATION_RECEIPT_SIGNING_KEY_ID?: string; + NOEMA_CONTINUATION_DISPATCH_STATE?: DurableObjectNamespace; NOEMA_RATE_LIMIT_PER_MINUTE?: string; NOEMA_OIDC_JWKS_CACHE_TTL_SECONDS?: string; NOEMA_INSTALLATION_CACHE_TTL_SECONDS?: string; } -type JwtPayload = { +/** Minimal GitHub App configuration shared by bounded repository API adapters. */ +export interface GitHubAppEnv { + GITHUB_API_BASE: string; + GITHUB_APP_ID: string; + GITHUB_APP_PRIVATE_KEY_PEM: string; + GITHUB_APP_INSTALLATION_ID?: string; + NOEMA_INSTALLATION_CACHE_TTL_SECONDS?: string; +} + +/** Verified GitHub Actions OIDC claims retained for downstream authorization checks. */ +export type JwtPayload = { iss?: string; aud?: string | string[]; repository?: string; @@ -107,7 +128,9 @@ const maxLocalRateLimitBuckets = 10_000; let oidcKeysCache: TimedCache | undefined; const installationIdCache = new Map>(); -class ApiError extends Error { +/** Bounded internal API failure carrying only stable response and upstream status metadata. */ +export class ApiError extends Error { + /** Creates one sanitized API failure without retaining request or credential values. */ constructor( public code: ErrorCode, public status: number, @@ -763,7 +786,14 @@ async function importGithubAppPrivateKey(pem: string): Promise { return crypto.subtle.importKey("pkcs8", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]); } -async function createGitHubAppJwt(env: Env): Promise { +/** + * Creates a short-lived GitHub App assertion from one typed Worker credential binding. + * @param env GitHub App id and PKCS#8 private-key bindings. + * @returns A signed compact JWT accepted only by GitHub App endpoints. + */ +export async function createGitHubAppJwt( + env: Pick, +): Promise { const now = Math.floor(Date.now() / 1000); const header = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" }))); const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ iat: now - 60, exp: now + 540, iss: env.GITHUB_APP_ID }))); @@ -772,18 +802,30 @@ async function createGitHubAppJwt(env: Env): Promise { return `${header}.${payload}.${base64UrlEncode(signature)}`; } -type GitHubJsonRequestInit = RequestInit & { +/** + * GitHub request options requiring an explicit reviewed header record so shared + * fixed-origin egress never inherits ambient or caller-selected authorization. + */ +export type GitHubJsonRequestInit = RequestInit & { headers: Record; }; -async function githubJson( +/** + * Sends one GitHub REST request through the reviewed fixed-origin header and + * redirect policy shared by credential exchange and continuation dispatch. + * @param path Reviewed GitHub REST path, never a caller-selected absolute URL. + * @param init Explicit request method, body, and authorization headers. + * @param env Exact GitHub Cloud API base binding validated by the public edge. + * @returns The unconsumed GitHub response for operation-specific status handling. + */ +export async function githubApiRequest( path: string, init: GitHubJsonRequestInit, - env: Env, - expectedStatus: number, -): Promise> { - const response = await fetch(new URL(path, env.GITHUB_API_BASE), { + env: Pick, +): Promise { + return fetch(new URL(path, env.GITHUB_API_BASE), { ...init, + redirect: "error", headers: { accept: "application/vnd.github+json", "user-agent": "noema", @@ -791,6 +833,24 @@ async function githubJson( ...init.headers, }, }); +} + +/** + * Reads one successful bounded, duplicate-key-free GitHub JSON object response. + * @param path Reviewed GitHub REST path. + * @param init Explicit request options and authorization header. + * @param env Exact GitHub Cloud API base binding. + * @param expectedStatus Only successful status accepted by this operation. + * @returns The decoded top-level JSON object. + * @throws {ApiError} When status, media type, body bounds, UTF-8, or JSON shape fails closed. + */ +export async function githubJson( + path: string, + init: GitHubJsonRequestInit, + env: Pick, + expectedStatus: number, +): Promise> { + const response = await githubApiRequest(path, init, env); if (!response.ok) { if (response.status === 429) { throw new ApiError("ERR_RATE_LIMIT", 429, "GitHub API rate limit reached", undefined, response.status); @@ -830,7 +890,7 @@ async function githubJson( async function resolveInstallationId( appJwt: string, repository: string, - env: Env, + env: GitHubAppEnv, ): Promise { const configuredInstallationId = env.GITHUB_APP_INSTALLATION_ID; if (configuredInstallationId) { @@ -885,14 +945,26 @@ async function resolveInstallationId( }; } -async function createInstallationToken(repository: string, env: Env): Promise { +/** + * Mints one repository-limited GitHub App installation token with explicit permissions. + * @param repository Canonical owner/name repository selected by the calling trust boundary. + * @param env GitHub App and fixed API configuration. + * @param permissions Exact GitHub installation-token permissions for this operation. + * @returns The bounded token and canonical GitHub expiry timestamp. + * @throws {ApiError} When installation lookup, minting, or token validation fails closed. + */ +export async function createGitHubInstallationToken( + repository: string, + env: GitHubAppEnv, + permissions: Readonly>, +): Promise { const appJwt = await createGitHubAppJwt(env); let installationResolution = await resolveInstallationId(appJwt, repository, env); let installationId = installationResolution.value; const mintInstallationToken = (id: string) => githubJson(`/app/installations/${id}/access_tokens`, { method: "POST", headers: { authorization: `Bearer ${appJwt}` }, - body: JSON.stringify({ repositories: [repository.split("/", 2)[1]], permissions: { pull_requests: "write", contents: "read", checks: "read" } }), + body: JSON.stringify({ repositories: [repository.split("/", 2)[1]], permissions }), }, env, 201); let token: Record; @@ -956,6 +1028,14 @@ async function createInstallationToken(repository: string, env: Env): Promise { + return createGitHubInstallationToken(repository, env, { + pull_requests: "write", + contents: "read", + checks: "read", + }); +} + async function parseExchangeRequestBody(request: Request): Promise { if (request.body === null) return {}; const contentType = request.headers.get("content-type") || ""; @@ -1067,6 +1147,14 @@ async function handleExchange(request: Request, env: Env, traceId: string): Prom }; } +const productionContinuationDispatchDependencies = { + ...continuationDispatchDependencies, + verifyOidc: (token: string, env: ContinuationDispatchHandlerEnv) => + verifyGithubOidcJwt(token, env as unknown as Env), + claimOidc: (claims: JwtPayload, env: ContinuationDispatchHandlerEnv) => + claimVerifiedOidcUsage(claims, env as unknown as Env), +}; + /** * Base public Worker entrypoint for Noema health and credential exchange. It validates * methods, OIDC/GitHub App exchange policy, local rate limits, structured errors, and @@ -1128,6 +1216,26 @@ export default { }); return withOperationalHeaders(response, traceId, latency_ms); } + if (url.pathname === "/v1/continuation-dispatches") { + enforceRateLimit(request, env, route); + const response = await handleContinuationDispatch( + request, + env as Env & ContinuationDispatchHandlerEnv, + traceId, + productionContinuationDispatchDependencies, + ); + status = response.status; + const latency_ms = Math.round(performance.now() - startedAt); + logRequest({ + route, + method, + status_code: status, + latency_ms, + trace_id: traceId, + ...(status >= 400 ? { error_code: (await response.clone().json() as { error_code?: ErrorCode }).error_code } : {}), + }); + return withOperationalHeaders(response, traceId, latency_ms); + } status = 404; const response = errorResponse("ERR_VALIDATION_INPUT", 404, "Endpoint not found", traceId, { path: route }); const latency_ms = Math.round(performance.now() - startedAt); diff --git a/src/runtime-entrypoint.ts b/src/runtime-entrypoint.ts index 60c02eca1..f527a6ba2 100644 --- a/src/runtime-entrypoint.ts +++ b/src/runtime-entrypoint.ts @@ -8,6 +8,7 @@ import { normalizeGitHubAppPrivateKeyPem } from "./github-app-private-key"; import { evaluateRuntimeReadiness } from "./runtime-readiness"; export { NoemaOidcReplayGuard, NoemaRateLimiter }; +export { NoemaContinuationDispatchState } from "./continuation-dispatch/dispatch-state"; export { NoemaWorkflowState } from "./workflow-task-execution/workflow-state-durable-object"; export { NoemaExternalExtensionLifecycle } from "./tool-capability/external-extension-lifecycle-durable-object"; @@ -19,6 +20,7 @@ export { NoemaExternalExtensionLifecycle } from "./tool-capability/external-exte */ export interface Env extends BaseEnv { ALLOWED_WORKFLOW_SHA?: string; + NOEMA_CONTINUATION_DISPATCH_STATE: DurableObjectNamespace; } const canonicalTraceHeaderPattern = /^[A-Za-z0-9._:-]+$/; @@ -160,16 +162,43 @@ function exchangeUrlResponse(request: Request): Response { }); } -function recordExchangeUrlFailure(request: Request): void { +function continuationDispatchUrlResponse(request: Request): Response { + const traceId = traceIdFromRequest(request); + return new Response(JSON.stringify({ + ok: false, + error_code: "ERR_DISPATCH_REQUEST_INVALID" satisfies ErrorCode, + message: "Continuation dispatch URL contains unreviewed authority", + details: { + hint: "Send the exact /v1/continuation-dispatches resource URL without a query or fragment.", + policy: "exact-continuation-dispatch-url", + }, + trace_id: traceId, + }), { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "x-trace-id": traceId, + "x-latency-ms": "0", + }, + }); +} + +function recordExactUrlFailure(request: Request, route: string): void { + const exchangeRoute = route === "/exchange"; try { console.log(JSON.stringify({ - event: "exchange_url", - route: "/exchange", + event: exchangeRoute ? "exchange_url" : "continuation_dispatch_url", + route, method: request.method, status_code: 400, - error_code: "ERR_VALIDATION_INPUT" satisfies ErrorCode, + error_code: exchangeRoute + ? "ERR_VALIDATION_INPUT" satisfies ErrorCode + : "ERR_DISPATCH_REQUEST_INVALID" satisfies ErrorCode, outcome: "rejected", - policy: "exact-exchange-url", + policy: exchangeRoute ? "exact-exchange-url" : "exact-continuation-dispatch-url", })); } catch { // Logging must not convert a fail-closed input response into an exception. @@ -258,11 +287,13 @@ export default { const boundedRequest = canonicalTraceRequest(request); const url = new URL(boundedRequest.url); if ( - url.pathname === "/exchange" + (url.pathname === "/exchange" || url.pathname === "/v1/continuation-dispatches") && url.href !== `${url.origin}${url.pathname}` ) { - recordExchangeUrlFailure(boundedRequest); - return exchangeUrlResponse(boundedRequest); + recordExactUrlFailure(boundedRequest, url.pathname); + return url.pathname === "/exchange" + ? exchangeUrlResponse(boundedRequest) + : continuationDispatchUrlResponse(boundedRequest); } const runtimeEnv = runtimeCredentialEnv(env); if (url.pathname === "/ready") { diff --git a/src/runtime-readiness.ts b/src/runtime-readiness.ts index e1f8be0f1..83cd3fc23 100644 --- a/src/runtime-readiness.ts +++ b/src/runtime-readiness.ts @@ -29,7 +29,13 @@ export type RuntimeReadinessFailure = | "github_app_private_key" | "github_app_installation_id" | "noema_rate_limiter" - | "noema_oidc_replay_guard"; + | "noema_oidc_replay_guard" + | "continuation_dispatch_github_app_id" + | "continuation_dispatch_github_app_private_key" + | "continuation_dispatch_github_app_installation_id" + | "continuation_receipt_signing_private_key" + | "continuation_receipt_signing_key_id" + | "noema_continuation_dispatch_state"; /** * Environment values required to decide whether Noema can safely accept @@ -52,6 +58,12 @@ export interface RuntimeReadinessEnv { GITHUB_APP_INSTALLATION_ID?: string; NOEMA_RATE_LIMITER?: DurableObjectNamespace; NOEMA_OIDC_REPLAY_GUARD?: DurableObjectNamespace; + CONTINUATION_DISPATCH_GITHUB_APP_ID?: string; + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM?: string; + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID?: string; + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM?: string; + CONTINUATION_RECEIPT_SIGNING_KEY_ID?: string; + NOEMA_CONTINUATION_DISPATCH_STATE?: DurableObjectNamespace; } /** @@ -75,6 +87,14 @@ const privateKeyReadinessCache = new WeakMap< RuntimeReadinessEnv, PrivateKeyReadinessCacheEntry >(); +const continuationAppKeyReadinessCache = new WeakMap< + RuntimeReadinessEnv, + PrivateKeyReadinessCacheEntry +>(); +const continuationReceiptKeyReadinessCache = new WeakMap< + RuntimeReadinessEnv, + PrivateKeyReadinessCacheEntry +>(); /** * Admit only the canonical organization-owned reusable-workflow repository. @@ -224,6 +244,19 @@ async function isImportablePrivateKey(value: string | undefined): Promise { + try { + const match = privateKeyPattern.exec(value ?? ""); + if (!match) return false; + const binary = atob(match[1].replace(/\s+/gu, "")); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + await crypto.subtle.importKey("pkcs8", bytes, "Ed25519", false, ["sign"]); + return true; + } catch { + return false; + } +} + /** Reuse only an unchanged private-key import while other bindings stay live. */ function cachedPrivateKeyImportability(env: RuntimeReadinessEnv): Promise { const privateKeyPem = env.GITHUB_APP_PRIVATE_KEY_PEM; @@ -235,6 +268,19 @@ function cachedPrivateKeyImportability(env: RuntimeReadinessEnv): Promise, + env: RuntimeReadinessEnv, + privateKeyPem: string | undefined, + importer: (value: string | undefined) => Promise, +): Promise { + const cached = cache.get(env); + if (cached && cached.privateKeyPem === privateKeyPem) return cached.importability; + const importability = importer(privateKeyPem); + cache.set(env, { privateKeyPem, importability }); + return importability; +} + /** * Evaluate the offline configuration required for credential-exchange traffic. * @@ -302,6 +348,34 @@ export async function evaluateRuntimeReadiness( if (!isDurableObjectNamespace(env.NOEMA_OIDC_REPLAY_GUARD)) { failedChecks.push("noema_oidc_replay_guard"); } + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_ID)) { + failedChecks.push("continuation_dispatch_github_app_id"); + } + if (!await cachedConfiguredKeyImportability( + continuationAppKeyReadinessCache, + env, + env.CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM, + isImportablePrivateKey, + )) { + failedChecks.push("continuation_dispatch_github_app_private_key"); + } + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID)) { + failedChecks.push("continuation_dispatch_github_app_installation_id"); + } + if (!await cachedConfiguredKeyImportability( + continuationReceiptKeyReadinessCache, + env, + env.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM, + isImportableEd25519PrivateKey, + )) { + failedChecks.push("continuation_receipt_signing_private_key"); + } + if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(env.CONTINUATION_RECEIPT_SIGNING_KEY_ID ?? "")) { + failedChecks.push("continuation_receipt_signing_key_id"); + } + if (!isDurableObjectNamespace(env.NOEMA_CONTINUATION_DISPATCH_STATE)) { + failedChecks.push("noema_continuation_dispatch_state"); + } return { ready: failedChecks.length === 0, diff --git a/src/worker.ts b/src/worker.ts index ec57e5eec..d6cdf7059 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -316,7 +316,10 @@ function withDistributedRateLimitHeaders( export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - if (url.pathname !== "/exchange") { + if ( + url.pathname !== "/exchange" + && url.pathname !== "/v1/continuation-dispatches" + ) { return baseWorker.fetch(request, env); } @@ -407,7 +410,10 @@ export default { return withDistributedRateLimitHeaders(response, decision); } - if (response.headers.get("x-oidc-replay-protection") === "verified-before-mint") { + if ( + response.headers.get("x-oidc-replay-protection") === "verified-before-mint" + || response.headers.get("x-oidc-replay-protection") === "verified-before-dispatch" + ) { const headers = new Headers(response.headers); headers.set("x-oidc-replay-protection", "single-use"); return withDistributedRateLimitHeaders(new Response(response.body, { @@ -493,4 +499,4 @@ export default { headers, }), decision); }, -}; \ No newline at end of file +}; diff --git a/test/continuation-dispatch-base-success.test.ts b/test/continuation-dispatch-base-success.test.ts new file mode 100644 index 000000000..ef7a3006a --- /dev/null +++ b/test/continuation-dispatch-base-success.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("continuation dispatch base-worker success telemetry", () => { + afterEach(() => { + vi.doUnmock("../src/continuation-dispatch/handler"); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("records an accepted broker response without fabricating an error code", async () => { + const handleContinuationDispatch = vi.fn(async () => Response.json({ + ok: true, + data: { receipt: { outcome: "accepted" } }, + trace_id: "continuation-success", + })); + vi.doMock("../src/continuation-dispatch/handler", async (importOriginal) => ({ + ...await importOriginal(), + handleContinuationDispatch, + })); + const { default: worker } = await import("../src/index"); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const response = await worker.fetch( + new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + "content-type": "application/json", + "x-request-id": "continuation-success", + }, + body: "{}", + }), + { NOEMA_RATE_LIMIT_PER_MINUTE: "1000" } as never, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("x-trace-id")).toBe("continuation-success"); + expect(handleContinuationDispatch).toHaveBeenCalledOnce(); + const record = JSON.parse(String(log.mock.calls.at(-1)?.[0])) as Record; + expect(record).toMatchObject({ + event: "http_request", + route: "/v1/continuation-dispatches", + method: "POST", + status_code: 200, + trace_id: "continuation-success", + }); + expect(record).not.toHaveProperty("error_code"); + }); +}); diff --git a/test/continuation-dispatch-contract.test.ts b/test/continuation-dispatch-contract.test.ts new file mode 100644 index 000000000..d07588b0d --- /dev/null +++ b/test/continuation-dispatch-contract.test.ts @@ -0,0 +1,255 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + canonicalContinuationIdentity, + canonicalContinuationRequest, + continuationDispatchIdentity, + continuationRequestDigest, + dispatchMapping, + parseContinuationDispatchRequest, + type ContinuationDispatchRequest, +} from "../src/continuation-dispatch/contract"; + +const validRequest = { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/example-product", + pull_request_number: 42, + expected_head_sha: "a".repeat(40), + expected_base_sha: "b".repeat(40), + expected_base_ref: "main", + transport_retry_attempt: 1, +} as const; + +const workflowIdentity = { workflow_sha: "c".repeat(40) } as const; + +function parseValue(value: unknown): ContinuationDispatchRequest { + return parseContinuationDispatchRequest(JSON.stringify(value)); +} + +describe("continuation dispatch request contract", () => { + it("accepts exactly the released closed request", () => { + const parsed = parseValue(validRequest); + + expect(parsed).toEqual(validRequest); + expect(Object.isFrozen(parsed)).toBe(true); + }); + + it("accepts JSON grammar whitespace between a decoded member name and its colon", () => { + const raw = JSON.stringify(validRequest).replace('"contract_version":', '"contract_version" \n :'); + + expect(parseContinuationDispatchRequest(raw)).toEqual(validRequest); + }); + + it("accepts both released actions and the inherited second continuation attempt", () => { + const parsed = parseValue({ + ...validRequest, + dispatch_action: "strix_scan_continuation", + transport_retry_attempt: 2, + }); + + expect(parsed.dispatch_action).toBe("strix_scan_continuation"); + expect(parsed.transport_retry_attempt).toBe(2); + }); + + it.each([ + ["unknown member", { ...validRequest, event_type: "noema-review" }], + ["unknown nested member", { ...validRequest, arbitrary: { nested: "member" } }], + ["array root", [validRequest]], + ["null root", null], + ["string root", "request"], + ])("rejects %s", (_label, value) => { + expect(() => parseValue(value)).toThrow(/continuation dispatch request/i); + }); + + it.each([ + ["direct duplicate", `{"contract_version":"ignored","contract_version":"${validRequest.contract_version}","dispatch_action":"${validRequest.dispatch_action}","central_repository":"${validRequest.central_repository}","source_repository":"${validRequest.source_repository}","pull_request_number":42,"expected_head_sha":"${validRequest.expected_head_sha}","expected_base_sha":"${validRequest.expected_base_sha}","expected_base_ref":"main","transport_retry_attempt":1}`], + ["escape-equivalent duplicate", `{"contract_version":"${validRequest.contract_version}","dispatch_action":"ignored","dispatch_acti\\u006fn":"${validRequest.dispatch_action}","central_repository":"${validRequest.central_repository}","source_repository":"${validRequest.source_repository}","pull_request_number":42,"expected_head_sha":"${validRequest.expected_head_sha}","expected_base_sha":"${validRequest.expected_base_sha}","expected_base_ref":"main","transport_retry_attempt":1}`], + ])("rejects %s decoded keys before last-key-wins parsing", (_label, raw) => { + expect(() => parseContinuationDispatchRequest(raw)).toThrow(/duplicate/i); + }); + + it.each([ + ["contract version", { ...validRequest, contract_version: 1 }], + ["action", { ...validRequest, dispatch_action: [validRequest.dispatch_action] }], + ["central repository", { ...validRequest, central_repository: null }], + ["source repository", { ...validRequest, source_repository: {} }], + ["pull request number", { ...validRequest, pull_request_number: "42" }], + ["head SHA", { ...validRequest, expected_head_sha: true }], + ["base SHA", { ...validRequest, expected_base_sha: [] }], + ["base ref", { ...validRequest, expected_base_ref: 7 }], + ["retry attempt", { ...validRequest, transport_retry_attempt: "1" }], + ])("rejects typed lookalike for %s", (_label, value) => { + expect(() => parseValue(value)).toThrow(/continuation dispatch request/i); + }); + + it.each([ + " ContextualWisdomLab/example-product", + "ContextualWisdomLab/example-product ", + "OtherOrganization/example-product", + "ContextualWisdomLab", + "ContextualWisdomLab/", + "ContextualWisdomLab/.", + "ContextualWisdomLab/..", + "ContextualWisdomLab/first/second", + "ContextualWisdomLab/example product", + ])("rejects non-canonical source repository %j", (source_repository) => { + expect(() => parseValue({ ...validRequest, source_repository })).toThrow(/source_repository/i); + }); + + it.each([ + 0, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + ])("rejects non-positive-safe pull request number %j", (pull_request_number) => { + expect(() => parseValue({ ...validRequest, pull_request_number })).toThrow(/pull_request_number/i); + }); + + it.each([ + "A".repeat(40), + "a".repeat(39), + "g".repeat(40), + ])("rejects non-canonical head SHA %j", (expected_head_sha) => { + expect(() => parseValue({ ...validRequest, expected_head_sha })).toThrow(/expected_head_sha/i); + }); + + it.each([ + "B".repeat(40), + "b".repeat(39), + "z".repeat(40), + ])("rejects non-canonical base SHA %j", (expected_base_sha) => { + expect(() => parseValue({ ...validRequest, expected_base_sha })).toThrow(/expected_base_sha/i); + }); + + it.each([ + "", + "@", + "/main", + " main", + "main ", + "main.", + "feature..branch", + "feature.lock", + "feature@{branch", + "feature//branch", + "feature\\branch", + "feature~branch", + "feature^branch", + "feature:branch", + "feature?branch", + "feature*branch", + "feature[branch", + "-main", + ".hidden", + "feature/\ud800", + "feature/\udc00", + "feature/", + ])("rejects base ref outside git check-ref-format --branch %j", (expected_base_ref) => { + expect(() => parseValue({ ...validRequest, expected_base_ref })).toThrow(/expected_base_ref/i); + }); + + it("accepts a canonical slash-delimited base ref", () => { + expect(parseValue({ ...validRequest, expected_base_ref: "release/next" }).expected_base_ref) + .toBe("release/next"); + expect(parseValue({ ...validRequest, expected_base_ref: "release/🚀" }).expected_base_ref) + .toBe("release/🚀"); + }); + + it("rejects every non-canonical target", () => { + for (const central_repository of [ + "ContextualWisdomLab/contextual-orchestrator", + " ContextualWisdomLab/.github", + "ContextualWisdomLab/.github ", + ]) { + expect(() => parseValue({ ...validRequest, central_repository })).toThrow(/central_repository/i); + } + }); + + it("rejects arbitrary actions", () => { + expect(() => parseValue({ ...validRequest, dispatch_action: "repository_dispatch" })) + .toThrow(/dispatch_action/i); + }); + + it.each([0, 3, 1.5, true, null])("rejects retry attempt %j", (transport_retry_attempt) => { + expect(() => parseValue({ ...validRequest, transport_retry_attempt })).toThrow(/transport_retry_attempt/i); + }); + + it.each([ + "", + "{} {}", + `${JSON.stringify(validRequest)} null`, + "{", + ])("rejects malformed or additional JSON values %j", (raw) => { + expect(() => parseContinuationDispatchRequest(raw)).toThrow(/continuation dispatch request/i); + }); + + it("rejects a non-string runtime argument before JSON parsing", () => { + const parseRuntimeValue = parseContinuationDispatchRequest as (value: unknown) => ContinuationDispatchRequest; + + expect(() => parseRuntimeValue(null)).toThrow(/JSON text/i); + }); +}); + +describe("continuation dispatch canonical identity", () => { + it("maps only released actions to fixed event types", () => { + expect(dispatchMapping("noema_review_continuation")).toEqual({ eventType: "noema-review" }); + expect(dispatchMapping("strix_scan_continuation")).toEqual({ eventType: "strix-scan" }); + expect(() => dispatchMapping("arbitrary" as never)).toThrow(/dispatch action/i); + }); + + it("serializes the named identity in RFC 8785 lexicographic key order", () => { + const canonical = canonicalContinuationRequest(parseValue(validRequest), workflowIdentity); + + expect(canonical).toBe( + `{"central_repository":"ContextualWisdomLab/.github","contract_version":"noema.continuation-dispatch.v1","dispatch_action":"noema_review_continuation","expected_base_ref":"main","expected_base_sha":"${"b".repeat(40)}","expected_head_sha":"${"a".repeat(40)}","pull_request_number":42,"source_repository":"ContextualWisdomLab/example-product","transport_retry_attempt":1,"workflow_sha":"${"c".repeat(40)}"}`, + ); + }); + + it("produces the exact lowercase SHA-256 digest of the canonical bytes", async () => { + const request = parseValue(validRequest); + const canonical = canonicalContinuationRequest(request, workflowIdentity); + const expected = createHash("sha256").update(canonical, "utf8").digest("hex"); + + await expect(continuationRequestDigest(request, workflowIdentity)).resolves.toBe(expected); + expect(expected).toMatch(/^[0-9a-f]{64}$/u); + }); + + it("serializes retry attempts through one logical identity while retaining distinct request digests", async () => { + const first = parseValue(validRequest); + const second = parseValue({ ...validRequest, transport_retry_attempt: 2 }); + const canonicalIdentity = canonicalContinuationIdentity(first, workflowIdentity); + + expect(canonicalIdentity).not.toContain("transport_retry_attempt"); + await expect(continuationDispatchIdentity(first, workflowIdentity)).resolves.toBe( + await continuationDispatchIdentity(second, workflowIdentity), + ); + await expect(continuationRequestDigest(first, workflowIdentity)).resolves.not.toBe( + await continuationRequestDigest(second, workflowIdentity), + ); + }); + + it("fails closed when runtime workflow identity is not a lowercase full commit SHA", async () => { + const request = parseValue(validRequest); + + expect(() => canonicalContinuationRequest(request, { workflow_sha: "C".repeat(40) })) + .toThrow(/workflow_sha/i); + await expect(continuationRequestDigest(request, { workflow_sha: "c".repeat(39) })) + .rejects.toThrow(/workflow_sha/i); + }); + + it("rejects non-object and open workflow identities", () => { + const request = parseValue(validRequest); + const canonicalRuntimeIdentity = canonicalContinuationRequest as ( + candidateRequest: ContinuationDispatchRequest, + candidateIdentity: unknown, + ) => string; + + expect(() => canonicalRuntimeIdentity(request, null)).toThrow(/workflow_sha/i); + expect(() => canonicalRuntimeIdentity(request, { + workflow_sha: workflowIdentity.workflow_sha, + mutable_ref: "main", + })).toThrow(/workflow_sha/i); + }); +}); diff --git a/test/continuation-dispatch-docs-contract.test.ts b/test/continuation-dispatch-docs-contract.test.ts new file mode 100644 index 000000000..b200799bf --- /dev/null +++ b/test/continuation-dispatch-docs-contract.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function text(path: string): string { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +} + +const requiredDocs = [ + "README.md", + "CHANGELOG.md", + "docs/api-spec.md", + "docs/api-stability-contract.md", + "docs/PRD.md", + "docs/TRD.md", + "docs/threat-model.md", + "docs/runbook.md", + "docs/product-technical-gap-baseline.md", +] as const; + +describe("continuation dispatch publication contracts", () => { + it("publishes a closed, credential-free OpenAPI operation", () => { + const document = JSON.parse(text("openapi.json")) as { + paths?: Record }>; + components?: { schemas?: Record }; + }; + const operation = document.paths?.["/v1/continuation-dispatches"]?.post; + expect(operation).toMatchObject({ + operationId: "createContinuationDispatch", + security: [{ githubActionsOidc: [] }], + "x-request-body-limit-bytes": 8192, + }); + expect(JSON.stringify(operation)).toContain("noema.continuation-dispatch.v1"); + expect(JSON.stringify(operation)).toContain("ERR_DISPATCH_REPLAY_CONFLICT"); + expect(JSON.stringify(operation)).not.toMatch(/installation[_ -]?token|access[_ -]?token/i); + expect(document.components?.schemas).toHaveProperty("ContinuationDispatchRequest"); + expect(document.components?.schemas).toHaveProperty("SignedContinuationReceipt"); + expect(document.components?.schemas?.SignedContinuationReceipt).toMatchObject({ + properties: { + receipt_version: { const: "noema.continuation-dispatch-receipt.v1" }, + }, + }); + }); + + it("publishes the endpoint and all six stable dispatch errors across buyer contracts", () => { + const api = text("docs/api-spec.md"); + const stability = text("docs/api-stability-contract.md"); + for (const code of [ + "ERR_DISPATCH_REQUEST_INVALID", + "ERR_DISPATCH_IDENTITY_DENIED", + "ERR_DISPATCH_LIVE_STATE_STALE", + "ERR_DISPATCH_REPLAY_CONFLICT", + "ERR_GITHUB_DISPATCH_AUTHORIZATION", + "ERR_GITHUB_DISPATCH_UPSTREAM", + ]) { + expect(api).toContain(code); + expect(stability).toContain(code); + } + for (const path of requiredDocs) { + expect(text(path), path).toContain("/v1/continuation-dispatches"); + } + }); + + it("documents every required runtime binding without publishing secret values", () => { + const runbook = text("docs/runbook.md"); + for (const binding of [ + "CONTINUATION_DISPATCH_GITHUB_APP_ID", + "CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM", + "CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID", + "CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM", + "CONTINUATION_RECEIPT_SIGNING_KEY_ID", + "NOEMA_CONTINUATION_DISPATCH_STATE", + ]) { + expect(runbook).toContain(binding); + } + expect(runbook).toContain("secret value"); + }); + + it("keeps ADR 0019 Proposed and release-before-consumer order explicit", () => { + const adr = text("docs/adr/0019-continuation-dispatch-broker.md"); + expect(adr).toMatch(/Status:\s*Proposed/u); + expect(adr).toContain("ContextualWisdomLab/.github#2540"); + expect(adr).toMatch(/immutable Noema release[\s\S]*ContextualWisdomLab\/.github#2540/u); + expect(text("docs/adr/README.md")).toContain("0019-continuation-dispatch-broker.md"); + }); + + it("states that receipts contain no GitHub credential or bearer", () => { + const api = text("docs/api-spec.md"); + expect(api).toMatch(/receipt[\s\S]{0,500}(?:no token|no GitHub credential|credential-free)/iu); + expect(api).toContain("Ed25519"); + expect(api).toContain("RFC 8785"); + expect(api).toMatch(/immutable release[\s\S]{0,300}key_id[\s\S]{0,300}SPKI/iu); + expect(api).toMatch(/Draft[\s\S]{0,300}offline verification[\s\S]{0,200}(?:not|않)/iu); + }); +}); diff --git a/test/continuation-dispatch-entrypoint.test.ts b/test/continuation-dispatch-entrypoint.test.ts new file mode 100644 index 000000000..5013135f2 --- /dev/null +++ b/test/continuation-dispatch-entrypoint.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import entrypoint, { type Env } from "../src/entrypoint"; + +const workflowRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const workflowSha = "1".repeat(40); +const headSha = "2".repeat(40); +const baseSha = "3".repeat(40); +const signingKid = "continuation-dispatch-production-wiring"; +const discoveryUrl = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; +const jwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; +let signingPrivateKey: CryptoKey; +let signingPublicJwk: JsonWebKey; + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +async function signedJwt(payload: Record): Promise { + const header = encodeJson({ alg: "RS256", kid: signingKid }); + const claims = encodeJson(payload); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingPrivateKey, + new TextEncoder().encode(`${header}.${claims}`), + ); + return `${header}.${claims}.${Buffer.from(signature).toString("base64url")}`; +} + +function dispatchBody(): string { + return JSON.stringify({ + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/.github", + pull_request_number: 736, + expected_head_sha: headSha, + expected_base_sha: baseSha, + expected_base_ref: "main", + transport_retry_attempt: 1, + }); +} + +function continuationRequest(token: string): Request { + return new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.31", + }, + body: dispatchBody(), + }); +} + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey({ + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, true, ["sign", "verify"]) as CryptoKeyPair; + signingPrivateKey = pair.privateKey; + signingPublicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("continuation dispatch production entrypoint", () => { + it("attributes boundary failures to the continuation dispatch route", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await entrypoint.fetch( + new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { authorization: "Bearer malformed" }, + body: dispatchBody(), + }), + {} as Env, + ); + await entrypoint.fetch(continuationRequest("a.b.c"), {} as Env); + + const records = logSpy.mock.calls.map(([record]) => JSON.parse(String(record)) as { + route?: string; + }); + expect(records).toHaveLength(2); + expect(records.map(({ route }) => route)).toEqual([ + "/v1/continuation-dispatches", + "/v1/continuation-dispatches", + ]); + }); + + it("returns the versioned method boundary and validates both central GitHub App identifiers", async () => { + const wrongMethod = await entrypoint.fetch( + new Request("https://noema.example/v1/continuation-dispatches"), + {} as Env, + ); + const missingCentralApp = await entrypoint.fetch(continuationRequest("a.b.c"), { + GITHUB_APP_ID: "1", + } as Env); + const missingCentralInstallation = await entrypoint.fetch(continuationRequest("a.b.c"), { + GITHUB_APP_ID: "1", + CONTINUATION_DISPATCH_GITHUB_APP_ID: "2", + } as Env); + + expect(wrongMethod.status).toBe(405); + expect(wrongMethod.headers.get("allow")).toBe("POST"); + expect([missingCentralApp.status, missingCentralInstallation.status]).toEqual([503, 503]); + }); + + it("rejects a malformed continuation bearer with the dispatch identity contract", async () => { + const response = await entrypoint.fetch( + new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + authorization: "Bearer invalid", + "content-type": "application/json", + }, + body: dispatchBody(), + }), + {} as Env, + ); + + expect(response.status).toBe(401); + expect(response.headers.get("allow")).toBeNull(); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="noema", error="invalid_token"', + ); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_IDENTITY_DENIED", + message: "Continuation identity is malformed", + details: { hint: expect.any(String) }, + }); + }); + + it("verifies a signed production OIDC identity before failing closed on a missing replay guard", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === discoveryUrl) return Response.json({ jwks_uri: jwksUrl }); + if (url === jwksUrl) { + return Response.json({ keys: [{ ...signingPublicJwk, kid: signingKid, kty: "RSA" }] }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + const now = Math.floor(Date.now() / 1_000); + const token = await signedJwt({ + iss: "https://token.actions.githubusercontent.com", + aud: "cwl-noema-review", + repository_owner: "ContextualWisdomLab", + repository_owner_id: "295022177", + repository: "ContextualWisdomLab/.github", + repository_id: "1274066402", + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + job_workflow_ref: workflowRef, + job_workflow_sha: workflowSha, + jti: "continuation-production-wiring-jti", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const { default: isolatedEntrypoint } = await import("../src/entrypoint"); + + const response = await isolatedEntrypoint.fetch(continuationRequest(token), { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: workflowRef, + ALLOWED_WORKFLOW_SHA: workflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-replay-claim", + CONTINUATION_DISPATCH_GITHUB_APP_ID: "2", + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: "3", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_RATE_LIMITER: { + idFromName: (name: string) => ({ toString: () => name }) as DurableObjectId, + get: () => ({ + fetch: async () => Response.json({ + allowed: true, + limit: 1000, + remaining: 999, + retry_after_seconds: 0, + }), + }) as unknown as DurableObjectStub, + } as unknown as DurableObjectNamespace, + NOEMA_OIDC_REPLAY_GUARD: { + idFromName: (name: string) => ({ toString: () => name }) as DurableObjectId, + get: () => ({ + fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { + expires_at_epoch_seconds: number; + }; + return Response.json({ + accepted: true, + expires_at_epoch_seconds: body.expires_at_epoch_seconds, + }, { status: 201 }); + }, + }) as unknown as DurableObjectStub, + } as unknown as DurableObjectNamespace, + } as Env); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + message: "Continuation state unavailable", + }); + }); +}); diff --git a/test/continuation-dispatch-github-adapter.test.ts b/test/continuation-dispatch-github-adapter.test.ts new file mode 100644 index 000000000..e831ddb11 --- /dev/null +++ b/test/continuation-dispatch-github-adapter.test.ts @@ -0,0 +1,381 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + centralContinuationDispatchBody, + ContinuationGitHubAdapterError, + dispatchCentralContinuation, + prepareCentralContinuation, + readAndVerifyLivePullRequest, + type ContinuationGitHubAdapterEnv, +} from "../src/continuation-dispatch/github-adapter"; +import type { ContinuationDispatchRequest } from "../src/continuation-dispatch/contract"; +import type { JwtPayload } from "../src/index"; + +const SOURCE_REPOSITORY = "ContextualWisdomLab/noema"; +const CENTRAL_REPOSITORY = "ContextualWisdomLab/.github"; +const HEAD_SHA = "a".repeat(40); +const BASE_SHA = "b".repeat(40); +const BASE_REF = "main"; +const SOURCE_TOKEN = "source-installation-token-secret"; +const CENTRAL_TOKEN = "central-installation-token-secret"; + +let appPrivateKeyPem: string; + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +beforeAll(async () => { + const appKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + appPrivateKeyPem = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function request(overrides: Partial = {}): ContinuationDispatchRequest { + return { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: CENTRAL_REPOSITORY, + source_repository: SOURCE_REPOSITORY, + pull_request_number: 42, + expected_head_sha: HEAD_SHA, + expected_base_sha: BASE_SHA, + expected_base_ref: BASE_REF, + transport_retry_attempt: 1, + ...overrides, + }; +} + +function claims(overrides: Partial = {}): JwtPayload { + return { + repository: SOURCE_REPOSITORY, + repository_owner: "ContextualWisdomLab", + ...overrides, + }; +} + +function env(): ContinuationGitHubAdapterEnv { + return { + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "101", + GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + GITHUB_APP_INSTALLATION_ID: "1001", + CONTINUATION_DISPATCH_GITHUB_APP_ID: "202", + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: "2002", + }; +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function installationToken(token: string): Response { + return json({ token, expires_at: new Date(Date.now() + 30 * 60_000).toISOString() }, 201); +} + +function livePullRequest(overrides: Record = {}): Record { + return { + state: "open", + draft: false, + head: { + sha: HEAD_SHA, + ref: "repair/noema-pr-736-task3", + repo: { full_name: SOURCE_REPOSITORY }, + }, + base: { + sha: BASE_SHA, + ref: BASE_REF, + repo: { full_name: SOURCE_REPOSITORY }, + }, + ...overrides, + }; +} + +function mockSourceRead(pullRequest: Response | (() => Response)): ReturnType { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + expect(init?.redirect).toBe("error"); + if (url === `https://api.github.com/repos/${SOURCE_REPOSITORY}/installation`) { + return json({ id: 1001 }); + } + if (url === "https://api.github.com/app/installations/1001/access_tokens") { + expect(JSON.parse(String(init?.body))).toEqual({ + repositories: ["noema"], + permissions: { pull_requests: "read" }, + }); + return installationToken(SOURCE_TOKEN); + } + if (url === `https://api.github.com/repos/${SOURCE_REPOSITORY}/pulls/42`) { + expect(init?.method).toBe("GET"); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${SOURCE_TOKEN}`); + return typeof pullRequest === "function" ? pullRequest() : pullRequest; + } + return new Response("unexpected request", { status: 500 }); + }); +} + +describe("live pull request admission", () => { + it("classifies a missing source App installation as unavailable, not PR stale", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 404 })); + + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).rejects.toMatchObject({ + classification: "upstream_unavailable", + upstreamStatus: 404, + }); + }); + + it("returns the exact verified live PR identity", async () => { + mockSourceRead(json(livePullRequest())); + + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).resolves.toEqual({ + repository: SOURCE_REPOSITORY, + pullRequestNumber: 42, + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseRef: BASE_REF, + }); + }); + + it("rejects an OIDC repository mismatch before GitHub credential use", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await expect(readAndVerifyLivePullRequest( + claims({ repository: "ContextualWisdomLab/other" }), + request(), + env(), + )).rejects.toMatchObject({ classification: "identity_denied" }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ["closed", { state: "closed" }], + ["draft", { draft: true }], + ["moved head", { head: { sha: "c".repeat(40), ref: "other", repo: { full_name: SOURCE_REPOSITORY } } }], + ["fork", { head: { sha: HEAD_SHA, ref: "branch", repo: { full_name: "ContextualWisdomLab/fork" } } }], + ["wrong base repository", { base: { sha: BASE_SHA, ref: BASE_REF, repo: { full_name: "ContextualWisdomLab/other" } } }], + ["wrong base sha", { base: { sha: "d".repeat(40), ref: BASE_REF, repo: { full_name: SOURCE_REPOSITORY } } }], + ["wrong base ref", { base: { sha: BASE_SHA, ref: "release", repo: { full_name: SOURCE_REPOSITORY } } }], + ])("rejects a %s PR as stale", async (_label, override) => { + mockSourceRead(json(livePullRequest(override))); + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).rejects.toMatchObject({ + classification: "live_state_stale", + }); + }); + + it.each([ + [403, "identity_denied"], + [404, "live_state_stale"], + [422, "live_state_stale"], + [500, "upstream_unavailable"], + ] as const)("classifies GitHub PR status %i as %s", async (status, classification) => { + mockSourceRead(new Response(null, { status })); + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).rejects.toMatchObject({ + classification, + upstreamStatus: status, + }); + }); + + it("classifies a GitHub PR network failure without leaking credentials", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + mockSourceRead(() => { + throw new Error(`network failed near ${SOURCE_TOKEN}`); + }); + + let failure: unknown; + try { + await readAndVerifyLivePullRequest(claims(), request(), env()); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ContinuationGitHubAdapterError); + expect(failure).toMatchObject({ classification: "upstream_unavailable" }); + expect(String(failure)).not.toContain(SOURCE_TOKEN); + expect(logSpy.mock.calls.flat().join("\n")).not.toContain(SOURCE_TOKEN); + }); +}); + +function mockCentralDispatch(dispatchResponse: Response | (() => Response)): ReturnType { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + expect(init?.redirect).toBe("error"); + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/installation`) { + return json({ id: 2002 }); + } + if (url === "https://api.github.com/app/installations/2002/access_tokens") { + expect(JSON.parse(String(init?.body))).toEqual({ + repositories: [".github"], + permissions: { contents: "write" }, + }); + return installationToken(CENTRAL_TOKEN); + } + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/dispatches`) { + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${CENTRAL_TOKEN}`); + expect(init?.body).toBe(centralContinuationDispatchBody(request())); + expect(JSON.parse(String(init?.body))).toEqual({ + event_type: "noema-review", + client_payload: { + source_repository: SOURCE_REPOSITORY, + pull_request_number: 42, + expected_head_sha: HEAD_SHA, + expected_base_sha: BASE_SHA, + expected_base_ref: BASE_REF, + transport_retry_attempt: 1, + }, + }); + return typeof dispatchResponse === "function" ? dispatchResponse() : dispatchResponse; + } + return new Response("unexpected request", { status: 500 }); + }); +} + +describe("fixed central dispatch", () => { + it("prepares the central credential before the external dispatch can begin", async () => { + const fetchSpy = mockCentralDispatch(new Response(null, { status: 204 })); + + const prepared = await prepareCentralContinuation(env()); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + await expect(prepared.send(request())).resolves.toEqual({ + outcome: "accepted", + upstreamStatus: 204, + eventType: "noema-review", + }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("classifies a missing central App installation as unavailable, never source-PR stale", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + expect(init?.redirect).toBe("error"); + const url = String(input); + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/installation`) { + return json({ id: 2002 }); + } + if (url === "https://api.github.com/app/installations/2002/access_tokens") { + return new Response(null, { status: 404 }); + } + return new Response("unexpected request", { status: 500 }); + }); + + await expect(dispatchCentralContinuation(request(), env())).rejects.toMatchObject({ + classification: "upstream_unavailable", + upstreamStatus: 404, + }); + }); + + it("sanitizes a central App installation-token network failure", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/installation`) { + return json({ id: 2002 }); + } + if (url === "https://api.github.com/app/installations/2002/access_tokens") { + throw new Error(`network failed near ${CENTRAL_TOKEN}`); + } + return new Response("unexpected request", { status: 500 }); + }); + + let failure: unknown; + try { + await dispatchCentralContinuation(request(), env()); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ classification: "upstream_unavailable" }); + expect(String(failure)).not.toContain(CENTRAL_TOKEN); + }); + + it("uses only the fixed repository, path, event, payload, and central App", async () => { + const fetchSpy = mockCentralDispatch(new Response(null, { status: 204 })); + + await expect(dispatchCentralContinuation(request(), env())).resolves.toEqual({ + outcome: "accepted", + upstreamStatus: 204, + eventType: "noema-review", + }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("fails the opaque prepared capability closed at its exact installation-token expiry", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00.000Z")); + const fetchSpy = mockCentralDispatch(new Response(null, { status: 204 })); + const prepared = await prepareCentralContinuation(env()); + vi.setSystemTime(new Date("2030-01-01T00:30:00.000Z")); + + expect(() => prepared.assertFresh()).toThrow(ContinuationGitHubAdapterError); + await expect(prepared.send(request())).rejects.toMatchObject({ + name: "ContinuationDispatchNotStartedError", + classification: "upstream_unavailable", + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("maps the released Strix action without caller-selected dispatch authority", async () => { + mockCentralDispatch(new Response(null, { status: 204 })).mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/installation")) return json({ id: 2002 }); + if (url.endsWith("/access_tokens")) return installationToken(CENTRAL_TOKEN); + expect(JSON.parse(String(init?.body))).toMatchObject({ event_type: "strix-scan" }); + return new Response(null, { status: 204 }); + }); + + await expect(dispatchCentralContinuation( + request({ dispatch_action: "strix_scan_continuation" }), + env(), + )).resolves.toMatchObject({ outcome: "accepted", eventType: "strix-scan" }); + }); + + it.each([403, 404, 422] as const)("persists GitHub dispatch status %i as denied", async (status) => { + mockCentralDispatch(new Response(null, { status })); + await expect(dispatchCentralContinuation(request(), env())).resolves.toEqual({ + outcome: "denied", + upstreamStatus: status, + eventType: "noema-review", + }); + }); + + it("persists GitHub dispatch 5xx as indeterminate", async () => { + mockCentralDispatch(new Response(null, { status: 503 })); + await expect(dispatchCentralContinuation(request(), env())).resolves.toEqual({ + outcome: "indeterminate", + upstreamStatus: 503, + eventType: "noema-review", + }); + }); + + it("persists redirect denial or another dispatch network failure as indeterminate without leaking its token", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + mockCentralDispatch(() => { + throw new TypeError(`redirect blocked for ${CENTRAL_TOKEN}`); + }); + + const result = await dispatchCentralContinuation(request(), env()); + expect(result).toEqual({ + outcome: "indeterminate", + eventType: "noema-review", + }); + expect(JSON.stringify(result)).not.toContain(CENTRAL_TOKEN); + expect(errorSpy.mock.calls.flat().join("\n")).not.toContain(CENTRAL_TOKEN); + }); +}); diff --git a/test/continuation-dispatch-receipt.test.ts b/test/continuation-dispatch-receipt.test.ts new file mode 100644 index 000000000..f5a66915e --- /dev/null +++ b/test/continuation-dispatch-receipt.test.ts @@ -0,0 +1,373 @@ +import { + createPrivateKey, + createPublicKey, + sign as nodeSign, + verify as nodeVerify, +} from "node:crypto"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + continuationRequestDigest, + type ContinuationDispatchRequest, +} from "../src/continuation-dispatch/contract"; +import { + signContinuationReceipt, + verifyContinuationReceipt, + type ContinuationReceiptFields, + type ContinuationReceiptSigningEnv, +} from "../src/continuation-dispatch/receipt"; + +const request: ContinuationDispatchRequest = { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/noema", + pull_request_number: 736, + expected_head_sha: "a".repeat(40), + expected_base_sha: "b".repeat(40), + expected_base_ref: "main", + transport_retry_attempt: 1, +}; + +const requestDigest = "444f21c4b7de67b163b30e7a45108340d32a4d7011000487cd177ff511a6bb62"; +const identity = "d".repeat(64); +const emittedPayloadDigest = "e".repeat(64); +const receiptId = "00000000-0000-4000-8000-000000000000"; +const traceId = "11111111-1111-4111-8111-111111111111"; +const keyId = "noema-continuation-receipt-2026-09"; +const issuedAt = 1_798_646_400; +const expiresAt = 1_798_647_000; + +let privateKeyPem: string; +let publicKeyPem: string; +let otherPublicKeyPem: string; + +function pem(label: "PRIVATE KEY" | "PUBLIC KEY", der: ArrayBuffer): string { + const base64 = Buffer.from(der).toString("base64"); + const body = base64.match(/.{1,64}/gu)?.join("\n") ?? base64; + return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----`; +} + +function decodeBase64Url(value: string): Buffer { + return Buffer.from(value, "base64url"); +} + +beforeAll(async () => { + expect(await continuationRequestDigest(request, { workflow_sha: "f".repeat(40) })) + .toBe(requestDigest); + const pair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + privateKeyPem = pem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", pair.privateKey)); + publicKeyPem = pem("PUBLIC KEY", await crypto.subtle.exportKey("spki", pair.publicKey)); + + const otherPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + otherPublicKeyPem = pem( + "PUBLIC KEY", + await crypto.subtle.exportKey("spki", otherPair.publicKey), + ); +}); + +function env(overrides: Partial = {}): ContinuationReceiptSigningEnv { + return { + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: privateKeyPem, + CONTINUATION_RECEIPT_SIGNING_KEY_ID: keyId, + ...overrides, + }; +} + +function fields(overrides: Partial = {}): ContinuationReceiptFields { + return { + request, + requestDigest, + idempotencyIdentity: identity, + workflowRef: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + workflowSha: "f".repeat(40), + emittedPayloadDigest, + dispatchResult: { + outcome: "accepted", + upstreamStatus: 204, + eventType: "noema-review", + }, + oidcLifetime: { iat: issuedAt, exp: expiresAt }, + receiptId, + traceId, + ...overrides, + }; +} + +describe("continuation dispatch signed receipt", () => { + it("signs the exact RFC 8785 flat receipt bytes with deterministic Ed25519", async () => { + const receipt = await signContinuationReceipt(fields(), env()); + const expectedCanonical = JSON.stringify({ + algorithm: "Ed25519", + central_repository: "ContextualWisdomLab/.github", + contract_version: "noema.continuation-dispatch.v1", + emitted_payload_digest: emittedPayloadDigest, + event_type: "noema-review", + expires_at: expiresAt, + idempotency_identity: identity, + issued_at: issuedAt, + key_id: keyId, + outcome: "accepted", + pull_request_number: 736, + receipt_id: receiptId, + receipt_version: "noema.continuation-dispatch-receipt.v1", + request_digest: requestDigest, + source_base_ref: "main", + source_base_sha: "b".repeat(40), + source_head_sha: "a".repeat(40), + source_repository: "ContextualWisdomLab/noema", + trace_id: traceId, + transport_retry_attempt: 1, + upstream_status: 204, + workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + workflow_sha: "f".repeat(40), + }); + const privateKey = createPrivateKey(privateKeyPem); + const publicKey = createPublicKey(publicKeyPem); + const expectedSignature = nodeSign(null, Buffer.from(expectedCanonical), privateKey); + + expect(decodeBase64Url(receipt.signature)).toEqual(expectedSignature); + expect(nodeVerify( + null, + Buffer.from(expectedCanonical), + publicKey, + decodeBase64Url(receipt.signature), + )).toBe(true); + expect((await signContinuationReceipt(fields(), env())).signature).toBe(receipt.signature); + }); + + it("verifies a signed receipt with the released public-key contract", async () => { + const receipt = await signContinuationReceipt(fields(), env()); + + await expect(verifyContinuationReceipt(receipt, publicKeyPem)).resolves.toBe(true); + await expect(verifyContinuationReceipt(receipt, otherPublicKeyPem)).resolves.toBe(false); + }); + + it("copies issued-at and expiry from the admitted OIDC lifetime without inventing a timeout", async () => { + const receipt = await signContinuationReceipt(fields({ + oidcLifetime: { iat: 1_798_650_001, exp: 1_798_650_777 }, + }), env()); + + expect(receipt.issued_at).toBe(1_798_650_001); + expect(receipt.expires_at).toBe(1_798_650_777); + }); + + it("preserves a valid supplementary-plane workflow ref", async () => { + const workflowRef = "ContextualWisdomLab/.github/.github/workflows/continuation-🚀.yml@refs/heads/main"; + const receipt = await signContinuationReceipt(fields({ workflowRef }), env()); + + expect(receipt.workflow_ref).toBe(workflowRef); + await expect(verifyContinuationReceipt(receipt, publicKeyPem)).resolves.toBe(true); + }); + + it("rejects a request digest that does not bind the canonical request and workflow SHA", async () => { + await expect(signContinuationReceipt(fields({ requestDigest: "c".repeat(64) }), env())) + .rejects.toThrow(/request digest/i); + }); + + it.each([ + [{ outcome: "denied", upstreamStatus: 403, eventType: "noema-review" }, 403], + [{ outcome: "denied", upstreamStatus: 404, eventType: "noema-review" }, 404], + [{ outcome: "denied", upstreamStatus: 422, eventType: "noema-review" }, 422], + [{ outcome: "indeterminate", eventType: "noema-review" }, null], + [{ outcome: "indeterminate", upstreamStatus: 503, eventType: "noema-review" }, 503], + ] as const)("signs the terminal dispatch result %j without retry authority", async (dispatchResult, status) => { + const receipt = await signContinuationReceipt(fields({ dispatchResult }), env()); + + expect(receipt.outcome).toBe(dispatchResult.outcome); + expect(receipt.upstream_status).toBe(status); + await expect(verifyContinuationReceipt(receipt, publicKeyPem)).resolves.toBe(true); + }); + + it.each([ + [null, /result/i], + [{ outcome: "accepted", eventType: "noema-review" }, /outcome/i], + [{ outcome: "accepted", upstreamStatus: 204, eventType: "strix-scan" }, /event type/i], + [{ outcome: "accepted", upstreamStatus: 204, eventType: "noema-review", token: "secret" }, /members/i], + [{ outcome: "denied", upstreamStatus: 401, eventType: "noema-review" }, /outcome/i], + [{ outcome: "indeterminate", upstreamStatus: 99, eventType: "noema-review" }, /outcome/i], + [{ outcome: "indeterminate", upstreamStatus: 600, eventType: "noema-review" }, /outcome/i], + [{ outcome: "indeterminate", upstreamStatus: 503.5, eventType: "noema-review" }, /outcome/i], + ])("rejects a dispatch result outside the fixed adapter contract: %j", async (dispatchResult, message) => { + await expect(signContinuationReceipt(fields({ + dispatchResult: dispatchResult as ContinuationReceiptFields["dispatchResult"], + }), env())).rejects.toThrow(message); + }); + + it.each([ + [{ iat: issuedAt, exp: issuedAt }, "ordered"], + [{ iat: issuedAt, exp: issuedAt - 1 }, "ordered"], + [{ iat: issuedAt + 0.5, exp: expiresAt }, "integer"], + [{ iat: issuedAt, exp: Number.POSITIVE_INFINITY }, "integer"], + ])("rejects an OIDC lifetime that is not a canonical positive interval: %j", async (oidcLifetime, _label) => { + await expect(signContinuationReceipt(fields({ oidcLifetime }), env())) + .rejects.toThrow(/OIDC lifetime/i); + }); + + it.each([ + ["outcome", { outcome: "denied" }], + ["request digest", { request_digest: "0".repeat(64) }], + ["expiry", { expires_at: expiresAt + 1 }], + ["event", { event_type: "strix-scan" }], + ["unknown member", { unexpected: "authority" }], + ])("fails verification after %s mutation", async (_label, mutation) => { + const receipt = await signContinuationReceipt(fields(), env()); + const mutated = { ...receipt, ...mutation }; + + await expect(verifyContinuationReceipt(mutated, publicKeyPem)).resolves.toBe(false); + }); + + it.each([ + ["algorithm", { algorithm: "ES256" }], + ["central repository", { central_repository: "ContextualWisdomLab/noema" }], + ["contract version", { contract_version: "noema.continuation-dispatch.v2" }], + ["receipt version", { receipt_version: "noema.continuation-dispatch-receipt.v2" }], + ["payload digest type", { emitted_payload_digest: 7 }], + ["payload digest shape", { emitted_payload_digest: "0" }], + ["event type", { event_type: "arbitrary-event" }], + ["issued-at type", { issued_at: "now" }], + ["expiry type", { expires_at: "later" }], + ["issued-at sign", { issued_at: 0 }], + ["expiry order", { expires_at: issuedAt }], + ["idempotency type", { idempotency_identity: 8 }], + ["idempotency shape", { idempotency_identity: "0" }], + ["key-id type", { key_id: 9 }], + ["key-id shape", { key_id: "bad key" }], + ["outcome", { outcome: "unknown" }], + ["PR type", { pull_request_number: "736" }], + ["PR sign", { pull_request_number: 0 }], + ["receipt-id type", { receipt_id: 1 }], + ["receipt-id shape", { receipt_id: "receipt" }], + ["request-digest type", { request_digest: 2 }], + ["request-digest shape", { request_digest: "digest" }], + ["base-ref type", { source_base_ref: 3 }], + ["base-ref empty", { source_base_ref: "" }], + ["base-SHA type", { source_base_sha: 4 }], + ["base-SHA shape", { source_base_sha: "b" }], + ["head-SHA type", { source_head_sha: 5 }], + ["head-SHA shape", { source_head_sha: "a" }], + ["source repository", { source_repository: 6 }], + ["trace type", { trace_id: 7 }], + ["trace shape", { trace_id: "bad trace" }], + ["retry attempt", { transport_retry_attempt: 3 }], + ["workflow-ref type", { workflow_ref: 8 }], + ["workflow-ref shape", { workflow_ref: "bad\nref" }], + ["workflow-SHA type", { workflow_sha: 9 }], + ["workflow-SHA shape", { workflow_sha: "f" }], + ])("fails closed when the signed %s schema is malformed", async (_label, mutation) => { + const receipt = await signContinuationReceipt(fields(), env()); + + await expect(verifyContinuationReceipt({ ...receipt, ...mutation }, publicKeyPem)) + .resolves.toBe(false); + }); + + it("rejects outcome/status combinations that are impossible for the fixed adapter", async () => { + const accepted = await signContinuationReceipt(fields(), env()); + const denied = await signContinuationReceipt(fields({ + dispatchResult: { outcome: "denied", upstreamStatus: 403, eventType: "noema-review" }, + }), env()); + const indeterminate = await signContinuationReceipt(fields({ + dispatchResult: { outcome: "indeterminate", eventType: "noema-review" }, + }), env()); + + for (const candidate of [ + { ...accepted, upstream_status: 200 }, + { ...denied, upstream_status: 401 }, + { ...indeterminate, upstream_status: "503" }, + { ...indeterminate, upstream_status: 99 }, + { ...indeterminate, upstream_status: 600 }, + ]) { + await expect(verifyContinuationReceipt(candidate, publicKeyPem)).resolves.toBe(false); + } + }); + + it("returns a credential-free receipt and rejects secret-shaped input members", async () => { + const signingEnv = env(); + const receipt = await signContinuationReceipt(fields(), signingEnv); + const serialized = JSON.stringify(receipt); + + expect(serialized).not.toContain(signingEnv.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM); + expect(serialized).not.toMatch(/token|bearer|assertion|private[_-]?key/iu); + + for (const secretMember of ["token", "bearer", "assertion", "private_key"] as const) { + await expect(signContinuationReceipt({ + ...fields(), + [secretMember]: "must-not-cross", + } as ContinuationReceiptFields, signingEnv)).rejects.toThrow(/members/i); + } + }); + + it("uses only the dedicated bindings when the Worker passes its composite environment", async () => { + const receipt = await signContinuationReceipt(fields(), { + ...env(), + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_PRIVATE_KEY_PEM: "must-not-cross", + } as ContinuationReceiptSigningEnv); + + expect(receipt.key_id).toBe(keyId); + expect(JSON.stringify(receipt)).not.toContain("must-not-cross"); + }); + + it.each([ + [() => null, () => env(), /fields/i], + [() => [], () => env(), /fields/i], + [() => fields(), () => null, /environment/i], + [() => ({ ...fields(), requestDigest: "0" }), () => env(), /request identity/i], + [() => ({ ...fields(), idempotencyIdentity: "0" }), () => env(), /request identity/i], + [() => ({ ...fields(), emittedPayloadDigest: "0" }), () => env(), /payload digest/i], + [() => ({ ...fields(), workflowRef: 8 }), () => env(), /workflow ref/i], + [() => ({ ...fields(), workflowRef: "bad ref" }), () => env(), /workflow ref/i], + [() => ({ ...fields(), workflowRef: "valid\ud800" }), () => env(), /workflow ref/i], + [() => ({ ...fields(), workflowRef: "valid\udc00" }), () => env(), /workflow ref/i], + [() => ({ ...fields(), receiptId: "receipt" }), () => env(), /receipt or trace/i], + [() => ({ ...fields(), traceId: "bad trace" }), () => env(), /receipt or trace/i], + ])("rejects non-canonical signing input", async (fieldsFactory, envFactory, message) => { + await expect(signContinuationReceipt( + fieldsFactory() as ContinuationReceiptFields, + envFactory() as ContinuationReceiptSigningEnv, + )).rejects.toThrow(message); + }); + + it.each(["", " key", "key ", "key\nline", "\u0000key", ["key"]])( + "rejects a non-canonical signing key id %j", + async (candidate) => { + await expect(signContinuationReceipt(fields(), env({ + CONTINUATION_RECEIPT_SIGNING_KEY_ID: candidate, + }))).rejects.toThrow(/key id/i); + }, + ); + + it("rejects a non-PKCS8 signing key and fails closed for a non-SPKI verification key", async () => { + await expect(signContinuationReceipt(fields(), env({ + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: publicKeyPem, + }))).rejects.toThrow(/private key/i); + await expect(verifyContinuationReceipt( + await signContinuationReceipt(fields(), env()), + privateKeyPem, + )).resolves.toBe(false); + + await expect(signContinuationReceipt(fields(), env({ + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: publicKeyPem + .replaceAll("PUBLIC KEY", "PRIVATE KEY"), + }))).rejects.toThrow(/private key or operation/i); + await expect(verifyContinuationReceipt( + await signContinuationReceipt(fields(), env()), + privateKeyPem.replaceAll("PRIVATE KEY", "PUBLIC KEY"), + )).resolves.toBe(false); + }); + + it("fails closed for malformed PEM and detached-signature encodings", async () => { + const receipt = await signContinuationReceipt(fields(), env()); + const malformedPem = "-----BEGIN PUBLIC KEY-----\nAA\n-----END PUBLIC KEY-----"; + const invalidPaddedPem = "-----BEGIN PUBLIC KEY-----\nA===\n-----END PUBLIC KEY-----"; + + await expect(verifyContinuationReceipt(receipt, null as unknown as string)).resolves.toBe(false); + await expect(verifyContinuationReceipt(receipt, malformedPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt(receipt, invalidPaddedPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt(null, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: 1 }, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: "=" }, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: "A" }, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: "_x" }, publicKeyPem)).resolves.toBe(false); + }); +}); diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts new file mode 100644 index 000000000..cb86983ac --- /dev/null +++ b/test/continuation-dispatch-state.test.ts @@ -0,0 +1,1229 @@ +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + abortContinuationReservation, + ContinuationDispatchStateConflict, + ContinuationDispatchStateUnavailable, + NoemaContinuationDispatchState, + commitContinuationOutcome, + finalizeContinuationOutcome, + releasePreEffectContinuationOutcome, + reserveContinuationDispatch, + type ContinuationDispatchReservation, + type ContinuationDispatchStateEnv, +} from "../src/continuation-dispatch/dispatch-state"; +import * as runtimeEntrypoint from "../src/runtime-entrypoint"; + +const identity = "a".repeat(64); +const digest = "b".repeat(64); +const reservationExpiresAtEpochSeconds = Math.floor(Date.now() / 1_000) + 3_600; +const endpoint = "https://noema-continuation-dispatch-state.internal/command"; + +class MemoryStorage { + readonly records = new Map(); + transactionCount = 0; + alarmAt: number | null = null; + + async get(key: string): Promise { + return structuredClone(this.records.get(key)) as T | undefined; + } + + async put(key: string, value: T): Promise { + this.records.set(key, structuredClone(value)); + } + + async delete(key: string): Promise { + return this.records.delete(key); + } + + async transaction(callback: (transaction: MemoryStorage) => Promise): Promise { + this.transactionCount += 1; + return callback(this); + } + + async getAlarm(): Promise { return this.alarmAt; } + + async setAlarm(scheduledTime: number | Date): Promise { + this.alarmAt = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime; + } + + async deleteAlarm(): Promise { this.alarmAt = null; } + replaceOnlyRecord(value: unknown): void { + const key = [...this.records.keys()][0]; + if (key === undefined) throw new Error("fixture has no continuation state record"); + this.records.set(key, structuredClone(value)); + } +} + +class InProcessNamespace { + readonly storageByName = new Map(); + + idFromName(name: string): DurableObjectId { + return { name, toString: () => name } as unknown as DurableObjectId; + } + + get(objectId: DurableObjectId): DurableObjectStub { + const name = objectId.toString(); + const storage = this.storageByName.get(name) ?? new MemoryStorage(); + this.storageByName.set(name, storage); + const object = new NoemaContinuationDispatchState({ + id: { name } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + return { + fetch: (input: RequestInfo | URL, init?: RequestInit) => object.fetch(new Request(input, init)), + } as unknown as DurableObjectStub; + } +} + +function fixture(): { + env: ContinuationDispatchStateEnv; + namespace: InProcessNamespace; +} { + const namespace = new InProcessNamespace(); + return { + env: { + NOEMA_CONTINUATION_DISPATCH_STATE: namespace as unknown as DurableObjectNamespace, + }, + namespace, + }; +} + +function requireNewReservation( + reservation: ContinuationDispatchReservation, +): Extract { + if (reservation.kind !== "reserved") { + throw new Error(`expected a new reservation, received ${reservation.kind}`); + } + return reservation; +} + +function fixedReservation(): Extract { + return { + kind: "reserved", + identity, + digest, + reservationId: "00000000-0000-4000-8000-000000000000", + }; +} + +function envWithTransport( + fetch: DurableObjectStub["fetch"], + idFromName: (name: string) => DurableObjectId = (name) => ( + { name, toString: () => name } as unknown as DurableObjectId + ), +): ContinuationDispatchStateEnv { + return { + NOEMA_CONTINUATION_DISPATCH_STATE: { + idFromName, + get: () => ({ fetch }) as DurableObjectStub, + } as unknown as DurableObjectNamespace, + }; +} + +describe("continuation dispatch exactly-once state", () => { + afterEach(() => vi.restoreAllMocks()); + + it("schedules reservation recovery at the exact OIDC authorization expiry", async () => { + const { env, namespace } = fixture(); + + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + + expect(namespace.storageByName.get(`continuation:${identity}`)?.alarmAt) + .toBe(reservationExpiresAtEpochSeconds * 1_000); + }); + + it("rejects a reservation whose exact OIDC authorization has already expired", async () => { + const { env, namespace } = fixture(); + vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + expect(storage.records.size).toBe(0); + expect(storage.alarmAt).toBeNull(); + }); + + it("removes an expired retained reservation before rejecting its stale authority", async () => { + const { env, namespace } = fixture(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + + expect(storage.records.size).toBe(0); + expect(storage.alarmAt).toBeNull(); + }); + + it("creates a fresh reservation after expired retained authority is removed", async () => { + const { env, namespace } = fixture(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + const first = requireNewReservation(await reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + const freshExpiry = reservationExpiresAtEpochSeconds + 60; + + const replacement = requireNewReservation(await reserveContinuationDispatch( + env, + identity, + digest, + freshExpiry, + )); + + expect(replacement.reservationId).not.toBe(first.reservationId); + expect(storage.records.size).toBe(1); + expect(storage.alarmAt).toBe(freshExpiry * 1_000); + }); + + it("cannot commit after exact OIDC expiry even when the alarm is delayed", async () => { + const { env, namespace } = fixture(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + const reservation = requireNewReservation(await reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await expect(commitContinuationOutcome(env, reservation, { + outcome: "indeterminate", + receipt_id: "expired-authority", + })).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + + expect(storage.records.size).toBe(0); + expect(storage.alarmAt).toBeNull(); + }); + + it("releases an expired reserved state so a crashed pre-dispatch attempt can be retried", async () => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await state.alarm(); + dateNow.mockRestore(); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).resolves.toMatchObject({ kind: "reserved", identity, digest }); + }); + + it("reschedules a non-expired reservation at its exact retained expiry", async () => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + storage.alarmAt = null; + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const dateNow = vi.spyOn(Date, "now").mockReturnValue( + reservationExpiresAtEpochSeconds * 1_000 - 1, + ); + + await state.alarm(); + dateNow.mockRestore(); + + expect(storage.records.size).toBe(1); + expect(storage.alarmAt).toBe(reservationExpiresAtEpochSeconds * 1_000); + }); + + it("preserves immutable terminal evidence when the reservation alarm fires", async () => { + const { env, namespace } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )); + await commitContinuationOutcome(env, reservation, { outcome: "accepted", receipt_id: "retained" }); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await state.alarm(); + dateNow.mockRestore(); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).resolves.toMatchObject({ kind: "replay", outcome: "accepted" }); + }); + + it("grants only the first atomic reservation while an exact concurrent replay stays in progress", async () => { + const { env, namespace } = fixture(); + + const first = await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const replay = await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + + expect(first).toMatchObject({ kind: "reserved", identity, digest }); + expect(first).toHaveProperty("reservationId"); + expect(replay).toEqual({ kind: "in_progress", identity, digest }); + expect(namespace.storageByName.get(`continuation:${identity}`)?.transactionCount).toBe(2); + expect(namespace.storageByName.get(`continuation:${identity}`)?.alarmAt).not.toBeNull(); + }); + + it("removes only an expired reservation when its alarm fires", async () => { + const storage = new MemoryStorage(); + const object = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + vi.spyOn(Date, "now").mockReturnValue(1_000); + await object.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "reserve", + identity, + digest, + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), + })); + expect(storage.records.size).toBe(1); + vi.spyOn(Date, "now").mockReturnValue(storage.alarmAt! + 1); + + await object.alarm(); + + expect(storage.records.size).toBe(0); + expect(storage.alarmAt).toBeNull(); + }); + + it("keeps malformed and unexpired state fail closed while clearing irrelevant alarms", async () => { + const makeObject = (storage: MemoryStorage) => new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + + const empty = new MemoryStorage(); + empty.alarmAt = 10; + await makeObject(empty).alarm(); + expect(empty.alarmAt).toBeNull(); + + const malformed = new MemoryStorage(); + malformed.records.set("continuation-dispatch-state", { status: "reserved" }); + malformed.alarmAt = 20; + await expect(makeObject(malformed).alarm()).rejects.toThrow( + "continuation dispatch alarm found invalid retained state", + ); + expect(malformed.records.size).toBe(1); + expect(malformed.alarmAt).toBe(20); + + const terminal = new MemoryStorage(); + terminal.records.set("continuation-dispatch-state", { + version: "noema.continuation-dispatch-state.v1", + identity, + request_digest: digest, + status: "accepted", + reservation_id: "00000000-0000-4000-8000-000000000000", + receipt: { outcome: "accepted" }, + }); + terminal.alarmAt = 30; + await makeObject(terminal).alarm(); + expect(terminal.records.size).toBe(1); + expect(terminal.alarmAt).toBeNull(); + + const unexpired = new MemoryStorage(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + await makeObject(unexpired).fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "reserve", + identity, + digest, + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), + })); + const scheduled = unexpired.alarmAt; + await makeObject(unexpired).alarm(); + expect(unexpired.records.size).toBe(1); + expect(unexpired.alarmAt).toBe(scheduled); + }); + + it.each(["accepted", "denied", "indeterminate"] as const)( + "persists an exact %s outcome and replays its receipt without a new dispatch reservation", + async (outcome) => { + const { env, namespace } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + const receipt = { + outcome, + receipt_id: `receipt-${outcome}`, + request_digest: digest, + } as const; + + await commitContinuationOutcome(env, reservation, receipt); + const replay = await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + + expect(replay).toEqual({ kind: "replay", identity, digest, outcome, receipt }); + expect(namespace.storageByName.get(`continuation:${identity}`)?.transactionCount).toBe(3); + }, + ); + + it.each(["accepted", "denied", "indeterminate"] as const)( + "replays a legacy v1 terminal %s record while legacy reservations remain fail closed", + async (outcome) => { + const { env, namespace } = fixture(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + const receipt = { outcome, receipt_id: `legacy-${outcome}` } as const; + storage.replaceOnlyRecord({ + version: "noema.continuation-dispatch-state.v1", + identity, + request_digest: digest, + status: outcome, + reservation_id: "00000000-0000-4000-8000-000000000000", + receipt, + }); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).resolves.toEqual({ kind: "replay", identity, digest, outcome, receipt }); + }, + ); + + it("rejects the same logical identity with a different request digest without changing retained state", async () => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + const retained = structuredClone([...storage.records.values()][0]); + + await expect(reserveContinuationDispatch(env, identity, "c".repeat(64), reservationExpiresAtEpochSeconds)) + .rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + expect([...storage.records.values()][0]).toEqual(retained); + }); + + it("releases only the exact fresh reservation before an external effect", async () => { + const { env } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); + + await abortContinuationReservation(env, reservation); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toMatchObject({ + kind: "reserved", + identity, + digest, + }); + }); + + it("releases only the exact committed indeterminate receipt when dispatch never started", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + const receipt = { + outcome: "indeterminate" as const, + receipt_id: "pre-effect-receipt", + request_digest: digest, + signature: "pre-effect-signature", + }; + await commitContinuationOutcome(env, reservation, receipt); + await releasePreEffectContinuationOutcome(env, reservation, receipt); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) + .resolves.toMatchObject({ kind: "reserved", identity, digest }); + }); + + it("retains committed evidence when the pre-effect receipt does not match exactly", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + const receipt = { + outcome: "indeterminate" as const, + receipt_id: "retained-receipt", + signature: "retained-signature", + }; + await commitContinuationOutcome(env, reservation, receipt); + + await expect(releasePreEffectContinuationOutcome(env, reservation, { + ...receipt, + signature: "different-signature", + })).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) + .resolves.toMatchObject({ kind: "replay", outcome: "indeterminate", receipt }); + }); + + it("upgrades retained indeterminate evidence without changing immutable receipt authority", async () => { + const { env } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); + const pending = { + outcome: "indeterminate" as const, + receipt_id: "same-receipt", + request_digest: digest, + upstream_status: null, + signature: "pending-signature", + }; + const accepted = { + ...pending, + outcome: "accepted" as const, + upstream_status: 204, + signature: "accepted-signature", + }; + + await commitContinuationOutcome(env, reservation, pending); + await finalizeContinuationOutcome(env, reservation, accepted); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ + kind: "replay", + identity, + digest, + outcome: "accepted", + receipt: accepted, + }); + }); + + it("requires the original reservation capability before committing a terminal outcome", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + + await expect(commitContinuationOutcome(env, { + ...reservation, + reservationId: crypto.randomUUID(), + }, { + outcome: "accepted", + receipt_id: "wrong-capability", + })).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ + kind: "in_progress", + identity, + digest, + }); + }); + + it.each([ + null, + "corrupt", + {}, + { version: "wrong", identity, request_digest: digest, status: "reserved", reservation_id: crypto.randomUUID() }, + { version: "noema.continuation-dispatch-state.v1", identity: "A".repeat(64), request_digest: digest, status: "reserved", reservation_id: crypto.randomUUID() }, + { version: "noema.continuation-dispatch-state.v1", identity, request_digest: digest, status: "accepted", reservation_id: crypto.randomUUID() }, + { version: "noema.continuation-dispatch-state.v1", identity, request_digest: digest, status: "reserved", reservation_id: crypto.randomUUID(), receipt: {} }, + ])("fails closed without replacing malformed retained state %#", async (corrupt) => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + storage.replaceOnlyRecord(corrupt); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + expect([...storage.records.values()][0]).toEqual(corrupt); + }); + + it("rejects an oversized receipt while preserving the in-progress reservation", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + + await expect(commitContinuationOutcome(env, reservation, { + outcome: "accepted", + evidence: "x".repeat(16_384), + })).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ + kind: "in_progress", + identity, + digest, + }); + }); + + it("retains nested JSON receipt evidence without changing its semantic structure", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + const receipt = { + outcome: "accepted", + evidence: [{ claim: "verified", samples: [1, true, null, "exact"] }], + } as const; + + await commitContinuationOutcome(env, reservation, receipt); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ + kind: "replay", + identity, + digest, + outcome: "accepted", + receipt, + }); + }); + + it("fails closed when receipt property access is not deterministic JSON", async () => { + const receipt = Object.defineProperty({ outcome: "accepted" }, "evidence", { + enumerable: true, + get: () => { + throw new Error("getter failed"); + }, + }); + + await expect(commitContinuationOutcome( + envWithTransport(async () => new Response()), + fixedReservation(), + receipt, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([ + { outcome: "accepted", evidence: undefined }, + { outcome: "accepted", evidence: new Date(0) }, + { outcome: "accepted", evidence: Number.NaN }, + (() => { + const receipt: Record = { outcome: "accepted" }; + receipt.evidence = receipt; + return receipt; + })(), + ])("rejects non-JSON receipt values %#", async (receipt) => { + await expect(commitContinuationOutcome( + envWithTransport(async () => new Response()), + fixedReservation(), + receipt as { readonly outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("fails closed when receipt serialization changes after validation", async () => { + let reads = 0; + const receipt = Object.defineProperty({ outcome: "accepted" }, "evidence", { + enumerable: true, + get: () => { + reads += 1; + if (reads === 1) return "initial"; + throw new Error("serialization changed"); + }, + }); + + await expect(commitContinuationOutcome( + envWithTransport(async () => new Response()), + fixedReservation(), + receipt, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); +}); + +describe("continuation dispatch private command boundary", () => { + function object(): NoemaContinuationDispatchState { + return new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: new MemoryStorage() as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + } + + it.each([ + new Request(endpoint, { method: "GET" }), + new Request(`${endpoint}/extra`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }), + ])("rejects a wrong endpoint or method", async (request) => { + const response = await object().fetch(request); + expect(response.status).toBe(404); + }); + + it("rejects non-JSON, malformed, duplicate, open, and oversized commands before storage", async () => { + const requests = [ + new Request(endpoint, { method: "POST", body: "{}" }), + new Request(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: "{" }), + new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"operation":"reserve","operation":"commit","identity":"${identity}","digest":"${digest}"}`, + }), + new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest, extra: true }), + }), + new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest, padding: "x".repeat(16_384) }), + }), + ]; + + const statuses: number[] = []; + for (const request of requests) statuses.push((await object().fetch(request)).status); + + expect(statuses).toEqual([415, 400, 400, 400, 413]); + }); + + it("rejects non-canonical command variants before they can reach storage", async () => { + const reservationId = fixedReservation().reservationId; + const requests = [ + { operation: "commit", identity, digest, reservation_id: reservationId, receipt: { outcome: "accepted" }, extra: true }, + { operation: "commit", identity, digest, reservation_id: reservationId, receipt: { outcome: "pending" } }, + { operation: "unknown", identity, digest }, + { identity, digest }, + ].map((body) => new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + })); + + for (const request of requests) { + await expect(object().fetch(request).then((response) => response.status)).resolves.toBe(400); + } + }); + + it("rejects malformed abort and finalize capabilities before storage", async () => { + const reservationId = fixedReservation().reservationId; + const requests = [ + { operation: "abort", identity, digest, reservation_id: reservationId, extra: true }, + { operation: "abort", identity: "invalid", digest, reservation_id: reservationId }, + { operation: "finalize", identity, digest, reservation_id: reservationId, receipt: { outcome: "accepted" }, extra: true }, + { operation: "finalize", identity, digest, reservation_id: reservationId, receipt: { outcome: "indeterminate" } }, + { operation: "release_pre_effect", identity, digest, reservation_id: reservationId, receipt: { outcome: "accepted" } }, + { operation: "release_pre_effect", identity, digest, reservation_id: reservationId, receipt: { outcome: "indeterminate" }, extra: true }, + ].map((body) => new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + })); + + for (const request of requests) { + await expect(object().fetch(request).then((response) => response.status)).resolves.toBe(400); + } + }); + + it("rejects decoded duplicate keys inside escaped nested array evidence", async () => { + const body = `{"operation":"commit","identity":"${identity}","digest":"${digest}",` + + `"reservation_id":"${fixedReservation().reservationId}",` + + '"receipt":{"outcome":"accepted","items":[{"key":1,"k\\u0065y":2}]}}'; + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + })); + + expect(response.status).toBe(400); + }); + + it("accepts insignificant whitespace while scanning decoded object keys", async () => { + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"operation" :"reserve","identity":"${identity}","digest":"${digest}",` + + `"reservation_expires_at":${reservationExpiresAtEpochSeconds}}`, + })); + + expect(response.status).toBe(201); + }); + + it.each(["8193", "01", "invalid"])( + "rejects invalid or over-limit declared request length %s", + async (contentLength) => { + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": contentLength, + }, + body: "{}", + })); + + expect(response.status).toBe(413); + }, + ); + + it("rejects an invalid declared length and an absent request body without cancellation", async () => { + const invalidLength = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json", "content-length": "invalid" }, + })); + const noBody = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + })); + + expect([invalidLength.status, noBody.status]).toEqual([413, 400]); + }); + + it("rejects a reserve command with a non-canonical digest", async () => { + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "reserve", + identity, + digest: "invalid", + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), + })); + + expect(response.status).toBe(400); + }); + + it("rejects a non-JSON request with no body", async () => { + const response = await object().fetch(new Request(endpoint, { method: "POST" })); + + expect(response.status).toBe(415); + }); + + it("fails closed when the request body stream cannot be read", async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error("request stream failed")); + }, + }); + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + duplex: "half", + } as RequestInit & { duplex: "half" })); + + expect(response.status).toBe(400); + }); + + it("fails closed when the request body reader cannot be acquired", async () => { + const request = new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: new ReadableStream({}), + duplex: "half", + } as RequestInit & { duplex: "half" }); + const lock = request.body!.getReader(); + + await expect(object().fetch(request).then((response) => response.status)).resolves.toBe(400); + lock.releaseLock(); + }); + + it("maps storage transaction failure to a closed unavailable response", async () => { + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: { + transaction: async () => { + throw new Error("storage failed"); + }, + } as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const response = await state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "reserve", + identity, + digest, + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), + })); + + expect(response.status).toBe(503); + }); + + it("distinguishes absent and corrupt retained state during commit", async () => { + const reservation = fixedReservation(); + const command = JSON.stringify({ + operation: "commit", + identity, + digest, + reservation_id: reservation.reservationId, + receipt: { outcome: "accepted" }, + }); + const empty = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: command, + })); + + const storage = new MemoryStorage(); + storage.records.set("continuation-dispatch-state", "corrupt"); + const corrupt = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const invalid = await corrupt.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: command, + })); + + expect([empty.status, invalid.status]).toEqual([409, 500]); + }); + + it.each(["abort", "finalize"] as const)( + "distinguishes absent, corrupt, and mismatched retained state during %s", + async (operation) => { + const reservation = fixedReservation(); + const command = { + operation, + identity, + digest, + reservation_id: reservation.reservationId, + ...(operation === "finalize" ? { receipt: { outcome: "accepted" } } : {}), + }; + const invoke = (state: NoemaContinuationDispatchState) => state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(command), + })); + + const absent = await invoke(object()); + const corruptStorage = new MemoryStorage(); + corruptStorage.records.set("continuation-dispatch-state", "corrupt"); + const corrupt = await invoke(new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: corruptStorage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState)); + const { env } = fixture(); + const fresh = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); + if (operation === "finalize") { + await commitContinuationOutcome(env, fresh, { outcome: "accepted" }); + } else { + await commitContinuationOutcome(env, fresh, { outcome: "indeterminate" }); + } + const namespace = env.NOEMA_CONTINUATION_DISPATCH_STATE as unknown as InProcessNamespace; + const mismatched = await invoke(new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: namespace.storageByName.get(`continuation:${identity}`)! as unknown as DurableObjectStorage, + } as unknown as DurableObjectState)); + + expect([absent.status, corrupt.status, mismatched.status]).toEqual([409, 500, 409]); + }, + ); + + it("distinguishes absent and corrupt retained state during pre-effect release", async () => { + const reservation = fixedReservation(); + const command = JSON.stringify({ + operation: "release_pre_effect", + identity, + digest, + reservation_id: reservation.reservationId, + receipt: { outcome: "indeterminate" }, + }); + const invoke = (state: NoemaContinuationDispatchState) => state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: command, + })); + const absent = await invoke(object()); + const corruptStorage = new MemoryStorage(); + corruptStorage.records.set("continuation-dispatch-state", "corrupt"); + const corrupt = await invoke(new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: corruptStorage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState)); + + expect([absent.status, corrupt.status]).toEqual([409, 500]); + }); + + it("rejects a finalized receipt that would exceed the retained-state byte envelope", async () => { + const storage = new MemoryStorage(); + const reservation = fixedReservation(); + storage.records.set("continuation-dispatch-state", { + version: "noema.continuation-dispatch-state.v1", + identity, + request_digest: digest, + status: "indeterminate", + reservation_id: reservation.reservationId, + receipt: { outcome: "indeterminate", receipt_id: "same", signature: "small" }, + }); + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const response = await state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "finalize", + identity, + digest, + reservation_id: reservation.reservationId, + receipt: { outcome: "accepted", receipt_id: "same", signature: "x".repeat(7_850) }, + }), + })); + + expect(response.status).toBe(500); + }); + + it("rejects a terminal record whose retained state crosses the byte envelope", async () => { + const { env } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); + const receipt = { outcome: "accepted", evidence: "x".repeat(7_900) } as const; + + await expect(commitContinuationOutcome(env, reservation, receipt)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("exports and declares the SQLite Durable Object binding", () => { + expect(runtimeEntrypoint.NoemaContinuationDispatchState) + .toBe(NoemaContinuationDispatchState); + const wrangler = readFileSync(new URL("../wrangler.toml", import.meta.url), "utf8"); + expect(wrangler).toContain('name = "NOEMA_CONTINUATION_DISPATCH_STATE"'); + expect(wrangler).toContain('class_name = "NoemaContinuationDispatchState"'); + expect(wrangler).toContain('[exports.NoemaContinuationDispatchState]\ntype = "durable-object"\nstorage = "sqlite"'); + }); +}); + +describe("continuation dispatch state client boundary", () => { + it.each([ + new Response("not json", { status: 200, headers: { "content-type": "text/plain" } }), + new Response(null, { status: 200, headers: { "content-type": "text/plain" } }), + new Response("{}", { status: 200, headers: { "content-type": "application/json", "content-length": "8193" } }), + new Response(null, { status: 200, headers: { "content-type": "application/json", "content-length": "8193" } }), + new Response(null, { status: 200, headers: { "content-type": "application/json" } }), + new Response("{", { status: 200, headers: { "content-type": "application/json" } }), + new Response('{"ok":true,"ok":false}', { status: 200, headers: { "content-type": "application/json" } }), + ])("rejects an untrustworthy reservation response %#", async (response) => { + await expect(reserveContinuationDispatch( + envWithTransport(async () => response), + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("rejects a streamed reservation response that exceeds the byte envelope", async () => { + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("x".repeat(8_193))); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + + await expect(reserveContinuationDispatch( + envWithTransport(async () => response), + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("normalizes decision reader acquisition failure", async () => { + const response = new Response(new ReadableStream({}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + const lock = response.body!.getReader(); + + await expect(reserveContinuationDispatch( + envWithTransport(async () => response), + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + lock.releaseLock(); + }); + + it.each(["identity", "digest"] as const)("rejects an invalid %s before transport", async (field) => { + const env = envWithTransport(async () => { + throw new Error("transport must not run"); + }); + + await expect(reserveContinuationDispatch( + env, + field === "identity" ? "invalid" : identity, + field === "digest" ? "invalid" : digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER])( + "rejects a non-canonical reservation expiry %s before transport", + async (reservationExpiry) => { + const env = envWithTransport(async () => { + throw new Error("transport must not run"); + }); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiry)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([new Error("namespace failed"), "namespace failed"])( + "maps namespace lookup failure %# without leaking transport details", + async (failure) => { + const env = envWithTransport( + async () => new Response(), + () => { + throw failure; + }, + ); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps reservation transport failure %# to unavailable", + async (failure) => { + await expect(reserveContinuationDispatch( + envWithTransport(async () => { + throw failure; + }), + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it("rejects invalid and mismatched successful reservation decisions", async () => { + const invalid = new Response(JSON.stringify({ ok: true, data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + await expect(reserveContinuationDispatch( + envWithTransport(async () => invalid), + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + + const mismatched = new Response(JSON.stringify({ + ok: true, + data: { kind: "in_progress", identity: "c".repeat(64), digest }, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + await expect(reserveContinuationDispatch( + envWithTransport(async () => mismatched), + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps commit transport failure %# to unavailable", + async (failure) => { + await expect(commitContinuationOutcome( + envWithTransport(async () => { + throw failure; + }), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([ + new Response(JSON.stringify({ ok: false }), { status: 200, headers: { "content-type": "application/json" } }), + new Response(JSON.stringify({ ok: true }), { status: 201, headers: { "content-type": "application/json" } }), + ])("rejects an invalid commit decision %#", async (response) => { + await expect(commitContinuationOutcome( + envWithTransport(async () => response), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([ + ["abort", abortContinuationReservation, fixedReservation(), undefined], + ["finalize", finalizeContinuationOutcome, fixedReservation(), { outcome: "accepted" }], + ] as const)("rejects a non-canonical %s request before transport", async (_name, operation, reservation, receipt) => { + const invalid = { ...reservation, identity: "invalid" }; + const pending = receipt === undefined + ? operation(envWithTransport(async () => new Response()), invalid as never) + : operation(envWithTransport(async () => new Response()), invalid as never, receipt as never); + + await expect(pending).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps abort transport failure %# to unavailable", + async (failure) => { + await expect(abortContinuationReservation( + envWithTransport(async () => { throw failure; }), + fixedReservation(), + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps finalize transport failure %# to unavailable", + async (failure) => { + await expect(finalizeContinuationOutcome( + envWithTransport(async () => { throw failure; }), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it("rejects a non-canonical pre-effect release before transport", async () => { + await expect(releasePreEffectContinuationOutcome( + envWithTransport(async () => { throw new Error("transport must not run"); }), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps pre-effect release transport failure %# to unavailable", + async (failure) => { + await expect(releasePreEffectContinuationOutcome( + envWithTransport(async () => { throw failure; }), + fixedReservation(), + { outcome: "indeterminate" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it("rejects a malformed successful pre-effect release decision", async () => { + await expect(releasePreEffectContinuationOutcome( + envWithTransport(async () => new Response( + JSON.stringify({ ok: false }), + { status: 200, headers: { "content-type": "application/json" } }, + )), + fixedReservation(), + { outcome: "indeterminate" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([ + ["abort", abortContinuationReservation, undefined], + ["finalize", finalizeContinuationOutcome, { outcome: "accepted" }], + ] as const)("maps %s conflicts and malformed success decisions", async (_name, operation, receipt) => { + for (const response of [ + new Response(JSON.stringify({ ok: false }), { status: 409, headers: { "content-type": "application/json" } }), + new Response(JSON.stringify({ ok: false }), { status: 200, headers: { "content-type": "application/json" } }), + ]) { + const pending = receipt === undefined + ? operation(envWithTransport(async () => response), fixedReservation()) + : operation(envWithTransport(async () => response), fixedReservation(), receipt); + await expect(pending).rejects.toBeInstanceOf( + response.status === 409 ? ContinuationDispatchStateConflict : ContinuationDispatchStateUnavailable, + ); + } + }); +}); diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts new file mode 100644 index 000000000..47bf00864 --- /dev/null +++ b/test/continuation-dispatch-worker.test.ts @@ -0,0 +1,951 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import baseWorker from "../src/index"; +import runtimeWorker from "../src/runtime-entrypoint"; +import protectedWorker from "../src/worker"; +import { + continuationDispatchDependencies, + handleContinuationDispatch, + type ContinuationDispatchHandlerDependencies, +} from "../src/continuation-dispatch/handler"; +import { + ContinuationDispatchStateConflict, + ContinuationDispatchStateUnavailable, +} from "../src/continuation-dispatch/dispatch-state"; +import { + ContinuationDispatchNotStartedError, + ContinuationGitHubAdapterError, +} from "../src/continuation-dispatch/github-adapter"; +import { verifyContinuationReceipt } from "../src/continuation-dispatch/receipt"; + +const oidcIssuedAtEpochSeconds = Math.floor(Date.now() / 1_000); +const oidcExpiresAtEpochSeconds = oidcIssuedAtEpochSeconds + 300; +const workflowSha = "1".repeat(40); +const headSha = "2".repeat(40); +const baseSha = "3".repeat(40); +const workflowRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const testJwt = [ + Buffer.from(JSON.stringify({ alg: "RS256", kid: "task-5" })).toString("base64url"), + Buffer.from(JSON.stringify({ sub: "task-5" })).toString("base64url"), + Buffer.from([0]).toString("base64url"), +].join("."); +const requestBody = { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/noema", + pull_request_number: 736, + expected_head_sha: headSha, + expected_base_sha: baseSha, + expected_base_ref: "main", + transport_retry_attempt: 1, +} as const; + +let signingPrivateKeyPem = ""; +let signingPublicKeyPem = ""; + +function pem(label: "PRIVATE KEY" | "PUBLIC KEY", bytes: ArrayBuffer): string { + const base64 = Buffer.from(bytes).toString("base64"); + return `-----BEGIN ${label}-----\n${base64.match(/.{1,64}/g)?.join("\n")}\n-----END ${label}-----`; +} + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + signingPrivateKeyPem = pem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", pair.privateKey)); + signingPublicKeyPem = pem("PUBLIC KEY", await crypto.subtle.exportKey("spki", pair.publicKey)); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function request(body: string = JSON.stringify(requestBody), init: RequestInit = {}): Request { + return new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + "x-request-id": "trace-task-5", + ...(init.headers ?? {}), + }, + body, + ...init, + }); +} + +function harness() { + const calls: string[] = []; + let retained: Record | undefined; + const reservation = { + kind: "reserved" as const, + identity: "a".repeat(64), + digest: "a".repeat(64), + reservationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }; + const dependencies: ContinuationDispatchHandlerDependencies = { + verifyOidc: async () => ({ + repository: requestBody.source_repository, + job_workflow_ref: workflowRef, + job_workflow_sha: workflowSha, + iat: oidcIssuedAtEpochSeconds, + exp: oidcExpiresAtEpochSeconds, + jti: "task-5-jti", + }), + claimOidc: async () => { + calls.push("claim"); + return true; + }, + readLivePullRequest: async () => { + calls.push("live-pr"); + return { + repository: requestBody.source_repository, + pullRequestNumber: requestBody.pull_request_number, + headSha, + baseSha, + baseRef: "main", + }; + }, + reserve: async (_env, identity, digest, reservationExpiresAt) => { + calls.push("reserve"); + expect(reservationExpiresAt).toBe(oidcExpiresAtEpochSeconds); + if (retained !== undefined) { + return { + kind: "replay" as const, + identity, + digest, + outcome: retained.outcome as "accepted" | "denied" | "indeterminate", + receipt: retained, + }; + } + return { ...reservation, identity, digest }; + }, + prepareDispatch: async () => { + calls.push("prepare-dispatch"); + return { + assertFresh: () => undefined, + send: async () => ({ outcome: "accepted", upstreamStatus: 204, eventType: "noema-review" }), + }; + }, + dispatch: async () => { + calls.push("dispatch"); + return { outcome: "accepted" as const, upstreamStatus: 204, eventType: "noema-review" as const }; + }, + commit: async (_env, _reservation, receipt) => { + calls.push("commit"); + retained = receipt; + }, + abort: async () => { + calls.push("abort"); + }, + releasePreEffect: async () => { + calls.push("release-pre-effect"); + retained = undefined; + }, + finalize: async (_env, _reservation, receipt) => { + calls.push("finalize"); + retained = receipt; + }, + }; + const env = { + ALLOWED_WORKFLOW_SHA: workflowSha, + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: signingPrivateKeyPem, + CONTINUATION_RECEIPT_SIGNING_KEY_ID: "noema-continuation-2026-09", + } as never; + return { calls, dependencies, env }; +} + +describe("continuation dispatch public route", () => { + it("delegates the default dispatch boundary to the prepared credential", async () => { + const send = vi.fn(async () => ({ + outcome: "accepted" as const, + upstreamStatus: 204, + eventType: "noema-review" as const, + })); + + await expect(continuationDispatchDependencies.dispatch( + requestBody, + { assertFresh: () => undefined, send }, + )).resolves.toMatchObject({ outcome: "accepted", upstreamStatus: 204 }); + expect(send).toHaveBeenCalledWith(requestBody); + }); + + it("applies the distributed rate limit before dispatch authentication", async () => { + const limiterFetch = vi.fn(async () => Response.json({ + allowed: false, + limit: 60, + remaining: 0, + retry_after_seconds: 37, + })); + const limiter = { + idFromName: () => ({ toString: () => "continuation-client" }) as DurableObjectId, + get: () => ({ fetch: limiterFetch }) as unknown as DurableObjectStub, + } as unknown as DurableObjectNamespace; + + const response = await protectedWorker.fetch( + request(JSON.stringify(requestBody), { + headers: { + authorization: "Bearer invalid", + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.20", + }, + }), + { + NOEMA_RATE_LIMITER: limiter, + NOEMA_RATE_LIMIT_PER_MINUTE: "60", + } as never, + ); + + expect(limiterFetch).toHaveBeenCalledOnce(); + expect(response.status).toBe(429); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_RATE_LIMIT", + }); + }); + + it("rejects query authority on the exact public resource before runtime configuration", async () => { + const response = await runtimeWorker.fetch( + new Request("https://noema.example/v1/continuation-dispatches?authority=attacker"), + {} as never, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_REQUEST_INVALID", + }); + }); + + it("is admitted by the base worker instead of falling through to 404", async () => { + const response = await baseWorker.fetch( + new Request("https://noema.example/v1/continuation-dispatches"), + {} as never, + ); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_REQUEST_INVALID", + }); + }); + + it("verifies, claims, checks live state, dispatches once, commits, and returns a verifiable receipt", async () => { + const { calls, dependencies, env } = harness(); + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const payload = await response.json() as { ok: true; data: { receipt: unknown }; trace_id: string }; + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("x-trace-id")).toBe("trace-task-5"); + expect(response.headers.get("x-oidc-replay-protection")).toBe("verified-before-dispatch"); + expect(payload.ok).toBe(true); + expect(payload.trace_id).toBe("trace-task-5"); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "finalize", + ]); + await expect(verifyContinuationReceipt(payload.data.receipt, signingPublicKeyPem)).resolves.toBe(true); + expect(JSON.stringify(payload)).not.toMatch(/token|bearer|assertion|private_key/i); + }); + + it("accepts the workflow_ref fallback and emits the fixed Strix event identity", async () => { + const { calls, dependencies, env } = harness(); + dependencies.verifyOidc = async () => ({ + repository: requestBody.source_repository, + workflow_ref: workflowRef, + workflow_sha: workflowSha, + iat: oidcIssuedAtEpochSeconds, + exp: oidcExpiresAtEpochSeconds, + jti: "task-5-strix-jti", + }); + dependencies.dispatch = async (candidate) => { + calls.push("dispatch"); + expect(candidate.dispatch_action).toBe("strix_scan_continuation"); + return { outcome: "accepted", upstreamStatus: 204, eventType: "strix-scan" }; + }; + + const response = await handleContinuationDispatch(request(JSON.stringify({ + ...requestBody, + dispatch_action: "strix_scan_continuation", + })), env, "trace-task-5", dependencies); + + expect(response.status).toBe(200); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "finalize", + ]); + }); + + it("returns the exact retained receipt on replay without a second dispatch", async () => { + const { calls, dependencies, env } = harness(); + const first = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const firstPayload = await first.json() as { data: { receipt: unknown } }; + const second = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const secondPayload = await second.json() as { data: { receipt: unknown } }; + + expect(second.status).toBe(200); + expect(second.headers.get("x-continuation-replay")).toBe("exact"); + expect(secondPayload.data.receipt).toEqual(firstPayload.data.receipt); + expect(calls.filter((call) => call === "dispatch")).toHaveLength(1); + expect(calls.filter((call) => call === "live-pr")).toHaveLength(1); + }); + + it.each([ + ["denied", 502, "ERR_GITHUB_DISPATCH_AUTHORIZATION"], + ["indeterminate", 503, "ERR_GITHUB_DISPATCH_UPSTREAM"], + ] as const)("preserves the %s outcome contract on exact replay", async (outcome, status, errorCode) => { + const { calls, dependencies, env } = harness(); + dependencies.reserve = async (_env, identity, digest) => { + calls.push("reserve"); + return { + kind: "replay", + identity, + digest, + outcome, + receipt: { outcome, receipt_id: `retained-${outcome}` }, + }; + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(response.headers.get("x-continuation-replay")).toBe("exact"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: errorCode, + details: { receipt: { outcome, receipt_id: `retained-${outcome}` } }, + }); + expect(calls).toEqual(["claim", "reserve"]); + }); + + it("serializes retry attempt two through the first attempt identity without a second dispatch", async () => { + const { calls, dependencies, env } = harness(); + let retainedIdentity: string | undefined; + let retainedDigest: string | undefined; + dependencies.reserve = async (_env, identity, digest) => { + calls.push("reserve"); + if (retainedIdentity === undefined) { + retainedIdentity = identity; + retainedDigest = digest; + return { + kind: "reserved", + identity, + digest, + reservationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }; + } + expect(identity).toBe(retainedIdentity); + expect(digest).not.toBe(retainedDigest); + throw new ContinuationDispatchStateConflict(); + }; + + const first = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const second = await handleContinuationDispatch( + request(JSON.stringify({ ...requestBody, transport_retry_attempt: 2 })), + env, + "trace-task-5", + dependencies, + ); + + expect(first.status).toBe(200); + expect(second.status).toBe(409); + expect(calls.filter((call) => call === "dispatch")).toHaveLength(1); + }); + + it("commits indeterminate evidence before dispatch and preserves it when finalization fails", async () => { + const { calls, dependencies, env } = harness(); + dependencies.finalize = async () => { + calls.push("finalize"); + throw new Error("state unavailable"); + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const payload = await response.json() as { details: { receipt: { outcome: string } } }; + + expect(response.status).toBe(503); + expect(calls.indexOf("commit")).toBeLessThan(calls.indexOf("dispatch")); + expect(payload.details.receipt.outcome).toBe("indeterminate"); + }); + + it("fails a retained digest conflict closed before dispatch", async () => { + const { dependencies, env } = harness(); + dependencies.reserve = async () => { + throw new ContinuationDispatchStateConflict(); + }; + const dispatch = vi.spyOn(dependencies, "dispatch"); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_REPLAY_CONFLICT", + }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("fails unavailable and in-progress state closed before live GitHub access", async () => { + for (const { reserve, status } of [ + { + reserve: async () => { throw new ContinuationDispatchStateUnavailable("state unavailable"); }, + status: 503, + }, + { + reserve: async () => ({ kind: "in_progress" as const, identity: "a".repeat(64), digest: "b".repeat(64) }), + status: 409, + }, + ]) { + const { calls, dependencies, env } = harness(); + dependencies.reserve = reserve; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(calls).toEqual(["claim"]); + } + }); + + it("maps an unexpected reservation implementation failure to unavailable", async () => { + const { calls, dependencies, env } = harness(); + dependencies.reserve = async () => { throw new Error("unexpected state failure"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim"]); + }); + + it("releases a fresh reservation when the final live PR check is stale", async () => { + const { calls, dependencies, env } = harness(); + dependencies.readLivePullRequest = async () => { + calls.push("live-pr"); + throw new ContinuationGitHubAdapterError("live_state_stale"); + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(409); + expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); + }); + + it.each([ + [new ContinuationGitHubAdapterError("identity_denied"), 403], + [new ContinuationGitHubAdapterError("upstream_unavailable"), 503], + [new Error("GitHub unavailable"), 503], + ] as const)("releases the reservation before classifying live-state failure %#", async (failure, status) => { + const { calls, dependencies, env } = harness(); + dependencies.readLivePullRequest = async () => { + calls.push("live-pr"); + throw failure; + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); + }); + + it("fails closed when a reservation cannot be released after live-state failure", async () => { + const { dependencies, env } = harness(); + dependencies.readLivePullRequest = async () => { throw new Error("GitHub unavailable"); }; + dependencies.abort = async () => { throw new Error("state unavailable"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Continuation reservation could not be released", + }); + }); + + it("releases the reservation when receipt signing is misconfigured", async () => { + const { calls, dependencies, env } = harness(); + const response = await handleContinuationDispatch(request(), { + ...env, + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: "invalid", + }, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); + }); + + it("reports signing failure as state-unavailable when its reservation cannot be released", async () => { + const { dependencies, env } = harness(); + dependencies.abort = async () => { throw new Error("state unavailable"); }; + const response = await handleContinuationDispatch(request(), { + ...env, + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: "invalid", + }, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Continuation reservation could not be released", + }); + }); + + it("does not dispatch when pre-dispatch evidence cannot be committed", async () => { + const { calls, dependencies, env } = harness(); + dependencies.commit = async () => { + calls.push("commit"); + throw new Error("state unavailable"); + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "prepare-dispatch", "commit", "abort"]); + }); + + it("does not dispatch when a persisted commit response is lost and replays its indeterminate evidence", async () => { + const { calls, dependencies, env } = harness(); + const durableCommit = dependencies.commit; + dependencies.commit = async (...args) => { + await durableCommit(...args); + throw new Error("commit response lost"); + }; + dependencies.abort = async () => { + calls.push("abort"); + throw new ContinuationDispatchStateConflict(); + }; + + const first = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const replay = await handleContinuationDispatch(request(), env, "trace-task-5-replay", dependencies); + const replayPayload = await replay.json() as { details: { receipt: { outcome: string } } }; + + expect(first.status).toBe(503); + expect(replay.status).toBe(503); + expect(replay.headers.get("x-continuation-replay")).toBe("exact"); + expect(replayPayload.details.receipt.outcome).toBe("indeterminate"); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "abort", + "claim", + "reserve", + ]); + expect(calls).not.toContain("dispatch"); + }); + + it("releases the reservation when the central credential cannot be prepared", async () => { + const { calls, dependencies, env } = harness(); + Object.assign(dependencies, { + prepareDispatch: async () => { + calls.push("prepare-dispatch"); + throw new ContinuationGitHubAdapterError("upstream_unavailable", 503); + }, + }); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "prepare-dispatch", "abort"]); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + message: "Central dispatch credential unavailable", + }); + }); + + it("fails closed when a preparation failure reservation cannot be released", async () => { + const { dependencies, env } = harness(); + dependencies.prepareDispatch = async () => { + throw new ContinuationGitHubAdapterError("upstream_unavailable", 503); + }; + dependencies.abort = async () => { throw new Error("state unavailable"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Continuation reservation could not be released", + }); + }); + + it("releases the reservation when the prepared central credential expires before commit", async () => { + const { calls, dependencies, env } = harness(); + dependencies.prepareDispatch = async () => { + calls.push("prepare-dispatch"); + return { + assertFresh: () => { throw new ContinuationGitHubAdapterError("upstream_unavailable"); }, + send: async () => ({ outcome: "accepted", upstreamStatus: 204, eventType: "noema-review" }), + }; + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "prepare-dispatch", "abort"]); + await expect(response.json()).resolves.toMatchObject({ + message: "Central dispatch credential unavailable", + }); + }); + + it.each(["noema_review_continuation", "strix_scan_continuation"] as const)( + "returns retained indeterminate evidence when the %s dispatch transport throws", + async (dispatchAction) => { + const { calls, dependencies, env } = harness(); + dependencies.dispatch = async () => { + calls.push("dispatch"); + throw new Error("transport failed"); + }; + + const response = await handleContinuationDispatch(request(JSON.stringify({ + ...requestBody, + dispatch_action: dispatchAction, + })), env, "trace-task-5", dependencies); + const payload = await response.json() as { details: { receipt: { outcome: string } } }; + + expect(response.status).toBe(503); + expect(payload.details.receipt.outcome).toBe("indeterminate"); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + ]); + }, + ); + + it("retries when the committed dispatch provably never started", async () => { + const { calls, dependencies, env } = harness(); + let attempts = 0; + dependencies.dispatch = async () => { + calls.push("dispatch"); + attempts += 1; + if (attempts === 1) throw new ContinuationDispatchNotStartedError(); + return { outcome: "accepted", upstreamStatus: 204, eventType: "noema-review" }; + }; + + const first = await handleContinuationDispatch(request(), env, "trace-task-5-first", dependencies); + const retry = await handleContinuationDispatch(request(), env, "trace-task-5-retry", dependencies); + + expect(first.status).toBe(503); + expect(retry.status).toBe(200); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "release-pre-effect", + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "finalize", + ]); + }); + + it("fails closed when pre-effect committed evidence cannot be released", async () => { + const { dependencies, env } = harness(); + dependencies.dispatch = async () => { throw new ContinuationDispatchNotStartedError(); }; + dependencies.releasePreEffect = async () => { throw new Error("state unavailable"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Pre-effect continuation evidence could not be released", + details: { hint: expect.any(String) }, + }); + }); + + it("returns a signed denied receipt when GitHub rejects the fixed dispatch", async () => { + const { dependencies, env } = harness(); + dependencies.dispatch = async () => { + return { + outcome: "denied", + upstreamStatus: 403, + eventType: "noema-review", + }; + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const payload = await response.json() as { details: { receipt: unknown } }; + + expect(response.status).toBe(502); + expect(await verifyContinuationReceipt(payload.details.receipt, signingPublicKeyPem)).toBe(true); + }); + + it("fails closed on an impossible adapter outcome", async () => { + const { dependencies, env } = harness(); + dependencies.dispatch = async () => { + return { + outcome: "unexpected", + eventType: "noema-review", + } as never; + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ message: "Unexpected continuation outcome" }); + }); + + it.each(["verifyOidc", "claimOidc"] as const)( + "classifies unavailable %s infrastructure as upstream instead of invalid identity", + async (boundary) => { + const { dependencies, env } = harness(); + dependencies[boundary] = vi.fn(async () => { + throw { code: boundary === "verifyOidc" ? "ERR_OIDC_VERIFICATION" : "ERR_AUTH_REPLAY", status: 503 }; + }) as never; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + }); + }, + ); + + it.each([ + ["verify", async (dependencies: ContinuationDispatchHandlerDependencies) => { + dependencies.verifyOidc = async () => { throw "invalid identity"; }; + }], + ["claim", async (dependencies: ContinuationDispatchHandlerDependencies) => { + dependencies.claimOidc = async () => false; + }], + ] as const)("denies a non-infrastructure %s failure", async (_boundary, configure) => { + const { dependencies, env } = harness(); + await configure(dependencies); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="noema", error="invalid_token"', + ); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_DISPATCH_IDENTITY_DENIED", + details: { hint: expect.any(String) }, + }); + }); + + it("denies a non-infrastructure replay-claim exception", async () => { + const { dependencies, env } = harness(); + dependencies.claimOidc = async () => { throw "claim denied"; }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(401); + }); + + it("rejects a workflow SHA mismatch before replay claim or GitHub access", async () => { + const { calls, dependencies, env } = harness(); + dependencies.verifyOidc = async () => ({ + repository: requestBody.source_repository, + job_workflow_ref: workflowRef, + job_workflow_sha: "f".repeat(40), + iat: oidcIssuedAtEpochSeconds, + exp: oidcExpiresAtEpochSeconds, + jti: "wrong-workflow-sha", + }); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(403); + expect(calls).toEqual([]); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_IDENTITY_DENIED", + }); + }); + + it.each([ + ["GET", undefined, 405], + ["POST", { "content-type": "text/plain" }, 415], + ])("rejects %s or invalid media type before OIDC verification", async (method, headers, status) => { + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const candidate = method === "GET" + ? new Request("https://noema.example/v1/continuation-dispatches", { method }) + : request(JSON.stringify(requestBody), { headers }); + + const response = await handleContinuationDispatch(candidate, env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(verify).not.toHaveBeenCalled(); + }); + + it("rejects malformed JSON and a missing bearer before verification", async () => { + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const malformed = await handleContinuationDispatch(request("{"), env, "trace-task-5", dependencies); + const missingBearer = await handleContinuationDispatch(request(JSON.stringify(requestBody), { + headers: { authorization: "", "content-type": "application/json" }, + }), env, "trace-task-5", dependencies); + + expect([malformed.status, missingBearer.status]).toEqual([400, 401]); + expect(missingBearer.headers.get("www-authenticate")).toContain("Bearer"); + expect(verify).not.toHaveBeenCalled(); + }); + + it("rejects an invalid declared length and an absent body", async () => { + const { dependencies, env } = harness(); + const invalidLength = await handleContinuationDispatch(request(JSON.stringify(requestBody), { + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + "content-length": "01", + }, + }), env, "trace-task-5", dependencies); + const absent = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { authorization: `Bearer ${testJwt}`, "content-type": "application/json" }, + }, + ), env, "trace-task-5", dependencies); + + expect([invalidLength.status, absent.status]).toEqual([413, 400]); + }); + + it("rejects an absent media type, an oversized declared length, and an absent bearer", async () => { + const { dependencies, env } = harness(); + const noMediaType = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { method: "POST" }, + ), env, "trace-task-5", dependencies); + const declaredOversize = await handleContinuationDispatch(request("{}", { + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + "content-length": "8193", + }, + }), env, "trace-task-5", dependencies); + const noBearer = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }, + ), env, "trace-task-5", dependencies); + + expect([noMediaType.status, declaredOversize.status, noBearer.status]).toEqual([415, 413, 401]); + }); + + it("normalizes body reader acquisition and stream failures", async () => { + const { dependencies, env } = harness(); + const lockedRequest = request(); + const lock = lockedRequest.body!.getReader(); + const locked = await handleContinuationDispatch(lockedRequest, env, "trace-task-5", dependencies); + lock.releaseLock(); + const streamFailure = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { authorization: `Bearer ${testJwt}`, "content-type": "application/json" }, + body: new ReadableStream({ + start(controller) { controller.error(new Error("stream failed")); }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }, + ), env, "trace-task-5", dependencies); + + expect([locked.status, streamFailure.status]).toEqual([400, 400]); + }); + + it("keeps rejection authoritative when request-body cancellation rejects", async () => { + const { dependencies, env } = harness(); + const candidate = new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: new ReadableStream({ + cancel() { throw new Error("cancel failed"); }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const response = await handleContinuationDispatch(candidate, env, "trace-task-5", dependencies); + await Promise.resolve(); + + expect(response.status).toBe(415); + }); + + it("keeps an oversized-stream rejection authoritative when reader cancellation rejects", async () => { + const { dependencies, env } = harness(); + const candidate = new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { "content-type": "application/json" }, + body: new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(8_193)); }, + cancel() { throw new Error("cancel failed"); }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const response = await handleContinuationDispatch(candidate, env, "trace-task-5", dependencies); + await Promise.resolve(); + + expect(response.status).toBe(413); + }); + + it("rejects a declared or streamed body above 8 KiB before OIDC verification", async () => { + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const response = await handleContinuationDispatch( + request(`{"padding":"${"x".repeat(8_192)}"}`), + env, + "trace-task-5", + dependencies, + ); + + expect(response.status).toBe(413); + expect(verify).not.toHaveBeenCalled(); + }); + + it("rejects a stalled body after the absolute read deadline before OIDC verification", async () => { + vi.useFakeTimers(); + const cancellations: string[] = []; + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"contract_version":')); + }, + cancel(reason) { + cancellations.push(String(reason)); + }, + }); + const pending = handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + }, + body: stream, + duplex: "half", + } as RequestInit, + ), env, "trace-task-5", dependencies); + + await vi.advanceTimersByTimeAsync(10_000); + const response = await pending; + + expect(response.status).toBe(408); + expect(verify).not.toHaveBeenCalled(); + expect(cancellations).toEqual(["continuation dispatch body read deadline exceeded"]); + }); +}); diff --git a/test/documentation-current-trust-authority.test.ts b/test/documentation-current-trust-authority.test.ts index ad7c57e58..fd9dedb3e 100644 --- a/test/documentation-current-trust-authority.test.ts +++ b/test/documentation-current-trust-authority.test.ts @@ -85,7 +85,7 @@ describe("current protected trust authority documentation", () => { expect(prd).not.toContain("graph/evaluation slice is intentionally non-durable"); expect(prd).not.toContain("authenticated evaluation evidence, Policy / Approval, durable graph/rejection history"); - expect(trd).toContain("### 2.4 Protected procedural graph advisory runtime"); + expect(trd).toContain("### 2.5 Protected procedural graph advisory runtime"); expect(trd).not.toContain("Active PR #585 adds"); expect(trd).not.toContain("## Candidate implementation — PR #585"); expect(trd).toContain("bounded durable evaluation/rejection history"); diff --git a/test/github-app-private-key-envelope.test.ts b/test/github-app-private-key-envelope.test.ts index cbc29d211..0cd22fb24 100644 --- a/test/github-app-private-key-envelope.test.ts +++ b/test/github-app-private-key-envelope.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import worker, { type Env } from "../src/index"; import { normalizeGitHubAppPrivateKeyPem } from "../src/github-app-private-key"; import runtimeWorker, { type Env as RuntimeEnv } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; @@ -107,12 +108,13 @@ function acceptingReplayGuard(): DurableObjectNamespace { } as unknown as DurableObjectNamespace; } -function readinessEnv(privateKeyPem: string): RuntimeEnv { +async function readinessEnv(privateKeyPem: string): Promise { const namespace = { idFromName: vi.fn(), get: vi.fn(), } as unknown as DurableObjectNamespace; return { + ...await continuationReadyBindings(namespace), ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", ALLOWED_AUDIENCE: "cwl-noema-review", ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", @@ -194,7 +196,7 @@ describe("GitHub App private-key authority", () => { it("makes the production runtime ready with the PKCS#1 RSA key format downloaded from GitHub Apps", async () => { const response = await runtimeWorker.fetch( new Request("https://noema.example/ready"), - readinessEnv(appPrivateKeyPkcs1Pem), + await readinessEnv(appPrivateKeyPkcs1Pem), ); expect(response.status).toBe(200); @@ -210,7 +212,7 @@ describe("GitHub App private-key authority", () => { it("makes the production runtime ready with a canonical PKCS#8 key ending in one newline", async () => { const response = await runtimeWorker.fetch( new Request("https://noema.example/ready"), - readinessEnv(`${appPrivateKeyPem}\n`), + await readinessEnv(`${appPrivateKeyPem}\n`), ); expect(response.status).toBe(200); diff --git a/test/openapi-contract.test.ts b/test/openapi-contract.test.ts index 8e9b50359..466e826ae 100644 --- a/test/openapi-contract.test.ts +++ b/test/openapi-contract.test.ts @@ -35,13 +35,51 @@ function schemaMatchesString(spec: Record, schema: Record, schema: Record, value: unknown): boolean { + const resolved = resolveLocalRef(spec, schema); + if (Array.isArray(resolved.allOf) && !resolved.allOf.every((part: Record) => schemaMatchesValue(spec, part, value))) { + return false; + } + if (Array.isArray(resolved.anyOf) && !resolved.anyOf.some((part: Record) => schemaMatchesValue(spec, part, value))) { + return false; + } + if ( + Array.isArray(resolved.oneOf) + && resolved.oneOf.filter((part: Record) => schemaMatchesValue(spec, part, value)).length !== 1 + ) { + return false; + } + if (resolved.const !== undefined && value !== resolved.const) return false; + if (Array.isArray(resolved.enum) && !resolved.enum.includes(value)) return false; + if (resolved.type === "string") { + return typeof value === "string" + && (typeof resolved.pattern !== "string" || new RegExp(resolved.pattern).test(value)); + } + if (resolved.type === "boolean") return typeof value === "boolean"; + if (resolved.type !== "object") return true; + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + + const record = value as Record; + if (Array.isArray(resolved.required) && resolved.required.some((key: string) => !(key in record))) return false; + const properties = resolved.properties ?? {}; + if (resolved.additionalProperties === false && Object.keys(record).some((key) => !(key in properties))) return false; + return Object.entries(properties).every(([key, propertySchema]) => ( + !(key in record) || schemaMatchesValue(spec, propertySchema as Record, record[key]) + )); +} + describe("machine-readable public HTTP contract", () => { it("publishes the supported health, readiness, and exchange surface", async () => { const spec = await loadOpenApi(); expect(spec.openapi).toBe("3.1.0"); expect(spec.info).toMatchObject({ title: "Noema API", version: "0.1.0" }); - expect(Object.keys(spec.paths).sort()).toEqual(["/exchange", "/health", "/ready"]); + expect(Object.keys(spec.paths).sort()).toEqual([ + "/exchange", + "/health", + "/ready", + "/v1/continuation-dispatches", + ]); expect(spec.paths["/health"].get.responses["200"]).toBeDefined(); expect(spec.paths["/ready"].get.responses["200"]).toBeDefined(); expect(spec.paths["/ready"].get.responses["503"]).toBeDefined(); @@ -59,6 +97,9 @@ describe("machine-readable public HTTP contract", () => { expect(spec.paths["/exchange"].post.responses["500"]).toBeDefined(); expect(spec.paths["/exchange"].post.responses["502"]).toBeDefined(); expect(spec.paths["/exchange"].post.responses["503"]).toBeDefined(); + expect(spec.paths["/v1/continuation-dispatches"].post.responses["200"]).toBeDefined(); + expect(spec.paths["/v1/continuation-dispatches"].post.responses["408"]).toBeDefined(); + expect(spec.paths["/v1/continuation-dispatches"].post.responses["409"]).toBeDefined(); }); it("keeps authentication and bounded-input semantics explicit", async () => { @@ -79,6 +120,54 @@ describe("machine-readable public HTTP contract", () => { expect(exchange["x-request-body-read-deadline-ms"]).toBe(10000); expect(exchange.responses["401"].headers["WWW-Authenticate"]).toBeDefined(); expect(exchange.responses["429"].headers["Retry-After"]).toBeDefined(); + + const continuation = spec.paths["/v1/continuation-dispatches"].post; + expect(continuation["x-request-body-read-deadline-ms"]).toBe(10000); + expect(continuation.responses["408"]).toEqual({ $ref: "#/components/responses/RequestTimeout" }); + expect(continuation.responses["401"].headers["WWW-Authenticate"]).toBeDefined(); + expect(continuation.responses["200"].headers["X-Continuation-Replay"]).toBeDefined(); + expect(continuation.responses["502"].headers["X-Continuation-Replay"]).toBeDefined(); + expect(continuation.responses["502"].content["application/json"].schema).toEqual({ + $ref: "#/components/schemas/ContinuationErrorResponse", + }); + expect(continuation.responses["503"].headers["X-Continuation-Replay"]).toBeDefined(); + const unavailableSchema = continuation.responses["503"].content["application/json"].schema; + expect(unavailableSchema).toEqual({ + anyOf: [ + { $ref: "#/components/schemas/ContinuationErrorResponse" }, + { $ref: "#/components/schemas/ErrorResponse" }, + ], + }); + const dispatchUnavailable = { + ok: false, + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + message: "GitHub continuation dispatch outcome is indeterminate", + details: { hint: "Retry only with the same idempotency identity." }, + trace_id: "trace-dispatch", + }; + const configurationUnavailable = { + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API configuration is invalid", + details: { policy: "CONTINUATION_DISPATCH_GITHUB_APP_ID must be canonical." }, + trace_id: "trace-configuration", + }; + const [continuationErrorSchema, errorSchema] = unavailableSchema.anyOf; + expect(schemaMatchesValue(spec, continuationErrorSchema, dispatchUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, errorSchema, dispatchUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, unavailableSchema, dispatchUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, continuationErrorSchema, configurationUnavailable)).toBe(false); + expect(schemaMatchesValue(spec, errorSchema, configurationUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, unavailableSchema, configurationUnavailable)).toBe(true); + expect(spec.components.schemas.ContinuationErrorDetails).toMatchObject({ + type: "object", + additionalProperties: false, + required: ["hint"], + }); + expect(spec.components.schemas.ContinuationErrorDetails.properties.receipt).toEqual({ + $ref: "#/components/schemas/SignedContinuationReceipt", + }); + expect(spec.components.responses.UnsupportedMediaType.description).not.toMatch(/Exchange/u); }); it("executes the RE2-safe repository locator against realistic owner/name values", async () => { diff --git a/test/patch-validator-image-build-regression.test.ts b/test/patch-validator-image-build-regression.test.ts index 42c2ae072..bad334b18 100644 --- a/test/patch-validator-image-build-regression.test.ts +++ b/test/patch-validator-image-build-regression.test.ts @@ -3,8 +3,21 @@ import { describe, expect, it } from "vitest"; const dockerfile = readFileSync("Dockerfile.patch-validator", "utf8"); const imageWorkflow = readFileSync(".github/workflows/patch-validator-image.yml", "utf8"); +const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { + devEngines: { runtime: { version: string } }; + overrides: { undici: string }; +}; describe("patch-validator exact-toolchain image build regression", () => { + it("pins the Node release that embeds the patched undici runtime", () => { + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); + expect(dockerfile).toContain( + "ARG NODE_SOURCE_SHA256=a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc", + ); + expect(packageJson.devEngines.runtime.version).toBe("24.21.0"); + expect(packageJson.overrides.undici).toBe("7.29.1"); + }); + it("builds the static runtime with the exact Node/npm toolchain and patched c-ares source", () => { expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); expect(dockerfile).toContain('test "$(/opt/node/bin/npm --version)" = "11.19.0"'); diff --git a/test/procedural-protected-documentation-contract.test.ts b/test/procedural-protected-documentation-contract.test.ts index 7fb773960..4d050c29e 100644 --- a/test/procedural-protected-documentation-contract.test.ts +++ b/test/procedural-protected-documentation-contract.test.ts @@ -119,7 +119,7 @@ describe("protected procedural documentation authority", () => { ); const trdSection = section( document("docs/TRD.md"), - "### 2.4 Protected procedural graph advisory runtime", + "### 2.5 Protected procedural graph advisory runtime", ); const baselineSection = section( document("docs/product-technical-gap-baseline.md"), @@ -172,7 +172,7 @@ describe("protected procedural documentation authority", () => { ); const trdSection = section( document("docs/TRD.md"), - "### 2.4 Protected procedural graph advisory runtime", + "### 2.5 Protected procedural graph advisory runtime", ); const baselineSection = section( document("docs/product-technical-gap-baseline.md"), diff --git a/test/product-technical-gap-current-candidate-contract.test.ts b/test/product-technical-gap-current-candidate-contract.test.ts index 8aaba64b4..6d6dbfade 100644 --- a/test/product-technical-gap-current-candidate-contract.test.ts +++ b/test/product-technical-gap-current-candidate-contract.test.ts @@ -12,6 +12,16 @@ describe("product-technical gap current authority", () => { expect(baseline).toContain("queued is not GREEN"); }); + it("records the continuation merge-forward without retaining superseded proposal language", () => { + const baseline = readFileSync("docs/product-technical-gap-baseline.md", "utf8"); + + expect(baseline).toContain("CI oracle repair가 exact-head hosted GREEN"); + expect(baseline).toContain("ordinary two-parent merge로 통합"); + expect(baseline).toContain("prerequisite #737 final exact head"); + expect(baseline).not.toContain("source repair and exact final-verifier assertion proposed"); + expect(baseline).not.toContain("publish ordinary child"); + }); + it("records integrated external-extension admission and the durable lifecycle successor without overclaiming completion", () => { const baseline = readFileSync("docs/product-technical-gap-baseline.md", "utf8"); diff --git a/test/runtime-private-key-canonicality.test.ts b/test/runtime-private-key-canonicality.test.ts index a17ac4b90..f1c04f3af 100644 --- a/test/runtime-private-key-canonicality.test.ts +++ b/test/runtime-private-key-canonicality.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { normalizeGitHubAppPrivateKeyPem } from "../src/github-app-private-key"; import runtimeWorker, { type Env as RuntimeEnv } from "../src/runtime-entrypoint"; import { evaluateRuntimeReadiness } from "../src/runtime-readiness"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; @@ -86,4 +87,20 @@ describe("runtime GitHub App private-key canonical authority", () => { expect(result.ready).toBe(false); expect(result.failedChecks).toContain("github_app_private_key"); }); + + it("rejects a syntactically wrapped but non-importable continuation receipt key", async () => { + const keyPair = await generateRsaKeyPair(); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); + const env = readinessEnv(pemFromPkcs8(pkcs8)); + Object.assign(env, await continuationReadyBindings(env.NOEMA_RATE_LIMITER)); + env.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM = + "-----BEGIN PRIVATE KEY-----\nAA==\n-----END PRIVATE KEY-----"; + + const result = await evaluateRuntimeReadiness(env); + + expect(result).toEqual({ + ready: false, + failedChecks: ["continuation_receipt_signing_private_key"], + }); + }); }); diff --git a/test/runtime-private-key-normalization-cache.test.ts b/test/runtime-private-key-normalization-cache.test.ts index c85a8dc75..212017e69 100644 --- a/test/runtime-private-key-normalization-cache.test.ts +++ b/test/runtime-private-key-normalization-cache.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import runtimeWorker, { type Env as RuntimeEnv } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; let appPrivateKeyPem: string; @@ -24,12 +25,13 @@ beforeAll(async () => { appPrivateKeyPem = pemFromPkcs8(pkcs8); }); -function readinessEnv(privateKeyPem: string): RuntimeEnv { +async function readinessEnv(privateKeyPem: string): Promise { const namespace = { idFromName: vi.fn(), get: vi.fn(), } as unknown as DurableObjectNamespace; return { + ...await continuationReadyBindings(namespace), ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", ALLOWED_AUDIENCE: "cwl-noema-review", ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", @@ -48,7 +50,7 @@ function readinessEnv(privateKeyPem: string): RuntimeEnv { describe("runtime private-key normalization cache", () => { it("reuses the WebCrypto import decision for an unchanged newline-terminated secret", async () => { - const env = readinessEnv(`${appPrivateKeyPem}\n`); + const env = await readinessEnv(`${appPrivateKeyPem}\n`); const importSpy = vi.spyOn(crypto.subtle, "importKey"); const first = await runtimeWorker.fetch( @@ -62,11 +64,11 @@ describe("runtime private-key normalization cache", () => { expect(first.status).toBe(200); expect(second.status).toBe(200); - expect(importSpy).toHaveBeenCalledTimes(1); + expect(importSpy).toHaveBeenCalledTimes(3); }); it("does not retain a withdrawn binding in the cached normalized environment", async () => { - const env = readinessEnv(`${appPrivateKeyPem}\n`); + const env = await readinessEnv(`${appPrivateKeyPem}\n`); const first = await runtimeWorker.fetch( new Request("https://noema.example/ready"), diff --git a/test/runtime-readiness-cache.test.ts b/test/runtime-readiness-cache.test.ts index 8d7509113..73e96c9ba 100644 --- a/test/runtime-readiness-cache.test.ts +++ b/test/runtime-readiness-cache.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -42,7 +43,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMITER: dummyNamespace(), NOEMA_OIDC_REPLAY_GUARD: dummyNamespace(), @@ -67,7 +68,7 @@ describe("runtime-readiness private-key import cache", () => { expect(first.status).toBe(200); expect(second.status).toBe(200); - expect(importKey).toHaveBeenCalledTimes(1); + expect(importKey).toHaveBeenCalledTimes(3); }); it("evaluates separate deployment environments independently", async () => { @@ -80,7 +81,7 @@ describe("runtime-readiness private-key import cache", () => { expect(first.status).toBe(200); expect(second.status).toBe(200); - expect(importKey).toHaveBeenCalledTimes(2); + expect(importKey).toHaveBeenCalledTimes(6); }); it("re-evaluates non-key bindings when Cloudflare updates them in a reused isolate", async () => { @@ -97,7 +98,7 @@ describe("runtime-readiness private-key import cache", () => { error_code: "ERR_SERVICE_NOT_READY", details: { failed_checks: "github_app_id" }, }); - expect(importKey).toHaveBeenCalledTimes(1); + expect(importKey).toHaveBeenCalledTimes(3); }); it("imports a rotated private-key binding again in a reused isolate", async () => { @@ -110,6 +111,6 @@ describe("runtime-readiness private-key import cache", () => { expect(beforeRotation.status).toBe(200); expect(afterRotation.status).toBe(200); - expect(importKey).toHaveBeenCalledTimes(2); + expect(importKey).toHaveBeenCalledTimes(4); }); }); diff --git a/test/runtime-readiness-durable-bindings.test.ts b/test/runtime-readiness-durable-bindings.test.ts index a17e6a843..a73fe4800 100644 --- a/test/runtime-readiness-durable-bindings.test.ts +++ b/test/runtime-readiness-durable-bindings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -59,7 +60,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_SHA: "a".repeat(40), GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), diff --git a/test/runtime-readiness-egress-capability.test.ts b/test/runtime-readiness-egress-capability.test.ts index 961f4602e..a47609eae 100644 --- a/test/runtime-readiness-egress-capability.test.ts +++ b/test/runtime-readiness-egress-capability.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -43,7 +44,7 @@ async function readyEnv(): Promise { ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), diff --git a/test/runtime-readiness-fixture.ts b/test/runtime-readiness-fixture.ts new file mode 100644 index 000000000..822f23d4a --- /dev/null +++ b/test/runtime-readiness-fixture.ts @@ -0,0 +1,29 @@ +async function privateKeyPem(algorithm: RsaHashedKeyGenParams | "Ed25519"): Promise { + const pair = await crypto.subtle.generateKey( + algorithm, + true, + ["sign", "verify"], + ); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + const base64 = Buffer.from(pkcs8).toString("base64"); + return `-----BEGIN PRIVATE KEY-----\n${base64.match(/.{1,64}/g)?.join("\n")}\n-----END PRIVATE KEY-----`; +} + +/** Supply complete, importable continuation bindings to readiness tests focused on another boundary. */ +export async function continuationReadyBindings(namespace: DurableObjectNamespace) { + const githubPrivateKey = await privateKeyPem({ + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }); + return { + GITHUB_APP_PRIVATE_KEY_PEM: githubPrivateKey, + CONTINUATION_DISPATCH_GITHUB_APP_ID: "223344", + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM: githubPrivateKey, + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: "445566", + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: await privateKeyPem("Ed25519"), + CONTINUATION_RECEIPT_SIGNING_KEY_ID: "noema-continuation-test", + NOEMA_CONTINUATION_DISPATCH_STATE: namespace, + }; +} diff --git a/test/runtime-readiness-missing-config.test.ts b/test/runtime-readiness-missing-config.test.ts index aece7afd8..8abdcfc8f 100644 --- a/test/runtime-readiness-missing-config.test.ts +++ b/test/runtime-readiness-missing-config.test.ts @@ -27,9 +27,15 @@ describe("Noema readiness with absent configuration", () => { "github_app_private_key", "noema_rate_limiter", "noema_oidc_replay_guard", + "continuation_dispatch_github_app_id", + "continuation_dispatch_github_app_private_key", + "continuation_dispatch_github_app_installation_id", + "continuation_receipt_signing_private_key", + "continuation_receipt_signing_key_id", + "noema_continuation_dispatch_state", ].join(","), ); - expect(payload.details.failed_checks).not.toContain("github_app_installation_id"); + expect(payload.details.failed_checks.split(",")).not.toContain("github_app_installation_id"); vi.restoreAllMocks(); }); diff --git a/test/runtime-readiness-ref-format.test.ts b/test/runtime-readiness-ref-format.test.ts index 3ddb1834d..5905f40da 100644 --- a/test/runtime-readiness-ref-format.test.ts +++ b/test/runtime-readiness-ref-format.test.ts @@ -3,6 +3,7 @@ import { evaluateRuntimeReadiness, type RuntimeReadinessEnv, } from "../src/runtime-readiness"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -45,7 +46,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_SHA: "a".repeat(40), GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMITER: dummyNamespace(), NOEMA_OIDC_REPLAY_GUARD: dummyNamespace(), diff --git a/test/runtime-readiness-trace-correlation.test.ts b/test/runtime-readiness-trace-correlation.test.ts index 81afe1358..bd1e5ae21 100644 --- a/test/runtime-readiness-trace-correlation.test.ts +++ b/test/runtime-readiness-trace-correlation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -42,7 +43,7 @@ async function readyEnv(): Promise { ALLOWED_WORKFLOW_SHA: "17052a7ca3c16db90932a4d6036b43165ddee418", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), diff --git a/test/runtime-readiness.test.ts b/test/runtime-readiness.test.ts index b3292d6b1..134618a40 100644 --- a/test/runtime-readiness.test.ts +++ b/test/runtime-readiness.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -42,7 +43,7 @@ async function readyEnv(): Promise { ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), diff --git a/wrangler.toml b/wrangler.toml index eb5937904..f3d2821c0 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -18,6 +18,10 @@ class_name = "NoemaWorkflowState" name = "NOEMA_EXTERNAL_EXTENSION_LIFECYCLE" class_name = "NoemaExternalExtensionLifecycle" +[[durable_objects.bindings]] +name = "NOEMA_CONTINUATION_DISPATCH_STATE" +class_name = "NoemaContinuationDispatchState" + [exports.NoemaRateLimiter] type = "durable-object" storage = "sqlite" @@ -34,6 +38,10 @@ storage = "sqlite" type = "durable-object" storage = "sqlite" +[exports.NoemaContinuationDispatchState] +type = "durable-object" +storage = "sqlite" + [vars] ALLOWED_ISSUER = "https://token.actions.githubusercontent.com" ALLOWED_AUDIENCE = "cwl-noema-review"