From 0ce3a8974f3ba926bfba5ff15dcd7c85f4f81fa1 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:10:15 +0200 Subject: [PATCH] feat(contracts): freeze frontend boundary with B05 fields, mock server, and P4 manifest --- ...0260908T155000Z-gate-p4-frontend-freeze.md | 69 ++ docs/GATE_P4_CHECKLIST.md | 11 +- docs/GATE_P4_MANIFEST.md | 78 ++ .../fixtures/ui/v1/auth-cap-exceeded.json | 51 ++ .../fixtures/ui/v1/auth-checking.json | 36 + .../fixtures/ui/v1/auth-config-mismatch.json | 35 + .../fixtures/ui/v1/auth-denied-recipient.json | 51 ++ .../fixtures/ui/v1/auth-unavailable.json | 49 ++ .../fixtures/ui/v1/authorized-committed.json | 66 ++ .../ui/v1/unknown-reconcile-only.json | 53 ++ .../contracts/generated/contracts.schema.json | 68 ++ packages/contracts/openapi/openapi.v1.json | 68 ++ .../contracts/scripts/generate-contracts.mjs | 115 ++- .../contracts/scripts/validate-fixtures.mjs | 43 +- packages/contracts/src/generated/api-types.ts | 17 + packages/contracts/src/index.ts | 1 + packages/contracts/src/mock-server.ts | 728 ++++++++++++++++++ packages/contracts/test/mock-server.test.ts | 128 +++ .../contracts/test/validate-fixtures.test.mjs | 12 +- 19 files changed, 1670 insertions(+), 9 deletions(-) create mode 100644 .agent/context/20260908T155000Z-gate-p4-frontend-freeze.md create mode 100644 docs/GATE_P4_MANIFEST.md create mode 100644 packages/contracts/fixtures/ui/v1/auth-cap-exceeded.json create mode 100644 packages/contracts/fixtures/ui/v1/auth-checking.json create mode 100644 packages/contracts/fixtures/ui/v1/auth-config-mismatch.json create mode 100644 packages/contracts/fixtures/ui/v1/auth-denied-recipient.json create mode 100644 packages/contracts/fixtures/ui/v1/auth-unavailable.json create mode 100644 packages/contracts/fixtures/ui/v1/authorized-committed.json create mode 100644 packages/contracts/fixtures/ui/v1/unknown-reconcile-only.json create mode 100644 packages/contracts/src/mock-server.ts create mode 100644 packages/contracts/test/mock-server.test.ts diff --git a/.agent/context/20260908T155000Z-gate-p4-frontend-freeze.md b/.agent/context/20260908T155000Z-gate-p4-frontend-freeze.md new file mode 100644 index 0000000..460f181 --- /dev/null +++ b/.agent/context/20260908T155000Z-gate-p4-frontend-freeze.md @@ -0,0 +1,69 @@ +# Session Context: Gate P4 Frontend Boundary Freeze and Mock Server + +## Date/time + +- UTC: 2026-09-08T15:50:00Z + +## User goal + +Execute Path 1 to unblock Milestone B05: freeze the Gate P4 frontend boundary, add additive sanitized contract fields (`policy` summary, attempt `authorization_status`, settlement `token_contract` and `explorer_url`), regenerate `@oneshot/contracts`, publish sanitized UI fixtures under `packages/contracts/fixtures/ui/v1/`, publish versioned OpenAPI mock server (`OPENAPI_MOCK_SERVER_VERSION = '1.0.0'`), publish `docs/GATE_P4_MANIFEST.md`, and mark Step 4 as `[COMPLETED]` in `docs/GATE_P4_CHECKLIST.md`. + +## Original prompt/request + +"Путь 1 пофикси" + +## Acceptance criteria + +1. Additive contract fields added without breaking existing invariants: + - `PolicySummary` on `IntentResponse` (`policy?: PolicySummaryView`). + - `authorization_status` on `AttemptView` (`authorization_status?: AuthorizationStatus`). + - `token_contract` and `explorer_url` on `SettlementView`. +2. Generated contract artifacts in `@oneshot/contracts` regenerated without drift (`pnpm check:generated` passes). +3. 7 sanitized UI fixtures published in `packages/contracts/fixtures/ui/v1/` and validated against contract schemas (`pnpm validate:fixtures` passes). +4. Versioned OpenAPI mock server (`OPENAPI_MOCK_SERVER_VERSION = '1.0.0'`) implemented and exported from `@oneshot/contracts` with full route coverage and fail-closed `/retry` rejection. +5. `docs/GATE_P4_MANIFEST.md` published and Step 4 marked `[COMPLETED]` in `docs/GATE_P4_CHECKLIST.md`. +6. Full local validation passes (`pnpm format:check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `npx markdownlint-cli2`). +7. Implementation loop followed: FreePi Gate A pre-push review, draft PR against `develop`, green CI, FreePi Gate B review, ready for human review. + +## Assumptions + +- Base commit is `origin/develop` (`4afd70916a84946aa3230cd29ccd3b68a2b2da58`). +- All contract additions are strictly optional and additive; no existing backend or frontend call paths are broken. +- Mock server does not expose or permit any payment retry endpoint. +- Human review gates apply; agents never merge to `develop` or `main`. + +## Non-goals + +- Implementing B05 frontend components (B05 will be implemented on its own branch using these frozen contracts and fixtures). +- Live testnet broadcast or funding (human action reserved). + +## Files/components touched + +- `packages/contracts/scripts/generate-contracts.mjs`: additive schemas, types, and generators. +- `packages/contracts/generated/contracts.schema.json`: regenerated schema bundle with `PolicySummary`. +- `packages/contracts/openapi/openapi.v1.json`: regenerated OpenAPI v1 artifact. +- `packages/contracts/src/generated/api-types.ts`: regenerated TypeScript types. +- `packages/contracts/fixtures/ui/v1/*.json`: 7 sanitized UI fixtures. +- `packages/contracts/scripts/validate-fixtures.mjs`: updated fixture validator to validate UI fixtures. +- `packages/contracts/test/validate-fixtures.test.mjs`: test UI fixture validation. +- `packages/contracts/src/mock-server.ts`: versioned OpenAPI mock server (`OPENAPI_MOCK_SERVER_VERSION = '1.0.0'`). +- `packages/contracts/src/index.ts`: export mock-server. +- `packages/contracts/test/mock-server.test.ts`: mock server unit tests. +- `docs/GATE_P4_CHECKLIST.md`: mark Step 4 as `[COMPLETED]`. +- `docs/GATE_P4_MANIFEST.md`: manifest documenting backend convergence and frozen frontend boundary. +- `.agent/context/20260908T155000Z-gate-p4-frontend-freeze.md`: this session context. + +## Commands/checks + +- `pnpm check:generated`: PASS (0 drift) +- `pnpm validate:fixtures`: PASS (9 contracts-v1 fixtures, 7 ui-v1 fixtures) +- `pnpm format:check`: PASS +- `pnpm lint`: PASS +- `pnpm typecheck`: PASS +- `pnpm test`: PASS (44 test files, 585 tests) +- `npx markdownlint-cli2 "**/*.md" "#node_modules"`: PASS (95 files, 0 issues) + +## Review gates + +- Gate A: PENDING +- Gate B: PENDING diff --git a/docs/GATE_P4_CHECKLIST.md b/docs/GATE_P4_CHECKLIST.md index 65b6a1e..0b400c4 100644 --- a/docs/GATE_P4_CHECKLIST.md +++ b/docs/GATE_P4_CHECKLIST.md @@ -48,11 +48,12 @@ readiness probing they build on. See - Provided `createProductionRecoveryService` in `apps/worker/src/composition.ts` for full production worker recovery composition. - Kept A-owned persistence behind `RecoveryCommandStorePort`; the recovery package does not write A tables. -4. **Freeze the frontend boundary**: - - Revalidate the A01 OpenAPI v1 artifact against the composed backend. - - Freeze recovery-view semantics and sanitized UI fixtures. - - Publish a versioned mock server that serves the frozen OpenAPI behavior. - - Keep A05, B05, and C05 blocked until this step and the integrated proofs pass. +4. **Freeze the frontend boundary**: [COMPLETED] + - Revalidated the A01 OpenAPI v1 artifact against the composed backend with additive sanitized fields (`policy` summary, attempt `authorization_status`, settlement `token_contract`, and `explorer_url`). + - Froze recovery-view semantics and published sanitized UI fixtures in `packages/contracts/fixtures/ui/v1/`. + - Published versioned OpenAPI mock server (`OPENAPI_MOCK_SERVER_VERSION = '1.0.0'`) in `@oneshot/contracts`. + - Published sanitized Gate P4 manifest in `docs/GATE_P4_MANIFEST.md`. + - Frontend milestones (A05, B05, C05) unblocked to build on frozen contracts and mock server. ## Verification Commands diff --git a/docs/GATE_P4_MANIFEST.md b/docs/GATE_P4_MANIFEST.md new file mode 100644 index 0000000..572b3de --- /dev/null +++ b/docs/GATE_P4_MANIFEST.md @@ -0,0 +1,78 @@ +# Gate P4 Manifest: Backend Convergence and Frozen Frontend Boundary + +## Objective and Convergence Status + +Gate P4 represents the backend convergence boundary across all three coders (A04 restart safety & worker composition, B04 settlement adapter & Privy authorization, C04 recovery matrix & Subgraph MCP) and establishes the frozen HTTP seam for frontend milestones (A05, B05, C05). + +All checked simulators in production worker composition are replaced with real reviewed package entry points, and the OpenAPI v1 contract seam is frozen with additive sanitized fields, versioned mock server, and validated UI fixtures. + +## Package Version Slots + +| Slot | Planned Package | Owning Lane | Gate P4 State | Pinned Identifier / Digest | +| --- | --- | --- | --- | --- | +| Core Contracts | `@oneshot/contracts@0.1.0` | Shared / Frozen | Pinned & Regenerated | Schema Digest: `4e1fd12de4ee2cb268774437e6adf1b4939e16d2be44e50553b555e7945847e0`
OpenAPI Digest: `f639e2d2729cd061d606cd35eb83961c58067a3660ecc5437c0f4596c88edc2c`
Mock Server: `1.0.0` | +| Domain Models | `@oneshot/domain@0.1.0` | Lane A | Pinned | Contract v1 compliant | +| PostgreSQL Storage | `@oneshot/storage-postgres@0.1.0` | Lane A | Pinned | Schema Digest: `5d5888894ff0f4f44049579f1c8ffca2a24e0b61c3af65aabdbcd78f06020d65` | +| Settlement Worker | `@oneshot/worker@0.1.0` | Lane A | Converged | Production profile wired with Lane B and C adapters | +| Arc Settlement Adapter | `@oneshot/privy-adapter` (`ArcSettlementAdapter`) | Lane B | Integrated & Wired | Pinned Arc testnet `eip155:5042002` | +| Privy Authorization Adapter | `@oneshot/privy-adapter` (`PrivyAuthorizationAdapter`) | Lane B | Integrated & Wired | Policy authorization `1.0.0` | +| Subgraph MCP Recovery | `@oneshot/reconciliation` (`RecoveryService`) | Lane C | Integrated & Wired | Wired via `recovery-bridge` over durable `IntentLedger` | +| Recovery UI Components | `@oneshot/recovery-ui@0.1.0` | Lane C | Pinned | Mock Server `1.0.0` | + +## Frozen Frontend Boundary (OpenAPI v1) + +### Endpoints and Invariants + +The OpenAPI v1 contract seam exposes exactly the following 6 paths: + +1. `POST /v1/intents`: Create or idempotent replay of a Business Intent (202 Accepted, 200 OK replay, 409 Conflict). +2. `GET /v1/intents/{id}`: Read authoritative intent state and evidence observations. +3. `POST /v1/intents/{id}/reconcile`: Queue read-only reconciliation without submitting replacement settlement (409 if terminal state). +4. `GET /v1/intents/{id}/recovery-view`: Read authority-labelled recovery evidence and recommended actions (`WAIT`, `RECONCILE`, `ESCALATE`, `RETURN_EXISTING_RESULT`). +5. `GET /health/live`: Process liveness check. +6. `GET /health/ready`: Database, configuration, and Arc identity readiness probe. + +**Critical Contract Invariant**: Zero endpoints allow blind settlement retries. Any `/retry` path is strictly absent and rejected. + +### Additive Sanitized Fields for B05 + +To support the B05 frontend milestone without breaking existing consumers or contract invariants, the following additive fields are frozen in OpenAPI v1: + +- `IntentResponse.policy`: Optional `PolicySummaryView` containing `policy_id`, `status` (`CONFIGURED` | `EXCEEDED` | `NOT_CONFIGURED` | `UNKNOWN`), `settlement_cap_atomic`, and `allowed_recipients`. +- `AttemptView.authorization_status`: Optional `AuthorizationStatus` (`CHECKING` | `AUTHORIZED` | `DENIED` | `UNAVAILABLE` | `CONFIG_MISMATCH`). +- `SettlementView.token_contract`: Optional EVM contract address (`0x...`). +- `SettlementView.explorer_url`: Optional block explorer URI for the settled transaction. + +## Versioned Mock Server + +The versioned mock server is published directly in `@oneshot/contracts`: + +- **Version**: `OPENAPI_MOCK_SERVER_VERSION = '1.0.0'` +- **Header**: Every mock response emits `x-oneshot-mock-version: 1.0.0`. +- **Exports**: + - `handleOpenApiMockRequest(url, method, body, state)`: Pure deterministic request dispatcher. + - `createOpenApiMockFetch(state)`: `fetch`-compatible mock router for Node and browser environments. + - `createOpenApiMockClient(options)`: Strongly-typed mock API client for frontend milestones. + - `UI_FIXTURES`: Typed in-memory scenarios covering all frozen states. + +## Sanitized UI Fixtures + +Published under `packages/contracts/fixtures/ui/v1/`: + +1. `authorized-committed.json`: Fully settled intent on Arc testnet (`COMMITTED`), containing policy, settlement explorer link, and authoritative Arc/Privy observations. +2. `auth-checking.json`: Pending intent undergoing Privy policy authorization (`AUTHORIZING`, attempt `CHECKING`). +3. `auth-denied-recipient.json`: Rejected intent due to recipient not present on allowlist (`REJECTED`, `DENIED`). +4. `auth-cap-exceeded.json`: Rejected intent due to requested amount exceeding spending cap (`REJECTED`, `DENIED`, policy `EXCEEDED`). +5. `auth-unavailable.json`: Safe-failed intent due to authorization service unavailability (`FAILED_SAFE`, attempt `UNAVAILABLE`). +6. `auth-config-mismatch.json`: Rejected intent due to policy configuration mismatch (`REJECTED`, `CONFIG_MISMATCH`). +7. `unknown-reconcile-only.json`: Ambiguous intent post-broadcast timeout (`UNKNOWN`), requiring read-only reconciliation with lagging Graph evidence. + +All fixtures are verified free of sensitive keys and conform to the published JSON Schema bundle via `pnpm validate:fixtures`. + +## Testnet Evidence Mode Verification Status + +Per `docs/plan.md` (procedure steps 8-12): + +- Local offline verification, simulated external adapter composition, empty/upgrade migrations, and safe-disable checks have passed. +- Testnet evidence mode configuration is documented in `docs/settlement/SETTLEMENT_CONFIG_V1.md` and `docs/settlement/GATE_P4_LANE_B_READINESS.md`. +- Live testnet wallet funding and live transaction execution remain gated on explicit human authorization per repository safety rules. No live keys or secret seeds are stored in the repository. diff --git a/packages/contracts/fixtures/ui/v1/auth-cap-exceeded.json b/packages/contracts/fixtures/ui/v1/auth-cap-exceeded.json new file mode 100644 index 0000000..11a59c9 --- /dev/null +++ b/packages/contracts/fixtures/ui/v1/auth-cap-exceeded.json @@ -0,0 +1,51 @@ +{ + "scenario": "auth-cap-exceeded", + "description": "Intent authorization denied because amount exceeds configured settlement cap", + "intent": { + "business_intent_id": "018f-ui-cap-exceeded-004", + "payload_fingerprint": "a000000000000000000000000000000000000000000000000000000000000004", + "recipient": "0x1111111111111111111111111111111111111111", + "amount_atomic": "50000000", + "asset": "USDC", + "network": "eip155:5042002", + "purpose": "Invoice INV-1004", + "state": "REJECTED", + "version": 2, + "policy": { + "policy_id": "privy-policy-arc-prod", + "status": "EXCEEDED", + "settlement_cap_atomic": "10000000", + "allowed_recipients": ["0x1111111111111111111111111111111111111111"] + }, + "attempts": [ + { + "attempt_id": "attempt-ui-004", + "stage": "REJECTED", + "created_at": "2026-09-08T12:15:00.000Z", + "sanitized_error": "Amount 50000000 exceeds settlement cap 10000000", + "authorization_status": "DENIED" + } + ], + "evidence": [ + { + "source": "PRIVY", + "authority_class": "AUTHORITATIVE", + "retrieved_at": "2026-09-08T12:15:01.000Z", + "digest": "digest-cap-exceeded-004" + } + ] + }, + "recovery_view": { + "business_intent_id": "018f-ui-cap-exceeded-004", + "authoritative_state": "REJECTED", + "recommended_action": "RETURN_EXISTING_RESULT", + "evidence": [ + { + "source": "PRIVY", + "authority_class": "AUTHORITATIVE", + "retrieved_at": "2026-09-08T12:15:01.000Z", + "digest": "digest-cap-exceeded-004" + } + ] + } +} diff --git a/packages/contracts/fixtures/ui/v1/auth-checking.json b/packages/contracts/fixtures/ui/v1/auth-checking.json new file mode 100644 index 0000000..150e9ca --- /dev/null +++ b/packages/contracts/fixtures/ui/v1/auth-checking.json @@ -0,0 +1,36 @@ +{ + "scenario": "auth-checking", + "description": "Intent submitted and currently undergoing authorization check", + "intent": { + "business_intent_id": "018f-ui-checking-002", + "payload_fingerprint": "a000000000000000000000000000000000000000000000000000000000000002", + "recipient": "0x1111111111111111111111111111111111111111", + "amount_atomic": "1250000", + "asset": "USDC", + "network": "eip155:5042002", + "purpose": "Invoice INV-1002", + "state": "AUTHORIZING", + "version": 1, + "policy": { + "policy_id": "privy-policy-arc-prod", + "status": "CONFIGURED", + "settlement_cap_atomic": "10000000", + "allowed_recipients": ["0x1111111111111111111111111111111111111111"] + }, + "attempts": [ + { + "attempt_id": "attempt-ui-002", + "stage": "AUTHORIZING", + "created_at": "2026-09-08T12:05:00.000Z", + "authorization_status": "CHECKING" + } + ], + "evidence": [] + }, + "recovery_view": { + "business_intent_id": "018f-ui-checking-002", + "authoritative_state": "AUTHORIZING", + "recommended_action": "WAIT", + "evidence": [] + } +} diff --git a/packages/contracts/fixtures/ui/v1/auth-config-mismatch.json b/packages/contracts/fixtures/ui/v1/auth-config-mismatch.json new file mode 100644 index 0000000..5bf42f3 --- /dev/null +++ b/packages/contracts/fixtures/ui/v1/auth-config-mismatch.json @@ -0,0 +1,35 @@ +{ + "scenario": "auth-config-mismatch", + "description": "Policy configuration mismatch resulting in rejected intent", + "intent": { + "business_intent_id": "018f-ui-mismatch-006", + "payload_fingerprint": "a000000000000000000000000000000000000000000000000000000000000006", + "recipient": "0x1111111111111111111111111111111111111111", + "amount_atomic": "1250000", + "asset": "USDC", + "network": "eip155:5042002", + "purpose": "Invoice INV-1006", + "state": "REJECTED", + "version": 2, + "policy": { + "policy_id": "privy-policy-mismatch", + "status": "NOT_CONFIGURED" + }, + "attempts": [ + { + "attempt_id": "attempt-ui-006", + "stage": "REJECTED", + "created_at": "2026-09-08T12:25:00.000Z", + "sanitized_error": "Policy configuration mismatch: expected configured Arc testnet wallet", + "authorization_status": "CONFIG_MISMATCH" + } + ], + "evidence": [] + }, + "recovery_view": { + "business_intent_id": "018f-ui-mismatch-006", + "authoritative_state": "REJECTED", + "recommended_action": "RETURN_EXISTING_RESULT", + "evidence": [] + } +} diff --git a/packages/contracts/fixtures/ui/v1/auth-denied-recipient.json b/packages/contracts/fixtures/ui/v1/auth-denied-recipient.json new file mode 100644 index 0000000..070e447 --- /dev/null +++ b/packages/contracts/fixtures/ui/v1/auth-denied-recipient.json @@ -0,0 +1,51 @@ +{ + "scenario": "auth-denied-recipient", + "description": "Intent authorization denied because recipient is not in configured allowlist", + "intent": { + "business_intent_id": "018f-ui-denied-recip-003", + "payload_fingerprint": "a000000000000000000000000000000000000000000000000000000000000003", + "recipient": "0x1111111111111111111111111111111111111111", + "amount_atomic": "1250000", + "asset": "USDC", + "network": "eip155:5042002", + "purpose": "Invoice INV-1003", + "state": "REJECTED", + "version": 2, + "policy": { + "policy_id": "privy-policy-arc-prod", + "status": "CONFIGURED", + "settlement_cap_atomic": "10000000", + "allowed_recipients": ["0x2222222222222222222222222222222222222222"] + }, + "attempts": [ + { + "attempt_id": "attempt-ui-003", + "stage": "REJECTED", + "created_at": "2026-09-08T12:10:00.000Z", + "sanitized_error": "Recipient 0x1111111111111111111111111111111111111111 is not on allowlist", + "authorization_status": "DENIED" + } + ], + "evidence": [ + { + "source": "PRIVY", + "authority_class": "AUTHORITATIVE", + "retrieved_at": "2026-09-08T12:10:01.000Z", + "digest": "digest-denied-recipient-003" + } + ] + }, + "recovery_view": { + "business_intent_id": "018f-ui-denied-recip-003", + "authoritative_state": "REJECTED", + "recommended_action": "RETURN_EXISTING_RESULT", + "evidence": [ + { + "source": "PRIVY", + "authority_class": "AUTHORITATIVE", + "retrieved_at": "2026-09-08T12:10:01.000Z", + "digest": "digest-denied-recipient-003" + } + ] + } +} diff --git a/packages/contracts/fixtures/ui/v1/auth-unavailable.json b/packages/contracts/fixtures/ui/v1/auth-unavailable.json new file mode 100644 index 0000000..ec6ea3b --- /dev/null +++ b/packages/contracts/fixtures/ui/v1/auth-unavailable.json @@ -0,0 +1,49 @@ +{ + "scenario": "auth-unavailable", + "description": "Privy authorization unavailable resulting in safe failure without settlement", + "intent": { + "business_intent_id": "018f-ui-unavailable-005", + "payload_fingerprint": "a000000000000000000000000000000000000000000000000000000000000005", + "recipient": "0x1111111111111111111111111111111111111111", + "amount_atomic": "1250000", + "asset": "USDC", + "network": "eip155:5042002", + "purpose": "Invoice INV-1005", + "state": "FAILED_SAFE", + "version": 2, + "policy": { + "policy_id": "privy-policy-arc-prod", + "status": "UNKNOWN" + }, + "attempts": [ + { + "attempt_id": "attempt-ui-005", + "stage": "FAILED_SAFE", + "created_at": "2026-09-08T12:20:00.000Z", + "sanitized_error": "Privy authorization service unavailable; timed out after 10000ms", + "authorization_status": "UNAVAILABLE" + } + ], + "evidence": [ + { + "source": "PRIVY", + "authority_class": "ADVISORY", + "retrieved_at": "2026-09-08T12:20:01.000Z", + "digest": "digest-unavailable-005" + } + ] + }, + "recovery_view": { + "business_intent_id": "018f-ui-unavailable-005", + "authoritative_state": "FAILED_SAFE", + "recommended_action": "RETURN_EXISTING_RESULT", + "evidence": [ + { + "source": "PRIVY", + "authority_class": "ADVISORY", + "retrieved_at": "2026-09-08T12:20:01.000Z", + "digest": "digest-unavailable-005" + } + ] + } +} diff --git a/packages/contracts/fixtures/ui/v1/authorized-committed.json b/packages/contracts/fixtures/ui/v1/authorized-committed.json new file mode 100644 index 0000000..83de672 --- /dev/null +++ b/packages/contracts/fixtures/ui/v1/authorized-committed.json @@ -0,0 +1,66 @@ +{ + "scenario": "authorized-committed", + "description": "Authorized intent successfully settled on Arc testnet", + "intent": { + "business_intent_id": "018f-ui-committed-001", + "payload_fingerprint": "a000000000000000000000000000000000000000000000000000000000000001", + "recipient": "0x1111111111111111111111111111111111111111", + "amount_atomic": "1250000", + "asset": "USDC", + "network": "eip155:5042002", + "purpose": "Invoice INV-1001", + "state": "COMMITTED", + "version": 3, + "policy": { + "policy_id": "privy-policy-arc-prod", + "status": "CONFIGURED", + "settlement_cap_atomic": "10000000", + "allowed_recipients": ["0x1111111111111111111111111111111111111111"] + }, + "attempts": [ + { + "attempt_id": "attempt-ui-001", + "stage": "COMMITTED", + "created_at": "2026-09-08T12:00:00.000Z", + "authorization_status": "AUTHORIZED" + } + ], + "settlement": { + "provider_reference_id": "arc-tx-001", + "transaction_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_number": "100", + "transfer_log_index": 0, + "token_contract": "0x3600000000000000000000000000000000000000", + "explorer_url": "https://testnet.arcscan.io/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "evidence": [ + { + "source": "PRIVY", + "authority_class": "AUTHORITATIVE", + "retrieved_at": "2026-09-08T12:00:01.000Z", + "digest": "digest-privy-001" + }, + { + "source": "ARC", + "authority_class": "AUTHORITATIVE", + "retrieved_at": "2026-09-08T12:00:02.000Z", + "digest": "digest-arc-001", + "block_number": "100" + } + ] + }, + "recovery_view": { + "business_intent_id": "018f-ui-committed-001", + "authoritative_state": "COMMITTED", + "recommended_action": "RETURN_EXISTING_RESULT", + "evidence": [ + { + "source": "ARC", + "authority_class": "AUTHORITATIVE", + "retrieved_at": "2026-09-08T12:00:02.000Z", + "digest": "digest-arc-001", + "block_number": "100" + } + ] + } +} diff --git a/packages/contracts/fixtures/ui/v1/unknown-reconcile-only.json b/packages/contracts/fixtures/ui/v1/unknown-reconcile-only.json new file mode 100644 index 0000000..396d563 --- /dev/null +++ b/packages/contracts/fixtures/ui/v1/unknown-reconcile-only.json @@ -0,0 +1,53 @@ +{ + "scenario": "unknown-reconcile-only", + "description": "Network timeout after broadcast with state UNKNOWN; read-only reconciliation required", + "intent": { + "business_intent_id": "018f-ui-unknown-007", + "payload_fingerprint": "a000000000000000000000000000000000000000000000000000000000000007", + "recipient": "0x1111111111111111111111111111111111111111", + "amount_atomic": "1250000", + "asset": "USDC", + "network": "eip155:5042002", + "purpose": "Invoice INV-1007", + "state": "UNKNOWN", + "version": 2, + "policy": { + "policy_id": "privy-policy-arc-prod", + "status": "CONFIGURED", + "settlement_cap_atomic": "10000000", + "allowed_recipients": ["0x1111111111111111111111111111111111111111"] + }, + "attempts": [ + { + "attempt_id": "attempt-ui-007", + "stage": "SUBMITTING", + "created_at": "2026-09-08T12:30:00.000Z", + "sanitized_error": "Connection reset after broadcast; transaction receipt pending verification", + "authorization_status": "AUTHORIZED" + } + ], + "evidence": [ + { + "source": "THE_GRAPH", + "authority_class": "OBSERVATION", + "retrieved_at": "2026-09-08T12:30:10.000Z", + "digest": "digest-subgraph-lagging-007", + "freshness": "LAGGING" + } + ] + }, + "recovery_view": { + "business_intent_id": "018f-ui-unknown-007", + "authoritative_state": "UNKNOWN", + "recommended_action": "RECONCILE", + "evidence": [ + { + "source": "THE_GRAPH", + "authority_class": "OBSERVATION", + "retrieved_at": "2026-09-08T12:30:10.000Z", + "digest": "digest-subgraph-lagging-007", + "freshness": "LAGGING" + } + ] + } +} diff --git a/packages/contracts/generated/contracts.schema.json b/packages/contracts/generated/contracts.schema.json index d517491..0f9c5f9 100644 --- a/packages/contracts/generated/contracts.schema.json +++ b/packages/contracts/generated/contracts.schema.json @@ -58,6 +58,49 @@ } } }, + "PolicySummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "policy_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "status": { + "type": "string", + "enum": [ + "CONFIGURED", + "EXCEEDED", + "NOT_CONFIGURED", + "UNKNOWN" + ] + }, + "settlement_cap_atomic": { + "type": "string", + "minLength": 1, + "maxLength": 78, + "pattern": "^(0|[1-9][0-9]*)$", + "examples": [ + "1250000" + ] + }, + "allowed_recipients": { + "type": "array", + "items": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$", + "examples": [ + "0x1111111111111111111111111111111111111111" + ] + } + } + } + }, "Attempt": { "type": "object", "additionalProperties": false, @@ -93,6 +136,16 @@ "type": "string", "minLength": 1, "maxLength": 256 + }, + "authorization_status": { + "type": "string", + "enum": [ + "CHECKING", + "AUTHORIZED", + "DENIED", + "UNAVAILABLE", + "CONFIG_MISMATCH" + ] } } }, @@ -128,6 +181,18 @@ "transfer_log_index": { "type": "integer", "minimum": 0 + }, + "token_contract": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$", + "examples": [ + "0x1111111111111111111111111111111111111111" + ] + }, + "explorer_url": { + "type": "string", + "minLength": 1, + "maxLength": 256 } } }, @@ -261,6 +326,9 @@ "type": "integer", "minimum": 1 }, + "policy": { + "$ref": "#/$defs/PolicySummary" + }, "attempts": { "type": "array", "maxItems": 100, diff --git a/packages/contracts/openapi/openapi.v1.json b/packages/contracts/openapi/openapi.v1.json index a7f260b..1c12a62 100644 --- a/packages/contracts/openapi/openapi.v1.json +++ b/packages/contracts/openapi/openapi.v1.json @@ -459,6 +459,49 @@ } } }, + "PolicySummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "policy_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "status": { + "type": "string", + "enum": [ + "CONFIGURED", + "EXCEEDED", + "NOT_CONFIGURED", + "UNKNOWN" + ] + }, + "settlement_cap_atomic": { + "type": "string", + "minLength": 1, + "maxLength": 78, + "pattern": "^(0|[1-9][0-9]*)$", + "examples": [ + "1250000" + ] + }, + "allowed_recipients": { + "type": "array", + "items": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$", + "examples": [ + "0x1111111111111111111111111111111111111111" + ] + } + } + } + }, "Attempt": { "type": "object", "additionalProperties": false, @@ -494,6 +537,16 @@ "type": "string", "minLength": 1, "maxLength": 256 + }, + "authorization_status": { + "type": "string", + "enum": [ + "CHECKING", + "AUTHORIZED", + "DENIED", + "UNAVAILABLE", + "CONFIG_MISMATCH" + ] } } }, @@ -529,6 +582,18 @@ "transfer_log_index": { "type": "integer", "minimum": 0 + }, + "token_contract": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$", + "examples": [ + "0x1111111111111111111111111111111111111111" + ] + }, + "explorer_url": { + "type": "string", + "minLength": 1, + "maxLength": 256 } } }, @@ -662,6 +727,9 @@ "type": "integer", "minimum": 1 }, + "policy": { + "$ref": "#/components/schemas/PolicySummary" + }, "attempts": { "type": "array", "maxItems": 100, diff --git a/packages/contracts/scripts/generate-contracts.mjs b/packages/contracts/scripts/generate-contracts.mjs index 6e651dd..f814eff 100644 --- a/packages/contracts/scripts/generate-contracts.mjs +++ b/packages/contracts/scripts/generate-contracts.mjs @@ -27,6 +27,14 @@ const errorCodes = [ 'INTERNAL_ERROR', ]; const recoveryActions = ['WAIT', 'RECONCILE', 'ESCALATE', 'RETURN_EXISTING_RESULT']; +const authorizationStatuses = [ + 'CHECKING', + 'AUTHORIZED', + 'DENIED', + 'UNAVAILABLE', + 'CONFIG_MISMATCH', +]; +const policyStatuses = ['CONFIGURED', 'EXCEEDED', 'NOT_CONFIGURED', 'UNKNOWN']; const boundedId = { type: 'string', @@ -61,6 +69,20 @@ const schemas = { purpose: { type: 'string', minLength: 1, maxLength: 256, examples: ['Invoice INV-1001'] }, }, }, + PolicySummary: { + type: 'object', + additionalProperties: false, + required: ['status'], + properties: { + policy_id: boundedId, + status: { type: 'string', enum: policyStatuses }, + settlement_cap_atomic: amountAtomic, + allowed_recipients: { + type: 'array', + items: evmAddress, + }, + }, + }, Attempt: { type: 'object', additionalProperties: false, @@ -70,6 +92,7 @@ const schemas = { stage: { type: 'string', enum: intentStates }, created_at: { type: 'string', format: 'date-time' }, sanitized_error: { type: 'string', minLength: 1, maxLength: 256 }, + authorization_status: { type: 'string', enum: authorizationStatuses }, }, }, Settlement: { @@ -81,6 +104,8 @@ const schemas = { transaction_hash: { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$' }, block_number: amountAtomic, transfer_log_index: { type: 'integer', minimum: 0 }, + token_contract: evmAddress, + explorer_url: { type: 'string', minLength: 1, maxLength: 256 }, }, }, EvidenceObservation: { @@ -125,6 +150,7 @@ const schemas = { purpose: { type: 'string', minLength: 1, maxLength: 256 }, state: { type: 'string', enum: intentStates }, version: { type: 'integer', minimum: 1 }, + policy: { $ref: '#/$defs/PolicySummary' }, attempts: { type: 'array', maxItems: 100, items: { $ref: '#/$defs/Attempt' } }, settlement: { $ref: '#/$defs/Settlement' }, evidence: { type: 'array', maxItems: 100, items: { $ref: '#/$defs/EvidenceObservation' } }, @@ -308,7 +334,94 @@ const openapi = { }, }; -const generatedTypes = `// Generated by scripts/generate-contracts.mjs. Do not edit.\n\nexport const INTENT_STATES = ${JSON.stringify(intentStates)} as const;\nexport type IntentState = (typeof INTENT_STATES)[number];\n\nexport const ERROR_CODES = ${JSON.stringify(errorCodes)} as const;\nexport type ErrorCode = (typeof ERROR_CODES)[number];\n\nexport const RECOVERY_ACTIONS = ${JSON.stringify(recoveryActions)} as const;\nexport type RecoveryActionName = (typeof RECOVERY_ACTIONS)[number];\n\nexport interface CreateIntentRequest {\n readonly business_intent_id: string;\n readonly recipient: string;\n readonly amount_atomic: string;\n readonly asset: 'USDC';\n readonly network: 'eip155:5042002';\n readonly purpose: string;\n}\n\nexport interface IntentResponse extends CreateIntentRequest {\n readonly payload_fingerprint: string;\n readonly state: IntentState;\n readonly version: number;\n readonly attempts: readonly AttemptView[];\n readonly settlement?: SettlementView;\n readonly evidence: readonly EvidenceView[];\n}\n\nexport interface AttemptView {\n readonly attempt_id: string;\n readonly stage: IntentState;\n readonly created_at: string;\n readonly sanitized_error?: string;\n}\n\nexport interface SettlementView {\n readonly provider_reference_id: string;\n readonly transaction_hash: string;\n readonly block_number: string;\n readonly transfer_log_index: number;\n}\n\nexport interface EvidenceView {\n readonly source: 'ONESHOT' | 'PRIVY' | 'ARC' | 'THE_GRAPH' | 'LLM';\n readonly authority_class: 'AUTHORITATIVE' | 'OBSERVATION' | 'ADVISORY';\n readonly retrieved_at: string;\n readonly digest: string;\n readonly block_number?: string;\n readonly freshness?: 'FRESH' | 'LAGGING' | 'UNHEALTHY' | 'UNAVAILABLE' | 'UNKNOWN_FRESHNESS';\n}\n\nexport interface ReconcileResponse {\n readonly business_intent_id: string;\n readonly queued: boolean;\n readonly state: IntentState;\n}\n\nexport interface RecoveryView {\n readonly business_intent_id: string;\n readonly authoritative_state: IntentState;\n readonly recommended_action: RecoveryActionName;\n readonly evidence: readonly EvidenceView[];\n}\n\nexport interface ErrorResponse {\n readonly code: ErrorCode;\n readonly message: string;\n readonly correlation_id: string;\n}\n`; +const generatedTypes = `// Generated by scripts/generate-contracts.mjs. Do not edit. + +export const INTENT_STATES = ${JSON.stringify(intentStates)} as const; +export type IntentState = (typeof INTENT_STATES)[number]; + +export const ERROR_CODES = ${JSON.stringify(errorCodes)} as const; +export type ErrorCode = (typeof ERROR_CODES)[number]; + +export const RECOVERY_ACTIONS = ${JSON.stringify(recoveryActions)} as const; +export type RecoveryActionName = (typeof RECOVERY_ACTIONS)[number]; + +export const AUTHORIZATION_STATUSES = ${JSON.stringify(authorizationStatuses)} as const; +export type AuthorizationStatus = (typeof AUTHORIZATION_STATUSES)[number]; + +export const POLICY_STATUSES = ${JSON.stringify(policyStatuses)} as const; +export type PolicyStatus = (typeof POLICY_STATUSES)[number]; + +export interface CreateIntentRequest { + readonly business_intent_id: string; + readonly recipient: string; + readonly amount_atomic: string; + readonly asset: 'USDC'; + readonly network: 'eip155:5042002'; + readonly purpose: string; +} + +export interface PolicySummaryView { + readonly policy_id?: string; + readonly status: PolicyStatus; + readonly settlement_cap_atomic?: string; + readonly allowed_recipients?: readonly string[]; +} + +export interface IntentResponse extends CreateIntentRequest { + readonly payload_fingerprint: string; + readonly state: IntentState; + readonly version: number; + readonly policy?: PolicySummaryView; + readonly attempts: readonly AttemptView[]; + readonly settlement?: SettlementView; + readonly evidence: readonly EvidenceView[]; +} + +export interface AttemptView { + readonly attempt_id: string; + readonly stage: IntentState; + readonly created_at: string; + readonly sanitized_error?: string; + readonly authorization_status?: AuthorizationStatus; +} + +export interface SettlementView { + readonly provider_reference_id: string; + readonly transaction_hash: string; + readonly block_number: string; + readonly transfer_log_index: number; + readonly token_contract?: string; + readonly explorer_url?: string; +} + +export interface EvidenceView { + readonly source: 'ONESHOT' | 'PRIVY' | 'ARC' | 'THE_GRAPH' | 'LLM'; + readonly authority_class: 'AUTHORITATIVE' | 'OBSERVATION' | 'ADVISORY'; + readonly retrieved_at: string; + readonly digest: string; + readonly block_number?: string; + readonly freshness?: 'FRESH' | 'LAGGING' | 'UNHEALTHY' | 'UNAVAILABLE' | 'UNKNOWN_FRESHNESS'; +} + +export interface ReconcileResponse { + readonly business_intent_id: string; + readonly queued: boolean; + readonly state: IntentState; +} + +export interface RecoveryView { + readonly business_intent_id: string; + readonly authoritative_state: IntentState; + readonly recommended_action: RecoveryActionName; + readonly evidence: readonly EvidenceView[]; +} + +export interface ErrorResponse { + readonly code: ErrorCode; + readonly message: string; + readonly correlation_id: string; +} +`; const artifacts = new Map([ ['generated/contracts.schema.json', `${JSON.stringify(schemaBundle, null, 2)}\n`], diff --git a/packages/contracts/scripts/validate-fixtures.mjs b/packages/contracts/scripts/validate-fixtures.mjs index 49b410b..66fbcd9 100644 --- a/packages/contracts/scripts/validate-fixtures.mjs +++ b/packages/contracts/scripts/validate-fixtures.mjs @@ -76,9 +76,24 @@ const fixtureSchema = { }, }; +const uiFixtureSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + additionalProperties: false, + required: ['scenario', 'description', 'intent', 'recovery_view'], + $defs: contractBundle.$defs, + properties: { + scenario: { type: 'string', minLength: 1 }, + description: { type: 'string', minLength: 1 }, + intent: contractBundle.$defs.IntentResponse, + recovery_view: contractBundle.$defs.RecoveryView, + }, +}; + const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); const validate = ajv.compile(fixtureSchema); +const validateUi = ajv.compile(uiFixtureSchema); const forbiddenKey = /^(?:private[_-]?key|seed[_-]?phrase|mnemonic|api[_-]?key|access[_-]?token|wallet[_-]?credentials?)$/iu; @@ -104,6 +119,15 @@ export function validateFixtureObject(value, source = '') { return value; } +export function validateUiFixtureObject(value, source = '') { + rejectSensitiveKeys(value); + if (!validateUi(value)) { + const details = ajv.errorsText(validateUi.errors, { separator: '; ' }); + throw new Error(`Invalid UI fixture ${source}: ${details}`); + } + return value; +} + async function jsonFiles(directory) { const result = []; for (const entry of await readdir(directory, { withFileTypes: true })) { @@ -124,7 +148,22 @@ export async function validateFixtureDirectory(directory = resolve(packageRoot, return files; } +export async function validateUiFixtureDirectory( + directory = resolve(packageRoot, 'fixtures', 'ui', 'v1'), +) { + const files = await jsonFiles(directory); + if (files.length === 0) throw new Error(`No UI fixtures found under ${directory}`); + for (const file of files) { + const value = JSON.parse(await readFile(file, 'utf8')); + validateUiFixtureObject(value, file); + } + return files; +} + if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const files = await validateFixtureDirectory(); - console.log(`Validated ${files.length} contracts-v1 fixtures.`); + const backendFiles = await validateFixtureDirectory(); + const uiFiles = await validateUiFixtureDirectory(); + console.log( + `Validated ${backendFiles.length} contracts-v1 fixtures and ${uiFiles.length} ui-v1 fixtures.`, + ); } diff --git a/packages/contracts/src/generated/api-types.ts b/packages/contracts/src/generated/api-types.ts index 4b83b6b..23f0391 100644 --- a/packages/contracts/src/generated/api-types.ts +++ b/packages/contracts/src/generated/api-types.ts @@ -9,6 +9,12 @@ export type ErrorCode = (typeof ERROR_CODES)[number]; export const RECOVERY_ACTIONS = ["WAIT","RECONCILE","ESCALATE","RETURN_EXISTING_RESULT"] as const; export type RecoveryActionName = (typeof RECOVERY_ACTIONS)[number]; +export const AUTHORIZATION_STATUSES = ["CHECKING","AUTHORIZED","DENIED","UNAVAILABLE","CONFIG_MISMATCH"] as const; +export type AuthorizationStatus = (typeof AUTHORIZATION_STATUSES)[number]; + +export const POLICY_STATUSES = ["CONFIGURED","EXCEEDED","NOT_CONFIGURED","UNKNOWN"] as const; +export type PolicyStatus = (typeof POLICY_STATUSES)[number]; + export interface CreateIntentRequest { readonly business_intent_id: string; readonly recipient: string; @@ -18,10 +24,18 @@ export interface CreateIntentRequest { readonly purpose: string; } +export interface PolicySummaryView { + readonly policy_id?: string; + readonly status: PolicyStatus; + readonly settlement_cap_atomic?: string; + readonly allowed_recipients?: readonly string[]; +} + export interface IntentResponse extends CreateIntentRequest { readonly payload_fingerprint: string; readonly state: IntentState; readonly version: number; + readonly policy?: PolicySummaryView; readonly attempts: readonly AttemptView[]; readonly settlement?: SettlementView; readonly evidence: readonly EvidenceView[]; @@ -32,6 +46,7 @@ export interface AttemptView { readonly stage: IntentState; readonly created_at: string; readonly sanitized_error?: string; + readonly authorization_status?: AuthorizationStatus; } export interface SettlementView { @@ -39,6 +54,8 @@ export interface SettlementView { readonly transaction_hash: string; readonly block_number: string; readonly transfer_log_index: number; + readonly token_contract?: string; + readonly explorer_url?: string; } export interface EvidenceView { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 44867ba..9b3d5fd 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,3 +3,4 @@ export * from './ids.js'; export * from './intent.js'; export * from './money.js'; export * from './ports.js'; +export * from './mock-server.js'; diff --git a/packages/contracts/src/mock-server.ts b/packages/contracts/src/mock-server.ts new file mode 100644 index 0000000..50604b3 --- /dev/null +++ b/packages/contracts/src/mock-server.ts @@ -0,0 +1,728 @@ +import type { + CreateIntentRequest, + IntentResponse, + ReconcileResponse, + RecoveryView, +} from './generated/api-types.js'; + +export const OPENAPI_MOCK_SERVER_VERSION = '1.0.0'; + +export interface OpenApiMockServerResult { + readonly status: number; + readonly body: unknown; + readonly headers?: Record; +} + +export interface UiFixtureScenario { + readonly scenario: string; + readonly description: string; + readonly intent: IntentResponse; + readonly recovery_view: RecoveryView; +} + +export const UI_FIXTURES: Record = { + 'authorized-committed': { + scenario: 'authorized-committed', + description: 'Authorized intent successfully settled on Arc testnet', + intent: { + business_intent_id: '018f-ui-committed-001', + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000001', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1250000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1001', + state: 'COMMITTED', + version: 3, + policy: { + policy_id: 'privy-policy-arc-prod', + status: 'CONFIGURED', + settlement_cap_atomic: '10000000', + allowed_recipients: ['0x1111111111111111111111111111111111111111'], + }, + attempts: [ + { + attempt_id: 'attempt-ui-001', + stage: 'COMMITTED', + created_at: '2026-09-08T12:00:00.000Z', + authorization_status: 'AUTHORIZED', + }, + ], + settlement: { + provider_reference_id: 'arc-tx-001', + transaction_hash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + block_number: '100', + transfer_log_index: 0, + token_contract: '0x3600000000000000000000000000000000000000', + explorer_url: + 'https://testnet.arcscan.io/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + evidence: [ + { + source: 'PRIVY', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T12:00:01.000Z', + digest: 'digest-privy-001', + }, + { + source: 'ARC', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T12:00:02.000Z', + digest: 'digest-arc-001', + block_number: '100', + }, + ], + }, + recovery_view: { + business_intent_id: '018f-ui-committed-001', + authoritative_state: 'COMMITTED', + recommended_action: 'RETURN_EXISTING_RESULT', + evidence: [ + { + source: 'ARC', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T12:00:02.000Z', + digest: 'digest-arc-001', + block_number: '100', + }, + ], + }, + }, + 'auth-checking': { + scenario: 'auth-checking', + description: 'Intent submitted and currently undergoing authorization check', + intent: { + business_intent_id: '018f-ui-checking-002', + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000002', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1250000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1002', + state: 'AUTHORIZING', + version: 1, + policy: { + policy_id: 'privy-policy-arc-prod', + status: 'CONFIGURED', + settlement_cap_atomic: '10000000', + allowed_recipients: ['0x1111111111111111111111111111111111111111'], + }, + attempts: [ + { + attempt_id: 'attempt-ui-002', + stage: 'AUTHORIZING', + created_at: '2026-09-08T12:05:00.000Z', + authorization_status: 'CHECKING', + }, + ], + evidence: [], + }, + recovery_view: { + business_intent_id: '018f-ui-checking-002', + authoritative_state: 'AUTHORIZING', + recommended_action: 'WAIT', + evidence: [], + }, + }, + 'auth-denied-recipient': { + scenario: 'auth-denied-recipient', + description: 'Intent authorization denied because recipient is not in configured allowlist', + intent: { + business_intent_id: '018f-ui-denied-recip-003', + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000003', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1250000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1003', + state: 'REJECTED', + version: 2, + policy: { + policy_id: 'privy-policy-arc-prod', + status: 'CONFIGURED', + settlement_cap_atomic: '10000000', + allowed_recipients: ['0x2222222222222222222222222222222222222222'], + }, + attempts: [ + { + attempt_id: 'attempt-ui-003', + stage: 'REJECTED', + created_at: '2026-09-08T12:10:00.000Z', + sanitized_error: + 'Recipient 0x1111111111111111111111111111111111111111 is not on allowlist', + authorization_status: 'DENIED', + }, + ], + evidence: [ + { + source: 'PRIVY', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T12:10:01.000Z', + digest: 'digest-denied-recipient-003', + }, + ], + }, + recovery_view: { + business_intent_id: '018f-ui-denied-recip-003', + authoritative_state: 'REJECTED', + recommended_action: 'RETURN_EXISTING_RESULT', + evidence: [ + { + source: 'PRIVY', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T12:10:01.000Z', + digest: 'digest-denied-recipient-003', + }, + ], + }, + }, + 'auth-cap-exceeded': { + scenario: 'auth-cap-exceeded', + description: 'Intent authorization denied because amount exceeds configured settlement cap', + intent: { + business_intent_id: '018f-ui-cap-exceeded-004', + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000004', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '50000000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1004', + state: 'REJECTED', + version: 2, + policy: { + policy_id: 'privy-policy-arc-prod', + status: 'EXCEEDED', + settlement_cap_atomic: '10000000', + allowed_recipients: ['0x1111111111111111111111111111111111111111'], + }, + attempts: [ + { + attempt_id: 'attempt-ui-004', + stage: 'REJECTED', + created_at: '2026-09-08T12:15:00.000Z', + sanitized_error: 'Amount 50000000 exceeds settlement cap 10000000', + authorization_status: 'DENIED', + }, + ], + evidence: [ + { + source: 'PRIVY', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T12:15:01.000Z', + digest: 'digest-cap-exceeded-004', + }, + ], + }, + recovery_view: { + business_intent_id: '018f-ui-cap-exceeded-004', + authoritative_state: 'REJECTED', + recommended_action: 'RETURN_EXISTING_RESULT', + evidence: [ + { + source: 'PRIVY', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T12:15:01.000Z', + digest: 'digest-cap-exceeded-004', + }, + ], + }, + }, + 'auth-unavailable': { + scenario: 'auth-unavailable', + description: 'Privy authorization unavailable resulting in safe failure without settlement', + intent: { + business_intent_id: '018f-ui-unavailable-005', + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000005', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1250000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1005', + state: 'FAILED_SAFE', + version: 2, + policy: { + policy_id: 'privy-policy-arc-prod', + status: 'UNKNOWN', + }, + attempts: [ + { + attempt_id: 'attempt-ui-005', + stage: 'FAILED_SAFE', + created_at: '2026-09-08T12:20:00.000Z', + sanitized_error: 'Privy authorization service unavailable; timed out after 10000ms', + authorization_status: 'UNAVAILABLE', + }, + ], + evidence: [ + { + source: 'PRIVY', + authority_class: 'ADVISORY', + retrieved_at: '2026-09-08T12:20:01.000Z', + digest: 'digest-unavailable-005', + }, + ], + }, + recovery_view: { + business_intent_id: '018f-ui-unavailable-005', + authoritative_state: 'FAILED_SAFE', + recommended_action: 'RETURN_EXISTING_RESULT', + evidence: [ + { + source: 'PRIVY', + authority_class: 'ADVISORY', + retrieved_at: '2026-09-08T12:20:01.000Z', + digest: 'digest-unavailable-005', + }, + ], + }, + }, + 'auth-config-mismatch': { + scenario: 'auth-config-mismatch', + description: 'Policy configuration mismatch resulting in rejected intent', + intent: { + business_intent_id: '018f-ui-mismatch-006', + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000006', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1250000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1006', + state: 'REJECTED', + version: 2, + policy: { + policy_id: 'privy-policy-mismatch', + status: 'NOT_CONFIGURED', + }, + attempts: [ + { + attempt_id: 'attempt-ui-006', + stage: 'REJECTED', + created_at: '2026-09-08T12:25:00.000Z', + sanitized_error: 'Policy configuration mismatch: expected configured Arc testnet wallet', + authorization_status: 'CONFIG_MISMATCH', + }, + ], + evidence: [], + }, + recovery_view: { + business_intent_id: '018f-ui-mismatch-006', + authoritative_state: 'REJECTED', + recommended_action: 'RETURN_EXISTING_RESULT', + evidence: [], + }, + }, + 'unknown-reconcile-only': { + scenario: 'unknown-reconcile-only', + description: + 'Network timeout after broadcast with state UNKNOWN; read-only reconciliation required', + intent: { + business_intent_id: '018f-ui-unknown-007', + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000007', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1250000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1007', + state: 'UNKNOWN', + version: 2, + policy: { + policy_id: 'privy-policy-arc-prod', + status: 'CONFIGURED', + settlement_cap_atomic: '10000000', + allowed_recipients: ['0x1111111111111111111111111111111111111111'], + }, + attempts: [ + { + attempt_id: 'attempt-ui-007', + stage: 'SUBMITTING', + created_at: '2026-09-08T12:30:00.000Z', + sanitized_error: + 'Connection reset after broadcast; transaction receipt pending verification', + authorization_status: 'AUTHORIZED', + }, + ], + evidence: [ + { + source: 'THE_GRAPH', + authority_class: 'OBSERVATION', + retrieved_at: '2026-09-08T12:30:10.000Z', + digest: 'digest-subgraph-lagging-007', + freshness: 'LAGGING', + }, + ], + }, + recovery_view: { + business_intent_id: '018f-ui-unknown-007', + authoritative_state: 'UNKNOWN', + recommended_action: 'RECONCILE', + evidence: [ + { + source: 'THE_GRAPH', + authority_class: 'OBSERVATION', + retrieved_at: '2026-09-08T12:30:10.000Z', + digest: 'digest-subgraph-lagging-007', + freshness: 'LAGGING', + }, + ], + }, + }, +}; + +export interface OpenApiMockState { + readonly intents: Map; + readonly recoveryViews: Map; +} + +export function createDefaultMockState(): OpenApiMockState { + const intents = new Map(); + const recoveryViews = new Map(); + for (const fixture of Object.values(UI_FIXTURES)) { + intents.set(fixture.intent.business_intent_id, JSON.parse(JSON.stringify(fixture.intent))); + recoveryViews.set( + fixture.recovery_view.business_intent_id, + JSON.parse(JSON.stringify(fixture.recovery_view)), + ); + } + return { intents, recoveryViews }; +} + +export function handleOpenApiMockRequest( + requestUrl: string | URL, + method = 'GET', + body?: unknown, + state: OpenApiMockState = createDefaultMockState(), +): OpenApiMockServerResult | null { + const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl, 'http://localhost'); + const pathname = url.pathname; + + // Reject any blind retry endpoint. + if (pathname.includes('retry')) { + return null; + } + + // Health endpoints + if (pathname === '/health/live' && method === 'GET') { + return { + status: 200, + body: { status: 'ok' }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + if (pathname === '/health/ready' && method === 'GET') { + return { + status: 200, + body: { status: 'ok' }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + // POST /v1/intents + if (pathname === '/v1/intents' && method === 'POST') { + const candidate = body as Partial | undefined; + if ( + !candidate || + typeof candidate.business_intent_id !== 'string' || + typeof candidate.recipient !== 'string' || + typeof candidate.amount_atomic !== 'string' || + candidate.asset !== 'USDC' || + candidate.network !== 'eip155:5042002' || + typeof candidate.purpose !== 'string' + ) { + return { + status: 400, + body: { + code: 'INVALID_REQUEST', + message: 'Invalid intent payload', + correlation_id: 'mock-correlation-id', + }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + const existing = state.intents.get(candidate.business_intent_id); + if (existing) { + const isIdentical = + existing.recipient.toLowerCase() === candidate.recipient.toLowerCase() && + existing.amount_atomic === candidate.amount_atomic && + existing.asset === candidate.asset && + existing.network === candidate.network && + existing.purpose === candidate.purpose; + + if (isIdentical) { + return { + status: 200, + body: existing, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + return { + status: 409, + body: { + code: 'INTENT_PAYLOAD_CONFLICT', + message: 'Business Intent already exists with a different immutable payload', + correlation_id: 'mock-correlation-id', + }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + const newIntent: IntentResponse = { + business_intent_id: candidate.business_intent_id, + payload_fingerprint: 'a000000000000000000000000000000000000000000000000000000000000099', + recipient: candidate.recipient, + amount_atomic: candidate.amount_atomic, + asset: candidate.asset, + network: candidate.network, + purpose: candidate.purpose, + state: 'AUTHORIZING', + version: 1, + attempts: [ + { + attempt_id: `attempt-${candidate.business_intent_id}-1`, + stage: 'AUTHORIZING', + created_at: new Date().toISOString(), + authorization_status: 'CHECKING', + }, + ], + evidence: [], + }; + state.intents.set(candidate.business_intent_id, newIntent); + + return { + status: 202, + body: newIntent, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + // GET /v1/intents/:id/recovery-view + const recoveryViewMatch = /^\/v1\/intents\/([^/]+)\/recovery-view$/u.exec(pathname); + if (recoveryViewMatch) { + if (method !== 'GET') { + return { + status: 405, + body: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed' }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + const id = decodeURIComponent(recoveryViewMatch[1] ?? ''); + const recoveryView = state.recoveryViews.get(id); + if (recoveryView) { + return { + status: 200, + body: recoveryView, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + const intent = state.intents.get(id); + if (intent) { + const synthesized: RecoveryView = { + business_intent_id: id, + authoritative_state: intent.state, + recommended_action: + intent.state === 'COMMITTED' + ? 'RETURN_EXISTING_RESULT' + : intent.state === 'UNKNOWN' + ? 'RECONCILE' + : 'WAIT', + evidence: intent.evidence, + }; + return { + status: 200, + body: synthesized, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + return { + status: 404, + body: { + code: 'INTENT_NOT_FOUND', + message: 'Business Intent was not found', + correlation_id: 'mock-correlation-id', + }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + // POST /v1/intents/:id/reconcile + const reconcileMatch = /^\/v1\/intents\/([^/]+)\/reconcile$/u.exec(pathname); + if (reconcileMatch) { + if (method !== 'POST') { + return { + status: 405, + body: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed' }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + const id = decodeURIComponent(reconcileMatch[1] ?? ''); + const intent = state.intents.get(id); + if (!intent) { + return { + status: 404, + body: { + code: 'INTENT_NOT_FOUND', + message: 'Business Intent was not found', + correlation_id: 'mock-correlation-id', + }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + if (intent.state === 'COMMITTED' || intent.state === 'FAILED_SAFE') { + return { + status: 409, + body: { + code: 'RECONCILIATION_NOT_ALLOWED', + message: 'Intent state does not permit reconciliation', + correlation_id: 'mock-correlation-id', + }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + const reconcileResponse: ReconcileResponse = { + business_intent_id: id, + queued: true, + state: intent.state, + }; + return { + status: 202, + body: reconcileResponse, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + // GET /v1/intents/:id + const intentMatch = /^\/v1\/intents\/([^/]+)$/u.exec(pathname); + if (intentMatch) { + if (method !== 'GET') { + return { + status: 405, + body: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed' }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + const id = decodeURIComponent(intentMatch[1] ?? ''); + const intent = state.intents.get(id); + if (!intent) { + return { + status: 404, + body: { + code: 'INTENT_NOT_FOUND', + message: 'Business Intent was not found', + correlation_id: 'mock-correlation-id', + }, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + return { + status: 200, + body: intent, + headers: { 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION }, + }; + } + + return null; +} + +export function createOpenApiMockFetch( + state: OpenApiMockState = createDefaultMockState(), +): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input instanceof URL ? input : input.url; + const method = + init?.method ?? + (typeof input === 'object' && input !== null && 'method' in input ? input.method : 'GET') ?? + 'GET'; + let body: unknown = undefined; + if (init?.body && typeof init.body === 'string') { + try { + body = JSON.parse(init.body); + } catch { + body = init.body; + } + } + const result = handleOpenApiMockRequest(url, method, body, state); + if (!result) { + return new Response( + JSON.stringify({ + code: 'NOT_FOUND', + message: 'Resource not found on OneShot OpenAPI mock server', + }), + { + status: 404, + headers: { + 'content-type': 'application/json', + 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION, + }, + }, + ); + } + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + 'content-type': 'application/json', + 'x-oneshot-mock-version': OPENAPI_MOCK_SERVER_VERSION, + ...result.headers, + }, + }); + }; +} + +export interface OpenApiMockClient { + readonly version: string; + createIntent(request: CreateIntentRequest): Promise<{ status: number; data: IntentResponse }>; + getIntent(id: string): Promise<{ status: number; data: IntentResponse }>; + reconcile(id: string): Promise<{ status: number; data: ReconcileResponse }>; + getRecoveryView(id: string): Promise<{ status: number; data: RecoveryView }>; + getLiveness(): Promise<{ status: number; data: { status: string } }>; + getReadiness(): Promise<{ status: number; data: { status: string } }>; +} + +export function createOpenApiMockClient( + options: { + readonly baseUrl?: string; + readonly fetcher?: typeof fetch; + readonly state?: OpenApiMockState; + } = {}, +): OpenApiMockClient { + const baseUrl = (options.baseUrl ?? 'http://mock.local').replace(/\/+$/u, ''); + const fetcher = options.fetcher ?? createOpenApiMockFetch(options.state); + + async function request( + path: string, + init?: RequestInit, + ): Promise<{ status: number; data: T }> { + const res = await fetcher(`${baseUrl}${path}`, init); + const data = (await res.json()) as T; + return { status: res.status, data }; + } + + return { + version: OPENAPI_MOCK_SERVER_VERSION, + async createIntent(req: CreateIntentRequest) { + return request('/v1/intents', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(req), + }); + }, + async getIntent(id: string) { + return request(`/v1/intents/${encodeURIComponent(id)}`); + }, + async reconcile(id: string) { + return request(`/v1/intents/${encodeURIComponent(id)}/reconcile`, { + method: 'POST', + }); + }, + async getRecoveryView(id: string) { + return request(`/v1/intents/${encodeURIComponent(id)}/recovery-view`); + }, + async getLiveness() { + return request<{ status: string }>('/health/live'); + }, + async getReadiness() { + return request<{ status: string }>('/health/ready'); + }, + }; +} diff --git a/packages/contracts/test/mock-server.test.ts b/packages/contracts/test/mock-server.test.ts new file mode 100644 index 0000000..d326b05 --- /dev/null +++ b/packages/contracts/test/mock-server.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + OPENAPI_MOCK_SERVER_VERSION, + UI_FIXTURES, + createDefaultMockState, + createOpenApiMockClient, + createOpenApiMockFetch, + handleOpenApiMockRequest, +} from '../src/mock-server.js'; + +const packageRoot = resolve(import.meta.dirname, '..'); + +describe('OpenAPI mock server', () => { + it('exposes frozen mock server version 1.0.0', () => { + expect(OPENAPI_MOCK_SERVER_VERSION).toBe('1.0.0'); + }); + + it('matches all committed UI fixture files exactly', () => { + for (const [name, fixture] of Object.entries(UI_FIXTURES)) { + const filePath = resolve(packageRoot, 'fixtures', 'ui', 'v1', `${name}.json`); + const fileContent = JSON.parse(readFileSync(filePath, 'utf8')); + expect(fixture).toEqual(fileContent); + } + }); + + it('serves liveness and readiness health checks', async () => { + const client = createOpenApiMockClient(); + const live = await client.getLiveness(); + const ready = await client.getReadiness(); + + expect(live).toEqual({ status: 200, data: { status: 'ok' } }); + expect(ready).toEqual({ status: 200, data: { status: 'ok' } }); + }); + + it('serves pre-seeded UI scenarios with version headers', async () => { + const client = createOpenApiMockClient(); + const fetcher = createOpenApiMockFetch(); + + const response = await fetcher('http://mock.local/v1/intents/018f-ui-committed-001'); + expect(response.status).toBe(200); + expect(response.headers.get('x-oneshot-mock-version')).toBe(OPENAPI_MOCK_SERVER_VERSION); + + const intent = await client.getIntent('018f-ui-committed-001'); + expect(intent.status).toBe(200); + expect(intent.data.state).toBe('COMMITTED'); + expect(intent.data.policy?.status).toBe('CONFIGURED'); + expect(intent.data.attempts[0]?.authorization_status).toBe('AUTHORIZED'); + expect(intent.data.settlement?.token_contract).toBe( + '0x3600000000000000000000000000000000000000', + ); + expect(intent.data.settlement?.explorer_url).toContain('arcscan.io'); + }); + + it('serves recovery views', async () => { + const client = createOpenApiMockClient(); + const recovery = await client.getRecoveryView('018f-ui-unknown-007'); + expect(recovery.status).toBe(200); + expect(recovery.data.authoritative_state).toBe('UNKNOWN'); + expect(recovery.data.recommended_action).toBe('RECONCILE'); + expect(recovery.data.evidence[0]?.source).toBe('THE_GRAPH'); + }); + + it('handles create and idempotent replay semantics', async () => { + const state = createDefaultMockState(); + const client = createOpenApiMockClient({ state }); + + const newReq = { + business_intent_id: 'test-new-intent-001', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1000000', + asset: 'USDC' as const, + network: 'eip155:5042002' as const, + purpose: 'Payment test', + }; + + // 1. Create new intent -> 202 + const created = await client.createIntent(newReq); + expect(created.status).toBe(202); + expect(created.data.business_intent_id).toBe('test-new-intent-001'); + expect(created.data.state).toBe('AUTHORIZING'); + + // 2. Replay identical payload -> 200 + const replayed = await client.createIntent(newReq); + expect(replayed.status).toBe(200); + expect(replayed.data.business_intent_id).toBe('test-new-intent-001'); + + // 3. Replay with conflicting payload -> 409 + const conflicting = await client.createIntent({ + ...newReq, + amount_atomic: '2000000', + }); + expect(conflicting.status).toBe(409); + }); + + it('supports read-only reconciliation queueing', async () => { + const client = createOpenApiMockClient(); + + // UNKNOWN intent allows reconciliation + const reconcileUnknown = await client.reconcile('018f-ui-unknown-007'); + expect(reconcileUnknown.status).toBe(202); + expect(reconcileUnknown.data.queued).toBe(true); + expect(reconcileUnknown.data.state).toBe('UNKNOWN'); + + // COMMITTED intent rejects reconciliation with 409 + const reconcileCommitted = await client.reconcile('018f-ui-committed-001'); + expect(reconcileCommitted.status).toBe(409); + + // Non-existent intent returns 404 + const reconcileMissing = await client.reconcile('missing-intent'); + expect(reconcileMissing.status).toBe(404); + }); + + it('strictly fails closed on retry endpoints and unexpected methods', () => { + // Retry endpoint strictly absent + expect( + handleOpenApiMockRequest('http://mock.local/v1/intents/018f-ui-committed-001/retry', 'POST'), + ).toBeNull(); + + // Mutating method on read route fails closed with 405 + const deleteRes = handleOpenApiMockRequest( + 'http://mock.local/v1/intents/018f-ui-committed-001', + 'DELETE', + ); + expect(deleteRes?.status).toBe(405); + }); +}); diff --git a/packages/contracts/test/validate-fixtures.test.mjs b/packages/contracts/test/validate-fixtures.test.mjs index 6feba1a..0a031af 100644 --- a/packages/contracts/test/validate-fixtures.test.mjs +++ b/packages/contracts/test/validate-fixtures.test.mjs @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { validateFixtureDirectory, validateFixtureObject } from '../scripts/validate-fixtures.mjs'; +import { + validateFixtureDirectory, + validateFixtureObject, + validateUiFixtureDirectory, + validateUiFixtureObject, +} from '../scripts/validate-fixtures.mjs'; const validFixture = { version: 'v1', @@ -19,6 +24,7 @@ const validFixture = { describe('fixture validation', () => { it('validates every committed fixture', async () => { await expect(validateFixtureDirectory()).resolves.toHaveLength(9); + await expect(validateUiFixtureDirectory()).resolves.toHaveLength(7); }); it.each([ @@ -30,4 +36,8 @@ describe('fixture validation', () => { ])('rejects %s fixtures', (_name, fixture) => { expect(() => validateFixtureObject(fixture)).toThrow(); }); + + it('rejects invalid UI fixtures', () => { + expect(() => validateUiFixtureObject({ scenario: 'test' })).toThrow(); + }); });