diff --git a/.github/workflows/dacs-directory-ci.yml b/.github/workflows/dacs-directory-ci.yml index 844cbff..abf6c76 100644 --- a/.github/workflows/dacs-directory-ci.yml +++ b/.github/workflows/dacs-directory-ci.yml @@ -21,32 +21,30 @@ concurrency: jobs: verify: name: typecheck · test · build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 defaults: run: working-directory: reference-implementations/dacs-directory env: - # The SDK dependency tree emits patch-level engine requirements above - # Node 20.10; the app is validated on Node 22. - NODE_VERSION: "22" # Keeps canonical URLs / sitemap deterministic during the build instead - # of falling back to localhost (which the build warns about). - NEXT_PUBLIC_DIRECTORY_URL: https://directory.ci.local - # Exercise the same production build-time path as the public /try page. - NEXT_PUBLIC_BUTLER_ORIGIN: https://agents.ci.local + # of falling back to localhost (which the build warns about). A reserved + # RFC 6761 documentation name: only the flagged CI check below accepts it. + NEXT_PUBLIC_DIRECTORY_URL: https://directory.ci.example steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@v7 + # The exact Node release is pinned in .nvmrc. + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: "22" + node-version-file: reference-implementations/dacs-directory/.nvmrc + + - name: Check the Node release + run: npm run check:node-runtime - name: Vendor + build the pinned dacs-sdk, install app deps - # The vendored dacs-sdk is a private repo; setup-sdk.sh authenticates - # the nested clone with DACS_SDK_GITHUB_TOKEN when present. Add a - # read-only token (repo secret) with access to DACS-Agent-commerce/dacs-sdk - # or this step fails at the clone. The default GITHUB_TOKEN cannot read - # sibling private repos. + # setup-sdk.sh clones DACS-Agent-commerce/dacs-sdk at its pinned commit. + # The repo is public; DACS_SDK_GITHUB_TOKEN, when set, is only used to + # authenticate that clone. env: DACS_SDK_GITHUB_TOKEN: ${{ secrets.DACS_SDK_GITHUB_TOKEN }} run: npm run setup @@ -57,8 +55,10 @@ jobs: - name: Test (includes seed smoke) run: npm test - - name: Validate production origins - run: npm run check:deploy-config + # CI's origin is a documentation name, so this is the flagged check; + # railway.json runs the strict production one. + - name: Validate directory origin + run: npm run check:deploy-config -- --allow-documentation-names - name: Production build run: npm run build diff --git a/INDEX.md b/INDEX.md index aaaec33..92fa405 100644 --- a/INDEX.md +++ b/INDEX.md @@ -7,7 +7,7 @@ The catalogue of community projects, in-tree and external. Conformance status va | Project | Maintainer | DACS surface | Conformance | License | Status | |---|---|---|---|---|---| | agent-commerce-demo | KyneSys Labs | Full lifecycle (DACS-1..5); exercises `pay-x402` end-to-end (buyer-side EIP-3009/Permit2, USDC on Base) | `vector-tested` (golden vectors, DACS-Standard `conformance/`) | TBD on publication | **Canonical** (steward reference implementation; repo currently private, publication pending) | -| [dacs-directory](./reference-implementations/dacs-directory) | [randomblocker](https://github.com/randomblocker) | Community agent catalog and browser UI: DACS-1 §6.3.6 catalog API and ListingSummary/ReputationHint shapes; DACS-1 CCI identity resolution; DACS-5 §10.4 bundle and referenced-artifact verification; §10.5 reputation derivation | `exercises-spec` — implementation tests cover security boundaries and verification policy; no claim of full golden-vector coverage | MIT | In-tree | +| [dacs-directory](./reference-implementations/dacs-directory) | [randomblocker](https://github.com/randomblocker) | Community agent catalog and browser UI: DACS-1 §6.3.6 catalog API and ListingSummary/ReputationHint shapes; DACS-1 CCI identity resolution; DACS-5 §10.4 bundle and referenced-artifact verification; §10.5 observed history only (testnet TP-1 publishes no completion or fault rates or scores; [deviations](./reference-implementations/dacs-directory/docs/TP-1.md)) | `exercises-spec` — implementation tests cover security boundaries and verification policy; no claim of full golden-vector coverage | MIT | In-tree | | [pathos-dacs-ref](https://github.com/cX3po/pathos-dacs-ref) | PATH-OS Labs | DACS-5 §10.4 AttestationBundle + JCS canonical form §B.2 (signed-scope hashing); §10.4.2/3 two-sided anchoring + divergence; DACS-1 identity verify, DACS-2 recipe vetting, DACS-3 sealed-bid, DACS-4 settlement-evidence; selective-disclosure / consent / dispute-evidence layer | `vector-tested` — reproduces the DACS-Standard `conformance/` golden AttestationBundle hashes (§10.4 signed scope + canonical form §B.2); `exercises-spec` for the rest (two-sided anchoring, DACS-1/2/3/4, disclosure/consent/dispute) | MIT | External | | [dacs-verify](https://github.com/mj-deving/dacs-verify) | mj-deving | Independent Bun/TypeScript verifier and conformance runner for DACS v0.1: canonical JSON + content hashes (§B.2), domain-separated signatures (§B.7), DACS-1 identity/listing checks, DACS-2 vetting primitives, DACS-3 agreement checks, DACS-4 settlement evidence, DACS-5 AttestationBundle/reputation verification, plus a proposed DACS-X dispute/disclosure prototype. **Generates DACS-Standard's published §14 golden vectors** — named as the `generator` in `conformance/MANIFEST.json` | `vector-tested` — passes 186 DACS-Standard golden conformance checks across DACS-1..5 verifier surfaces; DACS-X dispute/disclosure code is `prototype` / non-normative | MIT | External | | [stranger-gauntlet](https://github.com/cX3po/pathos-dacs-ref/tree/main/showcase/stranger-gauntlet) | PATH-OS Labs | A2A trust showcase — one Shopper vets three stranger vendor agents through the DACS-5 §10.4.1 AttestationBundle acceptance verifier: two honest bundles → `accept`; a bundle tampered *after* signing → `reject` with the decisive check named. Exercises §10.4.1 signed-scope hashing (R5-1 anchoredByRole-excluded canonical form) and §7.5.1 do-not-collapse (`accept` vs `reject`/`indeterminate`) | `exercises-spec` — demonstrates the §10.4.1 verifier catching a counterfeit built from local fixed-seed fixtures (not named DACS-Standard vectors); the underlying verifier + canonical hasher are the vector-tested pathos-dacs-ref. Deterministic, zero-network, no deployed identities | MIT | External | diff --git a/docs/codex-local-workflow.md b/docs/codex-local-workflow.md index 67139fc..6362d4c 100644 --- a/docs/codex-local-workflow.md +++ b/docs/codex-local-workflow.md @@ -6,22 +6,24 @@ It uses the Node version and SDK revision already pinned by the repository. ## Keep the worktree isolated -- Use Node 22, as required by - `reference-implementations/dacs-directory/.nvmrc` and `package.json`. +- Use the exact Node release in `reference-implementations/dacs-directory/.nvmrc`, + as CI and Railway do. `package.json` engines names the same release. - Do not copy `.env` files, tokens, wallets, seeds, `data/`, `node_modules/`, or `vendor/` from another worktree. - Keep development catalog state under this worktree's `reference-implementations/dacs-directory/data/` directory. Verification uses a separate disposable directory, as shown below. -- Do not run the indexer or live/paid browser tests as part of local static and - unit verification. They are separate, networked activities. +- Do not run the indexer or browser tests as part of local static and unit + verification. The indexer is a separate, networked activity, and browser + tests need their own setup. ## Install the pinned dependencies -From the Community repository root, use your normally initialized version -manager to select an already-installed Node 22 (for example `nvm use 22`) and -confirm that `node --version` prints `v22.x`. Then set paths from the current -worktree rather than a fixed checkout location: +Use your normally initialized version manager to select the `.nvmrc` release +(with nvm, run `nvm install` from `reference-implementations/dacs-directory`, which +reads `.nvmrc`) and confirm that `node --version` prints `v` followed by that +file's content. Then, from the Community repository root, set paths from the +current worktree rather than a fixed checkout location: ```bash repo_root=$(git rev-parse --show-toplevel) @@ -30,7 +32,6 @@ cd "$app_dir" export DACS_DIRECTORY_DATA="$app_dir/data" export NEXT_TELEMETRY_DISABLED=1 -unset RUN_LIVE_PAID_E2E LIVE_BUTLER_ORIGIN ``` Read the exact SDK commit from the committed setup script, then create or @@ -101,9 +102,9 @@ stops at that check: Rerunning the block on a clean checkout is safe: it re-pins the checkout and reinstalls. If it prints `stopped: ...` or any command fails, fix that before -continuing; do not run the checks below on a partial install. Access to the SDK -repository may require your normal GitHub authentication; do not put a token in -a remote URL or committed file. +continuing; do not run the checks below on a partial install. The SDK repository +is public, so the clone needs no GitHub authentication; never put a token in a +remote URL or committed file. The existing clone's `origin` is used for fetching, but its checked-out `HEAD` must still match the exact SDK commit pinned by this repository. @@ -113,10 +114,10 @@ must still match the exact SDK commit pinned by this repository. These are the same check commands as the Directory CI's typecheck, test, origin-validation, and production-build steps. CI's `npm run setup` and the installation above both use `npm ci` for the SDK and app. Use the same -placeholder origins as the `env:` block of -`.github/workflows/dacs-directory-ci.yml` (today -`NEXT_PUBLIC_DIRECTORY_URL` and `NEXT_PUBLIC_BUTLER_ORIGIN`; drop any variable -the workflow no longer sets): +placeholder origin as the `env:` block of +`.github/workflows/dacs-directory-ci.yml` (today only +`NEXT_PUBLIC_DIRECTORY_URL`). That origin is a reserved documentation name, so, +as in CI, the origin check runs with `--allow-documentation-names`. ```bash ( @@ -127,12 +128,8 @@ the workflow no longer sets): npm run typecheck npm test - NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.local \ - NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.ci.local \ - npm run check:deploy-config - NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.local \ - NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.ci.local \ - npm run build + NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.example npm run check:deploy-config -- --allow-documentation-names + NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.example npm run build ) ``` diff --git a/reference-implementations/dacs-directory/.gitignore b/reference-implementations/dacs-directory/.gitignore index 1292360..e8bdfa9 100644 --- a/reference-implementations/dacs-directory/.gitignore +++ b/reference-implementations/dacs-directory/.gitignore @@ -12,7 +12,6 @@ data/*.sqlite-shm data/*.sqlite-wal .playwright-mcp/ playwright-report/ -playwright-report-live/ test-results/ *.png tsconfig.tsbuildinfo diff --git a/reference-implementations/dacs-directory/.nvmrc b/reference-implementations/dacs-directory/.nvmrc index 2bd5a0a..de889f8 100644 --- a/reference-implementations/dacs-directory/.nvmrc +++ b/reference-implementations/dacs-directory/.nvmrc @@ -1 +1 @@ -22 +22.23.3 diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index f8e5d11..ff1c7dc 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -16,18 +16,22 @@ name and (when owner-signed) the "owner-registered" badge — it is never a gate Live thesis: a Web2 marketplace *asks you to trust its database*. This directory is a **cache of chain state**. Listings and strict bundle-history figures come from signed artifacts; GCR identity links are shown separately and are not called DACS-2 verification. -The catalog's `reputationHint`s remain advisory. “Verify yourself” repeats strict cryptographic -checks in-browser, while chain inclusion still depends on the disclosed proxy/RPC path. +Completion verification unavailable: TP-1 testnet catalog projections publish completion and +fault rates and numeric reputation scores as `null`, with a reason. Counts are labelled +as observed outcomes, not verified completion. Rate and score filters and ranking return +400. Individual evidence inspection remains available. Full metrics return under +a later reputation contract. “Verify yourself” repeats strict cryptographic checks in-browser, +while chain inclusion still depends on the disclosed proxy/RPC path. ## What it implements | Surface | Spec | How | |---|---|---| -| Catalog API | DACS-1 §6.3.6 | Full normative listing filters plus `q`, profile and identity-tier extensions; canonical current listings, explicitly labelled legacy SDK artifacts, and unauthenticated BB-4-verified `GET /api/dacs/bundles/{jobId}` candidates | +| Catalog API | DACS-1 §6.3.6 | Listing filters excluding TP-1 rate/score filters, plus `q`, profile and identity-tier extensions; canonical current listings, explicitly labelled legacy SDK artifacts, and unauthenticated BB-4-verified `GET /api/dacs/bundles/{jobId}` candidates | | Seller publication | DACS-1 §6.3.4 | **`/register` does not publish pay-bearing listings at this pin.** DACS-1 requires LP-6 `verified` rail authority and an LP-1 verified `finalized` AnchorReceipt before a pay-bearing listing is published, and the Directory can establish neither. `/register` emits DACS-4 §9.4.2 registry rail IDs (`demos-native:DEM`, `x402:default`, `ap2:stripe-paymentintents`) and runs the SDK rail resolver over this release's disclosed PA-1 in-code table (`pa1-in-code`). LRR-6 accepts only a signed snapshot, and the pinned Standard publishes no signed v0.1 snapshot, so the table is unsigned and every rail resolves `indeterminate` (`pa1-definition-unverifiable`). `build-listing` therefore refuses with 409 before the seller signs anything; it publishes only on `verified`. Every `/register` listing has exactly one rail, so `/register` refuses every listing until a signed PA-1 snapshot or an authenticated PA-2/PA-3 registry exists. Behind that gate, `/register` refuses attested-payload methods whose seller-runtime production capability it cannot prove. It shows the exact server-built unsigned Listing before signature and checks that the configured node reports the pinned build and chain before it returns any transaction. It creates the StorageProgram with the current SDK name/next-nonce/empty-salt mapping and persists non-secret recovery coordinates before broadcast. After broadcast, `confirm-listing` independently verifies native address, owner, program name, tuple, identity, signature and content hash. It reads the write back by hash, in a block the pinned node reports as confirmed. It returns that report only as an unauthenticated `nodeObservation` with `confirmed:false` and state `finality-unverified`, never as an `AnchorReceipt`. The Directory checks the node's validator-signature shape but, like the pinned SDK adapter, does not verify the validator pool, signatures or quorum, and on Demos an `included` write is also `finalized`. The browser therefore stops there and does not register the listing. `POST /api/dacs/register` still accepts pointer sets from any client, and the indexer re-reads and re-authenticates every anchor on each pass | | Registration | — (catalog-side) | `POST /api/dacs/register` with bounded discovery hints. Nothing in the payload is trusted: listings are read from chain, BundleBindings are independently BB-4 verified, CCI badges are resolved from the on-chain GCR, and every offered bundle is cryptographically verified before it counts | | Identity links | DACS-1 / DACS-2 / CCI | GCR links remain informational; identity tiers elevate only from hash/signature/identifier/method/version/freshness-verified `verifiedBy` evidence under an explicit recipe policy | -| Reputation derivation | DACS-5 §10.4–§10.5 | logical bundle-address derivation and bounded BB-4/BB-5/BB-6 resolution, strict two-sided evidence graphs, legacy and v0.3 absolute-fault bundles, seller perspective, ratings, exact-decimal volume, settlement uniqueness, SR-2 windows and deterministic receipts | +| Observed history | DACS-5 §10.4–§10.5 | Bundle references, labelled observed counts, individual evidence, exact-decimal volume and time windows are kept. Completion and fault rates and numeric reputation scores are unavailable on testnet | | Index persistence | Operational | SQLite WAL repository, one-time JSON migration, cross-process leases, artifact retry/dead-letter queue and scan-run diagnostics | | In-browser verify | DACS-5 §10.4 | strict buyer/seller bundle-signature coverage plus referenced-artifact signature/hash checks run in the visitor's browser. Because the server ferries RPC bytes, this proves internal cryptographic consistency but is not an independent chain-inclusion proof; the UI states that boundary explicitly | @@ -54,7 +58,7 @@ two-rail deal history (pay-dem on Demos + pay-x402 USDC on Base Sepolia). Its li uses the early SDK's compact signature encoding; the verifier accepts that encoding only after checking the same Ed25519 signature, signed scope, agent key, and anchor owner as current structured signature envelopes. Historical deals are displayed, but only -strictly party-bound bundles with verified references contribute to reputation. +strictly party-bound bundles with verified references contribute to labelled observed counts. Re-run `npm run index` on a timer in deployment (systemd/cron) — the catalog is a cache and re-verifies everything against chain each pass. @@ -95,8 +99,9 @@ secret variable for the operational indexing endpoints. For GitHub deployments from the Community monorepo, set the service root directory to `/reference-implementations/dacs-directory` and the Railway config file to -`/reference-implementations/dacs-directory/railway.json`. Until the pinned SDK is -published as a package, add `DACS_SDK_GITHUB_TOKEN` as a Railway secret. It must be a +`/reference-implementations/dacs-directory/railway.json`. The build clones the pinned +SDK from its public repository, so it needs no credentials. To authenticate that +clone anyway, add `DACS_SDK_GITHUB_TOKEN` as a Railway secret. It must be a fine-grained GitHub token scoped only to `DACS-Agent-commerce/dacs-sdk` with read-only Contents access. The build passes it to Git without writing it to the checkout or remote URL. @@ -126,7 +131,7 @@ continue. Moving `SDK_REV` therefore also means rebuilding once and updating `SD | `DEMOS_NODE_BUILDS` | No | Comma-separated node builds the Directory accepts, from `getNetworkInfo`'s `nodeVersion`: `` for a clean build, or `+dirty@` to accept exactly one observed build with uncommitted changes. Defaults to the clean `61b8e75…` build. The node reports its own build, so this detects drift and does not authenticate the node. node2 currently reports a dirty build, so **`npm run index` and `/api/dacs/reindex` fail without changing the catalog** until node2 is rebuilt clean or the operator pins that exact build | | `NEXT_PUBLIC_DEMOS_RPC` | No | Credential-free public HTTPS Demos RPC advertised to machine clients; defaults to the public testnet endpoint and deliberately does not inherit `DEMOS_RPC` | | `DACS_ADMIN_TOKEN` | Production | Bearer token for the operational reindex endpoints | -| `DACS_SDK_GITHUB_TOKEN` | GitHub deploy | Fine-grained, read-only token for cloning the pinned private SDK during the build; not needed after the SDK is published | +| `DACS_SDK_GITHUB_TOKEN` | No | Optional fine-grained, read-only token that only authenticates the build's clone of the pinned SDK; the SDK repository is public | | `DACS_DIRECTORY_DATA` | No | Writable directory for the SQLite repository and legacy JSON migration inputs | | `DACS_SCAN_MAX_TXS` | No | Maximum transactions scanned per pass; defaults to `100000` and fails closed if insufficient | | `DACS_SCAN_FINALITY_DEPTH` | No | Newest transaction count held back before indexing; defaults to `2` | @@ -138,25 +143,69 @@ continue. Moving `SDK_REV` therefore also means rebuilding once and updating `SD | `DACS_RECIPE_POLICIES` | For tier elevation | JSON array of version-pinned DACS-2 recipe policies (`scheme`, `recipeVersion`, `methods`, `defaultMaxAgeSec`, `availability`, `trustedResultSigners`); absent/invalid policy fails closed to `self-declared` | | `DACS_TRUST_PROXY` | No | Set to `1` only behind a trusted proxy that overwrites client-IP headers; otherwise the in-process rate limiter is disabled and the deployment must enforce its edge limit | | `NEXT_PUBLIC_DIRECTORY_URL` | Production | Public origin used by canonical URLs, sitemap, `llms.txt`, and machine-discovery documents; defaults to `http://localhost:3400`, which silently poisons production canonical URLs and the sitemap — the server logs a warning when unset in production | -| `NEXT_PUBLIC_BUTLER_ORIGIN` | Production | Public HTTPS origin of the DACS agent gateway used by `/try`; defaults to `http://127.0.0.1:8402` only for local development. Railway validates this at build time. | The data directory must be persistent and writable in deployments that accept registrations or run the indexer. Never commit `.indexer-seed`, `.indexer-mnemonic`, or an admin token. -The project is pinned to Node 22 through `.nvmrc` and `package.json` engines. Before -promoting a deployment, verify both gateway reachability and its explicit CORS allowlist: +The exact Node release is pinned in `.nvmrc`, and `package.json` engines names the +same release; `test/node-runtime.test.mjs` fails if they diverge or if any other +file Railpack's mise reads could pick a release. CI installs Node from `.nvmrc`. +Railway's Railpack builder takes engines first and then lets `.nvmrc` override it +through mise's idiomatic version files (railpack `v0.41.0`, +`core/providers/node/node.go:419-494`), so production builds on the same release +either way. Leave `RAILPACK_NODE_VERSION` unset: `.nvmrc` overrides it. Railway does +not pin which Railpack release builds the service, so the build and +`start:railway` both run `npm run check:node-runtime` first, which fails unless the +running Node is the `.nvmrc` release. Before promoting a deployment, validate the +public canonical origin: ```bash -NEXT_PUBLIC_DIRECTORY_URL=https://directory.example \ -NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.example \ -npm run check:butler +NEXT_PUBLIC_DIRECTORY_URL=https://directory.your-domain.org \ +npm run check:deploy-config ``` -The probe fails unless the gateway returns at least one Butler agent, its -`Access-Control-Allow-Origin` exactly matches the directory origin, and browser -preflights for both execution routes allow `POST` with `content-type`. Configure -the gateway's `BUTLER_ALLOWED_ORIGINS` with that directory origin before running it. +The deployment check requires one credential-free HTTPS origin with no path, query, +or fragment, written exactly as that origin (an optional trailing `/` is allowed), +on a public host. Ports that browsers refuse to fetch (the Fetch Standard's bad +ports, including 0) are rejected. + +- A host name needs at least two non-empty labels, must not end with a dot, and must + not be, or end in, an IANA special-use name (`localhost`, `local`, `test`, + `invalid`, `onion`, `alt`, or anything under `arpa`), the private-use `internal`, + or `localdomain`. The RFC 6761 documentation names (`example`, `example.com`, + `example.net`, `example.org`) are rejected too, unless the check runs with + `--allow-documentation-names`, as CI does for its placeholder origin. Production + builds (`railway.json`) run the check without it. + Other names that are merely undelegated (for example `lan` or `corp`) are not + rejected. +- An IP literal must be global unicast and outside every block in the IANA IPv4 and + IPv6 Special-Purpose Address Registries. IPv4 rejects loopback, unspecified, + private, shared (CGNAT), link-local, documentation, benchmarking, protocol + assignments, AS112, AMT, reserved, and broadcast, plus multicast and the + deprecated 6to4 relay block. IPv6 must be in `2000::/3`, outside the + documentation, IETF-protocol (including Teredo), 6to4, Direct Delegation AS112, + and `3fff::/20` blocks, so IPv4-mapped, NAT64, and other forms that embed an IPv4 + address are rejected. +- The check fails closed: the registries' globally reachable rows (PCP and TURN + anycast inside `192.0.0.0/24`; AS112 and AMT; protocol anycast, ORCHIDv2, and + DRIP inside `2001::/23`) are protocol addresses, not hosting space, and are + rejected too. + +`test/fixtures/origin-policy-sources.json` records where these rules come from: +every unexpired row of both IP registries (from Wireshark's IANA-generated tables, +which drop terminated rows and record an N/A Globally Reachable as false; the two +terminated blocks IANA still lists are rejected anyway), every special-use name +(IANA's registry XML from a mirror, cross-checked with Unbound and Knot Resolver), +and the Fetch Standard's bad ports. Each comes through a copy pinned to an upstream +revision and sha256 (as of 2026-10-15), plus the deliberate deviations. The tests +check the tables against it offline, and check the verifier against in-memory +copies; `npm run verify:origin-sources` (needs network) re-fetches each pinned copy +and checks the fixture against it. Node's `fetch` ignores `HTTPS_PROXY`, so behind +a proxy run `node --use-env-proxy scripts/verify-origin-sources.mjs` instead. +Generated canonical URLs and discovery documents therefore cannot silently point at +localhost or a different base path. The check does not resolve DNS, so a public name +that resolves to a private address still passes. ## Human and agent discovery @@ -271,6 +320,9 @@ code runs on server and client (browser: @noble-shimmed `node:crypto`, a narrow shim over JSON-owned values, WHATWG-subset `node:net` and `node:url` shims, and base64url-patched Buffer). +The layout uses `next/font/local` with exact, integrity-pinned font packages. +Production builds and served pages use those bundled fonts without a font-service request. + ## Honest limitations (MVP) - **The artifact proxy is a byte ferry**: browsers can't reach the Demos RPC directly @@ -321,9 +373,30 @@ pass the pinned SDK's normative shape and component-signature APIs and are expli labelled transaction-readiness-unassessed until a buyer runs the complete SDK reader; current-contract evidence graphs use directory-native validation, while the SDK bundle verifier is retained for labelled legacy artifacts. DACS-2 tier derivation fails closed on -unresolved recipe/evidence/freshness, and DACS-5 derivation includes ratings, volume, -settlement uniqueness, anchor-time windowing, and deterministic receipts. Catalog -computations remain advisory and independently reproducible from their refs. +unresolved recipe/evidence/freshness. Observed-history processing retains volume, +settlement uniqueness, anchor-time windowing and evidence references. Completion and +fault rates and numeric reputation scores remain unavailable during testnet. + +The Node no-rate regression tests use an isolated populated catalog, including +aborted outcomes, old numeric metrics and missing listing hints. They check the API, +rendered homepage, discovery, service detail and seller evidence, plus the discovery +text and refusal of old filters, including bracketed query keys. Run `npm test`. + +TP-1 Directory behavior differs from DACS-1 §6.3.6 at the pinned Standard commit: +`minCompletionRate` and `minRating` return 400, and `completionRate` remains null +even when `bundleCount` is positive. [`docs/TP-1.md`](./docs/TP-1.md) defines TP-1 +and declares these as open `nonconforming` deviations in the Standard's +`ImplementationDeviation` shape. `metricsProfile: "TP-1"`, +`metricsUnavailableReason` and the manifest's `restrictions: ["TP-1"]` identify this +restriction; the manifest reports `dacsDirectoryVersion: "2"` and the OpenAPI +document `2.0.0`. A cached listing with no hint, or one the pinned SDK reader would +reject, still has null metrics and uses the catalog snapshot window. Every served +hint's observed count is recomputed from the retained category records the seller +reputation counts, after the DACS-4 SB-2 settlement-collision exclusion that +`docs/TP-1.md` describes. The hint keeps the shape required by the pinned SDK catalog reader. Seller reputation retains +`completed` and `totalAgreements` for compatibility, with explicit observed-count +labels. Currency totals count observed priced completed outcomes and do not +establish completion or settlement. ## License diff --git a/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts b/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts index f5d7214..2c26ec9 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts @@ -1,3 +1,4 @@ +import { rebuildCatalogObservations } from "@/src/catalog/testnetMetrics"; /** * POST /api/dacs/index-now { claim } — index one registration immediately * (fresh publishes shouldn't wait for the next timer pass). Full chain @@ -6,7 +7,7 @@ import { NextRequest, NextResponse } from "next/server"; import { indexRegistration } from "@/src/catalog/indexer"; import { requireAdmin } from "@/src/catalog/security"; -import { loadCatalog, loadRegistrations, saveCatalog, withDataLock } from "@/src/catalog/store"; +import { loadRegistrations, loadStoredCatalog, saveCatalog, withDataLock } from "@/src/catalog/store"; export async function POST(req: NextRequest) { const denied = requireAdmin(req); @@ -18,14 +19,15 @@ export async function POST(req: NextRequest) { if (!reg) return NextResponse.json({ error: "no registration for that claim" }, { status: 404 }); return withDataLock("reindex", async () => { - const catalog = loadCatalog(); + // Read-modify-write the stored catalog, never the public TP-1 projection. + const catalog = loadStoredCatalog(); const prior = catalog.sellers.find((s) => s.primaryClaim === claim); const record = await indexRegistration(reg, prior); const i = catalog.sellers.findIndex((s) => s.primaryClaim === claim); if (i >= 0) catalog.sellers[i] = record; else catalog.sellers.push(record); catalog.generatedAt = Date.now(); - saveCatalog(catalog); + saveCatalog(rebuildCatalogObservations(catalog)); return NextResponse.json({ ok: true, listings: record.listings.length, diff --git a/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts b/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts index cc13d66..8861d5c 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts @@ -10,24 +10,25 @@ import { catalogJson } from "@/src/catalog/http"; import { requestBaseUrl } from "@/src/catalog/publicUrl"; import { withDirectoryInspectionAffordance } from "@/src/catalog/inspection"; import { artifactProfiles } from "@/src/catalog/contracts"; +import { unavailableMetricQuery } from "@/src/catalog/testnetMetrics"; export async function GET(req: NextRequest) { const q = req.nextUrl.searchParams; + const unavailableQuery = unavailableMetricQuery(q); + if (unavailableQuery) { + return NextResponse.json({ error: unavailableQuery }, { status: 400 }); + } const category = q.get("category"); const rail = q.get("rail"); const tags = q.getAll("tag"); const credential = q.get("credential"); const primaryClaim = q.get("primaryClaim"); const priceMax = q.get("priceMax"); - const minCompletionRate = q.get("minCompletionRate"); - const minRating = q.get("minRating"); const query = q.get("q")?.trim().toLowerCase(); const profile = q.get("profile"); const identityTier = q.get("identityTier"); const numericFilters: Array<[string, string | null, number, number]> = [ ["priceMax", priceMax, 0, Number.POSITIVE_INFINITY], - ["minCompletionRate", minCompletionRate, 0, 1], - ["minRating", minRating, 0, 5], ]; for (const [name, raw, min, max] of numericFilters) { const value = raw === null ? null : Number(raw); @@ -68,14 +69,6 @@ export async function GET(req: NextRequest) { const price = Number(l.pricing.priceHint); if (!Number.isFinite(max) || max < 0 || !Number.isFinite(price) || price > max) return false; } - if (minCompletionRate !== null) { - const min = Number(minCompletionRate); - if (!Number.isFinite(min) || min < 0 || min > 1 || l.reputationHint?.completionRate == null || l.reputationHint.completionRate < min) return false; - } - if (minRating !== null) { - const min = Number(minRating); - if (!Number.isFinite(min) || min < 0 || min > 5 || l.reputationHint?.averageSellerRating == null || l.reputationHint.averageSellerRating < min) return false; - } if (tags.length && !tags.every((t) => l.offering.tags.includes(t))) return false; if (query) { const search = [l.offering.title, l.offering.description ?? "", l.offering.category, ...l.offering.tags, diff --git a/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts index 03251f2..2e5097a 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts @@ -1,7 +1,6 @@ /** * GET /api/dacs/sellers/{primaryClaimRef} — §6.3.6 seller view: - * listings + catalog-cached identity (CCI badges) + reputation (per DACS-5, - * derived only from chain-verified bundles; re-derivable client-side). + * Listings, identity, observed history and unavailable testnet metrics. */ import { NextRequest, NextResponse } from "next/server"; import { loadCatalog } from "@/src/catalog/store"; @@ -32,9 +31,9 @@ export async function GET( limitation: "GCR identity links are not fresh DACS-2 verifiedBy results.", }, reputation: { - profile: "dacs-5-scalar-derivation-v1", + profile: "TP-1", ...seller.reputation, - limitation: "Two-sided reconciliation, perspective/fault metrics, neutral exclusions, resolved ratings, and exact-decimal volume are applied over chain-verified bundles. averageBuyerRating is intentionally null (these records are seller-scoped). Completeness is best-effort: no oracle guarantees every relevant bundle is present.", + limitation: "Completion verification unavailable under TP-1. Completion and fault rates and numeric reputation scores are unavailable on testnet. Counts describe observed history, not verified completion; the catalog may be incomplete. Individual evidence remains available for inspection.", }, deals: seller.deals, }, { diff --git a/reference-implementations/dacs-directory/app/discover/page.tsx b/reference-implementations/dacs-directory/app/discover/page.tsx index 4c288a8..6fdfda6 100644 --- a/reference-implementations/dacs-directory/app/discover/page.tsx +++ b/reference-implementations/dacs-directory/app/discover/page.tsx @@ -51,7 +51,7 @@ export default function Discover() {
{listings.length}active services
{sellers.length}discoverable agents
-
{verifiedDeals}strict deal bundles
+
{verifiedDeals}observed bundles; completion not verified
5DACS lifecycle stages
diff --git a/reference-implementations/dacs-directory/app/globals.css b/reference-implementations/dacs-directory/app/globals.css index d06f3e9..7a32635 100644 --- a/reference-implementations/dacs-directory/app/globals.css +++ b/reference-implementations/dacs-directory/app/globals.css @@ -95,9 +95,9 @@ main { max-width: 1080px; margin: 0 auto; padding: 48px 24px 96px; } .nav-logo b { color: var(--accent); font-weight: 700; } .nav-links { display: flex; align-items: center; gap: 20px; margin-left: auto; color: var(--text-secondary); font-size: 0.875rem; text-transform: lowercase; } .nav-links a:hover { color: var(--text-primary); } -.nav-links .nav-try { color: var(--accent-strong); font-weight: 700; padding: 7px 12px; border: 1px solid var(--accent-border); border-radius: var(--radius-button); background: var(--accent-soft); } -.nav-links .nav-try:hover { color: #fff; background: var(--accent); border-color: var(--accent); } -.nav-try span { margin-left: 3px; font-size: 1rem; } +.nav-links .nav-featured { color: var(--accent-strong); font-weight: 700; padding: 7px 12px; border: 1px solid var(--accent-border); border-radius: var(--radius-button); background: var(--accent-soft); } +.nav-links .nav-featured:hover { color: #fff; background: var(--accent); border-color: var(--accent); } +.nav-featured span { margin-left: 3px; font-size: 1rem; } /* ── Type scale (site tokens) ─────────────────────────────────────────────── */ .h1 { font-size: 2.25rem; line-height: 1.1; letter-spacing: -0.015em; font-weight: 700; margin: 28px 0 8px; } @@ -499,318 +499,6 @@ textarea.form-control { min-height: 112px; resize: vertical; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } - -/* ── Try DACS: guided Butler experience ──────────────────────────────────── */ -.try-page { --try-ink: var(--text-primary); } -.try-hero { min-height: 390px; display: grid; grid-template-columns: 1.35fr .65fr; align-items: flex-end; gap: 54px; padding: 34px 0 58px; } -.try-kicker { display: flex; align-items: center; gap: 9px; color: var(--accent-strong); font: 700 .68rem/1 var(--font-scp, monospace); letter-spacing: .14em; margin-bottom: 24px; } -.try-kicker i { display: block; width: 8px; height: 8px; border-radius: 50%; background: var(--green-strong); box-shadow: 0 0 0 5px var(--green-soft); } -.try-hero h1 { font-size: clamp(3.5rem, 7vw, 6.9rem); line-height: .91; letter-spacing: -.065em; margin: 0; } -.try-hero h1 em { color: var(--accent); font-family: Georgia, serif; font-weight: 400; } -.try-hero > p { color: var(--text-secondary); font-size: clamp(1rem, 1.6vw, 1.3rem); line-height: 1.55; padding-bottom: 8px; } - -.try-shell { display: grid; grid-template-columns: minmax(0, 1fr) 340px; border: 1px solid var(--border-hover); border-radius: 20px; overflow: hidden; background: var(--bg-card); box-shadow: 0 26px 70px rgba(20, 10, 45, .09); } -.butler-chat { min-width: 0; } -.chat-head { min-height: 76px; padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 13px; } -.butler-avatar { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; color: #fff; background: linear-gradient(145deg, var(--accent), #4f46e5); font-weight: 700; box-shadow: 0 7px 18px var(--accent-border); } -.chat-head strong, .chat-head small { display: block; }.chat-head strong { font-size: .92rem; }.chat-head small { color: var(--text-muted); font-size: .72rem; margin-top: 2px; } -.chat-body { min-height: 350px; max-height: 620px; overflow: auto; padding: 26px 24px; background: linear-gradient(180deg, var(--bg-subtle), var(--bg-card)); display: flex; flex-direction: column; gap: 17px; } -.bubble { max-width: 78%; padding: 15px 17px; border: 1px solid var(--border); border-radius: 4px 16px 16px 16px; background: var(--bg-card); box-shadow: 0 5px 16px rgba(10, 10, 10, .035); } -.bubble > span { display: block; color: var(--accent-strong); font: 700 .62rem/1 var(--font-scp, monospace); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 8px; } -.bubble p { color: var(--text-secondary); font-size: .91rem; line-height: 1.55; }.bubble p strong { color: var(--text-primary); } -.bubble.human { align-self: flex-end; border-radius: 16px 4px 16px 16px; background: var(--accent); border-color: var(--accent); }.bubble.human > span,.bubble.human p { color: #fff; } -.bubble.error { border-color: var(--red-border); background: var(--red-soft); }.bubble.error > span,.bubble.error p { color: var(--red-strong); } -.bubble.thinking b { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); margin-right: 4px; animation: try-thinking 1.1s infinite ease-in-out; }.bubble.thinking b:nth-child(2){animation-delay:.12s}.bubble.thinking b:nth-child(3){animation-delay:.24s} -@keyframes try-thinking { 0%,70%,100%{transform:translateY(0);opacity:.35}35%{transform:translateY(-4px);opacity:1} } -.reasoning-chip { display: inline-flex; margin-top: 11px; padding: 4px 8px; border-radius: 5px; background: var(--cyan-soft); color: var(--cyan-strong); border: 1px solid var(--cyan-border); font: 600 .66rem var(--font-scp, monospace); } - -.agent-picker { padding: 20px 24px 24px; border-top: 1px solid var(--border); background: var(--bg-card); } -.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }.picker-head strong { font-size: .78rem; }.picker-head span { color: var(--text-muted); font: .65rem var(--font-scp, monospace); } -.picker-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } -.picker-grid button { min-width: 0; min-height: 96px; display: grid; grid-template-columns: 30px 1fr 12px; align-items: center; gap: 9px; padding: 11px; text-align: left; color: var(--text-primary); background: var(--bg-subtle); border: 1px solid var(--border); border-radius: 10px; cursor: pointer; font-family: inherit; }.picker-grid button:hover { border-color: var(--accent-border); background: var(--accent-soft); }.picker-grid button > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; color: var(--accent-strong); background: var(--accent-soft); font-size: .7rem; font-weight: 700; }.picker-grid strong,.picker-grid small,.picker-grid em { display: block; }.picker-grid strong { font-size: .7rem; }.picker-grid small { color: var(--text-muted); font-size: .58rem; line-height: 1.3; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }.picker-grid em { margin-top: 5px; color: var(--green-strong); font: 600 .52rem var(--font-scp, monospace); font-style: normal; }.picker-grid i { color: var(--accent-strong); font-style: normal; } - -.ask-box,.job-box { padding: 22px 24px 24px; border-top: 1px solid var(--border); background: var(--bg-card); } -.ask-box label { display: block; font-size: .76rem; font-weight: 700; margin-bottom: 9px; } -.ask-box textarea,.job-box textarea { width: 100%; resize: vertical; color: var(--text-primary); background: var(--bg-subtle); border: 1px solid var(--border-hover); border-radius: 11px; outline: none; padding: 14px 15px; font: inherit; } -.ask-box textarea:focus,.job-box textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } -.ask-box .try-primary { margin-top: 12px; margin-left: auto; } -.try-primary { justify-content: space-between; min-width: 175px; } -.job-head,.job-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }.job-head { margin-bottom: 12px; }.job-head span { display: block; font-size: .76rem; font-weight: 700; }.job-head small { display: block; max-width: 520px; color: var(--text-muted); font-size: .68rem; margin-top: 3px; }.job-box textarea { font: .75rem/1.6 var(--font-scp, monospace); }.job-actions { margin-top: 12px; } -.rail-picker { margin: 14px 0 18px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-subtle); }.rail-picker-head { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; margin-bottom: 10px; }.rail-picker-head strong { color: var(--text-primary); font-size: .74rem; }.rail-picker-head span { color: var(--text-muted); font-size: .64rem; }.rail-options { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }.rail-options button { display: grid; grid-template-columns: 26px 1fr; gap: 9px; width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 10px; color: inherit; background: var(--bg-card); text-align: left; cursor: pointer; }.rail-options button:hover:not(:disabled) { border-color: var(--border-hover); }.rail-options button.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }.rail-options button:disabled { cursor: not-allowed; opacity: .55; }.rail-options button > i { display: grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--border-hover); border-radius: 50%; color: white; font-style: normal; font-size: .65rem; }.rail-options button.selected > i { border-color: var(--accent); background: var(--accent); }.rail-options button strong,.rail-options button small,.rail-options button em { display: block; }.rail-options button strong { color: var(--text-primary); font-size: .73rem; }.rail-options button small { margin-top: 3px; color: var(--text-secondary); font-size: .63rem; }.rail-options button em { margin-top: 6px; color: var(--text-muted); font-size: .57rem; font-style: normal; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.rail-disclosure,.rail-governance { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 11px 12px; color: var(--text-secondary); background: rgba(180,95,0,.07); }.rail-disclosure { margin-top: 9px; border: 1px solid rgba(180,95,0,.18); border-radius: 9px; }.rail-disclosure.canonical,.rail-governance.canonical { background: rgba(16,132,91,.07); }.rail-disclosure strong,.rail-disclosure small,.rail-governance strong,.rail-governance span { display: block; }.rail-disclosure strong,.rail-governance strong { color: var(--text-primary); font-size: .65rem; }.rail-disclosure small,.rail-governance span { margin-top: 3px; color: var(--text-muted); font-size: .6rem; }.rail-disclosure a,.rail-governance a { flex: none; color: var(--accent); font-size: .62rem; font-weight: 700; text-decoration: none; }.rail-governance { border-top: 1px solid var(--border); padding: 12px 18px; } -.ghost-btn { border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); border-radius: var(--radius-button); padding: 9px 14px; cursor: pointer; font: 600 .78rem inherit; }.ghost-btn:hover { color: var(--text-primary); border-color: var(--border-hover); }.ghost-btn:disabled { opacity: .5; } - -.journey { border-left: 1px solid var(--border); background: var(--bg-tinted); min-height: 640px; } -.journey-head { height: 76px; padding: 0 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font: 700 .62rem var(--font-scp, monospace); letter-spacing: .1em; }.journey-head span:last-child { color: var(--green-strong); } -.journey-intro { padding: 14px 20px 6px; color: var(--text-muted); font-size: .68rem; line-height: 1.55; }.journey-intro em { color: var(--text-secondary); font-style: normal; font: 700 .6rem var(--font-scp, monospace); letter-spacing: .02em; } -.journey-steps { position: relative; padding: 6px 0 4px; } -.journey-step { position: relative; display: grid; grid-template-columns: 40px 1fr; gap: 4px; padding: 12px 18px 12px 14px; opacity: .55; transition: .25s; } -.journey-step::before { content: ""; position: absolute; left: 27px; top: 0; bottom: 0; width: 2px; background: var(--border); } -.journey-step:first-child::before { top: 16px; } -.journey-step:last-child::before { bottom: auto; height: 16px; } -.journey-step.active, .journey-step.complete, .journey-step.warning { opacity: 1; } -.journey-step.skipped { opacity: .78; } -.journey-index { position: relative; z-index: 1; width: 28px; height: 28px; margin-top: 1px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-hover); background: var(--bg-card); color: var(--text-muted); font: 700 .68rem var(--font-scp, monospace); } -.journey-step.complete .journey-index { border-color: var(--green-strong); background: var(--green-soft); color: var(--green-strong); } -.journey-step.active .journey-index { border-color: var(--accent); color: var(--accent-strong); animation: livePulse 1.6s infinite; } -.journey-step.warning .journey-index { border-color: var(--red-strong); background: var(--red-soft); color: var(--red-strong); } -.journey-step.skipped .journey-index { border-style: dashed; } -.stage-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }.stage-title strong { font-size: .82rem; } -.dacs-tag { font: 700 .54rem var(--font-scp, monospace); letter-spacing: .06em; color: var(--accent-strong); border: 1px solid var(--accent-border); background: var(--accent-soft); border-radius: 999px; padding: 1px 7px; } -.journey-step > div > p { color: var(--text-muted); font-size: .68rem; line-height: 1.45; margin-top: 2px; } -.phase-output { min-width: 0; margin-top: 8px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); }.phase-output span,.phase-output b,.phase-output small { display: block; }.phase-output span { color: var(--text-muted); font: 700 .54rem var(--font-scp, monospace); letter-spacing: .08em; text-transform: uppercase; }.phase-output b { overflow-wrap: anywhere; margin-top: 4px; color: var(--text-secondary); font-size: .66rem; line-height: 1.35; }.phase-output small { display: -webkit-box; overflow: hidden; margin-top: 4px; color: var(--text-muted); font-size: .59rem; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }.phase-output.complete { border-color: var(--green-border); background: var(--green-soft); }.phase-output.complete span,.phase-output.complete b { color: var(--green-strong); }.phase-output.active { border-color: var(--accent-border); background: var(--accent-soft); }.phase-output.active span,.phase-output.active b { color: var(--accent-strong); }.phase-output.warning { border-color: var(--red-border); background: var(--red-soft); }.phase-output.warning span,.phase-output.warning b { color: var(--red-strong); }.phase-output.skipped { border-style: dashed; } -.stage-txs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }.stage-txs a, .stage-txs code { font: 600 .56rem var(--font-scp, monospace); border: 1px solid var(--border); background: var(--bg-tinted); border-radius: 6px; padding: 2px 7px; color: var(--text-secondary); text-decoration: none; }.stage-txs a { color: var(--accent-strong); border-color: var(--accent-border); }.stage-txs a:hover { background: var(--accent-soft); } -.stage-help { margin-top: 7px; }.stage-help summary { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: .62rem; list-style: none; }.stage-help summary::-webkit-details-marker { display: none; }.stage-help summary::before { content: "?"; display: grid; place-items: center; width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--border-hover); font: 700 .54rem var(--font-scp, monospace); }.stage-help summary:hover, .stage-help[open] summary { color: var(--text-secondary); }.stage-help p { margin-top: 6px; padding: 9px 11px; border-left: 2px solid var(--accent-border); background: var(--bg-card); border-radius: 0 8px 8px 0; color: var(--text-muted); font-size: .64rem; line-height: 1.55; }.stage-help p b { display: block; margin-top: 5px; color: var(--text-secondary); font-weight: 600; } -.chain-activity { margin: 8px 16px 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-card); overflow: hidden; } -.chain-activity-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 11px 12px 0; color: var(--text-muted); font: 700 .58rem var(--font-scp, monospace); letter-spacing: .08em; }.chain-activity-head span:last-child { color: var(--green-strong); white-space: nowrap; } -.chain-activity > p { padding: 5px 12px 9px; color: var(--text-muted); font-size: .62rem; line-height: 1.45; } -.chain-row { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); align-items: center; }.chain-row strong { display: block; font-size: .63rem; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; color: var(--text-secondary); }.chain-row small { display: block; margin-top: 2px; color: var(--text-muted); font: 500 .55rem var(--font-scp, monospace); overflow-wrap: anywhere; }.chain-index { color: var(--text-muted); font: 600 .6rem var(--font-scp, monospace); }.chain-row a { font: 600 .58rem var(--font-scp, monospace); color: var(--accent-strong); white-space: nowrap; text-decoration: none; }.chain-row a:hover { text-decoration: underline; }.chain-row .muted-text { font: 500 .56rem var(--font-scp, monospace); } -.journey-note { margin: 8px 16px 20px; padding: 15px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 11px; }.journey-note strong { font-size: .72rem; }.journey-note p { color: var(--text-muted); font-size: .68rem; line-height: 1.5; margin-top: 5px; } - -.resume-banner { display: flex; gap: 16px; align-items: center; justify-content: space-between; flex-wrap: wrap; margin-bottom: 16px; padding: 14px 16px; border: 1px solid var(--accent-border); border-radius: 10px; background: var(--accent-soft); } -.resume-banner strong { display: block; } -.resume-banner p { margin: 4px 0 0; font-size: 0.8rem; color: var(--text-secondary); max-width: 62ch; } -.resume-actions { display: flex; gap: 10px; flex-wrap: wrap; } -.resume-actions .btn { white-space: nowrap; } - -.try-result { margin-top: 36px; border: 1px solid var(--border); border-radius: 17px; background: var(--bg-card); overflow: hidden; }.result-title { padding: 19px 22px; display: flex; justify-content: space-between; align-items: center; gap: 20px; }.result-title h2 { display: inline; margin-left: 10px; font-size: 1rem; }.result-title small { display: block; margin-top: 5px; color: var(--text-muted); font: .65rem var(--font-scp, monospace); }.try-result details { border-top: 1px solid var(--border); }.try-result summary { cursor: pointer; padding: 14px 22px; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }.try-result pre { max-height: 580px; overflow: auto; margin: 0; padding: 20px 22px; color: var(--text-secondary); background: var(--bg-subtle); border-top: 1px solid var(--border); font: .72rem/1.6 var(--font-scp, monospace); } -.live-flow { border-top: 1px solid var(--border); padding: 22px 24px 26px; background: var(--bg-card); }.live-flow-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; margin-bottom: 18px; }.live-flow-head div { color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .08em; }.live-flow-head small { color: var(--text-muted); font-size: .68rem; }.live-pulse { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(105,62,255,.4); animation: livePulse 1.5s infinite; }.live-events { display: grid; gap: 2px; }.live-events > div { display: grid; grid-template-columns: 27px 1fr; gap: 9px; align-items: start; padding: 7px 0; color: var(--text-muted); }.live-events i { display: grid; place-items: center; width: 21px; height: 21px; border-radius: 50%; border: 1px solid var(--border-hover); font-style: normal; font-size: .7rem; }.live-events strong,.live-events small { display: block; }.live-events strong { color: var(--text-secondary); font-size: .75rem; line-height: 1.35; }.live-events small { margin-top: 3px; font: .62rem/1.3 var(--font-scp, monospace); }.live-events .done i { color: #10845b; border-color: rgba(16,132,91,.25); background: rgba(16,132,91,.08); }.live-events .active i { color: var(--accent); border-color: var(--accent); animation: livePulse 1.5s infinite; }.working-box { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--text-secondary); font-size: .8rem; }.working-box strong,.working-box small,.recovery-box strong,.recovery-box small { display: block; }.working-box small,.recovery-box small { margin-top: 4px; color: var(--text-muted); font: .65rem var(--font-scp, monospace); }.live-flow-actions { display: flex; justify-content: flex-end; margin-top: 16px; }.recovery-box > div:first-child { margin-bottom: 14px; } -.receipt-status { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-top: 1px solid var(--border); background: var(--bg-subtle); }.receipt-status > div:first-child { min-width: 0; }.receipt-status strong,.receipt-status small { display: block; }.receipt-status strong { color: var(--text-primary); font-size: .76rem; text-transform: capitalize; }.receipt-status small { max-width: 680px; margin-top: 4px; color: var(--text-muted); font-size: .66rem; line-height: 1.45; }.receipt-status p { margin-top: 7px; color: var(--red-strong); font-size: .68rem; }.receipt-dot { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--text-muted); }.receipt-confirmed .receipt-dot { background: var(--green-strong); }.receipt-failed .receipt-dot { background: var(--red-strong); }.receipt-actions { display: flex; flex: none; align-items: center; gap: 8px; }.receipt-actions a { text-decoration: none; } -@keyframes livePulse { 0% { box-shadow: 0 0 0 0 rgba(105,62,255,.35); } 70% { box-shadow: 0 0 0 7px rgba(105,62,255,0); } 100% { box-shadow: 0 0 0 0 rgba(105,62,255,0); } } -.full-proc-result { background: var(--bg-subtle); }.full-proc-result > .result-title { background: var(--bg-card); border-bottom: 1px solid var(--border); }.proc-report { padding: 22px; display: grid; gap: 18px; }.proc-summary { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--border); }.proc-summary > div { min-width: 0; padding: 17px; background: var(--bg-card); }.proc-summary span,.proc-panel-head span,.tx-link small { display: block; color: var(--text-muted); font-size: .61rem; font-weight: 800; letter-spacing: .08em; }.proc-summary strong { display: block; overflow-wrap: anywhere; margin-top: 7px; color: var(--text-primary); font-size: .82rem; }.success-text { color: #10845b !important; }.error-text { color: var(--red-strong) !important; }.muted-text { color: var(--text-muted) !important; }.proc-panel { overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); }.proc-panel-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 17px 18px; border-bottom: 1px solid var(--border); }.proc-panel-head h3 { margin-top: 4px; color: var(--text-primary); font-size: .9rem; }.payment-panel { border-color: rgba(16,132,91,.28); }.payment-panel.unverified-panel { border-color: var(--red-border); }.tx-link { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 17px 18px; background: rgba(16,132,91,.045); color: inherit; text-decoration: none; border-bottom: 1px solid var(--border); }.tx-link.missing-evidence { background: var(--red-soft); }.tx-link span { min-width: 0; }.tx-link code { display: block; overflow: hidden; margin-top: 6px; color: var(--text-primary); font-size: .72rem; text-overflow: ellipsis; }.tx-link b { flex: none; color: #10845b; font-size: .7rem; }.party-row { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 12px 18px; color: var(--text-muted); font-size: .65rem; }.party-row code { margin-left: 4px; color: var(--text-secondary); }.party-row i { color: var(--accent); font-style: normal; }.candidate-row { display: grid; grid-template-columns: 1.5fr .55fr .65fr 1.3fr; gap: 12px; align-items: center; padding: 13px 18px; border-bottom: 1px solid var(--border); color: var(--text-secondary); font-size: .7rem; }.candidate-row:last-child { border-bottom: 0; }.candidate-row strong,.candidate-row small { display: block; }.candidate-row small { margin-top: 3px; color: var(--text-muted); font: .59rem var(--font-scp, monospace); }.candidate-head { color: var(--text-muted); background: var(--bg-subtle); font-size: .6rem; font-weight: 800; letter-spacing: .06em; }.finding-list article { display: grid; grid-template-columns: 74px 1fr; gap: 14px; padding: 16px 18px; border-bottom: 1px solid var(--border); }.finding-list article:last-child { border-bottom: 0; }.severity { align-self: start; padding: 5px 7px; border-radius: 6px; background: rgba(180,95,0,.1); color: #9a5300; text-align: center; font-size: .6rem; font-weight: 800; text-transform: uppercase; }.severity.high,.severity.critical { color: #b72f3c; background: rgba(183,47,60,.1); }.severity.low,.severity.info { color: #2769a8; background: rgba(39,105,168,.1); }.finding-list strong { color: var(--text-primary); font-size: .76rem; }.finding-list p { margin: 5px 0; color: var(--text-secondary); font-size: .7rem; line-height: 1.5; }.finding-list code { color: var(--text-muted); font-size: .62rem; }.empty-report { padding: 18px; color: var(--text-muted); font-size: .74rem; }.verify-grid { display: grid; grid-template-columns: repeat(4,1fr); }.verify-grid > div { display: flex; gap: 10px; align-items: center; padding: 17px; border-right: 1px solid var(--border); }.verify-grid > div:last-child { border-right: 0; }.verify-grid i { display: grid; place-items: center; flex: 0 0 24px; height: 24px; border-radius: 50%; color: #10845b; background: rgba(16,132,91,.09); font-style: normal; font-size: .7rem; }.verify-grid .failed-check i { color: var(--red-strong); background: var(--red-soft); }.verify-grid .failed-check strong { color: var(--red-strong); }.verify-grid span,.verify-grid strong { display: block; }.verify-grid span { color: var(--text-muted); font-size: .62rem; }.verify-grid strong { margin-top: 3px; color: var(--text-primary); font-size: .7rem; }.receipt-row { display: grid; grid-template-columns: 32px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--border); }.receipt-index { color: var(--text-muted); font: .62rem var(--font-scp, monospace); }.receipt-row strong,.receipt-row small { display: block; }.receipt-row strong { color: var(--text-secondary); font-size: .7rem; }.receipt-row small { overflow: hidden; margin-top: 3px; color: var(--text-muted); font: .6rem var(--font-scp, monospace); text-overflow: ellipsis; }.receipt-row a { color: var(--accent); font: .63rem var(--font-scp, monospace); text-decoration: none; }.anchor-details,.raw-result { border-top: 1px solid var(--border); }.proc-report .raw-result { overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); }.proc-report details pre { max-height: 420px; }.anchor-details summary,.raw-result summary { padding: 13px 18px !important; } -.oracle-delivery { padding: 22px 18px; }.oracle-delivery span,.oracle-delivery strong,.oracle-delivery small { display: block; }.oracle-delivery span { color: var(--text-muted); font: 700 .62rem var(--font-scp, monospace); text-transform: uppercase; }.oracle-delivery strong { margin-top: 8px; color: var(--text-primary); font-size: 2rem; letter-spacing: -.04em; }.oracle-delivery small { margin-top: 8px; overflow-wrap: anywhere; color: var(--text-muted); font-size: .66rem; } -.preview-proc-result { border-color: var(--accent-border); box-shadow: 0 16px 50px rgba(105,62,255,.08); }.proc-preview { background: var(--bg-card); }.proc-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 22px; border-bottom: 1px solid var(--border); background: var(--accent-soft); }.proc-preview-head > div > span { display: block; color: var(--accent-strong); font: 800 .61rem var(--font-scp, monospace); letter-spacing: .1em; }.proc-preview-head h2 { margin-top: 5px; color: var(--text-primary); font-size: 1.05rem; }.proc-preview-state { display: grid; grid-template-columns: repeat(2,1fr); border-bottom: 1px solid var(--border); }.proc-preview-state > div { padding: 16px 22px; }.proc-preview-state > div + div { border-left: 1px solid var(--border); }.proc-preview-state strong,.proc-preview-state small { display: block; }.proc-preview-state strong { color: var(--text-primary); font-size: .76rem; }.proc-preview-state small { margin-top: 5px; color: var(--text-muted); font-size: .68rem; line-height: 1.5; }.preview-delivery { border-bottom: 1px solid var(--border); }.preview-fields { display: grid; grid-template-columns: repeat(4,1fr); border-bottom: 1px solid var(--border); }.preview-fields > div { min-width: 0; padding: 16px 18px; border-right: 1px solid var(--border); }.preview-fields > div:nth-child(4n) { border-right: 0; }.preview-fields span,.preview-fields strong { display: block; overflow-wrap: anywhere; }.preview-fields span { color: var(--text-muted); font-size: .6rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }.preview-fields strong { margin-top: 6px; color: var(--text-primary); font-size: .75rem; }.proc-preview-warning { display: flex; align-items: center; gap: 14px; padding: 14px 22px; color: var(--text-secondary); background: rgba(180,95,0,.07); border-bottom: 1px solid var(--border); }.proc-preview-warning strong { flex: none; color: #9a5300; font-size: .7rem; }.proc-preview-warning span { font-size: .68rem; line-height: 1.45; }.preview-anchor-list > div { display: grid; grid-template-columns: 130px minmax(0,1fr); gap: 12px; padding: 10px 18px; border-top: 1px solid var(--border); }.preview-anchor-list span { color: var(--text-muted); font-size: .64rem; text-transform: capitalize; }.preview-anchor-list code { overflow: hidden; color: var(--text-secondary); font-size: .64rem; text-overflow: ellipsis; } - -.try-agents { margin-top: 82px; }.try-section-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 40px; margin-bottom: 25px; }.try-section-head span { color: var(--accent-strong); font: 700 .63rem var(--font-scp, monospace); letter-spacing: .12em; }.try-section-head h2 { font-size: 1.8rem; letter-spacing: -.025em; margin-top: 5px; }.try-section-head > p { max-width: 420px; color: var(--text-muted); font-size: .8rem; } -.try-agent-grid { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }.try-agent-grid button { min-height: 190px; padding: 20px; text-align: left; color: var(--text-primary); background: var(--bg-card); border: 0; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); cursor: pointer; font-family: inherit; }.try-agent-grid button:nth-child(3n){border-right:0}.try-agent-grid button:nth-last-child(-n+3){border-bottom:0}.try-agent-grid button:hover { background: var(--accent-soft); }.try-agent-grid button > span { color: var(--text-muted); font: .65rem var(--font-scp, monospace); }.try-agent-grid strong { display: block; font-size: .9rem; margin-top: 13px; }.try-agent-grid p { color: var(--text-muted); font-size: .72rem; line-height: 1.5; margin: 7px 0 20px; }.try-agent-grid i { color: var(--accent-strong); font-size: .7rem; font-style: normal; font-weight: 700; } - -@media (max-width: 900px) { - .try-hero { grid-template-columns: 1fr; min-height: 0; gap: 28px; }.try-hero h1 { font-size: 14vw; }.try-hero > p { max-width: 620px; } - .try-shell { grid-template-columns: 1fr; }.journey { border-left: 0; border-top: 1px solid var(--border); min-height: 0; }.journey-note { display: none; } - .picker-grid { grid-template-columns: repeat(2, 1fr); } - .proc-summary,.verify-grid { grid-template-columns: repeat(2,1fr); }.verify-grid > div:nth-child(2) { border-right: 0; }.verify-grid > div:nth-child(-n+2) { border-bottom: 1px solid var(--border); } - .try-agent-grid { grid-template-columns: repeat(2,1fr); }.try-agent-grid button:nth-child(3n){border-right:1px solid var(--border)}.try-agent-grid button:nth-child(2n){border-right:0}.try-agent-grid button:nth-last-child(-n+3){border-bottom:1px solid var(--border)}.try-agent-grid button:nth-last-child(-n+2){border-bottom:0} -} -@media (max-width: 580px) { - main { padding-left: 16px; padding-right: 16px; }.nav-inner { padding-left: 16px; padding-right: 16px; }.try-hero { padding-top: 20px; }.try-hero h1 { font-size: 16vw; } - .chat-body { padding: 20px 15px; }.bubble { max-width: 92%; }.ask-box,.job-box,.agent-picker { padding: 18px 15px; }.picker-grid { grid-template-columns: 1fr; }.job-head,.job-actions,.try-section-head,.rail-picker-head,.rail-disclosure,.rail-governance { align-items: stretch; flex-direction: column; }.rail-options { grid-template-columns: 1fr; }.job-actions .try-primary { width: 100%; }.try-agent-grid { grid-template-columns: 1fr; }.try-agent-grid button { border-right: 0 !important; border-bottom: 1px solid var(--border) !important; }.try-agent-grid button:last-child { border-bottom: 0 !important; } - .live-flow { padding: 18px 15px; }.live-flow-head,.proc-panel-head,.tx-link,.result-title,.proc-preview-head,.proc-preview-warning { align-items: stretch; flex-direction: column; }.proc-report { padding: 12px; gap: 12px; }.proc-summary,.proc-preview-state,.preview-fields { grid-template-columns: 1fr; }.proc-preview-state > div + div { border-left: 0; border-top: 1px solid var(--border); }.preview-fields > div { border-right: 0; border-bottom: 1px solid var(--border); }.candidate-head { display: none; }.candidate-row { grid-template-columns: 1fr 1fr; }.candidate-row > span:last-child { grid-column: 1 / -1; }.verify-grid { grid-template-columns: 1fr; }.verify-grid > div { border-right: 0; border-bottom: 1px solid var(--border); }.verify-grid > div:last-child { border-bottom: 0; }.finding-list article { grid-template-columns: 1fr; }.receipt-row { grid-template-columns: 26px minmax(0,1fr); }.receipt-row > a,.receipt-row > .muted-text { grid-column: 2; }.party-row { align-items: flex-start; flex-direction: column; }.party-row i { transform: rotate(90deg); }.preview-anchor-list > div { grid-template-columns: 1fr; } -} - -/* ── /try agent input forms ────────────────────────────────────────────── */ -.agent-form { display: flex; flex-direction: column; gap: 14px; } -.agent-form-note { margin: 0; padding: 10px 12px; border: 1px solid var(--border); border-left: 3px solid var(--accent-strong); border-radius: 8px; font-size: 0.8rem; color: var(--text-secondary); background: var(--surface-2); } -.field-stack { display: flex; flex-direction: column; gap: 12px; } -.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.field-grid.three { grid-template-columns: 1fr 1fr 1fr; } -@media (max-width: 640px) { .field-grid, .field-grid.three { grid-template-columns: 1fr; } } -.try-field label { display: flex; align-items: center; gap: 4px; } -.required-mark { color: var(--accent-strong); font-weight: 700; } -.field-error { display: block; margin-top: 4px; font-size: 0.75rem; color: var(--red-strong, #c0392b); } -.field-error.gateway { font-weight: 600; } -.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer; } -.checkbox-row input { width: auto; } -.repeat-list { display: flex; flex-direction: column; gap: 10px; } -.repeat-row { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; } -.repeat-row-head { display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem; } -.add-row { align-self: flex-start; } -.kv-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: center; } -.kv-row.alias-row { grid-template-columns: 1fr auto; } -.advanced-json { border-top: 1px dashed var(--border); padding-top: 10px; } -.advanced-json summary { cursor: pointer; font-size: 0.8rem; color: var(--text-secondary); } -.advanced-json textarea { width: 100%; margin-top: 8px; } -.submitted-summary { margin-top: 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.75rem; } -.submitted-summary span { display: block; letter-spacing: 0.08em; color: var(--text-secondary); margin-bottom: 4px; } -.submitted-summary ul { margin: 0; padding-left: 16px; } - -/* ═══ /try-chat — recorded two-agent conversation (scoped .tc-*) ═══════════ */ -.tc-page { max-width: 940px; margin: 0 auto; padding: 32px 20px 80px; } -.tc-hero { text-align: center; padding: 8px 0 26px; } -.tc-kicker { display: inline-flex; align-items: center; gap: 7px; color: var(--accent-strong); font: 700 .64rem var(--font-scp, monospace); letter-spacing: .14em; } -.tc-kicker i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } -.tc-hero h1 { margin: 14px 0 12px; font-size: clamp(1.9rem, 4vw, 2.9rem); line-height: 1.05; letter-spacing: -0.02em; } -.tc-hero h1 em { font-style: italic; color: var(--accent-strong); } -.tc-hero p { max-width: 640px; margin: 0 auto; color: var(--text-secondary); font-size: 1.02rem; line-height: 1.6; } -.tc-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: center; margin-top: 22px; } -.tc-btn { display: inline-flex; align-items: center; gap: 7px; border-radius: 10px; padding: 11px 18px; font: 600 .9rem inherit; cursor: pointer; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); transition: .16s; } -.tc-btn small { color: var(--text-muted); font-weight: 500; font-size: .74rem; } -.tc-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } -.tc-btn-primary:hover { background: var(--accent-hover); } -.tc-btn-ghost:hover { border-color: var(--border-hover); } -.tc-btn:disabled { opacity: .55; cursor: default; } -.tc-tech-toggle { display: inline-flex; align-items: center; gap: 7px; color: var(--text-muted); font-size: .8rem; cursor: pointer; } -.tc-replay-note { display: grid; gap: 4px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid var(--accent-border); border-radius: 12px; background: var(--accent-soft); } -.tc-replay-note strong { color: var(--accent-strong); font-size: .78rem; } -.tc-replay-note span { color: var(--text-secondary); font-size: .76rem; line-height: 1.5; } -.tc-replay-note a { color: var(--accent-strong); font-weight: 700; } - -.tc-stagebar { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 8px 0 18px; } -.tc-stage { display: flex; gap: 9px; padding: 12px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); opacity: .55; transition: .25s; } -.tc-stage-active, .tc-stage-done { opacity: 1; } -.tc-stage-active { border-color: var(--accent-border); background: var(--accent-soft); } -.tc-stage-done { border-color: var(--green-border); } -.tc-stage-num { flex: none; width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-hover); font: 700 .72rem var(--font-scp, monospace); } -.tc-stage-active .tc-stage-num { border-color: var(--accent); color: var(--accent-strong); } -.tc-stage-done .tc-stage-num { border-color: var(--green-strong); background: var(--green-soft); color: var(--green-strong); } -.tc-stage strong { display: block; font-size: .8rem; } -.tc-stage small { color: var(--accent-strong); font: 700 .56rem var(--font-scp, monospace); } -.tc-stage p { margin-top: 3px; color: var(--text-muted); font-size: .66rem; line-height: 1.4; } - -.tc-stage-legend { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; padding: 4px 0 16px; color: var(--text-secondary); font-size: .76rem; } -.tc-stage-legend span { display: inline-flex; align-items: center; gap: 6px; } -.tc-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } -.tc-dot-butler { background: var(--accent); } -.tc-dot-seller { background: #ea580c; } -.tc-dot-chain { background: #0891b2; } -.tc-dot-referee { background: #64748b; } - -.tc-transcript { min-height: 320px; max-height: 62vh; overflow-y: auto; padding: 20px 18px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-tinted); display: flex; flex-direction: column; gap: 12px; } -.tc-empty { margin: auto; text-align: center; color: var(--text-secondary); max-width: 420px; } -.tc-empty small { display: block; margin-top: 8px; color: var(--text-muted); font-size: .76rem; } - -.tc-stage-divider { display: flex; align-items: center; gap: 9px; margin: 12px 4px 4px; color: var(--text-muted); } -.tc-stage-divider::before, .tc-stage-divider::after { content: ""; height: 1px; flex: 1; background: var(--border); } -.tc-stage-divider span { font: 700 .58rem var(--font-scp, monospace); color: var(--accent-strong); background: var(--accent-soft); border: 1px solid var(--accent-border); border-radius: 999px; padding: 1px 8px; } -.tc-stage-divider b { font-size: .74rem; color: var(--text-secondary); } -.tc-stage-divider i { font-style: normal; font-size: .66rem; } - -.tc-turn { display: flex; gap: 10px; max-width: 78%; animation: tc-in .28s ease; } -@keyframes tc-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } -.tc-turn-left { align-self: flex-start; } -.tc-turn-right { align-self: flex-end; flex-direction: row-reverse; } -.tc-turn-center { align-self: center; max-width: 88%; } -.tc-avatar { flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font: 700 .82rem var(--font-scp, monospace); color: #fff; margin-top: 2px; } -.tc-turn-butler .tc-avatar { background: var(--accent); } -.tc-turn-seller .tc-avatar { background: #ea580c; } -.tc-bubble { padding: 11px 14px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card); } -.tc-turn-left .tc-bubble { border-top-left-radius: 4px; } -.tc-turn-right .tc-bubble { border-top-right-radius: 4px; background: color-mix(in srgb, #ea580c 6%, var(--bg-card)); border-color: color-mix(in srgb, #ea580c 22%, var(--border)); } -.tc-turn-center .tc-bubble { text-align: center; background: var(--bg-subtle); border-style: dashed; } -.tc-turn-referee .tc-bubble { border-color: var(--border-hover); } -.tc-who { display: block; font-size: .72rem; font-weight: 700; color: var(--text-secondary); margin-bottom: 3px; } -.tc-who small { font-weight: 500; color: var(--text-muted); } -.tc-bubble p { font-size: .9rem; line-height: 1.5; color: var(--text-primary); } -.tc-turn-center .tc-bubble p { font-size: .82rem; color: var(--text-secondary); } - -.tc-receipt { --receipt-ink: #0e7490; display: inline-flex; align-items: center; gap: 8px; margin-top: 8px; padding: 5px 9px; border-radius: 8px; background: color-mix(in srgb, var(--receipt-ink) 9%, transparent); border: 1px solid color-mix(in srgb, var(--receipt-ink) 28%, transparent); } -[data-theme="dark"] .tc-receipt { --receipt-ink: #67e8f9; } -.tc-receipt-tag { font: 700 .56rem var(--font-scp, monospace); letter-spacing: .05em; color: var(--receipt-ink); } -.tc-receipt a { font: 600 .64rem var(--font-scp, monospace); color: var(--receipt-ink); } -.tc-receipt code { font: 600 .64rem var(--font-scp, monospace); color: var(--text-muted); } -.tc-raw { margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--border); } -.tc-raw span { display: block; font: 700 .52rem var(--font-scp, monospace); letter-spacing: .06em; color: var(--text-muted); text-transform: uppercase; } -.tc-raw code { font: .64rem var(--font-scp, monospace); color: var(--text-secondary); overflow-wrap: anywhere; } - -.tc-typing { align-self: flex-start; display: inline-flex; gap: 4px; padding: 10px 14px; } -.tc-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); animation: tc-blink 1.2s infinite; } -.tc-typing span:nth-child(2) { animation-delay: .2s; } -.tc-typing span:nth-child(3) { animation-delay: .4s; } -@keyframes tc-blink { 0%, 60%, 100% { opacity: .3; } 30% { opacity: 1; } } - -.tc-outcome { margin-top: 20px; padding: 22px; border: 1px solid var(--green-border); border-radius: 16px; background: color-mix(in srgb, var(--green-soft) 40%, var(--bg-card)); text-align: center; } -.tc-outcome-badge { display: inline-block; font: 700 .74rem var(--font-scp, monospace); color: var(--green-strong); border: 1px solid var(--green-border); border-radius: 999px; padding: 4px 12px; margin-bottom: 12px; } -.tc-outcome p { max-width: 620px; margin: 0 auto; color: var(--text-secondary); font-size: .95rem; line-height: 1.6; } -.tc-outcome-actions { display: flex; gap: 10px; justify-content: center; margin-top: 16px; } - -@media (max-width: 720px) { - .tc-stagebar { grid-template-columns: 1fr 1fr; } - .tc-stage p { display: none; } - .tc-turn { max-width: 92%; } -} - -/* ═══ Homepage — recorded deal landing page (scoped .hp-*, reuses .tc-* bubbles) ═ */ -.hp-page { display: flex; flex-direction: column; gap: 72px; padding-bottom: 40px; } -.hp-hero { - display: grid; - grid-template: - "copy demo" auto - "actions demo" auto - / minmax(0, 1.05fr) minmax(0, 1fr); - column-gap: 44px; - align-content: center; - padding-top: 16px; -} -.hp-hero-copy { grid-area: copy; align-self: end; } -.hp-hero-actions { grid-area: actions; align-self: start; } -.hp-hero-copy h1 { margin: 14px 0 14px; font-size: clamp(2rem, 4.4vw, 3.1rem); line-height: 1.06; letter-spacing: -0.02em; } -.hp-hero-copy h1 em { font-style: italic; color: var(--accent-strong); } -.hp-hero-copy p { color: var(--text-secondary); font-size: 1rem; line-height: 1.65; max-width: 520px; } -.hp-cta-row { display: flex; align-items: center; gap: 18px; margin-top: 22px; flex-wrap: wrap; } -.hp-cta-center { justify-content: center; } -.hp-cta-ghost { color: var(--accent-strong); font-weight: 600; font-size: .92rem; } -.hp-stats { display: flex; gap: 28px; margin-top: 28px; flex-wrap: wrap; } -.hp-stats div { display: flex; flex-direction: column; } -.hp-stats strong { font-size: 1.35rem; letter-spacing: -0.01em; } -.hp-stats span { color: var(--text-muted); font-size: .72rem; } - -.hp-demo { grid-area: demo; align-self: center; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); overflow: hidden; } -.hp-demo-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); } -.hp-live { display: inline-flex; align-items: center; gap: 7px; color: var(--text-muted); font: 600 .66rem var(--font-scp, monospace); letter-spacing: .04em; } -.hp-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: livePulse 1.6s infinite; } -.hp-demo-actions { display: flex; align-items: center; gap: 10px; } -.hp-demo-stages { display: flex; gap: 6px; } -.hp-demo-stages span { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-hover); color: var(--text-muted); font: 700 .62rem var(--font-scp, monospace); transition: .25s; } -.hp-demo-stages span.active { border-color: var(--accent); color: var(--accent-strong); } -.hp-demo-stages span.done { border-color: var(--green-strong); background: var(--green-soft); color: var(--green-strong); } -.hp-demo-control { border: 1px solid var(--border); border-radius: var(--radius-chip); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; padding: 4px 8px; font: 600 .62rem var(--font-scp, monospace); } -.hp-demo-control:hover { border-color: var(--border-hover); color: var(--text-primary); } -.hp-demo-scroll { height: 380px; overflow-y: auto; padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; background: var(--bg-tinted); } -.hp-turn .tc-bubble p { font-size: .8rem; } -.hp-turn .tc-who { font-size: .64rem; } -.hp-turn .tc-avatar { width: 26px; height: 26px; font-size: .7rem; } -.hp-typing { padding: 6px 10px; } -.hp-settled { align-self: center; margin-top: 6px; color: var(--green-strong); font: 600 .68rem var(--font-scp, monospace); } -.hp-demo-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--border); font-size: .72rem; color: var(--text-muted); flex-wrap: wrap; } -.hp-demo-foot em { font-style: normal; color: var(--text-secondary); } -.hp-demo-foot a { color: var(--accent-strong); font-weight: 600; } - -.hp-section-head { max-width: 640px; margin-bottom: 24px; } -.hp-section-head h2 { margin-top: 10px; font-size: clamp(1.4rem, 2.6vw, 1.9rem); letter-spacing: -0.015em; } -.hp-section-head p { margin-top: 10px; color: var(--text-secondary); font-size: .92rem; line-height: 1.6; } - -.hp-deal-list { border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); overflow: hidden; } -.hp-deal-row { display: grid; grid-template-columns: 200px 1fr 260px; gap: 20px; align-items: baseline; padding: 16px 20px; } -.hp-deal-row + .hp-deal-row { border-top: 1px solid var(--border); } -.hp-deal-name { display: flex; flex-direction: column; gap: 5px; } -.hp-deal-name h3 { font-size: .92rem; } -.hp-deal-row p { color: var(--text-secondary); font-size: .82rem; line-height: 1.55; } -.hp-deal-row > code { color: var(--text-muted); font: .66rem var(--font-scp, monospace); overflow-wrap: anywhere; justify-self: end; text-align: right; } -.hp-deal-status { align-self: flex-start; font: 600 .62rem var(--font-scp, monospace); } -.hp-deal-status-recorded { color: var(--green-strong); } -.hp-deal-status-live { color: var(--accent-strong); } -.hp-deal-status-spec { color: var(--text-muted); } - -.hp-why-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); overflow: hidden; } -.hp-why { padding: 18px 16px; } -.hp-why + .hp-why { border-left: 1px solid var(--border); } -.hp-why .mono { font: 600 .62rem var(--font-scp, monospace); color: var(--accent-strong); } -.hp-why strong { display: block; margin-top: 6px; font-size: .92rem; } -.hp-why p { margin-top: 6px; color: var(--text-secondary); font-size: .76rem; line-height: 1.55; } -.hp-receipts-note { max-width: 640px; margin-top: 14px; color: var(--text-muted); font-size: .8rem; line-height: 1.6; } -.hp-receipts-note a { color: var(--accent-strong); font-weight: 600; } - -.hp-closing { padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-tinted); } -.hp-closing p { margin: 8px 0 16px; max-width: 560px; color: var(--text-secondary); font-size: .875rem; } - -.hp-stats-empty { margin-top: 28px; color: var(--text-muted); font: 600 .72rem var(--font-scp, monospace); } - -@media (max-width: 900px) { - .hp-hero { - grid-template: - "copy" - "demo" - "actions" - / minmax(0, 1fr); - row-gap: 28px; - } - .hp-hero-copy, .hp-demo, .hp-hero-actions { align-self: stretch; } - .hp-hero-actions .hp-cta-row { margin-top: 0; } - .hp-deal-row { grid-template-columns: 1fr; gap: 8px; } - .hp-deal-row > code { justify-self: start; text-align: left; } - .hp-why-grid { grid-template-columns: 1fr; } - .hp-why + .hp-why { border-left: 0; border-top: 1px solid var(--border); } - .hp-demo-scroll { height: 320px; } -} - @media (max-width: 1120px) { .nav-menu-button { display: grid; place-items: center; } .nav .nav-links { diff --git a/reference-implementations/dacs-directory/app/how-it-works/page.tsx b/reference-implementations/dacs-directory/app/how-it-works/page.tsx index ad0fcac..74bce1d 100644 --- a/reference-implementations/dacs-directory/app/how-it-works/page.tsx +++ b/reference-implementations/dacs-directory/app/how-it-works/page.tsx @@ -10,7 +10,7 @@ export const metadata = { const TRUST = [ { title: "Identity signals stay separate", text: "A signed listing proves control of its signing key. GCR links connect that key to accounts or wallets. Only a fresh passing DACS-2 result can elevate the identity to DACS-verified; the directory never treats those three signals as interchangeable.", }, - { title: "Deal history is derived, not reviewed", text: "The directory counts a bundle only after strict signature/reference checks, reconciles buyer and seller copies, and applies perspective, fault and neutral-outcome rules. Ratings and transactional volume remain empty until their signed records can be resolved.", }, + { title: "Completion verification unavailable", text: "Completion and fault rates and numeric reputation scores are unavailable on testnet. Counts describe observed history, not verified completion. You can still inspect each deal's evidence. Full metrics return under a later reputation contract.", }, { title: "The directory is a cache — verify the cryptography", text: "“Verify yourself” checks required party signatures and referenced-artifact signatures/hashes in your browser. The server still ferries RPC bytes, so this proves internal consistency rather than independent chain inclusion; a future Demos proof/CORS-safe read path is needed to remove that final trust boundary.", }, { title: "Discovery is not permission to pay", text: "The directory authenticates catalog candidates. It does not apply a buyer's revocation, rail-authority, payload-capability, or delegated signer-control policy. A transacting buyer must dereference the exact artifact and obtain a verified result from the current SDK reader.", }, ]; diff --git a/reference-implementations/dacs-directory/app/layout.tsx b/reference-implementations/dacs-directory/app/layout.tsx index 7c85e5e..65cdd47 100644 --- a/reference-implementations/dacs-directory/app/layout.tsx +++ b/reference-implementations/dacs-directory/app/layout.tsx @@ -1,20 +1,24 @@ import "./globals.css"; -import { Plus_Jakarta_Sans, Source_Code_Pro } from "next/font/google"; +import localFont from "next/font/local"; import Link from "next/link"; import type { Metadata } from "next"; import type { ReactNode } from "react"; import SiteNav from "@/src/components/SiteNav"; import { directoryBaseUrl } from "@/src/catalog/publicUrl"; -const jakarta = Plus_Jakarta_Sans({ - subsets: ["latin"], +const jakarta = localFont({ + src: "../node_modules/@fontsource-variable/plus-jakarta-sans/files/plus-jakarta-sans-latin-wght-normal.woff2", variable: "--font-jakarta", - weight: ["400", "500", "600", "700"], + weight: "200 800", + style: "normal", + display: "swap", }); -const sourceCodePro = Source_Code_Pro({ - subsets: ["latin"], +const sourceCodePro = localFont({ + src: "../node_modules/@fontsource-variable/source-code-pro/files/source-code-pro-latin-wght-normal.woff2", variable: "--font-scp", - weight: ["400", "500", "600"], + weight: "200 900", + style: "normal", + display: "swap", }); export const metadata: Metadata = { diff --git a/reference-implementations/dacs-directory/app/llms.txt/route.ts b/reference-implementations/dacs-directory/app/llms.txt/route.ts index fe22f7b..fba6cd0 100644 --- a/reference-implementations/dacs-directory/app/llms.txt/route.ts +++ b/reference-implementations/dacs-directory/app/llms.txt/route.ts @@ -19,7 +19,7 @@ DACS Directory discovers signed, chain-anchored agent services for humans and so ## Discovery -GET ${base}/api/dacs/listings supports category, repeated tag, credential, primaryClaim, identityTier, rail, priceMax, minCompletionRate, minRating, cursor and limit. Directory extensions are q and profile. Every result includes an anchor and contentHash; dereference the listing-detail URL before engaging. identityTier is derived only from fresh, passing, version-pinned DACS-2 verifiedBy evidence; missing recipe policy fails closed to self-declared. +GET ${base}/api/dacs/listings supports category, repeated tag, credential, primaryClaim, identityTier, rail, priceMax, cursor and limit. Directory extensions are q and profile. Completion verification unavailable: completion and fault rates and numeric reputation scores are null on testnet under TP-1. Rate/score filters (including minCompletionRate, minRating and trackRecord) and ranking parameters return 400. Observed counts are not verified completion; individual evidence remains inspectable. Every result includes an anchor and contentHash; dereference the listing-detail URL before engaging. identityTier is derived only from fresh, passing, version-pinned DACS-2 verifiedBy evidence; missing recipe policy fails closed to self-declared. artifactProfile=dacs-v0.1 identifies a current structured Listing. artifactProfile=legacy-sdk-v0.1 identifies the pinned SDK compatibility shape. artifactProfile=fixture-listing identifies a local fixture that is not signed or chain-anchored. Treat a missing profile as legacy for backward compatibility. A publicEndpoint, when present, is an advertised engagement route rather than a trust anchor. reachabilityHint is a time-stamped, non-authoritative catalog probe; treat stale hints as unknown and never use them for validity, identity, revocation, or reputation decisions. diff --git a/reference-implementations/dacs-directory/app/page.tsx b/reference-implementations/dacs-directory/app/page.tsx index be9c3d8..59ccc71 100644 --- a/reference-implementations/dacs-directory/app/page.tsx +++ b/reference-implementations/dacs-directory/app/page.tsx @@ -1,141 +1,104 @@ import Link from "next/link"; import type { Metadata } from "next"; -import HomeDealDemo from "@/src/components/HomeDealDemo"; -import { homeCatalogDisplayState } from "@/src/components/home-hero-state"; +import { homeCatalogSummary } from "@/src/components/home-hero-state"; import { loadCatalog } from "@/src/catalog/store"; -import { activeCatalogListings, activeCatalogSellers } from "@/src/catalog/discovery"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { - title: { absolute: "DACS Directory · Verifiable agent commerce" }, - description: "Watch a real agent-to-agent purchase with verifiable receipts, then discover the agents that trade this way.", + title: { absolute: "DACS Directory · Verifiable agent discovery" }, + description: "Discover independently indexed agent services and inspect their signed listings, identities, and deal evidence.", alternates: { canonical: "/" }, }; -const DEAL_TYPES = [ +const DIRECTORY_CAPABILITIES = [ { - name: "Fixed price", - line: "A posted price — 0.01 DEM per call.", - spec: "pricing: fixed · negotiate-fixed-price", - status: "live", - tone: "live" as const, + title: "Current signed listings", + body: "The indexer reads chain state, verifies listing signatures and content hashes, and excludes validly revoked versions from active discovery.", + href: "/discover", + action: "Browse active services", }, { - name: "Metered", - line: "Per-unit pricing; the total locks at commit.", - spec: "pricing: metered · MTR-1..5, DACS-4 v0.3", - status: "spec v0.3", - tone: "spec" as const, + title: "Inspectable evidence", + body: "Deal pages expose the referenced artifacts and verification result instead of turning an advisory reputation hint into a trust claim.", + href: "/verify", + action: "Verify a deal", }, { - name: "RFQ", - line: "Quote and counter inside a signed band. The deal above is an RFQ.", - spec: "pricing: negotiable · negotiate-rfq", - status: "recorded above", - tone: "recorded" as const, - }, - { - name: "Sealed bid", - line: "Hidden bids, revealed together; a published rule picks the winner.", - spec: "pricing: auction · negotiate-sealed-envelope", - status: "spec v0.3", - tone: "spec" as const, + title: "Machine-readable catalog", + body: "Agents can discover the same filtered catalog through the linked API, manifest, schema, and canonical service records.", + href: "/api/dacs", + action: "Open the developer API", }, ]; export default function Home() { - const catalog = loadCatalog(); - const sellers = activeCatalogSellers(catalog.sellers); - const listings = activeCatalogListings(catalog); - const verifiedDeals = sellers.reduce((sum, seller) => sum + seller.deals.filter((deal) => deal.refsVerified).length, 0); - const indexed = catalog.generatedAt > 0; - const catalogDisplayState = homeCatalogDisplayState(indexed, listings.length); - const indexedAgoMin = indexed ? Math.max(0, Math.round((Date.now() - catalog.generatedAt) / 60_000)) : 0; - const indexedAgo = indexedAgoMin < 60 ? `${indexedAgoMin}m` : `${Math.round(indexedAgoMin / 60)}h`; + const summary = homeCatalogSummary(loadCatalog(), Date.now()); return ( -
-
-
-
verifiable agent commerce
-

This is a real deal between two agents.

-

- A buyer agent purchases a code audit from a seller agent — price agreed, DEM paid, work - delivered, five receipts on the Demos chain. This directory indexes the agents that trade this way. -

+ <> +
+
chain-indexed service discovery
+

Find agents you can verify.

+

+ The Community Directory indexes DACS service listings from chain state, verifies the + artifacts it can prove, and exposes the same catalog to people and software. +

+
+ Browse the directory + List your service
- -
-
- Browse the directory - Run a deal yourself → +
+
+ {summary.activeServices} + active services +
+
+ {summary.sellers} + indexed sellers +
+
+ {summary.verifiedDeals} + observed bundles; completion not verified +
+
+ {summary.freshness.value} + {summary.freshness.label}
- {catalogDisplayState === "summary" ? ( -
-
{listings.length}active services
-
{sellers.length}indexed agents
-
{verifiedDeals}verified deals
-
{indexedAgo}since last index
-
- ) : ( -

- {catalogDisplayState === "indexing" ? "indexing the chain…" : "no active services indexed yet"} -

- )}
-
-
-
four ways to price a deal
-

From a posted price to a sealed auction

-

Every route ends in the same five receipts.

+
+
+
+
directory boundary
+

Discovery backed by evidence

+
+ How verification works →
-
- {DEAL_TYPES.map((deal) => ( -
-

{deal.name}

{deal.status}
-

{deal.line}

- {deal.spec} -
+
+ {DIRECTORY_CAPABILITIES.map((capability) => ( +
+

{capability.title}

+

{capability.body}

+ {capability.action} → +
))}
-
-
-
one deal · five receipts
-

Why five stages

-
-
- {[ - { n: 1, name: "Identify", why: "One primary identity with explicitly linked wallets and Web2 accounts." }, - { n: 2, name: "Vet", why: "Credentials, sanctions screens and reputation, checked before committing." }, - { n: 3, name: "Negotiate", why: "Off-chain conversation, on-chain commitments. Terms anchor at commit." }, - { n: 4, name: "Settle", why: "Value moves on the agreed rail; both sides clear in the same window." }, - { n: 5, name: "Verify", why: "A tamper-proof attestation closes the loop. Auditable forever after." }, - ].map((stage) => ( -
- DACS-{stage.n} - {stage.name} -

{stage.why}

-
- ))} -
-

- Each stage anchors its receipt before the next begins. How it works → +

+
publish once · discover openly
+

Run an agent? Make its service discoverable.

+

+ Publish a signed listing on Demos and submit its bounded discovery coordinates. Registration + helps the catalog find it; the Directory still verifies the chain artifact independently.

-
- -
-

Run an agent? Get listed.

-

Publish a signed listing on-chain; the catalog verifies it and indexes every deal you complete.

-
- Register an agent - Verify a deal yourself → +
+ Register a service + Read the machine manifest
-
+ ); } diff --git a/reference-implementations/dacs-directory/app/schemas/listing-summary.schema.json/route.ts b/reference-implementations/dacs-directory/app/schemas/listing-summary.schema.json/route.ts index bd89196..08211bc 100644 --- a/reference-implementations/dacs-directory/app/schemas/listing-summary.schema.json/route.ts +++ b/reference-implementations/dacs-directory/app/schemas/listing-summary.schema.json/route.ts @@ -3,5 +3,7 @@ import { listingSummarySchema } from "@/src/catalog/contracts"; import { catalogJson } from "@/src/catalog/http"; export async function GET(req: NextRequest) { - return catalogJson(req, listingSummarySchema, { cacheControl: "public, max-age=86400, immutable" }); + // The schema URL is unversioned, so it must not be cached as immutable: a + // contract change has to reach clients within minutes (ETag revalidates). + return catalogJson(req, listingSummarySchema, { cacheControl: "public, max-age=300, must-revalidate" }); } diff --git a/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx b/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx index 80768d3..21ffff3 100644 --- a/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx +++ b/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx @@ -6,6 +6,7 @@ import CopyText from "@/src/components/CopyText"; import { ChipGroup, CciChip } from "@/src/components/Badge"; import { railLabel, deliveryLabel, negotiationLabel, tierMeta } from "@/src/components/labels"; import { loadCatalog } from "@/src/catalog/store"; +import { COMPLETION_VERIFICATION_UNAVAILABLE, observedOutcomesLabel } from "@/src/catalog/testnetMetricPolicy"; export const dynamic = "force-dynamic"; const EXPLORER = "https://explorer.demos.sh"; @@ -94,9 +95,9 @@ export default async function Seller({ params }: { params: Promise<{ claim: stri

-
{seller.reputation.completed}/{seller.reputation.totalAgreements}
strict bundles completed
+
{COMPLETION_VERIFICATION_UNAVAILABLE}
{observedOutcomesLabel(seller.reputation.totalAgreements)}
{activeListingCount}
active listing{activeListingCount === 1 ? "" : "s"}
-
{seller.deals.filter((d) => d.refsVerified).length}
chain-verified bundles
+
{seller.deals.filter((d) => d.refsVerified).length}
observed bundles; completion not verified
@@ -163,7 +164,7 @@ export default async function Seller({ params }: { params: Promise<{ claim: stri
- + {[...seller.deals].sort((a, b) => (b.finalisedAt ?? 0) - (a.finalisedAt ?? 0)).map((d) => ( @@ -173,7 +174,7 @@ export default async function Seller({ params }: { params: Promise<{ claim: stri
{d.finalisedAt ? new Date(d.finalisedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "—"}
- +
DealRailCompletedCatalog check
DealRailObserved outcomeCatalog check
{d.rail}{d.outcome === "completed" ? "✓" : (d.outcome ?? "—")}{d.outcome ?? "—"} {d.refsVerified ? "sig + refs verified" : d.signatureVerified ? "sig only" : "unverified"} diff --git a/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx b/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx index 5a25518..e0f837b 100644 --- a/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx +++ b/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx @@ -127,7 +127,7 @@ export default async function ServicePage({ params }: { params: Promise
  • –
    Transaction readiness

    Unassessed here; buyers must run the current SDK reader with their own rail, revocation, payload, and signer-control dependencies

  • {engagementEndpoint ? "•" : "–"}
    Endpoint declaration

    {evidence.endpoint.label}; this says nothing about availability

  • {evidence.reachability.kind === "reachable" ? "✓" : "–"}
    Reachability

    {evidence.reachability.label}

  • -
  • {evidence.deals.completed ? "✓" : "–"}
    Two-sided deal evidence

    {evidence.deals.label}. {evidence.deals.explanation}.

  • +
  • –
    {evidence.deals.explanation}

    {evidence.deals.label}.

  • diff --git a/reference-implementations/dacs-directory/app/sitemap.ts b/reference-implementations/dacs-directory/app/sitemap.ts index 80fb742..93ba8fa 100644 --- a/reference-implementations/dacs-directory/app/sitemap.ts +++ b/reference-implementations/dacs-directory/app/sitemap.ts @@ -11,8 +11,6 @@ export default function sitemap(): MetadataRoute.Sitemap { const staticRoutes: MetadataRoute.Sitemap = [ { url: base, lastModified: modified, changeFrequency: "hourly", priority: 1 }, { url: `${base}/discover`, lastModified: modified, changeFrequency: "hourly", priority: 0.9 }, - { url: `${base}/try`, changeFrequency: "weekly", priority: 0.9 }, - { url: `${base}/try-chat`, changeFrequency: "monthly", priority: 0.7 }, { url: `${base}/how-it-works`, changeFrequency: "monthly", priority: 0.7 }, { url: `${base}/verify`, changeFrequency: "monthly", priority: 0.6 }, { url: `${base}/register`, changeFrequency: "monthly", priority: 0.6 }, diff --git a/reference-implementations/dacs-directory/app/try-chat/page.tsx b/reference-implementations/dacs-directory/app/try-chat/page.tsx deleted file mode 100644 index 4b63c87..0000000 --- a/reference-implementations/dacs-directory/app/try-chat/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import TryChat from "@/src/components/TryChat"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Watch a recorded DACS deal", - description: "Replay a completed deal between a buyer's Butler and a seller's Auditor, with genuine on-chain receipts and no new payment.", - alternates: { canonical: "/try-chat" }, -}; - -// Zero-cost explainer. All live procurement remains on /try, which owns the -// idempotency, recovery, payment-rail selection, and evidence verification. -export default function TryChatPage() { - return ; -} diff --git a/reference-implementations/dacs-directory/app/try/page.tsx b/reference-implementations/dacs-directory/app/try/page.tsx deleted file mode 100644 index 8194fb1..0000000 --- a/reference-implementations/dacs-directory/app/try/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import TryDacs from "@/src/components/TryDacs"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Try DACS", - description: "Run a live fixed-price or RFQ agent procurement, pay with DEM or Base Sepolia USDC through x402, and inspect every DACS receipt.", - alternates: { canonical: "/try" }, -}; - -export default function TryPage() { - return ; -} diff --git a/reference-implementations/dacs-directory/app/verify/page.tsx b/reference-implementations/dacs-directory/app/verify/page.tsx index 2c6c3dd..1cd597f 100644 --- a/reference-implementations/dacs-directory/app/verify/page.tsx +++ b/reference-implementations/dacs-directory/app/verify/page.tsx @@ -7,6 +7,7 @@ */ import { useState } from "react"; import VerifyDeal from "@/src/components/VerifyDeal"; +import { ObservedOutcomeBadge } from "@/src/components/Badge"; type Loaded = { ref: string; buyer: string; jobId?: string; outcome?: string }; @@ -91,7 +92,7 @@ export default function VerifyPage() {
    {loaded.jobId && deal{loaded.jobId}} - {loaded.outcome && {loaded.outcome}} + {loaded.outcome && } buyer{loaded.buyer.slice(0, 26)}…
    diff --git a/reference-implementations/dacs-directory/docs/TP-1.md b/reference-implementations/dacs-directory/docs/TP-1.md new file mode 100644 index 0000000..dfb47f5 --- /dev/null +++ b/reference-implementations/dacs-directory/docs/TP-1.md @@ -0,0 +1,105 @@ +# TP-1: testnet no-rate restriction (version 1) + +TP-1 is the restriction this Directory applies while completion cannot be +verified on testnet. This file is the definition the code cites +(`metricsProfile: "TP-1"`, manifest `restrictions: ["TP-1"]`). It is versioned +with this repository, so a commit of this repository pins it. + +It is measured against the DACS Standard at +[`d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20`](https://github.com/DACS-Agent-commerce/DACS-Standard/tree/d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20), +the revision the pinned SDK declares. + +## Rules + +1. Every public surface publishes `completionRate`, + `counterpartyAdjustedCompletionRate`, `counterpartyFaultRate`, + `averageBuyerRating` and `averageSellerRating` as `null`, with + `metricsUnavailableReason: "Completion verification unavailable"` and + `metricsProfile: "TP-1"`. Individual rating values are `null`. +2. Rate and score filters (`minCompletionRate`, `minRating`, `trackRecord`, + `maxFaultRate`, the metric names themselves, `reputationScore`) and + sorting or ranking parameters return 400. +3. The projection runs on every read, so a catalog cached by an older release + is safe to serve. Writers read and save the stored catalog, never the + projection. Unknown `*Rate`, `*Rating` and `*Score` keys are dropped at + seller, deal and listing level. +4. Observed counts (`completed`, `totalAgreements`, `bundleCount`), currency + totals and `bundleRefs` are rebuilt together from eligible retained records, + labelled as observations that do not establish completion or settlement. + Records without the current `settlementProjectionVersion` contribute to + neither seller aggregates nor listing hints until a successful reindex. + Saving an older catalog does not upgrade its records. +5. A listing hint keeps the shape the pinned SDK catalog reader requires. A + missing or malformed cached hint is replaced by one over the listing's + category and the catalog snapshot window. Its `bundleCount` is always + recomputed on read from the retained records the seller reputation counts + (the settlement rule below), within the hint's scope and window. + +## Contract versions + +TP-1 removed two DACS-1 §6.3.6 parameters, so the manifest reports +`dacsDirectoryVersion: "2"` and the OpenAPI document `info.version: "2.0.0"`. +The ListingSummary schema URL is unversioned and is served with a short, +revalidated cache lifetime. + +## Declared deviations + +In the Standard's `ImplementationDeviation` shape (`spec/CONFORMANCE-PLAN.md`, +IM-5). An open `nonconforming` deviation invalidates any conformance claim for +the affected capability; this Directory makes none for these capabilities. + +```json +[ + { + "id": "tp-1-null-rates-and-refused-rate-filters", + "capabilityRefs": ["directory-catalog-api", "directory-reputation-hints"], + "ruleRefs": ["DACS-1-6.3.6", "DACS-5-10.5.3", "DACS-5-10.5.4"], + "status": "open", + "effect": "nonconforming", + "description": "minCompletionRate and minRating return 400 instead of filtering. completionRate and averageSellerRating are null even when bundleCount is positive, and published reputation carries no re-derivable metrics." + }, + { + "id": "sb3-session-binding-unverified", + "capabilityRefs": ["directory-reputation-hints"], + "ruleRefs": ["SB-3"], + "status": "open", + "effect": "nonconforming", + "description": "The Directory does not independently authenticate the rail profile's mandatory settlement-side session binding. Counts, references and currency totals are observations only; they cannot establish settlement, transaction readiness, a final verification decision, or a DACS-5 replayable derivation." + }, + { + "id": "sb1-settlement-id-without-ledger-check", + "capabilityRefs": ["directory-reputation-hints"], + "ruleRefs": ["SB-1", "SB-2"], + "status": "open", + "effect": "nonconforming", + "description": "The settlement-tx-id is projected from the signed TxRef without independently authenticated ledger data: asset, payer, agreed payee, amount, finality and x402 receipt-to-transaction/network binding are not established. No settlement-side collision winner is authenticated; all competing records are excluded across the complete presented catalog. Arms whose identity needs that ledger data (legacy evm, solana and x402, legacy non-Demos rails, and all other arms) are not projected, and their records do not count." + } +] +``` + +Settlement uniqueness (SB-1, SB-2) applies to the records counted in seller +reputation and in listing hints: + +- Success payment evidence requires a nonempty array of exact TxRef shapes. + The phase index comes from the evidence anchor's metadata-of-record PC-2 + logical address, read with the verified signed bytes. Its complete canonical + job/CF-4 rail/index tuple must match the signed job, agreement and bundle. + Registry rail IDs and phase handlers are distinct: the pinned Standard table + determines the handler, and the signed listing pipeline must bind that handler + and rail at the same index. Unknown registry entries/versions and unsupported + ST-8 resolved records are withheld until their authority can be resolved. + Demos, EVM ERC-20, Solana SPL and x402 rails accept only their corresponding + TxRef arms. A mismatch excludes the deal from every aggregate. +- Each `paymentTxRefs` entry of success-outcome payment evidence is + projected to its SB-1 `settlement-tx-id`: `demos` gives `demos:{txHash}`; + `evm-event` and + `x402-event` give `evm:{chainId}:{txHash}:{logIndex}`; `solana-instruction` + gives `solana:{cluster}:{signature}:{instructionIndex}`. A record citing a + reference that cannot be projected this way does not count. +- The complete catalog set of otherwise countable records, across all sellers, + is grouped by that id before seller, category or window projection. One record + repeating one `(jobId, phaseIndex)` claim + counts once. A group claimed by more than one record or tuple follows SB-2 + rule (2): this Directory verifies no settlement-side binding authority, so + the group is indeterminate and none of its records count, whatever their + `observedAt` or arrival order. diff --git a/reference-implementations/dacs-directory/e2e/README.md b/reference-implementations/dacs-directory/e2e/README.md index e0dca01..26d01ea 100644 --- a/reference-implementations/dacs-directory/e2e/README.md +++ b/reference-implementations/dacs-directory/e2e/README.md @@ -1,34 +1,17 @@ -# `/try` browser tests +# DACS Directory browser tests -The Playwright suite has two layers: +The Playwright suite covers the Directory's public discovery and seller-publication +surfaces without contacting a live chain or spending funds. -- `npm run test:e2e` runs seven deterministic browser regressions: six mocked `/try` payment-safety scenarios plus a `/try-chat` replay check that proves the explainer cannot dispatch a purchase. It never spends DEM or USDC and is safe for CI. -- `npm run test:e2e:live` contains five serial checks against the live Butler gateway. It is skipped unless the operator explicitly authorizes a capped testnet purchase. - -## Install the browser +Install the browser once: ```bash npx playwright install chromium ``` -## Run the zero-cost suite - -```bash -npm run test:e2e -``` - -## Run the live suite - -The live suite makes exactly one new procurement purchase. The remaining four checks inspect that job or prove that a second POST is not sent. It refuses to run with a budget cap above 5 DEM. - -```bash -RUN_LIVE_PAID_E2E=1 LIVE_E2E_MAX_DEM=5 npm run test:e2e:live -``` - -Optional overrides: - -- `LIVE_BUTLER_ORIGIN` changes the gateway origin. -- Mock artifacts are written under `test-results/playwright/` and `playwright-report/`. -- Live artifacts are isolated under `test-results/playwright-live/` and `playwright-report-live/`, so a routine mocked run cannot overwrite payment evidence. +- `npm run test:e2e` runs the deterministic landing-page, discovery, navigation, and + registration regressions used in CI. +- `npm run test:e2e:ui` opens Playwright's interactive runner for local debugging. -Treat the live command as a payment authorization. Do not add `RUN_LIVE_PAID_E2E=1` to normal CI secrets or repository configuration. +The configured development server starts on `http://localhost:3400`; the readiness +check uses `/`, and `NEXT_PUBLIC_DIRECTORY_URL` is set to the same local origin. diff --git a/reference-implementations/dacs-directory/e2e/home.spec.ts b/reference-implementations/dacs-directory/e2e/home.spec.ts index b3502c2..d33a2f2 100644 --- a/reference-implementations/dacs-directory/e2e/home.spec.ts +++ b/reference-implementations/dacs-directory/e2e/home.spec.ts @@ -1,24 +1,25 @@ import { expect, test } from "@playwright/test"; -test("the landing page leads to discovery and exposes playback controls", async ({ page }) => { +test("the landing page is catalog-led", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: "This is a real deal between two agents." })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Find agents you can verify." })).toBeVisible(); await expect(page.getByRole("link", { name: "Browse the directory" })).toHaveAttribute("href", "/discover"); + await expect(page.getByRole("link", { name: "List your service", exact: true })).toHaveAttribute("href", "/register"); + await expect(page.getByLabel("Catalog summary")).toBeVisible(); + await expect(page.getByText("initial chain index")).toBeVisible(); - const playback = page.getByRole("button", { name: "Pause" }); - await expect(playback).toBeVisible(); - await playback.click(); - await expect(page.getByRole("button", { name: "Play" })).toHaveAttribute("aria-pressed", "true"); - + // The landing page shares /discover's heading, so check the page itself. await page.goto("/discover"); + await expect(page).toHaveURL(/\/discover$/); + await expect(page).toHaveTitle("Discover agents · DACS Directory"); await expect(page.getByRole("heading", { name: "Find agents you can verify." })).toBeVisible(); }); test("the proposal URL redirects to the landing page", async ({ page }) => { await page.goto("/home-proposal"); await expect(page).toHaveURL(/\/$/); - await expect(page.getByRole("heading", { name: "This is a real deal between two agents." })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Find agents you can verify." })).toBeVisible(); }); test("the primary navigation collapses before it can overflow", async ({ page }) => { @@ -29,44 +30,18 @@ test("the primary navigation collapses before it can overflow", async ({ page }) await expect(menu).toBeVisible(); await menu.click(); await expect(page.getByRole("link", { name: "discover", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "list your service", exact: true })).toBeVisible(); }); -test("the mobile hero keeps its visual and keyboard order aligned", async ({ page }) => { +test("the mobile landing page keeps primary actions and catalog state visible", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); - const heroBlocks = page.locator(".hp-hero-copy, .hp-demo, .hp-hero-actions"); - await expect(heroBlocks).toHaveCount(3); - const blockTops = await heroBlocks.evaluateAll((blocks) => - blocks.map((block) => Math.round(block.getBoundingClientRect().top)), - ); - assertNondecreasing(blockTops); - - const emptyState = page.locator(".hp-stats-empty"); - await expect(emptyState).toHaveText("indexing the chain…"); - const emptyStateFontSize = await emptyState.evaluate((element) => - Number.parseFloat(getComputedStyle(element).fontSize), - ); - expect(emptyStateFontSize).toBeLessThan(13); - - const focusableLabels = await page.locator(".hp-hero a, .hp-hero button").evaluateAll((elements) => - elements.map((element) => element.textContent?.trim()), - ); - expect(focusableLabels).toEqual([ - "Pause", - "try dacs →", - "Browse the directory", - "Run a deal yourself →", - ]); + await expect(page.getByRole("heading", { name: "Find agents you can verify." })).toBeVisible(); + await expect(page.getByRole("link", { name: "Browse the directory" })).toBeVisible(); + await expect(page.getByRole("link", { name: "List your service", exact: true })).toBeVisible(); - const focusableTops = await page.locator(".hp-hero a, .hp-hero button").evaluateAll((elements) => - elements.map((element) => Math.round(element.getBoundingClientRect().top)), - ); - assertNondecreasing(focusableTops); + const summary = page.getByLabel("Catalog summary"); + await expect(summary).toBeVisible(); + await expect(summary.locator(":scope > div")).toHaveCount(4); }); - -function assertNondecreasing(values: number[]) { - for (let index = 1; index < values.length; index += 1) { - expect(values[index]).toBeGreaterThanOrEqual(values[index - 1]!); - } -} diff --git a/reference-implementations/dacs-directory/e2e/try-chat.spec.ts b/reference-implementations/dacs-directory/e2e/try-chat.spec.ts deleted file mode 100644 index 6467998..0000000 --- a/reference-implementations/dacs-directory/e2e/try-chat.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("the recorded deal is inspectable and cannot dispatch a purchase", async ({ page }) => { - let procurementRequests = 0; - await page.route("**/demo/procurement**", async (route) => { - procurementRequests += 1; - await route.abort("blockedbyclient"); - }); - - await page.goto("/try-chat"); - await expect(page.getByLabel("Recorded replay disclosure")).toContainText("never starts a job or spends funds"); - await expect(page.getByRole("link", { name: /Run a live deal/ })).toHaveAttribute("href", "/try"); - - await page.getByRole("button", { name: /Watch the recorded deal/ }).click(); - await page.getByRole("button", { name: "Show the full deal now" }).click(); - - await expect(page.locator(".tc-outcome-badge")).toContainText("Recorded deal settled & verified"); - await expect(page.locator(".tc-stage-done")).toHaveCount(5); - const payment = page.getByRole("link", { name: /verify tx 53dd8a7b…e0ff24/ }); - await expect(payment).toHaveAttribute("href", "https://explorer.demos.sh/transactions/53dd8a7b34f7d29377c27599e17a5742b2c7296dd048b1235c04359957e0ff24"); - expect(procurementRequests).toBe(0); -}); diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts b/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts deleted file mode 100644 index e80b787..0000000 --- a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { expect, type BrowserContext, type Page, type Route } from "@playwright/test"; - -export const PROCUREMENT_RUN_KEY = "dacs-try:procurement-run"; -export const PROCUREMENT_LOCK_NAME = "dacs-try:procurement-dispatch"; - -export const exampleInput = { - goal: "Audit the supplied source and return a content-bound security report.", - budgetDem: 5, - files: [{ path: "app.js", content: "export const greeting = 'hello';\n" }], -}; - -const x402Governance = { - status: "operator-provisional", - conformantAuthority: false, - signer: "did:demos:agent:mock-steward", - disclosure: "https://github.com/DACS-Agent-commerce/DACS-Standard/issues/274", -}; - -function railReadiness() { - return { - "pay-dem": { executable: true, reasons: [] }, - "pay-x402": { executable: true, reasons: [], railGovernance: x402Governance }, - }; -} - -const commonProfile = { - timing: { healthyMinSec: 10, healthyMaxSec: 30, hardTimeoutSec: 180, protocolFloorSec: 0 }, - confirmationGates: ["commit-agreement", "payment"], - paymentRails: ["pay-dem", "pay-x402"], - implementationStatus: "live", - executable: true, - reasons: [], -}; - -export const procurementOptions = { - profiles: [ - { - ...commonProfile, - id: "oracle-auto-accept", - title: "Buy an attested crypto price", - agentName: "Oracle Desk", - serviceId: "oracle-data", - mode: "fixed-price-auto-accept", - negotiationPhase: "negotiate-fixed-price", - summary: "Buy a posted-price public data point.", - fields: [], - sampleInput: { product: "crypto-price", params: { id: "bitcoin" }, paymentRail: "pay-dem" }, - railInputs: [ - { rail: "pay-dem", fields: [], sampleInput: { product: "crypto-price", params: { id: "bitcoin" }, paymentRail: "pay-dem" } }, - { rail: "pay-x402", fields: [], sampleInput: { product: "crypto-price", params: { id: "bitcoin" }, paymentRail: "pay-x402" } }, - ], - railReadiness: railReadiness(), - }, - { - ...commonProfile, - id: "dd-live-fixed", - title: "Commission a due-diligence report", - agentName: "Due Diligence Researcher", - serviceId: "due-diligence", - mode: "fixed-price-co-sign", - negotiationPhase: "negotiate-fixed-price", - summary: "Buy a jointly signed fixed-price research report.", - fields: [], - sampleInput: { kind: "npm-package", subject: "express", paymentRail: "pay-dem" }, - railInputs: [ - { rail: "pay-dem", fields: [], sampleInput: { kind: "npm-package", subject: "express", paymentRail: "pay-dem" } }, - { rail: "pay-x402", fields: [], sampleInput: { kind: "npm-package", subject: "express", paymentRail: "pay-x402" } }, - ], - railReadiness: railReadiness(), - }, - { - ...commonProfile, - id: "security-audit-rfq", - title: "Negotiate a bounded security audit", - agentName: "Security Auditor", - serviceId: "security-audit", - mode: "rfq", - negotiationPhase: "negotiate-rfq", - summary: "Run a live RFQ and buy a content-bound security report.", - fields: [], - sampleInput: { ...exampleInput, paymentRail: "pay-dem" }, - railInputs: [ - { rail: "pay-dem", fields: [], sampleInput: { ...exampleInput, paymentRail: "pay-dem" } }, - { - rail: "pay-x402", - fields: [], - sampleInput: { goal: exampleInput.goal, budgetUsdc: 0.1, files: exampleInput.files, paymentRail: "pay-x402" }, - }, - ], - railReadiness: railReadiness(), - }, - ], -}; - -const at = "2026-07-20T12:00:00.000Z"; - -export const acceptedResult = { - status: "settled-and-accepted", - decision: { - outcome: "selected", - winner: { provider: "Security Auditor", listingId: "audit-negotiator", price: 1 }, - candidates: [{ provider: "Security Auditor", listingId: "audit-negotiator", askPrice: 1, chosenRail: "pay-dem" }], - }, - negotiation: { - protocol: "l2ps", - terms: { tier: "bounded", deadline: "5 minutes", price: 1 }, - buyerSignature: { party: "buyer", algorithm: "ed25519", value: "buyer-signature" }, - sellerSignature: { party: "seller", algorithm: "ed25519", value: "seller-signature" }, - }, - settlement: { - amountDem: 1, - rail: "pay-dem", - payer: "did:demos:buyer", - payee: "did:demos:seller", - txHash: "mock-payment-transaction", - }, - delivery: { verified: true, report: { findings: [] } }, - evaluation: { - accepted: true, - rulingValid: true, - ruling: { verdict: "accept" }, - }, - bundleVerification: { ok: true }, - reconciliation: { reconciled: true }, - anchors: { listing: "mock-listing-anchor", agreement: "mock-agreement-anchor" }, - transactions: [ - { kind: "listing", name: "DACS-1 listing", address: "mock-listing-anchor", txRef: "mock-listing-tx" }, - { kind: "vet", name: "DACS-2 vet", address: "mock-vet-anchor", txRef: "mock-vet-tx" }, - { kind: "agreement", name: "DACS-3 agreement", address: "mock-agreement-anchor", txRef: "mock-agreement-tx" }, - { kind: "payment", name: "DEM payment", txRef: "mock-payment-transaction" }, - { kind: "bundle", name: "DACS-5 bundle", address: "mock-bundle-anchor", txRef: "mock-bundle-tx" }, - ], -}; - -export const completedJob = { - id: "job-e2e-1", - status: "complete", - phase: "complete", - preview: null, - events: [ - { phase: "discovering", label: "Signed listing verified", at, txRef: "mock-listing-tx" }, - { phase: "selecting", label: "Counterparty vet anchored", at, txRef: "mock-vet-tx" }, - { phase: "agreeing", label: "Dual-signed agreement anchored", at, txRef: "mock-agreement-tx" }, - { phase: "settling", label: "Payment evidence recorded", at, txRef: "mock-payment-transaction" }, - { phase: "complete", label: "Reconciled DACS-5 bundle anchored", at, txRef: "mock-bundle-tx" }, - ], - result: acceptedResult, -}; - -export const securityPreviewJob = { - id: completedJob.id, - status: "running", - phase: "verifying", - events: [ - ...completedJob.events.slice(0, 4), - { phase: "verifying", label: "Seller delivery and payment evidence verified", at, txRef: "mock-delivery-tx" }, - ], - preview: { - kind: "dacs-procurement-delivery-preview", - status: "report-verified-finalising-dacs5", - jobId: "web-auditor-e2e-1", - delivery: { - verified: true, - report: { - version: 1, - target: "(posted content)", - findings: [{ - id: "SEC-1", - severity: "high", - title: "Unsafe dynamic execution", - detail: "A dynamic code path requires review.", - file: "app.js", - line: 4, - }], - }, - }, - anchors: { - listing: "mock-listing-anchor", - agreement: "mock-agreement-anchor", - commitment: "mock-commitment-anchor", - paymentEvidence: "mock-payment-evidence-anchor", - delivery: "mock-delivery-anchor", - deliveryEvidence: "mock-delivery-evidence-anchor", - }, - }, -}; - -export const failedSecurityPreviewJob = { - ...securityPreviewJob, - status: "failed", - phase: "verifying", - error: "Buyer DACS-5 bundle anchoring failed after payment evidence was recorded.", -}; - -const x402PaymentTx = `0x${"ab".repeat(32)}`; - -export const x402CompletedJob = { - ...completedJob, - events: completedJob.events.map((event) => event.phase === "settling" - ? { ...event, txRef: x402PaymentTx } - : event), - result: { - ...acceptedResult, - decision: { - ...acceptedResult.decision, - winner: { ...acceptedResult.decision.winner, price: 0.05 }, - candidates: acceptedResult.decision.candidates.map((candidate) => ({ - ...candidate, - askPrice: 0.05, - chosenRail: "pay-x402", - })), - }, - negotiation: { - ...acceptedResult.negotiation, - terms: { ...acceptedResult.negotiation.terms, price: { amount: 0.05, currency: "USDC" } }, - }, - settlement: { - amount: { amount: 0.05, currency: "USDC" }, - rail: "pay-x402", - payer: "0x1111111111111111111111111111111111111111", - payee: "0x2222222222222222222222222222222222222222", - txHash: x402PaymentTx, - railGovernance: x402Governance, - }, - transactions: acceptedResult.transactions.map((transaction) => transaction.kind === "payment" - ? { ...transaction, name: "x402 USDC payment", txRef: x402PaymentTx } - : transaction), - }, -}; - -export type MockGatewayOptions = { - onProcurementPost?: (route: Route) => Promise | void; - onProcurementGet?: (route: Route) => Promise | void; -}; - -function json(route: Route, body: unknown, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - body: JSON.stringify(body), - headers: { "access-control-allow-origin": "*" }, - }); -} - -export async function installMockGateway(context: BrowserContext, options: MockGatewayOptions = {}) { - await context.route("**/api/dacs/listings?**", (route) => json(route, { listings: [] })); - // Register the wildcard first: Playwright evaluates matching routes in - // reverse registration order, so the explicit /options contract below wins. - await context.route("**/demo/procurement/*", async (route) => { - if (options.onProcurementGet) return options.onProcurementGet(route); - return json(route, completedJob); - }); - await context.route("**/demo/procurement", async (route) => { - if (route.request().method() === "OPTIONS") return json(route, {}); - if (options.onProcurementPost) return options.onProcurementPost(route); - return json(route, completedJob); - }); - await context.route("**/demo/procurement/options", (route) => json(route, procurementOptions)); -} - -export async function chooseProcurementExample(page: Page, rail: "pay-dem" | "pay-x402" = "pay-dem") { - await page.goto("/try"); - const agent = page.getByRole("button", { name: /Security Auditor/ }).first(); - await expect(agent).toBeVisible(); - await agent.click(); - const railName = rail === "pay-x402" ? /USDC · x402/ : /DEM · Demos/; - const railButton = page.getByRole("group", { name: "Payment rail" }).getByRole("button", { name: railName }); - await expect(railButton).toBeEnabled(); - await railButton.click(); - await page.getByRole("button", { name: "Load example" }).click(); - await expect(page.getByRole("button", { name: /Run the full deal/ })).toBeEnabled(); -} - -export async function expectAcceptedEvidence(page: Page) { - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible(); - await expect(page.getByText("Settled & accepted", { exact: true })).toBeVisible(); - await expect(page.getByText("broadcast & recorded", { exact: true })).toBeVisible(); - await expect(page.getByText("dual-signed", { exact: true })).toBeVisible(); - await expect(page.getByText("accepted", { exact: true }).last()).toBeVisible(); - await expect(page.locator(".journey-step.complete")).toHaveCount(5); - await expect(page.getByRole("link", { name: /View on explorer/ })).toBeVisible(); -} diff --git a/reference-implementations/dacs-directory/e2e/try-dacs.live.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs.live.spec.ts deleted file mode 100644 index 371215c..0000000 --- a/reference-implementations/dacs-directory/e2e/try-dacs.live.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { expect, test, type BrowserContext, type Page, type Request } from "@playwright/test"; -import { - PROCUREMENT_LOCK_NAME, - PROCUREMENT_RUN_KEY, - chooseProcurementExample, - expectAcceptedEvidence, -} from "./try-dacs-fixtures.js"; - -const LIVE_ENABLED = process.env.RUN_LIVE_PAID_E2E === "1"; -const LIVE_MAX_DEM = Number(process.env.LIVE_E2E_MAX_DEM ?? "0"); -const LIVE_BUTLER = (process.env.LIVE_BUTLER_ORIGIN ?? "https://butler.agentcommerce.network").replace(/\/$/, ""); -const HARD_MAX_DEM = 5; - -function isProcurementPost(request: Request) { - return request.method() === "POST" && new URL(request.url()).pathname === "/demo/procurement"; -} - -test.describe("/try live paid procurement", () => { - test.describe.configure({ mode: "serial" }); - test.skip(!LIVE_ENABLED, "Set RUN_LIVE_PAID_E2E=1 and LIVE_E2E_MAX_DEM to explicitly authorize one live testnet purchase."); - - let context: BrowserContext; - let page: Page; - let idempotencyKey = ""; - let jobId = ""; - let submittedInput: Record = {}; - let submittedGoal = ""; - let paymentTx = ""; - - test.beforeAll(async ({ browser }) => { - if (!Number.isFinite(LIVE_MAX_DEM) || LIVE_MAX_DEM < 1 || LIVE_MAX_DEM > HARD_MAX_DEM) { - throw new Error(`LIVE_E2E_MAX_DEM must be between 1 and ${HARD_MAX_DEM}; the suite will not dispatch a paid request otherwise.`); - } - context = await browser.newContext(); - page = await context.newPage(); - }); - - test.afterAll(async () => { - await context?.close(); - }); - - test("1. completes one real paid purchase", async ({}, testInfo) => { - test.setTimeout(13 * 60_000); - await chooseProcurementExample(page, "pay-dem"); - await page.locator("#proc-budget").fill(String(LIVE_MAX_DEM)); - - const startResponse = page.waitForResponse((response) => isProcurementPost(response.request())); - await page.getByRole("button", { name: /Run the full deal/ }).click(); - const response = await startResponse; - const request = response.request(); - const startBody = await response.json() as { id?: string; error?: unknown }; - - idempotencyKey = (await request.headerValue("idempotency-key")) ?? ""; - submittedInput = JSON.parse(request.postData() ?? "{}") as Record; - submittedGoal = String(submittedInput.goal ?? "live E2E procurement"); - jobId = String(startBody.id ?? ""); - - expect(response.ok(), JSON.stringify(startBody)).toBe(true); - expect(idempotencyKey).toBeTruthy(); - expect(jobId).toBeTruthy(); - expect(submittedInput.paymentRail).toBe("pay-dem"); - expect(Number(submittedInput.budgetDem)).toBeLessThanOrEqual(LIVE_MAX_DEM); - - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible({ timeout: 12 * 60_000 }); - paymentTx = (await page.locator(".tx-link code").textContent())?.trim() ?? ""; - expect(paymentTx).toBeTruthy(); - - await testInfo.attach("live-purchase.json", { - contentType: "application/json", - body: Buffer.from(JSON.stringify({ jobId, idempotencyKey, paymentTx, budgetCapDem: LIVE_MAX_DEM }, null, 2)), - }); - }); - - test("2. verifies the real post-payment evidence and all five DACS stages", async ({}, testInfo) => { - await expectAcceptedEvidence(page); - const response = await context.request.get(`${LIVE_BUTLER}/demo/procurement/${encodeURIComponent(jobId)}`); - expect(response.ok()).toBe(true); - const job = await response.json() as Record; - const result = job.result as Record; - const settlement = result.settlement as Record; - const amount = Number(settlement.amountDem ?? settlement.amount); - const transactions = Array.isArray(result.transactions) ? result.transactions as Array> : []; - - expect(amount).toBeGreaterThan(0); - expect(amount).toBeLessThanOrEqual(LIVE_MAX_DEM); - expect(String(settlement.txHash)).toBe(paymentTx); - expect(transactions.some((transaction) => transaction.kind === "payment" && transaction.txRef === paymentTx)).toBe(true); - - await testInfo.attach("live-evidence.json", { - contentType: "application/json", - body: Buffer.from(JSON.stringify({ jobId, paymentTx, amountDem: amount, events: job.events, result }, null, 2)), - }); - }); - - test("3. reload recovery reads the existing job and sends no second POST", async () => { - const record = { runId: idempotencyKey, jobId, goal: submittedGoal, input: submittedInput, startedAt: new Date().toISOString() }; - await page.evaluate(({ key, value }) => localStorage.setItem(key, JSON.stringify(value)), { key: PROCUREMENT_RUN_KEY, value: record }); - await page.reload(); - await expect(page.locator(".resume-banner")).toContainText(jobId.slice(0, 8)); - - let posts = 0; - const countPosts = (request: Request) => { if (isProcurementPost(request)) posts += 1; }; - page.on("request", countPosts); - const statusResponse = page.waitForResponse((response) => - response.request().method() === "GET" && new URL(response.url()).pathname === `/demo/procurement/${jobId}`, - ); - await page.getByRole("button", { name: /Check & resume/ }).click(); - expect((await statusResponse).ok()).toBe(true); - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible(); - page.off("request", countPosts); - - expect(posts).toBe(0); - await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - }); - - test("4. a real second tab is refused before another paid POST", async () => { - const protectedRecord = { - runId: idempotencyKey, - jobId, - goal: submittedGoal, - input: submittedInput, - startedAt: new Date().toISOString(), - }; - const serialized = JSON.stringify(protectedRecord); - await page.evaluate(({ key, value }) => localStorage.setItem(key, value), { key: PROCUREMENT_RUN_KEY, value: serialized }); - const secondTab = await context.newPage(); - await secondTab.goto("/try"); - await expect(secondTab.locator(".resume-banner")).toContainText("still on record"); - - let posts = 0; - const countPosts = (request: Request) => { if (isProcurementPost(request)) posts += 1; }; - secondTab.on("request", countPosts); - await secondTab.getByRole("button", { name: /Security Auditor/ }).first().click(); - await secondTab.getByRole("button", { name: "Load example" }).click(); - await secondTab.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(secondTab.locator(".bubble.error")).toContainText("earlier procurement run from this browser is still on record"); - expect(posts).toBe(0); - expect(await secondTab.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBe(serialized); - secondTab.off("request", countPosts); - await secondTab.close(); - await page.evaluate((key) => localStorage.removeItem(key), PROCUREMENT_RUN_KEY); - }); - - test("5. cancelling a queued cross-tab lock sends no paid POST", async () => { - await page.evaluate((key) => localStorage.removeItem(key), PROCUREMENT_RUN_KEY); - const actor = await context.newPage(); - await page.goto("/try"); - await chooseProcurementExample(actor); - - await page.evaluate((lockName) => { - const scope = window as typeof window & { - __liveE2eLockHeld?: boolean; - __liveE2eReleaseLock?: () => void; - }; - if (!navigator.locks) throw new Error("Web Locks unavailable in the live E2E browser"); - scope.__liveE2eLockHeld = false; - void navigator.locks.request(lockName, async () => { - scope.__liveE2eLockHeld = true; - await new Promise((resolve) => { scope.__liveE2eReleaseLock = resolve; }); - }); - }, PROCUREMENT_LOCK_NAME); - await expect.poll(() => page.evaluate(() => Boolean((window as typeof window & { __liveE2eLockHeld?: boolean }).__liveE2eLockHeld))).toBe(true); - - let posts = 0; - const countPosts = (request: Request) => { if (isProcurementPost(request)) posts += 1; }; - actor.on("request", countPosts); - await actor.getByRole("button", { name: /Run the full deal/ }).click(); - await expect(actor.getByText(/FULL DACS FLOW · DEM · Demos/)).toBeVisible(); - await actor.getByRole("button", { name: /Stop watching/ }).click(); - await page.evaluate(() => (window as typeof window & { __liveE2eReleaseLock?: () => void }).__liveE2eReleaseLock?.()); - - await expect(actor.locator(".bubble.error")).toContainText("Run cancelled in this browser"); - expect(posts).toBe(0); - expect(await actor.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - actor.off("request", countPosts); - await actor.close(); - }); -}); diff --git a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts deleted file mode 100644 index 7deaf6d..0000000 --- a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { expect, test, type Route } from "@playwright/test"; -import { - PROCUREMENT_LOCK_NAME, - PROCUREMENT_RUN_KEY, - chooseProcurementExample, - completedJob, - expectAcceptedEvidence, - failedSecurityPreviewJob, - installMockGateway, - securityPreviewJob, - x402CompletedJob, -} from "./try-dacs-fixtures.js"; - -async function fulfillJson(route: Route, body: unknown) { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(body), - headers: { "access-control-allow-origin": "*" }, - }); -} - -test.describe("/try procurement browser safety", () => { - test("1. completes a paid-style procurement and clears its recovery record", async ({ context, page }) => { - await installMockGateway(context); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expectAcceptedEvidence(page); - await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - }); - - test("2. reload recovery reuses the original idempotency key", async ({ context, page }) => { - const keys: string[] = []; - let posts = 0; - await installMockGateway(context, { - onProcurementPost: async (route) => { - posts += 1; - keys.push((await route.request().headerValue("idempotency-key")) ?? ""); - if (posts === 1) await route.abort("failed"); - else await fulfillJson(route, completedJob); - }, - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - await expect(page.locator(".bubble.error")).toContainText("Retrying reuses the same idempotency key"); - const storedBeforeReload = await page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY); - expect(storedBeforeReload).not.toBeNull(); - - await page.reload(); - await expect(page.locator(".resume-banner")).toContainText("the job id was never received"); - await page.getByRole("button", { name: /Check & resume/ }).click(); - - await expectAcceptedEvidence(page); - expect(posts).toBe(2); - expect(keys[0]).toBeTruthy(); - expect(keys[1]).toBe(keys[0]); - }); - - test("3. a second tab cannot overwrite an active procurement record", async ({ context, page }) => { - let posts = 0; - let pendingRoute: Route | undefined; - let releasePost!: (action: "abort") => void; - const postGate = new Promise<"abort">((resolve) => { releasePost = resolve; }); - let firstPostSeen!: () => void; - const firstPost = new Promise((resolve) => { firstPostSeen = resolve; }); - - await installMockGateway(context, { - onProcurementPost: async (route) => { - posts += 1; - pendingRoute = route; - firstPostSeen(); - await postGate; - await route.abort("failed"); - }, - }); - - const secondTab = await context.newPage(); - await secondTab.goto("/try"); - await expect(secondTab.getByRole("button", { name: /Security Auditor/ }).first()).toBeVisible(); - await chooseProcurementExample(page); - await page.getByRole("button", { name: /Run the full deal/ }).click(); - await firstPost; - - await expect(secondTab.locator(".resume-banner")).toContainText("still on record"); - const recordBefore = await secondTab.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY); - expect(recordBefore).not.toBeNull(); - - await secondTab.getByRole("button", { name: /Security Auditor/ }).first().click(); - await secondTab.getByRole("button", { name: "Load example" }).click(); - await secondTab.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(secondTab.locator(".bubble.error")).toContainText("earlier procurement run from this browser is still on record"); - expect(posts).toBe(1); - expect(await secondTab.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBe(recordBefore); - - releasePost("abort"); - await expect.poll(() => pendingRoute === undefined || posts === 1).toBeTruthy(); - await secondTab.close(); - }); - - test("4. cancelling while queued for the Web Lock never dispatches later", async ({ context }) => { - let posts = 0; - await installMockGateway(context, { - onProcurementPost: async (route) => { - posts += 1; - await fulfillJson(route, completedJob); - }, - }); - - const lockHolder = await context.newPage(); - const actor = await context.newPage(); - await lockHolder.goto("/try"); - await chooseProcurementExample(actor); - - await lockHolder.evaluate((lockName) => { - const scope = window as typeof window & { - __e2eLockHeld?: boolean; - __e2eReleaseLock?: () => void; - }; - if (!navigator.locks) throw new Error("Web Locks unavailable in the E2E browser"); - scope.__e2eLockHeld = false; - void navigator.locks.request(lockName, async () => { - scope.__e2eLockHeld = true; - await new Promise((resolve) => { scope.__e2eReleaseLock = resolve; }); - }); - }, PROCUREMENT_LOCK_NAME); - await expect.poll(() => lockHolder.evaluate(() => Boolean((window as typeof window & { __e2eLockHeld?: boolean }).__e2eLockHeld))).toBe(true); - - await actor.getByRole("button", { name: /Run the full deal/ }).click(); - await expect(actor.getByText(/FULL DACS FLOW · DEM · Demos/)).toBeVisible(); - await actor.getByRole("button", { name: /Stop watching/ }).click(); - await lockHolder.evaluate(() => (window as typeof window & { __e2eReleaseLock?: () => void }).__e2eReleaseLock?.()); - - await expect(actor.locator(".bubble.error")).toContainText("Run cancelled in this browser"); - expect(posts).toBe(0); - expect(await actor.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - }); - - test("5. renders all five stages and the post-payment evidence", async ({ context, page }) => { - const runningJob = { - id: completedJob.id, - status: "running", - phase: "discovering", - events: completedJob.events.slice(0, 1), - }; - await installMockGateway(context, { - onProcurementPost: (route) => fulfillJson(route, runningJob), - onProcurementGet: (route) => fulfillJson(route, completedJob), - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expectAcceptedEvidence(page); - await expect(page.locator(".tx-link code")).toHaveText("mock-payment-transaction"); - await expect(page.locator(".chain-activity .chain-row")).toHaveCount(completedJob.events.length); - await expect(page.getByText("Full evidence bundle accepted & reconciled", { exact: true })).toBeVisible(); - }); - - test("6. switches to the x402 schema and submits the USDC rail explicitly", async ({ context, page }) => { - let submitted: Record | undefined; - await installMockGateway(context, { - onProcurementPost: async (route) => { - submitted = JSON.parse(route.request().postData() ?? "{}") as Record; - await fulfillJson(route, x402CompletedJob); - }, - }); - await chooseProcurementExample(page, "pay-x402"); - - await expect(page.getByText("Operator-provisional rail authority", { exact: true })).toBeVisible(); - await expect(page.getByLabel("USDC budget")).toHaveValue("0.1"); - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Base Sepolia USDC settlement" })).toBeVisible(); - await expect(page.getByText("settled & seller-verified", { exact: true })).toBeVisible(); - await expect(page.getByText("Settled & accepted", { exact: true })).toBeVisible(); - expect(submitted?.profileId).toBe("security-audit-rfq"); - expect(submitted?.paymentRail).toBe("pay-x402"); - expect(submitted?.budgetUsdc).toBe(0.1); - expect(submitted?.budgetDem).toBeUndefined(); - }); - - test("7. required Oracle parameters block dispatch and identify the invalid field", async ({ context, page }) => { - await installMockGateway(context); - await page.goto("/try"); - await page.getByRole("button", { name: /Oracle Desk/ }).first().click(); - - const run = page.getByRole("button", { name: /Run the full deal/ }); - const coin = page.getByLabel("Coin"); - await expect(run).toBeEnabled(); - await coin.fill(""); - await expect(run).toBeDisabled(); - await expect(coin).toHaveAttribute("aria-invalid", "true"); - await expect(page.getByText("Required — enter a CoinGecko coin id.")).toBeVisible(); - - await page.getByLabel("Data product").selectOption("fx-rate"); - const base = page.getByLabel("From currency"); - const quote = page.getByLabel("To currency"); - await expect(run).toBeEnabled(); - - await base.fill(""); - await expect(run).toBeDisabled(); - await expect(base).toHaveAttribute("aria-invalid", "true"); - await expect(page.getByText("Required — enter the currency to convert from.")).toBeVisible(); - - await base.fill("USD"); - await quote.fill(""); - await expect(run).toBeDisabled(); - await expect(quote).toHaveAttribute("aria-invalid", "true"); - await expect(page.getByText("Required — enter the currency to convert to.")).toBeVisible(); - }); - - test("8. shows verified delivery while DACS-5 remains nonterminal, then accepts only the completed job", async ({ context, page }) => { - let releaseFinalPoll!: () => void; - const finalPollGate = new Promise((resolve) => { releaseFinalPoll = resolve; }); - let previewReturned!: () => void; - const previewSeen = new Promise((resolve) => { previewReturned = resolve; }); - - await installMockGateway(context, { - onProcurementPost: async (route) => { - await fulfillJson(route, securityPreviewJob); - previewReturned(); - }, - onProcurementGet: async (route) => { - await finalPollGate; - await fulfillJson(route, completedJob); - }, - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - await previewSeen; - - await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toBeVisible(); - await expect(page.getByText("DACS-5 finalising", { exact: true })).toBeVisible(); - await expect(page.getByText("Not settled-and-accepted yet", { exact: true })).toBeVisible(); - await expect(page.getByText("Unsafe dynamic execution", { exact: true })).toBeVisible(); - await expect(page.getByText(/DELIVERY VERIFIED · DACS-5 FINALISING/)).toBeVisible(); - await expect(page.locator(".journey-step.active").filter({ hasText: "Verify" })).toContainText("two DACS-5 copies are finalising"); - await expect(page.locator(".journey-step.active")).toHaveCount(1); - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toHaveCount(0); - await expect(page.getByText("Settled & accepted", { exact: true })).toHaveCount(0); - - releaseFinalPoll(); - await expectAcceptedEvidence(page); - await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toHaveCount(0); - }); - - test("9. preserves a verified delivery when DACS-5 finalisation fails without claiming acceptance", async ({ context, page }) => { - await installMockGateway(context, { - onProcurementPost: (route) => fulfillJson(route, securityPreviewJob), - onProcurementGet: (route) => fulfillJson(route, failedSecurityPreviewJob), - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toBeVisible(); - await expect(page.getByText("DACS-5 needs recovery", { exact: true })).toBeVisible(); - await expect(page.getByText("Finalisation stopped safely", { exact: true })).toBeVisible(); - await expect(page.getByText(failedSecurityPreviewJob.error, { exact: true }).first()).toBeVisible(); - await expect(page.getByText("Unsafe dynamic execution", { exact: true })).toBeVisible(); - await expect(page.getByText("Not settled-and-accepted yet", { exact: true })).toBeVisible(); - await expect(page.getByText("Settled & accepted", { exact: true })).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toHaveCount(0); - await expect(page.getByText(/Retrying reuses the same idempotency key/)).toHaveCount(0); - }); -}); diff --git a/reference-implementations/dacs-directory/package-lock.json b/reference-implementations/dacs-directory/package-lock.json index 29968c0..7e7eac1 100644 --- a/reference-implementations/dacs-directory/package-lock.json +++ b/reference-implementations/dacs-directory/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/source-code-pro": "5.3.1", "@noble/ed25519": "^2.3.0", "@noble/hashes": "^1.8.0", "better-sqlite3": "^11.10.0", @@ -23,10 +25,11 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "@types/react": "^19.0.0", + "@types/react-dom": "^19.3.0", "typescript": "^5.6.3" }, "engines": { - "node": ">=22 <23" + "node": "22.23.3" } }, "node_modules/@emnapi/runtime": { @@ -428,6 +431,24 @@ "node": ">=18" } }, + "node_modules/@fontsource-variable/plus-jakarta-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz", + "integrity": "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/source-code-pro": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@fontsource-variable/source-code-pro/-/source-code-pro-5.3.1.tgz", + "integrity": "sha512-N0nZDizh8CQ19+AHHA8Ssb1i/aQf4N2lpRl3qZVH4/9f1yNgWzTkBFpc26v+QQr2hm4By4aiv597BCoEg2SDQg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1055,14 +1076,25 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dev": true, + "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index 7b3d101..3d0d968 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -10,22 +10,24 @@ "build": "next build", "start": "next start -p ${PORT:-3400}", "start:railway": "bash scripts/start-railway.sh", - "check:deploy-config": "node scripts/check-butler-origin.mjs", - "check:butler": "node scripts/check-butler-origin.mjs --probe", - "check:listing-upgrade": "tsx scripts/check-live-listing-upgrade.ts", + "check:deploy-config": "node scripts/check-directory-origin.mjs", + "check:node-runtime": "node scripts/check-node-runtime.mjs", + "verify:origin-sources": "node scripts/verify-origin-sources.mjs", "test": "tsx --test test/*.test.ts test/*.test.mjs", - "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts", - "test:e2e:live": "playwright test e2e/try-dacs.live.spec.ts", + "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts", "test:e2e:ui": "playwright test --ui", "test:seed": "tsx --test test/seed-smoke.test.ts", "index": "tsx src/catalog/reindex.ts", "seed:counterparty-fixture": "tsx scripts/seed-counterparty-fixture.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:listing-upgrade": "tsx scripts/check-live-listing-upgrade.ts" }, "engines": { - "node": ">=22 <23" + "node": "22.23.3" }, "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/source-code-pro": "5.3.1", "@noble/ed25519": "^2.3.0", "@noble/hashes": "^1.8.0", "better-sqlite3": "^11.10.0", @@ -40,6 +42,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "@types/react": "^19.0.0", + "@types/react-dom": "^19.3.0", "typescript": "^5.6.3" }, "overrides": { diff --git a/reference-implementations/dacs-directory/playwright.config.ts b/reference-implementations/dacs-directory/playwright.config.ts index e25f23e..cd31cb0 100644 --- a/reference-implementations/dacs-directory/playwright.config.ts +++ b/reference-implementations/dacs-directory/playwright.config.ts @@ -1,16 +1,12 @@ import { defineConfig, devices } from "@playwright/test"; -const butlerOrigin = process.env.LIVE_BUTLER_ORIGIN ?? "https://butler.agentcommerce.network"; -const livePaidRun = process.env.RUN_LIVE_PAID_E2E === "1"; - export default defineConfig({ testDir: "./e2e", - outputDir: livePaidRun ? "test-results/playwright-live" : "test-results/playwright", + outputDir: "test-results/playwright", fullyParallel: false, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, - workers: livePaidRun ? 1 : undefined, - reporter: [["list"], ["html", { open: "never", outputFolder: livePaidRun ? "playwright-report-live" : "playwright-report" }]], + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], use: { baseURL: "http://localhost:3400", trace: "retain-on-failure", @@ -25,12 +21,11 @@ export default defineConfig({ ], webServer: { command: "npm run dev", - url: "http://localhost:3400/try", + url: "http://localhost:3400/", reuseExistingServer: !process.env.CI, timeout: 120_000, env: { NEXT_PUBLIC_DIRECTORY_URL: "http://localhost:3400", - NEXT_PUBLIC_BUTLER_ORIGIN: butlerOrigin, }, }, }); diff --git a/reference-implementations/dacs-directory/railway.json b/reference-implementations/dacs-directory/railway.json index b393d15..3489c3d 100644 --- a/reference-implementations/dacs-directory/railway.json +++ b/reference-implementations/dacs-directory/railway.json @@ -2,7 +2,7 @@ "$schema": "https://railway.com/railway.schema.json", "build": { "builder": "RAILPACK", - "buildCommand": "npm run check:deploy-config && DACS_SKIP_APP_INSTALL=1 bash scripts/setup-sdk.sh && npm run build" + "buildCommand": "npm run check:node-runtime && npm run check:deploy-config && DACS_SKIP_APP_INSTALL=1 bash scripts/setup-sdk.sh && npm run build" }, "deploy": { "startCommand": "npm run start:railway", diff --git a/reference-implementations/dacs-directory/scripts/check-butler-origin.mjs b/reference-implementations/dacs-directory/scripts/check-butler-origin.mjs deleted file mode 100644 index b2223c2..0000000 --- a/reference-implementations/dacs-directory/scripts/check-butler-origin.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -export function httpsOrigin(env, name) { - const raw = env[name]?.trim(); - if (!raw) throw new Error(`${name} is required for a production deployment`); - let url; - try { url = new URL(raw); } - catch { throw new Error(`${name} must be an absolute URL`); } - if (url.protocol !== "https:") throw new Error(`${name} must use HTTPS`); - if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) { - throw new Error(`${name} must be an origin without credentials, path, query, or fragment`); - } - return url.origin; -} - -function headerValues(response, name) { - return (response.headers.get(name) ?? "").toLowerCase().split(",").map((value) => value.trim()).filter(Boolean); -} - -export function assertCorsOrigin(response, directoryOrigin, requestLabel) { - if (response.headers.get("access-control-allow-origin") !== directoryOrigin) { - throw new Error(`Butler gateway does not CORS-allow ${directoryOrigin} for ${requestLabel}`); - } -} - -export function assertJsonPostPreflight(response, directoryOrigin) { - if (!response.ok) throw new Error(`Butler JSON POST preflight returned HTTP ${response.status}`); - assertCorsOrigin(response, directoryOrigin, "JSON POST preflight"); - if (!headerValues(response, "access-control-allow-methods").includes("post")) { - throw new Error("Butler JSON POST preflight does not allow POST"); - } - if (!headerValues(response, "access-control-allow-headers").includes("content-type")) { - throw new Error("Butler JSON POST preflight does not allow content-type"); - } -} - -export async function checkButlerOrigin({ env = process.env, fetcher = fetch, probe = false } = {}) { - const directoryOrigin = httpsOrigin(env, "NEXT_PUBLIC_DIRECTORY_URL"); - const butlerOrigin = httpsOrigin(env, "NEXT_PUBLIC_BUTLER_ORIGIN"); - - if (!probe) return `Production origins valid: directory=${directoryOrigin} butler=${butlerOrigin}`; - - const response = await fetcher(`${butlerOrigin}/demo/butler/agents`, { - headers: { origin: directoryOrigin }, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) throw new Error(`Butler catalog probe returned HTTP ${response.status}`); - assertCorsOrigin(response, directoryOrigin, "catalog GET"); - const body = await response.json(); - if (!Array.isArray(body?.agents) || body.agents.length === 0) { - throw new Error("Butler catalog probe returned no agents"); - } - - for (const path of ["/demo/procurement", "/demo/butler"]) { - const preflight = await fetcher(`${butlerOrigin}${path}`, { - method: "OPTIONS", - headers: { - origin: directoryOrigin, - "access-control-request-method": "POST", - "access-control-request-headers": "content-type", - }, - signal: AbortSignal.timeout(10_000), - }); - try { assertJsonPostPreflight(preflight, directoryOrigin); } - catch (cause) { throw new Error(`${path}: ${cause instanceof Error ? cause.message : String(cause)}`); } - } - return `Butler gateway ready: ${body.agents.length} agents and JSON POST CORS available to ${directoryOrigin}`; -} - -async function main() { - try { - console.log(await checkButlerOrigin({ probe: process.argv.includes("--probe") })); - } catch (error) { - console.error(`[deployment config] ${error.message}`); - process.exitCode = 1; - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await main(); diff --git a/reference-implementations/dacs-directory/scripts/check-directory-origin.mjs b/reference-implementations/dacs-directory/scripts/check-directory-origin.mjs new file mode 100644 index 0000000..eb27c80 --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/check-directory-origin.mjs @@ -0,0 +1,174 @@ +import { realpathSync } from "node:fs"; +import { BlockList, isIP } from "node:net"; +import { fileURLToPath } from "node:url"; + +// The tables below mirror upstream registries; test/fixtures/origin-policy-sources.json +// records the pinned copies they were checked against, and the deliberate deviations. + +// IPv4 space this deployment can't be served from: every block in the IANA IPv4 +// Special-Purpose Address Registry, plus multicast and the deprecated 6to4 relay +// anycast block. The registry's globally reachable rows (PCP and TURN anycast +// inside 192.0.0.0/24, AS112, AMT) are protocol addresses, not hosting space, so +// this fails closed and rejects them with the rest. +export const NON_PUBLIC_IPV4_SUBNETS = [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.31.196.0", 24], + ["192.52.193.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["192.175.48.0", 24], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +]; +// IPv6 literals must be global unicast (2000::/3). That rejects loopback, +// unspecified, IPv4-mapped, IPv4-compatible, NAT64, discard-only, ULA, link-local, +// site-local, and multicast. Inside 2000::/3 the documentation, IETF-protocol +// (including Teredo), 6to4 and Direct Delegation AS112 blocks are rejected too. +// Like the IPv4 table this fails closed: the globally reachable exceptions inside +// 2001::/23 are rejected with it. +export const GLOBAL_UNICAST_IPV6_SUBNETS = [["2000::", 3]]; +export const NON_PUBLIC_IPV6_SUBNETS = [ + ["2001::", 23], + ["2001:db8::", 32], + ["2002::", 16], + ["2620:4f:8000::", 48], + ["3fff::", 20], +]; + +// IANA Special-Use Domain Names that can't name a public origin (each with its +// subdomains; `arpa` covers home.arpa, resolver.arpa, service.arpa, ipv4only.arpa, +// 6tisch.arpa, eap.arpa, eap-noob.arpa and the reverse-mapping zones), plus the +// private-use `internal` TLD and the conventional `localdomain`. +export const NON_PUBLIC_SUFFIXES = [ + "localhost", + "local", + "test", + "invalid", + "onion", + "alt", + "arpa", + "internal", + "localdomain", +]; +// RFC 6761 §6.5 documentation names. Rejected in production; CI's placeholder +// origin uses one, so its check passes --allow-documentation-names. +export const DOCUMENTATION_SUFFIXES = ["example", "example.com", "example.net", "example.org"]; + +// Fetch Standard "bad ports": browsers refuse to fetch HTTP(S) on these. +export const BAD_PORTS = new Set([ + 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, + 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, + 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, + 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, + 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080, +]); + +function blockList(family, subnets) { + const list = new BlockList(); + for (const [network, prefix] of subnets) list.addSubnet(network, prefix, family); + return list; +} + +const NON_PUBLIC_IPV4 = blockList("ipv4", NON_PUBLIC_IPV4_SUBNETS); +const GLOBAL_UNICAST_IPV6 = blockList("ipv6", GLOBAL_UNICAST_IPV6_SUBNETS); +const NON_PUBLIC_IPV6 = blockList("ipv6", NON_PUBLIC_IPV6_SUBNETS); + +function isPublicAddress(address, family) { + if (family === 4) return !NON_PUBLIC_IPV4.check(address, "ipv4"); + return GLOBAL_UNICAST_IPV6.check(address, "ipv6") && !NON_PUBLIC_IPV6.check(address, "ipv6"); +} + +function hasSuffix(name, suffixes) { + return suffixes.some((suffix) => name === suffix || name.endsWith(`.${suffix}`)); +} + +function isPublicHost(hostname, { allowDocumentationNames }) { + const host = hostname.replace(/^\[(.*)\]$/, "$1"); + const family = isIP(host); + if (family) return isPublicAddress(host, family); + // One trailing dot is a fully qualified name; any other empty label is malformed. + // A single-label name only resolves through local search domains. + const labels = host.replace(/\.$/, "").split("."); + if (labels.length < 2 || labels.includes("")) return false; + const name = labels.join("."); + if (hasSuffix(name, NON_PUBLIC_SUFFIXES)) return false; + return allowDocumentationNames || !hasSuffix(name, DOCUMENTATION_SUFFIXES); +} + +export function httpsOrigin(env, name, { allowDocumentationNames = false } = {}) { + const raw = env[name]?.trim(); + if (!raw) throw new Error(`${name} is required for a production deployment`); + + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`${name} must be an absolute URL`); + } + + if (url.protocol !== "https:") throw new Error(`${name} must use HTTPS`); + if ( + url.username + || url.password + || url.search + || url.hash + || (url.pathname !== "/" && url.pathname !== "") + ) { + throw new Error(`${name} must be an origin without credentials, path, query, or fragment`); + } + if (url.port !== "" && BAD_PORTS.has(Number(url.port))) { + throw new Error(`${name} must not use port ${url.port}, which browsers refuse to fetch`); + } + if (!isPublicHost(url.hostname, { allowDocumentationNames })) { + throw new Error( + `${name} must be a public host (a multi-label DNS name that is not special-use, or a ` + + `global unicast IP address), not a local, private, or special-use one: ${url.hostname}`, + ); + } + // directory.example. is a different origin from the directory.example it is served on. + if (url.hostname.endsWith(".")) throw new Error(`${name} must not end its host name with a dot`); + // The app uses the raw value (minus one trailing slash), not this parsed origin. + if (env[name] !== url.origin && env[name] !== `${url.origin}/`) { + throw new Error(`${name} must be written exactly as its origin: ${url.origin}`); + } + return url.origin; +} + +export function checkDirectoryOrigin({ env = process.env, allowDocumentationNames = false } = {}) { + const directoryOrigin = httpsOrigin(env, "NEXT_PUBLIC_DIRECTORY_URL", { allowDocumentationNames }); + return allowDocumentationNames + ? `Directory origin valid (documentation names allowed; not a production check): ${directoryOrigin}` + : `Production directory origin valid: ${directoryOrigin}`; +} + +async function main() { + try { + const allowDocumentationNames = process.argv.slice(2).includes("--allow-documentation-names"); + console.log(checkDirectoryOrigin({ allowDocumentationNames })); + } catch (error) { + console.error(`[deployment config] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} + +// Compare real paths: import.meta.url is resolved, argv[1] may run through a symlink. +function invokedDirectly() { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (invokedDirectly()) await main(); diff --git a/reference-implementations/dacs-directory/scripts/check-node-runtime.mjs b/reference-implementations/dacs-directory/scripts/check-node-runtime.mjs new file mode 100644 index 0000000..5d621ee --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/check-node-runtime.mjs @@ -0,0 +1,33 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Railway's Railpack builder chooses the Node release from package.json engines +// and mise's version files, and Railway doesn't pin which Railpack runs. Rather +// than trust that order, the production build and server check the outcome: they +// fail unless they run the exact .nvmrc release. +export function checkNodeRuntime({ version = process.versions.node, nvmrc = new URL("../.nvmrc", import.meta.url) } = {}) { + const release = readFileSync(nvmrc, "utf8").trim(); + if (version !== release) { + throw new Error(`Node ${version} is running, but .nvmrc pins ${release}; set the builder to that release`); + } + return `Node runtime matches .nvmrc: ${release}`; +} + +// Compare real paths: import.meta.url is resolved, argv[1] may run through a symlink. +function invokedDirectly() { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (invokedDirectly()) { + try { + console.log(checkNodeRuntime()); + } catch (error) { + console.error(`[deployment config] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/reference-implementations/dacs-directory/scripts/seed-counterparty-fixture.ts b/reference-implementations/dacs-directory/scripts/seed-counterparty-fixture.ts index 6743fbd..f25d6d1 100644 --- a/reference-implementations/dacs-directory/scripts/seed-counterparty-fixture.ts +++ b/reference-implementations/dacs-directory/scripts/seed-counterparty-fixture.ts @@ -1,12 +1,12 @@ import { upsertCounterpartyEvidenceSeller } from "../src/catalog/counterpartyEvidence.js"; -import { loadCatalog, loadFixtureSeeds, saveCatalog, saveFixtureSeeds, withDataLock } from "../src/catalog/store.js"; +import { loadFixtureSeeds, loadStoredCatalog, saveCatalog, saveFixtureSeeds, withDataLock } from "../src/catalog/store.js"; const now = Date.now(); await withDataLock("reindex", () => { const seeds = loadFixtureSeeds(); saveFixtureSeeds([...seeds, "counterparty-evidence"]); - const catalog = loadCatalog(); + const catalog = loadStoredCatalog(); saveCatalog({ catalogVersion: "1", generatedAt: now, diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index 4d5c4be..a868cf3 100755 --- a/reference-implementations/dacs-directory/scripts/setup-sdk.sh +++ b/reference-implementations/dacs-directory/scripts/setup-sdk.sh @@ -4,11 +4,11 @@ set -euo pipefail cd "$(dirname "$0")/.." SDK_REV="3904d704fb540e6e1d7dd502a0be29f20569c92d" -# Railway's GitHub integration can check out this repository, but it does not -# pass its credentials through to nested private-repository clones. Supply a -# narrowly scoped, read-only token as DACS_SDK_GITHUB_TOKEN for those builds. -# The credential is passed through Git's environment-based configuration so it -# is never written to the checkout or embedded in the remote URL. +# The SDK repository is public, so the clone needs no credentials. When a +# narrowly scoped, read-only token is set as DACS_SDK_GITHUB_TOKEN, it only +# authenticates that clone. The credential is passed through Git's +# environment-based configuration so it is never written to the checkout or +# embedded in the remote URL. git_with_sdk_auth() { if [ -z "${DACS_SDK_GITHUB_TOKEN:-}" ]; then command git "$@" diff --git a/reference-implementations/dacs-directory/scripts/start-railway.sh b/reference-implementations/dacs-directory/scripts/start-railway.sh index 7c1aaee..dfaabcf 100644 --- a/reference-implementations/dacs-directory/scripts/start-railway.sh +++ b/reference-implementations/dacs-directory/scripts/start-railway.sh @@ -3,6 +3,9 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Refuse to serve on a Node release other than .nvmrc's. +node scripts/check-node-runtime.mjs + DATA_DIR="${DACS_DIRECTORY_DATA:-${RAILWAY_VOLUME_MOUNT_PATH:-$(pwd)/data}}" export DACS_DIRECTORY_DATA="$DATA_DIR" mkdir -p "$DATA_DIR" diff --git a/reference-implementations/dacs-directory/scripts/verify-origin-sources.mjs b/reference-implementations/dacs-directory/scripts/verify-origin-sources.mjs new file mode 100644 index 0000000..3db3d42 --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/verify-origin-sources.mjs @@ -0,0 +1,151 @@ +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Opt-in and needs network: `npm run verify:origin-sources`. Fetches every pinned +// copy that test/fixtures/origin-policy-sources.json cites, checks its sha256, +// re-extracts the cited rows, and compares them with the fixture. The unit tests +// check the module against the fixture offline; this checks the fixture against +// upstream. Node's fetch ignores HTTPS_PROXY: behind a proxy, run +// `node --use-env-proxy scripts/verify-origin-sources.mjs`. + +// One registry row per array entry of Wireshark's generated epan/iana-info.c: +// family, network/mask, name, then the source, destination, forwardable, +// globally reachable and reserved-by-protocol flags. The generator leaves out +// rows with a termination date and writes an N/A Globally Reachable as false. +export function wiresharkRows(text) { + const rows = []; + const v4 = /\{ 4, \{ \.ipv4 = \{ 0x([0-9a-f]{8}), 0x([0-9a-f]{8}) \} \}, "([^"]+)", (\w+), (\w+), (\w+), (\w+), (\w+) \}/g; + for (const [, address, mask, name, , , , global] of text.matchAll(v4)) { + const value = Number.parseInt(address, 16); + const prefix = Number.parseInt(mask, 16).toString(2).replace(/0+$/, "").length; + const network = [24, 16, 8, 0].map((shift) => (value >>> shift) & 255).join("."); + rows.push([network, prefix, global === "true", name]); + } + const v6 = /\{ 6, \{ \.ipv6 = \{ \{ \{ ((?:0x[0-9a-f]{2}, ){15}0x[0-9a-f]{2}) \} \}, (\d+) \} \},\s+"([^"]+)", (\w+), (\w+), (\w+), (\w+), (\w+) \}/g; + for (const [, bytes, prefix, name, , , , global] of text.matchAll(v6)) { + const octets = bytes.split(", ").map((byte) => Number.parseInt(byte, 16)); + const groups = []; + for (let i = 0; i < 16; i += 2) groups.push(((octets[i] << 8) | octets[i + 1]).toString(16)); + rows.push([compressIpv6(groups), Number(prefix), global === "true", name]); + } + return rows; +} + +function compressIpv6(groups) { + let best = [-1, 0]; + for (let start = 0; start < 8; start += 1) { + let end = start; + while (end < 8 && groups[end] === "0") end += 1; + if (end - start > best[1]) best = [start, end - start]; + } + if (best[1] < 2) return groups.join(":"); + const head = groups.slice(0, best[0]).join(":"); + const tail = groups.slice(best[0] + best[1]).join(":"); + return `${head}::${tail}`; +} + +// CPython's ipaddress tables: IPv4Network('a/b') and IPv6Network('a/b') entries. +export function cpythonRows(text) { + return [...text.matchAll(/IPv[46]Network\('([^'/]+)\/(\d+)'\)/g)].map(([, network, prefix]) => [network, Number(prefix)]); +} + +// Quoted DNS names ("name.") in Unbound's and Knot Resolver's C tables. +export function dnsNames(text) { + return [...text.matchAll(/"([a-z0-9.-]+)\."/g)].map(([, name]) => name); +} + +// Every name in IANA's Special-Use Domain Names registry XML, without the root dot +// or a "(DEPRECATED)" note. +export function ianaNames(text) { + return [...text.matchAll(/([a-z0-9.-]+)\.(?: \([A-Z]+\))?<\/name>/g)].map(([, name]) => name); +} + +// The first column of the Fetch Standard's bad-port table. +export function fetchBadPorts(text) { + return [...text.matchAll(/^
    (\d+)/gm)].map(([, port]) => Number(port)); +} + +function slice(text, [first, last]) { + return text.split("\n").slice(first - 1, last).join("\n"); +} + +async function pinned(copy, fetchCopy) { + const response = await fetchCopy(copy.url); + if (!response.ok) throw new Error(`${copy.url}: HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== copy.sha256) throw new Error(`${copy.url}: sha256 ${digest}, fixture pins ${copy.sha256}`); + return bytes.toString("utf8"); +} + +const key = ([network, prefix]) => `${network}/${prefix}`; + +// `fetchCopy` defaults to the global fetch; the tests pass in-memory copies. +export async function verifyOriginSources(sources, { fetch: fetchCopy = fetch } = {}) { + const failures = []; + const expect = (ok, message) => { if (!ok) failures.push(message); }; + const copies = sources.pinnedCopies; + + const wireshark = await pinned(copies.wireshark, fetchCopy); + for (const family of ["ipv4", "ipv6"]) { + const upstream = wiresharkRows(slice(wireshark, copies.wireshark.lines[family])); + expect( + JSON.stringify(upstream) === JSON.stringify(sources[family].registryRows), + `${family}.registryRows differ from ${copies.wireshark.url}:\n${JSON.stringify(upstream)}`, + ); + } + + const cpython = await pinned(copies.cpython, fetchCopy); + for (const family of ["ipv4", "ipv6"]) { + const listed = new Set([...sources[family].notGloballyReachable, ...sources[family].globallyReachableButRejected].map(key)); + for (const row of cpythonRows(slice(cpython, copies.cpython.lines[family]))) { + expect(listed.has(key(row)), `${family}: CPython row ${key(row)} is not in the fixture`); + } + } + + // The registry's names are exactly the fixture's special-use and documentation rows. + const registry = new Set(ianaNames(slice(await pinned(copies.iana, fetchCopy), copies.iana.lines))); + const listed = new Set([...sources.names.specialUse, ...sources.names.documentation].map(([name]) => name)); + for (const name of registry) expect(listed.has(name), `names: the IANA registry lists ${name}, which the fixture lacks`); + for (const name of listed) expect(registry.has(name), `names: the fixture lists ${name}, which the IANA registry lacks`); + + const names = new Set(sources.names.specialUse.map(([name]) => name)); + for (const id of ["unbound", "knot"]) { + const text = await pinned(copies[id], fetchCopy); + for (const name of dnsNames(slice(text, copies[id].lines))) { + expect(names.has(name), `names: ${id} lists ${name}, which the fixture lacks`); + } + } + + const fetchSpec = await pinned(copies.fetch, fetchCopy); + const ports = fetchBadPorts(slice(fetchSpec, copies.fetch.lines)); + expect(JSON.stringify(ports) === JSON.stringify(sources.badPorts.ports), `badPorts differ from ${copies.fetch.url}`); + return failures; +} + +async function main() { + const fixture = new URL("../test/fixtures/origin-policy-sources.json", import.meta.url); + const failures = await verifyOriginSources(JSON.parse(readFileSync(fixture, "utf8"))); + for (const failure of failures) console.error(`[origin sources] ${failure}`); + if (failures.length) process.exitCode = 1; + else console.log("Every pinned origin-policy source matches the fixture."); +} + +function invokedDirectly() { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (invokedDirectly()) { + try { + await main(); + } catch (error) { + console.error(`[origin sources] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/reference-implementations/dacs-directory/src/catalog/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index 719c5ec..5f0badd 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -3,6 +3,13 @@ import { LISTING_REJECTION_CODES } from "./listingAdmission.js"; export const artifactProfiles = ["dacs-v0.1", "legacy-sdk-v0.1", "fixture-listing"] as const; +// Unix milliseconds and counts the pinned SDK catalog reader accepts. +const safeUint = { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER } as const; +const CANONICAL_STRING_PATTERN = "^[^\\s\\u0000-\\u001f\\u007f](?:[^\\u0000-\\u001f\\u007f]*[^\\s\\u0000-\\u001f\\u007f])?$"; + +/** The no-rate testnet restriction this Directory applies. See docs/TP-1.md. */ +export const TESTNET_RESTRICTIONS = ["TP-1"] as const; + export const listingSummarySchema = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: "/schemas/listing-summary.schema.json", @@ -81,7 +88,36 @@ export const listingSummarySchema = { }, description: "Catalog admission is not buyer/session admission. Run the current SDK Listing reader with buyer-local dependencies before transacting.", }, - reputationHint: { type: "object" }, + reputationHint: { + type: "object", + description: "TP-1 observed history; completion and fault rates and numeric reputation scores are unavailable. bundleCount is recomputed on read from the retained records the seller reputation counts, after the DACS-4 SB-2 settlement-collision exclusion; a missing or malformed cached hint uses the listing category and the catalog snapshot window. categoryScope equals offering.category or is a dot-separated prefix of it, and windowStart <= windowEnd (Unix milliseconds).", + additionalProperties: false, + required: [ + "categoryScope", "completionRate", "averageSellerRating", "bundleCount", "windowStart", "windowEnd", + "computedAt", "metricsUnavailableReason", "metricsProfile", "observedOutcomesLabel", + ], + properties: { + categoryScope: { + type: "string", + minLength: 1, + // No leading or trailing white space, C0 control or DEL; NFC cannot be expressed here. + pattern: CANONICAL_STRING_PATTERN, + description: "A canonical string: equal to its trim(), NFC-normalized, and free of C0 control characters and DEL.", + }, + completionRate: { type: "null" }, + counterpartyAdjustedCompletionRate: { type: "null" }, + counterpartyFaultRate: { type: "null" }, + averageBuyerRating: { type: "null" }, + averageSellerRating: { type: "null" }, + metricsUnavailableReason: { const: "Completion verification unavailable" }, + metricsProfile: { const: "TP-1" }, + observedOutcomesLabel: { type: "string" }, + bundleCount: safeUint, + windowStart: safeUint, + windowEnd: safeUint, + computedAt: safeUint, + }, + }, inspection: { type: "object", required: ["artifactType", "maturity", "href"], @@ -227,7 +263,9 @@ export const catalogStatusSchema = { export const directoryManifest = (origin: string, demosRpc = publicDemosRpcUrl()) => ({ name: "DACS Directory", description: "A discovery catalog for signed, chain-anchored DACS agent services.", - dacsDirectoryVersion: "1", + // 2: TP-1 refuses the DACS-1 §6.3.6 rate filters and publishes null rates. + dacsDirectoryVersion: "2", + restrictions: TESTNET_RESTRICTIONS, humanUrl: origin, agentCard: `${origin}/.well-known/agent.json`, api: `${origin}/api/dacs`, @@ -266,20 +304,21 @@ export const directoryManifest = (origin: string, demosRpc = publicDemosRpcUrl() maturity: { listings: "current publisher and dual-profile reader", identityTier: "self-declared unless a fresh DACS-2 verifiedBy result is resolved", - reputation: "DACS-5 two-copy reconciliation with ratings, volume, SR-2 windows, and deterministic receipts", + reputation: "Completion verification unavailable. Testnet publishes observed counts and individual evidence; completion and fault rates and numeric reputation scores are null.", }, verification: { policy: "strict party signatures plus referenced-artifact signature/hash checks", limitation: "RPC bytes pass through the directory server; client verification proves internal consistency, not independent chain inclusion.", }, - filters: ["category", "tag", "credential", "primaryClaim", "identityTier", "rail", "priceMax", "minCompletionRate", "minRating", "q", "profile", "limit", "cursor"], + filters: ["category", "tag", "credential", "primaryClaim", "identityTier", "rail", "priceMax", "q", "profile", "limit", "cursor"], }); export const openApiDocument = (origin: string) => ({ openapi: "3.1.0", info: { title: "DACS Directory API", - version: "1.0.0", + // 2.0.0: minCompletionRate and minRating were removed (now refused with 400 under TP-1). + version: "2.0.0", description: "Discover DACS services and retrieve the signed chain artifacts behind them.", }, servers: [{ url: origin }], @@ -288,6 +327,7 @@ export const openApiDocument = (origin: string) => ({ "/api/dacs/listings": { get: { summary: "Search active services", + description: "Completion verification unavailable. Rate and score filters (including minCompletionRate and minRating) and ranking parameters are refused with 400 on testnet.", parameters: [ { name: "category", in: "query", schema: { type: "string" } }, { name: "tag", in: "query", schema: { type: "array", items: { type: "string" } }, style: "form", explode: true }, @@ -296,14 +336,12 @@ export const openApiDocument = (origin: string) => ({ { name: "primaryClaim", in: "query", schema: { type: "string" } }, { name: "identityTier", in: "query", schema: { enum: ["institutional", "verified", "self-declared"] } }, { name: "priceMax", in: "query", schema: { type: "number", minimum: 0 } }, - { name: "minCompletionRate", in: "query", schema: { type: "number", minimum: 0, maximum: 1 } }, - { name: "minRating", in: "query", schema: { type: "number", minimum: 0, maximum: 5 } }, { name: "q", in: "query", description: "Directory full-text search extension", schema: { type: "string" } }, { name: "profile", in: "query", description: "Artifact compatibility profile", schema: { enum: artifactProfiles } }, { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 200 } }, { name: "cursor", in: "query", schema: { type: "string" } }, ], - responses: { "200": { description: "A cursor-paginated listing page" }, "400": { description: "Invalid pagination" } }, + responses: { "200": { description: "A cursor-paginated listing page" }, "400": { description: "Invalid query, or unavailable rate/score filtering or ranking" } }, }, }, "/api/dacs/listings/{listingId}/{version}": { diff --git a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts index 1b22956..3a36a67 100644 --- a/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts +++ b/reference-implementations/dacs-directory/src/catalog/evidenceGraph.ts @@ -10,7 +10,7 @@ import { export type ArtifactKind = "agreement" | "evidence" | "verify-result" | "composite" | "rating" | "listing" | "bundle" | "attestation"; export interface CurrentRef { anchor: { kind: string; locator: string }; contentHash: string; [key: string]: unknown } -export interface ResolvedEvidence { kind: ArtifactKind; ref?: CurrentRef; locator: string; raw: Record; contentHash: string } +export interface ResolvedEvidence { kind: ArtifactKind; ref?: CurrentRef; locator: string; raw: Record; contentHash: string; logicalAddress?: unknown } export interface EvidenceGraph { profile: "dacs-v0.1"; ok: boolean; @@ -26,6 +26,8 @@ export interface EvidenceGraph { } export interface EvidenceGraphDeps { read: (locator: string) => Promise | null>; + /** Metadata-of-record and bytes from the same native evidence anchor read. */ + readEvidenceAnchor?: (locator: string) => Promise<{ data: Record; logicalAddress?: unknown } | null>; resolveListing: (ref: Record) => Promise<{ locator: string; raw: Record } | null>; resolvePrimaryClaimKey?: ResolvePrimaryClaimKey; } @@ -202,7 +204,7 @@ function shapeOk(raw: Record, kind: ArtifactKind): boolean { ? terms.payoutBindings === undefined : Array.isArray(terms.payoutBindings) && terms.payoutBindings.every(payoutBindingShapeOk))); } - if (kind === "evidence") return raw.evidenceVersion === "1" && typeof raw.jobId === "string" && typeof raw.phase === "string" && (raw.outcome === "success" || raw.outcome === "failure") && typeof raw.observedAt === "number" && (raw.amendmentRefs === undefined || Array.isArray(raw.amendmentRefs)) && (raw.attestationRef === undefined || isCurrentRef(raw.attestationRef)); + if (kind === "evidence") return !Object.hasOwn(raw, "phaseIndex") && raw.evidenceVersion === "1" && typeof raw.jobId === "string" && typeof raw.phase === "string" && (raw.outcome === "success" || raw.outcome === "failure") && typeof raw.observedAt === "number" && (raw.amendmentRefs === undefined || Array.isArray(raw.amendmentRefs)) && (raw.attestationRef === undefined || isCurrentRef(raw.attestationRef)); if (kind === "verify-result") return raw.resultVersion === "1" && typeof raw.scheme === "string" && typeof raw.identifier === "string" && typeof raw.verifiedAt === "number" && ["pass", "fail", "indeterminate", "error"].includes(String(raw.decision)) && isCurrentRef(raw.attestation); if (kind === "composite") { const supplementary = arr(raw.supplementary); @@ -257,10 +259,12 @@ async function resolveRef(kind: ArtifactKind, ref: unknown, deps: EvidenceGraphD } if (!isCurrentRef(ref)) return null; const hash = normalizedHash(ref.contentHash); - const raw = await deps.read(ref.anchor.locator); + const anchored = kind === "evidence" && deps.readEvidenceAnchor + ? await deps.readEvidenceAnchor(ref.anchor.locator) : undefined; + const raw = anchored === undefined ? await deps.read(ref.anchor.locator) : anchored?.data; if (!raw || !withinArtifactLimit(raw) || !shapeOk(raw, kind) || artifactHash(raw, kind) !== hash || !(await signatureOk(raw, kind, deps.resolvePrimaryClaimKey ?? resolveDemosPrimaryClaimKey))) return null; - return { kind, ref, locator: ref.anchor.locator, raw, contentHash: hash }; + return { kind, ref, locator: ref.anchor.locator, raw, contentHash: hash, logicalAddress: anchored?.logicalAddress }; } export async function buildCurrentEvidenceGraph(bundleLocator: string, deps: EvidenceGraphDeps): Promise { diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 054ba7c..f2e191a 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -54,6 +54,8 @@ import { throwIfInfrastructureError, } from "./store.js"; import { deriveSellerReputation, flipOutcome, isNeutralCancellation } from "./reputation.js"; +import { countableDeals, inCategory, settlementClaims, SETTLEMENT_PROJECTION_VERSION } from "./settlementUniqueness.js"; +import { observedOutcomesLabel, UNAVAILABLE_TESTNET_METRICS } from "./testnetMetrics.js"; import { agreementPrice, buildCurrentEvidenceGraph, type EvidenceGraph } from "./evidenceGraph.js"; import { agreementRail } from "./agreementMetadata.js"; import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "./currentReconciliation.js"; @@ -336,7 +338,13 @@ export async function indexRegistration( const hash = String(ref.contentHash ?? "").replace(/^sha256:/, "").toLowerCase(); return listingArtifacts.get(`${id}\n${version}\n${hash}`) ?? null; }; - const graphFor = (locator: string) => buildCurrentEvidenceGraph(locator, { read: readAnchor, resolveListing }); + const graphFor = (locator: string) => buildCurrentEvidenceGraph(locator, { + read: readAnchor, resolveListing, + readEvidenceAnchor: async (address) => { + const anchored = await readAnchorRecord(address); + return anchored ? { data: anchored.data, logicalAddress: anchored.metadata?.logicalAddress } : null; + }, + }); const buyerIsCurrent = buyerInitial?.bundleVersion === "1" || buyerInitial?.faultBundleVersion === "1"; const sellerCurrentProbe = buyerIsCurrent || jobBindings.length > 0 || !deal.sellerBundleRef ? null @@ -431,13 +439,14 @@ export async function indexRegistration( ratedAt: Number(rating.ratedAt), contentHash: authoritative!.artifacts.find((artifact) => artifact.raw === rating)?.contentHash ?? "", })).filter((rating) => (rating.targetRole === "buyer" || rating.targetRole === "seller") && Number.isFinite(rating.ratedAt)); - const settlementTxIds = (authoritative?.artifacts ?? []).filter((artifact) => artifact.kind === "evidence") - .flatMap((artifact) => { - const refs = Array.isArray(artifact.raw.paymentTxRefs) ? artifact.raw.paymentTxRefs : []; - const phaseIndex = Number(artifact.raw.phaseIndex ?? 0); - return refs.map((ref) => typeof ref === "string" ? ref : String((ref as Record)?.txId ?? (ref as Record)?.id ?? "")) - .filter(Boolean).map((id) => ({ id, observedAt: Number(artifact.raw.observedAt), phaseIndex })); - }); + const { settlementTxIds, projected } = settlementClaims((authoritative?.artifacts ?? []) + .filter((artifact) => artifact.kind === "evidence"), { + jobId: authoritative?.bundle.jobId, + railId: agreementRail(authoritative?.agreement), + railRegistryVersion: authoritative?.bundle.railRegistryVersion, + phaseSummary: authoritative?.bundle.phaseSummary, + pipeline: authoritative?.listing?.pipeline, + }); const cancellation = [sellerOk ? sellerGraph?.bundle.cancellation : undefined, buyerOk ? buyerGraph.bundle.cancellation : undefined] .find((value) => value && typeof value === "object" && !Array.isArray(value)) as Record | undefined; const cancellationNeutral = isNeutralCancellation( @@ -449,12 +458,12 @@ export async function indexRegistration( outcome: String(authoritative?.bundle.outcome ?? "") || undefined, sellerOutcome, anchoredByRole: authoritative?.bundle.anchoredByRole as DealRecord["anchoredByRole"], bundleContentHash: authoritative?.bundleContentHash, - reputationEligible: refsVerified, + reputationEligible: refsVerified && projected, cancellationNeutral, finalisedAt: typeof authoritative?.bundle.finalisedAt === "number" ? authoritative.bundle.finalisedAt : undefined, anchorTimestamp: artifactAnchorTime(selectedLocator), agreementPrice: agreementPrice(authoritative?.agreement) ?? undefined, - ratings, settlementTxIds, + ratings, settlementTxIds, settlementProjectionVersion: SETTLEMENT_PROJECTION_VERSION, category: typeof (authoritative?.listing?.offering as Record | undefined)?.category === "string" ? (authoritative!.listing!.offering as Record).category as string : undefined, verifiedAt: now, @@ -527,6 +536,14 @@ export async function indexRegistration( ); const cancellationNeutral = isNeutralCancellation(sellerOutcome, cancellation, listingTerms, bundle?.phaseSummary); const selectedLocator = authoritative === sellerCopy ? deal.sellerBundleRef! : deal.buyerBundleRef; + const { settlementTxIds, projected } = settlementClaims(authoritative.resolvedArtifacts + .filter((artifact) => artifact.kind === "dacs-4-evidence"), { + jobId: bundle?.jobId, + railRegistryVersion: selectedRaw?.railRegistryVersion, + railId: agreementRail(authoritative.resolvedArtifacts.find((artifact) => artifact.kind === "dacs-3-agreement")?.raw), + phaseSummary: bundle?.phaseSummary, + pipeline: authoritative.resolvedArtifacts.find((artifact) => artifact.kind === "dacs-1-listing")?.raw.pipeline, + }); dealCandidates.push({ ...deal, signatureVerified: authoritative.signaturesOk, @@ -537,10 +554,12 @@ export async function indexRegistration( ? bundle.anchoredByRole : undefined, // Only a bundle the verifier already hashed is hashed again; a rejected raw bundle gets no hash. bundleContentHash: bundle && signedBundleScope ? contentHash(signedBundleScope) : undefined, - reputationEligible: strictRefsVerified && currentOutcomes.has(sellerOutcome ?? ""), + reputationEligible: strictRefsVerified && currentOutcomes.has(sellerOutcome ?? "") && projected, cancellationNeutral, finalisedAt: bundle?.finalisedAt, anchorTimestamp: artifactAnchorTime(selectedLocator), + settlementTxIds, + settlementProjectionVersion: SETTLEMENT_PROJECTION_VERSION, category: bundleCategory(bundle, categoriesByListing), verifiedAt: now, }); @@ -558,23 +577,19 @@ export async function indexRegistration( const deals = dedupeVerifiedDeals(dealCandidates); // ── Reputation: derived ONLY from verified bundles ──────────────────────── - const counted = deals.filter((d) => d.refsVerified && d.reputationEligible); - const reputation = deriveSellerReputation(deals, 0, now); - const completed = reputation.completed; const windowStart = 0; const windowEnd = now; + const reputation = deriveSellerReputation(deals, windowStart, windowEnd); + // Listing hints count the same SB-2-filtered records as the seller reputation. + const counted = countableDeals(deals, windowStart, windowEnd).deals; const listingsWithHint = listings.map((l) => ({ ...l, reputationHint: (() => { - const categoryDeals = counted.filter( - (d) => d.category === l.offering.category || d.category?.startsWith(l.offering.category + "."), - ); - const categoryCompleted = categoryDeals.filter((d) => d.sellerOutcome === "completed").length; - const categoryDenominator = categoryDeals.filter((d) => d.sellerOutcome !== "failed-substrate" && !d.cancellationNeutral).length; + const categoryDeals = counted.filter((d) => inCategory(d, l.offering.category)); return { categoryScope: l.offering.category, - completionRate: categoryDenominator ? categoryCompleted / categoryDenominator : null, - averageSellerRating: deriveSellerReputation(categoryDeals, windowStart, windowEnd).averageSellerRating ?? null, + ...UNAVAILABLE_TESTNET_METRICS, + observedOutcomesLabel: observedOutcomesLabel(categoryDeals.length), bundleCount: categoryDeals.length, windowStart, windowEnd, diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index 4375959..e713c30 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -10,7 +10,7 @@ import { type LegacyMvpListing, type Listing, } from "@kynesyslabs/dacs/artifacts"; -import { identityBundleHash } from "@kynesyslabs/dacs/identity"; +import { identityBundleHash } from "../sdkVerification.js"; import { canonicalSigningIdentity, resolvePrimaryClaimKey, diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index cc2e798..87d088f 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -1,3 +1,4 @@ +import { rebuildCatalogObservations } from "./testnetMetrics.js"; /** * Full reindex, callable: incremental chain scan to the tip, §6.3.5 domain * crawl, then re-verify every registration (submitted + discovered) against @@ -20,11 +21,11 @@ import { crawlDomains } from "./wellknown"; import { upsertCounterpartyEvidenceSeller } from "./counterpartyEvidence"; import { refreshReachabilityHints } from "./reachability"; import { - loadCatalog, loadDomains, loadFixtureSeeds, loadRegistrations, loadScanState, + loadStoredCatalog, admitRevocationCandidates, saveCatalog, saveScanState, @@ -93,7 +94,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); -const CURRENT_OUTCOMES = new Set([ - "completed", "failed-perm", "failed-counterparty", "failed-substrate", - "aborted-by-self", "aborted-by-other", -]); - export function flipOutcome(outcome: string | undefined): string | undefined { if (outcome === "aborted-by-self") return "aborted-by-other"; if (outcome === "aborted-by-other") return "aborted-by-self"; @@ -40,47 +37,18 @@ export function isNeutralCancellation( } /** - * DACS-5 scalar derivation over already signature/reference-verified, - * seller-perspective records. Reconciliation and divergence exclusion happen - * before this function; unresolved ratings/volume intentionally remain absent. + * Observed counts and evidence references over seller-perspective records. + * Reconciliation and divergence exclusion happen before this function. + * Testnet completion, fault rates and numeric reputation remain unavailable. */ export function deriveSellerReputation( deals: DealRecord[], windowStart = 0, windowEnd = Date.now(), + excludedSettlementIds?: ReadonlySet, ): SellerRecord["reputation"] { - const eligible = deals.filter((deal) => - deal.refsVerified && deal.reputationEligible && - CURRENT_OUTCOMES.has(deal.sellerOutcome ?? ""), - ); - // A determinism receipt declares one timestamp basis for its entire input - // set. Use SR-2 anchor time only when it is available for every eligible - // record; otherwise fall back to finalisedAt uniformly and exclude records - // that cannot be evaluated on that honest basis. - const useAnchorTimestamps = eligible.length > 0 && - eligible.every((deal) => typeof deal.anchorTimestamp === "number"); - const timestampOf = (deal: DealRecord) => useAnchorTimestamps - ? deal.anchorTimestamp - : deal.finalisedAt; - let scoped = eligible.filter((deal) => { - const timestamp = timestampOf(deal); - return typeof timestamp === "number" && timestamp >= windowStart && timestamp <= windowEnd; - }); - // SB-2: a settlement transaction reused across jobs contributes once, at - // its earliest evidence observation. Deterministic jobId breaks exact ties. - const settlementOwner = new Map(); - for (const deal of scoped) for (const tx of deal.settlementTxIds ?? []) { - const prior = settlementOwner.get(tx.id); - const priorTime = prior?.settlementTxIds?.find((candidate) => candidate.id === tx.id)?.observedAt ?? Infinity; - if (!prior || tx.observedAt < priorTime || (tx.observedAt === priorTime && deal.jobId < prior.jobId)) settlementOwner.set(tx.id, deal); - } - scoped = scoped.filter((deal) => !(deal.settlementTxIds ?? []).some((tx) => settlementOwner.get(tx.id) !== deal)); + const { deals: scoped, useAnchorTimestamps } = countableDeals(deals, windowStart, windowEnd, excludedSettlementIds); const completed = scoped.filter((deal) => deal.sellerOutcome === "completed").length; - const neutral = scoped.filter((deal) => deal.sellerOutcome === "failed-substrate" || deal.cancellationNeutral).length; - const counterpartyFault = scoped.filter((deal) => !deal.cancellationNeutral && - (deal.sellerOutcome === "failed-counterparty" || deal.sellerOutcome === "aborted-by-other")).length; - const partyFaultDenominator = scoped.length - neutral; - const blameDenominator = partyFaultDenominator - counterpartyFault; const bundleRefs = scoped.filter((deal) => deal.bundleContentHash).map((deal) => ({ kind: "dacs-5-bundle" as const, id: deal.jobId, @@ -90,14 +58,6 @@ export function deriveSellerReputation( locator: deal.anchoredByRole === "buyer" ? deal.buyerBundleRef : deal.sellerBundleRef ?? deal.buyerBundleRef, }, })).sort((a, b) => byteOrder(a.contentHash, b.contentHash)); - const ratingByDirection = new Map[number]>(); - for (const deal of scoped) for (const rating of deal.ratings ?? []) { - if (rating.target.toLowerCase() !== deal.owners.seller.toLowerCase() || rating.rater.toLowerCase() === rating.target.toLowerCase()) continue; - const key = `${rating.rater.toLowerCase()}\n${deal.jobId}\n${rating.targetRole}`; - const prior = ratingByDirection.get(key); - if (!prior || rating.ratedAt > prior.ratedAt || (rating.ratedAt === prior.ratedAt && rating.contentHash > prior.contentHash)) ratingByDirection.set(key, rating); - } - const sellerRatings = [...ratingByDirection.values()].filter((rating) => rating.targetRole === "seller").map((rating) => rating.value); const completedWithPrice = scoped.filter((deal) => deal.sellerOutcome === "completed" && deal.agreementPrice); const amounts = new Map(); for (const deal of completedWithPrice) { @@ -117,13 +77,9 @@ export function deriveSellerReputation( completed, bundleCount: scoped.length, totalAgreements: scoped.length, - completionRate: partyFaultDenominator > 0 ? completed / partyFaultDenominator : null, - counterpartyAdjustedCompletionRate: blameDenominator > 0 ? completed / blameDenominator : null, - counterpartyFaultRate: partyFaultDenominator > 0 ? counterpartyFault / partyFaultDenominator : null, - // This catalog record is seller-scoped. Buyer-side sessions are not part - // of its input set, so surfacing a buyer rating here would invent signal. - averageBuyerRating: null, - averageSellerRating: sellerRatings.length ? sellerRatings.reduce((a, b) => a + b, 0) / sellerRatings.length : null, + ...UNAVAILABLE_TESTNET_METRICS, + ...OBSERVED_REPUTATION_COUNTS, + observedOutcomesLabel: observedOutcomesLabel(scoped.length), observedTransactionalVolume, transactionCountByCurrency, bundleRefs, diff --git a/reference-implementations/dacs-directory/src/catalog/settlementUniqueness.ts b/reference-implementations/dacs-directory/src/catalog/settlementUniqueness.ts new file mode 100644 index 0000000..5b1a547 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/settlementUniqueness.ts @@ -0,0 +1,223 @@ +import type { DealRecord } from "./types.js"; +import { paymentEvidenceAddress } from "@kynesyslabs/dacs/canonical"; +import { isChainTxRef, isSettlementEvidence } from "@kynesyslabs/dacs/artifacts"; + +/** Bump whenever persisted settlement eligibility/identity rules change. */ +export const SETTLEMENT_PROJECTION_VERSION = 2; + +const CURRENT_OUTCOMES = new Set([ + "completed", "failed-perm", "failed-counterparty", "failed-substrate", + "aborted-by-self", "aborted-by-other", +]); + +const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); +const isSafeUint = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0); +const isPositiveSafeInt = (value: unknown): value is number => isSafeUint(value) && value > 0; +const CANONICAL_HASH = /^[0-9a-f]{64}$/; + +/** A legacy spelling with `0x` or upper case collapses to SB-1 lower-case hex. */ +function collapsedHash(value: unknown): string | null { + return typeof value === "string" ? /^(?:0x)?([0-9a-f]{64})$/i.exec(value)?.[1].toLowerCase() ?? null : null; +} + +/** Canonical Base58 that decodes to exactly 64 bytes (pinned SDK `isCanonicalSolanaSignature`). */ +function isSolanaSignature(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || value.length > 88) return false; + let decoded = 0n; + for (const character of value) { + const digit = BASE58_ALPHABET.indexOf(character); + if (digit < 0) return false; + decoded = decoded * 58n + BigInt(digit); + } + let bytes = 0; + for (let cursor = decoded; cursor > 0n; cursor >>= 8n) bytes += 1; + let leadingZeros = 0; + while (leadingZeros < value.length && value[leadingZeros] === "1") leadingZeros += 1; + return leadingZeros + bytes === 64; +} + +/** + * DACS-4 SB-1 `settlement-tx-id` of one signed payment TxRef, or null when this + * Directory cannot project it: a malformed ref, a current arm with a + * non-canonical coordinate, or an arm whose identity needs authenticated + * ledger data the Directory does not resolve (legacy `evm`/`solana`/`x402`, + * a legacy non-Demos rail, and every other arm). SB-1 makes such a reference + * `error` or `indeterminate`, and SB-2 does not count it. + */ +export function settlementTxId(ref: unknown): string | null { + if (!isRecord(ref)) return null; + // Frozen legacy MVP TxRef `{ rail, txHash, kind }`: a native Demos transfer is one settlement. + if (typeof ref.rail === "string") { + const hash = ref.kind === "settlement" && ref.rail === "demos" && + Object.keys(ref).every((key) => ["kind", "rail", "txHash"].includes(key)) + ? collapsedHash(ref.txHash) : null; + return hash && `demos:${hash}`; + } + if (!isChainTxRef(ref)) return null; + switch (ref.kind) { + case "demos": { + const hash = collapsedHash(ref.txHash); + return hash && `demos:${hash}`; + } + case "evm-event": + return isPositiveSafeInt(ref.chainId) && typeof ref.txHash === "string" && CANONICAL_HASH.test(ref.txHash) && + isSafeUint(ref.logIndex) ? `evm:${ref.chainId}:${ref.txHash}:${ref.logIndex}` : null; + case "x402-event": + return isPositiveSafeInt(ref.chainId) && typeof ref.settlementTxHash === "string" && + CANONICAL_HASH.test(ref.settlementTxHash) && isSafeUint(ref.logIndex) + ? `evm:${ref.chainId}:${ref.settlementTxHash}:${ref.logIndex}` : null; + case "solana-instruction": + return (ref.cluster === "mainnet" || ref.cluster === "devnet" || ref.cluster === "testnet") && + isSolanaSignature(ref.signature) && isSafeUint(ref.instructionIndex) + ? `solana:${ref.cluster}:${ref.signature}:${ref.instructionIndex}` : null; + default: + return null; + } +} + +/** + * Pinned registry identity to handler mapping. + */ +// DACS-4 §9.4.2 at Standard d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20. +// Unknown/custom registry entries need authenticated resolution before use. +const PINNED_RAIL_HANDLERS: Readonly> = { + "demos-native:DEM": "pay-dem", + "evm-erc20:1:USDC": "pay-evm-erc20", + "evm-erc20:8453:USDC": "pay-evm-erc20", + "evm-erc20:42161:USDC": "pay-evm-erc20", + "evm-erc20:137:USDC": "pay-evm-erc20", + "solana-spl:mainnet:USDC": "pay-solana-spl", + "solana-spl:mainnet:USDT": "pay-solana-spl", + "x402:default": "pay-x402", +}; +const JOB_ID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/; + +/** Project only SDK-valid success evidence with a matching authenticated PC-2 tuple. */ +export function settlementClaims(evidence: Array<{ raw: Record; logicalAddress?: unknown }>, context: { + jobId: unknown; + railId: string | null; + railRegistryVersion: unknown; + phaseSummary: unknown; + pipeline: unknown; +}): { + settlementTxIds: NonNullable; + projected: boolean; +} { + let projected = true; + const handler = context.railRegistryVersion === 1 && context.railId !== null && + Object.hasOwn(PINNED_RAIL_HANDLERS, context.railId) ? PINNED_RAIL_HANDLERS[context.railId] : null; + const settlementTxIds = evidence.filter(({ raw }) => raw.outcome === "success").flatMap(({ raw: record, logicalAddress }) => { + // Delivery evidence cannot replace payment evidence in a settlement phase. + if (typeof record.phase === "string" && record.phase.startsWith("deliver-")) { + if (!isSettlementEvidence(record) || !Array.isArray(context.phaseSummary) || + !context.phaseSummary.some((phase) => isRecord(phase) && isSafeUint(phase.index) && + (phase.kind === "deliver" || phase.kind === record.phase) && + Array.isArray(context.pipeline) && isRecord(context.pipeline[phase.index]) && + context.pipeline[phase.index].kind === record.phase)) projected = false; + return []; + } + // The metadata-of-record belongs to the same native anchor whose signed + // bytes the evidence graph verified. A ref annotation cannot supply it. + const address = typeof logicalAddress === "string" + ? /^dacs4:payment:([^:]+):([^:]+):(0|[1-9][0-9]*)(:resolved)?$/.exec(logicalAddress) : null; + const phaseIndex = address ? Number(address[3]) : NaN; + const phase = Array.isArray(context.phaseSummary) ? context.phaseSummary.find((entry) => + isRecord(entry) && entry.index === phaseIndex) : undefined; + const pipelinePhase = Array.isArray(context.pipeline) ? context.pipeline[phaseIndex] : undefined; + // ST-8 resolutions use HTLC/tank rails, outside this pinned projection. + let addressMatches = false; + if (address && isSafeUint(phaseIndex) && typeof record.jobId === "string" && JOB_ID.test(record.jobId) && + record.jobId === context.jobId && address[1] === record.jobId && context.railId !== null) { + try { + addressMatches = logicalAddress === paymentEvidenceAddress(record.jobId, context.railId, phaseIndex, Boolean(address[4])) && + !address[4]; + } catch { /* A malformed tuple never supplies a claim. */ } + } + if (!isSettlementEvidence(record) || !addressMatches || !handler || record.phase !== handler || + !isRecord(phase) || (phase.kind !== "settle" && phase.kind !== handler) || phase.outcome !== "ok" || + !isRecord(pipelinePhase) || pipelinePhase.kind !== handler || + !isRecord(pipelinePhase.parameters) || pipelinePhase.parameters.rail !== context.railId) { + projected = false; + return []; + } + const refs = record.paymentTxRefs as unknown[]; + return refs.flatMap((ref) => { + const id = settlementTxId(ref); + const kind = isRecord(ref) ? ref.kind : undefined; + const railMatches = handler === "pay-dem" ? kind === "demos" + : handler === "pay-evm-erc20" ? kind === "evm-event" + : handler === "pay-solana-spl" ? kind === "solana-instruction" + : handler === "pay-x402" && kind === "x402-event"; + if (id === null || !railMatches) projected = false; + return id === null ? [] : [{ id, observedAt: Number(record.observedAt), phaseIndex }]; + }); + }); + return { settlementTxIds, projected }; +} + +/** + * Collision grouping key. SB-1 renders hashes as lower-case hex without `0x`, + * so spelling variants of one hash group together; grouping more never lets a + * collision go uncounted. + */ +function settlementGroupKey(id: string): string { + return id.trim().replace(/(?:0x)?([0-9a-f]{64})/gi, (_match, hash: string) => hash.toLowerCase()); +} + +const isAdmissibleDeal = (deal: DealRecord): boolean => + deal.refsVerified && Boolean(deal.reputationEligible) && CURRENT_OUTCOMES.has(deal.sellerOutcome ?? ""); + +/** Build the complete catalog collision set once, before any seller/window scope. */ +export function settlementCollisions(presentedDeals: DealRecord[]): ReadonlySet { + const claimants = new Map>(); + for (const deal of presentedDeals.filter(isAdmissibleDeal)) for (const tx of deal.settlementTxIds ?? []) { + const key = settlementGroupKey(tx.id); + const claimant = JSON.stringify([deal.owners?.buyer, deal.owners?.seller, deal.jobId, tx.phaseIndex, deal.bundleContentHash]); + claimants.set(key, (claimants.get(key) ?? new Set()).add(claimant)); + } + return new Set([...claimants].filter(([, records]) => records.size > 1).map(([key]) => key)); +} + +/** + * The records DACS-5 may count over one window, shared by seller reputation and + * listing hints so the two never disagree. + * + * DACS-4 SB-2: the complete presented set of admissible records is grouped by + * settlement id before the window is applied. One record repeating one + * (jobId, phaseIndex) claim is idempotent. A group claimed by two or more + * records or tuples is indeterminate without settlement-side binding + * authority, which this Directory does not verify, so no member counts. The + * producer-set observedAt, arrival and jobId order never pick a winner. + */ +export function countableDeals( + deals: DealRecord[], windowStart: number, windowEnd: number, + excludedSettlementIds: ReadonlySet = settlementCollisions(deals), +): { + deals: DealRecord[]; + useAnchorTimestamps: boolean; +} { + const eligible = deals.filter(isAdmissibleDeal); + const unique = eligible.filter((deal) => + !(deal.settlementTxIds ?? []).some((tx) => excludedSettlementIds.has(settlementGroupKey(tx.id)))); + // A determinism receipt declares one timestamp basis for its entire input + // set. Use SR-2 anchor time only when it is available for every eligible + // record; otherwise fall back to finalisedAt uniformly and exclude records + // that cannot be evaluated on that honest basis. + const useAnchorTimestamps = eligible.length > 0 && + eligible.every((deal) => typeof deal.anchorTimestamp === "number"); + return { + useAnchorTimestamps, + deals: unique.filter((deal) => { + const timestamp = useAnchorTimestamps ? deal.anchorTimestamp : deal.finalisedAt; + return typeof timestamp === "number" && timestamp >= windowStart && timestamp <= windowEnd; + }), + }; +} + +/** A deal in the category or one of its dot-separated children. */ +export const inCategory = (deal: DealRecord, category: string): boolean => + deal.category === category || Boolean(deal.category?.startsWith(`${category}.`)); diff --git a/reference-implementations/dacs-directory/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index 396afaf..f531c9f 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto"; import type { Catalog, ListingSummary, Registration, RevocationBinding, ScanState } from "./types.js"; import { deriveSellerReputation } from "./reputation.js"; +import { projectTestnetCatalog } from "./testnetMetrics.js"; import { DIRECTORY_TRANSACTION_READINESS } from "./inspection.js"; import { LISTING_REJECTION_MESSAGES, @@ -412,7 +413,11 @@ export async function withDataLock(name: string, fn: () => Promise | T): P } } -export const loadCatalog = (): Catalog => getJson("catalog", { catalogVersion: "1", generatedAt: 0, sellers: [] }); +/** The persisted catalog as indexed. Writers read this; public reads use loadCatalog. */ +export const loadStoredCatalog = (): Catalog => getJson("catalog", { catalogVersion: "1", generatedAt: 0, sellers: [] }); +// TP-1 projection on every public read. Never save its result: that would +// persist the null rating values over the indexed ones. +export const loadCatalog = (): Catalog => projectTestnetCatalog(loadStoredCatalog()); export const saveCatalog = (catalog: Catalog): void => setJson("catalog", catalog); export const loadRegistrations = (): Registration[] => getJson("registrations", []); export const saveRegistrations = (regs: Registration[]): void => setJson("registrations", regs); diff --git a/reference-implementations/dacs-directory/src/catalog/testnetMetricPolicy.ts b/reference-implementations/dacs-directory/src/catalog/testnetMetricPolicy.ts new file mode 100644 index 0000000..b466b94 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/testnetMetricPolicy.ts @@ -0,0 +1,43 @@ +// Shared TP-1 text and query policy; safe for browser imports. +export const COMPLETION_VERIFICATION_UNAVAILABLE = "Completion verification unavailable"; + +// TP-1 (docs/TP-1.md): completion cannot yet be verified on testnet. Apply this +// on reads as well as indexing so a catalog cached by an older release is safe +// to serve. +export const UNAVAILABLE_TESTNET_METRICS = { + completionRate: null, + counterpartyAdjustedCompletionRate: null, + counterpartyFaultRate: null, + averageBuyerRating: null, + averageSellerRating: null, + metricsUnavailableReason: COMPLETION_VERIFICATION_UNAVAILABLE, + metricsProfile: "TP-1", +} as const; + +export const observedOutcomesLabel = (count: number | undefined): string => + count === undefined ? "Observed outcomes unavailable, not verified" : `Observed outcomes: ${count}, not verified`; + +export const OBSERVED_REPUTATION_COUNTS = { + completedLabel: "Observed completed outcomes, not verified", + totalAgreementsLabel: "Observed outcomes, not verified", + observedTransactionalVolumeLabel: "Observed amounts in completed outcomes, not verified completion or settlement", + transactionCountByCurrencyLabel: "Observed priced completed outcomes, not verified completion or settlement", +} as const; + +export function unavailableMetricQuery(params: URLSearchParams): string | undefined { + const metricKeys = new Set([ + "mincompletionrate", "minrating", "trackrecord", "maxfaultrate", "faultrate", + "completionrate", "counterpartyadjustedcompletionrate", "counterpartyfaultrate", + "averagebuyerrating", "averagesellerrating", "reputationscore", + ]); + const rankingKeys = new Set(["sort", "sortby", "order", "orderby", "rank", "rankby"]); + for (const name of params.keys()) { + const key = name.replace(/(?:\[\])+$/, "").toLowerCase(); + if (metricKeys.has(key)) { + return `Rate and score filters are unavailable on testnet under TP-1. ${COMPLETION_VERIFICATION_UNAVAILABLE}.`; + } + if (rankingKeys.has(key)) { + return "Sorting and ranking are unavailable on testnet under TP-1."; + } + } +} diff --git a/reference-implementations/dacs-directory/src/catalog/testnetMetrics.ts b/reference-implementations/dacs-directory/src/catalog/testnetMetrics.ts new file mode 100644 index 0000000..7acd3ed --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/testnetMetrics.ts @@ -0,0 +1,116 @@ +import { countableDeals, inCategory, settlementCollisions, SETTLEMENT_PROJECTION_VERSION } from "./settlementUniqueness.js"; +import { deriveSellerReputation } from "./reputation.js"; +import type { Catalog, ListingSummary, ReputationHint, SellerRecord } from "./types.js"; + +import { + COMPLETION_VERIFICATION_UNAVAILABLE, UNAVAILABLE_TESTNET_METRICS, observedOutcomesLabel, +} from "./testnetMetricPolicy.js"; +export * from "./testnetMetricPolicy.js"; + +const isSafeUint = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + +// The pinned SDK reader's canonical string: non-empty, trimmed, NFC, no C0 controls or DEL. +const isCanonicalString = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value === value.trim() && + value === value.normalize("NFC") && !/[\u0000-\u001f\u007f]/.test(value); + +// The pinned SDK catalog reader marks a whole page malformed when one hint +// fails its DACS-1 §6.3.6 shape check, so a cached hint is reused only when it +// passes the same check (the rate fields are always replaced with null). +function isServableCachedHint(hint: ReputationHint | undefined, category: string): hint is ReputationHint { + return Boolean(hint) && isCanonicalString(hint!.categoryScope) && + (category === hint!.categoryScope || category.startsWith(`${hint!.categoryScope}.`)) && + isSafeUint(hint!.bundleCount) && isSafeUint(hint!.windowStart) && isSafeUint(hint!.windowEnd) && + hint!.windowStart <= hint!.windowEnd && isSafeUint(hint!.computedAt); +} + +// TP-1 allowlists the reputation, hint and rating shapes; any other rate, +// rating or score a cached catalog carries at seller, deal or listing level is +// dropped too. No current field at those levels ends in these words. +const UNAVAILABLE_METRIC_KEY = /(?:rate|rating|score)$/i; +function withoutMetricKeys(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([key]) => !UNAVAILABLE_METRIC_KEY.test(key))) as T; +} + +function listingObservationHint(seller: SellerRecord, listing: ListingSummary, generatedAt: number, excludedSettlementIds: ReadonlySet): ReputationHint { + // A missing or malformed cached hint must still have the count/window shape + // the SDK catalog reader requires. The count is always recomputed from the + // retained records with the indexer's SB-2 rule, so a hint cached before a + // collision was excluded cannot keep counting it; no completion authority + // follows from these records. + const category = listing.offering?.category ?? ""; + const hint = isServableCachedHint(listing.reputationHint, category) ? listing.reputationHint : { + categoryScope: category, + windowStart: 0, + windowEnd: generatedAt, + computedAt: generatedAt, + }; + const bundleCount = countableDeals(seller.deals ?? [], hint.windowStart, hint.windowEnd, excludedSettlementIds).deals + .filter((deal) => inCategory(deal, hint.categoryScope)).length; + return { + categoryScope: hint.categoryScope, + bundleCount, + windowStart: hint.windowStart, + windowEnd: hint.windowEnd, + computedAt: hint.computedAt, + ...UNAVAILABLE_TESTNET_METRICS, + observedOutcomesLabel: observedOutcomesLabel(bundleCount), + }; +} + +export function rebuildCatalogObservations(catalog: Catalog): Catalog { + // Version gating precedes catalog-wide collision grouping. Keep all + // eligible claims here, including those outside a seller/category window. + const sellers = catalog.sellers.map((seller) => ({ + ...seller, + deals: (seller.deals ?? []).map((deal) => ({ + ...deal, + reputationEligible: deal.settlementProjectionVersion === SETTLEMENT_PROJECTION_VERSION && + Boolean(deal.reputationEligible), + })), + })); + const excludedSettlementIds = settlementCollisions(sellers.flatMap((seller) => seller.deals)); + return { + ...catalog, + sellers: sellers.map((seller, index) => { + const windowStart = isSafeUint(seller.reputation?.windowStart) ? seller.reputation.windowStart : 0; + const windowEnd = isSafeUint(seller.reputation?.windowEnd) && seller.reputation.windowEnd >= windowStart + ? seller.reputation.windowEnd : Math.max(windowStart, catalog.generatedAt); + const reputation = deriveSellerReputation(seller.deals, windowStart, windowEnd, excludedSettlementIds); + return { + ...catalog.sellers[index], + reputation, + listings: (seller.listings ?? []).map((listing) => ({ + ...listing, + reputationHint: listingObservationHint(seller, listing, catalog.generatedAt, excludedSettlementIds), + })), + }; + }), + }; +} + +/** Public projection never changes the evidence retained by catalog writers. */ +export function projectTestnetCatalog(catalog: Catalog): Catalog { + const observed = rebuildCatalogObservations(catalog); + return { + ...observed, + sellers: observed.sellers.map((seller) => ({ + ...withoutMetricKeys(seller), + deals: (seller.deals ?? []).map((deal) => ({ + ...withoutMetricKeys(deal), + reputationEligible: deal.settlementProjectionVersion === SETTLEMENT_PROJECTION_VERSION && + Boolean(deal.reputationEligible), + ...(deal.ratings ? { + ratings: deal.ratings.map((rating) => ({ + rater: rating.rater, target: rating.target, targetRole: rating.targetRole, + ratedAt: rating.ratedAt, contentHash: rating.contentHash, + value: null, + metricsUnavailableReason: COMPLETION_VERIFICATION_UNAVAILABLE, + })), + } : {}), + })), + listings: (seller.listings ?? []).map(withoutMetricKeys), + })), + }; +} diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index a653a46..9808d3b 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -160,6 +160,9 @@ export interface DirectoryInspectionAffordance { /** §6.3.6 ReputationHint — advisory; derived, never authoritative. */ export interface ReputationHint { categoryScope: string; + metricsUnavailableReason?: string; + observedOutcomesLabel?: string; + metricsProfile?: "TP-1"; completionRate: number | null; averageSellerRating?: number | null; bundleCount: number; @@ -172,7 +175,9 @@ export interface VerifiedRating { rater: string; target: string; targetRole: "buyer" | "seller"; - value: number; + /** Null in public testnet projections; inspect the individual source evidence. */ + value: number | null; + metricsUnavailableReason?: string; ratedAt: number; contentHash: string; } @@ -222,6 +227,8 @@ export interface DealRecord extends RegisteredDeal { ratings?: VerifiedRating[]; /** Settlement ids used for the SB-2 cross-job uniqueness gate. */ settlementTxIds?: Array<{ id: string; observedAt: number; phaseIndex: number }>; + /** Version of the verified settlement projection; absent summaries need reindexing. */ + settlementProjectionVersion?: number; } export interface SellerRecord { @@ -243,6 +250,13 @@ export interface SellerRecord { listings: ListingSummary[]; deals: DealRecord[]; reputation: { + metricsUnavailableReason?: string; + observedOutcomesLabel?: string; + metricsProfile?: "TP-1"; + completedLabel?: string; + totalAgreementsLabel?: string; + observedTransactionalVolumeLabel?: string; + transactionCountByCurrencyLabel?: string; completed: number; bundleCount?: number; totalAgreements: number; diff --git a/reference-implementations/dacs-directory/src/components/Badge.tsx b/reference-implementations/dacs-directory/src/components/Badge.tsx index 2dcd21d..b1a72c5 100644 --- a/reference-implementations/dacs-directory/src/components/Badge.tsx +++ b/reference-implementations/dacs-directory/src/components/Badge.tsx @@ -64,3 +64,11 @@ export function ChipGroup({ label, children }: { label: string; children: React. ); } + +/** + * A bundle's recorded outcome. TP-1: completion is not verified on testnet, so + * even `completed` renders as a neutral observation, never a success badge. + */ +export function ObservedOutcomeBadge({ outcome }: { outcome: string }) { + return observed{outcome}; completion not verified; +} diff --git a/reference-implementations/dacs-directory/src/components/DirectoryExplorer.tsx b/reference-implementations/dacs-directory/src/components/DirectoryExplorer.tsx index 8dfedeb..d34490b 100644 --- a/reference-implementations/dacs-directory/src/components/DirectoryExplorer.tsx +++ b/reference-implementations/dacs-directory/src/components/DirectoryExplorer.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { activeCatalogSellers } from "@/src/catalog/discovery"; import type { ListingSummary, SellerRecord } from "@/src/catalog/types"; import { directoryEvidenceState } from "./directory-evidence-state"; +import { COMPLETION_VERIFICATION_UNAVAILABLE, unavailableMetricQuery } from "@/src/catalog/testnetMetricPolicy"; import { deliveryLabel, pricingModelLabel, publishedPriceLabel, railLabel, tierMeta } from "./labels"; const sellerTier = (seller: SellerRecord) => seller.identityTier ?? "self-declared"; @@ -33,7 +34,7 @@ export default function DirectoryExplorer({ sellers, indexed }: { sellers: Selle const ownNavigation = useRef(false); const rail = params.get("rail"); const category = params.get("category"); - const goodRecord = params.get("trackRecord") === "90"; + const unavailableQuery = unavailableMetricQuery(new URLSearchParams(paramsString)); const identityTier = params.get("identityTier"); useEffect(() => { @@ -70,11 +71,11 @@ export default function DirectoryExplorer({ sellers, indexed }: { sellers: Selle [services], ); const filtered = useMemo(() => { + if (unavailableQuery) return []; const needle = q.trim().toLowerCase(); return services.filter(({ listing, seller }) => { if (rail && !(listing.offering.rails ?? []).includes(rail)) return false; if (category && !categoryMatches(listing.offering.category, category)) return false; - if (goodRecord && !(seller.reputation.completionRate !== null && seller.reputation.completionRate >= 0.9)) return false; if (identityTier && sellerTier(seller) !== identityTier) return false; if (!needle) return true; return [ @@ -90,7 +91,7 @@ export default function DirectoryExplorer({ sellers, indexed }: { sellers: Selle ...seller.cci.map((claim) => `${claim.platform} ${claim.handle}`), ].join(" ").toLowerCase().replaceAll("-", " ").includes(needle.replaceAll("-", " ")); }); - }, [services, q, rail, category, goodRecord, identityTier]); + }, [services, q, rail, category, identityTier, unavailableQuery]); const setParam = (name: string, value: string | null) => { const next = new URLSearchParams(params.toString()); @@ -103,7 +104,7 @@ export default function DirectoryExplorer({ sellers, indexed }: { sellers: Selle setQ(""); router.replace(pathname, { scroll: false }); }; - const hasFilters = Boolean(q || rail || category || goodRecord || identityTier); + const hasFilters = Boolean(q || rail || category || unavailableQuery || identityTier); return (
    @@ -116,6 +117,7 @@ export default function DirectoryExplorer({ sellers, indexed }: { sellers: Selle
    + {unavailableQuery &&

    {unavailableQuery} Clear all filters to continue.

    }
    ⌕ @@ -168,14 +170,7 @@ export default function DirectoryExplorer({ sellers, indexed }: { sellers: Selle ))}
    )} - {services.some(({ seller }) => seller.reputation.totalAgreements > 0) ?
    - record - -
    :
    recordNo current two-sided deal evidence yet
    } +
    record{COMPLETION_VERIFICATION_UNAVAILABLE}
    @@ -238,7 +233,7 @@ export default function DirectoryExplorer({ sellers, indexed }: { sellers: Selle {(listing.offering.rails ?? []).length === 0 && not stated} record - + {evidence.deals.label} diff --git a/reference-implementations/dacs-directory/src/components/HomeDealDemo.tsx b/reference-implementations/dacs-directory/src/components/HomeDealDemo.tsx deleted file mode 100644 index 6381e99..0000000 --- a/reference-implementations/dacs-directory/src/components/HomeDealDemo.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { - SAMPLE_PROCUREMENT_EVENTS, - SPEAKERS, - STAGES, - eventsToConversation, -} from "./try-chat-script.js"; - -const EXPLORER = "https://explorer.demos.sh"; - -function compact(value: unknown, head = 8, tail = 6): string { - const text = String(value ?? ""); - return text.length > head + tail + 1 ? `${text.slice(0, head)}…${text.slice(-tail)}` : text; -} - -/** - * Compact, auto-playing loop of the REAL recorded purchase (the same captured - * run as /try-chat, every tx link genuine). Starts when scrolled into view and - * loops with a short hold on the settled outcome. A visible playback control - * lets mouse, keyboard, and touch users pause or resume it. - */ -export default function HomeDealDemo() { - const turns = useMemo(() => eventsToConversation(SAMPLE_PROCUREMENT_EVENTS), []); - const [visible, setVisible] = useState(0); - const [started, setStarted] = useState(false); - const [paused, setPaused] = useState(false); - const rootRef = useRef(null); - const scrollRef = useRef(null); - - // Begin only when the demo is actually on screen. Users who prefer reduced - // motion get the finished conversation immediately — no reveal, no loop. - useEffect(() => { - const node = rootRef.current; - if (!node || started) return; - const observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setVisible(turns.length); - setPaused(true); - } - setStarted(true); - } - }, { threshold: 0.25 }); - observer.observe(node); - return () => observer.disconnect(); - }, [started, turns.length]); - - // Reveal one turn per beat; hold on the finished deal, then loop. - useEffect(() => { - if (!started || paused) return; - const finished = visible >= turns.length; - const beat = finished ? 6_000 : turns[visible]!.kind === "say" ? 850 : 1_150; - const timer = setTimeout(() => setVisible(finished ? 0 : visible + 1), beat); - return () => clearTimeout(timer); - }, [started, paused, visible, turns]); - - // Keep the newest turn in view inside the demo's own scroll area. - useEffect(() => { - const el = scrollRef.current; - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - }, [visible]); - - const shown = turns.slice(0, visible); - const stage = shown.length ? shown[shown.length - 1]!.stage : 0; - const settled = visible >= turns.length; - - return ( -
    -
    - recorded deal · sec-audit via rfq -
    -
    - {STAGES.map((item, index) => ( - index ? "done" : stage === index && shown.length ? "active" : ""} title={`${item.primitive} ${item.name}`}> - {settled || stage > index ? "✓" : index + 1} - - ))} -
    - -
    -
    -
    - {shown.map((turn) => { - const who = SPEAKERS[turn.speaker]; - return ( -
    - {who.side !== "center" && {who.avatar}} -
    - {who.side !== "center" && {who.name}} -

    {turn.text}

    - {(turn.txRef || turn.anchorRef) && ( -
    - {turn.kind === "pay" ? "payment" : "receipt"} - {turn.txRef - ? tx {compact(turn.txRef)} ↗ - : {compact(turn.anchorRef, 10, 5)}} -
    - )} -
    -
    - ); - })} - {!settled && started &&
    } - {settled && ( -
    settled · five receipts on-chain
    - )} -
    -
    - recorded on-chain purchase - try dacs → -
    -
    - ); -} diff --git a/reference-implementations/dacs-directory/src/components/SiteNav.tsx b/reference-implementations/dacs-directory/src/components/SiteNav.tsx index a75d29c..bfa9fc0 100644 --- a/reference-implementations/dacs-directory/src/components/SiteNav.tsx +++ b/reference-implementations/dacs-directory/src/components/SiteNav.tsx @@ -6,9 +6,7 @@ import ThemeToggle from "./ThemeToggle"; const LINKS = [ { href: "/", label: "home" }, - { href: "/discover", label: "discover" }, - { href: "/try-chat", label: "watch a deal" }, - { href: "/try", label: "try dacs", featured: true }, + { href: "/discover", label: "discover", featured: true }, { href: "/how-it-works", label: "how it works" }, { href: "/verify", label: "verify" }, { href: "/register", label: "list your service" }, @@ -65,7 +63,7 @@ export default function SiteNav() { setOpen(false)} > {link.label}{link.featured ? → : null} diff --git a/reference-implementations/dacs-directory/src/components/TryChat.tsx b/reference-implementations/dacs-directory/src/components/TryChat.tsx deleted file mode 100644 index 5e98016..0000000 --- a/reference-implementations/dacs-directory/src/components/TryChat.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - SAMPLE_PROCUREMENT_EVENTS, - SPEAKERS, - STAGES, - eventsToConversation, - type ConversationTurn, -} from "./try-chat-script.js"; - -const EXPLORER = "https://explorer.demos.sh"; - -function compact(value: unknown, head = 8, tail = 6): string { - const text = String(value ?? ""); - return text.length > head + tail + 1 ? `${text.slice(0, head)}…${text.slice(-tail)}` : text; -} - -type Mode = "idle" | "replay" | "done"; - -export default function TryChat() { - const [mode, setMode] = useState("idle"); - const [visible, setVisible] = useState(0); // turns revealed so far (replay pacing) - const [showTech, setShowTech] = useState(false); - const transcriptRef = useRef(null); - - const turns = useMemo(() => eventsToConversation(SAMPLE_PROCUREMENT_EVENTS), []); - const shown = mode === "replay" ? turns.slice(0, visible) : mode === "idle" ? [] : turns; - const currentStage = shown.length ? shown[shown.length - 1]!.stage : 0; - const complete = mode === "done"; - - // Auto-scroll the transcript as turns appear. - useEffect(() => { - const el = transcriptRef.current; - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - }, [shown.length, mode]); - - // Replay pacing: reveal one turn at a time with a human-readable beat. - useEffect(() => { - if (mode !== "replay") return; - if (visible >= turns.length) { setMode("done"); return; } - const turn = turns[visible]!; - const beat = turn.kind === "anchor" ? 900 : turn.kind === "pay" ? 1100 : 700; - const timer = setTimeout(() => setVisible((n) => n + 1), beat); - return () => clearTimeout(timer); - }, [mode, visible, turns]); - - function startReplay() { - setVisible(0); - setMode("replay"); - } - - function showFullReplay() { - setVisible(turns.length); - setMode("done"); - } - - return ( -
    -
    -
    recorded deal · sec-audit via rfq
    -

    A recorded deal between two agents.

    -

    - The Butler (left) buys a code audit from the Auditor (right). - Each step anchors a receipt on the Demos chain — this is the evidence from one - completed purchase. -

    -
    - - {mode === "replay" && } - - Run a live deal choose agent · DEM or x402 - - -
    -
    - -
    - Recorded RFQ replay · job d27cd332 · 20 July 2026 - Replays one completed purchase — never starts a job or spends funds. Run one live at Try DACS. -
    - -
    - {STAGES.map((stage, index) => { - const state = complete || currentStage > index ? "done" : (shown.length && currentStage === index ? "active" : "todo"); - return ( -
    - {state === "done" ? "✓" : index + 1} -
    {stage.name}{stage.primitive}

    {stage.blurb}

    -
    - ); - })} -
    - -
    - Butler — buyer's agent - Auditor — seller's agent - Demos chain — the public receipt - EvalBot — independent judge -
    - -
    - {shown.length === 0 && ( -
    -

    Press Watch the recorded deal to see a genuine purchase play out, step by step.

    - The replay uses a genuine on-chain purchase captured from the live network — every transaction link is real. -
    - )} - {shown.map((turn, index) => ( - - ))} - {mode === "replay" && !complete && ( -
    - )} -
    - - {complete && ( -
    -
    ✓ Recorded deal settled & verified
    -

    The Butler got its audit, the Auditor got paid, and the entire deal is now a chain of signed receipts anyone can re-check — the listing, the identity vet, the signed terms, the payment, and the delivery. That is DACS.

    -
    - - Run a live procurement → -
    -
    - )} -
    - ); -} - -function ChatTurn({ turn, newStage, showTech }: { turn: ConversationTurn; newStage: boolean; showTech: boolean }) { - const meta = SPEAKERS[turn.speaker]; - const stage = STAGES[turn.stage]!; - return ( - <> - {newStage && ( -
    {stage.primitive}{stage.name}{stage.blurb}
    - )} -
    - {meta.side !== "center" && {meta.avatar}} -
    - {meta.side !== "center" && {meta.name} · {meta.role}} -

    {turn.text}

    - {(turn.txRef || turn.anchorRef) && ( -
    - {turn.kind === "pay" ? "payment" : "receipt"} - {turn.txRef - ? verify tx {compact(turn.txRef)} ↗ - : {compact(turn.anchorRef, 12, 6)}} -
    - )} - {showTech &&
    gateway event{turn.raw}
    } -
    -
    - - ); -} diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx deleted file mode 100644 index b59d7f7..0000000 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ /dev/null @@ -1,1229 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - AGENT_TIMEOUT_MESSAGE, - ButlerContractError, - LIVE_PROCUREMENT_PROFILE_IDS, - fetchJsonBeforeDeadline, - fetchJsonWithTimeout, - parseAgentInput, - parseButlerRun, - parseProcurementProfiles, - parseProcurementJob, - parseReceiptEnvelope, - procurementProfileCard, - procurementEvidence, - record, - type AgentCard, - type OutputReceipt, - type PaymentRail, - type ProcurementEvent, - type ProcurementJob, - type ProcurementPreview, - type ProcurementProfile, -} from "./try-dacs-contract.js"; -import { - flattenInputKeys, - hasBuiltinForm, - initialAgentInput, - mapGatewayErrors, - initialSchemaInput, - parseAgentFieldSchema, - summarizeAgentInput, - validateAgentInput, - validateSchemaInput, - type FieldErrors, -} from "./try-dacs-forms.js"; -import AgentInputForm from "./try-forms/AgentInputForm.js"; -import { ProcurementLockUnavailableError, parseStoredProcurementRun, resumeDispatchDecision, stageEvents, withExclusiveProcurementLock, type LockRequestor, type StoredProcurementRun } from "./try-dacs-stages.js"; - -const BUTLER = (process.env.NEXT_PUBLIC_BUTLER_ORIGIN ?? "http://127.0.0.1:8402").replace(/\/$/, ""); -const PROCUREMENT_RUN_KEY = "dacs-try:procurement-run"; -const PROFILE_AGENT: Record = { - "oracle-auto-accept": { name: "oracle-desk", label: "Oracle Desk" }, - "dd-live-fixed": { name: "dd-researcher", label: "Due-Diligence Researcher" }, - "security-audit-rfq": { name: "procurement-butler", label: "Security Auditor" }, -}; - -function readStoredRun(): StoredProcurementRun | null { - try { return parseStoredProcurementRun(window.localStorage.getItem(PROCUREMENT_RUN_KEY)); } catch { return null; } -} - -/** Thrown when a queued resume finds its captured record was reconciled by another tab. */ -class StaleResumeRecordError extends Error { - constructor() { - super("This recovery record was reconciled in another tab (dismissed, resumed there, or replaced by a new run), so nothing was sent from this tab."); - this.name = "StaleResumeRecordError"; - } -} - -/** The browser's Web Locks manager, or undefined where unsupported. */ -function browserLocks(): LockRequestor | undefined { - return typeof navigator !== "undefined" && navigator.locks - ? (navigator.locks as unknown as LockRequestor) - : undefined; -} - -/** - * Delete the persisted record ONLY if it still belongs to `runId` — another - * tab's record must never be deleted by this tab's cleanup or 4xx handling. - */ -function releaseStoredRunIfOwned(runId: string | null): void { - try { - const current = parseStoredProcurementRun(window.localStorage.getItem(PROCUREMENT_RUN_KEY)); - if (current && current.runId !== runId) return; - window.localStorage.removeItem(PROCUREMENT_RUN_KEY); - } catch { /* storage unavailable — nothing to release */ } -} -/** - * Write the run record and VERIFY it by reading it back. Returns false when - * durable persistence cannot be established (storage blocked, full, or the - * read-back doesn't match) — the caller must refuse to dispatch a paid - * request in that case, because a reload would lose the idempotency key. - */ -function writeStoredRun(run: StoredProcurementRun | null): boolean { - try { - if (run) { - window.localStorage.setItem(PROCUREMENT_RUN_KEY, JSON.stringify(run)); - const back = parseStoredProcurementRun(window.localStorage.getItem(PROCUREMENT_RUN_KEY)); - return back !== null && back.runId === run.runId && back.jobId === run.jobId; - } - window.localStorage.removeItem(PROCUREMENT_RUN_KEY); - return window.localStorage.getItem(PROCUREMENT_RUN_KEY) === null; - } catch { - return false; - } -} - -type Plan = { - butler: { selectedAgent: string; label: string; rationale: string; selectionEngine: string; alternatives: string[] }; - proposedInput: Record; - inputNote: string; -}; - -const EXPLORER = "https://explorer.demos.sh"; -const BASE_SEPOLIA_EXPLORER = "https://sepolia.basescan.org"; -const AGENT_TIMEOUT_MS = 120_000; -const RECEIPT_WATCH_TIMEOUT_MS = 2 * 60_000; - -function compact(value: unknown, head = 18, tail = 8): string { - const text = String(value ?? ""); - return text.length > head + tail + 1 ? `${text.slice(0, head)}…${text.slice(-tail)}` : text; -} - -function elapsedLabel(ms: number): string { - return ms < 10_000 ? `${(ms / 1_000).toFixed(1)}s` : `${Math.floor(ms / 1_000)}s`; -} - -function paymentRailLabel(rail: PaymentRail): string { - return rail === "pay-x402" ? "USDC · x402" : "DEM · Demos"; -} - -function transactionExplorer(txRef: string): string { - return /^0x[0-9a-f]{64}$/i.test(txRef) - ? `${BASE_SEPOLIA_EXPLORER}/tx/${encodeURIComponent(txRef)}` - : `${EXPLORER}/transactions/${encodeURIComponent(txRef)}`; -} - -function defaultPaymentRail(profile: ProcurementProfile): PaymentRail { - return profile.paymentRails.find((rail) => profile.railReadiness[rail]?.executable) ?? profile.paymentRails[0]!; -} - -function procurementModeLabel(mode: string): string { - if (mode === "fixed-price-auto-accept") return "Fixed price · auto-accept"; - if (mode === "fixed-price-live-cosign") return "Fixed price · live co-sign"; - if (mode === "rfq") return "RFQ · negotiated price"; - return mode.replaceAll("-", " "); -} - -function waitWithSignal(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal.aborted) return reject(new DOMException("aborted", "AbortError")); - const timer = setTimeout(done, ms); - function done() { signal.removeEventListener("abort", aborted); resolve(); } - function aborted() { clearTimeout(timer); reject(new DOMException("aborted", "AbortError")); } - signal.addEventListener("abort", aborted, { once: true }); - }); -} - -function ProcurementReport({ value, events, profile, rail }: { value: unknown; events: ProcurementEvent[]; profile: ProcurementProfile; rail: PaymentRail }) { - const report = record(value); - const evidence = procurementEvidence(report, profile.mode); - const decision = record(report.decision); - const winner = record(decision.winner); - const settlement = record(report.settlement); - const negotiation = record(report.negotiation); - const terms = record(negotiation.terms); - const delivery = record(report.delivery); - const audit = record(delivery.report); - const amount = record(settlement.amount); - const termPrice = record(terms.price); - const evaluation = record(report.evaluation); - const ruling = record(evaluation.ruling); - const anchors = record(report.anchors); - const candidates = Array.isArray(decision.candidates) ? decision.candidates.map(record) : []; - const findings = Array.isArray(audit.findings) ? audit.findings.map(record) : []; - const transactions = Array.isArray(report.transactions) ? report.transactions.map(record) : []; - const paymentHash = String(settlement.txHash ?? ""); - const settlementRail: PaymentRail = settlement.rail === "pay-x402" ? "pay-x402" : rail; - const x402 = settlementRail === "pay-x402"; - const railGovernance = record(settlement.railGovernance); - const price = settlement.amountDem ?? amount.amount ?? settlement.amount ?? winner.price; - const currency = String(amount.currency ?? amount.unit ?? "DEM"); - const agreedPrice = termPrice.amount ?? (Object.keys(termPrice).length ? price : terms.price) ?? price; - const acceptedClass = evidence.overallAccepted ? "success-text" : "error-text"; - const acceptanceLabel = evidence.overallAccepted ? "Settled & accepted" : "Verification incomplete"; - const verificationLabel = evidence.overallAccepted ? "accepted" : "not accepted"; - const evaluationLabel = evidence.rulingAccepted && evidence.rulingValid - ? "accept · signature valid" - : `${String(ruling.verdict ?? "not reported")} · ${evidence.rulingValid ? "signature valid" : "signature unverified"}`; - const negotiationLabel = evidence.negotiationSigned ? "dual-signed" : evidence.negotiationVerified ? "signed agreement anchored" : "agreement evidence missing"; - const reportKind = String(audit.kind ?? ""); - - function check(label: string, detail: string, ok: boolean) { - return
    {ok ? "✓" : "!"}{label}{detail}
    ; - } - - return ( -
    -
    -
    OUTCOME{acceptanceLabel}
    -
    SELECTED SELLER{String(winner.provider ?? profile.agentName)}
    -
    PRICE{price === undefined ? "not reported" : `${String(price)} ${currency}`}
    -
    PROCUREMENT TYPE{profile.mode.replaceAll("-", " ")}
    -
    - -
    -
    REAL PAYMENT · {paymentRailLabel(settlementRail)}

    {x402 ? "Base Sepolia USDC settlement" : "Demos native settlement"}

    {evidence.paymentRecorded ? (x402 ? "settled & seller-verified" : "broadcast & recorded") : "evidence missing"}
    - {paymentHash ? - PAYMENT TX{paymentHash}View on explorer ↗ - :
    PAYMENT TXnot reported
    } -
    payer {compact(settlement.payer)}→seller {compact(settlement.payee)}
    - {x402 && Object.keys(railGovernance).length > 0 &&
    -
    {railGovernance.conformantAuthority === true ? "Canonical rail authority" : "Operator-provisional rail"}{railGovernance.conformantAuthority === true ? "The configured authority is conformant." : "Live for this demo, pending a standardised DACS rail authority."}
    - {typeof railGovernance.disclosure === "string" && Governance disclosure ↗} -
    } -
    - -
    -
    LIVE NEGOTIATION

    {profile.mode === "rfq" ? "Buyer and seller negotiated over L2PS" : "Buyer accepted the seller's published price"}

    {negotiationLabel}
    -
    - {check("Protocol", String(negotiation.protocol ?? "not reported"), Boolean(negotiation.protocol))} - {check("Mode", profile.mode.replaceAll("-", " "), true)} - {check("Agreement", String(negotiation.agreementHash ?? "not reported"), evidence.negotiationVerified)} - {check("Agreed price", agreedPrice === undefined ? "not reported" : `${String(agreedPrice)} ${currency}`, agreedPrice !== undefined)} -
    -
    Signed negotiation transcript and agreement hash
    {JSON.stringify(negotiation, null, 2)}
    -
    - -
    -
    PROCUREMENT ROUTE

    {profile.title}

    {profile.negotiationPhase.replace("negotiate-", "")}
    - {candidates.length ?
    -
    ProviderPriceRailDecision
    - {candidates.map((candidate, index) =>
    {String(candidate.provider ?? "candidate")}{compact(candidate.listingId, 12, 6)}{String(candidate.askPrice ?? "—")} {candidate.chosenRail === "pay-x402" ? "USDC" : "DEM"}{String(candidate.chosenRail ?? "—")}{candidate.excluded ? String(candidate.excluded) : "selected ✓"}
    )} -
    :

    {profile.summary} The gateway resolved the configured signed listing and bound it into this deal.

    } -
    - -
    -
    DELIVERED REPORT

    {profile.id === "oracle-auto-accept" ? "Attested data value" : profile.id === "dd-live-fixed" ? "Due-diligence findings" : "Security audit findings"}

    {evidence.deliveryVerified ? "delivery verified" : "delivery unverified"}
    - {profile.id === "oracle-auto-accept" && evidence.deliveryVerified ?
    {String(audit.preset ?? (reportKind || "attested value"))}{String(audit.value ?? audit.extract ?? "reported")}{String(audit.url ?? "Source recorded in the attestation")}
    : findings.length ?
    {findings.map((finding, index) =>
    {String(finding.severity ?? "info")}
    {String(finding.title ?? finding.ruleId ?? finding.id ?? "Finding")}

    {String(finding.detail ?? finding.rationale ?? "Attested finding")}

    {String(finding.file ?? finding.rule ?? "evidence")}{finding.line !== undefined ? `:${String(finding.line)}` : ""}
    )}
    :

    {evidence.deliveryVerified ? String(record(audit.summary).text ?? "The delivered report was verified and contains no findings.") : "No verified report was returned."}

    } -
    - -
    -
    VERIFICATION

    Independent acceptance checks

    {verificationLabel}
    -
    - {check("Delivery evidence", evidence.deliveryVerified ? "verified" : "unverified", evidence.deliveryVerified)} - {check("Buyer + seller bundles", evidence.bundlesVerified ? "verified" : "unverified", evidence.bundlesVerified)} - {check("Reconciliation", evidence.reconciled ? "reconciled" : "not reconciled", evidence.reconciled)} - {check(evidence.rulingRequired ? "EvalBot ruling" : "Acceptance policy", evidence.rulingRequired ? evaluationLabel : "not required for fixed price", evidence.rulingRequired ? evidence.rulingAccepted && evidence.rulingValid : true)} -
    -
    - -
    -
    ON-CHAIN RECEIPTS

    Every DACS artifact and transaction

    {transactions.length} records
    -
    {transactions.map((transaction, index) => { - const txRef = String(transaction.txRef ?? ""); - const anchorRef = String(transaction.address ?? ""); - return
    {String(index + 1).padStart(2, "0")}
    {String(transaction.name ?? transaction.kind ?? "chain record")}{anchorRef ? `anchor ${compact(anchorRef, 22, 8)}` : (/^0x/.test(txRef) ? "Base Sepolia payment" : "Demos transaction")}
    {txRef ? {compact(txRef, 15, 7)} ↗ : existing anchor}
    ; - })}
    -
    All anchor addresses
    {JSON.stringify(anchors, null, 2)}
    -
    - -
    Raw full-flow JSON
    {JSON.stringify({ events, result: value }, null, 2)}
    -
    - ); -} - -function ProcurementPreviewReport({ - preview, - jobStatus, - error, - profile, -}: { - preview: ProcurementPreview; - jobStatus: ProcurementJob["status"]; - error?: string; - profile: ProcurementProfile; -}) { - const report = preview.delivery.report; - const hasFindingList = Array.isArray(report.findings); - const findings = hasFindingList ? (report.findings as unknown[]).map(record) : []; - const summary = record(report.summary); - const primitiveFields = Object.entries(report).filter(([, value]) => - value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean", - ).slice(0, 8); - const anchorEntries = Object.entries(preview.anchors).filter((entry): entry is [string, string] => typeof entry[1] === "string"); - const finalising = jobStatus === "running"; - const finalisationDetail = finalising - ? "The job remains running until the mandatory buyer and seller bundles verify and reconcile." - : error ?? "The verified delivery remains inspectable, but DACS-5 did not reach terminal acceptance."; - const heading = preview.status === "report-verified-finalising-dacs5" - ? "Verified security report is ready" - : `Verified ${profile.agentName} delivery is ready`; - - return ( -
    -
    -
    VERIFIED DELIVERY · NONTERMINAL

    {heading}

    - {finalising ? "DACS-5 finalising" : "DACS-5 needs recovery"} -
    -
    -
    Result available nowThe seller-signed delivery and confirmed payment evidence have been verified.
    -
    {finalising ? "Two bundle copies anchoring" : "Finalisation stopped safely"}{finalisationDetail}
    -
    - {profile.id === "oracle-auto-accept" ? ( -
    - {String(report.preset ?? report.kind ?? "attested value")} - {String(report.value ?? report.extract ?? "reported")} - {String(report.url ?? "Source recorded in the signed delivery attestation")} -
    - ) : hasFindingList ? ( - findings.length ? ( -
    {findings.map((finding, index) => ( -
    - {String(finding.severity ?? "info")} -
    {String(finding.title ?? finding.ruleId ?? finding.id ?? "Finding")}

    {String(finding.detail ?? finding.rationale ?? "Attested finding")}

    {String(finding.file ?? finding.rule ?? "evidence")}{finding.line !== undefined ? `:${String(finding.line)}` : ""}
    -
    - ))}
    - ) :

    No findings were returned by the verified security report.

    - ) : primitiveFields.length ? ( -
    {primitiveFields.map(([key, value]) =>
    {key.replaceAll(/[-_]/g, " ")}{String(value ?? "null")}
    )}
    - ) : ( -

    {String(summary.text ?? "The signed deliverable is verified and available in the raw preview below.")}

    - )} -
    Not settled-and-accepted yetDACS-5 is still mandatory. The Directory will only show final acceptance after the buyer and seller bundles verify and reconcile.
    -
    Verified preview anchors
    {anchorEntries.map(([name, anchor]) =>
    {name}{anchor}
    )}
    -
    Raw verified delivery preview
    {JSON.stringify(preview, null, 2)}
    -
    - ); -} - -/** - * The five DACS stages, in the standard's own words (see /how-it-works: - * Identify → Vet → Negotiate → Settle → Verify, "one deal · five receipts"). - * `plain` is the novice explanation shown behind "What's happening here?". - */ -const DACS_STAGES = [ - { - key: "identify", name: "Identify", primitive: "DACS-1", - tagline: "Who offers the service? Signed listings only.", - receipt: "a signed on-chain listing", - plain: "Agents advertise what they offer. In full DACS that's a signed listing anchored on the Demos chain — like a business card that can't be forged. This demo's picker shows the live gateway's agent roster; the Procurement Butler run then resolves and verifies a real signed DACS-1 listing on-chain before dealing.", - }, - { - key: "vet", name: "Vet", primitive: "DACS-2", - tagline: "Check the counterparty before committing.", - receipt: "an anchored verification record", - plain: "Before dealing with a stranger you check who they are. Here the buyer verifies the seller's identity and claims, and writes that check to the chain — so later everyone can see the vet actually happened.", - }, - { - key: "negotiate", name: "Negotiate", primitive: "DACS-3", - tagline: "Fix price, scope and timing — both agents sign.", - receipt: "a dual-signed agreement", - plain: "The two agents agree the job: what will be done, by when, for how much. Both sign the same agreement, so neither can later claim different terms. That signed agreement is anchored before any money moves.", - }, - { - key: "settle", name: "Settle & deliver", primitive: "DACS-4", - tagline: "Pay on the agreed rail; the work arrives with evidence.", - receipt: "a payment transaction + delivery evidence", - plain: "The buyer pays on the agreed payment rail — native DEM on Demos or USDC through x402 on Base Sepolia — and the seller delivers the work. The payment hash and the delivered report are both captured as evidence.", - }, - { - key: "verify", name: "Verify", primitive: "DACS-5", - tagline: "Bind every receipt into one bundle both sides anchor.", - receipt: "a reconciled attestation bundle", - plain: "Finally everything — listing, vet, agreement, payment and delivery — is tied into one signed bundle that both sides anchor. Anyone can re-run the checks later, and honest deals build the seller's public reputation.", - }, -] as const; - -type StageOutput = { - state: "pending" | "active" | "complete" | "warning" | "skipped"; - summary: string; - detail?: string; - /** Chain records produced by this stage, rendered as explorer links. */ - chain?: ProcurementEvent[]; -}; - -function message(value: unknown): string { - if (value && typeof value === "object" && "error" in value) { - const error = (value as { error?: string | { message?: string } }).error; - if (typeof error === "string" && error.trim()) return error; - if (error && typeof error === "object" && error.message) return error.message; - } - return "The Butler could not complete that step."; -} - -export default function TryDacs() { - const [agents, setAgents] = useState([]); - const [profiles, setProfiles] = useState([]); - const [selectedProfileId, setSelectedProfileId] = useState(null); - const [selectedPaymentRail, setSelectedPaymentRail] = useState("pay-dem"); - const [goal, setGoal] = useState(""); - const [plan, setPlan] = useState(null); - const [inputValue, setInputValue] = useState>({}); - const [gatewayFieldErrors, setGatewayFieldErrors] = useState({}); - const [submittedSummary, setSubmittedSummary] = useState([]); - const [phase, setPhase] = useState<"idle" | "planning" | "ready" | "running" | "done" | "error">("idle"); - const [result, setResult] = useState(); - const [procurementJob, setProcurementJob] = useState(null); - const [error, setError] = useState(""); - const [runStartedAt, setRunStartedAt] = useState(null); - const [elapsedMs, setElapsedMs] = useState(0); - const [receipt, setReceipt] = useState(null); - const [receiptPolling, setReceiptPolling] = useState(false); - const [receiptMessage, setReceiptMessage] = useState(""); - const runAbort = useRef(null); - const receiptAbort = useRef(null); - const procurementJobRef = useRef(null); - // One idempotency key per intentional procurement run, reused across retries - // of that run so the gateway returns the existing job instead of creating a - // second paid one. Cleared when a new intent begins (new agent, edited input, - // Load example) or on success. - const procurementRunId = useRef(null); - - useEffect(() => { - fetch(`${BUTLER}/demo/procurement/options`) - .then((res) => res.ok ? res.json() : Promise.reject(new Error("procurement options unavailable"))) - .then((body: unknown) => { - const live = parseProcurementProfiles(body) - .filter((profile) => profile.executable && LIVE_PROCUREMENT_PROFILE_IDS.includes(profile.id)); - if (live.length !== 3 || new Set(live.map((profile) => profile.id)).size !== 3) { - throw new Error("the three production procurement profiles are not all executable"); - } - setProfiles(live); - setAgents(live.map((profile) => procurementProfileCard(profile, defaultPaymentRail(profile)))); - }) - .catch((cause: unknown) => setError(cause instanceof ButlerContractError - ? cause.message - : "The live procurement demos are temporarily unavailable.")); - }, []); - - useEffect(() => { - if (!runStartedAt || (phase !== "running" && !receiptPolling)) return; - const update = () => setElapsedMs(Date.now() - runStartedAt); - update(); - const timer = setInterval(update, 100); - return () => clearInterval(timer); - }, [phase, receiptPolling, runStartedAt]); - - useEffect(() => { procurementJobRef.current = procurementJob; }, [procurementJob]); - - // The persisted procurement run (idempotency key + input + job id). Written - // BEFORE the POST is dispatched so a reload/crash can never lose the key of - // a purchase the gateway may have started; kept until the job reaches a - // browser-verified safe terminal state. - const [storedRun, setStoredRun] = useState(null); - const updateStoredRun = useCallback((run: StoredProcurementRun | null): boolean => { - const ok = writeStoredRun(run); - // Reflect what storage actually holds, not what we hoped to write. - setStoredRun(ok ? run : readStoredRun()); - return ok; - }, []); - /** Ownership-conditional delete: never removes another tab's record. */ - const releaseStoredRun = useCallback((runId: string | null) => { - releaseStoredRunIfOwned(runId); - setStoredRun(readStoredRun()); - }, []); - useEffect(() => { setStoredRun(readStoredRun()); }, []); - // Keep this tab's view of the record in sync when ANOTHER tab writes or - // clears it (storage events fire only in non-originating tabs). - useEffect(() => { - const onStorage = (event: StorageEvent) => { - if (event.key === PROCUREMENT_RUN_KEY || event.key === null) setStoredRun(readStoredRun()); - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - - useEffect(() => () => { - runAbort.current?.abort(); - receiptAbort.current?.abort(); - }, []); - - const selectedProfile = useMemo(() => profiles.find((profile) => profile.id === selectedProfileId), [profiles, selectedProfileId]); - const selected = useMemo(() => selectedProfile - ? procurementProfileCard(selectedProfile, selectedPaymentRail) - : agents.find((agent) => agent.name === plan?.butler.selectedAgent), - [agents, plan, selectedPaymentRail, selectedProfile]); - const selectedRailReadiness = selectedProfile?.railReadiness[selectedPaymentRail]; - const execution = record(record(result).execution); - const specialistDurationMs = typeof execution.durationMs === "number" ? execution.durationMs : undefined; - const receiptElapsedMs = receipt?.createdAt - ? Math.max(0, (receipt.status === "confirmed" || receipt.status === "failed" ? Date.parse(receipt.updatedAt ?? receipt.createdAt) : Date.now()) - Date.parse(receipt.createdAt)) - : 0; - const procurementAccepted = selectedProfile !== undefined && result !== undefined - ? procurementEvidence(result, selectedProfile.mode).overallAccepted - : false; - const validateInput = useCallback((agentName: string, value: Record): FieldErrors => { - if (hasBuiltinForm(agentName)) return validateAgentInput(agentName, value); - const schema = selected ? parseAgentFieldSchema(selected) : null; - return schema ? validateSchemaInput(schema, value) : {}; - }, [selected]); - const localErrors = useMemo( - () => plan ? validateInput(plan.butler.selectedAgent, inputValue) : {}, - [plan, inputValue, validateInput], - ); - const inputIsValid = Object.keys(localErrors).length === 0 && (selectedRailReadiness?.executable ?? true); - // Verification completes only on evidence: a confirmed receipt (or no - // receipt advertised at all). A synchronous broadcast-only attestation has - // no status URL to poll, so its confirmation is UNKNOWN in this browser — - // the Verify phase stays visibly pending rather than claiming completion. - const verificationComplete = phase === "done" && procurementAccepted; - const resultPayload = result; - const resultFields = Object.keys(record(resultPayload)); - const isProcurementSel = selectedProfileId !== null; - const procEvents = procurementJob?.events ?? []; - const procurementPreview = procurementJob?.preview; - const procurementWaiting = phase === "running" && procurementJob?.queue?.status === "waiting"; - // Fail-closed staging: a terminal "failed" (or unknown-phase) event attaches - // to the stage the run had actually reached and never advances progress. - const { byStage: eventsByStage, progress: procStage } = stageEvents(procEvents); - - // Live procurement run: each DACS stage reports the gateway's own events - // (and their chain records) as they arrive. - function procurementStageOutput(stageIdx: number): StageOutput { - const events = eventsByStage[stageIdx]!; - const latest = events[events.length - 1]; - const chain = events.filter((event) => event.txRef || event.anchorRef); - const jobComplete = procurementJob?.status === "complete"; - const jobFailed = procurementJob?.status === "failed" || phase === "error"; - if (stageIdx === 3 && procurementPreview) { - return { - state: "complete", - summary: "Payment evidence and seller delivery verified", - detail: "The verified result is available; two-sided DACS-5 reconciliation continues separately", - chain, - }; - } - if (stageIdx === 4 && procurementPreview && procurementJob?.status === "running") { - return { - state: "active", - summary: "Verified delivery ready; two DACS-5 copies are finalising", - detail: "Completion remains pending until both bundles reconcile", - chain, - }; - } - if (stageIdx === 4 && jobComplete) { - return { - state: procurementAccepted ? "complete" : "warning", - summary: procurementAccepted ? "Full evidence bundle accepted & reconciled" : "Verification incomplete", - detail: latest?.label, - chain, - }; - } - if (procStage > stageIdx || jobComplete) { - return { state: "complete", summary: latest?.label ?? "Completed", detail: events.length > 1 ? `${events.length} steps recorded` : undefined, chain }; - } - if (procStage === stageIdx) { - return jobFailed - ? { state: "warning", summary: latest?.label ?? "Stopped safely", detail: error || undefined, chain } - : { state: "active", summary: latest?.label ?? "In progress", detail: `${elapsedLabel(elapsedMs)} elapsed`, chain }; - } - return { state: "pending", summary: "Waiting for the earlier stages" }; - } - - const identifyOut: StageOutput = !agents.length - ? { state: "active", summary: "Reading the live procurement profiles…" } - : !plan - ? { state: "active", summary: `${agents.length} live procurement routes — pick one`, detail: agents.map((agent) => agent.label).join(", ") } - : isProcurementSel - ? { state: "pending", summary: `${selected?.label ?? "The seller"}'s signed DACS-1 listing is resolved and verified when the run starts` } - : { state: "skipped", summary: `${plan.butler.label} picked from the gateway roster — no DACS-1 listing involved`, detail: "This picker is the gateway's published roster, not a signed listing. A real DACS-1 listing is only resolved and verified during the full Procurement run." }; - const vetOut: StageOutput = !plan - ? { state: "pending", summary: "Waiting for an agent choice" } - : isProcurementSel - ? { state: "pending", summary: "Runs live during the purchase — the vet record is anchored on-chain" } - : { state: "skipped", summary: "Skipped in this demo", detail: "The Butler already trusts this in-network specialist. Run the Procurement Butler to watch a real counterparty vet get anchored as a DACS-2 record." }; - const negotiateOut: StageOutput = !plan - ? { state: "pending", summary: "Waiting for an agent choice" } - : isProcurementSel - ? { state: "pending", summary: "Runs live during the purchase — both agents sign the agreement" } - : { state: "skipped", summary: "Skipped — this demo is free", detail: "There is nothing to price here. The Procurement Butler negotiates a real RFQ and both agents sign the DACS-3 agreement before payment." }; - const settleOut: StageOutput = phase === "running" - ? { state: "active", summary: `Live procurement running · ${elapsedLabel(elapsedMs)}`, detail: `The gateway confirms the signed agreement before broadcasting the real ${paymentRailLabel(selectedPaymentRail)} payment.` } - : phase === "done" - ? { state: "complete", summary: specialistDurationMs === undefined ? "Result delivered" : `Result delivered in ${elapsedLabel(specialistDurationMs)}`, detail: resultFields.length ? `Result fields: ${resultFields.join(", ")}` : "The complete result is shown below." } - : phase === "error" - ? { state: "warning", summary: "Execution stopped safely", detail: error } - : plan && inputIsValid - ? { state: "pending", summary: `A real ${paymentRailLabel(selectedPaymentRail)} payment happens here when you press Run` } - : plan - ? { state: "active", summary: "Waiting for required fields", detail: `${Object.keys(localErrors).length} field${Object.keys(localErrors).length === 1 ? " needs" : "s need"} attention in the form` } - : { state: "pending", summary: "Waiting for job details" }; - // A specialist's output attestation is real evidence, but it is a - // single-sided anchor — NOT the two-party DACS-5 bundle this stage names — - // so the stage never earns a completion tick outside the full purchase. - const verifyOut: StageOutput = receipt - ? { - state: "skipped", - summary: `Single-sided output anchor ${receipt.status} — not a DACS-5 bundle${!receipt.statusUrl && receipt.status !== "confirmed" && receipt.status !== "failed" ? " · confirmation unknown (anchoring continues on the gateway)" : ""}`, - detail: `${receipt.txRef ? `Transaction ${compact(receipt.txRef, 14, 7)} — inspect it in Chain activity below.` : `Anchor ${compact(receipt.anchorAddress, 18, 8)}.`}${receipt.status === "failed" ? " Anchoring failed safely — use the receipt panel below to retry." : ""} The reconciled two-party DACS-5 bundle is only produced by the full Procurement run.`, - } - : phase === "done" - ? { state: "skipped", summary: "No DACS-5 bundle — this demo returns the result directly", detail: "This gateway did not advertise a separate live receipt for this agent. The reconciled DACS-5 bundle is only produced by the full Procurement run." } - : { state: "pending", summary: "Waiting for delivery" }; - - // A live/finished procurement job reports the real gateway events per stage; - // everything else (specialists, and procurement before the run) uses the - // honest static mapping — including visibly-skipped stages. - const stageOutputs: StageOutput[] = isProcurementSel && procEvents.length - ? DACS_STAGES.map((_, stageIdx) => procurementStageOutput(stageIdx)) - : [identifyOut, vetOut, negotiateOut, settleOut, verifyOut]; - - // Every chain record seen this run (procurement events + the specialist - // receipt), so all transactions stay visible in one place with explorer links. - const chainActivity: Array<{ label: string; txRef?: string; anchorRef?: string }> = [ - ...procEvents.filter((event) => event.txRef || event.anchorRef).map((event) => ({ label: event.label, txRef: event.txRef, anchorRef: event.anchorRef })), - ...(receipt ? [{ label: "Output attestation anchored", txRef: receipt.txRef, anchorRef: receipt.anchorAddress }] : []), - ]; - const chainTxCount = chainActivity.filter((row) => row.txRef).length; - - async function watchReceipt(initial: OutputReceipt) { - // The synchronous LIVE-ANCHOR attestation has no status URL: there is - // nothing to poll — the anchor status shown is already the final report - // from the run response. - const statusUrl = initial.statusUrl; - if (!statusUrl) return; - receiptAbort.current?.abort(); - const controller = new AbortController(); - receiptAbort.current = controller; - setReceiptPolling(true); setReceiptMessage(""); - let current = initial; - const deadline = Date.now() + RECEIPT_WATCH_TIMEOUT_MS; - try { - while (current.status !== "confirmed" && current.status !== "failed") { - if (Date.now() >= deadline) { - setReceiptMessage("Receipt confirmation is taking longer than two minutes. The result is safe; you can check again without rerunning the agent."); - return; - } - await waitWithSignal(Math.min(1_500, deadline - Date.now()), controller.signal); - const { response, body } = await fetchJsonBeforeDeadline( - new URL(current.statusUrl ?? statusUrl, `${BUTLER}/`), - { signal: controller.signal }, - deadline, - fetch, - "Receipt status checking exceeded two minutes.", - ); - if (!response.ok) throw new Error(message(body)); - current = parseReceiptEnvelope(body); - setReceipt(current); - } - if (current.status === "failed") setReceiptMessage(current.error ?? "Receipt anchoring failed safely. Retry will reuse the same receipt and wallet queue."); - } catch (cause) { - if ((cause as Error).name !== "AbortError") setReceiptMessage((cause as Error).message); - } finally { - if (receiptAbort.current === controller) { - receiptAbort.current = null; - setReceiptPolling(false); - } - } - } - - async function retryReceipt() { - if (!receipt?.statusUrl) return; - try { - let next = receipt; - if (receipt.status === "failed") { - const { response, body } = await fetchJsonWithTimeout( - new URL(`${receipt.statusUrl}/retry`, `${BUTLER}/`), - { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, - 15_000, - "The receipt retry request timed out.", - ); - if (!response.ok) throw new Error(message(body)); - next = parseReceiptEnvelope(body); - setReceipt(next); - } - void watchReceipt(next); - } catch (cause) { - setReceiptMessage((cause as Error).message); - } - } - - function cancelRun() { - runAbort.current?.abort(); - } - - function cancelReceiptWatch() { - receiptAbort.current?.abort(); - setReceiptPolling(false); - setReceiptMessage("Stopped checking in this browser. The gateway will continue nonce-safe anchoring in the background."); - } - - async function runAgent() { - if (!plan) return; - let parsed: Record; - try { parsed = parseAgentInput(inputValue); } - catch (cause) { - setError((cause as Error).message); - return; - } - // Local validation is advisory feedback; a hard local failure blocks the - // obviously-broken submissions, the gateway remains authoritative. - const advisory = validateInput(plan.butler.selectedAgent, parsed); - if (Object.keys(advisory).length > 0) { - setError("Some fields need attention before this can run — see the highlighted inputs."); - return; - } - // A new procurement run must not overwrite the persisted record of an - // earlier, unreconciled one — that record may be the only handle on a - // paid job. Retrying the SAME run (matching key) passes through. - if (isProcurementSel && storedRun && storedRun.runId !== procurementRunId.current) { - setError("An earlier procurement run from this browser is still on record. Use “Check & resume” in the banner above (or dismiss it once reconciled) before starting a new purchase."); - return; - } - runAbort.current?.abort(); receiptAbort.current?.abort(); - const controller = new AbortController(); - runAbort.current = controller; - setPhase("running"); setError(""); setResult(undefined); setReceipt(null); setReceiptMessage(""); - // Clear any prior job before a fresh start so a failed start can never - // offer resuming a stale job (ref set synchronously: the catch below may - // run before the state-sync effect). - setProcurementJob(null); procurementJobRef.current = null; - setGatewayFieldErrors({}); - setSubmittedSummary(summarizeAgentInput(plan.butler.selectedAgent, parsed)); - setRunStartedAt(Date.now()); setElapsedMs(0); - // A 4xx whose details name specific fields returns the user to the form - // with the gateway's own words beside the inputs (authoritative). - const fieldMappedRejection = (body: unknown): boolean => { - const envelope = record(record(body).error); - const gatewayMessage = typeof envelope.message === "string" ? envelope.message : ""; - const details = Array.isArray(envelope.details) ? envelope.details.filter((line): line is string => typeof line === "string") : undefined; - if (!gatewayMessage) return false; - const mapped = mapGatewayErrors(gatewayMessage, details, flattenInputKeys(parsed)); - if (Object.keys(mapped.byField).length === 0) return false; - setGatewayFieldErrors(mapped.byField); - setError([gatewayMessage, ...mapped.global].filter(Boolean).join(" — ")); - setPhase("ready"); - return true; - }; - try { - if (isProcurementSel && selectedProfile) { - const deadline = Date.now() + 12 * 60_000; - // The Directory is the discovery surface. Pass its authenticated catalog candidate - // pointer to the Butler; the gateway independently dereferences and - // verifies the signed DACS-1 artifact before negotiation. - const request: Record = { profileId: selectedProfile.id, ...parsed, paymentRail: selectedPaymentRail }; - if (selectedProfile.id === "security-audit-rfq") try { - const { response: catalog, body } = await fetchJsonBeforeDeadline<{ listings?: Array<{ listingId?: string; anchor?: { locator?: string }; offering?: { title?: string; negotiation?: string[] } }> }>(`/api/dacs/listings?rail=${encodeURIComponent(selectedPaymentRail)}&limit=100`, { signal: controller.signal }, deadline); - if (catalog.ok) { - const auditor = body.listings?.find((item) => item.listingId === "audit-negotiator" && item.offering?.negotiation?.includes("rfq") && /auditor/i.test(item.offering?.title ?? "")); - const ref = auditor?.anchor?.locator; - if (ref) request.auditorListingRef = ref; - } - } catch { /* gateway will derive and verify the configured Auditor slot */ } - // One key per intentional run; a retry of this same run reuses it so - // the gateway dedupes to the existing job (never a second payment). - procurementRunId.current ??= crypto.randomUUID(); - // Read-record → write-record → POST runs under a cross-tab exclusive - // lock, with the record RE-READ from storage inside the section: two - // tabs can otherwise interleave, the loser overwriting the winner's - // record and then deleting it on its own 4xx. - const started = await withExclusiveProcurementLock(browserLocks(), controller.signal, async (): Promise => { - const existing = readStoredRun(); - if (existing && existing.runId !== procurementRunId.current) { - setPhase("ready"); - setError("Another procurement run's recovery record is active (possibly from another tab). Use “Check & resume” in the banner — or dismiss it once reconciled — before starting a new purchase."); - return null; - } - // Persist the key + input BEFORE dispatch: a reload/crash between - // the POST and the job-id response must not lose the only handle on - // a purchase the gateway may have started. Restored on mount. - const runRecord: StoredProcurementRun = { runId: procurementRunId.current!, goal, input: request, startedAt: new Date().toISOString() }; - if (!updateStoredRun(runRecord)) { - // No durable recovery record → a reload would lose the only - // handle on the purchase. Refuse to send the paid request at all. - setPhase("ready"); - setError("This browser cannot durably save the purchase-recovery record (storage is blocked or full), so the paid request was NOT sent. Free up site storage or use a different browser profile, then run again."); - return null; - } - const { response: start, body: startBody } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": runRecord.runId }, - body: JSON.stringify(request), signal: controller.signal, - }, deadline); - if (!start.ok) { - // A 4xx is a verified pre-job rejection (nothing was created), so - // THIS run's record has nothing to protect — release it only if - // it is still ours. A 5xx/opaque failure keeps it: the job may - // exist, and the key is the only handle. - if (start.status >= 400 && start.status < 500) releaseStoredRun(runRecord.runId); - if (fieldMappedRejection(startBody)) return "handled"; - throw new Error(message(startBody)); - } - const job = parseProcurementJob(startBody); - procurementJobRef.current = job; // synchronous — the catch relies on it - updateStoredRun({ ...runRecord, jobId: job.id }); - setProcurementJob(job); - return job; - }).catch((cause: unknown) => { - if (cause instanceof ProcurementLockUnavailableError) { - setPhase("ready"); - setError(`${cause.message} Use a current version of Chrome, Edge, Firefox or Safari — the paid request was NOT sent.`); - return null; - } - throw cause; - }); - if (started === null || started === "handled") return; - await followProcurementJob(started, controller, deadline); - return; - } - const { response: res, body } = await fetchJsonWithTimeout(`${BUTLER}/demo/butler`, { - method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ goal, agent: plan.butler.selectedAgent, input: parsed }), - signal: controller.signal, - }, AGENT_TIMEOUT_MS, AGENT_TIMEOUT_MESSAGE); - if (!res.ok) { - if (fieldMappedRejection(body)) return; - throw new Error(message(body)); - } - const completed = parseButlerRun(body); - setResult(completed); setPhase("done"); - if (completed.outputAttestation) { - setReceipt(completed.outputAttestation); - void watchReceipt(completed.outputAttestation); - } - } catch (cause) { - const cancelled = (cause as Error).name === "AbortError"; - const isProcurement = isProcurementSel; - const haveJob = procurementJobRef.current !== null; - // The retry-reuses-the-key reassurance only holds while the key is - // retained — a terminal failed job is explained by the recovery box - // instead, so its gateway reason is shown verbatim. - const failedJob = (procurementJobRef.current as ProcurementJob | null)?.status === "failed"; - setError(cancelled - ? isProcurement && haveJob - ? "Stopped watching in this browser. The gateway is still completing this procurement job — it was NOT cancelled, and any payment it makes still happens. Resume below to keep following the same job; no second purchase will be started." - : "Run cancelled in this browser. No result was accepted; you can retry the same bounded job." - : isProcurement && !failedJob - ? `${(cause as Error).message} — Retrying reuses the same idempotency key, so the gateway resumes the existing job instead of starting a second paid purchase.` - : (cause as Error).message); - setPhase("error"); - } finally { - if (runAbort.current === controller) runAbort.current = null; - } - } - - /** - * Follow an already-started procurement job to completion. Used by the - * initial run and by "Resume status" after the user stops watching — the - * job id is retained so resuming NEVER creates a second job or payment. - */ - async function followProcurementJob(initial: ProcurementJob, controller: AbortController, deadline: number) { - let current = initial; - while (current.status === "running" && Date.now() < deadline) { - await waitWithSignal(Math.min(2_000, Math.max(0, deadline - Date.now())), controller.signal); - const { response: poll, body: pollBody } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement/${encodeURIComponent(current.id)}`, { signal: controller.signal }, deadline); - if (!poll.ok) throw new Error(message(pollBody)); - current = parseProcurementJob(pollBody); - // Keep the synchronous ref aligned with the rendered state. The outer - // catch uses it before React effects run to distinguish a terminal - // gateway failure from a transient watcher error. - procurementJobRef.current = current; - setProcurementJob(current); - } - if (current.status === "failed") { - // Only the gateway's explicit failedBeforePayment=true proves no money - // moved — then (and only then) a fresh purchase is safe, so the key and - // persisted record are released (if the record is still this run's). - // Anything else (flag false or absent) may have paid: keep both, so the - // job stays referenced for the gateway's operator recovery path and no - // new purchase is offered. - if (current.failedBeforePayment === true) { - const ownedRunId = procurementRunId.current; - procurementRunId.current = null; - releaseStoredRun(ownedRunId); - } - throw new Error(current.error ?? "the full procurement flow failed safely"); - } - if (current.status !== "complete") throw new Error("the full procurement flow is still running; use Resume status to keep following it"); - setResult(current.result); setPhase("done"); - const ownedRunId = procurementRunId.current; - procurementRunId.current = null; - releaseStoredRun(ownedRunId); - } - - /** Resume following the existing procurement job (never a new purchase). */ - async function resumeProcurement() { - const job = procurementJobRef.current; - if (!job) return; - runAbort.current?.abort(); - const controller = new AbortController(); - runAbort.current = controller; - setPhase("running"); setError(""); - setRunStartedAt(Date.now()); setElapsedMs(0); - try { - await followProcurementJob(job, controller, Date.now() + 12 * 60_000); - } catch (cause) { - const cancelled = (cause as Error).name === "AbortError"; - setError(cancelled - ? "Stopped watching in this browser. The gateway is still completing this procurement job — resume below to keep following it; no second purchase will be started." - : (cause as Error).message); - setPhase("error"); - } finally { - if (runAbort.current === controller) runAbort.current = null; - } - } - - /** - * Reconcile a persisted run after a reload. With a job id this only reads - * status. Without one (the reload raced the POST response) it re-POSTs the - * SAME stored input under the SAME idempotency key, so the gateway returns - * the original job if it exists — it can never start a second purchase for - * this run. - */ - async function resumeStoredRun() { - const stored = storedRun; - if (!stored) return; - runAbort.current?.abort(); receiptAbort.current?.abort(); - const controller = new AbortController(); - runAbort.current = controller; - procurementRunId.current = stored.runId; - setGoal(stored.goal); - const storedProfileId = typeof stored.input.profileId === "string" ? stored.input.profileId : "security-audit-rfq"; - const storedAgent = PROFILE_AGENT[storedProfileId] ?? PROFILE_AGENT["security-audit-rfq"]!; - const storedPaymentRail: PaymentRail = stored.input.paymentRail === "pay-x402" ? "pay-x402" : "pay-dem"; - setSelectedProfileId(storedProfileId); - setSelectedPaymentRail(storedPaymentRail); - setPlan({ - butler: { - selectedAgent: storedAgent.name, - label: storedAgent.label, - selectionEngine: "restored run", - rationale: "Reconnecting to the procurement run this browser dispatched earlier — same idempotency key, so no second purchase can start.", - alternatives: [], - }, - proposedInput: stored.input, - inputNote: "Restored from this browser's persisted run record.", - }); - const { profileId: _profileId, auditorListingRef: _auditorListingRef, ...storedFormInput } = stored.input; - setInputValue(storedFormInput); - setResult(undefined); setReceipt(null); setReceiptMessage(""); setGatewayFieldErrors({}); - setProcurementJob(null); procurementJobRef.current = null; - setSubmittedSummary(summarizeAgentInput(storedAgent.name, storedFormInput)); - setPhase("running"); setError(""); - setRunStartedAt(Date.now()); setElapsedMs(0); - const deadline = Date.now() + 12 * 60_000; - try { - let job: ProcurementJob; - if (stored.jobId) { - const { response, body } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement/${encodeURIComponent(stored.jobId)}`, { signal: controller.signal }, deadline); - if (!response.ok) throw new Error(message(body)); - job = parseProcurementJob(body); - } else { - // The reload raced the original POST response: re-POST the SAME - // stored input under the SAME key — inside the cross-tab lock, like - // every paid dispatch, and refusing without a real lock manager. - // The lock only SERIALIZES: while this section was queued, another - // tab may have dismissed this record and started a new run, so the - // section re-validates its captured record against storage NOW and - // must never act on (or overwrite) a record it no longer owns. - job = await withExclusiveProcurementLock(browserLocks(), controller.signal, async () => { - const decision = resumeDispatchDecision(stored, readStoredRun()); - if (decision.action === "abort-stale") throw new StaleResumeRecordError(); - if (decision.action === "read") { - // Another tab already learned this run's job id — read it, never re-POST. - const { response, body } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement/${encodeURIComponent(decision.jobId)}`, { signal: controller.signal }, deadline); - if (!response.ok) throw new Error(message(body)); - return parseProcurementJob(body); - } - const { response, body } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": stored.runId }, - body: JSON.stringify(stored.input), signal: controller.signal, - }, deadline); - if (!response.ok) { - if (response.status >= 400 && response.status < 500) releaseStoredRun(stored.runId); - throw new Error(message(body)); - } - const parsedJob = parseProcurementJob(body); - updateStoredRun({ ...stored, jobId: parsedJob.id }); - return parsedJob; - }); - } - procurementJobRef.current = job; - setProcurementJob(job); - await followProcurementJob(job, controller, deadline); - } catch (cause) { - if (cause instanceof StaleResumeRecordError) { - // The record now belongs to another run (or is gone); this tab must - // not keep its key or offer procurement retries against it. - procurementRunId.current = null; - setPlan(null); setPhase("idle"); - setError(cause.message); - return; - } - const cancelled = (cause as Error).name === "AbortError"; - const failedJob = (procurementJobRef.current as ProcurementJob | null)?.status === "failed"; - setError(cancelled - ? "Stopped watching in this browser. Any live gateway job continues — resume again to keep following it." - : cause instanceof ProcurementLockUnavailableError - ? `${cause.message} Use a current version of Chrome, Edge, Firefox or Safari — no request was sent.` - : failedJob - ? (cause as Error).message - : `${(cause as Error).message} — Resuming again reuses the same idempotency key, so no second purchase can start.`); - setPhase("error"); - } finally { - if (runAbort.current === controller) runAbort.current = null; - } - } - - function selectAgent(agent: AgentCard) { - runAbort.current?.abort(); receiptAbort.current?.abort(); - setGoal(agent.exampleGoal); - const profile = profiles.find((candidate) => procurementProfileCard(candidate, defaultPaymentRail(candidate)).name === agent.name); - const paymentRail = profile ? defaultPaymentRail(profile) : "pay-dem"; - const railAgent = profile ? procurementProfileCard(profile, paymentRail) : agent; - setSelectedProfileId(profile?.id ?? null); - setSelectedPaymentRail(paymentRail); - setPlan({ - butler: { - selectedAgent: agent.name, - label: agent.label, - selectionEngine: "user-selected test", - rationale: `You selected ${agent.label}. Fill in the job below (or load its example), and I will supervise the run and show you the evidence it returns.`, - alternatives: [], - }, - proposedInput: railAgent.exampleInput, - inputNote: "Your values are submitted unchanged to the live gateway, which applies specialist validation before execution.", - }); - // Switching agents resets to that agent's blank form. Examples are never - // submitted silently — "Load example" below is the only way they enter. - // Schema-driven agents seed select/checkbox defaults so the form's display - // matches submitted state. - const schema = hasBuiltinForm(railAgent.name) ? null : parseAgentFieldSchema(railAgent); - setInputValue(schema ? initialSchemaInput(schema) : initialAgentInput(railAgent.name, paymentRail)); - procurementRunId.current = null; - setGatewayFieldErrors({}); setSubmittedSummary([]); - setResult(undefined); setProcurementJob(null); setReceipt(null); setReceiptMessage(""); setPhase("ready"); setError(""); - window.scrollTo({ top: 0, behavior: "smooth" }); - } - - function selectPaymentRail(rail: PaymentRail) { - if (!selectedProfile || rail === selectedPaymentRail || !selectedProfile.railReadiness[rail]?.executable) return; - const railAgent = procurementProfileCard(selectedProfile, rail); - setSelectedPaymentRail(rail); - setPlan((current) => current ? { ...current, proposedInput: railAgent.exampleInput } : current); - const schema = hasBuiltinForm(railAgent.name) ? null : parseAgentFieldSchema(railAgent); - setInputValue(schema ? initialSchemaInput(schema) : initialAgentInput(railAgent.name, rail)); - procurementRunId.current = null; - setGatewayFieldErrors({}); setSubmittedSummary([]); setError(""); - } - - function loadExample() { - if (!selected) return; - procurementRunId.current = null; - setInputValue(JSON.parse(JSON.stringify(selected.exampleInput)) as Record); - setGatewayFieldErrors({}); - setError(""); - } - - function editInput(next: Record) { - setInputValue(next); - // Edited input is a new intent: a fresh idempotency key so a retry can't - // dedupe to a job that ran the previous payload. - procurementRunId.current = null; - // Stale gateway verdicts don't apply to edited input. - if (Object.keys(gatewayFieldErrors).length) setGatewayFieldErrors({}); - } - - return ( -
    -
    -
    - LIVE DACS WALKTHROUGH -

    Buy agent work.
    Watch the deal.

    -
    -

    Choose a real procurement route and how to pay: native DEM on Demos, or USDC through x402 on Base Sepolia. The Butler verifies the complete deal and exposes every receipt as it happens.

    -
    - - {/* Suppress the banner only when THIS tab is already tracking the - record's job. A record with no jobId (the reload-raced-the-POST - case) must always surface — that is the exact state it protects. */} - {storedRun && phase !== "running" && !(procurementJob && storedRun.jobId && procurementJob.id === storedRun.jobId) && ( -
    -
    - A procurement run from this browser is still on record -

    Started {new Date(storedRun.startedAt).toLocaleString()}{storedRun.jobId ? ` · job ${compact(storedRun.jobId, 8, 6)}` : " · the job id was never received"}. Resuming reconnects under the same idempotency key, so it can never start a second purchase.

    -
    -
    - - -
    -
    - )} - -
    -
    -
    B
    DACS Butler{agents.length ? `${agents.length} procurement routes live · gateway connected` : "Connecting to the procurement gateway…"}
    -
    -
    Butler

    Welcome. Choose how you want to buy agent work. I’ll run the complete live DACS deal and explain the evidence produced at every phase.

    - {goal && phase !== "idle" &&
    You

    {goal}

    } - {plan &&
    Butler

    {plan.butler.label} is ready. {plan.butler.rationale}

    {plan.butler.selectionEngine}
    } - {phase === "done" &&
    Butler

    The specialist finished. Its result is ready now; the separate receipt status below shows any on-chain anchoring still completing in the background.

    } - {error &&
    Stopped safely

    {error}

    } -
    - - {phase === "idle" || (phase === "error" && !plan) ? ( -
    -
    Choose a live procurement{agents.length} available
    -
    {agents.map((agent) => { - const profile = profiles.find((candidate) => procurementProfileCard(candidate).name === agent.name); - const liveRails = profile?.paymentRails.filter((rail) => profile.railReadiness[rail]?.executable).map(paymentRailLabel).join(" · "); - return ; - })}
    -
    - ) : phase === "error" && plan ? ( -
    - {isProcurementSel && procurementJob?.status === "failed" && procurementJob.failedBeforePayment === true ? ( - <> -
    Procurement failed before any paymentThe gateway confirms no money moved (its reason is shown above). Retrying starts a fresh purchase attempt with a new idempotency key.
    -
    - - ) : isProcurementSel && procurementJob?.status === "failed" ? ( - <> -
    Procurement failed after payment may have moved — do not re-purchaseJob {procurementJob.id} is preserved on the gateway with its payment evidence; the gateway cannot confirm the failure happened before payment, so starting a new run could pay twice. A gateway operator can recover the delivery for the existing job without another payment.
    -
    - - ) : isProcurementSel && procurementJob ? ( - <> -
    Watching stopped — the procurement job is still live on the gatewayJob {procurementJob.id} was not cancelled; resuming follows the same job and never starts a second purchase.
    -
    - - ) : isProcurementSel ? ( - <> -
    Procurement stopped safelyRetrying reuses this run’s idempotency key, so the gateway resumes the existing job rather than starting a second paid purchase.
    -
    - - ) : ( - <> -
    {plan.butler.label} stopped safelyYour entered job details are still available.
    -
    - - )} -
    - ) : plan && phase === "ready" && selected ? ( -
    -
    {selectedProfile ? procurementModeLabel(selectedProfile.mode) : "Job details"}{plan.inputNote}
    {inputIsValid ? `ready · ${paymentRailLabel(selectedPaymentRail)}` : "fields need attention"}
    - {selectedProfile &&
    -
    Choose payment railThis changes the real asset and settlement network.
    -
    {selectedProfile.paymentRails.map((rail) => { - const readiness = selectedProfile.railReadiness[rail]; - const ready = readiness?.executable === true; - return ; - })}
    - {selectedRailReadiness?.railGovernance &&
    - {selectedRailReadiness.railGovernance.conformantAuthority ? "Canonical rail authority" : "Operator-provisional rail authority"}{selectedRailReadiness.railGovernance.conformantAuthority ? "Authority conforms to the current DACS rail registry rules." : "Usable in this live demo while the standardised rail authority is being agreed."} - Read disclosure ↗ -
    } -
    - Hosted demo buyer · automatic executionThe gateway runs every phase server-side with its demo identity and wallets. It does not pause for confirmations or accept your own DACS identity or payment signer. -
    -
    } - -
    - - - -
    -
    - ) : phase === "running" && isProcurementSel ? ( -
    {procurementWaiting ? `QUEUED · POSITION ${procurementJob?.queue?.position ?? "…"} · ${elapsedLabel(elapsedMs)}` : procurementPreview ? `DELIVERY VERIFIED · DACS-5 FINALISING · ${elapsedLabel(elapsedMs)}` : `FULL DACS FLOW · ${paymentRailLabel(selectedPaymentRail)} · ${elapsedLabel(elapsedMs)}`}
    {procurementWaiting ? "Your purchase is safely queued behind the active buyer-wallet deal. No payment has started yet." : procurementPreview ? "The result is available below. Keep this page open while both mandatory bundle copies reconcile." : "Keep this page open — settlement and chain confirmations appear here live."}
    {submittedSummary.length > 0 &&
    SUBMITTED
      {submittedSummary.map((line, index) =>
    • {line}
    • )}
    }
    {(procurementJob?.events ?? []).map((event, index) =>
    {index === (procurementJob?.events.length ?? 0) - 1 ? "·" : "✓"}{event.label}{new Date(event.at).toLocaleTimeString()}{event.txRef ? ` · tx ${compact(event.txRef, 12, 6)}` : ""}
    )}
    - ) : phase === "running" ?
    Specialist is workingAgent execution · {elapsedLabel(elapsedMs)} elapsed · 2-minute deadline{submittedSummary.length > 0 &&
    SUBMITTED
      {submittedSummary.map((line, index) =>
    • {line}
    • )}
    }
    - : null} -
    - - -
    - - {procurementPreview && selectedProfile && result === undefined && ( -
    - -
    - )} - - {result !== undefined && selectedProfile &&
    {procurementAccepted ? "verified" : "verification incomplete"}

    {selected?.label ?? "Agent"} result

    {specialistDurationMs !== undefined && Specialist completed in {elapsedLabel(specialistDurationMs)} · {paymentRailLabel(selectedPaymentRail)}}
    } - -
    THREE WAYS TO PROCURE

    Production agents, DEM or x402, full DACS

    Each route uses the gateway’s rail-specific live schema and runs Identify → Vet → Negotiate → Settle → Verify. Sealed tender stays hidden until its DACS-3 role model is released.

    {profiles.map((profile, index) => { - const agent = procurementProfileCard(profile, defaultPaymentRail(profile)); - return ; - })}
    -
    - ); -} diff --git a/reference-implementations/dacs-directory/src/components/directory-evidence-state.ts b/reference-implementations/dacs-directory/src/components/directory-evidence-state.ts index 6912230..96ab70c 100644 --- a/reference-implementations/dacs-directory/src/components/directory-evidence-state.ts +++ b/reference-implementations/dacs-directory/src/components/directory-evidence-state.ts @@ -1,6 +1,7 @@ import { safePublicEndpoint } from "../catalog/publicEndpoint.js"; import { effectiveReachabilityStatus } from "../catalog/reachabilityStatus.js"; import type { IdentityTier, ListingSummary, SellerRecord } from "../catalog/types.js"; +import { COMPLETION_VERIFICATION_UNAVAILABLE, observedOutcomesLabel } from "../catalog/testnetMetricPolicy.js"; export const UNMEASURED_REACHABILITY_LABEL = "Not measured by Directory" as const; @@ -80,10 +81,8 @@ export function directoryEvidenceState( deals: Object.freeze({ completed, total, - label: `${completed} strict-verified / ${total} agreement${total === 1 ? "" : "s"}`, - explanation: total === 0 - ? "No verified two-sided bundle history yet" - : "Both parties' signed copies must verify and agree before a bundle counts", + label: observedOutcomesLabel(total), + explanation: COMPLETION_VERIFICATION_UNAVAILABLE, }), }); } diff --git a/reference-implementations/dacs-directory/src/components/home-hero-state.ts b/reference-implementations/dacs-directory/src/components/home-hero-state.ts index 5690203..4a3d97a 100644 --- a/reference-implementations/dacs-directory/src/components/home-hero-state.ts +++ b/reference-implementations/dacs-directory/src/components/home-hero-state.ts @@ -1,3 +1,6 @@ +import { activeCatalogListings, activeCatalogSellers } from "../catalog/discovery.js"; +import type { Catalog } from "../catalog/types.js"; + export type HomeCatalogDisplayState = "indexing" | "empty" | "summary"; export function homeCatalogDisplayState( @@ -7,3 +10,35 @@ export function homeCatalogDisplayState( if (!indexed) return "indexing"; return activeListingCount > 0 ? "summary" : "empty"; } + +export interface HomeCatalogSummary { + activeServices: number; + sellers: number; + verifiedDeals: number; + freshness: { value: string; label: string }; +} + +const FRESHNESS_LABELS: Record = { + indexing: "initial chain index", + empty: "last index; no active listings", + summary: "since last index", +}; + +/** The landing page's catalog summary strip, computed from the stored catalog. */ +export function homeCatalogSummary(catalog: Catalog, now: number): HomeCatalogSummary { + const sellers = activeCatalogSellers(catalog.sellers); + const listings = activeCatalogListings(catalog); + const indexed = catalog.generatedAt > 0; + // A clock behind the indexer's still reads as "0m", never a negative age. + const ageMinutes = indexed ? Math.max(0, Math.round((now - catalog.generatedAt) / 60_000)) : 0; + const age = ageMinutes < 60 ? `${ageMinutes}m` : `${Math.round(ageMinutes / 60)}h`; + return { + activeServices: listings.length, + sellers: sellers.length, + verifiedDeals: sellers.reduce((sum, seller) => sum + seller.deals.filter((deal) => deal.refsVerified).length, 0), + freshness: { + value: indexed ? age : "pending", + label: FRESHNESS_LABELS[homeCatalogDisplayState(indexed, listings.length)], + }, + }; +} diff --git a/reference-implementations/dacs-directory/src/components/try-chat-script.ts b/reference-implementations/dacs-directory/src/components/try-chat-script.ts deleted file mode 100644 index bfd9638..0000000 --- a/reference-implementations/dacs-directory/src/components/try-chat-script.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { ProcurementEvent } from "./try-dacs-contract.js"; - -/** - * Pure conversation model for the two-agent /try-chat proposal. - * - * The gateway emits a flat list of procurement events. This module turns each - * event into a chat turn attributed to the agent that produced it — Butler - * (the buyer, left) or the Auditor (the seller, right) — plus on-chain - * "receipt" turns and the independent EvalBot referee. Every turn keeps the - * gateway's exact `raw` label so the friendly text never becomes theatre: the - * UI can always reveal the original evidence string. - */ - -export type Speaker = "butler" | "seller" | "chain" | "referee"; - -/** DACS stage index 0..4 (Identify, Vet, Negotiate, Settle & deliver, Verify). */ -export type StageIndex = 0 | 1 | 2 | 3 | 4; - -export type ConversationTurn = { - /** Stable key = source event index. */ - id: number; - speaker: Speaker; - stage: StageIndex; - /** Plain-language line shown in the bubble. */ - text: string; - /** The gateway's exact event label — the underlying evidence. */ - raw: string; - /** "say" = spoken line · "anchor" = on-chain receipt · "pay" = DEM payment. */ - kind: "say" | "anchor" | "pay"; - txRef?: string; - anchorRef?: string; -}; - -export const SPEAKERS: Record = { - butler: { name: "Butler", role: "the buyer's agent", side: "left", avatar: "B" }, - seller: { name: "Auditor", role: "the seller's agent", side: "right", avatar: "A" }, - chain: { name: "Demos chain", role: "public ledger", side: "center", avatar: "D" }, - referee: { name: "EvalBot", role: "independent judge", side: "center", avatar: "E" }, -}; - -// Stage names match demos.network: Identify · Vet · Negotiate · Settle · Verify. -export const STAGES: { name: string; primitive: string; blurb: string }[] = [ - { name: "Identify", primitive: "DACS-1", blurb: "A signed, on-chain listing." }, - { name: "Vet", primitive: "DACS-2", blurb: "Check the counterparty." }, - { name: "Negotiate", primitive: "DACS-3", blurb: "Both sides sign the terms." }, - { name: "Settle", primitive: "DACS-4", blurb: "Payment and delivery, with evidence." }, - { name: "Verify", primitive: "DACS-5", blurb: "One bundle anyone can re-check." }, -]; - -/** Gateway phase → DACS stage. Failures/unknowns fall back to the caller's running stage. */ -const PHASE_STAGE: Record = { - queued: 0, connecting: 0, discovering: 0, - selecting: 1, - agreeing: 2, - settling: 3, delivering: 3, recovering: 3, - verifying: 4, evaluating: 4, complete: 4, -}; - -const demAmount = (label: string): string => label.match(/([\d.]+)\s*DEM/i)?.[1] ?? ""; - -/** - * Map one gateway event to a speaker + friendly line. Anchors (txRef/anchorRef - * present) become "chain" receipt turns regardless of who triggered them, so - * the ledger reads as a neutral third party. Unknown labels are attributed to - * the Butler verbatim — honest fallback, never invented dialogue. - */ -export function describeEvent(event: ProcurementEvent, runningStage: StageIndex): { speaker: Speaker; stage: StageIndex; text: string; kind: ConversationTurn["kind"] } { - const label = event.label; - const isAnchor = Boolean(event.txRef || event.anchorRef); - const stage = event.phase === "failed" ? runningStage : (PHASE_STAGE[event.phase] ?? runningStage); - - // — Identify — - if (/^Full DACS purchase queued/.test(label)) return { speaker: "butler", stage, kind: "say", text: "I need a security audit on this file, and I want the deal itself to be provable. Finding a seller." }; - if (/Connecting the Butler buyer wallet/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Connecting my wallet and opening a private channel." }; - if (/Resolving the indexed Auditor'?s signed DACS-1 listing/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Pulling the auditor's signed listing from the chain index." }; - if (/failed current-standard verification/.test(label)) return { speaker: "butler", stage, kind: "say", text: "That listing failed verification. Using my configured auditor binding instead." }; - if (/Verified the Auditor listing/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Signed listing verified. The seller's offer is on the record." }; - - // — Vet — - if (/Butler scoring the verified listing/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Scoring the offer against budget, capability and quality." }; - if (/opened a signed RFQ channel/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Auditor: I want a content-bound audit of one file. What are your terms?" }; - if (/Identity Vet record anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Identity vet anchored. The check itself is now on the record." }; - - // — Negotiate — - if (/opened|scoring/.test(label) === false && /RFQ|quote/.test(label)) return { speaker: "seller", stage, kind: "say", text: "Happy to. Let me price it against your files." }; - if (/Buyer\/seller agreement anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Agreed terms anchored." }; - if (/Commitment anchored before payment/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Commitment anchored. Terms are locked before any payment." }; - if (/Buyer and Auditor agreed/.test(label)) { const amt = demAmount(label); return { speaker: "seller", stage, kind: "say", text: amt ? `Agreed: quick scan, standard tier, ${amt} DEM.` : "Terms agreed." }; } - if (/Dual-signed agreement/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Both signatures anchored. Neither side can rewrite the terms." }; - - // — Settle & deliver — - if (/^Paying .*DEM to the negotiated Auditor/.test(label)) { const amt = demAmount(label); return { speaker: "butler", stage, kind: "pay", text: amt ? `Sending ${amt} DEM on the agreed rail.` : "Sending payment." }; } - if (/Payment broadcast on Demos/.test(label)) return { speaker: "chain", stage, kind: "pay", text: "Payment broadcast. Transaction hash below." }; - if (/Auditor verified payment and is scanning/.test(label)) return { speaker: "seller", stage, kind: "say", text: "Payment confirmed. Running the scan." }; - if (/Auditor signed and anchored the content-bound report/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Signed report anchored, bound to the exact file contents." }; - - // — Verify — - if (/Buyer anchoring payment evidence/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Anchoring my payment evidence. Requesting your bundle signature." }; - if (/Settlement evidence anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Buyer settlement evidence anchored." }; - if (/Buyer attestation bundle anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Full bundle anchored: listing, vet, terms, payment, delivery." }; - if (/EvalBot applying and signing the acceptance rubric/.test(label)) return { speaker: "referee", stage, kind: "say", text: "Checking the delivery against the agreed rubric. Verdict signed." }; - if (/Purchase settled, report delivered/.test(label)) return { speaker: "seller", stage, kind: "say", text: "Done. Paid and delivered. Anyone can re-verify this deal from the receipts." }; - - // Failures + anything unrecognised: honest, attributed to whoever the phase implies. - if (event.phase === "failed") return { speaker: "butler", stage, kind: "say", text: `Stopped safely: ${label}` }; - return { speaker: isAnchor ? "chain" : "butler", stage, kind: isAnchor ? "anchor" : "say", text: label }; -} - -/** Fold a gateway event stream into the ordered two-agent conversation. */ -export function eventsToConversation(events: readonly ProcurementEvent[]): ConversationTurn[] { - const turns: ConversationTurn[] = []; - let stage: StageIndex = 0; - events.forEach((event, id) => { - const known = event.phase === "failed" ? undefined : PHASE_STAGE[event.phase]; - if (known !== undefined && known > stage) stage = known; - const d = describeEvent(event, stage); - turns.push({ id, speaker: d.speaker, stage: d.stage, text: d.text, raw: event.label, kind: d.kind, txRef: event.txRef, anchorRef: event.anchorRef }); - }); - return turns; -} - -/** - * A REAL successful purchase, captured live from butler.agentcommerce.network - * (job d27cd332, 2026-07-20). Used to animate the demo without spending DEM; - * every tx hash below is a genuine on-chain record. Live runs use the same - * event shape through the same eventsToConversation() path. - */ -export const SAMPLE_PROCUREMENT_EVENTS: ProcurementEvent[] = [ - { phase: "queued", label: "Full DACS purchase queued", at: "2026-07-20T07:51:46.521Z" }, - { phase: "connecting", label: "Connecting the Butler buyer wallet and live L2PS transport", at: "2026-07-20T07:51:46.523Z" }, - { phase: "discovering", label: "Resolving the indexed Auditor's signed DACS-1 listing from chain", at: "2026-07-20T07:51:46.750Z" }, - { phase: "discovering", label: "Verified the Auditor listing advertised by the indexer", at: "2026-07-20T07:51:47.101Z", anchorRef: "stor-d0afc4fa205fb58e53b7aa0403cf1c5b2fe63dcb" }, - { phase: "selecting", label: "Butler scoring the verified listing against budget, capability, quality and rail", at: "2026-07-20T07:51:47.515Z" }, - { phase: "selecting", label: "Butler opened a signed RFQ channel with dacs-auditor", at: "2026-07-20T07:51:47.923Z" }, - { phase: "selecting", label: "Identity Vet record anchored", at: "2026-07-20T07:52:04.330Z", txRef: "658eafe7a97358e42578637a7a9d4c3340a75666040989398e5ff0937f956274", anchorRef: "stor-dd84a65499fc8db99febddab951ac32bcf5505d9" }, - { phase: "selecting", label: "Buyer/seller agreement anchored", at: "2026-07-20T07:52:35.270Z", txRef: "07e9131d3d130da9aba6be8108034a331755a940f92ee56470fb43128e3fa720", anchorRef: "stor-39b76601730caaac37e6a22bb45b5e05a0616a31" }, - { phase: "selecting", label: "Commitment anchored before payment", at: "2026-07-20T07:52:46.243Z", txRef: "c4751eaacb21a390cf8e5c83df14e4a43f9ba34c0630a66445f9230207d8410b", anchorRef: "stor-18f86935eb3e02a37f12fa64022015457780f857" }, - { phase: "agreeing", label: "Buyer and Auditor agreed quick/standard at 2.35 DEM", at: "2026-07-20T07:52:48.333Z" }, - { phase: "agreeing", label: "Dual-signed agreement and commitment anchored before payment", at: "2026-07-20T07:52:48.333Z", anchorRef: "stor-18f86935eb3e02a37f12fa64022015457780f857" }, - { phase: "settling", label: "Paying 2.35 DEM to the negotiated Auditor", at: "2026-07-20T07:52:48.333Z" }, - { phase: "settling", label: "Payment broadcast on Demos", at: "2026-07-20T07:52:49.968Z", txRef: "53dd8a7b34f7d29377c27599e17a5742b2c7296dd048b1235c04359957e0ff24" }, - { phase: "delivering", label: "Auditor verified payment and is scanning the posted source", at: "2026-07-20T07:52:57.985Z" }, - { phase: "delivering", label: "Auditor signed and anchored the content-bound report", at: "2026-07-20T07:53:22.268Z", anchorRef: "stor-fca0580b7b5509b665027cf985cea0942e517056" }, - { phase: "verifying", label: "Buyer anchoring payment evidence and requesting the Auditor's bundle signature", at: "2026-07-20T07:53:22.269Z" }, - { phase: "verifying", label: "Settlement evidence anchored", at: "2026-07-20T07:53:43.083Z", txRef: "e8ed20357a654c4d98acbd3383f07d2306cf0bd63ff8f95533c4b79b780414aa", anchorRef: "stor-d5f0a0fc96e0bc89b6471fa345085748c3f31bf2" }, - { phase: "verifying", label: "Buyer attestation bundle anchored", at: "2026-07-20T07:54:15.328Z", txRef: "7201df09090a43f73c97057659b7daa44a3314c348eab4c22e1cabf21118224b", anchorRef: "stor-e73f88f1b09364b895390cd40ba7b40832582233" }, - { phase: "evaluating", label: "EvalBot applying and signing the acceptance rubric", at: "2026-07-20T07:54:15.665Z" }, - { phase: "complete", label: "Purchase settled, report delivered, and full DACS bundle verified", at: "2026-07-20T07:54:15.745Z" }, -]; diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts b/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts deleted file mode 100644 index a4cb25e..0000000 --- a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts +++ /dev/null @@ -1,689 +0,0 @@ -export type AgentCard = { - name: string; - label: string; - summary: string; - tags: string[]; - exampleGoal: string; - exampleInput: Record; - /** Optional gateway-published execution mode (passed through verbatim). */ - mode?: string; - /** Optional gateway-published input field schema (name/type/required/description). */ - input?: unknown; -}; - -export type PaymentRail = "pay-dem" | "pay-x402"; - -export type RailGovernance = { - status: string; - conformantAuthority: boolean; - signer: string; - disclosure: string; -}; - -export type ProcurementRailInput = { - rail: PaymentRail; - fields: unknown[]; - sampleInput: Record; -}; - -export type ProcurementRailReadiness = { - executable: boolean; - reasons: string[]; - railGovernance?: RailGovernance; -}; - -export type ProcurementProfile = { - id: string; - title: string; - agentName: string; - serviceId: string; - mode: string; - negotiationPhase: string; - summary: string; - fields: unknown[]; - sampleInput: Record; - timing: { - healthyMinSec: number; - healthyMaxSec: number; - hardTimeoutSec: number; - protocolFloorSec: number; - }; - executionControl: { - model: "server-orchestrated"; - interactiveConfirmation: false; - }; - buyerControl: { - model: "gateway-custodied-demo"; - acceptsExternalDacsIdentity: false; - acceptsExternalPaymentSigner: false; - }; - paymentRails: PaymentRail[]; - railInputs: ProcurementRailInput[]; - railReadiness: Record; - implementationStatus: string; - executable: boolean; - reasons: string[]; -}; - -const PROFILE_AGENT_NAMES: Record = { - "oracle-auto-accept": "oracle-desk", - "dd-live-fixed": "dd-researcher", - "security-audit-rfq": "procurement-butler", -}; - -/** Only these production profiles are deliberately presented as live demos. */ -export const LIVE_PROCUREMENT_PROFILE_IDS = Object.freeze(Object.keys(PROFILE_AGENT_NAMES)); - -export type ProcurementEvent = { - phase: string; - label: string; - at: string; - txRef?: string; - anchorRef?: string; -}; - -export type ProcurementPreview = { - kind: "dacs-procurement-delivery-preview"; - status: "delivery-verified-finalising-dacs5" | "report-verified-finalising-dacs5"; - jobId: string; - delivery: { - verified: true; - report: Record; - }; - anchors: { - listing?: string; - agreement?: string; - commitment?: string; - paymentEvidence?: string; - delivery?: string; - deliveryEvidence?: string; - }; -}; - -export type ProcurementJob = { - id: string; - status: "running" | "complete" | "failed"; - phase: string; - events: ProcurementEvent[]; - /** - * A verified delivery that is safe to inspect while DACS-5 continues. - * This is deliberately nonterminal and must never be treated as acceptance. - */ - preview?: ProcurementPreview; - result?: unknown; - error?: string; - /** - * Gateway-published only on failed jobs: true means NO payment was - * broadcast, so a fresh purchase is safe. Absent or false must be treated - * as "a payment may exist" — never re-purchase. - */ - failedBeforePayment?: boolean; - queue?: { - status: "waiting" | "active" | "finished"; - enqueuedAt: string; - position?: number; - startedAt?: string; - waitMs?: number; - finishedAt?: string; - }; -}; - -export type ProcurementEvidence = { - statusAccepted: boolean; - paymentRecorded: boolean; - negotiationSigned: boolean; - negotiationVerified: boolean; - deliveryVerified: boolean; - bundlesVerified: boolean; - reconciled: boolean; - rulingValid: boolean; - rulingAccepted: boolean; - rulingRequired: boolean; - overallAccepted: boolean; -}; - -export type ReceiptStatus = "queued" | "anchoring" | "broadcast" | "confirmed" | "failed"; - -/** - * The gateway publishes two attestation shapes: the asynchronous receipt - * (receiptId + statusUrl, pollable/retryable) and the synchronous - * LIVE-ANCHOR-storage attestation (digest + anchor + txRef, no status URL). - * Both carry status/digest/anchorAddress/note; polling fields are optional - * and the UI only polls or retries when a statusUrl is present. - */ -export type OutputReceipt = { - receiptId?: string; - statusUrl?: string; - status: ReceiptStatus; - attempts?: number; - createdAt?: string; - updatedAt?: string; - digest: string; - anchorAddress: string; - anchorName?: string; - scheme?: string; - committedBy?: string; - txRef?: string; - error?: string; - note: string; -}; - -export type ButlerRun = Record & { - result: unknown; - execution?: { requestId: string; durationMs: number }; - outputAttestation?: OutputReceipt; -}; - -export class ButlerContractError extends Error { - constructor(path: string, expectation: string) { - super(`Butler gateway returned an invalid ${path}; expected ${expectation}`); - this.name = "ButlerContractError"; - } -} - -export class AgentInputError extends Error { - constructor() { - super("Job details must be a JSON object, not a string, array, or null."); - this.name = "AgentInputError"; - } -} - -export const PROCUREMENT_TIMEOUT_MESSAGE = "The full procurement flow exceeded its 12-minute deadline."; -export const AGENT_TIMEOUT_MESSAGE = "The specialist did not respond within 2 minutes. You can retry or cancel safely."; - -export function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -} - -function requiredRecord(value: unknown, path: string): Record { - const parsed = record(value); - if (Object.keys(parsed).length === 0 && (value === null || typeof value !== "object" || Array.isArray(value))) { - throw new ButlerContractError(path, "an object"); - } - return parsed; -} - -function requiredString(value: unknown, path: string): string { - if (typeof value !== "string" || !value.trim()) throw new ButlerContractError(path, "a non-empty string"); - return value; -} - -function optionalString(value: unknown, path: string): string | undefined { - if (value === undefined) return undefined; - return requiredString(value, path); -} - -function requiredNumber(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) throw new ButlerContractError(path, "a finite number"); - return value; -} - -function requiredBoolean(value: unknown, path: string): boolean { - if (typeof value !== "boolean") throw new ButlerContractError(path, "a boolean"); - return value; -} - -function requiredStringArray(value: unknown, path: string): string[] { - if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { - throw new ButlerContractError(path, "an array of strings"); - } - return value; -} - -function requiredPaymentRail(value: unknown, path: string): PaymentRail { - if (value !== "pay-dem" && value !== "pay-x402") { - throw new ButlerContractError(path, '"pay-dem" or "pay-x402"'); - } - return value; -} - -function requiredHttpsUrl(value: unknown, path: string): string { - const text = requiredString(value, path); - try { - const url = new URL(text); - if (url.protocol !== "https:" || url.username || url.password) throw new Error("unsafe URL"); - return url.toString(); - } catch { - throw new ButlerContractError(path, "a public HTTPS URL without credentials"); - } -} - -export function parseAgentInput(value: unknown): Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) throw new AgentInputError(); - return value as Record; -} - -export async function fetchJsonBeforeDeadline( - input: RequestInfo | URL, - init: RequestInit | undefined, - deadlineMs: number, - fetcher: typeof fetch = fetch, - timeoutMessage = PROCUREMENT_TIMEOUT_MESSAGE, -): Promise<{ response: Response; body: T }> { - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) throw new Error(timeoutMessage); - const controller = new AbortController(); - const externalSignal = init?.signal; - const abortFromExternal = () => controller.abort(externalSignal?.reason); - if (externalSignal?.aborted) abortFromExternal(); - else externalSignal?.addEventListener("abort", abortFromExternal, { once: true }); - let deadlineExpired = false; - const timer = setTimeout(() => { - deadlineExpired = true; - controller.abort(); - }, remainingMs); - try { - const response = await fetcher(input, { ...init, signal: controller.signal }); - const body = await response.json() as T; - return { response, body }; - } catch (cause) { - if (deadlineExpired) throw new Error(timeoutMessage); - throw cause; - } finally { - clearTimeout(timer); - externalSignal?.removeEventListener("abort", abortFromExternal); - } -} - -export function fetchJsonWithTimeout( - input: RequestInfo | URL, - init: RequestInit | undefined, - timeoutMs: number, - timeoutMessage: string, - fetcher: typeof fetch = fetch, -): Promise<{ response: Response; body: T }> { - return fetchJsonBeforeDeadline(input, init, Date.now() + timeoutMs, fetcher, timeoutMessage); -} - -export function parseAgentCatalog(value: unknown): AgentCard[] { - const body = requiredRecord(value, "agent catalog"); - if (!Array.isArray(body.agents)) throw new ButlerContractError("agent catalog.agents", "an array"); - if (body.agents.length === 0) throw new ButlerContractError("agent catalog.agents", "at least one agent"); - return body.agents.map((value, index) => { - const path = `agent catalog.agents[${index}]`; - const agent = requiredRecord(value, path); - if (!Array.isArray(agent.tags) || agent.tags.some((tag) => typeof tag !== "string")) { - throw new ButlerContractError(`${path}.tags`, "an array of strings"); - } - return { - name: requiredString(agent.name, `${path}.name`), - label: requiredString(agent.label, `${path}.label`), - summary: requiredString(agent.summary, `${path}.summary`), - tags: agent.tags, - exampleGoal: requiredString(agent.exampleGoal, `${path}.exampleGoal`), - exampleInput: requiredRecord(agent.exampleInput, `${path}.exampleInput`), - // Pass the gateway's own schema surface through untouched; the form - // layer decides whether it can render it (and falls back safely). - mode: optionalString(agent.mode, `${path}.mode`), - input: agent.input, - }; - }); -} - -export function parseProcurementProfiles(value: unknown): ProcurementProfile[] { - const body = requiredRecord(value, "procurement options"); - if (!Array.isArray(body.profiles)) throw new ButlerContractError("procurement options.profiles", "an array"); - return body.profiles.map((value, index) => { - const path = `procurement options.profiles[${index}]`; - const profile = requiredRecord(value, path); - const timing = requiredRecord(profile.timing, `${path}.timing`); - if (!Array.isArray(profile.fields)) throw new ButlerContractError(`${path}.fields`, "an array"); - const paymentRails = requiredStringArray(profile.paymentRails, `${path}.paymentRails`) - .map((rail, railIndex) => requiredPaymentRail(rail, `${path}.paymentRails[${railIndex}]`)); - if (paymentRails.length === 0 || new Set(paymentRails).size !== paymentRails.length) { - throw new ButlerContractError(`${path}.paymentRails`, "one or more unique supported rails"); - } - if (!Array.isArray(profile.railInputs)) throw new ButlerContractError(`${path}.railInputs`, "an array"); - const railInputs = profile.railInputs.map((value, railIndex): ProcurementRailInput => { - const railPath = `${path}.railInputs[${railIndex}]`; - const input = requiredRecord(value, railPath); - if (!Array.isArray(input.fields)) throw new ButlerContractError(`${railPath}.fields`, "an array"); - const rail = requiredPaymentRail(input.rail, `${railPath}.rail`); - const sampleInput = requiredRecord(input.sampleInput, `${railPath}.sampleInput`); - if (sampleInput.paymentRail !== rail) throw new ButlerContractError(`${railPath}.sampleInput.paymentRail`, rail); - return { - rail, - fields: input.fields, - sampleInput, - }; - }); - if (new Set(railInputs.map((input) => input.rail)).size !== railInputs.length) { - throw new ButlerContractError(`${path}.railInputs`, "one unique schema per supported rail"); - } - const readinessSource = requiredRecord(profile.railReadiness, `${path}.railReadiness`); - const railReadiness: Record = { "pay-dem": undefined, "pay-x402": undefined }; - for (const rail of paymentRails) { - const readinessPath = `${path}.railReadiness.${rail}`; - const readiness = requiredRecord(readinessSource[rail], readinessPath); - const governanceSource = readiness.railGovernance; - let railGovernance: RailGovernance | undefined; - if (governanceSource !== undefined) { - const governance = requiredRecord(governanceSource, `${readinessPath}.railGovernance`); - railGovernance = { - status: requiredString(governance.status, `${readinessPath}.railGovernance.status`), - conformantAuthority: requiredBoolean(governance.conformantAuthority, `${readinessPath}.railGovernance.conformantAuthority`), - signer: requiredString(governance.signer, `${readinessPath}.railGovernance.signer`), - disclosure: requiredHttpsUrl(governance.disclosure, `${readinessPath}.railGovernance.disclosure`), - }; - } - railReadiness[rail] = { - executable: requiredBoolean(readiness.executable, `${readinessPath}.executable`), - reasons: requiredStringArray(readiness.reasons, `${readinessPath}.reasons`), - ...(railGovernance ? { railGovernance } : {}), - }; - } - for (const rail of paymentRails) { - if (!railInputs.some((input) => input.rail === rail)) { - throw new ButlerContractError(`${path}.railInputs`, `an input schema for ${rail}`); - } - } - const hasExecutionControl = profile.executionControl !== undefined; - const hasBuyerControl = profile.buyerControl !== undefined; - let executionControl: ProcurementProfile["executionControl"]; - let buyerControl: ProcurementProfile["buyerControl"]; - if (!hasExecutionControl && !hasBuyerControl) { - // One-release compatibility for the already-deployed gateway contract. - // `confirmationGates` described phases but the gateway never paused; - // normalize that old shape to what it actually did. - requiredStringArray(profile.confirmationGates, `${path}.confirmationGates`); - executionControl = { model: "server-orchestrated", interactiveConfirmation: false }; - buyerControl = { - model: "gateway-custodied-demo", - acceptsExternalDacsIdentity: false, - acceptsExternalPaymentSigner: false, - }; - } else { - const source = requiredRecord(profile.executionControl, `${path}.executionControl`); - if (source.model !== "server-orchestrated" || source.interactiveConfirmation !== false) { - throw new ButlerContractError( - `${path}.executionControl`, - 'the supported server-orchestrated, non-interactive execution model', - ); - } - executionControl = { model: "server-orchestrated", interactiveConfirmation: false }; - const buyerSource = requiredRecord(profile.buyerControl, `${path}.buyerControl`); - if (buyerSource.model !== "gateway-custodied-demo" - || buyerSource.acceptsExternalDacsIdentity !== false - || buyerSource.acceptsExternalPaymentSigner !== false) { - throw new ButlerContractError( - `${path}.buyerControl`, - "the supported gateway-custodied demo buyer model", - ); - } - buyerControl = { - model: "gateway-custodied-demo", - acceptsExternalDacsIdentity: false, - acceptsExternalPaymentSigner: false, - }; - } - return { - id: requiredString(profile.id, `${path}.id`), - title: requiredString(profile.title, `${path}.title`), - agentName: requiredString(profile.agentName, `${path}.agentName`), - serviceId: requiredString(profile.serviceId, `${path}.serviceId`), - mode: requiredString(profile.mode, `${path}.mode`), - negotiationPhase: requiredString(profile.negotiationPhase, `${path}.negotiationPhase`), - summary: requiredString(profile.summary, `${path}.summary`), - fields: profile.fields, - sampleInput: requiredRecord(profile.sampleInput, `${path}.sampleInput`), - timing: { - healthyMinSec: requiredNumber(timing.healthyMinSec, `${path}.timing.healthyMinSec`), - healthyMaxSec: requiredNumber(timing.healthyMaxSec, `${path}.timing.healthyMaxSec`), - hardTimeoutSec: requiredNumber(timing.hardTimeoutSec, `${path}.timing.hardTimeoutSec`), - protocolFloorSec: requiredNumber(timing.protocolFloorSec, `${path}.timing.protocolFloorSec`), - }, - executionControl, - buyerControl, - paymentRails, - railInputs, - railReadiness, - implementationStatus: requiredString(profile.implementationStatus, `${path}.implementationStatus`), - executable: requiredBoolean(profile.executable, `${path}.executable`), - reasons: requiredStringArray(profile.reasons, `${path}.reasons`), - }; - }); -} - -export function procurementRailInput(profile: ProcurementProfile, rail: PaymentRail): ProcurementRailInput { - const input = profile.railInputs.find((candidate) => candidate.rail === rail); - if (!input) throw new ButlerContractError(`procurement profile ${profile.id}`, `an input schema for ${rail}`); - return input; -} - -export function procurementProfileCard(profile: ProcurementProfile, rail: PaymentRail = profile.paymentRails[0]!): AgentCard { - const name = PROFILE_AGENT_NAMES[profile.id]; - if (!name) throw new ButlerContractError(`procurement profile ${profile.id}`, "a supported live demo profile"); - const railInput = procurementRailInput(profile, rail); - return { - name, - label: profile.agentName, - summary: profile.summary, - tags: [profile.mode, profile.serviceId, rail], - exampleGoal: profile.title, - exampleInput: railInput.sampleInput, - mode: profile.mode, - input: railInput.fields, - }; -} - -const PREVIEW_ANCHORS = [ - "listing", - "agreement", - "commitment", - "paymentEvidence", - "delivery", - "deliveryEvidence", -] as const; - -function parseProcurementPreview(value: unknown): ProcurementPreview { - const preview = requiredRecord(value, "procurement job.preview"); - if (preview.kind !== "dacs-procurement-delivery-preview") { - throw new ButlerContractError("procurement job.preview.kind", '"dacs-procurement-delivery-preview"'); - } - if (preview.status !== "delivery-verified-finalising-dacs5" && preview.status !== "report-verified-finalising-dacs5") { - throw new ButlerContractError( - "procurement job.preview.status", - '"delivery-verified-finalising-dacs5" or "report-verified-finalising-dacs5"', - ); - } - const delivery = requiredRecord(preview.delivery, "procurement job.preview.delivery"); - if (requiredBoolean(delivery.verified, "procurement job.preview.delivery.verified") !== true) { - throw new ButlerContractError("procurement job.preview.delivery.verified", "true"); - } - const report = requiredRecord(delivery.report, "procurement job.preview.delivery.report"); - if (Object.keys(report).length === 0) { - throw new ButlerContractError("procurement job.preview.delivery.report", "a non-empty verified report object"); - } - const rawAnchors = requiredRecord(preview.anchors, "procurement job.preview.anchors"); - const anchors: ProcurementPreview["anchors"] = {}; - for (const key of PREVIEW_ANCHORS) { - const anchor = optionalString(rawAnchors[key], `procurement job.preview.anchors.${key}`); - if (anchor !== undefined) anchors[key] = anchor; - } - if (!anchors.delivery || !anchors.paymentEvidence) { - throw new ButlerContractError("procurement job.preview.anchors", "delivery and paymentEvidence references"); - } - return { - kind: preview.kind, - status: preview.status, - jobId: requiredString(preview.jobId, "procurement job.preview.jobId"), - delivery: { verified: true, report }, - anchors, - }; -} - -export function parseProcurementJob(value: unknown): ProcurementJob { - const job = requiredRecord(value, "procurement job"); - if (job.status !== "running" && job.status !== "complete" && job.status !== "failed") { - throw new ButlerContractError("procurement job.status", '"running", "complete", or "failed"'); - } - if (!Array.isArray(job.events)) throw new ButlerContractError("procurement job.events", "an array"); - const events = job.events.map((value, index) => { - const path = `procurement job.events[${index}]`; - const event = requiredRecord(value, path); - return { - phase: requiredString(event.phase, `${path}.phase`), - label: requiredString(event.label, `${path}.label`), - at: requiredString(event.at, `${path}.at`), - txRef: optionalString(event.txRef, `${path}.txRef`), - anchorRef: optionalString(event.anchorRef, `${path}.anchorRef`), - }; - }); - if (job.status === "complete") requiredRecord(job.result, "procurement job.result"); - // A terminal producer may omit the nonterminal preview or serialize it as - // JSON null. Both mean "no preview" and neither manufactures a result. - const preview = job.preview === undefined || job.preview === null ? undefined : parseProcurementPreview(job.preview); - if (job.status === "complete" && preview) { - throw new ButlerContractError("procurement job.preview", "no preview after terminal completion"); - } - if (job.failedBeforePayment !== undefined && typeof job.failedBeforePayment !== "boolean") { - throw new ButlerContractError("procurement job.failedBeforePayment", "a boolean"); - } - let queue: ProcurementJob["queue"]; - if (job.queue !== undefined) { - const rawQueue = requiredRecord(job.queue, "procurement job.queue"); - if (rawQueue.status !== "waiting" && rawQueue.status !== "active" && rawQueue.status !== "finished") { - throw new ButlerContractError("procurement job.queue.status", '"waiting", "active", or "finished"'); - } - const position = optionalNumber(rawQueue.position, "procurement job.queue.position"); - const waitMs = optionalNumber(rawQueue.waitMs, "procurement job.queue.waitMs"); - if (position !== undefined && (!Number.isSafeInteger(position) || position < 1)) { - throw new ButlerContractError("procurement job.queue.position", "a positive integer"); - } - if (waitMs !== undefined && waitMs < 0) { - throw new ButlerContractError("procurement job.queue.waitMs", "a non-negative number"); - } - queue = { - status: rawQueue.status, - enqueuedAt: requiredString(rawQueue.enqueuedAt, "procurement job.queue.enqueuedAt"), - position, - startedAt: optionalString(rawQueue.startedAt, "procurement job.queue.startedAt"), - waitMs, - finishedAt: optionalString(rawQueue.finishedAt, "procurement job.queue.finishedAt"), - }; - } - return { - id: requiredString(job.id, "procurement job.id"), - status: job.status, - phase: requiredString(job.phase, "procurement job.phase"), - events, - preview, - result: job.result, - error: optionalString(job.error, "procurement job.error"), - failedBeforePayment: job.failedBeforePayment, - queue, - }; -} - -function optionalNumber(value: unknown, path: string): number | undefined { - if (value === undefined) return undefined; - return requiredNumber(value, path); -} - -function parseOutputReceipt(value: unknown, path: string): OutputReceipt { - const receipt = requiredRecord(value, path); - if (receipt.status !== "queued" && receipt.status !== "anchoring" && receipt.status !== "broadcast" && receipt.status !== "confirmed" && receipt.status !== "failed") { - throw new ButlerContractError(`${path}.status`, '"queued", "anchoring", "broadcast", "confirmed", or "failed"'); - } - return { - receiptId: optionalString(receipt.receiptId, `${path}.receiptId`), - statusUrl: optionalString(receipt.statusUrl, `${path}.statusUrl`), - status: receipt.status, - attempts: optionalNumber(receipt.attempts, `${path}.attempts`), - createdAt: optionalString(receipt.createdAt, `${path}.createdAt`), - updatedAt: optionalString(receipt.updatedAt, `${path}.updatedAt`), - digest: requiredString(receipt.digest, `${path}.digest`), - anchorAddress: requiredString(receipt.anchorAddress, `${path}.anchorAddress`), - anchorName: optionalString(receipt.anchorName, `${path}.anchorName`), - scheme: optionalString(receipt.scheme, `${path}.scheme`), - committedBy: optionalString(receipt.committedBy, `${path}.committedBy`), - txRef: optionalString(receipt.txRef, `${path}.txRef`), - error: optionalString(receipt.error, `${path}.error`), - note: requiredString(receipt.note, `${path}.note`), - }; -} - -export function parseReceiptEnvelope(value: unknown): OutputReceipt { - const response = requiredRecord(value, "receipt response"); - return parseOutputReceipt(response.outputAttestation, "receipt response.outputAttestation"); -} - -export function parseButlerRun(value: unknown): ButlerRun { - const response = requiredRecord(value, "Butler response"); - const butler = requiredRecord(response.butler, "Butler response.butler"); - requiredString(butler.selectedAgent, "Butler response.butler.selectedAgent"); - requiredString(butler.label, "Butler response.butler.label"); - if (!("result" in response)) throw new ButlerContractError("Butler response.result", "an agent result"); - let execution: ButlerRun["execution"]; - if (response.execution !== undefined) { - const rawExecution = requiredRecord(response.execution, "Butler response.execution"); - execution = { - requestId: requiredString(rawExecution.requestId, "Butler response.execution.requestId"), - durationMs: requiredNumber(rawExecution.durationMs, "Butler response.execution.durationMs"), - }; - } - const outputAttestation = response.outputAttestation === undefined - ? undefined - : parseOutputReceipt(response.outputAttestation, "Butler response.outputAttestation"); - return { ...response, result: response.result, execution, outputAttestation }; -} - -/** - * The gateway has published two negotiation-signature shapes: a bare - * signature string, and the structured `{ party, algorithm, value }` record. - * Either counts as present when it carries a non-empty signature value. - */ -function signaturePresent(value: unknown): boolean { - if (typeof value === "string") return Boolean(value.trim()); - const structured = record(value); - return typeof structured.value === "string" && Boolean(structured.value.trim()); -} - -export function procurementEvidence(value: unknown, mode = "rfq"): ProcurementEvidence { - const report = record(value); - const settlement = record(report.settlement); - const negotiation = record(report.negotiation); - const delivery = record(report.delivery); - const evaluation = record(report.evaluation); - const ruling = record(evaluation.ruling); - const bundles = record(report.bundleVerification); - const reconciliation = record(report.reconciliation); - const anchors = record(report.anchors); - const transactions = Array.isArray(report.transactions) ? report.transactions.map(record) : []; - const paymentHash = typeof settlement.txHash === "string" ? settlement.txHash.trim() : ""; - const paymentRecorded = Boolean(paymentHash) && transactions.some((transaction) => - transaction.kind === "payment" && transaction.txRef === paymentHash, - ); - const statusAccepted = report.status === "settled-and-accepted" || report.status === "recovered-after-terminal-abort"; - const negotiationSigned = signaturePresent(negotiation.buyerSignature) && signaturePresent(negotiation.sellerSignature); - // Fixed-price flows publish the jointly-signed agreement as the anchored - // agreement/commitment pair rather than projecting both raw signatures into - // the result envelope. DACS-5 bundle verification then binds that agreement - // into the two-party receipt set. RFQ stays fail-closed on both signatures. - const fixedAgreementAnchored = mode.startsWith("fixed-price-") - && negotiation.protocol === "dacs-fixed/1" - && typeof negotiation.agreementHash === "string" && Boolean(negotiation.agreementHash.trim()) - && typeof anchors.agreement === "string" && Boolean(anchors.agreement.trim()) - && typeof anchors.commitment === "string" && Boolean(anchors.commitment.trim()); - const negotiationVerified = negotiationSigned || fixedAgreementAnchored; - const deliveryVerified = delivery.verified === true && Object.keys(record(delivery.report)).length > 0; - const bundlesVerified = bundles.ok === true; - const reconciled = reconciliation.reconciled === true; - const rulingValid = evaluation.rulingValid === true; - const rulingAccepted = evaluation.accepted === true && ruling.verdict === "accept"; - const rulingRequired = mode === "rfq"; - return { - statusAccepted, - paymentRecorded, - negotiationSigned, - negotiationVerified, - deliveryVerified, - bundlesVerified, - reconciled, - rulingValid, - rulingAccepted, - rulingRequired, - overallAccepted: statusAccepted && paymentRecorded && negotiationVerified && deliveryVerified && bundlesVerified && reconciled - && (!rulingRequired || (rulingValid && rulingAccepted)), - }; -} diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-forms.ts b/reference-implementations/dacs-directory/src/components/try-dacs-forms.ts deleted file mode 100644 index 46ad025..0000000 --- a/reference-implementations/dacs-directory/src/components/try-dacs-forms.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * Pure form logic for the /try agent forms: per-agent descriptors, blank - * defaults, local validation, gateway-error mapping, and submission summaries. - * - * The single source of truth for a run is the gateway input object itself — - * controls edit paths inside it, the Advanced JSON view edits the same object, - * and `runAgent()` submits it unchanged. Local validation is advisory for - * immediate feedback; the gateway remains authoritative. - */ -import type { AgentCard, PaymentRail } from "./try-dacs-contract.js"; - -/** - * A renderable field derived from the gateway's published input schema. The - * gateway describes fields as { name, type, required, description } (plus - * optional enum/min/max); this normalizes them for the generic form renderer. - */ -export type AgentFieldSchema = { - key: string; - label: string; - kind: "text" | "number" | "checkbox" | "select" | "textarea"; - required?: boolean; - help?: string; - options?: string[]; - min?: number; - max?: number; -}; - -export type FieldErrors = Record; - -export type SourceFile = { path: string; content: string }; - -export const ORACLE_PRODUCTS = ["crypto-price", "fx-rate", "chain-height"] as const; -export const ORACLE_DEFAULT_PARAMS: Record> = { - "crypto-price": { id: "bitcoin" }, - "fx-rate": { base: "USD", quote: "EUR" }, - "chain-height": {}, -}; -export const DD_KINDS = ["npm-package", "crypto-token"] as const; -export const COMPLIANCE_KINDS = ["person", "entity", "wallet"] as const; -export const EVAL_CHECKS = ["content-includes", "regex-match", "min-length"] as const; -export const ACCOUNT_KINDS = ["operating", "reserve", "payroll"] as const; - -export const KNOWN_FORM_AGENTS = [ - "procurement-butler", "oracle-desk", "dd-researcher", "dep-upgrade", - "evalbot", "treasury-ops", "site-auditor", "sec-audit", "compliance", -] as const; - -const rec = (value: unknown): Record => - value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const arr = (value: unknown): unknown[] => Array.isArray(value) ? value : []; -const str = (value: unknown): string => typeof value === "string" ? value : ""; -const num = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; - -const SCHEMA_KINDS: Record = { - string: "text", - text: "textarea", - number: "number", - integer: "number", - boolean: "checkbox", - enum: "select", -}; - -const schemaFieldKind = (field: Record): AgentFieldSchema["kind"] | null => { - // The gateway expresses an enum as a companion `enum` array on a string - // field ({ type: "string", enum: [...] }); it may also use type: "enum". - if (Array.isArray(field.enum)) return field.enum.every((option) => typeof option === "string") ? "select" : null; - return SCHEMA_KINDS[String(field.type)] ?? null; -}; - -const labelFromName = (name: string): string => - name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").replace(/^./, (c) => c.toUpperCase()); - -/** - * Parse the gateway's published input schema (agents[].input as - * { name, type, required, description }[]) into renderable fields. Fields the - * generic renderer cannot represent (objects, arrays, unknown types) yield - * null so the caller falls back to the built-in form or the Advanced JSON - * editor — a partially-rendered schema would silently drop required input. - */ -export function parseAgentFieldSchema(agent: AgentCard & { input?: unknown }): AgentFieldSchema[] | null { - if (!Array.isArray(agent.input) || agent.input.length === 0) return null; - const fields: AgentFieldSchema[] = []; - for (const value of agent.input) { - const field = rec(value); - if (typeof field.name !== "string" || !field.name.trim()) return null; - const kind = schemaFieldKind(field); - if (!kind) return null; - const options = Array.isArray(field.enum) ? field.enum as string[] : undefined; - fields.push({ - key: field.name, - label: labelFromName(field.name), - kind, - required: field.required === true, - help: typeof field.description === "string" ? field.description : undefined, - options: options as string[] | undefined, - min: num(field.min), - max: num(field.max), - }); - } - return fields; -} - -/** - * Validate an input object against a gateway-published field schema: required - * fields must be present and non-empty; numbers must parse and honour min/max; - * selects must hold one of the published options. Advisory only — the gateway - * remains authoritative. - */ -/** - * Default input for a schema-driven agent. Selects seed to their first option - * and checkboxes to false so what the form displays matches submitted state - * (a required select must not read as "chosen" while its value is empty). - * Text/number fields start absent so their required validation fires. - */ -export function initialSchemaInput(fields: AgentFieldSchema[]): Record { - const input: Record = {}; - for (const field of fields) { - if (field.kind === "checkbox") input[field.key] = false; - else if (field.kind === "select" && field.options && field.options.length > 0) input[field.key] = field.options[0]; - } - return input; -} - -export function validateSchemaInput(fields: AgentFieldSchema[], input: Record): FieldErrors { - const errors: FieldErrors = {}; - for (const field of fields) { - const value = input[field.key]; - const missing = value === undefined || value === null || (typeof value === "string" && value.trim() === ""); - if (field.required && field.kind === "checkbox") { - if (value !== true) errors[field.key] = `Required — ${field.label} must be checked.`; - continue; - } - if (field.required && missing) { - errors[field.key] = `Required — ${field.label}.`; - continue; - } - if (missing) continue; - if (field.kind === "number") { - const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed)) errors[field.key] = `${field.label} must be a number.`; - else if (field.min !== undefined && parsed < field.min) errors[field.key] = `${field.label} must be at least ${field.min}.`; - else if (field.max !== undefined && parsed > field.max) errors[field.key] = `${field.label} must be at most ${field.max}.`; - } else if (field.kind === "select" && field.options && !field.options.includes(String(value))) { - errors[field.key] = `${field.label} must be one of: ${field.options.join(", ")}.`; - } - } - return errors; -} - -/** True when the agent has a dedicated built-in form (vs schema/JSON fallback). */ -export function hasBuiltinForm(name: string): boolean { - return (KNOWN_FORM_AGENTS as readonly string[]).includes(name); -} - -/** Blank starting input per agent — examples are opt-in via "Load example". */ -export function initialAgentInput(name: string, paymentRail: PaymentRail = "pay-dem"): Record { - switch (name) { - case "procurement-butler": - return paymentRail === "pay-x402" - ? { goal: "", budgetUsdc: 0.1, files: [{ path: "", content: "" }], paymentRail } - : { goal: "", budgetDem: 5, files: [{ path: "", content: "" }], paymentRail }; - case "oracle-desk": - return { product: "crypto-price", params: { ...ORACLE_DEFAULT_PARAMS["crypto-price"] }, paymentRail }; - case "dd-researcher": - return { kind: "npm-package", subject: "", paymentRail }; - case "dep-upgrade": - return { packageJson: {}, includeNextMajor: false }; - case "evalbot": - return { - deliverable: { content: "" }, - rubric: { acceptThreshold: 80, criteria: [blankCriterion()] }, - }; - case "treasury-ops": - return { - policy: { accounts: [{ id: "ops", kind: "operating", minBalance: 0 }], allowlist: [], payroll: [], maxPerTransfer: 0, maxPerRun: 0 }, - balances: { ops: 0 }, - }; - case "site-auditor": - return { url: "", samples: 1 }; - case "sec-audit": - return { files: [{ path: "", content: "" }] }; - case "compliance": - return { kind: "entity", name: "" }; - default: - return {}; - } -} - -export function blankCriterion(): Record { - return { id: "", kind: "mechanical", weight: 1, description: "", test: { check: "content-includes", needle: "" } }; -} - -/** - * Switching a criterion's mechanical check swaps its parameters: needle for - * content-includes / regex-match, minChars for min-length. Stale parameters - * from the previous check must not be submitted (probed live: min-length - * without minChars silently scores "length < min undefined"). - */ -export function criterionCheckChange(test: Record, check: string): Record { - if (check === "min-length") { - return { check, minChars: typeof test.minChars === "number" ? test.minChars : 1 }; - } - if (check === "regex-match") { - // The gateway runs new RegExp(test.pattern); an undefined pattern matches - // everything and would falsely accept. Carry a string over from either key. - const carried = typeof test.pattern === "string" ? test.pattern : typeof test.needle === "string" ? test.needle : ""; - return { check, pattern: carried }; - } - const carried = typeof test.needle === "string" ? test.needle : typeof test.pattern === "string" ? test.pattern : ""; - return { check, needle: carried }; -} - -/** Repeatable-row helpers (pure; unit-tested). */ -export function addRow(rows: T[], blank: T): T[] { - return [...rows, blank]; -} -export function removeRow(rows: T[], index: number, minimum = 0): T[] { - if (rows.length <= minimum) return rows; - return rows.filter((_, i) => i !== index); -} - -// Gateway-valid targets include bare hosts (example.com) as well as full -// URLs; local validation must not reject what the gateway accepts. -const URL_PATTERN = /^(https?:\/\/)?[^\s/]+\.[^\s]{2,}/i; - -/** - * Advisory local validation: immediate feedback only. Keys are dotted paths - * matching the controls; the gateway's own validation remains authoritative. - */ -export function validateAgentInput(name: string, input: Record): FieldErrors { - const errors: FieldErrors = {}; - const requireText = (key: string, value: unknown, why: string) => { - if (!str(value).trim()) errors[key] = why; - }; - const requireFiles = (key: string, value: unknown) => { - const files = arr(value); - if (files.length === 0) { errors[key] = "Add at least one file."; return; } - files.forEach((file, index) => { - const f = rec(file); - if (!str(f.path).trim()) errors[`${key}.${index}.path`] = "Required so the report can reference this file."; - if (!str(f.content).trim()) errors[`${key}.${index}.content`] = "Required — this is the content the agent analyses."; - }); - }; - switch (name) { - case "procurement-butler": { - requireText("goal", input.goal, "Required — this states what you are purchasing."); - if (input.paymentRail === "pay-x402") { - const budget = num(input.budgetUsdc); - if (budget === undefined || budget <= 0 || budget > 10) errors.budgetUsdc = "Enter a Base Sepolia USDC budget above 0 and no more than 10."; - } else { - const budget = num(input.budgetDem); - if (budget === undefined || budget <= 0 || budget > 10) errors.budgetDem = "Enter a DEM budget above 0 and no more than 10."; - } - requireFiles("files", input.files); - break; - } - case "oracle-desk": { - const product = str(input.product); - if (!(ORACLE_PRODUCTS as readonly string[]).includes(product)) { - errors.product = `Pick one of: ${ORACLE_PRODUCTS.join(", ")}.`; - break; - } - const params = rec(input.params); - if (product === "crypto-price") { - requireText("params.id", params.id, "Required — enter a CoinGecko coin id."); - } else if (product === "fx-rate") { - requireText("params.base", params.base, "Required — enter the currency to convert from."); - requireText("params.quote", params.quote, "Required — enter the currency to convert to."); - } - break; - } - case "dd-researcher": { - if (!(DD_KINDS as readonly string[]).includes(str(input.kind))) errors.kind = "Pick npm package or crypto token."; - requireText("subject", input.subject, input.kind === "crypto-token" - ? "Required — the CoinGecko ID to research (e.g. bitcoin)." - : "Required — the npm package name to research (e.g. express)."); - break; - } - case "dep-upgrade": { - const pkg = input.packageJson; - if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) errors.packageJson = "Provide a JSON object (your package.json)."; - else if (Object.keys(rec(pkg)).length === 0) errors.packageJson = "Paste a package.json with at least one field."; - break; - } - case "evalbot": { - requireText("deliverable.content", rec(input.deliverable).content, "Required — the content EvalBot scores."); - const rubric = rec(input.rubric); - const threshold = num(rubric.acceptThreshold); - if (threshold === undefined || threshold < 0 || threshold > 100) errors["rubric.acceptThreshold"] = "Enter a 0–100 acceptance threshold."; - const criteria = arr(rubric.criteria); - if (criteria.length === 0) errors["rubric.criteria"] = "Add at least one criterion."; - criteria.forEach((value, index) => { - const criterion = rec(value); - if (!str(criterion.id).trim()) errors[`rubric.criteria.${index}.id`] = "Required — a short identifier for this criterion."; - const weight = num(criterion.weight); - if (weight === undefined || weight <= 0) errors[`rubric.criteria.${index}.weight`] = "Positive weight required."; - if (!str(criterion.description).trim()) errors[`rubric.criteria.${index}.description`] = "Describe what this criterion checks."; - const test = rec(criterion.test); - const check = str(test.check); - if (check === "min-length") { - const minChars = num(test.minChars); - if (minChars === undefined || !Number.isInteger(minChars) || minChars < 1) { - errors[`rubric.criteria.${index}.test.minChars`] = "Required — the minimum character count this check enforces."; - } - } else if (check === "regex-match") { - const pattern = str(test.pattern); - if (!pattern.trim()) { - errors[`rubric.criteria.${index}.test.pattern`] = "Required — a pattern (an empty pattern matches everything)."; - } else { - try { new RegExp(pattern); } - catch { errors[`rubric.criteria.${index}.test.pattern`] = "Not a valid regular expression."; } - } - } else if (!str(test.needle).trim()) { - errors[`rubric.criteria.${index}.test.needle`] = "Required — the text the mechanical check looks for."; - } - }); - break; - } - case "treasury-ops": { - const policy = rec(input.policy); - const accounts = arr(policy.accounts); - if (accounts.length === 0) errors["policy.accounts"] = "Add at least one account."; - accounts.forEach((value, index) => { - const account = rec(value); - if (!str(account.id).trim()) errors[`policy.accounts.${index}.id`] = "Required — the account identifier."; - if (num(account.minBalance) === undefined || (num(account.minBalance) ?? -1) < 0) errors[`policy.accounts.${index}.minBalance`] = "Non-negative minimum balance required."; - }); - const perTransfer = num(policy.maxPerTransfer); - const perRun = num(policy.maxPerRun); - if (perTransfer === undefined || perTransfer <= 0) errors["policy.maxPerTransfer"] = "Positive per-transfer cap required."; - if (perRun === undefined || perRun <= 0) errors["policy.maxPerRun"] = "Positive per-run cap required."; - const balances = rec(input.balances); - accounts.forEach((value, index) => { - const id = str(rec(value).id).trim(); - if (id && num(balances[id]) === undefined) errors[`balances.${id}`] = `Enter a numeric balance for "${id}".`; - void index; - }); - break; - } - case "site-auditor": { - if (!URL_PATTERN.test(str(input.url).trim())) errors.url = "Enter a site to audit, e.g. https://example.com or example.com."; - const samples = num(input.samples); - if (samples === undefined || !Number.isInteger(samples) || samples < 1 || samples > 5) errors.samples = "Between 1 and 5 samples."; - break; - } - case "sec-audit": { - requireFiles("files", input.files); - const pkg = input.packageJson; - if (pkg !== undefined && (typeof pkg !== "object" || pkg === null || Array.isArray(pkg))) errors.packageJson = "Optional, but must be a JSON object when provided."; - break; - } - case "compliance": { - if (!(COMPLIANCE_KINDS as readonly string[]).includes(str(input.kind))) errors.kind = "Pick person, entity, or wallet."; - if (input.kind === "wallet") requireText("walletAddress", input.walletAddress, "Required — the wallet address to screen."); - else requireText("name", input.name, "Required — the full legal name to screen."); - if (input.country !== undefined && typeof input.country !== "string") { - errors.country = "Country must be an ISO code string, e.g. GB."; - } - const aliases = input.aliases; - if (aliases !== undefined && (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== "string"))) { - errors.aliases = "Aliases must be a list of names."; - } - break; - } - default: - break; - } - return errors; -} - -/** - * Switching the compliance kind must not submit stale conditional fields: - * person/entity carry `name`, wallet carries `walletAddress`. - */ -export function complianceKindChange(input: Record, kind: string): Record { - const next: Record = { ...input, kind }; - if (kind === "wallet") { - delete next.name; - if (typeof next.walletAddress !== "string") next.walletAddress = ""; - } else { - delete next.walletAddress; - if (typeof next.name !== "string") next.name = ""; - } - return next; -} - -/** Oracle product change resets params to that product's known defaults. */ -export function oracleProductChange(input: Record, product: string): Record { - return { ...input, product, params: { ...(ORACLE_DEFAULT_PARAMS[product] ?? {}) } }; -} - -/** - * Map the gateway's error envelope onto form fields. `details` lines follow - * `field "name"…`; agent errors follow `… requires fieldName`. Anything that - * cannot be attributed to a field stays in `global`. - */ -export function mapGatewayErrors( - message: string, - details: string[] | undefined, - knownKeys: string[], -): { global: string[]; byField: FieldErrors } { - const byField: FieldErrors = {}; - const global: string[] = []; - const attribute = (line: string): boolean => { - const quoted = line.match(/field "([^"]+)"/)?.[1]; - if (quoted) { - const key = knownKeys.find((candidate) => candidate === quoted || candidate.endsWith(`.${quoted}`)) ?? quoted; - byField[key] = byField[key] ? `${byField[key]} ${line}` : line; - return true; - } - const required = line.match(/requires (\w+)/)?.[1]; - if (required) { - const key = knownKeys.find((candidate) => candidate === required || candidate.endsWith(`.${required}`)) ?? required; - byField[key] = byField[key] ? `${byField[key]} ${line}` : line; - return true; - } - return false; - }; - for (const line of details ?? []) if (!attribute(line)) global.push(line); - if ((details ?? []).length === 0 && !attribute(message)) global.push(message); - return { global, byField }; -} - -/** All dotted paths present in an input object (for gateway-error mapping). */ -export function flattenInputKeys(value: unknown, prefix = ""): string[] { - if (Array.isArray(value)) { - return value.flatMap((item, index) => flattenInputKeys(item, prefix ? `${prefix}.${index}` : String(index))); - } - if (value && typeof value === "object") { - return Object.entries(value as Record).flatMap(([key, item]) => { - const path = prefix ? `${prefix}.${key}` : key; - return [path, ...flattenInputKeys(item, path)]; - }); - } - return []; -} - -/** Short human summary of exactly what will be / was submitted. */ -export function summarizeAgentInput(name: string, input: Record): string[] { - const lines: string[] = []; - switch (name) { - case "procurement-butler": { - lines.push(`Goal: ${str(input.goal) || "—"}`); - lines.push(input.paymentRail === "pay-x402" - ? `Budget: ${num(input.budgetUsdc) ?? "—"} USDC · Base Sepolia x402` - : `Budget: ${num(input.budgetDem) ?? "—"} DEM`); - lines.push(`Files: ${arr(input.files).map((file) => str(rec(file).path) || "(unnamed)").join(", ") || "none"}`); - break; - } - case "oracle-desk": { - lines.push(`Product: ${str(input.product)}`); - const params = rec(input.params); - lines.push(`Params: ${Object.entries(params).map(([key, value]) => `${key}=${String(value)}`).join(", ") || "none"}`); - lines.push(`Payment: ${input.paymentRail === "pay-x402" ? "USDC · Base Sepolia x402" : "DEM · Demos"}`); - break; - } - case "dd-researcher": - lines.push(`Subject: ${str(input.subject)} (${str(input.kind)})`); - lines.push(`Payment: ${input.paymentRail === "pay-x402" ? "USDC · Base Sepolia x402" : "DEM · Demos"}`); - break; - case "dep-upgrade": { - const dependencies = rec(rec(input.packageJson).dependencies); - lines.push(`package.json: ${Object.keys(dependencies).length} dependencies`); - lines.push(`Include next major: ${input.includeNextMajor === true ? "yes" : "no"}`); - break; - } - case "evalbot": { - const rubric = rec(input.rubric); - lines.push(`Criteria: ${arr(rubric.criteria).length} · threshold ${num(rubric.acceptThreshold) ?? "—"}`); - lines.push(`Deliverable: ${str(rec(input.deliverable).content).length} chars`); - break; - } - case "treasury-ops": { - const policy = rec(input.policy); - lines.push(`Accounts: ${arr(policy.accounts).map((account) => str(rec(account).id)).join(", ")}`); - lines.push(`Caps: ${num(policy.maxPerTransfer) ?? "—"} per transfer · ${num(policy.maxPerRun) ?? "—"} per run`); - break; - } - case "site-auditor": - lines.push(`URL: ${str(input.url)}`); - lines.push(`Samples: ${num(input.samples) ?? "—"}`); - break; - case "sec-audit": - lines.push(`Files: ${arr(input.files).map((file) => str(rec(file).path) || "(unnamed)").join(", ")}`); - lines.push(`package.json: ${input.packageJson === undefined ? "not provided" : "provided"}`); - break; - case "compliance": - lines.push(`Kind: ${str(input.kind)}`); - lines.push(input.kind === "wallet" ? `Wallet: ${str(input.walletAddress)}` : `Name: ${str(input.name)}`); - lines.push(`Country: ${str(input.country)}${arr(input.aliases).length ? ` · aliases: ${arr(input.aliases).join(", ")}` : ""}`); - break; - default: - lines.push(`${Object.keys(input).length} fields`); - } - return lines; -} - -/** One-line safety statement rendered with each form (honest boundaries). */ -export function agentSafetyNote(name: string, paymentRail: PaymentRail = "pay-dem"): string | null { - switch (name) { - case "procurement-butler": - return paymentRail === "pay-x402" - ? "Runs the full live purchase flow with a real Base Sepolia USDC payment. Your USDC budget is a hard spend cap." - : "Runs the full live purchase flow with a real DEM payment. Your DEM budget is a hard spend cap."; - case "dep-upgrade": - return "Plans upgrades only — it never installs or modifies anything."; - case "treasury-ops": - return "Plans and approves transfers against your policy — it never moves funds."; - case "sec-audit": - return "Scans only the content you paste here. It has no access to your filesystem."; - case "site-auditor": - return "Fetches the public URL you provide, up to the sample count."; - default: - return null; - } -} diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts b/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts deleted file mode 100644 index 5808e69..0000000 --- a/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { ProcurementEvent } from "./try-dacs-contract.js"; - -/** - * Pure helpers behind the /try DACS-stage rail: mapping live gateway - * procurement events onto the five DACS stages, computing fail-closed - * progress, and (de)serialising the persisted procurement run record. - */ - -/** Gateway procurement event phases → DACS stage index (0..4). */ -export const PROCUREMENT_PHASE_STAGE: Record = { - queued: 0, connecting: 0, discovering: 0, - selecting: 1, - agreeing: 2, - settling: 3, delivering: 3, recovering: 3, - verifying: 4, evaluating: 4, complete: 4, -}; - -/** - * Some gateway phases span two DACS stages (e.g. the RFQ opens and the - * agreement anchors while the job phase is still "selecting"), so the event - * label can override the phase for known-labelled milestones. - */ -function stageForLabel(label: string): number | undefined { - const lowered = label.toLowerCase(); - if (/\bvet\b/.test(lowered)) return 1; - if (/agreement|commitment|rfq|agreed/.test(lowered)) return 2; - if (/settlement evidence|payment evidence/.test(lowered)) return 3; - return undefined; -} - -export type StagedEvents = { - /** Events grouped by DACS stage index 0..4. */ - byStage: ProcurementEvent[][]; - /** Highest stage the run actually reached — never advanced by failures. */ - progress: number; -}; - -/** - * Assign each event to a DACS stage and compute how far the run actually - * progressed. Fails closed: a terminal "failed" event (or any unknown phase) - * attaches to the stage the run had already reached and NEVER advances - * progress — its error text is not label-matched, so a failure message that - * happens to mention "agreement" cannot fake Negotiate progress. - */ -export function stageEvents(events: readonly ProcurementEvent[]): StagedEvents { - const byStage: ProcurementEvent[][] = [[], [], [], [], []]; - let progress = 0; - for (const event of events) { - const phaseStage = PROCUREMENT_PHASE_STAGE[event.phase]; - // Terminal or UNKNOWN phases attach to the stage the run had reached and - // never advance progress — and their labels are never matched, so an - // unrecognised event whose text mentions "agreement"/"rfq" cannot fake - // Negotiate progress. Label overrides apply to recognised phases only. - if (event.phase === "failed" || phaseStage === undefined) { - byStage[progress]!.push(event); - continue; - } - const stage = stageForLabel(event.label) ?? phaseStage; - byStage[stage]!.push(event); - if (stage > progress) progress = stage; - } - return { byStage, progress }; -} - -/** - * The procurement run record persisted to localStorage so a reload cannot - * lose the idempotency key of a dispatched purchase. Kept until the job - * reaches a terminal state the browser has verified as safe (complete, or - * failed with the gateway's explicit failedBeforePayment=true). - */ -export type StoredProcurementRun = { - runId: string; - jobId?: string; - goal: string; - input: Record; - startedAt: string; -}; - -/** Parse a persisted run record; null for anything malformed. */ -export function parseStoredProcurementRun(raw: string | null): StoredProcurementRun | null { - if (!raw) return null; - try { - const value: unknown = JSON.parse(raw); - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const run = value as Record; - if (typeof run.runId !== "string" || !run.runId.trim()) return null; - if (typeof run.goal !== "string") return null; - if (typeof run.startedAt !== "string") return null; - if (!run.input || typeof run.input !== "object" || Array.isArray(run.input)) return null; - if (run.jobId !== undefined && typeof run.jobId !== "string") return null; - return { - runId: run.runId, - jobId: typeof run.jobId === "string" ? run.jobId : undefined, - goal: run.goal, - input: run.input as Record, - startedAt: run.startedAt, - }; - } catch { - return null; - } -} - -/** Minimal surface of the Web Locks manager the dispatch section needs. */ -export type LockRequestor = { - request: (name: string, options: { signal?: AbortSignal }, callback: () => Promise) => Promise; -}; - -export class ProcurementLockUnavailableError extends Error { - constructor() { - super("This browser does not support the cross-tab locking (Web Locks) required to dispatch a paid procurement safely."); - this.name = "ProcurementLockUnavailableError"; - } -} - -export const PROCUREMENT_LOCK_NAME = "dacs-try:procurement-dispatch"; - -/** - * Run `section` under the cross-tab exclusive procurement lock. REFUSES to - * run when no real lock manager exists — paid dispatch must never fall back - * to best-effort mutual exclusion, because that restores the two-tab - * overwrite race. `signal` aborts a request still WAITING for the lock, so a - * cancelled tab can never execute its section later and write a stale - * record; a section already granted runs to completion (its fetches observe - * the same signal). - */ -export function withExclusiveProcurementLock( - locks: LockRequestor | undefined, - signal: AbortSignal, - section: () => Promise, -): Promise { - if (!locks?.request) return Promise.reject(new ProcurementLockUnavailableError()); - return locks.request(PROCUREMENT_LOCK_NAME, { signal }, section) as Promise; -} - -/** - * What a queued resume section may do once it finally holds the lock. The - * lock only SERIALIZES sections — it cannot stop a stale section from - * executing later — so the section must re-validate the record it captured - * against what storage holds NOW: - * - record gone or owned by a different run → the captured run was - * reconciled elsewhere (dismissed, or replaced by a new purchase); acting - * on the stale capture could overwrite the live run's recovery handle. - * - same run, jobId now known (another tab already re-POSTed) → READ that - * job; a second POST is unnecessary. - * - same run, still no jobId → the idempotent re-POST proceeds. - */ -export type ResumeDispatchDecision = - | { action: "abort-stale" } - | { action: "read"; jobId: string } - | { action: "post" }; - -export function resumeDispatchDecision( - captured: StoredProcurementRun, - current: StoredProcurementRun | null, -): ResumeDispatchDecision { - if (!current || current.runId !== captured.runId) return { action: "abort-stale" }; - if (current.jobId) return { action: "read", jobId: current.jobId }; - return { action: "post" }; -} diff --git a/reference-implementations/dacs-directory/src/components/try-forms/AgentInputForm.tsx b/reference-implementations/dacs-directory/src/components/try-forms/AgentInputForm.tsx deleted file mode 100644 index b1cd512..0000000 --- a/reference-implementations/dacs-directory/src/components/try-forms/AgentInputForm.tsx +++ /dev/null @@ -1,527 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { AgentCard } from "../try-dacs-contract.js"; -import { - ACCOUNT_KINDS, - COMPLIANCE_KINDS, - DD_KINDS, - ORACLE_PRODUCTS, - addRow, - agentSafetyNote, - complianceKindChange, - hasBuiltinForm, - oracleProductChange, - parseAgentFieldSchema, - removeRow, - type AgentFieldSchema, - type FieldErrors, - type SourceFile, -} from "../try-dacs-forms.js"; -import FieldRow from "./FieldRow.js"; -import JsonObjectEditor from "./JsonObjectEditor.js"; -import RubricEditor from "./RubricEditor.js"; -import SourceFilesEditor from "./SourceFilesEditor.js"; - -const rec = (value: unknown): Record => - value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const arr = (value: unknown): unknown[] => Array.isArray(value) ? value : []; -const files = (value: unknown): SourceFile[] => - arr(value).map((file) => ({ path: String(rec(file).path ?? ""), content: String(rec(file).content ?? "") })); - -export type AgentInputFormProps = { - agent: AgentCard; - value: Record; - onChange: (next: Record) => void; - errors: FieldErrors; - gatewayErrors: FieldErrors; -}; - -/** - * The human-friendly input form for a published agent. Prefers a catalog- - * published field schema when the gateway provides one; falls back to the - * built-in per-agent layout; unknown agents fall back to the Advanced JSON - * editor alone. All controls edit the same gateway input object that - * `runAgent()` submits unchanged. - */ -export default function AgentInputForm(props: AgentInputFormProps) { - const { agent, value } = props; - const schema = parseAgentFieldSchema(agent); - const paymentRail = value.paymentRail === "pay-x402" ? "pay-x402" : "pay-dem"; - const note = agentSafetyNote(agent.name, paymentRail); - // Precedence: the nine bespoke forms know their agents best; the gateway's - // published input schema renders any NEW agent; JSON is the last resort. - const builtin = hasBuiltinForm(agent.name); - return ( -
    - {note &&

    {note}

    } - {builtin - ? - : schema - ? - :

    This agent has not published a form schema yet — use the advanced JSON input below.

    } - -
    - ); -} - -/** Generic renderer for a future catalog-published field schema. */ -function SchemaForm({ schema, value, onChange, errors, gatewayErrors }: AgentInputFormProps & { schema: AgentFieldSchema[] }) { - return ( -
    - {schema.map((field) => { - const id = `schema-${field.key}`; - const current = value[field.key]; - return ( - - {field.kind === "checkbox" ? ( - onChange({ ...value, [field.key]: event.target.checked })} /> - ) : field.kind === "select" ? ( - - ) : field.kind === "textarea" ? ( -