diff --git a/.github/trigger-deploy b/.github/trigger-deploy index 2b6b599c..5aef4763 100644 --- a/.github/trigger-deploy +++ b/.github/trigger-deploy @@ -2,3 +2,4 @@ 2026-07-17T16:15:00Z deploy openapi-fallback crawl 2026-07-17T17:20:00Z deploy manifest-merge + alias collapse 2026-07-17T18:00:00Z deploy price tiebreak +2026-07-18T02:00:00Z deploy wish-bridge qualified-demand gate diff --git a/.github/trigger-test b/.github/trigger-test index 1f1d47f9..778e78bf 100644 --- a/.github/trigger-test +++ b/.github/trigger-test @@ -13,3 +13,4 @@ 2026-07-17T17:05:00Z manifest-path bazaar merge + router alias collapse 2026-07-17T17:45:00Z price tiebreak: known beats unknown 2026-07-18T01:40:00Z wish bridge: qualified-demand gate against single-source bursts +2026-07-18T02:30:00Z operator private demand board diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index df49a95a..651e6737 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1013,6 +1013,9 @@ jobs: - name: Sell hub page tests (offline, fixture snapshot) run: node scripts/test-sell-page.js + - name: Operator demand-board page tests (verdict rendering, no double-escape, robots disallow — offline) + run: node scripts/test-operator-wishes.js + - name: Index self-serve registration tests (offline) run: node scripts/test-index-register.js diff --git a/scripts/test-operator-wishes.js b/scripts/test-operator-wishes.js new file mode 100644 index 00000000..f087ec2e --- /dev/null +++ b/scripts/test-operator-wishes.js @@ -0,0 +1,67 @@ +// Offline unit tests for the token-gated operator demand board +// (src/operator-wishes.js) and its robots.txt disallow. Pure rendering, no +// server boot: the route-level 404 gate is exercised by the manual/boot path; +// here we assert the page reflects the qualification verdicts correctly and +// that the operator surface is kept out of robots. +import { operatorWishesPage } from "../src/operator-wishes.js"; +import { robotsTxt } from "../src/seo.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const BASE = "https://agent402.tools"; +const aggregate = { + distinctClusters: 4, + totalWishes: 130, + threshold: 5, + qualifyMinSpanHours: 24, + clusters: [ + // single-source burst — at threshold but must read as held, not qualified + { text: "synthora mesh 962 m2m", count: 103, qualified: false, + sources: { api: 103, mcp: 0, "find-miss": 0 }, firstSeen: "2026-07-17T02:00:00.000Z", lastSeen: "2026-07-17T06:00:00.000Z" }, + // genuine multi-source demand — qualified + { text: "convert heic to png batch", count: 6, qualified: true, + sources: { api: 2, mcp: 2, "find-miss": 2 }, firstSeen: "2026-07-15T10:00:00.000Z", lastSeen: "2026-07-16T10:00:00.000Z" }, + // below threshold + { text: "solidity auditor", count: 2, qualified: false, + sources: { api: 0, mcp: 0, "find-miss": 2 }, firstSeen: "2026-07-13T10:00:00.000Z", lastSeen: "2026-07-13T10:05:00.000Z" }, + // an entity with HTML-significant chars already esc()'d by getWishesAggregate + { text: "handle <script> safely", count: 1, qualified: false, + sources: { api: 1, mcp: 0, "find-miss": 0 }, firstSeen: "2026-07-16T00:00:00.000Z", lastSeen: "2026-07-16T00:00:00.000Z" }, + ], +}; + +const html = operatorWishesPage(BASE, aggregate); + +// --- summary reflects the split --- +ok(/Distinct clusters/.test(html) && html.includes(">4<"), "summary shows distinct cluster count"); +ok(html.includes("Qualified") && html.includes(">1<"), "summary shows 1 qualified"); +ok(/Held/.test(html), "summary shows the held (spam/near-miss) bucket"); + +// --- per-cluster verdicts --- +ok(/qualified<\/span>/.test(html), "a qualified cluster renders the qualified badge"); +ok(/single-source<\/span>/.test(html), "the single-source burst renders as single-source, not qualified"); +ok(/below<\/span>/.test(html), "the sub-threshold cluster renders as below"); +ok(html.includes("one surface, not corroborated"), "single-source note explains why it is held"); + +// --- the operator can see everything, including unqualified/below rows --- +ok(html.includes("synthora mesh 962 m2m"), "single-source spam is still visible to the operator"); +ok(html.includes("convert heic to png batch"), "qualified demand is visible"); +ok(html.includes("solidity auditor"), "below-threshold demand is visible"); + +// --- already-escaped text is not double-escaped --- +ok(html.includes("<script>") && !html.includes(" +${ledgerFooterCompact()}`; + + return ledgerShell({ + title: "Operator · Agent demand — Agent402", + description: "Agent402 operator dashboard — full agent demand board.", + canonical: `${baseUrl}/__operator/wishes`, + baseUrl, + activePath: "__none__", + extraCss, + body, + }); +} diff --git a/src/operator.js b/src/operator.js index 72f2e066..290d5128 100644 --- a/src/operator.js +++ b/src/operator.js @@ -76,7 +76,7 @@ td a:hover{color:var(--accent)}

Operator dashboard

-

Per-tool usage, settlement split, and live activity. Auto-refreshes every 10s. Not public — gated by AGENT402_OPERATOR_TOKEN. Tollbooth leads

+

Per-tool usage, settlement split, and live activity. Auto-refreshes every 10s. Not public — gated by AGENT402_OPERATOR_TOKEN. Agent demand · Tollbooth leads

Total calls
${esc(t.total ?? 0)}
all tools, all rails
diff --git a/src/seo.js b/src/seo.js index 213a3286..f60e6faf 100644 --- a/src/seo.js +++ b/src/seo.js @@ -9,7 +9,9 @@ import { CHAIN_PAGES } from "./market-page.js"; export function robotsTxt(baseUrl) { // Explicitly welcome AI/agent crawlers and search engines; point them at the - // machine-readable surfaces. Disallow only the wallet-scoped memory endpoints. + // machine-readable surfaces. Disallow the wallet-scoped memory endpoints and + // the token-gated operator dashboard (already 404 without the token — this + // just keeps well-behaved crawlers from probing the path at all). const agents = [ "GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "anthropic-ai", "PerplexityBot", "Google-Extended", "Googlebot", "Bingbot", "Applebot", "Applebot-Extended", @@ -21,6 +23,7 @@ export function robotsTxt(baseUrl) { User-agent: * Allow: / Disallow: /api/memory +Disallow: /__operator # Machine-readable catalogs for agents: ${baseUrl}/llms.txt , ${baseUrl}/openapi.json , ${baseUrl}/api/pricing , ${baseUrl}/api/cacheable , ${baseUrl}/.well-known/x402 , ${baseUrl}/api/reliability , ${baseUrl}/api/find?q={task} , ${baseUrl}/api/route , ${baseUrl}/api/leaderboard Sitemap: ${baseUrl}/sitemap.xml diff --git a/src/server.js b/src/server.js index 2f89cd81..03cd1484 100644 --- a/src/server.js +++ b/src/server.js @@ -29,6 +29,7 @@ import { tollboothLandingPage } from "./tollbooth-landing.js"; import { tollboothCloudPage } from "./tollbooth-cloud.js"; import { tollboothWaitlistPage } from "./tollbooth-waitlist.js"; import { operatorLeadsPage } from "./operator-leads.js"; +import { operatorWishesPage } from "./operator-wishes.js"; import { initLeadsDb, insertLead, listLeads, countLeads, leadsDbEnabled } from "./leads-db.js"; import { cacheEnabled, cacheGet, cacheSet, cacheKeyFor, CACHEABLE_ROUTES, noteCacheOutcome, cacheCounters } from "./cache.js"; import { initAnalyticsDb, recordToolCall, getAnalytics, analyticsEnabled } from "./analytics-db.js"; @@ -1014,6 +1015,10 @@ app.get("/__operator/stats", (req, res) => { if (!operatorTokenOk(getOperatorToken(req))) return res.status(404).json({ error: "Not found" }); res.json(getOperatorBreakdown({ prices: TOOL_PRICES, walletOnlySet: WALLET_ONLY_SLUGS })); }); +app.get("/__operator/wishes", (req, res) => { + if (!operatorTokenOk(getOperatorToken(req))) return res.status(404).type("html").send("

Not found.

"); + res.type("html").send(operatorWishesPage(BASE_URL, getWishesAggregate({ limit: 500 }))); +}); app.get("/__operator/leads", async (req, res) => { if (!operatorTokenOk(getOperatorToken(req))) return res.status(404).type("html").send("

Not found.

"); const list = await listLeads({ limit: 200 });