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/docs/codex-local-workflow.md b/docs/codex-local-workflow.md
index 8cbe7be..ba88b0b 100644
--- a/docs/codex-local-workflow.md
+++ b/docs/codex-local-workflow.md
@@ -6,21 +6,23 @@ 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 any mutable catalog state under this worktree's
`reference-implementations/dacs-directory/data/` directory.
-- 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 the 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)
@@ -29,7 +31,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
@@ -70,29 +71,26 @@ building whatever the SDK checkout happens to contain. It builds only after
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.
## Run deterministic checks
These are the same check commands as the Directory CI's typecheck, test,
origin-validation, and production-build steps. The install differs: CI runs
`npm run setup` (`npm install`), while this workflow uses `npm ci`. 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`. It still
+rejects `.local` and other non-public hosts, so do not substitute a local origin:
```bash
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
```
These commands do not require the indexer, a wallet, paid commerce, or a shared
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 454124b..d7a2f4d 100644
--- a/reference-implementations/dacs-directory/README.md
+++ b/reference-implementations/dacs-directory/README.md
@@ -91,8 +91,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.
@@ -114,7 +115,7 @@ vendor directory.
| `DEMOS_RPC` | No | Demos RPC base URL; defaults to the public testnet endpoint |
| `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` |
@@ -126,25 +127,69 @@ vendor directory.
| `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
diff --git a/reference-implementations/dacs-directory/app/globals.css b/reference-implementations/dacs-directory/app/globals.css
index bcee314..ea9b288 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; }
@@ -488,318 +488,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/page.tsx b/reference-implementations/dacs-directory/app/page.tsx
index be9c3d8..b974cd6 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.
+
- {[
- { 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/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/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 ffb52af..1e75791 100644
--- a/reference-implementations/dacs-directory/package-lock.json
+++ b/reference-implementations/dacs-directory/package-lock.json
@@ -26,7 +26,7 @@
"typescript": "^5.6.3"
},
"engines": {
- "node": ">=22 <23"
+ "node": "22.23.3"
}
},
"node_modules/@emnapi/runtime": {
diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json
index f3937b4..044311a 100644
--- a/reference-implementations/dacs-directory/package.json
+++ b/reference-implementations/dacs-directory/package.json
@@ -10,11 +10,11 @@
"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: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",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit"
},
"engines": {
- "node": ">=22 <23"
+ "node": "22.23.3"
},
"dependencies": {
"@noble/ed25519": "^2.3.0",
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/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh
index ef9bdeb..a3aa7d3 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="44d8ff2a07df8c951b94619d20b957b4bb5ce140"
-# 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/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}
-
- ))}
-
- 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 (
-
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.
{evidence.deliveryVerified ? String(record(audit.summary).text ?? "The delivered report was verified and contains no findings.") : "No verified report was returned."}
{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 verified listing
- // 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.
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.
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.
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.
- {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.
-
{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."}
{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.