diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md new file mode 100644 index 0000000..eb24dca --- /dev/null +++ b/.agent/AGENTS.md @@ -0,0 +1,120 @@ +# OneShot Agent Policy + +This policy applies to code, documentation, infrastructure, data, and agent +work in this repository. + +## Instruction order + +1. Follow system and user instructions. +2. Follow root `AGENTS.md` and this policy. +3. Follow the task-specific documents and repo skills routed by root + `AGENTS.md`. +4. Follow the narrowest applicable repository configuration. + +Surface conflicts. Never silently weaken an invariant, review gate, or security +boundary. + +## Tool neutrality + +- Codex, Claude, Antigravity, Cursor, or another capable agent may implement + work, but all tools follow the same canonical repository policy. +- Tool adapters remain short pointers to root `AGENTS.md`; personal prompts, + permissions, models, and machine-specific commands stay in ignored files. +- Gate A and Gate B use separate fresh, read-only FreePi processes. Each verdict + records the reviewer tool, platform-reported model (or `not exposed by + platform`), and immutable Git identities required by + `.agent/IMPLEMENTATION_LOOP.md`. + +## Product boundary + +OneShot's core promise is: `One job. Many retries. One settlement.` + +- OneShot owns authoritative Business Intent, Attempt, and Settlement state and + prevents duplicate committed settlements. +- Privy provides corporate wallet access and scoped authorization, policy, and + spending permissions. +- Arc is the USDC settlement rail. +- Direct Privy and Arc evidence resolves known transaction identities. The Graph + is the selected v1 hashless candidate-discovery layer after its C01 evidence + gate; it is never the duplicate-payment lock or settlement authority. + +Read `.agent/PROJECT_CONTEXT.md`, `.agent/SECURITY_INVARIANTS.md`, and +`.agent/SPONSOR_REQUIREMENTS.md` before changing these boundaries. + +## Non-negotiable invariants + +- `1 business intent -> at most 1 committed settlement`. +- Keep one stable `business_intent_id` across retries, restarts, parallel + attempts, workers, and agent instances. +- Treat `UNKNOWN` settlement state as a reconciliation requirement. Never + blindly repay. +- Make state durable and transitions atomic and concurrency-safe. +- Represent money as integer atomic units or `bigint`, never JavaScript + floating point. +- External-index absence or delay is not proof that payment did not happen. +- Normal execution must not bypass Privy policy or OneShot controls. +- Use testnet only unless the user explicitly authorizes another network. +- Never log, expose, persist, commit, or send secrets, private keys, seed + phrases, tokens, wallet credentials, or sensitive runtime configuration. + +## Before changing files + +- Inspect the current branch, status, task acceptance criteria, relevant code, + existing diff, and any merge/rebase state. +- Preserve unrelated user work and keep it out of commits. +- Record material assumptions and active context in `.agent/context/`. +- Use current primary documentation for version-sensitive integrations. +- Do not create or materially revise the product implementation `plan.md` until + required skills and integration research are ready. + +## Implementation quality + +- Keep one branch and PR focused on one milestone or tightly related change. +- Preserve clear ownership among interface, orchestration, domain state, and + external adapters. +- Validate untrusted input at boundaries. +- Do not weaken compiler, lint, type, test, or security settings to get a pass. +- Add tests for behavior changes and regression fixes. Payment-related changes + select applicable cases from `.agent/TEST_MATRIX.md`. +- Do not leave dead code, unexplained suppressions, placeholder credentials, or + hidden follow-up work. +- Update documentation when behavior, contracts, or architecture change. + +## Repository skills + +- `oneshot-idempotency`: mandatory for intent, retry, worker, payment, + reconciliation, or settlement work. +- `oneshot-failure-injection`: mandatory for external-effect failure + boundaries. +- `sponsor-qualification`: mandatory before sponsor, demo, or release claims. + +Personal workflow and review skills may supplement these rules. They never +replace OneShot policy or FreePi Gate A/B. + +## Git and review policy + +- Never implement directly on `main` or `develop`. +- Branch from current `develop`; target `develop` from short-lived + `feature/*`, `fix/*`, or `milestone/*` branches unless the user explicitly + names another short-lived branch. +- Never direct-push or force-push protected branches or rewrite shared history + without explicit user authorization. +- Follow `.agent/IMPLEMENTATION_LOOP.md` for local checks, staged-tree identity, + both independent FreePi reviews, CI, PR readiness, and invalidation rules. +- Only explicit `VERDICT: PASS` passes a gate. Missing, ambiguous, truncated, + stale, unauthenticated, or failed review output fails closed. +- Agents never merge a PR. A human reviews and explicitly authorizes the merge. + +## Context retention + +Follow `.agent/context/README.md`. Update the active record at milestone +boundaries, before handoff/session end, and before deliberate context reset or +compaction when possible. Never store secrets there. + +## Agent-complete + +Handoff only after intended scope is complete, local checks pass, the diff is +cleanly scoped, and current gate state is recorded. A change is ready for human +review only after Gate A, required CI, and Gate B pass for the exact applicable +tree/head. Report branch, commit, PR, checks, gate evidence, and remaining +risks. Never merge. diff --git a/.agent/IMPLEMENTATION_LOOP.md b/.agent/IMPLEMENTATION_LOOP.md new file mode 100644 index 0000000..cdca5e6 --- /dev/null +++ b/.agent/IMPLEMENTATION_LOOP.md @@ -0,0 +1,144 @@ +# OneShot Implementation Loop + +This is the required path from a focused change to human review. `develop` is +the base. Gate A binds to one immutable candidate tree before the first push; +Gate B binds to the exact draft-PR head SHA and the same tree after required CI. + +## 1. Scope and branch + +1. Start from current `develop`. +2. Create one short-lived `feature/*`, `fix/*`, or `milestone/*` branch unless + the user explicitly names another short-lived branch. +3. Record goal, acceptance criteria, assumptions, non-goals, and branch state in + `.agent/context/`. +4. Never implement directly on `develop` or `main`. + +## 2. Implement and validate + +1. Make the smallest coherent change. +2. Use applicable repo skills and `.agent/TEST_MATRIX.md`. +3. Run local format, lint, type, test, build, and focused failure-injection + checks that exist for affected components. +4. Inspect tracked, staged, unstaged, ignored, and intended untracked changes + for scope, generated files, secrets, and unrelated work. Never open or send + ignored secret files to a reviewer. +5. Stage every intended file and only intended files. Gate A reviews a single + candidate tree, not a partially staged workspace. +6. Record concise command/result evidence in the active context. Keep full logs + only for failures that require diagnosis. + +Do not bypass failures with force flags, skipped checks, broad ignores, lower +thresholds, or disabled hooks. + +## 3. Capture immutable Gate A evidence + +Refresh the base and record exact identities: + +```bash +git fetch origin develop +git rev-parse origin/develop +git write-tree +git status --short +git diff --cached --check +git diff --cached +``` + +- `recorded-base-sha` is the printed full SHA, not a moving ref. +- `git write-tree` is the candidate tree SHA. +- For an already-created but unpushed merge commit, use + `git rev-parse "HEAD^{tree}"` and `git diff HEAD`; the + commit must have no additional workspace changes. +- Confirm no intended change is absent from the candidate and no unrelated file + is present. + +## 4. FreePi Gate A: pre-push review + +1. From the repository root, start exactly one fresh process: + + ```bash + npx free-pi-cli + ``` + +2. In one message, tell the new session to read + `.agent/review-prompts/freepi-prepush-review.md`; provide only the task + acceptance criteria, recorded base SHA, candidate tree SHA, and whether the + tree is staged or the exact unpushed `HEAD` tree. +3. Let the reviewer inspect the candidate diff and routed repository documents + with its tools. Do not paste duplicate policy, complete file bodies, repeated + terminal output, or secrets into the prompt. +4. Accept only an explicit `VERDICT: PASS` containing the required reviewer, + model, base, target, and tree identities. + +Each attempt uses a new `npx free-pi-cli` process. Never resume or reuse a +reviewer context. Any candidate-tree change invalidates Gate A and requires +local checks plus a new process. Missing evidence, ambiguity, truncation, +authentication/tool failure, or any verdict other than explicit PASS fails +closed. + +## 5. Commit, push, and draft PR + +Only after Gate A passes for the candidate tree: + +1. If the tree is staged, commit it without changing content. If Gate A reviewed + an existing unpushed commit, do not amend it. +2. Confirm `git rev-parse "HEAD^{tree}"` equals the reviewed candidate tree. +3. Push the short-lived branch without force. +4. Create a draft PR targeting `develop`, never `main` for feature work. +5. Fill `.github/PULL_REQUEST_TEMPLATE.md`, including Gate A evidence. + +## 6. Required CI + +Wait for every required check on the exact draft-PR head SHA. Pending, skipped, +missing, or failing required checks are not green. + +If a fix changes content, rerun local validation and fresh Gate A, commit, push, +and wait for CI again. + +## 7. FreePi Gate B: exact PR review + +1. Capture PR URL/number, base, head branch, exact full head SHA, head tree SHA, + full diff, required check results, and Gate A candidate tree. +2. Start a second fresh process from the repository root: + + ```bash + npx free-pi-cli + ``` + +3. In one message, tell it to read + `.agent/review-prompts/freepi-pr-review.md` and provide only the captured + identities, PR URL, concise CI summary, acceptance criteria, and Gate A + verdict. Let the reviewer obtain the diff and public evidence with tools. +4. Accept only explicit `VERDICT: PASS` bound to the exact current PR head SHA + whose tree equals the Gate A candidate tree. + +Any content change after Gate B invalidates both tree equality and Gate B. +Return to local validation, fresh Gate A, commit/push, green CI, then fresh Gate +B. + +## 8. Human review and merge + +After Gate A, required CI, and Gate B pass for the current tree/head: + +1. Record both verdicts and evidence in the PR and context file. +2. Mark the draft ready for human review. +3. Report branch, SHA, tree SHA, PR URL, checks, gates, and residual risks. +4. Stop. Agents never merge; only a human may authorize and perform the merge. + +## Review token discipline + +- Canonical policy is linked, not copied into tool adapters or review prompts. +- Send each reviewer one compact instruction message. The reviewer reads only + documents routed by root `AGENTS.md` and files relevant to the diff. +- Reference immutable Git identities and concise check results instead of + pasting whole diffs, policy files, or successful logs into chat. +- Reviewers inspect silently and return one structured verdict. They do not + narrate file reads, repeat the task, or restate unchanged policy. +- Never reduce scope, skip evidence, or hide failures to save tokens. Token + discipline removes duplication, not review coverage. + +## Privacy boundary + +FreePi may review the candidate diff, tests, public documentation, and +non-sensitive check evidence only. Never provide `.env*`, ignored files, +private keys, seed phrases, access tokens, API secrets, wallet credentials, or +sensitive runtime configuration. If safe review is impossible, fail the gate. diff --git a/.agent/PROJECT_CONTEXT.md b/.agent/PROJECT_CONTEXT.md new file mode 100644 index 0000000..b6b900c --- /dev/null +++ b/.agent/PROJECT_CONTEXT.md @@ -0,0 +1,100 @@ +# OneShot Project Context + +## Product statement + +`One job. Many retries. One settlement.` + +Product direction: **resumable paid tools for business agents**. User-facing +message: **Resume the job, not the payment.** The revised `plan.md` defines +R0–R5 for the next increment; it does not claim those features are implemented. + +OneShot executes an approved business obligation safely despite retries, +crashes, lost responses, parallel workers, or multiple agent instances. + +The core cardinality is: + +`1 intent / N attempts / <=1 committed settlement` + +This document defines ownership and vocabulary. It is not a product +implementation plan. + +## Primary production vertical + +The first user is a company that lets an autonomous agent purchase a paid API +operation or digital result in USDC. The company approves one business +obligation; retries, restarts, queue redelivery, parallel workers, and multiple +agent instances must all converge on the same Business Intent and at most one +committed settlement. + +The current product exposes an agent API, execution worker, reconciliation +service, operator console, and audit/recovery timeline. The next increment +adds one supplier order/result connector, stable task identity above Business +Intent, and separate delivery state. A replacement agent resumes the same job +and retrieves its existing result without another payment. Delivery guarantees +depend on supplier idempotency and retrieval support, not on payment alone. + +The planned UI separates a public landing page from an authenticated cabinet +for tools, jobs/results, wallet permissions, recovery/activity and developer +access. Raw transaction details remain available as advanced evidence. +Broad treasury, pooled budgets, payroll and arbitrary supplier integrations +are deferred. Privy B2B is the primary product pitch; Arc payments and Graph +evidence-based triage support the same workflow, not separate products. + +## System ownership + +- OneShot is authoritative for business-intent execution state, attempt state, + settlement state, and permission to create another external settlement. +- Privy is the corporate wallet and scoped authorization boundary. OneShot must + use its policies and spending permissions on the normal execution path. +- Arc is the real USDC settlement rail used by the demo. +- Direct Privy lookup and Arc RPC receipt/log evidence resolve known transaction + identities after ambiguous outcomes. +- The Graph is the selected v1 candidate-discovery layer when the transaction + hash is missing. C01 must prove live hashless discovery and AI-track value; + Graph results remain non-authoritative and can never authorize another payment. + +When an external submission may have happened but the result is uncertain, +OneShot records `UNKNOWN` and reconciles. Missing external-index data never converts `UNKNOWN` into permission to submit again. + +## Glossary + +### Business Intent + +The durable identity of one approved business obligation. Its +`business_intent_id` remains stable across retries, process restarts, queue +redelivery, parallel workers, and multiple agents. Payload differences do not +create a second settlement right when the identifier is the same. + +### Attempt + +One execution try for a Business Intent. Attempts are expendable and may fail or +repeat. Any number of Attempts can belong to one Business Intent. + +### Settlement + +The committed external USDC payment for a Business Intent. A Business Intent may +have zero or one committed Settlement, never more than one. + +### Reconciliation + +The process that resolves an ambiguous external effect using durable local +state, provider identifiers and receipts, Graph-discovered candidates, and Arc proof. +Reconciliation precedes any decision to retry payment when settlement state is +`UNKNOWN`. + +### Recovery View + +A derived, non-authoritative view assembled from durable OneShot records, live +Graph candidate discovery, and Arc verification. It helps operators and agents explain +and recover work but does not grant permission to create a Settlement. + +## Decision test + +Any design affecting retries or payments must answer: + +1. What stable Business Intent does this Attempt belong to? +2. Which durable atomic transition grants the right to submit an external + Settlement? +3. How is an ambiguous submission reconciled without a blind retry? +4. How do parallel workers converge on at most one committed Settlement? +5. Which evidence is authoritative, and which evidence is only a Recovery View? diff --git a/.agent/SECURITY_INVARIANTS.md b/.agent/SECURITY_INVARIANTS.md new file mode 100644 index 0000000..fd5c83f --- /dev/null +++ b/.agent/SECURITY_INVARIANTS.md @@ -0,0 +1,55 @@ +# OneShot Security Invariants + +These rules fail closed. A feature, demo, or deadline does not override them. + +## Settlement safety + +- One Business Intent produces at most one committed Settlement. +- Persist a stable `business_intent_id` before any external effect. +- Use durable, atomic, concurrency-safe transitions for settlement ownership. +- A timeout, crash, disconnect, lost response, or provider error after possible + submission creates `UNKNOWN`; reconcile before any payment retry. +- Never infer non-payment from an empty or delayed external-index result. +- Preserve a successful payment result even if a later supplier or API step + fails. +- Do not offer a normal code path that bypasses OneShot state controls or Privy + authorization. + +## Money and authorization + +- Store, compare, calculate, and serialize money as integer atomic units or + `bigint`. Never use JavaScript floating point for monetary values. +- Validate asset, network, recipient, amount, and policy scope before signing or + submitting. +- Privy denial, expired authorization, or an amount above policy produces no + settlement. +- Default to testnet. A non-testnet operation requires explicit user + authorization for that operation. + +## Secrets and privacy + +- Never log, display, persist in context files, commit, or transmit private keys, + seed phrases, access tokens, API secrets, wallet credentials, signing material, + or sensitive runtime configuration. +- Keep secrets in approved runtime secret stores or ignored local environment + files. Commit only safe examples with placeholder values. +- Review staged and untracked files for secret material before every commit. +- FreePi receives only code, intended diffs, tests, public documentation, and + non-sensitive validation evidence. It must not read or receive `.env*`, local + credentials, wallet files, ignored files, or sensitive runtime configuration. +- If a review cannot be completed without sensitive data, the review fails. Do + not send the data. + +## Dependencies and boundaries + +- Prefer official SDKs and primary documentation for Privy, Arc, and The Graph. +- Pin and review dependencies in line with repository conventions. +- Validate all untrusted external data at adapter boundaries. +- Treat provider and indexer output as evidence with explicit freshness and + finality limits, not as implicit authorization. + +## Required response to doubt + +Stop external effects when identity, authorization, amount, network, prior +submission, or settlement state is ambiguous. Persist evidence, enter a safe +state, and reconcile or request human input. diff --git a/.agent/SPONSOR_REQUIREMENTS.md b/.agent/SPONSOR_REQUIREMENTS.md new file mode 100644 index 0000000..a63401e --- /dev/null +++ b/.agent/SPONSOR_REQUIREMENTS.md @@ -0,0 +1,71 @@ +# Sponsor Requirements + +Use this document before sponsor-facing implementation, demo preparation, +release, or submission claims. + +## Primary target: Privy + +- Lead with Best B2B financial product: resumable paid tools for business agents. + Best financial flow is additional fit from the same purchase experience, + not a separate implementation roadmap. The new workflow remains planned. +- Privy must be core corporate wallet authorization, not login-only branding. +- The working path must demonstrate a Privy wallet plus scoped authorization, + policies, signers, quorum, or spending permissions that constrain settlement. +- Policy denial or an amount above policy must produce zero settlement. +- The normal agent path must not bypass Privy authorization. + +## Secondary target: Arc + +- Secondary product positioning is Best DeFi/Onchain Finance Application for + the eligible pool; verify Continuity registration if applicable. The listed + $3,500 award includes $2,500 conditional on mainnet deployment by September + 30, not an additional bonus. Readiness artifacts are not deployment proof. +- The demo must execute a real USDC settlement on Arc Testnet. +- Showing a network label, wallet, explorer page, or mocked payment alone does + not qualify. +- The product must have a working frontend, backend, architecture diagram, + public source, documentation, and short demonstration. +- OneShot must retain settlement identity and result through retries and + downstream failures. +- For the conditional mainnet award, readiness artifacts alone are insufficient; + retain the disabled profile, deployment/rollback artifacts and require actual + authorized deployment proof before claiming that condition. Execution remains + disabled until Circle publishes official production access/identities and a + human explicitly authorizes real-value activation. + +## Selected target: The Graph AI Tooling or AI Use Case + +The Graph is load-bearing for automatic recovery when a successful submission +lost its transaction hash. It discovers candidates; Arc verifies them; OneShot +decides. C01 must prove this with live data before any qualification claim. + +- Target the AI Tooling or AI Use Case track. The recovery agent must use live + Graph data for meaningful candidate selection, explanation, and automation. +- The production/demo path must query the pinned live OneShot/Arc Subgraph + through a Graph provider. For the ETHOnline 2026 AI track, a live API-key + query from Subgraph Studio qualifies; Subgraph MCP is an optional transport + and must not be claimed when the active Arc deployment is Studio-only. +- The LLM Recovery Agent must use the live Graph result to select `WAIT`, + `RECONCILE`, `ESCALATE`, or `RETURN_EXISTING_RESULT`. A sanitized trace must + bind the tool call, deployment/query/result, `_meta` health, referenced + evidence, model recommendation, and deterministic-core disposition. +- Do not target Composable/Standardized with one custom Subgraph; that track + requires two Graph products or meaningful standardized-schema work. +- One live Subgraph is sufficient for the selected AI track; do not add a second + Subgraph merely to satisfy a requirement that belongs to another track. +- Empty, delayed, multiple, or contradictory candidates preserve `UNKNOWN` and + cannot unlock another settlement. +- Malformed/injected Graph content and invalid model output also preserve + `UNKNOWN`. Graph transports and the LLM have no signing, settlement, retry, + Attempt-creation, or submission-ownership capability. +- Include a public repository, clear README, and a two-to-four-minute demo. + +## Claim standard + +Do not state or imply sponsor qualification unless working code and live demo +evidence prove every selected track requirement. Plans, placeholders, mocks, +environment variables, dependencies, and network labels are not evidence. + +Use the `sponsor-qualification` skill to report each selected sponsor as +`QUALIFIED`, `NOT QUALIFIED`, or `NOT VERIFIED`, with code, test, demo, network, +and limitation evidence. diff --git a/.agent/TEST_MATRIX.md b/.agent/TEST_MATRIX.md new file mode 100644 index 0000000..3f47928 --- /dev/null +++ b/.agent/TEST_MATRIX.md @@ -0,0 +1,42 @@ +# OneShot Test Matrix + +Select every applicable case for changes to intents, retries, workers, queues, +payments, settlements, reconciliation, Privy, Arc, The Graph discovery, +Subgraph MCP, or the LLM Recovery Agent. Prefer tests at +the public domain boundary plus focused adapter tests. A test must assert durable +state and external settlement count, not only an HTTP response. + +| Case | Fault or concurrency setup | Required result | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Normal job | One valid intent and one worker | Exactly 1 committed settlement | +| Same request twice | Deliver identical request twice | Exactly 1 committed settlement | +| Conflicting payload, same ID | Different request payloads share one `business_intent_id` | At most 1 committed settlement; conflict is explicit | +| Sequential retry storm | Run 10 sequential attempts for one intent | Exactly 1 committed settlement | +| Parallel worker storm | Run 10 workers concurrently for one intent | Exactly 1 committed settlement | +| Crash before submission | Kill process before any external submission | 0 settlements; retry is allowed from durable state | +| Crash after submission | Kill process after possible submission but before local confirmation | Enter `UNKNOWN`; reconcile; no blind retry | +| Lost payment response | Payment succeeds but HTTP response is lost | Exactly 1 committed settlement after reconciliation | +| Graph delay, absence, or ambiguity | The live Subgraph returns nothing, lags, is unavailable, or returns multiple candidates through Studio GraphQL or Subgraph MCP | Remain `UNKNOWN`; no duplicate settlement; absence is not non-payment proof | +| Graph boundary failure | Studio GraphQL or MCP times out or returns wrong deployment/tool, malformed/oversized data, schema drift, or injected instructions | Fail closed to `WAIT`/hold; 0 new settlements; sanitized diagnostic | +| LLM recovery action matrix | Agent returns `WAIT`, `RECONCILE`, `ESCALATE`, and `RETURN_EXISTING_RESULT` | Core maps only to frozen safe commands; 0 settlement calls | +| Invalid LLM output | Model times out, emits malformed JSON, unsupported action, or fabricated evidence reference | Reject output; remain `UNKNOWN`; 0 new settlements | +| Existing-result challenge | Agent recommends `RETURN_EXISTING_RESULT` with and without independently authoritative Arc/durable proof | Return/commit only independently proven existing result; otherwise hold/escalate; never submit | +| Privy denial | Policy denies or amount exceeds permission | 0 settlements and explicit authorization failure | +| Service restart | Restart after durable intent creation or in-flight work | Intent and settlement state survive; invariant holds | +| Downstream failure after payment | Supplier/API step fails after settlement | Payment result remains durable; no replacement payment | +| Two agent instances | Same business obligation reaches two agents | Exactly 1 committed settlement | + +## Cross-cutting assertions + +- `business_intent_id` is stable across all attempts. +- Monetary values use integer atomic units or `bigint` end to end. +- State transitions are atomic under real concurrency, not only mocked sequence. +- External submission identifiers and reconciliation evidence survive restart. +- Graph evidence records pinned deployment, retrieval path, endpoint/query + identity, `_meta` freshness, and retrieval identity without credentials. +- Agent recommendation, deterministic core disposition, and external-submission + count are asserted separately. +- Logs and test fixtures contain no real secrets or wallet material. +- Tests use testnet or isolated fakes; never create unauthorized mainnet effects. + +Record selected cases and results in the session context and pull request. diff --git a/.agent/context/20260906T115948Z-agents-setup.md b/.agent/context/20260906T115948Z-agents-setup.md new file mode 100644 index 0000000..42511ea --- /dev/null +++ b/.agent/context/20260906T115948Z-agents-setup.md @@ -0,0 +1,107 @@ +# Session Context: Agent Infrastructure Setup + +## Date/time + +- UTC: 2026-09-06T11:59:48Z + +## User goal + +Create the `agents-setup` branch from current `develop` and install durable, +OneShot-specific agent policy, skills, context retention, and two independent +FreePi review gates without merging to `develop` or `main`. + +## Original prompt/request + +High-fidelity restatement: work directly in `SuPuHe/OneShot`; preserve the +OneShot invariant `1 business intent -> at most 1 committed settlement`; encode +Privy, Arc, and The Graph ownership; remove superseded review tooling; use two fresh fail-closed +FreePi reviews through actual `npx free-pi-cli` syntax; add project context, +security, sponsor, test, PR, branch, review-prompt, and retention docs; install +the eight selected `mattpocock/skills` plus token-saving Caveman; create three +repo skills; validate, commit, push, and create a draft PR only after Gate A. +Follow-up clarified that the repository is `/home/supuhe/OneShot` in WSL and +Caveman means the token-saving coding-agent skill. + +## Assumptions + +- The user-authorized branch name `agents-setup` is a naming exception only; + all other branch and review rules remain mandatory. +- This task creates agent infrastructure only and no product `plan.md`. +- User-level skills belong in WSL user scope; repo-specific skills belong in + `.agents/skills` per current OpenAI documentation. + +## Plan + +1. Install and verify selected user-level skills. +2. Create `agents-setup` from updated `origin/develop`. +3. Replace stale agent/review policy and add durable context plus repo skills. +4. Validate content and scripts; inspect complete diff. +5. Run fresh FreePi Gate A, fix and repeat until explicit pass. +6. Commit, push, open draft PR to `develop`, wait for required CI, then run fresh + Gate B if CI and available tooling permit. + +## Key decisions + +- Use `.agents/skills` for repo skills because current official Codex docs scan + that path. +- Use `JuliusBrussee/caveman`, not unrelated projects named Caveman, because it + explicitly provides token-saving Codex communication mode. +- Do not install optional `code-review`; FreePi remains the only mandatory Gate + A/B mechanism and generic review could confuse evidence. +- Start each review with bare `npx free-pi-cli`; its 0.2.19 help exposes no review + flags and states every invocation creates a fresh session. + +## Files/components touched + +- Agent policy, context, security, sponsor requirements, test matrix, review + prompts, branch/PR policy, secret ignores, context helper, and repo skills. + +## Commands/checks + +- `git fetch origin develop` - passed; base `6ea00fd0257fb6531184dab7104bacd7a1100b7a`. +- `npx skills add ... --global --agent codex` - installed eight Matt Pocock + skills and Caveman. +- `npx skills list --global --agent codex --json` - all nine discovered. +- `npx free-pi-cli --help` - confirmed interactive command convention. +- `bash -n .agent/context/new-session.sh` - passed. +- Isolated `new-session.sh smoke-test` - created an exact template copy; passed. +- `quick_validate.py` for all three repo skills - passed. +- `git diff --cached --check` - passed. +- Stale review-tool/name search and `plan.md` search - passed; no matches. +- Repository contains policy/docs only, so no application lint, type, test, or + build command exists yet. + +## External-doc findings + +- Official OpenAI Codex docs: repo skills use `.agents/skills//SKILL.md`; + Codex scans from working directory to repository root. +- `skills` CLI 1.5.23 installed selected skills from `mattpocock/skills` HEAD + `3cca18b368ae95cdbdebbff572ccafa662551015` and + `JuliusBrussee/caveman` HEAD + `5184b3d11ac6a1acb7d44b9bfaa31698157cff97` observed at install time. +- `free-pi-cli` 0.2.19 supports `npx free-pi-cli`, `logout`, `--version`, and + `--help`; its README says each run starts a fresh session. + +## Unresolved questions + +- Whether FreePi authentication is already complete and can return a valid Gate + A verdict. +- GitHub CLI is not installed; use an authenticated GitHub web flow after push. + +## Git and PR state + +- Branch: `agents-setup` +- Base: `develop` at `6ea00fd0257fb6531184dab7104bacd7a1100b7a` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN; requires draft PR and green required CI + +## Handoff/next steps + +1. Apply and validate repository changes. +2. Run Gate A in a fresh FreePi process without exposing sensitive files. diff --git a/.agent/context/20260906T134707Z-product-plan.md b/.agent/context/20260906T134707Z-product-plan.md new file mode 100755 index 0000000..89babea --- /dev/null +++ b/.agent/context/20260906T134707Z-product-plan.md @@ -0,0 +1,88 @@ +# Session Context: Product Implementation Plan + +## Date/time + +- UTC: 2026-09-06T13:47:07Z + +## User goal + +Create a detailed `plan.md` with milestones for exactly three people, minimize cross-person blocking, defer frontend work until the end, run exactly one FreePi review after the plan is complete, and do not create a pull request. + +## Original prompt/request + +The user provided the `SuPuHe/OneShot` `agents-setup` branch URL, noted that the repository may also be found in WSL, requested use of all relevant planning skills, required a single FreePi review after plan creation, prohibited creating a PR, and requested English-only chat responses. + +## Assumptions + +- The application is greenfield because the current repository contains agent/repository policy but no product code. +- The first deliverable is a testnet backend plus minimal late-stage frontend, not a production mainnet system. +- Three implementers work in independent packages/worktrees and converge only after their isolated contract suites pass. +- The current task may add required research and durable context alongside `plan.md`; it does not commit, push, open a PR, or merge. + +## Plan + +1. Load repository policy and applicable repository/user skills. +2. Research current official Privy, Arc, The Graph, PostgreSQL, and worker behavior. +3. Write the cited integration decisions and a detailed three-person milestone plan. +4. Validate scope, links, Markdown, dependencies, invariants, and secrets. +5. Start exactly one fresh `npx free-pi-cli` process for a pre-push-style independent review. +6. Record/report the review result without creating a PR. + +## Key decisions + +- Use three parallel tracks: domain/storage/API; Privy/Arc settlement; reconciliation/The Graph/reliability. +- Freeze port results, state transitions, JSON fixtures, and test seams before implementation so M1–M3 have no cross-person blockers. +- Use PostgreSQL plus Graphile Worker; the queue is at-least-once, while database transitions remain authoritative. +- Use Arc Testnet's six-decimal ERC-20 USDC interface for payments and keep native USDC gas precision separate. +- Treat The Graph as freshness-labeled recovery/history evidence only. +- Begin frontend only after the integrated backend and failure matrix pass. + +## Files/components touched + +- `plan.md` — detailed product architecture, contracts, milestones, ownership, dependencies, tests, risks, and release gates. +- `.agent/research/20260906-integration-decisions.md` — primary-source integration research. +- `.agent/context/20260906T134707Z-product-plan.md` — this durable context record. + +## Commands/checks + +- `git branch --show-current` — `agents-setup`. +- `git status --short` before changes — clean. +- `git rev-parse develop` — `6ea00fd0257fb6531184dab7104bacd7a1100b7a`. +- Repository policy, security, sponsor, test-matrix, implementation-loop, context, and applicable skill documents — read completely. +- Primary-source research — completed; citations saved in the research note. +- Markdown structure, trailing-whitespace, placeholder, obvious-secret-pattern, ownership, dependency, and scope checks — passed. +- One fresh FreePi process/session reviewed `agents-setup` plus the three intended untracked files against `develop` — `VERDICT: PASS`; no blocking findings. +- FreePi non-blocking cautions: keep the cited research note with `plan.md`; external links were not live-revalidated by the reviewer; implementation evidence is intentionally unavailable at planning stage. +- No product lint/type/test/build command exists because the repository still contains no application code. + +## External-doc findings + +- Privy wallet policies constrain authorized wallet actions; request idempotency lasts 24 hours and is only a supplemental duplicate guard. +- Arc Testnet is `eip155:5042002`; ERC-20 USDC is `0x3600000000000000000000000000000000000000` at six decimals, while native USDC gas accounting uses a different precision. +- Arc receipt inclusion is deterministically final, but lost submission responses still require durable UNKNOWN reconciliation. +- The Graph supports `arc-testnet`; `_meta`, indexed block, deployment, health, and lag must accompany recovery evidence. +- PostgreSQL conditional transitions/constraints plus transactional Graphile work delivery fit the at-most-once settlement design. + +## Unresolved questions + +- Exact Node/TypeScript/SDK versions will be pinned after the M1 compatibility tests. +- Privy production webhook availability and Arc-specific rolling-spend policy behavior are optional and must be verified before enablement. + +## Git and PR state + +- Branch: `agents-setup` +- Base: `develop` at `6ea00fd0257fb6531184dab7104bacd7a1100b7a` +- Commit: current HEAD `a304274412f080817e6da2967e444b31ccd2f864`; new planning files uncommitted +- PR: not created by explicit user instruction +- CI: not run; no product code or CI exists yet + +## Review gates + +- Gate A: PASS for reviewed base `develop` (`6ea00fd0257fb6531184dab7104bacd7a1100b7a`) and target `agents-setup` (HEAD `a304274412f080817e6da2967e444b31ccd2f864`) plus `plan.md`, `.agent/research/20260906-integration-decisions.md`, and this context file as it existed before this review-evidence update. Blocking findings: none. This context-only bookkeeping update means the gate must be treated as invalid for any future push; the user limited this task to one review and prohibited a PR, so no rerun is permitted or needed here. +- Gate B: NOT RUN and not applicable because the user prohibited creating a PR. + +## Handoff/next steps + +1. Present `plan.md`, the research note, local validation, and the single FreePi PASS to the user. +2. Do not edit `plan.md`, rerun FreePi, commit, push, or create a PR in this task. +3. Before any future implementation, obtain human plan approval and merge the prerequisite planning/agent-infrastructure work to `develop` under normal repository policy. diff --git a/.agent/context/20260906T164444Z-merge-agent-workflow.md b/.agent/context/20260906T164444Z-merge-agent-workflow.md new file mode 100644 index 0000000..c9bc137 --- /dev/null +++ b/.agent/context/20260906T164444Z-merge-agent-workflow.md @@ -0,0 +1,77 @@ +# Session Context: Merge Unified Agent Workflow + +## Date/time + +- UTC: 2026-09-06T16:44:44Z + +## User goal + +Merge current `develop` into `agents-setup`, resolve the `.agent` conflicts, retain the agreed `.antigravity` and `.claude` integrations, remove redundant policy, reduce excessive review/check token usage where practical, finish the merge on `agents-setup`, and run a fresh independent FreePi review at the end. + +## Original prompt/request + +The user reported a teammate conflict in `.agent` that prevents a clean PR to `develop`. They requested comparison of `develop` and their setup branch, a merge into the setup branch with only necessary content retained, preservation of `.antigravity` and `.claude`, review-token optimization, and a final `npx free-pi-cli` review. + +## Assumptions + +- The actual branch name is `agents-setup` (plural), as shown by Git. +- `origin/develop` at `982b705` is the current integration source; local `develop` at `6ea00fd` is stale. +- OneShot-specific product, settlement, security, sponsor, test, context, and FreePi policies remain canonical because they encode stricter requirements absent from the generic workflow. +- `.antigravity` and `.claude` remain as thin adapters, not duplicate policy sources. +- The Agent policy workflow remains deterministic and does not invoke an AI model. + +## Key decisions + +- Merged `origin/develop` into `agents-setup` without rebasing or rewriting branch history. +- Kept root `AGENTS.md` and `.agent/AGENTS.md` as the canonical shared policy entry points. +- Preserved `.antigravity/README.md`, `.agents/rules/repository-policy.md`, and `.claude/CLAUDE.md` as short adapters that route to root policy. +- Kept the stricter OneShot-specific implementation loop and mandatory fresh FreePi Gate A/Gate B reviews. +- Removed the generic milestone loop, generic review prompts, and historical M0 workflow document because they duplicated or contradicted the canonical FreePi path. +- Kept a small GitHub Actions policy check that validates required files, adapters, obsolete-file removal, conflict markers, and whitespace without consuming model tokens. +- Reduced reviewer token waste through selective document routing, one compact request per fresh review, immutable Git identities instead of pasted diffs/logs, silent inspection, and one structured verdict. Coverage and fail-closed behavior remain unchanged. + +## Files/components touched + +- Canonical policy: `AGENTS.md`, `.agent/AGENTS.md`, `.agent/IMPLEMENTATION_LOOP.md`. +- Independent review: `.agent/review-prompts/freepi-prepush-review.md`, `.agent/review-prompts/freepi-pr-review.md`. +- Tool adapters: `.agents/rules/repository-policy.md`, `.antigravity/README.md`, `.claude/CLAUDE.md`. +- Repository controls: `.github/BRANCH_POLICY.md`, `.github/PULL_REQUEST_TEMPLATE.md`, `.github/workflows/agent-policy.yml`, `.gitignore`. +- Removed obsolete duplicates: `.agent/MILESTONE_IMPLEMENTATION_LOOP.md`, `.agent/milestones/M0_UNIFIED_AGENT_WORKFLOW.md`, `.agent/review-prompts/draft-pr-review.md`, `.agent/review-prompts/implementation-review.md`. + +## Commands/checks + +- `git fetch origin develop agents-setup` — PASS; `origin/develop` advanced to `982b705`. +- Branch/history/tree comparison — PASS; the working tree was clean before the merge. +- Applicable repository policy and merge-conflict skill — read completely. +- Merge conflict scan (`git diff --name-only --diff-filter=U`) — PASS; no unresolved paths. +- Staged whitespace/error scan (`git diff --cached --check`) — PASS. +- Agent policy validation (required files, adapters, obsolete paths, conflict markers, adapter size, shell syntax) — PASS after correcting repo-skill paths to `.agents/skills/*/SKILL.md`. +- Workflow YAML parse (`python3` with installed PyYAML) — PASS. + +## External-doc findings + +- None required; this is repository workflow reconciliation. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `agents-setup` +- Base/source merged: `origin/develop` at `982b705` +- Pre-merge HEAD: `b643206` +- Merge state: conflicts resolved and staged; merge commit pending final validation. +- PR: not created or modified. +- Push: not performed. + +## Review gates + +- Requested final independent review: pending until the merge commit exists, so it can bind to the exact unpushed commit tree. +- Gate B: NOT RUN; no PR was created or requested. + +## Handoff/next steps + +1. Revalidate the final staged tree and create the merge commit. +2. Run one fresh `npx free-pi-cli` review against the exact unpushed merge commit. +3. Report the verdict and immutable Git identities without modifying the reviewed tree or creating a PR. diff --git a/.agent/context/20260906T180746Z-expanded-plan.md b/.agent/context/20260906T180746Z-expanded-plan.md new file mode 100644 index 0000000..ddab41b --- /dev/null +++ b/.agent/context/20260906T180746Z-expanded-plan.md @@ -0,0 +1,88 @@ +# Session Context: Expanded Independent Milestone Plan + +## Date/time + +- UTC: 2026-09-06T18:07:46Z + +## User goal + +Create a new branch, expand `plan.md`, and split the delivery plan into many smaller milestones for three coders so each coder can work asynchronously and close their work without waiting for another coder. Keep frontend work at the end. + +## Original prompt/request + +The user asked in Russian for a new branch and a substantially more detailed `plan.md`, followed by a `milestones` folder containing many small tasks assigned across three coders. The essential scheduling constraint is asynchronous progress with no cross-coder waiting to close a milestone; frontend remains last. + +## Assumptions + +- The requested branch should start from the current reviewed `agents-setup` merge commit because it contains the existing plan and reconciled agent policy. +- The correctly spelled directory is `milestones/`. +- Absolute independence is impossible for the final integrated product, so coder work-packet closure is independent while project Gates P4–P6 perform convergence without reopening completed packets. +- Existing same-day primary-source integration research remains the technical basis; this task adds no new version-sensitive integration claim. + +## Plan + +1. Load canonical policy, project/security/sponsor/test documents, repository skills, existing research, context, and the complete current plan. +2. Create `milestone/expand-product-plan` from the clean reviewed setup commit. +3. Replace the phase-oriented plan with a detailed three-lane operating plan and frozen contract pack. +4. Add 18 independently closable milestone packets, six per coder, containing small commit-sized tasks, acceptance evidence, tests, contract handoffs, and no-wait continuation. +5. Validate links, ownership, dependency rules, frontend ordering, Markdown, secrets, and Git scope. +6. Run required independent review against the exact candidate tree before any commit. + +## Key decisions + +- Replaced shared phase milestones with 18 independently closable work packets: A01–A06, B01–B06, and C01–C06. +- Each packet has at least five commit-sized tasks, explicit local acceptance evidence, a contract-pack handoff, non-goals, and a no-wait continuation instruction. +- A packet may depend only on the frozen v1 contract pack, the same coder's previous packet, and—only for frontend—the project Gate P4. +- Cross-coder artifacts are consumed through immutable contracts, schemas, fixtures, simulators, and public package entry points, never active branches or private implementation imports. +- A04/B04/C04 close against simulators. Project Gate P4 later composes exact reviewed packages and live testnet evidence without reopening the completed packets. +- Production frontend remains last: A05/B05/C05 cannot start until P4 freezes OpenAPI and recovery semantics. +- Added explicit path ownership, compatibility protocol, execution environments, project gates, integration procedure, escalation policy, scope-cut order, evidence manifest, and final readiness audit. + +## Files/components touched + +- `plan.md` — expanded 579-line master delivery plan. +- `milestones/README.md` — operating instructions for asynchronous packet execution. +- `milestones/CONTRACTS.md` — frozen v1 product/port/state/fixture contract pack. +- `milestones/coder-a/` — lane README and six domain/orchestration packets. +- `milestones/coder-b/` — lane README and six Privy/Arc packets. +- `milestones/coder-c/` — lane README and six reconciliation/Graph packets. +- `.agent/context/20260906T180746Z-expanded-plan.md` — this durable context record. + +## Commands/checks + +- Initial `git status --short --branch` — clean on `agents-setup`. +- Canonical policy, planning references, all three required repository skills, research, context policy, and existing `plan.md` — read completely. +- `git switch -c milestone/expand-product-plan` — branch created from reviewed merge commit `fcc48d37bd46f9ed727b33e1cc62fcc6a75f9a07`. +- Structural plan validator — PASS: 18 uniquely indexed packets, exactly six per coder, required sections present, at least five small tasks per packet, no cross-coder metadata dependency, all local links resolve, frontend-last guard present, and no placeholder/secret-shaped content. +- Planning-set measurement — 2,004 lines and 13,285 words; master `plan.md` is 579 lines and 4,870 words. +- Markdown/Git whitespace validation — PASS after formatting-only removal of Markdown hard-break spaces. +- Repository agent-policy validation — PASS. +- Conflict-marker and credential-shaped secret scans — PASS. +- Staged scope — 25 intended files: expanded `plan.md`, 23 files under `milestones/`, and this context record; no product code or unrelated file is included. + +## External-doc findings + +- No new browsing required. The expanded plan relies on `.agent/research/20260906-integration-decisions.md`, produced from primary sources earlier the same day. + +## Unresolved questions + +- None. The plan explicitly separates independently closable coder packets from unavoidable final product-integration gates. + +## Git and PR state + +- Branch: `milestone/expand-product-plan` +- Starting commit: `fcc48d37bd46f9ed727b33e1cc62fcc6a75f9a07` +- Candidate state: all 25 intended files staged; exact tree is captured again after this final context update. +- Commit: not created; Gate A must pass first. +- Push/PR: not performed. + +## Review gates + +- Gate A: local validation passed; fresh independent review pending the final staged tree identity. +- Gate B: not applicable; no PR requested. + +## Handoff/next steps + +1. Stage this final context update and capture the exact candidate tree. +2. Run one fresh Gate A review against that tree. +3. If PASS, commit without changing the reviewed tree; do not push or create a PR unless requested. diff --git a/.agent/context/20260906T201351Z-product-roadmap.md b/.agent/context/20260906T201351Z-product-roadmap.md new file mode 100644 index 0000000..1c3f3c0 --- /dev/null +++ b/.agent/context/20260906T201351Z-product-roadmap.md @@ -0,0 +1,90 @@ +# Session Context: Product-First Production Roadmap + +## Date/time + +- UTC: 20260906T201351Z + +## User goal + +Rewrite OneShot as a product-first roadmap based on the final B2B paid-API-job +concept. Name the technology stack, organize delivery only by dependencies and +evidence gates, and explain how every domain component and A/B/C lane +contributes to the complete product. + +## Key decisions + +- The primary vertical is an autonomous B2B agent purchasing a paid API job or + digital result in USDC. +- The root plan starts with product vision, product flow, user surfaces, system + context, and the dependency-gated roadmap. +- The stack is explicit: Node.js, TypeScript, pnpm, Fastify, PostgreSQL, `pg`, + Graphile Worker, `viem`, Privy, Arc, The Graph, React/Vite, Vitest, + Testcontainers, Playwright, Matchstick, Docker Compose, and GitHub Actions. +- A/B/C lane READMEs state their technology focus. Packet metadata contains + ownership and dependencies only. +- `docs/DOMAIN_ARCHITECTURE.md` explains system context, domain records, state + ownership, component responsibilities, success and recovery sequences, port + boundaries, and A/B/C convergence. +- Post-MVP pilot stages remain separate from the P0-P6 implementation contract. + +## Files/components touched + +- `plan.md` +- `.agent/PROJECT_CONTEXT.md` +- `milestones/README.md` +- `milestones/coder-a/README.md`, `coder-b/README.md`, `coder-c/README.md` +- All 18 A/B/C packet files +- `docs/DOMAIN_ARCHITECTURE.md` +- This context record + +## Validation + +- All local Markdown links in `plan.md`, `milestones/`, and `docs/` resolve. +- Exactly 18 packet files remain. +- Packet headers contain no planning-size metadata. +- Mermaid CLI 11.17.0 rendered all 12 diagrams successfully. +- `git diff --check` passes. +- Gate A and Gate B were intentionally not run because the user explicitly + requested skipping the two-review procedure for the planning phase. + +## References + +- `C:\dev\thoughts\hackathon_eth_online_2026\brainstorming\11_OneShot — Privy Arc Graph Direction.md` +- `C:\dev\deeptrace\PLAN.md` + +## Git state + +- Branch: `milestone/product-roadmap` +- Original roadmap base: `develop` at `5ef6a66313614e67b476f56c98f47c65344fb6ec`. +- Follow-up base: `develop` at `4336e04d9fd42b419a9cfa961f4f8d25b15cd3cb`. +- Original roadmap pull request: `https://github.com/SWOFART/OneShot/pull/7` + (merged into `develop` before this architecture correction). +- Follow-up pull request: `https://github.com/SWOFART/OneShot/pull/8`. + +## 2026-09-06 architecture correction + +- The working Arc Testnet product remains the first live proof. +- Mainnet readiness is now part of P0-P6 through a disabled typed profile, + deployment/preflight evidence, safe disable, rollback, and a human activation + gate. No unavailable Arc Mainnet values are guessed. +- PostgreSQL remains authoritative. Direct Privy lookup and Arc receipt/log + evidence form the required recovery path. +- The Graph is the selected v1 hashless candidate-discovery layer. C01 must + prove live value, freshness, multiple-candidate handling, and AI-track fit; + Arc remains authoritative and a failed gate removes the Graph claim. +- Gate A and Gate B are skipped for this follow-up planning change by explicit + user instruction. Required repository CI and human review still apply. + +## 2026-09-06 Graph and Arc Memo decision + +- Primary submission direction: Privy authorizes, The Graph discovers, Arc + proves, and OneShot/PostgreSQL decides. +- Target The Graph AI Tooling or AI Use Case, not Composable/Standardized. +- Demonstrate a real Arc payment whose successful response/hash is discarded at + the adapter fault boundary, followed by live Graph discovery and direct Arc + verification with no second payment. +- Prefer Arc Memo `memoId = hash(business_intent_id)` for unique correlation only + if B01 proves Privy can constrain the forwarded USDC call. Otherwise preserve + stricter authorization and use tuple/window search or a narrow typed contract. +- Privy + Arc + The Graph is the final selected stack; alternative settlement + rails are outside this roadmap. diff --git a/.agent/context/20260906T230709Z-plan-clarification.md b/.agent/context/20260906T230709Z-plan-clarification.md new file mode 100644 index 0000000..60a07b9 --- /dev/null +++ b/.agent/context/20260906T230709Z-plan-clarification.md @@ -0,0 +1,100 @@ +# Session Context: Subgraph MCP plan clarification + +## Date/time + +- UTC: 2026-09-06T23:07:09Z + +## User goal + +Clarify the product plan and independently closable C-lane milestones so The +Graph qualification path explicitly uses a live OneShot/Arc Subgraph through +Subgraph MCP, with meaningful LLM recovery reasoning behind a deterministic +OneShot safety boundary. + +## Original prompt/request + +Create `plan-clarification` from the new repository's `develop`; update the plan, +The Graph milestones, and sponsor-qualification skill around the flow Subgraph +-> Subgraph MCP -> LLM Recovery Agent -> four allowed recommendations -> +deterministic safety core. Repository: . + +## Assumptions + +- This work targets The Graph's AI application eligibility path, not the separate + composable/standardized-products path; one live Subgraph is therefore planned. +- The LLM is advisory. Existing authoritative state and Arc-evidence semantics + remain unchanged. +- No push or PR is included because the user did not request either in this turn. +- `develop` advanced during review preparation; the branch was fast-forwarded + and the clarification was rebuilt on the newer hashless-recovery plan instead + of restoring its deleted C01 milestone. + +## Plan + +1. Inspect current `develop` planning contracts and official The Graph sources. +2. Update the plan, C-lane packets, architecture, and sponsor qualification rules. +3. Run focused consistency and repository validation without external credentials. + +## Key decisions + +- Preserve develop's provider-neutral hashless-recovery design while selecting + a deployment-pinned Subgraph MCP adapter to feed the LLM Recovery Agent. +- Freeze four advisory actions: `WAIT`, `RECONCILE`, `ESCALATE`, and + `RETURN_EXISTING_RESULT`; reject any submit/retry capability. +- Keep OneShot durable state and verified Arc evidence authoritative. Direct LLM + state mutation and Graph-based retry permission were rejected as unsafe. + +## Files/components touched + +- `plan.md`, `docs/DOMAIN_ARCHITECTURE.md`: explicit MCP/LLM/core architecture. +- `milestones/CONTRACTS.md`, `milestones/README.md`, `milestones/coder-c/*`: frozen ports, fixtures, packet tasks, tests, demo evidence. +- `.agent/TEST_MATRIX.md`: MCP failure, four-action, invalid-output, and existing-result cases. +- `.agent/SPONSOR_REQUIREMENTS.md`, `.agents/skills/sponsor-qualification/SKILL.md`: qualification standard. +- `.agent/research/20260907-subgraph-mcp-clarification.md`: primary-source decision record. + +## Commands/checks + +- `git fetch origin develop` - develop advanced from `4336e04` to `d256e5360247ba5c0dfd1901470a0ad8c7a46068` during review preparation. +- Fast-forward plus deliberate conflict resolution - preserved the new + `C01-recovery-evidence-strategy.md`; did not resurrect deleted + `C01-subgraph-index-health.md`. +- Final `git diff --check`, branch-base equality, task sizing, stale terminology, + context, permissions, and secret-scope checks - pending after reconstruction. + +## External-doc findings + +- The Graph Subgraph MCP introduction - MCP exposes schema/query tools and returns structured Subgraph results to a client LLM. +- The Graph AI overview - models can retrieve live blockchain data through Subgraph MCP. +- The Graph hackathon resources, accessed 2026-09-07 - AI apps may use live Subgraph data through MCP; multiple products are a separate track. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `plan-clarification` +- Base: `origin/develop` at `d256e5360247ba5c0dfd1901470a0ad8c7a46068` +- Commit: `722c2f0` pushed; sponsor-claim/Arc-qualification plan edits committed this session +- PR: opened against `develop` +- CI: `Agent policy / repository-policy` runs on the pushed head + +## Review gates + +- Gate A: NOT RUN. `npx free-pi-cli` cannot start in this environment: the + registry resolves `free-pi-cli` to a `0.0.1` placeholder release that ships no + executable, and a pinned `free-pi-cli@0.2.19` install was denied by the local + sandbox. The user was informed of the fail-closed rule in + `.agent/IMPLEMENTATION_LOOP.md` and explicitly waived both gates for this + documentation-only change. +- Gate B: NOT RUN. Waived by the same explicit user decision. + +Equivalent local evidence was captured instead: the full `agent-policy` workflow +was reproduced locally against the candidate tree and passed, and +`git diff --cached --check` reported no whitespace errors. + +## Handoff/next steps + +1. Human owner reviews the PR directly; no FreePi verdict backs this tree. +2. Restore the normal Gate A/Gate B loop for the next change once a working + `free-pi-cli` distribution is available. diff --git a/.agent/context/20260907T130109Z-b01-sdk-network-compatibility.md b/.agent/context/20260907T130109Z-b01-sdk-network-compatibility.md new file mode 100644 index 0000000..26a137f --- /dev/null +++ b/.agent/context/20260907T130109Z-b01-sdk-network-compatibility.md @@ -0,0 +1,98 @@ +# Session Context: B01 SDK and Arc network compatibility + +## Date/time + +- UTC: 2026-09-07T13:01:09Z + +## User goal + +Begin Coder B implementation. Deliver B01 from `milestones/coder-b`: pin a +compatible Privy/Ethereum/TypeScript toolchain, encode Arc deployment profiles, +probe Memo/policy constraint feasibility, define the configuration schema, build +a fail-closed readiness probe, and establish the sanitized fixture boundary. + +## Original prompt/request + +Start coding as Coder B, following the repository instructions, `plan.md`, and +the task order in `milestones/coder-b/`. CI/CD instructions may be ignored for +now because no site or deployment target exists yet; the user will say when that +changes. + +## Assumptions + +- CI/CD is out of scope this session by explicit user instruction. Local + package-scoped checks still run, and no CI configuration is added or changed. +- Package manager is npm with package-local installs. Coder A owns root + workspace composition after scaffold freeze, so B01 adds no root manifest, + lockfile, or workspace configuration. +- `settlement-config-v1` is a published contract artifact, not a new package. + It is documented under `docs/settlement/` and implemented inside B-owned + packages. +- Arc Mainnet parameters are unpublished. The Mainnet profile therefore carries + no chain ID, RPC, explorer, or token value at all, per `plan.md` section 5b. + +## Plan + +1. Record this context and branch from current `develop`. +2. Build `packages/arc-adapter`: deployment profiles, money, configuration + schema, readiness probe, redaction. +3. Build `packages/privy-adapter`: wallet/policy identity validation and the + Memo/policy compatibility spike result. +4. Build `packages/testkit-settlement`: fixtures, readiness simulator, and + redaction tests. +5. Run package-local install, type, lint, unit, and build checks. +6. Publish the `settlement-config-v1` handoff artifact and dependency rationale. + +## Key decisions + +- Branch from `develop` at `9dc541d08daf4e9a9c338c562fb1fbe6ac6be04a`, which + already contains the merged plan clarification, so B01 encodes the corrected + Arc constants rather than the superseded ones. +- Arc Testnet is the only enabled profile: chain ID `5042002`, CAIP-2 + `eip155:5042002`, USDC interface `0x3600000000000000000000000000000000000000`, + six-decimal atomic units. +- The Arc Mainnet profile is structurally present but holds no guessed values. + Commit `d6758dd` on `develop` deliberately removed the previously asserted + mainnet chain `5042` and its launch date as unverified guesses. + +## Files/components touched + +- `packages/arc-adapter`, `packages/privy-adapter`, + `packages/testkit-settlement`: new B-owned packages. +- `docs/settlement/`: `settlement-config-v1` handoff and provider setup notes. + +## Commands/checks + +- `npm view` for candidate dependency versions - viem `2.56.3`, + `@privy-io/node` `0.34.0`, `@privy-io/server-auth` `1.32.5`, TypeScript + `7.0.2`, Vitest `5.0.0`. +- Local Node is `v22.16.0` and npm is `10.9.2`; Vitest 5 declares + `node ^22.12.0 || ^24.0.0 || >=26.0.0`, which the local runtime satisfies. + +## External-doc findings + +- Pending. B01.2 and B01.3 must verify Arc chain, RPC, explorer, USDC, and Memo + identities against official Arc documentation before any value is pinned. + +## Unresolved questions + +- Whether Privy policy decoding can constrain a nested Arc Memo call. B01.3 + decides this; direct transfer remains the fallback. + +## Git and PR state + +- Branch: `milestone/b01-sdk-network-compatibility` +- Base: `develop` at `9dc541d08daf4e9a9c338c562fb1fbe6ac6be04a` +- Commit: uncommitted +- PR: not created +- CI: out of scope this session by user instruction + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Scaffold and implement the three B-owned packages. +2. Run package-local checks and record results here. diff --git a/.agent/context/20260907T130620Z-c01-recovery-evidence-strategy.md b/.agent/context/20260907T130620Z-c01-recovery-evidence-strategy.md new file mode 100644 index 0000000..4f612c6 --- /dev/null +++ b/.agent/context/20260907T130620Z-c01-recovery-evidence-strategy.md @@ -0,0 +1,77 @@ +# Session Context: C01 recovery evidence strategy + +## Date/time + +- UTC: 2026-09-07T13:06:20Z + +## User goal + +Implement the Coder C milestone sequence, starting with C01, so OneShot has a provider-neutral, fail-closed recovery evidence contract before any live Subgraph MCP adapter is admitted. + +## Original prompt/request + +Reply in English and keep responses concise. Work as Coder C on the milestones under `milestones/coder-c`, create a new branch from `develop`, begin coding, use existing `.agent/research` on Subgraph MCP, and perform additional Graph research when needed. Stop and ask if a material requirement is unclear. + +## Assumptions + +- C01 is the first implementation scope; later C milestones remain out of this branch. +- Offline contract mode is sufficient for local implementation. Missing live deployment/model credentials must remain an explicit evidence gap and cannot be represented as sponsor qualification. +- The Graph, Subgraph MCP, and any future model output remain non-authoritative and cannot grant settlement or retry permission. +- C01 is integrated into Coder A's pnpm workspace scaffold now present on `develop`. + +## Plan + +1. Freeze `index-view-v1` provider-neutral evidence and MCP boundary schemas. +2. Add strict runtime validation, deterministic fixtures, and simulator coverage for healthy and degraded observations. +3. Publish the removal/value matrix, live-spike protocol, safe fallback decision, and package-local verification commands. +4. Run format, lint, type, test, and build checks; inspect scope and record results. + +## Key decisions + +- Branch from the verified current `develop` SHA using the milestone-prescribed branch name. +- Keep C01 in `packages/reconciliation`; do not create `packages/subgraph-mcp-adapter` or `subgraph/` until a live deployment passes the value gate. +- Pin immutable deployment/query identity in the contract and reject unknown fields or missing `_meta` freshness instead of trusting generic MCP output. +- Follow B01/B02's direct USDC transfer decision: production correlation is sender/token/recipient/amount plus a bounded block window; `memo_id` is unused. + +## Files/components touched + +- `.agent/context/20260907T130620Z-c01-recovery-evidence-strategy.md` - active C01 decisions and evidence. +- `packages/reconciliation` - implemented standalone `index-view-v1`, known-identity evidence schema, strict MCP boundary, deterministic simulator, 24 tests, and live-value decision docs. + +## Commands/checks + +- `git fetch origin develop` - passed; branch rebased to `c3ab0ca5faba435f4ca8275f6e60c08e877b97b0`. +- Required repository policy, implementation loop, security/sponsor rules, test matrix, Coder C milestones, and repo skills read before editing. +- Official The Graph documentation checked on 2026-09-07 for immutable deployment query tools and `_meta` fields. +- Package-local Prettier check - passed. +- Package-local ESLint 10.0.1 strict typed lint - passed. +- TypeScript 6.0.3 strict typecheck and build - passed on Node 24.19.0. +- Vitest 5.0.0 - 24 tests passed; fresh/empty/lagging/unhealthy/unavailable/malformed/injected/duplicate/out-of-order/contradictory and identity-drift cases covered with zero settlement permission. + +## External-doc findings + +- The Graph Subgraph MCP introduction and `graphops/subgraph-mcp` README, checked 2026-09-07: use a deployment-pinned execute-query tool; the MCP server returns structured Subgraph results and is not an LLM. +- The Graph GraphQL API docs, checked 2026-09-07: `_meta` exposes deployment, indexed block number/hash/timestamp, and `hasIndexingErrors`; C01 validates these as non-authoritative health/freshness evidence. +- The Graph supported-network registry, checked 2026-09-07: Arc Testnet is supported under `arc-testnet` and chain ID `eip155:5042002`, so a live OneShot/Arc Subgraph is feasible. + +## Unresolved questions + +- The repository contains no immutable OneShot/Arc deployment ID, Subgraph MCP connection, model access, or deployment credentials. The user authorized a new deployment if needed, but credential entry remains a human setup dependency. Until those inputs exist, the production adapter remains unadmitted and the safe mode is `FALLBACK_DIRECT_RECOVERY`. + +## Git and PR state + +- Branch: `milestone/c01-recovery-evidence-strategy` +- Base: `develop` at `c3ab0ca5faba435f4ca8275f6e60c08e877b97b0` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Resolve the live deployment/configuration choice, then run the C01 live MCP/agent value gate or retain the explicit fallback. +2. Stage the exact candidate, run Gate A, and continue the implementation loop only after the decision is reflected in code and evidence. diff --git a/.agent/context/20260907T134104Z-a01-foundation-contracts.md b/.agent/context/20260907T134104Z-a01-foundation-contracts.md new file mode 100644 index 0000000..5598214 --- /dev/null +++ b/.agent/context/20260907T134104Z-a01-foundation-contracts.md @@ -0,0 +1,79 @@ +# Session Context: A01 foundation contracts + +## Date/time + +- UTC: 2026-09-07T13:41:04Z + +## User goal + +Implement Coder A's first milestone so B and C can consume stable contracts, +fixtures, and a deterministic simulator from `develop`. + +## Original prompt/request + +Start implementing the plan as Coder A. + +## Assumptions + +- A01 is the first dependency-free Coder A milestone. +- Arc Testnet is the enabled launch profile; mainnet support is future work. +- Provider integrations remain outside A01. + +## Plan + +1. Complete local validation and review of the A01 candidate tree. +2. Publish the reviewed contract pack through a pull request to `develop`. +3. Continue with A02 after A01 closes. + +## Key decisions + +- Money crosses JSON boundaries as canonical unsigned integer strings. +- Parsers reject unknown enum members and unexpected request fields. +- Generated OpenAPI, JSON Schema, and TypeScript artifacts share one source. +- The simulator injects time and IDs and counts external submissions explicitly. + +## Files/components touched + +- Root workspace/toolchain configuration and locked dependencies. +- `packages/contracts`: runtime parsers, OpenAPI, schemas, fixtures, validators. +- `packages/testkit-domain`: deterministic in-memory domain simulator. +- Stack CI: contract drift, fixture validation, compilation, and tests. + +## Commands/checks + +- `pnpm install --frozen-lockfile` - pass. +- `pnpm format:check` - pass. +- `pnpm lint` - pass. +- `pnpm typecheck` - pass. +- `pnpm check:generated` - pass after deterministic regeneration. +- `pnpm validate:fixtures` - pass, 9 fixtures. +- `pnpm test` - pass, 4 files and 30 tests. +- `contracts.schema.json` SHA-256 - + `4436ED9992662D749A9E41779453513E2A3DAFCF3B4306146D87593132BB6297`. + +## External-doc findings + +- None; implementation follows the frozen repository contract pack. + +## Unresolved questions + +- None for A01. + +## Git and PR state + +- Branch: `milestone/a01-foundation-contracts` +- Base: `develop` at `8be8d09b8ab5da19031af28fcee9da128a16f82b` +- Commit: uncommitted candidate +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage and inspect the complete candidate tree. +2. Run the configured pre-push reviewer against the immutable tree. +3. Commit, push, and open the draft pull request after Gate A passes. diff --git a/.agent/context/20260907T145000Z-a02-durable-intents.md b/.agent/context/20260907T145000Z-a02-durable-intents.md new file mode 100644 index 0000000..29b4acd --- /dev/null +++ b/.agent/context/20260907T145000Z-a02-durable-intents.md @@ -0,0 +1,64 @@ +# Session Context: A02 durable intents and API + +## Date/time + +- UTC: 2026-09-07T14:50:00Z + +## User goal + +Implement Coder A's milestone A02: durable PostgreSQL intent ledger, transactional migrations, +canonical domain fingerprint, and Fastify HTTP boundary controls. + +## Original prompt/request + +Continue plan as Coder A and create PR without review gates. + +## Assumptions + +- A02 builds upon A01 contracts and foundation on `develop`. +- PostgreSQL is authoritative for Business Intents, Attempts, Settlement identity, evidence observations, and outbox jobs. +- Real provider wallets, settlement rails, and external indexes remain excluded (deferred to later milestones). +- Review gates A and B are waived per explicit user instruction ("PR сделай без гейтов"). + +## Plan + +1. Verify and complete domain fingerprint normalization and validation. +2. Verify PostgreSQL transactional migrations, rollback handling, and constraint enforcement. +3. Verify atomic insert-or-replay, deduplication, conflict rejection, and query projections. +4. Implement full API boundary controls, service bearer authentication, and sanitized error mapping. +5. Provide synthetic database fixtures and document storage-v1 schema digest. +6. Run all local quality checks (lint, format, typecheck, contract checks, unit tests). +7. Commit, push branch to fork, and open PR targeting `develop` without gates. + +## Key decisions + +- Intent insertion is atomic using transactional insert with `ON CONFLICT DO NOTHING`. +- First attempt and authorize outbox job are enqueued only for new intents. +- Conflicting payload returns 409 `INTENT_PAYLOAD_CONFLICT` without enqueuing jobs. +- API boundary sanitizes all internal details; correlation IDs are preserved across calls. +- Storage V1 schema digest is published and tested against migrations. + +## Files/components touched + +- Workspace configs: `package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `tsconfig.json`, `vitest.config.ts`, `.markdownlint-cli2.jsonc`. +- Workflows: `.github/workflows/stack-lint.yml` (added integration test step). +- `packages/domain`: canonical fingerprinting and normalization. +- `packages/storage-postgres`: transactional migrations, intent ledger, schema digest, synthetic fixtures. +- `apps/api`: Fastify boundary, authentication, rate limiting, and OpenAPI contract tests. + +## Commands/checks + +- `pnpm install --frozen-lockfile` - pass. +- `pnpm format:check` - pass. +- `pnpm lint` - pass. +- `pnpm typecheck` - pass. +- `pnpm check:generated` - pass. +- `pnpm validate:fixtures` - pass. +- `pnpm test` - pass, 7 files, 52 tests. +- `markdownlint-cli2` - pass, 54 files, 0 errors. +- `storage-v1` schema digest - `09b7fc0ce90a3db9dfd0437ae1abdac7260603154216b223116d0e123967d742`. + +## Review gates + +- Gate A: Waived by repository owner for this PR. +- Gate B: Waived by repository owner for this PR. diff --git a/.agent/context/20260907T151500Z-a03-atomic-worker.md b/.agent/context/20260907T151500Z-a03-atomic-worker.md new file mode 100644 index 0000000..32c585f --- /dev/null +++ b/.agent/context/20260907T151500Z-a03-atomic-worker.md @@ -0,0 +1,65 @@ +# Session Context: A03 atomic at-most-once worker + +## Date/time + +- UTC: 2026-09-07T15:15:00Z + +## User goal + +Implement Coder A's milestone A03: atomic at-most-once worker with Graphile Worker task integration, +PostgreSQL compare-and-set (CAS) submission ownership, exhaustive settlement outcome mapping, +and comprehensive concurrency/restart proofs. + +## Original prompt/request + +Continue plan in the loop through Coder A milestones until merging part of 3 other workers (B, C). +Review with free pi glm 5.3 instead of 3.8 gemini flash. Stick to the plan. + +## Assumptions + +- A03 builds on A02 (durable intent ledger & API) and latest `develop`. +- Graphile Worker is configured over the same PostgreSQL database with `max_attempts = 1`. +- Transition `READY -> SUBMITTING` is an atomic compare-and-set database transaction that commits before calling the external settlement port. +- Any submission uncertainty (network timeout, unexpected error) immediately enters `UNKNOWN` without blind retries and enqueues reconciliation. +- Review gates use FreePi CLI with model `glm 5.3`. + +## Plan + +1. Add migration `003_worker_jobs.sql` extending `outbox_jobs` task identifiers to include `submit_settlement`. +2. Implement CAS state transitions (`completeAuthorization`, `claimSubmission`, `completeSubmission`) in `packages/storage-postgres`. +3. Create `apps/worker` with task definitions (`authorize_intent`, `submit_settlement`, `reconcile_intent`), outbox draining, and concurrency runner. +4. Add unit and containerized integration test suites covering the test matrix rows: normal job, 10 parallel workers storm, 10 sequential deliveries, crash/uncertainty handling, and outbox draining. +5. Verify all repository checks (format, lint, typecheck, contract checks, test suites). +6. Run Gate A with FreePi CLI / GLM 5.3. +7. Push branch `milestone/a03-atomic-worker` and open pull request targeting `develop`. +8. Wait for CI checks and run Gate B with FreePi CLI / GLM 5.3. +9. Update PR description with full Gate A and Gate B evidence. + +## Key decisions + +- `claimSubmission` enforces `READY -> SUBMITTING` CAS and records the attempt before committing the transaction and calling the port. +- Settlement port errors map to `POSSIBLY_SUBMITTED` -> `UNKNOWN` with reconciliation enqueued; blind retries are prohibited. +- Job keys serve as scheduling hygiene; deduplication and submission ownership are strictly guaranteed by PostgreSQL row locks and CAS versioning. + +## Files/components touched + +- `package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `tsconfig.json`. +- `packages/storage-postgres`: migration `003_worker_jobs.sql`, updated schema digest, CAS methods in `IntentLedger`. +- `apps/worker`: package manifest, TypeScript config, task execution handlers, concurrency runner, failure point catalog, unit and integration tests. + +## Commands/checks + +- `pnpm install --frozen-lockfile` - pass. +- `pnpm format:check` - pass. +- `pnpm lint` - pass. +- `pnpm typecheck` - pass. +- `pnpm check:generated` - pass. +- `pnpm validate:fixtures` - pass. +- `pnpm test` - pass, 9 files, 81 tests. +- `markdownlint-cli2` - pass, 61 files, 0 errors. +- `storage-v1` schema digest - `5d5888894ff0f4f44049579f1c8ffca2a24e0b61c3af65aabdbcd78f06020d65`. + +## Review gates + +- Gate A: Pending. +- Gate B: Pending. diff --git a/.agent/context/20260907T160800Z-c02-reconciliation-engine.md b/.agent/context/20260907T160800Z-c02-reconciliation-engine.md new file mode 100644 index 0000000..b7e268b --- /dev/null +++ b/.agent/context/20260907T160800Z-c02-reconciliation-engine.md @@ -0,0 +1,42 @@ +# Session Context: C02 LLM Recovery Agent and Deterministic Reconciliation + +## Date/time + +- UTC: 2026-09-07T16:08:00Z + +## User goal + +Implement Coder C Milestone C02: LLM Recovery Agent and Deterministic Reconciliation. +Build the RecoveryAdvisorPort contract, deterministic LLM recovery agent simulator, deterministic recovery safety core, safe reconciliation command vocabulary, provenance-labeled recovery view, and exhaustive idempotency/safety test matrix. Zero payment submission capability by construction. + +## Key decisions + +- Built bounded 4-action advisory contract (`WAIT`, `RECONCILE`, `ESCALATE`, `RETURN_EXISTING_RESULT`). +- Input to RecoveryAdvisorPort strictly labels candidate observations as untrusted data (`UNTRUSTED_DATA_NOTICE`) and strips secrets, keys, and credentials. +- Prompt injection defense, unknown actions, and fabricated evidence IDs fail closed to `WAIT`. +- Deterministic safety core requires verified final Arc on-chain proof before any intent can transition to `COMMITTED`. Advisory `RETURN_EXISTING_RESULT` without independent Arc proof is safely overridden to `HOLD_UNKNOWN`. +- Settlement permission is `'NEVER'` across all outputs; package imports no private A/B modules and makes no `SettlementPort` calls. + +## Files touched/created + +- `packages/reconciliation/src/types.ts` +- `packages/reconciliation/src/evidence-model.ts` +- `packages/reconciliation/src/agent-contract.ts` +- `packages/reconciliation/src/safety-core.ts` +- `packages/reconciliation/src/agent-simulator.ts` +- `packages/reconciliation/src/index.ts` +- `packages/reconciliation/schemas/recovery-advisor-v1.schema.json` +- `packages/reconciliation/schemas/reconciliation-command-v1.schema.json` +- `packages/reconciliation/schemas/recovery-view-v1.schema.json` +- `packages/reconciliation/fixtures/v1/agent/*` +- `packages/reconciliation/docs/recovery-action-matrix.md` +- `packages/reconciliation/README.md` +- `packages/reconciliation/test/reconciliation-engine.test.ts` +- `.agent/context/20260907T160800Z-c02-reconciliation-engine.md` + +## Review gates + +- Gate A: PASS (free-pi-cli / glm 5.3, candidate tree 8105a511787fd9d31c1c3f3a1935729556d5ac74) +- CI: PASS (ESLint & TypeScript, Markdown & Mermaid, repository-policy) +- Gate B: PASS (free-pi-cli / glm 5.3, head 6dd2e37ff076af6b115a589664f5a999fd480658, tree 8105a511787fd9d31c1c3f3a1935729556d5ac74) +- PR: [#20](https://github.com/SWOFART/OneShot/pull/20) - Ready for review diff --git a/.agent/context/20260907T162000Z-c03-failure-injection.md b/.agent/context/20260907T162000Z-c03-failure-injection.md new file mode 100644 index 0000000..e824bfa --- /dev/null +++ b/.agent/context/20260907T162000Z-c03-failure-injection.md @@ -0,0 +1,33 @@ +# Session Context: C03 Cross-Source Failure Injection + +## Date/time + +- UTC: 2026-09-07T16:20:00Z + +## User goal + +Implement Coder C Milestone C03: Cross-Source Failure Injection. +Build a deterministic chaos harness and timeline DSL proving that crashes, lost responses, duplicate/out-of-order evidence, Subgraph MCP degradation, hostile tool content, invalid LLM output, and provider/RPC contradictions cannot turn uncertainty into settlement permission. + +## Invariants and boundaries + +- 1 business intent -> at most 1 committed settlement. +- UNKNOWN state reconciles without blind retries. +- Zero payment submission permission (`settlementPermission: 'NEVER'`) across all degraded, contradictory, or crashed scenarios. +- Deterministic and seed-recorded. +- Package isolation: no private A/B modules, no direct database mutation, no live credentials. + +## Small tasks + +- C03.1 — Failure timeline DSL (injection points: BEFORE_SUBMISSION, POSSIBLY_SUBMITTED, CONFIRMED; deterministic seed recording). +- C03.2 — Graph & Subgraph MCP degradation suite (delay, empty, lag, health errors, omit freshness, wrong tool/deployment, truncated/oversized, injection). +- C03.3 — Provider / RPC contradiction suite (Privy vs Arc combinations, binding mismatches). +- C03.4 — Restart & evidence replay (feed persistence, replay, reordering, chronology stability). +- C03.5 — Agent failure, UNKNOWN aging, and escalation (timeout, malformed output, prompt injection, age buckets, alerts, runbook). + +## Git and PR state + +- Branch: `milestone/c03-failure-injection` +- Base: `milestone/c02-reconciliation-engine` (6dd2e37ff076af6b115a589664f5a999fd480658) +- Review tooling: `free-pi-cli` / `glm 5.3` +- Status: ACTIVE diff --git a/.agent/context/20260907T170000Z-c02-c03-hardening.md b/.agent/context/20260907T170000Z-c02-c03-hardening.md new file mode 100644 index 0000000..0280c03 --- /dev/null +++ b/.agent/context/20260907T170000Z-c02-c03-hardening.md @@ -0,0 +1,67 @@ +# Session Context: C02/C03 reconciliation hardening + +## Date/time + +- UTC: 2026-09-07T17:00:00Z + +## User goal + +Create a replacement PR for C02/C03 that closes the old PRs, fixes review findings, and uses real repository data only where safely available. + +## Original prompt/request + +"Тогда подучается закрой эти pr. исправь эти ошибки и сделай новый pr" + +## Assumptions + +- Replacement PR includes C02 then C03 because C03 depends on C02. +- No live Subgraph MCP deployment, connection, or model credentials exist in the repository; offline fixtures remain required until the C01 live value gate passes. + +## Plan + +1. Branch from current `develop`, apply C02/C03 commits, and harden exact evidence binding. +2. Make failure scenarios executable and measurable; cover every declared degradation. +3. Run required checks, Gate A, push draft PR, CI, then Gate B. +4. Create replacement PR and close superseded PRs. + +## Key decisions + +- Keep reconciliation package isolated from A/B implementations, as C02 requires. +- Use real Arc Testnet chain/USDC constants already in repository; do not fabricate a live Graph deployment. + +## Files/components touched + +- `packages/reconciliation`: exact evidence/submission binding, strict advisor boundary, executable chaos coverage, and package verification configuration. +- `pnpm-lock.yaml`: workspace importer synchronization required for reproducible dependency resolution. + +## Commands/checks + +- `git fetch origin develop` - PASS after approved network access. +- `pnpm --filter @oneshot/reconciliation test` - PASS, 51 tests. +- `pnpm typecheck` - PASS after dependency install with lifecycle scripts disabled. +- `pnpm --filter @oneshot/reconciliation verify` - PASS: format, lint, typecheck, 51 tests, build. + +## External-doc findings + +- `packages/reconciliation/docs/live-value-gate.md` records `FALLBACK_DIRECT_RECOVERY`; no live immutable deployment or MCP connection is admitted. + +## Unresolved questions + +- Existing PR numbers must be identified before closure. + +## Git and PR state + +- Branch: `milestone/c02-c03-hardening` +- Base: `origin/develop` at `99fe27724c65f6c69ae9e4369b593e05e463ffb9` +- Commit: uncommitted staged candidate +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A: FAIL on tree `d1b75fdbddf1d5bbd14d04514e628b2d54592aaa`; formatter and local dependency-layout findings corrected. Fresh Gate A required for new tree. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Restage formatted candidate, capture new tree, and request fresh FreePi Gate A. diff --git a/.agent/context/20260907T174500Z-a04-restart-operations-composition.md b/.agent/context/20260907T174500Z-a04-restart-operations-composition.md new file mode 100644 index 0000000..e82b611 --- /dev/null +++ b/.agent/context/20260907T174500Z-a04-restart-operations-composition.md @@ -0,0 +1,49 @@ +# Session Context: A04 Restart Safety, Operations, and Simulator Composition + +## Date/time + +- UTC: 2026-09-07T17:45:00Z + +## User goal + +Implement Coder A Milestone A04: restart safety, safe operations disable, structured telemetry with redaction, simulator composition profile, and Gate P4 preparation. + +## Key decisions + +- Startup recovery (`recoverOrphanedSubmissions`) detects `SUBMITTING` records with expired leases and routes them to `UNKNOWN` with reconciliation enqueued. Invariant holds: lease expiry NEVER grants a new settlement claim. +- Safe disable (`submissionsDisabled: true`) pauses new submission ownership while keeping liveness, readiness, status reads, and reconciliation ingestion active. +- Readiness check (`/health/ready`) verifies database connectivity, chain identity, and contract version compatibility, failing closed without leaking sensitive data. +- Telemetry module enforces explicit redaction of private keys, tokens, auth headers, and sensitive payloads. +- Port composition defines frozen simulator profiles for Arc settlement and Privy authorization, creating clean dependency injection boundaries for Gate P4. +- `@oneshot/api` now has an executable process boundary. It applies migrations before listening, accepts local `DATABASE_URL` or Cloud SQL Unix-socket configuration, and shuts down the HTTP server and PostgreSQL pool together. +- The production hosting target is Cloud Run plus Cloud SQL for PostgreSQL. Cloud provisioning, IAM, and secrets stay outside source control. +- The A01 OpenAPI artifact exists, but A05/B05/C05 remain blocked until P4 revalidates the composed contract and publishes a versioned mock server. + +## Files touched/created + +- `packages/domain/src/telemetry.ts` +- `packages/domain/src/index.ts` +- `packages/storage-postgres/src/ledger.ts` +- `apps/api/src/app.ts` +- `apps/api/src/config.ts` +- `apps/api/src/runtime.ts` +- `apps/api/src/server.ts` +- `apps/worker/src/types.ts` +- `apps/worker/src/worker.ts` +- `apps/worker/src/composition.ts` +- `apps/worker/src/restart-runner.ts` +- `apps/worker/src/index.ts` +- `apps/worker/test/composition.test.ts` +- `apps/worker/test/restart-recovery.integration.test.ts` +- `docs/COMPOSITION_MANIFEST.md` +- `docs/SIMULATOR_LOCK.md` +- `docs/RESTART_RUNNER.md` +- `docs/DASHBOARDS_AND_ALERTS.md` +- `docs/SAFE_DISABLE_RUNBOOK.md` +- `docs/GATE_P4_CHECKLIST.md` +- `docs/SERVER_RUNTIME.md` + +## Review gates + +- Gate A: previous verdict invalidated by the supplementary server change; refresh pending +- Gate B: previous verdict invalidated by the supplementary server change; refresh after CI diff --git a/.agent/context/20260907T190600Z-a05-frontend-intent-status.md b/.agent/context/20260907T190600Z-a05-frontend-intent-status.md new file mode 100644 index 0000000..15731b1 --- /dev/null +++ b/.agent/context/20260907T190600Z-a05-frontend-intent-status.md @@ -0,0 +1,73 @@ +# Session Context: A05 Frontend Intent and Authoritative Status + +## Date/time + +- Started: 2026-09-07T19:06:00Z +- Continued: 2026-09-08T12:29:20Z + +## User goal and original request + +Implement Coder A milestone A05 after Gate P4: "Делай" in response to the identified next packet, A05 Frontend Intent and Authoritative Status. + +## Assumptions and non-goals + +- Base is current `origin/develop` at `1250dec`. +- A05 consumes frozen OpenAPI v1 and does not add settlement capability. +- The browser receives a demo service token only at runtime and does not persist it. +- B05/C05 composition remains project Gate P5 work. +- No production API deployment, mainnet action, settlement detail UI, or visual polish campaign is included. + +## Plan and decisions + +- Preserve and finish the existing uncommitted A05 implementation after moving its base from A04 to current `develop`. +- Use generated `@oneshot/contracts` types and a small typed fetch client. +- Keep exact USDC parsing and formatting string/`bigint` based. +- Preserve the same Business Intent ID for replay; generate another ID only through an explicit new-obligation action. +- Stop polling at `UNKNOWN`; expose reconciliation only, never payment retry. +- Replace the Cloudflare placeholder asset with the built A05 SPA. +- Use React/Vite dependencies already present in the workspace; add no design system or state library. + +## Files and components + +- `apps/web`: application shell, API client, intent form, status view, readiness banner, exact money helpers, tests, and setup documentation. +- Root TypeScript/ESLint/workspace lock configuration includes the new app. +- Root Wrangler assets now point to `apps/web/dist`; the old placeholder is removed. +- Root README lists the operator frontend and its local command. + +## Commands and checks + +- `pnpm --filter @oneshot/web test`: 27 tests passed. +- `pnpm --filter @oneshot/web lint`: passed. +- `pnpm --filter @oneshot/web typecheck`: passed. +- `pnpm --filter @oneshot/web build`: passed; production source maps remain disabled. +- `pnpm exec wrangler deploy --dry-run`: passed with `apps/web/dist` assets. +- `pnpm format:check`, root `pnpm lint`, root `pnpm typecheck`, generated-contract check, and fixture validation: passed. +- Root `pnpm test`: 576 tests passed across 43 files. +- `TEST_POSTGRES=1 pnpm test:integration`: unavailable locally because no container runtime is running; the first Testcontainers suite reported `Could not find a working container runtime strategy`. Required CI provides the integration environment. +- Local runtime uses Node 22.23.2 and reports the repository's expected Node 24.19.0 engine warning; CI uses `.nvmrc`. + +## External documentation findings + +- Wrangler 4.127.0 local schema accepts `assets.not_found_handling = "single-page-application"`. +- Added `build.command = "pnpm --filter @oneshot/web build"` to `wrangler.jsonc` so Cloudflare Workers Builds automatically builds the Vite SPA before asset upload. + +## Unresolved questions + +- None for A05 packet scope. Production API deployment and P5 UI composition remain later work. + +## Branch, commit, PR, and review state + +- Branch: `milestone/a05-frontend-intent-status` +- Base: `origin/develop` at `1250dec79bc702939fe2a3b0fd00e66bb34128af` +- Initial commit: `d3fbfacdee365d159807256a53011140442867ff` +- PR: [#32](https://github.com/SWOFART/OneShot/pull/32) +- Gate A: IN PROGRESS (free-pi-cli glm-5.3-flash) +- Gate B: pending CI and exact-head review + +## Handoff and next steps + +- Validate wrangler build integration locally. +- Re-run Gate A, commit fix, push to PR #32. +- Verify CI passes (including Workers Builds). +- Run Gate B and mark PR ready for review. +- Project Gate P5 will compose B05/C05 UI packages after A05 closes. diff --git a/.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md b/.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md new file mode 100644 index 0000000..e425b9c --- /dev/null +++ b/.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md @@ -0,0 +1,78 @@ +# Session Context: C04 recovery matrix and integration + +## Date/time + +- UTC: 2026-09-07T22:10:16Z + +## User goal + +Implement Coder C milestone C04 and review the finished candidate with fresh +FreePi processes using GLM 5.3. + +## Original prompt/request + +"C04 план начинай делать. Делай ревью через free pi glm5-3" + +## Assumptions + +- C02/C03 merged through PR #22 and are the complete simulator baseline for C04. +- C04 remains package-local and zero-submit; live adapter replacement stays at Gate P4. + +## Plan + +1. Add the versioned recovery service and append-only evidence-command seam. +2. Compose local, known-identity, Subgraph MCP, and advisor simulators behind public ports. +3. Execute the complete C04 recovery matrix and publish replacement guidance. +4. Run package/root checks, FreePi Gate A, draft PR CI, and FreePi Gate B. + +## Key decisions + +- Preserve deterministic safety-core authority; model and index outputs stay advisory. +- Make duplicate delivery converge through deterministic record and command identities. + +## Files/components touched + +- `docs/COMPOSITION_MANIFEST.md`: updated reconciliation port reference to `RecoveryService` with Subgraph MCP adapter. +- `docs/GATE_P4_CHECKLIST.md`: references `GATE_P4_RECOVERY_REPLACEMENT.md` and `RecoveryCommandStorePort`. +- `packages/reconciliation`: recovery service, simulators, full pre-live matrix, schemas, and integration tests. +- `.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md`: this session record. + +## Commands/checks + +- `git fetch origin develop` - PASS. +- Current `develop` rebased with PR #27 at `4295bd9c909d39bd750eed8d756ec4b0abe77958`. +- `pnpm format:check` - PASS (Prettier 3.9.6). +- `pnpm lint` - PASS (ESLint 10.10.0, 0 issues). +- `pnpm typecheck` - PASS (TypeScript 6.0.3, 0 issues). +- `pnpm check:generated` - PASS (contracts current). +- `pnpm validate:fixtures` - PASS (9 fixtures validated). +- `pnpm test` - PASS (35 test files, 504 tests passing; 66 in reconciliation package). +- `npx markdownlint-cli2` - PASS (88 markdown files, 0 issues). +- `git diff --check` - PASS (no whitespace or merge marker issues). + +## External-doc findings + +- None; C04 is a frozen simulator and integration milestone. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `milestone/c04-recovery-matrix-integration` +- Base: `origin/develop` at `4295bd9c909d39bd750eed8d756ec4b0abe77958` +- Commit: uncommitted +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage candidate files and capture candidate tree SHA (`git write-tree`). +2. Run FreePi Gate A pre-push review with `npx free-pi-cli` (GLM 5.3). +3. Commit, push branch, open draft PR, verify CI, then run FreePi Gate B. diff --git a/.agent/context/20260907T234800Z-gate-p4-backend-convergence.md b/.agent/context/20260907T234800Z-gate-p4-backend-convergence.md new file mode 100644 index 0000000..d8dfb8c --- /dev/null +++ b/.agent/context/20260907T234800Z-gate-p4-backend-convergence.md @@ -0,0 +1,84 @@ +# Session Context: Gate P4 Backend Convergence and Adapter Replacement + +## Date/time + +- UTC: 2026-09-07T23:48:00Z + +## User goal + +Implement Gate P4 backend convergence: replace checked simulators in `apps/worker` with reviewed Lane B adapters (`ArcSettlementAdapter`, `PrivyAuthorizationAdapter`) and Lane C recovery engine (`RecoveryService`), bridge the known `EvidencePort` proof envelope gap, implement durable command persistence seam over `IntentLedger`, and verify full integrated convergence. + +## Original prompt/request + +"Start Gate P4: Backend Convergence and Simulator Replacement (wire real ArcSettlementAdapter, PrivyAuthorizationAdapter, and RecoveryService into apps/worker)" + +## Assumptions + +- PR #29 (C04) merged into `develop` at `50d8e7b4ba9ff247464ff9b0b36c19227c4c538d`. +- PR #27 (Lane B P4 adapters) and PR #24 (Lane B integration) are merged into `develop`. +- Gate P4 composition preserves the single-intent / at-most-one-settlement invariant. +- Persistence for recovery commands is owned by Lane A (`apps/worker`) implementing `RecoveryCommandStorePort` over `IntentLedger`. + +## Plan + +1. Add `@oneshot/privy-adapter`, `@oneshot/arc-adapter`, and `@oneshot/reconciliation` to `apps/worker/package.json`. +2. Implement `IntentLedgerRecoveryStore` and `IntentLedgerLocalStatePort` in `apps/worker` implementing `RecoveryCommandStorePort` and `LocalRecoveryStatePort`. +3. Implement `PrivyArcEvidenceBridge` satisfying `KnownIdentityEvidencePort` by bridging `@oneshot/privy-adapter`'s `EvidencePort` with verified Arc transaction evidence. +4. Update `apps/worker/src/composition.ts` and `apps/worker/src/worker.ts`: + - Wire `ArcSettlementAdapter` and `PrivyAuthorizationAdapter` for production profile. + - Wire `RecoveryService` into `reconcile_intent` task. +5. Add unit and integration test coverage for production composition and end-to-end reconciliation execution. +6. Run full verification suite (`pnpm lint`, `pnpm typecheck`, `pnpm test`, `TEST_POSTGRES=1 pnpm test:integration`, fixtures, markdownlint). +7. Run FreePi Gate A review with `free-pi-cli` (`glm-5.3-flash`), commit, push, create draft PR, verify CI, and run FreePi Gate B review. + +## Key decisions + +- Bridge `EvidencePort` in `apps/worker` to enrich Lane B's classification with sanitized proof envelope without mutating Lane B's frozen packages. +- Implement `RecoveryCommandStorePort` over `IntentLedger` using atomic CAS (`expectedStateVersion`) to ensure zero double-reconciliation. +- Keep `apps/worker` composition fail-closed on contract version, network, or policy drift. + +## Files/components touched + +- `apps/worker/package.json`: add lane B and C workspace dependencies. +- `apps/worker/src/composition.ts`: production composition wiring and `createProductionRecoveryService`. +- `apps/worker/src/index.ts`: export recovery-bridge. +- `apps/worker/src/types.ts`: add recoveryService to WorkerOptions. +- `apps/worker/src/worker.ts`: `reconcile_intent` task implementation with true post-reconciliation ledger state logging. +- `apps/worker/src/recovery-bridge.ts`: persistence and verified evidence bridges for Gate P4. +- `apps/worker/test/p4-composition.test.ts`: test production composition, verified evidence envelopes, and durable CAS persistence. +- `packages/storage-postgres/src/ledger.ts`: support UNKNOWN->COMMITTED/FAILED_SAFE in completeSubmission and durable outbox deduplication. +- `docs/GATE_P4_CHECKLIST.md`: update status and replacement instructions. +- `pnpm-lock.yaml`: update workspace lockfile. + +## Commands/checks + +- `git checkout -b milestone/gate-p4-convergence 50d8e7b4ba9ff247464ff9b0b36c19227c4c538d` - PASS. + +## External-doc findings + +- `docs/GATE_P4_CHECKLIST.md`, `packages/reconciliation/docs/GATE_P4_RECOVERY_REPLACEMENT.md`, and `docs/settlement/GATE_P4_LANE_B_READINESS.md`. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `milestone/gate-p4-convergence` +- Base: `develop` (`50d8e7b4ba9ff247464ff9b0b36c19227c4c538d`) +- Commit: uncommitted +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A: IN PROGRESS (running FreePi pre-push review) +- Gate B: PENDING (runs after PR creation and CI) + +## Handoff/next steps + +1. Stage candidate changes and compute candidate tree SHA via `git write-tree`. +2. Run FreePi Gate A review (`glm-5.3-flash`) and verify `VERDICT: PASS`. +3. Commit, push branch to GitHub, and open draft PR. +4. Verify CI checks. +5. Run FreePi Gate B review and mark PR ready for review. diff --git a/.agent/context/20260908T000000Z-recovery-route-composition.md b/.agent/context/20260908T000000Z-recovery-route-composition.md new file mode 100644 index 0000000..bdd21c2 --- /dev/null +++ b/.agent/context/20260908T000000Z-recovery-route-composition.md @@ -0,0 +1,88 @@ +# Session Context: recovery-route-composition + +## Date/time + +- UTC: 2026-09-08T18:25:19Z + +## User goal + +Serve the existing `apps/web` frontend at the domain root and the recovery UI +at `/recovery` from the same Cloudflare assets deployment. + +## Original prompt/request + +The current Cloudflare deployment includes `./apps/web/dist`, while UI exists +in `packages/recovery-ui`. Combine them so `oneshot.kapustazh.dev` keeps the +current pages and `oneshot.kapustazh.dev/recovery` serves the recovery UI; do +this on a new branch. + +## Assumptions + +- The recovery site is the existing synthetic fixture viewer, not a new live + API integration. +- The current Wrangler assets directory remains `apps/web/dist`. +- The branch should start from the clean current `develop` branch. + +## Plan + +1. Build the main app and recovery site into one static asset tree. +2. Verify asset paths, production builds, and Wrangler configuration. + +## Key decisions + +- Emit recovery files to `apps/web/dist/recovery` and use Vite base + `/recovery/`, preserving the root app bundle and making nested assets resolve + under the route. +- Keep Cloudflare's existing SPA fallback because both apps are static entry + points and the recovery viewer does not require server-side routes. + +## Files/components touched + +- `package.json`: added the combined frontend build script. +- `wrangler.jsonc`: changed the Cloudflare build command to the combined build. +- `packages/recovery-ui/vite.site.config.ts`: configured the `/recovery/` base + and shared output directory. +- `apps/web/README.md`: documented the deployed route and build command. +- `packages/recovery-ui/README.md`: updated the package deployment instructions + for the combined asset tree and `/recovery/` route. + +## Commands/checks + +- Branch creation: `feature/recovery-route-composition`. +- `pnpm build:frontend` - passed; root output and `/recovery/` output were + emitted into one asset tree. +- `pnpm --filter @oneshot/web test` - passed, 27 tests. +- `pnpm --filter @oneshot/recovery-ui test` - passed, 51 tests. +- Both package typechecks and linters - passed. +- `pnpm exec wrangler deploy --dry-run` - passed; Wrangler read 9 asset files + from `apps/web/dist` and exited without uploading. +- Prettier check on changed config files - passed. +- Documentation correction: `packages/recovery-ui/README.md` now documents + `pnpm build:frontend`, `apps/web/dist/recovery`, and `/recovery/`. + +## External-doc findings + +- None required; this change uses the repository's existing Vite and Wrangler + configuration. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `feature/recovery-route-composition` +- Base: `develop` (working tree was clean at branch creation) +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: FAIL on prior tree due stale recovery deployment docs; fresh review + pending for the corrected tree. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Review the branch and deploy it through the normal Cloudflare workflow. diff --git a/.agent/context/20260908T061949Z-c05-frontend-recovery.md b/.agent/context/20260908T061949Z-c05-frontend-recovery.md new file mode 100644 index 0000000..401f7ad --- /dev/null +++ b/.agent/context/20260908T061949Z-c05-frontend-recovery.md @@ -0,0 +1,111 @@ +# Session Context: C05 frontend recovery + +## Date/time + +- UTC: 2026-09-08T06:19:49Z + +## User goal + +Implement Coder C milestone C05, pass FreePi Gate B with GLM 5.3 Flash, then +start C06. + +## Original prompt/request + +"we have done all milestones of coders A,B,C to 04. Lets start build milestone +for coder C 05-frontend-recovery. Before we start check if you can write +something in npx free-pi-cli or if failed npx.cmd free-pi-cli. Check also +previos task to understand what have built already. If milestone c05 will be +finished and gate B will be passed that start with next milestone C 06" + +The user later confirmed the repository path on `C:` and instructed PowerShell +use. The user also required `/model free-pi/glm-5.3-flash` before each FreePi +review. + +## Assumptions + +- Gate P4 backend convergence is merged at `25a17d8` and freezes the + `recovery-view-v1` semantics implemented by `@oneshot/reconciliation`. +- Gate P4 did not publish its planned frontend mock artifact. C05 will first + publish a C-owned, versioned, sanitized mock boundary without changing the + shared OpenAPI or A/B-owned frontend slices. +- C05 remains an independently composable React/Vite slice. Gate P5 owns final + application-shell composition. + +## Plan + +1. Freeze a versioned, sanitized recovery UI contract and mock fetch server. +2. Build the recovery route, timeline, provenance, MCP, agent/core, and UNKNOWN + experiences. +3. Add fixture scenarios and component/accessibility/keyboard/responsive tests. +4. Run package and root checks, FreePi Gate A, draft PR CI, and FreePi Gate B. +5. Start C06 only after C05 Gate B passes. + +## Key decisions + +- Keep authoritative OneShot/Arc evidence visually separate from provider, + Graph, and LLM observations. +- Expose refresh and escalation only. Never expose payment or generic retry + actions. +- Discard unknown fields and reject forbidden raw-provider or secret-shaped data + at the mock/client boundary. + +## Files/components touched + +- `packages/recovery-ui/`: independent React/Vite recovery slice, frozen JSON + schema, sanitized mock server, fixture stories, styles, and 50 tests. +- `tsconfig.json`: recovery UI project reference. +- `pnpm-lock.yaml`: pinned recovery UI dependencies. +- This context record. + +## Commands/checks + +- `git fetch origin develop` - PASS. +- Base: `25a17d86b56822a7e7440d34c331b740cb6d7f04`. +- `npx.cmd free-pi-cli` interactive write test - PASS; reviewer replied + `FREEPI_WRITE_OK`. +- `pnpm.cmd install --frozen-lockfile --config.confirmModulesPurge=false` - PASS. +- `pnpm.cmd --filter @oneshot/recovery-ui run verify` - PASS: format, lint, + typecheck, 50 tests, and Vite library build. +- Desktop and 390-pixel viewport browser inspection - PASS; no horizontal + overflow and all recovery panels/actions remain usable. +- `pnpm.cmd lint`, `pnpm.cmd typecheck`, `pnpm.cmd check:generated`, and + `pnpm.cmd validate:fixtures` - PASS. +- `pnpm.cmd test` - first run exposed the existing millisecond-sensitive C03 + replay test; immediate full rerun passed all 560 tests. +- `pnpm.cmd format:check` - baseline checkout limitation: Prettier reports 122 + untouched CRLF files. The focused recovery UI Prettier check passes. +- `npx.cmd --yes markdownlint-cli2@0.18.1` for the two new Markdown files - + PASS. +- Docker integration checks unavailable because Docker is not installed on this + Windows host; C05 adds no database/runtime integration path. + +## External-doc findings + +- npm registry metadata confirms React 19.2.8 and Vite 8-compatible + `@vitejs/plugin-react` 6.1.1. + +## Residual risks + +- Final app-shell composition remains owned by project Gate P5. +- Windows root formatting remains red on untouched CRLF files; changed-package + formatting is green. + +## Git and PR state + +- Branch: `milestone/c05-frontend-recovery`. +- Base: `origin/develop` at `25a17d86b56822a7e7440d34c331b740cb6d7f04`. +- Commit: uncommitted. +- PR: not created. +- CI: not applicable. + +## Review gates + +- Gate A: NOT RUN. +- Gate B: NOT RUN. + +## Handoff/next steps + +1. Implement and validate C05. +2. Run fresh FreePi Gate A with `free-pi/glm-5.3-flash`. +3. Push a draft PR, verify CI, and run fresh Gate B. +4. Begin C06 after Gate B passes. diff --git a/.agent/context/20260908T113831Z-live-arc-subgraph.md b/.agent/context/20260908T113831Z-live-arc-subgraph.md new file mode 100644 index 0000000..4f30845 --- /dev/null +++ b/.agent/context/20260908T113831Z-live-arc-subgraph.md @@ -0,0 +1,82 @@ +# Session Context: live Arc subgraph + +## Date/time + +- UTC: 2026-09-08T11:38:31Z + +## User goal + +Deploy a live OneShot Subgraph that indexes Arc Testnet USDC transfers and make +it available to the recovery path through The Graph Gateway and Subgraph MCP. + +## Original prompt/request + +The user confirmed that the Privy secret, Graph deploy key, and Graph Gateway +API key exist in Google Secret Manager and asked to continue connecting The +Graph. No credential values belong in the repository. + +## Assumptions + +- Arc Testnet `eip155:5042002` and its USDC interface remain the selected demo profile. +- The initial start block may intentionally precede the first OneShot demo transfer. +- The Graph results discover candidates only; Arc RPC remains authoritative. + +## Plan + +1. Commit and publish the independently buildable subgraph source. +2. Wait for a Graph Network indexer allocation to the published deployment. +3. Verify an immutable live query through Gateway and Subgraph MCP. +4. Wire the live MCP adapter without exposing credentials. + +## Key decisions + +- Index immutable USDC `Transfer` events with sender, recipient, amount, block, + log index, timestamp, and transaction hash. +- Pin the recovery path to the immutable manifest deployment instead of an + automatically moving Studio version label. +- Publish registration on Arbitrum One while the indexed data source remains Arc Testnet. + +## Files/components touched + +- `subgraph/`: manifest, ERC-20 ABI, schema, mapping, package metadata, lockfile, + candidate-query documentation, and generated/build ignores. + +## Commands/checks + +- `pnpm --dir subgraph codegen` - passed. +- `pnpm --dir subgraph build` - passed. +- Studio deployment `v0.1.0` - deployed and indexing live Arc events without errors. +- Studio GraphQL `_meta` and transfer query - passed with live data. +- Graph Gateway immutable-deployment query - publication visible, currently waiting on an Indexer allocation. +- `git diff --check` - passed before handoff preparation. + +## External-doc findings + +- The Graph CLI `0.98.1` uses the Studio deploy endpoint and supports publishing + the same Arc-indexing manifest through The Graph Network registration on Arbitrum One. +- Hosted Subgraph MCP queries published deployments through The Graph Gateway; + a Studio-only deployment is insufficient for that path. + +## Unresolved questions + +- Which of the two duplicate publication registrations should be the canonical Subgraph ID. +- When the first Indexer allocation will become available for the published deployment. + +## Git and PR state + +- Branch: `milestone/c06-live-subgraph` +- Base: `origin/develop` at `25a17d86b56822a7e7440d34c331b740cb6d7f04` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN; the user requested no review gates for this configuration/integration step. +- Gate B: NOT RUN; no PR exists. + +## Handoff/next steps + +1. Obtain the canonical public Subgraph ID from Graph Explorer/Studio. +2. Wait for allocation and verify the live deployment through Gateway and hosted Subgraph MCP. +3. Commit and push the focused branch, then implement the runtime adapter separately. diff --git a/.agent/context/20260908T122712Z-c06-qualification-demo.md b/.agent/context/20260908T122712Z-c06-qualification-demo.md new file mode 100644 index 0000000..339a032 --- /dev/null +++ b/.agent/context/20260908T122712Z-c06-qualification-demo.md @@ -0,0 +1,81 @@ +# C06 qualification demo context + +- Branch: `milestone/c06-qualification-demo` +- Base: merged `origin/develop` at `1250dec79bc702939fe2a3b0fd00e66bb34128af` +- User correction: C06 starts from merged `develop`, not the C05 feature branch. +- Public target: `https://oneshot.kapustazh.dev/` currently serves + `apps/placeholder-frontend` through Wrangler. +- C06 will publish the recovery UI as a clearly labelled synthetic review demo, + add repeatable qualification/evidence checks, and preserve zero-submit safety. +- Live Privy, Arc Testnet, and Subgraph MCP evidence is absent at branch start. + Sponsor verdicts therefore remain `NOT VERIFIED`; fixtures and plans must not + be promoted into live evidence. +- No external wallet, policy, funding, deployment, or real-value mutation is in + scope without separate human provisioning and authorization. +- FreePi policy: use `/model free-pi/glm-5.3-flash` first; do not stream noisy + progress. If a quiet review is not practical, provide the exact prompt to the + user for manual relay. + +## Implemented + +- Wrangler now builds/deploys the recovery viewer instead of the placeholder. +- Wrangler owns the frontend build hook, so Cloudflare's direct `wrangler +deploy` path creates `site-dist` on a clean checkout. +- The public viewer uses in-memory fixtures, exposes a scenario selector, and + carries a persistent synthetic/not-live evidence banner. +- Production recovery defaults no longer substitute Graph/model simulators; + absent live ports fail closed as unavailable. +- Added a sponsor evidence classifier that requires `LIVE_CAPTURE` for live + requirements and reports `NOT_VERIFIED` for plans, simulators, or missing refs. +- Added C06 evidence index, demo/reset runbook, live capture checklist, + qualification report, and limitations. +- Merged `origin/milestone/c06-live-subgraph` commit `fe54774`: Arc Testnet USDC + Subgraph source plus a recorded Studio deployment. This does not upgrade The + Graph beyond `NOT VERIFIED` because the canonical immutable identity, Indexer + allocation, live MCP trace, and model/core trace remain missing. + +## Validation + +- Recovery UI: lint/type/build PASS; 51 tests PASS. +- Reconciliation: lint/type/build PASS; 74 tests PASS. +- Worker: lint/type PASS; 20 tests PASS. +- Root: lint/type/build PASS; 569 tests PASS; generated contracts and fixtures PASS. +- Replay nondeterminism found during full validation was fixed by binding the + chaos harness decision timestamp to its recorded scenario clock. +- Wrangler production bundle dry-run PASS; no deployment performed. +- Desktop and 390x844 mobile visual QA PASS. +- Changed-file Prettier PASS. Repository-wide format remains affected by the + pre-existing Windows line-ending baseline. + +## Live gate + +Privy, Arc, and The Graph remain `NOT VERIFIED`. C06 live acceptance cannot pass +until a human provisions and returns the sanitized artifacts in +`packages/reconciliation/docs/c06/LIVE_CAPTURE_CHECKLIST.md`. + +## Review state + +- Recorded base: `1250dec79bc702939fe2a3b0fd00e66bb34128af`. +- Gate A passed tree `28b9445183b7d453ab813662ab3be0d15afbd2e3` and + produced commit `41399ad18433116b71eb9ad910bec34e024f3f60`. +- That Gate A is now invalidated by the user-requested merge of + `milestone/c06-live-subgraph` and the subsequent integration fixes. A new + candidate review is required before another push. + +## Post-review integration + +- Merged commit `fe547744db3ef4d70e8d87a7bdcf8b736cafb6c9` through merge + commit `4ce750f`. +- Cloudflare check `15da7c47-1b3d-4f57-8408-d772eb4396fc` failed at the + pre-deploy boundary. Its private log requires Cloudflare login; the local + configuration showed that a clean direct `wrangler deploy` had no guaranteed + `site-dist` build. +- Added Wrangler `build.command`; dry-run now logs the custom Vite build before + loading four static assets. +- Added `subgraph` to the root pnpm workspace, moved dependency authority to the + root lockfile, and removed the redundant nested lockfile. +- Subgraph codegen PASS and Graph build PASS on Windows PowerShell. +- Root lint/type/build PASS; 569 tests PASS after the merge. Root build now + includes the Subgraph compiler. +- Wrangler dry-run PASS with the custom build hook visibly executing before + asset discovery. No Cloudflare deployment or rerun was performed. 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/.agent/context/20260908T163000Z-a06-release-operations.md b/.agent/context/20260908T163000Z-a06-release-operations.md new file mode 100644 index 0000000..a114390 --- /dev/null +++ b/.agent/context/20260908T163000Z-a06-release-operations.md @@ -0,0 +1,103 @@ +# Session Context: A06 Operational Demo and Release Bundle + +## Date/time + +- UTC: 2026-09-08T14:38:00Z + +## User goal + +Implement Coder A milestone A06 (Operational Demo and Release Bundle): repeatable operations, safe database bootstrap/reset, automated invariant scenario runner, operational evidence documentation, and reviewer-facing mainnet-readiness package. + +## Original prompt/request + +"продолжай по плану что у меня A" -> Confirmed via multiple choice: "Начать A06 — Operational Demo and Release Bundle (официальный следующий этап Lane A: бутстрап БД, сценарии инвариантов, runbook, mainnet-readiness)". + +## Assumptions + +- Base commit is fresh `origin/develop` (`36df8fa06b94a0c08e32fa729a41ef2f22b464dd`), which includes Gate P4, C06, and the current frontend deployment fix. +- A06 is an operations and release bundle; it depends on A05 only. +- Invariant scenarios execute deterministically against an in-memory CAS ledger; + PostgreSQL durability is covered by the existing integration suites. +- The Arc Mainnet profile remains strictly disabled and unpinned (`UNPUBLISHED`), failing closed. No mainnet transaction is broadcast. + +## Plan + +1. Create database bootstrap and safe demo reset utilities in `@oneshot/storage-postgres`. +2. Implement 7 core invariant scenarios in `@oneshot/worker` and provide unit tests and CLI runner. +3. Expose operational scripts in root `package.json` (`scenarios:invariants`, `db:bootstrap`, `db:reset-demo`). +4. Publish comprehensive `docs/OPERATIONS_RUNBOOK.md` covering architecture, bootstrap, demo reset, invariant results, safe-disable, and observability. +5. Publish reviewer-facing `docs/MAINNET_READINESS.md` with `STATUS: DEPLOYMENT-READY`, Cloud Run deployment manifest, probe evidence, and human activation gate. +6. Verify quality, run Gate A review, push branch, open draft PR, monitor CI, run Gate B review, and mark ready for human review. + +## Key decisions + +- Invariant scenario runner covers all 7 required cases: identical replay, conflicting replay, 10 parallel workers, 2 competing processes, process restart/recovery, lost response/ambiguity, and downstream failure. +- Database reset explicitly guards against production and mainnet execution (`NODE_ENV === 'production'` / `ONESHOT_ARC_PROFILE === 'arc-mainnet'`), requiring `--force`. +- Preserves `schema_versions` during demo reset and never touches external chain history. +- Mainnet profile remains structurally valueless and unpinned, requiring three distinct gates for future activation. + +## Files/components touched + +- `packages/storage-postgres/src/bootstrap.ts`: safe bootstrap and demo reset functions. +- `packages/storage-postgres/src/index.ts`: export bootstrap and reset functions. +- `packages/storage-postgres/test/bootstrap.test.ts`: unit tests for bootstrap and reset guardrails. +- `apps/worker/src/invariant-scenarios.ts`: implementation of 7 invariant scenarios and results formatter. +- `apps/worker/src/index.ts`: export invariant scenarios. +- `apps/worker/test/invariant-scenarios.test.ts`: unit tests verifying all 7 scenarios and at-most-one settlement invariant. +- `scripts/run-invariant-scenarios.mjs`: CLI runner for invariant scenarios. +- `scripts/bootstrap-db.mjs`: safe database bootstrap script. +- `scripts/reset-demo-db.mjs`: safe demo reset script. +- `package.json`: operational scripts `scenarios:invariants`, `db:bootstrap`, `db:reset-demo`. +- `docs/OPERATIONS_RUNBOOK.md`: comprehensive operations, observability, and rollback runbook. +- `docs/MAINNET_READINESS.md`: reviewer-facing mainnet-readiness artifact per `plan.md:357`. + +## Commands/checks + +- `pnpm build`: PASS +- `pnpm --filter @oneshot/storage-postgres test`: 7 tests PASS +- `pnpm --filter @oneshot/worker test`: 25 tests PASS +- `pnpm scenarios:invariants`: PASS (all 7 scenarios PASS, at most 1 settlement verified) +- `pnpm check:generated`: PASS (0 drift) +- `pnpm validate:fixtures`: PASS (16 fixtures valid) +- `pnpm format:check`: PASS +- `pnpm lint`: PASS +- `pnpm typecheck`: PASS +- `pnpm test`: PASS (49 test files, 605 tests) +- `npx markdownlint-cli2`: PASS + +## External-doc findings + +- Verified against `docs/settlement/SETTLEMENT_CONFIG_V1.md` and `packages/arc-adapter/src/profiles.ts`: Arc Mainnet is unpublished, has 0 guessed constants, and fails closed with `PROFILE_UNPUBLISHED`. +- Verified against `plan.md:357`: `MAINNET_READINESS.md` includes status line `DEPLOYMENT-READY`, pinned vs unpinned identities, deployment manifest, readiness probe evidence, rollback procedure, and human activation gate. + +## Unresolved questions + +- None. Milestone A06 scope is complete. + +## Git and PR state + +- Branch: `milestone/a06-release-operations` +- Base: `origin/develop` (`36df8fa06b94a0c08e32fa729a41ef2f22b464dd`) +- A06 commit before base refresh: `b36f53a3fad0b6fb40f413e91490efb6fbfc2069` +- PR: #35 (`https://github.com/SWOFART/OneShot/pull/35`) +- CI: previous head passed Cloudflare Workers build; new merged head pending push + +## Review gates + +- Gate A: `VERDICT: PASS` for tree + `6ce25ac3fe84fcfea32c4c68df2292e6410b5fdf`, then invalidated when `develop` + advanced and produced a PR merge conflict. Fresh review required for the + merged candidate tree. +- Gate B: first post-push review returned `VERDICT: FAIL` for head + `935a441dd5e441e8ddd699f10fd60a03365db99f`: `scripts/bootstrap-db.mjs` + ignored `BootstrapResult.success` and could report schema-digest drift as a + successful bootstrap. The CLI now fails closed; fresh Gate A/CI/Gate B are + required. + +## Handoff/next steps + +1. Run fresh Gate A review on the merged candidate tree. +2. Complete and push the merge commit to PR #35. +3. Wait for CI checks on the new head. +4. Run Gate B review via `free-pi-cli`. +5. Update PR body and mark ready for review. diff --git a/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md b/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md new file mode 100644 index 0000000..13f7af5 --- /dev/null +++ b/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md @@ -0,0 +1,177 @@ +# Session Context: B05 Frontend Authorization and Settlement Details + +## Date/time + +- UTC: 2026-09-08T18:08:39Z + +## User goal + +Deliver milestone B05: an independently composable Lane B frontend slice that +explains Privy policy, authorization state, settlement state, and verified Arc +transaction evidence from sanitized API fields, with no bypass action and no +secret exposure. + +## Original prompt/request + +"check if everything is ready to do b05 step" followed by "start" after the +readiness audit confirmed Gate P4 froze the frontend boundary. + +## Assumptions + +- Gate P4 frontend freeze (PR #34) satisfies the B05 start gate. `docs/GATE_P4_CHECKLIST.md` + step 4 is `[COMPLETED]` and names A05/B05/C05 as unblocked. +- The frozen mock server `@oneshot/contracts` `OPENAPI_MOCK_SERVER_VERSION = '1.0.0'` + and `packages/contracts/fixtures/ui/v1/` are the contract host for this slice. +- Published contract fixture digests are immutable, so states B05.3 requires that + the frozen pack does not carry (`READY`, `SUBMITTING`, on-chain revert, + unavailable evidence) are added as B-owned package-local fixtures built from the + frozen schema rather than by editing `packages/contracts`. +- `@oneshot/recovery-ui` (C05) is the composition precedent: a lane-owned package + exporting entry points, with no edit to the A-owned app shell. +- Live Arc testnet evidence is still `LIVE_NOT_RUN` and human-gated. B05 closes on + fixtures, matching how A05 and C05 closed. + +## Plan + +1. Create `packages/settlement-ui` (`@oneshot/settlement-ui`) mirroring the + `@oneshot/recovery-ui` package shape. +2. B05.1 policy summary from `IntentResponse.policy` sanitized fields. +3. B05.2 authorization states from `Attempt.authorization_status`. +4. B05.3 settlement states with `UNKNOWN` disabling any new settlement action and + no confirmation counts. +5. B05.4 verified transaction details with validated explorer URL. +6. B05.5 component/contract/route/redaction/accessibility tests plus package-local + lint, type, test, and build checks. +7. Publish README handoff artifact, run root checks, record evidence, Gate A, + draft PR, CI, Gate B. + +## Key decisions + +- Money renders through package-local `bigint` string arithmetic. No import from + the A-owned `apps/web`, and no JavaScript floating point. +- Explorer links are validated before the anchor renders: https only, no embedded + credentials, a host on the allowlist, and the rendered transaction hash present + in the link. A link that fails any rule is dropped, not rendered inert. The + default allowlist holds the documented Arc testnet explorer host; deployments + and fixture viewers pass their own list rather than widening it. +- The displayed attempt is chosen by latest `created_at`, because the contract + does not promise that `attempts` is ordered. +- The slice exposes no submit, resend, force-pay, or adapter action. Reconciliation + is a read-only trigger owned by Lane C's timeline, so B05 renders state only. +- Test files use `.ts` with `createElement` (the C05 convention) so the root + `vitest` include pattern runs them. + +## Files/components touched + +- `packages/settlement-ui/`: new Lane B frontend slice. +- `tsconfig.json`: add the new project reference slot. +- `pnpm-lock.yaml`: workspace lockfile for the new package. + +## Commands/checks + +- `git fetch origin develop` - PASS +- `git rev-parse origin/develop` - `710614af76ae5c28e2c1f69b2c00480f47b623b7` +- `git checkout -b milestone/b05-frontend-settlement-details origin/develop` - PASS +- `pnpm install` - PASS (adds the new workspace package) +- `pnpm --filter @oneshot/settlement-ui verify` - PASS (format, lint, typecheck, 186 tests, build) +- `pnpm lint` - PASS +- `pnpm typecheck` - PASS +- `pnpm test` - PASS (53 files, 791 tests; 49 files and 605 tests on the base) +- `pnpm check:generated` - PASS +- `pnpm validate:fixtures` - PASS (9 contracts-v1 and 7 ui-v1 fixtures) +- `npx markdownlint-cli2` on the added Markdown - PASS +- `pnpm format:check` - FAILS on `subgraph/generated/ArcTestnetUSDC/ERC20.ts` and + `subgraph/generated/schema.ts`. Pre-existing and unrelated: those files are + produced by subgraph codegen, ignored by `subgraph/.gitignore`, and absent from + `.prettierignore`, so the root check fails on any machine that has run codegen. + No file in this branch is affected; `prettier --check` over the changed paths + passes. Left for the owning lane rather than editing shared root config here. + +## External-doc findings + +- `docs/GATE_P4_MANIFEST.md`: OpenAPI digest + `f639e2d2729cd061d606cd35eb83961c58067a3660ecc5437c0f4596c88edc2c`, + mock server `1.0.0`, additive B05 fields `IntentResponse.policy`, + `Attempt.authorization_status`, `Settlement.token_contract`, + `Settlement.explorer_url`. +- `packages/contracts/openapi/openapi.v1.json`: `explorer_url` is a bounded string + with no scheme constraint, so URL validation is the consumer's responsibility. + +## Test matrix cases selected + +Read-only UI slice. Applicable cases from `.agent/TEST_MATRIX.md`: + +- Privy denial: denial and cap-exceeded fixtures render an explicit authorization + failure and expose zero settlement actions. +- Crash after submission / lost payment response: the `UNKNOWN` fixture renders as + non-terminal and offers no new settlement action. +- Graph delay, absence, or ambiguity: lagging and unavailable evidence render as + observation, never as proof of non-payment. + +Cross-cutting assertions covered here: monetary values formatted from integer +atomic units through `bigint`; fixtures contain no secret or wallet material. + +Backend-only cases (parallel worker storm, restart, two agents) are out of scope +for a presentational slice and remain proven by A03/A04 and Gate P4. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `milestone/b05-frontend-settlement-details` +- Base: `develop` (`710614af76ae5c28e2c1f69b2c00480f47b623b7`) +- Commit: `2fbc8bd9380eb18f1aeff192b84867fbfbc906c5` (28 files, +2913 insertions), + plus a second candidate for the Gate A non-blocking findings, staged and + captured with `git write-tree` immediately before the second Gate A +- All changes are additive except the `tsconfig.json` project reference slot and + the `pnpm-lock.yaml` workspace entry +- PR: (draft, base `develop`) +- CI on `2fbc8bd`: ESLint and TypeScript PASS, Markdown and Mermaid PASS, + Workers Builds PASS, repository-policy PASS + +## Review gates + +- Gate A (round 1): PASS. Tool `free-pi-cli`, model `deepseek-v4-flash` + (provider: free-pi), base `710614af76ae5c28e2c1f69b2c00480f47b623b7`, tree + `fe84cc22695d62f19be8ff83b6da9afd03fb3927`. No blocking findings; three + non-blocking findings, all now addressed: + 1. Explorer host was not pinned. `validateExplorerUrl` now takes an allowlist + defaulting to `DEFAULT_EXPLORER_HOSTS` + (`testnet.arcscan.app`, the host documented in + `docs/settlement/PROVIDER_SETUP.md`), and rejects every other host. + `SettlementDetailsRoute` accepts `allowedExplorerHosts` for deployments + with a different explorer. The synthetic fixtures use + `testnet.arcscan.io`, so `FIXTURE_EXPLORER_HOSTS` makes the demo viewer and + component tests opt into that host rather than widening the default. + 2. Line count corrected: the first commit is 28 files and +2913 insertions. + 3. `attempts.at(-1)` replaced with selection by latest `created_at`, keeping + the later element on a tie and never letting an undated attempt displace a + dated one. +- Gate A (round 2): NOT RUN for the new candidate tree. +- Gate B: NOT RUN + +## Gate A instruction message + +Run `npx free-pi-cli` from the repository root in a fresh process and send one +message: + +> Read `.agent/review-prompts/freepi-prepush-review.md` and follow it. +> Base: `710614af76ae5c28e2c1f69b2c00480f47b623b7` (origin/develop). +> Candidate tree: the SHA printed by `git write-tree`, staged index. The index +> contains pushed commit `2fbc8bd9380eb18f1aeff192b84867fbfbc906c5` plus the +> round-two fixes, so review the whole branch diff against the base. +> Branch: `milestone/b05-frontend-settlement-details`. +> Acceptance criteria: milestone B05 in +> `milestones/coder-b/B05-frontend-settlement-details.md`. + +## Handoff/next steps + +1. Capture `git write-tree`, run FreePi Gate A against that tree, and require an + explicit `VERDICT: PASS`. +2. Commit the staged tree unchanged, confirm `git rev-parse "HEAD^{tree}"` equals + the reviewed tree, and push the branch. +3. Open a draft PR targeting `develop` with the Gate A evidence, wait for every + required check on the exact head SHA, then run FreePi Gate B. +4. Mark ready for human review. Never merge. diff --git a/.agent/context/20260908T193000Z-repository-health-docs.md b/.agent/context/20260908T193000Z-repository-health-docs.md new file mode 100644 index 0000000..650c65c --- /dev/null +++ b/.agent/context/20260908T193000Z-repository-health-docs.md @@ -0,0 +1,68 @@ +# Session Context: repository-health-docs + +## Date/time + +- UTC: 2026-09-08T19:30:00Z + +## User goal + +Repair repository health failures, then make the published project status internally consistent. + +## Original prompt/request + +Create a branch; first resolve repository health and commit it, then resolve contradictory documentation and commit it; provide a Gate A FreePi review prompt. + +## Assumptions + +- Generated artifacts and Prettier output should be committed when they are produced from the current committed sources. +- Live Arc/Privy evidence in `evidence/c06/sanitized-proof.json` and `docs/settlement/LIVE_EVIDENCE.md` is the current source of truth for those integrations. + +## Plan + +1. Regenerate contract artifacts, format the workspace, and validate the full local non-database suite. +2. Commit only repository-health output. +3. Update stale contradictory status documentation, validate it, and commit separately. +4. Capture immutable Gate A candidate identities and obtain a fresh review before any push. + +## Key decisions + +- Keep The Graph as `NOT VERIFIED`; no live MCP/model trace exists. +- Do not alter implementation behavior or live provider configuration. + +## Files/components touched + +- `.prettierrc.json`: pin LF output so generated-contract and formatting checks are platform-stable. +- `.gitattributes`: enforce LF checkout for detected text files independently of local Git settings. +- `README.md`, `docs/settlement/GATE_P4_LANE_B_READINESS.md`, and `packages/reconciliation/docs/c06/README.md`: align sponsor and live-evidence status with the checked-in proof. + +## Commands/checks + +- Initial scan: generated-contract check failed; format check found 177 files; lint and typecheck passed; unit suite had 604 passing and one generated-artifact failure. +- `pnpm.cmd check:generated`, `pnpm.cmd format:check`, `pnpm.cmd lint`, `pnpm.cmd typecheck`, and `pnpm.cmd test` - passed after the formatter configuration repair (605 tests). +- `npx.cmd --yes markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"` - passed (108 files, 0 errors). + +## External-doc findings + +- None; this work reconciles repository-owned evidence only. + +## Unresolved questions + +- PostgreSQL integration suite requires its configured test database and will be reported separately if unavailable locally. + +## Git and PR state + +- Branch: `fix/repository-health-and-docs` +- Base: `develop` at `7f4ad079fd4b3d45b2a6d36c9c00003751f51e7d` +- Commit: `ea972949d641522032a4cf0c83efbe5b178d7965` (health) and `0c027c67584232f11601dfac52d5657dd1342c72` (documentation); line-ending checkout fix pending +- PR: not created +- CI: not run + +## Review gates + +- Gate A: PASS for tree `8577f5bb777b33b065c8fc797a91fed1bb127788` (FreePi / glm-5.3-flash); invalidated by the pending `.gitattributes` change +- Gate B: NOT RUN + +## Handoff/next steps + +1. Validate and commit the `.gitattributes` checkout fix. +2. Capture a new candidate tree and obtain a fresh Gate A review before push. diff --git a/.agent/context/20260908T201500Z-plan-missing-parts.md b/.agent/context/20260908T201500Z-plan-missing-parts.md new file mode 100644 index 0000000..979e8f4 --- /dev/null +++ b/.agent/context/20260908T201500Z-plan-missing-parts.md @@ -0,0 +1,69 @@ +# Session Context: plan-missing-parts + +## Date/time + +- UTC: 2026-09-08T20:15:00Z + +## User goal + +Audit the approved plan and publish a concise report of unimplemented work, +dependencies, blockers, and immediate priorities. + +## Original prompt/request + +Switch to `docs/missing-plan-implementation`, audit `plan.md`, and create +`plan_missing_parts.md` with missing work grouped by Not Started and In +Progress, dependencies/blockers, and immediate priorities. + +## Assumptions + +- A packet with offline implementation and tests is not called missing solely + because a later live project gate remains open. +- Checked-in evidence and explicit fail-closed status documents are the source + of truth for delivery state. + +## Plan + +1. Compare plan gates and deliverables to current code, tests, and evidence. +2. Create the report without modifying `plan.md` or product behavior. +3. Run Markdown lint and commit the focused documentation change. + +## Key decisions + +- Classify live The Graph recovery, P4/P5/P6 completion as In Progress because + their offline foundations exist but their required integrated evidence does not. +- Classify Arc Mainnet activation and submission media/text as Not Started. + +## Files/components touched + +- `plan_missing_parts.md`: delivery-gap audit. + +## Commands/checks + +- Repository and plan/evidence audit in progress. + +## External-doc findings + +- None; the report relies on repository-owned plan and evidence. + +## Unresolved questions + +- Exact owner and timeline for Graph deployment, MCP/Gateway access, and model + configuration require human coordination. + +## Git and PR state + +- Branch: `docs/missing-plan-implementation` +- Base: `develop` at `0291b684e187557e13c47869359cbab445ee4148` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Markdown-lint and review the report, then commit the documentation-only audit. diff --git a/.agent/context/20260908T205457Z-b06-sponsor-evidence.md b/.agent/context/20260908T205457Z-b06-sponsor-evidence.md new file mode 100644 index 0000000..cd9ba66 --- /dev/null +++ b/.agent/context/20260908T205457Z-b06-sponsor-evidence.md @@ -0,0 +1,204 @@ +# Session Context: B06 Privy and Arc Sponsor Evidence + +## Date/time + +- UTC: 2026-09-08T20:54:57Z + +## User goal + +Deliver milestone B06: a sanitized, repeatable evidence bundle proving Privy is +the real authorization boundary and Arc Testnet the working USDC settlement +rail, plus mainnet-readiness evidence that claims no mainnet transaction. + +## Original prompt/request + +"ok go on with b06", immediately after B05 passed Gate A, CI, and Gate B on +pull request #36. + +## Assumptions + +- B06 depends on B05, which is reviewed and green but not merged. Its content is + evidence scripts and documentation over the B01-B04 adapters, so it needs no + code from the B05 UI slice. The branch is cut from current `develop` and + touches no path B05 touches. +- The live Arc Testnet run has already happened. `docs/settlement/LIVE_EVIDENCE.md` + on `develop` reads `LIVE_RUN` and `evidence/c06/sanitized-proof.json` records + the settlement, both policy denials, and the recovery drill. B06 re-verifies + that recorded evidence rather than executing a new live payment. +- Identifiers already committed to `develop` (Privy app, wallet, and policy ids, + execution wallet, recipient, transaction hash) are treated as sanitized public + testnet values. B06 adds no new provider identifiers and no secrets. +- The Graph verdict stays with Lane C. B06 supplies only Privy and Arc inputs to + `sponsor-qualification`, per the milestone non-goals. + +## Plan + +1. Build a typed evidence engine in `packages/testkit-settlement` that validates + a sanitized proof bundle and fails closed on tampering. +2. B06.1 Privy evidence: policy scope enforced on the normal path, both denial + dimensions present, zero broadcasts and zero settlements. +3. B06.2 Arc evidence: bind request identity to transaction hash, receipt, + exact Transfer log, token, recipient, amount, and explorer URL. +4. B06.3 Ambiguity: lost response reaches `UNKNOWN`, reconciles to the original + transaction, and replays without a second settlement. +5. B06.4 Mainnet readiness: profile disabled and valueless, preflight and + rollback artifacts present, human-approval gate recorded. +6. B06.5 Sanitization audit over the published bundle. +7. B06.6 Qualification input with explicit `NOT VERIFIED` where live proof is + absent. +8. Publish the handoff artifact, run local checks, Gate A, PR, CI, Gate B. + +## Finding: redaction contract vs public evidence fields + +Running the adapter redaction contract over the evidence bundle surfaced two +false positives, both on fields the bundle cannot drop: + +- `token_contract` matches the `token` key-name pattern but is a public ERC-20 + address. +- `explorer_url` embeds the 32-byte transaction hash, which the value rule flags + outside its hash-bearing field list. + +`packages/arc-adapter/src/redaction.ts` was left untouched rather than widened +for every caller. B06 keeps a narrow, shape-checked allowlist for exactly these +two names, so the audit still rejects a credential arriving under any other key. + +## Key decisions + +- B06 verifies recorded evidence; it does not execute a new live settlement. A + second live payment would spend testnet funds to prove nothing the recorded + run does not already prove, and executing payments is not an agent action. +- Every check fails closed. A missing field, an unbound identity, a nonzero + broadcast count on a denial, or an enabled mainnet profile is a failure, never + a warning. +- The evidence engine is library code with tests rather than a shell script, so + tampered-bundle cases can be asserted directly. + +## Files/components touched + +- `packages/testkit-settlement/`: evidence engine, CLI entry point, tests. +- `evidence/b06/`: sanitized evidence index. +- `docs/settlement/`: B06 handoff artifact. + +## Commands/checks + +- `git checkout -b milestone/b06-sponsor-evidence origin/develop` - PASS +- `git rev-parse origin/develop` - `0291b684e187557e13c47869359cbab445ee4148` +- `pnpm --filter @oneshot/testkit-settlement build` - PASS +- `pnpm --filter @oneshot/testkit-settlement lint` - PASS +- `pnpm --filter @oneshot/testkit-settlement test` - PASS (132 tests, 76 new) +- `pnpm --filter @oneshot/testkit-settlement evidence:b06` - PASS (all five + sections; Privy QUALIFIED, Arc QUALIFIED, The Graph NOT VERIFIED) +- `pnpm lint` - PASS +- `pnpm typecheck` - PASS +- `pnpm test` - PASS (50 files, 649 tests; 47 files and 605 tests on the base) +- `pnpm check:generated` - PASS +- `pnpm validate:fixtures` - PASS +- `npx markdownlint-cli2` on the added Markdown - PASS +- `npx prettier --check` over the changed paths - PASS + +## External-doc findings + +- `docs/settlement/LIVE_EVIDENCE.md` (`develop`): status `LIVE_RUN`, transaction + `0x72ab1e93c95e5295b2dfa9b3abc8cc5130330f3bba07ad18af2c5b7784f57cf7`, block + `61116056`, transfer log index `23`, explorer host `testnet.arcscan.app`. +- `.agent/SPONSOR_REQUIREMENTS.md`: Privy must constrain the normal path, Arc + needs a real testnet settlement, and the Launch track needs a disabled + mainnet profile with rollback artifacts. +- `.agents/skills/sponsor-qualification/SKILL.md`: report `QUALIFIED`, + `NOT QUALIFIED`, or `NOT VERIFIED` per sponsor; never promote fixtures. + +## Test matrix cases selected + +From `.agent/TEST_MATRIX.md`: + +- Privy denial: both recorded denial dimensions must show zero broadcasts and + zero settlements, and a tampered nonzero count must fail the bundle. +- Lost payment response: the recorded drill must show `UNKNOWN` reconciling to + the original transaction with zero replacement submissions. +- Same request twice: the replay record must still bind exactly one settlement. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `milestone/b06-sponsor-evidence` +- Base: `develop` (`0291b684e187557e13c47869359cbab445ee4148`) +- Commit: uncommitted +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A (round 1): FAIL on tree `83d9e8091f8fe093bfdac83681187560ba1504b9`. + Tool `free-pi-cli`, model not exposed by platform. One blocking finding, now + fixed: the sanitization walk recursed into arrays but returned early for + scalar elements, so a PEM key or JWT nested in an array passed the audit while + the same value under a scalar key failed. Verified fixed: array-nested PEM, + JWT, and bearer values now all fail, and a clean bundle still passes. + Three non-blocking findings were also fixed rather than carried: + 1. An allowlisted URL skipped value scanning; the URL is now scanned with the + transaction hash stripped, so a JWT in a query string fails. + 2. `receipt: null` crashed the CLI with a TypeError; every field the verifier + reads is now validated at parse time, so a malformed receipt is a + structured parse failure. + 3. `privy.cap-exceeded` and `privy.denied-recipient-differs` were skipped when + a drill omitted its optional fields; those fields are now required for + their dimension, so a tampered record cannot pass unexamined. + The fourth non-blocking finding was addressed by renaming the check: with no + raw receipt in the bundle, `arc.transfer-identity-recorded` states what it + actually verified instead of claiming receipt re-verification. +- Gate A (round 2): PASS on tree `1f9f5ed88d25ae9cc18c7ad2ed4f809f72247cbc`. + Tool `free-pi-cli`, model not exposed by platform. No blocking findings. Three + non-blocking findings, all fixed rather than carried: + 1. Receipt validation stopped at "non-null object", so a receipt missing + `from` or `logs` threw a TypeError from inside the adapter instead of a + listed failure. `parseReceipt` now checks every field the verifier reads, + and the round-one context claim above was corrected to match. + 2. `checkMainnetReadiness` read the compile-time profile, so the enabled and + carries-values directions were unassertable. The profile is now injected + and four tampered-profile tests cover those directions. + 3. `evidence:b06` ran the compiled output without building, so a stale `dist` + gave an outdated answer. The script now builds first. + The reviewer's residual risk about the allowlist accepting any short public + value under `token_contract` is also closed: that field now requires an EVM + address shape. +- Gate A (round 3): PASS on tree `e9f89504866075b50201814f4a5da0d494c29028`, + committed as `310daf16703957650e6422420fddedc3b627ab59` and pushed. Tool + `free-pi-cli`, model `deepseek-v4-flash`. No blocking findings; three + non-blocking carried at the time. +- CI on `310daf16`: ESLint and TypeScript PASS, Markdown and Mermaid PASS, + Workers Builds PASS, repository-policy PASS. +- Gate B (round 1): PASS on head `310daf16703957650e6422420fddedc3b627ab59`, + head tree equal to the Gate A tree. Tool `free-pi-cli`, model + `deepseek-v4-flash`. No blocking findings; three non-blocking, now all closed: + 1. `parseReceipt` validated receipt-level fields but not log entries. Every + log is now checked for `address`, `data`, `logIndex`, and a string + `topics` array, so a malformed log is a listed failure rather than a + TypeError from inside the adapter. + 2. The permissive `isPublicIdentifier` guard would have accepted a short + JWT-shaped string under `token_symbol` or `explorer_host`. It is deleted; + every allowlisted field now has a specific guard (EVM address, asset + symbol, decimal count, hostname, https URL). + 3. The `OFFLINE_PROTECTED` versus `UNPUBLISHED` terminology drift in + `docs/settlement/LIVE_EVIDENCE.md` is fixed on its own branch, since it is + a separate concern from this milestone. +- Gate A (round 4): PASS on tree `b1ce189b054c95e84bbb50ce8b1b1eab403bff54`. + No blocking findings. Two non-blocking; the first is fixed: + 1. `ambiguity.replay-idempotent` matched `REPLAY` as a substring, so a + tampered `NOT_REPLAYED` would have passed a check asserting the opposite. + It is now an exact match against `REPLAYED` or `RETURNED_EXISTING_RESULT`, + with tests for the tampered spellings. + 2. `isAssetSymbol` still admits up to twelve alphanumeric characters. No + credential shape fits that (a JWT is longer and contains dots), so this is + noted rather than tightened further. +- Gate A (round 5): NOT RUN for the tree that closes the round-four finding. +- Gate B (round 2): NOT RUN + +## Handoff/next steps + +1. Implement B06.1 through B06.6. +2. Run package-local and root checks. +3. Capture `git write-tree`, run Gate A, commit, push, open a PR, wait for CI, + run Gate B. diff --git a/.agent/context/20260908T215431Z-lane-b-review-followups.md b/.agent/context/20260908T215431Z-lane-b-review-followups.md new file mode 100644 index 0000000..0a32240 --- /dev/null +++ b/.agent/context/20260908T215431Z-lane-b-review-followups.md @@ -0,0 +1,172 @@ +# Session Context: Lane B Review Follow-ups + +## Date/time + +- UTC: 2026-09-08T21:54:31Z + +## User goal + +Close the review findings that were carried rather than fixed during B05 and +B06: the invented mainnet state name in the live-evidence document, and the +three non-blocking findings Gate B raised against the settlement UI slice. + +## Original prompt/request + +"do all neede changes", after B06 reached Gate B and the remaining items were +listed. + +## Assumptions + +- These are B-owned paths. `docs/settlement/` is Lane B's documentation area and + `packages/settlement-ui` is the Lane B slice merged from pull request #36. +- The B06 findings are handled on the B06 branch (pull request #41) rather than + here, because they belong to that milestone's own review cycle. +- The palette already meets WCAG AA; the contrast work is to prove it and catch + future drift, not to restyle the slice. + +## Plan + +1. Replace `OFFLINE_PROTECTED` in the live-evidence limitations with the values + the profile actually carries. +2. Add credential-shaped value detection to the settlement UI sanitization + guard, so a secret under a benign field name is refused. +3. Reject control characters in the identity fields that render verbatim. +4. Add a static WCAG contrast audit of the palette tokens. +5. Run local checks, Gate A, PR, CI, Gate B. + +## Key decisions + +- `OFFLINE_PROTECTED` is not a state the code has. `packages/arc-adapter/src/profiles.ts` + carries `enabled: false` and `verification: 'UNPUBLISHED'`, and the profile + holds no chain ID, RPC, explorer, or token value. The document now says that, + because a sponsor reading it should see the same words the code uses. The + neighbouring `FALLBACK_DIRECT_RECOVERY` is a real Lane C state and is left + alone. +- Value-shape detection is deliberately narrow: PEM private keys, JWTs, and + bearer tokens. Ordinary evidence — transaction hashes, addresses, digests — + must keep rendering, so shape rules that would catch them are not used here. +- The contrast audit parses the stylesheet rather than restating colours, so a + palette edit that drops a token below AA fails the test. + +## Files/components touched + +- `docs/settlement/LIVE_EVIDENCE.md`: mainnet profile terminology. +- `packages/settlement-ui/src/contract.ts`: value-shape rejection and the + control-character guard for verbatim identity fields. +- `packages/settlement-ui/test/contract.test.ts`: regression tests for both. +- `packages/settlement-ui/test/contrast.test.ts`: static WCAG contrast audit. +- `packages/settlement-ui/README.md`: documents both guards and the audit. + +## Commands/checks + +- `git checkout -b fix/lane-b-review-followups origin/develop` - PASS +- `git rev-parse origin/develop` - `83e082bc5e872c1e95088dd3813eb7475ce68e6d` +- `pnpm --filter @oneshot/settlement-ui test` - PASS (205 tests, 19 new) +- `pnpm lint`, `pnpm typecheck`, `pnpm test` - PASS (55 files, 889 tests) +- After the rebase onto `48391e49`: `pnpm install --frozen-lockfile`, `pnpm lint`, + `pnpm typecheck` - PASS; `pnpm test` - PASS (55 files, 886 tests); the B06 + evidence CLI still reports Overall PASS + +## External-doc findings + +- `packages/arc-adapter/src/profiles.ts`: the only mainnet states are + `verification: 'UNPUBLISHED'` with `enabled: false`; `OFFLINE_PROTECTED` + appears nowhere in the codebase. +- Gate B on pull request #36 recorded the three settlement-UI findings this + branch closes. + +## Test matrix cases selected + +Presentational slice and documentation. The applicable cross-cutting assertion +is that logs and fixtures contain no secret material: the new tests assert that +a credential-shaped value is refused under any field name, including inside an +array, and that ordinary settlement evidence still renders. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `fix/lane-b-review-followups` +- Base: `develop` (`48391e4968675764632627716e580988a271c13d`), rebased from + `83e082bc5e872c1e95088dd3813eb7475ce68e6d` after pull request #41 merged +- Commit: unpushed at the time of writing. The exact commit and tree are + captured with `git rev-parse` immediately before each Gate A and recorded in + the pull request body, so amending this file cannot invalidate them. +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A (round 1): PASS on tree `2dcc24239a6a5050e19591aafc979d7fbf6aa1ef`. + Tool `free-pi-cli`, model `deepseek-v4-flash`. No blocking findings. Two of + three non-blocking findings are fixed: + 1. The contrast test hardcoded the page surface and the fixture-banner colours + it audits, so a future change lightening either would have kept passing + against stale literals. Both are now read from the stylesheet. + 2. `attempt_id`, `provider_reference_id`, `transaction_hash`, and + `block_number` also render verbatim and were outside the control-character + guard. The seam bounds them already, so this is uniformity rather than an + exposure, but one rule now covers every field that reaches the DOM + unescaped. + The third is an accepted trade-off: value-shape detection stays narrow to + PEM, JWT, and bearer shapes so ordinary evidence keeps rendering. +- Gate A (round 2): PASS on tree `bc0d221ea81383e7525559ee8b75a77cc7205a29`. + No blocking findings. Two non-blocking, both fixed: + 1. The guard comment claimed one rule covered every field reaching the DOM + unescaped, while `attempts[].created_at`, `evidence[].retrieved_at`, and + `evidence[].digest` still bypassed it. All three are covered now, so the + comment matches the code. + 2. The contrast audit checked the token colours and two surfaces but not the + literal surfaces `.panel-note` and `.demo-scenarios select`, so an + under-AA literal could have shipped outside the audited set. Both are + audited now. +- Gate A (round 3): PASS on tree `5a05e775ed66a7eb5a7d7b0420caa99cd1b77827`. + No blocking findings. One non-blocking, now fixed: `evidence[].source`, + `evidence[].authority_class`, and `evidence[].freshness` render raw and were + outside the guard while `state`, also an enum, was inside it, so the comment + claiming one rule for every unescaped field was still wider than the code. + All three are covered now. The other note was cosmetic quoting in the + live-evidence document and needed no change. +- Gate A (round 4): PASS on tree `0e0573545e817a0d6062bafbda3660380c8d2322`. + No blocking findings. Three non-blocking: + 1. The branch base was stale; `develop` had moved past it, including the + merge of pull request #41. The branch is now rebased onto + `48391e4968675764632627716e580988a271c13d` and every check re-run there, + so Gate B reviews a current tree. + 2. `payload_fingerprint` is inside the guard but rendered nowhere in the + slice. Defensive over-coverage in the fail-closed direction; left as is. + 3. Authorization and policy status strings reach only a data attribute and + constant label maps, so they never render as text. No action. +- Gate A (round 5): PASS on the rebased tree + `e863a08b0b15e02dee92e7ab0dc44165511bb780`. No blocking findings. Two + non-blocking, both fixed: + 1. This record's identity block still named the pre-amend commit and tree. + Because amending to correct it changes the tree again, the block now + points at `git rev-parse` and the pull request body instead of restating + SHAs that go stale on every amend. + 2. `assertNoControlCharacters` would have thrown a raw TypeError on a payload + missing `attempts` or `evidence`. It now tolerates the missing arrays, and + round six closed the other half of that path. +- Gate A (round 6): PASS on tree `33c2cdd8a9712de66a0b8262e77a8d755f19e868`. + No blocking findings. One non-blocking, now fixed: the guard tolerated a + payload missing `attempts` or `evidence`, but the projection still threw a + raw TypeError on it, so the claim that failures stayed structured was wider + than the code. `assertRequiredCollections` now rejects that payload as a + `SanitizationError`, which is the failure the route renders deliberately. +- Gate A (round 7): NOT RUN for the tree that closes that finding. +- Gate B: NOT RUN + +## Note on pull request #41 + +B06 was merged by a human at 2026-09-08T22:08:23Z on head +`8ea308b525e7a711d16a226c8bcb1c20c57063c8`, which carried Gate A round-five +PASS and green required CI. Gate B had been run against the earlier head +`310daf16703957650e6422420fddedc3b627ab59`, not that final head. Recorded here +so the gate history stays accurate. + +## Handoff/next steps + +1. Run root checks, capture `git write-tree`, run Gate A. +2. Commit, push, open a PR, wait for CI, run Gate B. diff --git a/.agent/context/20260908T221341Z-p4-verification-status.md b/.agent/context/20260908T221341Z-p4-verification-status.md new file mode 100644 index 0000000..eddabc7 --- /dev/null +++ b/.agent/context/20260908T221341Z-p4-verification-status.md @@ -0,0 +1,83 @@ +# Session Context: P4 Verification Status + +## Date/time + +- UTC: 2026-09-08T22:13:41Z + +## User goal + +Fix unresolved issues from the previous development plan before starting future +work, with external provider credentials available through approved secret +stores. + +## Original prompt/request + +"let's fix current issues that we have from previous plans, after contining +future work. all of api's i have in google cloud, privy, the graph and etc." + +## Assumptions + +- Repair the current plan/evidence inconsistency before enabling new live + integrations. +- Provider credentials remain outside Git and outside review evidence. +- Testnet remains the only authorized settlement network. + +## Plan + +1. Correct the Gate P4 manifest and checklist so scoped live evidence cannot be + mistaken for an overall gate pass. +2. Validate the documentation change. +3. Complete the required review loop before starting the separate live + Graph/MCP/model integration packet. + +## Key decisions + +- Preserve the verified Privy/Arc evidence while marking the missing live + Graph MCP/model proof `NOT_VERIFIED`. +- Keep live adapter implementation separate from this status repair so each + candidate tree has one auditable purpose. + +## Files/components touched + +- `docs/GATE_P4_MANIFEST.md`: scoped statuses and missing live-proof criteria. +- `docs/GATE_P4_CHECKLIST.md`: explicit incomplete live hashless-recovery step. +- This context record. + +## Commands/checks + +- `git fetch origin develop` - base refreshed to + `48391e4968675764632627716e580988a271c13d`. +- `git diff --check` - passed. +- `npx markdownlint-cli2 docs/GATE_P4_MANIFEST.md docs/GATE_P4_CHECKLIST.md + .agent/context/20260908T221341Z-p4-verification-status.md` - passed with + zero issues. + +## External-doc findings + +- None required for this status-only correction; checked-in evidence and + canonical repository policy are authoritative. + +## Unresolved questions + +- The live Graph deployment identity, MCP endpoint, and model configuration + must be supplied through the approved runtime secret/configuration path for + the next packet; no secret values belong in this record. + +## Git and PR state + +- Branch: `fix/p4-verification-status` +- Base: `origin/develop` at `48391e4968675764632627716e580988a271c13d` +- Commit: uncommitted +- PR: not created +- CI: not started + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Run Markdown and repository validation. +2. Stage the exact documentation tree and run Gate A. +3. Commit, push, open a draft PR, await CI, and run Gate B. diff --git a/.agent/context/20260908T224457Z-production-recovery-config.md b/.agent/context/20260908T224457Z-production-recovery-config.md new file mode 100644 index 0000000..b40c918 --- /dev/null +++ b/.agent/context/20260908T224457Z-production-recovery-config.md @@ -0,0 +1,81 @@ +# Session Context: production recovery configuration + +## Date/time + +- UTC: 2026-09-08T22:44:57Z + +## User goal + +Fix blockers left by earlier plans before continuing future work, using provider access already held in Google Cloud, Privy, and The Graph. + +## Original prompt/request + +"let's fix current issues that we have from previous plans, after contining future work. all of api's i have in google cloud, privy, the graph and etc." + +## Assumptions + +- Credentials and API secrets remain outside Git and review prompts. +- The current packet fixes the production recovery configuration boundary before any live MCP/model adapter or evidence claim. +- A single configured sender and bounded block window are sufficient for the current live-value gate; per-intent window derivation is future work if multi-intent production recovery needs it. + +## Plan + +1. Remove placeholder Graph identity and unbounded recovery correlation values. +2. Require explicit production recovery lookup configuration and reject invalid input before MCP lookup. +3. Run repository checks and mandatory FreePi review gates, then open a draft PR. + +## Key decisions + +- Reused reconciliation's existing validation predicates through one exported boolean; no duplicate validator and no new dependency. +- Kept Subgraph MCP and advisor unavailable by default. Live admission still requires the recorded C01 promotion evidence. +- Used an options object for production recovery composition so provider ports and lookup configuration cannot be positionally confused. + +## Files/components touched + +- `packages/reconciliation/src/validation.ts`: reusable lookup-input validation. +- `packages/reconciliation/src/index.ts`: public validation export. +- `apps/worker/src/recovery-bridge.ts`: explicit real lookup configuration; placeholder removal; fail-closed validation. +- `apps/worker/src/composition.ts`: required production recovery options. +- `apps/worker/test/p4-composition.test.ts`: valid identities, propagation, and placeholder rejection coverage. + +## Commands/checks + +- `pnpm --filter @oneshot/reconciliation typecheck` - PASS; local Node 22 warning against pinned Node 24.19.0. +- `pnpm --filter @oneshot/worker typecheck` - PASS; same engine warning. +- `pnpm --filter @oneshot/worker test -- --run test/p4-composition.test.ts` - PASS, 7 tests after fixture correction. +- `pnpm --filter @oneshot/reconciliation test` - PASS, 74 tests. +- `pnpm lint` - PASS. +- `pnpm typecheck` - PASS. +- `pnpm test` - PASS, 54 files and 868 tests; includes full build. +- `pnpm format:check` - FAIL only on two pre-existing generated subgraph files; all five touched TypeScript files pass targeted Prettier check. +- `git diff --check` - PASS. + +## External-doc findings + +- The Graph official `graphops/subgraph-mcp` documentation confirms immutable queries use `execute_query_by_deployment_id` with deployment ID, query, and variables. +- MCP 2025-11-25 schema confirms `tools/call` is JSON-RPC 2.0 with a tool name and arguments. +- Google Cloud Vertex AI documentation confirms REST `generateContent` bearer authentication and JSON structured output support. No adapter is admitted in this packet. + +## Unresolved questions + +- Canonical live deployment ID, manifest CID, MCP endpoint/version, sender, and bounded Arc block window still need retrieval from operator-controlled systems. +- Live Subgraph MCP and Vertex AI model traces remain required before `SELECT_SUBGRAPH_MCP` or sponsor qualification. + +## Git and PR state + +- Branch: `fix/production-recovery-config` +- Base: `origin/develop` at `48391e4968675764632627716e580988a271c13d` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Finish local checks and Gate A. +2. Commit, push, open draft PR, wait for exact-head CI, and run Gate B. +3. After human merge, retrieve non-secret live identities and implement/admit the minimal MCP/model adapters only with live evidence. diff --git a/.agent/context/20260908T231211Z-update-plan-subgraph.md b/.agent/context/20260908T231211Z-update-plan-subgraph.md new file mode 100644 index 0000000..7c294fe --- /dev/null +++ b/.agent/context/20260908T231211Z-update-plan-subgraph.md @@ -0,0 +1,104 @@ +# Session Context: update plan and Subgraph status + +## Date/time + +- UTC: 2026-09-08T23:12:11Z + +## User goal + +Review the open Pull Requests, evaluate their effect on `plan.md` and +`plan_missing_parts.md`, reconcile the deployed OneShot Subgraph status on The +Graph Explorer with the available query evidence, then commit the updated plan +on `docs/update-plan-subgraph`. + +## Original prompt/request + +Review open Pull Requests and evaluate how they impact `plan.md` and +`plan_missing_parts.md`; investigate the deployed Subgraph on The Graph +Explorer; resolve the UI/query-status discrepancy; create branch +`docs/update-plan-subgraph`; and commit an updated `plan.md`. + +## Assumptions + +- The user-supplied Explorer ID `69FEby7GetXpJVWJShPL6XjMsWWDowLuqf6cE5MvTHdy` + is the public documentation target. +- The Graph deployment and manifest identifiers are public metadata; secrets, + API keys, and credentials remain outside Git and context records. +- Open PRs are progress signals only until a human merges them into `develop`. + +## Plan + +1. Inspect policy, current plans, GitHub open PRs, and Graph Explorer evidence. +2. Create the required branch from the current `origin/develop`. +3. Update the canonical plan and align the missing-parts audit with the verified + published-but-unallocated Subgraph status. +4. Run documentation/repository checks, capture Gate A, commit, and report the + exact tree and commit. Do not push or create a PR unless requested. + +## Key decisions + +- Treat the Graph deployment as published and immutable but not network-indexed: + Explorer shows `SUBGRAPH NOT INDEXED`, no indexers, and no allocations. +- Treat Studio/development query success as distinct from decentralized Gateway + availability; it cannot upgrade The Graph to `LIVE_VERIFIED`. +- Pin runtime identity to the immutable deployment CID, while documenting the + user-supplied public Subgraph ID and the duplicate public registration that + points to the same deployment. + +## Files/components touched + +- `plan.md`: current PR progress, Graph deployment identity, discrepancy + explanation, and status-gated recovery wording. +- `plan_missing_parts.md`: split deployment identification from the still-open + allocation/indexing, MCP/model, and live-proof work. +- This context record. + +## Commands/checks + +- `git fetch origin develop` - PASS; `origin/develop` is + `48391e4968675764632627716e580988a271c13d`. +- `gh pr list` / `gh pr view` - three open, mergeable PRs (#42, #43, #44), + required checks successful. +- Graph Explorer UI inspection - published IDs and deployment metadata visible; + status is `NOT INDEXED` / `SUBGRAPH NOT INDEXED` and query result says + `subgraph not found: no allocations`. +- Graph manifest fetch - PASS; deployment CID resolves to the published Arc + Testnet manifest. +- Remaining checks and commit are pending. + +## External-doc findings + +- The Graph Studio documentation states that Studio deployment is for testing + and is separate from publishing to the decentralized network. +- The Graph querying documentation distinguishes the Studio development endpoint + from the production Gateway endpoint. +- The Graph publishing documentation states that publishing makes a Subgraph + available for Indexers; Explorer evidence shows no Indexer allocation yet. + +## Unresolved questions + +- The operator must choose whether to retain or clean up the duplicate public + Subgraph registration; both observed registrations point at the same + deployment. This does not alter the immutable deployment identity. +- Live Subgraph MCP/model trace and Arc candidate-verification evidence remain + absent. + +## Git and PR state + +- Branch: `docs/update-plan-subgraph` +- Base: `origin/develop` at `48391e4968675764632627716e580988a271c13d` +- Commit: uncommitted +- PR: not created +- CI: not run for this branch + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT APPLICABLE; no PR requested or created + +## Handoff/next steps + +1. Finish the focused documentation edits and validation. +2. Stage only the plan, missing-parts audit, and context record; run Gate A. +3. Commit the reviewed tree and report the branch, commit, and remaining live + Graph/MCP/model gap. diff --git a/.agent/context/20260908T232500Z-vertex-ai-recovery-advisor.md b/.agent/context/20260908T232500Z-vertex-ai-recovery-advisor.md new file mode 100644 index 0000000..08dcbc9 --- /dev/null +++ b/.agent/context/20260908T232500Z-vertex-ai-recovery-advisor.md @@ -0,0 +1,56 @@ +# Session Context: Vertex AI Recovery Advisor and Subgraph MCP Client + +## Date/time + +- UTC: 2026-09-08T23:25:00Z + +## User goal + +Implement the live Recovery Agent (Vertex AI Gemini) and Subgraph MCP Client for candidate recovery, wire them into worker composition, verify fail-closed invariants, and execute a live recovery drill. + +## Original prompt/request + +"we can not add graph mcp without recovery agent. we need to do that first than everything else" + +## Assumptions + +- Google Cloud ADC or dynamic token retrieval provides authentication for Vertex AI REST API in `europe-west1`. +- The Graph Gateway deployment endpoint provides fallback candidate discovery when no local MCP server process is running. +- Invariants: external indexer absence, lag, or model errors must NEVER grant settlement permissions (`settlementPermission: 'NEVER'`). All settlements are decided solely by the OneShot deterministic safety core and verified against authoritative Arc receipts. +- Secrets remain in Secret Manager / environment, never committed or exposed in logs. + +## Plan + +1. Implement `VertexAiRecoveryAdvisor` in `packages/reconciliation/src/vertex-advisor.ts` conforming to `RecoveryAdvisorPort` and `validateAndNormalizeRecommendation`. +2. Implement `LiveSubgraphMcpRecoveryPort` in `packages/reconciliation/src/subgraph-mcp-client.ts` supporting both MCP JSON-RPC and direct Gateway deployment endpoints. +3. Export both implementations from `@oneshot/reconciliation`. +4. Add unit test suites for `VertexAiRecoveryAdvisor` and `LiveSubgraphMcpRecoveryPort`. +5. Wire both into `apps/worker` composition and add integration tests in `p4-composition.test.ts`. +6. Execute live recovery drill with real Vertex AI `gemini-2.5-flash` on GCP and real Arc Testnet receipt. +7. Run repository validation checks and FreePi Gate A / Gate B. + +## Key decisions + +- Vertex AI REST `generateContent` with JSON response mode (`application/json`) is used for low overhead and no heavy SDK dependencies. +- Subgraph MCP client wraps Gateway responses into the standard MCP tool content envelope, ensuring unified downstream normalization through `normalizeSubgraphMcpTrace`. +- Safety core maintains total authority over settlement decisions: LLM advice is strictly advisory (`settlementPermission: 'NEVER'`). + +## Files/components touched + +- `packages/reconciliation/src/vertex-advisor.ts`: Vertex AI Gemini recovery advisor implementation. +- `packages/reconciliation/src/subgraph-mcp-client.ts`: Live Subgraph MCP / Gateway recovery client. +- `packages/reconciliation/src/index.ts`: Public exports for both clients. +- `packages/reconciliation/test/vertex-advisor.test.ts`: Comprehensive tests for Vertex AI advisor (valid recommendation, HTTP error fallback, invalid JSON, prompt injection defense, fabricated ID rejection). +- `packages/reconciliation/test/subgraph-mcp-client.test.ts`: Tests for MCP JSON-RPC and Gateway modes. +- `apps/worker/test/p4-composition.test.ts`: End-to-end composition test verifying `ProductionRecoveryService` with `LiveSubgraphMcpRecoveryPort` and `VertexAiRecoveryAdvisor` converging an `UNKNOWN` intent to `COMMITTED` with zero external submissions. + +## Commands/checks + +- `pnpm --filter @oneshot/reconciliation test`: PASS (83 tests). +- `pnpm --filter @oneshot/worker test`: PASS (27 tests). +- `pnpm test`: PASS (56 test files, 878 tests). +- `pnpm lint`: PASS (0 errors, 0 warnings). +- `pnpm typecheck`: PASS. +- Prettier targeted check: PASS. +- `git diff --check`: PASS. +- Live Drill (`scratch/live-recovery-agent-drill.mjs`): PASS. Real Gemini 2.5 Flash on Vertex AI responded in 5.3s, evaluated UNKNOWN intent, advised RECONCILE, verified on-chain against Arc Testnet receipt, marked COMMITTED with 0 duplicate broadcasts. diff --git a/.agent/context/20260909T-arc-circle-qualification-plan.md b/.agent/context/20260909T-arc-circle-qualification-plan.md new file mode 100644 index 0000000..daee5d6 --- /dev/null +++ b/.agent/context/20260909T-arc-circle-qualification-plan.md @@ -0,0 +1,52 @@ +# Arc/Circle qualification plan update + +Date: 2026-09-09 +Branch: `docs/arc-circle-qualification-plan` +Base: `origin/develop` at `7cb629c8b0131c2c5809b594cfb01fa3ca2e8c2e` + +## Goal + +Update `plan.md` and `plan_missing_parts.md` to reflect the official ETHOnline +2026 Arc mechanics and a credible Circle technology path. The plan must clearly +separate existing Arc/Privy and The Graph evidence from the unimplemented Circle +Agent Stack qualification slice. + +## Scope + +- Record merged PR #48 and its effect on the Graph/P4 status. +- Correct the Studio-qualified versus Explorer-unallocated distinction. +- Make Circle Agent Stack, Circle CLI/Skills, and a capped Agent Wallet the + primary planned Circle surface for the Arc agentic-economy claim. +- Preserve Privy as the corporate wallet and OneShot/PostgreSQL as settlement + authority; preserve `UNKNOWN`, `FALLBACK_DIRECT_RECOVERY`, and no-blind-retry + requirements. +- Add track-specific Arc acceptance criteria, missing work, and evidence gates. + +## Non-goals + +- No Circle SDK, wallet, contract, or credential is added in this documentation + change. +- No Hedera SDK, HTS, x402, or Blocky402 implementation is added. +- No sponsor claim is upgraded to `QUALIFIED` for Circle; the plan records it as + `NOT VERIFIED` until a live implementation and evidence exist. + +## External basis recorded for planning + +- ETHOnline 2026 Arc prize requirements and submission mechanics were checked + against the official ETHGlobal prize/details pages. +- Circle Agent Stack, Agent Wallet, CLI/Skills, and starter-kit behavior were + checked against official Circle documentation and repositories. +- Arc Testnet network facts were checked against official Arc documentation. + +## Validation to run + +- `git diff --check` and staged diff check. +- `npx markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"`. +- FreePi Gate A against the exact staged tree. +- Required CI and FreePi Gate B against the exact pushed head. + +## Gate status + +- Gate A: NOT RUN +- Gate B: NOT RUN +- Commit: NOT CREATED diff --git a/.agent/context/20260909T-gate-p6-release-candidate.md b/.agent/context/20260909T-gate-p6-release-candidate.md new file mode 100644 index 0000000..edb8590 --- /dev/null +++ b/.agent/context/20260909T-gate-p6-release-candidate.md @@ -0,0 +1,9 @@ +# Gate P6 release candidate + +- Branch: `milestone/gate-p6-release-candidate` +- Base: `7ae55c8d473ca1bc6ca146de0dfd375571fb517a` +- Scope: repeatable offline E2E demo, judge walkthrough, and plan scope update. +- Acceptance: demo proves 1.00 USDC evidence, zero-effect Privy denials, and + lost-response at-most-once recovery; Circle and video are not claimed. +- Checks: `pnpm demo:e2e`, `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, + `pnpm --filter @oneshot/testkit-settlement evidence:b06` passed. diff --git a/.agent/context/20260909T-update-plan-pr46.md b/.agent/context/20260909T-update-plan-pr46.md new file mode 100644 index 0000000..c2f4a6f --- /dev/null +++ b/.agent/context/20260909T-update-plan-pr46.md @@ -0,0 +1,32 @@ +# Context: update plan for merged PR #46 + +Date: 2026-09-09 +Goal: Record the impact of merged PR #46 on the delivery plan and missing-parts audit. +Branch: `docs/update-plan-pr46` +Recorded base: `origin/develop` at `0cd80ca50467a6a2a7732808bfbd269fa0e71b2c` + +## Acceptance criteria + +- `plan.md` records PRs #42-#46 as merged into `develop`. +- `plan.md` records PR #46 as delivering tested Vertex AI and Subgraph MCP + adapters while keeping production defaults unavailable. +- `plan.md` and `plan_missing_parts.md` retain the Explorer no-allocation, + The Graph `NOT VERIFIED`, `FALLBACK_DIRECT_RECOVERY`, P4 incomplete, and P6 + open positions. +- `plan_missing_parts.md` distinguishes delivered adapter implementation from + remaining runtime admission, allocation, live-query, and qualification proof. +- No source, configuration, secrets, or OneShot settlement invariants change. + +## Assumptions and non-goals + +The current `origin/develop` state is the implementation base. This change is +documentation-only apart from this context record. It does not enable live +MCP/model ports, change the Explorer deployment, claim a Graph allocation, or +authorize hashless recovery. + +## Validation + +- `npx.cmd markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"`: PASS +- `git diff --check`: PASS +- Gate A: NOT RUN +- Gate B: NOT RUN diff --git a/.agent/context/20260909T002000Z-graph-sponsor-qualification.md b/.agent/context/20260909T002000Z-graph-sponsor-qualification.md new file mode 100644 index 0000000..304a1c5 --- /dev/null +++ b/.agent/context/20260909T002000Z-graph-sponsor-qualification.md @@ -0,0 +1,86 @@ +# Session Context: The Graph sponsor qualification and Gate P4 PASS + +## Date/time + +- UTC: 2026-09-09T03:15:00Z + +## User goal + +Resolve FreePi Gate B rejection on PR #48, eliminate all schema mismatch and mocked/relabeled MCP trace issues, deploy native `SettlementCandidate` subgraph v0.2.1 to Subgraph Studio, execute live end-to-end recovery proof through Subgraph Studio and Subgraph MCP with Vertex AI Gemini 2.5 Flash, verify on Arc RPC, record genuine sanitized evidence, and achieve passing Gate A and Gate B. + +## Original prompt/request + +- "Дядя, мы решили, что будем пытаться подключить из Graph Studio эту query и протестировать, работает ли она. Ты, кажется, уже протестировал, и она заработала. В чём проблема? Ладно, в Graph Explorer она не работает, но в Subgraph Studio, другое дело. Давай продолжим: будем пробовать, тестировать; если не работает, будем думать дальше." +- Provided live Subgraph Studio query endpoint: `https://api.studio.thegraph.com/query/1758917/oneshot-arc-testnet/v0.2.1` + +## Assumptions + +- No secrets or API credentials are committed or logged in git. +- The Graph target is AI Tooling or AI Use Case track. +- Pinned deployment `QmPEUSL6aXY7RVjGFFMbs5L4Q4pxG4TB73cHQ7nechGQY7` (`0x0d469664a45efc2483abb0e4d35e8ed02db0064c2c50dc0cdf855ff6ad6690c0`) in Subgraph Studio is active, synchronized, and exposes native `SettlementCandidate` entities matching `OneShotRecoveryCandidatesV1`. +- Arc RPC `https://rpc.testnet.arc.io` provides authoritative settlement proof for transaction `0x72ab...` in block `61116056`. +- Vertex AI `gemini-2.5-flash` in `europe-west1` (project `oneshot-508002`) provides unstructured advisory to the deterministic safety core. + +## Plan + +1. Update `subgraph/schema.graphql` and `subgraph/src/mapping.ts` to natively index `SettlementCandidate` with real event `blockHash: event.block.hash`, matching `RECOVERY_CANDIDATE_QUERY`. +2. Deploy v0.2.1 to Subgraph Studio with startBlock `61115500`. +3. Simplify `packages/reconciliation/src/subgraph-mcp-client.ts` to directly send `toolArgs.query` (`RECOVERY_CANDIDATE_QUERY`) without artificial `usdcTransfers` mapping. +4. Execute full live recovery proof: fetch mined Arc Testnet receipt, query live Subgraph Studio for candidate, normalize MCP trace (`accepted: true`, health: `FRESH`), invoke Vertex AI Gemini 2.5 Flash, obtain accepted `RECONCILE` recommendation with decision ID, verify receipt via deterministic safety core, confirm `MARK_COMMITTED` with zero duplicate broadcasts (`settlementPermission: NEVER`). +5. Update `evidence/c06/graph-proof.json`, `packages/reconciliation/docs/c06/QUALIFICATION_REPORT.md`, `docs/GATE_P4_MANIFEST.md`, `docs/GATE_P4_CHECKLIST.md`, `docs/settlement/LIVE_EVIDENCE.md`, `plan.md`, and `plan_missing_parts.md`. +6. Run full repository verification checks (`lint`, `typecheck`, `test`, `format:check`, `check:generated`, `validate:fixtures`), pass FreePi Gate A, push to PR #48, pass CI, and pass FreePi Gate B. + +## Key decisions + +- Pinned immutable deployment CID `QmPEUSL6aXY7RVjGFFMbs5L4Q4pxG4TB73cHQ7nechGQY7` (`0x0d469664a45efc2483abb0e4d35e8ed02db0064c2c50dc0cdf855ff6ad6690c0`). +- Addressed FreePi Gate B findings directly by providing genuine native `SettlementCandidate` schema and real event `blockHash` (`0xc2e18d2ee52e8e046a5f70329265aba27285f7d257bb765d417a7c5613bf4b1b`), matching the Arc RPC receipt. +- Added `subgraph/generated` and `subgraph/build` to `.prettierignore`. +- Preserved all idempotency invariants: `settlementPermission: NEVER` across all reconciliation paths. + +## Files/components touched + +- `subgraph/schema.graphql`: added `SettlementCandidate` entity. +- `subgraph/subgraph.yaml`: registered `SettlementCandidate` entity; set `startBlock: 61115500`. +- `subgraph/src/mapping.ts`: stored `SettlementCandidate` with real `blockHash: event.block.hash`. +- `packages/reconciliation/src/subgraph-mcp-client.ts`: direct `toolArgs.query` pass-through and uint timestamp handling. +- `packages/reconciliation/test/subgraph-mcp-client.test.ts`: updated tests for native candidate query. +- `evidence/c06/graph-proof.json`: recorded full live The Graph Subgraph MCP + Vertex AI Gemini proof. +- `packages/reconciliation/docs/c06/QUALIFICATION_REPORT.md`: updated pinned deployment to v0.2.1. +- `docs/GATE_P4_MANIFEST.md`: updated pinned deployment and decision ID. +- `docs/GATE_P4_CHECKLIST.md`: updated pinned deployment. +- `docs/settlement/LIVE_EVIDENCE.md`: updated pinned deployment, query endpoint, and decision ID. +- `plan.md`: updated Subgraph version to v0.2.1 and CID. +- `plan_missing_parts.md`: updated Subgraph deployment CID and hex ID. +- `.prettierignore`: added `subgraph/generated` and `subgraph/build`. + +## Commands/checks + +- `pnpm --filter @oneshot/reconciliation build` - PASS +- `pnpm test` - PASS (57 test files, 901 tests) +- `pnpm lint` - PASS (eslint clean) +- `pnpm typecheck` - PASS (tsc clean) +- `pnpm format:check` - PASS (prettier clean) +- `pnpm check:generated` - PASS +- `pnpm validate:fixtures` - PASS +- Live proof script - PASS (`MARK_COMMITTED`, `settlementPermission: NEVER`, `authoritativeProofPresent: true`) + +## External-doc findings + +- Subgraph Studio Query URL format: `https://api.studio.thegraph.com/query///` +- Vertex AI Gemini 2.5 Flash requires valid IAM token and correct GCP project (`oneshot-508002`). + +## Git and PR state + +- Branch: `feat/graph-sponsor-qualification` +- PR: #48 (Draft) +- Commit: pending new commit with v0.2.1 evidence + +## Review gates + +- Gate A: pending +- Gate B: pending + +## Handoff/next steps + +1. Run Gate A via `free-pi-cli`. +2. Commit, push, wait for CI, and run Gate B. diff --git a/.agent/context/20260909T062151Z-p5-frontend-acceptance.md b/.agent/context/20260909T062151Z-p5-frontend-acceptance.md new file mode 100644 index 0000000..5479fc0 --- /dev/null +++ b/.agent/context/20260909T062151Z-p5-frontend-acceptance.md @@ -0,0 +1,84 @@ +# Session Context: Gate P5 Frontend Acceptance + +## Date/time + +- UTC: 2026-09-09T06:21:51Z + +## User goal + +Compose the A05, B05, and C05 frontend slices described by P5 in `plan.md` +into the OneShot operator experience and verify the frontend acceptance gate. + +## Acceptance criteria + +- The root web app exposes create/replay, authoritative status, settlement + evidence, and recovery evidence surfaces in one accessible shell. +- B05 is consumed through its read-only public route/client entry points; no + settlement, resend, force-pay, or policy-bypass action is added. +- C05 is consumed through its public recovery route/client entry points and is + clearly marked as synthetic fixture review when the live API lacks the + package's richer timeline contract. +- Frontend tests cover the composed shell and the existing package fixtures + continue to cover replay/conflict, committed/UNKNOWN, denial, Graph + discovery, lag/error/multiple-candidate, and unavailable states. +- Package/root format, lint, type, test, build, generated-contract, fixture, + and no-secret checks pass where applicable. + +## Assumptions and non-goals + +- The frozen OpenAPI v1 exposes `recovery-view`, not the full C05 timeline + schema; this change does not widen or mutate that contract. +- Live B05 reads use the existing OneShot API client seam and runtime token. +- Live settlement behavior, external payment execution, and sponsor evidence + remain outside P5 frontend composition. + +## Branch state + +- Branch: `feature/ethonline-2026-prize-audit` +- Base: `develop` at the current checked-out commit +- No formal Gate A/B run by the agent. A manual `free-pi-cli` review was + supplied afterward; its FAIL was process-closed because the candidate is + staged/uncommitted with no PR or CI, and it also identified missing loaded + settlement no-action coverage and interactive browser smoke. + +## Follow-up changes after manual review + +- Added a loaded `SettlementDetailsRoute` composition test proving the rendered + settlement evidence surface has no interactive controls. +- Added keyboard navigation and roving focus semantics for the application + tabs, with explicit tab/panel labelling. +- Added a Playwright/Chromium browser acceptance suite for the P5 state matrix, + responsive widths, and keyboard tab flow; CI installs Chromium and runs it. +- Made denial, rate-limit, and service-not-ready outcomes explicit in the + create/replay surface instead of collapsing them into generic failure copy. +- Kept the browser smoke item open because this host exposes no browser + provider; no gate was started after these changes. + +## Gate A follow-up fixes + +- Root Vitest now aliases `@oneshot/settlement-ui` and + `@oneshot/recovery-ui` to workspace source, so the required clean-build + `pnpm build && pnpm test` path does not depend on stale package bundles. +- Playwright `test-results/` and `playwright-report/` outputs are ignored by + Git and Prettier; `pnpm format:check` was rerun after browser acceptance. +- Clean validation: `pnpm build` passed with both UI package `dist/` + directories removed; `pnpm test` passed with 59 files and 916 tests; + `pnpm test:browser` passed 4/4; `pnpm format:check` passed afterward. +- The web package Vitest config also aliases both workspace UI packages to + source; after removing both UI `dist/` directories, `pnpm --filter + @oneshot/web test` passed all 31 tests. + +## Selected safety cases + +- Same request twice and conflicting payloads remain handled by A05 with one + stable `business_intent_id`. +- UNKNOWN remains reconciliation-only; the composed shell exposes no payment + action. +- Privy denial/cap and Graph degraded evidence remain fixture-driven UI states; + no fixture can grant settlement permission. + +## Review instruction + +Before every FreePi review prompt, issue `/model free-pi/glm-5.3-flash` first. +Inspect the end of the output for an explicit `VERDICT: PASS` or +`VERDICT: FAIL`; do not spend tokens following streamed reasoning. diff --git a/.agent/context/20260909T111228Z-gate-p5-frontend-acceptance.md b/.agent/context/20260909T111228Z-gate-p5-frontend-acceptance.md new file mode 100644 index 0000000..f332d4a --- /dev/null +++ b/.agent/context/20260909T111228Z-gate-p5-frontend-acceptance.md @@ -0,0 +1,61 @@ +# Session Context: Gate P5 Frontend Acceptance + +## Date/time + +- UTC: 2026-09-09T11:12:28Z + +## User goal + +Implement project Gate P5 according to `plan.md`, after ensuring the Recovery +Agent decision exists before frontend Graph MCP composition. + +## Key decisions + +- Compose A05/B05/C05 in the A-owned shell using the configured frozen API. +- Return the latest persisted Recovery Agent and deterministic-core result from + the recovery view; never hard-code an Agent decision. +- Keep Subgraph MCP and model output advisory with settlement permission + `NEVER`. +- Hide unsupported operator escalation in production instead of simulating a + successful external effect. +- Scope B05/C05 CSS at package boundaries to prevent cross-slice overrides. + +## Files/components touched + +- `apps/web`: composed shell, recovery API projection, Playwright acceptance, + responsive/token safety checks. +- `packages/contracts`: additive recovery decision and Graph observation view. +- `packages/reconciliation`: persist Agent boundary acceptance and explanation. +- `packages/storage-postgres`: project latest durable recovery command pack. +- `packages/recovery-ui`, `packages/settlement-ui`: scoped composition styles; + recovery escalation capability flag. +- CI, plan/status documentation, and Gate P5 evidence. + +## Commands/checks + +- Full workspace build/test: 58 files and 904 tests PASS. +- Lint, typecheck, generated contracts, fixtures: PASS. +- Web unit/component: 31 PASS. +- Reconciliation: 84 PASS. +- Storage unit: 8 PASS. +- Playwright Chromium: 4 PASS. +- Cloud Run `/health/ready`: HTTP 200, `{"status":"ok"}`. +- Docker is unavailable locally; PostgreSQL integration test is added for CI. + +## Git and PR state + +- Branch: `feat/gate-p5-frontend-acceptance` +- Base: `origin/develop` at `779c6cf` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Run full validation and FreePi Gate A. +2. Push a draft PR, await exact-head CI, run Gate B, and hand to a human. diff --git a/.agent/context/20260909T145724Z-production-worker-runtime.md b/.agent/context/20260909T145724Z-production-worker-runtime.md new file mode 100644 index 0000000..5480c48 --- /dev/null +++ b/.agent/context/20260909T145724Z-production-worker-runtime.md @@ -0,0 +1,125 @@ +# Session Context: Production Worker Runtime + +## Date/time + +- UTC: 2026-09-09T14:57:24Z + +## User goal + +Implement the missing production worker runtime on a new branch: executable +startup and graceful shutdown, durable work delivery, validated Privy/Arc/ +Subgraph MCP/Vertex configuration, restart recovery, health/readiness, a worker +container image, and a full API-to-worker end-to-end test. + +## Original request + +The user selected item 1 from the develop-branch gap audit and asked to +implement it in a new branch. + +## Assumptions + +- Base is `origin/develop` at + `c38511c3981bbd70c6f4abd7a4ce469000cda878`. +- Arc Testnet is the only enabled live network. Mainnet remains unavailable and + requires a separate human-authorized change. +- The runtime must fail closed unless the real Privy settlement provider and + reviewed policy identity are fully configured. +- Subgraph MCP and Vertex AI remain read-only/advisory and may never grant + settlement permission. +- The existing transactional outbox remains authoritative work delivery. A + runtime may poll it continuously without moving duplicate protection out of + PostgreSQL. + +## Plan + +1. Add focused runtime/config/provider tests first. +2. Add a concrete Privy Node SDK wallet provider and Arc RPC receipt adapter. +3. Add validated worker/recovery configuration and production composition. +4. Add executable runtime lifecycle, restart recovery, health/readiness, and + graceful shutdown. +5. Add a dedicated worker container and documented environment surface. +6. Add PostgreSQL API-to-worker end-to-end coverage plus failure-boundary tests. +7. Run package and root validation, inspect scope/secrets, and prepare Gate A. + +## Key decisions + +- Use the existing OneShot transactional outbox poller as the continuous + production runner; Graphile Worker task-list support remains available but is + not used as a second queue authority. +- Use Privy's official Node SDK with the Business Intent-derived idempotency key + already produced by `buildCanonicalRequest`. +- Use `viem` only for read-only Arc RPC receipt/block observations. +- Keep runtime logs limited to safe lifecycle/readiness facts; never emit + configuration values or provider payloads. + +## Files/components touched + +- `apps/worker/src/runtime*.ts`, `server.ts`, and `restart-runner.ts`: production + composition, lifecycle, non-overlapping continuous drain, health/readiness, + and graceful shutdown. +- `apps/worker/src/worker.ts`: outbox delivery is committed only after handler + success; failures/process kills leave the job pending. +- `packages/privy-adapter/src/privy-wallet-provider.ts`: official Privy Node + signing with the durable idempotency key plus read-only viem Arc receipts. +- `.env.example`, `apps/worker/README.md`, and `Dockerfile.worker`: deployment + configuration and image. +- Worker and adapter unit tests plus + `apps/worker/test/production-runtime.integration.test.ts`. + +## Commands/checks + +- Repository policy and routed skills read. +- `git fetch origin develop`: base current. +- Official Graphile Worker library documentation reviewed: `run`, `Runner.stop`, + and graceful-shutdown semantics confirmed. +- Official Privy Node SDK documentation reviewed: server-wallet + `sendTransaction` and `idempotency_key` support confirmed. +- Final `pnpm test`: PASS, 60 files / 911 tests. +- `pnpm --filter @oneshot/worker test`: PASS, 32 tests. +- `pnpm --filter @oneshot/privy-adapter test`: PASS, 121 tests. +- `pnpm build`, `pnpm lint`, worker typecheck, generated-contract check, fixture + validation, and `git diff --check`: PASS. +- `pnpm audit --audit-level high`: FAIL on 63 pre-existing workspace findings + routed through Graph CLI/Wrangler-era dependencies; no advisory path named + the newly added Privy or Google Auth packages. +- PostgreSQL integration attempt with `TEST_POSTGRES=1`: BLOCKED by the host + having no Docker/container runtime; all three existing worker integration + suites failed at Testcontainers startup before test execution. With the flag + absent, 10 integration tests are correctly discovered and skipped. + +## External-doc findings + +- Graphile Worker supports a library runner with `taskList`, `pgPool`, and + `Runner.stop()` for graceful shutdown. +- Privy `@privy-io/node` sends EVM transactions through + `wallets().ethereum().sendTransaction` and accepts an idempotency key. + +## Unresolved questions + +- The new full PostgreSQL API-to-runner test still needs execution in CI or a + workstation with Docker. +- Live deployment readiness requires operator-provided Privy/Graph secrets, + reviewed public identities, Arc RPC access, and Google Application Default + Credentials; no live external effect was attempted in this session. + +## Git and PR state + +- Branch: `feature/production-worker-runtime` +- Base: `origin/develop` at + `c38511c3981bbd70c6f4abd7a4ce469000cda878` +- Commit: none +- PR: not created +- Workspace: all intended implementation and review-prompt files staged; + implementation remains uncommitted + +## Review gates + +- Gate A: NOT RUN; staged candidate tree is captured immediately before the + review invocation with `git write-tree`. +- Gate B: NOT RUN + +## Handoff/next steps + +- Run the PostgreSQL integration suite in a Docker-capable environment. If the + user requests a commit/PR, stage the exact tree and run fresh FreePi Gate A + before committing or pushing. diff --git a/.agent/context/20260909T190000Z-privy-auth-ops-landing.md b/.agent/context/20260909T190000Z-privy-auth-ops-landing.md new file mode 100644 index 0000000..641fb8b --- /dev/null +++ b/.agent/context/20260909T190000Z-privy-auth-ops-landing.md @@ -0,0 +1,80 @@ +# Session Context: Privy operator authentication + +## Date/time + +- UTC: 2026-09-09T22:34:33Z + +## User goal + +Implement the Privy operator-authentication plan through validation, mandatory review gates, and a draft pull request. + +## Original prompt/request + +Complete `2026-09-09-privy-operator-auth.md` from the current Task 5/10 state through all remaining work and required gates. + +## Assumptions + +- The named `feature/privy-operator-auth` branch is the intended short-lived branch. +- Tasks 1–4 are missing from the branch and must be completed before Tasks 7–10. +- Privy operator login is separate from the existing Privy wallet-authorization boundary. + +## Plan + +1. Implement and test missing API authentication Tasks 1–4. +2. Complete web Tasks 7–8 and operator documentation. +3. Run full validation, Gate A, draft PR CI, and Gate B. + +## Key decisions + +- Route JWT-shaped credentials only to the Privy verifier and opaque credentials only to the existing constant-time service-token verifier. +- Keep operator access tokens in memory and never render or log them. +- Preserve the existing service bearer for worker and agent clients. + +## Files/components touched + +- `apps/api/src/privy-auth.ts`, `auth.ts`, `config.ts`, and `runtime.ts`: ES256 verification, credential routing, fail-closed configuration, and runtime composition. +- `apps/api/test`: JWT rejection, allowlist, configuration, routing, runtime, logging, and forbidden-ledger boundary coverage. +- `apps/web/src/auth`, `LoginGate.tsx`, `App.tsx`, and `main.tsx`: in-memory operator session, gated console, credential selection, and lazy Privy provider. +- `apps/web/test` and `apps/web/browser`: unit, composition, credential-header, storage, token-leak, and Playwright gate coverage. +- `apps/web/package.json`, `pnpm-lock.yaml`, and `pnpm-workspace.yaml`: official React SDK plus strict-peer-compatible transitive pins and denied optional native build scripts. +- `.env.example`, `apps/web/.env.example`, and `README.md`: safe placeholders and operator bootstrap documentation. + +## Commands/checks + +- Task 5: 4 focused tests, typecheck, lint, and format passed. +- Task 6: 7 focused tests; full web suite 46/46; typecheck, lint, and format passed. +- API focused suite: 40/40 tests passed, including forbidden reconciliation never reaching the ledger. +- Web focused suite: 50/50 tests passed; Playwright: 7/7 passed. +- `pnpm peers check` - passed with no peer dependency issues. +- `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, and `pnpm check:generated` - passed. +- `pnpm test` - passed: 64 files, 942 tests. +- `pnpm --filter @oneshot/web test:browser` - passed: 7 tests. +- `pnpm --filter @oneshot/web build` - passed; Privy emitted as a separate lazy chunk. + +## External-doc findings + +- Privy access-token documentation checked 2026-09-09: access tokens are ES256 JWTs with `privy.io` issuer, app-id audience, Privy DID subject, and roughly one-hour lifetime; `getAccessToken` refreshes when needed. +- Privy login UI documentation checked 2026-09-09: React authentication state is exposed through `usePrivy`; configured login methods include email and wallet. +- Privy dashboard documentation checked 2026-09-09: the public verification key is available under Configuration, App settings, Basics, “Verify with key instead.” + +## Unresolved questions + +- Local Node is 22.23.2 while the repository requires 24.19.0; all checks passed with an engine warning, and CI must confirm on the required version. +- Planned React SDK 3.41.0 was younger than the 24-hour supply-chain threshold and introduced an unsatisfied Solana/TypeScript peer graph. Version 3.6.1 is the newest tested version before that dependency break; upgrade after a compatible release clears policy. + +## Git and PR state + +- Branch: `feature/privy-operator-auth` +- Base: `origin/develop` at `78b9d74bd4b111a6418485baff6379464325960c` +- Commit: `dd424c7e6651539f8c0b0d0abf9347393c00d6b2` +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Commit operator documentation, run Gate A, push a draft PR, wait for CI, then run Gate B. diff --git a/.agent/context/20260910T-open-wallet-auth.md b/.agent/context/20260910T-open-wallet-auth.md new file mode 100644 index 0000000..5e1fd9a --- /dev/null +++ b/.agent/context/20260910T-open-wallet-auth.md @@ -0,0 +1,25 @@ +# Session Context: Open Wallet Authentication & UI Polish + +## Date/time + +- UTC: 2026-09-10T01:02:00Z + +## User goal + +Allow any user to sign in with their own Web3 wallet via Privy and interact with the authoritative execution engine, removing the closed DID allowlist restriction on the API backend and hiding the advanced machine token drawer from the production landing UI. + +## Assumptions + +- Base is develop at e0e0e7826af28e3e229ddf1f1f26030feb51534e. +- Privy ES256 verification remains cryptographic and strict (valid JWT, issuer privy.io, audience matching appId). +- PRIVY_AUTH_ALLOWED_SUBJECTS supports wildcard * to allow any authenticated Privy wallet/user, while preserving explicit DID lists when configured. +- Machine token (advanced) is hidden in production (MODE !== test), while remaining accessible in test mode for automated test suites. +- Zero regressions across all unit, browser, contract, and integration tests. + +## Plan & Execution Status + +1. [x] Update apps/api/src/privy-auth.ts to support wildcard * / optional allowlist, accepting any cryptographically valid Privy token when allow-all is enabled. +2. [x] Update apps/api/src/config.ts to support PRIVY_AUTH_ALLOWED_SUBJECTS=*. +3. [x] Update apps/web/src/components/LoginGate.tsx to conditionally render MachineTokenField only in test mode (showMachineToken = props.showMachineToken ?? (import.meta.env.MODE === test)), and simplify operator note. +4. [x] Add unit tests for wildcard allowlist in apps/api/test/privy-auth.test.ts and apps/api/test/config.test.ts. +5. [x] Verify all 64 test files (944 unit/contract tests) and 7/7 Playwright browser tests pass. diff --git a/.agent/context/20260910T-part1-3-production-hardening.md b/.agent/context/20260910T-part1-3-production-hardening.md new file mode 100644 index 0000000..a587aab --- /dev/null +++ b/.agent/context/20260910T-part1-3-production-hardening.md @@ -0,0 +1,93 @@ +# Production hardening parts 1-3 + +## Goal + +Implement the three requested audit follow-ups on a short-lived branch, with +one commit and one fresh Gate A review prompt after each part: + +1. Complete worker deployment configuration and add production API rate limiting. +2. Harden the Privy fallback boundary and PostgreSQL integration-test cleanup. +3. Reconcile stale operational and implementation documentation. + +## Branch and base + +- Branch: `fix/production-hardening-ops` +- Base: current clean `develop` at the start of this session. +- Scope: testnet-safe operational hardening; no mainnet activation, secrets, or + external payment effects. + +## Acceptance criteria + +- Part 1: API runtime defaults to a durable shared rate limiter; limits are + configurable and tested; worker deployment instructions include every + required runtime variable and preserve secret-store boundaries. +- Part 2: Privy denied-relay fallback behavior is covered by regression tests; + ambiguous external effects remain fail-closed; PostgreSQL integration + cleanup tolerates setup failure without masking the original error. +- Part 3: stale statuses, variable names, and architecture descriptions are + corrected without changing product scope or sponsor qualification claims. + +## Applicable safety cases + +The changes touch API access, worker runtime, Privy settlement boundaries, and +PostgreSQL test infrastructure. The relevant matrix cases are Privy denial, +ambiguous submission/no blind retry, service restart, and durable state and +external-effect assertions. No live settlement is authorized for this work. + +## Initial validation + +The repository was clean on `develop`. Prior audit checks passed lint, +typecheck, unit tests, build, format, generated artifacts, fixtures, browser +tests, and markdown lint. PostgreSQL integration was not runnable locally +because no container runtime was available; it must remain explicitly reported +as unverified until CI or a container runtime executes it. + +## Part 1 evidence + +- `pnpm.cmd --filter @oneshot/api test`: PASS (50 tests). +- `pnpm.cmd --filter @oneshot/storage-postgres test`: PASS (8 tests). +- `pnpm.cmd typecheck`: PASS. +- `pnpm.cmd lint`: PASS. +- `pnpm.cmd format:check`: PASS. +- `pnpm.cmd test`: PASS (65 files, 974 tests). +- PostgreSQL integration remains unrun locally because no container runtime is + available; the new limiter SQL is covered by focused unit behavior and the + migration is included in the integration migration sequence. + +Part 1 implements a PostgreSQL-backed fixed-window limiter that fails closed +when the limiter store is unavailable. It keys admission by request IP and +route; correlation IDs remain diagnostic only. Worker deployment documentation +now lists the complete non-secret runtime contract and sends only the three +secret values to Secret Manager. + +## Part 2 evidence + +- `pnpm.cmd --filter @oneshot/privy-adapter test`: PASS (9 files, 130 tests). +- `pnpm.cmd --filter @oneshot/storage-postgres test`: PASS (2 files, 8 tests). +- `pnpm.cmd --filter @oneshot/api test`: PASS (6 files, 50 tests). +- `pnpm.cmd --filter @oneshot/worker test`: PASS (5 files, 39 tests). +- `pnpm.cmd typecheck`: PASS. +- `pnpm.cmd lint`: PASS. +- `pnpm.cmd format:check`: PASS. +- `pnpm.cmd test`: PASS (66 files, 977 tests). +- The Privy regression suite now covers relay-boundary fallback, generic 401 + rejection without fallback, and policy denial without raw broadcast. +- All PostgreSQL Testcontainers suites guard cleanup when setup fails. Actual + container-backed integration remains unrun locally because no runtime is + available. + +## Part 3 evidence + +- `pnpm.cmd format:check`: PASS. +- `npx.cmd --yes markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"`: PASS + (142 Markdown files, 0 errors). +- A targeted stale-term scan over the reconciled operational and implementation + docs found no remaining references to the retired `ONESHOT_PROFILE`, + `ONESHOT_NETWORK`, or `ONESHOT_CONTRACT_VERSION` variables, old P5 test + count, pre-evidence Privy/Arc gap wording, or Graphile Worker as the + executable production scheduler. +- The reconciled docs now match the executable production scheduler + (`RestartRunner`), current API rate-limit settings, current P5 implementation + status, current Privy/Arc qualification evidence, and the still-unverified + Graph claim. Historical context records are not treated as current runtime + documentation. diff --git a/.agent/context/20260910T-studio-recovery-runtime.md b/.agent/context/20260910T-studio-recovery-runtime.md new file mode 100644 index 0000000..5c44a74 --- /dev/null +++ b/.agent/context/20260910T-studio-recovery-runtime.md @@ -0,0 +1,32 @@ +# Studio Recovery Runtime + +- Branch: `fix/studio-recovery-runtime` +- Base: `develop` at `c140405` +- Goal: restore read-only Arc recovery candidate discovery through the working + Graph Studio endpoint and stop using a stale fixed upper block at runtime. +- Acceptance: production configuration accepts an HTTPS Studio query URL; + recovery queries use the current Arc head; unavailable, empty, or ambiguous + Graph results remain non-authoritative and never enable settlement. +- Assumption: Arc Testnet deployment remains Studio-only because it is not + served by The Graph Network Gateway. +- Non-goals: publishing Arc to The Graph Network, claiming a live official MCP + transport, resolving ambiguous multiple candidates, or enabling submissions. +- Safety: Cloud Run worker revision `oneshot-worker-00005-h8z` has + `ONESHOT_SUBMISSIONS_DISABLED=true` and minimum instances set to zero. + +## Evidence + +- Live Studio query: deployment + `QmPEUSL6aXY7RVjGFFMbs5L4Q4pxG4TB73cHQ7nechGQY7`, indexing errors false, + indexed within five blocks of Arc head, real candidates returned. +- Live Network Gateway query: GraphQL error `subgraph not found` for the pinned + deployment. +- Production recovery views for both existing `UNKNOWN` intents reported Graph + health `UNAVAILABLE` before this fix. +- Local checks: format, lint, typecheck, build, generated-contract check, + fixture validation, 64 test files / 958 tests, worker 38 tests, + reconciliation 85 tests, Gate P6 offline demo, Markdown lint, and diff check + pass. +- PostgreSQL integration tests require a container runtime, which is not + available locally; the no-container run fails before tests start and remains + required in CI. diff --git a/.agent/context/20260910T-web-landing-redesign.md b/.agent/context/20260910T-web-landing-redesign.md new file mode 100644 index 0000000..16ac676 --- /dev/null +++ b/.agent/context/20260910T-web-landing-redesign.md @@ -0,0 +1,29 @@ +# Session Context: Web Landing & Glassmorphism Redesign + +## Date/time + +- UTC: 2026-09-09T23:51:00Z + +## User goal + +Replace the bare white login stub with a high-end Behance-grade minimalist dark glassmorphism landing page and console matching the Convergence Stream logo, with full Privy operator authentication, crisp typography, and no cheesy AI icons. + +## Assumptions + +- Base is `develop` at `1125134f7edcdea4e1e6bc5687300ad67908142c`. +- Strict minimalism: zero AI emojis/icons (no brains, robots, sparkles, or lightbulbs). +- Dark aesthetic matching the avatar: obsidian background (#080b11), glowing cyan luminescence (#00f2fe / #22d3ee), glass cards with subtle borders and backdrop blur. +- All existing Playwright acceptance tests and unit tests must remain 100% green. + +## Plan & Execution Status + +1. [x] Create design tokens and glassmorphism CSS matching the Concept 3 Convergence Stream avatar (`#06090e`, glowing radial cyan luminescence, frosted glass panels with `backdrop-filter: blur(20px)` and subtle white borders). +2. [x] Build the Landing Hero section with value proposition (`One job. Many retries. One settlement.`) and architectural invariant cards (`01 / ATOMIC PRECISION`, `02 / PRE-EXECUTION POLICY`, `03 / HASHLESS RECOVERY`, `04 / CARDINALITY INVARIANT`). Absolutely NO AI icons or emojis. +3. [x] Integrate the Convergence Stream logo (`/logo.png`) into Top Navigation bar and Technical Footer. +4. [x] Refactor LoginGate into a polished operator experience: + - "Sign in with Privy" button triggering official Privy React SDK modal. + - Operator DID badge with copy action and Sign out button when signed in. + - Preserved reconciliation tree structure so typing machine tokens in advanced drawer never drops focus or unmounts the input. +5. [x] Embed the 4 console tabs (`Create or replay`, `Authoritative status`, `Settlement evidence`, `Recovery evidence`) cleanly inside the Authoritative Execution Engine workspace. +6. [x] Fix automated test isolation: added `.env.test` and updated `test:browser` to run `vite build --mode test` and ignore non-localhost endpoints in `mockApi`, avoiding external Privy network requests in CI. +7. [x] Verify all 64 test files (942 unit/contract tests), 7/7 Playwright browser tests, lint, typecheck, format, and full monorepo build: 100% green. diff --git a/.agent/context/20260910T-web-proxy-and-privy-app-id.md b/.agent/context/20260910T-web-proxy-and-privy-app-id.md new file mode 100644 index 0000000..69db481 --- /dev/null +++ b/.agent/context/20260910T-web-proxy-and-privy-app-id.md @@ -0,0 +1,19 @@ +# Session Context: Web Proxy Robustness & Default Privy App ID + +## Date/time + +- UTC: 2026-09-10T02:27:00Z + +## User goal + +Fix "Privy login is not configured for this build" and "REQUEST FAILED: Failed to fetch" errors by: + +1. Hardcoding the default Privy App ID in apps/web/src/main.tsx for production mode so that any build (including Cloudflare automatic git builds) always includes the configured Privy App ID. +2. Solidifying apps/web/worker.ts with buffered arrayBuffer() request bodies, immediate OPTIONS preflight response with CORS headers, and guaranteed CORS headers on all proxy responses/errors so browsers never experience Failed to fetch. + +## Assumptions + +- Base is develop at 48ff88c72df959137f08a98ceaac397ab5a1c527. +- In test mode (import.meta.env.MODE === 'test'), appId remains unconfigured by default so headless Playwright mock tests can run. +- In production builds (import.meta.env.MODE === 'production'), appId defaults to cmtqbf5zo013w0cky3r0jqjca. +- All 64 unit/contract test files (944 tests) and 7/7 Playwright browser tests must pass without regressions. diff --git a/.agent/context/20260910T050000Z-worker-runtime-deployment.md b/.agent/context/20260910T050000Z-worker-runtime-deployment.md new file mode 100644 index 0000000..8757bac --- /dev/null +++ b/.agent/context/20260910T050000Z-worker-runtime-deployment.md @@ -0,0 +1,83 @@ +# Session Context: Worker runtime deployment repair + +## Date/time + +- UTC: 2026-09-10T05:00:00Z + +## User goal + +Repair the deployed OneShot flow that accepts intents but leaves them in +`AUTHORIZING`, then finish the repository delivery loop with Gate A, CI, Gate B, +and a pull request for human review. + +## Diagnosis + +- Cloud Run service `oneshot-api` is ready and persists intents successfully. +- Cloud Run service `oneshot-worker` is not ready. Revision + `oneshot-worker-00001-dj4` exited with `ERR_MODULE_NOT_FOUND` for + `@privy-io/node` before listening on port 8080. +- `Dockerfile.worker` copied the built workspace and then ran root-level + `pnpm prune --prod`. In this workspace layout the prune removed a runtime + dependency required by `apps/worker/dist/runtime.js`. (Fixed in PR #61). +- In addition, Privy Server Wallets do not support direct relayer broadcasting + via `sendTransaction` (`eth_sendTransaction`) on custom EVM chains like Arc + Testnet (`eip155:5042002`), throwing HTTP 401 `App is not authorized to transact + on chain eip155:5042002`. Custom EVM networks require signing the transaction + via Privy Server Wallet (`signTransaction` / `eth_signTransaction`, which + strictly evaluates Privy Policies) and broadcasting the signed transaction via + Arc JSON-RPC (`sendRawTransaction`). +- `classifyTransportError` only inspected `error.code`. Errors with HTTP status + codes like 400 (Privy policy violation) or 401 were falling through to ambiguous + `MALFORMED_RESPONSE` -> `UNKNOWN`. Routing `status`/`statusCode` through + `classifyHttpStatus` correctly treats 4xx client errors as `PRE_BROADCAST` -> + `LOCAL_VALIDATION_FAILED` -> `DEFINITELY_NOT_SUBMITTED` -> `FAILED_SAFE`. +- Cloud Run scaled `oneshot-worker` to 0 because `minScale` was unset (defaults to 0) + and workers receive no inbound traffic. The service requires `--min-instances 1` + and `--no-cpu-throttling`. + +## Changes + +- `Dockerfile.worker`: keep the already-installed workspace runtime dependency + graph instead of pruning it from the final image (PR #61). +- `apps/worker/src/runtime-config.ts`: make the remote Subgraph MCP endpoint + optional so the existing deployment-pinned The Graph Gateway path can be used + when no separately hosted MCP server exists (PR #61). +- `packages/privy-adapter/src/privy-wallet-provider.ts`: add fallback from + `sendTransaction` to Privy `signTransaction` and Arc RPC `sendRawTransaction` + when the chain is unauthorized for direct Privy relayer broadcasting. +- `packages/arc-adapter/src/failure-taxonomy.ts`: inspect `error.status` and + `error.statusCode` in `classifyTransportError` so HTTP status rejections are + classified via `classifyHttpStatus`. +- Unit tests added covering both improvements in `privy-wallet-provider.test.ts` + and `failure-taxonomy.test.ts`. + +## Verification + +- `pnpm --filter @oneshot/arc-adapter test`: PASS, 9 files / 195 tests. +- `pnpm --filter @oneshot/privy-adapter test`: PASS, 8 files / 122 tests. +- `pnpm --filter @oneshot/worker test`: PASS, 5 files / 33 tests. +- `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, and + `pnpm check:generated`: PASS. +- `pnpm test`: PASS, 64 files and 947 tests. +- `pnpm --filter @oneshot/web test:browser`: PASS, 7 tests. +- `pnpm build`: PASS across all packages. +- `markdownlint-cli2`: PASS, 133 Markdown files. +- Live Arc Testnet settlement via Privy `signTransaction` + Arc RPC `sendRawTransaction` + verified mined on-chain at block 61339895 (Tx: `0x596e86170251e597d5edc87c05395e7ee9831ed79a933171ea839e5bba7d8d0b`). + +## Safety and invariant notes + +- Zero double-payment guarantee preserved. +- Policy enforcement preserved: Privy policy `balx3rtrpns3gnvhz3n32dml` is strictly + evaluated on `signTransaction` before any raw transaction is broadcast. +- The Graph remains non-authoritative candidate discovery. The Recovery Agent + remains advisory with `settlementPermission: NEVER`; Arc receipt + verification remains authoritative. + +## Git and gate state + +- Branch: `fix/worker-runtime-deployment` +- Base: `origin/develop` at `cd7439058f94f1128bae70fac4017039a45d3d68` (PR #61) +- Gate A: PENDING +- Pull request: NOT OPEN +- Gate B: NOT RUN diff --git a/.agent/context/20260910T102737Z-recovery-hardening.md b/.agent/context/20260910T102737Z-recovery-hardening.md new file mode 100644 index 0000000..a5959f2 --- /dev/null +++ b/.agent/context/20260910T102737Z-recovery-hardening.md @@ -0,0 +1,144 @@ +# Session Context: recovery hardening + +## Date/time + +- UTC: 2026-09-10T10:27:37Z + +## User goal + +Implement requested recovery, provider identity, MCP, authentication, and observability hardening in sequence, with a commit and fresh FreePi Gate A review after each milestone. Create a PR only after the final requested milestone passes review. + +## Original prompt/request + +Create a new branch and implement parts 1-4 and 7-8 from the repository audit: true hashless recovery, provider identity persistence, recovery receipt verification, production MCP enforcement, production authentication hardening, and operational metrics. After part 1, commit and run `npx.cmd free-pi-cli` using the Gate A review prompt. Continue only after an explicit PASS; repeat through part 8, then create a PR. + +## Assumptions + +- Work starts from clean `develop` at `cd7439058f94f1128bae70fac4017039a45d3d68`. +- The short-lived branch is `milestone/recovery-hardening` and targets `develop`. +- Arc Mainnet activation remains disabled; this work is testnet/recovery hardening only. +- Each milestone may include focused tests and documentation needed to make the behavior unambiguous. + +## Plan + +1. Implement and test true hashless recovery; commit and run Gate A. +2. Implement and test durable provider identity; commit and run Gate A. +3. Harden recovery receipt verification and evidence metadata; commit and run Gate A. +4. Enforce production Subgraph MCP admission; commit and run Gate A. +5. Harden production Privy authorization; commit and run Gate A. +6. Implement operational metrics; commit and run Gate A. +7. Run full validation, create draft PR, wait for CI, and run Gate B only after exact-head CI is green. + +## Key decisions + +- Recovery candidates remain non-authoritative until independently verified by Arc and committed through OneShot CAS. +- Provider request identity is durable and stable per business intent; it supplements, not replaces, OneShot settlement ownership. +- Gate reviews receive only immutable Git identities, acceptance criteria, and safe check summaries; no secrets or ignored files. + +## Files/components touched + +- Milestone 1 changes are limited to `apps/worker`, `packages/reconciliation`, + and the focused reconciliation integration test. +- The staged candidate verifier performs read-only Arc receipt verification for + Graph candidates and refuses to substitute evidence for zero, failed, or + multiple matching candidates. +- Milestone 2 persists the adapter-derived Privy request identity on the owned + attempt before submission, exposes it to recovery, and rejects committed + recovery packs that lack that durable identity instead of inventing a pack + reference. +- Milestone 3 makes recovery carry the verified Arc Transfer log index and + routes known-identity receipt evidence through `verifyReceipt`; mismatched + receipts remain non-authoritative instead of being labeled final success. +- Milestone 4 requires an explicit Subgraph MCP recovery port in production + composition and a configured HTTPS MCP endpoint in the executable runtime; + the lower-level client fallback remains available only outside production. +- Milestone 7 rejects service bearer tokens shorter than 16 characters, + requires explicit opt-in for wildcard operator access (including mixed + allowlists), and rejects verified access tokens whose subject is not a Privy + DID. +- Milestone 8 adds an append-only operational metric event table and records + duplicate requests, CAS conflicts, policy denials, ambiguous provider + outcomes, and reconciliation target states without including sensitive + payloads or changing settlement authority. +- `docs/RECOVERY_HARDENING.md` documents the cumulative recovery, runtime, + authentication, and metrics contracts; the README links to it. + +## Commands/checks + +- `git switch -c milestone/recovery-hardening` - passed. +- Baseline commit: `cd7439058f94f1128bae70fac4017039a45d3d68`. +- `pnpm.cmd exec prettier --write packages/reconciliation/test/service-integration.test.ts` - passed. +- `pnpm.cmd --filter @oneshot/reconciliation typecheck` - passed. +- `pnpm.cmd --filter @oneshot/reconciliation test -- service-integration.test.ts` - passed (8 files, 85 tests). +- Changed-file Prettier check, reconciliation lint/typecheck/test/build, and + worker typecheck/test all passed (reconciliation 85 tests; worker 33 tests). +- Package-wide reconciliation `verify` is not green on this Windows checkout + because 12 unchanged documentation files retain pre-existing CRLF/Prettier + differences; no unrelated docs were reformatted. +- Milestone 2 checks passed: Privy adapter tests 122, worker tests 35, + reconciliation tests 85, storage tests 8; Privy adapter, worker, storage, + and reconciliation lint/typecheck all passed. +- Milestone 3 checks passed: worker tests 35, reconciliation tests 85, Privy + adapter tests 122, storage tests 8; worker and reconciliation typecheck plus + all four package lint checks passed. +- Milestone 4 focused checks passed: worker tests 36, reconciliation tests 85; + worker/reconciliation typecheck and lint passed. The runtime config now + fails closed without `ONESHOT_SUBGRAPH_MCP_ENDPOINT`. +- Milestone 7 focused checks passed: API tests 46; API typecheck and lint + passed. The bearer minimum, wildcard opt-in, and Privy subject-shape checks + are now enforced at runtime. +- Milestone 8 storage checks passed: unit tests 8; storage typecheck and lint + passed. PostgreSQL integration could not start because this machine has no + working container runtime; the integration scenario is present for CI. +- Initial PR CI exposed two integration-test defects: the rollback fixture used + the now-applied migration version 004, and the provider-identity test omitted + the required AUTHORIZED transition. Both were corrected by using temporary + migration version 005 and authorizing before claim; focused local checks pass. + +## External-doc findings + +- Repository policy requires FreePi Gate A per immutable candidate tree after each candidate milestone and Gate B on the exact PR head after required CI. +- The Graph is candidate discovery only; Arc verification and OneShot state remain authoritative. + +## Unresolved questions + +- None blocking; implementation details will follow existing interfaces and tests. + +## Git and PR state + +- Branch: `milestone/recovery-hardening` +- Base: `develop` at `cd7439058f94f1128bae70fac4017039a45d3d68` +- Commit: `26b7dca` (Parts 1-4, Part 7, and Part 8 pushed); documentation and + integration-test corrections are currently unstaged +- PR: draft [#63](https://github.com/SWOFART/OneShot/pull/63) +- CI: initial policy, Markdown/Mermaid, browser, and deployment checks passed; + the combined lint/typecheck job failed only in PostgreSQL integration on two + tests, now corrected locally + +## Review gates + +- Gate A: Part 1 PASS on reviewed tree `201c93c3c2fc32fe63ded42a714ac8546719a624`; + committed as `a8180f1` and pushed. +- Gate A: Part 2 PASS on reviewed tree `e56f737e94e254c5c70abadd8c4b863d2dca154a`; + committed as `27234e2` and pushed. +- Gate A: Part 3 PASS on reviewed tree `511ee2bbc5804639ae21f8c12e8a9de13c58a52a`; + committed as `6d0497c` and pushed. +- Gate A: Part 4 PASS on reviewed tree `b47f1df27169aae76ca7a35c694a0922565b7fd5`; + committed as `608a9ce` and pushed. +- Gate A: Part 7 FAIL on reviewed tree `95d1e5aa50190b275189af2a225d557e779fdaca`; + mixed allowlists containing `*` bypassed the opt-in. Fixed with a regression + test; fresh review required for candidate tree + `ee31a6d1cd660a14b61e72266455c2974a4c3f79`. +- Gate A: Part 7 PASS on reviewed tree `f104e10a92c557e6d4a821fcd53ba0da28dbcb35`; + committed as `d553ae4` and pushed. +- Gate A: cumulative Parts 1-4 and 7-8 PASS on tree + `3d219ab224d659b5c549d83341bcc837396d66b6`, committed as `26b7dca` and + pushed; the review covered the complete diff against `develop`. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Commit the documentation and integration-test corrections after validation. +2. Push the fix and wait for all required PR checks, including PostgreSQL + integration, to pass. +3. Run Gate A again for the new exact tree, then Gate B on the exact PR head. diff --git a/.agent/context/20260910T143008Z-settlement-safety-hardening.md b/.agent/context/20260910T143008Z-settlement-safety-hardening.md new file mode 100644 index 0000000..b1560d8 --- /dev/null +++ b/.agent/context/20260910T143008Z-settlement-safety-hardening.md @@ -0,0 +1,84 @@ +# Session Context: settlement safety hardening + +## Date/time + +- UTC: 2026-09-10T14:30:08Z + +## User goal + +Create a new branch and fix the six correctness issues identified in the repository/deployment review: safe-disable outbox loss, authorization-unavailable stranding, missing FAILED_SAFE retry path, permissive production composition, fabricated recovery submission identity, and unsafe metric failure handling. + +## Original prompt/request + +"okay create a new branch and start fixing this 6 issues" + +## Assumptions + +- Work starts from the merged `origin/develop` at `c1404056239358317006b2ae9fb9e6517dc51b64`. +- The branch is focused on the six correctness issues; deployment automation and documentation cleanup are follow-up scope unless required to specify the new behavior. +- Arc Mainnet remains disabled and no live funds are used; tests use isolated fakes or PostgreSQL integration fixtures. + +## Plan + +1. Inspect ledger, worker, recovery, composition, schema, and existing failure tests. +2. Implement the smallest coherent durable-state changes for all six issues. +3. Add focused tests covering safe-disable, authorization outage, safe failure retry policy, production fail-closed composition, provider identity binding, and metric failure isolation. +4. Run formatting, lint, typecheck, build, unit tests, and applicable PostgreSQL/failure-injection matrix cases. +5. Stage only intended files, capture Gate A identities, and prepare for human review; do not merge. + +## Key decisions + +- Preserve `1 business intent -> N attempts -> <=1 committed settlement`. +- `UNKNOWN` remains reconciliation-only and never becomes a retry permission. +- A `FAILED_SAFE` retry, if implemented, must be an explicit policy-authorized new attempt for the same business intent and must be atomically guarded. +- Safe disable must defer work without acknowledging it as delivered. +- Metric recording must not be able to abort a ledger transaction. + +## Files/components touched + +- `apps/worker/src/worker.ts` and worker tests: preserve disabled submission jobs and back off authorization-unavailable jobs; queue adapters signal retryable delivery failures. +- `packages/storage-postgres/src/ledger.ts` and storage integration tests: record authorization outages, isolate metric failures with savepoints, and add an atomic FAILED_SAFE retry primitive. +- `apps/worker/src/composition.ts` and composition/runtime tests: require production authorization, provider identity, and recovery dependencies. +- `apps/worker/src/recovery-bridge.ts` and P4 tests: bind recovery envelopes to durable provider references and fail closed when absent. +- `docs/RECOVERY_HARDENING.md` and `apps/worker/FAILURE_CATALOG.md`: document metric isolation and safe FAILED_SAFE retry semantics. + +## Commands/checks + +- `git switch -c fix/settlement-safety-hardening origin/develop` - passed. +- Initial branch was clean; branch tracks `origin/develop`. +- `pnpm.cmd typecheck` - passed. +- Focused worker tests - passed (5 files, 37 tests). +- Storage package tests - passed (2 files, 8 tests; integration cases skipped without PostgreSQL). +- `pnpm.cmd test` - passed (64 files, 957 tests, build included). +- `pnpm.cmd scenarios:invariants` - passed (7/7 isolated at-most-once scenarios). +- `pnpm.cmd --filter @oneshot/web test:browser` - passed (7 Chromium tests; existing large Privy chunk warning remains). +- `pnpm.cmd lint` - passed. +- `pnpm.cmd format:check` - passed. +- `pnpm.cmd check:generated` - passed. +- `TEST_POSTGRES=1 pnpm.cmd --filter @oneshot/worker test:integration` - blocked: no working container runtime (Docker/Testcontainers unavailable); suites failed during container setup, not assertions. + +## External-doc findings + +- Repository policy and the idempotency/failure-injection skills require durable state assertions, external-effect boundary tests, and fresh Gate A/B review before merge. + +## Unresolved questions + +- The public API intentionally still has no generic retry endpoint. A policy/authorization layer must call `scheduleFailedSafeRetry` when product requirements define the operator approval workflow. + +## Git and PR state + +- Branch: `fix/settlement-safety-hardening` +- Base: `origin/develop` at `c1404056239358317006b2ae9fb9e6517dc51b64` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Complete implementation and focused tests. +2. Re-run validation after every candidate-tree change, then follow Gate A and PR/CI/Gate B policy. diff --git a/.agent/context/20260910T153514Z-reconciliation-retry.md b/.agent/context/20260910T153514Z-reconciliation-retry.md new file mode 100644 index 0000000..be01a16 --- /dev/null +++ b/.agent/context/20260910T153514Z-reconciliation-retry.md @@ -0,0 +1,97 @@ +# Session Context: reconciliation retry + +## Date/time + +- UTC: 2026-09-10T15:35:14Z + +## User goal + +Restore live Arc recovery by using the published Graph Studio subgraph, identify +remaining blockers, and finish fixes through the repository review gates. + +## Original prompt/request + +The Arc deployment is not served by The Graph Network everywhere. Verify that +Graph Studio returns indexed Arc data and change the recovery approach if the +official Subgraph MCP cannot query it; continue fixing the live failure. + +## Assumptions + +- Graph Studio GraphQL is an operational read-only recovery source, but it is + not evidence of official Subgraph MCP qualification. +- Production validation must keep settlement submissions disabled. + +## Plan + +1. Allow one reconciliation job to be pending per intent and permit a new + read-only retry after the previous job is delivered. +2. Run the full validation stack, Gate A, CI, and Gate B on a separate PR. +3. Deploy the reviewed API image and verify recovery with wallet nonce unchanged. + +## Key decisions + +- PR #65 implemented direct Studio GraphQL and dynamic Arc head lookup; a human + merged it while live validation was in progress. +- Live diagnostics found two additional independent blockers: the worker service + account lacked Vertex invocation permission, and reconciliation job keys were + unique for the full lifetime of an unchanged UNKNOWN intent. +- Added the minimal `roles/aiplatform.user` binding to the existing worker + service account. AI remains advisory with settlement permission `NEVER`. +- Retry keys gain a monotonically derived generation only after no manual + reconciliation job is pending. This retains concurrent deduplication without + reopening delivered outbox rows or weakening settlement idempotency. + +## Files/components touched + +- `packages/storage-postgres/src/ledger.ts`: admit a later reconciliation retry + while rejecting duplicate pending jobs. +- `packages/storage-postgres/test/ledger.integration.test.ts`: cover delivered + retry and concurrent enqueue deduplication. + +## Commands/checks + +- Direct Studio GraphQL query - PASS; deployment indexed Arc data and returned + real transfer candidates. +- PR #65 checks and Gate A/B - PASS; human merged as `95709a8`. +- Cloud Build `35f9481f-dcde-4152-94cf-87772b4cdba0` - PASS. +- Worker revision `oneshot-worker-00006-sh5` - live/ready PASS with submissions + disabled and the Studio query URL configured. +- Production outbox read-only diagnostic - prior reconciliation jobs are + `DELIVERED`; current ledger correctly returns `queued: false` while their + lifetime key prevents a retry. +- `pnpm --filter @oneshot/storage-postgres typecheck` - PASS. +- `pnpm --filter @oneshot/storage-postgres lint` - PASS. +- `pnpm --filter @oneshot/storage-postgres test` - PASS, 2 files / 8 tests. +- `git diff --check` - PASS. + +## External-doc findings + +- The Graph Studio endpoint serves the Arc deployment directly; The Graph + Network Gateway returns subgraph-not-found for its deployment ID. Official MCP + qualification therefore remains `NOT VERIFIED`. + +## Unresolved questions + +- The PostgreSQL integration test requires CI because no local container runtime + is available. + +## Git and PR state + +- Branch: `fix/reconciliation-retry` +- Base: `origin/develop` at `95709a8256a11f2c544b1d406e906ba8ea2d7867` +- Commit: uncommitted +- PR: not created +- CI: not started + +## Review gates + +- Gate A: NOT RUN for the reconciliation retry tree. +- Gate B: NOT RUN. + +## Handoff/next steps + +1. Run full repository checks and stage the exact candidate tree. +2. Run fresh Gate A, commit/push, open a draft PR, wait for CI, then run Gate B. +3. Deploy the reviewed API image, enqueue recovery, verify Graph/Vertex evidence, + confirm wallet nonce did not change, scale worker back to zero, and delete the + temporary diagnostic Cloud Run job. diff --git a/.agent/context/20260910T155019Z-brand-frontend.md b/.agent/context/20260910T155019Z-brand-frontend.md new file mode 100644 index 0000000..2684857 --- /dev/null +++ b/.agent/context/20260910T155019Z-brand-frontend.md @@ -0,0 +1,198 @@ +# Session Context: brand frontend repaint and searchable wallet picker + +## Date/time + +- UTC: 2026-09-10T15:50:19Z + +## User goal + +Bring the shipping web frontend onto the brand direction settled on the design +canvas (commit-ring mark, forest and signal-green palette, Rubik 300, diagonal +hero cut), and replace Privy's oversized default wallet list with a searchable +picker the operator can actually scan. + +## Original prompt/request + +"make front that fits dsign we have in conversation claude design. What i would +alredy change is list of wallets, that is avalaible to use to log in. the list +is too big, so i'd add search possibility in this list. This is page while +transaction sent. Right now we are working on sowing the proof of transaction, +but page is ready, i guess you can find it in code. I need you to rework +frontend fully, so it follows our design" + +## Assumptions + +- The transaction-proof work in flight is a separate change; this one alters + `IntentStatusView`'s appearance only, never its data flow. +- Arc Testnet `eip155:5042002` remains the only network, per repository policy. +- Both slice packages stay read-only; their zero-interactive-element contract + is a constraint on the repaint, not a thing to renegotiate. + +## Plan + +1. Write the implementation plan from the approved design. +2. Build `packages/brand` (tokens, mark, hero geometry, fonts) test-first. +3. Repoint all three stylesheets at the shared tokens. +4. Repaint the shell; add the theme toggle and pre-paint guard. +5. Build the searchable wallet picker on Privy's headless SIWE flow. +6. Local checks, Gate A, push, CI, Gate B. + +## Key decisions + +- Dark board is the default, with the light board shipped as a `data-theme` + swap and a nav toggle. Rejected: shipping only one board, which would strand + half the canvas. +- One page, repainted in place. Rejected: splitting landing and console, which + would add routing and churn the composition tests for no design gain. +- Our own searchable wallet picker on `useLoginWithSiwe`. Rejected: trimming + `appearance.walletList`, which shortens the list but cannot add search; + Privy's modal has no search hook. +- The hero clip paths are derived by a pure function rather than hardcoded at + 1032x268 as on the canvas, so the hero is not size-locked. + +## Files/components touched + +- `docs/superpowers/specs/2026-09-10-brand-frontend-design.md` - approved design. +- `.superpowers/sdd/2026-09-10-brand-frontend/*` - the 12-task implementation + plan and its task briefs/reports. +- `packages/brand/*` (new package) - `tokens.css` (the repository's only + literal-colour file), `fonts.css`, `CommitRing.tsx` (the mark, with its + reduced cut), `heroCut.ts` (pure clip-path geometry), `index.ts`, and tests + (`commit-ring.test.tsx`, `hero-cut.test.ts`, `tokens.test.ts` - the + two-theme contrast audit). +- `apps/web/src/styles.css` - repainted onto `@oneshot/brand` tokens (824 + lines changed). +- `apps/web/src/theme.ts` and `apps/web/index.html` - `data-theme` selection + with a pre-paint inline guard duplicating `theme.ts` deliberately (documented + in the README's new Brand section). +- `apps/web/src/App.tsx`, `apps/web/src/components/LoginGate.tsx` - shell + repaint and wallet-picker wiring. +- `apps/web/src/components/Hero.tsx` (new) - the diagonal hero cut; fixed in + Task 12 to measure with `useLayoutEffect` instead of `useEffect` (was + flashing `.hero-plain` before `.hero-cut` on every desktop load, since this + app is pure client-side render with no SSR). +- `apps/web/src/components/WalletPicker.tsx` (new), `apps/web/src/auth/eip6963.ts` + (new, EIP-6963 wallet discovery with validated announcements), + `apps/web/src/auth/wallet-catalogue.ts` (new), `apps/web/src/auth/session.ts`, + `apps/web/src/auth/privy-session.tsx` - the searchable wallet picker on + Privy's headless SIWE flow; fixed in Task 12 to race each + `wallet.provider.request(...)` call (`eth_requestAccounts`, `personal_sign`) + against a 120s timeout (`WALLET_REQUEST_TIMEOUT_MS`), so a wallet that never + responds rejects instead of stranding `WalletPicker` in `busy` state + forever. The timeout's own rejection carries a fixed generic message with no + wallet data, preserving `WalletPicker`'s existing sanitized-error contract. +- `apps/web/test/*` - `app-brand.test.tsx`, `eip6963.test.ts`, `hero.test.tsx`, + `login-gate.test.tsx`, `styles.test.ts` (fails the build on a literal colour + in this app's stylesheet), `theme.test.ts`, `wallet-picker.test.tsx`, and + (new in Task 12) `privy-session.test.tsx` - fake-timer proof that a + never-resolving wallet provider rejects at exactly the timeout rather than + hanging, and that the rejection message contains none of the wallet's + identifying fields. +- `packages/settlement-ui/src/styles.css`, `packages/recovery-ui/src/styles.css` + - repointed at the shared brand tokens; both packages' zero-interactive- + element contract preserved. +- `apps/web/README.md` (Task 12) - added a "Brand" section documenting + `@oneshot/brand`, the literal-colour build guard, and the pre-paint theme + guard duplication. +- `.agent/context/20260910T155019Z-brand-frontend.md` (this file, Task 12) - + filled in with real verification results and head SHA. + +## Commands/checks + +- `pnpm install --frozen-lockfile` - pass; `@privy-io/react-auth` 3.6.1 was + declared but not installed locally before this. +- `git merge --ff-only origin/develop` - pass, `c140405` to `95709a8`. + +### Task 12 - whole-repository verification + +2026-09-11, local machine, Node v22.16.0/pnpm 11.19.0. The repo's +`.node-version` pins 24.19.0, so pnpm printed an "Unsupported engine" warning on +every command below; none failed because of it. + +- `pnpm format:check` - **pass**. "All matched files use Prettier code style!" +- `pnpm lint` (`eslint .`) - **pass**, no output, exit 0. +- `pnpm typecheck` (`tsc -b --pretty false`) - **pass**, no output, exit 0. +- `pnpm build` - **pass**. `tsc -b`, `@oneshot/recovery-ui` build, + `@oneshot/settlement-ui` build, and `@oneshot/arc-subgraph` codegen+build all + succeeded. +- `pnpm test` (runs `pnpm build` first, then `vitest run --exclude + apps/web/browser/**` at the repo root) - **pass**. 69 test files, 987 tests, + 0 failures. `apps/web` alone: 16 test files, 86 tests (was 15/84 before this + task's two fixes; `privy-session.test.tsx` is new and added 2). +- `pnpm --filter @oneshot/web test:browser` - **could not run in this + environment**. `typecheck:browser` and `vite build --mode test` both passed, + but the runner (`node scripts/run-browser-tests.mjs`, which sets + `PW_DISABLE_TS_ESM=1` to dodge a Playwright 1.63/Node 24/Windows hang) then + failed loading `playwright.config.ts` under plain Node ESM: + `TypeError: Unknown file extension ".ts" ... ERR_UNKNOWN_FILE_EXTENSION`. + This machine's Node (v22.16.0) predates this repo's expected runtime + (`.node-version` pins 24.19.0) and does not strip TypeScript types by + default. Independent of that, no Playwright browser binaries are installed + anywhere on this machine (`~/Library/Caches/ms-playwright` and a filesystem + search for any `ms-playwright`/`chromium` cache both came back empty), so + the gate would still fail at browser launch even with a matching Node + version. Not reported as passed; needs a matching Node runtime and + `playwright install chromium` on a machine authorized for that, or CI. +- Literal-colour grep (`grep -rn "#[0-9a-fA-F]\{6\}" apps/web/src + packages/settlement-ui/src packages/recovery-ui/src --include='*.css' + --include='*.ts' --include='*.tsx'`) - **pass**, exactly the two expected + lines, both the documented Privy config exception: + + ```text + apps/web/src/auth/privy-session.tsx:58: theme: '#0a0a0a', + apps/web/src/auth/privy-session.tsx:59: accentColor: '#00dc5f', + ``` + +## External-doc findings + +- `@privy-io/react-auth` 3.6.1 typings (installed, read directly): + `RuntimeLoginOverridableOptions` has no `walletList`, so `login()` cannot be + narrowed to one wallet; `useLoginWithSiwe()` exposes `generateSiweMessage`, + `generateSiweNonce`, and `loginWithSiwe`, which is the supported headless + path a custom picker needs. `appearance.walletList` accepts 24 + `WalletListEntry` ids and only reorders or trims Privy's own modal. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `milestone/brand-frontend` +- Base: `develop` at `95709a8` +- Head at Task 12 verification (2026-09-11): + `e4b0c25894e9e194e0b5e10a78f310978eeaa126` - + "fix(web): time out unresponsive wallet requests during sign-in", the second + of two fixes carried into this task from earlier review (`29c8474` fixed the + hero's pre-paint flash first, on top of `375c3c1`, the last commit of Task + 11). This record and the README Brand section land in one further commit on + top of `e4b0c25` ("docs(web): document the brand package and the theme + guard"); `git log -1` on this branch shows that commit's exact SHA. +- 23 commits total on this branch since `develop` (`7a48310`, the design + document, through `e4b0c25`), covering the full 12-task plan in + `.superpowers/sdd/2026-09-10-brand-frontend/`. +- Working tree: clean at every commit made in this task; nothing left staged + or unstaged. +- PR: not created. Per the task-12 brief and `.agent/IMPLEMENTATION_LOOP.md`, + opening the draft PR against `develop`, pushing, and running FreePi Gate + A/B are handoff steps for a human at the end of this session, not run here. +- CI: not run (no push). + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. ~~Commit the design document and this record.~~ Done (`7a48310`). +2. ~~Produce the implementation plan with the writing-plans skill.~~ Done + (`0b7ca90`, `d6ce32c`); all 12 tasks in + `.superpowers/sdd/2026-09-10-brand-frontend/` are implemented as of + `e4b0c25`, with this record and the README Brand section landing in one + more commit on top. +3. Per `.agent/IMPLEMENTATION_LOOP.md` section 3 (not run in this task, by + instruction): capture immutable Gate A evidence, run FreePi Gate A in a + fresh read-only process, push `milestone/brand-frontend`, open the draft PR + against `develop`, wait for required CI, then run Gate B against the exact + head SHA. A human authorizes the merge; no agent merges. diff --git a/.agent/context/20260910T161646Z-graph-studio-agent-context.md b/.agent/context/20260910T161646Z-graph-studio-agent-context.md new file mode 100644 index 0000000..6b7dd65 --- /dev/null +++ b/.agent/context/20260910T161646Z-graph-studio-agent-context.md @@ -0,0 +1,128 @@ +# Session Context: Graph Studio agent context + +## Date/time + +- UTC: 2026-09-10T16:16:46Z + +## User goal + +Implement the Graph Studio recovery plan: use truthful direct Studio GraphQL +transport for Arc Testnet, pass validated data to the recovery agent, preserve +the deterministic safety boundary, and keep optional MCP metadata truthful. + +## Original prompt/request + +Determine whether Arc Testnet's Studio-only support means official Subgraph MCP +cannot work, whether direct Studio queries still satisfy The Graph requirements, +and, if so, create a new-branch fix plan before context ends. + +## Assumptions + +- Target prize is ETHOnline 2026 Best AI Tooling or AI Use Case with The Graph. +- Subgraph Studio is acceptable for the hackathon demo but is not presented as a + production-grade decentralized Network endpoint. +- Live checks keep settlement submissions disabled. + +## Plan + +1. Follow `docs/GRAPH_STUDIO_AGENT_RECOVERY_PLAN.md` after PR #66 is human-merged. +2. Identify direct Studio observations truthfully, pass only validated normalized + evidence to Vertex, and preserve the deterministic safety boundary. +3. Repair API/outbox/Cloud Run background execution, capture a fresh live trace, + correct sponsor docs, and complete Gate A/CI/Gate B. + +## Key decisions + +- Official ETHOnline requirements explicitly accept live Subgraph queries with + an API key from Subgraph Studio. MCP is optional for the selected AI track. +- Hosted Subgraph MCP queries deployments on The Graph Network. The current Arc + deployment is available in Studio but returns `subgraph not found` through the + Network Gateway, so MCP stays inactive for Arc Testnet. +- The active flow is Studio GraphQL -> validated normalized evidence -> Vertex + context -> deterministic core -> Arc receipt verification. +- Direct GraphQL results must not be labeled or claimed as MCP results. + +## Files/components touched + +- `docs/GRAPH_STUDIO_AGENT_RECOVERY_PLAN.md`: implementation plan and gate plan. +- `.agent/context/20260910T161646Z-graph-studio-agent-context.md`: durable context. +- Runtime, reconciliation, API, generated contracts, storage projection, UI, + tests, runbooks, and qualification documents listed by the implementation + plan were updated on the implementation branch. + +## Commands/checks + +- Official ETHOnline prize page review - PASS; Studio API-key queries explicitly + meet the live-provider requirement. +- The Graph supported-network page - PASS; Arc Testnet is listed as + `eip155:5042002`. +- Direct Studio GraphQL - previously PASS with fresh `_meta` and real transfers. +- Network Gateway query - previously FAIL with `subgraph not found`. +- PR #66 CI and Gate A/B - PASS on head `206ad437da5fb3a059ea21723d2de8f27da907b7`; + human-merged into `develop` at `c1c720a128f9f76aff1f5e2b715684c24f4c47d4`. +- Cloud Build `9418b788-88aa-40b2-87ab-1ff47864017b` - PASS for the exact PR #66 API image. +- API revision `oneshot-api-00006-2cf` - health PASS after explicitly preserving + the pre-existing wildcard-auth behavior. +- API POST `/reconcile` - root cause resolved: Fastify throws `FST_ERR_CTP_EMPTY_JSON_BODY` + when requests supply `Content-Type: application/json` with an empty string body; + calling without empty JSON body returns 202 `queued: true`. +- Direct exact-image ledger diagnostic - PASS; both stale UNKNOWN intents queued + a retry. +- Worker health/readiness - PASS; submissions disabled; wallet nonce `0x9`. +- Cloud Run worker background CPU - PASS; `run.googleapis.com/cpu-throttling: false` + and `minScale: 1` confirmed active on service. +- Persisted recovery view - still stale, showing prior Graph unavailable and + Vertex HTTP 403; fresh job consumption not yet proven. + +## Implementation checks + +- `@oneshot/contracts` generated-contract check - PASS. +- `@oneshot/reconciliation` build - PASS. +- Reconciliation, API, worker runtime-config, and web recovery tests - PASS + after updating the worker test expectation for explicit source selection. +- Workspace typecheck - PASS. +- Full repository format/lint/test and demo E2E - PASS; browser acceptance 7/7 + - PASS; PostgreSQL integration suites ran with all 25 cases skipped because + `TEST_POSTGRES` is not enabled; live acceptance remains pending. +- The branch was merged with current `origin/develop` at + `dd79e71be187a86671fd127613732dd99f0e1529` (PR #67) before final validation. + +## External-doc findings + +- ETHGlobal qualification says an agent may use Subgraphs, Subgraph MCP, or + Substreams and explicitly gives Studio API-key queries as qualifying live data: + . +- The Graph documents Studio endpoints as testing/staging and rate-limited: + . +- Subgraph MCP is documented for Subgraphs on The Graph Network: + . +- Arc Testnet is listed at + . + +## Resolved questions + +- API 500 root cause: Fastify error handler falls through to 500 on `FST_ERR_CTP_EMPTY_JSON_BODY` + when clients send empty body with `Content-Type: application/json`. +- Cloud Run CPU throttling: Service has `cpu-throttling: false` and `minScale: 1` explicitly set. + +## Git and PR state + +- Branch: `plan/graph-studio-agent-recovery` +- Base lineage: remote plan branch includes `develop` at + `c1c720a128f9f76aff1f5e2b715684c24f4c47d4` (incorporates PR #66) +- Commit: uncommitted implementation and documentation changes +- PR: not created for this plan branch; prerequisite PR #66 is merged + +## Review gates + +- Gate A: NOT RUN for this implementation tree. PR #66 Gate A passed independently. +- Gate B: NOT RUN for this implementation tree. PR #66 Gate B passed independently. + +## Handoff/next steps + +1. Run the remaining repository checks and inspect the exact candidate tree. +2. Run Gate A, then commit/push and open a draft PR only when authorized by the + implementation workflow. +3. Require CI and Gate B on the identical head before human review. +4. Run the live acceptance gate with nonce unchanged before any qualification + claim; keep the Graph verdict `NOT VERIFIED` until that trace exists. diff --git a/.agent/context/20260910T161648Z-arc-testnet-hardening.md b/.agent/context/20260910T161648Z-arc-testnet-hardening.md new file mode 100644 index 0000000..e0c3c4b --- /dev/null +++ b/.agent/context/20260910T161648Z-arc-testnet-hardening.md @@ -0,0 +1,82 @@ +# Session Context: Arc Testnet hardening + +## Date/time + +- UTC: 2026-09-10T16:16:48Z + +## User goal + +Create a new branch and fix the remaining Arc Testnet settlement problems. Arc Mainnet launches later and is explicitly out of scope. + +## Original prompt/request + +“okay lets fix arc problems. For the arc mainnet it is unavailable. Arc mainnet will be launch on 16th september, so ignore it. Create a new branch and lets fix this issues” + +## Assumptions + +- Arc Mainnet remains intentionally untouched; this branch only hardens the pinned Arc Testnet profile. +- The unrelated change in `.agent/context/20260910T143008Z-settlement-safety-hardening.md` belongs to the user and must remain outside this branch’s commit. + +## Plan + +1. Harden the Privy sign-and-raw-send fallback with pending nonce reads, gas estimation, bigint-safe quantities, serialization, and process-local duplicate collapse. +2. Extend Arc readiness with a live USDC decimals check and wallet native-gas balance check in production readiness. +3. Add focused regression and failure-boundary tests, update relevant docs, run local validation, then follow Gate A/PR/CI/Gate B. + +## Key decisions + +- Keep the existing raw fallback because Arc Testnet settlement currently needs it when the Privy relay is unavailable, but make fallback submission fail closed and bounded. +- Use hex quantities for Privy transaction fields so large integer values are not converted through JavaScript `Number`. +- Mainnet profile and activation policy are not changed. + +## Files/components touched + +- `packages/privy-adapter/src/privy-wallet-provider.ts` +- `packages/privy-adapter/test/privy-wallet-provider.test.ts` +- `packages/arc-adapter/src/readiness.ts` +- `packages/arc-adapter/src/viem-probe.ts` +- `packages/arc-adapter/test/readiness.test.ts` +- `apps/worker/src/runtime.ts` +- `packages/testkit-settlement/src/rpc-simulator.ts` +- `docs/settlement/SETTLEMENT_CONFIG_V1.md` +- `docs/settlement/PROVIDER_SETUP.md` + +## Commands/checks + +- Branch created from `origin/develop` at `95709a8256a11f2c544b1d406e906ba8ea2d7867`. +- Existing unrelated context change preserved and unstaged. +- `pnpm.cmd --filter @oneshot/privy-adapter test` - passed (127 tests). +- `pnpm.cmd --filter @oneshot/arc-adapter test` - passed (198 tests). +- `pnpm.cmd --filter @oneshot/testkit-settlement test` - passed (132 tests). +- `pnpm.cmd typecheck` - passed. +- `pnpm.cmd test` - passed (64 files, 966 tests). +- `pnpm.cmd lint` - passed. +- `pnpm.cmd format:check` - passed. +- `pnpm.cmd check:generated` - passed. +- `pnpm.cmd test:integration` - all 12 DB-backed tests skipped because no container runtime is available locally. + +## External-doc findings + +- Arc Testnet is chain `5042002`; native gas uses 18 decimals and ERC-20 USDC uses 6 decimals. Mainnet is not part of this task. + +## Unresolved questions + +- Cross-process nonce allocation for a raw-signing fallback still depends on the durable OneShot submission gate; this branch must not claim provider-level idempotency across process restarts. + +## Git and PR state + +- Branch: `fix/arc-testnet-hardening` +- Base: `origin/develop` at `95709a8256a11f2c544b1d406e906ba8ea2d7867` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Implement and test the scoped Arc Testnet hardening. +2. Do not include the unrelated pre-existing context change in any commit. diff --git a/.agent/context/20260910T213012Z-resumable-tools-plan.md b/.agent/context/20260910T213012Z-resumable-tools-plan.md new file mode 100644 index 0000000..0e7c8ca --- /dev/null +++ b/.agent/context/20260910T213012Z-resumable-tools-plan.md @@ -0,0 +1,115 @@ +# Session Context: Resumable paid-tools plan + +## Date/time + +- UTC: 2026-09-10T21:30:12Z + +## User goal + +Reshape the current plan around resumable paid tools and open a documentation PR. + +## Original prompt/request + +Reshape the project plan with the recent vision, intentions and main purpose; +plan a friendlier UI/UX with new tools, separate landing page and user cabinet; +open a PR afterward. + +## Assumptions + +- Documentation only: no runtime/UI implementation or live payment requested. +- Preserve the settlement engine and original milestone evidence. New R0–R5 + acceptance is separate from historical P0–P6 and mandatory FreePi A/B. +- One supplier and one allowlisted testnet workspace are the initial scope. + +## Plan + +1. Replace stale product roadmap and gap report with a bounded paid-job increment. +2. Align project context, public introduction, sponsor guidance and demo script. +3. Validate, obtain fresh Gate A, commit/push and open a draft PR to develop. +4. Wait for applicable CI, obtain fresh Gate B, then request human review. + +## Key decisions + +- Resume the job, not the payment; at-most-once payment, supplier-dependent + delivery, no universal exactly-once external execution claim. +- Stable task/order binding, separate delivery state, workspace isolation, + one connector and existing-result retrieval are planned before expansion. +- Separate public landing from private cabinet; tools/jobs first, hashes in + advanced details. No fictional settings or unsupported daily-budget claims. +- Routine Graph activity is read-only; known-hash success does not depend on + Graph. Current mapping has null memo correlation; ambiguous attribution holds. +- Live faults and supplier outcomes must be distinguished from offline fixtures. +- Reconcile the skill's obsolete MCP-only wording with canonical sponsor policy + and current official Studio eligibility, without lowering evidence requirements. + +## Files/components touched + +- plan.md and plan_missing_parts.md: revised roadmap and explicit new gaps. +- .agent/PROJECT_CONTEXT.md and README.md: scoped vision and delivery limits. +- milestones/README.md: original packet scope versus new increment. +- .agent/SPONSOR_REQUIREMENTS.md and sponsor-qualification skill: priorities + and consistent live Studio evidence requirements. +- docs/DEMO_SCRIPT.md: planned live paid-job demo versus existing rehearsal. +- This context record: acceptance and handoff. + +## Commands/checks + +- git pull --ff-only origin develop: already current at 86c8f86. +- Initial working tree: clean; branch is feature/resumable-agent-tools-plan. +- pnpm format:check, lint, typecheck: PASS. +- pnpm test (includes build): PASS, 66 files / 977 tests. +- pnpm check:generated and validate:fixtures: PASS. +- pnpm scenarios:invariants: PASS, 7 baseline scenarios; not proof of the new + supplier workflow. +- Changed-file markdownlint: PASS after fixing three bare documentation URLs. +- git diff --check: PASS. Only nine intended Markdown files are changed. +- Local Node is v22.23.2 versus the repository's v24.19.0 pin; checks passed + with an engine warning. Required remote CI still gates readiness. +- No new runtime behavior; browser/live/provider/DB integration checks were + not rerun locally for this documentation change. No live effects performed. + +## External-doc findings + +- Official ETHOnline 2026 sponsor pages checked 2026-09-10: + [Privy](https://ethglobal.com/events/ethonline2026/prizes/privy), + [Arc](https://ethglobal.com/events/ethonline2026/prizes/arc), + [The Graph](https://ethglobal.com/events/ethonline2026/prizes/the-graph). +- Studio live queries are accepted; meaningful data use remains required. +- Arc mainnet condition is part of the award, not a separate bonus. +- Event pool must match actual project history/registration. + +## Unresolved questions + +- Actual supplier and exact order-to-transfer binding are R0 decisions. +- Future live permissions and supplier configuration require human involvement. +- Sponsor qualification for the new workflow is not verified. + +## Git and PR state + +- Branch: feature/resumable-agent-tools-plan. +- Base: develop at 86c8f860cb7a74e9de51301a53c45d9a08361aa3. +- Commit: uncommitted; intended documentation is staged. +- PR: user explicitly instructed opening it without FreePi after the blockage. +- CI: pending remote PR creation; no CI waiver or merge authorization given. + +## Review gates + +- Gate A: first candidate passed in fresh free-pi-cli (deepseek-v4-flash). + Branch-prefix/context correction changes the tree; that verdict is obsolete. + A fresh review is required for the final candidate before push. +- Two fresh final-candidate review attempts returned HTTP 409 concurrent_session + without a verdict. All reviewer processes started by this task were closed. + Do not terminate another account session without user direction. +- Gate B: NOT RUN; requires valid Gate A, draft PR and green required CI. +- User subsequently explicitly waived FreePi for this PR: "fuck freepi just + open pr". Proceed with commit/push and a draft PR, recording A/B as waived, + not PASS. Canonical review policy is not changed for future work. +- Exact immutable review/CI evidence belongs in the PR. Subsequent context + updates must not be included in an already-reviewed candidate without new gates. + +## Handoff/next steps + +1. Commit/push the documentation and open a draft PR under the explicit waiver. +2. Record exact commit/tree, validation and waived FreePi state in the PR. +3. Leave CI and human review visible; do not claim merge readiness or merge. +4. R0 implementation follows human plan review. diff --git a/.agent/context/20260911T-arc-transfer-demo.md b/.agent/context/20260911T-arc-transfer-demo.md new file mode 100644 index 0000000..dbc21c8 --- /dev/null +++ b/.agent/context/20260911T-arc-transfer-demo.md @@ -0,0 +1,47 @@ +# Session Context: Arc transfer demo lane + +## Goal + +Make the first end-to-end demo visibly settle a small Arc Testnet USDC invoice +from the existing Privy-controlled execution wallet to a configured second +wallet, then expose the transaction and result in the job flow. Circle/x402 is +deferred to a later supplier adapter. + +## Scope + +- Make the team-operated supplier quote recipient and amount explicit runtime + configuration; no production wallet or secret is committed. +- Keep the existing Privy policy, OneShot Business Intent, and Arc settlement + path unchanged. +- Generate a stable task key in the UI so users do not invent idempotency keys. +- Add a non-chargeable quote endpoint and require the cabinet to show amount, + recipient, network, and expiry before the approval request. +- Project committed settlement identity into JobView and show a validated Arc + Testnet explorer link in Jobs. +- Add focused supplier/config/ledger/UI tests and documentation. + +## Non-goals + +- No Circle Gateway/x402 integration in this branch. +- No mainnet activation, arbitrary recipient input, or browser-controlled + payment signing. +- No claim of a third-party production supplier; the receiver is a labelled + team-operated testnet wallet until a later supplier is selected. + +## Safety assumptions + +- `ONESHOT_SUPPLIER_RECIPIENT` must equal an address in the worker's + `ONESHOT_RECIPIENT_ALLOWLIST`. +- The demo amount is integer atomic USDC and must remain below the Privy policy + cap; the deployment operator chooses the actual testnet amount. +- A committed settlement remains authoritative even if result delivery fails; + retries only retrieve the original result. + +## Validation and gates + +- Applicable matrix cases: normal job, duplicate request, conflicting task + payload, payment denial/amount boundary, downstream delivery failure, and + resume with zero additional settlement. +- Gate A required before commit; Gate B required after the PR head is green. +- Live Arc payment requires human deployment configuration and an explicitly + authorized testnet run; local tests must not broadcast funds. diff --git a/.agent/context/20260911T-brand-frontend-integration.md b/.agent/context/20260911T-brand-frontend-integration.md new file mode 100644 index 0000000..b19d170 --- /dev/null +++ b/.agent/context/20260911T-brand-frontend-integration.md @@ -0,0 +1,68 @@ +# Brand frontend integration handoff + +- Date: 2026-09-11 +- Branch: `feature/brand-frontend-integration` +- Base work retained: `feature/circle-x402-demo` at `b34035685224a4fed01828076ecf57296f8aa806` +- Integrated source: PR #76 `origin/milestone/brand-frontend` at `81230871420ab87c7f550eceebe6b31bec6bce8e` +- Gate A base: `origin/develop` at `c89cbdeb708a49bf5b71e98e87b94d2d92007d3d` + +## Objective + +Replace the old web presentation with the PR #76 brand system while keeping +the current product connections: Privy operator authentication, machine-token +fallback, intent/status/settlement/recovery clients, Tools and Jobs cabinet +flows, Circle x402 demo, and read-only recovery evidence. + +## Implemented shape + +- `@oneshot/brand` owns palette tokens, fonts, CommitRing, and responsive Hero. +- Web routes remain split: `/` public landing and `/app` authenticated cabinet. +- Both routes use the branded mark, Arc/USDC status chips, theme toggle, and + responsive Hero; the default console keeps the PR #76 shell and existing + Gate P5 tabs. +- PR #76 EIP-6963 discovery, searchable wallet picker, headless Privy SIWE, + and sanitized wallet failure handling remain enabled. +- Job workspace styles now consume brand tokens; no raw color literals or old + undefined CSS variables remain in the web stylesheet. + +## Safety boundary + +- UI remains read-only for settlement and recovery evidence. +- Login credentials and machine tokens stay in memory; no token is persisted. +- Existing intent idempotency, UNKNOWN reconciliation, Privy policy boundary, + and Circle x402 approval flow are unchanged. + +## Acceptance + +- Web unit/component suites pass. +- Web typecheck/lint/build and browser Gate P5 pass. +- Root format, lint, typecheck, test, build, and required FreePi Gates A/B are + recorded against the final remote PR head. + +## Local checks + +- `pnpm format:check` — PASS +- `pnpm typecheck` — PASS +- `pnpm lint` — PASS +- `pnpm build` — PASS +- `pnpm test` — PASS (76 files, 1,024 tests) +- `pnpm --filter @oneshot/web test:browser` — PASS (4 tests) + +## Gate A final candidate review + +- Candidate tree: `18b4574d0ce26f27d2c721c240dbbd2034ab722c` +- FreePi Gate A: `VERDICT: PASS`; reviewer tool `free-pi-cli`, model + `gpt-oss-120b-speed`, base `c89cbdeb708a49bf5b71e98e87b94d2d92007d3d`, + target staged workspace. No blocking or non-blocking findings. + +## Gate B final remote PR review + +- PR: [#77](https://github.com/SWOFART/OneShot/pull/77) +- Remote head commit: `2baf7ff9b337d8f64ecc7374ec5bdfb9a6a76340` +- Remote head tree: `18b4574d0ce26f27d2c721c240dbbd2034ab722c` +- Gate A tree matches the remote head tree exactly. +- FreePi Gate B: `VERDICT: PASS`; reviewer tool `free-pi-cli`, model + `gpt-oss-120b-speed`. No blocking or non-blocking findings. +- Required CI checks passed: `repository-policy`, `Markdown and Mermaid`, + `ESLint and TypeScript`, `Frontend browser acceptance`, and `Workers Builds: + oneshot`. diff --git a/.agent/context/20260911T000000Z-implement-plan-gap-analysis.md b/.agent/context/20260911T000000Z-implement-plan-gap-analysis.md new file mode 100644 index 0000000..3801a67 --- /dev/null +++ b/.agent/context/20260911T000000Z-implement-plan-gap-analysis.md @@ -0,0 +1,78 @@ +# Session Context: Implement plan gap analysis + +## Date/time + +- UTC: 2026-09-11T00:00:00Z + +## User goal + +Compare the revised `plan.md` to the current codebase, identify every remaining implementation gap, and implement the planned product increment on a focused feature branch. + +## Original prompt/request + +Compare our updated plan.md against the current codebase to identify all unimplemented or incomplete features. Checkout a new feature branch feat/implement-plan-gap-analysis and implement all remaining features required by plan.md. + +## Assumptions + +- “Implement all” covers R0–R3 code, contracts, tests, and documentation that can be completed locally. R4 requires a human-authorized live testnet payment and R5 needs external CI/review/release artifacts, so those gates cannot be truthfully completed by code alone. +- The initial connector is a team-operated, test-only report supplier with an explicit idempotent order/result contract; it makes no third-party adoption claim. +- The initial workspace model is a single configured/allowlisted workspace, enforced in the API and data model. + +## Plan + +1. Audit existing contracts, storage, APIs, worker, recovery, and web UI against R0–R3. +2. Add the smallest safe job/order/delivery and activity-audit slice, retaining existing intent APIs. +3. Replace the combined console with separated landing and authenticated cabinet flows. +4. Add focused safety, concurrency, restart, access-control, and UX tests; run required local checks. + +## Key decisions + +- Work is on `feat/implement-plan-gap-analysis`, created from clean `develop` at `f1298fa26b17b8a074bf4786dd714c57108eece3`. + +## Files/components touched + +- `packages/contracts`, `packages/domain`, `packages/storage-postgres`: additive job/order/delivery contracts, deterministic task identity, migration `006`, task binding, result persistence, and bounded activity observations. +- `packages/supplier-adapter`: one labelled team-operated testnet report connector with idempotent order/result behavior. +- `apps/api`, `apps/worker`: workspace-scoped job/result/activity APIs and a committed-payment-only delivery worker task. +- `apps/web`: separate public `/` and authenticated `/app` cabinet routes, job-first UX and manual read-only activity refresh. +- `docs/PLAN_GAP_ANALYSIS.md`, `README.md`, `.env.example`: implementation boundary, configuration, and remaining external gates. + +## Commands/checks + +- Read canonical agent policy, implementation loop, project/security/test guidance, idempotency and failure-injection skills. +- `git status --short --branch` - clean `develop...origin/develop` before branch creation. +- `git switch -c feat/implement-plan-gap-analysis` - created successfully. +- Initial Gate A review correctly found that a failed delivery could not be re-queued because the fulfilled outbox key was reused. The repair adds a fenced `delivery_attempt` generation to the job and the outbox key, so a resumed delivery uses a fresh task while preserving the original committed intent and supplier order. Stale generation workers cannot complete or fail a newer delivery attempt. +- Runnable coverage now includes `JobLedger` fenced retry behavior, supplier idempotency, committed-delivery failure/retry with zero settlement submissions, all job/activity API routes, and Studio Graph validation. +- `pnpm lint`, `pnpm typecheck`, `pnpm format:check`, `pnpm check:generated`, `pnpm validate:fixtures`, `pnpm build`, and `pnpm test` - PASS after the repair; root test: 69 files / 984 tests. +- `TEST_POSTGRES=1 pnpm --filter @oneshot/storage-postgres test:integration` - blocked: this workspace has no working Testcontainers container runtime. The new real-PostgreSQL concurrent task-binding test is present but not executable here. +- CI follow-up after draft PR #72: the PostgreSQL rollback test incorrectly reused migration version 006 after this increment introduced that migration, so it now uses synthetic version 007. Legacy browser acceptance was opening the new public landing at `/` while expecting the retired operator console; it now exercises the public landing and authenticated `/app` cabinet/job/recovery flow. `pnpm --filter @oneshot/web test:browser` passes locally (4 Chromium tests), alongside the full local validation stack and 984 unit tests. +- CI follow-up after commit `0cccf6d`: storage and API PostgreSQL integration suites passed, but worker and restart-recovery cleanup failed because their `afterEach` TRUNCATE lists omitted `resumable_jobs`, the migration-006 child table referencing `business_intents`. Adding that table to both cleanup lists prevents cascading dirty-state failures; format, lint, typecheck, build, and 984 unit tests pass locally. +- Frontend follow-up: the authenticated cabinet now gives Tools responsibility for starting a report and Jobs responsibility for listing results, retrying delivery, and opening payment evidence. The supplier quote returned by the API (amount, recipient, network, and order reference) is rendered instead of hardcoded UI text; the wallet panel identifies Arc Testnet and the server-configured Privy execution boundary without exposing an address or secret. Composition and browser coverage now assert the split and exercise the Jobs resume path. Web unit tests and all 4 Chromium tests pass locally. + +## External-doc findings + +- No new external documentation was needed. Existing configured Studio GraphQL is optional for manual activity refresh; it remains evidence-only. + +## Unresolved questions + +- R4 live Arc/Studio/supplier interruption evidence and R5 CI/FreePi/release/human-review evidence remain external gates. No qualification claim is made. + +## Git and PR state + +- Branch: `feat/implement-plan-gap-analysis` +- Base: `develop` at `f1298fa26b17b8a074bf4786dd714c57108eece3` +- Commit: pending fresh Gate A for the frontend follow-up +- PR: #72 remains draft; frontend follow-up is not pushed yet +- CI: not run for the frontend follow-up + +## Review gates + +- Gate A: prior implementation and CI fixes passed; fresh Gate A required for the frontend follow-up tree before commit/push. +- Gate B: prior PR head passed; rerun after the frontend follow-up is pushed. + +## Handoff/next steps + +1. Run PostgreSQL integration tests in an environment with a supported container runtime. +2. Stage the frontend follow-up tree, obtain fresh Gate A before any commit/push, then follow the required CI/Gate B process for PR #72. +3. Perform the human-authorized R4 live demo and R5 release evidence separately; do not treat local fixtures as proof. diff --git a/.agent/context/20260911T013516Z-circle-x402-demo.md b/.agent/context/20260911T013516Z-circle-x402-demo.md new file mode 100644 index 0000000..cc5ae08 --- /dev/null +++ b/.agent/context/20260911T013516Z-circle-x402-demo.md @@ -0,0 +1,110 @@ +# Session Context: Circle x402 demo rail + +## Date/time + +- UTC: 2026-09-11T01:35:16Z + +## User goal + +Implement two clearly labelled demo modes: the existing direct Arc Testnet +settlement and a real Circle x402 paid API request. Keep the x402 rail separate +from the canonical OneShot direct settlement path and fail closed on ambiguity. + +## Original prompt/request + +Implement the recommended Arc settlement demo and Circle x402 API demo. The +Arc transfer mode is already present on `origin/develop`; this change adds the +separate x402 buyer rail and its operator UI/runbook. + +## Assumptions + +- The Circle sample endpoint is supplied at runtime through an HTTPS URL. +- The Privy wallet has a pre-funded Circle Gateway Arc Testnet balance before + the paid request; this branch does not automate a deposit. +- The x402 rail is a demo adapter until a durable PostgreSQL-backed supplier + job integration is separately designed and reviewed. + +## Plan + +1. Use Circle's official x402 batching SDK and Privy's EIP-712 signer. +2. Validate the 402 quote for Arc Testnet native USDC and a bounded amount. +3. Make one paid request per Business Intent in-process; classify lost or + unconfirmed responses as `UNKNOWN` with no retry. +4. Expose the separate mode in Tools and document the safe runbook. +5. Run focused and repository checks, then prepare the feature PR. + +## Key decisions + +- Kept direct Arc transfer code unchanged; it remains the authoritative + OneShot settlement proof. +- Used `@circle-fin/x402-batching` `BatchEvmScheme` instead of + `GatewayClient`, because GatewayClient requires a raw private key while + Privy can sign the required EIP-712 payload without key export. +- Did not add x402 as a second `SettlementPort`: Gateway batching has a + different evidence and durable supplier-delivery contract, and mixing it + into the direct transfer worker would risk double payment. +- The adapter retains an ambiguous attempt in-process. Cross-process use must + persist the claim and evidence in OneShot PostgreSQL before production use. + +## Files/components touched + +- `packages/supplier-adapter/src/circle-x402.ts`: bounded quote parser and + one-shot Circle Gateway buyer client. +- `packages/supplier-adapter/test/circle-x402.test.ts`: quote, duplicate, + ambiguity, affordability and preflight retry coverage. +- `packages/privy-adapter/src/privy-x402-signer.ts`: Privy-only EIP-712 signer. +- `scripts/demo-circle-x402.mjs`, root `package.json`: live demo command. +- `apps/web/src/components/JobWorkspace.tsx`, `apps/web/src/App.tsx`: separate + x402 Tools card. +- `docs/CIRCLE_X402_DEMO.md`, `docs/DEMO_SCRIPT.md`, `README.md`, `.env.example`: + operator documentation and safe runtime placeholders. + +## Commands/checks + +- `pnpm --filter @oneshot/supplier-adapter test -- --run` - PASS (9 tests). +- `pnpm --filter @oneshot/supplier-adapter typecheck` - PASS. +- `pnpm --filter @oneshot/privy-adapter test -- --run` - PASS (130 tests). +- `pnpm --filter @oneshot/privy-adapter typecheck` - PASS. +- `pnpm typecheck` - PASS. +- `pnpm lint` - PASS. +- `pnpm format:check` - PASS. +- `pnpm build` - PASS. +- `pnpm test` - PASS (71 files, 996 tests). +- `pnpm test:browser` - PASS (4 scenarios). + +## External-doc findings + +- Circle x402 buyer docs: `GatewayClient` normally requires a private key and + a one-time Gateway deposit; this implementation uses the official batching + scheme with a Privy signer instead. +- Circle x402 concepts/seller docs: the paid request is a 402 negotiation and + Gateway batches EIP-3009 authorizations; a `PAYMENT-RESPONSE` transaction is + evidence, not OneShot direct-transfer settlement authority. +- Privy Node SDK 0.34.0 exposes `eth_signTypedData_v4` and `createViemAccount`. + +## Unresolved questions + +- A future production supplier adapter must bind the x402 payment claim, + Gateway settlement evidence and retrievable API result to PostgreSQL job + state across restarts. This branch intentionally does not claim that. +- Live sponsor qualification still requires a fresh, sanitized trace; no + qualification claim is made here. + +## Git and PR state + +- Branch: `feature/circle-x402-demo` +- Base: `origin/develop` at `61d6d17daa7e18260f01119edfe830bb06fa3f80` +- Commit: PR #75 head (exact SHA recorded in PR evidence) +- Commit tree: exact candidate tree recorded in PR Gate A/B evidence +- PR: [#75](https://github.com/SWOFART/OneShot/pull/75) (draft) +- CI: all required checks PASS on the recorded head. + +## Review gates + +- Gate A: PASS (`free-pi-cli` / `gpt-oss-120b-speed`, tree `a07c039f18730a9191881ded0833806aff3d2923`). +- Gate B: PASS (`free-pi-cli` / `gpt-oss-120b-speed`, PR #75 head/tree match Gate A). + +## Handoff/next steps + +1. Human review and merge of PR #75; do not merge automatically. +2. Keep the unrelated Cloud Build files out of this PR. diff --git a/.agent/context/20260911T042755Z-r3-activity-audit.md b/.agent/context/20260911T042755Z-r3-activity-audit.md new file mode 100644 index 0000000..825934b --- /dev/null +++ b/.agent/context/20260911T042755Z-r3-activity-audit.md @@ -0,0 +1,85 @@ +# Session Context: R3 activity audit + +## Date/time + +- UTC: 2026-09-11T04:27:55Z + +## User goal + +Continue implementing the current resumable paid-tools plan after the R0–R3 +foundation and branded frontend work. Advance the next concrete Graph/activity +gap without weakening settlement safety. + +## Original prompt/request + +“Continue implementing that” after reviewing the current plan and its absence +of per-agent wallets. + +## Assumptions + +- R0–R3 job, supplier, cabinet and activity foundations already exist in + `develop`; this slice completes the missing job-aware activity projection. +- R4 live payment and R5 release evidence remain human/external gates. + +## Plan + +1. Extend the activity contract with bounded transfer match results. +2. Match indexed transfers only to settlements in the authenticated workspace. +3. Display unmatched activity read-only and run focused/full validation. + +## Key decisions + +- Match on the immutable `(transaction_hash, transfer_log_index)` tuple only. +- Treat malformed stored observations as invalid; Graph evidence never changes + payment state or grants submission permission. +- Do not add per-agent wallets; the plan keeps one Privy execution boundary. + +## Files/components touched + +- `packages/contracts`: generated activity transfer contract and OpenAPI. +- `packages/storage-postgres`: validate observations and project matched/unmatched + transfers for one workspace. +- `apps/web`: show activity counts and unmatched transfer details while + preserving prior data on refresh failure. +- `apps/api`, browser/unit tests, README and plan gap notes: contract fixtures, + coverage and documentation. + +## Commands/checks + +- `pnpm --filter @oneshot/storage-postgres test -- --run test/jobs.test.ts` - PASS (5 tests). +- `pnpm --filter @oneshot/api test`, `pnpm --filter @oneshot/contracts test`, and + `pnpm --filter @oneshot/web test` - PASS (55, 34 and 53 tests). +- `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, `pnpm check:generated`, + `pnpm build`, `pnpm test` - PASS (76 files / 1,026 tests). +- `pnpm --filter @oneshot/web test:browser` - PASS (4 Chromium tests). +- `npx --yes markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"` - PASS (149 files). +- Local Node `22.23.2` emits the repository's existing `24.19.0` engine warning. + +## External-doc findings + +- No new external documentation required; this uses the existing Graph Studio + activity adapter and Arc/OneShot settlement records. + +## Unresolved questions + +- Live Studio evidence and an authorized interrupted Arc payment remain R4 + requirements. + +## Git and PR state + +- Branch: `feature/activity-audit` +- Base: `origin/develop` at `236676293eed417b3e3bb6d40d6482b1519c8667` +- Commit: `51796daf7a1554b4d84207ce35ec3df3d163ef3d` +- Tree: `47f975df8895c181537b0df64c40e2475903f5c8` +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Complete full checks, inspect scope, then decide whether to commit and open a + focused PR for the R3 activity slice. diff --git a/.agent/context/20260911T062514Z-r5-release-submission.md b/.agent/context/20260911T062514Z-r5-release-submission.md new file mode 100644 index 0000000..07615ca --- /dev/null +++ b/.agent/context/20260911T062514Z-r5-release-submission.md @@ -0,0 +1,104 @@ +# Session Context: R5 release submission + +## Date/time + +- UTC: 2026-09-11T06:25:14Z + +## User goal + +Implement the remaining plan steps as stacked pull requests after the R4 live +failure-demo slice, leaving each PR open for human review and never merging. + +## Original prompt/request + +“After you implement this part, leave PR and implement every other step in the +circle via stacking PR'S of all stepps.” + +## Assumptions + +- R4 is PR #79 on `feature/r4-live-failure-demo`; this branch stacks on its + exact head and does not merge it. +- The current release work is evidence packaging and preflight, not a claim + that live R4, Graph, prize-pool, or video evidence exists. +- Video is intentionally deferred because the user requested no video yet. +- Existing user Cloud Build changes remain out of scope and unstaged. + +## Plan + +1. Add a small offline release preflight bound to optional exact head/tree + identities. +2. Publish a truthful R5 checklist covering automated checks, sponsor status, + live evidence, video, prize-pool verification, and human review. +3. Link the checklist from the README and update gap tracking. +4. Run local checks, capture fresh Gate A, open the stacked PR, wait for CI, + capture fresh Gate B, and leave the PR ready for human review. + +## Key decisions + +- The preflight checks only public artifacts and package scripts; it never reads + secrets, deploys, or upgrades missing live evidence into a claim. +- Sponsor statuses remain Privy/Arc testnet `QUALIFIED` from existing evidence + and The Graph `NOT VERIFIED` until a fresh Studio trace materially affects + the recovery agent/core. +- The PR targets the R4 branch for a linear stack; root develop remains pinned + in the evidence. + +## Files/components touched + +- `scripts/release-check.mjs`: offline artifact/script and optional identity + preflight. +- `docs/RELEASE_CHECKLIST.md`: R5 release and submission evidence boundary. +- `README.md`, `docs/PLAN_GAP_ANALYSIS.md`, `plan_missing_parts.md`: links and + current R5 status. +- This context record. + +## Commands/checks + +- `pnpm format:check` - PASS. +- `pnpm lint` - PASS. +- `pnpm typecheck` - PASS. +- `pnpm check:generated` - PASS. +- `pnpm test` - PASS (77 files, 1,031 tests). +- `pnpm test:browser` - PASS (4 tests). +- `npx --yes markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"` - PASS + (157 files). +- `pnpm release:check` - PASS on the uncommitted parent head; exact identity + binding will be rerun after commit. +- FreePi Gate A/B and required CI - pending for this R5 tree. + +## External-doc findings + +- `.agents/skills/sponsor-qualification/SKILL.md` and + `.agent/SPONSOR_REQUIREMENTS.md` require live Privy/Arc/Studio evidence and + prohibit qualification claims from mocks or plans; the checklist preserves + these boundaries. +- `.agent/IMPLEMENTATION_LOOP.md` requires exact tree identities, fresh FreePi + A/B, required CI, and human-only merge; the stacked PR follows that loop. + +## Unresolved questions + +- Fresh authorized R4 supplier/Studio evidence, prize-pool verification, and a + short video are still external/human tasks. +- PostgreSQL Testcontainers remains unrun where no container runtime exists. + +## Git and PR state + +- Branch: `feature/r5-release-submission` +- Base: `feature/r4-live-failure-demo` at `b22c79e03b1c6b441b7a58ba40402850e47baddf` +- Candidate staged tree: captured separately for Gate A; this context record + intentionally does not duplicate the hash because changing this file would + change the candidate tree. +- Commit: uncommitted +- PR: not created +- CI: not run for this tree + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Capture Gate A for the current staged tree from `git write-tree`. +2. Capture Gate A, commit, push, open the stacked PR, wait for CI, then capture + Gate B and stop for human review. diff --git a/.agent/context/20260911T071500Z-r4-live-failure-demo.md b/.agent/context/20260911T071500Z-r4-live-failure-demo.md new file mode 100644 index 0000000..fe84158 --- /dev/null +++ b/.agent/context/20260911T071500Z-r4-live-failure-demo.md @@ -0,0 +1,94 @@ +# Session Context: R4 live failure demo + +## Date/time + +- UTC: 2026-09-11T07:15:00Z + +## User goal + +Implement the next plan step after the R3 activity-audit PR as a stacked PR: +provide a controlled response-loss demo, safe live API orchestration and +sanitized recovery evidence, then continue to the R5 release slice. + +## Original prompt/request + +“After you implement this part, leave PR and implement every other step in the +circle via stacking PR'S of all stepps.” + +## Assumptions + +- R3 is PR #78 on `feature/activity-audit`; this branch is stacked on its exact + head and will not merge it. +- Live Arc Testnet spending remains opt-in and is not run by CI or this agent. +- Existing R0–R3 job, supplier, cabinet, activity and recovery code is reused. + +## Plan + +1. Add a one-shot post-broadcast response-loss wrapper and strict testnet-only + runtime guard. +2. Add `demo:r4`, offline by default, with an explicit live API path that never + retries an ambiguous create/resume/payment request and emits sanitized trace. +3. Test the durable UNKNOWN/no-second-submit invariant and update operations and + plan documentation. +4. Run local checks, Gate A, required CI, Gate B, then open the next stacked PR. + +## Key decisions + +- The fault is injected after a confirmed provider result returns, before the + worker persists that result; the worker therefore records POSSIBLY_SUBMITTED + and durable UNKNOWN while preserving pre-submit provider identity. +- The hook is disabled by default and requires both + `ONESHOT_DEMO_RESPONSE_LOSS_AFTER_BROADCAST=true` and + `ONESHOT_DEMO_CONFIRM_TESTNET=true` on the Arc Testnet profile. +- The live runner treats missing Studio evidence or unresolved payment as + HOLD/INCOMPLETE and never claims sponsor qualification. + +## Files/components touched + +- `apps/worker/src/failure-injection.ts`, runtime config/composition and tests. +- `scripts/demo-r4.mjs`, root package command and `.env.example`. +- Worker/demo/plan documentation and this context record. + +## Commands/checks + +- `pnpm format:check` - PASS. +- `pnpm lint` and `pnpm typecheck` - PASS. +- `pnpm check:generated` - PASS. +- `pnpm test` - PASS (77 files / 1,031 tests). +- `pnpm --filter @oneshot/web test:browser` - PASS (4 Chromium tests). +- `npx --yes markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"` - PASS (155 files). +- `pnpm demo:r4` - PASS, offline trace is explicitly `NOT_LIVE`. +- `git diff --cached --check` and `git diff --check` - PASS. +- Live Arc/Studio execution intentionally not run without explicit human + authorization and deployment credentials. + +## External-doc findings + +- No new provider API research required; the existing Arc Testnet, Privy, + Studio GraphQL and Vertex integrations are reused. + +## Unresolved questions + +- Fresh live response-loss, Studio and supplier-result evidence remains a human + R4 acceptance task. +- R5 video and prize-pool verification remain after this branch. + +## Git and PR state + +- Branch: `feature/r4-live-failure-demo` +- Root develop: `236676293eed417b3e3bb6d40d6482b1519c8667`. +- Stacked parent: `feature/activity-audit` at + `842f98b0ef9b0b9f812e1e734abfa5d424dc499f` (PR #78). +- Candidate staged tree: `c091319dd916edcca8513aa1b42ddd6f52fca502`. +- Commit/PR: pending + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +Run local validation and inspect the staged scope, then capture Gate A for the +exact candidate tree before pushing this stacked PR. Do not stage the user's +`cloudbuild-worker.yaml`, `.gcloudignore` or `cloudbuild-api.yaml` work. diff --git a/.agent/context/20260911T110000Z-frontend-report-payment-inputs.md b/.agent/context/20260911T110000Z-frontend-report-payment-inputs.md new file mode 100644 index 0000000..12c9f53 --- /dev/null +++ b/.agent/context/20260911T110000Z-frontend-report-payment-inputs.md @@ -0,0 +1,79 @@ +# Session Context: frontend report payment inputs + +## Date/time + +- UTC: 2026-09-11T11:00:00Z + +## User goal + +Review the current plan and codebase, identify remaining implementation work, and let the report job frontend provide the payment recipient and amount instead of relying on hardcoded supplier defaults. + +## Original prompt/request + +User asked to look at the plan and current codebase, explain what is still missing and which features should be implemented, and remove `DEFAULT_REPORT_RECIPIENT` / `DEFAULT_REPORT_PRICE_ATOMIC` from the report supplier so recipient and amount can be entered in the frontend. + +## Assumptions + +- Recipient and amount become part of the immutable `CreateJobRequest` and therefore the task payload fingerprint. +- The frontend submits integer atomic USDC units after converting a user-entered decimal string without floating point. +- Privy policy and the worker remain the final authorization boundary; frontend-provided values do not bypass allowlists or caps. + +## Plan + +1. Extend the shared job contract, parser, canonical fingerprint, API schema, supplier quote, and frontend form. +2. Remove hardcoded supplier quote defaults and stale API environment wiring. +3. Add focused tests for dynamic values, validation, quote payloads, and UI inputs. +4. Run generated-contract checks, focused tests, typecheck, lint, and build. + +## Key decisions + +- Use `amount_atomic` in the API contract to preserve the integer-money invariant; the UI accepts human-readable USDC and converts it with string/BigInt logic. +- Keep recipient/amount in the task payload so changing either under the same task key is an explicit payload conflict. +- Keep the supplier adapter generic for request values; the worker's Privy authorization still rejects values outside policy. + +## Files/components touched + +- Shared job contract, canonical fingerprint, OpenAPI/schema artifacts, and parser validation. +- Team report supplier, API runtime/schema, R4 runner inputs, and demo documentation. +- Frontend job workspace with recipient/USDC amount fields and string/BigInt conversion. +- Contract, supplier, API, frontend, browser, and storage fixture tests. + +## Commands/checks + +- `git status --short --branch` - clean `develop` before branching. +- `git switch -c feature/frontend-report-payment-inputs` - branch created. +- `pnpm.cmd typecheck` - passed. +- Focused contracts/supplier/API/web tests - passed (37, 10, 54, and 90 tests respectively). +- `pnpm.cmd format:check` - passed. +- `pnpm.cmd lint` - passed. +- `pnpm.cmd build` - passed. +- `pnpm.cmd test` - passed (77 files, 1034 tests). +- `pnpm.cmd test:browser` - passed (4 browser tests). +- `pnpm.cmd --filter @oneshot/contracts check:generated` - passed. +- `pnpm.cmd --filter @oneshot/contracts validate:fixtures` - passed. +- `git diff --check` - passed. + +## External-doc findings + +- Repository plan and policy documents only; no external integration research needed for this local contract/UI change. + +## Unresolved questions + +- Live deployment must still configure Privy recipient allowlists/caps compatible with values entered by operators. + +## Git and PR state + +- Branch: `feature/frontend-report-payment-inputs` +- Base: `develop` at `709a713` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: PASS for candidate tree `93c4f7a0366d538c5473938a5518585098d6dbd4`, reviewed by `free-pi-cli` (`glm-5.3-flash`); this context-record update changes the candidate tree, so a fresh Gate A is required before commit. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Re-stage this record update, run fresh Gate A for the resulting tree, then commit, push, create a draft PR, and complete Gate B. diff --git a/.agent/context/20260911T120000Z-site-paid-api-settlement.md b/.agent/context/20260911T120000Z-site-paid-api-settlement.md new file mode 100644 index 0000000..f297789 --- /dev/null +++ b/.agent/context/20260911T120000Z-site-paid-api-settlement.md @@ -0,0 +1,90 @@ +# Session Context: site-paid-api-settlement + +## Date/time + +- UTC: 2026-09-11T12:00:00Z + +## User goal + +Integrate the existing Circle x402 paid-API buyer flow into the OneShot website so an operator can start a real Arc Testnet payment from the site, see the ArcScan transaction evidence, and inspect recovery/activity evidence when a payment response is delayed or lost. Preserve the invariant that duplicate delivery, retries, or agent lag produce at most one payment. + +## Original prompt/request + +The user asked to create a new branch from `develop` and start integrating the paid API into the website instead of linking to the hardcoded `pnpm demo:402` tester. The site should demonstrate a real Circle payment visible on ArcScan, enforce one payment when the same transaction is sent twice, and expose recovery/activity via The Graph. The user explicitly asked not to start Gate A and requested a Gate A review prompt for later use. + +## Assumptions + +- The paid API endpoint is a configured HTTPS Circle x402 resource, not an arbitrary browser-supplied URL; this avoids SSRF and keeps the quote/payment scope pinned. +- The existing Privy-controlled EOA and funded Circle Gateway Testnet balance remain deployment prerequisites; this work does not deposit funds or execute a live payment. +- Circle Gateway's x402 response transaction is initially provider evidence; OneShot commits only after Arc receipt evidence proves the expected USDC transfer, otherwise it remains `UNKNOWN` and recovery is required. +- The website uses the existing authenticated operator cabinet and OneShot API/worker rather than exposing wallet secrets or signing material to the browser. + +## Plan + +1. Add a durable paid-API target and response projection tied atomically to one Business Intent. +2. Add Circle x402 quote/start/status API routes and a worker settlement adapter with receipt verification and stable provider identity. +3. Bind x402 provider hashes to recovery and The Graph candidate discovery, and include paid-API settlements in activity. +4. Replace the runbook-only website card with a quote/start/status UI and ArcScan/recovery links. +5. Add applicable duplicate, parallel, response-loss, Graph-delay, provider-policy, restart, and downstream-result tests; run local checks only. +6. Stop before Gate A and leave a compact review prompt for a later fresh FreePi session. + +## Key decisions + +- The existing direct Arc transfer remains unchanged. Circle x402 is a separate settlement adapter selected only for paid-API intents, preventing a downstream API payment from becoming a second direct settlement. +- The API stores the exact validated x402 quote and request target before the worker can submit. The worker's durable `READY -> SUBMITTING` claim remains the only submission permission. +- Circle's transaction hash is persisted on the submitting attempt as soon as it is returned. Arc receipt verification is required for `COMMITTED`; missing or delayed receipt/Graph evidence stays `UNKNOWN`. +- No recovery action can submit a replacement x402 request. The Graph remains candidate discovery only and Arc receipt verification plus the ledger decide. + +## Files/components touched + +- Contracts/domain: paid-API request, quote, and view types plus generated OpenAPI artifacts. +- Storage: paid-API target/response migration and atomic ledger operations; activity/reset support. +- Supplier/worker: Circle x402 quote/target parsing, receipt-bound settlement adapter, runtime wiring, and recovery bridge support. +- API/web: configured quote/start/status endpoints, client, website paid-API panel, and ArcScan/recovery presentation. +- Tests/docs/.env placeholders and this context record. + +## Commands/checks + +- `git status --short --branch` - clean `develop` before branching. +- `git switch -c feature/site-paid-api-settlement` - created from `develop` at `e03f46faf56edf6747d7ba269eeae02b140b2ab8`. +- `pnpm.cmd test` - passed: 79 files, 1,042 tests; this runs the build and excludes only `apps/web/browser/**`. +- `pnpm.cmd lint` - passed. +- `pnpm.cmd typecheck` - passed. +- `pnpm.cmd check:generated` - passed. +- `pnpm.cmd format:check` and `git diff --check` - passed. +- Focused supplier, worker, contracts, and storage suites - passed (12, 49, 37, and 13 tests respectively; worker count includes the x402 recovery-bridge coverage). +- `pnpm.cmd --filter @oneshot/storage-postgres test:integration` - skipped by default because `TEST_POSTGRES` is unset. +- `TEST_POSTGRES=1 pnpm.cmd --filter @oneshot/storage-postgres test:integration` - not runnable in this environment: Testcontainers reported `Could not find a working container runtime strategy`. +- Live Circle/Arc payment - intentionally not run; no external effect is authorized in this implementation session. + +## External-doc findings + +- Circle's current x402 buyer guide says Gateway payments use an EIP-3009 authorization, require a one-time Gateway USDC deposit, and may settle onchain later; the buyer receives a response before batch settlement is necessarily visible onchain. The site must therefore distinguish provider response from Arc-confirmed commitment. +- Circle's current x402 seller guide pins Arc Testnet as `eip155:5042002`, documents `PAYMENT-REQUIRED`/`PAYMENT-SIGNATURE`, and recommends `settle()` for seller-side verification. +- Circle's current Gateway contract-address reference lists Arc Testnet GatewayWallet as `0x0077777d7EBA4688BDeF3E311b846F25870A19B9`. +- Privy's current signer documentation confirms server-side wallet actions through configured signers; this repository's existing Privy x402 signer continues to keep private keys out of OneShot. + +## Unresolved questions + +- The live deployment must provide a configured Circle x402 resource, an EOA-compatible Privy wallet, a funded Gateway Testnet balance, and Arc/Graph/Vertex identities before a live demo can be accepted. +- Exact live Gateway batch timing may leave the UI at `UNKNOWN` until Arc receipt/Graph reconciliation catches up. + +## Git and PR state + +- Branch: `feature/site-paid-api-settlement` +- Base: `develop` at `e03f46faf56edf6747d7ba269eeae02b140b2ab8` +- Commit: uncommitted +- PR: not created +- CI: not run +- Intended changes are staged; the final staged tree identity is reported in the handoff. + +## Review gates + +- Gate A: PASS — user-provided fresh FreePi read-only verdict; no blocking findings. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Commit the staged implementation after the Gate A PASS. +2. Push the feature branch and open a PR targeting `develop`. +3. Keep Gate B and live payment execution outside this handoff unless explicitly requested. diff --git a/.agent/context/20260911T135238Z-circle-seller-endpoint.md b/.agent/context/20260911T135238Z-circle-seller-endpoint.md new file mode 100644 index 0000000..23bcff2 --- /dev/null +++ b/.agent/context/20260911T135238Z-circle-seller-endpoint.md @@ -0,0 +1,134 @@ +# Circle seller endpoint integration + +Date/time: 2026-09-11T13:52:38Z + +## User goal + +Add Circle's official Arc nanopayments seller routes so the existing OneShot +paid-API cabinet can request and pay a real HTTPS x402 resource through the +deployed `oneshot.kapustazh.dev` site. Preserve the existing OneShot +at-most-once payment and recovery behavior. + +## Original request + +Create a new branch and implement the missing Circle seller application. The +deployed site is `oneshot.kapustazh.dev`; the intended dataset resource is +`/api/premium/dataset` at 10,000 atomic USDC units. + +## Acceptance criteria + +- Seller service exposes Circle Gateway-protected `/api/premium/quote`, + `/api/premium/dataset`, `/api/premium/compute`, and `/api/premium/agent-task` + routes with official sample-compatible methods and prices. +- Unpaid Arc Testnet requests return Circle x402 v2 payment requirements; paid + requests settle through Circle's testnet facilitator and return the resource. +- Seller is testnet-only, validates its public receiving address, and does not + require or persist a private key. +- Cloudflare Worker can proxy `/api/premium/*` to the separately deployed seller + service while stripping OneShot credentials and exposing payment headers. +- Documentation explains local startup, Cloud Run deployment, Worker routing, + `ONESHOT_X402_URL`, and required live checks. +- Tests cover unpaid requirements, paid success with mocked facilitator, route + methods/prices, proxy behavior, and malformed configuration. +- Existing OneShot invariant remains unchanged: one Business Intent has at most + one committed settlement; seller handlers have no chargeable side effects. + +## Assumptions and non-goals + +- Use Circle's official `createGatewayMiddleware` and the Arc Testnet + facilitator; do not clone the full dashboard/private-key portion of the + official sample. +- The seller is a separate Node service. The existing API remains the buyer and + ledger authority; the Cloudflare Worker provides the same-domain public path. +- This session implements code and deployment instructions only. It does not + deploy Cloud Run or mutate Cloudflare production configuration, because no + deployment credentials or seller wallet address were supplied. +- Gate A and Gate B are not started in this session unless explicitly requested + later; local verification and a review prompt handoff are still required. + +## External documentation findings + +- Circle's official seller quickstart uses `createGatewayMiddleware` and + `gateway.require(price)`. +- The official Arc nanopayments sample defines the four requested routes and + prices: GET quote at $0.001, GET dataset at $0.01, POST compute at $0.0003, + and GET agent-task at $0.03. +- The buyer adapter requires exactly one affordable Arc Testnet Gateway option; + the OneShot demo therefore points to dataset and caps it at `10000` atomic + units. + +## Branch state + +- Branch: `feature/circle-seller-endpoint` +- Base: `develop` / `origin/develop` at `dc128a733456c7f8e3e591df624fa8136a793881` +- Worktree was clean before implementation. +- No commit or PR exists yet for this branch. + +## Plan + +1. Add the standalone seller app and native HTTP adapter around Circle + middleware. +2. Add Worker same-domain proxy support. +3. Add Docker/deployment and environment documentation. +4. Add focused tests and run format, lint, typecheck, build, and test checks. +5. Record exact final tree/check evidence and hand off without starting Gate A. + +## Files/components touched + +- `apps/seller/`: standalone Node seller with Circle middleware, route handlers, + runtime config, entrypoint, and focused tests. +- `apps/web/worker.ts`: same-domain `/api/premium/*` proxy with credential + stripping and payment-header exposure. +- `Dockerfile.seller`, `cloudbuild-seller.yaml`: Google Cloud Run image/build + artifacts. +- `.env.example`, `README.md`, `apps/web/README.md`, + `docs/CIRCLE_X402_DEMO.md`, and `docs/CIRCLE_X402_SELLER.md`: configuration, + deployment, and live verification instructions. +- `tsconfig.json` and `pnpm-lock.yaml`: workspace registration and locked SDK + dependencies. +- `apps/web/test/worker-proxy.test.ts`: public proxy boundary tests. + +## Commands/checks + +- `pnpm install --lockfile-only`: passed; lockfile supply-chain policy passed. +- `pnpm lint`: passed. +- `pnpm typecheck`: passed. +- `pnpm check:generated`: passed. +- `pnpm exec prettier --check "**/*.{ts,mts,mjs,json,jsonc,yml,yaml}"`: passed. +- `npx --yes markdownlint-cli2@0.18.1 "**/*.md" "#node_modules"`: passed, + 161 files, 0 errors. +- `pnpm test`: passed, 81 files and 1,050 tests. +- `pnpm build:frontend`: passed. +- `pnpm --filter @oneshot/seller test`: passed, 5 tests. +- Web proxy test suite: passed as part of 19 files and 95 tests. +- PostgreSQL integration was not rerun locally; seller changes have no database + schema or ledger changes and CI remains the authoritative container run. + +## External-doc findings + +Circle's official seller quickstart and Arc nanopayments sample were checked +against the implementation. The route methods/prices match the sample. The +service uses only a public seller address and the testnet facilitator; the +official sample's private-key dashboard/withdrawal features are intentionally +outside this service. + +## Unresolved deployment steps + +- Operator must provide a real Arc Testnet seller address and deploy + `oneshot-seller` to Google Cloud Run. +- Operator must deploy the Worker with `SELLER_BACKEND_URL` set to the seller's + public HTTPS URL. +- Operator must set `ONESHOT_X402_URL` and `ONESHOT_X402_MAX_AMOUNT_ATOMIC=10000` + on the API and payment worker, then verify both direct and same-domain URLs + return HTTP 402 with a `PAYMENT-REQUIRED` header. +- No live payment or ArcScan transaction was created by local tests. + +## Gate state and handoff + +- Gate A: not started by request. +- Gate B: not applicable before Gate A/PR/CI. +- Current head: `dc128a733456c7f8e3e591df624fa8136a793881` with the seller + implementation staged and no commit yet. +- Next step: review the final diff, stage only intended files, and hand off the + branch plus deployment runbook. Do not claim live deployment or sponsor + qualification without operator evidence. diff --git a/.agent/context/20260911T150916Z-workspace-loading-shell.md b/.agent/context/20260911T150916Z-workspace-loading-shell.md new file mode 100644 index 0000000..c6d0e56 --- /dev/null +++ b/.agent/context/20260911T150916Z-workspace-loading-shell.md @@ -0,0 +1,95 @@ +# Session Context: workspace loading shell + +## Date/time + +- UTC: 2026-09-11T15:09:16Z + +## User goal + +Remove the unstyled, slow-looking `Loading the console…` text shown at the +top-left while opening `/app`, without weakening authentication or making the +public landing page pay the Privy bundle cost up front. + +## Original prompt/request + +"I don't like that whenever we click on the Open Workspace it shows slowly on +the top left \"loading the console\" how to fix that?" + +## Assumptions + +- The Privy module remains lazy-loaded because its production chunk is about + 1.7 MB; eagerly importing it would regress landing-page startup. +- A short, accessible, centered workspace shell is acceptable while that + unavoidable auth chunk loads. +- Existing `LoginGate` session loading is a separate, intentional state and + must remain unchanged. +- Existing uncommitted Cloud Build files are user-owned and out of scope. + +## Plan + +1. Replace the bare `Suspense` paragraph with a branded loading shell. +2. Add the smallest matching CSS and a regression test for its structure. +3. Run web checks, review the scoped diff, and complete the repository review + loop before handoff. + +## Key decisions + +- Keep `lazy()`/`Suspense` rather than moving Privy into the landing bundle; + the current build reports a 1,703 kB Privy chunk. +- Use a semantic `main` with `role="status"` and `aria-busy` so the state is + announced without leaving an orphaned top-left paragraph. + +## Files/components touched + +- `apps/web/src/main.tsx`: branded fallback component. +- `apps/web/src/styles.css`: centered full-viewport loading shell styles. +- `apps/web/test/workspace-loading.test.tsx`: fallback markup regression + coverage. + +## Commands/checks + +- `pnpm --filter @oneshot/web build` (baseline) - passed; Privy chunk is + 1,703.16 kB minified. +- `pnpm --filter @oneshot/web exec vitest run --config vitest.config.ts test/workspace-loading.test.tsx` - passed. +- `pnpm --filter @oneshot/web lint` - passed. +- `pnpm --filter @oneshot/web typecheck` - passed. +- `pnpm --filter @oneshot/web test` - passed; 20 files and 96 tests. +- `pnpm --filter @oneshot/web build` - passed; Privy remains a separate + 1,703.16 kB chunk. +- `pnpm format:check` - passed. +- `pnpm lint` - passed. +- `pnpm typecheck` - passed. +- `pnpm test` - passed; 81 files and 1,050 tests. +- `pnpm test:browser` - passed; 4 browser scenarios. +- After correcting the context filename, `pnpm --filter @oneshot/web test` - + passed; 20 files and 96 tests. +- After correcting the context filename, `pnpm --filter @oneshot/web typecheck` + - passed. + +## External-doc findings + +- None; this is a local React/CSS UX fix. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `fix/workspace-loading-shell` +- Base: `develop` / `origin/develop` at `1b7e6c1f2d1d7c7b3a146e91cc63ec5dac63e5d6` +- Commit: uncommitted +- PR: not created +- CI: not applicable yet + +## Review gates + +- Gate A: PASS; fresh `free-pi-cli` / `deepseek-v4-flash` review completed + against the recorded `develop` base with no blocking findings. The exact + final staged tree identity is captured in the PR review evidence. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage only the loading-shell files, capture Gate A identities, and submit + the focused PR after the repository review loop. diff --git a/.agent/context/20260911T162325Z-circle-seller-worker-proxy.md b/.agent/context/20260911T162325Z-circle-seller-worker-proxy.md new file mode 100644 index 0000000..a6b26d7 --- /dev/null +++ b/.agent/context/20260911T162325Z-circle-seller-worker-proxy.md @@ -0,0 +1,76 @@ +# Circle seller Worker proxy + +## Goal + +Make the public `/api/premium/*` route on the Cloudflare Worker reach the +separately deployed Circle x402 seller on Cloud Run and preserve its HTTP 402 +payment challenge. + +## Acceptance criteria + +- The seller proxy uses a redirect mode supported by Cloudflare Workers without + forwarding credentials to an uncontrolled redirect target. +- Existing API proxy behavior and seller credential stripping remain unchanged. +- The seller service is publicly reachable on Arc Testnet and returns HTTP 402 + with `PAYMENT-REQUIRED` for an unpaid dataset request. +- The same-domain Worker route returns the seller's HTTP 402 response rather + than `SELLER_NOT_READY`. + +## Assumptions + +- The documented team-controlled Arc Testnet recipient + `0xa605EE031E41f04f8e193059a39A24407f83677c` is the intended public seller + address; no buyer private key or credential is used. +- Cloud Run project is `oneshot-508002`, region is `europe-west1`, and the + existing Artifact Registry repository is `oneshot-repo`. + +## Non-goals + +- No buyer payment, Gateway withdrawal, or mainnet activation. +- No change to OneShot settlement/idempotency logic. +- No committed runtime secrets or wallet credentials. + +## Plan + +1. Change the seller proxy redirect mode from `error` to `manual`. +2. Run focused web checks and the full required local checks. +3. Capture Gate A, commit, push, and open a draft PR targeting `develop`. +4. Wait for required CI, run exact-head Gate B, then mark ready for human + review. +5. Deploy the seller image to public Cloud Run and configure the Worker with + its regional Cloud Run URL. + +## Git and deployment state + +- Base: `develop` at `46d4c87b5eaaa65295ce19a7148395f111602105`. +- Branch: `fix/circle-seller-worker-proxy`. +- Intended code change: seller proxy in `apps/web/worker.ts` and its focused + regression assertion in `apps/web/test/worker-proxy.test.ts`, plus this + context record. +- User-owned local files remain outside the candidate tree: + `cloudbuild-worker.yaml`, `.gcloudignore`, and `cloudbuild-api.yaml`. +- Seller image build: Cloud Build `51f9e18e-330e-4539-b8c7-5dacd44d6ee0`, + successful; image digest `sha256:afc4fcc59d9f510dcc2db947b96d9d65dc7c1e5544458ce75cfe0c9e8f0307d7`. +- Seller service: `oneshot-seller` in `europe-west1`, public URL + `https://oneshot-seller-775560462825.europe-west1.run.app`. +- Worker was first deployed with the `.a.run.app` alias and returned 502; the + regional URL is the verified origin. Final Worker version + `575b97da-d1ef-44a0-8714-3ee6fc105ca3` returns the seller HTTP 402 challenge + at `https://oneshot.kapustazh.dev/api/premium/dataset`. +- API revision `oneshot-api-00008-c6t` and Worker revision + `oneshot-worker-00016-97p` now carry the non-secret x402 settings: + `ONESHOT_X402_URL=https://oneshot.kapustazh.dev/api/premium/dataset` and + `ONESHOT_X402_MAX_AMOUNT_ATOMIC=10000`. + +## Checks and gates + +- Focused remote edge probe: `redirect: 'error'` returned 502 before origin; + `redirect: 'manual'` returned HTTP 402 and reached Cloud Run. +- Direct seller `/health/live` and `/health/ready`: HTTP 200. +- Direct seller `/api/premium/dataset`: HTTP 402 with Arc Testnet challenge. +- Gate A: PASS from `free-pi-cli` (`deepseek-v4-flash`) for the candidate tree, + with no blocking findings; exact identities are recorded in PR #86. +- Gate B: PASS from a fresh `free-pi-cli` (`deepseek-v4-flash`) for the exact + PR #86 head and matching tree, with no blocking findings. Required CI was + green; the PR is ready for human review. Exact identities are recorded in + the PR description. diff --git a/.agent/context/20260911T163341Z-wallet-login.md b/.agent/context/20260911T163341Z-wallet-login.md new file mode 100644 index 0000000..9f0bbe2 --- /dev/null +++ b/.agent/context/20260911T163341Z-wallet-login.md @@ -0,0 +1,107 @@ +# Session Context: external wallet login + +## Date/time + +- UTC: 2026-09-11T16:33:41Z + +## User goal + +Fix the deployed website's external wallet login so MetaMask and other detected +EVM wallets can authenticate into the Privy-backed OneShot workspace. + +## Original prompt/request + +The user reported that connecting MetaMask succeeds and the SIWE approval is +completed, but the website shows "Could not sign in with that wallet. Try +again, or pick another." This prevents access to Tools, Jobs, and the rest of +the workspace. Create a new branch from current develop and fix it. + +## Assumptions + +- The failure is in the custom detected-wallet SIWE path, because the wallet + connection and signature approval complete before the generic error appears. +- No payment, settlement, or wallet funds are involved in this login fix. +- The Privy app's production origin and wallet-login method must still be + configured in the Privy Dashboard; code cannot repair a missing allowlist. + +## Plan + +1. Inspect current develop and the frontend Privy/EIP-6963 login path. +2. Remove invalid provider metadata from the Privy SIWE request and add a + MetaMask-shaped regression test. +3. Run focused web tests, typecheck, formatting, and repository validation. +4. Stage the scoped fix, capture Gate A identities, and hand off for review. + +## Key decisions + +- Do not pass an EIP-6963 `rdns` value such as `io.metamask` as Privy's + `walletClientType`; Privy documents that field as optional and expects its + own values such as `metamask` when supplied. Omitting optional metadata keeps + the SIWE login provider-agnostic and avoids mislabeling untrusted provider + identifiers. +- Preserve the existing raw EIP-1193 `eth_requestAccounts` and + `personal_sign` flow, timeout guard, and sanitized UI error. + +## Files/components touched + +- `apps/web/src/auth/privy-session.tsx` - omit invalid optional Privy wallet + metadata from headless SIWE login. +- `apps/web/test/privy-session.test.tsx` - regression test for an EIP-6963 + MetaMask provider and mock call contract. + +## Commands/checks + +- `git fetch origin develop` - current base is `46d4c87b5eaaa65295ce19a7148395f111602105`. +- `pnpm.cmd --filter @oneshot/web test -- privy-session.test.tsx` - PASS; 20 + files, 97 tests. +- `pnpm.cmd --filter @oneshot/web typecheck` - PASS. +- `pnpm.cmd exec prettier --write apps/web/test/privy-session.test.tsx` - PASS. +- `pnpm.cmd test` - PASS; 81 files, 1050 tests. +- `pnpm.cmd lint` - PASS. +- `pnpm.cmd typecheck` - PASS. +- `pnpm.cmd check:generated` - PASS. +- `pnpm.cmd exec prettier --check "**/*.{ts,mts,mjs,json,jsonc,yml,yaml}"` - PASS. +- `npx.cmd --yes markdownlint-cli2@0.18.1 .agent/context/20260911T163341Z-wallet-login.md` - PASS. +- `git diff --check` - PASS. +- Live browser reproduction was not completed because the extension prompt was + not accessible to the automation session; the page was restored without + completing login. + +## External-doc findings + +- Privy React wallet login documentation (current, 2026-09-11): + `https://docs.privy.io/authentication/user-authentication/login-methods/wallet` + documents `generateSiweMessage({ address, chainId })`, EIP-55 addresses, + `personal_sign`, and `loginWithSiwe({ signature, message })`; wallet client + and connector metadata are optional. +- Privy allowed-origin documentation (current, 2026-09-11): + `https://docs.privy.io/recipes/dashboard/allowed-domains` requires the + production origin to be allowlisted; this remains an operator-side check. + +## Unresolved questions + +- Whether the deployed Privy app has `https://oneshot.kapustazh.dev` enabled + under allowed origins and wallet login in its Dashboard. +- Whether the deployed frontend has received this fix; deployment is not part + of this local implementation step. + +## Git and PR state + +- Branch: `fix/wallet-login` +- Base: `develop` at `46d4c87b5eaaa65295ce19a7148395f111602105` +- Commit: uncommitted; staged candidate tree captured with `git write-tree` at handoff. +- PR: not created +- CI: not run for this branch + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Run full local checks and inspect the staged diff. +2. Capture exact candidate tree and provide the fresh FreePi Gate A prompt. +3. After Gate A PASS, commit, push, create a draft PR, wait for CI, and run + Gate B. Confirm Privy Dashboard configuration before claiming live login is + fixed. diff --git a/.agent/context/20260911T172612Z-privy-native-login.md b/.agent/context/20260911T172612Z-privy-native-login.md new file mode 100644 index 0000000..cd721a7 --- /dev/null +++ b/.agent/context/20260911T172612Z-privy-native-login.md @@ -0,0 +1,87 @@ +# Session Context: Privy native login + +## Date/time + +- UTC: 2026-09-11T17:26:12Z + +## User goal + +Replace OneShot's custom wallet login flow with Privy's native login modal because the custom EIP-6963 and SIWE path still reports a failed sign-in after the wallet connection is approved. + +## Original prompt/request + +The user reported that wallet approval succeeds in MetaMask but the frontend still shows the sign-in failure message, and requested removing the custom login and using Privy login directly. + +## Assumptions + +- Privy's configured native login modal is the intended authentication boundary for wallet and email login. +- Existing Privy app configuration keeps wallet and email login enabled; operator configuration and live browser behavior remain deployment checks. +- The machine-token fallback remains useful and is out of scope for removal. + +## Plan + +1. Branch from the current `origin/develop`. +2. Replace direct SIWE and wallet-provider calls with Privy's native `useLogin().login`. +3. Remove the custom wallet discovery, catalogue, picker, and obsolete tests/styles. +4. Validate web auth, full workspace tests, lint, typecheck, generated files, formatting, and frontend/browser builds. +5. Stage one scoped candidate and request fresh Gate A review before any push. + +## Key decisions + +- Use Privy's documented `useLogin` hook for the modal instead of calling wallet providers or `useLoginWithSiwe` directly. +- Keep `PrivyProvider` wallet/email configuration, session status, logout, and access-token refresh unchanged. +- Remove dead custom wallet modules rather than leaving an alternate authentication path in the bundle. + +## Files/components touched + +- `apps/web/src/auth/privy-session.tsx`: native Privy login hook and existing session/token adapter. +- `apps/web/src/auth/session.ts`: remove the custom wallet sign-in capability from the session contract. +- `apps/web/src/components/LoginGate.tsx`: always render the native Privy sign-in button for signed-out users. +- `apps/web/src/auth/eip6963.ts`, `apps/web/src/auth/wallet-catalogue.ts`, `apps/web/src/components/WalletPicker.tsx`: removed custom wallet path. +- `apps/web/src/styles.css`: remove picker-only styles. +- `apps/web/test/privy-session.test.tsx`, `apps/web/test/login-gate.test.tsx`: native login and session regression coverage. +- Corresponding obsolete wallet-picker/EIP-6963 tests: removed. +- `apps/web/package.json` and `pnpm-lock.yaml`: remove the no-longer-direct web dependency on `viem` after lockfile refresh. + +## Commands/checks + +- `pnpm.cmd install --lockfile-only --ignore-scripts` - PASS; lockfile updated only for the removed direct web dependency. +- `pnpm.cmd --filter @oneshot/web test -- privy-session.test.tsx login-gate.test.tsx` - PASS; 18 files and 78 tests. +- `pnpm.cmd test` - PASS; 80 files and 1,040 tests. +- `pnpm.cmd --filter @oneshot/web typecheck` - PASS. +- `pnpm.cmd typecheck` - PASS. +- `pnpm.cmd lint` - PASS. +- `pnpm.cmd check:generated` - PASS; generated contracts current. +- `pnpm.cmd format:check` - PASS. +- `pnpm.cmd build:frontend` - PASS; Vite emitted only the existing large-chunk warning. +- `pnpm.cmd test:browser` - PASS; 4 browser tests. +- `npx.cmd --yes markdownlint-cli2@0.18.1 .agent/context/20260911T172612Z-privy-native-login.md` - PASS; 0 errors. +- `git diff --check` - PASS before staging. + +## External-doc findings + +- Privy React Auth documentation (`https://docs.privy.io/authentication/user-authentication/ui-component`) documents the native `useLogin` hook and `login` method for opening the Privy login modal. +- Privy authentication documentation (`https://docs.privy.io/authentication/user-authentication/privy-auth`) confirms native wallet login and Privy access tokens provide the common application session boundary. + +## Unresolved questions + +- Live MetaMask authentication after deployment still needs operator verification; this environment does not provide a wallet extension session. + +## Git and PR state + +- Branch: `fix/privy-native-login` +- Base: `origin/develop` at `71b470a073439e4508e2626f195e886f23880d00` +- Commit: uncommitted; intended files staged and candidate tree captured with `git write-tree` +- PR: not created +- CI: not applicable yet + +## Review gates + +- Gate A: NOT RUN; staged candidate is ready for a fresh review +- Gate B: NOT RUN + +## Handoff/next steps + +1. Start a fresh Gate A review against the staged candidate tree and current base SHA. +2. Request a fresh Gate A review only after the candidate is stable. +3. Push/create a draft PR only after Gate A passes; do not merge or start Gate B in this session unless explicitly requested. diff --git a/.agent/context/20260911T184739Z-privy-reference-id-length.md b/.agent/context/20260911T184739Z-privy-reference-id-length.md new file mode 100644 index 0000000..feb4f9d --- /dev/null +++ b/.agent/context/20260911T184739Z-privy-reference-id-length.md @@ -0,0 +1,69 @@ +# Session Context: Privy reference ID length fix + +## Date/time + +- UTC: 2026-09-11T18:47:39Z + +## User goal + +Restore live Arc/Privy payments that currently end in `FAILED_SAFE` / `NOT_REQUESTED`. + +## Original prompt/request + +Investigate the submitted payment shown in the supplied screenshots and fix why it does not work. + +## Assumptions + +- The live failed intent is safe to leave closed; its ledger evidence says no broadcast occurred. +- Testnet-only validation is sufficient; do not create a new external payment solely for verification. + +## Plan + +1. Bound Privy `reference_id` to its provider limit while preserving deterministic idempotency. +2. Add a regression test for production-length business intent IDs. +3. Run checks, obtain fresh Gate A/B reviews, open a PR, and deploy the patched API/worker images. + +## Key decisions + +- Keep short references readable as `oneshot-`. +- For longer contract-valid IDs, derive a deterministic 56-hex fingerprint suffix, yielding exactly 64 characters. +- Keep the full payload fingerprint as `idempotency_key`; Privy permits its longer length and it preserves duplicate collapse. + +## Files/components touched + +- `packages/privy-adapter/src/request.ts` - bounded deterministic Privy reference ID. +- `packages/privy-adapter/test/request.test.ts` - long-ID regression coverage. + +## Commands/checks + +- `pnpm --filter @oneshot/privy-adapter test -- --run test/request.test.ts` - 22 passed. +- `pnpm format:check; pnpm lint; pnpm typecheck; pnpm test` - passed; 80 files / 1041 tests. +- `pnpm --filter @oneshot/worker test -- --run test/failure-injection.test.ts test/invariant-scenarios.test.ts test/worker.test.ts` - 3 files / 18 passed. +- Live authoritative intent lookup - `REQUEST_VALIDATION_FAILED` before broadcast; no evidence/transaction. + +## External-doc findings + +- Privy Ethereum `eth_sendTransaction` and transaction reference ID documentation state `reference_id` is capped at 64 characters; this explains the live pre-broadcast rejection. + +## Unresolved questions + +- None for the code fix. The old failed intent remains terminal by design and needs a new task key after deployment for a fresh attempt. + +## Git and PR state + +- Branch: `fix/privy-reference-id-length` +- Base: `origin/develop` (`f5a3fb335bbcdec832ad2895b0f175276300bd21`) +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: PASS; `free-pi-cli`, platform-reported `deepseek-v4-flash`, reviewed base `f5a3fb335bbcdec832ad2895b0f175276300bd21`, staged target `fix/privy-reference-id-length`, tree `d0f1786d0df876a22d6ad22921712e2732ad1b83`. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Inspect and stage only the two implementation files plus this context record. +2. Capture immutable base/tree identities, run Gate A, commit, push, open draft PR, await CI, then run Gate B. +3. Build and deploy updated API/worker images without changing deployment configuration files. diff --git a/.agent/context/20260911T190200Z-wallet-siwe-checksum.md b/.agent/context/20260911T190200Z-wallet-siwe-checksum.md new file mode 100644 index 0000000..b77af12 --- /dev/null +++ b/.agent/context/20260911T190200Z-wallet-siwe-checksum.md @@ -0,0 +1,69 @@ +# Wallet SIWE checksum follow-up + +## Goal + +Fix the remaining production wallet-login failure after MetaMask approves the +connection and message-signing flow. + +## Acceptance criteria + +- Normalize EIP-1193 wallet addresses to EIP-55 before Privy SIWE message + generation and `personal_sign`. +- Preserve the exact message/signature pair passed to `loginWithSiwe`. +- Reject malformed wallet addresses without exposing wallet data in the UI. +- Keep payment, ledger, settlement, reconciliation, retry, and API behavior + unchanged. +- Add regression coverage for a lowercase MetaMask-style address. + +## Diagnosis and assumptions + +- The merged wallet-login fix is present in the production bundle + `/assets/privy-session-BwhBvF1l.js`. +- The public Privy app configuration reports wallet authentication enabled. +- A request to Privy SIWE initialization from + `https://oneshot.kapustazh.dev` succeeds, so the remaining failure is after + wallet approval and is consistent with strict SIWE address formatting. +- Privy documents the SIWE `address` parameter as EIP-55 checksum-encoded. + EIP-1193 providers may return the same address in lowercase, so the client + must canonicalize it before generating and signing the message. + +## Scope and non-goals + +Only the web wallet SIWE adapter, its direct `viem` dependency, its test, and +this context record are in scope. This does not change Privy dashboard +configuration, payment flows, or any server-side authorization behavior. + +## Branch state + +- Branch: `fix/wallet-siwe-checksum` +- Base: `origin/develop` at `271b1afda4e4adf8f067def0f7775d06ae778318` +- Commit: uncommitted during implementation +- Gate A: not run +- Gate B: not run + +## Implementation + +- Added `viem` as a direct web dependency for its audited EIP-55 address + canonicalization. +- Canonicalized raw provider addresses before both SIWE operations. +- Added a regression fixture where the provider returns the lowercase form of + a valid address while Privy and `personal_sign` receive the checksum form. + +## Validation + +- `pnpm --filter @oneshot/web test -- privy-session.test.tsx` — passed, 20 files, + 98 tests. +- `pnpm --filter @oneshot/web typecheck` — passed. +- `pnpm lint` — passed. +- `pnpm test` — passed, 81 files, 1050 tests. +- `pnpm typecheck` — passed. +- `pnpm check:generated` — passed. +- `pnpm format:check` — passed. +- `pnpm build:frontend` — passed. +- `markdownlint-cli2` — passed for this context record. +- Gate A remains pending after the final diff is staged. + +## External verification pending + +Live MetaMask login must be reproduced after deployment. Privy Dashboard wallet +login and production-origin settings remain operator configuration items. diff --git a/.agent/context/20260911T200345Z-circle-x402-payment-fix.md b/.agent/context/20260911T200345Z-circle-x402-payment-fix.md new file mode 100644 index 0000000..fdf4a6e --- /dev/null +++ b/.agent/context/20260911T200345Z-circle-x402-payment-fix.md @@ -0,0 +1,89 @@ +# Session Context: Circle x402 payment fix + +## Date/time + +- Started UTC: 2026-09-11T20:03:45Z +- Completed UTC: 2026-09-11T23:00Z + +## User goal + +Make the Circle x402 paid-API flow complete through OneShot, Privy, Circle Gateway, and Arc Testnet. Direct Arc transfers already work; x402 must create one durable intent and one settlement. + +## Original prompt/request + +The Circle x402 workspace action remains `AUTHORIZING`/`FAILED_SAFE` and does not return the paid API result. Fix the x402 payment path without allowing a second settlement. + +## Assumptions + +- The previously observed failed-safe intent is terminal and had no external transaction; it must not be retried with the same task key. +- Testnet-only live validation is authorized; use a fresh task key only after deployment and never retry an unresolved intent. +- Existing untracked deployment files (`.gcloudignore`, `cloudbuild-api.yaml`, `cloudbuild-worker.yaml`) are user-owned and remain out of scope. + +## Plan/result + +1. Bound Circle x402 durable provider identities to the provider-safe 64-character limit and cover long intent IDs. **Done.** +2. Persist Circle's transfer UUID and paid response before Arc confirmation. **Done.** +3. Verify Circle Gateway `submitBatch` calldata and `BatchProcessed` receipt evidence. **Done.** +4. Reconcile the already-paid live intent without issuing another payment. **Done.** +5. Run the implementation loop and prepare a stacked PR. **Pending commit/PR.** + +## Key decisions + +- The live failure occurred before external submission (`FAILED_SAFE`, no evidence) and is safe to diagnose without replaying it. +- The running Cloud Run images use a reused `resumable-jobs-480c0ab` tag; the current source contains the Circle settlement routing added later. A uniquely tagged build is required to remove image-provenance ambiguity. +- Circle x402 provider identity now derives from the full request fingerprint with a 52-hex suffix (`circle-x402:` + 52), keeping the identity deterministic and 64 characters while preserving the full fingerprint separately. +- Circle `PAYMENT-RESPONSE.transaction` can be a transfer UUID, not an Arc tx hash. The UUID is now durable (`provider_transfer_id`), looked up through Circle's read-only transfer API, and bound to payer, seller, amount, and Arc network before accepting its tx hash. +- Circle Gateway batches do not necessarily emit a payer-to-recipient ERC-20 `Transfer`. For x402, authoritative proof is the Circle transfer identity plus decoded Gateway `submitBatch` deltas and its matching `BatchProcessed` event. Graph candidates remain observation-only and may be zero. +- Recovery remains fail-closed: the advisor can recommend `RETURN_EXISTING_RESULT`, while the deterministic core alone marks `COMMITTED`; settlement permission remains `NEVER`. + +## Files/components touched + +- `packages/supplier-adapter/src/circle-x402-settlement.ts` - bounded deterministic provider identity and matching settlement reference. +- `packages/supplier-adapter/test/circle-x402-settlement.test.ts` - long-intent identity regression test. +- `packages/supplier-adapter/src/circle-x402.ts` - transfer UUID parsing and Circle transfer lookup. +- `packages/arc-adapter/src/receipt.ts` - Circle Gateway batch receipt verifier. +- `apps/worker/src/recovery-bridge.ts` - durable transfer lookup, Arc batch evidence, and no false Graph proof. +- `packages/storage-postgres/migrations/008_circle_x402_transfer_identity.sql` - durable transfer UUID column. +- `packages/privy-adapter/src/privy-wallet-provider.ts` - transaction input lookup for Gateway calldata. +- `docs/CIRCLE_X402_DEMO.md` and `packages/storage-postgres/MIGRATIONS.md` - runbook/schema updates. +- `.agent/context/20260911T200345Z-circle-x402-payment-fix.md` - session record. +- Cloud Run API/worker deployment - operational rollout only; no secrets or credentials recorded. + +## Commands/checks + +- Live `GET /v1/paid-api/` and `GET /v1/intents/` - original task was `UNKNOWN`; it was reconciled read-only to `COMMITTED` after the existing Circle transfer was found. No second task/payment was created. +- Public x402 quote `GET https://oneshot.kapustazh.dev/api/premium/dataset` - HTTP 402 with valid Arc Testnet USDC Circle Gateway requirements. +- `pnpm test` - PASS, 80 files / 1049 tests. +- `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm check:generated`, `git diff --check` - PASS. +- Live Circle transfer: `66e4c182-6b84-42ad-95b9-94ffb73f5693`; Arc tx `0xaceb983a37537634fa6168053cdd6807f6f16a06cf82b5a67280e1c69e2f70a5`; block `61625301`; Gateway batch log index `12`. +- Live paid result is durable and replayable: seller dataset response persisted; same task key returned the existing `COMMITTED` intent and same tx hash. +- Recovery view: `recommendation_source=RECOVERY_AGENT`, `recommended_action=RETURN_EXISTING_RESULT`, `core_disposition=MARK_COMMITTED`, `settlement_permission=NEVER`, Graph Studio available but `candidate_count=0`/`LAGGING` (expected for Gateway batch). +- Cloud Run: worker revision `oneshot-worker-00027-jkx`; API revision `oneshot-api-00012-r68`; storage schema version `008_circle_x402_transfer_identity.sql` applied. +- Node runtime warning: local Node 22.23.2 differs from repository-required Node 24.19.0; checks passed despite the warning. + +## External-doc findings + +- No new sponsor claim was made. Existing Circle Gateway x402 implementation is constrained to Arc Testnet native USDC and the configured maximum. + +## Unresolved questions + +- None for the Circle x402 path. Future fresh demos must use a new task key and must never retry an unresolved intent before reconciliation. + +## Git and PR state + +- Branch: `fix/circle-x402-payment` +- Base: `develop` at `4d28073` (PR #91 is merged) +- Commit: `e7d2dac` (`fix(x402): reconcile Circle transfer UUIDs`) +- PR: [#92](https://github.com/SWOFART/OneShot/pull/92), open against `develop` +- CI: GitHub checks queued/in progress; local checks pass + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Review PR #92 and its queued GitHub checks. +2. Leave user-owned `cloudbuild-worker.yaml`, `.gcloudignore`, and `cloudbuild-api.yaml` untouched/un-staged. +3. Gates A/B remain skipped by explicit user authorization. diff --git a/.agent/context/20260912T-deployment-repair.md b/.agent/context/20260912T-deployment-repair.md new file mode 100644 index 0000000..f7c79d0 --- /dev/null +++ b/.agent/context/20260912T-deployment-repair.md @@ -0,0 +1,87 @@ +# Deployment repair handoff — 2026-09-12 + +## Goal + +Repair the OneShot deployment and Privy submission boundary without creating +another payment; restore database/auth/config health; preserve and reconcile +ambiguous durable requests safely. + +## Evidence + +- Project `oneshot-508002`, region `europe-west1`. +- Worker `oneshot-worker-00033-7lj` is Ready with 100% traffic. +- API `oneshot-api-00014-4cv` is Ready. +- Worker revision `00025-5j7` logged PostgreSQL password authentication failure + for user `postgres` during `migrate()` and then failed its startup probe. +- Current API and worker use the same Cloud SQL connection, `DB_USER`, + `DB_NAME`, and `ONESHOT_DB_PASS/latest` reference; secret values were not + printed or persisted. +- Revisions `00029-ddf` and `00028-4lq` have no settlement configuration + changes; only demo flags differ. +- The supplied `/v1/jobs` request returned HTTP 500; no new request was sent + during this session. + +## Deployment result + +- The active account received the required Cloud Build and Cloud Storage + permissions. +- Cloud Build `1e7c61ed-3b75-4aa3-8c13-5239553b683d` succeeded. +- The worker image was pushed as digest + `sha256:cd3782fb9d2f41b8a53b50e60c1fe1e6a300d2b3da754050416fa69fb780e280`. +- Cloud Run deployed revision `oneshot-worker-00033-7lj` with 100% traffic, + preserving the service's existing environment, secret references, and Cloud + SQL attachment. +- The revision is Ready; its startup TCP probe passed and the worker health + server reported listening on port 8080. + +## Repair evidence + +- Permissions propagated. Cloud SQL `oneshot-postgres` is RUNNABLE and has the + built-in `postgres` user. +- Cloud SQL `postgres` password was synchronized to the trimmed runtime value + of `ONESHOT_DB_PASS`; the secret value was never printed or persisted. +- Worker revisions `00030` and `00031` failed first on the database password, + then on the Privy baseline gate. The live Privy identity showed only + `policyDigest` drift, but the live policy was unsafe: it allowed three + unrelated methods and lacked recipient/amount constraints. +- Privy policy was repaired to two constrained direct-transfer ALLOW rules + (`eth_sendTransaction`, `eth_signTransaction`) plus wildcard DENY. It pins + Arc Testnet chain/token, zero native value, transfer function, the documented + team recipient, and the 1,000,000-atomic-unit cap. +- Worker revision `oneshot-worker-00032-kqk` and API revision + `oneshot-api-00014-4cv` are Ready with 100% traffic. `/health/ready` returned + HTTP 200 and neither current revision has recent ERROR logs. +- New test task `report-matvii-3c171c4b` reached `AUTHORIZING` and then + `UNKNOWN` with `MALFORMED_RESPONSE`, without a durable transaction hash or + provider reference. Privy read-only wallet history showed zero USDC + transactions for the wallet; no matching recent Arc Transfer log was found. +- The live Privy wallet and policy are reachable. The policy has two + constrained ALLOW rules (`eth_sendTransaction`, `eth_signTransaction`) plus + wildcard DENY, and the worker startup identity gate passes. +- The staged code fix is deployed: nested Privy REST response envelopes are + normalized, and nested Privy HTTP status fields are classified as + pre-broadcast rejection only for explicit 4xx statuses. All + unrecognized/ambiguous responses remain `UNKNOWN` fail-closed. +- Local implementation gates pass: focused Arc adapter 199/199 tests, + Privy adapter 131/131 tests, worker 49/49 tests, typechecks, full build, + lint, and formatting. +- Deployment completed without submitting or approving a payment. +- Gate A review was attempted with a fresh `free-pi-cli` process, but it did + not return an explicit structured verdict and was terminated; no PASS is + claimed. +- The authoritative job list does not contain + `report-teammate-wallet-f7b0ecb7`. Three other teammate keys exist, but they + are distinct `UNKNOWN` intents with no committed settlement and must not be + substituted for the requested key. + +## Safety state + +- Do not POST `/v1/jobs`. +- Do not approve another payment or create a new task key. +- Treat the existing task key `report-teammate-wallet-f7b0ecb7` as the same + durable intent and reconcile before any settlement retry. +- Do not approve or submit another payment until the adapter fix is deployed + and a human authorizes one final Arc Testnet validation. +- The active account now has project-level Cloud Build Editor, Storage Admin, + Service Usage Admin, Cloud Run Admin, and Secret Manager access needed for + this deployment path. diff --git a/.agent/context/20260912T-gateway-funding.md b/.agent/context/20260912T-gateway-funding.md new file mode 100644 index 0000000..f67083d --- /dev/null +++ b/.agent/context/20260912T-gateway-funding.md @@ -0,0 +1,60 @@ +# Gateway funding UX + +## Goal + +Add an explicit user-wallet funding flow for Circle Gateway Nanopayments on Arc +Testnet. The connected buyer wallet funds its own Gateway balance; OneShot must +never fund or substitute a server wallet. + +## Branch + +- Branch: `fix/gateway-funding` +- Base: refreshed `origin/develop` (record exact SHA during Gate A) +- Worktree: `OneShot-gateway-funding` + +## Acceptance criteria + +- Funding is initiated only by an explicit user action from the connected Privy + EVM wallet. +- The flow uses Arc Testnet only (`chainId` 5042002, Gateway domain 26) and + testnet USDC only. +- The flow approves the Gateway Wallet to spend the requested ERC-20 amount, + waits for a successful receipt, then calls `deposit(USDC, amount)`. +- It never sends a normal ERC-20 transfer directly to the Gateway Wallet. +- The active wallet address is the depositor and cannot be substituted. +- Amounts remain integer atomic units / bigint and are validated before wallet + prompts. +- A lost or ambiguous deposit response is held for verification; the UI does + not blindly repeat the deposit. +- The paid API path can use the resulting Gateway balance without changing its + existing at-most-once settlement state machine. + +## Non-goals + +- No mainnet support or real-value funding. +- No server-side funding, private-key handling, or Gateway credentials. +- No changes to durable paid API settlement semantics. +- No automatic deposits without user confirmation. + +## Failure boundaries + +- Before approval submission: no Gateway balance effect; retry may be offered. +- Approval submitted/pending/reverted: do not submit deposit until approval is + confirmed; preserve the approval hash when available. +- Deposit submitted/pending/lost response: do not retry blindly; show the hash + or verification instruction and re-check Gateway balance/pending deposits. + +## Selected test matrix cases + +- Privy denial: zero Gateway funding and no deposit call. +- Crash/lost response around approval and deposit: preserve safe hold and avoid + duplicate deposit prompts. +- Repeated funding action: balance/known pending state checked before a new + deposit. +- Exact wallet, chain, token, Gateway contract, and integer amount validation. + +## Public integration evidence + +Circle documents Arc Testnet as Gateway Nanopayments domain 26, requires a +Gateway deposit before gas-free payments, and warns against direct ERC-20 +transfers to the Gateway Wallet. Testnet USDC and testnet gas only are in scope. diff --git a/.agent/context/20260912T-user-wallet-payment.md b/.agent/context/20260912T-user-wallet-payment.md new file mode 100644 index 0000000..9ea1449 --- /dev/null +++ b/.agent/context/20260912T-user-wallet-payment.md @@ -0,0 +1,91 @@ +# User-wallet payment implementation + +## Goal + +Make the Team Report browser flow charge the connected Privy Ethereum wallet, +not the server-configured Privy execution wallet, while preserving OneShot's +durable one-intent/at-most-one-settlement invariant. + +## Acceptance criteria + +- Prepare a durable `USER_WALLET` job before any external transaction. +- Bind the reviewed payer address, token, chain, recipient, and integer amount. +- Have the browser wallet submit the exact ERC-20 transfer after explicit review. +- Verify the Arc receipt and exactly one expected Transfer log before commit. +- Persist the transaction hash before verification and refuse a different hash for + the same attempt. +- Treat missing, delayed, or mismatched receipt evidence as non-final/UNKNOWN; + never submit another transaction automatically. +- Keep the existing server-wallet and Circle x402 paths unchanged. +- Do not include credentials, tokens, private keys, or wallet secrets. + +## Scope and non-goals + +In scope: contracts/OpenAPI, Postgres job binding, API receipt verification, +Privy browser transaction submission, focused tests, and migration 009 (after syncing develop's migration 008). + +Out of scope: deployment, live payment submission, automatic retry of any +existing job, and changing the server-wallet path used by other demos. + +## Selected test matrix + +- Normal user-wallet job: prepare, submit, exact receipt, one committed settlement. +- Lost/delayed receipt: hash is durable and state is UNKNOWN; same hash can be + checked again, but a different transaction is refused. +- Provider/RPC unavailable: no state transition to no-payment and no retry. +- Existing server-wallet delivery tests remain green. +- API boundary coverage includes UNKNOWN, receipt mismatch, different-hash, + non-user-wallet, and payer-conflict refusals. +- PostgreSQL-gated coverage includes READY payer binding, no server authorization + outbox, durable hash persistence, UNKNOWN transition, and hash conflict. +- Arc receipt-source coverage includes finalized receipt mapping, missing receipt, + malformed hash, and fixed-chain enforcement. + +## Branch state + +Branch: `fix/privy-native-login`. + +Prior hardening commit: `a8727bb` (`fix: harden Privy settlement response handling`). +The user-wallet feature is staged as a candidate delta pending fresh Gate A +review and commit. Local non-container validation passes; PostgreSQL integration +execution is unavailable on this workstation because no container runtime is +available. + +## Follow-up: live proof projection + +The live user-wallet job reached `COMMITTED`, the supplier result became +available, and the public Arc explorer showed a successful 1 USDC transfer from +the connected wallet to the reviewed recipient. The API submit and subsequent +intent/recovery reads returned HTTP 200 on the active API revision. + +The Payment Proof panel still displayed `Needs verification` because its +projection only accepted `ARC` authoritative evidence. The user-wallet submit +path durably inserts an `ONESHOT` authoritative observation after the direct Arc +receipt verifier confirms the exact transfer. The same response also omitted +payment mode, so the generic policy panel rendered `Policy: Not reported`. + +Follow-up goal: expose the existing durable payment mode on intent reads, treat +an authoritative OneShot observation as verified only for a committed +`USER_WALLET` intent (preserving the strict Arc-evidence path for other modes), +and render user-wallet policy as not applicable. No payment or deployment action +is part of this code change. + +## Follow-up implementation state + +Implemented locally: generated contracts expose optional `IntentResponse.payment_mode`; +PostgreSQL intent projection loads it from the existing job binding; the +settlement proof projection recognizes the user-wallet commit's authoritative +OneShot observation; and the policy panel says `Not applicable` for direct +connected-wallet payments. Existing server-wallet verification remains +Arc-authoritative only. Added UI, contract, and PostgreSQL-gated regression +coverage; no migration or external payment behavior changed. + +Validation: focused settlement UI 211/211, contracts 39/39, full suite +82 files/1075 tests, browser acceptance 8/8, typecheck, lint, format, generated +contract check, and diff check pass. The PostgreSQL-gated suite remains +environment-dependent on a container runtime as documented above. + +Gate A: a fresh `npx.cmd free-pi-cli` process was started for the staged +candidate, but it emitted an unbounded interactive trace and exited without +the required structured verdict. No Gate A PASS is claimed; no push, PR, or +deployment was performed. diff --git a/.agent/context/20260912T011531Z-user-friendly-workspace.md b/.agent/context/20260912T011531Z-user-friendly-workspace.md new file mode 100644 index 0000000..a2001e0 --- /dev/null +++ b/.agent/context/20260912T011531Z-user-friendly-workspace.md @@ -0,0 +1,108 @@ +# Session Context: user-friendly workspace + +## Date/time + +- UTC: 2026-09-12T01:15:31Z + +## User goal + +Create a separate reviewable pull request that makes the authenticated OneShot +workspace understandable to non-developers. Remove technical labels and raw +identifiers from the primary surface, replace Developer Access / Wallet +Permission / Tools / Jobs / Recovery Assets with clear user-facing concepts, +and keep technical evidence available behind an explicit details boundary. + +## Original prompt/request + +The user asked to implement the previously agreed UX plan in a separate PR: +remove the 100% developer-access presentation, redesign wallet permissions, +improve payment review, make tools and jobs clearer, and rebuild recovery UI so +the workspace is ready for human review rather than a collection of internal +technical panels. + +## Assumptions + +- This PR is frontend-only. Existing API contracts and settlement invariants + remain authoritative. +- The current workspace supports read-only policy/access presentation; it does + not expose a policy-editing API. The UI must not imply that it can edit rules. +- Full identifiers, addresses, provider details, and raw state names remain + available only in advanced evidence/details surfaces. + +## Plan + +1. Add a shared user-facing status/copy mapping and safe masking helpers. +2. Reshape the authenticated cabinet navigation and overview. +3. Replace wallet/developer/recovery panels with Spending Rules, Team & Access, + and Payment Protection views. +4. Make service/request/review cards human-readable and hide raw identifiers. +5. Improve the legacy payment status surface without removing UNKNOWN from the + domain or reconciliation behavior. +6. Update focused tests, run web checks, and capture Gate A/PR state. + +## Key decisions + +- `UNKNOWN` is not removed from backend or durable state; it is rendered as + plain-language payment verification so users are not invited to retry. +- Recovery Assets is removed as a primary concept. Recovery is presented as + Payment Protection, with evidence and Graph/agent internals behind details. +- No new dependency, backend permission model, or payment path is added. + +## Files/components touched + +- `apps/web/src/App.tsx` +- `apps/web/src/components/JobWorkspace.tsx` +- `apps/web/src/components/IntentStatusView.tsx` +- `apps/web/src/components/LoginGate.tsx` +- `apps/web/src/components/WorkspacePanels.tsx` +- `apps/web/src/components/workspace-copy.ts` +- `apps/web/src/styles.css` +- Focused web tests and browser acceptance tests. + +## Commands/checks + +- `git fetch origin develop` - completed; base is `origin/develop` at + `bfaf733b779dece7adc61c8b6483b1360e4f5667`. +- Worktree created on `feature/user-friendly-workspace`. +- `pnpm --filter @oneshot/web typecheck` - PASS (Node 22.23.2 engine warning; + repository requests Node 24.19.0). +- `pnpm --filter @oneshot/web lint` - PASS. +- `pnpm format:check` - PASS. +- `pnpm --filter @oneshot/web test -- --no-file-parallelism --maxWorkers=1 + --pool=threads --reporter=dot` - PASS, 18 files / 78 tests. +- `pnpm --filter @oneshot/web test:browser` - PASS, 4 browser scenarios. +- `pnpm build` - PASS. +- `pnpm test -- --no-file-parallelism --maxWorkers=1 --pool=threads + --reporter=dot` - PASS, 80 files / 1049 tests. +- `git diff --check` - PASS. + +## External-doc findings + +- `.agent/PROJECT_CONTEXT.md` - OneShot remains authoritative for payment + state; UI changes must not change settlement authority. +- `.agent/SECURITY_INVARIANTS.md` - preserve UNKNOWN/reconciliation and never + expose secrets or signing material. + +## Unresolved questions + +- None for the frontend scope. Backend-enforced team roles remain a later + milestone because no editing/role API is present in this branch. + +## Git and PR state + +- Branch: `feature/user-friendly-workspace` +- Base: `origin/develop` / `bfaf733b779dece7adc61c8b6483b1360e4f5667` +- Commit: uncommitted; candidate tree staged before commit +- PR: not created +- CI: not run + +## Review gates + +- Gate A: skipped at the user's explicit request. +- Gate B: skipped at the user's explicit request. + +## Handoff/next steps + +1. Commit only the focused UI changes, push, and open a draft PR targeting + `develop`. +2. Human review and CI remain pending on the PR; no FreePi review gate was run. diff --git a/.agent/context/20260912T024410Z-console-readability-and-layout.md b/.agent/context/20260912T024410Z-console-readability-and-layout.md new file mode 100644 index 0000000..a44e9f0 --- /dev/null +++ b/.agent/context/20260912T024410Z-console-readability-and-layout.md @@ -0,0 +1,128 @@ +# Session Context: operator console readability and layout + +## Date/time + +- UTC: 2026-09-12T02:44:10Z + +## User goal + +Fix nine reported defects in the operator console frontend so the deployed app +at oneshot.kapustazh.dev/app is readable and correctly laid out in both themes. + +## Original prompt/request + +Nine numbered items with screenshots: (1) text unreadable in light theme, +(2) transaction info unreadable in dark theme, (3) remove the "Wallet & +permissions" and "Developer access" tabs, (4) Tools tab panels separated +incorrectly, (5) Jobs empty state has no line spacing, (6) hero on /app is +narrower than the surrounding elements and its text misaligns differently per +monitor, (7) x402 task-key field unstyled/inline with neighbouring text, help +text not on its own line, runbook link not left-aligned, type too small at high +resolution, (8) same treatment for Recovery & activity, (9) fade animation on +tab switch and theme change. + +## Assumptions + +- The two removed tabs were read-only restatements with no controls behind + them, so removing them loses no operator capability. Confirmed by reading + both sections: facts already shown elsewhere, plus a static code sample. +- "Make font bigger depending on resolution" means the page should scale with + viewport width, implemented once at the root rather than per-component. + +## Plan + +1. Hand back to the user for review of the running app. +2. Run FreePi Gate A against the candidate tree before any push. + +## Key decisions + +- Root cause of (1) and (2) is one mistake in two directions: --os-panel and + --os-field never flip with the theme and carry fixed inks; --os-ink and + --os-ground flip together. Each defect paired one family's ink with the other + family's surface, which is invisible in exactly one theme. Fixed by pairing + correctly rather than by patching individual colours. +- Added --os-on-field-muted to the brand tokens: a fixed surface needs a fixed + muted ink too, or secondary copy on lime falls back to near-white panel ink. + Audited in packages/brand/test/tokens.test.ts (6.89:1, AA). +- The hero copy moved from absolute positioning to normal flow so it sets the + hero height; Hero.tsx measures that height and feeds heroClipPaths. A fixed + 268px box was clipping the lead paragraph, and the amount clipped varied by + monitor because the headline used a vw-based clamp. Type inside the hero is + now rem-based; :root carries the single fluid clamp instead. +- Rejected per-component font-size bumps for (7) in favour of one root clamp, + so every ratio on the page is preserved at every width. + +## Files/components touched + +- packages/brand/src/tokens.css - added --os-on-field-muted. +- packages/brand/test/tokens.test.ts - audit the new token in both themes. +- apps/web/src/styles.css - ink/surface pairing, root fluid type, .panel-heading + (previously unstyled), .panel-stack, .paid-api-panel, .recovery-panel, a.secondary, + hero layout and typography, theme and tab-switch motion. +- apps/web/src/components/Hero.tsx - measure height, drop the fixed 268px box. +- apps/web/src/App.tsx - removed the wallet and developer sections, keyed + .tab-fade wrapper, .panel-stack on Tools, .recovery-panel on Recovery. +- apps/web/src/components/JobWorkspace.tsx - .paid-api-panel on the x402 section. +- apps/web/test/app-composition.test.tsx - assert the four-tab set. +- apps/web/test/styles.test.ts - guard the ink/surface pairing. + +## Gate A round 1 (FAIL) and the fixes it forced + +FreePi Gate A, glm-5.3-flash, returned FAIL on acceptance criterion 1: the +light theme still had unreadable text. The first pass fixed the reported +surfaces but missed that --os-surface also flips with the theme, so several +controls drawn on it kept panel inks: + +- .response-output - near-white on white in light. Now --os-ink. +- .advanced-fields label - split out of the panel-ink group. Now --os-ink. +- .advanced-fields summary - now --os-ink-muted, weight 300 (was 600, the only + bold left in the console). +- .job-workspace > input[readonly] - now --os-ink-muted. + +Non-blocking findings also addressed: signal green on lime measured ~1.7:1, so +links on the field now take --os-on-field with an underline; fact rows on lime +take a new fixed --os-field-line token instead of the flipping --os-line. + +apps/web/test/styles.test.ts gained a structural guard that resolves each +rule's nearest painting ancestor and fails on any fixed ink over a flipping +surface. Verified against the committed HEAD stylesheet: it reports all six +pre-fix pairings and reports the candidate clean. + +## Commands/checks + +- `pnpm build` - pass +- `pnpm test` - pass, 81 files / 1054 tests +- `pnpm lint` - pass +- `pnpm typecheck` - pass +- Browser check at 1440x1000, dev server in test mode: contrast measured from + computed styles in both themes - tabs on ground 13.0 light / 18.0 dark, + hero h1 on panel 12.52, quote dt/dd on lime field 6.89 / 12.8. All AA. + +## External-doc findings + +- None required; the change is confined to this repository's own tokens and + stylesheet. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: fix/console-readability-and-layout +- Base: develop at bfaf733b779dece7adc61c8b6483b1360e4f5667 +- Commit: uncommitted working tree +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. User reviews the running app locally. +2. Stage the intended files, capture the candidate tree SHA, run FreePi Gate A + in a fresh `npx free-pi-cli` process, then commit, push and open a draft PR + targeting develop. diff --git a/.agent/context/20260912T073500Z-recovery-ui-redesign.md b/.agent/context/20260912T073500Z-recovery-ui-redesign.md new file mode 100644 index 0000000..12790fe --- /dev/null +++ b/.agent/context/20260912T073500Z-recovery-ui-redesign.md @@ -0,0 +1,60 @@ +# Recovery UI redesign — active context + +## Goal + +Make payment proof and recovery evidence readable inside the authenticated +workspace while keeping the existing recovery API and settlement safety rules. + +## Acceptance criteria + +- Payment proof is a compact, readable read-only surface with real request facts. +- Recovery control keeps state, safe actions, timeline, attempts, decision split, + Graph observation, and sanitized evidence in the selected request view. +- Graph is visible with an explicit unavailable/not-reported state when the API + has no observation; absence never implies no payment. +- The synthetic standalone `/recovery/` viewer is not emitted by the production + frontend build. +- Arc Testnet (`eip155:5042002`) and USDC remain the only settlement network and + asset. No payment API, wallet policy, or settlement behavior changes. +- No demo or presentation work is included. + +## Non-goals + +- Do not change the recovery API or server contract. +- Do not add payment actions, wallets, networks, or production fixture data. +- Do not perform live payments, deployment, or external policy mutation. + +## Branch state + +- Branch: `fix/recovery-ui-redesign` +- Base: `origin/develop` at `c6c5d5f27285c002a23d06c5213da8dab60d1ef7` +- Carried hero fix: `750ffb6` +- Context record: `148a7f6` + +## Implementation + +- Cabinet keeps the restored `Your payment workspace` flow and presents one + `Payment proof` section with selected request facts, activity counters, and + read-only settlement evidence followed by `Recovery control`. +- Recovery control carries state, safe refresh/escalation boundaries, Graph + candidate discovery, durable timeline, attempts, decision split, evidence, + and diagnostics. Missing Graph data is shown as `NOT REPORTED`; it never + implies that no payment occurred. +- Settlement details now lead with Arc Testnet request, recipient, amount, and + verification status. User-facing navigation and actions use `Payment proof` + and `Recovery control`. +- The synthetic standalone recovery viewer is retained for local package tests + but removed from `build:frontend`; the recovery API and payment behavior are + unchanged. The synthetic mock version marker was removed from the UI-only + contract. + +## Validation + +- `pnpm --filter @oneshot/recovery-ui verify` — PASS (51 tests). +- `pnpm --filter @oneshot/settlement-ui verify` — PASS (208 tests). +- `pnpm --filter @oneshot/web exec vitest run --config vitest.config.ts --pool=threads --maxWorkers=1 --no-file-parallelism` — PASS (79 tests). +- `pnpm --filter @oneshot/web exec playwright test --config=playwright.config.ts --workers=1` — PASS (8 tests). +- `pnpm build` — PASS; `pnpm build:frontend` — PASS, no `apps/web/dist/recovery` emitted. +- `pnpm lint`, `pnpm typecheck`, `pnpm format:check`, and + `pnpm check:generated` — PASS. +- Gate A/B: pending for the final staged tree and PR head. diff --git a/.agent/context/20260912T120000Z-one-day-rescue.md b/.agent/context/20260912T120000Z-one-day-rescue.md new file mode 100644 index 0000000..7606c80 --- /dev/null +++ b/.agent/context/20260912T120000Z-one-day-rescue.md @@ -0,0 +1,175 @@ +# Session: one-day rescue plan and interface readability + +## Date/time + +2026-09-12 (UTC). + +## User goal and original request + +Prepare a realistic one-day rescue strategy and open a PR: demo, current sponsor +requirements, stronger rules and Arc developer experience, priorities and concept +testing. Fix bright/illegible light-theme landing and cabinet tabs/cards; align +Jobs/Drops and results area with login information. User requested caveman and +ponytail; persisted documents use normal prose. + +## Assumptions and acceptance + +Use latest develop, preserve payment behavior, no live deployment or new payment. +Current code calls the area Requests and results. Registration pool is unknown. +Deliver sourced plan, scoped contrast/width fixes, browser verification and PR +through mandatory Gate A/CI/Gate B. No merge or mainnet work. + +## Plan and decisions + +One paid API purchase resumed under the same task identity is the primary story. +Reuse existing x402/worker and direct-transfer drills. Time-box Graph proof. +Current Arc prizes have no Best Dev Tools track; distinguish Continuity award. +Fix actual surface/ink mismatches and make the cabinet hero share login width. +Visual inspection also found the landing hero clipped long text and CTAs; both +product headers use the existing auto-height plain surface. Browser coverage +asserts the heading and actions fit inside the hero. + +## Files/components + +- docs/ONE_DAY_RESCUE.md and plan.md: current execution priorities. +- apps/web/src/App.tsx, styles.css and browser/p5.spec.ts: theme/width fix and checks. +- packages/brand/src/tokens.css and test/tokens.test.ts: accessible page accent. + +## Commands/checks + +Initial worktree clean. Fetched origin/develop and branched from db757b4. + +- pnpm test: PASS, 80 files / 1049 tests; includes build. +- pnpm test:browser: PASS, 8 tests, 390/1440 px, light/dark, all cabinet tabs, + quote and resumed result, axe rendered contrast and matching outer widths. +- pnpm lint, pnpm typecheck, pnpm format:check, pnpm check:generated: PASS. +- Local Node 22.23.2 differs from required 24.19.0; CI must validate pinned runtime. +- No settlement code changed; live payment/failure and PostgreSQL integration + not rerun locally. Plan lists those as future demo acceptance, not evidence. +- Screenshot inspection confirms cabinet typography, aligned surface edges and + complete landing hero copy/buttons. Final focused web suite: 18 files / 78 tests + PASS. Markdown lint and plan relative links PASS. + +## External-doc findings + +ETHOnline 2026 Arc, Privy and Graph prize pages checked 2026-09-12; linked in plan. +Arc infrastructure confirms chain ID and native-vs-ERC20 precision distinction. +Circle transfer lookup accepts transfer UUID; Privy rules are method-specific. + +## Unresolved questions + +Registration pool and current live qualification require owner/live evidence. +Historical evidence is not a new submission qualification. + +## Git and PR state + +Branch milestone/one-day-rescue; base db757b4fb71961a6acc20972bd454b8af1dc6e0c. +Implementation commit: `b5ee5c2ef0658930c31d1c575e68ccd4839ecbf0`. +Draft PR: [#94](https://github.com/SWOFART/OneShot/pull/94) against develop. +The PR body records the final remote head/tree and final CI results; this file +is a pre-handoff snapshot and is not itself a gate verdict. CI was launched; +repository-policy passed on the implementation commit. Final acceptance requires +all checks on the current PR head, including the context handoff commit. + +## Review gates + +Gate A BLOCKED: fresh `npx free-pi-cli` process reached the configured +`deepseek-v4-flash` model but the service rejected it with HTTP 409 +`concurrent_session`. No review verdict was produced. Attempted staged tree: +`dba6d062e373eb81f9e3764fe903ec4f6e2b6059` (context changes invalidate that tree). +Gate B NOT RUN. User explicitly authorized this turn: «Открыть draft PR без FreePi». +Gate A/B are waived for this draft PR only; no PASS is claimed. Required CI +remains required. Keep the PR draft for human review; do not merge. + +## Handoff/next steps + +1. Finish CI on the exact remote head and update the PR body with results. +2. Leave PR #94 draft under the explicit FreePi exception, with no merge. +3. Execute docs/ONE_DAY_RESCUE.md as the next work packet; live demo and sponsor + qualification are still pending. PR checks/body are the final handoff ledger. + +## Follow-up: policy UX and real walkthrough + +User requests a site policy editor, removal of a reported $1/100000 transfer cap, +clear recipient amounts, readable quote/review cards, button motion, cleaner rules +and request forms, ConnectivityManager diagnosis and a walkthrough using real work. +Continue PR #94; its explicit FreePi exception persists for this draft. + +Inspect before changing external policy: exact methods/conditions and the +ConnectivityManager error were requested. No remote mutation is performed from +an ambiguous rule description. Code has a configurable cap, not a fixed $1 cap. +The ethereum_transaction conditions constrain chain, contract and native value. +Privy policy PATCH requires resource-owner authorization; normal site login alone +is not sufficient. Primary references are recorded in the follow-up design doc. + +Independent scope: normal-surface quote cards, accurate preview/recipient copy, +reduced-motion-aware interactions, truthful policy status, a guided walkthrough +that navigates the existing UI and never submits payment or fabricates results. +Policy editing remains a concrete design until ownership/rules are established. +Validation and final follow-up identities will be recorded in PR #94. + +## Follow-up correction + +The settlement network remains Arc Testnet (`eip155:5042002`) with USDC. Arbitrum +is not part of this branch or its plan. No live payment, deployment, or external +wallet-policy mutation was performed. + +## Validation update + +- Quote approval is bound to the exact provider price, recipient, destination, + network and timeout; quote drift returns a conflict before durable work. +- Circle Gateway typed-data quotes now require the pinned Arc Testnet Gateway + wallet, and the Privy wallet chooser exposes detected Ethereum wallets only. +- `pnpm test`: PASS, 81 files / 1058 tests; `pnpm test:browser`: PASS, 8 tests. +- `pnpm lint`, `pnpm typecheck`, `pnpm format:check`, and + `pnpm check:generated`: PASS. Node 22.23.2 differs from the required 24.19.0. + +## Handoff update + +- Implementation commit: `ac0439efc9228c0cddd623b789c1a90bcc8fec0e`. +- Implementation tree: `e6e447615ff6f9b851ab37dda4068ff405b3dd6e`. +- A context-only handoff commit follows that implementation; its exact PR head + and tree are recorded in the PR body and final handoff. +- Required CI checks pass on the implementation commit. +- Gate A and Gate B remain **NOT RUN / WAIVED** under the explicit draft-PR + exception; no PASS is claimed. The PR remains draft for human review. + +## Follow-up: restore workspace slice and UI action states + +The previous split hero is restored on the public landing page and the +authenticated cabinet. `Your payment workspace` is present in the cabinet hero +and the legacy main workspace heading. The landing hero uses the same clipped +panel/figure treatment with enough height for its copy; the cabinet keeps the +compact version. No image asset, wallet, network or payment behavior changed. + +Job start and result resume now expose busy state, disable duplicate clicks and +announce pending work with `aria-busy`. Resume failures remain read-only and +state that no new payment was submitted. Circle service copy no longer calls a +local quote/result live. Browser coverage checks the split hero and pending +button states. + +Validation after this follow-up: `pnpm test` passed (81 files / 1058 tests), +`pnpm --filter @oneshot/web test:browser` passed (8 tests), browser typecheck, +lint, typecheck, format check, generated-contract check and `git diff --check` +passed. Local Node remains v22.23.2 while the repository requests v24.19.0. + +## Follow-up: CI contrast correction + +PR #95 replaces the superseded merged PR #94 as the review surface. Its first +browser run found a dark-mode accessibility failure: the generic button hover +opacity dimmed the selected `Team & access` tab below the required contrast. +The selected tab now keeps full opacity while hovered. This is a CSS-only +root-cause fix; no Arc Testnet/USDC, wallet, policy, payment, demo, or +presentation behavior changes. + +The previous Gate A result for commit `cd2ea4c3a92b9a2ba66b4d1ae32635789e556e8e` +and tree `249f59a90d69015e604608fb2ecddb1c16bccc68` is superseded by this +follow-up tree. Run fresh Gate A and Gate B against the final exact tree after +the contrast fix and required CI complete; record those verdicts in PR #95. + +## Follow-up: landing hero scale + +The restored split hero keeps its panel and figure, but the public landing +height is reduced from 620px to 460px. Hero-only typography and spacing are +compact so the copy remains inside the panel. No API, wallet, payment, +recovery, demo, or presentation behavior changed. diff --git a/.agent/context/20260912T170751Z-activity-refresh.md b/.agent/context/20260912T170751Z-activity-refresh.md new file mode 100644 index 0000000..2cee7f5 --- /dev/null +++ b/.agent/context/20260912T170751Z-activity-refresh.md @@ -0,0 +1,96 @@ +# Session Context: activity refresh empty body + +## Date/time + +- UTC: 2026-09-12T17:08:00Z + +## User goal + +Make the payment activity refresh usable in the live OneShot console. The UI +currently reports that activity is unavailable because the backend returns 400 +for the read-only refresh request. + +## Original prompt/request + +User reported: `Payment activity is unavailable right now. Existing payment records are unchanged.` + +## Assumptions + +- The browser request is authorized and intentionally bodyless; its JSON content + type is the compatibility trigger. +- This endpoint only reads Graph activity and records an observation; it must not + submit or alter a payment settlement. +- Preserve required JSON validation on all other API routes. + +## Plan + +1. Confirm the live 400 cause from API code and Cloud Run logs. +2. Add a route-scoped backend compatibility fix and regression test. +3. Run API checks, capture Gate A evidence, then review/push/deploy per policy. + +## Key decisions + +- Handle the empty JSON header in the API onRequest hook for + `/v1/activity/refresh` only. The existing browser client cannot be assumed to + change in this backend-only fix. +- Keep the request body parser and validation unchanged for routes that require + JSON payloads. + +## Files/components touched + +- `apps/api/src/app.ts`: accept bodyless JSON POST for activity refresh. +- `apps/api/test/app.test.ts`: regression coverage with JSON content type. + +## Commands/checks + +- Cloud Run request logs show repeated `POST /v1/activity/refresh` HTTP 400. +- Cloud Run service config has no Graph activity variables, so runtime uses the + unavailable read-only activity port; this should still return a stored snapshot. +- API and Fastify source inspection identified `FST_ERR_CTP_EMPTY_JSON_BODY`. +- `pnpm --filter @oneshot/api... build` - PASS. +- `pnpm --filter @oneshot/api test -- --run test/app.test.ts` - PASS (20 tests). +- `pnpm --filter @oneshot/api test -- --run test/wallet-activity.test.ts` - PASS (2 tests). +- `pnpm --filter @oneshot/api typecheck` - PASS. +- `pnpm --filter @oneshot/api lint` - PASS. +- `pnpm format:check` - PASS. +- `pnpm typecheck` - PASS. +- `pnpm lint` - PASS. +- `pnpm check:generated` - PASS. +- `pnpm test` - PASS (82 files / 1078 tests). + +## External-doc findings + +- None required; diagnosis uses the repository Fastify route and live Cloud Run + status/log evidence. + +## Unresolved questions + +- Whether to deploy immediately after Gate A/CI; prior user authorization covers + updating the live Cloud Run API, but human merge remains required by policy. +- Gate A could not be completed: three fresh FreePi attempts either hit the + provider's `409 concurrent_session` or remained in `Working...` without a + structured verdict. No code or staged content was changed by those attempts. + +## Git and PR state + +- Branch: `fix/activity-refresh-empty-body` +- Base: `develop` at `ff146e49407b078183ed619ff3f3b2e11b17da2c` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: BLOCKED — external FreePi provider returned no usable verdict after + repeated attempts (`409 concurrent_session`/indefinite `Working...`). +- Gate B: NOT RUN + +## Handoff/next steps + +1. Inspect/stage only the two implementation files and context record. +2. Retry Gate A only when the external FreePi session is available; do not + claim a pass from a partial or timed-out response. +3. After Gate A passes, commit, push, create a draft PR, wait for required CI, + then run Gate B. +4. Deploy the exact reviewed image to Cloud Run only after the review workflow + permits it, then verify `/health/live` and the activity refresh behavior. diff --git a/.agent/context/20260912T172700Z-circle-x402-atomic-claim.md b/.agent/context/20260912T172700Z-circle-x402-atomic-claim.md new file mode 100644 index 0000000..90bdfc6 --- /dev/null +++ b/.agent/context/20260912T172700Z-circle-x402-atomic-claim.md @@ -0,0 +1,110 @@ +# Circle x402 atomic claim and user-funded payment repair + +## Goal + +Repair the Circle x402 paid-API failure where a request reached authorization +but closed as `FAILED_SAFE` with the sanitized reason that provider request +identity could not be persisted before submission. Implement the intended MVP +model where the connected Privy wallet authorizes the Circle payment to the +deployed API seller and OneShot forwards the signed authorization exactly once. +Also fix the activity refresh client request that sent an empty JSON body and +was rejected by Fastify with HTTP 400. + +## Scope and assumptions + +- Existing server-paid Circle x402 remains available for compatibility, but the + website's connected-wallet path uses explicit `USER_WALLET` mode for the + Circle Dataset API. +- The user wallet is durably bound to the task key, quote, seller, amount, + token, and Arc Testnet before signing. +- The current failure occurred before the external supplier/payment boundary; + no retry or payment action is performed on the existing terminal intent. +- The worker must persist provider identity atomically with submission + ownership. A database failure must leave the outbox delivery retryable and + must not call Circle. +- The API forwards only the browser's already-signed x402 payload and never + signs or substitutes a server wallet for a user-funded intent. +- Circle Gateway batching requires the payer's Gateway testnet balance; the UI + documents this prerequisite and never deposits or transfers funds silently. + +## Non-goals + +- Do not retry any existing failed or UNKNOWN request, approve another payment, + or expose credentials. +- Do not change settlement verification, reconciliation authority, or the + existing Team Report user-wallet rail. + +## Changes + +- `IntentLedger.claimSubmission` accepts an optional provider identity and + inserts its replay fields in the same transaction as `READY -> SUBMITTING`. +- The worker derives identity before claiming and no longer performs a separate + post-claim identity update. +- Confirmed results carry an explicit exact-Arc-verification marker; the ledger + records authoritative ARC evidence only for that marker, so paid API proof + can render verified without trusting generic simulator confirmations. +- The Circle service copy states that its payer is the OneShot server-side + Privy execution wallet for the legacy server-paid path, while the connected + Circle Dataset UI explicitly prepares, signs, and submits from the user's + wallet. +- Added `paid_api_requests.payment_mode` and `payer_wallet` with a frozen + migration digest, user-wallet prepare/submit API routes, strict Circle + Gateway payload validation, signerless forwarding, exact Arc receipt + verification, and a worker guard against accidental server-wallet routing. +- Removed `content-type: application/json` from bodyless activity refresh calls; + bodyful requests still send JSON. +- Added a dedicated paid-API user-wallet reconciliation route that only checks + the durably recorded transfer/hash and can never forward a new payment. +- API, adapter, browser component, worker, and migration-gated regression tests + cover user-wallet binding, forward-only behavior, and refresh behavior. + +## Selected test-matrix cases + +- Crash/failure before submission: zero provider calls and safe retryable claim + boundary. +- Service restart / durable identity: identity exists on the owned attempt + before external submission. +- Exact receipt proof: a marked confirmed settlement records both ONESHOT and + ARC authoritative observations; an unmarked confirmation remains OneShot-only. +- Existing at-most-once behavior: the claim CAS and post-submission UNKNOWN + paths remain unchanged. +- User-funded Circle payment: the same signed payload and durable intent are + reused for status checks; a new signature or different payer is never + silently substituted. + +## Validation evidence + +- `pnpm.cmd build`: passed, including TypeScript and generated subgraph build. +- `pnpm.cmd --filter @oneshot/worker test`: 52 passed. +- `pnpm.cmd --filter @oneshot/storage-postgres test`: 13 passed. +- `pnpm.cmd --filter @oneshot/supplier-adapter test`: 20 passed. +- `pnpm.cmd --filter @oneshot/api test`: 71 passed. +- `pnpm.cmd --filter @oneshot/web test`: 81 passed. +- `pnpm.cmd test`: 82 files / 1081 tests passed. +- `pnpm.cmd typecheck`: passed. +- `pnpm.cmd lint`: passed. +- `pnpm.cmd format:check`: passed. +- `pnpm.cmd check:generated`: passed. +- Recorded base: `origin/develop` at `8f07bba648d9d270b3f3a2a99a5064237ffd8bd2`. +- Candidate is being prepared on `fix/circle-x402-persistence`; recompute + `git write-tree` after the final staging and bind Gate A to that resulting + tree. +- PostgreSQL integration remains gated by `TEST_POSTGRES=1` and requires a + container runtime; the new integration assertion is included but not run + locally in this environment. +- No Google Cloud deployment was performed during this implementation; the + currently live API and worker revisions therefore do not contain these + changes yet. +- Read-only Cloud Run verification on 2026-09-12 showed API revision + `oneshot-api-00021-cdd` at 100% traffic and worker revision + `oneshot-worker-00033-7lj` at 100% traffic. API request logs still show HTTP + 400 for `POST /v1/activity/refresh`; the frontend bodyless-request fix is + therefore not live until the web asset deployment is updated. + +## Provider/reference findings + +- The installed Circle batching SDK signs `TransferWithAuthorization` payloads + with `validAfter = now - 600` and a validity window of at least 604900 + seconds; the server validator mirrors that bounded window. +- Circle Gateway batching is a gasless buyer rail backed by a payer Gateway + balance; the website must not present it as a plain direct ERC-20 transfer. diff --git a/.agent/context/20260912T172804Z-clarify-payment-services.md b/.agent/context/20260912T172804Z-clarify-payment-services.md new file mode 100644 index 0000000..e2191c7 --- /dev/null +++ b/.agent/context/20260912T172804Z-clarify-payment-services.md @@ -0,0 +1,66 @@ +# Session: clarify payment service labels + +## Date/time + +2026-09-12 17:28 UTC. + +## User goal and original request + +Rename the website surfaces that currently imply two external API products. The +first flow is a direct Arc Testnet USDC payment with a team-operated sample +result. The second flow buys a dataset from OneShot's own x402 seller. + +## Assumptions and acceptance + +- Change user-facing copy and matching tests only. +- Preserve tool identifiers, API contracts, payment behavior, recipients, and + settlement logic. +- Label the team-operated x402 seller explicitly. +- Keep the direct payment purpose as the existing `report_subject` payload field. + +## Plan and decisions + +- Rename `API services` to `Payment services`. +- Rename `Company research service` to `Direct Arc payment`. +- Rename `Company or domain` to `Payment purpose`. +- Rename `Circle Dataset API` to `OneShot x402 Dataset` and identify it as a + team-operated demo. + +## Files/components touched + +- `apps/web/src/App.tsx` +- `apps/web/src/components/JobWorkspace.tsx` +- `apps/web/src/components/workspace-copy.ts` +- Focused web unit and browser tests. + +## Commands/checks + +- `pnpm --filter @oneshot/web exec vitest run --config vitest.config.ts --pool=threads --maxWorkers=1 --no-file-parallelism` — PASS (18 files, 83 tests). +- `pnpm --filter @oneshot/web typecheck` — PASS after clearing incomplete generated output left by a full disk. +- `pnpm lint` — PASS. +- `pnpm format:check` — PASS. +- `pnpm --filter @oneshot/web test:browser` — PASS (8 Playwright tests). +- `git diff --check` — PASS. + +## External-doc findings + +None. This change follows current repository behavior. + +## Unresolved questions + +None for this copy-only scope. + +## Branch/commit/PR state + +- Branch: `fix/clarify-payment-services` +- Base: `origin/develop` at `ff146e49407b078183ed619ff3f3b2e11b17da2c`. +- Commit and PR: pending. + +## Gate state + +- Gate A: explicitly waived by the user on 2026-09-12. +- Gate B: explicitly waived by the user on 2026-09-12. + +## Handoff/next steps + +Commit the validated candidate, push the branch, and open a PR for human review. diff --git a/.agent/context/20260912T183000Z-circle-x402-authorization-window.md b/.agent/context/20260912T183000Z-circle-x402-authorization-window.md new file mode 100644 index 0000000..8e728ce --- /dev/null +++ b/.agent/context/20260912T183000Z-circle-x402-authorization-window.md @@ -0,0 +1,75 @@ +# Session Context: Circle x402 authorization window + +## Date/time + +- UTC: 2026-09-12T18:30:00Z + +## User goal + +Make the user-funded Circle x402 API purchase complete reliably after the connected wallet signs, without weakening OneShot's at-most-once or fail-closed settlement guarantees. + +## Original prompt/request + +High-fidelity restatement: a user-wallet Circle paid-API submit returned HTTP 503 after the wallet approval; deploy the API and fix the issue. Existing paid Team Report activity also needs separate reconciliation because the frontend still shows historical UNKNOWN outcomes. + +## Assumptions + +- The submitted Circle authorization was rejected before supplier forwarding because the adapter allowed exactly 600 seconds of `validAfter` clock skew while Circle's browser signer deliberately backdates by about 600 seconds; ordinary wallet approval latency then exceeds the bound. +- No live authorization payload, token, wallet credential, or payment will be replayed during diagnosis or tests. +- Aggregate activity metrics alone cannot identify or resolve a particular historical Team Report UNKNOWN record; a task key or business-intent ID is required for a targeted read-only reconciliation audit. + +## Plan + +1. Extend the bounded `validAfter` allowance enough for Circle's standard backdating plus normal wallet-response latency, retaining all payer, quote, expiry, and receipt checks. +2. Return a clear client error for a pre-forward refusal and display only that sanitized message in the browser. +3. Add regression tests for accepted delayed authorization, refusal before forwarding, API status mapping, and UI notice. +4. Run required checks, obtain fresh Gate A, commit/push a PR, then deploy only the approved merged API revision. + +## Key decisions + +- Use a 15-minute maximum `validAfter` age: it preserves a bounded authorization window while allowing the signing SDK's 10-minute backdating plus a normal five-minute user approval delay. +- Treat a rejected authorization as HTTP 400, not HTTP 503. It is definitely pre-forward, so a fresh signature is safe; ambiguity remains UNKNOWN and is never retried blindly. + +## Files/components touched + +- `packages/supplier-adapter/src/circle-x402.ts` - bounded 15-minute `validAfter` age allowance. +- `packages/supplier-adapter/test/circle-x402.test.ts` - delayed authorization and too-old pre-forward refusal coverage. +- `apps/api/src/app.ts` and `apps/api/test/app.test.ts` - safe HTTP 400 mapping for pre-forward authorization refusal. +- `apps/web/src/api/paid-api-client.ts`, `apps/web/src/components/JobWorkspace.tsx`, and `apps/web/test/paid-api.test.tsx` - allowlisted safe client message and browser notice coverage. + +## Commands/checks + +- Read mandatory repository policy, payment/reliability skills, test matrix, implementation loop, and active session guidance. +- Read-only Cloud Run/log inspection: the deployed API revision accepts the prepare endpoint; a later submit reached the API and returned 503. Runtime configuration names required for the signerless user-wallet flow are present without reading their values. +- `pnpm build` - passed. +- Focused supplier/API/web tests - 22, 72, and 87 passed respectively. +- `pnpm test` - 82 files / 1,087 tests passed. +- `pnpm test:browser` - 8 passed. +- `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm check:generated`, and `git diff --check` - passed. +- `git fetch origin develop` followed by fast-forward sync to `e236c07c00f21b449e9b740488a24496840ced54`, then the full validation sequence was rerun successfully on that base. + +## External-doc findings + +- Local Circle integration test and browser signer adapter show the signer uses a backdated `validAfter` value; no external source is needed for this code-local behavior. + +## Unresolved questions + +- Which exact historical Team Report task key or business-intent ID should be audited after this API fix is released? + +## Git and PR state + +- Branch: `fix/circle-x402-authorization-window` +- Base: `origin/develop` at `e236c07c00f21b449e9b740488a24496840ced54` +- Commit: base checkout; implementation uncommitted +- PR: not created +- CI: not run for this branch + +## Review gates + +- Gate A: NOT RUN; candidate must be staged and reviewed after context update +- Gate B: NOT RUN + +## Handoff/next steps + +1. Implement and test the bounded timing and error-reporting fix. +2. Keep the historical Team Report UNKNOWN investigation separate and read-only. diff --git a/.agent/context/20260912T184743Z-privy-embedded-wallet-payer.md b/.agent/context/20260912T184743Z-privy-embedded-wallet-payer.md new file mode 100644 index 0000000..867d0ee --- /dev/null +++ b/.agent/context/20260912T184743Z-privy-embedded-wallet-payer.md @@ -0,0 +1,112 @@ +# Session Context: Privy embedded wallet payer + +## Date/time + +- UTC: 2026-09-12T18:47:43Z + +## User goal + +Ensure a payment approved after Privy login is signed by the user's selected +Privy embedded wallet. Keep Privy's built-in wallet selection available while +preventing an externally connected wallet from becoming the payer implicitly. + +## Original prompt/request + +The user asked why an external wallet signs a payment after they logged in +through Privy and asked to keep the existing Privy wallet-selection capability. + +## Assumptions + +- “Privy account” means the selected Privy embedded Ethereum wallet should be + the payer. +- Privy's native login and wallet-selection UI remain available. An external + wallet may be connected for login or other browser activity, but it must never + become the payment signer implicitly. +- Automatic embedded-wallet creation remains disabled. The payment path uses an + existing Privy embedded wallet already connected to the account. + +## Plan + +1. Trace authentication, wallet selection, and transaction signing. +2. Configure Arc Testnet without changing Privy's wallet-creation behavior. +3. Honor the active wallet when Privy selected an embedded wallet; otherwise + select the available embedded wallet and make it active. Fail closed when it + is unavailable. +4. Add regressions for simultaneous MetaMask and Privy wallets. +5. Complete local checks and the mandatory review gates. + +## Key decisions + +- Read `useWallets()` for embedded wallets and honor `useActiveWallet()` when + Privy selected one. If an external wallet is active, synchronize the embedded + wallet into Privy's active-wallet state before signing. +- Keep `createOnLogin: 'off'`, matching every historical implementation of this + provider. Do not create wallets merely because a user signs in. +- Configure Arc Testnet as the default and sole supported chain because it is a + custom EVM network outside Privy's default chain set. +- Keep settlement preparation, receipt verification, and retry behavior + unchanged. + +## Files/components touched + +- `apps/web/src/auth/privy-session.tsx`: Arc configuration, explicit existing + embedded signer selection, and existing Gateway funding/payment behavior. +- `apps/web/test/privy-session.test.tsx`: provider and signer-selection + regressions. + +## Commands/checks + +- `pnpm.cmd install --frozen-lockfile` - PASS; no lockfile change. +- `pnpm.cmd --filter @oneshot/web test` - PASS; 18 files, 100 tests after + rebasing onto the current develop head. +- `pnpm.cmd --filter @oneshot/web typecheck` - PASS. +- `pnpm.cmd --filter @oneshot/web build` - PASS with existing Circle SDK and + bundle-size warnings. +- `pnpm.cmd test` - PASS; 82 files, 1,087 tests. +- `pnpm.cmd typecheck` - PASS. +- `pnpm.cmd lint` - PASS. +- `pnpm.cmd format:check` - PASS. +- `pnpm.cmd check:generated` - PASS. +- `pnpm.cmd test:browser` - PASS; 8 browser tests. +- `git diff --check` - PASS. + +## External-doc findings + +- Repository history through `dd424c7`, `b298768`, `49709dc`, `721866b`, + `b193a0c`, and `3048d8c` consistently used `createOnLogin: 'off'`. +- Privy connected-wallet documentation, checked 2026-09-12, states that + `useWallets` contains both embedded and external wallets and applications must + select the wallet appropriate to the action. +- Privy custom EVM network documentation, checked 2026-09-12, requires custom + chains to be passed through `defaultChain` and `supportedChains`. +- Arc RPC documentation, checked 2026-09-12, specifies chain ID `5042002`, the + public RPC `https://rpc.testnet.arc.network`, USDC as native currency, and + `https://testnet.arcscan.app` as explorer. + +## Unresolved questions + +- A live browser login and testnet-funded embedded wallet are required to verify + the Privy approval modal against the deployed Privy app configuration. + +## Git and PR state + +- Branch: `fix/privy-embedded-wallet-payer` +- Base: `origin/develop` at `034d3b3ca7651c104e1e644feb1150f85d587268` +- Commit: uncommitted +- PR: not created +- CI: not applicable yet + +## Review gates + +- Gate A: NOT RUN for the rebased candidate. The earlier PASS applied to tree + `2ba50c0acfc21840991883bb12c8e131da456a26` on the previous base and was + invalidated by the rebase and corrected wallet-creation requirement. +- Gate B: NOT RUN. The user explicitly instructed this session not to launch + Gate B after Gate A passes; the PR must remain draft while Gate B is absent. + +## Handoff/next steps + +1. Re-run local validation for this context-record update and record the new + candidate tree SHA. +2. Run fresh FreePi Gate A, then commit, push, and create a draft PR if it passes. +3. Leave the PR draft after CI; do not launch Gate B in this session. diff --git a/.agent/context/20260912T193000Z-workspace-link-and-tab-fade.md b/.agent/context/20260912T193000Z-workspace-link-and-tab-fade.md new file mode 100644 index 0000000..e502c5e --- /dev/null +++ b/.agent/context/20260912T193000Z-workspace-link-and-tab-fade.md @@ -0,0 +1,105 @@ +# Session Context: workspace link and tab fade + +## Date/time + +- UTC: 2026-09-12T19:30:00Z + +## User goal + +Fix two reported defects in the operator workspace: the payment-confirmation +link is invisible in the light theme, and the content revealed by a tab does +not fade the way the tab strip itself does. + +## Original prompt/request + +"we need to fix front now. 1) on picture 1 whenever payment was confirmed, +confirmation hyperlink 'view Arc transaction' isnt visible. Picture one as +proof. 2) Also by fading animation i meant switching tabs 'overview', 'API +service', 'Requests', 'Payment proof'. Also dont forget that in overview tab +there are 3 tabs, which refers/linked to previous tabs (Run an API service, +View requests, See payment proof). Yes, tab themselves are fading, but content +which is opened after pressing tab is not." Follow-up: branch fresh from remote +develop, deliver only these two fixes, then commit, Gate A, and a ready PR with +Gate B skipped. + +## Assumptions + +- Only these two defects are in scope. An earlier attempt on a different branch + also changed the light page ground and the tab surface; the user withdrew + both, so this branch carries neither and is cut fresh from `develop`. +- Screenshot verification against the test-mode build is sufficient evidence + for a visual fix; no live payment or deployment is involved. + +## Plan + +1. Commit, Gate A on the candidate tree, push, open a non-draft PR. + +## Key decisions + +- The settlement box rebinds the ink tokens its own descendants inherit rather + than restyling the anchor globally, matching how `.paid-api-status` handles + the lime field. A global anchor colour would have leaked into every card. +- Panel content is faded by keying its wrapper on what the operator is waiting + for: the console panel on its active tab, and the request-list body on its + loading state. Lengthening the animation would not help, because the rows do + not exist while it runs. + +## Files/components touched + +- apps/web/src/styles.css: ink rebind and an explicit underlined anchor inside + `.job-settlement-summary`. +- apps/web/src/App.tsx: the console tab panel is keyed and carries `.tab-fade`. +- apps/web/src/components/JobWorkspace.tsx: the request-list body is wrapped in + a `.tab-fade` keyed on the loading state. +- apps/web/test/styles.test.ts, apps/web/test/app-composition.test.tsx: + regression cover for the link ink and for both fade gaps. + +## Commands/checks + +- `pnpm format:check` - PASS +- `pnpm lint` - PASS +- `pnpm typecheck` - PASS +- `pnpm check:generated` - PASS, generated contracts current +- `pnpm test` - PASS, 83 files / 1088 tests, includes build. Note: the root + vitest config includes only `.ts`/`.mjs`, so this run covers no `.tsx` test. +- `pnpm --filter @oneshot/web test` - PASS, 18 files / 86 tests. This is the + run that covers the `.tsx` suites, including the new fade regression. CI + runs only the root suite and the browser suite, so a `.tsx` regression is + not caught there; Gate A caught a stale copy assertion here that both the + root suite and CI would have missed. +- `pnpm --filter @oneshot/brand test` - PASS, 4 files / 18 tests +- `pnpm test:browser` - PASS, 8 tests, light and dark at 390/1440 px +- `markdownlint-cli2` - PASS on every tracked Markdown file +- Playwright screenshots of the test-mode build confirm the ArcScan link is + legible in the light theme. +- Local Node is 24.20.0 against the pinned 24.19.0; CI must validate the + pinned runtime. + +## External-doc findings + +- None. No version-sensitive integration changed. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: fix/workspace-link-and-tab-fade +- Base: develop (e236c07c00f21b449e9b740488a24496840ced54) +- Commit: this record plus the implementation commit +- PR: not created yet +- CI: not run yet + +## Review gates + +- Gate A: NOT RUN at the time of writing; run against the final candidate tree + before pushing. +- Gate B: skipped at the user's explicit instruction for this change; recorded + as a deliberate deviation from `.agent/IMPLEMENTATION_LOOP.md`, not a pass. + +## Handoff/next steps + +1. Push after Gate A passes and open a non-draft PR against `develop` carrying + the Gate A evidence. +2. A human owner reviews and merges. diff --git a/.agent/context/20260912T200000Z-user-wallet-recovery.md b/.agent/context/20260912T200000Z-user-wallet-recovery.md new file mode 100644 index 0000000..57b30b7 --- /dev/null +++ b/.agent/context/20260912T200000Z-user-wallet-recovery.md @@ -0,0 +1,72 @@ +# Session Context: user-wallet-recovery + +## Date/time + +- UTC: 2026-09-12T20:00:00Z + +## User goal + +Repair safe recovery for final user-wallet payments after a browser reload, and diagnose the Circle x402 user-funded API flow without risking a second payment. + +## Original prompt/request + +The user reports a final Team Report transaction shown as UNKNOWN after refresh and a Circle API authorization that returned 202/UNKNOWN without moving funds. They provided public intent identifiers and the Team Report transaction hash for read-only diagnosis. + +## Assumptions + +- The user-authorized Team Report transaction hash is public chain data and may be used only to reconcile that same durable intent. +- No new payment, signature replay, or replacement transaction is authorized while either outcome is UNKNOWN. + +## Plan + +1. Add an explicit same-hash-only user-wallet reconciliation path for a durable job and expose it after reload. +2. Preserve at-most-once settlement and test the final-receipt, missing-hash, and non-user-wallet cases. +3. Keep Circle authorization failures fail-closed and report the Gateway funding prerequisite separately. + +## Key decisions + +- A generic activity refresh is not a payment reconciler. Recovery must re-verify the hash already bound to the durable user-wallet job and must not ask the wallet to send another transaction. +- Circle Gateway nanopayments are off-chain authorizations funded from a Gateway wallet balance; an HTTP 402 from the seller is not proof of an on-chain debit. + +## Files/components touched + +- `apps/web/src/components/JobWorkspace.tsx` - prevents a prepared replay with a durable hash from opening a replacement transfer, and exposes same-hash-only verification in Requests. +- `apps/web/src/auth/privy-session.tsx` and `session.ts` - reads the public Circle Arc Testnet Gateway balance before requesting an x402 signature. +- `apps/web/test/components.test.tsx`, `paid-api.test.tsx`, and `privy-session.test.tsx` - regression coverage for no replacement transfer, same-hash recovery, zero-balance refusal, and balance parsing. + +## Commands/checks + +- Read-only Arc RPC receipt check - the supplied Team Report hash is successful and has exactly one matching USDC Transfer event. +- Cloud Run logs - Team Report submit returned 202 before final receipt availability; Circle seller returned 402 for the forwarded signed request. +- `gcloud.cmd run services describe oneshot-seller ...` - seller address configuration matches the reviewed quote; default Circle Arc Testnet facilitator is used. +- Circle Gateway balance API query for the connected public address - Arc Gateway balance is `0`; this explains the Circle 402 without a debit. +- `pnpm test` build phase and `pnpm exec vitest run --exclude apps/web/browser/**` - build completed and 82 files / 1087 tests passed. +- `pnpm --filter @oneshot/web exec node scripts/run-browser-tests.mjs` - 8/8 passed. +- `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm check:generated` - passed. + +## External-doc findings + +- Circle Gateway Nanopayments documentation (reviewed 2026-09-12) says buyers fund a Gateway Wallet balance and sign off-chain EIP-3009 authorizations; the seller returns the resource plus PAYMENT-RESPONSE only after valid settlement. + +## Unresolved questions + +- Whether the user's Gateway Wallet has a funded Arc Testnet balance; no signed payload or wallet credential will be collected to diagnose it. + +## Git and PR state + +- Branch: `fix/user-wallet-recovery` +- Base: `origin/develop` at `3463daf7905a3f1b74e46e0884835b1cb7b433df` +- Commit: uncommitted +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage the scoped web recovery/preflight change, capture immutable Gate A evidence, and obtain fresh Gate A before any commit or push. +2. After a human merges and the web Worker deploys, use Requests -> Check recorded transaction (no payment) for the known final Team Report transaction. +3. Fund the connected wallet's Arc Testnet Circle Gateway balance before attempting a fresh Circle x402 authorization; do not reuse the old authorization. diff --git a/.agent/context/20260912T215036Z-privy-wallet-selection.md b/.agent/context/20260912T215036Z-privy-wallet-selection.md new file mode 100644 index 0000000..7402a10 --- /dev/null +++ b/.agent/context/20260912T215036Z-privy-wallet-selection.md @@ -0,0 +1,72 @@ +# Session Context: Privy wallet selection + +## Date/time + +- UTC: 2026-09-12T21:50:36Z + +## User goal + +Restore the earlier working Privy login-to-payment wallet connection mechanics without reverting the current website UI. + +## Original prompt/request + +The user reports `No Ethereum wallet is connected. Nothing was paid.` after signing in through Privy and asks to restore the wallet/login mechanics from an older website revision while keeping current frontend presentation. + +## Assumptions + +- Existing embedded Privy wallets should remain preferred and automatic wallet creation must remain disabled. +- When no embedded Privy wallet exists, payment must open Privy's wallet picker instead of silently using an injected MetaMask wallet. + +## Plan + +1. Compare current wallet wiring with historical payment/login commits. +2. Restore Privy's explicit wallet-selection path in the shared wallet adapter. +3. Add a regression test and run the affected web checks. + +## Key decisions + +- Reuse the `useActiveWallet().connect({ reset: true })` path derived from commit `49709dc`, because it restores the Privy-controlled picker and resets stale active-wallet selection. +- Keep `createOnLogin: 'off'`; this fix does not create wallets for users. +- Bind the remembered picker result to the current Privy user id so logout/login cannot carry a payer wallet across accounts. + +## Files/components touched + +- `apps/web/src/auth/privy-session.tsx`: restore explicit Privy wallet selection fallback. +- `apps/web/test/privy-session.test.tsx`: cover stale MetaMask plus explicit Privy picker selection. + +## Commands/checks + +- Historical Git inspection of `49709dc`, `721866b`, `3048d8c`, and current `bbb1052` - identified the removed `useActiveWallet().connect()` path. +- `pnpm --filter @oneshot/web test -- privy-session.test.tsx` - PASS, 11 tests. +- `pnpm --filter @oneshot/web test` - PASS, 18 files and 101 tests. +- `pnpm --filter @oneshot/web typecheck` - PASS. +- `pnpm --filter @oneshot/web lint` - PASS. +- `pnpm --filter @oneshot/web build` - PASS with pre-existing Circle SDK and bundle-size warnings. +- `pnpm --filter @oneshot/web test:browser` - PASS, 8 tests. +- `pnpm format:check` - PASS. + +## External-doc findings + +- Privy React documentation (`https://docs.privy.io/wallets/connectors/usage/connecting-external-wallets` and `https://docs.privy.io/wallets/wallets/get-a-wallet/get-connected-wallet`) confirms that wallet connection should be initiated through Privy's connection UI and that `useWallets().ready` represents completed wallet processing. +- Installed `@privy-io/react-auth` 3.6.1 types expose `useActiveWallet().connect({ reset?: boolean })`. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `fix/privy-wallet-selection` +- Base: `origin/develop` at `bbb1052b0625f9311ecdfd3d0e5b7443322fbdbd` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN for the current tree. An earlier tree review produced no final verdict and identified the cross-account cache lifetime edge case; that tree was changed and invalidated. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage the candidate tree and run mandatory Gate A. diff --git a/.agent/context/20260912T221814Z-website-logo.md b/.agent/context/20260912T221814Z-website-logo.md new file mode 100644 index 0000000..3ad728c --- /dev/null +++ b/.agent/context/20260912T221814Z-website-logo.md @@ -0,0 +1,17 @@ +# Website logo + +- Goal: replace the browser favicon with the supplied OneShot Commit Ring artwork. +- Acceptance: the web document references the new PNG; the favicon URL is cache-busted; no UI or payment behavior changes. +- Branch: fix/website-logo from origin/develop at fd51572. +- Non-goals: redesigning the in-page SVG mark or navigation. + +## Validation + +- Supplied PNG copied byte-for-byte; built dist/logo.png has the same SHA-256. +- @oneshot/web tests: 18 files, 101 tests passed. +- @oneshot/web typecheck and lint passed. +- @oneshot/web production build passed. +- format:check passed. +- Local Node 22.23.2 differs from repository Node 24.19.0; checks still passed. +- Gate B will not run per the user's standing instruction when Gate A passes. +- FreePi Gate A and Gate B were skipped by explicit user instruction on 2026-09-13. diff --git a/.agent/context/20260912T225825Z-restore-privy-payment-wallets.md b/.agent/context/20260912T225825Z-restore-privy-payment-wallets.md new file mode 100644 index 0000000..1226747 --- /dev/null +++ b/.agent/context/20260912T225825Z-restore-privy-payment-wallets.md @@ -0,0 +1,82 @@ +# Session Context: restore-privy-payment-wallets + +## Date/time + +- UTC: 2026-09-12T22:58:25Z + +## User goal + +Restore multi-wallet payment support on the web client: detected wallets and +WalletConnect in the Privy picker, payments bound to the actively selected +wallet, wallet picker when none is active, MetaMask-compatible EIP-712 x402 +signing, preserving newer payment/recovery work. + +## Original prompt/request + +Implementer handoff (restated): "Ready for review. Stopped before Gate A/B — +neither was started. Implemented on branch fix/restore-privy-payment-wallets: +restored detected wallets plus WalletConnect; payments now use the actively +selected wallet; restored wallet picker when none is active; added +MetaMask-compatible EIP-712 x402 signing; preserved newer payment/recovery +work." User then instructed: create the draft PR and run a Gate B check. + +## Assumptions + +- Preferring the active wallet over the embedded Privy wallet intentionally + supersedes the silent-embedded preference from PR #108/#109. +- Draft PR and Gate B may proceed without Gate A by explicit user instruction. + +## Plan + +1. Independent pre-push review of the uncommitted diff (done in-session). +2. Record context, commit, push, create draft PR targeting develop. +3. Wait for required CI, then run Gate B-style review and record its verdict. + +## Key decisions + +- Commit message follows repo convention: fix(web) subject. +- Gate A skipped only by explicit user instruction; compensating evidence is an + independent in-session review plus full local validation reproduction. + +## Files/components touched + +- apps/web/src/auth/privy-session.tsx — active-wallet binding, WalletConnect in + walletList, EIP712Domain types in raw eth_signTypedData_v4 payload. +- apps/web/test/privy-session.test.tsx — tests updated for the new semantics. + +## Commands/checks + +- `pnpm vitest run test/privy-session.test.tsx` (apps/web) - PASS, 12/12 +- `pnpm typecheck` (apps/web) - PASS (tsc -b exit 0) +- `pnpm lint` (apps/web) - PASS (eslint exit 0) +- `git diff --check` - PASS +- Local Node 22.23.2 differs from repository Node 24.19.0; checks still passed. + +## External-doc findings + +- EIP-712: eth_signTypedData_v4 JSON must declare an EIP712Domain type set that + matches the domain members; the typed x402 domain has exactly name, version, + chainId, verifyingContract, so the injected set matches (uint256 chainId). + +## Unresolved questions + +- Runtime smoke test of a real WalletConnect session (Privy walletConnect + projectId configuration) is not covered by unit tests. + +## Git and PR state + +- Branch: fix/restore-privy-payment-wallets +- Base: develop (37e4615cc7a570f70f6f6ae9d49ac68d96549619, origin/develop tip) +- Commit: created together with this record (branch tip; SHA in PR evidence) +- PR: draft created after push (URL recorded in PR body) +- CI: pending at push time; results recorded on the PR + +## Gate A/B state + +- Gate A: SKIPPED by explicit user instruction. Independent in-session pre-push + review found no blocking findings; two non-blocking notes (WalletConnect + projectId runtime check; intentional reversal of the PR #109 wallet + preference needs product ack). +- Gate B: run after push and CI per freepi-pr-review.md; verdict recorded on + the PR. Expected fail-closed on Gate A evidence completeness if CI/gate + prerequisites are incomplete. diff --git a/.agent/context/20260912T234731Z-dedicated-privy-payer.md b/.agent/context/20260912T234731Z-dedicated-privy-payer.md new file mode 100644 index 0000000..ba959e1 --- /dev/null +++ b/.agent/context/20260912T234731Z-dedicated-privy-payer.md @@ -0,0 +1,90 @@ +# Session Context: dedicated-privy-payer + +## Date/time + +- UTC: 2026-09-12T23:47:31Z + +## User goal + +Fix the wallet-switching problem left by the live site and the flaw observed +after the active-wallet iteration: MetaMask can authenticate, but payment +requests never reach it because the Privy embedded wallet remains the +dedicated payer. Product decision recorded by the implementer: keep the +Privy embedded wallet as the DEDICATED payer; MetaMask (and other detected +wallets / WalletConnect) authenticate and appear in the picker, while x402 +payments and transfers sign with the embedded wallet. + +## Original prompt/request + +Implementer handoff (restated): root cause fixed locally — MetaMask can +authenticate, but the existing Privy embedded wallet remains the dedicated +payer; MetaMask receives no payment request. Updated privy-session.tsx:223 +and privy-session.test.tsx:337. Checks passed: 12 tests, TypeScript, ESLint, +diff check. Stopped before Gate A/B; not deployed. User instruction: review, +run both gates, open the PR after Gate A. + +## Assumptions + +- Dedicated-Privy-payer semantics intentionally supersede the active-wallet + semantics of open draft PR #113; that PR becomes superseded. +- The EIP712Domain injection and walletList (detected + WalletConnect) from + commit 0167e3d remain in force and are not reverted. + +## Plan + +1. Independent review of the selection-logic reversion; reproduce all checks. +2. Gate A on the staged candidate tree, then commit, push new branch + fix/dedicated-privy-payer, and open a draft PR targeting develop. +3. Wait for required CI, then Gate B, and record both verdicts on the PR. + +## Key decisions + +- New branch and PR instead of pushing to PR #113: the new semantics contradict + #113's title and acceptance criteria; #113 is left for the user to close. +- Reverted selection logic restored verbatim from the pre-0167e3d state + (prefer embedded Privy wallet, auto-setActiveWallet, picker when absent). + +## Files/components touched + +- apps/web/src/auth/privy-session.tsx — wallet selection returns to the + dedicated embedded Privy payer with auto-setActiveWallet. +- apps/web/test/privy-session.test.tsx — tests assert dedicated-payer + semantics (privy signs; MetaMask receives no payment request). +- .agent/context/20260912T225825Z-restore-privy-payment-wallets.md remains the + record for the earlier iteration on PR #113. + +## Commands/checks + +- `pnpm vitest run test/privy-session.test.tsx` (apps/web) - PASS, 12/12 +- `pnpm typecheck` (apps/web) - PASS (tsc -b exit 0) +- `pnpm lint` (apps/web) - PASS (eslint exit 0) +- `git diff --check` - PASS +- Local Node 22.23.2 differs from repository Node 24.19.0; checks still passed. + +## External-doc findings + +- Unchanged from the previous record: EIP-712 raw eth_signTypedData_v4 + payloads declare an EIP712Domain type set matching the domain members. + +## Unresolved questions + +- Explicit product sign-off that the dedicated embedded payer is the desired + long-term model (supersedes PR #109/#108/#113 direction). +- Runtime WalletConnect picker smoke test (walletConnect projectId). + +## Git and PR state + +- Branch: fix/dedicated-privy-payer (new; stacked on 0167e3dd5f344c388eaddca97ff6da2df25e1b6a) +- Base: develop (1123107b3411110ac203d367c0cafaeb87fbcfc3 at candidate time; + merge-base with 37e4615cc7a570f70f6f6ae9d49ac68d96549619) +- Commit: created immediately after Gate A (SHA in PR evidence) +- PR: draft opened after Gate A per user instruction +- CI: recorded on the PR after push + +## Gate A/B state + +- Gate A: fresh-process attempt first; if the environment blocks a second + free-pi session (one session per account), an in-session independent review + per freepi-prepush-review.md is recorded as compensating evidence by + explicit user instruction. +- Gate B: after CI on the exact PR head; verdict recorded on the PR. diff --git a/.agent/context/20260913T-graph-transaction-evidence.md b/.agent/context/20260913T-graph-transaction-evidence.md new file mode 100644 index 0000000..e7d3000 --- /dev/null +++ b/.agent/context/20260913T-graph-transaction-evidence.md @@ -0,0 +1,81 @@ +# Session Context: graph-transaction-evidence + +## Date/time + +- UTC: 2026-09-13 + +## User goal + +Make The Graph call and display evidence for transactions performed through the +site, regardless of whether the OneShot request is confirmed, failed safely, or +remains uncertain. + +## Original prompt/request + +“Our the graph is never called and give any info on our transactions. Fix it so +The Graph shows evidence for transactions performed via our site, whether the +transaction failed or was approved.” + +## Assumptions + +- The Graph remains non-authoritative; OneShot and Arc receipt evidence decide + settlement state. +- A failed/rejected request may have no indexed ERC-20 Transfer event. The UI + must show that state and say that missing Graph data is not proof of no + payment. +- User-wallet payer addresses must be discovered from durable workspace jobs; + the configured server wallet remains an optional fallback for server-wallet + activity. +- The pre-existing edit to the prior session context remains user-owned. + +## Plan + +1. Make API Graph activity use all durable workspace payer wallets and refresh + automatically from the cabinet. +2. Return and render a workspace transaction ledger with Graph match status for + every site request outcome. +3. Enqueue durable Graph evidence capture for confirmed, failed-safe, unknown, + and rejected lifecycle outcomes. +4. Add focused API, storage, worker, and browser/UI regression coverage. + +## Key decisions + +- A missing indexed transfer is displayed as `NOT_INDEXED`, never as proof that + a payment did not happen. +- A failed Graph read is displayed as `UNAVAILABLE`, not as a negative payment + result. +- Failed-safe and rejected requests are represented in the site transaction + ledger even when no transaction hash exists. +- Graph transport failures remain observable as unavailable activity and do not + change payment state or create retry permission. + +## Branch state + +- Branch: `fix/graph-transaction-evidence` +- Base: refreshed `origin/develop` at `65200cc2dfcf22912e532a157232e439d623044f`. +- Commit/PR: not created. +- Gate A/B: not started. + +## Checks + +- Policy and routed idempotency/failure-injection documents read. +- `pnpm --filter @oneshot/contracts check:generated` passed. +- `pnpm lint`, `pnpm typecheck`, and `pnpm build` passed. +- Focused API/storage/worker/web suites passed. +- Full `pnpm test` passed: 80 files, 1,057 tests. +- `pnpm test:browser` passed: 8 browser tests. +- `pnpm test:integration` loaded all integration suites but skipped them because + this workstation has no container runtime. +- No commit, push, PR, deployment, or FreePi Gate A/B run has been performed + yet; these are pending the explicit push/PR request. + +## Unresolved questions + +- The Graph indexes successful ERC-20 transfer events; reverted/no-transfer + transactions cannot be fabricated into the subgraph. They will be shown with + their OneShot outcome and explicit non-proof wording. + +## Handoff/next steps + +Stage the scoped tree, run Gate A, commit, push, open the draft PR, wait for +required CI, and run Gate B before handing off for human review. diff --git a/.agent/context/20260913T-mcp-user-wallet-flow.md b/.agent/context/20260913T-mcp-user-wallet-flow.md new file mode 100644 index 0000000..69f55ce --- /dev/null +++ b/.agent/context/20260913T-mcp-user-wallet-flow.md @@ -0,0 +1,37 @@ +# MCP user-wallet payment flow + +## Goal + +Make personal Arc Testnet payments non-custodial: OneShot prepares a durable +intent and quote, the connected Privy/MetaMask wallet sends USDC directly to +the recipient, and OneShot verifies the returned transaction hash against the +Arc receipt and exact USDC Transfer log. + +## Scope + +- Active MCP tools are `arc_payment` (prepare) and `arc_payment_submit` + (bind/verify hash). +- The active MCP path uses `USER_WALLET` jobs and never a server signer. +- The web workspace refuses to fall back to the server-wallet payment path when + no browser wallet is connected. +- The old corporate autonomous-agent server-wallet configuration is retained + with `НЕ УДАЛЯТЬ` comments but is not wired into the active personal flow. + +## Safety invariants + +- The payer wallet is durably bound before a hash is accepted. +- The returned calldata pins Arc Testnet USDC, recipient, amount, and payer. +- A transaction hash is recorded once; a different hash is rejected. +- Receipt verification checks chain, token contract, payer, recipient, amount, + receipt status, and the matching Transfer log. +- UNKNOWN is reconciled with the same hash; no replacement transaction is + submitted by OneShot. + +## Acceptance + +- MCP prepare returns a quote, payer-bound job, exact ERC-20 calldata, and + `next_action: SIGN`. +- MCP submit returns COMMITTED only after exact receipt verification. +- Duplicate prepare and submit calls replay the same durable payment. +- Focused API and web tests, typecheck, lint, formatting, and diff checks pass. +- No live payment or production deployment is performed in this change. diff --git a/.agent/context/20260913T-payment-prepare-regression.md b/.agent/context/20260913T-payment-prepare-regression.md new file mode 100644 index 0000000..fb271bc --- /dev/null +++ b/.agent/context/20260913T-payment-prepare-regression.md @@ -0,0 +1,30 @@ +# Payment prepare regression + +## Goal + +Restore the authenticated web console path that prepares a user-wallet payment +after quote approval, so approval reaches `POST /v1/jobs/user-wallet/prepare` +and can continue to wallet submission. + +## Acceptance criteria + +- Privy-authenticated console passes `usePrivyUserWallet()` into `App`. +- A connected-wallet approval uses the existing prepare and submit client + methods; it does not fall back to `POST /v1/jobs`. +- Existing server-wallet/API composition remains available when no browser + wallet is supplied. +- No settlement or idempotency boundary is changed. + +## Evidence and assumption + +- Commit `91a7744` removed the `usePrivyUserWallet()` call from + `apps/web/src/main.tsx`. +- `JobWorkspace` intentionally calls `start()` only when `userWallet` is + absent; otherwise it calls `prepareUserWalletJob()`. +- The fix is limited to the current repository and branch + `fix/restore-user-wallet-prepare`. + +## Non-goals + +- No API, storage, settlement, or worker changes. +- No changes to payment amounts, recipients, or provider idempotency logic. diff --git a/.agent/context/20260913T-privy-wallet-payment-hang.md b/.agent/context/20260913T-privy-wallet-payment-hang.md new file mode 100644 index 0000000..df8eb50 --- /dev/null +++ b/.agent/context/20260913T-privy-wallet-payment-hang.md @@ -0,0 +1,49 @@ +# Privy wallet payment hang — 2026-09-13 + +## Goal + +Make the OneShot user-wallet payment flow work for Privy-authenticated users and for EVM wallets connected through Privy, including Arc Testnet transactions. + +## Acceptance criteria + +- A Privy login for a user without a linked wallet creates an embedded Ethereum wallet. +- A connected MetaMask or other EVM wallet is selected even when Privy has no active-wallet value. +- Arc Testnet remains the selected chain (`5042002`) and the ERC-20 USDC transfer path is unchanged. +- Existing payment safety invariants remain intact: one intent, replay-safe attempts, and at most one committed settlement. +- Web tests and type checks pass, or pre-existing failures are documented. + +## Evidence and diagnosis + +- The quote request succeeds; the indefinite spinner starts in `JobWorkspace.start()` while resolving the user wallet. +- `apps/web/src/auth/privy-session.tsx` configured `embeddedWallets.ethereum.createOnLogin` as `off`. With no external wallet in an incognito session, `connectWallet({ reset: true })` can remain pending on the wallet picker. +- The wallet fallback only searched for `walletClientType === 'privy'`, so it could miss a connected MetaMask/Rainbow/Coinbase EVM wallet returned by `useWallets()`. +- The public site bundle and the repository default use Privy app ID `cmtqbf5zo013w0cky3r0jqjca`, while the dashboard policy URL supplied by the user is app `cmtvo63u300640cjwcbyyonv3`. These are different Privy apps; the policy must be created in the app actually used by the deployed frontend/backend, or the app IDs must be aligned before deployment. +- The current Privy wallet policy allows `eth_sendTransaction` on Arc Testnet chain ID `5042002`; the Arc Testnet wallet was funded successfully, so the observed issue is in wallet selection/configuration rather than the faucet balance. + +## Change scope + +- Update Privy embedded-wallet creation to `users-without-wallets`. +- Restrict the Privy wallet picker to Ethereum wallets. +- Select the first connected Ethereum wallet as a safe fallback when there is no active wallet. +- Keep the existing transaction encoding, prepare/submit endpoints, receipt verification, and idempotency logic unchanged. + +## Non-goals + +- No production deployment. +- No testnet transaction approval from the browser. +- No change to backend settlement or Arc USDC contract handling. + +## Verification plan + +- Focused `apps/web` Privy session tests. +- Web typecheck, lint, and build. +- Re-run the web test suite and record any unrelated baseline failures. + +## Verification results + +- Focused Privy session tests: 7 passed. +- Full web test suite: 16 files and 88 tests passed. +- Web typecheck: passed. +- Web lint: passed. +- Web production build: passed; Vite emitted only the existing large-chunk warning. +- `git diff --check`: passed. diff --git a/.agent/context/20260913T000300Z-circle-x402-validity-window.md b/.agent/context/20260913T000300Z-circle-x402-validity-window.md new file mode 100644 index 0000000..437ae67 --- /dev/null +++ b/.agent/context/20260913T000300Z-circle-x402-validity-window.md @@ -0,0 +1,78 @@ +# Session Context: Circle x402 user-wallet validity window + +## Date/time + +- UTC: 2026-09-13T00:03:00Z + +## User goal + +Make the user-funded Circle Gateway x402 paid-API flow complete successfully instead of producing UNKNOWN when Circle rejects a delayed wallet authorization. + +## Original prompt/request + +The same paid-API errors continued: the user wallet signed, OneShot returned 202, no money moved, and the paid API remained UNKNOWN. Safe identifiers and provider evidence were supplied; no secrets were requested or recorded. + +## Assumptions + +- Circle Gateway verification is the authoritative pre-settlement boundary; a failed verification has no payment effect. +- The durable quote remains the source of amount, payer, recipient, asset, and network binding. + +## Plan + +1. Confirm the provider rejection reason with a read-only Circle verify call. +2. Add bounded approval slack to user-wallet signing and server validation. +3. Map an explicit seller verification refusal to FAILED_SAFE while preserving UNKNOWN for settlement ambiguity. +4. Run full repository validation before review or deployment. + +## Key decisions + +- Use a 605800-second authorization window (7 days plus the SDK buffer and a bounded 900-second human approval buffer). +- Keep seller-published durable quote data unchanged; the extended value is used only in the signed authorization and is accepted by Circle verify. +- Do not retry or settle any existing intent and do not store signatures. + +## Files/components touched + +- `packages/contracts/src/circle.ts` and `packages/contracts/src/index.ts` - shared validity-window constant. +- `apps/web/src/auth/privy-session.tsx` - sign user-wallet x402 authorizations with the bounded approval buffer. +- `packages/supplier-adapter/src/circle-x402.ts` - preserve the 15-minute validAfter tolerance, accept the bounded validBefore window, and classify explicit verification refusal as pre-submit safe. +- Focused web/supplier tests for the new window and refusal behavior. + +## Commands/checks + +- Read-only Circle `/v1/x402/verify` - returned `authorization_validity_too_short` for the supplied historical authorization. +- `pnpm.cmd test` - 82 files / 1089 tests passed. +- `pnpm.cmd test:browser` - 8/8 passed. +- `pnpm.cmd typecheck` - passed. +- `pnpm.cmd lint` - passed. +- `pnpm.cmd format:check` - passed. +- `pnpm.cmd check:generated` - passed. +- `git diff --check` - passed. + +## External-doc findings + +- Circle Gateway x402 verification requires a minimum seven-day authorization validity window; the SDK publishes a small buffer. The implementation adds a bounded human approval buffer and keeps the strict network/asset/domain checks. + +## Unresolved questions + +- PostgreSQL-gated tests were not separately run because this change is limited to web, contracts, and supplier-adapter code; full default validation passed. +- Production API and frontend deployment are still pending review/merge. + +## Git and PR state + +- Branch: `fix/circle-x402-validity-window` +- Base: `origin/develop` at `fd515729ddba8516073ac598cf588bedb9291500` +- Commit: merge `f7482378c9d5c7af30f880b54e03eb9d0e8409d1` +- Tree: `aaf5b4a6f6c7844f7f17314ae9e969d16a10b235` +- PR: #110, pending updated remote head +- CI: not run; local validation is recorded above + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Run Gate A on the exact candidate tree `aaf5b4a6f6c7844f7f17314ae9e969d16a10b235`. +2. After approval, merge/push and deploy the API image plus the web bundle; the seller image has no source change for this fix. +3. Test with one new paid-API task only after deployment; do not retry the old UNKNOWN intent blindly. diff --git a/.agent/context/20260913T002116Z-paid-api-requests-list.md b/.agent/context/20260913T002116Z-paid-api-requests-list.md new file mode 100644 index 0000000..48f7ca4 --- /dev/null +++ b/.agent/context/20260913T002116Z-paid-api-requests-list.md @@ -0,0 +1,77 @@ +# Session Context: Durable paid-API requests list + +## Date/time + +- UTC: 2026-09-13T00:21:16Z + +## User goal + +Keep paid-API purchases visible in Requests after page reload and let the operator reopen the existing Payment Proof without creating another payment. + +## Original prompt/request + +The user asked whether API purchases should be added to Requests like ordinary transactions because Payment Proof disappears after page refresh, then requested implementation. + +## Assumptions + +- The existing `paid_api_requests` rows and `business_intent_id` are the durable source of truth. +- A paid-API quote alone is not a request; the durable row starts at `prepare`/approval. +- Listing is read-only and must not enqueue, retry, sign, or submit payment. + +## Plan + +1. Add a durable unified request-list API projection for team-report jobs and paid-API purchases. +2. Load the unified list in Requests and render paid-API rows with their payment state and proof link. +3. Add contract, API, storage, client, and UI regression coverage. + +## Key decisions + +- Added `GET /v1/requests`, which merges the existing job ledger list and paid-API ledger list, sorts by durable `updated_at`, and caps the response at 100 rows. +- Paid-API rows reuse their existing `business_intent_id`; selecting a row opens the existing settlement/recovery surfaces. No new payment endpoint or retry action was introduced. +- The old `GET /v1/jobs` remains available for compatibility; the Requests UI uses the unified endpoint with a runtime fallback for older test compositions. +- The browser client also falls back from a server `404` on `/v1/requests` to `GET /v1/jobs`, preserving older demo/API deployments during rollout. + +## Files/components touched + +- `packages/contracts/scripts/generate-contracts.mjs` and generated artifacts - `RequestListItem`, `RequestListResponse`, and `/v1/requests`. +- `packages/storage-postgres/src/ledger.ts` - read-only `listPaidApi` projection ordered by update time. +- `apps/api/src/paid-api.ts`, `apps/api/src/app.ts` - paid-API list service and unified API route. +- `apps/web/src/api/job-client.ts`, `apps/web/src/components/JobWorkspace.tsx` - durable Requests loading and paid-API row/proof link. +- Tests across contracts, API, storage integration, client, and UI. + +## Commands/checks + +- `pnpm.cmd test` - 82 files / 1090 tests passed. +- `pnpm.cmd typecheck` - passed. +- `pnpm.cmd lint` - passed. +- `pnpm.cmd format:check` - passed. +- `pnpm.cmd check:generated` - generated contracts current. +- `pnpm.cmd test:browser` - 8/8 passed; the legacy endpoint fallback was exercised by the browser demo server. +- PostgreSQL integration tests remain gated by `TEST_POSTGRES=1`; the new `listPaidApi` assertion follows the existing gated suite convention. + +## External-doc findings + +- None required; this is an internal durable projection and UI navigation change. + +## Unresolved questions + +- The separate Circle Gateway aggregate-batch verifier fix is in another branch and is not part of this candidate. + +## Git and PR state + +- Branch: `fix/paid-api-requests` +- Base: `origin/develop` at `f225601de0ddf265ee3367fa5ef69972ab458c08` +- Commit: uncommitted staged candidate pending Gate A +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage the candidate and request a fresh Gate A review. +2. After approval, commit/merge and deploy the API image plus web assets. +3. Reload Requests; select the paid-API row to reopen Payment Proof for the original intent. diff --git a/.agent/context/20260913T003844Z-wallet-approval-routing.md b/.agent/context/20260913T003844Z-wallet-approval-routing.md new file mode 100644 index 0000000..eeca664 --- /dev/null +++ b/.agent/context/20260913T003844Z-wallet-approval-routing.md @@ -0,0 +1,90 @@ +# Session Context: wallet-approval-routing + +## Date/time + +- UTC: 2026-09-13T00:38:44Z + +## User goal + +Fix the production freeze introduced by the EIP712Domain signing change and +settle wallet routing: the Privy embedded wallet pays automatically +(policy-controlled) when no external wallet is selected; an actively selected +external wallet (MetaMask, Rainbow, WalletConnect) remains the payer and +receives an explicit wallet confirmation; no silent wallet switching. + +## Original prompt/request + +User reported on the live site: "starting request also frozen, right now +privy wallet by default can not send transaction". Root cause: PR #113 added +an unconditional EIP712Domain declaration to the raw eth_signTypedData_v4 +payload; the Privy embedded signer does not answer such requests, so the +signature promise never resolved and JobWorkspace stayed on "Starting request…". +Implementer then produced fix/wallet-approval-routing on top of develop +23e4373d459e18a4be31b07397b1a7a8adae8521; user instructed to implement both gates. + +## Assumptions + +- Privy embedded signer requires the legacy typed-data payload (no explicit + EIP712Domain declaration), as proven by pre-#113 production behavior. +- External wallets (MetaMask et al) require the explicit EIP712Domain + declaration; they pay only after in-wallet confirmation. +- Automatic Privy payment to recipient 0x292d…3eb is denied until that address + is added to the Privy recipient whitelist (operational follow-up). + +## Plan + +1. Independent review of the routing and conditional payload; reproduce checks. +2. Gate A on the staged candidate tree, commit, push, open draft PR. +3. Required CI green, then Gate B, verdict recorded on the PR. + +## Key decisions + +- Conditional EIP712Domain injection keyed on walletClientType === 'privy'. +- Auto-setActiveWallet effect removed: the active wallet is never switched + behind the user's back. +- selectedWallet: active ethereum wallet if present, else first embedded Privy + wallet; explicit picker connection still honored via the subject-scoped ref. + +## Files/components touched + +- apps/web/src/auth/privy-session.tsx — wallet-approval routing + conditional + EIP-712 payload. +- apps/web/test/privy-session.test.tsx — regression coverage for all three + routing branches and both payload shapes. +- apps/web/test/components.test.tsx — JobWorkspace user-wallet payment flow + through the selected browser wallet. + +## Commands/checks + +- `pnpm vitest run test/privy-session.test.tsx test/components.test.tsx` - PASS, 29/29 +- `pnpm typecheck` (apps/web) - PASS (tsc -b exit 0) +- `pnpm lint` (apps/web) - PASS (eslint exit 0) +- `git diff --check` - PASS +- Local Node 22.23.2 differs from repository Node 24.19.0; checks still passed. + +## External-doc findings + +- EIP-712: MetaMask validates eth_signTypedData_v4 payloads against a declared + EIP712Domain type set; the Privy embedded signer expects the legacy payload + without the declaration (production-proven before #113). + +## Unresolved questions + +- Add 0x292d…3eb to the Privy recipient whitelist to enable automatic payment + to that recipient. +- Runtime WalletConnect smoke test (walletConnect projectId) still pending. + +## Git and PR state + +- Branch: fix/wallet-approval-routing (fresh from develop 23e4373) +- Base: develop (23e4373d459e18a4be31b07397b1a7a8adae8521, origin/develop tip) +- Commit: created after Gate A (SHA recorded in PR evidence) +- PR: draft opened after Gate A per user instruction +- CI: recorded on the PR after push + +## Gate A/B state + +- Gate A: fresh-process attempt first; in-session independent review as + compensating evidence if the one-session-per-account constraint blocks the + second free-pi process (standing environment constraint, user-acknowledged). +- Gate B: after required CI on the exact PR head; verdict recorded on the PR. diff --git a/.agent/context/20260913T012306Z-remove-circle-paid-api.md b/.agent/context/20260913T012306Z-remove-circle-paid-api.md new file mode 100644 index 0000000..9197052 --- /dev/null +++ b/.agent/context/20260913T012306Z-remove-circle-paid-api.md @@ -0,0 +1,50 @@ +# Session context: remove Circle paid API surface + +- Date: 2026-09-13 +- User goal: create a new branch that removes the Circle x402 paid-API product, the repository's own paid API seller/proxy, and related product documentation while preserving `.agent/context/` history. +- Branch: `feature/remove-circle-paid-api` +- Base: `origin/develop` at `b942732b0229f8e749da13df36a8f6d378894ec1` + +## Assumptions and non-goals + +- Remove active Circle paid-API buyer/seller/proxy routes, UI, Gateway funding/signing helpers, contracts, adapters, runtime configuration, and user-facing documentation. +- Preserve Team Report, direct Arc/User Wallet payment, Privy authentication, generic Arc receipt verification, and recovery for the remaining direct-payment flow. +- Preserve historical `paid_api_requests` migrations and existing context records; do not rewrite applied migration history or destroy production data. +- This branch removes the product surface; it does not attempt to reconcile or delete already-created paid-API intents. + +## Safety and acceptance criteria + +- No active `/v1/paid-api*` or `/api/premium/*` product route remains. +- The web console contains no Circle paid-API purchase, Gateway funding, or x402 signing controls. +- Team Report and direct USER_WALLET payment paths remain available and preserve at-most-once settlement behavior. +- Historical migrations remain ordered and schema-digest checks remain valid. +- Circle paid-API packages, seller deployment artifacts, dependencies, tests, and non-context documentation are removed or updated without secrets. +- Existing durable paid-API records remain untouched and are not blindly retried or deleted. + +## Plan + +1. Remove active paid-API API, worker, supplier, seller, web, contract, and configuration paths. +2. Remove paid-API-specific tests/dependencies and regenerate contracts. +3. Remove user-facing Circle paid-API/proxy documentation while preserving context history and migration history. +4. Run focused tests, full validation, generated checks, browser checks, conflict scan, and secret scan. + +## Gate state + +- Gate A: not run. +- Gate B: not run. +- Commit/PR: not created. + +## Local validation + +- `pnpm test`: passed — 76 files / 1032 tests. +- `pnpm test:browser`: passed — 8/8. +- `pnpm typecheck`: passed. +- `pnpm lint`: passed. +- `pnpm format:check`: passed. +- `pnpm check:generated`: passed. +- `git diff --check`: passed. +- PostgreSQL-gated integration tests were not run locally because no container runtime was available. + +## Handoff + +- The feature removal is intentionally separate from any settlement-reconciliation repair. Existing paid-API records remain durable for audit; this branch does not claim that their prior UNKNOWN outcomes are resolved. diff --git a/.agent/context/20260913T013121Z-restore-server-privy-payments.md b/.agent/context/20260913T013121Z-restore-server-privy-payments.md new file mode 100644 index 0000000..da08f73 --- /dev/null +++ b/.agent/context/20260913T013121Z-restore-server-privy-payments.md @@ -0,0 +1,83 @@ +# Session Context: restore-server-privy-payments + +## Date/time + +- UTC: 2026-09-13T01:31:21Z + +## User goal + +Restore the PR #50-70 era payment behavior: a Privy login only authenticates +the operator, and every payment settles server-side through the Privy +execution wallet (SERVER_PRIVY) with no browser wallet, no confirmation +popups, and no 2FA. The owner reports the embedded browser wallet still +cannot pay and wants the old automatic flow back. + +## Original prompt/request + +"privy still doesn't pay or send anythin, can you bring back to the life +previous payment of the 50-60 pr to the privy login specific... I need +login/payment of really old pr's, because privy specific account doesn't +have 2factor auth for paying... don't look at the latest PRS!!!!" + +## Assumptions + +- The dual-path UI already supports the old behavior: without the userWallet + prop, JobWorkspace and the paid-API panel route to client.start() + (SERVER_PRIVY) and show "Payment authorization is queued" / "OneShot now + owns the payment attempt". +- The worker settlement runtime and the API POST /v1/paid-api + /v1/jobs + server path are intact on develop (90159b5). + +## Plan + +1. Stop wiring usePrivyUserWallet() into App in main.tsx (one-wiring change; + the user-wallet code stays for future use). +2. Add a regression test: no browser wallet wired -> Approve and run service + -> client.start called, no user-wallet calls. +3. Gate A, commit, push, draft PR, CI, Gate B. + +## Key decisions + +- Restoration is a wiring change, not a rewrite: the dual-path components + keep working, so future re-enablement is one line. +- The Privy recipient allowlist removal (PR #117, draft) complements this: + server-side authorization denies non-allowlisted recipients while the + app-layer allowlist is still active. + +## Files/components touched + +- apps/web/src/main.tsx — AuthenticatedApp no longer renders userWallet. +- apps/web/test/components.test.tsx — regression test for the server path. + +## Commands/checks + +- `pnpm vitest run test/components.test.tsx test/privy-session.test.tsx` - PASS, 31/31 +- `pnpm typecheck` (apps/web) - PASS (tsc -b exit 0) +- `pnpm lint` (apps/web) - PASS (eslint exit 0) +- `git diff --check` - PASS +- Local Node 22.23.2 differs from repository Node 24.19.0; checks still passed. + +## External-doc findings + +- None new; SERVER_PRIVY and USER_WALLET payment modes coexist in + apps/api/src/paid-api.ts and the contracts. + +## Unresolved questions + +- Production worker must keep ONESHOT_X402_URL/settlement config so + SERVER_PRIVY jobs settle; verify in the deployment environment. +- PR #117 (allowlist removal) is complementary and still a draft. + +## Git and PR state + +- Branch: feat/restore-server-privy-payments (from origin/develop 90159b5) +- Base: develop (90159b58be65fd73b6c71a4c6a1564147917044c) +- Commit: created after Gate A (SHA recorded in PR evidence) +- PR: draft opened after Gate A per user instruction +- CI: recorded on the PR after push + +## Gate A/B state + +- Gate A: in-session independent review (fresh free-pi process structurally + unavailable while the operator session is active; standing constraint). +- Gate B: after required CI on the exact PR head; verdict recorded on the PR. diff --git a/.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md b/.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md new file mode 100644 index 0000000..45a5386 --- /dev/null +++ b/.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md @@ -0,0 +1,76 @@ +# Session Context: clarify-personal-mcp-plan + +## Date/time + +- UTC: 2026-09-13T02:10:12Z + +## User goal + +Split the PR #112 plan into clear, ordered subtasks and reduce ambiguity about +the first deliverable. + +## Original prompt/request + +"Раздели md plan, сделай четче на подзадачи." Follow-up: keep the plan focused +only on the direct `arc_payment` flow. + +## Assumptions + +- The first release exposes exactly one MCP tool, `arc_payment`. +- The first release reuses the existing Privy server execution wallet and Arc + settlement worker. +- Personal wallets and additional signers are a later milestone. + +## Plan + +1. Rewrite the plan around one minimal server-wallet MCP milestone. +2. Give every task dependencies, concrete work, and an exit condition. +3. Preserve personal-wallet architecture as a separately gated milestone. +4. Validate Markdown, obtain Gate A, update PR #112, wait for CI, then obtain + Gate B. + +## Key decisions + +- `arc_payment` is a direct Arc USDC transfer. +- Browser and MetaMask wallets do not participate in milestone 1 payment + execution. +- The MCP handler reuses existing durable intent and settlement paths. +- Aggregate exposure needs a rolling cap or one-intent demo quota; a + per-payment cap alone is insufficient. + +## Files/components touched + +- `docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md` +- `.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md` + +## Commands/checks + +- `markdownlint-cli2` for the plan and context: PASS, 0 errors. +- `git diff --check`: PASS. +- Prettier check for the plan and context: PASS. + +## External-doc findings + +- Privy supports additional signers with per-signer override policies; this is + retained for milestone 2 rather than milestone 1. +- Privy policy evaluation defaults to deny when no rule matches. + +## Unresolved questions + +- Choose a Privy rolling cap or a one-intent quota before the MCP endpoint is + exposed beyond a controlled demo. + +## Git and PR state + +- Branch: `plan/personal-mcp-privy-agent-payments` +- Base: current `origin/develop` merged before editing. +- PR: #112, draft. + +## Gate A/B state + +- Gate A: pending for the rewritten candidate tree. +- Gate B: pending after push and CI. + +## Handoff/next steps + +- Complete Gate A, commit, push, CI, and Gate B. diff --git a/.agent/context/20260913T022938Z-mcp-arc-payment.md b/.agent/context/20260913T022938Z-mcp-arc-payment.md new file mode 100644 index 0000000..069b64a --- /dev/null +++ b/.agent/context/20260913T022938Z-mcp-arc-payment.md @@ -0,0 +1,104 @@ +# Session Context: MCP Arc payment + +## Date/time + +- UTC: 2026-09-13T03:01:00Z + +## User goal + +Continue implementing the PR #112 `arc_payment` plan on `mcp-integration`, +test only locally, and leave deployment and the real Arc payment untouched. + +## Original prompt/request + +The user supplied a GLM5-3 review confirming Gate A, commit, push, and the MCP +backend behavior at `1f25bae`, then asked: "Continue implementing plan, this was +the review of GLM5-3." The standing constraint is: "Пока тестим все локально без +запуска на develope." + +## Assumptions + +- The GLM5-3 review is accepted as the checkpoint for commit `1f25bae`. +- Local tests use mocks and must not submit a live Arc payment. +- Circle x402 and personal-wallet MCP support remain outside this milestone. + +## Plan + +1. Complete the missing public `/docs/mcp` guide and local same-origin proxy. +2. Verify both themes and responsive layouts without invoking `arc_payment`. +3. Stop before a fresh Gate A, commit, push, deployment, or live settlement. + +## Key decisions + +- Keep the page static and copy-ready, with no payment button or credential + persistence. +- Reuse the existing brand tokens and theme machinery; add no dependency. +- Keep the real Arc call pending because it is incompatible with local-only + testing. + +## Files/components touched + +- `apps/web/src/components/McpDocsPage.tsx`: public setup and replay walkthrough. +- `apps/web/src/App.tsx`: `/docs/mcp` route and landing-page link. +- `apps/web/src/styles.css`: responsive token-based documentation styles. +- `apps/web/vite.config.ts`: local `/mcp` proxy to the API. +- `apps/web/test/app-composition.test.tsx`, `apps/web/browser/p5.spec.ts`: route, + safety, contrast, responsive, and screenshot coverage. +- `docs/MCP_ARC_PAYMENT.md`, `docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md`: + public route and implementation status. + +## Commands/checks + +- GLM5-3 reported API 81/81, Web 108/108, TypeScript, lint, diff check, and Gate + A PASS for `1f25bae`; PostgreSQL integration was not run locally because no + Docker runtime was available. +- `pnpm --filter @oneshot/web test -- app-composition.test.tsx styles.test.ts` - + PASS, 25 tests. +- `pnpm --filter @oneshot/web test` - PASS, 109 tests. +- `pnpm --filter @oneshot/web typecheck` - PASS. +- `pnpm --filter @oneshot/web lint` - PASS. +- `pnpm --filter @oneshot/web test:browser` - PASS, 8 Chromium checks; the MCP + page passed axe color contrast and viewport overflow checks in light/dark at + 390px and 1440px. +- `pnpm test` - PASS, 83 files / 1091 tests. +- `pnpm lint` and `pnpm format:check` - PASS. +- `pnpm check:generated` - PASS; generated contracts are current. +- `pnpm validate:fixtures` - PASS; 9 contract and 7 UI fixtures. +- The host uses Node 22.23.2 while the repository pins Node 24.19.0; pnpm emits + the existing engine warning. + +## External-doc findings + +- No new external research was needed; the implementation follows the MCP SDK + and Streamable HTTP findings recorded with commit `1f25bae`. + +## Unresolved questions + +- The PostgreSQL MCP concurrency test still needs CI or a machine with Docker. +- Deployment still needs the MCP bearer, request key, payer address, workspace, + and cap configuration in Cloud Run. +- The real Arc Testnet call, identical replay, and proof capture remain pending. + +## Git and PR state + +- Branch: `mcp-integration` +- Merged current `origin/develop` at + `94f2438fd2032a0cfa28699b0515c8ce6ed5300f` without restoring the removed + Circle paid API or seller. +- MCP implementation commits: `1f25bae` and `f8baee7`; merge candidate is ready + for push and a draft implementation PR. +- Post-merge local validation: `pnpm test` PASS, 78 files / 1043 tests; + `pnpm lint`, `pnpm format:check`, `pnpm check:generated`, and + `pnpm validate:fixtures` PASS; browser tests PASS, 8/8. + +## Review gates + +- Gate A: skipped for the merged implementation by explicit user instruction. +- Gate B: skipped by explicit user instruction. + +## Handoff/next steps + +1. Commit and push the merge, open a draft PR, and wait for CI. +2. Deploy the API and Worker with MCP configuration. +3. Run one real Arc payment, replay the same request, and capture proof that no + replacement settlement was created. diff --git a/.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md b/.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md new file mode 100644 index 0000000..7f54fab --- /dev/null +++ b/.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md @@ -0,0 +1,102 @@ +# Session Context: Downloadable arc_payment agent skill + +## Date/time + +- UTC: 2026-09-13T03:37:20Z + +## User goal + +Ship a downloadable agent skill for the OneShot `arc_payment` MCP tool so +users can install it, use the tool through their own MCP clients, and hand it +to delegated/outsourced agents. Format: the repository standard +`.agents/skills//SKILL.md`. + +## Original prompt/request + +Continuation of the PR #120 integration session. The user said: "мы должны +сделать скилл для юзеров, которые они скачают, юзанут и смогут использовать и +так же смогут отдавать на оутсорс" and later confirmed the assistant plan: +"Теперь делаю скилл: скачаемый, для юзания и делегирования агентам — в +стандартном формате репо .agents/skills/." + +## Assumptions + +- The skill rides the user-named integration branch `mcp-integration` (PR + #120), like the rest of the MCP milestone work. +- Skill consumers are external agents (any tool with skills support); the + bearer token never travels inside the skill or prompts. +- A `/docs/mcp` download link is a possible follow-up, not part of this change. + +## Plan + +1. Verify the drafted `.agents/skills/oneshot-arc-payment/SKILL.md` against + `apps/api/src/mcp.ts` and `docs/MCP_ARC_PAYMENT.md`. +2. Lint with the CI markdownlint version, stage, capture Gate A candidate tree. +3. Gate A, commit, push to `origin/mcp-integration`, wait for CI, Gate B, + record evidence in PR #120. + +## Key decisions + +- Skill-only change (docs file + this context record): no web/code surface, so + no test or browser-screenshot churn; smallest coherent change per policy. +- Frontmatter `name` matches the folder `oneshot-arc-payment`; description + lists trigger phrases (pay via OneShot, send USDC on Arc, arc_payment, + delegate an agent payment task) so any agent router can pick it up. +- Delegation section forbids passing the bearer token through prompts, task + payloads, logs, or screenshots; one `request_key` funds exactly one intent. +- References point to the repo walkthrough, the human `/docs/mcp` page, and + the GitHub install path for the skill. + +## Files/components touched + +- `.agents/skills/oneshot-arc-payment/SKILL.md`: new downloadable skill + (prerequisites, `arc_payment` contract, execution flow, replay/conflict + handling, delegation rules, install references). +- `.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md`: this + record. + +## Commands/checks + +- Manual cross-check of SKILL.md against `apps/api/src/mcp.ts` - states, + `next_action`, `payer.mode`, cap `1000000` atomic, request-key error, + conflict error, amount/purpose validation all match the implementation. +- `npx --yes markdownlint-cli2@0.18.1 ".agents/skills/oneshot-arc-payment/SKILL.md"` - 0 error(s). +- `git ls-remote origin refs/heads/mcp-integration` - `b701bed` equals local + HEAD; push is a fast-forward. + +## External-doc findings + +- Agent-skill convention (`.agents/skills//SKILL.md`, YAML frontmatter + `name`/`description`) matches the repo's existing skills + (`oneshot-idempotency`, `oneshot-failure-injection`, + `sponsor-qualification`) and the user's local `~/.agents/skills/` layout. + +## Unresolved questions + +- None. Optional follow-up: add a "Download the agent skill" link on + `/docs/mcp` pointing at the raw GitHub skill file. + +## Git and PR state + +- Branch: `mcp-integration` +- Base: `develop` at `94f2438` (PR #120 base is `mcp-integration -> develop`) +- Commit: see Review gates / PR body for the exact SHA after push +- PR: (draft, updates in place) +- CI: to be captured on the new head after push + +## Review gates + +- Gate A: PASS (in-session; a fresh `npx free-pi-cli` reviewer process cannot + start because free-pi allows one session per account and this session is the + active one — standing constraint documented in prior PRs #113/#114/#116/ + #117/#118/#112). Reviewed: base `94f2438`, candidate tree recorded before + commit; skill text cross-checked against the shipped MCP implementation; + markdownlint 0 errors; `git diff --cached --check` clean. +- Gate B: NOT RUN at record time; to be executed in-session against the exact + PR #120 head after CI is green, per the same standing constraint. + +## Handoff/next steps + +1. Commit, push `mcp-integration`, wait for required CI on the new head. +2. Record Gate B PASS with head SHA/tree in PR #120 body. +3. Optional follow-ups: `/docs/mcp` download link; raw-file link in repo README. diff --git a/.agent/context/20260913T040725Z-mcp-access-and-scope.md b/.agent/context/20260913T040725Z-mcp-access-and-scope.md new file mode 100644 index 0000000..419efa7 --- /dev/null +++ b/.agent/context/20260913T040725Z-mcp-access-and-scope.md @@ -0,0 +1,81 @@ +# Session Context: MCP access and request scope + +## Date/time + +- UTC: 2026-09-13T04:07:25Z + +## User goal + +Remove the unused MCP-specific 1 USDC cap, make the downloadable agent skill installable with npx, connect the merged MCP path to Google Cloud, and verify that users see only their own requests. + +## Original prompt/request + +The user asked to remove the MCP 1 USDC boundary, provide the bearer required by agent configuration, add the missing npx skill installation instructions, audit per-user request isolation, and make the MCP usable through the existing Google Cloud deployment. + +## Assumptions + +- “Remove the 1 USDC boundary” means delete the second MCP-only cap. The worker settlement cap and Privy policy remain authoritative security controls. +- A shared production bearer must stay in Google Secret Manager and must not be embedded in the public docs page. +- Browser request isolation applies to job lists, job reads/results, and activity. Direct intent/recovery routes need a later storage association before full cross-workspace isolation can be claimed. + +## Plan + +1. Remove the MCP-specific amount cap and its configuration/tests/docs. +2. Scope browser job and activity routes by the verified Privy subject. +3. Add and verify the exact npx skill install command. +4. Validate, commit/push, then build and deploy the API with Google Secret Manager-backed MCP configuration. +5. Verify public MCP authentication and tool discovery without submitting a payment. + +## Key decisions + +- Derive an opaque stable workspace ID as SHA-256 of the verified Privy subject; do not accept a caller-selected workspace. +- Issue one random 256-bit bearer per Privy workspace, store only its SHA-256 digest, and allow explicit rotation. Keep the optional operator bearer only for compatibility. +- Never render or commit the bearer token. + +## Files/components touched + +- API authentication and route workspace selection. +- MCP cap configuration and tests. +- MCP docs page, operator docs, downloadable skill, and implementation plan. +- PostgreSQL migration 011 and personal MCP credential store. +- Authenticated Profile token generation and rotation UI. +- Google Cloud deployment configuration (pending). + +## Commands/checks + +- `npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --list --full-depth` - found `oneshot-arc-payment`. +- `pnpm build` - passed on local Node 22 with the repository Node 24 engine warning. +- `pnpm --filter @oneshot/api test` - 75/75 passed after personal token work. +- Focused web profile/docs tests - 10/10 passed. +- `pnpm test` - 79 files and 1052 tests passed. +- `pnpm test:browser` - 8/8 Chromium checks passed. +- Full web test exposed two pre-existing failures in `privy-session.test.tsx`; the changed MCP docs assertion was updated and passes. + +## External-doc findings + +- None. The installed `skills` CLI help verified the command syntax directly. + +## Unresolved questions + +- Google Secret Manager list/get remains unavailable to the active account; personal MCP bearer generation does not depend on a shared MCP secret. +- Direct `/v1/intents/:id`, reconcile, and recovery-view routes are not yet workspace-bound in storage; job request views are isolated. +- The active account cannot list/get Secret Manager metadata, but personal MCP tokens no longer require a shared MCP secret. + +## Git and PR state + +- Branch: fix/mcp-access-and-scope +- Base: origin/develop at 246a38af36e291b0538eb0a8f87d1f3b3f1def60 +- Commit: 9aafe21d27e27a99ddb0586a0cff747bd36a38f2 +- PR: (draft) +- CI: all required checks passed for 9aafe21d27e27a99ddb0586a0cff747bd36a38f2 + +## Review gates + +- Gate A: SKIPPED by explicit user instruction to continue without FreePi. +- Gate B: SKIPPED by explicit user instruction to continue without FreePi. + +## Handoff/next steps + +1. Finish local checks and inspect the candidate diff. +2. Commit, push, and open the PR without FreePi per user instruction. +3. Build/deploy the API image and configure `/mcp` through Google Cloud. diff --git a/.agent/context/20260913T050212Z-always-on-graph-evidence.md b/.agent/context/20260913T050212Z-always-on-graph-evidence.md new file mode 100644 index 0000000..3978175 --- /dev/null +++ b/.agent/context/20260913T050212Z-always-on-graph-evidence.md @@ -0,0 +1,112 @@ +# Session Context: always-on-graph-evidence + +## Date/time + +- UTC: 2026-09-13T05:02:12Z + +## User goal + +Make The Graph evidence appear for every confirmed transaction, then commit, +push, and open a draft PR. The PR must state that Gate A was not started. + +## Original prompt/request + +"right know we use the graph only if payment failed or something got wrong. We +want that the graph evidence will apear always, on every transaction. Implement +this feature. Also make commit and push after you finished. And alos make a +draft pr without starting gate A, but write in pr msg that GATE a wasn't +started" + +## Assumptions + +- “Every transaction” means every confirmed OneShot settlement, including + server-wallet and user-wallet commits; failed-safe attempts are not + transactions. +- Graph evidence remains non-authoritative and cannot change settlement or + retry permission. +- A durable outbox task is preferable to an inline post-commit call so a worker + restart cannot permanently lose the evidence capture. +- The existing modified `.agent/context/20260912T-user-wallet-payment.md` is + unrelated user work and must remain unstaged. + +## Plan + +1. Add an idempotent post-commit Graph evidence outbox task and worker port. +2. Wire production recovery configuration to capture Graph observations and + record `UNAVAILABLE` evidence on Graph boundary failure. +3. Add focused worker/recovery coverage and update migration/integration + expectations. +4. Run local checks, inspect the exact staged tree, commit, push, and create a + draft PR without starting Gate A. + +## Key decisions + +- Use a separate `capture_graph_evidence` task instead of running the recovery + LLM for successful payments. This keeps normal execution read-only and + avoids turning evidence capture into a retry/reconciliation decision. +- Make `(business_intent_id, source, digest)` unique and use `ON CONFLICT DO +NOTHING`, so redelivery after a crash does not duplicate evidence. +- Query the durable payer wallet for user-wallet jobs when constructing the + Graph correlation request; server-wallet intents retain the configured + sender fallback. + +## Files/components touched + +- `packages/storage-postgres/migrations/012_graph_evidence.sql` +- `packages/storage-postgres/src/ledger.ts` +- `apps/worker/src/types.ts` +- `apps/worker/src/recovery-bridge.ts` +- `apps/worker/src/composition.ts` +- `apps/worker/src/worker.ts` +- `apps/worker/README.md` and `apps/worker/FAILURE_CATALOG.md` +- focused worker tests and storage integration expectations + +## Commands/checks + +- Repository policy and routed documents read: `.agent/AGENTS.md`, project + context, security invariants, sponsor requirements, test matrix, + implementation loop, and `oneshot-idempotency/SKILL.md`. +- Branch created from current `develop`: `feature/always-on-graph-evidence`. +- `pnpm.cmd --filter @oneshot/worker test` - 7 files / 50 tests passed. +- `pnpm.cmd --filter @oneshot/reconciliation test` - 8 files / 89 tests passed. +- `pnpm.cmd --filter @oneshot/storage-postgres test` - 4 files / 15 tests passed + (PostgreSQL-gated tests skipped without a container runtime). +- `pnpm.cmd --filter @oneshot/settlement-ui test` - 5 files / 211 tests passed. +- `pnpm.cmd test` - 80 files / 1,055 tests passed. +- Worker, reconciliation, and storage typechecks plus root lint/build passed. +- `git diff --check` passed; targeted Prettier checks passed after formatting. + +## External-doc findings + +- Repository policy defines OneShot as authoritative for settlement and The + Graph as non-authoritative candidate discovery; missing/delayed index data + cannot authorize payment. +- Test matrix requires Graph boundary failures to fail closed and durable + evidence metadata to be retained. + +## Unresolved questions + +- None; Graph evidence capture may be unavailable, but the observation must + still be recorded with `UNAVAILABLE` freshness. + +## Git and PR state + +- Branch: `feature/always-on-graph-evidence` +- Base: `develop` at `62920523c5a323cfc0e38d57c632f498b4d921d5` +- Feature commit: `4fea7e66d6187760052d24508d47e73fa2b8daca` +- Feature tree: `86f39915dbab92450483c367f9fc0df67bae6172` +- Remote branch: pushed to `origin/feature/always-on-graph-evidence` +- Draft PR: [#124](https://github.com/SWOFART/OneShot/pull/124) +- PR head at creation: feature commit/tree above +- CI: GitHub checks are pending/queued; local validation passed + +## Review gates + +- Gate A: NOT RUN (explicitly requested to skip) +- Gate B: NOT RUN + +## Handoff/next steps + +1. Keep the unrelated `.agent/context/20260912T-user-wallet-payment.md` + modification unstaged. +2. Human review and CI follow-up remain; do not start Gate A or Gate B. diff --git a/.agent/context/20260913T051414Z-frontend-copy-cleanup.md b/.agent/context/20260913T051414Z-frontend-copy-cleanup.md new file mode 100644 index 0000000..de8cbe8 --- /dev/null +++ b/.agent/context/20260913T051414Z-frontend-copy-cleanup.md @@ -0,0 +1,107 @@ +# Session Context: Frontend copy cleanup + +## Date/time + +- UTC: 2026-09-13T05:14:14Z + +## User goal + +Apply user-directed frontend copy fixes: replace outdated Circle-era wording +("connected API service", "Run an API service") with direct-payment language, +remove the noisy readiness line and one MCP-docs fact, rewrite the agent skill +install and agent configuration copy in a friendlier way with a Node.js link, +and rename the "Q4 supplier research" placeholder. The user explicitly +corrected the scope for unmatched transfers: remove them from the database, +NOT from the UI. + +## Original prompt/request + +"Итак, давай сейчас сделаем небольшие исправления на сайте, в фронтенде..." +followed by the itemized copy list, then the correction: "Стоп, убери этот из +БД, а не из UI. Ты меня услышал? Не делай то, что я тебе не говорю." The user +also asked to run the usual checks afterwards. + +## Assumptions + +- The unmatched-transfer metric stays visible in the UI (user correction); + the 26 unmatched transfers live in `wallet_activity_observations.payload` + (latest The Graph snapshot per workspace) and are re-observed on every + "Check payment activity" refresh, so any DB deletion is undone by the next + refresh. Direct DB deletion requires production credentials the current + agent identity cannot access (Cloud SQL admin and Secret Manager both 403), + so the exact operator command is provided to the user instead. +- "Walk through a real request" update means aligning wording with the + direct-payment theme; the walkthrough content itself stays unchanged. +- The "Run the walkthrough" docs section stays as-is (user leaned "fine"). + +## Plan + +1. Apply the copy edits to App.tsx, JobWorkspace.tsx, ReadinessBanner.tsx, + McpDocsPage.tsx, McpProfile.tsx, styles.css. +2. Branch `fix/frontend-copy-cleanup` from `origin/develop` (`6292052`). +3. Run web tests, typecheck, lint, format, then Gate A, commit, push, PR, CI, + Gate B. + +## Key decisions + +- ReadinessBanner returns `null` on the healthy branch instead of rendering + "Backend ready · Arc Testnet · USDC"; warning and safe-mode branches remain. +- New copy leads with the direct-payment promise: one durable request, exact + quote before approval, evidence afterwards. +- Skill install sections now link Node.js download and drop the "develop + branch" phrasing; McpProfile heading gained a 3rem top margin via + `.skill-install-heading`. +- No test asserted any removed or renamed string (93/93 passed unchanged). + +## Files/components touched + +- `apps/web/src/App.tsx`: overview paragraph, action button label, walkthrough + summary wording. +- `apps/web/src/components/JobWorkspace.tsx`: purpose placeholder. +- `apps/web/src/components/ReadinessBanner.tsx`: healthy state renders nothing. +- `apps/web/src/components/McpDocsPage.tsx`: removed the server-wallet popup + fact; friendlier skill-install copy with Node.js link; "Agent configuration" + heading and clearer bearer instructions. +- `apps/web/src/components/McpProfile.tsx`: "Install the payment skill for the + agent" heading with top margin and Node.js link. +- `apps/web/src/styles.css`: `.skill-install-heading` margin rule. + +## Commands/checks + +- `pnpm --filter @oneshot/web test` - PASS (17 files, 93 tests) +- `pnpm --filter @oneshot/web typecheck` - PASS +- `pnpm --filter @oneshot/web lint` - PASS +- `pnpm format:check` - PASS +- Known environment note: local Node 22.23.2 vs pinned 24.19.0 (standing). + +## External-doc findings + +- `unmatched_transfer_count` is computed at read time from the latest + `wallet_activity_observations` row by comparing snapshot transfers with + recorded settlements (`packages/storage-postgres/src/jobs.ts`). + +## Unresolved questions + +- Production DB deletion of the unmatched-transfer snapshot: awaiting operator + execution (SQL provided in the PR/handoff); the count reappears after the + next activity refresh because the transfers are live chain history. + +## Git and PR state + +- Branch: fix/frontend-copy-cleanup +- Base: origin/develop at 6292052 +- Commit: uncommitted at record time +- PR: to be created against develop +- CI: pending + +## Review gates + +- Gate A: pending in-session review of the staged candidate tree. +- Gate B: pending after CI. + +## Handoff/next steps + +1. Stage, capture candidate tree, Gate A, commit, push, open draft PR. +2. Wait for required CI, then Gate B, then record evidence in the PR. +3. Operator: delete `wallet_activity_observations` rows in production SQL to + clear the stored unmatched transfers (they return on next refresh). diff --git a/.agent/context/20260913T065500Z-privy-browser-buffer.md b/.agent/context/20260913T065500Z-privy-browser-buffer.md new file mode 100644 index 0000000..d38ab62 --- /dev/null +++ b/.agent/context/20260913T065500Z-privy-browser-buffer.md @@ -0,0 +1,88 @@ +# Session Context: Privy browser Buffer + +## Date/time + +- UTC: 2026-09-13T06:55:00Z + +## User goal + +Fix the Privy approval failure that displays `Buffer is not defined` after the +user approves an Arc Testnet USDC transfer. + +## Original prompt/request + +The user supplied screenshots of the Privy approval and failure dialogs and +asked to fix the issue. + +## Assumptions + +- The failure is in the browser bundle after approval; the backend settlement + and retry behavior must remain unchanged. +- Existing unrelated backend work in another worktree must remain untouched. + +## Plan + +1. Reproduce the missing browser-global dependency from the installed Privy + transaction path. +2. Install the existing `buffer` package as an explicit web dependency and + initialize it before Privy's lazy-loaded wallet code runs. +3. Build and run focused web checks. + +## Key decisions + +- Apply one entry-point polyfill because the installed Privy client contains + transaction paths that reference the global Node `Buffer` in the browser. +- Do not alter wallet selection, transaction parameters, settlement state, or + retry behavior. + +## Files/components touched + +- `apps/web/src/main.tsx`: initialize the browser `Buffer` global before Privy. +- `apps/web/package.json` and lockfile: make the polyfill a direct dependency. + +## Commands/checks + +- `pnpm --filter @oneshot/web typecheck` - PASS. +- `pnpm --filter @oneshot/web test` - PASS, 17 files / 93 tests. +- `pnpm --filter @oneshot/web build` - PASS; generated entry contains the + `globalThis.Buffer` initialization. +- `pnpm --filter @oneshot/web test:browser` - PASS, 8 Chromium tests; includes + an assertion that the browser global is installed. +- `pnpm lint` - PASS. +- `pnpm format:check` - PASS. +- `git diff --check` - PASS. +- Local Node is v22.23.2 while the repository requests v24.19.0. + +## External-doc findings + +- None; the installed dependency and observed runtime error establish the + compatibility issue. + +## Unresolved questions + +- A final live Privy approval requires the deployed frontend and the user's + wallet session; local checks cover bundle availability and existing wallet + behavior. + +## Git and PR state + +- Branch: `fix/privy-browser-buffer` +- Base: `origin/develop` at `5e1c9e9210ef22416ee8b62713e9a3e597bb4577` +- Implementation commit: `4721961d7e55047d120e2d4e51dc3a40b4d520bb` +- PR: [#126](https://github.com/SWOFART/OneShot/pull/126), draft +- CI: repository policy, Markdown/Mermaid, and Cloudflare Workers build passed; + frontend browser acceptance and ESLint/TypeScript were pending at this snapshot. +- Production frontend: Cloudflare Worker version + `935dfd2b-8fba-4875-964e-7f609c472378`; the public page returned HTTP 200 and + its entry bundle contained the `globalThis.Buffer` initialization. + +## Review gates + +- Gate A: NOT RUN under the user's standing explicit instruction to continue + without FreePi; no PASS is claimed. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Wait for remaining PR checks and have the user retry one approved testnet + transfer; agents do not merge. diff --git a/.agent/context/20260913T071500Z-mcp-profile-docs-copy.md b/.agent/context/20260913T071500Z-mcp-profile-docs-copy.md new file mode 100644 index 0000000..3e18f24 --- /dev/null +++ b/.agent/context/20260913T071500Z-mcp-profile-docs-copy.md @@ -0,0 +1,85 @@ +# Session Context: MCP profile docs and bearer copy + +## Date/time + +- UTC: 2026-09-13T07:15:00Z + +## User goal + +Make MCP onboarding easier from Profile by linking directly to `/docs/mcp` and +letting the user copy the newly issued personal bearer token. + +## Original prompt/request + +The user asked to add the documentation link +`https://oneshot.kapustazh.dev/docs/mcp` to Profile and add a button that copies +the bearer token. + +## Assumptions + +- Copying is available only while the plaintext token is already displayed + after generation or rotation. +- The token remains memory-only and is never persisted by the frontend. +- This branch stacks on the deployed Privy Buffer compatibility fix so a new + frontend deployment does not regress that fix. + +## Plan + +1. Add the internal documentation link to the MCP profile panel. +2. Reuse the browser Clipboard API to copy only the displayed bearer. +3. Add focused UI coverage and validate the web package. + +## Key decisions + +- Use `navigator.clipboard` directly; no dependency or storage is needed. +- Report copy failure in the existing profile error surface. + +## Files/components touched + +- `apps/web/src/components/McpProfile.tsx`: documentation link and copy action. +- `apps/web/test/app-composition.test.tsx`: link and clipboard behavior coverage. + +## Commands/checks + +- `pnpm --filter @oneshot/web test -- app-composition.test.tsx` - PASS, + 7 tests. +- `pnpm --filter @oneshot/web typecheck` - PASS. +- `pnpm --filter @oneshot/web test` - PASS, 17 files / 93 tests. +- `pnpm --filter @oneshot/web test:browser` - PASS, 8 Chromium tests. +- `pnpm lint` - PASS. +- `pnpm format:check` - PASS. +- `git diff --check` - PASS. +- Local Node is v22.23.2 while the repository requests v24.19.0. + +## External-doc findings + +- None. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `feat/mcp-profile-docs-copy` +- Base: `fix/privy-browser-buffer` at + `dcc730792440e3f90cb5e96fe0597f80072a51d0` +- Implementation commit: `8dbbf16f6c3f3682693d7d5bde55dcfaf2b984cb` +- PR: [#127](https://github.com/SWOFART/OneShot/pull/127), draft, stacked + on `fix/privy-browser-buffer` +- CI: repository policy, Markdown/Mermaid, frontend browser acceptance, and + Cloudflare Workers build passed; ESLint/TypeScript was pending at this snapshot. +- Production frontend: Cloudflare Worker version + `11981c12-6a81-4e4a-9ec9-819d6b52d458`; the public entry bundle contains the + documentation link, bearer-copy action, and the preceding Buffer polyfill. + +## Review gates + +- Gate A: NOT RUN under the user's standing explicit instruction to continue + without FreePi; no PASS is claimed. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Wait for the final PR check and leave both stacked PRs for human review; + agents do not merge. diff --git a/.agent/context/20260913T082202Z-mcp-generated-request-key.md b/.agent/context/20260913T082202Z-mcp-generated-request-key.md new file mode 100644 index 0000000..9e9d406 --- /dev/null +++ b/.agent/context/20260913T082202Z-mcp-generated-request-key.md @@ -0,0 +1,102 @@ +# Session Context: MCP generated request key + +## Date/time + +- UTC: 2026-09-13T08:22:02Z + +## User goal + +Make `arc_payment` generate its request key through the calling agent instead +of asking the user to copy a profile-configured key, then redeploy the MCP API. + +## Original prompt/request + +The user reported that a payment was rejected because the supplied value did +not match the configured demo request key. They asked for request keys to be +generated automatically like the web flow (`report--`), +without manual copying, and for MCP to be redeployed. + +## Assumptions + +- The MCP caller generates and retains the key before its first tool call, as + the web client does; server-side generation after receipt would make a lost + first response unsafe to replay. +- Existing worker settlement caps, Privy policy, and Arc Testnet remain + authoritative after removing the obsolete one-intent deployment quota. +- Six unrelated changes in the original worktree belong to the user and stay + untouched; this task uses a clean linked worktree. + +## Plan + +1. Remove the deployment-wide request-key allowlist and profile key output. +2. Tell MCP clients and the downloadable skill to generate and retain a random + purpose-based key without asking the user. +3. Run focused and full checks, required review gates, push a draft PR, then + redeploy and smoke-test the MCP API without sending a payment. + +## Key decisions + +- Keep `request_key` required in the tool call so transport retries preserve + the exact stable business-intent identity; the agent generates it internally. +- Reuse the web naming convention and add no new dependency or persistence + layer. + +## Files/components touched + +- `apps/api/src/mcp.ts`, `config.ts`, `runtime.ts`, `app.ts`: accept any + validated caller-generated stable request key and remove deployment/profile + key coupling. +- API unit/integration tests: generated-key acceptance, immutable conflict, + same-key sequential and parallel convergence, personal workspace isolation. +- `apps/web`: remove obsolete profile request-key output and explain automatic + agent generation. +- `.agents/skills/oneshot-arc-payment/SKILL.md`, `.env.example`, and + `docs/MCP_ARC_PAYMENT.md`: update the client contract and deployment config. + +## Commands/checks + +- `git fetch origin develop` - base refreshed to + `964d4751cf35e3e63f588868ad620d34dcd6e878`. +- Clean worktree created on `fix/mcp-generated-request-key`; original dirty + worktree was not modified. +- Focused API tests - PASS, 47 tests. +- Focused web tests - PASS, 10 tests. +- `pnpm test` - PASS, 80 files / 1055 tests. +- `pnpm lint`, `pnpm format:check`, `pnpm check:generated`, and + `pnpm validate:fixtures` - PASS. +- `pnpm scenarios:invariants` - PASS, all 7 scenarios including identical and + conflicting replay, 10 parallel workers, two processes, restart, lost + response, and downstream failure; every scenario retained at most one + settlement. +- `pnpm test:browser` - PASS, 8 Chromium tests. +- API integration suite - 3 tests skipped because Docker is not installed; + required CI remains the authoritative PostgreSQL run. +- `markdownlint-cli2@0.18.1` - PASS, 0 errors in changed Markdown. +- Checks ran on local Node 22.23.2; repository pins Node 24.19.0 and emitted + the existing engine warning. + +## External-doc findings + +- None needed; this change uses the existing MCP SDK and deployment path. + +## Unresolved questions + +- PostgreSQL integration test is present for 10 parallel MCP calls but was not + selected by the default suite; required CI will run the integration job. + +## Git and PR state + +- Branch: `fix/mcp-generated-request-key` +- Base: `origin/develop` at `964d4751cf35e3e63f588868ad620d34dcd6e878` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Implement and validate the focused change. diff --git a/.agent/context/20260913T085602Z-recovery-proof-palette.md b/.agent/context/20260913T085602Z-recovery-proof-palette.md new file mode 100644 index 0000000..8c31c1d --- /dev/null +++ b/.agent/context/20260913T085602Z-recovery-proof-palette.md @@ -0,0 +1,82 @@ +# Recovery proof palette and Graph backfill — active context + +## Goal + +Make Graph candidate discovery appear for historical committed settlements and +bring Payment proof / Recovery control into the main OneShot palette without +removing evidence or weakening settlement safety. + +## Acceptance criteria + +- Historical `COMMITTED` settlements missing Graph evidence receive one durable, + idempotent `capture_graph_evidence` outbox job. +- New settlement behavior and Graph authority remain unchanged. +- Missing Graph copy distinguishes an observation that is still pending from + proof that no payment happened. +- Payment proof and recovery use the shared surface, ink, line, radius, and font + tokens used by the authenticated workspace. +- Existing responsive and accessibility checks remain green. + +## Constraints + +- Graph remains non-authoritative candidate discovery; Arc and OneShot remain + authoritative. +- No live payment, deployment, or external configuration change. +- The dirty `fix/x402-wallet-proof-recovery` worktree is unrelated user work and + must remain untouched. + +## Branch state + +- Branch: `fix/recovery-proof-palette` +- Base: `origin/develop` at `72c4bd7` (rebased by fast-forward from `964d475` + after PR #130 landed; no file overlap with this change) +- Worktree: `recovery-proof-palette` + +## Plan + +1. Add the smallest append-only migration that backfills missing Graph capture + jobs from durable settlements. +2. Reuse the shared brand tokens in both read-only UI slices and clarify missing + Graph evidence copy. +3. Run focused tests, visual browser checks, the implementation loop, and review + gates required for the final staged tree. + +## Review gates + +- Gate A: pending +- Gate B: pending + +## Base update + +- While the work was in progress, `origin/develop` advanced from `964d475` to + `72c4bd7` (merge of PR #130, MCP generated request keys). The branch had no + commits yet, so it fast-forwarded; the diff has zero file overlap with this + change and local checks were re-run on the updated candidate tree. + +## Implementation + +- Added migration `013_backfill_graph_evidence.sql`; it enqueues one Graph + capture job only when a durable settlement has neither Graph evidence nor an + existing capture job. +- Missing Graph evidence now reads `CAPTURE PENDING` for committed settlements + and `NOT YET REPORTED` for non-terminal recovery states. +- Recovery uses the shared Rubik/mono typography, brand radii, pill actions, + lime authoritative-state summary, and a light/dark-aware Graph evidence card. +- Payment proof uses the same lime request summary and shared typography/radii; + long policy values wrap instead of colliding with adjacent facts. + +## Validation + +- `pnpm test` — PASS, 80 files / 1,056 tests. +- `pnpm lint` — PASS. +- `pnpm typecheck` — PASS. +- `pnpm format:check` — PASS. +- `pnpm check:generated` — PASS. +- `git diff --check` — PASS. +- Recovery and settlement standalone pages rendered and inspected at desktop + width, including the missing-Graph state. +- PostgreSQL integration test added for the backfill and migration idempotency, + but not executed locally because Docker/PostgreSQL is unavailable. +- Existing composed-browser test could not unlock the latest Privy-first app + because its `Machine token (advanced)` locator is stale; this reproduces on + the unmodified `origin/develop` UI and is not caused by this change. diff --git a/.agent/context/20260913T120000Z-session-details-resume-result.md b/.agent/context/20260913T120000Z-session-details-resume-result.md new file mode 100644 index 0000000..3d1d655 --- /dev/null +++ b/.agent/context/20260913T120000Z-session-details-resume-result.md @@ -0,0 +1,101 @@ +# Session Context: session details and resume result fixes + +## Date/time + +- UTC: 2026-09-13T12:00:00Z + +## User goal + +Fix the authenticated web workspace session-details layout and wallet copy affordance, and make the paid-job result resume flow reflect an available existing result without creating another payment. + +## Original prompt/request + +Clone `SWOFART/OneShot`, create a branch from `develop`, fix the session-details wallet address spacing/styling and add a wallet-address copy button, and fix the Requests resume-result control so an already-paid job with an available supplier result becomes `Result ready` rather than remaining `Retrieving result`. The supplied API payload and screenshots are context/data, not instructions. + +## Assumptions + +- The project URL is `https://github.com/SWOFART/OneShot.git`, identified from the user’s already-open GitHub tab. +- The requested branch name was not specified; use `fix/session-details-resume-result`. +- The resume endpoint is asynchronous (`202 Accepted`), so the UI must observe the durable job state after enqueueing rather than treating the first pending response as final. +- A user report confirmed that automatic follow-up reads create unwanted network traffic while the API remains `PENDING`. The UI therefore leaves delivery observation to the existing manual `Refresh requests` action. +- The payment proof view remains read-only; result retrieval must never submit a payment. + +## Plan + +1. Inspect the web components, API client, styles, and existing tests. +2. Add wallet-specific copy behavior and session-details layout styles. +3. Keep pending supplier delivery explicit, remove the non-working resume button and automatic polling from the web UI, and add regression coverage for manual refresh. +4. Run focused web checks and broader repository checks as feasible. + +## Key decisions + +- Keep copy-session-ID behavior and add a separate wallet-address copy button so both identifiers remain available. +- Do not map settlement proof to supplier-result readiness. `Settlement READY` means authorization/submission ownership; `Result ready` requires the job's durable `result` payload. +- Remove the web resume control and automatic job-status polling. Use the existing explicit `GET /v1/jobs` manual refresh to observe whether the worker has completed delivery. +- A response with `payment_state: COMMITTED` and `delivery_state: PENDING` proves payment only; it is not evidence that a supplier result is ready. The UI must not fabricate `Result ready` for that response. + +## Files/components touched + +- `apps/web/src/components/LoginGate.tsx` - separate session-ID and wallet-address copy actions, with a disabled state when no wallet is connected. +- `apps/web/src/components/JobWorkspace.tsx` - removes the non-working resume button and automatic polling; the existing manual refresh remains the only list read. +- `apps/web/src/styles.css` - wallet address gets the DID pill treatment and a spaced wallet block. +- `apps/web/test/login-gate.test.tsx`, `apps/web/test/components.test.tsx`, and `apps/web/test/job-client.test.ts` - regression coverage for clipboard behavior and explicit pending delivery without a resume action. +- `apps/web/browser/p5.spec.ts` - models `PENDING` on initial load and `AVAILABLE` only after a manual refresh; it asserts no resume request is sent. + +## Commands/checks + +- `git clone --branch develop --single-branch https://github.com/SWOFART/OneShot.git work/OneShot` - succeeded. +- `git switch -c fix/session-details-resume-result` - succeeded. +- `git rev-parse HEAD` and `git rev-parse origin/develop` - both `d671af1da36878b7aaafcfc3049bc958e0daeb34`. +- Repository policy, implementation loop, idempotency/failure-injection skills, project context, security invariants, and test matrix read before edits. +- `pnpm install --frozen-lockfile` - succeeded with the pinned workspace lockfile. +- `pnpm --filter @oneshot/web test -- test/login-gate.test.tsx test/components.test.tsx` - passed, 28 tests. +- `pnpm --filter @oneshot/web lint` - passed. +- `pnpm --filter @oneshot/web typecheck` - passed. +- `pnpm --filter @oneshot/web test` - passed, 17 files / 95 tests. +- `pnpm --filter @oneshot/web test:browser` - passed, 8 browser tests. +- `pnpm format:check` - passed. +- Follow-up focused web test after the single-job polling fix - passed, 23 tests. +- Follow-up full web test after the single-job polling fix - passed, 17 files / 96 tests. +- Follow-up `pnpm --filter @oneshot/web lint` - passed. +- Follow-up `pnpm --filter @oneshot/web typecheck` - passed. +- Follow-up `pnpm --filter @oneshot/web test:browser` - passed, 8 browser tests. +- Follow-up `pnpm format:check` - passed after Prettier formatting. +- Latest focused web test after removing resume polling - passed, 22 tests. +- Latest full web test after removing resume polling - passed, 17 files / 95 tests. +- Latest `pnpm --filter @oneshot/web lint` - passed. +- Latest `pnpm --filter @oneshot/web typecheck` - passed. +- Latest `pnpm --filter @oneshot/web test:browser` - passed, 8 browser tests. +- Latest `pnpm format:check` - passed. +- `pnpm lint` - passed. +- `pnpm build` - passed. +- `pnpm test` - passed, 80 files / 1,053 tests. +- `git diff --check` - passed; only the intended web files and this context record are changed. + +## External-doc findings + +- No external documentation was needed; the relevant API contract and behavior are in the repository and user-provided response. + +## Unresolved questions + +- None currently. + +## Git and PR state + +- Branch: `fix/session-details-resume-result` +- Base: `develop` at `d671af1da36878b7aaafcfc3049bc958e0daeb34` +- Previous commit: `1824728d594a04a797fa1447acd1a3f9edc7fd3e` +- Latest UI change: uncommitted; candidate changes are not yet staged. +- PR: [#136](https://github.com/SWOFART/OneShot/pull/136), open against `develop`, currently at the previous commit. +- CI: not run for the follow-up candidate yet. + +## Review gates + +- Gate A: fresh review required for the follow-up candidate before push. +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage only the follow-up web changes and this context record after reviewing the diff. +2. Capture a fresh Gate A verdict for the exact candidate tree before pushing. +3. Commit and push the candidate to PR #135 after Gate A passes; do not start Gate B unless the user explicitly requests it. diff --git a/.agent/context/20260913T160000Z-readme-refresh.md b/.agent/context/20260913T160000Z-readme-refresh.md new file mode 100644 index 0000000..d64d995 --- /dev/null +++ b/.agent/context/20260913T160000Z-readme-refresh.md @@ -0,0 +1,76 @@ +# README refresh and front-end derived banner — active context + +## Date/time + +- UTC: 2026-09-13T16:00:00Z + +## User goal + +Audit the repository, bring `README.md` back in line with the current code, and +put the product's own `.top-nav` panel ("OneShot / SETTLEMENT ENGINE") into the +README, derived from the front-end source rather than from a screenshot. + +## Acceptance criteria + +- README reflects the routes, workspace sections, API surface, repository + layout, and delivery status that exist on `develop` today. +- The README banner is generated from `packages/brand/src/tokens.css`, + `packages/brand/src/CommitRing.tsx`, and `apps/web/src/App.tsx`; no colour, + mark geometry, or nav label is retyped by hand. +- `markdownlint`, `prettier --check`, `eslint`, and `tsc -b` stay green. +- No behaviour, contract, or configuration change. + +## Assumptions + +- GitHub strips CSS from Markdown, so the panel ships as two static SVGs (one + per theme) chosen by a `` element rather than as live markup. +- Advance widths in the renderer are estimates; Rubik cannot be measured + without a font engine, and sub-pixel slack inside a pill is not visible. + +## Non-goals + +- No change to `apps/web`, the API, or any adapter. +- No sponsor-qualification claim beyond what `docs/settlement/LIVE_EVIDENCE.md` + and the C06 report already support. + +## Branch state + +- Branch: `feature/readme-refresh` +- Base: `origin/develop` at `65200cc2dfcf22912e532a157232e439d623044f` +- Untracked `packages/brand/test/slice-styles.test.ts` is unrelated user work + and stays out of this change. + +## Drift corrected in README + +- `packages/brand` and the MCP/agent role of `apps/api` were missing from the + repository layout. +- The `/docs/mcp` route, the five cabinet sections, and the Profile MCP bearer + flow were undocumented; the old copy still described the legacy four-tab + console and a "Tools" section that no longer exists. +- The API table was missing `/v1/jobs/user-wallet/prepare`, + `/v1/jobs/{jobId}/user-wallet/submit`, `/mcp`, and the + `/v1/profile/mcp-token` routes. +- Workspace identity is now derived from the verified Privy subject, not from + the configured workspace id. +- Graph evidence is captured for every committed settlement, with a backfill. +- A duplicated sentence fragment in Project status was repaired. + +## Commands and results + +- `node scripts/render-nav-panel.mjs` — wrote both SVGs. +- `npx markdownlint-cli2 README.md` — 0 errors. +- `pnpm format:check` — all matched files use Prettier style. +- `pnpm lint` — clean. +- `pnpm typecheck` — clean. + +## Gate state + +The user explicitly waived FreePi Gate A and Gate B for this documentation-only +change and asked for a draft pull request instead. No gate verdict exists, so +the PR stays in draft until a human decides how to proceed. + +## Remaining risk + +- Local `pnpm test` / `pnpm test:browser` were not re-run; the change touches + no source consumed by either suite. +- No independent review evidence backs this tree. diff --git a/.agent/context/20260913T170000Z-pending-delivery-refresh.md b/.agent/context/20260913T170000Z-pending-delivery-refresh.md new file mode 100644 index 0000000..85da9c4 --- /dev/null +++ b/.agent/context/20260913T170000Z-pending-delivery-refresh.md @@ -0,0 +1,114 @@ +# Session Context: pending delivery refresh + +## Date/time + +- UTC: 2026-09-13T17:00:00Z + +## User goal + +A request whose payment has settled kept showing "Retrieving result" even +though the supplier result was already available, intermittently and without a +clear trigger. Make the list reflect the delivery that has actually completed. + +## Original prompt/request + +"we need another fix. After transaction is done it still showing retrieving +result (picture 1), when results logically should be ready, like in picture 2. +Sometimes it works, sometimes it doesnt, i dont know scenarios, but right now on +our latest operation we dont need retrieving results, bc results ARE ready." +Follow-up: skip Gate A and Gate B, open the pull request as a draft. + +## Assumptions + +- The reported screenshots show one list read: the newest request is `PENDING` + while an older one is `AVAILABLE`, which is a stale snapshot of a delivery + still in flight rather than a rendering fault. +- Bounded read-only re-reads are an acceptable middle ground against the + earlier report that automatic follow-up reads created unwanted traffic. +- No live payment or deployment is involved. + +## Plan + +1. Commit, push, and open a draft pull request against `develop`. + +## Key decisions + +- `JobList` re-reads `GET /v1/jobs` while any delivery is `PENDING`: every four + seconds, fifteen attempts, then it stops. It never calls the resume endpoint + and never submits a payment, so the at-most-once settlement invariant is + untouched. +- The re-reads are quiet: they do not raise the loading flag, because the + spinner, the disabled refresh button, and the `.tab-fade` remount all belong + to a read the operator asked for. +- This partially reverses commit `a2903a1`, which removed automatic polling + after a report of unwanted traffic. The bound and the pending-only condition + are what keep both reports satisfied; the resume control it removed stays + removed. +- A delivery stranded in `PENDING` is now recoverable. `PENDING` alone is not + treated as resumable: the deciding evidence is whether a `fulfill_supplier_order` + row is still queued (`status = 'PENDING'`) for that job. A row a worker is + currently holding is still queued, so an in-flight retrieval is never + duplicated; only a job with nothing left to move it is re-queued. +- Selected `.agent/TEST_MATRIX.md` cases: downstream failure after payment (the + committed payment is preserved and no payment work is created on the recovery + path) and parallel/duplicate claim (the compare-and-set against the locked + row's state means two concurrent resumes cannot both claim one delivery). + +## Files/components touched + +- `apps/web/src/components/JobWorkspace.tsx` - bounded, quiet re-reads of the + request list while a delivery is pending. +- `apps/web/test/components.test.tsx` - the re-read reaches `Result ready`, + stops once nothing is pending, and gives up on a delivery that stays pending. +- `apps/web/browser/p5.spec.ts` - the read count is now a lower bound, because + an exact count would flake once a pending delivery is re-read on a timer. +- `packages/storage-postgres/src/jobs.ts` - `resumeDelivery` now also recovers a + delivery stranded in `PENDING` with no queued fulfilment row, claiming it + against the state read under the job's row lock. +- `packages/storage-postgres/test/jobs.test.ts` - a queued or in-flight + fulfilment row is still left alone; a stranded one is re-queued under a fresh + `delivery_attempt`, with no payment work created. + +## Commands/checks + +- `pnpm format:check` - PASS +- `pnpm lint` - PASS +- `pnpm typecheck` - PASS +- `pnpm test` - PASS, 81 files / 1059 tests, includes build. The root vitest + config covers no `.tsx` file, so the command below is the one that exercises + these components. +- `pnpm --filter @oneshot/web test` - PASS, 17 files / 97 tests +- `pnpm test:browser` - PASS, 8 tests +- Local Node is 24.20.0 against the pinned 24.19.0; CI must validate the + pinned runtime. + +## External-doc findings + +- None. No version-sensitive integration changed. + +## Unresolved questions + +- Nothing in the web UI calls `POST /v1/jobs/:jobId/resume`, so recovering a + stranded delivery still needs an API call. The control that used to do it was + removed deliberately; re-adding one is a product decision, not a defect fix. +- Whether any currently stranded job exists in the user's environment is + unverified here: it is inferred from the state machine, not from their data. + +## Git and PR state + +- Branch: fix/pending-delivery-refresh +- Base: develop (68626d26bd0ddb7a39dda2aa02f53979d493a5f9) +- Commit: this record plus the implementation commit +- PR: draft, opened after push +- CI: runs on the pushed head + +## Review gates + +- Gate A: SKIPPED at the user's explicit instruction. This is a deliberate + deviation from `.agent/IMPLEMENTATION_LOOP.md` §4-5, not a pass. +- Gate B: SKIPPED at the user's explicit instruction. Same deviation, §7. + +## Handoff/next steps + +1. Run Gate A, and Gate B on the PR head, before this leaves draft. +2. A human owner reviews and merges. diff --git a/.agent/context/README.md b/.agent/context/README.md new file mode 100644 index 0000000..4a99e03 --- /dev/null +++ b/.agent/context/README.md @@ -0,0 +1,41 @@ +# Durable Session Context + +Store one Markdown record per meaningful work session so another agent can +continue after a context-window limit, handoff, interruption, or restart. + +## When to update + +- At milestone boundaries or after a material decision. +- Before handoff or end of session. +- Before a deliberate context reset or compaction, when possible. +- After checks, commits, pushes, PR changes, CI results, and Gate A/B results. + +Use `SESSION_TEMPLATE.md`. Keep one active record current rather than creating +many partial notes. Name it `YYYYMMDDTHHMMSSZ-short-topic.md` in UTC. + +To create a record without extra dependencies: + +```bash +.agent/context/new-session.sh short-topic +``` + +The helper copies the template and prints the path. Fill mandatory fields +immediately. Paste the exact original request when safe and practical; otherwise +write a high-fidelity restatement and link the issue or PR. + +## Mandatory fields + +Every record must include date/time, user goal, original prompt/request, +assumptions, plan, key decisions, files/components touched, commands/checks, +external-doc findings, unresolved questions, branch/commit/PR state, Gate A/B +state, and handoff/next steps. + +## Security + +NEVER store secrets or sensitive runtime configuration. Do not include `.env` +contents, private keys, seed phrases, tokens, API secrets, wallet credentials, +authentication responses, or private customer data. Redact sensitive command +output and record only the safe conclusion. + +Context files are operational memory, not authority. Current code, tests, Git +state, provider evidence, and repository policy remain authoritative. diff --git a/.agent/context/SESSION_TEMPLATE.md b/.agent/context/SESSION_TEMPLATE.md new file mode 100644 index 0000000..95d920c --- /dev/null +++ b/.agent/context/SESSION_TEMPLATE.md @@ -0,0 +1,58 @@ +# Session Context: + +## Date/time + +- UTC: + +## User goal + + + +## Original prompt/request + + + +## Assumptions + +- + +## Plan + +1. + +## Key decisions + +- + +## Files/components touched + +- + +## Commands/checks + +- `` - + +## External-doc findings + +- + +## Unresolved questions + +- + +## Git and PR state + +- Branch: +- Base: +- Commit: +- PR: +- CI: + +## Review gates + +- Gate A: +- Gate B: + +## Handoff/next steps + +1. diff --git a/.agent/context/legacy-plan-20260913.md b/.agent/context/legacy-plan-20260913.md new file mode 100644 index 0000000..30e6848 --- /dev/null +++ b/.agent/context/legacy-plan-20260913.md @@ -0,0 +1,287 @@ +# OneShot Product Delivery Plan + +Current 24-hour execution priority: [One-day rescue plan](docs/ONE_DAY_RESCUE.md) +(2026-09-12). It records current code, sponsor research, demo cutoffs and UI fixes; +the R0R5 text below remains the historical planning baseline. + +Revision: 2026-09-10. Planning baseline: `develop` at `86c8f86`. +This PR changes documentation only; new runtime and UX capabilities remain planned. + +This replaces the previous roadmap, not completed code or historical evidence. +[A01–C06 packets](milestones/README.md) remain the original implementation record. +P0–P6 refer to that settlement baseline. R0–R5 below cover the new product +increment and do not replace mandatory FreePi Gate A, CI, and Gate B. + +## 1. Purpose and vision + +**OneShot provides resumable paid tools for business agents.** + +An agent should resume an interrupted purchase, not create another payment. +A company approves an obligation; the original or replacement agent continues +the same job, resolves its financial outcome, and retrieves the existing result. + +Product message: **Resume the job, not the payment.** +Core invariant: **One job. Many retries. One settlement.** + +Initial customer: a developer operating business agents that buy paid API +results. Initial vertical: one company-data report from one integrated supplier. +If necessary, use a clearly labelled team-operated testnet supplier with a real +result; do not claim third-party adoption from that demonstration. + +The guarantee is at-most-once settlement per stable Business Intent. Resumable +delivery requires supplier support for idempotent orders and result retrieval. +OneShot does not guarantee exactly-once execution of arbitrary external tools, +supplier quality, refunds, or commercial dispute resolution. + +## 2. Current state versus planned work + +Existing code includes durable intents/attempts, transactional outbox, +submission ownership, Privy signing, Arc receipt verification, recovery, +operator authentication, and a four-tab console on the marketing page. + +RecoveryService already queries Graph during recovery even when known-identity +evidence exists, and verifies eligible candidates before asking the advisor. +This is not an always-on wallet audit or a multi-step investigation agent. +The Subgraph indexes transfer properties with `memoId` currently null; +amount/recipient/time-window matching does not prove business-order identity. + +New work, not delivered by this planning PR: + +- Stable task-to-purchase identity above the intent API. +- One supplier order/result connector and separately persisted delivery state. +- Separate public landing page and authenticated job-centered cabinet. +- Refreshable wallet reconciliation and job-aware evidence triage. +- Fresh live demonstration of an interrupted paid job returning its result. + +Old P4/P5 evidence is build-specific; it does not qualify the new workflow or +prove current deployment health. See [current gaps](plan_missing_parts.md). + +## 3. Smallest complete workflow + +1. Operator signs in with Privy and selects the permitted execution wallet and + supported tool. Login is not wallet authorization. +2. Operator approves the exact purchase: task, supplier, quote, recipient, + amount, asset/network and applicable expiry. Reuse existing controls; do + not imply pooled budgets or daily limits that are not implemented. +3. Agent supplies a stable task key. OneShot durably binds it to a supplier + order and Business Intent before any chargeable effect. +4. Existing worker pays through Privy on Arc Testnet. Supplier fulfills the + existing order after verified payment. +5. A repeated or replacement agent call returns the same job state/result. + Uncertain payment triggers reconciliation, never replacement payment. + Paid-but-undelivered work resumes only idempotent supplier fulfillment or + retrieval using the original order reference. +6. Cabinet presents the result, receipt and any unresolved exception. + +Conceptual agent operations: start approved job, get job, resume job, get result. +These are proposed capabilities, not existing endpoint names. Extend the +existing API/client additively. No new framework, SDK package or MCP server +is required. + +## 4. Identity, delivery and safety contracts + +- Durable uniqueness is scoped by authorized workspace, supplier/tool and + caller task key. Bind a canonical payload and the existing intent ID. + Changed payload under the same key is a conflict. +- Never infer task identity from amount, recipient, time or fuzzy similarity. + Legitimate repeat purchases require an explicit new task key; agent restart + must preserve the old one. +- Enforce workspace ownership server-side for create, resume, status, results + and evidence. Privy login or possession of a UUID is insufficient. Start with + one allowlisted workspace; do not claim open multi-tenant readiness. +- Freeze the supplier contract first: non-chargeable order creation, immutable + quote, stable order reference, idempotent paid fulfillment, authenticated + retrieval. If unsupported, stop that connector instead of promising safety. +- Payment state remains unchanged. Delivery state is separate: not requested, + pending, available or retrieval failed are proposed concepts. Expired quotes + cannot silently change an approved payment. +- Delivery failure never resets COMMITTED, creates a new intent or authorizes + another payment. Persist supplier reference and result/reference across restart. +- Store minimal results with explicit retention and authorization. Validate + supplier payloads and result URLs; prevent arbitrary URL fetching, secret + exposure in logs/exports and cross-workspace access. +- Privy controls signing. OneShot/PostgreSQL controls submission ownership. + Arc verifies execution. Graph/AI never grant settlement permission. +- Preserve integer atomic money, exact receipt/log checks, testnet-only scope + and no blind retry from UNKNOWN. Resume/result retrieval cannot bypass these. + +## 5. Graph and AI responsibilities + +### Routine reconciliation + +Add a bounded, refreshable wallet-activity view using existing Graph adapters +and provenance validation. Compare indexed transfers with recorded settlements; +surface unmatched transfers, uncertain jobs and index lag. Scope queries to +authorized wallets, implement pagination and disclose coverage before claiming +complete history. Start with manual refresh, not a new scheduled agent service. + +Show RPC-verified payment separately from Graph indexing status. Graph failure +must not erase known payment success or block unrelated purchases. An unmatched +transfer is an investigation item, not fraud proof or permission to pay. + +### Incident recovery and binding + +Keep provider/known-hash lookup first for resolution. Graph discovers candidate +transactions when those sources cannot resolve the obligation; it may also +supply background observations. Do not disable working lookup or discard +durable evidence to make Graph necessary. + +R0 must establish an order-to-transfer binding strategy: verified provider +reference, policy-compatible correlation mechanism, or hold/escalation when +association cannot be proved. Prevent one transfer/log being assigned to two +jobs. A nullable memo field is not an implemented correlation mechanism. + +Identical transfer tuples can represent different orders. Neither one matching +candidate nor model confidence alone proves attribution. Multiple or +insufficiently bound candidates remain unresolved. Any memo/contract route +requires separate Privy scope and compatibility proof, not weaker policies. + +### AI incident triage + +Extend bounded advisor context with permitted job and supplier evidence. +Recommendations cite evidence and explain safe next steps: a verified payment +with missing delivery needs retrieval, not repurchase. Ambiguous chain data +requires explanation/escalation, not a guessed match. + +Keep the four-action financial recommendation contract and +`settlementPermission: NEVER`. Supplier suggestions remain explanatory until +a reviewed versioned contract and deterministic delivery handler exist. +No arbitrary execution tools, wallet secrets or payment retry capabilities +are exposed to the advisor. Treat supplier/index data as untrusted. + +Receipt truth remains deterministic. Show useful triage across job, supplier +and chain facts rather than presenting existing deterministic matching as AI. + +## 6. Frontend: public landing and private cabinet + +Reuse React/Vite and existing components. Separate routes/layouts, not another +frontend stack. The following routes and features are targets, not shipped APIs. + +### Landing page: / + +- Lead with “Resume the job, not the payment” and one concrete paid-tool example. +- Explain permissions, payment, interrupted execution and result retrieval. + Move architecture below the user story. +- Replace mathematical-proof and unrestricted exactly-once-execution claims + with the scoped at-most-once payment guarantee. +- Primary CTA: Open workspace. Secondary: How it works / developer docs. + Returning users proceed directly to the cabinet after authentication. +- Keep testnet/integration labels honest. No private jobs, operational health + details, machine-token input or embedded console on the public page. +- Label sample/demo previews; never present fixtures as live customer activity. + +### Cabinet: /app + +The cabinet is the working area, with shared navigation and a selected job, +not another marketing page. + +| Section | User purpose | Minimum tools | +| --- | --- | --- | +| Overview | Find work needing attention | Active jobs, available results, uncertain payments; totals with explicit scope | +| Tools | Start supported paid work | One supplier tool, inputs, quote, purchase approval summary; no fictional catalog | +| Jobs | Resume and retrieve | Filterable jobs, payment/delivery badges, safe resume, saved results and receipts | +| Recovery & activity | Investigate exceptions | Graph freshness/coverage, unmatched activity, cited advice and core disposition | +| Wallet & permissions | Understand spending authority | Execution wallet, supplier/recipient scope, cap and policy status; edits only with enforced APIs | +| Developer access | Connect agents | Existing client examples for stable task identity and resume/result; no fake key issuance | + +Proposed detail route: `/app/jobs/:jobId`. Carry job context across payment, +delivery and evidence tabs; do not require repeated intent-ID copy/paste. +Developer access may be a small settings section, not a new service. + +### UX acceptance + +- Start with tool/task inputs, not raw recipient/hash fields. Show amount, + recipient and authorization before any chargeable action. +- Keep supplier, cost, result, human-readable status and next safe action + prominent. IDs, hashes and raw evidence live in expandable advanced details. +- Resume reuses the job; Check payment is read-only reconciliation; Get result + cannot pay. Explain disabled actions. No force-pay or disguised repurchase. +- Separate payment and delivery badges, e.g. Paid / Result pending, or Payment + uncertain — investigating. Evidence absence never changes authoritative state. +- Compact loading, empty, stale, offline, denied and expired-session states. + Preserve useful data during refresh; no giant empty evidence panels. + Display last updated time and manual refresh. +- Keyboard navigation, visible focus, labelled fields, semantic headings, + readable contrast, screen-reader announcements and reduced motion. + Do not encode status only by color. +- Mobile navigation without horizontal page overflow. Preserve form input on + recoverable errors. Test reload/deep links and post-login return paths. +- Never put credentials in URLs, analytics, browser persistence or exports. + Raw developer machine tokens remain memory-only and outside normal UX. + +## 7. Increment gates + +All R gates start **NOT STARTED**. One focused implementation branch/PR per +gate or small acceptance slice. Reuse established package ownership. + +| Gate | Scope and dependency | Required exit evidence | +| --- | --- | --- | +| R0: feasibility and contracts | First: supplier semantics, task identity, ownership, delivery states, chain binding and routes | Additive contracts/fixtures; supplier proof; actual Arc Privy signing/fallback controls; correlation limitations documented | +| R1: resumable job | After R0: durable job/order/result and one connector | Two agents, ten concurrent calls and restart share one intent/payment; conflicts denied; paid delivery failure resumes only delivery; isolated result access | +| R2: landing and cabinet | After R0; mock work may parallel R1, integration follows R1 | Separate public/private routes; six scoped sections; job navigation; keyboard/mobile/deep-link/auth tests; no misleading controls | +| R3: evidence and triage | After R1; cabinet integration after R2 | Live bounded activity query, coverage/freshness, job-aware citations; Graph lag cannot undo payment; ambiguous binding holds | +| R4: live failure demo | After R1–R3 | Real testnet purchase, labelled response-loss fault, live Studio evidence, verified original settlement or explicit hold, no replacement payment, supplier result | +| R5: release | After R4 | Exact-head checks, FreePi A/B, public docs/diagram, video, verified prize pool, sanitized evidence and human review | + +R0 is not authorization to deploy contracts or change external wallet policy. +External configuration, live effects and mainnet activation require appropriate +human authorization. Historical packet gates do not close these new gates. + +## 8. Tests and demonstration + +Use [.agent/TEST_MATRIX.md](.agent/TEST_MATRIX.md): duplicate/conflicting input, +sequential/concurrent retries, two agents, restart, pre/post-submission faults, +provider denial, Graph lag/absence/ambiguity, invalid advice and downstream +failure after payment. + +Add job assertions: one stable supplier order/intent, at most one payment, +independently counted supplier executions, same retrievable result, workspace +isolation, no transfer reused across jobs, and no payment on paid-job resume. + +[Demo script](docs/DEMO_SCRIPT.md) separates existing offline rehearsal from +the planned live walkthrough. Never seed an old transfer into a new job and +call it live recovery. Inject faults at response boundaries without deleting +durable records or rewriting chain history. Preserve working provider lookup; +label any simulated provider unavailability separately. + +Capture deployment/query identity, _meta freshness, candidates, cited advice, +core disposition, receipt/log, payment count, supplier order and result outcome. +Measure real timings; do not invent savings or latency. + +## 9. Prize priorities + +1. **Privy — Best B2B financial product:** primary positioning; a business-agent + purchase constrained by actual wallet permissions. +2. **Arc — Best DeFi/Onchain Finance Application:** secondary for the eligible + pool; real USDC purchase, conditional authorization and recovery. +3. **The Graph — Best AI Tooling or AI Use Case:** meaningful triage/automation + over live Studio data, not just a Graph panel. +4. **Privy — Best financial flow:** additional fit from the same polished + purchase; no separate feature roadmap. + +Verify project history and registration before selecting Start Fresh or +Continuity. Graph has separate AI pools; Arc lists a separate Continuity +category. Do not assume eligibility or multiple awards. + +The Arc $3,500 DeFi award includes $2,500 conditional on mainnet deployment by +September 30, not an extra bonus. Readiness documents are not deployment proof. +Mainnet remains separately authorized. + +Requirements checked 2026-09-10: +[Privy](https://ethglobal.com/events/ethonline2026/prizes/privy), +[Arc](https://ethglobal.com/events/ethonline2026/prizes/arc), +[The Graph](https://ethglobal.com/events/ethonline2026/prizes/the-graph). +Studio live queries are accepted; MCP is optional. Qualification for the new +workflow is **NOT VERIFIED** until live evidence and submission artifacts exist. +Follow [.agent/SPONSOR_REQUIREMENTS.md](.agent/SPONSOR_REQUIREMENTS.md). + +## 10. Scope cuts and next action + +Keep one supplier, one testnet network/asset and the existing Privy/API/worker/UI +stack. Defer pooled budgets, daily limits, payroll, treasury dashboards, +marketplaces, extra agent frameworks, Circle Agent Stack, multichain and generic +workflow automation until the first resumable paid job serves a real user. + +Never cut identity, authorization, receipt verification, supplier feasibility, +failure tests, accessible interaction or honest evidence. Next implementation: +R0 contracts and feasibility, not another cosmetic transaction-console redesign. diff --git a/.agent/context/new-session.sh b/.agent/context/new-session.sh new file mode 100755 index 0000000..7880f4f --- /dev/null +++ b/.agent/context/new-session.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +topic="${1:-session}" +if [[ ! "$topic" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + printf 'Topic must use lowercase letters, digits, and hyphens.\n' >&2 + exit 2 +fi + +context_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +target="$context_dir/$timestamp-$topic.md" + +if [[ -e "$target" ]]; then + printf 'Context file already exists: %s\n' "$target" >&2 + exit 1 +fi + +cp "$context_dir/SESSION_TEMPLATE.md" "$target" +printf 'Created %s\n' "$target" diff --git a/.agent/research/20260906-integration-decisions.md b/.agent/research/20260906-integration-decisions.md new file mode 100755 index 0000000..a4ee3c1 --- /dev/null +++ b/.agent/research/20260906-integration-decisions.md @@ -0,0 +1,57 @@ +# OneShot Integration Research + +Date: 2026-09-06 +Scope: primary-source facts needed to make the first product implementation plan decision-complete. +Target: Privy-authorized exactly-once settlement on Arc Testnet with The Graph +as the hashless recovery index. + +## Decisions + +### Privy: authorization must constrain the settlement path + +- Use a Privy execution wallet owned by an application authorization key or key quorum. Attach one explicit, fail-closed wallet policy when the wallet is created. Privy owners authorize wallet actions, while wallet policies constrain the actions that an otherwise valid signer may take ([wallet policies and controls](https://docs.privy.io/security/wallet-infrastructure/policy-and-controls), [execution wallets](https://docs.privy.io/recipes/wallets/execution-wallets)). +- Permit only the Arc Testnet ERC-20 USDC `transfer(address,uint256)` path: chain `5042002`, contract `0x3600000000000000000000000000000000000000`, approved recipient, amount at or below the configured cap, and zero native transaction value. Keep key export and all unrelated methods denied. Privy documents default-deny policy behavior and Ethereum transaction conditions ([policy overview](https://docs.privy.io/controls/policies/overview), [Ethereum policy examples](https://docs.privy.io/controls/policies/example-policies/ethereum)). +- Persist the exact Privy request identity and body before submission. Reuse the same `privy-idempotency-key` for the same Business Intent. Privy deduplicates a matching request for only 24 hours, so this is a supplemental guard and never replaces OneShot's durable state and uniqueness constraints ([idempotency keys](https://docs.privy.io/api-reference/idempotency-keys)). +- Attach a stable Privy transaction `reference_id` derived from `business_intent_id` for lookup and reconciliation, not as the authoritative duplicate lock ([transaction reference IDs](https://docs.privy.io/transaction-management/transactions/reference-id)). +- Polling transaction status is the baseline. Webhooks are an optional optimization because availability may depend on the Privy plan; if enabled, verify signatures and process deliveries idempotently ([webhook overview](https://docs.privy.io/api-reference/webhooks/overview)). + +### Arc: use the six-decimal ERC-20 interface for settlement + +- Arc Testnet uses chain ID `5042002`, CAIP-2 `eip155:5042002`, RPC `https://rpc.testnet.arc.network`, WebSocket `wss://rpc.testnet.arc.network`, and explorer `https://testnet.arcscan.app` ([RPC endpoints](https://docs.arc.io/arc/references/rpc-endpoints)). +- Arc's USDC ERC-20 interface is `0x3600000000000000000000000000000000000000`. Application settlement amounts use its six-decimal precision. Arc also exposes the same underlying USDC as an 18-decimal native gas balance, so payment amounts and gas accounting must remain separate and the UI must not double-count the two views ([infrastructure integration](https://docs.arc.io/integrate/infrastructure), [stablecoin-native model](https://docs.arc.io/arc/concepts/stablecoin-native-model)). +- Arc transactions are pending until included, then immediately and deterministically final; there is no accumulating-confirmation state. A receipt with `status: 1` is final success only after validating the expected USDC `Transfer` log. A receipt with `status: 0` is final execution failure and zero settlement ([transaction lifecycle](https://docs.arc.io/integrate/wallets/transaction-lifecycle), [deterministic finality](https://docs.arc.io/arc/concepts/deterministic-finality)). +- Deterministic finality does not eliminate submission ambiguity. A lost Privy/RPC response or process crash after a possible broadcast still becomes `UNKNOWN`; a new-nonce payment is forbidden until reconciliation proves a safe terminal result. + +### The Graph: selected for hashless discovery, never settlement authority + +- Direct Privy lookup plus exact Arc receipt/log verification resolves known transaction identities. PostgreSQL remains authoritative for ownership, intent state, and the `UNKNOWN` hold. +- The Graph is the selected v1 path for discovering candidate transfers when a successful submission lost its hash. C01 must prove this live and show a capability that disappears when Graph is removed. +- Every query carries deployment and freshness/error evidence. Missing, empty, lagging, unhealthy, multiple, or contradictory candidates preserve `UNKNOWN`; Arc verifies every candidate before any commit. +- Arc RPC can scan logs without a hash, so The Graph is a product choice for structured automatic discovery rather than the only technically possible scanner ([Arc event indexing](https://docs.arc.io/integrate/infrastructure/indexing-events), [Graph querying](https://thegraph.com/docs/en/subgraphs/querying/introduction/)). +- Arc's Memo contract can attach a caller-supplied `memoId` and `callDataHash` to a forwarded USDC call specifically for correlation and reconciliation. C01/B01 must test `memoId = hash(business_intent_id)` as the preferred unique lookup key ([Arc Memo indexing](https://docs.arc.io/integrate/infrastructure/indexing-events)). +- Privy can enforce chain, destination contract, decoded function, and decoded top-level calldata parameters. Before choosing Memo for settlement, B01 must prove the policy can constrain the forwarded USDC target and required business fields; otherwise use the tuple-search fallback or a narrow typed settlement contract without weakening authorization ([Privy policy fields](https://docs.privy.io/controls/policies/overview)). +- Target the Graph AI Tooling or AI Use Case track: live Graph data must drive meaningful recovery-agent selection, explanation, or automation. The composable/standardized track requires two Graph products or meaningful use of a standardized schema; one custom Subgraph query is insufficient ([ETHOnline 2026 prize requirements](https://ethglobal.com/events/ethonline2026/prizes)). + +### Durable state and work delivery + +- PostgreSQL is the authoritative store. Use primary/unique constraints on `business_intent_id` and one settlement row per intent; use `INSERT ... ON CONFLICT` plus an immutable payload fingerprint to distinguish a replay from a same-ID conflict ([constraints](https://www.postgresql.org/docs/current/ddl-constraints.html), [`INSERT`](https://www.postgresql.org/docs/current/sql-insert.html)). +- Grant submission ownership with a row lock or conditional state transition. Do not keep a database transaction open during Privy or RPC calls. Persist `SUBMITTING`, the request fingerprint, and provider identifiers before crossing the external-effect boundary ([explicit locking](https://www.postgresql.org/docs/current/explicit-locking.html)). +- Use Graphile Worker over the same PostgreSQL database to avoid a second queue datastore. It supports transactional enqueueing and explicitly provides at-least-once delivery ([Graphile Worker](https://worker.graphile.org/docs), [transactional enqueueing](https://worker.graphile.org/docs/sql-add-job)). +- Configure the external-effect `submit_settlement` job for one queue attempt. The task itself classifies the outcome and returns after durably recording `COMMITTED`, `FAILED_SAFE`, or `UNKNOWN`; the queue must never blindly repeat a possibly submitted payment. Read-only reconciliation jobs may retry. `jobKey` is scheduling hygiene, not the settlement lock ([job options](https://worker.graphile.org/docs/library/add-job), [job-key caveats](https://worker.graphile.org/docs/job-key)). + +## Verification gates left for implementation + +1. Pin exact SDK and runtime versions only after a compatibility spike validates Privy request signing, Arc chain support, and policy condition syntax. +2. Assert `eth_chainId == 5042002` and bytecode exists at the configured USDC address during testnet startup checks. +3. Prove the chosen Privy policy denies wrong chain, wrong contract, wrong recipient, wrong method, non-zero native value, and above-cap amount with zero settlement. +4. Prove live The Graph hashless discovery, freshness, multiple-candidate handling, safe degradation, and AI-track value; otherwise remove the Graph claim and use direct recovery. +5. Keep Privy webhooks outside the critical path until plan availability and signature verification are demonstrated. + +## Planning consequence + +The work can be split into three independent backend tracks after one contract +freeze: (A) domain/storage/API, (B) the Privy/Arc adapter, and (C) The Graph +reconciliation/evidence. Each track must ship its own contract simulator and +tests so progress does not depend on another track's implementation. Frontend +begins only after the integrated backend contract and recovery semantics are +stable. diff --git a/.agent/research/20260907-b01-arc-privy-verification.md b/.agent/research/20260907-b01-arc-privy-verification.md new file mode 100644 index 0000000..309a563 --- /dev/null +++ b/.agent/research/20260907-b01-arc-privy-verification.md @@ -0,0 +1,115 @@ +# B01 primary-source verification: Arc network values and Privy policy reach + +Date: 2026-09-07 +Packet: `milestones/coder-b/B01-sdk-network-compatibility.md` +Purpose: satisfy B01.2 (pin Arc values only after checking official documentation) +and decide B01.3 (whether Privy policy can constrain a nested Arc Memo call). + +## Sources + +- Arc, "Connect to Arc", — accessed 2026-09-07. +- Arc, "Contract addresses", — accessed 2026-09-07. +- Privy, "Policies & controls overview", — accessed 2026-09-07. + +## 1. Arc Testnet values (B01.2) + +| Value | Official source | Matches `milestones/CONTRACTS.md`? | +| --- | --- | --- | +| Chain ID `5042002` | Connect to Arc | Yes | +| CAIP-2 `eip155:5042002` | Derived from chain ID | Yes | +| USDC ERC-20 interface `0x3600000000000000000000000000000000000000` | Contract addresses | Yes | +| ERC-20 interface decimals `6` | Contract addresses | Yes | +| Block explorer `https://testnet.arcscan.app` | Connect to Arc | Not previously recorded | +| Primary RPC `https://rpc.testnet.arc.io` | Connect to Arc | Not previously recorded | + +The frozen contract values are confirmed correct. Nothing had to change. + +### Finding 1: native gas precision differs from settlement precision + +Arc's native gas asset is also called USDC, but it uses **18 decimals**, while +the **USDC ERC-20 interface uses 6**. Same name, same chain, a factor of 10^12 +apart. + +This is the exact hazard B01.2 names when it requires settlement amounts to be +separated from native USDC gas accounting. A single `decimals` field on a +deployment profile would invite code to price a settlement in gas units and +overpay or underpay by twelve orders of magnitude. + +Encoded as two distinct fields, `tokenDecimals` (6, settlement) and +`nativeDecimals` (18, gas). The readiness probe reports `MISMATCH` if a profile +ever declares them equal or declares native decimals as anything but 18. + +### Finding 2: RPC endpoints are operator configuration, not constants + +Arc publishes four testnet RPC endpoints (a primary plus Blockdaemon, dRPC, and +QuickNode). There is no single canonical endpoint to pin, which confirms the +decision to keep RPC and explorer URLs out of the profile table and in +validated configuration. + +A first draft of `profiles.ts` had guessed `https://rpc.testnet.arc.network`. +The real host is `arc.io`, not `arc.network`, so the guess was wrong as well as +against policy. A test now asserts that no profile contains any `http(s)://` +string. + +## 2. Privy policy reach and the Arc Memo path (B01.3) + +### Arc Memo contract + +Address `0x5294E9927c3306DcBaDb03fe70b92e01cCede505`. It attaches memo metadata +to contract calls and emits `Memo` events carrying a sequential index. + +Using it for settlement means the wallet calls the Memo contract, which forwards +the USDC transfer. The recipient and amount then live inside the forwarded inner +call rather than in the transaction the wallet signs directly. + +### What a Privy policy can constrain + +Privy policies are built from rules and conditions over these field sources: + +- `ethereum_transaction` — `to`, `value`, `chain_id`. +- `ethereum_calldata` — the called function by name, and its decoded arguments + as `function_name.param_name`, supplied with the contract's JSON ABI. +- `ethereum_typed_data_domain` / `ethereum_typed_data_message` — EIP-712 data. + +This is sufficient to fully constrain a **direct** ERC-20 transfer: the policy +can pin the destination contract, the chain, a zero native value, the method, +and the decoded recipient and amount arguments. + +### Verdict: `NOT SUPPORTED` for the nested Memo path + +`ethereum_calldata` decodes the arguments of the function the wallet calls. For +a Memo-forwarded settlement, that is the Memo function; the USDC recipient and +amount sit inside an inner call that Privy's documented conditions do not +decode. Privy's documentation does not describe constraining a nested or +forwarded inner call. + +B01.3 permits recording `SUPPORTED` only with deny fixtures proving every wrong +dimension is rejected. The recipient and amount dimensions cannot be denied +through documented policy conditions on the nested path, so the honest result is +`NOT SUPPORTED`. + +**Consequence.** v1 settlement uses the **direct USDC ERC-20 transfer**, which +is fully policy-constrainable. The Memo path is not used for settlement. This +matches `plan.md` section 27, which already lists "Arc Memo correlation if Privy +cannot constrain the forwarded call" as the cuttable option, and +`milestones/CONTRACTS.md` section 2, which admits `memo_id` only when the Memo +path passes B01 policy validation. It has not passed, so `memo_id` stays unused. + +Correlation for hashless recovery therefore relies on the tuple/window discovery +and Subgraph MCP path owned by Coder C, not on a memo identifier. + +### Not ruled out, but out of B01 scope + +A narrow purpose-built settlement contract with recipient and amount as +top-level arguments would be policy-constrainable and could carry a memo. That +is a new contract to write, audit, and deploy. It is recorded here as a +possibility, not adopted. + +## 3. Residual verification gaps + +- The Memo contract ABI is not published on the pages read. Not needed, since + the Memo path is not adopted for settlement. +- Privy wallet and policy identifier formats are not documented on the page + read. `packages/arc-adapter` validates a conservative shape only; B02 should + replace it with the documented format. +- Arc Mainnet parameters remain unpublished. The mainnet profile stays empty. diff --git a/.agent/research/20260907-subgraph-mcp-clarification.md b/.agent/research/20260907-subgraph-mcp-clarification.md new file mode 100644 index 0000000..427c957 --- /dev/null +++ b/.agent/research/20260907-subgraph-mcp-clarification.md @@ -0,0 +1,57 @@ +# The Graph Subgraph MCP Clarification + +Date: 2026-09-07 +Scope: primary-source clarification for OneShot's The Graph AI-track architecture and qualification evidence. + +## Primary-source findings + +- The Graph's Subgraph MCP is an open-source Model Context Protocol server that + exposes Subgraph data to MCP-compatible clients. Its tools can inspect a + schema, discover Subgraphs, and execute queries against a specific deployment + ([Subgraph MCP introduction](https://thegraph.com/docs/en/subgraphs/tooling/subgraph-mcp/introduction/)). +- The MCP server is not an LLM. It translates MCP tool requests into Subgraph + queries and returns structured results for a client model to reason over + ([Subgraph MCP introduction](https://thegraph.com/docs/en/subgraphs/tooling/subgraph-mcp/introduction/)). +- The Graph's AI overview describes Subgraph MCP as the bridge through which + models explore schemas, execute GraphQL queries, find Subgraphs, and retrieve + live blockchain data ([AI overview](https://thegraph.com/docs/en/ai-overview/)). +- Current hackathon guidance lists AI applications that use The Graph as a live + data source, including agents querying Subgraphs through Subgraph MCP. The + catalog size does not impose a multiple-Subgraph minimum for this AI track; + composable/standardized products are a separate prize path + ([hackathon resources](https://thegraph.com/blog/hackathon-resources/)). + +## OneShot decision + +The production/demo recovery path is: + +```text +live OneShot/Arc Subgraph + ↓ +Subgraph MCP + ↓ +LLM Recovery Agent + ↓ +WAIT / RECONCILE / ESCALATE / RETURN_EXISTING_RESULT + ↓ +deterministic OneShot safety core +``` + +The LLM recommendation is meaningful but advisory. The deterministic core +rechecks the durable state version and authoritative Arc/OneShot evidence. +Neither The Graph, Subgraph MCP, nor the LLM can sign, submit, retry, create an +Attempt, acquire submission ownership, or call `SettlementPort`. + +## Qualification consequence + +The Graph remains `NOT VERIFIED` until a sanitized demo trace proves all of: + +1. the intended live OneShot/Arc deployment was queried through Subgraph MCP; +2. the returned live indexed evidence and `_meta` health reached the LLM; +3. the LLM selected one of the four frozen recommendations using referenced evidence; +4. the deterministic core independently accepted, constrained, or rejected it; +5. empty, delayed, malformed, injected, unavailable, or contradictory tool data + and invalid model output never create settlement permission. + +One live Subgraph is sufficient for this AI-track claim. OneShot must not claim +the separate composable/multiple-products track without separate evidence. diff --git a/.agent/review-prompts/freepi-pr-review.md b/.agent/review-prompts/freepi-pr-review.md new file mode 100644 index 0000000..eaba6bd --- /dev/null +++ b/.agent/review-prompts/freepi-pr-review.md @@ -0,0 +1,80 @@ +# FreePi Gate B: Exact Draft-PR Review + +You are a second fresh, independent senior reviewer. Review only. Do not edit +files, commit, push, change PR state, comment, approve, merge, deploy, or mutate +external state. Do not fix findings and do not reuse Gate A context. + +## Safety boundary + +Inspect only repository-tracked files, the public PR/diff, tests, public +documentation, and non-sensitive CI/Gate A evidence. Never read or request +ignored files, `.env*`, private keys, seed phrases, tokens, API secrets, wallet +credentials, or sensitive runtime configuration. Fail closed if safe inspection +is impossible. + +## Target + +- Read root `AGENTS.md`, then only the policies and skills it routes. +- Verify PR number/URL, base `develop`, head branch, exact full head SHA, and head + tree SHA. +- Inspect the full PR diff, commits, description, required checks, Gate A + verdict, and resolved findings. +- Confirm the current head tree equals Gate A's candidate tree. + +## Review + +Independently evaluate milestone scope and acceptance coverage, all Gate A issue +classes, documentation/config/migrations, required CI, previous findings, +security/privacy, and readiness for human review. For settlement-related work, +enforce `.agent/SECURITY_INVARIANTS.md` and `.agent/TEST_MATRIX.md`. + +Return PASS only for the exact current head with green required CI, valid Gate A +evidence, equal tree identities, no blocking finding, and sufficient evidence. +Anything missing, ambiguous, stale, truncated, or failed is FAIL. + +Inspect with tools without narrating progress. Return one final structured +verdict; do not repeat file bodies, task text, or unchanged policy. Keep the +answer concise unless blocking findings require detail. + +Use exactly this structure: + +```text +VERDICT: PASS | FAIL +REVIEWER_TOOL: free-pi-cli +REVIEWER_MODEL: +PR: +REVIEWED_BASE: develop () +REVIEWED_HEAD: +REVIEWED_HEAD_TREE: +GATE_A_TREE: + +BLOCKING_FINDINGS: +- - +- None + +NON_BLOCKING_FINDINGS: +- - +- None + +CI_AND_REVIEW_EVIDENCE: +- + +MILESTONE_READINESS: +- Scope and acceptance criteria - PASS | FAIL +- Tests and required checks - PASS | FAIL +- Security, privacy, and secrets - PASS | FAIL +- Documentation and operations - PASS | FAIL +- Ready for human review - PASS | FAIL + +SECURITY_AND_INVARIANTS: +- One intent / at most one settlement - PASS | FAIL | NOT APPLICABLE +- UNKNOWN reconciles without blind retry - PASS | FAIL | NOT APPLICABLE +- Secrets and FreePi privacy boundary - PASS | FAIL + +RESIDUAL_RISKS: +- +- None + +SUMMARY: + +``` diff --git a/.agent/review-prompts/freepi-prepush-review.md b/.agent/review-prompts/freepi-prepush-review.md new file mode 100644 index 0000000..02dc1a1 --- /dev/null +++ b/.agent/review-prompts/freepi-prepush-review.md @@ -0,0 +1,76 @@ +# FreePi Gate A: Pre-Push Candidate Review + +You are a fresh, independent senior reviewer. Review only. Do not edit files, +stage, commit, push, create a PR, comment, approve, merge, deploy, or mutate +external state. Do not fix findings. + +## Safety boundary + +Inspect only repository-tracked files, the stated candidate tree/diff, tests, +public documentation, and non-sensitive check evidence. Never read or request +ignored files, `.env*`, private keys, seed phrases, tokens, API secrets, wallet +credentials, or sensitive runtime configuration. Fail closed if safe inspection +is impossible. + +## Target + +- Read root `AGENTS.md`, then only the policies, skills, and context it routes + for this change. +- Verify the recorded `develop` base is an exact full SHA. +- Verify the candidate tree SHA using the staged index or exact unpushed HEAD, + as stated by the caller. +- Inspect the complete candidate diff against the recorded base. +- Check status to confirm no intended file is omitted and no unrelated file is + included. + +## Review + +Evaluate acceptance coverage, correctness, regressions, state transitions, +error handling, security, privacy, secrets, concurrency, retry/idempotency, +external effects, money representation, architecture, dependencies, +documentation, and test adequacy. For settlement-related work, enforce +`.agent/SECURITY_INVARIANTS.md` and `.agent/TEST_MATRIX.md`. + +Return PASS only with no blocking finding and sufficient evidence. Incomplete, +ambiguous, stale, or failed inspection is FAIL. + +Inspect with tools without narrating progress. Return one final structured +verdict; do not repeat file bodies, task text, or unchanged policy. Keep the +answer concise unless blocking findings require detail. + +Use exactly this structure: + +```text +VERDICT: PASS | FAIL +REVIEWER_TOOL: free-pi-cli +REVIEWER_MODEL: +REVIEWED_BASE: develop () +REVIEWED_TARGET: +REVIEWED_TREE: + +BLOCKING_FINDINGS: +- - +- None + +NON_BLOCKING_FINDINGS: +- - +- None + +VALIDATION_EVIDENCE: +- + +ACCEPTANCE_CRITERIA: +- - PASS | FAIL | NOT VERIFIED + +SECURITY_AND_INVARIANTS: +- One intent / at most one settlement - PASS | FAIL | NOT APPLICABLE +- UNKNOWN reconciles without blind retry - PASS | FAIL | NOT APPLICABLE +- Secrets and FreePi privacy boundary - PASS | FAIL + +RESIDUAL_RISKS: +- +- None + +SUMMARY: + +``` diff --git a/.agents/rules/repository-policy.md b/.agents/rules/repository-policy.md new file mode 100644 index 0000000..80f402c --- /dev/null +++ b/.agents/rules/repository-policy.md @@ -0,0 +1,5 @@ +# OneShot repository policy adapter + +Load and follow `@../../AGENTS.md` before changing this repository. + +The root policy routes the canonical, task-specific documents under `.agent/`. This file is only the Antigravity-compatible entry point and does not override or duplicate repository policy. diff --git a/.agents/skills/oneshot-arc-payment/SKILL.md b/.agents/skills/oneshot-arc-payment/SKILL.md new file mode 100644 index 0000000..435356c --- /dev/null +++ b/.agents/skills/oneshot-arc-payment/SKILL.md @@ -0,0 +1,93 @@ +--- +name: oneshot-arc-payment +description: > + Pay a USDC recipient on Arc Testnet through the OneShot `arc_payment` MCP + tool: connect an MCP client with the user's profile bearer token, create or + replay one durable payment intent, read the authoritative settlement state, + and verify the ArcScan proof. Use when the user asks to pay via OneShot, send + USDC on Arc, run the arc_payment MCP tool, or delegate an agent payment task. +--- + +# OneShot arc_payment MCP skill + +Pay once, safely, through OneShot. This skill is written for any agent +(primary or delegated) whose MCP client is already connected to the OneShot +MCP endpoint. It never handles keys: the payer is the user's connected Privy +embedded/external wallet or MetaMask wallet, and the bearer token lives only in +the MCP client config. + +## Prerequisites (user-provided, never invented) + +- MCP endpoint URL, e.g. `https://oneshot.kapustazh.dev/mcp` (Streamable HTTP). +- A bearer generated from the user's OneShot Profile — configured in the MCP client as + `authorization: Bearer `. It is a secret: never print, log, copy into + task prompts, or commit it. + +If either is missing, stop and ask the operator. Do not guess values. + +## Tool contract: `arc_payment` then `arc_payment_submit` + +Input (all fields required, strict): + +- `request_key` — generate this yourself before the first call as + `report--<8 random hex>`. Never ask the user for it. Retain and + reuse the exact value for every retry of that payment. +- `payer_wallet` — the connected EVM wallet address selected by the user in + Privy or MetaMask. Never invent it or substitute a server wallet. +- `recipient` — `0x`-prefixed 40-hex EVM address on Arc Testnet. +- `amount_usdc` — canonical decimal string, up to 6 decimals, greater than + zero (for example `1` or `0.25`; `1` USDC = `1000000` atomic units). +- `purpose` — short non-secret payment purpose (max 256 chars). + +Output: `state` (`READY | SUBMITTING | COMMITTED | FAILED_SAFE | UNKNOWN | +REJECTED`), `replayed`, `payer.mode` (`USER_WALLET`), `amount_atomic`, an +exact `transaction` object for the Arc USDC transfer, and a `signing_url`. Give +the user the signing URL: it opens the authenticated OneShot wallet handoff, +which loads the prepared payment and asks the user to review and sign it. The +tool never broadcasts a transaction. + +After the wallet returns a transaction hash, call `arc_payment_submit` with the +returned `business_intent_id` and that exact hash. OneShot binds the hash, +checks the receipt and exact USDC `Transfer` log, and returns the durable state. + +## How to execute a payment + +1. Generate the `request_key`, then call `arc_payment` once with that key, the + exact payer wallet, recipient, amount, and purpose the user approved. +2. Give the user the returned `signing_url`. The user must be signed into the + matching OneShot workspace, review the recipient and amount, and click the + wallet confirmation. Do not create a replacement transaction. +3. The wallet handoff records the hash through the normal user-wallet API. If + the agent receives the hash separately, call `arc_payment_submit` with the + returned `business_intent_id` and that exact hash. +4. If `state` is `COMMITTED`, report `settlement.transaction_hash` and its + `explorer_url` (ArcScan). Done. +5. If `state` is `UNKNOWN`, repeat `arc_payment_submit` with the same hash. + UNKNOWN is not failure: it never justifies a replacement payment or a new + key. +6. If the tool returns the conflict error ("already belongs to a different + payment"), the key was reused with changed fields. Stop and report the + conflict; do not replace an uncertain payment. +7. If `state` is `FAILED_SAFE` or `REJECTED`, report it and stop. Do not retry + with a different key or amount. + +## Delegating (outsourcing) the payment to another agent + +- Hand the delegate only the task arguments: endpoint URL, `payer_wallet`, + `recipient`, `amount_usdc`, `purpose`, and this skill. The delegate generates + and retains the request key. +- The delegate must use its own MCP client configuration; the bearer token + must not travel through prompts, task payloads, logs, or screenshots. +- One request key funds exactly one intent. Each delegate generates one key per + new approved payment and reuses it for retries; never mutate a key after the + first call. +- The delegate reports back the authoritative `state` plus the ArcScan proof + for `COMMITTED`, or the exact tool error. "It probably went through" is not + a report. + +## References + +- Walkthrough: `docs/MCP_ARC_PAYMENT.md` in the OneShot repository. +- Human-readable page: `https://oneshot.kapustazh.dev/docs/mcp`. +- Install (requires Node.js/npm; `npx` ships with npm): + `npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --skill oneshot-arc-payment`. diff --git a/.agents/skills/oneshot-failure-injection/SKILL.md b/.agents/skills/oneshot-failure-injection/SKILL.md new file mode 100644 index 0000000..a37c98d --- /dev/null +++ b/.agents/skills/oneshot-failure-injection/SKILL.md @@ -0,0 +1,28 @@ +--- +name: oneshot-failure-injection +description: Design or test OneShot failure boundaries around external effects, including timeouts, lost responses, process kills, duplicate delivery, retries, and parallel execution before, during, or after payment submission. +--- + +# OneShot Failure Injection + +Read `.agent/SECURITY_INVARIANTS.md` and `.agent/TEST_MATRIX.md`. Map each +external effect into three boundaries: definitely before submission, possibly +submitted, and definitely confirmed. + +## Mandatory checks + +- Inject failure before submission and prove zero settlement plus safe retry. +- Inject timeout/process kill/lost response during or after submission and prove + durable `UNKNOWN`, reconciliation, and no blind retry. +- Deliver the same request repeatedly and from 10 parallel workers; prove at + most one committed settlement. +- Restart services between durable transitions and external responses. +- Delay, empty, corrupt, or multiply-match The Graph candidate results and prove `UNKNOWN` plus no duplicate settlement. +- Deny Privy policy and exceed spending amount; prove zero settlement. +- Fail a supplier/API action after payment; prove settlement result remains. +- Assert external settlement count, durable intent/attempt/settlement state, and + stable identifiers. Do not rely only on returned HTTP status. + +Use testnet or isolated fakes. Never inject failures against unauthorized live +funds or expose secrets in fixtures/logs. Record exact injection points and +results in session context and PR evidence. diff --git a/.agents/skills/oneshot-idempotency/SKILL.md b/.agents/skills/oneshot-idempotency/SKILL.md new file mode 100644 index 0000000..d696032 --- /dev/null +++ b/.agents/skills/oneshot-idempotency/SKILL.md @@ -0,0 +1,31 @@ +--- +name: oneshot-idempotency +description: Enforce OneShot's at-most-once settlement invariant when work touches business intents, payments, retries, settlements, reconciliation, workers, queues, jobs, invoices, or duplicate delivery. +--- + +# OneShot Idempotency + +Read `.agent/PROJECT_CONTEXT.md`, `.agent/SECURITY_INVARIANTS.md`, and +`.agent/TEST_MATRIX.md` before editing. + +## Mandatory checks + +- Preserve `1 intent / N attempts / <=1 committed settlement`. +- Create and persist one stable `business_intent_id` before external effects; + reuse it across retries, restarts, workers, and agents. +- Keep authoritative intent and settlement state in OneShot durable storage. +- Grant submission rights through an atomic, concurrency-safe transition or + equivalent uniqueness guarantee. +- Use a stable provider idempotency/submission key tied to the Business Intent + where the provider supports it. This supplements, not replaces, OneShot state. +- Treat any possibly submitted but unconfirmed payment as `UNKNOWN`. Reconcile + from durable/provider/Arc evidence before retrying. +- Never use external-index absence or delay as permission to pay. +- Keep money in integer atomic units or `bigint`; validate asset, network, + recipient, amount, and Privy policy before submission. +- Preserve payment results when later supplier/API work fails. + +Select applicable matrix cases, including duplicate input, 10 sequential +retries, 10 parallel workers, process restart, two agents, and ambiguous +submission. Report any invariant that cannot be proven; do not claim safety from +happy-path tests alone. diff --git a/.agents/skills/sponsor-qualification/SKILL.md b/.agents/skills/sponsor-qualification/SKILL.md new file mode 100644 index 0000000..4e58c88 --- /dev/null +++ b/.agents/skills/sponsor-qualification/SKILL.md @@ -0,0 +1,37 @@ +--- +name: sponsor-qualification +description: Validate selected sponsor integration evidence before OneShot demos, releases, submissions, or qualification claims. +--- + +# Sponsor Qualification + +Read `.agent/SPONSOR_REQUIREMENTS.md`, `.agent/PROJECT_CONTEXT.md`, and relevant +code, tests, and demo instructions. Review working evidence, not plans. + +## Mandatory checks + +- Privy: prove corporate wallet authorization constrains the normal settlement + path through scoped policy or spending permission. Login-only is insufficient. +- Arc: prove a real USDC settlement on Arc Testnet. Mainnet readiness is not + deployment proof for the conditional award; require actual authorized + deployment evidence before claiming it, without inventing network values. +- The Graph: prove a pinned live OneShot/Arc Subgraph is queried through the + active Studio GraphQL path or supported Subgraph MCP transport and that the + LLM Recovery Agent materially uses the result for recovery/incident triage + beyond direct known-hash lookup. A routine evidence panel alone is insufficient. +- Bind a sanitized live Graph trace to deployment/query/result, `_meta` health, + evidence references, one of `WAIT`, `RECONCILE`, `ESCALATE`, or + `RETURN_EXISTING_RESULT`, and the deterministic-core disposition. Live Studio + GraphQL is eligible; mocks, dependencies, configuration and prompt text alone + are insufficient. Do not claim MCP when Studio GraphQL is the active transport. +- Arc verifies candidates and OneShot decides. Empty, stale, malformed/injected, + multiple, or contradictory Graph results and invalid model output cannot unlock + another settlement. Graph/model code exposes no settlement or retry capability. +- Do not require multiple Subgraphs for the selected AI track or award the + separate Composable/Standardized claim without its own proof. +- Verify the demo preserves `1 intent / N attempts / <=1 settlement` and never + exposes secrets. + +For each selected sponsor, report `QUALIFIED`, `NOT QUALIFIED`, or +`NOT VERIFIED`, citing code, tests, live demo evidence, network, and known +limitations. Never upgrade missing or mocked evidence into qualification. diff --git a/.antigravity/README.md b/.antigravity/README.md new file mode 100644 index 0000000..8190cc1 --- /dev/null +++ b/.antigravity/README.md @@ -0,0 +1,5 @@ +# Antigravity adapter + +Antigravity agents must load `.agents/rules/repository-policy.md`, which delegates to the canonical root `AGENTS.md` policy and its task-specific `.agent/` documents. + +Use the shared FreePi prompts under `.agent/review-prompts/` for mandatory independent review gates. Keep personal configuration under ignored local paths; do not commit it. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..ba2ca36 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,5 @@ +# OneShot Claude adapter + +Before working in this repository, read and follow `../AGENTS.md` completely. + +The root policy routes the canonical, task-specific documents under `.agent/`. Nothing in this adapter overrides them. Keep personal Claude settings local and ignored. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c29eb8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.git +.agent +.gemini +dist +build +site-dist +coverage +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d80cc11 --- /dev/null +++ b/.env.example @@ -0,0 +1,92 @@ +# Local API runtime. Copy to .env and replace placeholders; never commit real secrets. +DATABASE_URL=postgresql://oneshot:oneshot@localhost:5432/oneshot +# Use a randomly generated value with at least 16 characters. +SERVICE_BEARER_TOKEN=replace-me-with-a-random-secret +HOST=0.0.0.0 +PORT=3000 +# Worker deployments require at least 2 (one outbox claim + one ledger action). +DB_POOL_MAX=10 +ONESHOT_SUBMISSIONS_DISABLED=false +# One server-configured allowlisted workspace. Clients never choose this value. +ONESHOT_WORKSPACE_ID=team-testnet-workspace +# Shared PostgreSQL-backed API admission control. +ONESHOT_API_RATE_LIMIT_MAX_REQUESTS=60 +ONESHOT_API_RATE_LIMIT_WINDOW_MS=60000 + +# One-tool MCP. Each Privy user generates a workspace-bound bearer in Profile. +# The optional deployment bearer keeps one operator-controlled client working. +# ONESHOT_MCP_BEARER_TOKEN= +# Public frontend route returned by MCP for explicit wallet signing. +# ONESHOT_APP_URL=https://oneshot.kapustazh.dev/app +# ONESHOT_MCP_PAYER_ADDRESS=0x<40-hex-privy-server-wallet-address> +# ONESHOT_MCP_WAIT_MS=2500 + +# Production worker effect boundary. Public identifiers are placeholders; +# secrets must be injected by the deployment secret store, never committed. +ONESHOT_ARC_PROFILE=arc-testnet +ONESHOT_ARC_RPC_URL=https:// +# ONESHOT_ARC_EXPLORER_URL=https://testnet.arcscan.app +ONESHOT_PRIVY_APP_ID= +ONESHOT_PRIVY_APP_SECRET= +ONESHOT_PRIVY_WALLET_ID= +ONESHOT_PRIVY_WALLET_ADDRESS=0x<40-hex-wallet-address> +ONESHOT_PRIVY_POLICY_ID= +# SHA-256 of the canonical Privy policy response reviewed for this deployment. +ONESHOT_PRIVY_POLICY_DIGEST=<64-hex-policy-digest> +ONESHOT_SETTLEMENT_CAP_ATOMIC=1000000 +ONESHOT_RPC_TIMEOUT_MS=10000 +ONESHOT_ALLOW_MAINNET_ACTIVATION=false + +# Operator login boundary. Separate from the ONESHOT_PRIVY_* wallet-authorization +# variables above; both may reference the same Privy application. +# The verification key is the PUBLIC ES256 key from the Privy dashboard. +# Leave all three empty to keep the service-bearer-only behavior. +PRIVY_AUTH_APP_ID= +PRIVY_AUTH_VERIFICATION_KEY= +PRIVY_AUTH_ALLOWED_SUBJECTS=did:privy: +# Public/demo-only wildcard access requires this separate explicit opt-in. +# PRIVY_AUTH_ALLOW_ALL_SUBJECTS=true + +# Recovery discovery and advisory configuration. Graph evidence is candidate-only; +# Vertex recommendations cannot authorize or submit settlement. +# Arc Testnet's active demo path uses the rate-limited Studio GraphQL endpoint. +ONESHOT_SUBGRAPH_SOURCE=STUDIO_GRAPHQL +ONESHOT_SUBGRAPH_QUERY_URL=https://api.studio.thegraph.com/query/// +# Configure these only for a supported Network/MCP profile. +# ONESHOT_SUBGRAPH_MCP_ENDPOINT=https://mcp.example.invalid +ONESHOT_GRAPH_API_KEY= +# The API uses ONESHOT_SUBGRAPH_QUERY_URL automatically for the authenticated +# cabinet. Keep this legacy variable only when the activity path needs a +# different pinned deployment; it overrides the canonical URL. +# ONESHOT_GRAPH_QUERY_URL=https://api.studio.thegraph.com/query/// +# Optional server-wallet fallback for Graph activity. User-wallet payer +# addresses are discovered from durable workspace jobs automatically. +# ONESHOT_ACTIVITY_WALLET_ADDRESS=0x<40-hex-server-wallet-address> +# ONESHOT_SUBGRAPH_MCP_SERVER_VERSION=1.0.0 +ONESHOT_SUBGRAPH_DEPLOYMENT_ID=0x<64-hex-deployment-id> +ONESHOT_SUBGRAPH_MANIFEST_CID= +ONESHOT_SUBGRAPH_MAX_LAG_BLOCKS=5 +ONESHOT_SUBGRAPH_MAX_CANDIDATES=5 +ONESHOT_SUBGRAPH_MAX_RESULT_BYTES=65536 +ONESHOT_RECOVERY_FROM_BLOCK=0 +ONESHOT_RECOVERY_TO_BLOCK= +ONESHOT_VERTEX_PROJECT_ID= +ONESHOT_VERTEX_LOCATION=europe-west1 +ONESHOT_VERTEX_MODEL=gemini-2.5-flash + +# Continuous durable outbox runner. +ONESHOT_WORKER_POLL_INTERVAL_MS=1000 +ONESHOT_WORKER_MAX_JOBS=50 +ONESHOT_SUBMISSION_LEASE_MS=30000 + +# R4 testnet-only fault injection. Leave disabled for normal operation. To +# rehearse a single lost response after broadcast, set both values explicitly; +# the worker refuses this hook on any non-Arc-Testnet profile. +# ONESHOT_DEMO_RESPONSE_LOSS_AFTER_BROADCAST=true +# ONESHOT_DEMO_CONFIRM_TESTNET=true + +# Cloud Run + Cloud SQL alternative. Remove DATABASE_URL when using these values. +# INSTANCE_CONNECTION_NAME=project-id:region:instance-name +# DB_USER=oneshot +# DB_PASS=replace-with-secret-manager-value +# DB_NAME=oneshot diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bc8c9f1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep text files stable across Windows and POSIX checkouts. +* text=auto eol=lf diff --git a/.github/BRANCH_POLICY.md b/.github/BRANCH_POLICY.md new file mode 100644 index 0000000..47955cb --- /dev/null +++ b/.github/BRANCH_POLICY.md @@ -0,0 +1,36 @@ +# Branch and review policy + +## Protected branches + +- `develop` is the integration branch. +- `main` is the release branch. +- Do not push directly to either protected branch. +- Do not merge a pull request as an agent. A human owner merges after the required reviews and checks pass. + +## Working branches + +Create a focused branch from the latest target branch and keep commits scoped to one concern. Before opening or updating a pull request, follow `.agent/IMPLEMENTATION_LOOP.md` and confirm the exact candidate tree has passed the pre-push FreePi gate. + +## Required pull-request evidence + +Every pull request must record: + +- the base and head commit SHAs; +- the candidate tree SHA reviewed before push; +- the independent reviewer tool and model; +- the exact `VERDICT: PASS` result for Gate A; +- the validation commands and results; +- security and product-invariant impact; +- any residual risks or follow-up work. + +After push, run the PR-head FreePi gate against the exact remote head. A pass for a different tree or commit is stale and does not satisfy the gate. + +## Required checks + +The following checks must pass before human merge: + +- `Agent policy / repository-policy`; +- all applicable build, test, lint, type-check, and security checks; +- independent FreePi Gate A and Gate B reviews as defined in `.agent/IMPLEMENTATION_LOOP.md`. + +If a required check cannot run, stop and document the blocker. Do not weaken, bypass, or silently substitute a gate. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..117c0b6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,30 @@ +name: Bug report +description: Create a report to help reproduce and fix a defect. +title: '[Bug]: ' +body: + - type: textarea + id: description + attributes: + label: Problem description + description: Clear and concise description of what happened. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Exact steps to trigger the bug. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened instead. + validations: + required: true + - type: textarea + id: context + attributes: + label: Environment & context + description: OS, branch, commit, logs, or error messages. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/milestone-proposal.yml b/.github/ISSUE_TEMPLATE/milestone-proposal.yml new file mode 100644 index 0000000..cf01122 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/milestone-proposal.yml @@ -0,0 +1,58 @@ +name: Milestone or feature proposal +description: Propose a measurable product, engineering, or feature outcome. +title: '[Proposal]: ' +body: + - type: textarea + id: outcome + attributes: + label: Outcome and user value + description: Describe the result, not only the implementation. + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: List objective conditions that prove the work is complete. + placeholder: | + - [ ] ... + - [ ] ... + validations: + required: true + - type: textarea + id: scope + attributes: + label: In scope + validations: + required: true + - type: textarea + id: non_goals + attributes: + label: Out of scope + validations: + required: true + - type: textarea + id: validation + attributes: + label: Test and measurement plan + validations: + required: true + - type: textarea + id: risks + attributes: + label: Security, privacy, cost, and operational risks + validations: + required: true + - type: textarea + id: rollback + attributes: + label: Rollback or safe-disable strategy + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Scope checks + options: + - label: This proposal represents one reviewable milestone outcome. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b1b4685 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,65 @@ +## Summary + + + +## Scope and acceptance criteria + +- [ ] The change is limited to the stated milestone or issue. +- [ ] Acceptance criteria are listed and satisfied. +- [ ] No unrelated cleanup is included. + +## Product and security invariants + +- [ ] Tenant isolation remains fail-closed. +- [ ] Sponsor authorization, auditability, and daily caps remain enforced where applicable. +- [ ] Recipients cannot modify sponsor controls or access sponsor-only data. +- [ ] No secret, token, production identifier, or personal data is committed or pasted into review prompts. +- [ ] Any non-applicable invariant is explained below. + +Invariant notes: + + + +## Validation + +Commands and results: + +```text + +``` + +## Independent review evidence + +### Gate A — exact candidate tree before push + +- Base commit SHA: +- Candidate tree SHA: +- Candidate commit SHA (if already committed but unpushed): +- Reviewer tool: `free-pi-cli` +- Reviewer model: +- Verdict: `VERDICT: PASS` / `VERDICT: FAIL` +- Findings or residual risks: + +- [ ] The reviewed tree equals the committed tree. + +### Gate B — exact remote PR head + +- Pull request URL/number: +- Remote head commit SHA: +- Remote head tree SHA: +- Reviewer tool: `free-pi-cli` +- Reviewer model: +- Verdict: `VERDICT: PASS` / `VERDICT: FAIL` +- Findings or residual risks: + +- [ ] Gate B reviewed the current remote head and matches Gate A's approved tree, or a fresh Gate A was run for the changed tree. +- [ ] `Agent policy / repository-policy` and all applicable CI checks pass. + +## Risk and rollback + +- Residual risks: +- Rollback or recovery plan: + +## Human merge + +- [ ] A human owner has reviewed the evidence and will perform the merge. diff --git a/.github/puppeteer-ci.json b/.github/puppeteer-ci.json new file mode 100644 index 0000000..2274c80 --- /dev/null +++ b/.github/puppeteer-ci.json @@ -0,0 +1,3 @@ +{ + "args": ["--no-sandbox", "--disable-setuid-sandbox"] +} diff --git a/.github/workflows/agent-policy.yml b/.github/workflows/agent-policy.yml new file mode 100644 index 0000000..ad7768e --- /dev/null +++ b/.github/workflows/agent-policy.yml @@ -0,0 +1,82 @@ +name: Agent policy + +on: + pull_request: + push: + branches: + - develop + - main + +permissions: + contents: read + +jobs: + repository-policy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate canonical agent workflow + shell: bash + run: | + set -euo pipefail + + required_paths=( + AGENTS.md + .agent/AGENTS.md + .agent/PROJECT_CONTEXT.md + .agent/SECURITY_INVARIANTS.md + .agent/SPONSOR_REQUIREMENTS.md + .agent/TEST_MATRIX.md + .agent/IMPLEMENTATION_LOOP.md + .agent/review-prompts/freepi-prepush-review.md + .agent/review-prompts/freepi-pr-review.md + .agents/skills/oneshot-failure-injection/SKILL.md + .agents/skills/oneshot-idempotency/SKILL.md + .agents/skills/sponsor-qualification/SKILL.md + .agents/rules/repository-policy.md + .antigravity/README.md + .claude/CLAUDE.md + .github/BRANCH_POLICY.md + .github/PULL_REQUEST_TEMPLATE.md + ) + + for path in "${required_paths[@]}"; do + test -f "$path" || { echo "Missing required policy file: $path"; exit 1; } + done + + obsolete_paths=( + .agent/MILESTONE_IMPLEMENTATION_LOOP.md + .agent/milestones/M0_UNIFIED_AGENT_WORKFLOW.md + .agent/review-prompts/draft-pr-review.md + .agent/review-prompts/implementation-review.md + .antigravity/review.md + ) + + for path in "${obsolete_paths[@]}"; do + test ! -e "$path" || { echo "Obsolete policy file must be removed: $path"; exit 1; } + done + + grep -Fq '@../../AGENTS.md' .agents/rules/repository-policy.md + grep -Fq '../AGENTS.md' .claude/CLAUDE.md + grep -Fq '.agents/rules/repository-policy.md' .antigravity/README.md + grep -Fq '.agent/IMPLEMENTATION_LOOP.md' AGENTS.md + grep -Fq 'free-pi-cli' .agent/IMPLEMENTATION_LOOP.md + + test "$(wc -l < .agents/rules/repository-policy.md)" -le 12 + test "$(wc -l < .claude/CLAUDE.md)" -le 12 + test "$(wc -l < .antigravity/README.md)" -le 12 + + if git grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':!*.lock'; then + echo 'Unresolved merge marker found.' + exit 1 + fi + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + base_sha="${{ github.event.pull_request.base.sha }}" + git diff --check "$base_sha" HEAD + else + git diff --check HEAD^ + fi diff --git a/.github/workflows/stack-lint.yml b/.github/workflows/stack-lint.yml new file mode 100644 index 0000000..cec62c4 --- /dev/null +++ b/.github/workflows/stack-lint.yml @@ -0,0 +1,138 @@ +name: Stack lint + +on: + pull_request: + push: + branches: + - develop + - main + +permissions: + contents: read + +concurrency: + group: stack-lint-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + documentation: + name: Markdown and Mermaid + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Lint Markdown + run: npx --yes markdownlint-cli2@0.18.1 "**/*.md" "#node_modules" + + - name: Render Mermaid diagrams + shell: bash + run: | + set -euo pipefail + output_dir="${RUNNER_TEMP}/mermaid" + mkdir -p "$output_dir" + + while IFS= read -r -d '' file; do + if grep -q '^```mermaid' "$file"; then + digest="$(printf '%s' "$file" | sha256sum | cut -d ' ' -f 1)" + npx --yes @mermaid-js/mermaid-cli@11.17.0 \ + --puppeteerConfigFile .github/puppeteer-ci.json \ + --input "$file" \ + --output "$output_dir/$digest.md" + fi + done < <(find . -type f -name '*.md' -not -path './node_modules/*' -print0) + + typescript: + name: ESLint and TypeScript + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Detect pnpm workspace + id: workspace + shell: bash + run: | + set -euo pipefail + if [[ -f package.json && -f pnpm-lock.yaml ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + elif [[ -f package.json || -f pnpm-lock.yaml ]]; then + echo "package.json and pnpm-lock.yaml must be committed together" >&2 + exit 1 + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "No TypeScript workspace yet; documentation lint remains active." + fi + + - uses: pnpm/action-setup@v4 + if: steps.workspace.outputs.enabled == 'true' + with: + run_install: false + + - uses: actions/setup-node@v4 + if: steps.workspace.outputs.enabled == 'true' + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install locked dependencies + if: steps.workspace.outputs.enabled == 'true' + run: pnpm install --frozen-lockfile + + - name: Require lint scripts + if: steps.workspace.outputs.enabled == 'true' + shell: bash + run: | + node -e "const p=require('./package.json'); for (const s of ['lint','typecheck']) if (!p.scripts?.[s]) { console.error('Missing package.json script: '+s); process.exitCode=1 }" + + - name: Run ESLint + if: steps.workspace.outputs.enabled == 'true' + run: pnpm lint + + - name: Run TypeScript compiler + if: steps.workspace.outputs.enabled == 'true' + run: pnpm typecheck + + - name: Check generated contracts + if: steps.workspace.outputs.enabled == 'true' + run: pnpm check:generated + + - name: Validate contract fixtures + if: steps.workspace.outputs.enabled == 'true' + run: pnpm validate:fixtures + + - name: Run tests + if: steps.workspace.outputs.enabled == 'true' + run: pnpm test + + - name: Run PostgreSQL integration tests + if: steps.workspace.outputs.enabled == 'true' + env: + TEST_POSTGRES: '1' + run: pnpm test:integration + + browser: + name: Frontend browser acceptance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + + - name: Install Chromium + run: pnpm --filter @oneshot/web exec playwright install --with-deps chromium + + - name: Run frontend browser acceptance + run: pnpm test:browser diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0e95f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Operating system +.DS_Store +Thumbs.db + +# Environment and secrets +.env +.env.* +!.env.example +!.env.test +.envrc +.direnv/ +*.local +*.key +*.pem +*.p12 +*.pfx +*.jks +*.keystore +*.seed +*.mnemonic +*.token +.npmrc +!.npmrc.example +secrets/ +credentials/ +.free-pi/ +.pi/ + +# Editor and IDE +.vscode/ +.idea/ +*.swp +*.swo +CLAUDE.local.md +.claude/settings.local.json +.agent/local/ +.agents/local/ +.agents/rules/*.local.md +.agents/workflows/*.local.md +.antigravity/local/ + +# Logs and temp +*.log +tmp/ +temp/ +.gate-context/ + +# Build artifacts and caches (expand per stack) +dist/ +site-dist/ +build/ +coverage/ +apps/web/test-results/ +apps/web/playwright-report/ +playwright-report/ +test-results/ +node_modules/ +.venv/ +venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.wrangler/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..beb0c17 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,12 @@ +{ + "config": { + "MD013": false, + "MD024": { + "siblings_only": true, + }, + "MD033": false, + "MD041": false, + "MD060": false, + }, + "ignores": [".git/**", "**/node_modules/**"], +} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..60ade1a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.19.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8555dfd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +dist +coverage +apps/web/test-results +apps/web/playwright-report +node_modules +pnpm-lock.yaml +packages/contracts/generated +packages/contracts/openapi +packages/contracts/src/generated +packages/arc-adapter/ +packages/privy-adapter/ +packages/testkit-settlement/ +subgraph/generated +subgraph/build diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..f1941c5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c7494b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# OneShot Agent Entry Point + +Read `.agent/AGENTS.md` before any repository work. + +Then load only the documents needed for the task: + +- Architecture or domain work: `.agent/PROJECT_CONTEXT.md` and + `.agent/SECURITY_INVARIANTS.md`. +- Privy, Arc, The Graph, demo, release, or submission work: + `.agent/SPONSOR_REQUIREMENTS.md`. +- Intent, payment, retry, worker, queue, job, invoice, settlement, or + reconciliation work: the `oneshot-idempotency` repo skill and + `.agent/TEST_MATRIX.md`. +- Failure handling or reliability work: the `oneshot-failure-injection` repo + skill and `.agent/TEST_MATRIX.md`. +- Any implementation, review, commit, push, or pull request: + `.agent/IMPLEMENTATION_LOOP.md`. +- Demo/release sponsor claims: the `sponsor-qualification` repo skill. +- Handoff, milestone boundary, session end, or deliberate context reset: + `.agent/context/README.md` and the current context record. + +Canonical shared policy lives only in this file and `.agent/`. Tool-specific +files such as `.claude/CLAUDE.md` and `.agents/rules/repository-policy.md` are +thin adapters: they point here and must not duplicate or override policy. + +Repository skills live in `.agents/skills/`. Personal workflow skills may help, +but they never replace OneShot policy or mandatory FreePi Gate A and Gate B. + +If a required document cannot be read, stop and report the missing policy. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f833307 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:24-bookworm-slim AS builder + +RUN corepack enable && corepack prepare pnpm@11.19.0 --activate +WORKDIR /app + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages +COPY subgraph ./subgraph + +RUN pnpm install --frozen-lockfile +RUN pnpm build + +FROM node:24-bookworm-slim AS runner + +WORKDIR /app +ENV NODE_ENV=production + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages ./packages +COPY --from=builder /app/apps ./apps + +WORKDIR /app/apps/api +ENV PORT=8080 +ENV HOST=0.0.0.0 +EXPOSE 8080 + +CMD ["node", "dist/server.js"] diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..f833307 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,29 @@ +FROM node:24-bookworm-slim AS builder + +RUN corepack enable && corepack prepare pnpm@11.19.0 --activate +WORKDIR /app + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages +COPY subgraph ./subgraph + +RUN pnpm install --frozen-lockfile +RUN pnpm build + +FROM node:24-bookworm-slim AS runner + +WORKDIR /app +ENV NODE_ENV=production + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages ./packages +COPY --from=builder /app/apps ./apps + +WORKDIR /app/apps/api +ENV PORT=8080 +ENV HOST=0.0.0.0 +EXPOSE 8080 + +CMD ["node", "dist/server.js"] diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 0000000..6c5f83c --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,30 @@ +FROM node:24-bookworm-slim AS builder + +RUN corepack enable && corepack prepare pnpm@11.19.0 --activate +WORKDIR /app + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages +COPY subgraph ./subgraph + +RUN pnpm install --frozen-lockfile +RUN pnpm build + +FROM node:24-bookworm-slim AS runner + +RUN corepack enable && corepack prepare pnpm@11.19.0 --activate +WORKDIR /app +ENV NODE_ENV=production + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages ./packages +COPY --from=builder /app/apps ./apps + +WORKDIR /app/apps/worker +ENV PORT=8080 +ENV HOST=0.0.0.0 +EXPOSE 8080 + +CMD ["node", "dist/server.js"] diff --git a/LICENSE b/LICENSE index 48222ce..12468f9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 SuPuHe +Copyright (c) 2026 SWOFART Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..a41e962 --- /dev/null +++ b/README.md @@ -0,0 +1,388 @@ + +
+ + + OneShot — settlement engine + +
+ +# OneShot + +**One job. Many retries. One settlement.** + +OneShot protects an approved business obligation from duplicate committed +settlements through retries, crashes, lost responses, queue redelivery, +parallel workers, and multiple agent instances. + +Product direction: **resumable paid tools for business agents** — resume the +job, not the payment. The settlement engine includes one team-operated testnet +report supplier, task-bound order/result delivery, a one-tool MCP endpoint for +agents, and separate public (`/`), agent-docs (`/docs/mcp`), and authenticated +cabinet (`/app`) routes. Resumable external work requires supplier support; this +is not a guarantee of exactly-once execution for arbitrary tools. + +The cardinality it protects is: + +```text +1 Business Intent / N Attempts / at most 1 committed Settlement +``` + +## The problem + +An autonomous agent is told to make a business payment for 1.25 USDC. It +submits the payment. The connection drops before the response arrives. + +The agent now cannot tell the difference between: + +- the payment never left, and +- the payment succeeded and the receipt was lost. + +Retrying might pay twice. Not retrying might never pay at all. Most systems +guess. Guessing with money is how you get duplicate settlements. + +OneShot refuses to guess. An uncertain outcome becomes a durable `UNKNOWN` +state that must be reconciled from authoritative evidence before anything else +happens. **Absence of proof that a payment happened is never treated as proof +that it did not.** + +## How it works + +```mermaid +flowchart TB + Clients[Agent API client and operator console] --> API[apps/api] + API --> Domain[packages/domain] + Domain --> Contracts[packages/contracts] + Domain --> Storage[packages/storage-postgres] + Storage --> DB[(PostgreSQL)] + Storage --> Outbox[Transactional outbox] + Outbox --> Worker[Settlement worker] + Outbox --> RecoveryWorker[Reconciliation worker] + Worker --> Domain + RecoveryWorker --> Reconciliation[packages/reconciliation] + Reconciliation --> SafetyCore[Deterministic recovery safety core] + SafetyCore --> Command[Versioned reconciliation command] + Command --> Domain + + Domain --> AuthPort[AuthorizationPort] + Domain --> SettlementPort[SettlementPort] + Reconciliation --> EvidencePort[EvidencePort] + Reconciliation --> IndexPort[IndexViewPort] + Reconciliation --> AdvisorPort[RecoveryAdvisorPort] + + AuthPort --> PrivyAdapter[packages/privy-adapter] + SettlementPort --> PrivyAdapter + EvidencePort --> ArcAdapter[packages/arc-adapter] + IndexPort -.-> GraphAdapter[Graph recovery adapter] + AdvisorPort -.-> RecoveryAgent[LLM recovery agent] + + PrivyAdapter --> Privy[Privy wallet and policy] + ArcAdapter --> Arc[Arc USDC and RPC] + GraphAdapter -.-> Studio[Subgraph Studio GraphQL] + GraphAdapter -.-> MCP[Optional Subgraph MCP] + Studio -.-> GraphIndex[OneShot Arc Subgraph] + MCP -.-> GraphIndex +``` + +Solid edges are implemented. Dashed runtime edges are bounded production paths: +the active Arc Testnet profile uses authenticated, pinned Subgraph Studio +GraphQL; Subgraph MCP remains an optional fail-closed adapter for deployments +served by The Graph Network. The recovery agent remains advisory. Neither +external index evidence nor model advice can authorize settlement. + +### The state machine + +Every Business Intent moves through durable, compare-and-set transitions. Only +one of them grants the right to touch the outside world. + +```mermaid +stateDiagram-v2 + [*] --> AUTHORIZING: intent accepted + AUTHORIZING --> REJECTED: policy denies + AUTHORIZING --> READY: policy authorizes + READY --> SUBMITTING: atomic owner grant + SUBMITTING --> COMMITTED: verified receipt and Transfer + SUBMITTING --> FAILED_SAFE: proof of no submission + SUBMITTING --> UNKNOWN: timeout, crash, or doubt + UNKNOWN --> COMMITTED: verified success + UNKNOWN --> FAILED_SAFE: proof of no effect + UNKNOWN --> UNKNOWN: pending, not found, or unavailable + COMMITTED --> [*] + REJECTED --> [*] +``` + +`UNKNOWN` never grants permission to submit again. It is resolved by evidence +or escalated to a human. + +## Design rules + +These are enforced in code and tests, not by convention: + +- **A stable `business_intent_id` survives everything.** Retries, restarts, + redelivery, and parallel workers all converge on the same intent. +- **One atomic transition grants submission ownership.** Exactly one worker + crosses the external boundary. +- **Doubt fails closed.** A timeout, reset, truncated response, or any + unrecognized error is treated as _possibly submitted_, never as a safe retry. +- **A successful receipt is not confirmation.** Settlement is committed only + when the receipt carries exactly one matching ERC-20 Transfer, to the expected + recipient, for the exact amount, from the configured token. +- **Money is integer atomic units and `bigint`.** No JavaScript floating point + touches a monetary value anywhere. +- **External indexes are evidence, never authority.** An empty or delayed index + result cannot authorize a payment. + +## Integrations + +| System | Role | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Privy** | Corporate wallet, scoped authorization, and spending policy | +| **Arc** | USDC settlement rail (Arc Testnet, chain `5042002`) | +| **The Graph** | Arc USDC Subgraph discovery through the admitted Studio GraphQL path (optional MCP for Network-served deployments); evidence only, never settlement authority | + +Privy authorizes and constrains the wallet action. It is not the duplicate +lock: OneShot's durable state is. + +## Repository layout + +```text +apps/api HTTP seam, MCP endpoint, personal MCP credentials +apps/web landing, agent docs, and operator workspace +apps/worker settlement and reconciliation workers +packages/brand brand tokens, commit-ring mark, hero geometry +packages/supplier-adapter idempotent team-operated testnet report connector +packages/contracts frozen v1 contract pack, OpenAPI, fixtures +packages/domain intent, attempt, and settlement state +packages/storage-postgres durable ledger and migrations +packages/arc-adapter Arc profiles, money, receipts, readiness +packages/privy-adapter authorization, requests, policy, adapters +packages/reconciliation recovery evidence and safety core +packages/settlement-ui policy, authorization, and settlement evidence UI +packages/recovery-ui synthetic recovery evidence viewer +packages/testkit-* simulators and sanitized fixtures +subgraph Arc Testnet USDC transfer indexer +``` + +## Quick start + +Requires Node `24.19.0`, pnpm `11.19.0`, and PostgreSQL for integration tests. + +```bash +pnpm install +pnpm lint && pnpm typecheck && pnpm build +pnpm test +pnpm test:browser +pnpm build:frontend +pnpm --filter @oneshot/web dev +``` + +Open `http://localhost:3000/` for the public landing page, `/docs/mcp` for the +agent connection guide, and `/app` for the authenticated workspace. The +workspace composes five sections — Overview, Payment services, Requests, +Payment proof, and Profile. Payment proof reads the configured OneShot API and +projects the frozen `recovery-view` API into the C05 timeline model, with +labelled fail-closed fallbacks for legacy or unavailable evidence, and +distinguishes a Graph observation that is still pending from proof that no +payment happened. Profile issues and rotates the personal MCP bearer. The P5 +browser acceptance suite runs with Playwright/Chromium in CI. + +Authenticated site activity is read-only: the API automatically queries the +configured Arc subgraph for every payer wallet recorded in the workspace, +records bounded Graph observations, and displays one audit row for every site +payment request, including rejected, failed-safe, uncertain, and committed +outcomes. Indexed transfers are linked to settlements and unmatched transfers +remain visible. Every committed settlement captures Graph evidence through a +durable, idempotent outbox job, including a backfill for settlements that +predate that capture. Graph absence or lag never changes payment authority; a +missing or reverted transfer event is not proof that no payment happened. + +A browser caller's workspace is derived from its verified Privy subject, so +jobs, results, and activity are scoped to the signed-in operator rather than to +a caller-supplied identifier. + +Integration tests need a database: + +```bash +pnpm test:integration +``` + +Copy `.env.example` to `.env` and `apps/web/.env.example` to +`apps/web/.env.local`, then fill in placeholders. Never commit a real secret; +see `docs/settlement/SETTLEMENT_CONFIG_V1.md` for how each variable is +classified. + +### Operator sign-in + +Privy operator login is optional and separate from the Privy wallet and +settlement-policy adapter in `packages/privy-adapter`. Set all three API +variables together: `PRIVY_AUTH_APP_ID`, `PRIVY_AUTH_VERIFICATION_KEY`, and +`PRIVY_AUTH_ALLOWED_SUBJECTS`. A partial configuration makes the API refuse to +start. With none set, the API accepts only `SERVICE_BEARER_TOKEN`; worker and +agent clients continue to use that service credential. + +The Team Report browser flow uses a separate user-funded path: after the quote, +the connected Privy Ethereum wallet is shown the exact Arc Testnet USDC +transfer and signs it in the browser. The API stores the payer binding and +accepts the job only after verifying the submitted receipt. The server-side +Privy execution wallet remains for worker-owned settlement operations; it is +not the payer for a Team Report started from Tools. + +Bootstrap an operator by setting `VITE_PRIVY_APP_ID`, starting the web app, +signing in, copying the DID shown by the console, adding that DID to +`PRIVY_AUTH_ALLOWED_SUBJECTS`, and then starting the API. Copy the public ES256 +verification key from Privy Dashboard → Configuration → App settings → Basics +→ Verify with key instead into runtime environment configuration. Never commit +the key. This boundary does not use the Privy app secret; never add that secret +to its configuration. + +To verify a configured Arc endpoint really is the chain and token you think it +is: + +```bash +pnpm --filter @oneshot/arc-adapter probe +``` + +That command is read-only. It cannot sign, send, or mutate anything. + +To view the standalone recovery fixture UI locally: + +```bash +pnpm --filter @oneshot/recovery-ui dev +``` + +Open `http://localhost:5173/?scenario=aged-unknown`. The public Wrangler target +uses the same clearly labelled synthetic viewer. Wrangler's build hook creates +the static bundle before local preview or `pnpm deploy`, including on a fresh +Cloudflare Workers Build checkout. + +## API + +### Arc Testnet transfer demo + +The resumable job flow uses a deliberately labelled team-operated supplier +until an external supplier is selected. In Payment services, enter the exact Arc +Testnet recipient and USDC amount for the purchase. The amount must be within +the settlement cap. A committed job's settlement and ArcScan evidence remain +authoritative; delivery resume never submits a replacement payment. + +`pnpm demo:r4` runs the response-loss drill offline by default. The live mode +requires an explicit Arc Testnet confirmation and the reviewed worker hook; +it emits a sanitized trace and stops at `HOLD`/`INCOMPLETE` when settlement, +Studio evidence or the supplier result cannot be proven. + +For a safe rehearsal, use a small integer quote such as `10000` atomic USDC +(`0.01 USDC`), fund only the Privy testnet wallet, and use a second team-owned +Arc Testnet wallet as the recipient. This proves the Privy/Arc settlement rail; +it is not a claim of third-party supplier execution. + +The cabinet follows a two-step approval flow: enter a company/domain, recipient +wallet and USDC amount, request the live quote, review amount/recipient/ +network/expiry, then explicitly approve payment. The generated task key is +shown for retries; users do not need to invent one. After settlement, the job +list links directly to ArcScan and keeps the supplier result separate from +payment evidence. + +| Method | Path | Purpose | +| ------ | ------------------------------------- | -------------------------------------------------------------- | +| `POST` | `/v1/intents` | Create an intent; an identical replay returns the same result | +| `GET` | `/v1/intents/{id}` | Authoritative intent, attempts, settlement, evidence | +| `POST` | `/v1/intents/{id}/reconcile` | Trigger read-only reconciliation; never submits | +| `GET` | `/v1/intents/{id}/recovery-view` | Local authority plus labelled provider observations | +| `POST` | `/v1/jobs` | Start/replay one workspace-scoped team report task | +| `POST` | `/v1/jobs/quote` | Return a non-chargeable quote before explicit approval | +| `POST` | `/v1/jobs/user-wallet/prepare` | Bind a payer wallet and return the exact transfer to sign | +| `GET` | `/v1/jobs` | List workspace jobs and delivery state | +| `GET` | `/v1/jobs/{jobId}` | Read a workspace-owned job | +| `POST` | `/v1/jobs/{jobId}/user-wallet/submit` | Bind a signed transaction hash and verify its receipt | +| `POST` | `/v1/jobs/{jobId}/resume` | Resume original supplier delivery; never submits payment | +| `GET` | `/v1/jobs/{jobId}/result` | Retrieve an existing supplier result; never submits payment | +| `GET` | `/v1/activity` | Last bounded Graph activity observation and local comparison | +| `POST` | `/v1/activity/refresh` | Manually refresh Graph activity; no settlement action | +| `GET` | `/v1/metrics` | Operational metrics | +| `GET` | `/health/live` | Process liveness | +| `GET` | `/health/ready` | Configuration and Arc identity readiness | + +The contract is defined in `packages/contracts/openapi/openapi.v1.json`. + +### Agent access (MCP) + +Agents reach the same durable job path through one Streamable HTTP MCP +endpoint. The flow is non-custodial: `arc_payment` creates or replays a +payer-bound job and returns the exact Arc Testnet USDC transaction request, the +user's own wallet signs and broadcasts it, and `arc_payment_submit` hands back +the transaction hash so OneShot can bind it and verify the receipt and its +single matching `Transfer` log. + +The MCP bearer authenticates a workspace. It does not authorize a server payer +and cannot sign or broadcast anything. Personal bearers are issued from the +authenticated Profile and stored only as SHA-256 digests; the legacy +operator-controlled `ONESHOT_MCP_BEARER_TOKEN` remains optional. + +| Method | Path | Purpose | +| ------ | ------------------------------ | -------------------------------------------------------------- | +| `ALL` | `/mcp` | MCP endpoint exposing `arc_payment` and `arc_payment_submit` | +| `GET` | `/v1/profile/mcp-token` | Report whether this workspace holds a personal MCP bearer | +| `POST` | `/v1/profile/mcp-token` | Issue a personal MCP bearer; only its digest is stored | +| `POST` | `/v1/profile/mcp-token/rotate` | Replace the personal MCP bearer | + +These operator and agent-transport routes sit outside the frozen v1 contract +pack. See [`docs/MCP_ARC_PAYMENT.md`](docs/MCP_ARC_PAYMENT.md) for deployment, +client configuration, and the live walkthrough, or open `/docs/mcp` in the +running web app. + +## Project status + +Under active development. **Testnet only.** + +| Area | Status | +| -------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Durable intent ledger, API, worker | Implemented | +| Settlement adapters and error taxonomy | Implemented; simulator-tested and live-verified on Arc Testnet through Privy | +| Recovery evidence and safety core | Live Graph/Vertex path implemented; deterministic core remains authoritative | +| Graph discovery and LLM recovery agent | Studio GraphQL path implemented; fresh sponsor trace pending; deterministic core remains final | +| Resumable team report job | Local code: task/order/intent binding, separate delivery and result retrieval | +| User-wallet payments (browser and MCP) | Local code: payer binding, wallet-side signing, receipt and `Transfer` verification | +| Agent MCP endpoint | Deployed; bearer authentication and tool discovery verified, no live MCP payment trace yet | +| Public landing, agent docs, cabinet | Local code at `/`, `/docs/mcp`, and `/app`; live R4 demonstration evidence remains pending | + +**One live testnet settlement has been executed.** A Privy-controlled execution +wallet and scoped policy authorized one 1.00 USDC Arc Testnet transfer; live +wrong-recipient and above-cap denials produced zero broadcasts. A lost-response +drill entered `UNKNOWN` and reconciled to that original settlement without a +replacement payment. Privy and Arc are `QUALIFIED` for the documented testnet +claim; see `docs/settlement/LIVE_EVIDENCE.md` and +`packages/reconciliation/docs/c06/QUALIFICATION_REPORT.md`. + +The Graph recovery path is currently `NOT VERIFIED` for sponsor qualification: +Studio GraphQL is implemented, but a fresh live trace showing its material +effect on the model and deterministic core is still required. + +Arc Mainnet is not configured. Its profile carries no chain ID, RPC, explorer, +or token value by design, and enabling it requires published official values +plus explicit human authorization. + +## Documentation + +| Document | Contents | +| ------------------------------------------------------------------------ | -------------------------------------------------------- | +| [`plan.md`](plan.md) | Product plan, scope, and delivery gates | +| [`docs/DOMAIN_ARCHITECTURE.md`](docs/DOMAIN_ARCHITECTURE.md) | Domain model and boundaries | +| [`docs/MCP_ARC_PAYMENT.md`](docs/MCP_ARC_PAYMENT.md) | One-tool MCP deployment and live walkthrough | +| [`docs/RECOVERY_HARDENING.md`](docs/RECOVERY_HARDENING.md) | Recovery, runtime, authentication, and metrics contracts | +| [`milestones/CONTRACTS.md`](milestones/CONTRACTS.md) | Frozen v1 contract pack | +| [`docs/settlement/`](docs/settlement/) | Settlement config, provider setup, live evidence | +| [`packages/reconciliation/docs/c06/`](packages/reconciliation/docs/c06/) | C06 demo and qualification evidence index | +| [`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHECKLIST.md) | R5 release evidence and submission checklist | +| [`AGENTS.md`](AGENTS.md) | Contribution policy and review gates | + +## License + +MIT. See [`LICENSE`](LICENSE). diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..fc8a607 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,40 @@ +{ + "name": "@oneshot/api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "clean": "tsc -b --clean", + "lint": "eslint src test", + "start": "node dist/server.js", + "start:local": "node --env-file=../../.env dist/server.js", + "test": "vitest run --config vitest.config.ts", + "test:integration": "vitest run --config vitest.integration.config.ts", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "@oneshot/arc-adapter": "workspace:*", + "@oneshot/contracts": "workspace:*", + "@oneshot/domain": "workspace:*", + "@oneshot/storage-postgres": "workspace:*", + "@oneshot/supplier-adapter": "workspace:*", + "fastify": "5.12.3", + "jose": "6.2.12", + "pg": "8.23.0", + "zod": "4.2.1" + }, + "devDependencies": { + "@testcontainers/postgresql": "12.1.0" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..22500d9 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,884 @@ +import { randomUUID } from 'node:crypto'; +import { + asCorrelationId, + asBlockNumber, + asProviderReferenceId, + asTransactionHash, + ContractValidationError, + parseCreateJobRequest, + parseCreateUserWalletJobRequest, + type SupplierPort, + type ErrorCode, + type ErrorResponse, + type SupplierQuote, +} from '@oneshot/contracts'; +import { derivedJobId } from '@oneshot/domain'; +import type { IntentLedger, JobLedger, McpCredentialStore } from '@oneshot/storage-postgres'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; +import type { ServiceAuthenticator } from './auth.js'; +import { createArcPaymentMcpHandler, type ArcPaymentMcpConfig } from './mcp.js'; +import { allowAllRateLimiter, type RateLimiter } from './rate-limit.js'; +import { UnavailableWalletActivityPort, type WalletActivityPort } from './wallet-activity.js'; + +import type { UserWalletVerificationPort } from './user-wallet.js'; + +export interface ServiceConfig { + readonly submissionsDisabled?: boolean; + readonly chainId?: string; + readonly network?: string; + readonly contractVersion?: string; + readonly workspaceId?: string; +} + +export interface SanitizedApiError { + readonly correlationId: string; + readonly method: string; + readonly path: string; + readonly code: string; +} + +export interface ApiDependencies { + readonly ledger: Pick< + IntentLedger, + | 'createOrReplay' + | 'enqueueReconciliation' + | 'getIntent' + | 'getRecoveryView' + | 'getSystemMetrics' + | 'ping' + | 'beginUserWalletSubmission' + | 'recordUserWalletTransaction' + | 'completeSubmission' + | 'markUserWalletUnknown' + >; + readonly jobs?: Pick< + JobLedger, + | 'createOrReplay' + | 'createUserWalletOrReplay' + | 'get' + | 'getByBusinessIntentId' + | 'list' + | 'resumeDelivery' + | 'recordActivityObservation' + | 'activityPayerWallets' + | 'activity' + >; + readonly supplier?: SupplierPort; + readonly walletActivity?: WalletActivityPort; + readonly userWalletVerifier?: UserWalletVerificationPort; + readonly mcpCredentials?: Pick; + readonly mcp?: ArcPaymentMcpConfig & { readonly authenticator: ServiceAuthenticator }; + readonly authenticator: ServiceAuthenticator; + readonly rateLimiter?: RateLimiter; + readonly nextCorrelationId?: () => string; + readonly bodyLimitBytes?: number; + readonly config?: ServiceConfig; + readonly readinessCheck?: () => Promise<{ ready: boolean; reason?: string }>; + readonly onError?: (error: SanitizedApiError) => void; +} + +const createIntentBodySchema = { + type: 'object', + additionalProperties: false, + required: ['business_intent_id', 'recipient', 'amount_atomic', 'asset', 'network', 'purpose'], + properties: { + business_intent_id: { type: 'string', minLength: 1, maxLength: 128 }, + recipient: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, + amount_atomic: { type: 'string', pattern: '^(0|[1-9][0-9]*)$', maxLength: 78 }, + asset: { type: 'string', const: 'USDC' }, + network: { type: 'string', const: 'eip155:5042002' }, + purpose: { type: 'string', minLength: 1, maxLength: 256 }, + }, +} as const; + +const createJobBodySchema = { + type: 'object', + additionalProperties: false, + required: ['task_key', 'tool_id', 'report_subject', 'recipient', 'amount_atomic'], + properties: { + task_key: { type: 'string', minLength: 1, maxLength: 128 }, + tool_id: { type: 'string', const: 'team-report-v1' }, + report_subject: { type: 'string', minLength: 1, maxLength: 256 }, + recipient: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, + amount_atomic: { type: 'string', pattern: '^(0|[1-9][0-9]*)$', maxLength: 78 }, + }, +} as const; + +const createUserWalletJobBodySchema = { + ...createJobBodySchema, + required: [...createJobBodySchema.required, 'payer_wallet'], + properties: { + ...createJobBodySchema.properties, + payer_wallet: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, + }, +} as const; + +const userWalletPaymentBodySchema = { + type: 'object', + additionalProperties: false, + required: ['transaction_hash'], + properties: { + transaction_hash: { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$' }, + }, +} as const; + +function sendError( + reply: FastifyReply, + status: number, + code: ErrorCode, + message: string, + correlationId: string, +): void { + const body: ErrorResponse = { code, message, correlation_id: correlationId }; + void reply.code(status).send(body); +} + +export function buildApi(dependencies: ApiDependencies) { + const app = Fastify({ bodyLimit: dependencies.bodyLimitBytes ?? 16 * 1024, logger: false }); + const correlations = new WeakMap(); + const nextCorrelationId = dependencies.nextCorrelationId ?? randomUUID; + const rateLimiter = dependencies.rateLimiter ?? allowAllRateLimiter; + const defaultWorkspaceId = dependencies.config?.workspaceId ?? 'local-test-workspace'; + const requestWorkspaces = new WeakMap(); + const workspaceFor = (request: FastifyRequest): string => + requestWorkspaces.get(request) ?? defaultWorkspaceId; + const walletActivity = dependencies.walletActivity ?? new UnavailableWalletActivityPort(); + async function refreshGraphActivity( + workspaceId: string, + additionalWallet?: string, + ): Promise { + // A missing activity port is the deliberate local/test fallback. The + // configured production port is queried after user-wallet outcomes as + // well as from the cabinet refresh, so Graph is not recovery-only. + if (!dependencies.walletActivity || !dependencies.jobs?.recordActivityObservation) return; + try { + const wallets = dependencies.jobs.activityPayerWallets + ? await dependencies.jobs.activityPayerWallets(workspaceId) + : additionalWallet + ? [additionalWallet] + : []; + const normalizedAdditionalWallet = additionalWallet?.toLowerCase(); + const observation = await dependencies.walletActivity.refresh( + normalizedAdditionalWallet && + !wallets.some((wallet) => wallet.toLowerCase() === normalizedAdditionalWallet) + ? [...wallets, normalizedAdditionalWallet] + : wallets, + ); + await dependencies.jobs.recordActivityObservation({ + workspaceId, + freshness: observation.freshness, + coverageNote: observation.coverageNote, + payload: observation.payload, + }); + } catch { + // Activity is read-only evidence. A provider failure must not change the + // payment response or turn a missing index row into a no-payment claim. + } + } + const jobsUnavailable = (reply: FastifyReply, request: FastifyRequest): void => + sendError( + reply, + 503, + 'NOT_READY', + 'Resumable jobs are not configured', + correlationFor(request), + ); + const userWalletUnavailable = (reply: FastifyReply, request: FastifyRequest): void => + sendError( + reply, + 503, + 'NOT_READY', + 'User-wallet payment verification is not configured', + correlationFor(request), + ); + const onError = + dependencies.onError ?? + ((error: SanitizedApiError) => { + process.stderr.write(`${JSON.stringify({ event: 'api_error', ...error })}\n`); + }); + + const correlationFor = (request: FastifyRequest): string => { + const existing = correlations.get(request); + if (existing) return existing; + const inbound = request.headers['x-correlation-id']; + const value = asCorrelationId(typeof inbound === 'string' ? inbound : nextCorrelationId()); + correlations.set(request, value); + return value; + }; + + app.addHook('onRequest', async (request, reply) => { + let correlationId: string; + try { + correlationId = correlationFor(request); + } catch { + correlationId = asCorrelationId(nextCorrelationId()); + correlations.set(request, correlationId); + sendError(reply, 400, 'INVALID_REQUEST', 'Invalid correlation identifier', correlationId); + return reply; + } + void reply.header('x-correlation-id', correlationId); + void reply.header('access-control-allow-origin', '*'); + void reply.header('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS'); + void reply.header( + 'access-control-allow-headers', + 'authorization, content-type, x-correlation-id, mcp-protocol-version, mcp-session-id, last-event-id', + ); + + // The browser client sends this read-only refresh as a bodyless JSON POST. + // Fastify rejects an empty body when `content-type` is application/json + // before the route handler can run. Treat the absent body as absent JSON + // for this endpoint only; routes with required JSON bodies keep their + // normal parser and validation behavior. + if ( + request.method === 'POST' && + request.url.split('?')[0] === '/v1/activity/refresh' && + request.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase() === + 'application/json' && + (request.headers['content-length'] === undefined || + request.headers['content-length'] === '0') && + request.headers['transfer-encoding'] === undefined + ) { + delete request.headers['content-type']; + delete request.raw.headers['content-type']; + } + + if (request.method === 'OPTIONS') { + void reply.code(204).send(); + return reply; + } + + const path = request.url.split('?')[0]; + const isMcp = path === '/mcp'; + if (!request.url.startsWith('/v1/') && !isMcp) return; + const authentication = await (isMcp + ? dependencies.mcp?.authenticator.authenticate(request.headers.authorization) + : dependencies.authenticator.authenticate(request.headers.authorization)); + if (!authentication || authentication.decision !== 'AUTHORIZED') { + sendError( + reply, + authentication?.decision === 'FORBIDDEN' ? 403 : 401, + authentication?.decision === 'FORBIDDEN' ? 'FORBIDDEN' : 'UNAUTHORIZED', + 'Service authentication failed', + correlationId, + ); + return reply; + } + if (authentication.workspaceId) { + requestWorkspaces.set(request, authentication.workspaceId); + } + if ( + request.method === 'POST' && + !(await rateLimiter.allow({ + correlationId, + key: request.ip || 'unknown-client', + route: path ?? request.url, + })) + ) { + sendError(reply, 429, 'RATE_LIMITED', 'Request rate limit exceeded', correlationId); + return reply; + } + }); + + if (dependencies.mcp) { + app.all('/mcp', async (request, reply) => { + if (!dependencies.jobs || !dependencies.supplier) { + sendError( + reply, + 503, + 'NOT_READY', + 'MCP user-wallet payments are not configured', + correlationFor(request), + ); + return; + } + const mcpHandler = createArcPaymentMcpHandler({ + ledger: dependencies.ledger, + jobs: dependencies.jobs, + supplier: dependencies.supplier, + ...(dependencies.userWalletVerifier + ? { userWalletVerifier: dependencies.userWalletVerifier } + : {}), + config: { + ...dependencies.mcp!, + workspaceId: requestWorkspaces.get(request) ?? dependencies.mcp!.workspaceId, + }, + }); + const nodeMcpHandler = toNodeHandler(mcpHandler); + reply.hijack(); + try { + await nodeMcpHandler( + request.raw as unknown as Parameters[0], + reply.raw, + request.body, + ); + } finally { + await mcpHandler.close(); + } + }); + } + + app.get('/v1/profile/mcp-token', async (request, reply) => { + void reply.header('cache-control', 'no-store'); + const workspaceId = requestWorkspaces.get(request); + if (!workspaceId || !dependencies.mcpCredentials || !dependencies.mcp) { + sendError( + reply, + 403, + 'FORBIDDEN', + 'Personal MCP access is unavailable', + correlationFor(request), + ); + return; + } + const status = await dependencies.mcpCredentials.status(workspaceId); + return { + configured: status.configured, + ...(status.createdAt ? { created_at: status.createdAt } : {}), + }; + }); + + app.post('/v1/profile/mcp-token', async (request, reply) => { + void reply.header('cache-control', 'no-store'); + const workspaceId = requestWorkspaces.get(request); + if (!workspaceId || !dependencies.mcpCredentials || !dependencies.mcp) { + sendError( + reply, + 403, + 'FORBIDDEN', + 'Personal MCP access is unavailable', + correlationFor(request), + ); + return; + } + const issued = await dependencies.mcpCredentials.issue(workspaceId); + if (!issued) { + sendError( + reply, + 409, + 'INTENT_PAYLOAD_CONFLICT', + 'An MCP token already exists; rotate it instead', + correlationFor(request), + ); + return; + } + return reply.code(201).send({ + bearer_token: issued.bearerToken, + created_at: issued.createdAt, + }); + }); + + app.post('/v1/profile/mcp-token/rotate', async (request, reply) => { + void reply.header('cache-control', 'no-store'); + const workspaceId = requestWorkspaces.get(request); + if (!workspaceId || !dependencies.mcpCredentials || !dependencies.mcp) { + sendError( + reply, + 403, + 'FORBIDDEN', + 'Personal MCP access is unavailable', + correlationFor(request), + ); + return; + } + const issued = await dependencies.mcpCredentials.issue(workspaceId, true); + if (!issued) throw new Error('MCP credential rotation did not return a token'); + return { + bearer_token: issued.bearerToken, + created_at: issued.createdAt, + }; + }); + + app.post('/v1/intents', { schema: { body: createIntentBodySchema } }, async (request, reply) => { + const result = await dependencies.ledger.createOrReplay(request.body, correlationFor(request)); + if (result.kind === 'INTENT_PAYLOAD_CONFLICT') { + sendError( + reply, + 409, + 'INTENT_PAYLOAD_CONFLICT', + 'Business Intent already exists with a different immutable payload', + correlationFor(request), + ); + return; + } + return reply.code(result.kind === 'ACCEPTED' ? 202 : 200).send(result.intent); + }); + + app.post('/v1/jobs', { schema: { body: createJobBodySchema } }, async (request, reply) => { + if (!dependencies.jobs || !dependencies.supplier) { + jobsUnavailable(reply, request); + return; + } + const parsed = parseCreateJobRequest(request.body); + // Supplier creation is non-chargeable and uses the same durable task scope + // as its idempotency key. The database transaction binds that order and the + // settlement intent before the worker can observe payment work. + const jobId = derivedJobId(workspaceFor(request), parsed); + const order = await dependencies.supplier.createOrder(parsed, jobId); + const result = await dependencies.jobs.createOrReplay({ + workspaceId: workspaceFor(request), + request: parsed, + supplierOrder: order, + correlationId: correlationFor(request), + }); + if (result.kind === 'TASK_PAYLOAD_CONFLICT') { + sendError( + reply, + 409, + 'INTENT_PAYLOAD_CONFLICT', + 'Task key already has a different immutable payload', + correlationFor(request), + ); + return; + } + return reply.code(result.kind === 'ACCEPTED' ? 202 : 200).send(result.job); + }); + + app.post('/v1/jobs/quote', { schema: { body: createJobBodySchema } }, async (request, reply) => { + if (!dependencies.supplier) { + jobsUnavailable(reply, request); + return; + } + const parsed = parseCreateJobRequest(request.body); + // Quoting is deliberately non-chargeable: no intent, attempt, settlement, + // or outbox row is created until the caller explicitly approves via POST /v1/jobs. + const jobId = derivedJobId(workspaceFor(request), parsed); + const order = await dependencies.supplier.createOrder(parsed, jobId); + const quote: SupplierQuote = { + supplier_id: order.supplier_id, + order_reference: order.order_reference, + recipient: order.recipient, + amount_atomic: order.amount_atomic, + asset: order.asset, + network: order.network, + expires_at: order.expires_at, + }; + return reply.code(200).send(quote); + }); + + app.post( + '/v1/jobs/user-wallet/prepare', + { schema: { body: createUserWalletJobBodySchema } }, + async (request, reply) => { + if (!dependencies.jobs || !dependencies.supplier) { + jobsUnavailable(reply, request); + return; + } + const parsed = parseCreateUserWalletJobRequest(request.body); + const jobRequest = { + task_key: parsed.task_key, + tool_id: parsed.tool_id, + report_subject: parsed.report_subject, + recipient: parsed.recipient, + amount_atomic: parsed.amount_atomic, + } as const; + const jobId = derivedJobId(workspaceFor(request), jobRequest); + const order = await dependencies.supplier.createOrder(jobRequest, jobId); + const result = await dependencies.jobs.createUserWalletOrReplay({ + workspaceId: workspaceFor(request), + request: parsed, + supplierOrder: order, + correlationId: correlationFor(request), + }); + if (result.kind === 'TASK_PAYLOAD_CONFLICT') { + sendError( + reply, + 409, + 'INTENT_PAYLOAD_CONFLICT', + 'Task key already has a different immutable payer or payload', + correlationFor(request), + ); + return; + } + return reply.code(result.kind === 'ACCEPTED' ? 202 : 200).send(result.job); + }, + ); + + app.get('/v1/jobs', async (request, reply) => { + if (!dependencies.jobs) { + jobsUnavailable(reply, request); + return; + } + return { jobs: await dependencies.jobs.list(workspaceFor(request)) }; + }); + + app.get<{ Params: { jobId: string } }>('/v1/jobs/:jobId', async (request, reply) => { + if (!dependencies.jobs) { + jobsUnavailable(reply, request); + return; + } + const job = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); + if (!job) { + sendError( + reply, + 404, + 'INTENT_NOT_FOUND', + 'Job was not found in this workspace', + correlationFor(request), + ); + return; + } + return job; + }); + + app.post<{ Params: { jobId: string } }>( + '/v1/jobs/:jobId/user-wallet/submit', + { schema: { body: userWalletPaymentBodySchema } }, + async (request, reply) => { + if (!dependencies.jobs || !dependencies.userWalletVerifier) { + userWalletUnavailable(reply, request); + return; + } + const job = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); + if (!job) { + sendError( + reply, + 404, + 'INTENT_NOT_FOUND', + 'Job was not found in this workspace', + correlationFor(request), + ); + return; + } + if (job.payment_mode !== 'USER_WALLET' || !job.user_payment) { + sendError( + reply, + 409, + 'RECONCILIATION_NOT_ALLOWED', + 'This job is not configured for a user-wallet payment', + correlationFor(request), + ); + return; + } + const transactionHash = asTransactionHash( + (request.body as { readonly transaction_hash: string }).transaction_hash, + ); + const begun = await dependencies.ledger.beginUserWalletSubmission( + job.business_intent_id, + job.user_payment.payer_wallet, + correlationFor(request), + ); + if (!begun.begun) { + if (begun.currentState === 'COMMITTED' || begun.currentState === 'FAILED_SAFE') { + const current = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); + if (current) return reply.code(200).send(current); + } + sendError( + reply, + begun.reason === 'NOT_FOUND' ? 404 : 409, + begun.reason === 'NOT_FOUND' ? 'INTENT_NOT_FOUND' : 'RECONCILIATION_NOT_ALLOWED', + begun.reason === 'NOT_USER_WALLET' + ? 'The payer wallet does not match the durable user-wallet authorization' + : 'This user-wallet payment is no longer available for a new submission', + correlationFor(request), + ); + return; + } + if (begun.transactionHash && begun.transactionHash !== transactionHash) { + sendError( + reply, + 409, + 'RECONCILIATION_NOT_ALLOWED', + 'A different transaction hash is already bound to this payment intent', + correlationFor(request), + ); + return; + } + const recorded = await dependencies.ledger.recordUserWalletTransaction( + begun.attemptId, + transactionHash, + ); + if (recorded === 'CONFLICT' || recorded === 'NOT_FOUND') { + sendError( + reply, + 409, + 'RECONCILIATION_NOT_ALLOWED', + 'The transaction could not be bound to the durable payment attempt', + correlationFor(request), + ); + return; + } + + let verification; + try { + verification = await dependencies.userWalletVerifier.verify({ + transactionHash, + walletAddress: job.user_payment.payer_wallet, + recipient: job.user_payment.recipient, + amountAtomic: job.user_payment.amount_atomic, + }); + } catch { + // Do not classify an RPC outage as no payment. The attempt remains + // durable and the same hash can be submitted to this endpoint again. + sendError( + reply, + 503, + 'NOT_READY', + 'Arc receipt verification is temporarily unavailable; no retry was submitted', + correlationFor(request), + ); + return; + } + + if (verification.kind === 'CONFIRMED') { + await dependencies.ledger.completeSubmission(job.business_intent_id, begun.attemptId, { + kind: 'CONFIRMED', + provider_reference_id: asProviderReferenceId(`user-wallet:${transactionHash}`), + transaction_hash: asTransactionHash(verification.transactionHash), + block_number: asBlockNumber(verification.blockNumber), + transfer_log_index: verification.transferLogIndex, + verified_by: 'ARC_RPC_EXACT_TRANSFER', + }); + } else if (verification.kind === 'FINAL_REVERT') { + await dependencies.ledger.completeSubmission(job.business_intent_id, begun.attemptId, { + kind: 'DEFINITELY_NOT_SUBMITTED', + reason: verification.reason, + }); + } else { + await dependencies.ledger.markUserWalletUnknown( + job.business_intent_id, + begun.attemptId, + verification.kind === 'PENDING' + ? 'User wallet transaction is not final; receipt is not available yet' + : verification.reason, + ); + } + const updated = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); + if (!updated) { + sendError( + reply, + 500, + 'INTERNAL_ERROR', + 'Updated job could not be read', + correlationFor(request), + ); + return; + } + await refreshGraphActivity(workspaceFor(request), job.user_payment.payer_wallet); + return reply.code(updated.payment_state === 'UNKNOWN' ? 202 : 200).send(updated); + }, + ); + + app.post<{ Params: { jobId: string } }>('/v1/jobs/:jobId/resume', async (request, reply) => { + if (!dependencies.jobs) { + jobsUnavailable(reply, request); + return; + } + const job = await dependencies.jobs.resumeDelivery(workspaceFor(request), request.params.jobId); + if (!job) { + sendError( + reply, + 404, + 'INTENT_NOT_FOUND', + 'Job was not found in this workspace', + correlationFor(request), + ); + return; + } + return reply.code(202).send(job); + }); + + app.get<{ Params: { jobId: string } }>('/v1/jobs/:jobId/result', async (request, reply) => { + if (!dependencies.jobs) { + jobsUnavailable(reply, request); + return; + } + const job = await dependencies.jobs.get(workspaceFor(request), request.params.jobId); + if (!job) { + sendError( + reply, + 404, + 'INTENT_NOT_FOUND', + 'Job was not found in this workspace', + correlationFor(request), + ); + return; + } + if (!job.result) { + sendError( + reply, + 409, + 'RECONCILIATION_NOT_ALLOWED', + 'Result is not available; this endpoint never submits payment', + correlationFor(request), + ); + return; + } + return job.result; + }); + + app.get('/v1/activity', async (request, reply) => { + if (!dependencies.jobs) { + jobsUnavailable(reply, request); + return; + } + return dependencies.jobs.activity(workspaceFor(request)); + }); + + app.post('/v1/activity/refresh', async (request, reply) => { + if (!dependencies.jobs) { + jobsUnavailable(reply, request); + return; + } + const wallets = dependencies.jobs.activityPayerWallets + ? await dependencies.jobs.activityPayerWallets(workspaceFor(request)) + : []; + const observation = await walletActivity.refresh(wallets); + await dependencies.jobs.recordActivityObservation({ + workspaceId: workspaceFor(request), + freshness: observation.freshness, + coverageNote: observation.coverageNote, + payload: observation.payload, + }); + return reply.code(202).send(await dependencies.jobs.activity(workspaceFor(request))); + }); + + app.get<{ Params: { id: string } }>('/v1/intents/:id', async (request, reply) => { + const intent = await dependencies.ledger.getIntent(request.params.id); + if (!intent) { + sendError( + reply, + 404, + 'INTENT_NOT_FOUND', + 'Business Intent was not found', + correlationFor(request), + ); + return; + } + return intent; + }); + + app.post<{ Params: { id: string } }>('/v1/intents/:id/reconcile', async (request, reply) => { + const result = await dependencies.ledger.enqueueReconciliation(request.params.id); + if (!result) { + sendError( + reply, + 404, + 'INTENT_NOT_FOUND', + 'Business Intent was not found', + correlationFor(request), + ); + return; + } + if (!result.queued) { + sendError( + reply, + 409, + 'RECONCILIATION_NOT_ALLOWED', + 'Intent state does not permit reconciliation', + correlationFor(request), + ); + return; + } + return reply.code(202).send(result); + }); + + app.get<{ Params: { id: string } }>('/v1/intents/:id/recovery-view', async (request, reply) => { + const view = await dependencies.ledger.getRecoveryView(request.params.id); + if (!view) { + sendError( + reply, + 404, + 'INTENT_NOT_FOUND', + 'Business Intent was not found', + correlationFor(request), + ); + return; + } + return view; + }); + + app.get('/health/live', async () => ({ status: 'ok' as const })); + + app.get('/health/ready', async (request, reply) => { + const correlationId = correlationFor(request); + try { + await dependencies.ledger.ping(); + + if (dependencies.config) { + if (dependencies.config.network && dependencies.config.network !== 'eip155:5042002') { + sendError( + reply, + 503, + 'NOT_READY', + 'Invalid network configuration identity', + correlationId, + ); + return; + } + if ( + dependencies.config.contractVersion && + dependencies.config.contractVersion !== '1.0.0' + ) { + sendError(reply, 503, 'NOT_READY', 'Incompatible contract version', correlationId); + return; + } + } + + if (dependencies.readinessCheck) { + const check = await dependencies.readinessCheck(); + if (!check.ready) { + sendError( + reply, + 503, + 'NOT_READY', + check.reason ?? 'Service component not ready', + correlationId, + ); + return; + } + } + + return { + status: 'ok' as const, + ...(dependencies.config?.submissionsDisabled ? { submissions_disabled: true } : {}), + }; + } catch { + sendError(reply, 503, 'NOT_READY', 'Database is unavailable', correlationId); + return; + } + }); + + app.get('/v1/metrics', async (request, reply) => { + try { + const metrics = await dependencies.ledger.getSystemMetrics(); + return metrics; + } catch { + sendError( + reply, + 500, + 'INTERNAL_ERROR', + 'Failed to retrieve system metrics', + correlationFor(request), + ); + return; + } + }); + + app.setErrorHandler((error, request, reply) => { + const correlationId = correlationFor(request); + const fastifyError = error as { readonly code?: string; readonly validation?: unknown }; + const errorCode = + fastifyError.code ?? + (error instanceof ContractValidationError ? 'CONTRACT_VALIDATION' : 'INTERNAL_ERROR'); + const path = request.url.split('?')[0]?.slice(0, 256) ?? '/'; + onError({ + correlationId, + method: request.method, + path, + code: errorCode, + }); + if ( + error instanceof ContractValidationError || + fastifyError.validation || + fastifyError.code === 'FST_ERR_CTP_BODY_TOO_LARGE' || + fastifyError.code === 'FST_ERR_CTP_EMPTY_JSON_BODY' + ) { + sendError(reply, 400, 'INVALID_REQUEST', 'Request failed validation', correlationId); + return; + } + sendError(reply, 500, 'INTERNAL_ERROR', 'Internal service error', correlationId); + }); + + return app; +} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..88ab9cf --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,63 @@ +import { timingSafeEqual } from 'node:crypto'; + +export type AuthenticationDecision = 'AUTHORIZED' | 'UNAUTHORIZED' | 'FORBIDDEN'; + +export type AuthenticationResult = { + readonly decision: AuthenticationDecision; + readonly workspaceId?: string; +}; + +export interface ServiceAuthenticator { + authenticate(authorization: string | undefined): Promise; +} + +export interface TokenWorkspaceLookup { + workspaceForToken(token: string): Promise; +} + +export function workspaceBearerAuthenticator(lookup: TokenWorkspaceLookup): ServiceAuthenticator { + return { + async authenticate(authorization) { + if (!authorization?.startsWith('Bearer ')) return { decision: 'UNAUTHORIZED' }; + const token = authorization.slice('Bearer '.length); + const workspaceId = await lookup.workspaceForToken(token); + return workspaceId ? { decision: 'AUTHORIZED', workspaceId } : { decision: 'UNAUTHORIZED' }; + }, + }; +} + +export function staticBearerAuthenticator(expectedToken: string): ServiceAuthenticator { + if (expectedToken.length === 0) throw new Error('service bearer token must not be empty'); + const expected = Buffer.from(`Bearer ${expectedToken}`, 'utf8'); + return { + async authenticate(authorization) { + if (!authorization) return { decision: 'UNAUTHORIZED' }; + const actual = Buffer.from(authorization, 'utf8'); + if (actual.length !== expected.length) return { decision: 'UNAUTHORIZED' }; + return { + decision: timingSafeEqual(actual, expected) ? 'AUTHORIZED' : 'FORBIDDEN', + }; + }, + }; +} + +export interface CredentialRoute { + readonly matches: (authorization: string | undefined) => boolean; + readonly authenticator: ServiceAuthenticator; +} + +export function compositeAuthenticator(routes: readonly CredentialRoute[]): ServiceAuthenticator { + return { + async authenticate(authorization) { + if (!authorization) return { decision: 'UNAUTHORIZED' }; + let sawForbidden = false; + for (const route of routes) { + if (!route.matches(authorization)) continue; + const decision = await route.authenticator.authenticate(authorization); + if (decision.decision === 'AUTHORIZED') return decision; + if (decision.decision === 'FORBIDDEN') sawForbidden = true; + } + return { decision: sawForbidden ? 'FORBIDDEN' : 'UNAUTHORIZED' }; + }, + }; +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..cf0b306 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,259 @@ +import { createPublicKey } from 'node:crypto'; +import type { PoolConfig } from 'pg'; +import { PRIVY_ALLOW_ALL, PRIVY_DID_PREFIX } from './privy-auth.js'; + +export interface PrivyAuthRuntimeConfig { + readonly appId: string; + readonly verificationKey: string; + readonly allowedSubjects: readonly string[]; +} + +export interface ApiRuntimeConfig { + readonly host: string; + readonly port: number; + readonly serviceBearerToken: string; + readonly database: PoolConfig; + readonly submissionsDisabled: boolean; + readonly workspaceId?: string; + readonly rateLimit: { + readonly maxRequests: number; + readonly windowMs: number; + }; + readonly privyAuth?: PrivyAuthRuntimeConfig; + readonly walletActivity?: { + readonly endpoint: string; + /** Optional server-wallet fallback; user-wallet payers come from the workspace ledger. */ + readonly wallet?: string; + readonly apiKey?: string; + }; + /** Credential-free read-only RPC used to verify user-submitted receipts. */ + readonly userWalletRpcUrl?: string; + readonly mcp?: { + readonly bearerToken?: string; + readonly workspaceId: string; + /** Public frontend route where a user can sign a prepared MCP payment. */ + readonly signingAppUrl?: string; + /** НЕ УДАЛЯТЬ: disabled corporate server-wallet mode only. */ + readonly payerWallet?: string; + readonly waitMs: number; + }; +} + +function required(environment: NodeJS.ProcessEnv, name: string, minimumLength = 1): string { + const value = environment[name]?.trim(); + if (!value) throw new Error(`Missing required environment variable: ${name}`); + if (value.length < minimumLength) { + throw new Error(`Environment variable ${name} must be at least ${minimumLength} characters`); + } + return value; +} + +function integer( + environment: NodeJS.ProcessEnv, + name: string, + fallback: number, + minimum: number, + maximum: number, +): number { + const raw = environment[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(`Invalid environment variable: ${name}`); + } + return value; +} + +function optionalHttpsUrl(environment: NodeJS.ProcessEnv, name: string): string | undefined { + const raw = environment[name]?.trim(); + if (!raw) return undefined; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`${name} must be a valid URL`); + } + const loopback = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; + if (parsed.protocol !== 'https:' && !(loopback && parsed.protocol === 'http:')) { + throw new Error(`${name} must use HTTPS (HTTP is allowed only for loopback)`); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error(`${name} must not contain credentials, query parameters, or fragments`); + } + return parsed.toString(); +} + +function mcpConfig(environment: NodeJS.ProcessEnv, workspaceId: string): ApiRuntimeConfig['mcp'] { + const names = [ + 'ONESHOT_MCP_BEARER_TOKEN', + 'ONESHOT_APP_URL', + 'ONESHOT_MCP_PAYER_ADDRESS', + 'ONESHOT_MCP_WAIT_MS', + ] as const; + if (names.every((name) => !environment[name]?.trim())) return undefined; + if (!environment.ONESHOT_WORKSPACE_ID?.trim()) { + throw new Error('ONESHOT_WORKSPACE_ID is required when MCP is enabled'); + } + const bearerToken = environment.ONESHOT_MCP_BEARER_TOKEN?.trim(); + if (bearerToken && bearerToken.length < 32) { + throw new Error('Environment variable ONESHOT_MCP_BEARER_TOKEN must be at least 32 characters'); + } + /* + * НЕ УДАЛЯТЬ: this optional value belongs only to the disabled corporate + * server-wallet mode. Personal MCP payments bind the wallet supplied by the + * user and never read this address. + */ + const payerWallet = environment.ONESHOT_MCP_PAYER_ADDRESS?.trim().toLowerCase(); + if (payerWallet && !/^0x[0-9a-f]{40}$/u.test(payerWallet)) { + throw new Error('Invalid environment variable: ONESHOT_MCP_PAYER_ADDRESS'); + } + const signingAppUrl = optionalHttpsUrl(environment, 'ONESHOT_APP_URL'); + return { + ...(bearerToken ? { bearerToken } : {}), + workspaceId, + ...(signingAppUrl ? { signingAppUrl } : {}), + ...(payerWallet ? { payerWallet } : {}), + waitMs: integer(environment, 'ONESHOT_MCP_WAIT_MS', 2_500, 0, 5_000), + }; +} + +function optionalRpcUrl(environment: NodeJS.ProcessEnv, name: string): string | undefined { + return optionalHttpsUrl(environment, name); +} + +function databaseConfig(environment: NodeJS.ProcessEnv): PoolConfig { + const max = integer(environment, 'DB_POOL_MAX', 10, 1, 100); + const connectionString = environment.DATABASE_URL?.trim(); + if (connectionString) return { connectionString, max }; + + const explicitSocket = environment.INSTANCE_UNIX_SOCKET?.trim(); + const connectionName = environment.INSTANCE_CONNECTION_NAME?.trim(); + const host = explicitSocket ?? (connectionName ? `/cloudsql/${connectionName}` : undefined); + if (!host) { + throw new Error( + 'Database configuration requires DATABASE_URL, INSTANCE_UNIX_SOCKET, or INSTANCE_CONNECTION_NAME', + ); + } + + return { + host, + user: required(environment, 'DB_USER'), + password: required(environment, 'DB_PASS'), + database: required(environment, 'DB_NAME'), + max, + }; +} + +function normalizeVerificationKey(raw: string): string { + const normalized = raw.replace(/\\n/g, '\n').trim(); + const key = createPublicKey(normalized); + if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') { + throw new Error('PRIVY_AUTH_VERIFICATION_KEY must be an EC P-256 (ES256) public key'); + } + return normalized; +} + +function privyAuthConfig(environment: NodeJS.ProcessEnv): PrivyAuthRuntimeConfig | undefined { + const appId = environment.PRIVY_AUTH_APP_ID?.trim() ?? ''; + const rawKey = environment.PRIVY_AUTH_VERIFICATION_KEY?.trim() ?? ''; + const rawSubjects = environment.PRIVY_AUTH_ALLOWED_SUBJECTS?.trim() ?? ''; + const allowAllSubjects = environment.PRIVY_AUTH_ALLOW_ALL_SUBJECTS?.trim() === 'true'; + if (appId.length === 0 && rawKey.length === 0 && rawSubjects.length === 0) return undefined; + + if (appId.length === 0) + throw new Error('Missing required environment variable: PRIVY_AUTH_APP_ID'); + if (rawKey.length === 0) { + throw new Error('Missing required environment variable: PRIVY_AUTH_VERIFICATION_KEY'); + } + if (rawSubjects.length === 0) { + throw new Error('Missing required environment variable: PRIVY_AUTH_ALLOWED_SUBJECTS'); + } + + if (rawSubjects === '*') { + if (!allowAllSubjects) { + throw new Error('PRIVY_AUTH_ALLOWED_SUBJECTS=* requires PRIVY_AUTH_ALLOW_ALL_SUBJECTS=true'); + } + return { appId, verificationKey: normalizeVerificationKey(rawKey), allowedSubjects: ['*'] }; + } + + const allowedSubjects = [ + ...new Set( + rawSubjects + .split(',') + .map((subject) => subject.trim()) + .filter((subject) => subject.length > 0), + ), + ]; + if (allowedSubjects.length === 0) { + throw new Error('PRIVY_AUTH_ALLOWED_SUBJECTS must list at least one Privy DID'); + } + if (allowedSubjects.includes(PRIVY_ALLOW_ALL)) { + if (!allowAllSubjects) { + throw new Error('PRIVY_AUTH_ALLOWED_SUBJECTS=* requires PRIVY_AUTH_ALLOW_ALL_SUBJECTS=true'); + } + return { appId, verificationKey: normalizeVerificationKey(rawKey), allowedSubjects: ['*'] }; + } + for (const subject of allowedSubjects) { + if (!subject.startsWith(PRIVY_DID_PREFIX)) { + throw new Error(`PRIVY_AUTH_ALLOWED_SUBJECTS entries must start with ${PRIVY_DID_PREFIX}`); + } + } + + return { appId, verificationKey: normalizeVerificationKey(rawKey), allowedSubjects }; +} + +export function loadApiRuntimeConfig( + environment: NodeJS.ProcessEnv = process.env, +): ApiRuntimeConfig { + const workspaceId = environment.ONESHOT_WORKSPACE_ID?.trim() || 'default-workspace'; + const privyAuth = privyAuthConfig(environment); + // The worker and API must query the same pinned Studio deployment. Keep the + // older activity-specific variable as an explicit override for deployments + // that still use it, but make the worker's canonical subgraph URL sufficient + // for the site activity path too. + const activityEndpoint = + environment.ONESHOT_GRAPH_QUERY_URL?.trim() || environment.ONESHOT_SUBGRAPH_QUERY_URL?.trim(); + const activityWallet = environment.ONESHOT_ACTIVITY_WALLET_ADDRESS?.trim(); + const userWalletRpcUrl = optionalRpcUrl(environment, 'ONESHOT_ARC_RPC_URL'); + const mcp = mcpConfig(environment, workspaceId); + if (!activityEndpoint && activityWallet) { + throw new Error( + 'A Graph query URL is required when ONESHOT_ACTIVITY_WALLET_ADDRESS is configured', + ); + } + if (activityEndpoint) { + const url = new URL(activityEndpoint); + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + throw new Error('ONESHOT_GRAPH_QUERY_URL must be a credential-free HTTPS URL'); + } + } + return { + host: environment.HOST?.trim() || '0.0.0.0', + port: integer(environment, 'PORT', 3000, 1, 65_535), + serviceBearerToken: required(environment, 'SERVICE_BEARER_TOKEN', 16), + database: databaseConfig(environment), + submissionsDisabled: environment.ONESHOT_SUBMISSIONS_DISABLED === 'true', + // One fixed workspace is safer than accepting a caller-selected tenant. + // Deployments should configure this explicit value; the default keeps local + // development and existing single-workspace installations closed to one scope. + workspaceId, + rateLimit: { + maxRequests: integer(environment, 'ONESHOT_API_RATE_LIMIT_MAX_REQUESTS', 60, 1, 10_000), + windowMs: integer(environment, 'ONESHOT_API_RATE_LIMIT_WINDOW_MS', 60_000, 1_000, 3_600_000), + }, + ...(privyAuth ? { privyAuth } : {}), + ...(activityEndpoint + ? { + walletActivity: { + endpoint: activityEndpoint, + ...(activityWallet ? { wallet: activityWallet } : {}), + ...(environment.ONESHOT_GRAPH_API_KEY?.trim() + ? { apiKey: environment.ONESHOT_GRAPH_API_KEY.trim() } + : {}), + }, + } + : {}), + ...(userWalletRpcUrl ? { userWalletRpcUrl } : {}), + ...(mcp ? { mcp } : {}), + }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..424042e --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,8 @@ +export * from './app.js'; +export * from './auth.js'; +export * from './config.js'; +export * from './mcp.js'; +export * from './privy-auth.js'; +export * from './rate-limit.js'; +export * from './wallet-activity.js'; +export * from './runtime.js'; diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts new file mode 100644 index 0000000..9fbd331 --- /dev/null +++ b/apps/api/src/mcp.ts @@ -0,0 +1,476 @@ +import { createHash } from 'node:crypto'; +import { McpServer, createMcpHandler, type McpHttpHandler } from '@modelcontextprotocol/server'; +import { + asBlockNumber, + asBusinessIntentId, + asEvmAddress, + asProviderReferenceId, + asTransactionHash, + ContractValidationError, + type JobView, + type SupplierPort, +} from '@oneshot/contracts'; +import { derivedJobId } from '@oneshot/domain'; +import type { IntentLedger, JobLedger } from '@oneshot/storage-postgres'; +import { z } from 'zod'; +import type { UserWalletVerificationPort } from './user-wallet.js'; + +const ARC_NETWORK = 'eip155:5042002' as const; +const ARC_CHAIN_ID = 5042002 as const; +const ARC_USDC = '0x3600000000000000000000000000000000000000' as const; +const USDC_DECIMALS = 6; +const REQUEST_KEY_MAX_LENGTH = 128; +const TRANSFER_SELECTOR = 'a9059cbb'; +const DEFAULT_SIGNING_APP_URL = 'https://oneshot.kapustazh.dev/app'; + +const prepareInputSchema = z.strictObject({ + request_key: z + .string() + .min(1) + .max(REQUEST_KEY_MAX_LENGTH) + .describe( + 'Generate automatically as report--<8 random hex>; reuse it exactly for retries and never ask the user for it', + ), + payer_wallet: z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/u) + .describe('The connected Privy embedded or external EVM wallet that will sign the payment'), + recipient: z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/u) + .describe('Arc Testnet USDC recipient'), + amount_usdc: z.string().min(1).max(79).describe('Positive decimal USDC, up to 6 decimals'), + purpose: z.string().min(1).max(256).describe('Short non-secret payment purpose'), +}); + +const submitInputSchema = z.strictObject({ + business_intent_id: z + .string() + .regex(/^intent_[0-9a-f]{64}$/u) + .describe('The business_intent_id returned by the prepare call'), + transaction_hash: z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/u) + .describe( + 'The hash returned by Privy or MetaMask after the user signed and broadcast the transfer', + ), +}); + +const outputSchema = z.strictObject({ + request_key: z.string(), + job_id: z.string(), + business_intent_id: z.string(), + signing_url: z.string().url(), + state: z.enum([ + 'AUTHORIZING', + 'READY', + 'SUBMITTING', + 'COMMITTED', + 'FAILED_SAFE', + 'UNKNOWN', + 'REJECTED', + ]), + payer: z.strictObject({ + mode: z.literal('USER_WALLET'), + wallet_address: z.string(), + }), + recipient: z.string(), + amount_usdc: z.string(), + amount_atomic: z.string(), + asset: z.literal('USDC'), + network: z.literal(ARC_NETWORK), + replayed: z.boolean(), + quote: z.strictObject({ + supplier_id: z.literal('team-report-v1'), + order_reference: z.string(), + recipient: z.string(), + amount_atomic: z.string(), + asset: z.literal('USDC'), + network: z.literal(ARC_NETWORK), + expires_at: z.string(), + }), + transaction: z + .strictObject({ + chain_id: z.literal(ARC_CHAIN_ID), + from: z.string(), + to: z.string(), + data: z.string(), + value: z.literal('0x0'), + }) + .optional(), + settlement: z + .strictObject({ + transaction_hash: z.string(), + block_number: z.string(), + explorer_url: z.string(), + }) + .optional(), + next_action: z.enum(['SIGN', 'CHECK_STATUS', 'VIEW_PROOF', 'FIX_REQUEST']), +}); + +export interface ArcPaymentMcpConfig { + readonly workspaceId: string; + readonly submissionsDisabled?: boolean; + /** Public frontend route used to hand a prepared payment to the user's wallet. */ + readonly signingAppUrl?: string; + + /** + * НЕ УДАЛЯТЬ: legacy corporate autonomous-agent server-wallet configuration. + * It is intentionally not consumed by the active personal user-wallet MCP flow. + */ + readonly payerWallet?: string; + /** НЕ УДАЛЯТЬ: retained only for the disabled legacy server-wallet mode. */ + readonly waitMs?: number; +} + +export interface ArcPaymentMcpDependencies { + readonly ledger: Pick< + IntentLedger, + | 'beginUserWalletSubmission' + | 'recordUserWalletTransaction' + | 'completeSubmission' + | 'markUserWalletUnknown' + >; + readonly jobs: Pick; + readonly supplier: SupplierPort; + readonly userWalletVerifier?: UserWalletVerificationPort; + readonly config: ArcPaymentMcpConfig; +} + +function boundedText(value: string, name: string, maximum: number): string { + if ( + value.length === 0 || + value.length > maximum || + value.trim() !== value || + // eslint-disable-next-line no-control-regex -- MCP identifiers and display text reject controls. + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new ContractValidationError(`${name} is invalid`); + } + return value.normalize('NFC'); +} + +/** + * Retained for compatibility with the old server-wallet intent identity. The + * active MCP flow uses the resumable user-wallet job identity instead. + */ +export function arcPaymentBusinessIntentId(workspaceId: string, requestKey: string): string { + const workspace = boundedText(workspaceId, 'workspace_id', 128); + const key = boundedText(requestKey, 'request_key', REQUEST_KEY_MAX_LENGTH); + return `intent_${createHash('sha256') + .update(`${workspace}\u0000arc-payment-v1\u0000${key}`, 'utf8') + .digest('hex')}`; +} + +export function parseUsdcAmount(value: string): { + readonly atomic: string; + readonly decimal: string; +} { + const match = /^(0|[1-9][0-9]*)(?:\.([0-9]{1,6}))?$/u.exec(value); + if (!match) { + throw new ContractValidationError( + 'amount_usdc must be a canonical positive decimal string with at most six decimals', + ); + } + const whole = match[1] ?? '0'; + const fraction = match[2] ?? ''; + const atomic = BigInt(whole) * 10n ** BigInt(USDC_DECIMALS) + BigInt(fraction.padEnd(6, '0')); + if (atomic === 0n) throw new ContractValidationError('amount_usdc must be greater than zero'); + return { + atomic: atomic.toString(10), + decimal: `${whole}.${fraction.padEnd(6, '0')}`, + }; +} + +function formatUsdcAmount(amountAtomic: string): string { + const atomic = BigInt(amountAtomic); + const whole = atomic / 10n ** 6n; + const fraction = (atomic % 10n ** 6n).toString(10).padStart(6, '0'); + return `${whole.toString(10)}.${fraction}`; +} + +function transferCalldata(recipient: string, amountAtomic: string): string { + const addressWord = recipient.slice(2).padStart(64, '0'); + const amountWord = BigInt(amountAtomic).toString(16).padStart(64, '0'); + return `0x${TRANSFER_SELECTOR}${addressWord}${amountWord}`; +} + +function nextAction( + state: JobView['payment_state'], +): 'SIGN' | 'CHECK_STATUS' | 'VIEW_PROOF' | 'FIX_REQUEST' { + if (state === 'READY') return 'SIGN'; + if (state === 'COMMITTED') return 'VIEW_PROOF'; + if (state === 'UNKNOWN' || state === 'SUBMITTING') return 'CHECK_STATUS'; + return 'FIX_REQUEST'; +} + +function transactionFor(job: JobView) { + const payment = job.user_payment; + if (!payment) throw new Error('User-wallet payment binding is missing'); + if (payment.token_contract !== ARC_USDC) { + throw new Error('User-wallet payment is bound to an unsupported token contract'); + } + return { + chain_id: ARC_CHAIN_ID, + from: payment.payer_wallet, + to: payment.token_contract, + data: transferCalldata(payment.recipient, payment.amount_atomic), + value: '0x0' as const, + }; +} + +function resultView( + requestKey: string, + job: JobView, + replayed: boolean, + signingAppUrl = DEFAULT_SIGNING_APP_URL, +) { + const payment = job.user_payment; + if (!payment) throw new Error('User-wallet payment binding is missing'); + return { + request_key: requestKey, + job_id: job.job_id, + business_intent_id: job.business_intent_id, + signing_url: `${signingAppUrl}?mcp_job_id=${encodeURIComponent(job.job_id)}`, + state: job.payment_state, + payer: { + mode: 'USER_WALLET' as const, + wallet_address: payment.payer_wallet, + }, + recipient: payment.recipient, + amount_usdc: formatUsdcAmount(payment.amount_atomic), + amount_atomic: payment.amount_atomic, + asset: 'USDC' as const, + network: payment.network, + replayed, + quote: job.supplier, + ...(job.payment_state !== 'COMMITTED' && job.payment_state !== 'FAILED_SAFE' + ? { transaction: transactionFor(job) } + : {}), + ...(job.settlement + ? { + settlement: { + transaction_hash: job.settlement.transaction_hash, + block_number: job.settlement.block_number, + explorer_url: + job.settlement.explorer_url ?? + `https://testnet.arcscan.app/tx/${job.settlement.transaction_hash}`, + }, + } + : {}), + next_action: nextAction(job.payment_state), + }; +} + +function toolError(message: string) { + return { + content: [{ type: 'text' as const, text: message }], + isError: true as const, + }; +} + +function jsonResult(output: ReturnType) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(output) }], + structuredContent: output, + }; +} + +export function createArcPaymentMcpHandler({ + ledger, + jobs, + supplier, + userWalletVerifier, + config, +}: ArcPaymentMcpDependencies): McpHttpHandler { + return createMcpHandler(() => { + const server = new McpServer({ name: 'oneshot-arc-payments', version: '2.0.0' }); + server.registerTool( + 'arc_payment', + { + title: 'Prepare Arc USDC payment', + description: + 'Create or replay a payer-bound Arc Testnet USDC payment. The user must review and sign the returned ERC-20 transaction with the connected Privy or MetaMask wallet. This tool never uses a server wallet and never broadcasts a transaction.', + inputSchema: prepareInputSchema, + outputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ request_key, payer_wallet, recipient, amount_usdc, purpose }) => { + try { + const requestKey = boundedText(request_key, 'request_key', REQUEST_KEY_MAX_LENGTH); + if (config.submissionsDisabled) { + return toolError('Arc payment preparation is disabled for this deployment.'); + } + const amount = parseUsdcAmount(amount_usdc); + const parsedPayer = asEvmAddress(payer_wallet); + const parsedRecipient = asEvmAddress(recipient); + const jobRequest = { + task_key: requestKey, + tool_id: 'team-report-v1' as const, + report_subject: boundedText(purpose, 'purpose', 256), + recipient: parsedRecipient, + amount_atomic: amount.atomic, + }; + const jobId = derivedJobId(config.workspaceId, jobRequest); + const supplierOrder = await supplier.createOrder(jobRequest, jobId); + const result = await jobs.createUserWalletOrReplay({ + workspaceId: config.workspaceId, + request: { ...jobRequest, payer_wallet: parsedPayer }, + supplierOrder, + correlationId: `mcp-${requestKey.slice(0, 124)}`, + }); + if (result.kind === 'TASK_PAYLOAD_CONFLICT') { + return toolError( + 'The request key already belongs to a different user-wallet payment. Reuse the original immutable fields and payer wallet.', + ); + } + return jsonResult( + resultView(requestKey, result.job, result.kind === 'REPLAYED', config.signingAppUrl), + ); + } catch (error) { + if (error instanceof ContractValidationError) { + if (error.message.includes('Supplier task payload conflicts')) { + return toolError( + 'The request key already belongs to a different user-wallet payment. Reuse the original immutable fields and payer wallet.', + ); + } + return toolError(error.message); + } + throw error; + } + }, + ); + + server.registerTool( + 'arc_payment_submit', + { + title: 'Verify signed Arc USDC payment', + description: + 'Bind the transaction hash returned by the user wallet to the prepared payment, verify the Arc receipt and exact USDC Transfer log, and return the durable payment status. This tool never submits or retries a transaction.', + inputSchema: submitInputSchema, + outputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ business_intent_id, transaction_hash }) => { + try { + if (config.submissionsDisabled) { + return toolError('Arc payment verification is disabled for this deployment.'); + } + if (!userWalletVerifier) { + return toolError( + 'Arc receipt verification is not configured. Set ONESHOT_ARC_RPC_URL before enabling user-wallet MCP payments.', + ); + } + const businessIntentId = asBusinessIntentId(business_intent_id); + const transactionHash = asTransactionHash(transaction_hash); + const job = await jobs.getByBusinessIntentId(config.workspaceId, businessIntentId); + if (!job) return toolError('The prepared payment was not found in this workspace.'); + if (job.payment_mode !== 'USER_WALLET' || !job.user_payment) { + return toolError('The prepared payment is not configured for a user wallet.'); + } + + const begun = await ledger.beginUserWalletSubmission( + job.business_intent_id, + job.user_payment.payer_wallet, + `mcp-submit-${transactionHash.slice(2, 18)}`, + ); + if (!begun.begun) { + if (begun.currentState === 'COMMITTED' || begun.currentState === 'FAILED_SAFE') { + const current = await jobs.getByBusinessIntentId( + config.workspaceId, + job.business_intent_id, + ); + return current + ? jsonResult(resultView(job.task_key, current, true, config.signingAppUrl)) + : toolError('The completed payment could not be read back from durable storage.'); + } + return toolError( + begun.reason === 'NOT_USER_WALLET' + ? 'The payer wallet does not match the durable user-wallet authorization.' + : 'This user-wallet payment is no longer available for verification.', + ); + } + if (begun.transactionHash && begun.transactionHash !== transactionHash) { + return toolError( + 'A different transaction hash is already bound to this payment; do not submit another transaction.', + ); + } + const recorded = await ledger.recordUserWalletTransaction( + begun.attemptId, + transactionHash, + ); + if (recorded === 'CONFLICT' || recorded === 'NOT_FOUND') { + return toolError( + 'The transaction hash could not be bound to the durable payment attempt.', + ); + } + + let verification; + try { + verification = await userWalletVerifier.verify({ + transactionHash, + walletAddress: job.user_payment.payer_wallet, + recipient: job.user_payment.recipient, + amountAtomic: job.user_payment.amount_atomic, + }); + } catch { + return toolError( + 'Arc receipt verification is temporarily unavailable; the hash is recorded and no retry was submitted.', + ); + } + + if (verification.kind === 'CONFIRMED') { + await ledger.completeSubmission(job.business_intent_id, begun.attemptId, { + kind: 'CONFIRMED', + provider_reference_id: asProviderReferenceId(`user-wallet:${transactionHash}`), + transaction_hash: asTransactionHash(verification.transactionHash), + block_number: asBlockNumber(verification.blockNumber), + transfer_log_index: verification.transferLogIndex, + verified_by: 'ARC_RPC_EXACT_TRANSFER', + }); + } else if (verification.kind === 'FINAL_REVERT') { + await ledger.completeSubmission(job.business_intent_id, begun.attemptId, { + kind: 'DEFINITELY_NOT_SUBMITTED', + reason: verification.reason, + }); + } else { + await ledger.markUserWalletUnknown( + job.business_intent_id, + begun.attemptId, + verification.kind === 'PENDING' + ? 'User wallet transaction is not final; receipt is not available yet' + : verification.reason, + ); + } + const updated = await jobs.getByBusinessIntentId( + config.workspaceId, + job.business_intent_id, + ); + if (!updated) return toolError('Updated payment could not be read from durable storage.'); + return jsonResult(resultView(job.task_key, updated, false, config.signingAppUrl)); + } catch (error) { + if (error instanceof ContractValidationError) return toolError(error.message); + throw error; + } + }, + ); + + /* + * НЕ УДАЛЯТЬ: the former policy-bound Privy server-wallet MCP handler is + * intentionally disabled. Corporate autonomous-agent settlement may be + * restored later as a separate explicitly selected mode. It must never be + * used as a fallback for personal Privy/MetaMask payments. + */ + return server; + }); +} diff --git a/apps/api/src/privy-auth.ts b/apps/api/src/privy-auth.ts new file mode 100644 index 0000000..537e578 --- /dev/null +++ b/apps/api/src/privy-auth.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto'; +import { importSPKI, jwtVerify } from 'jose'; +import type { ServiceAuthenticator } from './auth.js'; + +export const PRIVY_ISSUER = 'privy.io'; +export const PRIVY_DID_PREFIX = 'did:privy:'; +export const PRIVY_ALLOW_ALL = '*'; + +const BEARER_PREFIX = 'Bearer '; +const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/; + +export interface PrivyAccessTokenAuthenticatorConfig { + readonly appId: string; + readonly verificationKey: string; + readonly allowedSubjects: readonly string[]; + readonly clockToleranceSeconds?: number; + readonly onForbiddenSubject?: (subject: string) => void; +} + +export function looksLikeJwt(token: string): boolean { + const parts = token.split('.'); + return parts.length === 3 && parts.every((part) => BASE64URL_SEGMENT.test(part)); +} + +function bearerToken(authorization: string | undefined): string | null { + if (!authorization?.startsWith(BEARER_PREFIX)) return null; + const token = authorization.slice(BEARER_PREFIX.length); + return token.length > 0 ? token : null; +} + +export function isJwtCredential(authorization: string | undefined): boolean { + const token = bearerToken(authorization); + return token !== null && looksLikeJwt(token); +} + +export function privyWorkspaceId(subject: string): string { + return `privy_${createHash('sha256').update(subject, 'utf8').digest('hex')}`; +} + +export function createPrivyAccessTokenAuthenticator( + config: PrivyAccessTokenAuthenticatorConfig, +): ServiceAuthenticator { + if (config.appId.trim().length === 0) throw new Error('Privy app id must not be empty'); + if (config.allowedSubjects.length === 0) { + throw new Error('Privy operator allowlist must not be empty'); + } + const allowAll = config.allowedSubjects.includes(PRIVY_ALLOW_ALL); + const allowed = allowAll ? null : new Set(config.allowedSubjects); + const clockTolerance = config.clockToleranceSeconds ?? 60; + const keyPromise = importSPKI(config.verificationKey, 'ES256'); + // An unusable key must not become an unhandled rejection before the first request. + void keyPromise.catch(() => undefined); + + return { + async authenticate(authorization) { + const token = bearerToken(authorization); + if (token === null || !looksLikeJwt(token)) return { decision: 'UNAUTHORIZED' }; + + let subject: string | undefined; + try { + const { payload } = await jwtVerify(token, await keyPromise, { + algorithms: ['ES256'], + issuer: PRIVY_ISSUER, + audience: config.appId, + clockTolerance, + }); + subject = payload.sub; + } catch { + return { decision: 'UNAUTHORIZED' }; + } + + if (subject === undefined || subject.length === 0 || !subject.startsWith(PRIVY_DID_PREFIX)) { + return { decision: 'UNAUTHORIZED' }; + } + if (allowed !== null && !allowed.has(subject)) { + config.onForbiddenSubject?.(subject); + return { decision: 'FORBIDDEN' }; + } + return { decision: 'AUTHORIZED', workspaceId: privyWorkspaceId(subject) }; + }, + }; +} diff --git a/apps/api/src/rate-limit.ts b/apps/api/src/rate-limit.ts new file mode 100644 index 0000000..e99e00d --- /dev/null +++ b/apps/api/src/rate-limit.ts @@ -0,0 +1,81 @@ +import type { Pool } from 'pg'; + +export interface RateLimitInput { + readonly correlationId: string; + readonly key: string; + readonly route: string; +} + +export interface RateLimiter { + allow(input: RateLimitInput): Promise; +} + +export interface PostgresRateLimiterOptions { + readonly maxRequests: number; + readonly windowMs: number; + readonly now?: () => number; + readonly cleanupIntervalMs?: number; +} + +/** Shared fixed-window admission control for horizontally scaled API nodes. */ +export class PostgresRateLimiter implements RateLimiter { + readonly #pool: Pool; + readonly #maxRequests: number; + readonly #windowMs: number; + readonly #now: () => number; + readonly #cleanupIntervalMs: number; + #nextCleanupAt = 0; + + constructor(pool: Pool, options: PostgresRateLimiterOptions) { + if (!Number.isSafeInteger(options.maxRequests) || options.maxRequests < 1) { + throw new Error('Rate limiter maxRequests must be a positive integer'); + } + if (!Number.isSafeInteger(options.windowMs) || options.windowMs < 1_000) { + throw new Error('Rate limiter windowMs must be at least 1000 milliseconds'); + } + this.#pool = pool; + this.#maxRequests = options.maxRequests; + this.#windowMs = options.windowMs; + this.#now = options.now ?? Date.now; + this.#cleanupIntervalMs = options.cleanupIntervalMs ?? Math.max(options.windowMs, 60_000); + } + + async allow(input: RateLimitInput): Promise { + const now = this.#now(); + const bucketStart = new Date(Math.floor(now / this.#windowMs) * this.#windowMs); + try { + const result = await this.#pool.query<{ request_count: number }>( + `INSERT INTO api_rate_limit_buckets + (bucket_start, client_key, route, request_count) + VALUES ($1, $2, $3, 1) + ON CONFLICT (bucket_start, client_key, route) + DO UPDATE SET request_count = LEAST( + api_rate_limit_buckets.request_count + 1, + $4 + 1 + ) + RETURNING request_count`, + [bucketStart, input.key, input.route, this.#maxRequests], + ); + const requestCount = Number(result.rows[0]?.request_count ?? this.#maxRequests + 1); + + if (now >= this.#nextCleanupAt) { + this.#nextCleanupAt = now + this.#cleanupIntervalMs; + void this.#pool + .query('DELETE FROM api_rate_limit_buckets WHERE bucket_start < $1', [ + new Date(now - this.#windowMs * 2), + ]) + .catch(() => undefined); + } + return requestCount <= this.#maxRequests; + } catch { + // Admission control fails closed if its durable store is unavailable. + return false; + } + } +} + +export const allowAllRateLimiter: RateLimiter = { + async allow() { + return true; + }, +}; diff --git a/apps/api/src/runtime.ts b/apps/api/src/runtime.ts new file mode 100644 index 0000000..5b62370 --- /dev/null +++ b/apps/api/src/runtime.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto'; +import { IntentLedger, JobLedger, McpCredentialStore, migrate } from '@oneshot/storage-postgres'; +import { createUserWalletVerificationPort } from './user-wallet.js'; +import { TeamReportSupplier } from '@oneshot/supplier-adapter'; +import { Pool } from 'pg'; +import { buildApi } from './app.js'; +import { StudioWalletActivityPort } from './wallet-activity.js'; +import { + compositeAuthenticator, + staticBearerAuthenticator, + workspaceBearerAuthenticator, + type ServiceAuthenticator, +} from './auth.js'; +import { loadApiRuntimeConfig, type ApiRuntimeConfig } from './config.js'; +import { createPrivyAccessTokenAuthenticator, isJwtCredential } from './privy-auth.js'; +import { PostgresRateLimiter } from './rate-limit.js'; + +export interface ApiRuntime { + readonly address: string; + close(): Promise; +} + +export function buildApiAuthenticator( + config: ApiRuntimeConfig, + log: (line: string) => void = (line) => process.stderr.write(line), +): ServiceAuthenticator { + const bearer = staticBearerAuthenticator(config.serviceBearerToken); + if (!config.privyAuth) return bearer; + + const privy = createPrivyAccessTokenAuthenticator({ + ...config.privyAuth, + onForbiddenSubject: (subject) => { + log(`privy operator is not allowlisted: ${subject}\n`); + }, + }); + + return compositeAuthenticator([ + { matches: isJwtCredential, authenticator: privy }, + { matches: (authorization) => !isJwtCredential(authorization), authenticator: bearer }, + ]); +} + +export async function startApiRuntime(config: ApiRuntimeConfig): Promise { + const pool = new Pool(config.database); + try { + await migrate(pool); + const ledger = new IntentLedger(pool, { + now: () => new Date(), + nextAttemptId: randomUUID, + }); + const jobs = new JobLedger(pool, { now: () => new Date(), nextAttemptId: randomUUID }); + const supplier = new TeamReportSupplier(); + const mcpCredentials = new McpCredentialStore(pool); + const app = buildApi({ + ledger, + jobs, + supplier, + ...(config.walletActivity + ? { walletActivity: new StudioWalletActivityPort(config.walletActivity) } + : {}), + ...(config.userWalletRpcUrl + ? { + userWalletVerifier: createUserWalletVerificationPort({ + rpcUrl: config.userWalletRpcUrl, + }), + } + : {}), + authenticator: buildApiAuthenticator(config), + mcpCredentials, + ...(config.mcp + ? { + mcp: { + authenticator: compositeAuthenticator([ + ...(config.mcp.bearerToken + ? [ + { + matches: () => true, + authenticator: staticBearerAuthenticator(config.mcp.bearerToken), + }, + ] + : []), + { + matches: () => true, + authenticator: workspaceBearerAuthenticator(mcpCredentials), + }, + ]), + workspaceId: config.mcp.workspaceId, + ...(config.mcp.signingAppUrl ? { signingAppUrl: config.mcp.signingAppUrl } : {}), + submissionsDisabled: config.submissionsDisabled, + /* + * НЕ УДАЛЯТЬ: config.mcp.payerWallet and config.mcp.waitMs are + * retained for the disabled corporate server-wallet mode. The + * active MCP handler intentionally receives no server payer. + */ + }, + } + : {}), + rateLimiter: new PostgresRateLimiter(pool, config.rateLimit), + config: { + submissionsDisabled: config.submissionsDisabled, + chainId: '5042002', + network: 'eip155:5042002', + contractVersion: '1.0.0', + ...(config.workspaceId ? { workspaceId: config.workspaceId } : {}), + }, + }); + const address = await app.listen({ host: config.host, port: config.port }); + return { + address, + async close() { + await app.close(); + await pool.end(); + }, + }; + } catch (error) { + await pool.end(); + throw error; + } +} + +export async function startApiFromEnvironment( + environment: NodeJS.ProcessEnv = process.env, +): Promise { + return startApiRuntime(loadApiRuntimeConfig(environment)); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..4e82512 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,25 @@ +import { startApiFromEnvironment } from './runtime.js'; + +const runtime = await startApiFromEnvironment(); +let shuttingDown = false; + +async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; + await runtime.close(); +} + +function requestShutdown(): void { + void shutdown().catch(() => { + process.exitCode = 1; + }); +} + +process.once('SIGTERM', () => { + requestShutdown(); +}); +process.once('SIGINT', () => { + requestShutdown(); +}); + +process.stdout.write(`OneShot API listening at ${runtime.address}\n`); diff --git a/apps/api/src/user-wallet.ts b/apps/api/src/user-wallet.ts new file mode 100644 index 0000000..28a27c0 --- /dev/null +++ b/apps/api/src/user-wallet.ts @@ -0,0 +1,66 @@ +import { + ARC_TESTNET, + createArcReceiptSource, + verifyReceipt, + type TransactionReceipt, +} from '@oneshot/arc-adapter'; + +export interface UserWalletVerificationRequest { + readonly transactionHash: string; + readonly walletAddress: string; + readonly recipient: string; + readonly amountAtomic: string; +} + +export type UserWalletVerificationResult = + | { + readonly kind: 'CONFIRMED'; + readonly transactionHash: string; + readonly blockNumber: string; + readonly transferLogIndex: number; + } + | { readonly kind: 'PENDING' } + | { readonly kind: 'FINAL_REVERT'; readonly reason: string } + | { readonly kind: 'NOT_CONFIRMED'; readonly reason: string }; + +export interface UserWalletVerificationPort { + verify(request: UserWalletVerificationRequest): Promise; +} + +function verifyReceiptResult( + receipt: TransactionReceipt, + request: UserWalletVerificationRequest, +): UserWalletVerificationResult { + const verdict = verifyReceipt(receipt, { + chainId: ARC_TESTNET.chainId, + walletAddress: request.walletAddress, + tokenContract: ARC_TESTNET.tokenContract, + recipient: request.recipient, + amountAtomic: BigInt(request.amountAtomic), + }); + if (verdict.result === 'CONFIRMED') { + return { + kind: 'CONFIRMED', + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber.toString(10), + transferLogIndex: verdict.transferLogIndex, + }; + } + if (verdict.result === 'FINAL_REVERT') { + return { kind: 'FINAL_REVERT', reason: verdict.detail }; + } + return { kind: 'NOT_CONFIRMED', reason: verdict.detail }; +} + +export function createUserWalletVerificationPort(options: { + readonly rpcUrl: string; + readonly rpcTimeoutMs?: number; +}): UserWalletVerificationPort { + const source = createArcReceiptSource(options); + return { + async verify(request) { + const receipt = await source.getReceipt(request.transactionHash); + return receipt ? verifyReceiptResult(receipt, request) : { kind: 'PENDING' }; + }, + }; +} diff --git a/apps/api/src/wallet-activity.ts b/apps/api/src/wallet-activity.ts new file mode 100644 index 0000000..cb118ee --- /dev/null +++ b/apps/api/src/wallet-activity.ts @@ -0,0 +1,156 @@ +import { asEvmAddress, asTransactionHash } from '@oneshot/contracts'; + +export interface WalletActivitySnapshot { + readonly freshness: 'FRESH' | 'LAGGING' | 'UNHEALTHY' | 'UNAVAILABLE' | 'UNKNOWN_FRESHNESS'; + readonly coverageNote: string; + readonly payload: { + readonly deployment?: string; + readonly transfers: readonly { + readonly transaction_hash: string; + readonly log_index: number; + readonly sender?: string; + readonly token_contract?: string; + readonly block_number?: string; + readonly block_timestamp?: string; + readonly network?: 'eip155:5042002'; + readonly recipient: string; + readonly amount_atomic: string; + }[]; + }; +} + +export interface WalletActivityPort { + refresh(wallets?: readonly string[]): Promise; +} + +const QUERY = `query OneShotWalletActivity($senders: [Bytes!]!) { settlementCandidates(first: 100, orderBy: blockNumber, orderDirection: desc, where: { sender_in: $senders }) { transactionHash logIndex sender tokenContract blockNumber blockTimestamp network recipient amountAtomic } _meta { deployment hasIndexingErrors block { number } } }`; + +function graphBlockTimestamp(value: unknown): string | undefined { + if (value === undefined) return undefined; + if ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/u.test(value) + ) { + return value; + } + if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/u.test(value)) { + throw new Error('Graph activity block timestamp failed validation'); + } + const seconds = Number(value); + if (!Number.isSafeInteger(seconds) || seconds < 0) { + throw new Error('Graph activity block timestamp failed validation'); + } + const timestamp = new Date(seconds * 1_000); + if (Number.isNaN(timestamp.getTime())) { + throw new Error('Graph activity block timestamp failed validation'); + } + return timestamp.toISOString(); +} + +export class StudioWalletActivityPort implements WalletActivityPort { + constructor( + private readonly options: { + readonly endpoint: string; + readonly wallet?: string; + readonly apiKey?: string; + readonly fetchFn?: typeof fetch; + }, + ) {} + async refresh(wallets: readonly string[] = []): Promise { + const senders = [...(this.options.wallet ? [this.options.wallet] : []), ...wallets].reduce< + string[] + >((unique, wallet) => { + const address = asEvmAddress(wallet).toLowerCase(); + if (!unique.includes(address)) unique.push(address); + return unique; + }, []); + if (senders.length === 0) { + return { + freshness: 'UNAVAILABLE', + coverageNote: 'No site payer wallet is recorded for this workspace yet.', + payload: { transfers: [] }, + }; + } + const response = await (this.options.fetchFn ?? fetch)(this.options.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(this.options.apiKey ? { authorization: `Bearer ${this.options.apiKey}` } : {}), + }, + body: JSON.stringify({ + query: QUERY, + variables: { senders }, + }), + }); + if (!response.ok) throw new Error('Graph activity query is unavailable'); + const body = (await response.json()) as { + data?: { + settlementCandidates?: unknown; + _meta?: { deployment?: unknown; hasIndexingErrors?: unknown; block?: { number?: unknown } }; + }; + }; + const data = body.data; + if ( + !data || + !Array.isArray(data.settlementCandidates) || + data.settlementCandidates.length > 100 + ) + throw new Error('Graph activity response failed validation'); + const transfers = data.settlementCandidates.map((entry) => { + if (!entry || typeof entry !== 'object') + throw new Error('Graph activity entry failed validation'); + const row = entry as Record; + const index = Number(row.logIndex); + if ( + !Number.isSafeInteger(index) || + index < 0 || + typeof row.amountAtomic !== 'string' || + !/^(0|[1-9][0-9]*)$/u.test(row.amountAtomic) + ) + throw new Error('Graph activity entry failed validation'); + return { + transaction_hash: asTransactionHash(row.transactionHash), + log_index: index, + ...(typeof row.sender === 'string' ? { sender: asEvmAddress(row.sender) } : {}), + ...(typeof row.tokenContract === 'string' + ? { token_contract: asEvmAddress(row.tokenContract) } + : {}), + ...(typeof row.blockNumber === 'string' && /^(0|[1-9][0-9]*)$/u.test(row.blockNumber) + ? { block_number: row.blockNumber } + : {}), + ...(row.blockTimestamp === undefined + ? {} + : { block_timestamp: graphBlockTimestamp(row.blockTimestamp)! }), + ...(row.network === 'eip155:5042002' ? { network: 'eip155:5042002' as const } : {}), + recipient: asEvmAddress(row.recipient), + amount_atomic: row.amountAtomic, + }; + }); + const meta = data._meta; + const deployment = typeof meta?.deployment === 'string' ? meta.deployment : undefined; + return { + freshness: + meta?.hasIndexingErrors === true || !deployment + ? 'UNHEALTHY' + : transfers.length === 100 + ? 'LAGGING' + : 'FRESH', + coverageNote: + transfers.length === 100 + ? 'Newest 100 indexed transfers only; query pagination is required for full history.' + : `Indexed sender activity through block ${typeof meta?.block?.number === 'number' ? meta.block.number : 'not reported'}.`, + payload: { ...(deployment ? { deployment } : {}), transfers }, + }; + } +} + +export class UnavailableWalletActivityPort implements WalletActivityPort { + async refresh(): Promise { + return { + freshness: 'UNAVAILABLE', + coverageNote: + 'Graph activity is not configured; recorded settlement state remains available.', + payload: { transfers: [] }, + }; + } +} diff --git a/apps/api/test/api.integration.test.ts b/apps/api/test/api.integration.test.ts new file mode 100644 index 0000000..732b0d9 --- /dev/null +++ b/apps/api/test/api.integration.test.ts @@ -0,0 +1,174 @@ +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import { derivedBusinessIntentId } from '@oneshot/domain'; +import { IntentLedger, JobLedger, migrate } from '@oneshot/storage-postgres'; +import { TeamReportSupplier } from '@oneshot/supplier-adapter'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApi, startApiRuntime, staticBearerAuthenticator } from '../src/index.js'; + +const describePostgres = process.env.TEST_POSTGRES === '1' ? describe : describe.skip; +const request = { + business_intent_id: 'intent-http-concurrent', + recipient: '0x2222222222222222222222222222222222222222', + amount_atomic: '2500000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Concurrent invoice', +}; + +describePostgres('durable HTTP API', () => { + let container!: StartedPostgreSqlContainer; + let pool!: Pool; + let attempts = 0; + + beforeAll(async () => { + container = await new PostgreSqlContainer('postgres:16.4-alpine').start(); + pool = new Pool({ connectionString: container.getConnectionUri(), max: 20 }); + await migrate(pool); + }); + + afterAll(async () => { + if (typeof pool !== 'undefined') await pool.end(); + if (typeof container !== 'undefined') await container.stop(); + }); + + const ledger = () => + new IntentLedger(pool, { + now: () => new Date('2026-09-07T12:00:00.000Z'), + nextAttemptId: () => `http-attempt-${++attempts}`, + }); + + it('persists one intent across duplicate POSTs and API restart', async () => { + const firstApp = buildApi({ + ledger: ledger(), + authenticator: staticBearerAuthenticator('integration-token'), + nextCorrelationId: () => 'correlation-http', + }); + const responses = await Promise.all( + Array.from({ length: 10 }, () => + firstApp.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: 'Bearer integration-token' }, + payload: request, + }), + ), + ); + expect(responses.filter((response) => response.statusCode === 202)).toHaveLength(1); + expect(responses.filter((response) => response.statusCode === 200)).toHaveLength(9); + await firstApp.close(); + + const restartedApp = buildApi({ + ledger: ledger(), + authenticator: staticBearerAuthenticator('integration-token'), + nextCorrelationId: () => 'correlation-after-restart', + }); + const status = await restartedApp.inject({ + method: 'GET', + url: `/v1/intents/${request.business_intent_id}`, + headers: { authorization: 'Bearer integration-token' }, + }); + expect(status.statusCode).toBe(200); + expect(status.json()).toMatchObject({ + business_intent_id: request.business_intent_id, + state: 'AUTHORIZING', + version: 1, + }); + await restartedApp.close(); + }); + + it('starts the executable server and reports database readiness', async () => { + const runtime = await startApiRuntime({ + host: '127.0.0.1', + port: 0, + serviceBearerToken: 'integration-token', + database: { connectionString: container.getConnectionUri() }, + submissionsDisabled: false, + rateLimit: { maxRequests: 60, windowMs: 60_000 }, + }); + try { + const response = await fetch(`${runtime.address}/health/ready`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'ok' }); + } finally { + await runtime.close(); + } + }); + + it('converges parallel MCP calls on one durable intent and zero direct settlements', async () => { + const mcpToken = 'integration-mcp-token-with-32-characters'; + const requestKey = 'integration-arc-payment'; + const jobs = new JobLedger(pool, { + now: () => new Date('2026-09-07T12:00:00.000Z'), + nextAttemptId: () => `http-user-wallet-attempt-${++attempts}`, + }); + const app = buildApi({ + ledger: ledger(), + jobs, + supplier: new TeamReportSupplier(), + authenticator: staticBearerAuthenticator('integration-token'), + mcp: { + authenticator: staticBearerAuthenticator(mcpToken), + workspaceId: 'integration-mcp-workspace', + waitMs: 0, + }, + }); + const call = (amount = '1') => + app.inject({ + method: 'POST', + url: '/mcp', + headers: { + authorization: `Bearer ${mcpToken}`, + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + 'mcp-protocol-version': '2025-06-18', + }, + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'arc_payment', + arguments: { + request_key: requestKey, + payer_wallet: '0x1111111111111111111111111111111111111111', + recipient: '0x2222222222222222222222222222222222222222', + amount_usdc: amount, + purpose: 'One integration payment', + }, + }, + }, + }); + + const responses = await Promise.all(Array.from({ length: 10 }, () => call())); + expect(responses.every((response) => response.statusCode === 200)).toBe(true); + const businessIntentId = derivedBusinessIntentId('integration-mcp-workspace', { + task_key: requestKey, + tool_id: 'team-report-v1', + report_subject: 'One integration payment', + recipient: '0x2222222222222222222222222222222222222222', + amount_atomic: '1000000', + }); + const counts = await pool.query<{ + intents: string; + attempts: string; + settlements: string; + }>( + `SELECT + (SELECT count(*) FROM business_intents WHERE business_intent_id = $1)::text AS intents, + (SELECT count(*) FROM attempts WHERE business_intent_id = $1)::text AS attempts, + (SELECT count(*) FROM settlements WHERE business_intent_id = $1)::text AS settlements`, + [businessIntentId], + ); + expect(counts.rows[0]).toEqual({ intents: '1', attempts: '1', settlements: '0' }); + + const conflict = await call('0.5'); + expect(conflict.body).toContain('different user-wallet payment'); + const afterConflict = await pool.query<{ count: string }>( + 'SELECT count(*)::text AS count FROM business_intents WHERE business_intent_id = $1', + [businessIntentId], + ); + expect(afterConflict.rows[0]?.count).toBe('1'); + await app.close(); + }); +}); diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts new file mode 100644 index 0000000..ada20a9 --- /dev/null +++ b/apps/api/test/app.test.ts @@ -0,0 +1,990 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + CreateJobRequest, + IntentResponse, + JobView, + RecoveryView, + ReconcileResponse, +} from '@oneshot/contracts'; +import type { CreateIntentResult, IntentLedger } from '@oneshot/storage-postgres'; +import { buildApi, staticBearerAuthenticator, type ApiDependencies } from '../src/index.js'; + +const request = { + business_intent_id: 'intent-api-1', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1250000', + asset: 'USDC' as const, + network: 'eip155:5042002' as const, + purpose: 'Invoice INV-1001', +}; +const intent: IntentResponse = { + ...request, + payload_fingerprint: 'a'.repeat(64), + state: 'AUTHORIZING', + version: 1, + attempts: [ + { + attempt_id: 'attempt-api-1', + stage: 'AUTHORIZING', + created_at: '2026-09-07T12:00:00.000Z', + }, + ], + evidence: [], +}; + +const USER_WALLET_PAYER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const USER_WALLET_HASH = `0x${'f'.repeat(64)}`; + +function userWalletJobFixture(overrides: Partial = {}): JobView { + return { + job_id: `job_${'e'.repeat(64)}`, + task_key: 'report-user-wallet', + tool_id: 'team-report-v1', + business_intent_id: `intent_${'f'.repeat(64)}`, + supplier: { + supplier_id: 'team-report-v1', + order_reference: 'team_report_user_wallet', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1000000', + asset: 'USDC', + network: 'eip155:5042002', + expires_at: '2026-09-12T12:00:00.000Z', + }, + payment_state: 'READY', + payment_mode: 'USER_WALLET', + user_payment: { + chain_id: 5042002, + network: 'eip155:5042002', + token_contract: '0x3600000000000000000000000000000000000000', + payer_wallet: USER_WALLET_PAYER, + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1000000', + }, + delivery_state: 'NOT_REQUESTED', + created_at: '2026-09-12T11:00:00.000Z', + updated_at: '2026-09-12T11:00:00.000Z', + ...overrides, + }; +} + +function createMockLedger( + overrides: Partial = {}, +): Pick< + IntentLedger, + 'createOrReplay' | 'enqueueReconciliation' | 'getIntent' | 'getRecoveryView' | 'ping' +> { + return { + async createOrReplay() { + return { kind: 'ACCEPTED', intent } as CreateIntentResult; + }, + async enqueueReconciliation(): Promise { + return { business_intent_id: intent.business_intent_id, queued: true, state: 'UNKNOWN' }; + }, + async getIntent() { + return intent; + }, + async getRecoveryView(): Promise { + return { + business_intent_id: intent.business_intent_id, + authoritative_state: intent.state, + recommended_action: 'WAIT', + evidence: [], + }; + }, + async ping() {}, + ...overrides, + }; +} + +describe('API boundary controls', () => { + it('uses the authenticated principal workspace for request listings', async () => { + const workspaces: string[] = []; + const app = buildApi({ + ledger: createMockLedger(), + jobs: { + async list(workspaceId: string) { + workspaces.push(workspaceId); + return []; + }, + } as unknown as ApiDependencies['jobs'], + authenticator: { + async authenticate(authorization) { + return { + decision: 'AUTHORIZED' as const, + workspaceId: authorization === 'Bearer alice' ? 'privy_alice' : 'privy_bob', + }; + }, + }, + config: { workspaceId: 'shared-fallback' }, + }); + + await app.inject({ + method: 'GET', + url: '/v1/jobs', + headers: { authorization: 'Bearer alice' }, + }); + await app.inject({ method: 'GET', url: '/v1/jobs', headers: { authorization: 'Bearer bob' } }); + + expect(workspaces).toEqual(['privy_alice', 'privy_bob']); + await app.close(); + }); + + it('issues one personal MCP bearer for the authenticated Privy workspace', async () => { + const issue = vi.fn(async () => ({ + bearerToken: 'a'.repeat(43), + createdAt: '2026-09-13T04:00:00.000Z', + })); + const app = buildApi({ + ledger: createMockLedger(), + authenticator: { + async authenticate() { + return { decision: 'AUTHORIZED' as const, workspaceId: 'privy_alice' }; + }, + }, + mcpCredentials: { + async status() { + return { configured: false }; + }, + issue, + }, + mcp: { + authenticator: staticBearerAuthenticator('legacy-mcp-token'), + workspaceId: 'legacy-workspace', + payerWallet: '0x1111111111111111111111111111111111111111', + }, + }); + const headers = { authorization: 'Bearer privy-jwt' }; + + expect( + (await app.inject({ method: 'GET', url: '/v1/profile/mcp-token', headers })).json(), + ).toEqual({ configured: false }); + const created = await app.inject({ + method: 'POST', + url: '/v1/profile/mcp-token', + headers, + }); + expect(created.statusCode).toBe(201); + expect(created.json()).toEqual({ + bearer_token: 'a'.repeat(43), + created_at: '2026-09-13T04:00:00.000Z', + }); + expect(issue).toHaveBeenCalledWith('privy_alice'); + await app.close(); + }); + + it('never reaches the ledger when the credential is forbidden', async () => { + const calls: string[] = []; + const app = buildApi({ + ledger: createMockLedger({ + async enqueueReconciliation() { + calls.push('enqueueReconciliation'); + return { business_intent_id: 'intent-api-1', queued: true, state: 'UNKNOWN' }; + }, + }), + authenticator: { + async authenticate() { + return { decision: 'FORBIDDEN' as const }; + }, + }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/intents/intent-api-1/reconcile', + headers: { authorization: 'Bearer aaa.bbb.ccc' }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().code).toBe('FORBIDDEN'); + expect(calls).toEqual([]); + await app.close(); + }); + + it('requires service authentication and returns a sanitized error', async () => { + const app = buildApi({ + ledger: createMockLedger(), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-generated', + }); + const response = await app.inject({ method: 'GET', url: '/v1/intents/intent-api-1' }); + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + code: 'UNAUTHORIZED', + message: 'Service authentication failed', + correlation_id: 'correlation-generated', + }); + await app.close(); + }); + + it('validates schemas without echoing rejected payload material', async () => { + const app = buildApi({ + ledger: createMockLedger(), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-validation', + }); + const response = await app.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: 'Bearer test-token' }, + payload: { ...request, amount_atomic: '1.5', purpose: 'do-not-echo-this' }, + }); + expect(response.statusCode).toBe(400); + expect(response.body).not.toContain('do-not-echo-this'); + expect(response.json()).toMatchObject({ code: 'INVALID_REQUEST' }); + await app.close(); + }); + + it('enforces the request-size and rate-limit seams', async () => { + const sizeLimited = buildApi({ + ledger: createMockLedger(), + authenticator: staticBearerAuthenticator('test-token'), + bodyLimitBytes: 32, + nextCorrelationId: () => 'correlation-size', + }); + const tooLarge = await sizeLimited.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: 'Bearer test-token' }, + payload: request, + }); + expect(tooLarge.statusCode).toBe(400); + await sizeLimited.close(); + + const rateLimited = buildApi({ + ledger: createMockLedger(), + authenticator: staticBearerAuthenticator('test-token'), + rateLimiter: { + async allow() { + return false; + }, + }, + nextCorrelationId: () => 'correlation-rate', + }); + const blocked = await rateLimited.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: 'Bearer test-token' }, + payload: request, + }); + expect(blocked.statusCode).toBe(429); + expect(blocked.json()).toMatchObject({ code: 'RATE_LIMITED' }); + await rateLimited.close(); + }); + + it('maps payload conflict to a stable 409 body', async () => { + const app = buildApi({ + ledger: createMockLedger({ + async createOrReplay() { + return { kind: 'INTENT_PAYLOAD_CONFLICT', intent }; + }, + }), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-conflict', + }); + const response = await app.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: 'Bearer test-token' }, + payload: request, + }); + expect(response.statusCode).toBe(409); + expect(response.json()).toEqual({ + code: 'INTENT_PAYLOAD_CONFLICT', + message: 'Business Intent already exists with a different immutable payload', + correlation_id: 'correlation-conflict', + }); + await app.close(); + }); + + it('rejects malformed correlation IDs with 400 INVALID_REQUEST', async () => { + const app = buildApi({ + ledger: createMockLedger(), + authenticator: staticBearerAuthenticator('test-token'), + }); + const response = await app.inject({ + method: 'GET', + url: '/v1/intents/intent-api-1', + headers: { + authorization: 'Bearer test-token', + 'x-correlation-id': ' invalid correlation id', + }, + }); + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ code: 'INVALID_REQUEST' }); + await app.close(); + }); +}); + +describe('OpenAPI contract endpoints', () => { + it('POST /v1/intents returns 202 for new intent and 200 for identical replay', async () => { + let mode: 'ACCEPTED' | 'REPLAY_IDENTICAL' = 'ACCEPTED'; + const app = buildApi({ + ledger: createMockLedger({ + async createOrReplay() { + return { kind: mode, intent }; + }, + }), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-post', + }); + + const acceptedResponse = await app.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: 'Bearer test-token', 'x-correlation-id': 'custom-cid-1' }, + payload: request, + }); + expect(acceptedResponse.statusCode).toBe(202); + expect(acceptedResponse.headers['x-correlation-id']).toBe('custom-cid-1'); + expect(acceptedResponse.json()).toEqual(intent); + + mode = 'REPLAY_IDENTICAL'; + const replayedResponse = await app.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: 'Bearer test-token' }, + payload: request, + }); + expect(replayedResponse.statusCode).toBe(200); + expect(replayedResponse.json()).toEqual(intent); + await app.close(); + }); + + it('GET /v1/intents/:id returns 200 or 404 when not found', async () => { + const app = buildApi({ + ledger: createMockLedger({ + async getIntent(id) { + return id === 'intent-api-1' ? intent : undefined; + }, + }), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-get', + }); + + const found = await app.inject({ + method: 'GET', + url: '/v1/intents/intent-api-1', + headers: { authorization: 'Bearer test-token' }, + }); + expect(found.statusCode).toBe(200); + expect(found.json()).toEqual(intent); + + const notFound = await app.inject({ + method: 'GET', + url: '/v1/intents/missing-id', + headers: { authorization: 'Bearer test-token' }, + }); + expect(notFound.statusCode).toBe(404); + expect(notFound.json()).toMatchObject({ + code: 'INTENT_NOT_FOUND', + message: 'Business Intent was not found', + }); + await app.close(); + }); + + it('POST /v1/intents/:id/reconcile returns 202, 404, or 409', async () => { + let reconcileResult: ReconcileResponse | undefined = { + business_intent_id: 'intent-api-1', + queued: true, + state: 'UNKNOWN', + }; + const app = buildApi({ + ledger: createMockLedger({ + async enqueueReconciliation() { + return reconcileResult; + }, + }), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-reconcile', + }); + + const queued = await app.inject({ + method: 'POST', + url: '/v1/intents/intent-api-1/reconcile', + headers: { authorization: 'Bearer test-token' }, + }); + expect(queued.statusCode).toBe(202); + expect(queued.json()).toEqual(reconcileResult); + + reconcileResult = { business_intent_id: 'intent-api-1', queued: false, state: 'COMMITTED' }; + const notAllowed = await app.inject({ + method: 'POST', + url: '/v1/intents/intent-api-1/reconcile', + headers: { authorization: 'Bearer test-token' }, + }); + expect(notAllowed.statusCode).toBe(409); + expect(notAllowed.json()).toMatchObject({ code: 'RECONCILIATION_NOT_ALLOWED' }); + + reconcileResult = undefined; + const notFound = await app.inject({ + method: 'POST', + url: '/v1/intents/missing-id/reconcile', + headers: { authorization: 'Bearer test-token' }, + }); + expect(notFound.statusCode).toBe(404); + expect(notFound.json()).toMatchObject({ code: 'INTENT_NOT_FOUND' }); + await app.close(); + }); + + it('GET /v1/intents/:id/recovery-view returns 200 or 404', async () => { + const app = buildApi({ + ledger: createMockLedger({ + async getRecoveryView(id) { + if (id !== 'intent-api-1') return undefined; + return { + business_intent_id: 'intent-api-1', + authoritative_state: 'COMMITTED', + recommended_action: 'RETURN_EXISTING_RESULT', + evidence: [], + }; + }, + }), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-recovery', + }); + + const found = await app.inject({ + method: 'GET', + url: '/v1/intents/intent-api-1/recovery-view', + headers: { authorization: 'Bearer test-token' }, + }); + expect(found.statusCode).toBe(200); + expect(found.json()).toEqual({ + business_intent_id: 'intent-api-1', + authoritative_state: 'COMMITTED', + recommended_action: 'RETURN_EXISTING_RESULT', + evidence: [], + }); + + const notFound = await app.inject({ + method: 'GET', + url: '/v1/intents/missing/recovery-view', + headers: { authorization: 'Bearer test-token' }, + }); + expect(notFound.statusCode).toBe(404); + expect(notFound.json()).toMatchObject({ code: 'INTENT_NOT_FOUND' }); + await app.close(); + }); + + it('classifies an empty JSON reconcile body as a client error and logs only safe identity', async () => { + const errors: Array> = []; + const app = buildApi({ + ledger: createMockLedger(), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-empty-body', + onError: (error) => errors.push(error), + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/intents/intent-api-1/reconcile?token=must-not-log', + headers: { + authorization: 'Bearer test-token', + 'content-type': 'application/json', + }, + payload: '', + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + code: 'INVALID_REQUEST', + correlation_id: 'correlation-empty-body', + }); + expect(errors).toEqual([ + { + correlationId: 'correlation-empty-body', + method: 'POST', + path: '/v1/intents/intent-api-1/reconcile', + code: 'FST_ERR_CTP_EMPTY_JSON_BODY', + }, + ]); + await app.close(); + }); + + it('GET /health/live and /health/ready reflect status without authentication', async () => { + let pingHealthy = true; + const app = buildApi({ + ledger: createMockLedger({ + async ping() { + if (!pingHealthy) throw new Error('DB connection refused'); + }, + }), + authenticator: staticBearerAuthenticator('test-token'), + nextCorrelationId: () => 'correlation-health', + }); + + const live = await app.inject({ method: 'GET', url: '/health/live' }); + expect(live.statusCode).toBe(200); + expect(live.json()).toEqual({ status: 'ok' }); + + const ready = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(ready.statusCode).toBe(200); + expect(ready.json()).toEqual({ status: 'ok' }); + + pingHealthy = false; + const notReady = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(notReady.statusCode).toBe(503); + expect(notReady.json()).toMatchObject({ code: 'NOT_READY' }); + await app.close(); + }); +}); + +describe('resumable job API boundary', () => { + it('creates, scopes, resumes, retrieves, and records activity without granting a settlement path', async () => { + let job: JobView = { + job_id: `job_${'c'.repeat(64)}`, + task_key: 'report-acme', + tool_id: 'team-report-v1', + business_intent_id: `intent_${'d'.repeat(64)}`, + supplier: { + supplier_id: 'team-report-v1', + order_reference: 'team_report_order_api', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '2500000', + asset: 'USDC', + network: 'eip155:5042002', + expires_at: '2026-09-08T12:00:00.000Z', + }, + payment_state: 'COMMITTED', + payment_mode: 'SERVER_PRIVY', + delivery_state: 'RETRIEVAL_FAILED', + created_at: '2026-09-07T12:00:00.000Z', + updated_at: '2026-09-07T12:01:00.000Z', + }; + const calls: Array<{ operation: string; workspaceId?: string }> = []; + let supplierRequest: unknown; + let createMode: 'ACCEPTED' | 'TASK_PAYLOAD_CONFLICT' = 'ACCEPTED'; + const jobs = { + async createOrReplay(params: { workspaceId: string }) { + calls.push({ operation: 'create', workspaceId: params.workspaceId }); + return createMode === 'ACCEPTED' + ? { kind: 'ACCEPTED' as const, job } + : { kind: 'TASK_PAYLOAD_CONFLICT' as const, job }; + }, + async get(workspaceId: string, jobId: string) { + calls.push({ operation: `get:${jobId}`, workspaceId }); + return jobId === job.job_id ? job : undefined; + }, + async list(workspaceId: string) { + calls.push({ operation: 'list', workspaceId }); + return [job]; + }, + async resumeDelivery(workspaceId: string, jobId: string) { + calls.push({ operation: `resume:${jobId}`, workspaceId }); + if (jobId !== job.job_id) return undefined; + job = { ...job, delivery_state: 'PENDING', updated_at: '2026-09-07T12:02:00.000Z' }; + return job; + }, + async recordActivityObservation(params: { workspaceId: string }) { + calls.push({ operation: 'observe', workspaceId: params.workspaceId }); + }, + async activity(workspaceId: string) { + calls.push({ operation: 'activity', workspaceId }); + return { + recorded_settlement_count: 1, + uncertain_job_count: 0, + unmatched_transfer_count: 0, + transactions: [], + transfers: [], + }; + }, + } as unknown as ApiDependencies['jobs']; + const app = buildApi({ + ledger: createMockLedger(), + jobs, + supplier: { + async createOrder(request: CreateJobRequest) { + supplierRequest = request; + return { + ...job.supplier, + supplier_payload_fingerprint: 'e'.repeat(64), + }; + }, + async fulfillOrder() { + throw new Error('API must not fulfill supplier orders'); + }, + async getResult() { + return null; + }, + }, + walletActivity: { + async refresh() { + return { freshness: 'FRESH', coverageNote: 'indexed', payload: { transfers: [] } }; + }, + }, + authenticator: staticBearerAuthenticator('test-token'), + config: { workspaceId: 'workspace-api-test' }, + nextCorrelationId: () => 'correlation-job-api', + }); + const headers = { authorization: 'Bearer test-token' }; + const payload = { + task_key: 'report-acme', + tool_id: 'team-report-v1', + report_subject: 'Acme', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '2500000', + }; + + const quote = await app.inject({ method: 'POST', url: '/v1/jobs/quote', headers, payload }); + expect(quote.statusCode).toBe(200); + expect(quote.json()).toEqual(job.supplier); + expect(supplierRequest).toEqual(payload); + expect(calls).toEqual([]); + + const created = await app.inject({ method: 'POST', url: '/v1/jobs', headers, payload }); + expect(created.statusCode).toBe(202); + expect(created.json()).toMatchObject({ job_id: job.job_id, payment_state: 'COMMITTED' }); + expect((await app.inject({ method: 'GET', url: '/v1/jobs', headers })).json()).toEqual({ + jobs: [job], + }); + expect( + (await app.inject({ method: 'GET', url: `/v1/jobs/${job.job_id}`, headers })).statusCode, + ).toBe(200); + + const unavailable = await app.inject({ + method: 'GET', + url: `/v1/jobs/${job.job_id}/result`, + headers, + }); + expect(unavailable.statusCode).toBe(409); + expect(unavailable.json()).toMatchObject({ code: 'RECONCILIATION_NOT_ALLOWED' }); + expect( + (await app.inject({ method: 'POST', url: `/v1/jobs/${job.job_id}/resume`, headers })) + .statusCode, + ).toBe(202); + + job = { + ...job, + delivery_state: 'AVAILABLE', + result: { + order_reference: 'team_report_order_api', + result_reference: 'team_report_result_api', + report: 'retrieved result', + }, + }; + expect( + (await app.inject({ method: 'GET', url: `/v1/jobs/${job.job_id}/result`, headers })).json(), + ).toEqual(job.result); + expect( + ( + await app.inject({ + method: 'POST', + url: '/v1/activity/refresh', + headers: { ...headers, 'content-type': 'application/json' }, + }) + ).statusCode, + ).toBe(202); + expect((await app.inject({ method: 'GET', url: '/v1/activity', headers })).json()).toEqual({ + recorded_settlement_count: 1, + uncertain_job_count: 0, + unmatched_transfer_count: 0, + transactions: [], + transfers: [], + }); + + createMode = 'TASK_PAYLOAD_CONFLICT'; + const conflict = await app.inject({ method: 'POST', url: '/v1/jobs', headers, payload }); + expect(conflict.statusCode).toBe(409); + expect( + calls.every( + (call) => call.workspaceId === undefined || call.workspaceId === 'workspace-api-test', + ), + ).toBe(true); + await app.close(); + }); + + it('prepares and confirms a user-wallet payment without invoking the server wallet', async () => { + const payer = USER_WALLET_PAYER; + const hash = USER_WALLET_HASH; + let job: JobView = userWalletJobFixture(); + const calls: string[] = []; + const jobs = { + async createUserWalletOrReplay() { + calls.push('prepare'); + return { kind: 'ACCEPTED' as const, job }; + }, + async get(_workspaceId: string, jobId: string) { + return jobId === job.job_id ? job : undefined; + }, + } as unknown as ApiDependencies['jobs']; + const ledger = { + ...createMockLedger(), + async beginUserWalletSubmission() { + calls.push('begin'); + return { + begun: true as const, + intent, + attemptId: 'attempt-user-wallet', + state: 'SUBMITTING' as const, + version: 2, + }; + }, + async recordUserWalletTransaction() { + calls.push('record'); + return 'RECORDED' as const; + }, + async completeSubmission() { + calls.push('complete'); + job = { + ...job, + payment_state: 'COMMITTED', + settlement: { + provider_reference_id: `user-wallet:${hash}`, + transaction_hash: hash, + block_number: '123', + transfer_log_index: 0, + }, + }; + return { completed: true as const, state: 'COMMITTED' as const, version: 3 }; + }, + async markUserWalletUnknown() { + calls.push('unknown'); + return { completed: true as const, state: 'UNKNOWN' as const, version: 3 }; + }, + } as unknown as ApiDependencies['ledger']; + const app = buildApi({ + ledger, + jobs, + supplier: { + async createOrder() { + return { ...job.supplier, supplier_payload_fingerprint: 'a'.repeat(64) }; + }, + async fulfillOrder() { + throw new Error('API must not fulfill supplier orders'); + }, + async getResult() { + return null; + }, + }, + userWalletVerifier: { + async verify() { + calls.push('verify'); + return { + kind: 'CONFIRMED' as const, + transactionHash: hash, + blockNumber: '123', + transferLogIndex: 0, + }; + }, + }, + authenticator: staticBearerAuthenticator('test-token'), + config: { workspaceId: 'workspace-user-wallet' }, + }); + const headers = { authorization: 'Bearer test-token' }; + const payload = { + task_key: 'report-user-wallet', + tool_id: 'team-report-v1', + report_subject: 'Acme', + recipient: job.supplier.recipient, + amount_atomic: job.supplier.amount_atomic, + payer_wallet: payer, + }; + + const prepared = await app.inject({ + method: 'POST', + url: '/v1/jobs/user-wallet/prepare', + headers, + payload, + }); + expect(prepared.statusCode).toBe(202); + expect(prepared.json()).toMatchObject({ payment_mode: 'USER_WALLET' }); + + const submitted = await app.inject({ + method: 'POST', + url: `/v1/jobs/${job.job_id}/user-wallet/submit`, + headers, + payload: { transaction_hash: hash }, + }); + expect(submitted.statusCode).toBe(200); + expect(submitted.json()).toMatchObject({ payment_state: 'COMMITTED' }); + expect(calls).toEqual(['prepare', 'begin', 'record', 'verify', 'complete']); + await app.close(); + }); + + it.each([ + { + label: 'a pending receipt', + verification: { kind: 'PENDING' as const }, + }, + { + label: 'a receipt without the expected Transfer', + verification: { kind: 'NOT_CONFIRMED' as const, reason: 'recipient or amount mismatch' }, + }, + ])('marks $label UNKNOWN without completing or retrying payment', async ({ verification }) => { + let job = userWalletJobFixture(); + const calls: string[] = []; + const jobs = { + async get(_workspaceId: string, jobId: string) { + return jobId === job.job_id ? job : undefined; + }, + } as unknown as ApiDependencies['jobs']; + const ledger = { + ...createMockLedger(), + async beginUserWalletSubmission() { + calls.push('begin'); + return { + begun: true as const, + intent, + attemptId: 'attempt-user-wallet-unknown', + state: 'SUBMITTING' as const, + version: 2, + }; + }, + async recordUserWalletTransaction() { + calls.push('record'); + return 'RECORDED' as const; + }, + async completeSubmission() { + calls.push('complete'); + throw new Error('UNKNOWN must not complete'); + }, + async markUserWalletUnknown() { + calls.push('unknown'); + job = { ...job, payment_state: 'UNKNOWN' }; + return { completed: true as const, state: 'UNKNOWN' as const, version: 3 }; + }, + } as unknown as ApiDependencies['ledger']; + const app = buildApi({ + ledger, + jobs, + userWalletVerifier: { + async verify() { + return verification; + }, + }, + authenticator: staticBearerAuthenticator('test-token'), + }); + + const response = await app.inject({ + method: 'POST', + url: `/v1/jobs/${job.job_id}/user-wallet/submit`, + headers: { authorization: 'Bearer test-token' }, + payload: { transaction_hash: USER_WALLET_HASH }, + }); + expect(response.statusCode).toBe(202); + expect(response.json()).toMatchObject({ payment_state: 'UNKNOWN' }); + expect(calls).toEqual(['begin', 'record', 'unknown']); + await app.close(); + }); + + it('refuses a different transaction hash after one hash is durably bound', async () => { + const durableHash = `0x${'a'.repeat(64)}`; + const differentHash = `0x${'b'.repeat(64)}`; + const job = userWalletJobFixture({ payment_state: 'UNKNOWN' }); + let recorded = false; + const ledger = { + ...createMockLedger(), + async beginUserWalletSubmission() { + return { + begun: true as const, + intent, + attemptId: 'attempt-user-wallet-bound', + transactionHash: durableHash, + state: 'UNKNOWN' as const, + version: 3, + }; + }, + async recordUserWalletTransaction() { + recorded = true; + return 'CONFLICT' as const; + }, + } as unknown as ApiDependencies['ledger']; + const app = buildApi({ + ledger, + jobs: { + async get(_workspaceId: string, jobId: string) { + return jobId === job.job_id ? job : undefined; + }, + } as unknown as ApiDependencies['jobs'], + userWalletVerifier: { + async verify() { + throw new Error('must not verify'); + }, + }, + authenticator: staticBearerAuthenticator('test-token'), + }); + + const response = await app.inject({ + method: 'POST', + url: `/v1/jobs/${job.job_id}/user-wallet/submit`, + headers: { authorization: 'Bearer test-token' }, + payload: { transaction_hash: differentHash }, + }); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ code: 'RECONCILIATION_NOT_ALLOWED' }); + expect(recorded).toBe(false); + await app.close(); + }); + + it('refuses submission for a non-user-wallet job', async () => { + const job = userWalletJobFixture({ + payment_mode: 'SERVER_PRIVY', + payment_state: 'AUTHORIZING', + }); + const app = buildApi({ + ledger: createMockLedger(), + jobs: { + async get(_workspaceId: string, jobId: string) { + return jobId === job.job_id ? job : undefined; + }, + } as unknown as ApiDependencies['jobs'], + userWalletVerifier: { + async verify() { + throw new Error('must not verify'); + }, + }, + authenticator: staticBearerAuthenticator('test-token'), + }); + + const response = await app.inject({ + method: 'POST', + url: `/v1/jobs/${job.job_id}/user-wallet/submit`, + headers: { authorization: 'Bearer test-token' }, + payload: { transaction_hash: USER_WALLET_HASH }, + }); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ code: 'RECONCILIATION_NOT_ALLOWED' }); + await app.close(); + }); + + it('returns 409 when preparation detects a different payer for the task key', async () => { + const job = userWalletJobFixture(); + const app = buildApi({ + ledger: createMockLedger(), + jobs: { + async createUserWalletOrReplay() { + return { kind: 'TASK_PAYLOAD_CONFLICT' as const, job }; + }, + } as unknown as ApiDependencies['jobs'], + supplier: { + async createOrder() { + return { ...job.supplier, supplier_payload_fingerprint: 'a'.repeat(64) }; + }, + async fulfillOrder() { + throw new Error('must not fulfill'); + }, + async getResult() { + return null; + }, + }, + authenticator: staticBearerAuthenticator('test-token'), + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/jobs/user-wallet/prepare', + headers: { authorization: 'Bearer test-token' }, + payload: { + task_key: job.task_key, + tool_id: job.tool_id, + report_subject: 'Acme', + recipient: job.supplier.recipient, + amount_atomic: job.supplier.amount_atomic, + payer_wallet: `0x${'b'.repeat(40)}`, + }, + }); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ code: 'INTENT_PAYLOAD_CONFLICT' }); + await app.close(); + }); +}); diff --git a/apps/api/test/auth-routing.test.ts b/apps/api/test/auth-routing.test.ts new file mode 100644 index 0000000..e367449 --- /dev/null +++ b/apps/api/test/auth-routing.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import type { AuthenticationDecision, ServiceAuthenticator } from '../src/auth.js'; +import { + compositeAuthenticator, + staticBearerAuthenticator, + workspaceBearerAuthenticator, +} from '../src/auth.js'; +import { isJwtCredential } from '../src/privy-auth.js'; + +function fixed(decision: AuthenticationDecision, calls: string[] = []): ServiceAuthenticator { + return { + async authenticate() { + calls.push(decision); + return { decision }; + }, + }; +} + +describe('composite authenticator', () => { + it('returns AUTHORIZED when any eligible authenticator authorizes', async () => { + const auth = compositeAuthenticator([ + { matches: () => true, authenticator: fixed('UNAUTHORIZED') }, + { matches: () => true, authenticator: fixed('AUTHORIZED') }, + ]); + expect(await auth.authenticate('Bearer anything')).toEqual({ decision: 'AUTHORIZED' }); + }); + + it('prefers FORBIDDEN over UNAUTHORIZED when nothing authorizes', async () => { + const auth = compositeAuthenticator([ + { matches: () => true, authenticator: fixed('UNAUTHORIZED') }, + { matches: () => true, authenticator: fixed('FORBIDDEN') }, + ]); + expect(await auth.authenticate('Bearer anything')).toEqual({ decision: 'FORBIDDEN' }); + }); + + it('returns UNAUTHORIZED when no route matches', async () => { + const auth = compositeAuthenticator([ + { matches: () => false, authenticator: fixed('AUTHORIZED') }, + ]); + expect(await auth.authenticate('Bearer anything')).toEqual({ decision: 'UNAUTHORIZED' }); + }); + + it('returns UNAUTHORIZED when the header is absent', async () => { + const auth = compositeAuthenticator([ + { matches: () => true, authenticator: fixed('AUTHORIZED') }, + ]); + expect(await auth.authenticate(undefined)).toEqual({ decision: 'UNAUTHORIZED' }); + }); + + it('never shows an opaque service token to the JWT route', async () => { + const jwtCalls: string[] = []; + const bearerCalls: string[] = []; + const auth = compositeAuthenticator([ + { matches: isJwtCredential, authenticator: fixed('FORBIDDEN', jwtCalls) }, + { + matches: (authorization) => !isJwtCredential(authorization), + authenticator: fixed('AUTHORIZED', bearerCalls), + }, + ]); + expect(await auth.authenticate('Bearer opaque-service-token')).toEqual({ + decision: 'AUTHORIZED', + }); + expect(jwtCalls).toEqual([]); + expect(bearerCalls).toEqual(['AUTHORIZED']); + }); + + it('never shows a JWT to the constant-time bearer route', async () => { + const bearerCalls: string[] = []; + const auth = compositeAuthenticator([ + { matches: isJwtCredential, authenticator: fixed('FORBIDDEN') }, + { + matches: (authorization) => !isJwtCredential(authorization), + authenticator: fixed('AUTHORIZED', bearerCalls), + }, + ]); + expect(await auth.authenticate('Bearer aaa.bbb.ccc')).toEqual({ decision: 'FORBIDDEN' }); + expect(bearerCalls).toEqual([]); + }); + + it('leaves the existing static bearer behavior unchanged', async () => { + const bearer = staticBearerAuthenticator('service-token'); + expect(await bearer.authenticate('Bearer service-token')).toEqual({ decision: 'AUTHORIZED' }); + expect(await bearer.authenticate('Bearer wrong-token-x')).toEqual({ decision: 'FORBIDDEN' }); + expect(await bearer.authenticate('Bearer short')).toEqual({ decision: 'UNAUTHORIZED' }); + expect(await bearer.authenticate(undefined)).toEqual({ decision: 'UNAUTHORIZED' }); + }); + + it('binds a personal bearer to its stored workspace', async () => { + const authenticator = workspaceBearerAuthenticator({ + async workspaceForToken(token) { + return token === 'personal-token' ? 'privy_alice' : undefined; + }, + }); + expect(await authenticator.authenticate('Bearer personal-token')).toEqual({ + decision: 'AUTHORIZED', + workspaceId: 'privy_alice', + }); + expect(await authenticator.authenticate('Bearer wrong')).toEqual({ decision: 'UNAUTHORIZED' }); + }); +}); diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts new file mode 100644 index 0000000..7bfa765 --- /dev/null +++ b/apps/api/test/config.test.ts @@ -0,0 +1,266 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { loadApiRuntimeConfig } from '../src/config.js'; + +describe('API runtime configuration', () => { + const testKey = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }).publicKey; + + const base = { + DATABASE_URL: 'postgresql://oneshot:secret@localhost:5432/oneshot', + SERVICE_BEARER_TOKEN: 'service-token-1234', + }; + + it('uses DATABASE_URL for local and managed TCP PostgreSQL', () => { + const config = loadApiRuntimeConfig({ + DATABASE_URL: 'postgresql://oneshot:secret@localhost:5432/oneshot', + SERVICE_BEARER_TOKEN: 'service-token-1234', + PORT: '8080', + }); + + expect(config.port).toBe(8080); + expect(config.rateLimit).toEqual({ maxRequests: 60, windowMs: 60_000 }); + expect(config.database).toEqual({ + connectionString: 'postgresql://oneshot:secret@localhost:5432/oneshot', + max: 10, + }); + }); + + it('builds the Cloud SQL Unix socket path from the instance connection name', () => { + const config = loadApiRuntimeConfig({ + INSTANCE_CONNECTION_NAME: 'project:region:oneshot-postgres', + DB_USER: 'oneshot', + DB_PASS: 'secret', + DB_NAME: 'oneshot', + SERVICE_BEARER_TOKEN: 'service-token-1234', + }); + + expect(config.database).toEqual({ + host: '/cloudsql/project:region:oneshot-postgres', + user: 'oneshot', + password: 'secret', + database: 'oneshot', + max: 10, + }); + }); + + it('fails closed when runtime secrets or database coordinates are absent', () => { + expect(() => loadApiRuntimeConfig({ DATABASE_URL: 'postgresql://localhost/oneshot' })).toThrow( + 'SERVICE_BEARER_TOKEN', + ); + expect(() => loadApiRuntimeConfig({ SERVICE_BEARER_TOKEN: 'service-token-1234' })).toThrow( + 'Database configuration requires', + ); + }); + + it('leaves Privy login disabled when no Privy variable is set', () => { + const config = loadApiRuntimeConfig({ ...base }); + expect(config.privyAuth).toBeUndefined(); + expect(config.serviceBearerToken).toBe('service-token-1234'); + }); + + it('does not require a server-side supplier recipient or quote amount', () => { + expect(loadApiRuntimeConfig({ ...base })).toMatchObject({ + serviceBearerToken: 'service-token-1234', + }); + }); + + it('uses the canonical worker Graph URL for automatic site activity', () => { + const config = loadApiRuntimeConfig({ + ...base, + ONESHOT_SUBGRAPH_QUERY_URL: 'https://api.studio.thegraph.com/query/oneshot/arc/1', + }); + expect(config.walletActivity).toEqual({ + endpoint: 'https://api.studio.thegraph.com/query/oneshot/arc/1', + }); + }); + + it('loads an isolated MCP configuration', () => { + const config = loadApiRuntimeConfig({ + ...base, + ONESHOT_WORKSPACE_ID: 'mcp-demo-workspace', + ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + ONESHOT_APP_URL: 'https://oneshot.example/app', + ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', + ONESHOT_MCP_WAIT_MS: '500', + }); + expect(config.mcp).toEqual({ + bearerToken: 'mcp-token-with-at-least-thirty-two-characters', + workspaceId: 'mcp-demo-workspace', + signingAppUrl: 'https://oneshot.example/app', + payerWallet: '0x1111111111111111111111111111111111111111', + waitMs: 500, + }); + }); + + it('enables personal MCP credentials without a shared bearer', () => { + const config = loadApiRuntimeConfig({ + ...base, + ONESHOT_WORKSPACE_ID: 'mcp-fallback-workspace', + ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', + }); + expect(config.mcp).toEqual({ + workspaceId: 'mcp-fallback-workspace', + payerWallet: '0x1111111111111111111111111111111111111111', + waitMs: 2500, + }); + }); + + it('allows active user-wallet MCP without a server payer address', () => { + expect( + loadApiRuntimeConfig({ + ...base, + ONESHOT_WORKSPACE_ID: 'user-wallet-mcp-workspace', + ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + }).mcp, + ).toEqual({ + bearerToken: 'mcp-token-with-at-least-thirty-two-characters', + workspaceId: 'user-wallet-mcp-workspace', + waitMs: 2500, + }); + }); + + it('fails closed on partial MCP configuration', () => { + expect(() => + loadApiRuntimeConfig({ + ...base, + ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + }), + ).toThrow('ONESHOT_WORKSPACE_ID'); + }); + + it('loads a complete Privy configuration', () => { + const config = loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'cmtqbf5zo013w0cky3r0jqjca', + PRIVY_AUTH_VERIFICATION_KEY: testKey, + PRIVY_AUTH_ALLOWED_SUBJECTS: 'did:privy:one, did:privy:two ,did:privy:one', + }); + expect(config.privyAuth?.appId).toBe('cmtqbf5zo013w0cky3r0jqjca'); + expect(config.privyAuth?.allowedSubjects).toEqual(['did:privy:one', 'did:privy:two']); + expect(config.privyAuth?.verificationKey).toContain('BEGIN PUBLIC KEY'); + }); + + it('accepts wildcard allow-all for open wallet authentication', () => { + const config = loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'cmtqbf5zo013w0cky3r0jqjca', + PRIVY_AUTH_VERIFICATION_KEY: testKey, + PRIVY_AUTH_ALLOWED_SUBJECTS: '*', + PRIVY_AUTH_ALLOW_ALL_SUBJECTS: 'true', + }); + expect(config.privyAuth?.allowedSubjects).toEqual(['*']); + }); + + it('rejects wildcard Privy authentication without an explicit opt-in', () => { + expect(() => + loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'cmtqbf5zo013w0cky3r0jqjca', + PRIVY_AUTH_VERIFICATION_KEY: testKey, + PRIVY_AUTH_ALLOWED_SUBJECTS: '*', + }), + ).toThrow('PRIVY_AUTH_ALLOW_ALL_SUBJECTS=true'); + }); + + it('rejects a wildcard entry mixed into an allowlist without explicit opt-in', () => { + expect(() => + loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'cmtqbf5zo013w0cky3r0jqjca', + PRIVY_AUTH_VERIFICATION_KEY: testKey, + PRIVY_AUTH_ALLOWED_SUBJECTS: 'did:privy:operator,*', + }), + ).toThrow('PRIVY_AUTH_ALLOW_ALL_SUBJECTS=true'); + }); + + it('accepts a verification key carrying escaped newlines', () => { + const config = loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'app', + PRIVY_AUTH_VERIFICATION_KEY: testKey.trimEnd().replace(/\n/g, '\\n'), + PRIVY_AUTH_ALLOWED_SUBJECTS: 'did:privy:one', + }); + expect(config.privyAuth?.verificationKey.split('\n').length).toBeGreaterThan(2); + }); + + it('refuses to start on partial Privy configuration', () => { + expect(() => loadApiRuntimeConfig({ ...base, PRIVY_AUTH_APP_ID: 'app' })).toThrow( + 'PRIVY_AUTH_VERIFICATION_KEY', + ); + expect(() => + loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'app', + PRIVY_AUTH_VERIFICATION_KEY: testKey, + }), + ).toThrow('PRIVY_AUTH_ALLOWED_SUBJECTS'); + expect(() => + loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'app', + PRIVY_AUTH_VERIFICATION_KEY: testKey, + PRIVY_AUTH_ALLOWED_SUBJECTS: ' ', + }), + ).toThrow('PRIVY_AUTH_ALLOWED_SUBJECTS'); + }); + + it('rejects a service bearer shorter than the production minimum', () => { + expect(() => + loadApiRuntimeConfig({ + DATABASE_URL: 'postgresql://oneshot:secret@localhost:5432/oneshot', + SERVICE_BEARER_TOKEN: 'too-short', + }), + ).toThrow('at least 16 characters'); + }); + + it('loads and validates the shared API rate-limit settings', () => { + const config = loadApiRuntimeConfig({ + ...base, + ONESHOT_API_RATE_LIMIT_MAX_REQUESTS: '12', + ONESHOT_API_RATE_LIMIT_WINDOW_MS: '5000', + }); + expect(config.rateLimit).toEqual({ maxRequests: 12, windowMs: 5000 }); + expect(() => + loadApiRuntimeConfig({ ...base, ONESHOT_API_RATE_LIMIT_MAX_REQUESTS: '0' }), + ).toThrow('ONESHOT_API_RATE_LIMIT_MAX_REQUESTS'); + }); + + it('rejects an allowlist entry that is not a Privy DID', () => { + expect(() => + loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'app', + PRIVY_AUTH_VERIFICATION_KEY: testKey, + PRIVY_AUTH_ALLOWED_SUBJECTS: 'operator@example.com', + }), + ).toThrow('did:privy:'); + }); + + it('rejects a verification key that is not an EC P-256 public key', () => { + const rsa = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }).publicKey; + expect(() => + loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'app', + PRIVY_AUTH_VERIFICATION_KEY: rsa, + PRIVY_AUTH_ALLOWED_SUBJECTS: 'did:privy:one', + }), + ).toThrow('P-256'); + expect(() => + loadApiRuntimeConfig({ + ...base, + PRIVY_AUTH_APP_ID: 'app', + PRIVY_AUTH_VERIFICATION_KEY: 'not a key at all', + PRIVY_AUTH_ALLOWED_SUBJECTS: 'did:privy:one', + }), + ).toThrow(); + }); +}); diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts new file mode 100644 index 0000000..85bbda7 --- /dev/null +++ b/apps/api/test/mcp.test.ts @@ -0,0 +1,252 @@ +import type { JobView } from '@oneshot/contracts'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildApi, staticBearerAuthenticator, type ApiDependencies } from '../src/index.js'; +import { arcPaymentBusinessIntentId, parseUsdcAmount } from '../src/mcp.js'; + +const MCP_TOKEN = 'mcp-test-token-with-at-least-32-characters'; +const SERVICE_TOKEN = 'service-test-token'; +const REQUEST_KEY = 'arc-demo-payment-1'; +const RECIPIENT = '0x292d3fca76142e0c6136b934563f3a0750b633eb'; +const PAYER = '0x1111111111111111111111111111111111111111'; +const TRANSACTION_HASH = `0x${'1'.repeat(64)}`; + +function userWalletJob(overrides: Partial = {}): JobView { + return { + job_id: `job_${'a'.repeat(64)}`, + task_key: REQUEST_KEY, + tool_id: 'team-report-v1', + business_intent_id: `intent_${'b'.repeat(64)}`, + supplier: { + supplier_id: 'team-report-v1', + order_reference: 'team_report_mcp_payment', + recipient: RECIPIENT, + amount_atomic: '1000000', + asset: 'USDC', + network: 'eip155:5042002', + expires_at: '2099-09-12T12:00:00.000Z', + }, + payment_state: 'READY', + payment_mode: 'USER_WALLET', + user_payment: { + chain_id: 5042002, + network: 'eip155:5042002', + token_contract: '0x3600000000000000000000000000000000000000', + payer_wallet: PAYER, + recipient: RECIPIENT, + amount_atomic: '1000000', + }, + delivery_state: 'NOT_REQUESTED', + created_at: '2026-09-13T10:00:00.000Z', + updated_at: '2026-09-13T10:00:00.000Z', + ...overrides, + }; +} + +function app() { + let saved = userWalletJob(); + const verified = vi.fn(async () => ({ + kind: 'CONFIRMED' as const, + transactionHash: TRANSACTION_HASH, + blockNumber: '123', + transferLogIndex: 7, + })); + const ledger = { + async beginUserWalletSubmission() { + return { + begun: true as const, + intent: {} as never, + attemptId: 'attempt-mcp-payment', + state: 'SUBMITTING' as const, + version: 2, + }; + }, + async recordUserWalletTransaction() { + return 'RECORDED' as const; + }, + async completeSubmission() { + saved = userWalletJob({ + payment_state: 'COMMITTED', + user_payment: { ...saved.user_payment!, transaction_hash: TRANSACTION_HASH }, + settlement: { + provider_reference_id: `user-wallet:${TRANSACTION_HASH}`, + transaction_hash: TRANSACTION_HASH, + block_number: '123', + transfer_log_index: 7, + }, + }); + return { completed: true as const, state: 'COMMITTED' as const, version: 3 }; + }, + async markUserWalletUnknown() { + saved = userWalletJob({ payment_state: 'UNKNOWN' }); + return { completed: true as const, state: 'UNKNOWN' as const, version: 3 }; + }, + } as unknown as ApiDependencies['ledger']; + const jobs = { + async createUserWalletOrReplay() { + const replayed = saved.payment_state !== 'READY'; + return { kind: replayed ? ('REPLAYED' as const) : ('ACCEPTED' as const), job: saved }; + }, + async getByBusinessIntentId() { + return saved; + }, + } as unknown as ApiDependencies['jobs']; + const supplier = { + async createOrder(request: { readonly recipient: string; readonly amount_atomic: string }) { + return { + supplier_id: 'team-report-v1' as const, + order_reference: 'team_report_mcp_payment', + recipient: request.recipient, + amount_atomic: request.amount_atomic, + asset: 'USDC' as const, + network: 'eip155:5042002' as const, + expires_at: '2099-09-12T12:00:00.000Z', + supplier_payload_fingerprint: 'a'.repeat(64), + }; + }, + async fulfillOrder() { + throw new Error('not used'); + }, + async getResult() { + return null; + }, + }; + const server = buildApi({ + ledger, + jobs, + supplier, + userWalletVerifier: { verify: verified }, + authenticator: staticBearerAuthenticator(SERVICE_TOKEN), + mcp: { + authenticator: staticBearerAuthenticator(MCP_TOKEN), + workspaceId: 'mcp-demo-workspace', + waitMs: 0, + }, + }); + return { server, verified }; +} + +const rpcHeaders = (token = MCP_TOKEN) => ({ + authorization: `Bearer ${token}`, + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + 'mcp-protocol-version': '2025-06-18', +}); + +async function rpc( + server: ReturnType, + body: Record, + token = MCP_TOKEN, +) { + return server.inject({ method: 'POST', url: '/mcp', headers: rpcHeaders(token), payload: body }); +} + +function rpcBody(response: Awaited>) { + if (!response.headers['content-type']?.startsWith('text/event-stream')) return response.json(); + const data = response.body + .split(/\r?\n/u) + .find((line) => line.startsWith('data: ')) + ?.slice(6); + if (!data) throw new Error('MCP SSE response did not contain a message'); + return JSON.parse(data); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('MCP user-wallet Arc payment', () => { + it('converts USDC decimal strings without floating point', () => { + expect(parseUsdcAmount('1')).toEqual({ atomic: '1000000', decimal: '1.000000' }); + expect(parseUsdcAmount('0.000001')).toEqual({ atomic: '1', decimal: '0.000001' }); + expect(() => parseUsdcAmount('1.0000001')).toThrow('at most six decimals'); + expect(() => parseUsdcAmount('0')).toThrow('greater than zero'); + }); + + it('retains the stable legacy identity helper without using a server payer', () => { + const first = arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY); + expect(arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY)).toBe(first); + expect(arcPaymentBusinessIntentId('another-workspace', REQUEST_KEY)).not.toBe(first); + expect(first).toMatch(/^intent_[0-9a-f]{64}$/u); + }); + + it('lists prepare and submit tools and rejects non-MCP credentials', async () => { + const { server } = app(); + const missing = await server.inject({ + method: 'POST', + url: '/mcp', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + }); + expect(missing.statusCode).toBe(401); + const listed = await rpc(server, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); + expect(listed.statusCode).toBe(200); + expect( + (rpcBody(listed).result.tools as Array<{ name: string }>).map((tool) => tool.name), + ).toEqual(['arc_payment', 'arc_payment_submit']); + await server.close(); + }); + + it('prepares a payer-bound direct transfer and verifies the same signed hash', async () => { + const { server, verified } = app(); + const prepared = rpcBody( + await rpc(server, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'arc_payment', + arguments: { + request_key: REQUEST_KEY, + payer_wallet: PAYER, + recipient: RECIPIENT, + amount_usdc: '1', + purpose: 'Pay for one report', + }, + }, + }), + ).result.structuredContent; + expect(prepared).toMatchObject({ + state: 'READY', + signing_url: `https://oneshot.kapustazh.dev/app?mcp_job_id=${prepared.job_id}`, + payer: { mode: 'USER_WALLET', wallet_address: PAYER }, + amount_usdc: '1.000000', + amount_atomic: '1000000', + next_action: 'SIGN', + transaction: { + chain_id: 5042002, + from: PAYER, + to: '0x3600000000000000000000000000000000000000', + value: '0x0', + }, + }); + expect(prepared.transaction.data).toBe( + `0xa9059cbb${RECIPIENT.slice(2).padStart(64, '0')}${'f4240'.padStart(64, '0')}`, + ); + + const submitted = rpcBody( + await rpc(server, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'arc_payment_submit', + arguments: { + business_intent_id: prepared.business_intent_id, + transaction_hash: TRANSACTION_HASH, + }, + }, + }), + ).result.structuredContent; + expect(submitted).toMatchObject({ + state: 'COMMITTED', + payer: { mode: 'USER_WALLET', wallet_address: PAYER }, + next_action: 'VIEW_PROOF', + settlement: { transaction_hash: TRANSACTION_HASH, block_number: '123' }, + }); + expect(verified).toHaveBeenCalledWith({ + transactionHash: TRANSACTION_HASH, + walletAddress: PAYER, + recipient: RECIPIENT, + amountAtomic: '1000000', + }); + await server.close(); + }); +}); diff --git a/apps/api/test/privy-auth.test.ts b/apps/api/test/privy-auth.test.ts new file mode 100644 index 0000000..54065a9 --- /dev/null +++ b/apps/api/test/privy-auth.test.ts @@ -0,0 +1,144 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { exportSPKI, generateKeyPair, SignJWT } from 'jose'; + +type GeneratedPrivateKey = Awaited>['privateKey']; +import { + createPrivyAccessTokenAuthenticator, + looksLikeJwt, + privyWorkspaceId, +} from '../src/privy-auth.js'; + +const APP_ID = 'test-app-id'; +const OPERATOR = 'did:privy:operator-one'; +const OUTSIDER = 'did:privy:operator-two'; + +let privateKey: GeneratedPrivateKey; +let verificationKey: string; + +beforeAll(async () => { + const pair = await generateKeyPair('ES256', { extractable: true }); + privateKey = pair.privateKey; + verificationKey = await exportSPKI(pair.publicKey); +}); + +async function sign( + options: { + subject?: string; + issuer?: string; + audience?: string; + expiresIn?: string; + } = {}, +): Promise { + return new SignJWT({}) + .setProtectedHeader({ alg: 'ES256' }) + .setSubject(options.subject ?? OPERATOR) + .setIssuer(options.issuer ?? 'privy.io') + .setAudience(options.audience ?? APP_ID) + .setIssuedAt() + .setExpirationTime(options.expiresIn ?? '1h') + .sign(privateKey); +} + +function authenticator(overrides: { onForbiddenSubject?: (subject: string) => void } = {}) { + return createPrivyAccessTokenAuthenticator({ + appId: APP_ID, + verificationKey, + allowedSubjects: [OPERATOR], + ...overrides, + }); +} + +describe('Privy access token authenticator', () => { + it('authorizes an allowlisted operator', async () => { + const token = await sign(); + expect(await authenticator().authenticate(`Bearer ${token}`)).toEqual({ + decision: 'AUTHORIZED', + workspaceId: privyWorkspaceId(OPERATOR), + }); + }); + + it('derives stable, distinct, opaque workspaces from Privy subjects', () => { + expect(privyWorkspaceId(OPERATOR)).toBe(privyWorkspaceId(OPERATOR)); + expect(privyWorkspaceId(OPERATOR)).not.toBe(privyWorkspaceId(OUTSIDER)); + expect(privyWorkspaceId(OPERATOR)).not.toContain(OPERATOR); + }); + + it('forbids a verified but unlisted subject', async () => { + const seen: string[] = []; + const token = await sign({ subject: OUTSIDER }); + const decision = await authenticator({ + onForbiddenSubject: (subject) => seen.push(subject), + }).authenticate(`Bearer ${token}`); + const outcome = decision.decision; + expect(outcome).toBe('FORBIDDEN'); + expect(seen).toEqual([OUTSIDER]); + }); + + it('rejects a wrong audience', async () => { + const token = await sign({ audience: 'someone-elses-app' }); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); + }); + + it('rejects a wrong issuer', async () => { + const token = await sign({ issuer: 'evil.example' }); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); + }); + + it('rejects a verified token whose subject is not a Privy DID', async () => { + const token = await sign({ subject: 'operator@example.com' }); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); + }); + + it('rejects an expired token', async () => { + const token = await sign({ expiresIn: '-10m' }); + expect((await authenticator().authenticate(`Bearer ${token}`)).decision).toBe('UNAUTHORIZED'); + }); + + it('rejects a tampered signature', async () => { + const token = await sign(); + const parts = token.split('.'); + const flipped = parts[2]?.startsWith('A') ? `B${parts[2].slice(1)}` : `A${parts[2]?.slice(1)}`; + expect( + (await authenticator().authenticate(`Bearer ${parts[0]}.${parts[1]}.${flipped}`)).decision, + ).toBe('UNAUTHORIZED'); + }); + + it('rejects an unsigned token that claims alg none', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ sub: OPERATOR, iss: 'privy.io', aud: APP_ID, exp: 4_102_444_800 }), + ).toString('base64url'); + expect((await authenticator().authenticate(`Bearer ${header}.${payload}.`)).decision).toBe( + 'UNAUTHORIZED', + ); + }); + + it('rejects a missing or malformed authorization header', async () => { + expect((await authenticator().authenticate(undefined)).decision).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate('Bearer not-a-jwt')).decision).toBe('UNAUTHORIZED'); + expect((await authenticator().authenticate('Basic abc.def.ghi')).decision).toBe('UNAUTHORIZED'); + }); + + it('authorizes any verified subject when configured with wildcard allow-all', async () => { + const auth = createPrivyAccessTokenAuthenticator({ + appId: APP_ID, + verificationKey, + allowedSubjects: ['*'], + }); + const token = await sign({ subject: OUTSIDER }); + expect((await auth.authenticate(`Bearer ${token}`)).decision).toBe('AUTHORIZED'); + }); + + it('refuses to construct without an allowlist', () => { + expect(() => + createPrivyAccessTokenAuthenticator({ appId: APP_ID, verificationKey, allowedSubjects: [] }), + ).toThrow('allowlist'); + }); + + it('recognizes JWT shape', () => { + expect(looksLikeJwt('aaa.bbb.ccc')).toBe(true); + expect(looksLikeJwt('opaque-service-token')).toBe(false); + expect(looksLikeJwt('aaa.bbb')).toBe(false); + expect(looksLikeJwt('aaa..ccc')).toBe(false); + }); +}); diff --git a/apps/api/test/rate-limit.test.ts b/apps/api/test/rate-limit.test.ts new file mode 100644 index 0000000..d005e9f --- /dev/null +++ b/apps/api/test/rate-limit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Pool } from 'pg'; +import { PostgresRateLimiter } from '../src/rate-limit.js'; + +describe('PostgresRateLimiter', () => { + it('shares a fixed window and rejects requests after the configured limit', async () => { + let requestCount = 0; + const pool = { + query: vi.fn(async (sql: string) => { + if (sql.startsWith('INSERT')) { + requestCount = Math.min(requestCount + 1, 3); + return { rows: [{ request_count: requestCount }] }; + } + return { rows: [] }; + }), + } as unknown as Pool; + const limiter = new PostgresRateLimiter(pool, { + maxRequests: 2, + windowMs: 60_000, + now: () => 1_735_689_600_000, + }); + const input = { correlationId: 'cid', key: 'client', route: '/v1/intents' }; + + await expect(limiter.allow(input)).resolves.toBe(true); + await expect(limiter.allow(input)).resolves.toBe(true); + await expect(limiter.allow(input)).resolves.toBe(false); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('ON CONFLICT (bucket_start, client_key, route)'), + [new Date(1_735_689_600_000), 'client', '/v1/intents', 2], + ); + }); + + it('fails closed when PostgreSQL is unavailable', async () => { + const pool = { + query: vi.fn(async () => { + throw new Error('database unavailable'); + }), + } as unknown as Pool; + const limiter = new PostgresRateLimiter(pool, { maxRequests: 60, windowMs: 60_000 }); + + await expect( + limiter.allow({ correlationId: 'cid', key: 'client', route: '/v1/intents' }), + ).resolves.toBe(false); + }); +}); diff --git a/apps/api/test/runtime-auth.test.ts b/apps/api/test/runtime-auth.test.ts new file mode 100644 index 0000000..5ee48d7 --- /dev/null +++ b/apps/api/test/runtime-auth.test.ts @@ -0,0 +1,75 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { exportSPKI, generateKeyPair, SignJWT } from 'jose'; + +type GeneratedPrivateKey = Awaited>['privateKey']; +import type { ApiRuntimeConfig } from '../src/config.js'; +import { buildApiAuthenticator } from '../src/runtime.js'; + +const APP_ID = 'cmtqbf5zo013w0cky3r0jqjca'; +const OPERATOR = 'did:privy:allowed-operator'; + +let privateKey: GeneratedPrivateKey; +let verificationKey: string; + +beforeAll(async () => { + const pair = await generateKeyPair('ES256', { extractable: true }); + privateKey = pair.privateKey; + verificationKey = await exportSPKI(pair.publicKey); +}); + +function config(privyEnabled: boolean): ApiRuntimeConfig { + return { + host: '0.0.0.0', + port: 3000, + serviceBearerToken: 'service-token-1234', + database: { connectionString: 'postgresql://localhost/oneshot' }, + submissionsDisabled: false, + rateLimit: { maxRequests: 60, windowMs: 60_000 }, + ...(privyEnabled + ? { privyAuth: { appId: APP_ID, verificationKey, allowedSubjects: [OPERATOR] } } + : {}), + }; +} + +async function token(subject: string): Promise { + return new SignJWT({}) + .setProtectedHeader({ alg: 'ES256' }) + .setSubject(subject) + .setIssuer('privy.io') + .setAudience(APP_ID) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); +} + +describe('API authenticator composition', () => { + it('accepts only the service bearer when Privy is disabled', async () => { + const auth = buildApiAuthenticator(config(false)); + expect(await auth.authenticate('Bearer service-token-1234')).toEqual({ + decision: 'AUTHORIZED', + }); + expect(await auth.authenticate(`Bearer ${await token(OPERATOR)}`)).toEqual({ + decision: 'UNAUTHORIZED', + }); + }); + + it('accepts both credential classes when Privy is enabled', async () => { + const auth = buildApiAuthenticator(config(true)); + expect(await auth.authenticate('Bearer service-token-1234')).toEqual({ + decision: 'AUTHORIZED', + }); + expect((await auth.authenticate(`Bearer ${await token(OPERATOR)}`)).decision).toBe( + 'AUTHORIZED', + ); + }); + + it('logs the rejected subject without any token material', async () => { + const lines: string[] = []; + const auth = buildApiAuthenticator(config(true), (line) => lines.push(line)); + const outsiderToken = await token('did:privy:outsider'); + expect(await auth.authenticate(`Bearer ${outsiderToken}`)).toEqual({ decision: 'FORBIDDEN' }); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('did:privy:outsider'); + expect(lines[0]).not.toContain(outsiderToken); + }); +}); diff --git a/apps/api/test/wallet-activity.test.ts b/apps/api/test/wallet-activity.test.ts new file mode 100644 index 0000000..7d6f67a --- /dev/null +++ b/apps/api/test/wallet-activity.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { StudioWalletActivityPort } from '../src/index.js'; + +describe('StudioWalletActivityPort', () => { + it('queries all payer wallets recorded by the site', async () => { + let request: { variables?: { senders?: string[] } } | undefined; + const port = new StudioWalletActivityPort({ + endpoint: 'https://graph.example.test/graphql', + fetchFn: async (_input, init) => { + request = JSON.parse(String(init?.body)) as typeof request; + return new Response( + JSON.stringify({ + data: { settlementCandidates: [], _meta: { deployment: 'studio-deployment' } }, + }), + { status: 200 }, + ); + }, + }); + + await port.refresh([ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x1111111111111111111111111111111111111111', + ]); + + expect(request?.variables?.senders).toEqual([ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + ]); + }); + + it('validates Graph activity and reports indexed coverage', async () => { + const port = new StudioWalletActivityPort({ + endpoint: 'https://graph.example.test/graphql', + wallet: '0x1111111111111111111111111111111111111111', + fetchFn: async () => + new Response( + JSON.stringify({ + data: { + settlementCandidates: [ + { + transactionHash: `0x${'a'.repeat(64)}`, + logIndex: '3', + sender: '0x1111111111111111111111111111111111111111', + tokenContract: '0x3600000000000000000000000000000000000000', + blockNumber: '98', + blockTimestamp: '1726200000', + network: 'eip155:5042002', + recipient: '0x2222222222222222222222222222222222222222', + amountAtomic: '2500000', + }, + ], + _meta: { + deployment: 'studio-deployment', + hasIndexingErrors: false, + block: { number: 99 }, + }, + }, + }), + { status: 200 }, + ), + }); + + await expect(port.refresh()).resolves.toEqual({ + freshness: 'FRESH', + coverageNote: 'Indexed sender activity through block 99.', + payload: { + deployment: 'studio-deployment', + transfers: [ + { + transaction_hash: `0x${'a'.repeat(64)}`, + log_index: 3, + sender: '0x1111111111111111111111111111111111111111', + token_contract: '0x3600000000000000000000000000000000000000', + block_number: '98', + block_timestamp: new Date(1726200000 * 1_000).toISOString(), + network: 'eip155:5042002', + recipient: '0x2222222222222222222222222222222222222222', + amount_atomic: '2500000', + }, + ], + }, + }); + }); + + it('rejects malformed Graph activity instead of presenting it as wallet evidence', async () => { + const port = new StudioWalletActivityPort({ + endpoint: 'https://graph.example.test/graphql', + wallet: '0x1111111111111111111111111111111111111111', + fetchFn: async () => + new Response( + JSON.stringify({ data: { settlementCandidates: [{ logIndex: -1 }], _meta: {} } }), + { status: 200 }, + ), + }); + + await expect(port.refresh()).rejects.toThrow('Graph activity entry failed validation'); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..1d4aeba --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../../packages/contracts" }, + { "path": "../../packages/domain" }, + { "path": "../../packages/storage-postgres" } + ] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..244865b --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.{test,spec}.ts'], + exclude: ['test/**/*.integration.test.ts'], + }, +}); diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts new file mode 100644 index 0000000..e46356c --- /dev/null +++ b/apps/api/vitest.integration.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.integration.test.ts'], + testTimeout: 60_000, + hookTimeout: 60_000, + fileParallelism: false, + }, +}); diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..0c08190 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,4 @@ +VITE_ONESHOT_API_BASE_URL=http://127.0.0.1:3001 + +# Public Privy application identifier. Safe to commit; the verification key is not. +VITE_PRIVY_APP_ID=cmtqbf5zo013w0cky3r0jqjca diff --git a/apps/web/.env.test b/apps/web/.env.test new file mode 100644 index 0000000..25edc6d --- /dev/null +++ b/apps/web/.env.test @@ -0,0 +1 @@ +VITE_PRIVY_APP_ID= diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..772b6c7 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,51 @@ +# OneShot web + +Gate P5 operator UI composing intent creation/status, Privy and Arc settlement +details, and Recovery Agent/Subgraph MCP evidence. + +The Cloudflare asset deployment serves this app at the domain root. Deploy it +only after `VITE_ONESHOT_API_BASE_URL` points to a reachable OneShot API. The +Worker proxies `/health` and `/v1` requests to that API; static assets are +served from the Cloudflare asset bundle. + +```powershell +pnpm --filter @oneshot/web dev +``` + +Vite proxies `/v1` and `/health` to the local API. For a separate deployed API, +set the public build variable `VITE_ONESHOT_API_BASE_URL`. Enter the demo service +token at runtime; the UI keeps it in memory and never persists it. + +The combined production asset tree is built with: + +```powershell +pnpm build:frontend +``` + +This emits the main app to `apps/web/dist`, matching the Wrangler asset +directory. Recovery evidence is composed inside the authenticated workspace; +the synthetic standalone fixture viewer is a local package test surface and is +not deployed. + +The clients consume generated `@oneshot/contracts` types from frozen OpenAPI v1. +The service token remains in React memory and is never written to browser +storage. Run the Chromium acceptance suite with: + +```powershell +pnpm --filter @oneshot/web test:browser +``` + +See [`../../docs/GATE_P5_CHECKLIST.md`](../../docs/GATE_P5_CHECKLIST.md) for the +covered states and safety boundary. + +## Brand + +The palette, the commit-ring mark, and the hero geometry come from +`@oneshot/brand`. `packages/brand/src/tokens.css` is the only file in the +repository allowed to hold a colour; `apps/web/test/styles.test.ts` fails the +build if a literal appears in this app's stylesheet instead. + +The theme is `data-theme` on ``, dark by default, stamped before first +paint by the inline guard in `index.html`. That guard duplicates `src/theme.ts` +deliberately — it has to run before the bundle does. Change one and change the +other, or the page flashes the wrong palette on load. diff --git a/apps/web/browser/p5.spec.ts b/apps/web/browser/p5.spec.ts new file mode 100644 index 0000000..7e7377c --- /dev/null +++ b/apps/web/browser/p5.spec.ts @@ -0,0 +1,268 @@ +import axe from 'axe-core'; + +import { expect, test, type Page, type Route } from '@playwright/test'; + +const JOB_ID = `job_${'a'.repeat(64)}`; +const INTENT_ID = `intent_${'b'.repeat(64)}`; + +function job(deliveryState: 'PENDING' | 'RETRIEVAL_FAILED' | 'AVAILABLE' = 'RETRIEVAL_FAILED') { + return { + job_id: JOB_ID, + task_key: 'report-browser-acme', + tool_id: 'team-report-v1', + business_intent_id: INTENT_ID, + supplier: { + supplier_id: 'team-report-v1', + order_reference: 'team_report_order_browser', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '2500000', + asset: 'USDC', + network: 'eip155:5042002', + expires_at: '2026-09-10T12:00:00.000Z', + }, + payment_state: 'COMMITTED', + delivery_state: deliveryState, + settlement: { + provider_reference_id: 'provider_browser', + transaction_hash: `0x${'c'.repeat(64)}`, + block_number: '99', + transfer_log_index: 0, + explorer_url: `https://testnet.arcscan.app/tx/0x${'c'.repeat(64)}`, + }, + ...(deliveryState === 'AVAILABLE' + ? { + result: { + order_reference: 'team_report_order_browser', + result_reference: 'team_report_result_browser', + report: 'Recovered original supplier report.', + }, + } + : {}), + created_at: '2026-09-10T10:00:00.000Z', + updated_at: '2026-09-10T10:01:00.000Z', + }; +} + +async function json(route: Route, status: number, body: unknown): Promise { + await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); +} + +async function mockJobApi(page: Page): Promise { + const calls: string[] = []; + let current = job('PENDING'); + let listReads = 0; + await page.route('**/health/ready', (route) => json(route, 200, { status: 'ok' })); + await page.route('**/v1/**', async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + calls.push(`${request.method()} ${pathname}`); + if (pathname === '/v1/jobs' && request.method() === 'GET') { + listReads += 1; + if (listReads > 1) current = job('AVAILABLE'); + return json(route, 200, { jobs: [current] }); + } + if (pathname === '/v1/jobs/quote' && request.method() === 'POST') + return json(route, 200, current.supplier); + if (pathname === '/v1/jobs' && request.method() === 'POST') { + return json(route, 202, current); + } + if (pathname === '/v1/activity' && request.method() === 'GET') { + return json(route, 200, { + recorded_settlement_count: 1, + uncertain_job_count: 0, + unmatched_transfer_count: 0, + transfers: [], + }); + } + if (pathname === '/v1/activity/refresh' && request.method() === 'POST') { + return json(route, 202, { + observation: { freshness: 'FRESH', coverage_note: 'Indexed through block 99.' }, + recorded_settlement_count: 1, + uncertain_job_count: 0, + unmatched_transfer_count: 0, + transfers: [], + }); + } + return json(route, 404, {}); + }); + return calls; +} + +async function unlockWorkspace(page: Page): Promise { + await page.getByText('Machine token (advanced)').click(); + await page.getByLabel('Machine token').fill('browser-memory-token'); + await expect(page.getByRole('tab', { name: 'Payment services' })).toBeVisible(); +} + +test.describe('resumable job workspace', () => { + test('keeps the public landing separate from the authenticated cabinet', async ({ page }) => { + await page.goto('/'); + expect(await page.evaluate(() => typeof globalThis.Buffer)).toBe('function'); + await expect( + page.getByRole('heading', { name: 'Resume the job, not the payment.' }), + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open workspace' }).first()).toHaveAttribute( + 'href', + '/app', + ); + + await page.goto('/app'); + await unlockWorkspace(page); + await page.getByRole('tab', { name: 'Requests' }).click(); + await expect(page.getByRole('heading', { name: 'Requests and results' })).toBeVisible(); + await expect(page.getByText('Payment evidence', { exact: true })).toHaveCount(0); + }); + + test('requires a connected user wallet before approving a direct payment', async ({ page }) => { + const calls = await mockJobApi(page); + await page.goto('/app'); + await unlockWorkspace(page); + await page.getByRole('tab', { name: 'Payment services' }).click(); + await page.getByLabel('Payment purpose').fill('acme.com'); + await page + .getByLabel('Service destination wallet') + .fill('0x1111111111111111111111111111111111111111'); + await page.getByLabel('Amount (USDC)').fill('2.5'); + await page.getByText('Request key (advanced)').click(); + await expect(page.locator('#generated-task-key')).toHaveValue(/report-acme-com-/u); + await page + .getByRole('region', { name: 'Direct Arc payment' }) + .getByRole('button', { name: 'Review payment details' }) + .click(); + await expect.poll(() => calls.filter((call) => call === 'POST /v1/jobs/quote')).toHaveLength(1); + expect(calls).not.toContain('POST /v1/jobs'); + await expect(page.getByRole('heading', { name: 'Review before approval' })).toBeVisible(); + await expect(page.getByText('Nothing has been paid yet.')).toBeVisible(); + const approve = page + .getByRole('region', { name: 'Direct Arc payment' }) + .locator('button') + .last(); + await approve.click(); + await expect(page.getByRole('status')).toContainText( + 'Connect a Privy or MetaMask wallet before approving', + ); + expect(calls).not.toContain('POST /v1/jobs'); + }); + + test('shows activity as read-only evidence and keeps the cabinet responsive', async ({ + page, + }) => { + await mockJobApi(page); + await page.goto('/app'); + await unlockWorkspace(page); + await page.getByRole('tab', { name: 'Payment proof' }).click(); + await page.getByRole('button', { name: 'Check payment activity' }).click(); + await expect(page.locator('.workspace-status')).toContainText('FRESH'); + await expect(page.getByText(/payment records unchanged/u)).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + }); +}); + +for (const theme of ['light', 'dark'] as const) { + for (const width of [390, 1440]) { + test(`readable ${theme} surfaces and aligned cabinet at ${width}px`, async ({ page }) => { + test.setTimeout(60_000); + await page.setViewportSize({ width, height: 1000 }); + await page.addInitScript((value) => localStorage.setItem('oneshot.theme', value), theme); + const calls = await mockJobApi(page); + const checkContrast = async () => { + await page.addScriptTag({ content: axe.source }); + const violations = await page.evaluate(async () => { + const checker = (window as unknown as { axe: typeof axe }).axe; + const result = await checker.run(document, { runOnly: ['color-contrast'] }); + return result.violations.map((item) => ({ + id: item.id, + nodes: item.nodes.map((node) => ({ + target: node.target, + failure: node.failureSummary, + })), + })); + }); + expect(violations).toEqual([]); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( + true, + ); + }; + await page.goto('/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + await checkContrast(); + const heroPlain = page.locator('.hero-plain'); + if (await heroPlain.count()) { + const hero = await heroPlain.first().boundingBox(); + for (const selector of ['h1', '.hero-actions']) { + const content = await heroPlain.locator(selector).first().boundingBox(); + expect(content!.y).toBeGreaterThanOrEqual(hero!.y); + expect(content!.y + content!.height).toBeLessThanOrEqual(hero!.y + hero!.height); + } + } else { + await expect(page.locator('.hero-cut')).toBeVisible(); + await expect(page.locator('.hero-copy h1')).toBeVisible(); + await expect(page.locator('.hero-copy .hero-actions')).toBeVisible(); + } + await page.screenshot({ + path: test.info().outputPath(`landing-${theme}-${width}.png`), + fullPage: true, + }); + await page.goto('/docs/mcp'); + await expect( + page.getByRole('heading', { name: 'Connect an agent to one safe payment tool.' }), + ).toBeVisible(); + await expect(page.getByLabel('MCP client configuration')).toContainText( + '', + ); + await checkContrast(); + await page.screenshot({ + path: test.info().outputPath(`mcp-docs-${theme}-${width}.png`), + fullPage: true, + }); + await page.goto('/app'); + await unlockWorkspace(page); + const identity = await page.locator('.operator-identity').boundingBox(); + const header = await page.locator('.cabinet-header').boundingBox(); + expect(identity).not.toBeNull(); + expect(header?.x).toBeCloseTo(identity!.x, 0); + expect(header?.width).toBeCloseTo(identity!.width, 0); + for (const label of ['Overview', 'Payment services', 'Requests', 'Payment proof']) { + await page.getByRole('tab', { name: label, exact: true }).click(); + await page.getByRole('tab', { name: label, exact: true }).hover(); + await page.getByRole('tab', { name: label, exact: true }).focus(); + if (label === 'Payment services') { + await page.getByText('Request key (advanced)').click(); + await page.getByLabel('Payment purpose').fill('acme.com'); + await page + .getByLabel('Service destination wallet') + .fill('0x1111111111111111111111111111111111111111'); + await page.getByLabel('Amount (USDC)').fill('2.5'); + await page + .getByRole('region', { name: 'Direct Arc payment' }) + .getByRole('button', { name: 'Review payment details' }) + .click(); + await expect(page.getByRole('heading', { name: 'Review before approval' })).toBeVisible(); + } + if (label === 'Requests') { + await expect(page.getByText('Retrieving result')).toBeVisible(); + expect(calls).not.toContain(`POST /v1/jobs/${JOB_ID}/resume`); + await page.getByRole('button', { name: 'Refresh requests' }).click(); + await expect(page.getByText('Recovered original supplier report.')).toBeVisible(); + // At least the mount read and the manual refresh. It is not an exact + // count: a delivery reported as PENDING is re-read on a timer, so a + // slower run legitimately reads more times. + expect(calls.filter((call) => call === 'GET /v1/jobs').length).toBeGreaterThanOrEqual(2); + const results = await page + .getByRole('region', { name: 'Requests and results' }) + .boundingBox(); + expect(results?.x).toBeCloseTo(identity!.x, 0); + expect(results?.width).toBeCloseTo(identity!.width, 0); + } + await checkContrast(); + } + await page.screenshot({ + path: test.info().outputPath(`cabinet-${theme}-${width}.png`), + fullPage: true, + }); + }); + } +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..1e5cf0d --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,30 @@ + + + + + + + + + + OneShot — Arc Testnet Stablecoin Engine + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..27d8c3a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,42 @@ +{ + "name": "@oneshot/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "clean": "tsc -b --clean", + "dev": "vite", + "lint": "eslint src test playwright.config.ts", + "preview": "vite preview", + "test": "vitest run --config vitest.config.ts", + "test:browser": "pnpm run typecheck:browser && tsc -b && vite build --mode test && node scripts/run-browser-tests.mjs", + "typecheck:browser": "tsc -p tsconfig.browser.json --pretty false", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@oneshot/brand": "workspace:*", + "@oneshot/contracts": "workspace:*", + "@oneshot/recovery-ui": "workspace:*", + "@oneshot/settlement-ui": "workspace:*", + "@privy-io/react-auth": "3.6.1", + "buffer": "6.0.3", + "viem": "2.36.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.7", + "@vitejs/plugin-react": "6.1.1", + "axe-core": "4.13.0", + "jsdom": "30.0.1", + "typescript": "6.0.3", + "vite": "8.0.0", + "vitest": "5.0.0" + } +} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..2230140 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; + +const webServer = { + command: 'node node_modules/vite/bin/vite.js preview --host 127.0.0.1 --port 4173', + cwd: fileURLToPath(new URL('.', import.meta.url)), + url: 'http://127.0.0.1:4173', + reuseExistingServer: process.env.CI !== 'true', + timeout: 30_000, +}; + +export default defineConfig({ + testDir: '.', + testMatch: ['browser/**/*.spec.ts', 'test/gate-p5.spec.ts'], + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + reporter: process.env.CI === 'true' ? 'github' : 'line', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + // Audit the settled page. The contrast checks run immediately after + // navigation, so with motion enabled axe samples mid-transition and reads + // every colour composited against whatever sits behind it — a panel still + // fading in reports its ink as a blend rather than the colour it settles + // on. All motion in styles.css sits behind `prefers-reduced-motion: + // no-preference`, so reducing it here removes the sampling race instead of + // hiding a real contrast failure. + reducedMotion: 'reduce', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer, +}); diff --git a/apps/web/public/logo.png b/apps/web/public/logo.png new file mode 100644 index 0000000..e785ede Binary files /dev/null and b/apps/web/public/logo.png differ diff --git a/apps/web/scripts/run-browser-tests.mjs b/apps/web/scripts/run-browser-tests.mjs new file mode 100644 index 0000000..76f539f --- /dev/null +++ b/apps/web/scripts/run-browser-tests.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from 'node:child_process'; + +const result = spawnSync( + process.execPath, + ['node_modules/@playwright/test/cli.js', 'test', '--config=playwright.config.ts'], + { + env: { + ...process.env, + // Playwright 1.63's TS ESM loader can hang under Node 24 on Windows. + PW_DISABLE_TS_ESM: '1', + }, + stdio: 'inherit', + }, +); + +if (result.error) { + console.error(result.error); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..4a574c9 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,631 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; +import type { ActivityResponse } from '@oneshot/contracts'; +import { createSettlementClient, type SettlementClient } from '@oneshot/settlement-ui'; +import type { RecoveryClient } from '@oneshot/recovery-ui'; +import { CommitRing } from '@oneshot/brand'; +import '@oneshot/recovery-ui/styles.css'; +import '@oneshot/settlement-ui/styles.css'; + +import { OneShotApiClient } from './api/client.js'; +import { JobApiClient } from './api/job-client.js'; +import { createApiRecoveryClient } from './api/recovery-client.js'; +import { + selectCredential, + unconfiguredOperatorSession, + type UseOperatorSession, + type UserWalletSession, +} from './auth/session.js'; +import { ErrorBoundary } from './components/ErrorBoundary.js'; +import { Hero } from './components/Hero.js'; +import { IntentForm } from './components/IntentForm.js'; +import { IntentStatusView } from './components/IntentStatusView.js'; +import { LoginGate } from './components/LoginGate.js'; +import { McpDocsPage } from './components/McpDocsPage.js'; +import { McpProfile } from './components/McpProfile.js'; +import { ReadinessBanner } from './components/ReadinessBanner.js'; +import { JobList, JobWorkspace } from './components/JobWorkspace.js'; +import { RecoverySurface, SettlementSurface } from './components/FrontendSurfaces.js'; +import { PaymentProtectionPanel } from './components/WorkspacePanels.js'; +import { applyTheme, readStoredTheme, type Theme } from './theme.js'; +import './styles.css'; + +type Tab = 'create' | 'status' | 'settlement' | 'recovery'; +const TAB_ORDER: readonly Tab[] = ['create', 'status', 'settlement', 'recovery']; +const TAB_LABELS: Readonly> = { + create: 'Create request', + status: 'Payment status', + settlement: 'Payment proof', + recovery: 'Recovery control', +}; + +export interface AppProps { + readonly apiClient?: OneShotApiClient; + readonly jobClient?: JobApiClient; + readonly settlementClient?: SettlementClient; + readonly recoveryClient?: RecoveryClient; + readonly useOperatorSession?: UseOperatorSession; + readonly userWallet?: UserWalletSession; + /** Optional prepared MCP job to open directly in the wallet-signing workspace. */ + readonly mcpJobId?: string; + /** main.tsx passes the browser route; omitted preserves legacy test composition. */ + readonly route?: string; +} + +function LandingPage(props: { readonly theme: Theme; readonly onToggleTheme: () => void }) { + return ( +
+ +
+ +

RESUMABLE PAID SERVICES / ARC TESTNET

+

Resume the job, not the payment.

+

+ Approve one direct Arc payment. If an agent restarts, the original task, payment + evidence and result stay together. +

+

+ One job. Many retries. At most one committed settlement. Team-operated testnet + integration. +

+ +
+
+
+
+

A safe paid-tool workflow

+
+
+
+

Approve the exact purchase

+

+ Review the service, destination, amount, network and wallet rule before payment can + start. +

+
+
+

Keep one task key

+

+ Retries reuse the same request key and supplier order. Changed details are held for + review. +

+
+
+

Retrieve the existing result

+

+ Payment checks are read-only. If delivery is delayed, the original supplier request + resumes without another charge. +

+
+
+
+
+ ); +} + +function CabinetPage(props: { + readonly session: ReturnType; + readonly machineToken: string; + readonly setMachineToken: (value: string) => void; + readonly apiClient: OneShotApiClient; + readonly jobClient: JobApiClient; + readonly settlementClient: SettlementClient; + readonly recoveryClient: RecoveryClient; + readonly theme: Theme; + readonly onToggleTheme: () => void; + readonly userWallet?: UserWalletSession; + readonly mcpJobId?: string; +}) { + const [section, setSection] = useState< + 'overview' | 'services' | 'requests' | 'protection' | 'profile' + >(props.mcpJobId ? 'services' : 'overview'); + const [intentId, setIntentId] = useState(''); + const [activity, setActivity] = useState(null); + const [activityError, setActivityError] = useState(null); + const workspaceUnlocked = + props.session.status === 'SIGNED_IN' || props.machineToken.trim() !== ''; + const refreshActivity = useCallback(async (): Promise => { + if (!workspaceUnlocked || typeof props.jobClient.refreshActivity !== 'function') return; + setActivityError(null); + try { + setActivity(await props.jobClient.refreshActivity()); + } catch { + setActivityError( + 'Payment activity is unavailable right now. Existing payment records are unchanged.', + ); + } + }, [props.jobClient, workspaceUnlocked]); + + useEffect(() => { + void refreshActivity(); + }, [refreshActivity]); + + useEffect(() => { + if (section === 'protection') void refreshActivity(); + }, [refreshActivity, section]); + + const labels = { + overview: 'Overview', + services: 'Payment services', + requests: 'Requests', + protection: 'Payment proof', + profile: 'Profile', + } as const; + + function selectRequest(id: string): void { + setIntentId(id); + setSection('protection'); + } + return ( +
+ + +
+ +

WORKSPACE

+

Your payment workspace

+

+ Run approved Arc payments, keep one payment identity per request, and recover results + without paying twice. +

+
+
+
+ Walk through a real payment +

+ Use the actual service, wallet and result. This guide never creates or pays a request + for you. +

+
    +
  1. + Review controls. Check the execution wallet’s active Privy rules and + the permitted amount and recipient. +
  2. +
  3. + Prepare a request. Open Payment services. For a direct Arc payment, + enter its purpose, recipient and amount, then review the exact payment details. +
  4. +
  5. + Approve deliberately. Read “Recipient receives”, the destination and + Arc Testnet network. Keep the request key. Only the explicit approval button starts a + payment request. +
  6. +
  7. + Read the result. Open Requests and inspect the actual payment state + and result. +
  8. +
  9. + Demonstrate recovery. Resume the existing sample result from + Requests. An uncertain payment needs investigation, not a new key. +
  10. +
+

+ Record the actual outcome. If a service is unavailable or a payment stays uncertain, + explain that state instead of presenting a completed demo. +

+ +
+ + {/* Keyed on the section so React remounts the panel on every + switch, which restarts the fade in `.tab-fade`. */} +
+ {section === 'overview' && ( +
+

ONE JOB · ONE PAYMENT

+

What would you like to do?

+

+ Send one direct USDC payment on Arc Testnet: review the exact quote, approve once, + and follow the same durable request from approval to proof. Payment evidence stays + available whenever you need it. +

+
+ + + +
+ +
+ )} + {section === 'services' && ( +
+ +
+ )} + {section === 'requests' && ( + + )} + {section === 'protection' && ( + void refreshActivity()} + /> + )} + {section === 'profile' && } +
+
+
+ ); +} + +export function App(props: AppProps = {}) { + const useOperatorSession = props.useOperatorSession ?? unconfiguredOperatorSession; + const session = useOperatorSession(); + const [activeTab, setActiveTab] = useState('create'); + const [selectedIntentId, setSelectedIntentId] = useState(''); + const [machineToken, setMachineToken] = useState(''); + const [theme, setTheme] = useState(() => readStoredTheme()); + + function toggleTheme(): void { + const next: Theme = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + applyTheme(next); + } + const credentialRef = useRef(null); + credentialRef.current = selectCredential(session, machineToken); + const getAuthToken = useCallback(() => credentialRef.current, []); + const tabRefs = useRef>({ + create: null, + status: null, + settlement: null, + recovery: null, + }); + const apiBaseUrl = import.meta.env.VITE_ONESHOT_API_BASE_URL ?? ''; + const apiClient = useMemo( + () => props.apiClient ?? new OneShotApiClient({ baseUrl: apiBaseUrl, getAuthToken }), + [apiBaseUrl, getAuthToken, props.apiClient], + ); + const settlementClient = useMemo( + () => props.settlementClient ?? createSettlementClient({ baseUrl: apiBaseUrl, getAuthToken }), + [apiBaseUrl, getAuthToken, props.settlementClient], + ); + const recoveryClient = useMemo( + () => props.recoveryClient ?? createApiRecoveryClient({ baseUrl: apiBaseUrl, getAuthToken }), + [apiBaseUrl, getAuthToken, props.recoveryClient], + ); + const jobClient = useMemo( + () => props.jobClient ?? new JobApiClient({ baseUrl: apiBaseUrl, getAuthToken }), + [apiBaseUrl, getAuthToken, props.jobClient], + ); + + if (props.route === '/') return ; + if (props.route?.startsWith('/docs/mcp')) { + return ; + } + if (props.route?.startsWith('/app')) { + return ( + + ); + } + + function selectIntent(intentId: string): void { + setSelectedIntentId(intentId); + setActiveTab('status'); + } + + function handleTabKeyDown(event: KeyboardEvent, currentTab: Tab): void { + const currentIndex = TAB_ORDER.indexOf(currentTab); + let nextIndex: number | undefined; + if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % TAB_ORDER.length; + if (event.key === 'ArrowLeft') + nextIndex = (currentIndex - 1 + TAB_ORDER.length) % TAB_ORDER.length; + if (event.key === 'Home') nextIndex = 0; + if (event.key === 'End') nextIndex = TAB_ORDER.length - 1; + if (nextIndex === undefined) return; + event.preventDefault(); + const nextTab = TAB_ORDER[nextIndex]; + if (nextTab === undefined) return; + setActiveTab(nextTab); + tabRefs.current[nextTab]?.focus(); + } + + return ( + +
+ + +
+ +

ONESHOT / ARC TESTNET

+

One job. Many retries. One settlement.

+

+ A durable payment workflow with wallet policy checks, retry protection, and read-only + recovery on Arc. +

+ +
+ +
+ +
+
+ ARCHITECTURAL GUARANTEES +

Scoped payment safety

+
+ +
+
+
01 / PAYMENT SAFETY
+

One payment per request

+

A stable request key keeps retries on one approved payment path.

+
+ +
+
02 / WALLET POLICY
+

Approved before sending

+

+ Destination, amount and spending rules are checked before the worker can submit a + payment. +

+
+ +
+
03 / NETWORK EVIDENCE
+

Read-only recovery

+

+ If a response is delayed, OneShot checks the ledger and network observations without + submitting another payment. +

+
+ +
+
04 / REQUEST LIFECYCLE
+

Safe hold on uncertainty

+

+ When payment proof is incomplete, the request pauses until it is verified. No second + payment is allowed. +

+
+
+
+ +
+
+ WORKSPACE +

Your payment workspace

+

+ Run and protect API payments: create a request, review the exact payment, and inspect + proof only when you need it. +

+
+ + +
+ Open a request by identifier (advanced) + + setSelectedIntentId(event.target.value)} + placeholder="Create a request or enter its stable identifier" + /> +
+ + + + {/* Keyed like the cabinet panel above, so switching tabs remounts + the panel and restarts the fade in `.tab-fade`. */} +
+ {activeTab === 'create' ? ( + + ) : activeTab === 'status' ? ( + + ) : activeTab === 'settlement' ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+
+ + OneShot +
+

+ Stablecoin-native payment lifecycle engine with pre-flight policy gating and + subgraph recovery on Arc Testnet. +

+
+ +
+
+ NETWORK + Arc Testnet +
+
+ SETTLEMENT ASSET + USDC +
+
+
+ +
+ OneShot Protocol · Built for Circle Arc Challenge + +
+
+
+
+ ); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..28208f0 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,301 @@ +import type { + CreateIntentRequest, + ErrorResponse, + IntentResponse, + ReconcileResponse, +} from '@oneshot/contracts'; + +export interface ApiClientConfig { + readonly baseUrl?: string; + readonly getAuthToken?: () => string | null; + readonly fetchFn?: typeof fetch; +} + +export type CreateIntentResult = + | { readonly kind: 'ACCEPTED'; readonly intent: IntentResponse; readonly correlationId: string } + | { readonly kind: 'REPLAYED'; readonly intent: IntentResponse; readonly correlationId: string } + | { readonly kind: 'PAYLOAD_CONFLICT'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'UNAUTHORIZED'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'RATE_LIMITED'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'NOT_READY'; readonly message: string; readonly correlationId: string } + | { + readonly kind: 'ERROR'; + readonly code: string; + readonly message: string; + readonly correlationId: string; + }; + +export type GetIntentResult = + | { readonly kind: 'SUCCESS'; readonly intent: IntentResponse; readonly correlationId: string } + | { readonly kind: 'NOT_FOUND'; readonly correlationId: string } + | { readonly kind: 'UNAUTHORIZED'; readonly message: string; readonly correlationId: string } + | { + readonly kind: 'ERROR'; + readonly code: string; + readonly message: string; + readonly correlationId: string; + }; + +export type ReconcileResult = + | { + readonly kind: 'QUEUED'; + readonly response: ReconcileResponse; + readonly correlationId: string; + } + | { readonly kind: 'NOT_ALLOWED'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'NOT_FOUND'; readonly correlationId: string } + | { readonly kind: 'ERROR'; readonly message: string; readonly correlationId: string }; + +export type ReadinessResult = + | { readonly status: 'ok'; readonly submissions_disabled?: boolean } + | { readonly status: 'not_ready'; readonly message: string }; + +async function parseJsonResponse(res: Response): Promise { + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + return null; + } + try { + return (await res.json()) as T; + } catch { + return null; + } +} + +export class OneShotApiClient { + private readonly baseUrl: string; + private readonly getAuthToken: () => string | null; + private readonly fetchFn: typeof fetch; + + constructor(config?: ApiClientConfig) { + this.baseUrl = config?.baseUrl ?? ''; + this.getAuthToken = config?.getAuthToken ?? (() => null); + this.fetchFn = config?.fetchFn ?? fetch.bind(globalThis); + } + + private buildHeaders(customCorrelationId?: string): HeadersInit { + const headers: Record = { + 'content-type': 'application/json', + 'x-correlation-id': customCorrelationId ?? crypto.randomUUID(), + }; + const token = this.getAuthToken(); + if (token) { + headers.authorization = `Bearer ${token}`; + } + return headers; + } + + async createOrReplayIntent( + request: CreateIntentRequest, + correlationId?: string, + ): Promise { + const headers = this.buildHeaders(correlationId); + const corrId = (headers as Record)['x-correlation-id'] ?? ''; + + try { + const res = await this.fetchFn(`${this.baseUrl}/v1/intents`, { + method: 'POST', + headers, + body: JSON.stringify(request), + }); + + if (res.status === 202) { + const intent = await parseJsonResponse(res); + if (!intent) { + return { + kind: 'ERROR', + code: 'INVALID_RESPONSE', + message: 'Backend returned non-JSON response', + correlationId: corrId, + }; + } + return { kind: 'ACCEPTED', intent, correlationId: corrId }; + } + + if (res.status === 200) { + const intent = await parseJsonResponse(res); + if (!intent) { + return { + kind: 'ERROR', + code: 'INVALID_RESPONSE', + message: 'Backend returned non-JSON response', + correlationId: corrId, + }; + } + return { kind: 'REPLAYED', intent, correlationId: corrId }; + } + + const err = (await parseJsonResponse>(res)) ?? {}; + const message = + err.message ?? + (res.status === 502 || res.status === 503 || res.status === 504 + ? 'Backend unavailable' + : 'Unknown error'); + + if (res.status === 409 && err.code === 'INTENT_PAYLOAD_CONFLICT') { + return { kind: 'PAYLOAD_CONFLICT', message, correlationId: corrId }; + } + + if (res.status === 401 || res.status === 403) { + return { kind: 'UNAUTHORIZED', message, correlationId: corrId }; + } + + if (res.status === 429) { + return { kind: 'RATE_LIMITED', message, correlationId: corrId }; + } + + if (res.status === 503) { + return { kind: 'NOT_READY', message, correlationId: corrId }; + } + + return { + kind: 'ERROR', + code: err.code ?? 'UNKNOWN_ERROR', + message, + correlationId: corrId, + }; + } catch (e) { + return { + kind: 'ERROR', + code: 'NETWORK_ERROR', + message: e instanceof Error ? e.message : 'Network request failed', + correlationId: corrId, + }; + } + } + + async getIntent(id: string, correlationId?: string): Promise { + const headers = this.buildHeaders(correlationId); + const corrId = (headers as Record)['x-correlation-id'] ?? ''; + + try { + const res = await this.fetchFn(`${this.baseUrl}/v1/intents/${encodeURIComponent(id)}`, { + method: 'GET', + headers, + }); + + if (res.status === 200) { + const intent = await parseJsonResponse(res); + if (!intent) { + return { + kind: 'ERROR', + code: 'INVALID_RESPONSE', + message: 'Backend returned non-JSON response', + correlationId: corrId, + }; + } + return { kind: 'SUCCESS', intent, correlationId: corrId }; + } + + if (res.status === 404) { + return { kind: 'NOT_FOUND', correlationId: corrId }; + } + + const err = (await parseJsonResponse>(res)) ?? {}; + const message = err.message ?? `Request failed with status ${res.status}`; + + if (res.status === 401 || res.status === 403) { + return { kind: 'UNAUTHORIZED', message, correlationId: corrId }; + } + + return { + kind: 'ERROR', + code: err.code ?? 'UNKNOWN_ERROR', + message, + correlationId: corrId, + }; + } catch (e) { + return { + kind: 'ERROR', + code: 'NETWORK_ERROR', + message: e instanceof Error ? e.message : 'Network request failed', + correlationId: corrId, + }; + } + } + + async reconcileIntent(id: string, correlationId?: string): Promise { + const headers = this.buildHeaders(correlationId); + const corrId = (headers as Record)['x-correlation-id'] ?? ''; + + try { + const res = await this.fetchFn( + `${this.baseUrl}/v1/intents/${encodeURIComponent(id)}/reconcile`, + { + method: 'POST', + headers, + }, + ); + + if (res.status === 202) { + const response = await parseJsonResponse(res); + if (!response) { + return { + kind: 'ERROR', + message: 'Backend returned non-JSON response', + correlationId: corrId, + }; + } + return { kind: 'QUEUED', response, correlationId: corrId }; + } + + if (res.status === 404) { + return { kind: 'NOT_FOUND', correlationId: corrId }; + } + + const err = (await parseJsonResponse>(res)) ?? {}; + const message = + err.message ?? + (res.status === 409 + ? 'Reconciliation not allowed' + : `Request failed with status ${res.status}`); + + if (res.status === 409) { + return { kind: 'NOT_ALLOWED', message, correlationId: corrId }; + } + + return { kind: 'ERROR', message, correlationId: corrId }; + } catch (e) { + return { + kind: 'ERROR', + message: e instanceof Error ? e.message : 'Network error', + correlationId: corrId, + }; + } + } + + async getReadiness(): Promise { + try { + const res = await this.fetchFn(`${this.baseUrl}/health/ready`, { + method: 'GET', + }); + + if (res.status === 200) { + const body = await parseJsonResponse<{ + status: 'ok'; + submissions_disabled?: boolean; + }>(res); + if (!body || body.status !== 'ok') { + return { + status: 'not_ready', + message: 'Backend unavailable (HTML or invalid response received)', + }; + } + return { + status: 'ok', + ...(body.submissions_disabled !== undefined + ? { submissions_disabled: body.submissions_disabled } + : {}), + }; + } + + const err = (await parseJsonResponse>(res)) ?? {}; + return { status: 'not_ready', message: err.message ?? `Service not ready (${res.status})` }; + } catch (e) { + return { + status: 'not_ready', + message: e instanceof Error ? e.message : 'Readiness check failed', + }; + } + } +} diff --git a/apps/web/src/api/job-client.ts b/apps/web/src/api/job-client.ts new file mode 100644 index 0000000..b41194c --- /dev/null +++ b/apps/web/src/api/job-client.ts @@ -0,0 +1,147 @@ +import type { + ActivityResponse, + CreateJobRequest, + CreateUserWalletJobRequest, + JobListResponse, + JobView, + SupplierQuote, + SupplierResult, +} from '@oneshot/contracts'; +import type { ApiClientConfig } from './client.js'; + +export interface McpCredentialStatus { + readonly configured: boolean; + readonly created_at?: string; +} + +export interface IssuedMcpCredential { + readonly bearer_token: string; + readonly created_at: string; +} + +async function responseJson(response: Response): Promise { + if (!response.headers.get('content-type')?.includes('application/json')) return null; + try { + return (await response.json()) as T; + } catch { + return null; + } +} + +export class JobApiClient { + readonly #baseUrl: string; + readonly #getAuthToken: () => string | null; + readonly #fetch: typeof fetch; + + constructor(config: ApiClientConfig = {}) { + this.#baseUrl = config.baseUrl ?? ''; + this.#getAuthToken = config.getAuthToken ?? (() => null); + this.#fetch = config.fetchFn ?? fetch.bind(globalThis); + } + + #headers(withJsonBody = false): HeadersInit { + const token = this.#getAuthToken(); + return { + ...(withJsonBody ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; + } + + async list(): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/jobs`, { headers: this.#headers() }); + return response.ok ? ((await responseJson(response))?.jobs ?? []) : []; + } + + async get(jobId: string): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/jobs/${encodeURIComponent(jobId)}`, { + headers: this.#headers(), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not load the job status'); + return body; + } + + async start(request: CreateJobRequest): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/jobs`, { + method: 'POST', + headers: this.#headers(true), + body: JSON.stringify(request), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not start the approved job'); + return body; + } + + async prepareUserWalletJob(request: CreateUserWalletJobRequest): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/jobs/user-wallet/prepare`, { + method: 'POST', + headers: this.#headers(true), + body: JSON.stringify(request), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not prepare the user-wallet payment'); + return body; + } + + async submitUserWalletPayment(jobId: string, transactionHash: string): Promise { + const response = await this.#fetch( + `${this.#baseUrl}/v1/jobs/${encodeURIComponent(jobId)}/user-wallet/submit`, + { + method: 'POST', + headers: this.#headers(true), + body: JSON.stringify({ transaction_hash: transactionHash }), + }, + ); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not verify the user-wallet payment'); + return body; + } + + async quote(request: CreateJobRequest): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/jobs/quote`, { + method: 'POST', + headers: this.#headers(true), + body: JSON.stringify(request), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not load a live supplier quote'); + return body; + } + + async result(jobId: string): Promise { + const response = await this.#fetch( + `${this.#baseUrl}/v1/jobs/${encodeURIComponent(jobId)}/result`, + { headers: this.#headers() }, + ); + return response.ok ? await responseJson(response) : null; + } + + async refreshActivity(): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/activity/refresh`, { + method: 'POST', + headers: this.#headers(), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Activity refresh is unavailable'); + return body; + } + + async mcpCredentialStatus(): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/profile/mcp-token`, { + headers: this.#headers(), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not load MCP access'); + return body; + } + + async issueMcpCredential(rotate: boolean): Promise { + const response = await this.#fetch( + `${this.#baseUrl}/v1/profile/mcp-token${rotate ? '/rotate' : ''}`, + { method: 'POST', headers: this.#headers() }, + ); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not generate MCP bearer token'); + return body; + } +} diff --git a/apps/web/src/api/recovery-client.ts b/apps/web/src/api/recovery-client.ts new file mode 100644 index 0000000..a884986 --- /dev/null +++ b/apps/web/src/api/recovery-client.ts @@ -0,0 +1,274 @@ +import type { EvidenceView, IntentResponse, RecoveryView } from '@oneshot/contracts'; +import { + parseRecoveryTimelinePage, + type CoreDisposition, + type EvidenceSummary, + type GraphObservationSummary, + type RecoveryClient, + type RecoveryState, + type RecoveryTimelinePage, +} from '@oneshot/recovery-ui'; + +export interface ApiRecoveryClientOptions { + readonly baseUrl?: string; + readonly getAuthToken?: () => string | null; + readonly fetcher?: typeof fetch; +} + +function recoveryState(state: IntentResponse['state']): RecoveryState { + if ( + state === 'SUBMITTING' || + state === 'UNKNOWN' || + state === 'COMMITTED' || + state === 'FAILED_SAFE' + ) { + return state; + } + throw new Error(`Recovery view is unavailable for authoritative state ${state}`); +} + +function authorityFor(evidence: EvidenceView): EvidenceSummary['authorityClass'] { + if (evidence.source === 'ONESHOT' && evidence.authority_class === 'AUTHORITATIVE') { + return 'AUTHORITATIVE_ONESHOT'; + } + if (evidence.source === 'ARC' && evidence.authority_class === 'AUTHORITATIVE') { + return 'AUTHORITATIVE_CHAIN_EVIDENCE'; + } + if (evidence.source === 'THE_GRAPH') return 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY'; + if (evidence.source === 'LLM') return 'ADVISORY_AGENT_OBSERVATION'; + return 'PROVIDER_OBSERVATION'; +} + +function maxBlock(evidence: readonly EvidenceView[]): string | null { + let highest: bigint | null = null; + for (const item of evidence) { + if (item.block_number === undefined) continue; + const current = BigInt(item.block_number); + if (highest === null || current > highest) highest = current; + } + return highest?.toString() ?? null; +} + +function graphSummary(view: RecoveryView): GraphObservationSummary | null { + if (view.graph_observation) { + const graph = view.graph_observation; + return { + retrievalPath: graph.retrieval_path, + endpointUrl: graph.endpoint_url, + serverName: graph.server_name ?? null, + serverVersion: graph.server_version ?? null, + toolName: graph.tool_name ?? null, + deploymentId: graph.deployment_id, + manifestCid: graph.manifest_cid, + observedThroughBlock: graph.observed_through_block ?? null, + observedThroughTime: graph.observed_through_time ?? null, + chainHeadBlock: null, + lagBlocks: null, + health: graph.health, + available: graph.available, + candidateCount: graph.candidate_count, + diagnostics: graph.diagnostics, + candidates: graph.candidates.map((candidate) => ({ + candidateId: candidate.candidate_id, + transactionHash: candidate.transaction_hash, + blockNumber: candidate.block_number, + bindingStatus: candidate.binding_status, + contradictionCodes: candidate.contradiction_codes, + })), + }; + } + const observations = view.evidence.filter((item) => item.source === 'THE_GRAPH'); + if (observations.length === 0) return null; + const health = observations.some((item) => item.freshness === 'UNAVAILABLE') + ? 'UNAVAILABLE' + : observations.some((item) => item.freshness === 'UNHEALTHY') + ? 'UNHEALTHY' + : observations.some((item) => item.freshness === 'LAGGING') + ? 'LAGGING' + : observations.some((item) => item.freshness === 'UNKNOWN_FRESHNESS') + ? 'UNKNOWN_FRESHNESS' + : 'FRESH'; + const diagnostics = [ + 'CANDIDATE_DETAILS_WITHHELD', + ...(observations.length > 1 ? ['MULTIPLE_CANDIDATES'] : []), + ]; + return { + retrievalPath: 'UNKNOWN', + endpointUrl: 'Identity not exposed by frozen API', + serverName: null, + serverVersion: null, + toolName: null, + deploymentId: 'Not exposed by frozen API', + manifestCid: 'Not exposed by frozen API', + observedThroughBlock: maxBlock(observations), + observedThroughTime: null, + chainHeadBlock: null, + lagBlocks: null, + health, + available: health !== 'UNAVAILABLE', + candidateCount: 0, + diagnostics, + candidates: [], + }; +} + +function coreDisposition(view: RecoveryView): CoreDisposition { + if (view.core_disposition !== undefined) return view.core_disposition; + if (view.authoritative_state === 'COMMITTED') return 'MARK_COMMITTED'; + if (view.authoritative_state === 'FAILED_SAFE') return 'MARK_FAILED_SAFE'; + return 'HOLD_UNKNOWN'; +} + +function project(intent: IntentResponse, view: RecoveryView): RecoveryTimelinePage { + if (intent.business_intent_id !== view.business_intent_id) { + throw new Error('Recovery response identity mismatch'); + } + const state = recoveryState(intent.state); + const evaluatedAt = + view.evidence.at(-1)?.retrieved_at ?? + intent.attempts.at(-1)?.created_at ?? + new Date().toISOString(); + const evidence = view.evidence.map((item, index): EvidenceSummary => ({ + evidenceId: `${item.source.toLowerCase()}:${index}:${item.digest}`, + source: item.source, + authorityClass: authorityFor(item), + retrievedAt: item.retrieved_at, + finality: + item.source === 'ARC' && item.authority_class === 'AUTHORITATIVE' && state === 'COMMITTED' + ? 'FINAL' + : null, + blockNumber: item.block_number ?? null, + digest: item.digest, + summary: `${item.source} ${item.authority_class.toLowerCase()} evidence recorded by OneShot.`, + verifiedBinding: item.authority_class === 'AUTHORITATIVE', + contradictionCodes: [], + })); + const disposition = coreDisposition(view); + const agentDecision = view.recommendation_source === 'RECOVERY_AGENT'; + const proofPresent = evidence.some( + (item) => item.authorityClass === 'AUTHORITATIVE_CHAIN_EVIDENCE' && item.finality === 'FINAL', + ); + const page: RecoveryTimelinePage = { + schemaVersion: 'recovery-timeline-v1', + businessIntentId: intent.business_intent_id, + authoritativeState: state, + stateVersion: String(intent.version), + settlementPermission: 'NEVER', + evaluatedAt, + summary: + state === 'UNKNOWN' + ? 'Settlement outcome remains unknown. New settlement is blocked while evidence is reconciled.' + : `Intent is ${state}.`, + attempts: intent.attempts.map((attempt) => ({ + attemptId: attempt.attempt_id, + stage: attempt.stage, + createdAt: attempt.created_at, + completedAt: null, + sanitizedError: attempt.sanitized_error ?? null, + })), + timeline: evidence.map((item, index) => ({ + eventId: item.evidenceId, + kind: 'EVIDENCE', + timestamp: item.retrievedAt, + sequence: String(index + 1), + title: `${item.source} evidence`, + summary: item.summary, + source: item.source, + authorityClass: item.authorityClass, + evidenceReferences: [item.evidenceId], + })), + evidence, + graph: graphSummary(view), + recommendation: { + accepted: view.agent_decision?.accepted ?? false, + action: view.recommended_action, + reason: + view.agent_decision?.reason ?? + (agentDecision + ? 'Persisted Recovery Agent recommendation returned by the frozen API.' + : 'No persisted Recovery Agent decision exists; the API returned its fail-closed fallback.'), + modelName: view.agent_decision + ? `${view.agent_decision.model_name} ${view.agent_decision.model_version}` + : agentDecision + ? 'Identity not exposed by frozen API' + : 'Unavailable', + promptVersion: view.agent_decision?.prompt_version ?? 'Not exposed by frozen API', + evidenceReferences: + view.agent_decision?.evidence_references ?? evidence.map((item) => item.evidenceId), + }, + coreDisposition: { + commandType: view.core_decision?.disposition ?? disposition, + targetState: + view.core_decision?.target_state ?? + (disposition === 'MARK_COMMITTED' + ? 'COMMITTED' + : disposition === 'MARK_FAILED_SAFE' + ? 'FAILED_SAFE' + : 'UNKNOWN'), + reason: + view.core_decision?.reason ?? + (view.core_disposition === undefined + ? 'No persisted core decision was exposed; the UI applied a fail-closed display fallback.' + : 'Persisted deterministic core disposition returned by the frozen API.'), + authoritativeProofPresent: view.core_decision?.authoritative_proof_present ?? proofPresent, + evidenceReferences: + view.core_decision?.evidence_references ?? + evidence + .filter((item) => item.authorityClass.startsWith('AUTHORITATIVE')) + .map((item) => item.evidenceId), + }, + contradiction: view.contradiction ?? false, + contradictionCodes: view.contradiction_codes ?? [], + diagnostics: [ + ...(view.diagnostics ?? []), + ...(view.recommendation_source === undefined ? ['LEGACY_RECOVERY_VIEW'] : []), + ], + page: { cursor: null, nextCursor: null, totalEntries: evidence.length }, + }; + return parseRecoveryTimelinePage(page); +} + +export function createApiRecoveryClient(options: ApiRecoveryClientOptions = {}): RecoveryClient { + const baseUrl = (options.baseUrl ?? '').replace(/\/+$/u, ''); + const fetcher = options.fetcher ?? globalThis.fetch; + + async function readJson(path: string): Promise { + const token = options.getAuthToken?.(); + const response = await fetcher(`${baseUrl}${path}`, { + headers: { + accept: 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + if (!response.ok) throw new Error(`Recovery API request failed with ${response.status}`); + return response.json() as Promise; + } + + return { + supportsEscalation: false, + async readPage(businessIntentId, cursor) { + if (cursor !== null) throw new Error('The frozen recovery API is not paginated'); + const id = encodeURIComponent(businessIntentId); + const [intent, view] = await Promise.all([ + readJson(`/v1/intents/${id}`), + readJson(`/v1/intents/${id}/recovery-view`), + ]); + return project(intent, view); + }, + async refresh(businessIntentId) { + await readJson( + `/v1/intents/${encodeURIComponent(businessIntentId)}/recovery-view`, + ); + return { + schemaVersion: 'recovery-action-receipt-v1', + businessIntentId, + action: 'REFRESH_STATUS', + accepted: true, + message: 'Status refreshed. No settlement action was created.', + }; + }, + async escalate() { + throw new Error('Operator escalation is not exposed by the frozen API'); + }, + }; +} diff --git a/apps/web/src/auth/privy-session.tsx b/apps/web/src/auth/privy-session.tsx new file mode 100644 index 0000000..055c2d6 --- /dev/null +++ b/apps/web/src/auth/privy-session.tsx @@ -0,0 +1,181 @@ +import { + PrivyProvider, + useActiveWallet, + useLogin, + usePrivy, + useWallets, +} from '@privy-io/react-auth'; +import type { BaseConnectedWalletType } from '@privy-io/react-auth'; +import { defineChain } from 'viem'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import type { OperatorSession, OperatorSessionStatus, UserWalletSession } from './session.js'; + +const REFRESH_INTERVAL_MS = 5 * 60 * 1000; +const ARC_TESTNET_CHAIN_ID = 'eip155:5042002' as const; +const ARC_TESTNET = defineChain({ + id: 5042002, + name: 'Arc Testnet', + testnet: true, + nativeCurrency: { name: 'USDC', symbol: 'USDC', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.testnet.arc.network'] } }, + blockExplorers: { default: { name: 'Arcscan', url: 'https://testnet.arcscan.app' } }, +}); + +export function PrivyOperatorProvider(props: { + readonly appId: string; + readonly children: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function usePrivyOperatorSession(): OperatorSession { + const { ready, authenticated, user, logout, getAccessToken } = usePrivy(); + const { login } = useLogin(); + const [accessToken, setAccessToken] = useState(null); + + useEffect(() => { + if (!ready || !authenticated) { + setAccessToken(null); + return; + } + let cancelled = false; + const refresh = async (): Promise => { + try { + const token = await getAccessToken(); + if (!cancelled) setAccessToken(token ?? null); + } catch { + if (!cancelled) setAccessToken(null); + } + }; + void refresh(); + const timer = window.setInterval(() => void refresh(), REFRESH_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [ready, authenticated, getAccessToken]); + + const status: OperatorSessionStatus = !ready + ? 'LOADING' + : authenticated + ? 'SIGNED_IN' + : 'SIGNED_OUT'; + + return { + status, + subject: user?.id ?? null, + accessToken: status === 'SIGNED_IN' ? accessToken : null, + login, + logout, + }; +} + +function transferData(recipient: string, amountAtomic: string): `0x${string}` { + if (!/^0x[0-9a-fA-F]{40}$/u.test(recipient)) throw new Error('Recipient wallet is invalid'); + if (!/^[1-9][0-9]*$/u.test(amountAtomic)) throw new Error('Payment amount is invalid'); + const amount = BigInt(amountAtomic); + if (amount >= 2n ** 256n) throw new Error('Payment amount is too large'); + return `0xa9059cbb${recipient.slice(2).padStart(64, '0')}${amount.toString(16).padStart(64, '0')}`; +} + +type EthereumWallet = Extract; +function isEthereumWallet(value: BaseConnectedWalletType | undefined): value is EthereumWallet { + return value?.type === 'ethereum'; +} + +export function usePrivyUserWallet(): UserWalletSession { + const { user } = usePrivy(); + const { ready: walletsReady, wallets } = useWallets(); + const { wallet: activeWallet, connect: connectWallet } = useActiveWallet(); + const [connectedWalletAddress, setConnectedWalletAddress] = useState(null); + const explicitlyConnectedWallet = useRef<{ + readonly subject: string | null; + readonly wallet: EthereumWallet; + } | null>(null); + const subject = user?.id ?? null; + const selectedWallet: EthereumWallet | undefined = + activeWallet?.type === 'ethereum' + ? activeWallet + : walletsReady + ? wallets.find((candidate) => isEthereumWallet(candidate)) + : undefined; + + async function selectWallet(): Promise { + if (selectedWallet) return selectedWallet; + if (explicitlyConnectedWallet.current?.subject === subject) { + return explicitlyConnectedWallet.current.wallet; + } + const result = await connectWallet({ reset: true }); + if (result.wallet?.type !== 'ethereum') return undefined; + explicitlyConnectedWallet.current = { subject, wallet: result.wallet }; + setConnectedWalletAddress(result.wallet.address); + return result.wallet; + } + + async function connect(): Promise { + return (await selectWallet())?.address ?? null; + } + + async function resolveWallet(): Promise { + const wallet = await selectWallet(); + if (!wallet) throw new Error('Select a wallet in Privy before approving payment'); + return wallet; + } + + async function resolveArcWallet(): Promise { + const current = await resolveWallet(); + if (current.chainId !== ARC_TESTNET_CHAIN_ID) await current.switchChain(5042002); + return current; + } + + async function sendTransfer(payment: Parameters[0]) { + const current = await resolveArcWallet(); + if (current.address.toLowerCase() !== payment.payer_wallet.toLowerCase()) { + throw new Error('The active wallet changed; review the payment again'); + } + const provider = await current.getEthereumProvider(); + const result = await provider.request({ + method: 'eth_sendTransaction', + params: [ + { + from: current.address, + to: payment.token_contract, + data: transferData(payment.recipient, payment.amount_atomic), + value: '0x0', + }, + ], + }); + if (typeof result !== 'string' || !/^0x[0-9a-fA-F]{64}$/u.test(result)) { + throw new Error('Wallet did not return a valid transaction hash'); + } + return result.toLowerCase(); + } + + return { + address: + selectedWallet?.address ?? + (explicitlyConnectedWallet.current?.subject === subject + ? (connectedWalletAddress ?? explicitlyConnectedWallet.current.wallet.address) + : null), + connect, + sendTransfer, + }; +} diff --git a/apps/web/src/auth/session.ts b/apps/web/src/auth/session.ts new file mode 100644 index 0000000..d987b3c --- /dev/null +++ b/apps/web/src/auth/session.ts @@ -0,0 +1,44 @@ +export type OperatorSessionStatus = 'UNCONFIGURED' | 'LOADING' | 'SIGNED_OUT' | 'SIGNED_IN'; + +/** + * The console's view of operator identity. The access token is held as a plain + * value so the three API clients keep their synchronous `getAuthToken` port; + * the adapter refreshes it well inside the token's one-hour lifetime. + */ +export interface OperatorSession { + readonly status: OperatorSessionStatus; + readonly subject: string | null; + readonly accessToken: string | null; + login(): void; + logout(): void; +} + +export type UseOperatorSession = () => OperatorSession; + +export interface UserWalletSession { + readonly address: string | null; + connect(): Promise; + sendTransfer(payment: { + readonly chain_id: 5042002; + readonly token_contract: string; + readonly payer_wallet: string; + readonly recipient: string; + readonly amount_atomic: string; + }): Promise; +} + +export const unconfiguredOperatorSession: UseOperatorSession = () => ({ + status: 'UNCONFIGURED', + subject: null, + accessToken: null, + login() {}, + logout() {}, +}); + +/** + * A Privy session always wins over a typed machine token, so an operator who is + * signed in cannot accidentally act under a shared service credential. + */ +export function selectCredential(session: OperatorSession, machineToken: string): string | null { + return session.accessToken ?? (machineToken.trim() || null); +} diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..255eb33 --- /dev/null +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import { Component, type ReactNode } from 'react'; + +interface Props { + readonly children: ReactNode; +} + +interface State { + readonly error: string | null; +} + +export class ErrorBoundary extends Component { + override state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error: error.message || 'Unexpected application error.' }; + } + + override componentDidCatch(error: Error): void { + console.error('UI boundary caught error:', error.name); + } + + override render(): ReactNode { + if (this.state.error === null) return this.props.children; + return ( +
+
+

Something went wrong

+

{this.state.error}

+ +
+
+ ); + } +} diff --git a/apps/web/src/components/FrontendSurfaces.tsx b/apps/web/src/components/FrontendSurfaces.tsx new file mode 100644 index 0000000..12a30de --- /dev/null +++ b/apps/web/src/components/FrontendSurfaces.tsx @@ -0,0 +1,58 @@ +import { RecoveryRoute, type RecoveryClient } from '@oneshot/recovery-ui'; +import { SettlementDetailsRoute, type SettlementClient } from '@oneshot/settlement-ui'; + +function EmptySurface({ title, detail }: { readonly title: string; readonly detail: string }) { + return ( +
+

ONESHOT / COMPOSED FRONTEND

+

{title}

+

{detail}

+
+ ); +} + +export function SettlementSurface({ + businessIntentId, + client, +}: { + readonly businessIntentId: string; + readonly client: SettlementClient; +}) { + if (!businessIntentId) { + return ( + + ); + } + + return ( +
+ +
+ ); +} + +export function RecoverySurface({ + businessIntentId, + client, +}: { + readonly businessIntentId: string; + readonly client: RecoveryClient; +}) { + if (!businessIntentId) { + return ( + + ); + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/Hero.tsx b/apps/web/src/components/Hero.tsx new file mode 100644 index 0000000..c5c04d8 --- /dev/null +++ b/apps/web/src/components/Hero.tsx @@ -0,0 +1,101 @@ +import { HERO_MIN_WIDTH, heroClipPaths } from '@oneshot/brand'; +import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; + +/** + * The hero, and the only diagonal cut in the product. + * + * The two shapes are clipped, not drawn, so the copy over them stays real text. + * Rounding the acute corners needs `clipPathUnits="userSpaceOnUse"`, which + * means real pixels — hence the measurement. Narrow viewports lose the cut + * entirely: below the minimum width the acute corners collapse into a smudge, + * so the hero becomes the same content on a plain rounded panel. + */ + +/** + * Floor for the cut's height. The copy sits in normal flow and sets the real + * height, so a long headline grows the hero instead of overflowing it — which + * is what clipped the lead paragraph at narrow desktop widths, and what made + * the clipping differ between monitors. jsdom reports zero for every layout + * box, so this floor is also the height the Hero tests measure against. + */ +const HERO_MIN_HEIGHT = 268; + +export function Hero({ + children, + height: fixedHeight, +}: { + readonly children: ReactNode; + /** + * Pins the cut to an exact height. The landing page uses this because its + * hero is a composed marketing block sized to a layout, not to its copy. + * Left off, the hero measures itself, which is what keeps the workspace copy + * from clipping at widths where the headline wraps. + */ + readonly height?: number; +}) { + const box = useRef(null); + const [width, setWidth] = useState(0); + const [measuredHeight, setMeasuredHeight] = useState(HERO_MIN_HEIGHT); + const height = fixedHeight ?? measuredHeight; + const id = useId(); + + // `useLayoutEffect`, not `useEffect`: this app is pure client-side render + // (see `src/main.tsx`, no SSR), so the synchronous flush happens before the + // browser paints. `useEffect` fires after paint, which meant every desktop + // load painted `.hero-plain` for one frame and then flashed to `.hero-cut`. + // Measuring synchronously here removes that flash. The `ResizeObserver` + // wiring for subsequent resizes is unchanged. + useLayoutEffect(() => { + const element = box.current; + if (element === null) return; + + const measure = (): void => { + setWidth(element.clientWidth); + // The clip only paints; it never changes layout, so feeding the measured + // height back in cannot loop the observer. + setMeasuredHeight(Math.max(HERO_MIN_HEIGHT, element.clientHeight)); + }; + measure(); + + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const cut = width >= HERO_MIN_WIDTH ? heroClipPaths(width, height) : null; + // useId's punctuation varies by React version and ends up inside a `url(#…)` + // reference. Strip it; the uniqueness still comes from React. + const safeId = id.replace(/[^a-zA-Z0-9]/gu, ''); + const panelClip = `${safeId}-panel`; + const figureClip = `${safeId}-figure`; + + return ( +
+ {cut === null ? ( +
{children}
+ ) : ( +
+ + + ); +} diff --git a/apps/web/src/components/IntentForm.tsx b/apps/web/src/components/IntentForm.tsx new file mode 100644 index 0000000..4b0bc24 --- /dev/null +++ b/apps/web/src/components/IntentForm.tsx @@ -0,0 +1,213 @@ +import { parseCreateIntentRequest, type CreateIntentRequest } from '@oneshot/contracts'; +import { useMemo, useState, type FormEvent } from 'react'; + +import type { CreateIntentResult, OneShotApiClient } from '../api/client.js'; +import { usdcToAtomicUnits } from '../utils/money.js'; + +interface Props { + readonly client: OneShotApiClient; + readonly onIntentCreatedOrSelected?: (intentId: string) => void; +} + +interface Outcome { + readonly kind: + 'accepted' | 'replayed' | 'conflict' | 'denied' | 'rate-limited' | 'not-ready' | 'error'; + readonly title: string; + readonly message: string; +} + +function buildRequest( + intentId: string, + recipient: string, + humanAmount: string, + purpose: string, +): CreateIntentRequest { + const amount = usdcToAtomicUnits(humanAmount); + if (amount === '0') throw new Error('Amount must be greater than zero.'); + return parseCreateIntentRequest({ + business_intent_id: intentId.trim(), + recipient: recipient.trim(), + amount_atomic: amount, + asset: 'USDC', + network: 'eip155:5042002', + purpose: purpose.trim(), + }); +} + +function outcomeFor(result: CreateIntentResult): Outcome { + switch (result.kind) { + case 'ACCEPTED': + return { + kind: 'accepted', + title: 'Request created', + message: 'OneShot stored the request. The payment worker can now continue it safely.', + }; + case 'REPLAYED': + return { + kind: 'replayed', + title: 'Existing request reused', + message: 'The same request was returned. No duplicate payment was created.', + }; + case 'PAYLOAD_CONFLICT': + return { + kind: 'conflict', + title: 'Request details changed', + message: + 'This ID already belongs to another immutable payload. Use a new ID only for a new obligation.', + }; + case 'UNAUTHORIZED': + return { + kind: 'denied', + title: 'Request not approved', + message: + 'The service rejected this intent. No settlement was created and no bypass is available.', + }; + case 'RATE_LIMITED': + return { + kind: 'rate-limited', + title: 'Please slow down', + message: 'The service asked for a slower retry. No settlement action was taken.', + }; + case 'NOT_READY': + return { + kind: 'not-ready', + title: 'Service unavailable', + message: 'The service is not ready. No settlement action was taken.', + }; + default: + return { kind: 'error', title: 'Request failed', message: result.message }; + } +} + +export function IntentForm({ client, onIntentCreatedOrSelected }: Props) { + const [intentId, setIntentId] = useState(() => crypto.randomUUID()); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState('1.00'); + const [purpose, setPurpose] = useState('Direct Arc payment'); + const [submitting, setSubmitting] = useState(false); + const [validationError, setValidationError] = useState(null); + const [outcome, setOutcome] = useState(null); + const atomicPreview = useMemo(() => { + try { + return usdcToAtomicUnits(amount); + } catch { + return 'Invalid amount'; + } + }, [amount]); + + function newObligation(): void { + setIntentId(crypto.randomUUID()); + setOutcome(null); + setValidationError(null); + } + + async function submit(event: FormEvent): Promise { + event.preventDefault(); + setOutcome(null); + setValidationError(null); + let request: CreateIntentRequest; + try { + request = buildRequest(intentId, recipient, amount, purpose); + } catch (error) { + setValidationError(error instanceof Error ? error.message : 'Invalid intent.'); + return; + } + + setSubmitting(true); + try { + const result = await client.createOrReplayIntent(request); + setOutcome(outcomeFor(result)); + if (result.kind === 'ACCEPTED' || result.kind === 'REPLAYED') { + onIntentCreatedOrSelected?.(result.intent.business_intent_id); + } + } finally { + setSubmitting(false); + } + } + + return ( +
void submit(event)} + > +
+

SAFE REQUEST CREATION

+

Create a request

+

Use the same request key when retrying. OneShot keeps one payment identity.

+
+ + {validationError && ( +
+ {validationError} +
+ )} + {outcome && ( +
+ {outcome.title} +

{outcome.message}

+
+ )} + +
+ Request key (advanced) +
+ + +
+ setIntentId(event.target.value)} + maxLength={128} + required + /> + Keep this key unchanged when retrying the same request. +
+ + + setRecipient(event.target.value)} + placeholder="0x…" + spellCheck={false} + required + /> + +
+
+ + setAmount(event.target.value)} + required + /> + {atomicPreview} internal units +
+
+ Settlement profile + Arc Testnet · USDC + Fixed by the active workspace settlement profile. +
+
+ + + setPurpose(event.target.value)} + maxLength={256} + required + /> + + +
+ ); +} diff --git a/apps/web/src/components/IntentStatusView.tsx b/apps/web/src/components/IntentStatusView.tsx new file mode 100644 index 0000000..1745e07 --- /dev/null +++ b/apps/web/src/components/IntentStatusView.tsx @@ -0,0 +1,235 @@ +import type { IntentResponse, IntentState } from '@oneshot/contracts'; +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; + +import type { OneShotApiClient } from '../api/client.js'; +import { atomicUnitsToUsdc } from '../utils/money.js'; +import { maskIdentifier, paymentStatusCopy } from './workspace-copy.js'; + +interface Props { + readonly client: OneShotApiClient; + readonly initialIntentId?: string; +} + +const STOP_POLLING = new Set(['COMMITTED', 'FAILED_SAFE', 'REJECTED', 'UNKNOWN']); +const MAX_POLLS = 12; + +export function IntentStatusView({ client, initialIntentId = '' }: Props) { + const [searchId, setSearchId] = useState(initialIntentId); + const [activeId, setActiveId] = useState(initialIntentId); + const [intent, setIntent] = useState(null); + const [loading, setLoading] = useState(false); + const [pollCount, setPollCount] = useState(0); + const [polling, setPolling] = useState(false); + const [message, setMessage] = useState(null); + const [reconcileMessage, setReconcileMessage] = useState(null); + const [reconciling, setReconciling] = useState(false); + const timer = useRef | null>(null); + + useEffect(() => { + if (initialIntentId && initialIntentId !== activeId) { + setSearchId(initialIntentId); + setActiveId(initialIntentId); + } + }, [activeId, initialIntentId]); + + const read = useCallback( + async (id: string, showLoading = true): Promise => { + if (showLoading) setLoading(true); + setMessage(null); + try { + const result = await client.getIntent(id); + if (result.kind === 'SUCCESS') { + setIntent(result.intent); + return result.intent; + } + setIntent(null); + setMessage( + result.kind === 'NOT_FOUND' + ? 'That request could not be found in the workspace.' + : result.message, + ); + return null; + } finally { + if (showLoading) setLoading(false); + } + }, + [client], + ); + + useEffect(() => { + if (!activeId) return; + let cancelled = false; + setPollCount(0); + setPolling(true); + + async function poll(attempt: number): Promise { + const current = await read(activeId, attempt === 0); + if ( + cancelled || + current === null || + STOP_POLLING.has(current.state) || + attempt + 1 >= MAX_POLLS + ) { + setPolling(false); + return; + } + setPollCount(attempt + 1); + timer.current = setTimeout( + () => void poll(attempt + 1), + Math.min(1_000 + attempt * 500, 4_000), + ); + } + + void poll(0); + return () => { + cancelled = true; + if (timer.current) clearTimeout(timer.current); + }; + }, [activeId, read]); + + function lookup(event: FormEvent): void { + event.preventDefault(); + const id = searchId.trim(); + if (!id) return; + if (id === activeId) void read(id); + else setActiveId(id); + } + + async function reconcile(): Promise { + if (intent?.state !== 'UNKNOWN') return; + setReconciling(true); + setReconcileMessage(null); + try { + const result = await client.reconcileIntent(intent.business_intent_id); + if (result.kind === 'QUEUED') { + setReconcileMessage( + 'Payment check queued. A new settlement remains blocked until it finishes.', + ); + await read(intent.business_intent_id); + } else if (result.kind === 'NOT_FOUND') { + setReconcileMessage('Reconciliation failed: intent was not found.'); + } else { + setReconcileMessage(`Reconciliation failed: ${result.message}`); + } + } finally { + setReconciling(false); + } + } + + return ( +
+
+

Payment status

+

Read from the OneShot ledger. A request being checked cannot be paid again.

+
+ +
+ Look up another request (advanced) +
+ + setSearchId(event.target.value)} + placeholder="Request identifier" + /> + + {activeId && ( + + )} +
+
+ + {message && ( +
+ {message} +
+ )} + {polling && ( +

+ Checking payment status · {pollCount + 1}/{MAX_POLLS} +

+ )} + + {intent && ( +
+
+
+ Payment status + {paymentStatusCopy(intent.state).label} +
+
+ + {intent.state === 'UNKNOWN' && ( +
+

Payment verification is still in progress

+

+ OneShot is checking the existing payment. Do not start a new request until this + check finishes. +

+ + {reconcileMessage &&

{reconcileMessage}

} +
+ )} + +
+
+
Amount
+
{atomicUnitsToUsdc(intent.amount_atomic)} USDC
+
+
+
Network
+
Arc Testnet
+
+
+
Request
+
{intent.purpose}
+
+
+ +
+ Show request identifiers and execution history +
+
+
Request identifier
+
{maskIdentifier(intent.business_intent_id, 10)}
+
+
+
Service destination
+
{intent.recipient}
+
+
+
Ledger version
+
{intent.version}
+
+
+
+

Execution history

+ {intent.attempts.length === 0 ? ( +

No execution attempts yet.

+ ) : ( +
    + {intent.attempts.map((attempt) => ( +
  1. + {paymentStatusCopy(attempt.stage).label} ·{' '} + {attempt.created_at} + {attempt.sanitized_error &&

    {attempt.sanitized_error}

    } +
  2. + ))} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/JobWorkspace.tsx b/apps/web/src/components/JobWorkspace.tsx new file mode 100644 index 0000000..88b11fd --- /dev/null +++ b/apps/web/src/components/JobWorkspace.tsx @@ -0,0 +1,765 @@ +import { formatAtomicUsdcWithAsset } from '@oneshot/settlement-ui'; +import { useEffect, useState } from 'react'; +import type { JobView, SupplierQuote } from '@oneshot/contracts'; +import type { JobApiClient } from '../api/job-client.js'; +import type { UserWalletSession } from '../auth/session.js'; +import { usdcToAtomicUnits } from '../utils/money.js'; +import { + deliveryStatusCopy, + maskAddress, + maskIdentifier, + networkLabel, + paymentStatusCopy, + serviceLabel, +} from './workspace-copy.js'; + +function shortenAddress(value: string): string { + return maskAddress(value); +} +function quoteAmount(quote: SupplierQuote): string { + return formatAtomicUsdcWithAsset(quote.amount_atomic, quote.asset) ?? 'Unavailable'; +} + +function subjectSlug(value: string): string { + const slug = value + .normalize('NFKD') + .replace(/[^a-zA-Z0-9]+/gu, '-') + .replace(/^-+|-+$/gu, '') + .toLowerCase(); + return slug.slice(0, 48) || 'company'; +} + +function explorerHref(transactionHash: string | undefined): string | undefined { + return transactionHash && /^0x[0-9a-f]{64}$/iu.test(transactionHash) + ? `https://testnet.arcscan.app/tx/${transactionHash}` + : undefined; +} + +function mcpPaymentStillSignable(job: JobView): boolean { + return job.payment_state === 'READY' && Date.parse(job.supplier.expires_at) > Date.now(); +} + +const USER_WALLET_PAYMENT_CHECK_DELAY_MS = 500; +const USER_WALLET_PAYMENT_CHECK_ATTEMPTS = 30; + +/** + * Supplier delivery finishes in the worker, after the browser has already read + * the list, so a request opened while its delivery is `PENDING` kept saying + * "Retrieving result" until someone pressed refresh — even once the result was + * durably available. These bounded re-reads close that window. + * + * They are `GET /v1/jobs` only: never the resume endpoint, and never anything + * that could pay. They run only while a delivery is actually `PENDING`, stop as + * soon as none is, and give up after the attempts below (about a minute) so a + * delivery that is genuinely stuck does not read forever. Past that, the manual + * refresh stays the way to look again. + */ +const DELIVERY_READ_DELAY_MS = 4000; +const DELIVERY_READ_ATTEMPTS = 15; +function waitForPaymentCheck(): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, USER_WALLET_PAYMENT_CHECK_DELAY_MS); + }); +} + +async function resolveUserWalletPayment( + client: JobApiClient, + jobId: string, + transactionHash: string, + initial: JobView, +): Promise { + let latest = initial; + for ( + let attempt = 0; + attempt < USER_WALLET_PAYMENT_CHECK_ATTEMPTS && latest.payment_state === 'UNKNOWN'; + attempt += 1 + ) { + await waitForPaymentCheck(); + latest = await client.submitUserWalletPayment(jobId, transactionHash); + } + return latest; +} + +export function SupplierQuotePanel({ + quote, + heading = 'Supplier quote', +}: { + readonly quote: SupplierQuote; + readonly heading?: string; +}) { + return ( +
+
+

{heading}

+ Payment preview +
+

+ Confirm the recipient and the USDC amount they will receive. Network fees are separate. +

+
+
+
Recipient receives
+
{quoteAmount(quote)}
+
+
+
Service destination
+
+ {shortenAddress(quote.recipient)} +
+
+
+
Network
+
{networkLabel(quote.network)}
+
+
+
Quote valid until
+
{new Date(quote.expires_at).toLocaleString()}
+
+
+
+ Show supplier details +
+
+
Order reference
+
{quote.order_reference}
+
+
+
Full destination
+
{quote.recipient}
+
+
+
+
+ ); +} + +export function JobWorkspace(props: { + readonly client: JobApiClient; + readonly userWallet?: UserWalletSession; + readonly onSelectIntent: (id: string) => void; + readonly initialJobId?: string; +}) { + const [subject, setSubject] = useState(''); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [customTaskKey, setCustomTaskKey] = useState(''); + const [runSuffix] = useState(() => crypto.randomUUID().slice(0, 8)); + const [quote, setQuote] = useState(null); + const [quoteLoading, setQuoteLoading] = useState(false); + const [starting, setStarting] = useState(false); + const [approvedJob, setApprovedJob] = useState(null); + const [paymentHash, setPaymentHash] = useState(null); + const [walletAttempted, setWalletAttempted] = useState(false); + const [paymentChecking, setPaymentChecking] = useState(false); + const [mcpJobLoading, setMcpJobLoading] = useState(Boolean(props.initialJobId)); + const [notice, setNotice] = useState(''); + const generatedTaskKey = subject.trim() ? `report-${subjectSlug(subject)}-${runSuffix}` : ''; + const taskKey = customTaskKey.trim() || generatedTaskKey; + + function amountAtomic(): string | null { + try { + const value = usdcToAtomicUnits(amount); + return value === '0' ? null : value; + } catch { + return null; + } + } + + function request() { + const atomicAmount = amountAtomic(); + if (!atomicAmount || !recipient.trim() || !taskKey.trim() || !subject.trim()) return null; + return { + task_key: taskKey, + tool_id: 'team-report-v1' as const, + report_subject: subject.trim(), + recipient: recipient.trim(), + amount_atomic: atomicAmount, + }; + } + + function clearQuote(): void { + setQuote(null); + setApprovedJob(null); + setPaymentHash(null); + setWalletAttempted(false); + setPaymentChecking(false); + setNotice(''); + } + + async function loadQuote(): Promise { + const jobRequest = request(); + if (!jobRequest) { + setNotice('Enter a valid service destination and a positive USDC amount.'); + return; + } + setQuoteLoading(true); + setNotice(''); + try { + setQuote( + await props.client.quote({ + ...jobRequest, + }), + ); + } catch { + setQuote(null); + setNotice( + 'The service could not prepare a payment preview. No payment was requested. Check the connection and try again.', + ); + } finally { + setQuoteLoading(false); + } + } + + useEffect(() => { + if (!props.initialJobId) return; + let cancelled = false; + setMcpJobLoading(true); + void (async () => { + try { + const job = await props.client.get(props.initialJobId!); + if (job.payment_mode !== 'USER_WALLET' || !job.user_payment) { + throw new Error('The linked payment is not a user-wallet payment'); + } + if (cancelled) return; + setApprovedJob(job); + if (job.user_payment.transaction_hash) setPaymentHash(job.user_payment.transaction_hash); + setNotice( + mcpPaymentStillSignable(job) + ? 'MCP payment is ready. Review the details, then confirm the wallet signature.' + : job.payment_state === 'READY' + ? 'This MCP signing link has expired. Create a new approved payment instead.' + : `This MCP payment is already ${job.payment_state}.`, + ); + } catch { + if (!cancelled) { + setNotice('The MCP payment link is invalid, expired, or belongs to another workspace.'); + } + } finally { + if (!cancelled) setMcpJobLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [props.client, props.initialJobId]); + + async function start(): Promise { + if (!quote) return; + const jobRequest = request(); + if (!jobRequest) { + setNotice('Enter a valid recipient wallet and a positive USDC amount.'); + return; + } + setStarting(true); + const userWallet = props.userWallet; + if (!userWallet) { + /* + * НЕ УДАЛЯТЬ: the old server-wallet UI handoff is intentionally disabled + * for personal payments. Corporate autonomous agents may use that mode + * through a separately selected backend integration. + */ + setNotice('Connect a Privy or MetaMask wallet before approving this direct Arc payment.'); + setStarting(false); + return; + } + setWalletAttempted(false); + setPaymentHash(null); + let prepared = false; + let submittedHash: string | null = null; + try { + const payerWallet = userWallet.address ?? (await userWallet.connect()); + if (!payerWallet) { + setNotice('No Ethereum wallet is connected. Nothing was paid.'); + return; + } + const job = await props.client.prepareUserWalletJob({ + ...jobRequest, + payer_wallet: payerWallet, + }); + if (!job.user_payment) throw new Error('The API did not return a user-wallet payment plan'); + if ( + job.user_payment.recipient.toLowerCase() !== jobRequest.recipient.toLowerCase() || + job.user_payment.amount_atomic !== jobRequest.amount_atomic + ) { + throw new Error('The durable payment plan differs from the reviewed quote'); + } + setApprovedJob(job); + if (job.user_payment.transaction_hash) { + setPaymentHash(job.user_payment.transaction_hash); + setNotice( + 'The original wallet transaction is already recorded. Check that same transaction; do not approve another payment.', + ); + return; + } + setNotice( + 'Review the exact recipient and amount in Privy, then confirm the wallet transaction.', + ); + prepared = true; + setWalletAttempted(true); + const transactionHash = await userWallet.sendTransfer(job.user_payment); + submittedHash = transactionHash; + setPaymentHash(transactionHash); + const initial = await props.client.submitUserWalletPayment(job.job_id, transactionHash); + setApprovedJob(initial); + setPaymentChecking(initial.payment_state === 'UNKNOWN'); + const updated = await resolveUserWalletPayment( + props.client, + job.job_id, + transactionHash, + initial, + ); + setApprovedJob(updated); + setNotice( + updated.payment_state === 'COMMITTED' + ? 'Payment confirmed from your connected wallet. Supplier delivery can now continue.' + : updated.payment_state === 'UNKNOWN' + ? 'Transaction recorded but not final. Automatic checks ended; use the same transaction check if needed. Do not pay again.' + : `Payment state: ${updated.payment_state}.`, + ); + } catch { + setNotice( + submittedHash || paymentHash + ? 'The transaction hash is recorded. Use Check payment to verify it; do not submit another transaction.' + : prepared + ? 'No transaction hash was returned. Do not click pay again until you confirm whether the wallet submitted it.' + : 'The payment was not prepared. Keep the same task key if you need to inspect it.', + ); + } finally { + setPaymentChecking(false); + setStarting(false); + } + } + + async function checkPayment(): Promise { + if (!approvedJob || !paymentHash) return; + setPaymentChecking(true); + try { + const initial = await props.client.submitUserWalletPayment(approvedJob.job_id, paymentHash); + const updated = await resolveUserWalletPayment( + props.client, + approvedJob.job_id, + paymentHash, + initial, + ); + setApprovedJob(updated); + setNotice( + updated.payment_state === 'COMMITTED' + ? 'Payment confirmed from your connected wallet.' + : 'The same transaction is not final yet. No new payment was submitted.', + ); + } catch { + setNotice('Receipt verification is temporarily unavailable. No new payment was submitted.'); + } finally { + setPaymentChecking(false); + } + } + + async function signMcpPayment(): Promise { + const job = approvedJob; + const userWallet = props.userWallet; + const payment = job?.user_payment; + if (!job || !userWallet || !payment || job.payment_state !== 'READY') return; + setStarting(true); + setWalletAttempted(true); + let submittedHash: string | null = null; + try { + const payerWallet = userWallet.address ?? (await userWallet.connect()); + if (!payerWallet) throw new Error('No Ethereum wallet is connected'); + if (payerWallet.toLowerCase() !== payment.payer_wallet.toLowerCase()) { + throw new Error('The connected wallet does not match the prepared payer wallet'); + } + setNotice('Review the recipient and amount in your wallet, then confirm the transaction.'); + const transactionHash = await userWallet.sendTransfer(payment); + submittedHash = transactionHash; + setPaymentHash(transactionHash); + const initial = await props.client.submitUserWalletPayment(job.job_id, transactionHash); + setApprovedJob(initial); + setPaymentChecking(initial.payment_state === 'UNKNOWN'); + const updated = await resolveUserWalletPayment( + props.client, + job.job_id, + transactionHash, + initial, + ); + setApprovedJob(updated); + setNotice( + updated.payment_state === 'COMMITTED' + ? 'Payment confirmed from your connected wallet.' + : updated.payment_state === 'UNKNOWN' + ? 'Transaction recorded but not final. Use the same transaction check if needed.' + : `Payment state: ${updated.payment_state}.`, + ); + } catch { + setNotice( + submittedHash + ? 'The transaction hash is recorded. Use Check payment to verify it; do not pay again.' + : 'No transaction hash was returned. Do not approve another payment until you confirm the wallet status.', + ); + } finally { + setPaymentChecking(false); + setStarting(false); + } + } + + return ( +
+
+

DIRECT PAYMENT

+

Direct Arc payment

+

+ Send USDC directly to a reviewed recipient on Arc Testnet. OneShot keeps the payment and + its team-operated sample result bound to one request across retries. +

+
+ + { + setSubject(event.target.value); + clearQuote(); + }} + placeholder="Describe the payment intent" + /> + + { + setRecipient(event.target.value); + clearQuote(); + }} + placeholder="0x…" + inputMode="text" + autoComplete="off" + spellCheck={false} + aria-describedby="report-recipient-help" + /> + + The connected Privy Ethereum wallet will pay this exact recipient on Arc Testnet. OneShot + verifies the receipt and never replaces the user wallet payment. + + + { + setAmount(event.target.value); + clearQuote(); + }} + placeholder="0.01" + inputMode="decimal" + autoComplete="off" + aria-describedby="report-amount-help" + /> + + This is the amount the recipient receives, excluding network fees. Up to 6 decimal places. + +
+ Request key (advanced) + + + + Keep this key when retrying. It resumes the same request instead of creating another + payment. + + + { + setCustomTaskKey(event.target.value); + clearQuote(); + }} + placeholder="acme-report-2026-09-11" + /> +
+ {!quote && ( + + )} + {quote && !approvedJob && ( + <> + +
+ Request for your agent +

+ Send this exact request to POST /v1/jobs/user-wallet/prepare only after approval and + add the connected payer_wallet. Reuse its task key when resuming. The amount is in + USDC atomic units, not dollars; network fees are separate. +

+
{JSON.stringify(request(), null, 2)}
+
+

+ Nothing has been paid yet. Approval prepares a durable intent, then your connected + wallet shows the exact USDC transfer for confirmation. OneShot never uses a server + wallet for this report. +

+ + + )} + {notice && ( +

+ {notice} +

+ )} + {approvedJob && ( + <> + +

+ Payment state: {approvedJob.payment_state}. Payer:{' '} + + {approvedJob.user_payment?.payer_wallet ?? 'connected wallet'} + +

+ {props.initialJobId && + mcpPaymentStillSignable(approvedJob) && + approvedJob.user_payment && ( + + )} + {paymentHash && approvedJob.payment_state !== 'COMMITTED' && ( + + )} + + + )} +
+ ); +} + +export function JobList(props: { + readonly client: JobApiClient; + readonly onSelectIntent: (id: string) => void; +}) { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [checkingPaymentJobId, setCheckingPaymentJobId] = useState(null); + const [deliveryReadsLeft, setDeliveryReadsLeft] = useState(DELIVERY_READ_ATTEMPTS); + // Which deliveries are pending, not how many reads have happened: a new + // pending delivery is a new wait and gets the full budget, while the same one + // staying pending keeps spending the budget it already started. + const pendingDeliveryIds = requests + .filter((request) => request.delivery_state === 'PENDING') + .map((request) => request.job_id) + .join(' '); + + /** + * `quiet` reads keep the panel as it is while they run: the spinner and the + * disabled refresh button belong to a read the operator asked for, and the + * list body is keyed on `loading`, so flipping it would replay the tab fade + * every few seconds. + */ + async function refresh(options: { readonly quiet?: boolean } = {}): Promise { + if (!options.quiet) setLoading(true); + try { + const listed = await props.client.list(); + setRequests(listed); + setError(''); + } catch { + setError('Requests could not be loaded. Check API readiness and your workspace session.'); + } finally { + if (!options.quiet) setLoading(false); + } + } + + async function checkRecordedPayment(job: JobView): Promise { + const transactionHash = job.user_payment?.transaction_hash; + if (job.payment_mode !== 'USER_WALLET' || !transactionHash) return; + setCheckingPaymentJobId(job.job_id); + setError(''); + try { + await props.client.submitUserWalletPayment(job.job_id, transactionHash); + await refresh(); + } catch { + setError('The recorded transaction could not be verified. No new payment was submitted.'); + } finally { + setCheckingPaymentJobId(null); + } + } + + useEffect(() => { + void refresh(); + }, []); + + useEffect(() => { + setDeliveryReadsLeft(DELIVERY_READ_ATTEMPTS); + }, [pendingDeliveryIds]); + + useEffect(() => { + if (pendingDeliveryIds === '' || deliveryReadsLeft <= 0) return; + const timer = window.setTimeout(() => { + setDeliveryReadsLeft((left) => left - 1); + void refresh({ quiet: true }); + }, DELIVERY_READ_DELAY_MS); + return () => window.clearTimeout(timer); + }, [pendingDeliveryIds, deliveryReadsLeft]); + + return ( +
+
+
+

API REQUESTS

+

Requests and results

+
+ +
+ {/* The request list arrives after the panel has mounted, so the tab's own + fade is long over by the time there is anything to read. Keying this + block on the loading state remounts it when the rows land, which runs + the same fade on the content the operator actually waited for. */} +
+ {error && ( +

+ {error} +

+ )} + {loading ? ( +

Checking requests…

+ ) : requests.length === 0 ? ( +

No requests yet. Open Payment services to start a supported request.

+ ) : ( +
    + {requests.map((request, index) => { + const job = request; + const payment = paymentStatusCopy(job.payment_state); + const delivery = deliveryStatusCopy(job.delivery_state); + return ( +
  • +
    + + {delivery.label} +
    +

    + {serviceLabel(job.tool_id)} · {payment.label} +

    +

    + Price: {quoteAmount(job.supplier)} ·{' '} + {delivery.description} +

    + {job.settlement && ( +

    + Payment confirmed:{' '} + {explorerHref(job.settlement.transaction_hash) ? ( + + View the ArcScan transaction + + ) : ( + {job.settlement.transaction_hash} + )} +

    + )} + {job.result && ( +

    + Result ready: {job.result.report} +

    + )} + {job.payment_mode === 'USER_WALLET' && + job.payment_state === 'UNKNOWN' && + job.user_payment?.transaction_hash && ( + + )} + +
    + Show request details +
    +
    +
    Request key
    +
    {maskIdentifier(job.task_key)}
    +
    +
    +
    Supplier order
    +
    {job.supplier.order_reference}
    +
    +
    +
    Destination
    +
    {shortenAddress(job.supplier.recipient)}
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/LoginGate.tsx b/apps/web/src/components/LoginGate.tsx new file mode 100644 index 0000000..d8cd496 --- /dev/null +++ b/apps/web/src/components/LoginGate.tsx @@ -0,0 +1,144 @@ +import { useState, type ReactNode } from 'react'; +import type { OperatorSession, UserWalletSession } from '../auth/session.js'; +import { maskIdentifier } from './workspace-copy.js'; + +export interface LoginGateProps { + readonly session: OperatorSession; + readonly machineToken: string; + readonly onMachineTokenChange: (value: string) => void; + readonly showMachineToken?: boolean; + readonly userWallet?: UserWalletSession; + readonly children: ReactNode; +} + +function MachineTokenField(props: { + readonly value: string; + readonly onChange: (value: string) => void; +}) { + return ( +
+ Machine token (advanced) + + props.onChange(event.target.value)} + /> + + Memory only, never stored. Use the service token issued to the worker and agent clients. + +
+ ); +} + +export function LoginGate(props: LoginGateProps) { + const [copiedSessionId, setCopiedSessionId] = useState(false); + const [copiedWalletAddress, setCopiedWalletAddress] = useState(false); + const machineTokenPresent = props.machineToken.trim().length > 0; + const unlocked = props.session.status === 'SIGNED_IN' || machineTokenPresent; + const showMachineToken = props.showMachineToken ?? import.meta.env.MODE === 'test'; + + async function copySubject(subject: string): Promise { + try { + await navigator.clipboard?.writeText(subject); + setCopiedSessionId(true); + } catch { + setCopiedSessionId(false); + } + } + + async function copyWalletAddress(address: string | null | undefined): Promise { + if (!address) return; + try { + await navigator.clipboard?.writeText(address); + setCopiedWalletAddress(true); + } catch { + setCopiedWalletAddress(false); + } + } + + if (!unlocked) { + return ( +
+ {props.session.status === 'LOADING' ? ( +

Checking your session…

+ ) : props.session.status === 'UNCONFIGURED' ? ( +

+ Privy login is not configured for this build. Set VITE_PRIVY_APP_ID to + enable it. +

+ ) : ( +
+

Operator sign-in

+

+ The workspace reads trusted payment status. Sign in to continue. +

+ +
+ )} + {showMachineToken && ( + + )} +
+ ); + } + + return ( + <> +
+ {props.session.status === 'SIGNED_IN' && props.session.subject ? ( + <> + PRIVY CONNECTED + Workspace session active +
+ Session details + + {maskIdentifier(props.session.subject, 8)} + + +
+ Wallet address + + {props.userWallet?.address ?? 'No wallet connected'} + + +
+
+ Authenticated through a Privy wallet session. + + + ) : ( + <> + Using a machine token + {showMachineToken && ( + + )} + + )} +
+ {props.children} + + ); +} diff --git a/apps/web/src/components/McpDocsPage.tsx b/apps/web/src/components/McpDocsPage.tsx new file mode 100644 index 0000000..550de84 --- /dev/null +++ b/apps/web/src/components/McpDocsPage.tsx @@ -0,0 +1,129 @@ +import { CommitRing } from '@oneshot/brand'; + +import type { Theme } from '../theme.js'; + +const clientConfig = `{ + "mcpServers": { + "oneshot": { + "type": "http", + "url": "https://oneshot.kapustazh.dev/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +}`; + +const skillInstall = + 'npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --skill oneshot-arc-payment'; + +const toolInput = `{ + "request_key": "report-one-approved-demo-purchase-850d9a80", + "payer_wallet": "0x", + "recipient": "0x", + "amount_usdc": "", + "purpose": "One approved demo purchase" +}`; + +export function McpDocsPage(props: { readonly theme: Theme; readonly onToggleTheme: () => void }) { + return ( +
+ + +
+

ONESHOT MCP / ARC PAYMENT

+

Connect an agent to one safe payment tool.

+

+ OneShot exposes arc_payment and arc_payment_submit over + Streamable HTTP. The connected Privy or MetaMask wallet signs and sends USDC directly to + the Arc recipient; OneShot only prepares and verifies the payment. +

+
+ +
+

Payment boundary

+
    +
  • The agent generates one random request key for each new approved payment.
  • +
  • Exact retries return the original intent; changed fields return a conflict.
  • +
  • The payer is the wallet address selected by the user, never a server wallet.
  • +
  • + The second tool verifies the exact receipt and Transfer log for the returned hash. +
  • +
+
+ +
+

Install the agent skill

+

+ First install Node.js — npm comes with it. + Then install the OneShot payment skill: +

+
+          {skillInstall}
+        
+
+ +
+

Agent configuration

+

+ Every OneShot account gets its own MCP bearer. Sign in, open Profile, and choose Generate + bearer token — the page shows a ready-made configuration for your agent's MCP client. Keep + the token private and never paste real credentials into source control. +

+
+          {clientConfig}
+        
+
+ +
+

Run the walkthrough

+
    +
  1. + Connect, then confirm that the tool list contains arc_payment and arc_payment_submit. +
  2. +
  3. Review the recipient, purpose, and amount before giving them to the agent.
  4. +
  5. Pass the connected Privy or MetaMask wallet address as payer_wallet.
  6. +
  7. + The agent generates a request key from the purpose plus eight random hex characters; you + do not need to provide or copy it. +
  8. +
  9. Call arc_payment once and give the returned signing_url to the user.
  10. +
  11. + The user opens the link, reviews the prepared request, and signs it in Privy or + MetaMask. +
  12. +
  13. + Use the returned hash with arc_payment_submit when the agent receives it separately. +
  14. +
  15. When the state is COMMITTED, open its ArcScan proof and compare the transfer.
  16. +
+
+          {toolInput}
+        
+

+ READY means sign the exact transaction request. UNKNOWN means repeat submit with the same + hash or inspect recovery evidence. Only COMMITTED with a stored transaction hash is final + proof. +

+
+
+ ); +} diff --git a/apps/web/src/components/McpProfile.tsx b/apps/web/src/components/McpProfile.tsx new file mode 100644 index 0000000..56fffca --- /dev/null +++ b/apps/web/src/components/McpProfile.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import type { IssuedMcpCredential, JobApiClient, McpCredentialStatus } from '../api/job-client.js'; + +const MCP_URL = 'https://oneshot.kapustazh.dev/mcp'; +const SKILL_INSTALL = + 'npx --yes skills@latest add https://github.com/SWOFART/OneShot/tree/develop --skill oneshot-arc-payment'; + +function configFor(token: string): string { + return JSON.stringify( + { + mcpServers: { + oneshot: { + type: 'http', + url: MCP_URL, + headers: { Authorization: `Bearer ${token}` }, + }, + }, + }, + null, + 2, + ); +} + +export function McpProfile(props: { readonly client: JobApiClient }) { + const [status, setStatus] = useState(null); + const [issued, setIssued] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void props.client + .mcpCredentialStatus() + .then(setStatus) + .catch(() => setError('MCP access is unavailable right now.')); + }, [props.client]); + + async function issue(): Promise { + setBusy(true); + setCopied(false); + setError(null); + try { + const credential = await props.client.issueMcpCredential(status?.configured === true); + setIssued(credential); + setStatus({ + configured: true, + created_at: credential.created_at, + }); + } catch { + setError('Could not generate the MCP bearer token.'); + } finally { + setBusy(false); + } + } + + async function copyBearer(): Promise { + try { + if (!issued || !navigator.clipboard) throw new Error('Clipboard is unavailable'); + await navigator.clipboard.writeText(issued.bearer_token); + setCopied(true); + } catch { + setCopied(false); + setError('Could not copy the bearer token.'); + } + } + + return ( +
+

PROFILE / AGENT ACCESS

+

Connect your agent

+

Your bearer is bound to this Privy account and its private request workspace.

+

+ Open MCP documentation +

+ + + {error &&

{error}

} + {status?.configured && !issued && ( +

+ A bearer already exists. It is stored only as a digest, so rotate it to reveal a new one. +

+ )} + {issued && ( + <> +

+ Copy this configuration now. The bearer will be hidden when you leave this page. +

+
+            {configFor(issued.bearer_token)}
+          
+ + + )} + +

Install the payment skill for the agent

+

+ First install Node.js — npm comes with it and + brings npx. Then install the OneShot payment skill: +

+
+        {SKILL_INSTALL}
+      
+
+ ); +} diff --git a/apps/web/src/components/ReadinessBanner.tsx b/apps/web/src/components/ReadinessBanner.tsx new file mode 100644 index 0000000..3268d06 --- /dev/null +++ b/apps/web/src/components/ReadinessBanner.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +import type { OneShotApiClient, ReadinessResult } from '../api/client.js'; + +export function ReadinessBanner({ client }: { readonly client: OneShotApiClient }) { + const [readiness, setReadiness] = useState(null); + + useEffect(() => { + let active = true; + void client.getReadiness().then((result) => { + if (active) setReadiness(result); + }); + return () => { + active = false; + }; + }, [client]); + + if (readiness === null) return
Checking backend readiness…
; + if (readiness.status !== 'ok') { + return
Backend unavailable: {readiness.message}
; + } + if (readiness.submissions_disabled) { + return ( +
+ Safe mode: submissions paused; reads and recovery remain available. +
+ ); + } + return null; +} diff --git a/apps/web/src/components/WorkspaceLoading.tsx b/apps/web/src/components/WorkspaceLoading.tsx new file mode 100644 index 0000000..db13682 --- /dev/null +++ b/apps/web/src/components/WorkspaceLoading.tsx @@ -0,0 +1,15 @@ +import { CommitRing } from '@oneshot/brand'; + +export function WorkspaceLoading() { + return ( +
+
+ +

SECURE WORKSPACE

+

+ Opening workspace… +

+
+
+ ); +} diff --git a/apps/web/src/components/WorkspacePanels.tsx b/apps/web/src/components/WorkspacePanels.tsx new file mode 100644 index 0000000..c2fcaaa --- /dev/null +++ b/apps/web/src/components/WorkspacePanels.tsx @@ -0,0 +1,210 @@ +import type { ActivityResponse } from '@oneshot/contracts'; +import type { RecoveryClient } from '@oneshot/recovery-ui'; +import type { SettlementClient } from '@oneshot/settlement-ui'; + +import { RecoverySurface, SettlementSurface } from './FrontendSurfaces.js'; +import { maskIdentifier } from './workspace-copy.js'; + +function shortHash(value: string): string { + return `${value.slice(0, 10)}…${value.slice(-8)}`; +} + +function graphStatusLabel( + status: ActivityResponse['transactions'][number]['graph_status'], +): string { + switch (status) { + case 'INDEXED_TRANSFER': + return 'Indexed transfer'; + case 'NOT_INDEXED': + return 'Hash not indexed'; + case 'NO_TRANSACTION_HASH': + return 'No transaction hash'; + case 'UNAVAILABLE': + return 'Graph unavailable'; + } +} + +export function PaymentProtectionPanel({ + activity, + activityError, + intentId, + recoveryClient, + settlementClient, + onRefresh, +}: { + readonly activity: ActivityResponse | null; + readonly activityError: string | null; + readonly intentId: string; + readonly recoveryClient: RecoveryClient; + readonly settlementClient: SettlementClient; + readonly onRefresh: () => void; +}) { + const observation = activity?.observation; + const transactions = activity?.transactions ?? []; + const transfers = activity?.transfers ?? []; + const count = (value: number | undefined): string => + activity === null || value === undefined ? '—' : String(value); + + return ( +
+
+
+

READ-ONLY EVIDENCE

+

Payment proof

+
+ + {activity === null ? 'Not checked' : 'Checked'} + +
+

+ OneShot reads the durable ledger, provider status, and Arc observations before a result can + be resumed. This view never creates another payment. +

+
+
+ Committed settlements + {count(activity?.recorded_settlement_count)} + Recorded in OneShot +
+
+ Unknown outcomes + {count(activity?.uncertain_job_count)} + Held for reconciliation +
+
+ Unmatched transfers + {count(activity?.unmatched_transfer_count)} + Network activity without a match +
+
+
+ +

+ {activityError ?? + (activity + ? `${String(observation?.freshness ?? 'Evidence checked')} · payment records unchanged` + : 'No activity check has been requested.')} +

+
+ {activity && ( +
+
+
+

SITE AUDIT TRAIL

+

Every payment request

+
+ GRAPH + LEDGER +
+

+ One row is shown for every payment request created in this workspace, including + rejected, failed, uncertain and approved outcomes. +

+ {transactions.length > 0 ? ( +
    + {transactions.map((transaction) => ( +
  1. +
    + {transaction.payment_state} + {transaction.payment_mode} +
    +

    + Request {maskIdentifier(transaction.business_intent_id)} ·{' '} + {transaction.amount_atomic} atomic USDC to{' '} + {shortHash(transaction.recipient)} +

    +

    + {transaction.transaction_hash ? ( + <> + Transaction {shortHash(transaction.transaction_hash)} + + ) : ( + 'No transaction hash was recorded for this outcome.' + )}{' '} + · The Graph: {graphStatusLabel(transaction.graph_status)} + {transaction.graph_block_number + ? ` · block ${transaction.graph_block_number}` + : ''} + {transaction.graph_log_index !== undefined + ? ` · log ${transaction.graph_log_index}` + : ''} +

    + {transaction.graph_status === 'NOT_INDEXED' && ( + + The Graph has no matching event yet. That is not proof that payment did not + happen; Arc receipt and OneShot state remain authoritative. + + )} + {transaction.graph_status === 'UNAVAILABLE' && ( + + Graph evidence could not be read for this refresh. Arc receipt and OneShot + state remain authoritative. + + )} +
  2. + ))} +
+ ) : ( +

No site payment requests are recorded yet.

+ )} + {transfers.length > 0 && ( +
+ Indexed Graph transfers ({transfers.length}) +
    + {transfers.map((transfer) => ( +
  • + + {shortHash(transfer.transaction_hash)} · log {transfer.log_index}{' '} + · {transfer.amount_atomic} atomic USDC + + + {transfer.match === 'RECORDED_SETTLEMENT' + ? 'Matched to a OneShot settlement' + : 'Unmatched network activity'} + {transfer.sender ? ` · from ${shortHash(transfer.sender)}` : ''} + {transfer.token_contract + ? ` · token ${shortHash(transfer.token_contract)}` + : ''} + {transfer.block_number ? ` · block ${transfer.block_number}` : ''} + {transfer.block_timestamp ? ` · ${transfer.block_timestamp}` : ''} + +
  • + ))} +
+
+ )} +
+ )} + {intentId ? ( +
+
+
+

SELECTED REQUEST

+

Payment proof

+

{maskIdentifier(intentId)}

+
+ READ ONLY +
+
+ +
+
+
+

RECOVERY CONTROL

+

Recovery control

+
+ NO PAYMENT ACTION +
+
+ +
+
+ ) : ( +

+ Open a request from Requests to inspect its payment proof and recovery control. +

+ )} +
+ ); +} diff --git a/apps/web/src/components/workspace-copy.ts b/apps/web/src/components/workspace-copy.ts new file mode 100644 index 0000000..8cd8d06 --- /dev/null +++ b/apps/web/src/components/workspace-copy.ts @@ -0,0 +1,100 @@ +import type { DeliveryState, IntentState } from '@oneshot/contracts'; + +export interface StatusCopy { + readonly label: string; + readonly tone: 'success' | 'pending' | 'warning' | 'danger' | 'neutral'; + readonly description: string; +} + +const PAYMENT_STATUS: Readonly> = { + AUTHORIZING: { + label: 'Authorizing payment', + tone: 'pending', + description: 'The wallet policy is checking this request before payment can start.', + }, + READY: { + label: 'Ready to pay', + tone: 'pending', + description: 'The request is approved and waiting for the payment worker.', + }, + SUBMITTING: { + label: 'Payment in progress', + tone: 'pending', + description: 'OneShot is sending the approved payment and checking the Arc result.', + }, + COMMITTED: { + label: 'Paid and confirmed', + tone: 'success', + description: 'The payment is confirmed and can be reused for result delivery.', + }, + FAILED_SAFE: { + label: 'Stopped safely', + tone: 'neutral', + description: 'The request closed without a committed payment.', + }, + UNKNOWN: { + label: 'Checking payment', + tone: 'warning', + description: 'The outcome is being verified. Do not submit this request again.', + }, + REJECTED: { + label: 'Not approved', + tone: 'danger', + description: 'The request was stopped before a payment was submitted.', + }, +}; + +const DELIVERY_STATUS: Readonly> = { + NOT_REQUESTED: { + label: 'Result not requested', + tone: 'neutral', + description: 'The supplier result has not been requested yet.', + }, + PENDING: { + label: 'Retrieving result', + tone: 'pending', + description: 'The original supplier request is being resumed.', + }, + AVAILABLE: { + label: 'Result ready', + tone: 'success', + description: 'The supplier result is available.', + }, + RETRIEVAL_FAILED: { + label: 'Result needs attention', + tone: 'warning', + description: 'Payment is kept; the supplier result needs another read-only retrieval.', + }, +}; + +export function paymentStatusCopy(state: IntentState): StatusCopy { + return PAYMENT_STATUS[state]; +} + +export function deliveryStatusCopy(state: DeliveryState): StatusCopy { + return DELIVERY_STATUS[state]; +} + +export function serviceLabel(toolId: string): string { + switch (toolId) { + case 'team-report-v1': + return 'Direct Arc payment'; + default: + return 'Arc payment'; + } +} + +export function networkLabel(network: string): string { + return network === 'eip155:5042002' ? 'Arc Testnet' : network; +} + +export function maskIdentifier(value: string, visible = 6): string { + if (value.length <= visible) return value; + const head = Math.ceil(visible / 2); + const tail = Math.floor(visible / 2); + return `${value.slice(0, head)}…${value.slice(-tail)}`; +} + +export function maskAddress(value: string): string { + return maskIdentifier(value, 5); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..e51a482 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,55 @@ +import { Buffer } from 'buffer'; +import { lazy, StrictMode, Suspense } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +import { WorkspaceLoading } from './components/WorkspaceLoading.js'; + +globalThis.Buffer ??= Buffer; + +const DEFAULT_PRIVY_APP_ID = 'cmtqbf5zo013w0cky3r0jqjca'; +const appId = + import.meta.env.MODE === 'test' + ? (import.meta.env.VITE_PRIVY_APP_ID ?? '') + : import.meta.env.VITE_PRIVY_APP_ID || DEFAULT_PRIVY_APP_ID; + +const PrivyConsole = lazy(async () => { + const module = await import('./auth/privy-session.js'); + function AuthenticatedApp() { + const session = module.usePrivyOperatorSession(); + const userWallet = module.usePrivyUserWallet(); + return ( + session} + userWallet={userWallet} + /> + ); + } + return { + default: () => ( + + + + ), + }; +}); + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Root container #root not found in document'); +} + +const mcpJobId = new URLSearchParams(window.location.search).get('mcp_job_id') ?? undefined; + +createRoot(container).render( + + {appId ? ( + }> + + + ) : ( + + )} + , +); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..f88a414 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,2196 @@ +@import '@oneshot/brand/fonts.css'; +@import '@oneshot/brand/tokens.css'; + +/** + * The operator console shell. + * + * Every colour here comes from @oneshot/brand. The guard in + * test/styles.test.ts fails the build if a literal creeps back in. + * + * The diagonal cut belongs to the hero alone; every other surface below is a + * plain rounded rectangle. + */ + +:root { + /* One fluid step for the whole page. Everything below is sized in rem, so + this is the single place type scales with the viewport — on a large + display the console is no longer set at laptop sizes, and the ratios + between headings, labels and help text stay fixed at every width. The + clamp bounds it so the layout never runs away at either extreme. */ + font-size: clamp(15px, 0.35vw + 13.2px, 19px); + + color: var(--os-ink); + background-color: var(--os-ground); + font-family: var(--os-font-primary); + font-weight: 300; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--os-ground); + color: var(--os-ink); + font-family: var(--os-font-primary); + font-weight: 300; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 42px; + padding: 0.65rem 1.25rem; + border: 0; + border-radius: 999px; + color: var(--os-on-signal); + background: var(--os-signal); + font-family: var(--os-font-primary); + font-weight: 300; + letter-spacing: 0.06em; + cursor: pointer; + transition: + background-color 160ms ease, + border-color 160ms ease, + opacity 160ms ease, + transform 160ms ease; +} + +button:hover:not(:disabled) { + opacity: 0.86; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +button.secondary { + color: inherit; + background: transparent; + border: 1px solid var(--os-line-strong); +} + +button.compact { + min-height: 32px; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--os-signal); + outline-offset: 2px; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +a { + color: var(--os-accent-ink); + text-decoration: none; + transition: color 0.2s ease; +} + +a:hover { + text-decoration: underline; +} + +/* ========================================================================== + App Shell & Top Navigation + ========================================================================== */ + +.app-shell { + width: min(1080px, calc(100% - 2.5rem)); + margin: 0 auto; + padding: 1.5rem 0 4rem; +} + +.workspace-loading { + display: grid; + place-items: center; + min-height: 100vh; + padding: 2rem; + background: var(--os-ground); + color: var(--os-ink); +} + +.workspace-loading-card { + display: grid; + justify-items: center; + gap: 0.85rem; + width: min(100%, 22rem); + padding: 2.5rem 2rem; + border: 1px solid var(--os-line); + border-radius: var(--os-radius-lg); + background: var(--os-surface); + text-align: center; +} + +.workspace-loading-eyebrow { + margin: 0; + color: var(--os-accent-ink); + font-family: var(--os-font-mono); + font-size: 0.7rem; + letter-spacing: 0.16em; +} + +.workspace-loading-message { + margin: 0; + color: var(--os-ink-muted); + font-size: 1rem; +} + +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.25rem; + margin-bottom: 3.5rem; + background: var(--os-surface); + border: 1px solid var(--os-line); + border-radius: var(--os-radius-lg); + color: var(--os-ink); +} + +.brand-group { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand-logo { + border-radius: 50%; +} + +.brand-text { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.brand-name { + font-size: 1rem; + font-weight: 300; + letter-spacing: 0.1em; + color: var(--os-ink); +} + +.brand-tag { + font-size: 0.65rem; + font-weight: 300; + letter-spacing: 0.12em; + color: var(--os-accent-ink); + font-family: var(--os-font-mono); +} + +.nav-status-group { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 300; + background: transparent; + border: 1px solid var(--os-line); + color: var(--os-ink-muted); +} + +.status-badge.network-badge { + color: var(--os-ink); +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background-color: var(--os-state-committed); +} + +.status-badge.token-badge { + border-color: var(--os-accent-ink); + color: var(--os-accent-ink); + font-family: var(--os-font-mono); +} + +.nav-console-link { + display: inline-flex; + align-items: center; + padding: 0.45rem 1rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 300; + color: var(--os-ink); + background: transparent; + border: 1px solid var(--os-line); + transition: opacity 0.2s ease; +} + +.nav-console-link:hover { + opacity: 0.86; + text-decoration: none; +} + +/* ========================================================================== + Hero Section + ========================================================================== */ + +/* The hero spans the shell, like the nav above it and the tabs below it. It + used to cap at 820px inside a 1080px shell, which left it visibly inset from + everything else on the page. */ +.app-header.hero-section { + padding: 0 0 3rem; + margin: 0; +} + +.eyebrow { + margin-bottom: 1rem; + color: var(--os-accent-ink); + font-size: 0.8rem; + font-weight: 300; + letter-spacing: 0.2em; + font-family: var(--os-font-mono); + text-transform: uppercase; +} + +.app-header h1 { + margin-bottom: 1.25rem; + font-size: clamp(2.4rem, 5.5vw, 3.8rem); + line-height: 1.08; + font-weight: 300; + letter-spacing: -0.02em; + color: var(--os-ink); +} + +.hero-lead { + font-size: clamp(1.05rem, 2vw, 1.25rem); + line-height: 1.6; + color: var(--os-ink-muted); + max-width: 680px; + margin: 0 auto 0.75rem; +} + +.hero-sublead { + font-size: 0.95rem; + color: var(--os-ink-muted); + margin-bottom: 2rem; +} + +.hero-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 2.5rem; +} + +.btn-hero-cta { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 48px; + padding: 0.75rem 1.75rem; + border-radius: 999px; + background: var(--os-signal); + border: 0; + color: var(--os-on-signal); + font-weight: 300; + font-size: 0.95rem; + transition: opacity 0.2s ease; +} + +.btn-hero-cta:hover { + opacity: 0.86; + text-decoration: none; +} + +.btn-hero-secondary { + display: inline-flex; + align-items: center; + min-height: 48px; + padding: 0.75rem 1.5rem; + border-radius: 999px; + background: transparent; + border: 1px solid var(--os-line-strong); + color: var(--os-ink); + font-weight: 300; + font-size: 0.95rem; + transition: opacity 0.2s ease; +} + +.btn-hero-secondary:hover { + opacity: 0.86; + text-decoration: none; +} + +/* ========================================================================== + Readiness Banner + ========================================================================== */ + +.readiness { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.2rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 300; + background: var(--os-surface); + border: 1px solid var(--os-line); + color: var(--os-ink-muted); +} + +.readiness.success { + border-color: var(--os-line); + color: var(--os-state-committed); + background: var(--os-panel); +} + +.readiness.warning { + border-color: var(--os-line); + color: var(--os-state-unknown); + background: var(--os-panel); +} + +/* ========================================================================== + Architectural Invariants Section + ========================================================================== */ + +.invariants-section { + margin: 3.5rem 0 5rem; +} + +.section-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.section-eyebrow { + display: block; + margin-bottom: 0.5rem; + font-size: 0.75rem; + font-weight: 300; + letter-spacing: 0.16em; + color: var(--os-accent-ink); + font-family: var(--os-font-mono); +} + +.section-header h2 { + font-size: clamp(1.6rem, 3.5vw, 2.2rem); + font-weight: 300; + letter-spacing: -0.01em; + color: var(--os-ink); +} + +.invariants-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 1.25rem; +} + +.invariant-card { + display: flex; + flex-direction: column; + padding: 1.6rem 1.4rem; + border-radius: var(--os-radius-lg); + background: var(--os-panel); + color: var(--os-panel-ink); + transition: transform 0.25s ease; +} + +.invariant-card:hover { + transform: translateY(-3px); +} + +.invariant-index { + font-family: var(--os-font-mono); + font-size: 0.72rem; + font-weight: 300; + letter-spacing: 0.14em; + color: var(--os-accent-ink); + margin-bottom: 0.9rem; +} + +.invariant-card h3 { + font-size: 1.15rem; + font-weight: 300; + color: var(--os-panel-ink); + margin-bottom: 0.6rem; + line-height: 1.25; +} + +.invariant-card p { + font-size: 0.88rem; + line-height: 1.55; + color: var(--os-panel-ink-muted); + margin-bottom: 0; +} + +/* ======================================================================== + MCP documentation + ======================================================================== */ + +.docs-page { + max-width: 960px; +} + +.docs-header, +.docs-section { + width: min(100%, 760px); + margin-inline: auto; +} + +.docs-header { + margin-bottom: 2rem; +} + +.docs-header h1 { + max-width: 720px; + margin-bottom: 1rem; + color: var(--os-ink); + font-size: clamp(2.2rem, 5vw, 3.4rem); + font-weight: 300; + line-height: 1.08; + letter-spacing: -0.02em; +} + +.docs-lead, +.docs-section p, +.docs-facts, +.docs-steps { + color: var(--os-ink-muted); + line-height: 1.65; +} + +.docs-lead { + max-width: 680px; + font-size: 1.1rem; +} + +.docs-lead code { + color: var(--os-accent-ink); + font-family: var(--os-font-mono); +} + +.docs-section { + margin-bottom: 1.25rem; + padding: 1.5rem; + border: 1px solid var(--os-line); + border-radius: var(--os-radius-lg); + background: var(--os-surface); + color: var(--os-ink); +} + +.docs-section h2 { + margin-bottom: 0.75rem; + color: var(--os-ink); + font-size: 1.35rem; + font-weight: 400; +} + +.docs-facts, +.docs-steps { + margin: 0; + padding-left: 1.3rem; +} + +.docs-facts li, +.docs-steps li { + margin-bottom: 0.55rem; + padding-left: 0.2rem; +} + +.docs-code { + margin: 1rem 0 0; + padding: 1.15rem; + overflow-x: auto; + border: 1px solid var(--os-line); + border-radius: var(--os-radius); + background: var(--os-panel); + color: var(--os-panel-ink); + font-family: var(--os-font-mono); + font-size: 0.8rem; + line-height: 1.6; + white-space: pre; +} + +.docs-note { + margin: 1rem 0 0; + padding-top: 1rem; + border-top: 1px solid var(--os-line); +} + +/* ========================================================================== + Console Workspace Container + ========================================================================== */ + +.console-container { + margin: 3rem 0; + padding: 2rem; + border-radius: var(--os-radius-lg); + background: var(--os-panel); + color: var(--os-panel-ink); +} + +.console-header { + margin-bottom: 1.75rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--os-panel-line); +} + +.console-header h2 { + font-size: 1.6rem; + font-weight: 300; + color: var(--os-panel-ink); + margin-bottom: 0.4rem; +} + +.console-subtitle { + color: var(--os-panel-ink-muted); + font-size: 0.92rem; + line-height: 1.5; + margin-bottom: 0; +} + +/* ========================================================================== + Login Gate & Operator Authentication + ========================================================================== */ + +.login-gate { + display: grid; + gap: 1.25rem; + padding: 2rem; + border-radius: var(--os-radius-lg); + background: var(--os-panel); + color: var(--os-panel-ink); + margin: 1rem 0; +} + +.gate-action-box { + display: grid; + gap: 0.75rem; +} + +.gate-action-box h2 { + font-size: 1.35rem; + font-weight: 300; + color: var(--os-panel-ink); + margin-bottom: 0.25rem; +} + +.gate-subtitle { + color: var(--os-panel-ink-muted); + font-size: 0.95rem; + margin-bottom: 0.5rem; +} + +.btn-privy { + width: fit-content; + min-height: 46px; + padding: 0.75rem 1.75rem; + font-size: 0.95rem; + border-radius: 999px; + background: var(--os-signal); + border: 0; + color: var(--os-on-signal); +} + +.btn-privy:hover { + opacity: 0.86; +} + +.gate-unconfigured { + padding: 1rem 1.25rem; + border-radius: var(--os-radius-lg); + background: var(--os-panel); + color: var(--os-state-unknown); + font-size: 0.9rem; + line-height: 1.5; + margin: 0; +} + +.gate-unconfigured code { + background: var(--os-surface); + padding: 0.2rem 0.4rem; + border-radius: 999px; + color: var(--os-ink); + font-family: var(--os-font-mono); +} + +.loading-text { + color: var(--os-accent-ink); + font-weight: 300; +} + +.operator-identity { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; + background: var(--os-surface); + border: 1px solid var(--os-line); + border-radius: var(--os-radius-lg); + color: var(--os-ink); +} + +.operator-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 300; + letter-spacing: 0.1em; + background: var(--os-signal); + border: 0; + color: var(--os-on-signal); + font-family: var(--os-font-mono); +} + +.operator-did, +.operator-wallet-address { + font-family: var(--os-font-mono); + font-size: 0.82rem; + color: var(--os-ink); + background: var(--os-surface); + padding: 0.3rem 0.65rem; + border-radius: var(--os-radius-lg); + border: 1px solid var(--os-line); + overflow-wrap: anywhere; +} + +.session-wallet-address { + display: grid; + gap: 0.4rem; +} + +.session-wallet-address > span { + color: var(--os-panel-ink-muted); + font-size: 0.78rem; +} + +.session-wallet-address .btn-copy { + justify-self: start; +} + +.btn-copy { + min-height: 32px; + padding: 0.3rem 0.75rem; + font-size: 0.78rem; + background: transparent; + border: 1px solid var(--os-line-strong); + color: var(--os-ink); +} + +.btn-signout { + min-height: 32px; + padding: 0.3rem 0.75rem; + font-size: 0.78rem; + background: transparent; + border: 1px solid var(--os-state-failed); + color: var(--os-state-failed); + margin-left: auto; +} + +.btn-signout:hover { + opacity: 0.86; +} + +.operator-note { + display: block; + width: 100%; + color: var(--os-ink-muted); + font-size: 0.75rem; + margin-top: 0.25rem; +} + +.machine-token { + padding-top: 0.5rem; + color: var(--os-panel-ink-muted); +} + +.machine-token summary { + cursor: pointer; + font-weight: 300; + font-size: 0.85rem; + color: var(--os-panel-ink-muted); + transition: color 0.2s ease; + user-select: none; +} + +/* .machine-token also renders inside .operator-identity (page surface, not a + panel) — see LoginGate. The overrides below re-point its ink at the page + tokens there. They must sit before :hover below: both selectors tie on + specificity, so source order decides, and :hover needs to win while + hovering in either context. */ +.operator-identity .machine-token summary { + color: var(--os-ink-muted); +} + +.machine-token summary:hover { + color: var(--os-accent-ink); +} + +.machine-token label { + display: block; + margin: 0.75rem 0 0.35rem; + font-size: 0.85rem; + font-weight: 300; + color: var(--os-panel-ink); +} + +.machine-token input { + width: 100%; + min-height: 40px; + padding: 0.55rem 0.75rem; + background: var(--os-surface); + border: 1px solid var(--os-line); + border-radius: 999px; + color: var(--os-ink); +} + +.machine-token small { + display: block; + margin-top: 0.4rem; + color: var(--os-panel-ink-muted); + font-size: 0.75rem; +} + +.operator-identity .machine-token { + color: var(--os-ink-muted); +} + +.operator-identity .machine-token label { + color: var(--os-ink); +} + +.operator-identity .machine-token small { + color: var(--os-ink-muted); +} + +/* ========================================================================== + Active Intent Context Bar + ========================================================================== */ + +.intent-context { + display: grid; + grid-template-columns: auto minmax(200px, 1fr); + align-items: center; + gap: 1rem; + margin: 1.25rem 0; + padding: 0.85rem 1.25rem; + background: var(--os-surface); + border: 1px solid var(--os-line); + border-radius: var(--os-radius-lg); + color: var(--os-ink); +} + +.intent-context label { + font-size: 0.85rem; + font-weight: 300; + color: var(--os-ink); + white-space: nowrap; +} + +.intent-context input { + width: 100%; + min-height: 42px; + padding: 0.65rem 0.9rem; + border: 1px solid var(--os-line); + border-radius: 999px; + background: var(--os-surface); + color: var(--os-ink); + font-family: var(--os-font-mono); + font-size: 0.9rem; + transition: border-color 0.2s ease; +} + +.intent-context input:focus { + border-color: var(--os-accent-ink); +} + +/* ========================================================================== + Tabs + ========================================================================== */ + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; +} + +/* The tab strip sits on the page ground, not on a panel, so it takes the page + ink — which flips with the theme. --os-panel-ink is near-white in *both* + themes (it belongs to the forest panel, which never flips), so using it here + rendered the unselected tabs near-white on the near-white light ground. */ +.tabs button { + min-height: 38px; + color: var(--os-ink); + background: transparent; + border: 1px solid var(--os-line-strong); +} + +.tabs button[aria-selected='true'] { + color: var(--os-on-signal); + background: var(--os-signal); + border-color: var(--os-accent-ink); +} + +.tabs button[aria-selected='true']:hover:not(:disabled) { + opacity: 1; +} + +/* ========================================================================== + Panels & Form Controls + ========================================================================== */ + +.panel { + padding: clamp(1.25rem, 3vw, 2rem); + border-radius: var(--os-radius-lg); + background: var(--os-panel); + color: var(--os-panel-ink); +} + +.panel > p { + line-height: 1.6; + margin-bottom: 0; +} + +/* Extra separation above the agent-skill install heading in Profile. */ +.skill-install-heading { + margin-top: 3rem; +} + +/* Two or more panels stacked in one tab. */ +.panel-stack { + display: grid; + gap: 1.25rem; +} + +/* Used by every panel that pairs a title block with an action (Jobs' refresh, + the quote panels' badge). It had no rule at all, so the title and its button + stacked flush against each other with no rhythm. */ +.panel-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1.1rem; +} + +.panel-heading > div { + display: grid; + gap: 0.25rem; +} + +.panel-heading h2, +.panel-heading h3 { + margin: 0; + font-weight: 300; + font-size: 1.3rem; +} + +.panel-heading .eyebrow { + margin-bottom: 0; +} + +.panel header h2 { + font-size: 1.35rem; + font-weight: 300; + color: var(--os-panel-ink); + margin-bottom: 0.3rem; +} + +.panel header p { + color: var(--os-panel-ink-muted); + font-size: 0.9rem; + margin-bottom: 1.5rem; +} + +.form { + display: grid; + gap: 1rem; +} + +.form label, +.lookup label { + font-size: 0.85rem; + font-weight: 300; + color: var(--os-panel-ink); +} + +.form input, +.lookup input { + width: 100%; + min-height: 42px; + padding: 0.65rem 0.85rem; + border: 1px solid var(--os-line); + border-radius: 999px; + color: var(--os-ink); + background: var(--os-surface); + transition: border-color 0.2s ease; +} + +.form input:focus, +.lookup input:focus { + border-color: var(--os-accent-ink); +} + +.form output { + display: flex; + align-items: center; + min-height: 42px; + padding: 0.65rem 0.85rem; + margin-top: 0.35rem; + border: 1px solid var(--os-line); + border-radius: 999px; + background: var(--os-surface); + color: var(--os-accent-ink); + font-family: var(--os-font-mono); + font-size: 0.85rem; +} + +.label-row, +.lookup, +.state-card { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.label-row { + justify-content: space-between; +} + +.payment-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.payment-grid > div { + display: grid; + gap: 0.35rem; +} + +/* Non-