From 803bffb6f7acee9f2b75d1144aa71e60ccca58d8 Mon Sep 17 00:00:00 2001 From: random block Date: Tue, 18 Aug 2026 15:03:34 +0100 Subject: [PATCH] Add recovery-only gateway demo drain --- .../dacs-directory/README.md | 1 + .../e2e/try-dacs-recovery.spec.ts | 37 +++++++++++++++++++ .../dacs-directory/package.json | 2 +- .../playwright.recovery.config.ts | 29 +++++++++++++++ .../dacs-directory/src/components/TryDacs.tsx | 29 +++++++++++---- .../src/components/gateway-demo-recovery.ts | 7 ++++ .../test/gateway-demo-recovery.test.ts | 14 +++++++ 7 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts create mode 100644 reference-implementations/dacs-directory/playwright.recovery.config.ts create mode 100644 reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts create mode 100644 reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 48b8e3a..88f9254 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -125,6 +125,7 @@ vendor directory. | `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. | +| `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY` | Temporary drain only | Set to exact `1` only after the gateway-owned demo is live. The old `/try` page blocks every fresh run but preserves origin-scoped `Check & resume` recovery. Remove the flag by deploying the gateway-demo removal after the drain window. | The data directory must be persistent and writable in deployments that accept registrations or run the indexer. Never commit `.indexer-seed`, `.indexer-mnemonic`, diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts new file mode 100644 index 0000000..89bb7bd --- /dev/null +++ b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { + PROCUREMENT_RUN_KEY, + completedJob, + expectAcceptedEvidence, + installMockGateway, +} from "./try-dacs-fixtures.js"; + +test("recovery-only deployment blocks fresh starts but preserves an existing job", async ({ context, page }) => { + let posts = 0; + await installMockGateway(context, { + onProcurementPost: async (route) => { + posts += 1; + await route.abort("blockedbyclient"); + }, + }); + await page.addInitScript(({ key, value }) => { + window.localStorage.setItem(key, JSON.stringify(value)); + }, { + key: PROCUREMENT_RUN_KEY, + value: { + runId: "recovery-only-existing-run", + goal: "Resume the existing security audit", + input: { profileId: "security-audit-rfq", paymentRail: "pay-dem", files: [{ path: "server.js", content: "safe" }] }, + startedAt: "2026-08-18T12:00:00.000Z", + jobId: completedJob.id, + }, + }); + + await page.goto("/try"); + await expect(page.getByTestId("gateway-demo-recovery-only")).toContainText("New purchases are paused"); + await expect(page.getByRole("button", { name: /Security Auditor/ }).first()).toBeDisabled(); + + await page.getByRole("button", { name: /Check & resume/ }).click(); + await expectAcceptedEvidence(page); + expect(posts).toBe(0); +}); diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index f3937b4..fedde86 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -13,7 +13,7 @@ "check:deploy-config": "node scripts/check-butler-origin.mjs", "check:butler": "node scripts/check-butler-origin.mjs --probe", "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": "playwright test e2e/home.spec.ts e2e/register.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts && playwright test --config playwright.recovery.config.ts e2e/try-dacs-recovery.spec.ts", "test:e2e:live": "playwright test e2e/try-dacs.live.spec.ts", "test:e2e:ui": "playwright test --ui", "test:seed": "tsx --test test/seed-smoke.test.ts", diff --git a/reference-implementations/dacs-directory/playwright.recovery.config.ts b/reference-implementations/dacs-directory/playwright.recovery.config.ts new file mode 100644 index 0000000..791b2f1 --- /dev/null +++ b/reference-implementations/dacs-directory/playwright.recovery.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + outputDir: "test-results/playwright-recovery", + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [["list"]], + use: { + baseURL: "http://localhost:3401", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "npx next dev -p 3401", + url: "http://localhost:3401/try", + reuseExistingServer: false, + timeout: 120_000, + env: { + NEXT_PUBLIC_DIRECTORY_URL: "http://localhost:3401", + NEXT_PUBLIC_BUTLER_ORIGIN: "https://butler.agentcommerce.network", + NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY: "1", + }, + }, +}); diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 5fcb84d..03f84b9 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -36,9 +36,11 @@ import { type FieldErrors, } from "./try-dacs-forms.js"; import AgentInputForm from "./try-forms/AgentInputForm.js"; +import { GATEWAY_DEMO_RECOVERY_MESSAGE, gatewayDemoRecoveryOnly } from "./gateway-demo-recovery.js"; import { ProcurementLockUnavailableError, parseStoredProcurementRun, resumeDispatchDecision, stageEvents, withExclusiveProcurementLock, type LockRequestor, type StoredProcurementRun } from "./try-dacs-stages.js"; const BUTLER = (process.env.NEXT_PUBLIC_BUTLER_ORIGIN ?? "http://127.0.0.1:8402").replace(/\/$/, ""); +const GATEWAY_DEMO_RECOVERY_ONLY = gatewayDemoRecoveryOnly(); const PROCUREMENT_RUN_KEY = "dacs-try:procurement-run"; const PROFILE_AGENT: Record = { "oracle-auto-accept": { name: "oracle-desk", label: "Oracle Desk" }, @@ -672,6 +674,10 @@ export default function TryDacs() { } async function runAgent() { + if (GATEWAY_DEMO_RECOVERY_ONLY) { + setError(GATEWAY_DEMO_RECOVERY_MESSAGE); + return; + } if (!plan) return; let parsed: Record; try { parsed = parseAgentInput(inputValue); } @@ -1058,6 +1064,15 @@ export default function TryDacs() {

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.

+ {GATEWAY_DEMO_RECOVERY_ONLY && ( +
+
+ New purchases are paused during the demo move +

{GATEWAY_DEMO_RECOVERY_MESSAGE}

+
+
+ )} + {/* 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. */} @@ -1091,7 +1106,7 @@ export default function TryDacs() {
{agents.map((agent) => { const profile = profiles.find((candidate) => procurementProfileCard(candidate).name === agent.name); const liveRails = profile?.paymentRails.filter((rail) => profile.railReadiness[rail]?.executable).map(paymentRailLabel).join(" · "); - return ; + return ; })}
) : phase === "error" && plan ? ( @@ -1099,7 +1114,7 @@ export default function TryDacs() { {isProcurementSel && procurementJob?.status === "failed" && procurementJob.failedBeforePayment === true ? ( <>
Procurement failed before any paymentThe gateway confirms no money moved (its reason is shown above). Retrying starts a fresh purchase attempt with a new idempotency key.
-
+
) : isProcurementSel && procurementJob?.status === "failed" ? ( <> @@ -1114,18 +1129,18 @@ export default function TryDacs() { ) : isProcurementSel ? ( <>
Procurement stopped safelyRetrying reuses this run’s idempotency key, so the gateway resumes the existing job rather than starting a second paid purchase.
-
+
) : ( <>
{plan.butler.label} stopped safelyYour entered job details are still available.
-
+
)} ) : plan && phase === "ready" && selected ? (
-
{selectedProfile ? procurementModeLabel(selectedProfile.mode) : "Job details"}{plan.inputNote}
{inputIsValid ? `ready · ${paymentRailLabel(selectedPaymentRail)}` : "fields need attention"}
+
{selectedProfile ? procurementModeLabel(selectedProfile.mode) : "Job details"}{plan.inputNote}
{GATEWAY_DEMO_RECOVERY_ONLY ? "recovery only" : inputIsValid ? `ready · ${paymentRailLabel(selectedPaymentRail)}` : "fields need attention"}
{selectedProfile &&
Choose payment railThis changes the real asset and settlement network.
{selectedProfile.paymentRails.map((rail) => { @@ -1148,7 +1163,7 @@ export default function TryDacs() {
- +
) : phase === "running" && isProcurementSel ? ( @@ -1222,7 +1237,7 @@ export default function TryDacs() {
THREE WAYS TO PROCURE

Production agents, DEM or x402, full DACS

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

{profiles.map((profile, index) => { const agent = procurementProfileCard(profile, defaultPaymentRail(profile)); - return ; + return ; })}
); diff --git a/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts new file mode 100644 index 0000000..96388d3 --- /dev/null +++ b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts @@ -0,0 +1,7 @@ +export const GATEWAY_DEMO_RECOVERY_MESSAGE = + "New purchases are paused while the buyer demo moves to its gateway-owned origin. Existing runs can still be checked and resumed with their original idempotency key."; + +/** Build-time drain control for the retiring Community-hosted buyer demo. */ +export function gatewayDemoRecoveryOnly(value = process.env.NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY): boolean { + return value === "1"; +} diff --git a/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts b/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts new file mode 100644 index 0000000..92a1cc2 --- /dev/null +++ b/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + GATEWAY_DEMO_RECOVERY_MESSAGE, + gatewayDemoRecoveryOnly, +} from "../src/components/gateway-demo-recovery.js"; + +test("gateway demo drain is opt-in and exact", () => { + assert.equal(gatewayDemoRecoveryOnly(undefined), false); + assert.equal(gatewayDemoRecoveryOnly("0"), false); + assert.equal(gatewayDemoRecoveryOnly("true"), false); + assert.equal(gatewayDemoRecoveryOnly("1"), true); + assert.match(GATEWAY_DEMO_RECOVERY_MESSAGE, /Existing runs can still be checked and resumed/); +});