Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/gateway-demo-cutover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Gateway demo cutover

Keep the Community drain PR in draft until the gateway-owned buyer UI is
deployed and smoke-tested, and an operator can look up a job by its
`Idempotency-Key`. The browser's reported run id is that key. A run with no
recorded job id needs operator reconciliation; the drain never re-sends it.

1. Before or atomically with the Community drain deployment, enable a gateway
gate that prevents the retiring Community origin from creating work through
`POST /demo/butler` and `POST /demo/procurement`. Preserve read-only
`GET /demo/procurement/:jobId` recovery. The gateway must check actual POSTs
before job creation, including when a browser has cached an earlier successful
CORS preflight. An old client deletes its recovery record on an ordinary 4xx
response, so a blocked replay must instead fail as a network/CORS error or
a retryable 503. Rejecting only the response's CORS headers after creating
work is too late.
2. In the gateway's isolated integration environment, load the pre-drain
Community client before activating that gate. Verify that fresh Butler and
procurement starts, and procurement replays with unknown idempotency keys,
create zero jobs and perform zero payments. Verify that a blocked replay
retains the browser's record, including with a previously cached POST
preflight, and that a recorded job id can still be read. Test the gateway-owned
UI separately so the origin gate does not prevent its normal operation.
3. Build and deploy Community with
`NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1`. The flag is inlined by `next build`;
changing it requires a rebuild. It controls pages loaded after deployment.
Already-open tabs keep their previous JavaScript until reloaded, which is why
the gateway gate is required.
4. Wait at least 12 minutes and reconcile gateway-visible jobs and any run ids
users report. Confirm the gateway gate is still active and check again for
Community-origin jobs immediately before removing the old pages. The wait
bounds dispatched jobs; it cannot bound when an idle old tab sends a request.
5. Only after those checks pass, merge and deploy Community PR #84. Keep the
gateway gate active after page removal to cover old tabs.

Community's mocked Playwright recovery pass verifies the newly loaded client
bundle. It does not verify the gateway gate. The gateway service and its
deployment configuration are maintained outside this repository.
1 change: 1 addition & 0 deletions reference-implementations/dacs-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,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 | Build-time (inlined by `next build`; rebuild and redeploy to change it). Set to exact `1` only after the gateway-owned demo is live. Pages loaded after the deploy block fresh runs and send no procurement POST: `Check & resume` only reads runs with a recorded gateway job id. A run without a job id is never re-sent; its run id is shown for manual reconciliation. Before or atomically with this deploy, the gateway must prevent the retiring Community origin from creating work, including requests from tabs loaded before the deploy. Follow the [cutover procedure](../../docs/gateway-demo-cutover.md) before removing the old pages. |

The data directory must be persistent and writable in deployments that accept
registrations or run the indexer. Never commit `.indexer-seed`, `.indexer-mnemonic`,
Expand Down
2 changes: 1 addition & 1 deletion reference-implementations/dacs-directory/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The Playwright suite has two layers:

- `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` runs two deterministic passes. The first (`playwright.config.ts`, port 3400) runs the landing-page, seller-registration, mocked `/try` payment-safety and `/try-chat` replay regressions. The second (`playwright.recovery.config.ts`, port 3401) starts a separate development server for `/try` with `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1` and runs `try-dacs-recovery.spec.ts`, which proves the recovery-only page sends no procurement POST: fresh starts are disabled, the dispatch handler refuses even a direct invocation, a run with a job id is read, and a run without one goes to manual reconciliation. The default config ignores that spec because it needs the flag. These mocked checks cover newly loaded pages; gateway enforcement for pre-deployment tabs must be checked separately as described in the [cutover procedure](../../../docs/gateway-demo-cutover.md). Neither pass spends DEM or USDC; both are 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
Expand Down
137 changes: 137 additions & 0 deletions reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { expect, test, type BrowserContext, type Locator, type Page } from "@playwright/test";
import {
PROCUREMENT_RUN_KEY,
completedJob,
expectAcceptedEvidence,
installMockGateway,
procurementOptions,
} from "./try-dacs-fixtures.js";

const storedInput = { profileId: "security-audit-rfq", paymentRail: "pay-dem", files: [{ path: "server.js", content: "safe" }] };

async function countProcurementPosts(context: BrowserContext) {
const posts = { count: 0 };
await installMockGateway(context, {
onProcurementPost: async (route) => {
posts.count += 1;
await route.abort("blockedbyclient");
},
});
return posts;
}

async function seedStoredRun(page: Page, value: Record<string, unknown>) {
await page.addInitScript(({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
}, { key: PROCUREMENT_RUN_KEY, value });
}

// Deliberately bypass the disabled DOM button to exercise the component's
// dispatch guard. React's props are used only in this test, with no production
// test hook; a normal click cannot invoke a disabled button's handler.
async function invokeClickHandler(button: Locator) {
await button.evaluate(async (element) => {
const key = Object.keys(element).find((key) => key.startsWith("__reactProps$"));
const props = key ? (element as unknown as Record<string, { onClick?: () => unknown }>)[key] : undefined;
if (typeof props?.onClick !== "function") throw new Error("React click handler not found");
await props.onClick();
});
}

test("recovery-only runAgent refuses a direct invocation with a valid purchase form", async ({ context, page }) => {
const posts = await countProcurementPosts(context);
let butlerPosts = 0;
await context.route("**/demo/butler", async (route) => {
if (route.request().method() === "POST") butlerPosts += 1;
await route.abort("blockedbyclient");
});
await page.goto("/try");
const agent = page.locator(".picker-grid").getByRole("button", { name: /Security Auditor/ });
await expect(agent).toBeDisabled();
await invokeClickHandler(agent);
await page.getByRole("button", { name: "Load example" }).click();
await expect(page.locator(".field-error")).toHaveCount(0);
const run = page.locator(".job-box").getByRole("button", { name: /New purchases paused/ });
await expect(run).toBeDisabled();
await invokeClickHandler(run);

await expect(page.locator(".bubble.error")).toContainText("New purchases are paused");
expect(posts.count).toBe(0);
expect(butlerPosts).toBe(0);
expect(await page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull();
});

test("recovery-only deployment blocks fresh starts but preserves an existing job", async ({ context, page }) => {
const posts = await countProcurementPosts(context);
await seedStoredRun(page, {
runId: "recovery-only-existing-run",
goal: "Resume the existing security audit",
input: storedInput,
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");
// Every fresh-start entry point is disabled: the picker grid and the lower
// "THREE WAYS TO PROCURE" grid, not just the first matching button.
await expect(page.locator(".try-agent-grid button")).toHaveCount(procurementOptions.profiles.length);
for (const button of await page.locator(".picker-grid button, .try-agent-grid button").all()) {
await expect(button).toBeDisabled();
}
const auditorButtons = await page.getByRole("button", { name: /Security Auditor/ }).all();
expect(auditorButtons.length).toBeGreaterThanOrEqual(2);
for (const button of auditorButtons) await expect(button).toBeDisabled();

await page.getByRole("button", { name: /Check & resume/ }).click();
await expectAcceptedEvidence(page);
expect(posts.count).toBe(0);
});

test("recovery-only deployment never re-sends a run whose job id was never received", async ({ context, page }) => {
const posts = await countProcurementPosts(context);
await seedStoredRun(page, {
runId: "recovery-only-unsent-run",
goal: "A run whose original POST may never have reached the gateway",
input: storedInput,
startedAt: new Date().toISOString(),
});

await page.goto("/try");
await expect(page.locator(".resume-banner[role=alert]")).toContainText("give the operator run id recovery-only-unsent-run");
await expect(page.getByRole("link", { name: /Ask for run reconciliation/ })).toHaveAttribute(
"href", "https://github.com/DACS-Agent-commerce/Community/issues/new?title=Gateway%20demo%20run%20reconciliation",
);
await page.getByRole("button", { name: /Check & resume/ }).click();

await expect(page.locator(".bubble.error")).toContainText("run id recovery-only-unsent-run");
await expect(page.locator(".bubble.error")).toContainText("nothing was sent");
expect(posts.count).toBe(0);
// The record stays so the banner keeps the run id until the user dismisses it.
const stored = await page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY);
expect(JSON.parse(stored ?? "null")).toMatchObject({ runId: "recovery-only-unsent-run" });
await expect(page.locator(".resume-banner[role=alert]")).toBeVisible();
});

test("recovery-only resume follows a job id another tab learned instead of re-sending", async ({ context, page }) => {
const posts = await countProcurementPosts(context);
const record = {
runId: "recovery-only-raced-run",
goal: "Resume the security audit another tab reconnected",
input: storedInput,
startedAt: new Date().toISOString(),
};
await seedStoredRun(page, record);

await page.goto("/try");
await expect(page.locator(".resume-banner[role=alert]")).toContainText("the job id was never received");
// Another tab of the same run received the job id after this tab loaded.
await page.evaluate(({ key, value }) => localStorage.setItem(key, JSON.stringify(value)), {
key: PROCUREMENT_RUN_KEY,
value: { ...record, jobId: completedJob.id },
});
await page.getByRole("button", { name: /Check & resume/ }).click();

await expectAcceptedEvidence(page);
expect(posts.count).toBe(0);
});
2 changes: 1 addition & 1 deletion reference-implementations/dacs-directory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions reference-implementations/dacs-directory/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const livePaidRun = process.env.RUN_LIVE_PAID_E2E === "1";

export default defineConfig({
testDir: "./e2e",
// Needs NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1 at build time; it runs
// only under playwright.recovery.config.ts on its own server.
testIgnore: /try-dacs-recovery\.spec\.ts$/,
outputDir: livePaidRun ? "test-results/playwright-live" : "test-results/playwright",
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./e2e",
testMatch: /try-dacs-recovery\.spec\.ts$/,
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",
},
},
});
Loading
Loading