Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,10 @@ docs/planning/planning--parallel-*-prompts--*.md

# Local redis snapshot from verification runs
dump.rdb

# Cloudflare snapshot fixtures.
# Some machines carry a global gitignore rule for `public/` (core.excludesFile),
# which would silently drop these and leave a hosted snapshot with no data at all.
# Repository rules take precedence over the global file, so state the intent here.
!src/web/public/
!src/web/public/**
31 changes: 31 additions & 0 deletions docs/demo/jessica-demo-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,37 @@ opens, explanation depth, and notes. What is not: no IP addresses, no user agent
identity, no page content. Everyone shares the same synthetic accounts, so account identity would be
noise — the self-entered name is the only attribution, and the panel says so on screen.

## Showing it to people who are NOT in the room

The LAN demo needs everyone on the same Wi-Fi. For an investor or a remote reviewer there is a
Cloudflare Pages snapshot: the same app, exported as static files, with **no API, no PostgreSQL and
no Redis** behind it.

```bash
npm run snapshot:capture # fixtures, recorded off the running demo
npm run snapshot:build # static export into src/web/out
npm run snapshot:serve # preview on http://127.0.0.1:4315
npm run snapshot:deploy # wrangler pages deploy
```

Reads are answered from fixtures captured by driving the real demo as each persona and recording
what the app actually requests — not hand-written, because a hand-written fixture drifts and renders
a plausible, wrong screen. The capture refuses to write an empty snapshot for the same reason.

What the snapshot deliberately cannot do: **writes**. Creating a contract or attesting is refused in
plain language rather than faked into looking successful, and a screen with no captured fixture says
so instead of rendering an empty page that reads as a real but empty product. Anyone who needs to
actually click through a write needs the live demo.

Two operational traps, both of which cost real time:

- A snapshot build **overwrites `.next`**, so the running local demo afterwards serves snapshot
client code that answers from fixtures and never calls `/api`. Run `npm run demo:reset:verify`
before capturing again. `snapshot:build` prints this reminder.
- Preview with `snapshot:serve`, never `serve -s`. SPA mode rewrites every unmatched path to
`index.html`, so each route returns the landing page with a `200` while the URL bar still looks
right — a preview that looks fine and tests nothing.

## The guided tour (self-driving, for five different readers)

The demo explains itself. Every route in the app carries a synced panel on the right with the
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"demo:credentials": "bash scripts/demo/credentials.sh",
"demo:verify": "bash scripts/demo/verify-live-stack.sh",
"demo:share": "bash scripts/demo/share.sh",
"snapshot:capture": "bash scripts/demo/snapshot.sh capture",
"snapshot:build": "bash scripts/demo/snapshot.sh build",
"snapshot:serve": "bash scripts/demo/snapshot.sh serve",
"snapshot:deploy": "bash scripts/demo/snapshot.sh deploy",
"demo:feedback": "bash scripts/demo/feedback.sh start",
"demo:feedback:stop": "bash scripts/demo/feedback.sh stop",
"demo:feedback:status": "bash scripts/demo/feedback.sh status",
Expand Down
167 changes: 167 additions & 0 deletions scripts/demo/capture-snapshot.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* Captures the API fixtures the Cloudflare snapshot serves.
*
* It drives the REAL local demo in a browser, as each synthetic persona, and records
* every /api response the app actually makes. Recording what the app requests -- rather
* than guessing endpoints from the API surface -- is the whole point: a hand-written
* fixture set drifts silently and produces a plausible, wrong screen, which is the
* worst thing a demo can do.
*
* Run it against a demo that has just passed its gate; the fixtures inherit whatever
* that run was showing, so they are only as honest as the run that produced them.
*/
import { mkdir, writeFile, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../..");
const outDir = path.join(repoRoot, "src/web/public/demo-snapshot");
const registryPath = path.join(repoRoot, "src/web/lib/guided-tour/registry.ts");

const webBase = process.env.STYX_DEMO_WEB_URL;
const demoPassword = process.env.STYX_DEMO_PASSWORD; // allow-secret: synthetic seed credential
if (!webBase) throw new Error("STYX_DEMO_WEB_URL must be set.");
if (!demoPassword) throw new Error("STYX_DEMO_PASSWORD must be set.");

const PERSONAS = {
river: "river@demo.styx.protocol",
moira: "dr.moira@demo.styx.protocol",
hr: "hr.lead@acheron.example",
alecto: "alecto@demo.styx.protocol",
sage: "sage@demo.styx.protocol",
};

/** Reads route + persona pairs straight out of the tour registry, so coverage tracks it. */
async function readRegistryRoutes() {
const source = await readFile(registryPath, "utf8");
const entries = [];
const blocks = source.split(/\n\s*\{\s*\n/);
for (const block of blocks) {
const routePath = block.match(/^\s*path:\s*"([^"]+)"/m)?.[1];
const persona = block.match(/^\s*persona:\s*"([^"]+)"/m)?.[1];
if (routePath && persona) entries.push({ path: routePath, persona });
}
return entries;
}

const registryRoutes = await readRegistryRoutes();
if (!registryRoutes.length) throw new Error("no routes parsed from the guided-tour registry.");

// Dynamic segments are exported with concrete synthetic ids; visit the same ones so the
// fixtures match the pages that actually exist in the export.
const CONCRETE = {
"[id]": "c1000000-0000-0000-0000-000000000001",
"[slug]": "recovery-abstinence",
"[linkId]": "demo",
};
const concretise = (route) =>
route.replace(/\[[^\]]+\]/g, (segment) => CONCRETE[segment] ?? "demo");

const { chromium } = await import("playwright");
const browser = await chromium.launch({ headless: true });

await mkdir(outDir, { recursive: true });
const written = [];

for (const [persona, email] of Object.entries(PERSONAS)) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });

// POST /auth/login is throttled at 5 per 60s per IP; five personas is right at the
// edge, so pace them rather than tripping it half way through a capture.
let token = "";
for (let attempt = 1; attempt <= 4; attempt += 1) {
const response = await context.request.post(`${webBase}/api/auth/login`, {
data: { email, password: demoPassword }, // allow-secret: synthetic seed credential
});
if (response.status() === 429) {
const waitMs = 21000;
console.log(` login throttled for ${persona}; waiting ${waitMs / 1000}s ...`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
if (!response.ok()) throw new Error(`login ${persona}: HTTP ${response.status()}`);
token = (await response.json()).token; // allow-secret: short-lived synthetic session token
break;
}
if (!token) throw new Error(`login ${persona}: exhausted attempts`);
await context.addCookies([{ name: "styx_auth_token", value: token, url: webBase }]);

const fixtures = {};
let unreadable = 0;
// Body reads must be started in the handler AND awaited before the next navigation.
// Playwright discards response bodies once the page navigates away, so an async
// read left dangling resolves to nothing -- silently, which reads as "the app made
// no API calls" rather than "the capture raced the navigation".
let pending = [];
const page = await context.newPage();
page.on("response", (response) => {
const url = new URL(response.url());
if (!url.pathname.startsWith("/api/")) return;
if (response.request().method() !== "GET") return;
if (!response.ok()) return; // a failed call is not a fixture
const apiPath = url.pathname.slice("/api".length) + (url.search || "");
pending.push(
response
.json()
.then((body) => {
fixtures[`GET ${apiPath}`] = body;
const bare = `GET ${url.pathname.slice("/api".length)}`;
if (!(bare in fixtures)) fixtures[bare] = body;
})
.catch(() => {
unreadable += 1;
}),
);
});

const drain = async () => {
const inflight = pending;
pending = [];
await Promise.allSettled(inflight);
};

const routes = registryRoutes
.filter((entry) => entry.persona === persona || entry.persona === "none")
.map((entry) => concretise(entry.path));

for (const route of routes) {
try {
await page.goto(`${webBase}${route}`, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(600);
} catch {
console.log(` (skipped ${route} — did not settle)`);
}
// Before the next navigation, not after the loop.
await drain();
}

await drain();
await page.close();
await context.close();

const file = path.join(outDir, `${persona}.json`);
await writeFile(file, `${JSON.stringify(fixtures, null, 2)}\n`, "utf8");
written.push({ persona, routes: routes.length, fixtures: Object.keys(fixtures).length });
Comment on lines +144 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate captures before overwriting existing fixtures

If a capture produces no API responses—the failure mode explicitly handled below—this write has already replaced the persona's previously valid fixture with {} before the script throws. snapshot.sh build checks only that JSON files exist, not that they contain entries, so a user who retries the build after the failed capture can publish an empty snapshot; collect and validate all persona results before replacing the committed files.

Useful? React with 👍 / 👎.

const note = unreadable ? ` (${unreadable} body/bodies unreadable)` : "";
console.log(` ${persona}: ${routes.length} routes → ${Object.keys(fixtures).length} fixtures${note}`);
}

await browser.close();

const empty = written.filter((entry) => entry.fixtures === 0);
if (empty.length) {
Comment on lines +153 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject incomplete persona captures

The success check accepts any persona with one captured response, even when the persona's primary tour route failed. The committed alecto.json, for example, contains only GET /users/me, while /fury immediately requests GET /fury/queue and GET /fury/stats; after persona switching is fixed, both calls receive snapshot 404s and the core auditor chapter shows an empty, disconnected workbench. Validate required endpoints or route failures per persona rather than checking only whether the total is zero.

Useful? React with 👍 / 👎.

// A persona with no fixtures produces a hosted screen full of nothing, which reads
// as a broken product rather than a missing capture. Refuse to ship that quietly.
throw new Error(
`no fixtures captured for: ${empty.map((entry) => entry.persona).join(", ")}.\n` +
"The usual cause is that the running demo is serving a SNAPSHOT build: a snapshot\n" +
"build overwrites .next, and its client answers from fixtures instead of calling\n" +
"/api, so the capture sees no API traffic at all. Confusingly, /api still proxies\n" +
"correctly, because rewrites were loaded when the server started.\n" +
"Rebuild the demo normally first: npm run demo:reset:verify",
);
}

console.log(`PASS: captured ${written.reduce((sum, e) => sum + e.fixtures, 0)} fixtures into ${outDir}`);
96 changes: 96 additions & 0 deletions scripts/demo/snapshot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Builds and ships the Cloudflare snapshot: a fully static export of the demo running
# on captured synthetic fixtures, for the people who are NOT in the room.
#
# It is a strictly smaller claim than the local demo. Reads work, the guided tour works,
# and every write is refused in plain language, because there is no API, no database and
# no Redis behind it -- only JSON captured from a demo run that passed its gate.
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
web_dir="$repo_root/src/web"
out_dir="$web_dir/out"
fixtures_dir="$web_dir/public/demo-snapshot"
native_state="$repo_root/artifacts/styx-demo-native.env"
project="${STYX_SNAPSHOT_PAGES_PROJECT:-styx-demo-snapshot}"

die() { echo "FAIL: $*" >&2; exit 1; }
info() { echo "▸ $*"; }
ok() { echo "✓ $*"; }

node24() {
if command -v mise >/dev/null 2>&1; then
mise x node@24 -- "$@"
else
"$@"
fi
}

capture() {
[ -f "$native_state" ] || die "start the demo first (npm run demo:reset:verify); fixtures are captured from a live run."
local web_url="" demo_password="" key value
while IFS='=' read -r key value; do
case "$key" in
STYX_DEMO_WEB_URL) web_url="$value" ;;
STYX_DEMO_PASSWORD) demo_password="$value" ;;
*) ;;
esac
done < "$native_state"
[ -n "$web_url" ] || die "no web URL in the demo state file."
[ -n "$demo_password" ] || die "no synthetic password in the demo state file."

info "Capturing fixtures from the live demo at ${web_url} ..."
STYX_DEMO_WEB_URL="$web_url" STYX_DEMO_PASSWORD="$demo_password" \
node24 node "$repo_root/scripts/demo/capture-snapshot.mjs"
}

build() {
[ -d "$fixtures_dir" ] || die "no fixtures. Run: npm run snapshot:capture"
local count
count="$(find "$fixtures_dir" -name '*.json' | wc -l | tr -d ' ')"
[ "$count" -gt 0 ] || die "fixtures directory is empty. Run: npm run snapshot:capture"

info "Building the static snapshot (${count} persona fixture file(s)) ..."
# A snapshot build overwrites .next, so the running demo would afterwards serve
# snapshot client code that answers from fixtures and never calls /api. Say so;
# this exact confusion cost two capture runs to diagnose.
cd "$web_dir"
NEXT_PUBLIC_STYX_SNAPSHOT=true \
NEXT_PUBLIC_STYX_TEST_MONEY_MODE=true \
NEXT_PUBLIC_STYX_PRIVATE_BETA=true \
NEXT_PUBLIC_STYX_ENV_LABEL=cloudflare-snapshot \
NEXT_PUBLIC_STYX_FEATURE_B2B_HR_UI=true \
Comment on lines +58 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Configure hosted feedback separately from the LAN collector

This enables the guided tour in the hosted build through NEXT_PUBLIC_STYX_TEST_MONEY_MODE, but it leaves the tour's feedback client targeting its default same-host port 4312. On an HTTPS Cloudflare Pages URL that resolves to https://<pages-host>:4312, where the presenter's LAN-only collector cannot exist, so all route telemetry is discarded and every remote viewer who submits a visible note receives a failure. Supply a reachable hosted collector URL or disable the feedback controls for snapshot builds.

Useful? React with 👍 / 👎.

NODE_ENV=production \
node24 npx next build
cd "$repo_root"

[ -f "$out_dir/index.html" ] || die "export produced no index.html."
ok "Static snapshot in ${out_dir} ($(find "$out_dir" -name '*.html' | wc -l | tr -d ' ') pages)."
echo " NOTE: this overwrote .next — rebuild the local demo before capturing again:"
echo " npm run demo:reset:verify"
}

serve() {
[ -f "$out_dir/index.html" ] || die "nothing built. Run: npm run snapshot:build"
info "Serving the snapshot on http://127.0.0.1:4315 (Ctrl-C to stop) ..."
# NOT `serve -s`. SPA mode rewrites every unmatched path to index.html, so each
# route returns the landing page with a 200 while the client router still shows the
# right URL -- a local preview that looks fine and tests nothing. This export has a
# real HTML file per route, which is also how Cloudflare Pages serves it.
node24 npx --yes serve "$out_dir" -l 4315
}

deploy() {
[ -f "$out_dir/index.html" ] || die "nothing built. Run: npm run snapshot:build"
command -v npx >/dev/null 2>&1 || die "npx is required to run wrangler."
info "Deploying to Cloudflare Pages project '${project}' ..."
npx --yes wrangler pages deploy "$out_dir" --project-name "$project"
}

case "${1:-}" in
capture) capture ;;
build) build ;;
serve) serve ;;
deploy) deploy ;;
*) die "usage: bash scripts/demo/snapshot.sh {capture|build|serve|deploy}" ;;
esac
18 changes: 18 additions & 0 deletions src/web/app/contracts/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { snapshotContractParams } from '../../../lib/snapshot-params';

/**
* Exists only to declare the pages the Cloudflare snapshot export generates for this
* dynamic segment -- `output: export` refuses to build a dynamic route without it, and
* the page itself is a client component, which cannot carry this export.
*
* It covers both /contracts/[id] and /contracts/[id]/attest, since both live under this
* segment. Outside a snapshot build the helper returns [], leaving these routes to
* render on demand exactly as they did before.
*/
export function generateStaticParams() {
return snapshotContractParams();
}

export default function ContractSegmentLayout({ children }: { children: React.ReactNode }) {
return children;
}
Loading
Loading